先日、個人開発で運営している壁紙アプリ(Beautiful HD Wallpapers)の審査準備をしていたとき、ストアのガイドライン PDF(40 ページ超)を Antigravity に添付して「課金まわりで引っかかりそうな項目を挙げてください」と頼みました。返ってきた答えはもっともらしく、実際に有用でした。ところが後半のサブスクリプション表記に関する条項が一つ、すっぽり抜け落ちていたのです。エージェントは「全部読んだ」かのような口調で答えるので、抜けに気づくまで私自身しばらく信じてしまいました。
この「長い PDF を添付すると、自信ありげに、しかし一部を読み飛ばす」挙動は、添付機能そのものの欠陥というより、長い文脈をどう渡すかという設計の問題です。ここでは、なぜ薄まるのかを整理したうえで、回答の根拠を必ず提示させ、その根拠が PDF に実在するかを検証するところまでを、動くコードでつくっていきます。
なぜ長い PDF は文脈の中で薄まるのか
PDF を丸ごと添付すると、その全文がモデルの文脈に流し込まれます。文脈に入っていれば「読める」のは確かですが、「読める」ことと「答えるときに参照する」ことは別です。長い文書では、注意が全体に分散し、冒頭と末尾は拾われやすい一方で、中盤の細かい条項ほど取りこぼされやすくなります。私自身の体感でも、抜けるのは決まって中盤の、短くて目立たない一文でした。
もう一つの落とし穴は、PDF のテキストレイヤーの質です。段組みや表が多いストア系の PDF では、抽出されたテキストの語順が崩れていることがあります。人間が見れば自然な表でも、抽出後は項目とその条件がばらばらの行に散ってしまい、エージェントが条項と例外を正しく結びつけられなくなります。
つまり対処すべきは二点です。第一に、参照すべき範囲を絞って渡すこと。第二に、エージェントが「どこを根拠にしたか」を必ず明示させ、それを検証することです。順に見ていきます。
まず「どこを根拠にしたか」を必ず言わせる
最初に効くのは、回答の形式を縛ることです。自由記述で答えさせると、根拠を示さずに要約だけが返ってきます。代わりに、結論の前に「ページ番号」と「原文の引用」を構造化して出させます。
あなたは添付したPDFのみを根拠に回答します。一般知識で補わないでください。
各指摘について、必ず次の順序で出力してください:
1. page: 根拠となったページ番号(PDFの通しページ)
2. quote: そのページからの原文引用(20〜60字、改変しない)
3. finding: 引用から導かれる、私のアプリへの具体的な影響
PDF内に根拠が見つからない項目は「該当箇所なし」と明記し、推測で補わないでください。
最後に、確認したページ番号の一覧を出力してください。
ポイントは「PDF のみを根拠に」「一般知識で補わない」と明示することです。モデルは添付がなくてもストアの一般論を語れてしまうため、これを禁じないと、PDF を読まずに常識で答えてしまいます。引用を 20〜60 字に限定しているのは、長すぎる引用は検証時に揺れやすく、短すぎると一意に特定できないためです。
この時点ですでに、抜けに気づきやすくなります。「確認したページ番号の一覧」が前半に偏っていれば、中盤を読み飛ばしているサインです。とはいえ、引用そのものが正しいかは人間が一つずつ照合するしかありません。そこを機械化します。
長い PDF はセクション単位に割って添付する
検証の前に、そもそも薄まりを減らす一手として、PDF をセクション単位に分割して、関係する範囲だけを添付する方法があります。40 ページを丸ごと渡すより、課金に関する 8 ページだけを渡すほうが、取りこぼしは目に見えて減りました。
以下は pypdf でページ範囲を指定して分割する例です。ブックマーク(しおり)があればそれを境界に使えますが、ない PDF も多いので、ここでは確認したページ範囲を手で指定する素朴な形にしています。
from pypdf import PdfReader, PdfWriter
reader = PdfReader( "store-guideline.pdf" )
# セクション境界を「0始まりの開始ページ」と「終了ページ(含まない)」で指定します
sections = {
"01-account" : ( 0 , 6 ),
"02-payments" : ( 6 , 14 ),
"03-privacy" : ( 14 , 22 ),
}
for name, (start, end) in sections.items():
writer = PdfWriter()
for i in range (start, end):
writer.add_page(reader.pages[i])
out = f "section- { name } .pdf"
with open (out, "wb" ) as f:
writer.write(f)
print ( f " { out } : { end - start } ページ" )
分割したら、Add Context メニューから関係するセクションだけをドラッグして添付します。こうすると、エージェントが参照すべき範囲が物理的に狭まるため、引用の精度が上がります。範囲を絞る発想は、大きなリポジトリで効く部分文字列検索によるグラウンディング と同じで、「全部渡して祈る」のではなく「必要な範囲を確実に渡す」という方針です。
ただし分割しすぎると、条項どうしの相互参照(「第3節の例外は第7節に従う」のような)が切れてしまいます。相互参照が多い文書では、関連する節をまとめて一つのセクションにするほうが安全です。
引用が PDF に実在するかを検証するゲート
ここが本題です。エージェントに「ページと引用」を出させても、その引用が本当にそのページにあるとは限りません。存在しないページ番号を書いたり、それらしい文を生成したり(いわゆる引用のハルシネーション)が起こり得ます。そこで、回答の引用を PDF の抽出テキストと突き合わせ、実在しなければ落とすゲートを用意します。
前提として、エージェントの回答を agent_citations.json(page と quote の配列)として保存しておきます。先ほどのプロンプトで構造化出力を強制しているので、抜き出すのは容易です。
import json
import re
import unicodedata
from pypdf import PdfReader
def normalize (text):
# 全角・半角、空白、改行の揺れを吸収して照合精度を上げます
text = unicodedata.normalize( "NFKC" , text)
return re.sub( r " \s + " , "" , text)
reader = PdfReader( "store-guideline.pdf" )
pages = [normalize(page.extract_text() or "" ) for page in reader.pages]
with open ( "agent_citations.json" , encoding = "utf-8" ) as f:
citations = json.load(f)
failures = []
for c in citations:
page_idx = c[ "page" ] - 1
quote = normalize(c[ "quote" ])
if page_idx < 0 or page_idx >= len (pages):
failures.append((c[ "page" ], "存在しないページ番号です" ))
elif quote and quote not in pages[page_idx]:
failures.append((c[ "page" ], "引用文がそのページに見つかりません" ))
if failures:
for page, reason in failures:
print ( f "NG p. { page } : { reason } " )
raise SystemExit ( 1 )
print ( f "OK: { len (citations) } 件の引用をすべて検証しました" )
normalize で空白や全角半角の揺れを潰しているのが要点です。これをしないと、抽出テキスト側の不規則な改行と引用文が一致せず、正しい引用まで誤って落としてしまいます。逆に、ここを潰しすぎると別の場所の文が偶然一致する危険があるので、引用は 20 字以上を目安に保つようにプロンプト側で縛っています。
このゲートを通った引用だけを信じる、と決めておくと、エージェントの口調に左右されずに済みます。落ちた引用があれば、それは「読み飛ばし」か「捏造」のどちらかなので、そのページだけを単体で添付して問い直します。
Before / After で挙動を比べる
実際にどう変わるかを、課金表記の確認を例に並べてみます。
観点 Before(丸ごと添付・自由記述) After(分割+引用強制+ゲート)
回答の形 「サブスク表記に注意が必要です」と要約のみ page と quote 付きで具体条項を提示
取りこぼし 中盤の更新後価格の表記条項が欠落 確認ページ一覧で欠落が可視化される
引用の信頼性 検証手段がない 実在しない引用は機械的に落ちる
修正の速さ 抜けに人が気づくまで進めない 落ちたページだけ再添付して即補完
After のほうが手間が増えているように見えますが、実際は逆でした。Before では「本当に全部見たのか」を人間が PDF と往復して確かめる必要があり、そこに一番時間がかかっていました。引用の実在をゲートが保証してくれると、人間は「落ちた項目」だけに集中できます。
よくある落とし穴
スキャン PDF にはテキストレイヤーがない。 紙をスキャンしただけの PDF は、抽出テキストが空になります。添付する前に、テキストが取れるかを確認しておきましょう。
# テキストレイヤーがあるか(スキャンPDFでないか)を確認します
pdftotext -layout store-guideline.pdf - | head -c 200
ここで何も出てこなければ、OCR をかけてから添付する必要があります。テキストが取れない PDF は、エージェントにとっては画像であり、引用検証も成立しません。
表示ページ番号と通しページ番号がずれる。 表紙や目次で本文のページ番号が「3」から始まる PDF では、エージェントが文書中の表示番号で答えると、検証スクリプトの通しインデックスと食い違います。プロンプトで「PDF の通しページ(先頭を1とする)」と明示し、ずれを防ぎます。
引用のハルシネーションは静かに起きる。 もっともらしい一文を生成して、それらしいページ番号を添えてくる場合があります。ゲートを通さずに目視だけで確認すると、人間も「ありそう」と感じて見逃します。だからこそ、目視ではなく実在照合に倒すのが安全です。
分割しすぎると相互参照が切れる。 前述のとおり、節をまたぐ例外規定は分割の境界で失われます。相互参照の多い文書では、分割の粒度を粗めに保つほうが取りこぼしが減ります。
日々の運用に落とす
私自身は、この引用ゲートを審査前チェックの一工程として回しています。短い確認なら、本筋の作業を止めずに使い捨てのサブエージェントへ脇道の質問を投げる /btw で済ませ、PDF を根拠にした重い確認だけをこのゲートに通す、という使い分けです。仕様書を起点に進める場面では、PDF を仕様として添付し仕様起点で作業する手順 と組み合わせると、仕様の取りこぼしも同じやり方で塞げます。
複数のアプリを一人で並行して更新していると、ストアのガイドラインのように「一箇所の見落としが申請差し戻しに直結する」文書ほど、エージェントの自信ありげな要約を鵜呑みにできません。引用を実在で裏づける仕組みを一度つくっておくと、文書が長くても、参照の正しさだけは機械が保証してくれます。
エージェントに任せる範囲を広げるほど、「本当に根拠を見たのか」を確かめる仕組みの価値が上がっていくのだと感じています。まずは手元の長い PDF を一つ選び、引用を強制するプロンプトと検証スクリプトを通してみてください。読み飛ばしが一目で見えるようになります。