紙が届いた瞬間、パイプラインは止まる
先週、ある取引先から受け取ったPDFを開いた瞬間に手が止まった。三枚綴りの発注書で、一枚目はきれいなデジタル出力、二枚目はどう見てもFAXを一度紙で受けて再スキャンしたもの、三枚目は誰かがボールペンで数量を書き加えたスキャン画像だった。同じ「PDF」という拡張子の下に、三つの別世界が同居している。
前回は全角半角·異体字·表記ゆれといった文字レベルの話を書いた(日本語の「揺れ」を正規化する — 全角半角·異体字·表記ゆれとLLM前処理)。あの正規化パイプラインは、テキストがすでに文字列として手元にある前提で成立する。ところが日本企業の現場では、その手前の「そもそも文字列になっていない」層が厚い。今回はそこの話だ。

「OCRを最初に走らせる」設計をやめた
正直に言うと、最初はOCRを起点に据えていた。PDFが来たらまずOCR、テキストを取れたらLLMに投げる、という素直な構成。だがある現場で、月に数千件届くFAX·スキャンPDFを処理する仕組みを組んだとき、この順序が根本的に間違っていることに気づいた。
ファイル名に「2026-07_A社_発注_R2.pdf」と書いてある文書を、なぜ中身のピクセルから読み直す必要があるのか。送信元のFAX番号は台帳と紐づいているし、メールで来たPDFなら送信元ドメインがある。過去六ヶ月の履歴を見れば、この取引先はいつも同じフォーマットの注文書を送ってくる。中身をOCRするより先に、周辺のメタデータで9割方の分類は済む。OCRは、その残り1割の「本当に中身を読まないと判別できないもの」に絞ればいい。
この方針転換は、以前S5で書いた図面仕分けの話とほぼ同じ構造だ。あのときも、図面の内側を画像認識するより、ファイル名·フォルダ階層·付随するExcelの方が情報量が多かった。「中身より外側を先に読む」は、日本の業務文書処理においてかなり普遍的に効く原則だと今は思っている。
メタデータ優先の分岐を素朴に書く
from dataclasses import dataclass
from pathlib import Path
import re
@dataclass
class DocRoute:
kind: str # "known_template" | "needs_ocr" | "human_review"
confidence: float
reason: str
FILENAME_PATTERNS = [
(re.compile(r"(?P\d{4}-\d{2})(?P[^_]+)発注"), "purchase_order"),
(re.compile(r"見積(?:書)?(?P[^_.]+)"), "quotation"),
(re.compile(r"納品書(?P[^_.]+)"), "delivery_note"),
]
def route(path: Path, sender: str | None, partner_master: dict) -> DocRoute:
name = path.stem
for pat, kind in FILENAME_PATTERNS:
m = pat.search(name)
if not m:
continue
partner = m.groupdict().get("partner")
if partner and partner in partner_master:
return DocRoute("known_template", 0.95,
f"filename+partner_master hit: {kind}/{partner}")
if sender and sender in partner_master:
return DocRoute("known_template", 0.8,
f"sender hit only: {sender}")
return DocRoute("needs_ocr", 0.0, "no metadata signal")
身も蓋もないコードだが、これで実際の流入の6〜8割は「known_template」に落ちる。テンプレートが分かっていれば、そのフォーマット専用の座標抽出やキー·バリュー抽出で済み、汎用OCR+LLMに投げる必要すらない。コスト的にも精度的にも、OCRを走らせない選択肢が一番強いというのが、今のところの実感だ。
それでもOCRが要る、その残り2割の現実
とはいえ残る2割は逃げられない。初めての取引先、手書きが混じった修正伝票、社内で誰かがスキャンしただけで名前もつけていないPDF。ここでようやく本題のOCRとレイアウト解析が登場する。
日本語文書のパースは、ここ一年でだいぶ景色が変わった。以前はTesseractに日本語辞書を突っ込んで、崩れた出力を後段で必死に整形する、というのが定番だった。今は日本語特化のレイアウト解析OSS(YomiTokuなど)、表構造に強いもの(MinerU系)、視覚要素に強いもの(glm-ocr系)と選択肢が増えた。ざっくり言えば「テキスト中心の帳票なら表に強いエンジン、図やチャートが混ざるなら視覚系」という住み分けで、単一のOCRで全部やろうとするのはもう筋が悪い。
崩れる場所は決まっている
実装で30分ハマったポイントを一つ共有すると、日本の帳票でOCRが崩れる場所はだいたい決まっている。第一に、罫線のない暗黙の表。空白と改行だけで列を揃えている見積書のようなものだ。第二に、セル結合された表のヘッダ。第三に、ハンコや訂正印がテキストに重なった箇所。第四に、二段組みや欄外注記の読み順。
このうちハンコと訂正印はどのエンジンも苦手で、Markdown出力を眺めると突然文字が化けるので、あとから見れば犯人はすぐわかる。だから筆者は、OCR結果をLLMに渡す前に必ずMarkdownとして再構成できるかを検査する薄い関数を挟むようにしている。
import re
def sanity_check_markdown(md: str) -> list[str]:
issues = []
# 表の列数が行ごとにぶれていないか
tables = re.findall(r"((?:|.*|\n)+)", md)
for t in tables:
rows = [r for r in t.strip().split("\n") if r.startswith("|")]
widths = {len(r.split("|")) for r in rows}
if len(widths) > 1:
issues.append(f"table column mismatch: {widths}")
# 明らかに壊れた文字(private use area)が混じっていないか
if re.search(r"[\uE000-\uF8FF]", md):
issues.append("private use area chars detected (印影の可能性)")
# 極端に短い行だけが連続していないか(段組み崩れ)
lines = [l for l in md.splitlines() if l.strip()]
short = sum(1 for l in lines if len(l) <= 4)
if lines and short / len(lines) > 0.4:
issues.append("suspicious short-line ratio (段組み or 縦書き崩れ)")
return issues
この関数が何か返してきたら、その文書はLLMに渡す前に人の目に回す。全件を人が見るのは無理でも、怪しいものだけを回すならワークフローに乗る。ここが「OCR→LLM直結」との一番の違いだと思う。
表構造は「Markdown表 or HTML表」で保持する
もう一つ、地味だが効いた話。LLMに表を渡すとき、Markdownのパイプ表現(|列|列|)で渡すか、HTMLの<table>で渡すかで、抽出精度がはっきり変わる。セル結合や複数行ヘッダがある帳票では、Markdownでは表現しきれずに情報が落ちる。逆に単純な二次元の表なら、Markdownの方がトークン効率がいい。
ここはケースごとに割り切っている。「単一ヘッダ·結合なし」ならMarkdown、それ以外はHTML。MinerU系のパーサはMarkdown出力の中にHTMLの<table>を混ぜてくれるので、そのままLLMに渡してもだいたい読める。ちなみにLLMに投げる直前で表だけ抜き出してJSON化する派もあるが、筆者は表の周辺のテキスト(タイトル·脚注)ごと文脈として渡した方が誤読が減るという経験則を持っている。表単体で見ると数字の意味が反転する帳票が、日本の会計文書には案外多いからだ(「(△)」表記など)。
段階的DXという設計思想
この一連の設計を組んでいて改めて思うのは、「FAXを全廃してデジタル化」ではなく「FAXは受け続けて、受けた後を自動化する」という段階的アプローチの方が、現場に着地するということだ。取引先に運用変更を求めない設計。PFUが今年始めた「ドキュメントDX」も、方向としては同じ思想に見える。
技術的にはFAXもスキャンPDFも、突き詰めれば画像だ。だから「画像として来る文書」の処理パイプラインを一本きれいに通しておけば、入り口がFAXでもメール添付でもチャット投稿でも、同じ後段で処理できる。この抽象化ができると、実装の再利用性が一気に上がる。
まとめと次回
今回書いたことを短く整理すると、(1)OCRは最後の手段、ファイル名·送信元·履歴などのメタデータで大半は捌ける、(2)残った文書だけを日本語特化のパーサに渡し、Markdown化した結果をsanity checkする、(3)表はケースに応じてMarkdown/HTMLを使い分け、周辺文脈ごとLLMに渡す、の三つに集約される。
次回は敬語·社内用語·固有名詞の話に踏み込む予定だ。ここまでで文字列にはなったものの、その文字列を業務システムやRAGに繋ぐと今度は「同じ会社を三通りに呼ぶ」「敬語の主語が消える」といった別種の問題が立ちはだかる。用語辞書と検証の話を書くつもりでいる。
筆者は 5years+ で日本語業務文書へのLLM適用を扱っている。