0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

階層型RAG完全ガイド:Part 2 - Parent–Child & Auto-Merging

0
Posted at

検索精度とコンテキスト完全性の両立メカニズム

連載「階層型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種類のストレージを協調して運用する点にあります。

  1. Vector Store: 類似度検索のために、ベクトル埋め込みを持つ ChildChunk を格納。
  2. Document Store(Key-Value): parent_id をキーとして、未加工の文脈を保持する ParentChunk を格納。
schema_parent_child.py
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日(休日の場合は前営業日)の給与支払い時に合算して口座振込にて支給する。

システムの処理フロー:

  1. Parent Chunk: 「第4条」全体(約200トークン)。
  2. Child Chunks:
    • Child 1: 「正社員に対しては、月額上限20,000円までの通勤手当の実費、および月額10,000円の昼食手当を支給する。」
    • Child 2: 「試用期間中の従業員については、通勤手当は全額実費支給の対象とするが、昼食手当の適用は本採用以降とする。」
  3. 検索ステップ: ユーザーの質問は Child 2 と極めて高いコサイン類似度($0.89$)を記録。
  4. 展開ステップ: システムは Child 2 のみをLLMに渡すのではなく、parent_id を使って「第4条」全体をロード。
  5. 回答生成: LLMは「通勤手当は支給されるが、昼食手当は本採用後まで支給されないこと」「支給日は毎月25日であること」という文脈を完全に把握し、漏れのない正確な回答を生成できます。

本番環境における重複排除(Deduplication)

Top-20 件の子チャンクを検索した際、上位5件がすべて同一のParent Chunkに属しているケースが多々あります。重複排除を行わずにParentを展開すると、同じテキストが5回プロンプトに挿入され、コンテキスト枠の80%が無駄に消費されてしまいます。

retrieval_dedup.py
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つの落とし穴

注意すべきアンチパターン:

  1. Parent Chunkが長すぎる(> 3000 tokens): 長大すぎる親チャンクを複数取得すると、プロンプトが肥大化してAPIコストとレイテンシが増大し、LLMの集中力も散漫になります。
  2. Child間のオーバーラップ不足: 文の境界に20〜30トークンの重複(Overlap)を持たせないと、専門用語や複合名詞が真っ二つに分断されます。
  3. Childへの見出しメタデータの埋め込み忘れ: 子チャンクのテキストが「反則金は9,000円とする」だけだと意味が曖昧になります。ベクトル化する際には "[道路交通法 > 信号無視] 反則金は9,000円とする" のように見出し情報を付与して埋め込むことが重要です。
  4. ストレージ間の同期漏れ: Vector DBのデータを更新した際にDocument Store側の親チャンクの更新・削除を忘れ、ランタイム時に KeyError が発生するケース。
  5. 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のアルゴリズム

  1. 各ノードが子ノードのIDリスト(children_ids)を保持する階層ツリーを構築。
  2. 統合閾値 merge_threshold(通常は $0.5$、つまり50%)を設定。
  3. 最下層(Leaf Nodes)でベクトル検索を実行。
  4. 親ノードごとにヒット率を計算:
    $$\text{Hit Ratio} = \frac{\text{ヒットした子ノード数}}{\text{その親に属する全子ノード数}}$$
  5. 判定ロジック:
    • $\text{Hit Ratio} \ge \text{merge_threshold}$ の場合:ヒットした子ノード群を削除し、親ノード1件に置き換える
    • $\text{Hit Ratio} < \text{merge_threshold}$ の場合:子ノードを個別のまま維持する
  6. この判定を上位階層に向かって再帰的に実行します。

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について解説します!


参考文献

  1. LangChain How-To Guide. Parent Document Retriever Implementation
  2. LlamaIndex Official Guide. Auto-Merging Retriever & Hierarchy Builder
  3. LlamaIndex Documentation. Hierarchical Node Parser & Metadata Extraction
  4. Pinecone Engineering Blog. Context Expansion and Parent-Child Retrieval
  5. Qdrant Documentation. Payload-based Document Grouping & Deduplication
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?