検索精度とコンテキスト完全性の両立メカニズム
連載「階層型RAG完全ガイド」について:
本シリーズは全5回を通じて、特定のフレームワーク(LangChain, LlamaIndex等)に依存せず、本番環境で真に機能する**検索システム設計(Retrieval System Design)**の基礎から応用までを網羅的に解説します。
1. Parent–Child Retrieval:2階層モデルの王道
Part 1 で解説した通り、RAGには「ベクトル検索は短い文章を好み、LLMの理解にはまとまった長い文章が必要である」という構造的なジレンマが存在します。
この課題を本番環境で最もスマートに解決する手法が、Parent–Child Retrieval(別名:Small-to-Big Retrieval) です。
例:コンビニの棚札と商品パッケージ
コンビニで買い物をするときの行動を思い浮かべてみてください。
- 陳列棚の小さなプライスカード(Child Chunk): 「日清 カップヌードル 236円」とだけ簡潔に書かれています。通路からひと目で商品を見つけ出す(ベクトル検索)のに最適です。
- 商品の外箱・パッケージ(Parent Chunk): 原材料名、アレルギー品目、賞味期限、3分間の調理手順、栄養成分表示がすべて記載されています。
目的の場所には短い棚札で素早くたどり着き、実際に食べるときにはパッケージ全体の詳細情報を読む。これがParent–Child Retrievalの基本原理です。
データ構造とマッピングの実装設計
Flat RAGとParent–Childの決定的な違いは、2種類のストレージを協調して運用する点にあります。
-
Vector Store: 類似度検索のために、ベクトル埋め込みを持つ
ChildChunkを格納。 -
Document Store(Key-Value):
parent_idをキーとして、未加工の文脈を保持するParentChunkを格納。
from pydantic import BaseModel, Field
from typing import Optional, Dict, Any, List
class ParentChunk(BaseModel):
"""Document Store(Redis, MongoDB, PostgreSQL等)に格納"""
id: str = Field(description="Parent Chunkの一意なUUID")
doc_id: str = Field(description="元ドキュメントの識別ID")
title: str = Field(description="章・節・条文の見出しタイトル")
content: str = Field(description="完全な文脈を含む本文(800 - 1500 tokens)")
child_ids: List[str] = Field(default_factory=list, description="属する子チャンクIDのリスト")
metadata: Dict[str, Any] = Field(default_factory=dict)
class ChildChunk(BaseModel):
"""Vector Store(Qdrant, Milvus, pgvector, Pinecone等)に格納"""
id: str = Field(description="Child Chunkの一意なUUID")
parent_id: str = Field(description="親ノード(ParentChunk.id)を参照する外部キー")
doc_id: str = Field(description="元ドキュメントの識別ID")
content: str = Field(description="検索用の短いテキスト(100 - 250 tokens)")
embedding: Optional[List[float]] = Field(default=None, description="ベクトル表現")
metadata: Dict[str, Any] = Field(default_factory=dict)
ParentとChildの生成戦略
┌───────────────────────────────────────────────────────────┐
│ PARENT(完全なコンテキスト)の生成パターン │
├───────────────────────────────────────────────────────────┤
│ 1. 見出し単位(Heading) : H2, H3セクションごとに分割。 │
│ 2. ページ単位(Page) : 1ページまたは見開き2ページ単位。│
│ 3. 条文単位(Clause) : 契約書や規程の「1つの条」全体。 │
│ 4. クラス単位(Class) : コードベースの1クラス全体。 │
│ 5. スライディング窓 : 1200トークンの大きめの窓。 │
└─────────────────────────────┬─────────────────────────────┘
│ 細分化(Split)
┌─────────────────────────────┴─────────────────────────────┐
│ CHILD(高精度な検索用)の生成パターン │
├───────────────────────────────────────────────────────────┤
│ 1. 段落単位(Paragraph) : 150トークン程度の段落。 │
│ 2. センテンス(Sentence): 1文または2〜3文のグループ。 │
│ 3. 想定Q&A(QA Pair) : 簡潔な質問と回答のペア。 │
│ 4. 関数単位(Function) : 個別のメソッドや関数。 │
│ 5. ファクト(Fact) : 個別の数値や特定要件。 │
└───────────────────────────────────────────────────────────┘
ケーススタディ:社内就業規則・福利厚生規程への適用
社員から次のような問い合わせがあった場合を考えます。
質問: 「試用期間中の2ヶ月間、通勤手当と昼食手当は支給されますか?」
元ドキュメント(就業規則の抜粋):
# 第4条:諸手当および福利厚生
1. 会社は、従業員の円滑な業務遂行を支援するため、本条の定めに従い手当を支給する。
2. 正社員に対しては、月額上限20,000円までの通勤手当の実費、および月額10,000円の昼食手当を支給する。
3. 試用期間中の従業員については、通勤手当は全額実費支給の対象とするが、昼食手当の適用は本採用以降とする。
4. 各手当は、毎月25日(休日の場合は前営業日)の給与支払い時に合算して口座振込にて支給する。
システムの処理フロー:
- Parent Chunk: 「第4条」全体(約200トークン)。
-
Child Chunks:
-
Child 1: 「正社員に対しては、月額上限20,000円までの通勤手当の実費、および月額10,000円の昼食手当を支給する。」 -
Child 2: 「試用期間中の従業員については、通勤手当は全額実費支給の対象とするが、昼食手当の適用は本採用以降とする。」
-
-
検索ステップ: ユーザーの質問は
Child 2と極めて高いコサイン類似度($0.89$)を記録。 -
展開ステップ: システムは
Child 2のみをLLMに渡すのではなく、parent_idを使って「第4条」全体をロード。 - 回答生成: LLMは「通勤手当は支給されるが、昼食手当は本採用後まで支給されないこと」「支給日は毎月25日であること」という文脈を完全に把握し、漏れのない正確な回答を生成できます。
本番環境における重複排除(Deduplication)
Top-20 件の子チャンクを検索した際、上位5件がすべて同一のParent Chunkに属しているケースが多々あります。重複排除を行わずにParentを展開すると、同じテキストが5回プロンプトに挿入され、コンテキスト枠の80%が無駄に消費されてしまいます。
def retrieve_parent_contexts(
query: str,
vector_store,
doc_store,
top_k_children: int = 20,
max_parents: int = 4
) -> List[ParentChunk]:
# 1. 最も類似度の高い子チャンク群を検索
matched_children = vector_store.similarity_search(query, k=top_k_children)
# 2. 親IDの順序を維持しながら重複を排除
seen_parent_ids = set()
ordered_parent_ids = []
for child in matched_children:
p_id = child.metadata.get("parent_id")
if p_id and p_id not in seen_parent_ids:
seen_parent_ids.add(p_id)
ordered_parent_ids.append(p_id)
if len(ordered_parent_ids) >= max_parents:
break
# 3. Document Storeから親テキスト全文を一括取得
parent_chunks = [doc_store.get(p_id) for p_id in ordered_parent_ids if doc_store.get(p_id)]
return parent_chunks
設計時に陥りがちな5つの落とし穴
注意すべきアンチパターン:
- Parent Chunkが長すぎる(> 3000 tokens): 長大すぎる親チャンクを複数取得すると、プロンプトが肥大化してAPIコストとレイテンシが増大し、LLMの集中力も散漫になります。
- Child間のオーバーラップ不足: 文の境界に20〜30トークンの重複(Overlap)を持たせないと、専門用語や複合名詞が真っ二つに分断されます。
-
Childへの見出しメタデータの埋め込み忘れ: 子チャンクのテキストが「反則金は9,000円とする」だけだと意味が曖昧になります。ベクトル化する際には
"[道路交通法 > 信号無視] 反則金は9,000円とする"のように見出し情報を付与して埋め込むことが重要です。 -
ストレージ間の同期漏れ: Vector DBのデータを更新した際にDocument Store側の親チャンクの更新・削除を忘れ、ランタイム時に
KeyErrorが発生するケース。 - Rerankerの省略: Parent-Childを採用したからといってRerankerを省くのは禁物です。子チャンクの候補群をCross-Encoderで再評価してから親を取得することで、精度はさらに15〜25%向上します。
2. Auto-Merging Retrieval:動的ツリー統合モデル
Parent–Childの限界と階層の深さ
Parent–Childは固定的な2階層モデル(1親対多子)です。そのため、たった1つの子チャンクがヒットしただけでも、親全体が無条件に展開されます。
しかし、実際の技術マニュアルや法規集は3〜4段階の深いツリー構造を持っています。
Document(セキュリティ管理規程)
└── Chapter(第2章:端末・デバイス管理)
└── Section(第8条:私有端末の業務利用 - BYOD)
└── Paragraph(8.1項:ウイルス対策ソフトの常時稼働義務)
└── Paragraph(8.2項:6桁以上のパスコード設定義務)
└── Paragraph(8.3項:紛失時の緊急連絡フロー)
「ウイルス対策」に関するピンポイントな質問に対しては 8.1項 だけで十分であり、第2章 全体をロードするのは過剰です。
一方で、「BYOD利用時の全義務」を問われた場合は 8.1, 8.2, 8.3 のすべてがヒットします。このときは個別の断片を並べるよりも、自動的にマージして 第8条 全体として渡すほうが遥かに読みやすくなります。
この動的な適応を実現するのが Auto-Merging Retrieval です。
メカニズムと統合閾値(Merge Threshold)
例:東京23区の気象警報
- 東京23区(新宿区、渋谷区、港区、千代田区など)の気象警報システムを例にします。
- 新宿区だけが大雨の場合(1/4 = 25%): 「新宿区に大雨警報」と個別具体的に発表します。
- 4区中3区が大雨の場合(3/4 = 75% $\ge$ 閾値50%): 個別の区名を読み上げるのではなく、上位の階層である「東京23区西部に大雨警報」へと**自動的に統合(Merge)**して発表します。
Auto-Mergingのアルゴリズム
- 各ノードが子ノードのIDリスト(
children_ids)を保持する階層ツリーを構築。 - 統合閾値
merge_threshold(通常は $0.5$、つまり50%)を設定。 - 最下層(Leaf Nodes)でベクトル検索を実行。
- 親ノードごとにヒット率を計算:
$$\text{Hit Ratio} = \frac{\text{ヒットした子ノード数}}{\text{その親に属する全子ノード数}}$$ -
判定ロジック:
- $\text{Hit Ratio} \ge \text{merge_threshold}$ の場合:ヒットした子ノード群を削除し、親ノード1件に置き換える。
- $\text{Hit Ratio} < \text{merge_threshold}$ の場合:子ノードを個別のまま維持する。
- この判定を上位階層に向かって再帰的に実行します。
3. 徹底比較:Parent–Child vs Auto-Merging
| 比較項目 | Parent–Child Retrieval | Auto-Merging Retrieval |
|---|---|---|
| 階層の深さ | 固定2階層(1親 対 多子) | 任意のN階層ツリー(Multi-level Tree) |
| 展開トリガー | 子が1件でもヒットすれば無条件で親を展開 | 子のヒット率が閾値(%)を超えた場合のみ統合 |
| コンテキスト効率 | 狭い質問に対して親が大きすぎると無駄が生じる | 質問の粒度に応じて柔軟に伸縮し極めて高効率 |
| インデックス構築負荷 | シンプルで構築・保守が容易 | ツリー構造の親子関係管理が必要 |
| 検索レイテンシ | 極めて高速(単一のKey-Value取得) | ツリーの走査処理により若干のオーバーヘッド(+5〜15ms) |
| 最適なユースケース | 8割のエンタープライズ社内文書 | 規程集、法規、辞書、大規模技術マニュアル |
4. どちらを採用すべきか?(Decision Guide)
-
Parent–Child を選ぶべきケース:
- Flat RAGからの脱却を図り、最短工数で確実な精度向上を得たいとき。
- 各セクションやページが独立した意味のまとまりを持っているとき。
-
Auto-Merging を選ぶべきケース:
- ドキュメントの階層が3段階以上に深くネストしているとき。
- 「詳細な個別質問」と「章全体にまたがる包括的質問」の双方が高頻度で寄せられるとき。
5. Part 2のまとめと次回予告
本章のキーポイント
- Parent–Child: 子でピンポイントに探し、親で完全に答える。親チャンクの重複排除は必須。
-
Auto-Merging: 統合閾値(
merge_threshold)を用いて、質問の広さに応じて取得粒度を自動調整する。 - これら2つのパターンを適切に使いこなすことで、実務における文脈欠落問題の約85%を解消できます。
👉 次回 階層型RAG完全ガイド:Part 3 - Multi-Representation Retrieval(多重表現検索) では、ベクトル検索が最も苦手とする「複雑な表データ」「ソースコード」「画像・グラフ」、そして質問と事実の乖離を埋める「想定質問生成(Hypothetical Questions)」を駆使したマルチ表現RAGについて解説します!
参考文献
- LangChain How-To Guide. Parent Document Retriever Implementation
- LlamaIndex Official Guide. Auto-Merging Retriever & Hierarchy Builder
- LlamaIndex Documentation. Hierarchical Node Parser & Metadata Extraction
- Pinecone Engineering Blog. Context Expansion and Parent-Child Retrieval
- Qdrant Documentation. Payload-based Document Grouping & Deduplication