なぜ「安く始めて、必要な時だけ高く払う」が正解なのか
PDFの1ページをPyMuPDFでパースすると5ミリ秒、コストゼロです。同じページをVision LLMに投げると10秒、1万倍のコストがかかります。問題はここです。
- 質問が散文(prose)にあれば → 安いパーサーが勝ちます。
- 答えが表、図、スキャン領域、平坦化されたレイアウトの中に隠れていれば → 安いパーサーは静かに役に立たない結果を返し、LLMはその上で自信満々に間違った答えを出します。
EasyOCRのような古典的OCRが典型例です。単語は復元するものの、周囲の表構造を静かに捨ててしまいます。答えだけ見れば正常に見え、元のページと照合するまで誰も気づきません。
そこで必要なのがフィードバックループです。最も安いパーサーで開始 → パイプラインの各チェックポイントで出力を評価 → 少なくとも1つが「この質問には不十分」とフラグを立てたら、その時点で重いパーサーにエスカレーション。本記事はその中でも最も高価で最も不安定なチェック、すなわちLLM自己評価(self-eval)をどう扱うべきかについての実践記録です。
本記事は、エンタープライズRAGシステムを4つのブリック(文書パース / 質問パース / 検索 / 生成)で構築するシリーズの一篇です。根拠資料はTowards Data Science 原文で確認できます。

エスカレーションカスケード — 8つのチェックを順番に
パイプラインは安いチェックが先、高いチェックが後になるよう設計されます。各チェックは次のチェックより安く、より信頼できる必要があります。
# チェック順序 (安い → 高い)
# 1. pre-parsing メタデータルーティング (ほぼ無料)
# 2. parsing-time シグナル: char density, flat-table fingerprint
# 3. chunk integrity
# 4. question parsing 段階の intent-aware routing
# 5. retrieval score-gap
# 6. retrieval context-gap
# 7. LLM self-eval (context_structured フラグ) ← 本記事の主題
# 8. post-generation groundedness (別LLM/NLI判定)
# 実際のパイプラインでエスカレーションをトリガーする部分
if not answer.context_structured:
# PyMuPDFでパースされたページをAzure DIで再パース
page_df = parse_with_azure(
pdf_path=pdf_path,
page_num=answer.cited_page,
method="azure_layout", # parsing_method カラムに記録される
)
# 同じ質問で再生成
answer = llm_answer_with_evidence(
question=question,
lines=page_df.text.tolist(),
)
Case A: 平坦化された表 → Azure DIへエスカレーション
Attention論文(Transformer原著)9ページのTable 3でbase行のh値を尋ねる質問を投げます。正解は8。PyMuPDFはこの表を13セル=13行に分解して返します。LLMはこれを縦に数えながらh列を探す必要があります。
# 1次生成結果 (PyMuPDFベース)
{
"answer": "8", # 値は正しい
"citation": "PyMuPDF L25 of page 9",
"context_structured": False, # ← 構造的リスクをLLM自身がフラグ
}
値は合っていましたがcontext_structured=Falseが立ちました。オーケストレーターはこの1ビットを見てAzure DIで再パースします。コストは約$0.003、4秒。同じ質問を再度投げると:
# 2次生成結果 (Azure DIベース)
{
"answer": "8",
"citation": "Azure row: | base | 6 | 512 | 2048 | 8 | ... |",
"context_structured": True, # 構造を信頼
}
パイプのマークダウン行でhが5番目のセルであることが|で明示されるため、LLMは列を数える必要がなくなります。同じ値、しかし今度は構造的に信頼可能です。
Case B: 図 → Vision LLMへエスカレーション
「Transformerのアーキテクチャは何か?」という質問。答えは3ページのFigure 1(エンコーダ・デコーダ図)にあります。PyMuPDFはtype='image'行とプレースホルダのみを返します。1次生成結果:
{
"answer": "周辺の散文によれば... (不完全)",
"context_structured": False,
"caveat": "figure referenced but absent", # 図が参照されているがパースされていない
}
パイプラインはimage_df.image_id=1のparsing_method='not_parsed'を確認し、Vision LLMへルーティングします。結果は6層、マルチヘッドアテンション、residual connection、layer norm、cross-attentionブリッジまで構造化された説明として返り、line_dfにparsing_method='vision_gpt4o'としてappendされます。論文15ページのうち、この1ページだけがVisionモデルを見ます。

⚠️ 18回のストレステストが明かした衝撃の事実
ここからが本記事の真価です。上記2ケースはベストケース、つまりLLM self-evalがうまく機能した場合です。しかし実際にストレステストを回すと、景色は完全に変わります。
実験設計
- 質問3種: Q_easy(直接ラベルされたセル) / Q_inheritance(空セル、列継承) / Q_far(表の最終行、類似数値多数)
- モデル3種: gpt-4o-mini, gpt-4o, gpt-4.1
- パーサー2種: PyMuPDF, Azure DI
- 計18回実行
結果1: 誤答4件中4件がcs=True
| 質問 | モデル/パーサー | 回答 | context_structured |
|---|---|---|---|
| Q_inheritance | mini/PyMuPDF | 16 (誤答) | True |
| Q_inheritance | 4o/PyMuPDF | 32 (誤答) | True |
| Q_inheritance | 4.1/PyMuPDF | 8 (正答) | False |
| Q_far | mini/PyMuPDF | 25.7 (誤答、BLEU列) | True |
弱いモデルが列を誤って整列したとき、自分が誤って整列したことに気づけません。 むしろcontext_structured=Trueと自信を持って主張します。唯一cs=Falseを立てたのは、答えも正しかったgpt-4.1でした。つまりLLMシグナルが発火した唯一の瞬間が、救済される必要がなかった瞬間だったのです。
結果2: 連続スコア(0.0〜1.0)も役に立たない
バイナリフラグの代わりにcontext_structured_scoreを0〜1で受け取ってみました。
- 正答平均: 0.919
- 誤答平均: 0.920
- 閾値0.95適用時: 誤答3件を捕捉、正答15件中9件を誤検知 (60% false alarm)
LLMの構造判定は連続のふりをしたバイモーダルです。スコアはバイナリフラグに対して何ももたらしません。
結果3: rationaleは安定、verdictは不安定
同じ入力、同じtemperatureでgpt-4.1を3回回してみました。
- rationale(説明): 毎回一貫 — 「headers split across multiple lines」「each cell on its own line, not aligned」
- verdict(フラグ): 毎回揺れる
モデルはパースがなぜおかしいかは正確に把握しています。それがエスカレーションすべきほど深刻かは安定的に判断できません。 そこで実践的な解はこうなります。
# rationaleテキストに決定論的ルールを適用
ESCALATION_PATTERNS = [
"split across multiple lines",
"each cell on its own line",
"blank cells",
"not aligned",
]
def should_escalate(rationale: str) -> bool:
return any(p in rationale.lower() for p in ESCALATION_PATTERNS)
バイナリやスコアを直接読む代わりに、rationaleテキストの上に決定論的ルールを載せることが唯一安定的な方法です。
結果4: check 8 (別LLM groundedness)こそが真の安全網
生成後、別のLLM(またはNLIモデル)が回答のすべての主張が引用チャンクで支持されているか検証します。判定者が新しいため、生成モデルが自分の回答を防衛する誘因がありません。check 7が見逃したhallucinationを捕捉できる唯一のチェックです。
日本の開発現場における注意点
日本のエンタープライズ、特に金融・公共系のシステムでこのパイプラインを運用する際の注意点です。監査要件の強い文書(規制対応filing、内部統制関連文書、契約書)はlazy parsingのデフォルトを例外とし、ingestion時点でfull parseを回すべきです。事後に再現可能な記録が残る必要があるからです。逆に社内Wikiや技術文書のようにクエリ寿命が短く反復参照が多い文書は、lazy + キャッシュが圧倒的に有利です。判断基準は一つ — 「この文書は寿命中に平均何回参照されるか?」 何度もならeager、数回か不明ならlazyです。
![]()
まとめ — 順序が変わるとパイプラインは崩壊する
本記事の核心を一行に圧縮するとこうなります。
LLM self-evalは最後の防衛線であり、最初の防衛線ではない。
18回のストレステストがこの順序を交渉不可にします。LLMのバイナリ判定は自身のコンテキスト品質に対して安定的でなく、別のfresh judge(check 8)だけがfabricationを捕捉します。
実務適用3ステップ
- 安いチェックから敷け。 char density, flat-table fingerprint, chunk integrity — この3つがほとんどの失敗を無料で捕捉します。
- LLM self-evalはrationaleだけ受け取れ。 verdictは決定論的ルールで自分で判定してください。バイナリ・スコアをそのまま信じてはいけません。
- check 8を必ず付ける。 別LLMまたはNLIベースのgroundedness検証。コストはLLM呼び出し1回、得られるものはhallucination防御です。
データモデルがすなわちキャッシュ
エスカレーションが起きるたびにline_dfにparsing_method='azure_layout'または'vision_gpt4o'行がappendされます。次の質問が同じページに触れれば、この行をそのまま読みます。キャッシュは別レイヤーではなく、retrievalが既に読んでいる同じテーブルです。 最も多く参照された文書の最も多く参照された領域が自動的にdeep-parsedバージョンを持ち、残りは安いパーサーのままです。システムはingestion時点の推測ではなく、実際の使用パターンに基づいてdeep parsing投資先を学習するのです。
次のステップ学習方向
- カスケード順序の最適化: チェック1・2の決定論的シグナルをどうチューニングするか
- check 8の実装: NLIモデル vs. 別LLM判定者のコスト・精度トレードオフ
- cross-reference処理: 文書間参照(Article 11予告)が同じself-correctionフレームでどう拡張されるか
あわせて読みたい
- Claude Code トークン90%削減のモデルルーティング戦略 — モデルルーティングの観点から同じ「安く始めて必要な時だけ高く払う」原則を扱っています。
- CSSで完璧なパイチャートを作る — セマンティクスとアクセシビリティを考慮した実践ガイド — パース・構造化とは異なる文脈のフロントエンド実践記事ですが、「構造を守りながら可視化する」という観点で比較する価値があります。