RAG では検索チャンクと生成文脈を分ける
RAG(Retrieval-Augmented Generation)は、質問に関係する外部文書を検索し、その取得結果を生成モデルへ渡して回答を作る構成です[1]。実装時には文書をチャンクへ分割しますが、ここで一つの chunk_size を検索と生成の両方へ使うと、値を変更したときに二つの条件が同時に変わります。
たとえば chunk_size=200 を 1000 へ変更すると、検索対象の大きさと、検索後に生成モデルへ渡る文章量が一緒に増えます。この構成では、検索結果が改善したのか、生成時の文脈が増えた効果なのかを切り分けにくくなります。検索単位と生成時の文脈を別の設計変数として扱うと、この問題を分解できます[2]。
この記事では LangChain の ParentDocumentRetriever を使い、次の構成を作ります。
文書
↓
1000 token の parent
├─ 200 token の child A
├─ 200 token の child B
├─ 200 token の child C
└─ 200 token の child D
↓
child を検索
↓
child B がヒット
↓
対応する parent を取得
↓
生成モデルへ渡す
検索は 200 token の child で行い、生成モデルへ渡す候補は 1000 token の parent へ戻します。ParentDocumentRetriever は、小さいチャンクを検索した後、そのチャンクが属する親文書を返すための Retriever です。親文書には元文書全体または、より大きいチャンクを使えます[3]。
実行環境を用意する
この記事では Python 3.12 を使用します。2026 年 9 月 17 日時点で確認した主要パッケージを次のバージョンへ固定します。
langchain-classic==1.0.8
langchain-core==1.6.3
langchain-openai==1.6.2
langchain-text-splitters==1.1.2
仮想環境を作成して依存パッケージをインストールします。
python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install \
'langchain-classic==1.0.8' \
'langchain-core==1.6.3' \
'langchain-openai==1.6.2' \
'langchain-text-splitters==1.1.2' \
'tiktoken'
埋め込み生成には OpenAIEmbeddings を使うため、API キーを環境変数へ設定します。
export OPENAI_API_KEY='YOUR_API_KEY'
今回確認したいのは検索単位と生成文脈の分離です。回答生成そのものは実行せず、検索された child と、最終的に返る parent を表示して確認します。
200 token と 1000 token を別々に定義する
RecursiveCharacterTextSplitter の通常の chunk_size は、既定では文字数を基準にします。今回は 200 token と 1000 token を比較したいため、from_tiktoken_encoder() を使います。このメソッドは tiktoken のエンコーダーで長さを数える Text Splitter を生成します[4]。
from langchain_text_splitters import RecursiveCharacterTextSplitter
MODEL_NAME = "text-embedding-3-small"
CHILD_CHUNK_SIZE = 200
PARENT_CHUNK_SIZE = 1000
child_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
model_name=MODEL_NAME,
chunk_size=CHILD_CHUNK_SIZE,
chunk_overlap=40,
)
parent_splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
model_name=MODEL_NAME,
chunk_size=PARENT_CHUNK_SIZE,
chunk_overlap=100,
)
ここでは二つの値に別の責務を持たせます。
| 設定値 | 役割 |
|---|---|
CHILD_CHUNK_SIZE=200 |
ベクトル検索で比較する単位 |
PARENT_CHUNK_SIZE=1000 |
child がヒットした後に復元する文脈 |
200 を調整すると検索粒度が変わり、1000 を調整すると生成モデルへ渡す文脈量が変わります。
比較用の文書を作る
検索対象となる規定と、その適用条件を離して配置した文書を使います。検索では「返金」に関する一文を取得しやすくしつつ、その一文だけでは適用対象が分からない状態を作ります。
from langchain_core.documents import Document
filler = (
"契約管理の一般事項として、登録情報に変更が生じた場合は更新手続きを行います。\n"
* 20
)
text = f"""
第1節 契約種別
年間契約については、途中解約に関する以下の規定を適用します。
{filler}
第2節 途中解約
契約を途中解約した場合、未利用期間の料金は返金しません。
月間契約は次回更新日の前日までに終了手続きを行えます。
"""
documents = [Document(page_content=text)]
質問は次の一文です。
query = "途中解約した場合、未利用期間の料金は返金されますか"
検索で重要なのは「途中解約」「未利用期間」「返金」に対応する箇所です。一方、回答を作るには「年間契約について適用する」という条件も必要です。
200 token の一段チャンクで検索する
最初に、検索チャンクをそのまま生成文脈として使う構成を作ります。
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model=MODEL_NAME)
flat_vectorstore = InMemoryVectorStore(embeddings)
flat_documents = child_splitter.split_documents(documents)
flat_vectorstore.add_documents(flat_documents)
flat_hits = flat_vectorstore.similarity_search(query, k=1)
print("=== flat retrieval ===")
for doc in flat_hits:
print(doc.page_content)
この構成では、検索に使った 200 token のチャンクがそのまま取得結果になります。返金規定を含む child が検索上位へ出ても、その child の外側にある「年間契約について適用する」という条件は生成文脈へ入りません。
確認対象は回答文ではなく、検索結果そのものです。次の条件を確認します。
- 「未利用期間の料金は返金しません」を含むチャンクが取得できたか
- 同じチャンク内に「年間契約について適用する」が含まれているか
前者が成立し、後者が欠けている場合、検索は成功していても生成へ渡す文脈が不足しています。
child を検索して parent を返す
次に ParentDocumentRetriever を使います。child はベクトルストアへ保存し、parent は別の docstore へ保存します。検索時には child を使い、ヒットした child の親 ID から parent を取得します[3]。
from langchain_classic.retrievers import ParentDocumentRetriever
from langchain_core.stores import InMemoryStore
from langchain_core.vectorstores import InMemoryVectorStore
child_vectorstore = InMemoryVectorStore(embeddings)
parent_store = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=child_vectorstore,
docstore=parent_store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
search_kwargs={"k": 1},
)
retriever.add_documents(documents)
add_documents() を呼ぶと、parent と child の対応関係が作られます。検索対象として埋め込みを持つのは child です。
child の検索結果を直接確認します。
child_hits = child_vectorstore.similarity_search(query, k=1)
print("=== child retrieval ===")
for doc in child_hits:
print(doc.page_content)
print(doc.metadata)
次に Retriever 経由で検索します。
parent_hits = retriever.invoke(query)
print("=== parent retrieval ===")
for doc in parent_hits:
print(doc.page_content)
同じ質問に対して、child_vectorstore.similarity_search() は検索された小さい child を返し、retriever.invoke() は対応する parent を返します。
確認するポイントは次の 3 点です。
- child に返金規定が含まれていること
- parent に同じ返金規定が含まれていること
- parent に「年間契約について適用する」という条件も含まれていること
この 3 点が成立すれば、検索単位を小さく保ったまま、回答生成に必要な周辺条件を復元できています。
token 数も表示して確認する
実際に検索結果の大きさを確認するため、tiktoken で token 数を表示します。
import tiktoken
encoder = tiktoken.encoding_for_model(MODEL_NAME)
def token_count(text):
return len(encoder.encode(text))
for doc in child_hits:
print("child tokens:", token_count(doc.page_content))
for doc in parent_hits:
print("parent tokens:", token_count(doc.page_content))
chunk_size は最大値なので、実際のチャンクが常に 200 token または 1000 token になるわけではありません。段落境界や区切り位置に応じて、それより短いチャンクも生成されます。確認すべきなのは固定長になっているかではなく、child と parent が別の上限値で管理されていることです。
検索失敗と文脈復元失敗を分けて判定する
最終回答だけを見てチャンクサイズを変更すると、どの工程を直したのか分からなくなります。検索結果と parent の内容を別々に観察すると、失敗箇所を次のように切り分けられます。
| 観測結果 | 判定 | 調整対象 |
|---|---|---|
| 必要な child が検索上位へ入らない | 検索失敗 |
CHILD_CHUNK_SIZE、埋め込みモデル、検索方式 |
| child は取得できるが条件や例外が parent に入らない | 文脈復元失敗 |
PARENT_CHUNK_SIZE、parent の境界 |
| child と parent の両方に必要情報がある | 検索と文脈復元は成功 | 生成処理を確認 |
この分け方では、たとえば child を 200 token に固定したまま parent を 500、1000、2000 と変更できます。逆に parent を固定し、child だけを 100、200、400 と変えて検索結果を比較することもできます。
二つの変数を独立させることで、検索性能を確認する試験と、生成に必要な文脈量を確認する試験を分離できます。
child は小さいほどよいとは限らない
検索単位の粒度は検索結果へ影響します。Dense X Retrieval では、文書、passage、sentence、proposition といった検索単位の違いを比較し、索引する粒度が検索性能と下流の質問応答へ影響することを検証しています[5]。
この結果は、child を最小化すれば常に改善するという意味ではありません。実際の RAG では、文書構造、質問の種類、埋め込みモデル、検索件数によって適切な検索単位が変わります。
そのため、調整順序は次のようにすると原因を追いやすくなります。
- parent を固定し、child の大きさを変えて必要箇所が検索されるか確認する
- child を固定し、parent の大きさを変えて条件や例外が復元されるか確認する
- 検索と文脈復元が成立した状態で生成結果を確認する
この順序なら、一つの chunk_size を変更して全工程の条件が同時に動く状態を避けられます。
parent の拡大にも上限を設ける
parent を大きくすると周辺情報を多く保持できます。一方で、生成モデルへ渡す token 数も増えます。質問と無関係な節まで parent に含まれると、入力コストと処理時間も増えます。
そのため PARENT_CHUNK_SIZE は「大きいほどよい値」ではなく、回答に必要な条件、例外、照応先を含めるための範囲として調整します。
たとえば child を 200 token に固定して、次の条件を比較します。
child = 200 token
parent = 500 token
child = 200 token
parent = 1000 token
child = 200 token
parent = 2000 token
各条件で見る値は次のとおりです。
- 必要な child が同じように検索されるか
- parent に適用条件と例外が含まれるか
- parent の token 数がどこまで増えるか
- 無関係な節がどこまで混入するか
この比較では child を固定しているため、parent の変更による影響だけを観察できます。
chunk_size を二つの設計値へ分ける
RAG の文書分割を一つの chunk_size だけで管理すると、検索粒度と生成時の文脈量が結合します。親子チャンクへ分けると、実装上の設定値も次の二つへ分離できます。
retrieval_chunk_size = 200
context_chunk_size = 1000
LangChain では、この役割を child_splitter と parent_splitter に対応させられます。
retriever = ParentDocumentRetriever(
vectorstore=child_vectorstore,
docstore=parent_store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
search_kwargs={"k": 1},
)
検索に必要な粒度と、回答生成に必要な文脈量は別の工程で決まります。child と parent を独立した設定値にすると、どちらの工程へ変更を加えたのかを明示したまま比較できます。
RAG のチャンク調整では、一つの数値から始めるより、まず検索単位と生成文脈を分離して観測できる構成にする方が、失敗原因を追いやすくなります。
参考文献
- Patrick Lewis et al., Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks(2020). https://proceedings.neurips.cc/paper/2020/hash/6b493230-Abstract.html
- id774, RAG の設計は「最適なチャンクサイズ」では終わらない(2026-09-06). https://blog.id774.net/entry/2026/09/06/5546/
- LangChain, ParentDocumentRetriever. https://reference.langchain.com/python/langchain-classic/retrievers/parent_document_retriever/ParentDocumentRetriever
- LangChain, from_tiktoken_encoder. https://reference.langchain.com/python/langchain-text-splitters/base/TextSplitter/from_tiktoken_encoder
- Tong Chen et al., Dense X Retrieval: What Retrieval Granularity Should We Use?(2024-11). https://aclanthology.org/2024.emnlp-main.845/