TL;DR: LLMには「どこからどこまで」だけを聞き、テキストはサーバーが直接取り出せばよいのです。3ページの実測値でOutputトークン90%削減、レイテンシ87%削減、コスト61%削減。
背景:DoclingからPyMuPDF + VLMへ
建築法規審査AIを開発する中で、建築告示・指針PDFを意味単位のチャンク(chunk)に分割する必要がありました。RAGパイプラインの検索単位として使用するためです。
当初はIBMのDoclingを使用していました。OCRモデルで文書構造を把握してからチャンキングする方式ですが、2つの課題がありました。
- 処理が重い:OCR・レイアウト分析モデル(RT-DETRなど)が含まれており、Dockerイメージサイズと処理時間が大きい
- カスタマイズが困難:内部パイプラインがブラックボックスに近く、建築文書特有の階層構造(章 > 条 > 項 > 号)や表の処理をきめ細かく制御しにくい
そこでOCRモデルを排除し、PyMuPDFでテキストとフォントメタデータを直接抽出する方式に切り替えました。構造分析はマルチモーダルLLM(VLM)のVision機能で代替すれば、OCR依存を完全に排除しながらも自由にカスタマイズできます。
PDF → PyMuPDF テキスト抽出 → ページ分類 → LLM 構造分析 → チャンク生成 → エンベディング
以下は実際の処理対象である建築告示PDF — 大邱法院総合庁舎設計公募指針書の本文3ページです。
| 3ページ | 4ページ | 5ページ |
|---|---|---|
![]() |
![]() |
![]() |
「1. 公募の目的」「2. 事業の概要(가〜마)」「3. 公募要綱(가〜마)」など、階層的なセクション構造を持つ本文です。これをLLMが意味単位で自動分割するのが目標ですが、ここで新たな課題が発生しました。
課題:LLM Outputトークンのコスト
LLM APIではOutputトークンの単価はInputの3〜5倍です。
| モデル | Input (/1M tokens) | Output (/1M tokens) | 倍率 |
|---|---|---|---|
| Claude Haiku 4.5 | $1.00 | $5.00 | 5x |
| Claude Sonnet 4.6 | $3.00 | $15.00 | 5x |
最もシンプルなアプローチは、LLMに分割されたセクションの内容をそのまま再出力させることです。
[Input] "1. 공모의 목적\n설계자들에게 참여의 기회를..."
[Output] {"heading": "1. 공모의 목적", "text": "설계자들에게 참여의 기회를..."}
↑ 入力をほぼそのまま繰り返す
Output ≈ Inputとなるため、54ページの文書では Outputだけで数万トークンが発生することは明白でした。Output単価が5倍の構造では、このままでは全体コストの80%以上がOutputになります。
解決策:整数インデックス参照方式
核心となるアイデアはシンプルです。
LLMにテキストの開始/終了インデックスのみを出力させ、原文テキストはサーバー側で直接取得する。
実際のデータを見ればすぐに理解できます。
[LLM Input — 78行, 3,897 tokens]
[page 3]
0 <<h:15.0pt>> 1. 공모의 목적
1 <<p:12.0pt>> 설계자들에게 참여의 기회를 제공하고 공정한 경쟁을 통하여 우수한 설계안(공모지침에
2 <<p:12.0pt>> 충실하면서도 청사의 외관은 부지 주변의 경관과 조화를 이루고...
...
5 <<h:15.0pt>> 2. 사업의 개요
6 <<p:13.0pt>> 가. 건물의 명칭
7 <<p:12.0pt>> 대구법원종합청사
...
31 <<h:15.0pt>> 3. 공모 요강
...
(3ページにわたり合計78行)
[LLM Output — 190 tokens]
[
{"heading": "1. 공모의 목적", "start": 0, "end": 4},
{"heading": "2. 사업의 개요", "start": 5, "end": 30},
{"heading": "3. 공모 요강", "start": 31, "end": 43},
{"heading": "마. 심사", "start": 44, "end": 67},
{"heading": "바. 참가 보수", "start": 68, "end": 74}
]
heading名 + 整数2つ。 原文テキストはサーバー側でline_list[start:end+1]を使って直接取得するため、LLMが再度出力する必要はありません。
実測結果
Claude Haiku 4.5(Bedrock Converse API)で上記3〜5ページを2つの方式で実際に処理した結果です。同一の入力テキストに対して「セクション内容をそのまま出力」vs「インデックスのみ出力」でプロンプトだけを変えました。
| テキスト全体出力 | インデックス参照 | 削減率 | |
|---|---|---|---|
| Output tokens | 1,865 | 190 | 90% |
| レイテンシ | 16.8秒 | 2.2秒 | 87% |
| コスト | $0.0123 | $0.0048 | 61% |
Outputトークンが減るとコストだけが削減されるわけではありません。 LLMの応答時間は生成するトークン数に比例するため、Outputが1,865 → 190に減ればレイテンシも16.8秒 → 2.2秒と87%削減されます。3ページで14秒の差があれば、54ページの文書では分単位の差になります。
コスト削減も重要ですが、文書処理の待ち時間が数倍短縮されることがユーザー体験にはより大きな違いをもたらします。
実装の詳細
インデックス付きアノテーションテキストの生成
def page_to_indexed_text(page_data: dict, line_counter: int = 0):
"""構造化テキスト → 整数インデックス付きアノテーション(LLM入力用)"""
output_lines = [f"[page {page_data['page_num']}]"]
line_list = []
for block in page_data["blocks"]:
for line in block["lines"]:
first_span = line["spans"][0]
size, bold = first_span["size"], first_span["bold"]
is_heading = bold or size >= 14
style = "h" if is_heading else "p"
suffix = ",bold" if bold else ""
tag = f"<<{style}:{size}pt{suffix}>>"
text = " ".join(s["text"] for s in line["spans"])
line_list.append({
"text": text, "page": page_data["page_num"],
"is_heading": is_heading, "bbox": _union_bbox(line), # 省略
})
output_lines.append(f"{line_counter} {tag} {text}")
line_counter += 1
return "\n".join(output_lines), line_list, line_counter
line_listに原文テキスト・ページ番号・bboxを保存し、LLMに送信するテキストにはインデックス番号とフォントアノテーションを付与します。LLMが{"start": 5, "end": 30}を返せば、サーバー側でline_list[5:31]を取得すれば完了です。
プロンプト
TEXT_CHUNKING_PROMPT = """<role>
건축 관련 고시/지침 PDF를 분석하여 논리적 섹션으로 분할하는 전문가입니다.
</role>
<input>
각 라인 앞에 0, 1, 2 같은 인덱스 번호가 부여되어 있습니다.
섹션의 시작과 끝 인덱스만 지정하면 됩니다.
</input>
<output_format>
JSON 배열만 출력하세요.
[{{"heading": "섹션 제목", "start": 시작_인덱스, "end": 끝_인덱스}}]
</output_format>
<text>
{text}
</text>"""
テキスト以外のページにも同一パターンを適用
このインデックス参照パターンは、テキストページだけでなく表や混合(テキスト+表)ページにも同様に適用できます。形式が異なるだけで原理は同じです。
| ページ種別 | LLMに渡すインデックス | LLMが出力するもの | サーバーが復元するもの |
|---|---|---|---|
| テキスト | 行インデックス 0, 1, 2... |
start, end |
line_list[start:end+1] |
| 表 | 表/行インデックス [表 0], 0:, 1:... |
index, header_row |
tables_data[index][row] |
| 混合 | ブロックID [B0], [B1]... |
block_refs: [0, 1] |
page_data["blocks"][i] |
表ページではPyMuPDFのfind_tables()でセルデータを抽出した後に行インデックスを付与し、混合ページではテキストブロック単位で[B0], [B1]...のIDを付与します。LLMはどのタイプでも整数ベースで位置を参照し、サーバーが原文を取得します。
追加機能:高精度なPDFハイライティング
line_listにはテキストだけでなくbbox(座標)も一緒に保存しているため、LLM出力のインデックス範囲から該当セクションの正確なPDF領域を即座に算出できます。テキスト全体出力方式であればLLM出力を原本PDFで再マッチングする必要がありましたが、インデックス方式ではline_list[i].bboxで即座に取得できます。
以下は上記PoC の5つのセクションのbboxを実際のPDF上にオーバーレイした結果です。各色が1つのセクションに対応しており、ページをまたぐセクション(例:「2. 事業の概要」が3〜4ページにまたがるケース)も正確にトラッキングされます。

まとめ
| テキスト全体出力 | インデックス参照 | |
|---|---|---|
| Output tokens | 1,865 | 190(90%削減) |
| レイテンシ | 16.8秒 | 2.2秒(87%削減) |
| コスト | $0.0123 | $0.0048(61%削減) |
| ハルシネーションリスク | テキストの変形が起こり得る | 原文をそのまま使用 |
| 実装の複雑さ | シンプル | インデキシング前処理が必要 |
LLMを使って文書を分類・分割する際、「結果を書き直してください」と依頼する代わりに「どこかだけ教えてください」と指示すれば、Outputトークンを劇的に削減できます。コストだけでなく応答速度も同時に短縮されるため、Output単価がInputの数倍である現在の料金体系では、その効果は倍増します。
このテクニックは、建築法規審査AIシステムの文書前処理パイプラインにおいて、PyMuPDF + Claude Haikuベースで実装され、実際に運用されています。


