Claudeのsearch_result引用を回帰テストする — 引用元の取り違えを検出
search_resultの引用が渡したどの検索結果を指すかを、index・source・cited_textの突き合わせで自動検査する方法。固定データのオフライン検査とライブ回帰の2段で組みます。
search_resultブロックで引用付きRAGを組んだとき、回答の引用が「渡した検索結果のどれを指すか」は自動で突き合わせられます。引用オブジェクトにはsearch_result_index、source、title、cited_textが入っており、この4つは送信したリクエストから再計算できるからです。再計算した値と食い違う引用を落とす関数を1つ書けば、引用元の取り違えは回帰テストで検出できます。
引用1件につき4つの突き合わせで取り違えを拾える
引用の各フィールドは、リクエスト側のsearch_resultと次の関係にあります。公式の定義にあるのは、sourceとtitleが元のブロックの値であること、cited_textがcontent[start_block_index:end_block_index]の連結であること、search_result_indexがリクエスト内の全search_resultを出現順に数えた0始まりの番号であることです。
| 検査 | 突き合わせ先 | 落ちたときに疑うこと |
|---|---|---|
| indexが範囲内 | 突き合わせ先リクエスト内のsearch_result件数 | 落ちたときに疑うこと番号の数え方、送信と検査で別の配列を見ている |
source・titleが一致 | 突き合わせ先results[index]の同名フィールド | 落ちたときに疑うこと番号がずれて別の結果を指している |
| ブロック範囲が妥当 | 突き合わせ先0 <= start < end <= len(content) | 落ちたときに疑うことcontent配列を加工してから検査している |
cited_textが一致 | 突き合わせ先content[start:end]を連結した文字列 | 落ちたときに疑うこと範囲か本文のどちらかが食い違っている |
sourceだけを見る検査では不十分です。同じtitleでsourceだけが違う結果が並ぶ構成では、titleの比較は素通りします。反対にsourceが同じ別ブロックを指していればsourceの比較は通ります。最後のcited_textの突き合わせが、ブロックの取り違えまで拾う最終防衛線になります。
番号の数え方そのものはsearch_resultを混在させたときのsearch_result_indexの番号仕様に、ブロック範囲の読み方はsearch_resultの引用粒度を細かくする方法にあります。ここでは、それを検査コードに落とします。
検査関数は「リクエストから期待値を再構成する」だけで書ける
検査関数は、送信したmessagesを先頭から走査してsearch_resultを出現順に集め、応答の引用を1件ずつ照合します。ユーザーメッセージ直下のブロックと、tool_resultの内側のブロックを同じ配列に積むのがポイントです。
# citecheck.py
def collect_results(messages):
found = []
for m in messages:
if isinstance(m["content"], str):
continue
for b in m["content"]:
if b["type"] == "search_result":
found.append(b)
elif b["type"] == "tool_result" and isinstance(b["content"], list):
found += [x for x in b["content"] if x["type"] == "search_result"]
return found
def check_citations(messages, response_content):
"""引用1件ごとの違反を文字列で返す。空なら合格。"""
results = collect_results(messages)
errors = []
for block in response_content:
for c in block.get("citations") or []:
if c["type"] != "search_result_location":
continue
i = c["search_result_index"]
if not 0 <= i < len(results):
errors.append(f"index {i} は範囲外(全{len(results)}件)")
continue
r = results[i]
if c["source"] != r["source"]:
errors.append(f"index {i}: source {c['source']} != {r['source']}")
if c["title"] != r["title"]:
errors.append(f"index {i}: title 不一致")
s, e = c["start_block_index"], c["end_block_index"]
if not 0 <= s < e <= len(r["content"]):
errors.append(f"index {i}: ブロック範囲 [{s}:{e}] が不正")
continue
text = "".join(x["text"] for x in r["content"][s:e])
if c["cited_text"] != text:
errors.append(f"index {i}: cited_text が content[{s}:{e}] と不一致")
return errors
def cited_sources(response_content):
return {c["source"] for b in response_content
for c in (b.get("citations") or [])}response_contentは応答のcontent配列を辞書に直したものです。typeがtextでないブロックにはcitationsが無いので、.get("citations") or []で読み飛ばします。
検査対象をsearch_result_locationに絞っているのは、同じ応答にほかの種類の引用が混ざる構成を想定したためです。citationsの型はドキュメント引用とsearch_result引用で別々に定義されています。
取り違えを人工的に作って、検査が落ちることを先に確かめる
検査関数は、正しい応答で通るだけでは信用できません。誤った引用を手で作り、確実に落ちることを先に確かめます。ここでは「同じタイトルで中身が違う2件の検索結果」を渡し、2つ目を指すべき引用が1つ目を指している状況を作ります。
# test_offline.py
from citecheck import check_citations, cited_sources
def sr(source, title, *texts):
return {"type": "search_result", "source": source, "title": title,
"content": [{"type": "text", "text": t} for t in texts],
"citations": {"enabled": True}}
MESSAGES = [{"role": "user", "content": [
sr("kb://billing-2024", "請求ガイド", "返金は30日以内。"),
sr("kb://billing-2025", "請求ガイド", "返金は14日以内。", "分割払いは不可。"),
{"type": "text", "text": "返金期限は?"},
]}]
def cite(i, s, e, src, title, text):
return {"type": "search_result_location", "cited_text": text, "source": src,
"title": title, "search_result_index": i,
"start_block_index": s, "end_block_index": e}
GOOD = [{"type": "text", "text": "14日以内です。",
"citations": [cite(1, 0, 1, "kb://billing-2025", "請求ガイド", "返金は14日以内。")]}]
SWAPPED = [{"type": "text", "text": "14日以内です。",
"citations": [cite(0, 0, 1, "kb://billing-2025", "請求ガイド", "返金は14日以内。")]}]
def test_good():
assert check_citations(MESSAGES, GOOD) == []
assert cited_sources(GOOD) == {"kb://billing-2025"}
def test_swapped_index_is_caught():
errs = check_citations(MESSAGES, SWAPPED)
assert any("source" in e for e in errs)
assert any("cited_text" in e for e in errs)SWAPPEDはsearch_result_indexだけが0にずれ、sourceとcited_textは2つ目の値のままです。実際の応答でこの形が出るとは限りません。検査が「番号と中身の食い違い」を検出できるかを試すための人工データです。
SWAPPEDを直接検査にかけると、次の2件が返ります。titleが両方とも「請求ガイド」なので、titleの比較は通っています。
python3 -c "from test_offline import *; print(check_citations(MESSAGES, SWAPPED))"
['index 0: source kb://billing-2025 != kb://billing-2024', 'index 0: cited_text が content[0:1] と不一致']titleだけを見る検査なら、この取り違えは見逃されます。fixtureには同名タイトルを必ず1組入れておくと、検査の網が粗くなったときに落ちます。
ライブ回帰テストは「性質」だけを検査する
実APIの応答は毎回同じ文面になるとは限りません。回答文の完全一致は回帰テストに向かず、検査するのは「どう答えても守られるべき性質」です。
- 引用が付いている(引用ゼロの回答は、期待する出典に基づく質問では失敗扱い)
check_citationsが空を返す(参照の整合性)- 引用された
sourceが、質問に対する期待ソースの集合に含まれる - 検索結果を並べ替えて送り直しても、期待ソースの集合は変わらない
3番目が取り違えの本命です。固定した質問に「どのsource由来の答えか」を人が決めておき、引用のsourceがその集合から外れたら失敗にします。
# test_live.py
import itertools, os
import anthropic
from citecheck import check_citations, cited_sources
from test_offline import sr
MODEL = os.environ["CITE_TEST_MODEL"] # 検査対象のモデルIDを環境変数で渡す
client = anthropic.Anthropic()
RESULTS = [
sr("kb://billing-2024", "請求ガイド(旧)", "返金は30日以内。"),
sr("kb://billing-2025", "請求ガイド(新)", "返金は14日以内。"),
sr("kb://shipping", "配送ガイド", "通常配送は3営業日。"),
]
CASES = [("現行の返金期限は?", {"kb://billing-2025"})]
def ask(results, question):
messages = [{"role": "user", "content": results
+ [{"type": "text", "text": question}]}]
resp = client.messages.create(model=MODEL, max_tokens=512, messages=messages)
return messages, [b.model_dump() for b in resp.content]
def test_expected_source_for_every_order():
for question, expected in CASES:
for perm in itertools.permutations(RESULTS):
messages, content = ask(list(perm), question)
assert check_citations(messages, content) == []
got = cited_sources(content)
assert got and got <= expected, (question, [r["source"] for r in perm], got)並べ替えは3件なら6通りで済みます。件数が増えたら全順列は避け、先頭・末尾・逆順などを固定で選びます。並べ替えが意味を持つのは、番号が出現順で決まる仕様だからです。順序を入れ替えるとsearch_result_indexの値が変わるので、番号と中身を結ぶ処理にバグがあれば順序の違いで露出します。
試験に使うcitations.enabledは全件でtrueにそろえています。引用の有効・無効を1つのリクエスト内で混ぜるとエラーになります。
落とし穴
期待ソースの集合が「新旧の版」だと、正解の取り決め自体が揺れる
旧版と新版のように、似た内容でsourceだけが違う結果を並べるケースは取り違えの検査に向いています。ただし期待ソースを決めるのは人です。質問文に「現行の」と書き、どちらが正解かが一意に決まるfixtureにします。どちらを引いても妥当な質問を入れると、テストが偶然の順序で揺れるだけになります。
検査が保証すること、保証しないこと
ここまでの検査は、引用の参照が壊れていないかを見るものです。回答文の主張がcited_textで本当に裏づけられているかは見ていません。cited_textは検索結果の本文の一部で、回答文との意味の対応は公式のどこにも保証されていません。
この区別は運用上重要です。参照の整合性は機械で全件検査できますが、意味の裏づけは別の手段が要ります。固定の質問と期待ソースに加えて、回答文とcited_textの組を別のモデルで採点する、または人が抜き取りで見る、といった層を重ねます。意味の裏づけを見る層は費用がかかり結果も揺れるので、参照の整合性が通ってから回します。
公式の整理では、cited_textは出力トークンに数えられず、次のターンに引用つきの履歴を送り返すときも入力トークンに数えられません。検査で全引用のcited_textを保存してもAPIの課金には響きません。
引用が付かない・エラーになる構成は別に検査する
回帰テストでは、引用が正しいことと同じくらい「引用が付かない構成を踏んでいないか」が重要です。公式が挙げている制約を、リクエスト組み立て側の単体テストにしておくと、送信前に落とせます。
- 引用は全件有効か全件無効のどちらかにそろえる。混在させるとエラーになる
tool_resultのcontentにsearch_resultを入れるなら、そのブロックはすべてsearch_resultにする。ほかの型と混ぜると検証エラーになるsearch_resultはユーザーメッセージ(ツール結果の内側を含む)にだけ置ける。assistantメッセージに置くと拒否される- ウェブ検索ツールを同じリクエストで有効にしているときは、すべての
search_resultで引用を有効にする - 引用がない既定のままだと何も付かない。
citationsは既定で無効なので、{"enabled": true}を明示する
最後の項目は、テストの冒頭で「全search_resultにcitations.enabledがtrue」を検査するだけで守れます。引用ゼロの回答が出たとき、設定漏れとモデルの挙動を切り分ける手間が減ります。
Claude Haiku 3は検索結果の引用に対応していないと公式に書かれています。検査対象のモデルIDを環境変数にしておけば、CIでモデルを差し替えるときに、非対応モデルを選んだ時点で原因が分かります。
ストリーミング応答にも同じ検査を使う
ストリーミングでは、引用はcontent_block_deltaイベントの中にcitations_deltaという種類の差分として届きます。1つの差分に入るのは引用1件で、現在のtextブロックのcitationsリストに追加される仕様です。
テストでは、ストリームを受けてtextブロックごとにcitationsを集め直し、完成したcontent配列を作ってからcheck_citationsに渡します。検査関数は完成後の形だけを前提にしているので、通常の応答とストリーミングの応答で共通に使えます。表示側の実装が差分の追加を取りこぼすと、画面に出る引用がテストの対象外で欠けます。画面の描画結果と完成したcontentの引用件数の一致も、1件検査に加えておくと取りこぼしを拾えます。
ツール経由の多ターン会話では「最終リクエスト」を検査に渡す
ツールの実行結果(tool_result)としてsearch_resultを返す構成では、検索が複数回走ると結果が複数のtool_resultに分かれます。公式の定義では、search_result_indexはリクエスト内の全search_resultを、複数のメッセージとツール結果をまたいで出現順に数えた番号です。1ターン目の検索で2件、2ターン目の検索で3件返したなら、2ターン目の結果は番号2から始まります。
検査で渡すmessagesは、引用を含む応答を得たときに送ったリクエストそのものにします。ターンごとに分けた配列や、後から整形し直した履歴を渡すと、collect_resultsが数える順序が実際の送信とずれ、正しい引用が「取り違え」として落ちます。次のようにリクエストを送った直後に保存しておくと、失敗時の再現にも使えます。
import json, pathlib
def ask_and_record(messages, tag):
resp = client.messages.create(model=MODEL, max_tokens=512, messages=messages)
content = [b.model_dump() for b in resp.content]
pathlib.Path(f"fixtures/{tag}.json").write_text(
json.dumps({"messages": messages, "content": content},
ensure_ascii=False, indent=2))
return content保存したfixtures/*.jsonは、そのままオフライン検査の入力になります。ライブで一度落ちた応答を固定データへ昇格させれば、モデルが変わっても同じ取り違えを再現できる回帰テストが増えていきます。messagesにAPIキーや顧客データを含めない運用にしておくと、リポジトリにそのままコミットできます。
失敗メッセージには、search_result_indexだけでなく配列全体のsource一覧も出しておくと、原因の切り分けが速くなります。一覧の何番目に期待するsourceが居るかが分かれば、番号のずれなのか、別の結果を引いたのかを見分けられます。
CIへの置き方
オフライン検査とライブ回帰は、走らせる頻度を分けると運用しやすくなります。
| 層 | 入力 | 走らせるとき | 落ちたら疑うこと |
|---|---|---|---|
| オフライン | 入力固定の応答データ | 走らせるとき毎回のコミット | 落ちたら疑うこと検査関数・fixture・送信前の組み立て |
| ライブ(少数の固定質問) | 入力実APIの応答 | 走らせるときモデル更新時、プロンプト変更時 | 落ちたら疑うことモデルの挙動変化、送信配列の順序の扱い |
ライブ側は応答が揺れる前提なので、1回の失敗で即座に落とすより、同じケースを複数回走らせて再現率で見る運用も選べます。そのときもcheck_citationsの違反は決定的な不整合なので、1件でも出たら失敗にします。判定を緩めてよいのは、期待ソースに関わる3番目の性質のほうです。
Claudeの引用付きRAGの全体像と、検索結果の与え方はClaude RAGの構築手順にあります。
まとめ
引用の参照の整合性は、search_result_index・source・title・cited_textをリクエストから再計算して突き合わせるだけで全件検査できます。同名タイトルで中身の違う結果をfixtureに入れ、検査が落ちることを先に確かめておくと、検査の網が粗くなる事故を防げます。回答文の意味が引用で裏づけられているかは別の層の仕事です。参照の整合性を安定して通してから、その上に重ねます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。