- Authors

- Name
- Youngju Kim
- @fjvbn20031
- なぜチャンキングがRAG品質の70%を決めるのか
- 戦略1:Fixed-size Chunking(最もシンプル)
- 戦略2:Semantic Chunking(意味ベース)
- 戦略3:Document-Structure-Aware Chunking
- 戦略4:Hierarchical(Parent-Child)Chunking
- 戦略5:RAPTOR(再帰的抽象処理)
- チャンクサイズ選択ガイド
- 実践的なチャンキング評価方法
- 戦略選択のまとめ
- 最後に
なぜチャンキングがRAG品質の70%を決めるのか
「Garbage in, garbage out.」
RAGシステムを初めて構築すると、この言葉を身をもって実感する。チャンキングが悪ければ、どんなに優れた埋め込みモデルを使っても、どんなに高価なLLMを使っても意味がない。
チャンキング(chunking)とは、長いドキュメントを検索可能な小さな単位に分割する作業だ。シンプルに聞こえるが、ここで下す一つひとつの判断が最終的なRAGパフォーマンスに大きな影響を与える。
なぜチャンキングがそれほど重要なのか?
埋め込みの情報密度の問題:埋め込みモデルはチャンク全体を1つのベクトルで表現する。チャンクが大きすぎると核心的な意味が希薄になり、小さすぎるとコンテキストが失われる。
検索と生成のトレードオフ:検索には小さく集中したチャンクが有利で、生成には十分なコンテキストが必要だ。この両方を同時に満たすのがチャンキング戦略の核心だ。
文書構造の破壊問題:文字数だけで分割すると、文章の途中や箇条書きの途中で切れて意味が完全に壊れる。
この記事では5つのチャンキング戦略を実際のコードとともに解説し、いつ何を使うべきかの判断基準を提示する。
戦略1:Fixed-size Chunking(最もシンプル)
最も基本的な方法だ。一定のトークン数・文字数単位で分割し、境界での問題を緩和するためにオーバーラップを設ける。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # トークン数基準(文字数のおよそ75%)
chunk_overlap=50, # 境界での重複トークン数
separators=["\n\n", "\n", ".", " ", ""]
# 優先順位:段落 > 行 > 文 > 単語 > 文字
)
chunks = splitter.split_text(document)
print(f"合計{len(chunks)}個のチャンクを生成")
print(f"最初のチャンク: {chunks[0][:100]}...")
RecursiveCharacterTextSplitterはセパレーターリストを順番に試みる。まず\n\n(段落区切り)で分割しようとし、チャンクが大きすぎれば\nで、それでも大きければ.で試みる。
メリット:実装がシンプルで高速。チャンクサイズが予測可能。
デメリット:意味の境界を考慮しない。文の途中で切れることがある。オーバーラップがあっても完全な解決策にはならない。
使うべき時:素早いプロトタイプ、文書構造が一定な場合、コストが重要な場合。
戦略2:Semantic Chunking(意味ベース)
埋め込みの類似度を使い、意味が変化するポイントで分割する方法だ。文章間の埋め込み距離が急増する点を境界とする。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai.embeddings import OpenAIEmbeddings
splitter = SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="percentile", # または"standard_deviation", "interquartile"
breakpoint_threshold_amount=95 # 上位5%の距離変化を境界として使用
)
# 内部処理:
# 1. 各文を埋め込み
# 2. 隣接する文間のコサイン距離を計算
# 3. 距離が閾値を超えた点で分割
chunks = splitter.create_documents([long_document])
メリット:意味単位で分割されるため各チャンクの内容が一貫している。検索精度が目に見えて向上する。
デメリット:埋め込みAPIの呼び出しコストがかかる。速度が遅い。短いドキュメントには過剰かもしれない。
使うべき時:多様なトピックが混在する長いドキュメント、意味の境界が不明確なドキュメント、検索精度が重要なプロダクションシステム。
実際のパフォーマンス差
同じドキュメントコーパスでfixed-sizeとsemantic chunkingを比較すると、semantic chunkingは検索精度(Precision)をおよそ15〜20%向上させる経験がある。特に「AだがBではない」という微妙なクエリで差が大きい。
戦略3:Document-Structure-Aware Chunking
ドキュメントの構造(見出し、リスト、コードブロックなど)を保持しながら分割する方法だ。
from langchain.text_splitter import MarkdownHeaderTextSplitter
# Markdownの見出しで分割
headers_to_split_on = [
("#", "h1"),
("##", "h2"),
("###", "h3"),
]
md_splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on,
strip_headers=False # チャンク内に見出しテキストを保持
)
md_header_splits = md_splitter.split_text(markdown_document)
# 各チャンクにメタデータとして見出し情報が含まれる
# md_header_splits[0].metadata = {'h1': '製品概要', 'h2': '主要機能'}
# このメタデータをフィルタリングに活用できる
# 見出しで分割後、まだ大きすぎる場合はさらに分割
from langchain.text_splitter import RecursiveCharacterTextSplitter
secondary_splitter = RecursiveCharacterTextSplitter(
chunk_size=512, chunk_overlap=50
)
final_chunks = secondary_splitter.split_documents(md_header_splits)
核心的な洞察:文書構造情報をメタデータとして保持することで、後から「インストールセクションのみで検索」といったフィルタリングが可能になる。これが単純なfixed-sizeチャンキングとの決定的な違いだ。
使うべき時:Markdownドキュメント、技術文書、よく構造化されたHTMLページ、セクションベースのフィルタリングが必要な場合。
戦略4:Hierarchical(Parent-Child)Chunking
個人的に最も気に入っている戦略だ。精確な検索と豊富なコンテキストという2つの目標を同時に達成するアプローチだ。
核心アイデア:小さいチャンクで検索し、そのチャンクの親(より大きなチャンク)をLLMに渡す。
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 親チャンク:大きなコンテキストウィンドウ(LLMに渡す)
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)
# 子チャンク:小さく集中した単位(埋め込みと検索に使用)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=200)
# ドキュメントストア:親ドキュメントを保管
docstore = InMemoryStore() # プロダクションではRedisやDBを使用
# ベクターストア:子チャンクの埋め込みを格納
vectorstore = FAISS.from_documents([], embedding_model)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# ドキュメント追加時に自動的に親子階層を作成
retriever.add_documents(documents)
# 検索時:
# 1. クエリに類似した子チャンクを検索(小さく精確な検索)
# 2. 該当する子の親IDを照会
# 3. 親チャンクを返す(LLMへの豊富なコンテキスト)
results = retriever.get_relevant_documents("検索クエリ")
なぜ効果的か:
検索段階では200トークンの小さい子チャンクを使用するため、埋め込みが集中している。「製品Aのリリース日は?」というクエリに対して、その情報が含まれる小さなチャンクを正確に見つける。
生成段階では、その子チャンクの親である2000トークンのチャンクをLLMに渡す。LLMは回答周辺の十分なコンテキストを持って回答できる。
プロダクションのヒント:InMemoryStoreはプロトタイプのみに使おう。プロダクションではサーバー再起動後もデータが保持されるよう、RedisやPostgreSQLをdocstoreとして使用すること。
戦略5:RAPTOR(再帰的抽象処理)
RAPTORは「Recursive Abstractive Processing for Tree-Organized Retrieval」の略だ。2024年にStanfordが発表した手法で、ドキュメントのツリー階層構造を自動的に構築する。
核心アイデア:類似したチャンクをクラスタリングし、各クラスターを要約して上位ノードを作る。これを再帰的に繰り返す。
リーフノード(元のチャンク):
[製品A機能1] [製品A機能2] [製品B機能1] [製品B比較]
クラスタリング+要約(レベル1):
[製品A概要:機能1、2の要約] [製品B概要:機能1、比較の要約]
より高い抽象レベル(レベル2):
[全製品ラインナップの比較概要]
# RAPTOR実装(概念コード)
from sklearn.mixture import GaussianMixture
import numpy as np
def build_raptor_tree(chunks, embeddings, levels=3):
"""
RAPTORツリーを再帰的に構築。
各レベルでクラスタリングした後、要約を生成。
"""
tree = {'level_0': chunks}
current_chunks = chunks
current_embeddings = embeddings
for level in range(1, levels + 1):
# 1. GMMでクラスタリング(BICで最適なk数を選択)
n_clusters = max(1, len(current_chunks) // 5)
gm = GaussianMixture(n_components=n_clusters, random_state=42)
gm.fit(current_embeddings)
labels = gm.predict(current_embeddings)
# 2. 各クラスター内のチャンクを結合して要約
summaries = []
for cluster_id in range(n_clusters):
cluster_chunks = [
current_chunks[i]
for i in range(len(current_chunks))
if labels[i] == cluster_id
]
combined_text = "\n\n".join(cluster_chunks)
summary = llm.summarize(combined_text) # LLMで要約
summaries.append(summary)
tree[f'level_{level}'] = summaries
# 次のレベルの入力として現在のレベルの要約を使用
current_chunks = summaries
current_embeddings = embed(summaries)
return tree
メリット:
- 具体的な質問(「製品Aの価格は?」)と抽象的な質問(「どんな製品ラインがありますか?」)の両方に対応
- 階層的検索が可能 — クエリのレベルに合った階層で検索できる
デメリット:
- 構築に時間がかかる(クラスタリング + LLM要約の繰り返し)
- コストが高い(要約のためのLLM呼び出しが多い)
- 実装の複雑さが高い
使うべき時:非常に大きなドキュメントコーパス、様々な抽象度のクエリが予想される場合、コストより品質が重要なエンタープライズプロダクション。
チャンクサイズ選択ガイド
| チャンクサイズ | 適した状況 | 主なリスク |
|---|---|---|
| 128-256トークン | 精確な事実クエリ、短答型 | コンテキスト不足でLLMが誤答を生成 |
| 512-1024トークン | 一般的なQA、説明型クエリ | ノイズ増加、関連性の希薄化 |
| 2048+トークン | 長い推論が必要、分析型クエリ | 埋め込み品質低下、核心情報の希薄化 |
経験則:多くの場合512トークンが最もバランスが良い。 特別な理由がなければここから始めよう。
実践的なチャンキング評価方法
チャンキング戦略を変更するたびに「感覚」で判断してはいけない。定量的に測定しよう。
from ragas.metrics import context_precision, context_recall
from ragas import evaluate
from datasets import Dataset
# 評価データセット:質問 + 正解のペア
eval_questions = [
"返品期間はどのくらいですか?",
"送料はいくらですか?",
# ...
]
ground_truths = [
"30日以内に返品可能",
"5,000円以上で送料無料",
# ...
]
def evaluate_chunking_strategy(strategy_name, retriever):
results = []
for q, gt in zip(eval_questions, ground_truths):
retrieved_docs = retriever.get_relevant_documents(q)
contexts = [doc.page_content for doc in retrieved_docs]
results.append({
"question": q,
"contexts": contexts,
"ground_truth": gt
})
dataset = Dataset.from_list(results)
scores = evaluate(dataset, metrics=[context_precision, context_recall])
print(f"\n=== {strategy_name} ===")
print(f"Context Precision: {scores['context_precision']:.3f}")
print(f"Context Recall: {scores['context_recall']:.3f}")
return scores
# 各戦略を比較
evaluate_chunking_strategy("Fixed-size 512", fixed_retriever)
evaluate_chunking_strategy("Semantic", semantic_retriever)
evaluate_chunking_strategy("Parent-Child", parent_child_retriever)
戦略選択のまとめ
| 状況 | 推奨戦略 |
|---|---|
| 素早いプロトタイプ | Fixed-size(512トークン) |
| 構造化されたMarkdown/HTMLドキュメント | Document-Structure-Aware |
| 多様なトピックが混在する長いドキュメント | Semantic Chunking |
| 精確な検索 + 豊富なコンテキストが必要 | Parent-Child |
| 大規模コーパス、様々なクエリタイプ | RAPTOR |
| 予算が十分で最高品質が必要 | RAPTOR + Parent-Child の組み合わせ |
最後に
チャンキングは「設定して忘れる」ものではない。サービスが成長し、ドキュメントのタイプが多様になれば、チャンキング戦略も一緒に進化させる必要がある。定期的に評価データセットを作成し、スコアを測定し、改善するサイクルを作ろう。
次の記事では、チャンキングの後の核心であるハイブリッド検索(BM25 + ベクター検索)を扱う。どんなに良いチャンキングをしても、検索自体が悪ければ意味がない。