自分が運営する記事群に対して「関連記事を選ぶ」「事実関係を照合する」ための社内エージェントを、Claude API で組んでいたときの話から始めます。私自身、4つの技術ブログを個人開発で回していて、日本語だけで合計600本を超える記事が手元にあります。検索でヒットした候補をプロンプトに連結し、「この主張の根拠になる記事はどれですか」と尋ねる——よくある自前RAGの形です。
最初の実装はそれなりに動いていました。ところが運用を続けるうちに、無視できない癖が見えてきます。Claude は「○○という記事に書かれています」と答えるのですが、その記事名が手元の候補に存在しないことがあるのです。連結した本文のどこを読んで判断したのかも追えません。根拠の出どころが言葉の中に溶けてしまい、後から検証できない。これは社内向けとはいえ、誠実さを欠く挙動でした。
この「どの資料の、どの部分を根拠にしたか」を構造化データとして必ず返させる仕組みが、Claude の Citations、とりわけ search_result コンテンツブロックです。本番に載せ替えて数週間運用した実装と、その過程で踏んだ落とし穴を残しておきます。
言葉で根拠を語らせると、なぜ検証できなくなるのか
自前で本文を連結して渡す方式は、Claude から見ると「ただの長い文字列」です。区切りに ## 記事A のような見出しを入れても、それは意味の境界としては伝わりますが、機械可読な参照子ではありません。結果として返ってくる根拠は「記事Aによると〜」という自然文で、これを正規表現で拾って元記事に紐付け直す後処理が必要になります。
この後処理が脆いのが問題でした。Claude が記事名を少し言い換えたり、複数記事の内容を統合して「これらの記事から」と曖昧にまとめたりすると、マッチに失敗します。私の環境では、引用の機械的な突合成功率がおよそ7割前後で頭打ちでした。残りの3割は人手で確認するしかなく、自動化の意味が半減していました。
根本原因は、参照のIDを渡していないことに尽きます。渡していない以上、返ってくるはずもありません。search_result は、まさにこの「IDを持った検索結果として資料を渡す」ためのコンテンツブロックです。
search_result コンテンツブロックの構造
search_result は、メッセージの content 配列に置ける専用ブロックです。1件の検索結果が、ソース識別子・タイトル・本文断片の配列という三点セットで表現されます。
search_result_block = {
"type" : "search_result" ,
"source" : "https://claudelab.net/ja/articles/api-sdk/claude-api-prompt-caching-monthly-cost-half-guide" ,
"title" : "プロンプトキャッシュで月額コストを半分にする実装" ,
"content" : [
{ "type" : "text" , "text" : "5分TTLのキャッシュブレークポイントをsystemの末尾に置くと…" },
{ "type" : "text" , "text" : "1時間TTLは静的な大きい前提に向きます。料金は…" },
],
"citations" : { "enabled" : True },
}
ポイントは content が文字列ではなくテキストブロックの配列であることです。Claude はこの配列の何番目を根拠にしたかをインデックスで返すので、断片の切り方がそのまま引用の粒度になります。段落単位で分けておくと、後から「記事のこの段落」へリンクを張りやすくなります。citations.enabled を true にしたブロックだけが引用の対象になる点も、最初に押さえておきたいところです。
最小実装:検索結果をそのまま渡す
自前の全文検索(私の場合は記事メタデータに対する単純な転置インデックス)でヒットした上位数件を、search_result ブロックに詰め替えて Messages API に渡します。
import anthropic
client = anthropic.Anthropic()
def to_search_results (hits):
blocks = []
for h in hits:
blocks.append({
"type" : "search_result" ,
"source" : h[ "url" ],
"title" : h[ "title" ],
"content" : [{ "type" : "text" , "text" : p} for p in h[ "paragraphs" ]],
"citations" : { "enabled" : True },
})
return blocks
hits = my_search( "プロンプトキャッシュの TTL はどう使い分けるか" , top_k = 5 )
resp = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 1024 ,
messages = [{
"role" : "user" ,
"content" : [
* to_search_results(hits),
{ "type" : "text" , "text" : "上の資料だけを根拠に、TTLの使い分けを3点でまとめてください。" },
],
}],
)
返ってきた応答の各テキストブロックには citations 配列がぶら下がり、cited_text・source・参照した断片のインデックスが構造化されて入ってきます。自然文から記事名を推測する後処理は、もう要りません。
Before / After:手組み連結からの移行
移行前後で何が変わるかを、コードで並べます。まず従来の手組み方式です。
# Before: 本文をひと続きの文字列に連結して渡す
context = ""
for i, h in enumerate (hits):
context += f "## 資料 { i + 1 } : { h[ 'title' ] }\n{ h[ 'full_text' ] }\n\n "
prompt = f """次の資料を参考に質問へ答え、末尾に [資料N] 形式で出典を書いてください。
{ context }
質問: { question } """
# → 出典は自然文。突合は正規表現頼みで、言い換えに弱い
これを search_result に置き換えます。
# After: 構造化された検索結果として渡す
resp = client.messages.create(
model = "claude-sonnet-4-6" ,
max_tokens = 1024 ,
messages = [{
"role" : "user" ,
"content" : [ * to_search_results(hits),
{ "type" : "text" , "text" : question}],
}],
)
# 引用は構造化データとして取れる
for block in resp.content:
if block.type != "text" :
continue
for c in getattr (block, "citations" , None ) or []:
print (c.source, "→" , c.cited_text[: 40 ])
実務上の効果は二つありました。一つは、出典を自然文で書かせる指示文が不要になり、full_text ではなく検索でヒットした関連段落だけを渡せるようになったこと。これで1リクエストあたりの送信トークンが、私のワークロードでは平均して約40%減りました。もう一つは、突合の成功率です。IDで返るため後処理のマッチが原理的に不要になり、人手確認に回していた3割がほぼ消えました。
citations を記事リンクへ還元する
引用が構造化されている最大の利点は、UI への落とし込みが素直になることです。回答テキストの該当箇所に、元記事へのリンク付き脚注を差し込みます。
def render_with_footnotes (content_blocks):
html, notes = [], []
for block in content_blocks:
if block.type != "text" :
continue
cites = getattr (block, "citations" , None ) or []
if not cites:
html.append(escape(block.text))
continue
marks = []
for c in cites:
idx = len (notes) + 1
notes.append((idx, c.source, c.title))
marks.append( f '<sup><a href="#fn { idx } ">[ { idx } ]</a></sup>' )
html.append(escape(block.text) + "" .join(marks))
foot = "" .join(
f '<li id="fn { i } "><a href=" { escape(src) } "> { escape(t) } </a></li>'
for i, src, t in notes
)
return "" .join(html) + f "<ol> { foot } </ol>"
source に記事の正規URLを入れておけば、脚注のリンク先がそのまま該当記事になります。私は段落単位で content を切っているので、URLにアンカーを足して該当段落まで飛ばす拡張も無理なく載りました。読者から見て「この回答はどこ発か」が一目で追える状態になり、社内ツールの信頼性がはっきり上がったと感じています。
ツールの返り値として search_result を返す
検索を別のツール(あるいは MCP サーバー)として切り出している場合、tool_result の中身として search_result ブロックを返せます。エージェントが自分で検索を呼び、その結果に引用が付く形です。
# tool_use への応答として search_result を詰める
tool_result = {
"type" : "tool_result" ,
"tool_use_id" : tu.id,
"content" : to_search_results(my_search(tu.input[ "query" ], top_k = 5 )),
}
messages.append({ "role" : "user" , "content" : [tool_result]})
この形にすると、検索→根拠付き回答→さらに掘り下げ、という多段のループでも引用が一貫して維持されます。私はこれを記事の事実照合パイプラインに組み込み、Claude Code から定期実行しています。検索の実体はツール側に閉じ込め、API へはIDを持った結果だけが渡るので、責務の分離もきれいに収まりました。
運用で見えた実測値
数週間の運用で取れた、移行前後のおおまかな比較です。社内ワークロードでの測定なので参考値ですが、傾向は安定していました。
指標 手組み連結(Before) search_result(After)
引用の機械的突合成功率 約70% ほぼ100%(IDで返る)
1リクエスト平均の送信トークン 基準 約40%削減
回答までの平均レイテンシ 約2.7秒 約2.1秒
人手による出典確認 全体の約30% 例外時のみ(数%)
レイテンシが縮んだのは、渡す本文が全文から関連段落へ絞られた副次効果です。コストとレイテンシが同時に下がり、しかも根拠が追えるようになるという、珍しく素直な改善でした。
本番で踏んだ落とし穴3点
きれいに移行できたわけではありません。実際にエラーやずれにぶつかった点を、対処とともに残します。
citations.enabled を付け忘れると引用が空になる 。ブロックを渡しているのに citations 配列が常に空、というときはまずここを疑ってください。結果整形側でフラグの付与を強制し、欠落時はその場で例外を投げるようにしました。
断片の切り方が粗いと引用も粗くなる 。content に長い1ブロックを入れると、引用が「段落まるごと」になって脚注の精度が落ちます。私は段落単位、長い段落はさらに文単位に近い粒度へ割ることで、根拠の指す範囲を実用的な狭さに保ちました。
検索の取りこぼしは引用では救えない 。当然ですが、search_result は「渡した資料の中から根拠を示す」仕組みです。そもそも関連記事が top_k に入っていなければ、Claude は誠実に「資料に根拠がない」と答えます。引用が空白続きのときは、API側ではなく自前検索のリコールを疑うのが先決でした。本番では検索のヒット件数とのセットで監視しています。
次に試すなら
まだ手組みでコンテキストを連結している自前RAGがあるなら、まず読み取り専用の照合エージェントなど、影響範囲の小さいところから search_result に差し替えてみることをお勧めします。引用がIDで返るだけで、後処理のコードがごっそり消え、回答の検証可能性が一段上がるのを体感できるはずです。仕様の細部はAnthropic の Citations ドキュメント が一次情報として正確です。
自分の書いたものに、自分のツールが正しく根拠を引いてくれる——地味ですが、運営を続けるうえで効いてくる安心感でした。お読みいただきありがとうございました。