はじめに / 対象と前提
社内ドキュメントや規約 PDF を Claude に読ませて質問に答えさせると、必ず「その回答、どこに書いてあった?」と聞かれる。プロンプトで「根拠を引用して」と頼む方法もあるが、引用文そのものを AI が作り変えてしまうことがあり、検証が面倒だった。
Claude API の Citations(引用機能) を使うと、回答の各部分に「元ドキュメントのどの範囲を根拠にしたか」が構造化データで付いて返ってくる。この記事では、Python で Citations を動かし、根拠付き回答を画面表示用に組み立てるまでの手順と、自分が実際に詰まった 3 つのハマりどころをまとめる。
想定読者
- Claude API を Python SDK で叩いたことがある人
- RAG や社内 Q&A で「回答の根拠表示」を作りたい人
動作環境(2026 年 10 月時点)
- Python 3.13
-
anthropicPython SDK(2026 年 10 月時点の最新版) - モデルは環境変数
MODELで切り替え(Citations 対応モデルを指定)
TL;DR
-
documentブロックに"citations": {"enabled": True}を付けるだけで、回答の text ブロックにcitations配列が付く - 回答は 1 つの text ではなく細切れの text ブロックの列 で返るので、結合処理を自前で書く必要がある
- 位置情報はドキュメントの種類で型が変わり、PDF の
end_page_numberは 排他(その手前まで)。Structured Outputs とは併用できず 400 になる
手順 / 動かし方
1. プレーンテキストを渡して引用付きで答えさせる
import os
import anthropic
client = anthropic.Anthropic()
MODEL = os.environ["MODEL"]
policy = """経費精算は発生月の翌月10日までに申請する。
10日を過ぎた申請は上長の承認に加えて経理部長の承認が必要。
交通費のうちタクシー代は、終電後または荷物運搬時のみ認める。"""
res = client.messages.create(
model=MODEL,
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{
"type": "document",
"source": {"type": "text", "media_type": "text/plain", "data": policy},
"title": "経費精算規程",
"citations": {"enabled": True},
},
{"type": "text", "text": "申請が11日になったら誰の承認が要る?"},
],
}],
)
for block in res.content:
print(repr(block.text), block.citations)
実行結果(要点だけ抜粋・整形):
'規程によると、' None
'10日を過ぎた申請には上長の承認に加えて経理部長の承認が必要です' [CitationCharLocation(cited_text='10日を過ぎた申請は上長の承認に加えて経理部長の承認が必要。\n', document_index=0, document_title='経費精算規程', start_char_index=23, end_char_index=54, type='char_location')]
'。したがって11日の申請では両方の承認が要ります。' None
ポイントは、回答が 3 つの text ブロックに分かれて返っている こと。根拠がある部分だけが独立したブロックになり、そこに citations が付く。cited_text は元テキストをそのまま抜き出したものなので、AI による言い換えは入らない。
プレーンテキストの場合、Claude は文単位でチャンク分割して引用するので、引用範囲は基本的に「文」になる。
2. 表示用に組み立てる
画面に出すときは、本文に [1] のような脚注番号を振り、末尾に引用元を並べる形が扱いやすい。
def render_with_footnotes(content):
body, notes = [], []
for block in content:
if block.type != "text":
continue
body.append(block.text)
for c in block.citations or []:
notes.append(c)
body.append(f"[{len(notes)}]")
lines = ["".join(body), ""]
for i, c in enumerate(notes, 1):
lines.append(f"[{i}] {c.document_title}: {location_label(c)}「{c.cited_text.strip()}」")
return "\n".join(lines)
def location_label(c):
if c.type == "char_location":
return f"{c.start_char_index}〜{c.end_char_index}文字目"
if c.type == "page_location":
# end_page_number は排他。表示用に -1 する(後述ハマりどころ2)
last = c.end_page_number - 1
return f"p.{c.start_page_number}" if last == c.start_page_number else f"p.{c.start_page_number}-{last}"
if c.type == "content_block_location":
return f"ブロック{c.start_block_index}〜{c.end_block_index - 1}"
return c.type
print(render_with_footnotes(res.content))
規程によると、10日を過ぎた申請には上長の承認に加えて経理部長の承認が必要です[1]。したがって11日の申請では両方の承認が要ります。
[1] 経費精算規程: 23〜54文字目「10日を過ぎた申請は上長の承認に加えて経理部長の承認が必要。」
3. PDF とカスタムチャンクを渡す
PDF は base64 で渡す。引用は page_location 型になる。
import base64
with open("handbook.pdf", "rb") as f:
pdf_b64 = base64.standard_b64encode(f.read()).decode()
pdf_doc = {
"type": "document",
"source": {"type": "base64", "media_type": "application/pdf", "data": pdf_b64},
"title": "社員ハンドブック",
"citations": {"enabled": True},
}
「文単位ではなく、自分で切ったチャンク単位で引用させたい」(FAQ の 1 問 1 答単位など)ときは content ソースを使う。引用は content_block_location 型になり、ブロック番号で返る。
faq_doc = {
"type": "document",
"source": {
"type": "content",
"content": [
{"type": "text", "text": "Q. 有給は何日? A. 入社半年で10日付与。"},
{"type": "text", "text": "Q. 在宅勤務の上限は? A. 週3日まで。"},
],
},
"title": "人事FAQ",
"citations": {"enabled": True},
}
ハマりどころ
ハマりどころ1:res.content[0].text だけ読むと回答が途中で切れる
症状:Citations を有効にした途端、回答が「規程によると、」で終わるようになった。
原因:引用ありの回答は、引用の境界ごとに text ブロックが分割される。従来の「content[0].text を読めば全部」というコードが、最初の断片しか拾わなくなる。
回避策:text ブロックを全部つなぐ。
answer = "".join(b.text for b in res.content if b.type == "text")
ストリーミングの場合も同様で、引用は citations_delta という別のデルタで届く。text_delta だけを処理していると引用情報が丸ごと落ちる。
ハマりどころ2:PDF のページ番号が 1 ずれる
症状:3 ページ目だけを引用しているのに「p.3-4」と表示された。
原因:page_location は start_page_number が 1 始まり、end_page_number は 排他(そのページの手前まで)。3 ページ目だけなら start=3, end=4 で返る。Python のスライスと同じ半開区間だと思えばよい。char_location の文字インデックスと content_block_location のブロック番号は 0 始まりで、こちらも end は排他。
回避策:表示時に end - 1 して閉区間に直す(上の location_label 参照)。型ごとに始まりの番号が違うので、type で分岐して扱う。
ハマりどころ3:Structured Outputs と一緒に使うと 400
症状:回答を JSON スキーマで固めつつ根拠も欲しくて、Structured Outputs(JSON スキーマ指定)と Citations を同時に指定したら 400 が返った。
原因:Citations は回答テキストの途中に引用ブロックを差し込む仕組みなので、出力を JSON スキーマに固定する Structured Outputs とは両立しない。API 側で併用不可としてエラーにしている。
回避策:どちらかを諦める。自分は「回答本文は Citations で取得 → 必要な項目の抽出は 2 回目の呼び出しで Structured Outputs」の 2 段構えにした。コストは増えるが、根拠の正確さと JSON の安定性を両方取れる。
もう 1 つ似た罠として、1 リクエスト内の document は全部 Citations 有効か、全部無効かのどちらか にする必要がある。一部の資料だけ有効にすると 400 になるので、「参考程度の資料」も含めて揃えておく。
背景・補足
-
cited_textは出力トークンとしてカウントされない。引用文を AI に書き写させる方式より、トークン面でも有利 - プロンプトキャッシュとは併用できる。同じ規程に何度も質問するなら、document ブロックに
cache_controlを付けておくと入力コストを抑えられる - 「根拠が見つからないなら答えない」ようにしたい場合は、システムプロンプトで指示したうえで、
citationsが 1 つも付かなかった回答を自前でフラグ付けするのが確実だった
まとめ
- Citations は
documentブロックに"citations": {"enabled": True}を足すだけで動く - 回答は細切れの text ブロックで返るので、必ず全ブロックを連結する(ストリーミングは
citations_deltaも処理) - 位置情報は型ごとに違い、end は常に排他。PDF のページは 1 始まり
- Structured Outputs とは併用不可、document の有効化は全部か無しか
- 社内 Q&A に入れるなら「引用ゼロの回答を検知する」仕組みまで作っておくと安心