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ページ
page3 page4 page5

「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ページにまたがるケース)も正確にトラッキングされます。

PDFハイライティングデモ — 5つのセクションがそれぞれ異なる色で3ページにわたりハイライト

まとめ

テキスト全体出力 インデックス参照
Output tokens 1,865 190(90%削減
レイテンシ 16.8秒 2.2秒(87%削減
コスト $0.0123 $0.0048(61%削減
ハルシネーションリスク テキストの変形が起こり得る 原文をそのまま使用
実装の複雑さ シンプル インデキシング前処理が必要

LLMを使って文書を分類・分割する際、「結果を書き直してください」と依頼する代わりに「どこかだけ教えてください」と指示すれば、Outputトークンを劇的に削減できます。コストだけでなく応答速度も同時に短縮されるため、Output単価がInputの数倍である現在の料金体系では、その効果は倍増します。


このテクニックは、建築法規審査AIシステムの文書前処理パイプラインにおいて、PyMuPDF + Claude Haikuベースで実装され、実際に運用されています。