Skip to content
Published on

RAGチャンキング戦略完全ガイド:ナイーブ分割からRAPTORまで

Authors

なぜチャンキングが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概要:機能12の要約] [製品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 + ベクター検索)を扱う。どんなに良いチャンキングをしても、検索自体が悪ければ意味がない。