Chunk Anchoring — RAGでチケットID幻覚を防ぐシンプルな手法

Chunk Anchoring — RAGでチケットID幻覚を防ぐシンプルな手法

RAGシステムでLLMが存在しないチケットIDを捏造する問題を、Chunk Anchoringとsource_uri検証の二層構造で解決します。
2026.07.12

はじめに

RAGシステムで「出典つき回答」を返したいのに、LLMが存在しないチケットIDを捏造する——そんな経験はありませんか?

社内サービスデスク向けのRAGシステムを構築した際、約1,000件以上のチケットをBedrock Knowledge Baseに投入し、チャンキング後の検索結果をもとにLLMが回答を生成する仕組みを作りました。検索精度は良好だったのですが、ある問題に気づきました。回答に含まれるチケットIDが、実際にKBから取得したチャンクのものと一致しないケースがあるのです。

本記事では、この問題を解決するために採用した「Chunk Anchoring(チャンクアンカー)」という手法と、それを補強する「source_uri検証」について紹介します。どちらもシンプルですが、引用の正確性が求められるRAGシステム全般に応用できるテクニックです。

前提・環境

  • Amazon Bedrock Knowledge Base(Titan Embed V2, 階層型チャンキング)
  • Claude 3.5 Sonnet(Bedrock Converse API 経由)
  • Python 3.13 + FastAPI
  • ナレッジソース:Redmineチケット約1,300件をMarkdown変換しS3に格納

問題:チャンキングでチケットIDが消える

階層型チャンキングの仕組み

Bedrock KBの階層型チャンキング(親チャンク1,500トークン / 子チャンク512トークン)を使っている場合、1つのMarkdownドキュメントは複数のチャンクに分割されます。

元のチケットドキュメントは以下のような構造です:

# チケット #12345: VPN接続が不安定になる

## 基本情報
- ステータス: 完了
- 担当者: 田中太郎
...

## 説明
社内VPN接続が断続的に切断される事象が発生...

## 対応履歴
### [2026-03-15] 佐藤次郎
- ステータス: 対応中 → 完了
ルーターのファームウェア更新で解消...

何が起きるか

チャンキングで分割されると、「説明」や「対応履歴」のチャンクにはチケットIDが含まれない場合があります:

チャンク1: "# チケット #12345: VPN接続が不安定になる\n## 基本情報\n..."  ← IDあり
チャンク2: "## 説明\n社内VPN接続が断続的に切断される事象が..."            ← IDなし!
チャンク3: "## 対応履歴\n### [2026-03-15] 佐藤次郎\nルーターの..."       ← IDなし!

LLMはチャンク2・3の内容から回答を生成する際、チケットIDを補おうとして存在しない番号を捏造します。たとえば「チケット #12300 で同様の事象が報告されています」のように、もっともらしいが実在しないIDを生成してしまうのです。

サービスデスクのオペレータが元チケットを確認する際、存在しないIDを提示されると導線が途切れ、RAGシステムへの信頼が失われます。

解決策1:Chunk Anchoring(チャンクアンカー)

アイデア

すべてのチャンクが自分自身の出典情報を持つようにする——これがChunk Anchoringの考え方です。

具体的には、Markdownドキュメントの各 ## セクション見出しの直後に、チケットIDと件名をブロッククォート形式で挿入します:

# チケット #12345: VPN接続が不安定になる

## 基本情報
- ステータス: 完了
...

## 説明
> チケット #12345: VPN接続が不安定になる
社内VPN接続が断続的に切断される事象が発生...

## 対応履歴
> チケット #12345: VPN接続が不安定になる
### [2026-03-15] 佐藤次郎
> チケット #12345: VPN接続が不安定になる
ルーターのファームウェア更新で解消...

これにより、どのチャンクが検索結果として返されても、必ずチケット番号と件名が含まれます

実装

変換処理(transform.py)の中で、チケットデータからMarkdownを生成する際にアンカーを埋め込みます:

def ticket_to_document(raw_ticket: dict, lookups=None) -> TicketDocument:
    issue = raw_ticket["issue"]
    issue_id = issue["id"]
    subject = issue.get("subject", "(タイトルなし)")

    # チャンクアンカー: 各セクションに繰り返し挿入
    _anchor = f"> チケット #{issue_id}: {subject}"

    sections = [
        f"# チケット #{issue_id}: {subject}",
        "",
        "## 基本情報",
        # ... 基本情報フィールド(直前に # タイトルがあるためアンカー不要)
    ]

    # 説明セクション
    if description:
        sections.extend(["", "## 説明", _anchor, clean_desc])

    # 対応履歴セクション
    if meaningful_journals:
        sections.extend(["", "## 対応履歴", _anchor])
        for journal in meaningful_journals:
            sections.append(f"\n### [{created}] {user}\n{_anchor}")
            # ... ジャーナル内容

ポイント:

  • ## 基本情報にはアンカーを入れない: 直前に # チケット #ID があるため、常に同一チャンクに含まれる
  • 対応履歴の各ジャーナルエントリにもアンカーを挿入: 対応履歴は長くなりがちで、複数チャンクにまたがることが多いため
  • ブロッククォート形式(>)を使用: LLMがアンカーを回答テキストとして使うのではなく、メタ情報として認識しやすくなる
  • 冪等な処理: 毎回同じアンカーが生成されるため、再変換しても重複しない

なぜ効果的か

この手法が効果的なのは、チャンクの粒度に依存しない点です。チャンキング戦略を変更しても(固定長、セマンティック、階層型など)、各セクションにアンカーが埋め込まれている限り、チケットIDは必ずチャンクに含まれます。

LLMは引用元を「推測」する必要がなくなり、チャンク内のアンカーテキストをそのまま参照できます。

解決策2:source_uri 検証(多層防御)

Chunk AnchoringでチケットIDの幻覚は大幅に減りましたが、100%の保証にはなりません。LLMが複数チャンクの情報を混同して別のチケットIDを返す可能性は依然として存在します。

そこで、バックエンド側でLLM出力を後処理し、取得チャンクに存在するIDのみを通すフィルタを追加しました。

ターン1:source_uri からの抽出

KBから取得した各チャンクには source_uri(S3上のファイルパス)が付与されています。ファイル名にチケットIDが含まれるため、正規表現で抽出できます:

valid_ticket_ids: set[int] = set()
for c in chunks:
    m = re.search(r"/(\d+)\.md", c["source_uri"])
    if m:
        valid_ticket_ids.add(int(m.group(1)))

s3://bucket/tickets/12345.md → ID 12345 のように、テキスト解析不要で最も信頼性が高い方法です。

ターン2以降:アンカーからの抽出

マルチターン会話では、ターン1でKB検索結果を含む拡張メッセージを構築し、後続ターンではそのメッセージを引き継ぎます。この場合、source_uri は直接利用できませんが、Chunk Anchoringで埋め込んだアンカー文字列を正規表現で抽出できます:

valid_ticket_ids = {
    int(m) for m in re.findall(r"チケット #(\d+)", messages[0].content)
}

Chunk Anchoringがここで二重に活きます: 検索時のID識別だけでなく、検証時のグラウンドトゥルース抽出にも使われるのです。

フィルタリング

LLMがストリーミングで返すチケット情報を、valid_ticket_ids で検証します:

async for item in generate_chat_answer(bedrock_messages, source_chunks_count):
    if isinstance(item, TicketResult):
        # valid_ticket_ids が空ならフォールバック(全件通過)
        if not valid_ticket_ids or item.issue_id in valid_ticket_ids:
            yield item
    elif isinstance(item, ChatResponse):
        if valid_ticket_ids:
            item.results = [
                r for r in item.results if r.issue_id in valid_ticket_ids
            ]
        yield item

フォールバック設計: valid_ticket_ids が空の場合(URI形式が期待と異なるなど)は検証をスキップし全件通過させます。フィルタリングの失敗で「結果がゼロ件になる」サイレント障害を防ぐためです。

二層構造の全体像

rag-chunk-anchoring-ticket-id-hallucination-prevention-two-layer

第1層(Chunk Anchoring) が予防的対策、第2層(source_uri検証) が検出的対策です。この二層構造により、チケットID幻覚をほぼ完全に防止できます。

適用範囲:チケット以外にも使えるか

Chunk Anchoringは、引用元の識別子が重要なRAGシステム全般に応用できます:

ドメイン アンカー形式の例
社内チケット管理 > チケット #12345: 件名
法律文書 > 第42条 個人情報の取扱い
製品マニュアル > [ModelX-2000] トラブルシューティング
学術論文 > [Smith2024] Large Language Models in Production
社内Wiki > KB-0042: VPN接続手順

共通するのは、チャンクが分割された後も出典を追跡する必要があるケースです。

代替案: メタデータJSONサイドカー

Bedrock KBは .metadata.json サイドカーファイルによるメタデータ付与をサポートしています。「チケットIDをドキュメント本文に繰り返し埋め込むのではなく、サイドカーに分離すべきでは?」という疑問は自然です。

サイドカーの限界

Bedrock KBのメタデータサイドカーはフィルタリング属性として機能します。チャンクテキストに自動挿入されるわけではありません:

{"metadataAttributes": {"ticket_id": 12345, "subject": "VPN接続が不安定"}}

このメタデータはKB検索時の絞り込みには使えますが、LLMが受け取るチャンクテキストには含まれません。つまり、チャンクからIDが欠落する問題はサイドカーだけでは解決しません。

サイドカー + オーケストレーション層での注入

カスタムオーケストレーションを構築している場合、検索後にサイドカーメタデータをチャンクに付与するアプローチが可能です:

for chunk in retrieved_chunks:
    meta = get_sidecar_metadata(chunk["source_uri"])
    chunk["text"] = f"> チケット #{meta['ticket_id']}: {meta['subject']}\n{chunk['text']}"

比較

Chunk Anchoring サイドカー + 注入
ドキュメントの可読性 アンカーが繰り返される ソースはクリーン
Embeddingトークン増加 ~3-5% なし
実装の複雑さ 変換スクリプトのみ 検索後処理が必要
マネージドRAGでそのまま動作 する しない(カスタムプロンプト組立が必要)
メタデータ更新時の再Embedding 必要 不要(サイドカーのみ更新)
マルチターンでの検証(第2層) メッセージテキストから抽出可能 メタデータを別途引き回す必要あり

本記事でChunk Anchoringを選んだ理由

  1. インフラ非依存: 検索バックエンドを変更しても、マネージドRAGに切り替えても動作する
  2. 第2層検証との相乗効果: アンカーテキストがマルチターン会話でもグラウンドトゥルース抽出に使える
  3. 実装のシンプルさ: 変換スクリプトの数行変更で完結し、クエリ時のメタデータルックアップが不要

カスタムオーケストレーションが前提で、メタデータの頻繁な更新が見込まれる場合は、サイドカー + 注入アプローチも有効な選択肢です。

実装時の注意点

トークン増加のトレードオフ

各セクションにアンカーを繰り返すため、ドキュメント全体のトークン数は増加します。約1,300件のチケットで実測したところ、トークン増加率は約3-5%でした。Embedding料金とストレージの微増はありますが、引用精度の改善と比較すれば十分に許容できるトレードオフです。

チャンキング戦略との相性

チャンキング戦略 Chunk Anchoringとの相性
階層型(Bedrock KB) 良好。## 見出しがチャンク境界のヒントになる
固定長 良好。セクション途中で分割されてもアンカーが残る
セマンティック 良好。意味単位ごとにアンカーが含まれる
文単位 注意。アンカーが全文の大部分を占める可能性がある

文単位の細かいチャンキングではアンカーのオーバーヘッドが目立ちますが、実用上そこまで細かく分割するケースは少ないでしょう。

フォールバック設計が重要

source_uri検証を導入する際は、検証失敗時の動作を明示的に設計してください。検証を厳格にしすぎると、URI形式の変更やメタデータの欠損時に「回答がゼロ件」というサイレント障害を引き起こします。「valid_ticket_ids が空なら全件通過」というフォールバックは、安全側に倒す設計として有効です。

まとめ

  • Chunk Anchoring: 各チャンクセクションに出典識別子(> チケット #ID: 件名)を埋め込み、チャンキングによるID喪失を防ぐ
  • source_uri検証: LLM出力をKBチャンクのグラウンドトゥルースと照合し、幻覚IDを除外する
  • 二層構造(予防 + 検出)で、引用精度をほぼ完全に保証
  • シンプルな実装で、引用の正確性が求められるRAGシステム全般に応用可能

RAGシステムで「出典の正確性」に課題を感じている方は、まずChunk Anchoringから試してみてください。数行のコード変更で、引用品質が大きく改善するはずです。


Claudeならクラスメソッドにお任せください

クラスメソッドは、Anthropic社とリセラー契約を締結しています。各種製品ガイドから、業種別の活用法、フェーズごとのお悩み解決などサービス支援ページにまとめております。まずはご覧いただき、お気軽にご相談ください。

サービス詳細を見る

この記事をシェアする

AWSのお困り事はクラスメソッドへ

関連記事