0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude API の Citations(引用)機能を実装する ― chunk分割とページ番号ズレ、ハルシネーション抑制の3つのハマりどころ【2026】

0
Posted at

はじめに / 対象と前提

Claude API で RAG(検索拡張生成)っぽいことをやると、必ず突き当たるのが「この回答、本当にソース文書のどこに書いてあるの?」問題です。自分で prompt に「引用元を明記して」と書いても、Claude は律儀に答えてくれる時もあれば、微妙にページ番号をズラしてくる時もあります。

Claude には Citations 機能という、ソース文書を document ブロックとして渡すと、応答の各文に「どの文書のどの部分から来たか」を機械可読な形で自動付与してくれる仕組みがあります。今回は PDF・プレーンテキストの2パターンで実装して、動かして、ハマった点をまとめます。

前提環境

  • Python 3.13
  • anthropic SDK 0.42 系以降(Citations 対応版)
  • モデル:Claude Sonnet 系(Citations 対応モデルを使用)

TL;DR

  • Citations は document コンテンツブロックに citations: {enabled: true} を付けるだけで有効化できる
  • 応答は text ブロックの配列になり、各ブロックに citations フィールド(引用範囲の配列)が付く
  • PDF は自動でページ単位に chunk 分割される一方、プレーンテキストは自分で chunk 境界を切る必要がある ← ここが最初のハマりどころ
  • citation オブジェクトの typepage_locationchar_location かでページ番号・文字範囲の復元処理が変わる
  • Citations を有効にすると 出力トークン数が体感 1.3〜1.5 倍程度に増える(引用メタデータ分)ので、コスト試算に入れておく

手順 / 動かし方

1. document ブロックを組み立てる(プレーンテキスト版)

import anthropic

client = anthropic.Anthropic()

source_text = open("manual.txt", encoding="utf-8").read()

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "document",
                    "source": {
                        "type": "text",
                        "media_type": "text/plain",
                        "data": source_text,
                    },
                    "title": "operation_manual.txt",
                    "citations": {"enabled": True},
                },
                {
                    "type": "text",
                    "text": "初期セットアップの手順を要約して。根拠も示して。",
                },
            ],
        }
    ],
)

2. 応答から citation を取り出す

for block in response.content:
    if block.type == "text":
        print("回答:", block.text)
        if block.citations:
            for c in block.citations:
                if c.type == "char_location":
                    print(
                        f"  引用元: {c.document_title} "
                        f"[{c.start_char_index}:{c.end_char_index}] "
                        f"= {c.cited_text!r}"
                    )

プレーンテキストの場合、citation は char_location 型で返ってきます。start_char_index / end_char_indexClaude 側が自動で決めた chunk 境界に基づく文字位置であり、こちらが指定した改行やパラグラフ単位とは一致しません。

3. PDF 版(page_location)

import base64

pdf_data = base64.standard_b64encode(open("spec.pdf", "rb").read()).decode()

response = client.messages.create(
    model="claude-sonnet-4-5",
    max_tokens=1024,
    messages=[
        {
            "role": "user",
            "content": [
                {
                    "type": "document",
                    "source": {
                        "type": "base64",
                        "media_type": "application/pdf",
                        "data": pdf_data,
                    },
                    "citations": {"enabled": True},
                },
                {"type": "text", "text": "料金プランの違いを表にまとめて"},
            ],
        }
    ],
)

for block in response.content:
    if block.type == "text" and block.citations:
        for c in block.citations:
            if c.type == "page_location":
                print(f"  p.{c.start_page_number}-{c.end_page_number}: {c.cited_text!r}")

PDF の場合は page_location 型になり、start_page_number / end_page_number が返ります。1-indexedである点に注意してください(0始まりだと思って UI に表示すると1ページずれます)。

ハマりどころ

① プレーンテキストの chunk 境界は自分で制御できない

document に生テキストをそのまま渡すと、Claude 側が独自ロジックで chunk に分割します。長い1つの文書を渡すと、意味の切れ目とズレた場所で char_location が返ってくることがあり、cited_text を UI にハイライト表示する際に不自然な位置で途切れることがありました。

回避策:こちらで見出し・段落単位に整形してから渡す、あるいは content を配列にして複数の document ブロック(文書を章単位で分割したもの)として渡すと、citation の粒度が意味単位に近づきました。

"content": [
    {"type": "document", "source": {...}, "title": "第1章", "citations": {"enabled": True}},
    {"type": "document", "source": {...}, "title": "第2章", "citations": {"enabled": True}},
    {"type": "text", "text": "..."},
]

cited_text と実データの文字列が完全一致しない

char_locationstart_char_index / end_char_index を使って元テキストをスライスすると、cited_text と微妙に異なる(空白の正規化などで)ケースがありました。インデックスをそのまま信用してオフセット計算するより、返ってきた cited_text 自体を表示に使う方が事故が少ないです。ズレを検知したい場合は difflib で類似度チェックを挟むと安全です。

import difflib

ratio = difflib.SequenceMatcher(None, sliced_text, c.cited_text).ratio()
if ratio < 0.9:
    logger.warning("citation index mismatch: ratio=%.2f", ratio)

③ Citations 有効時は出力トークンが想定より増える

citations: {enabled: True} を付けると、応答本文に加えて引用メタデータがレスポンス JSON に含まれます。これは usage.output_tokens にはメタデータ分は乗らない(citation は構造化フィールドで別枠)のですが、Claude が「根拠を示そう」として文章そのものを長めに生成する傾向があり、実測で通常プロンプトの1.3〜1.5倍程度トークンを消費しました。バッチ処理でコストを試算する際は、Citations 有効/無効で別々にベンチマークを取ることをおすすめします。

背景・補足

cited_text は Claude が生成したのではなく ソース文書から抽出された原文そのものです。要約・言い換えが入っても引用箇所は原文と照合でき、社内文書検索や FAQ ボットの「出典なしの断定」対策に向いています。自前で埋め込み検索の RAG パイプラインを組んでいる場合も、最終応答の生成ステップだけ Citations 付き document に差し替えれば、引用の正確性を Claude 側に委譲できます。

まとめ

  • Citations は document ブロックに citations: {enabled: true} を付けるだけで動く
  • プレーンテキストは chunk 境界が自動なので、章単位で document を分けるとハイライト精度が上がる
  • char_location のインデックスより cited_text 自体を信用した方が事故が少ない
  • PDF のページ番号は 1-indexed
  • Citations 有効時はトークン消費が増える前提でコスト試算する
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?