Chunk Anchoring — RAGでチケットID幻覚を防ぐシンプルな手法
はじめに
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形式が期待と異なるなど)は検証をスキップし全件通過させます。フィルタリングの失敗で「結果がゼロ件になる」サイレント障害を防ぐためです。
二層構造の全体像

第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を選んだ理由
- インフラ非依存: 検索バックエンドを変更しても、マネージドRAGに切り替えても動作する
- 第2層検証との相乗効果: アンカーテキストがマルチターン会話でもグラウンドトゥルース抽出に使える
- 実装のシンプルさ: 変換スクリプトの数行変更で完結し、クエリ時のメタデータルックアップが不要
カスタムオーケストレーションが前提で、メタデータの頻繁な更新が見込まれる場合は、サイドカー + 注入アプローチも有効な選択肢です。
実装時の注意点
トークン増加のトレードオフ
各セクションにアンカーを繰り返すため、ドキュメント全体のトークン数は増加します。約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から試してみてください。数行のコード変更で、引用品質が大きく改善するはずです。





