はじめに
この記事は「GraphRAGで日本語論文を扱う」シリーズの第6回です。
今回は、日本語テキストのチャンク分割(Chunking)の最適化について解説します。GraphRAGのデフォルト設定では、英語を前提としたトークンベースの分割が行われるため、日本語テキストでは単語の途中で分割されてしまう問題があります。
本記事では、GiNZA(spaCy + SudachiPy)を使用した日本語対応チャンカーの実装と、bge-m3などの日本語対応埋め込みモデルへの切り替え方法を紹介します。
シリーズ記事
| # | タイトル | 内容 |
|---|---|---|
| 1 | Ollama + GraphRAGで論文検索 | 基本セットアップ |
| 2 | GraphRAGのチャンク管理とカスタマイズ | チャンク設定の詳細 |
| 3 | GraphRAGのインデックス構築と最適化 | 並列処理・リトライ戦略 |
| 4 | GraphRAGのGlobal SearchとLocal Searchの使い分け | クエリモードの理解 |
| 5 | DRIFT/LazyGraphRAGによる高速検索 | 新クエリモードの活用 |
| 6 | 日本語テキストのチャンク分割最適化(本記事) | 日本語対応 |
日本語チャンク分割の課題
問題1: 単語境界がない
英語は単語間にスペースがあるため、トークン境界での分割が自然に機能します。しかし日本語には単語間のスペースがなく、tiktokenのトークン境界で分割すると、意味のある単位で分割されません。
英語: "Machine learning is powerful." → ["Machine", " learning", " is", " powerful", "."]
日本語: "機械学習は強力です。" → ["機械", "学", "習", "は", "強", "力", "です", "。"]
問題2: トークン数の違い
tiktokenのcl100k_baseエンコーディングでは、日本語1文字あたり約1〜3トークンを消費します。これは英語(1単語≈1〜2トークン)と比べて効率が悪いです。
import tiktoken
enc = tiktoken.get_encoding("cl100k_base")
# 英語
english = "This is a test."
print(f"英語: {len(english)}文字 → {len(enc.encode(english))}トークン")
# 英語: 15文字 → 5トークン
# 日本語
japanese = "これはテストです。"
print(f"日本語: {len(japanese)}文字 → {len(enc.encode(japanese))}トークン")
# 日本語: 9文字 → 11トークン
問題3: 文字化けの発生
デフォルトのトークンベース分割では、マルチバイト文字の途中で分割されることがあり、文字化けが発生する場合があります。
デフォルト分割の末尾: ...チャンク分割を実現する。### 3.1 形態素解析ベースの�
カスタム分割の末尾: ...チャンク分割を実現する。### 3.1 形態素解析ベースのチャンク分割
解決策: GiNZAベースの日本語チャンカー
GiNZAとは
GiNZAは、spaCyをベースとした日本語NLPライブラリです。SudachiPyを形態素解析器として使用し、高精度なトークン化・品詞タグ付け・文境界検出を提供します。
主な特徴:
- spaCy v3.7互換
- Universal Dependencies準拠
- 3種類の分割モード(A/B/C)
- Transformer版モデル(ja_ginza_electra)も利用可能
インストール
# 標準モデル
pip install -U ginza ja_ginza
# 高精度Transformer版(16GB以上のメモリ推奨)
pip install -U ginza ja_ginza_electra
# GraphRAG関連
pip install graphrag tiktoken
日本語チャンカーの実装
以下は、GiNZAを使用した日本語チャンカーの実装例です。
"""
日本語テキストチャンカー for GraphRAG
"""
import re
from dataclasses import dataclass
from typing import Optional, Callable
try:
import spacy
HAS_GINZA = True
except ImportError:
HAS_GINZA = False
import tiktoken
@dataclass
class JapaneseChunk:
"""チャンク結果"""
text: str
n_tokens: int
n_sentences: int
source_doc_indices: list[int]
@dataclass
class JapaneseChunkerConfig:
"""チャンカー設定"""
max_tokens: int = 800 # 日本語は小さめ推奨
overlap_tokens: int = 80
encoding_model: str = "cl100k_base"
spacy_model: str = "ja_ginza"
use_sentence_boundary: bool = True
fallback_to_regex: bool = True
class JapaneseTextChunker:
"""GiNZAベースの日本語チャンカー"""
def __init__(self, config: Optional[JapaneseChunkerConfig] = None):
self.config = config or JapaneseChunkerConfig()
self._nlp = None
self._encoder = None
def _get_nlp(self):
"""spaCyモデルを遅延ロード"""
if self._nlp is None and HAS_GINZA:
try:
self._nlp = spacy.load(self.config.spacy_model)
except OSError:
pass
return self._nlp
def _get_encoder(self):
"""tiktokenエンコーダーを取得"""
if self._encoder is None:
enc = tiktoken.get_encoding(self.config.encoding_model)
self._encoder = enc.encode
self._decoder = enc.decode
return self._encoder, self._decoder
def count_tokens(self, text: str) -> int:
"""トークン数をカウント"""
encode, _ = self._get_encoder()
return len(encode(text))
def split_sentences(self, text: str) -> list[str]:
"""文に分割"""
nlp = self._get_nlp()
if nlp:
doc = nlp(text)
return [sent.text.strip() for sent in doc.sents if sent.text.strip()]
# フォールバック: 正規表現
return [s.strip() for s in re.split(r'(?<=[。!?\n])\s*', text) if s.strip()]
def chunk_text(self, text: str) -> list[JapaneseChunk]:
"""テキストをチャンクに分割"""
if not text.strip():
return []
sentences = self.split_sentences(text)
chunks = []
current_sentences = []
current_tokens = 0
encode, decode = self._get_encoder()
for sentence in sentences:
sentence_tokens = len(encode(sentence))
# 1文がmax_tokensを超える場合
if sentence_tokens > self.config.max_tokens:
if current_sentences:
chunks.append(self._make_chunk(current_sentences, current_tokens))
current_sentences, current_tokens = [], 0
# 長い文を分割
chunks.extend(self._split_long_sentence(sentence))
continue
# 追加するとmax_tokensを超える場合
if current_tokens + sentence_tokens > self.config.max_tokens:
if current_sentences:
chunks.append(self._make_chunk(current_sentences, current_tokens))
# オーバーラップ
current_sentences, current_tokens = self._get_overlap(
current_sentences, encode
)
current_sentences.append(sentence)
current_tokens += sentence_tokens
if current_sentences:
chunks.append(self._make_chunk(current_sentences, current_tokens))
return chunks
def _make_chunk(self, sentences: list[str], n_tokens: int) -> JapaneseChunk:
return JapaneseChunk(
text="".join(sentences),
n_tokens=n_tokens,
n_sentences=len(sentences),
source_doc_indices=[0]
)
def _split_long_sentence(self, sentence: str) -> list[JapaneseChunk]:
"""長い文をトークンベースで分割"""
encode, decode = self._get_encoder()
encoded = encode(sentence)
chunks = []
for i in range(0, len(encoded),
self.config.max_tokens - self.config.overlap_tokens):
chunk_tokens = encoded[i:i + self.config.max_tokens]
chunks.append(JapaneseChunk(
text=decode(chunk_tokens),
n_tokens=len(chunk_tokens),
n_sentences=1,
source_doc_indices=[0]
))
return chunks
def _get_overlap(self, sentences: list[str], encode) -> tuple[list[str], int]:
"""オーバーラップ用の文を取得"""
overlap_sentences = []
overlap_tokens = 0
for sentence in reversed(sentences):
sentence_tokens = len(encode(sentence))
if overlap_tokens + sentence_tokens > self.config.overlap_tokens:
break
overlap_sentences.insert(0, sentence)
overlap_tokens += sentence_tokens
return overlap_sentences, overlap_tokens
使用例
# 設定
config = JapaneseChunkerConfig(
max_tokens=800,
overlap_tokens=80,
spacy_model="ja_ginza"
)
# チャンカー初期化
chunker = JapaneseTextChunker(config)
# テキスト分割
text = """
機械学習を用いた自然言語処理の最新動向について解説する。
近年、Transformerアーキテクチャの登場により、言語モデルの性能は飛躍的に向上した。
BERTやGPTに代表される事前学習モデルは、様々なNLPタスクで高い性能を発揮している。
"""
chunks = chunker.chunk_text(text)
for i, chunk in enumerate(chunks):
print(f"Chunk {i+1}: {chunk.n_tokens} tokens, {chunk.n_sentences} sentences")
print(f" {chunk.text[:50]}...")
日本語対応埋め込みモデル
Ollama対応モデル
日本語テキストの埋め込みには、多言語対応モデルを使用する必要があります。
| モデル | サイズ | 日本語対応 | 特徴 |
|---|---|---|---|
| bge-m3 | 567MB | ◎ | BAAI製、100言語対応、Multi-Granularity |
| nomic-embed-text | - | △ | 英語中心、日本語は非最適 |
| paraphrase-multilingual | 278MB | ○ | sentence-transformers |
| qwen3-embedding | 0.6B-8B | ◎ | 最新、複数サイズ |
bge-m3の導入
# Ollamaでbge-m3をダウンロード
ollama pull bge-m3
# 動作確認
ollama run bge-m3 "これはテストです"
GraphRAG設定
settings.yamlでbge-m3を使用する設定:
# 埋め込み設定
embeddings:
llm:
api_key: ${GRAPHRAG_API_KEY}
type: openai_embedding
model: bge-m3
api_base: http://localhost:11434/v1
# チャンク設定(日本語最適化)
chunks:
size: 800 # 日本語は小さめ
overlap: 80
strategy: sentences # 文ベース分割
encoding_model: cl100k_base
Azure AI Foundry を使用する場合
エンタープライズ環境やクラウドネイティブな構成では、Azure AI Foundry の埋め込みモデルを使用することで、スケーラビリティとセキュリティを両立できます。
Azure AI Foundry 対応埋め込みモデル
| モデル | 次元数 | 日本語対応 | 特徴 |
|---|---|---|---|
| text-embedding-3-large | 3,072 | ◎ | 最新・高精度、MIRACL平均54.9 |
| text-embedding-3-small | 1,536 | ◎ | コスト効率重視 |
| text-embedding-ada-002 | 1,536 | ○ | 旧世代、互換性用 |
| Cohere-embed-v3-multilingual | 1,024 | ◎ | 日本語含む10言語対応 |
| embed-v-4-0 (Cohere) | 256-1,536 | ◎ | 最新、マルチモーダル対応 |
推奨: 日本語論文を扱う場合は text-embedding-3-large または Cohere-embed-v3-multilingual を推奨します。MIRACLベンチマーク(多言語検索)で高いスコアを記録しています。
GraphRAG設定(Azure AI Foundry版)
# settings.yaml - Azure AI Foundry 版
embeddings:
llm:
api_key: ${AZURE_OPENAI_API_KEY}
type: openai_embedding
model: text-embedding-3-large
api_base: https://<your-resource>.openai.azure.com/
api_version: "2024-06-01"
# 次元数を削減してコスト最適化(オプション)
# dimensions: 1536
# チャンク設定(日本語最適化)
chunks:
size: 800
overlap: 80
strategy: sentences
encoding_model: cl100k_base
Cohere Embed v3 Multilingual を使用する場合
Azure AI Foundry ではCohereモデルもサーバーレスAPIとしてデプロイ可能です。
# settings.yaml - Cohere Embed v3 Multilingual 版
embeddings:
llm:
api_key: ${AZURE_AI_FOUNDRY_KEY}
type: openai_embedding
model: Cohere-embed-v3-multilingual
api_base: https://<your-resource>.services.ai.azure.com/models/
api_version: "2024-05-01-preview"
Azure AI Foundry のベストプラクティス
| 項目 | 推奨設定 | 理由 |
|---|---|---|
| リージョン | Japan East / Japan West | 日本語処理のレイテンシ最小化 |
| モデル選択 | text-embedding-3-large | MIRACLベンチマーク最高スコア |
| 次元数 | 3,072(デフォルト)または1,536 | 精度とコストのバランス |
| バッチサイズ | 最大2,048件 | API制限内で効率化 |
| トークン上限 | 8,192トークン/リクエスト | 日本語チャンクサイズ800推奨 |
コスト最適化のヒント
-
次元数の削減:
text-embedding-3-largeはdimensionsパラメータで次元数を削減可能# dimensions=1536 でも ada-002 より高精度 response = client.embeddings.create( model="text-embedding-3-large", input="日本語テキスト", dimensions=1536 # 3072 → 1536 でストレージ50%削減 ) -
バッチ処理: 複数チャンクを1リクエストで処理
# 最大2048件のバッチ処理 chunks = ["チャンク1", "チャンク2", ...] response = client.embeddings.create( model="text-embedding-3-large", input=chunks[:2048] ) -
リージョン配置: Azure AI Search と同一リージョンに配置してレイテンシ削減
検証結果
テスト環境
- GraphRAG 2.7.0
- GiNZA 5.2.0 + ja_ginza
- tiktoken (cl100k_base)
分割品質の比較
日本語の学術論文テキスト(約2,000文字)を使用して比較しました。
デフォルト(トークンベース):
チャンク末尾: ...形態素解析ベースの�
→ マルチバイト文字の途中で分割され、文字化けが発生
カスタム(日本語対応):
チャンク末尾: ...形態素解析ベースのチャンク分割
→ 文境界で適切に分割され、文字化けなし
トークン効率
| 項目 | デフォルト | カスタム |
|---|---|---|
| チャンク数 | 3 | 3 |
| 平均トークン数 | 631.7 | 631.7 |
| 分割品質 | △ 文字化けあり | ◎ 適切 |
| 処理時間 | 高速 | やや遅い(+10ms程度) |
まとめ
本記事では、GraphRAGで日本語テキストを扱う際のチャンク分割の課題と解決策を紹介しました。
ポイント
- GiNZAで文境界を検出: 意味のある単位で分割
- 文ベースでチャンクを構築: 文字化けを防止
- bge-m3で埋め込み: 日本語対応の多言語モデルを使用(ローカル環境)
-
Azure AI Foundry: エンタープライズ環境では
text-embedding-3-largeまたはCohere-embed-v3-multilingualを推奨 - チャンクサイズは小さめに: 日本語は1文字≈2-3トークン
次回予告
次回は、日本語論文でのGraphRAGの実際の検索精度評価と、パフォーマンス最適化について解説します。
参考文献
- GiNZA NLP Library - Megagon Labs
- Microsoft GraphRAG - GitHub
- multilingual-e5-large - HuggingFace
- BGE-M3 - Ollama
- spaCy Models & Languages - spaCy Documentation
- Azure AI Foundry Embedding Models - Microsoft Learn
- Cohere Embed v3 Multilingual - Microsoft Learn