執筆者:Kim Bo-geun

建築法規レビューAIで「4階以下」と「4階以上」を混同するとどうなるか。高さ上限が逆転し、違法建築が合法と判定される。この記事は、その一文字の違いを捕まえるための取り組みの記録です。

問題:PDF表は検索されるが信頼できない

建築法規レビューシステムは、地区単位計画告示や設計指針書などの建築関連PDFを分析し、建ぺい率、容積率、高さ制限などの基準を抽出します。PDF前処理パイプラインではDoclingを使用して文書をパースし、テキストをチャンキングした後、エンベディングを生成してハイブリッド検索(キーワード+セマンティック)を実現しています。

DoclingのHierarchicalChunkerは表の内容もMarkdown形式でチャンキングし、検索インデックスに含めます。表が完全に欠落するわけではありません。問題はそのMarkdownの品質でした。

  • 結合セル構造が壊れ、「建ぺい率60%」がどの街区番号に該当するかの関係が失われる
  • OCRエラー(「以下」→「以上」、「커」→「키」)がそのまま検索結果に表示される
  • エージェントが「街区1 建ぺい率」で検索してチャンクを見つけても、その値が正しいか信頼できない

地区単位計画告示の建ぺい率・容積率・高さ基準は、ほとんどが複合表に存在するため、これは致命的な問題でした。

テスト対象:大邱蓮湖公共住宅地区 地区計画告示

テストに使用したPDFは「国土交通部告示第2024-598号」(54ページ)で、Doclingが抽出した表は合計109個、全54ページに分布しています。

以下は核心となる表を含む36ページの実際のPDF画像です:

page_36 図1. 告示PDF 36ページ — 街区別建ぺい率/容積率/高さ基準表。結合セルが複雑に絡み合っている。

Docling Markdownの限界

DoclingはPDFの表を2Dグリッド(行×列配列)とMarkdownで抽出します。単純な表では問題なく動作しますが、建築告示PDFの複合表では結合セルの関係が失われ、韓国語OCRエラー(「커」→「키」)が発生し、どの値がどの街区に該当するかの関係が不明確になります。

アプローチ:Vision+OCRハイブリッド

なぜVisionなのか

LLMのVision機能は画像を直接見て解釈します。PDFページを画像としてレンダリングすれば、人間が表を読むのと同じ方法で結合セルの視覚的な境界を認識し、行と列間の論理的関係を把握して、構造化JSONとして出力できます。

しかし、Visionだけでは不十分でした。

Vision単体の限界:体系的エラー

Bedrock Claude Haiku 4.5でVision単体テストを実施したところ、高さと容積率フィールドで体系的な「以下」→「以上」エラーが発生しました。

36ページの実際のVision単体結果(エラー部分抜粋):

{
  "区分": "공1, 공2",
  "建ぺい率": "60%以下",
  "容積率": "400%以下",
  "高さ": "20階以上"   // ← 原文: "20階以下"
},
{
  "区分": "공3, 공4",
  "建ぺい率": "60%以下",
  "容積率": "400%以下",
  "高さ": "10階以上"   // ← 原文: "10階以下"
},
{
  "区分": "초1",
  "建ぺい率": "60%以下",
  "容積率": "200%以下",
  "高さ": "5階以上"    // ← 原文: "5階以下"
},
{
  "区分": "키1 ~ 키2",  // ← 原文: "커1, 커2"
  "建ぺい率": "60%以下",
  "容積率": "400%以下",
  "高さ": "8階以上"    // ← 原文: "8階以下"
}

37ページでも容積率1件のエラーが発生しました:

{
  "区分": "기타1",
  "建ぺい率": "60%以下",
  "容積率": "200%以上",  // ← 原文: "200%以下"
  "高さ": "4階以下"
}

興味深いのは、建ぺい率はすべて正確だった点です。韓国語の「이하」(以下)と「이상」(以上)の視覚的類似性が問題であり、特に高さフィールドでエラーが集中しました。

ハイブリッド:画像+OCRテキスト交差検証

核心的なアイデア:Docling OCRはテキスト認識に強く、Visionは構造認識に強い。両方を組み合わせたらどうなるか。

能力 Docling OCR Vision
テキスト認識(「以下」/「以上」) 強い 弱い
表構造の把握(結合セル) 弱い 強い
行・列関係の理解 弱い 強い

Doclingが既に抽出したMarkdownをOCRテキストとして画像と一緒に提供し、Visionに「画像の構造を見つつ、テキストはOCRと交差検証せよ」と指示すればよいのです。

プロンプトエンジニアリング:一文字の違いを捕まえる

ハイブリッド方式を導入しましたが、最初のプロンプトではまだ「以下」→「以上」エラーが発生しました。プロンプトに「画像の視覚情報を優先して」と書いたためです。Haikuはこの指示に忠実に従い、OCRの正しい「이하」(以下)を無視して画像の「이상」(以上)を採用しました。

Claudeプロンプトエンジニアリングガイドを参考にプロンプトを再設計しました。

適用した原則

1. <role> + WHY(なぜ正確でなければならないのか)

<role>
地区単位計画告示PDFから表を構造化JSONとして抽出する専門家です。
画像とOCRテキストが一緒に提供されます。
建築法規レビューに使用されるため、「以下」と「以上」の区別が正確でなければなりません。
</role>

単に「正確にせよ」ではなく、なぜ正確でなければならないのか(建築法規レビュー)を明示しました。LLMはコンテキストが与えられるとハルシネーションをより抑制できます。

2. <workflow> + ドメインヒント

<workflow>
1. 画像から表の構造(行/列/結合)を把握します。
2. 各セルのテキストを読み取り、OCRテキストと交差検証します。
   建ぺい率/容積率/高さのセルで「以下」と「以上」が出てきた場合、
   OCRテキストの該当セルと必ず比較してください。
   地区単位計画では建ぺい率・容積率・高さは上限規制であるため、
   「以下」が一般的です。
3. 以下のJSONスキーマに従って出力します。
</workflow>

ポイントは二つです:

  • 交差検証指示:「必ず比較してください」でOCRテキストの参照を強制
  • ドメインヒント:「上限規制であるため以下が一般的」 — モデルが確信のないとき、正しい方向に傾かせる

3. <examples> — Good/Bad対比

<examples>
<good_example>
OCR: "이하 60%"  →  "建ぺい率": "60%以下"
OCR: "층 이하 4"  →  "高さ": "4階以下"
</good_example>
<bad_example>
OCRで「以下」なのに「以上」と出力
→ 建築規制の方向が逆転し、法規レビューエラーが発生
</bad_example>
</examples>

Bad exampleで**結果(consequence)**を明示したことが重要です。「法規レビューエラーが発生」という実質的な被害を知らせると、モデルはそのパターンをより強く回避します。

結果:エラー6件→0件

実際のテスト結果(Bedrock Claude Haiku 4.5):

ページ 表の種類 Vision単体エラー ハイブリッドエラー
36 建ぺい率/容積率/高さ 高さ4件+街区番号1件 0件
37 建ぺい率/容積率/高さ 容積率1件(「以上」) 0件
38 面積調書 0件 0件
7 土地供給計画 0件 0件
40 道路現況 0件 0件

36ページの実際の抽出結果を並べて比較すると、違いが明確です:

Vision単体 — 高さフィールド全4件が「以上」エラー、街区番号「커」→「키」エラー

{"区分": "공1, 공2", "建ぺい率": "60%以下", "容積率": "400%以下", "高さ": "20階以上"},
{"区分": "공3, 공4", "建ぺい率": "60%以下", "容積率": "400%以下", "高さ": "10階以上"},
{"区分": "초1",      "建ぺい率": "60%以下", "容積率": "200%以下", "高さ": "5階以上"},
{"区分": "키1 ~ 키2", "建ぺい率": "60%以下", "容積率": "400%以下", "高さ": "8階以上"}

ハイブリッド — 全項目正確、街区番号も「커」で正しく抽出

{"区分": "공1, 공2", "建ぺい率": "60%以下", "容積率": "400%以下", "高さ": "20階以下"},
{"区分": "공3, 공4", "建ぺい率": "60%以下", "容積率": "400%以下", "高さ": "10階以下"},
{"区分": "초1",      "建ぺい率": "60%以下", "容積率": "200%以下", "高さ": "5階以下"},
{"区分": "커1, 커2", "建ぺい率": "60%以下", "容積率": "400%以下", "高さ": "8階以下"}

OCRテキストがテキスト認識のアンカー(基準点)の役割を果たし、高さと街区番号のエラーがすべて修正されました。

page_37 図2. 37ページ — 宗教施設・その他街区の規模基準。Vision単体で容積率「200%以上」のエラーが発生したが、ハイブリッドでは正確に「200%以下」と抽出された。

コスト比較(実測)

モデル 方式 ページ トークン(in+out) コスト 所要時間
Haiku 4.5 Vision単体 36 2,020+1,402 $0.0090 11.9s
Haiku 4.5 ハイブリッド 36 7,463+1,478 $0.0149 11.3s
Haiku 4.5 Vision単体 37 2,020+1,519 $0.0096 12.4s
Haiku 4.5 ハイブリッド 37 3,918+945 $0.0086 9.8s

ハイブリッドはOCRテキストの追加により入力トークンが増えますが、出力精度が高いためリトライが不要です。ページあたりの平均コストは $0.009〜0.015 の水準です。(Haiku 4.5公式単価:$1.00/MTok input、$5.00/MTok output)

参考として、Sonnet 4.5 Vision単体もテストしました。「以下」/「以上」は正確でしたが、街区番号「커」を「기」と誤認識し、コストは$0.028/ページ(Haikuの3倍)、所要時間は18.3秒(1.5倍)でした。プロンプトエンジニアリングとOCRハイブリッドを組み合わせれば、このタスクではHaikuだけで十分でした。

アーキテクチャ:検索レイヤーに手を加えない統合

核心設計

DoclingのHierarchicalChunkerが既に表のMarkdownをチャンキングしているため、Visionが生成する高品質なflat_textを追加チャンクとして同じインフラ(docling_chunks + docling_embeddings)に保存します。

Docling Markdownチャンク   → docling_chunks(既存、品質低い)
Vision抽出 flat_text      → docling_chunks(追加、構造化された高品質)
                          → docling_embeddings(エンベディングベクトル)

これにより:

  • DoclingチャンクとVisionチャンクが共存 — 検索時により正確なVisionチャンクが上位にランキング
  • 既存のキーワード/セマンティック検索コード変更0行
  • Visionが失敗してもDoclingチャンクがフォールバックとして動作

構造化JSONはdocling_tables.structured_dataに別途保存し、将来のプログラマティックな値の抽出やフロントエンドの表レンダリングに活用します。

パイプライン

PDFアップロード
  ├─ 1. S3ダウンロード(pdf_bytesを保持)
  ├─ 2. Docling変換(OCR+レイアウト分析)
  ├─ 3. テーブル抽出(DoclingTable+Markdown)
  ├─ 4. 画像抽出
  ├─ 5. ★ Vision表構造化(NEW)
  │     │
  │     ├─ ページ別グルーピング(同一ページの表をまとめる)
  │     ├─ PyMuPDFでページ → PNG画像
  │     ├─ Docling MarkdownをOCRテキストとして使用
  │     ├─ Bedrock Converse API(Haiku 4.5)呼び出し
  │     │
  │     ├─ structured_data → docling_tablesに保存
  │     ├─ flat_text → docling_chunksに保存
  │     └─ embedding → docling_embeddingsに保存
  ├─ 6. HierarchicalChunkerテキストチャンキング
  ├─ 7. エンベディング生成(テキスト+Vision表統合)
  └─ 8. DB保存

flat_text:あらゆる表を検索可能な平文に

Visionが出力する構造化JSONは表の種類ごとに異なります。これを再帰的に平文化する structured_json_to_flat_text 関数がすべての構造を処理します:

def _flatten_dict(obj, lines, depth=0):
    indent = "  " * depth
    if isinstance(obj, dict):
        for key, val in obj.items():
            if isinstance(val, (dict, list)):
                lines.append(f"{indent}{key}:")
                _flatten_dict(val, lines, depth + 1)
            else:
                lines.append(f"{indent}{key}: {val}")
    elif isinstance(obj, list):
        for item in obj:
            _flatten_dict(item, lines, depth)

36ページのハイブリッド抽出による実際のflat_text出力:

[変更 - 第20869号 官報 2024-11-08]

位置: 街区1 〜 街区5
用途:
  許容用途: 「駐車場法」第2条第1号の路外駐車場、...
  不許容用途: 許容用途以外の用途、...
規模:
  区分: 街区1
  建ぺい率: 60%以下
  容積率: 200%以下
  高さ: 4階以下

  区分: 街区2
  建ぺい率: 70%以下
  容積率: 500%以下
  高さ: 12階以下

キーワード「建ぺい率60%」「高さ4階」などで検索すると、該当チャンクがマッチングされます。

Graceful Degradation

Vision API呼び出しは外部サービスへの依存性があるため、障害に備えたフォールバックを設計しました:

if extract_tables and file_result.tables and pdf_bytes:
    try:
        vision_chunks, vision_embeddings, updated_tables = refine_tables_with_vision(
            pdf_bytes=pdf_bytes,
            tables=file_result.tables,
            source_id=source_id,
            file_id=file_id,
        )
        # Vision結果を反映
    except Exception as e:
        logger.warning(f"[Vision] 表構造化失敗(Doclingオリジナルを維持): {e}")

Visionが失敗した場合:

  • Doclingのオリジナルのマークダウンがそのまま維持される
  • 表が検索で不完全な既存動作にフォールバックする
  • テキストチャンキング/エンベディングは正常に進行

つまり、Visionは**付加的な価値(additive)**であり、**必須の依存性(dependency)**ではありません。

コスト分析

テストPDF(54ページ、表125個、61ユニークページ)基準の推定コスト:

項目 算出根拠 コスト
Docling処理(CPU) ローカル実行 ~$0
Vision表抽出(Haiku) 54ページ × $0.011/ページ ~$0.59
テキストエンベディング(Cohere) 既存パイプライン ~$0.05
合計 ~$0.64/PDF

表が含まれるページでのみVisionを呼び出すため、テキスト中心のPDFではコストはほとんど発生しません。

結論

  • 問題:Docling Markdownの低い表品質(結合セル構造の損失、OCRエラー「以下」→「以上」)
  • 解決:マルチモーダルVision+Docling OCRハイブリッド → 構造化JSON+検索用flat_text
  • 核心:プロンプトエンジニアリングによりHaikuクラスのモデルでもエラー0件を達成
    • <role> + WHY:「建築法規レビューに使用されるため」
    • <workflow> + ドメインヒント:「上限規制であるため以下が一般的」
    • <examples>:Good/Bad + consequence
  • 教訓:単に画像を信頼するよりも、OCRとの交差検証を強制し、ドメインコンテキスト(Why)を注入することがはるかに効果的です。

検索レイヤーに手を加えず、既存パイプラインに一段階追加するだけで、PDF表の検索品質が根本的に改善されました。最も優雅な解決策は、時として最小限のコード変更だけで完成します。