「自ら検証する」を、切って測ってみた
OCR の検証パイプラインを対象にした対照実験。検証・補正の機能を一つずつ切りながら効果を測りました。人手で正解を付けた 333 セルでは、全部切ると値の正確さが 93.7% → 91.3% に落ち、10 セルで座標が消えます。直したセル 22、壊したセル 0 — そしてどのパイプラインでも再現できる測定手法。
「自ら検証する OCR」は主張です。そして切って確かめられない主張は、測定とは呼べません。だから実際に切ってみました。
この記事はその結果です。space-ocr エンジンの検証・補正機能を一つずつ切りながら、エンジンとは無関係に作られた二つの正解と突き合わせて採点しました。数字と採点方法、そして各シグナルが何を保証するのかを順に書きます。LLM の上に抽出パイプラインを組んでいるなら、私たちの数字より測定方法のほうが役に立つはずです — 最後の節がその話です。
名前についての注記。 この測定は 2026 年 7 月、エンジン v52/v53 の時点のもので、以下に出てくるシグナル名も当時の API の名前です。公開 API はその後 v2 の応答形式に移りました。同じシグナルは現在、次の名前で返ります。
| 本記事の表記(2026-07) | 現行の POST /ocr/fields |
|---|---|
bbox | data.cells[path].box — {xmin, ymin, xmax, ymax}、0–1000 の正規化座標 |
vertices | quad — 4 点。box と必ず一緒に返ります |
bbox_source | evidence.source |
text_verified | evidence.text_match — 文字の突き合わせそのもの。最上位の verified はエンジン v82 以降 review を映す判定で、フラグが付けば false、突き合わせが走って何も付かなければ true、突き合わせる対象がなければ null |
needs_review | cells[path].review — null または {reasons}。文書全体の一覧は data.review.flagged[{path, reasons}] |
review_summary | data.review — {unit, declared, returned, boxed, verified, flagged, by_reason, notes} |
crop_verified / crop_mismatch | 現行も同じ名前 — evidence.crop_verified、レビュー理由の crop_mismatch |
以下の実験は測り直しても採点し直してもいません。変わったのは応答の名前だけです。
「昨日と同じなら正解」の罠
OCR の回帰テストの多くは、「以前うまくいった実行結果」を保存しておき、今日の結果をそれと突き合わせて採点します。何かが変わったことを検出するには有効ですが、正しさの証拠にはなりません。昨日の出力を正解の定義にすれば、今日の出力は当然それと一致するからです。私たちのテストも bbox 318/318 = 1.000 という数字を出しますが、この数字はテストの外では何の意味も持ちません。社内文書に「循環指標」と明記し、外には引用していません。
検証が本当に効いているかを測るには、パイプラインが手を出せない場所から正解を持ってくる必要があります。二つ使いました。
- 値 —
ground_truth.json。人が元の画像を見て手で書き起こしたテキストです。エンジンの出力とは無関係に作られています。 - 座標 — ページ自身の Vision OCR 結果。返したボックスの中に入る単語をつなげて読み、返した値と同じ文字列になるかを確かめます。過去の保存結果は一切見ません。
# The page's own OCR is the judge — no snapshot, no circularity.
def spells(box, vision_words, value):
inside = [
w["text"] for w in vision_words
if box["xmin"] <= (w["bbox"]["xmin"] + w["bbox"]["xmax"]) / 2 <= box["xmax"]
and box["ymin"] <= (w["bbox"]["ymin"] + w["bbox"]["ymax"]) / 2 <= box["ymax"]
]
return norm(value) in norm("".join(inside))
# A returned coordinate is wrong when the words under it do not spell
# the value that coordinate was attached to.
bad = [c for c in boxed_cells if not spells(c["bbox"], words, c["value"])]面白いのは二つ目です。座標を返すというのは「先月もこのボックスが出た」ではなく、この値をこの場所から読んだという主張です。そしてその主張は、ページ自身が検査できます。ページ全体が、位置のわかる OCR 文字で覆われているからです。ボックスの中の文字をつなげて読んで値と違うなら、その座標は間違い。過去の結果と比べる必要はありません。
測定に使った文書セット: 実際に問題になった文書を集めた自社の回帰ケース 11 件。人手で正解を付けたセルが 333 個あり、うち 317 個に座標が付きます。保存しておいた Vision 出力とモデル応答を再生する方式なので、結果は毎回同じです — 10 回走らせても同じ数字が出ます。難しい書類をあえて集めた自前のセットで、公開ベンチマークではありません。以下のすべての数字にこの但し書きが付きます。
検証を一つずつ切る
エンジンでモデル応答のあとに走る検証・補正の段は、それぞれ独立にオン・オフできます。モデル応答はバイト単位でそのままに、特定の段だけを外せるということです。ここで切ったのは三つ。
- 単語トークン照合 — モデルが「この値はページのこの単語から読んだ」と指した単語を、実際の OCR 単語と突き合わせ、本当にその値を綴っているか確かめてから座標に使う経路
- 座標ヒント — 別のモデル呼び出しで受け取る「値はだいたいこの辺り」という位置情報。同じ値がページに複数回現れるとき、どれを指すかを選ぶのに使います
- 表記の復元 — モデルが
2025年09月05日を2025-09-05に書き換えてしまったとき、ページに実際に印字された表記へ戻す段
| 構成 | 値の正確さ(人手の正解と比較) | 座標が付いたセル | ボックス内の文字が値と一致 |
|---|---|---|---|
| 全段オン | 312/333 = 93.7% | 317/333 | 292/317 = 92.1% |
| 全段オフ | 304/333 = 91.3% | 307/333 | 278/307 = 90.6% |
合計の数字は方向を隠します — 「+2.4 ポイント」だけでは、20 個直して 10 個壊したのか、何も壊していないのか区別できません。だからセル単位で突き合わせました。
| 切った段 | 直った値 | 壊れた値 | 直った座標 | 壊れた座標 |
|---|---|---|---|---|
| 単語トークン照合 | 0 | 0 | 10 | 0 |
| 座標ヒント | 0 | 0 | 2 | 0 |
| 表記の復元 | 8 | 0 | 2 | 0 |
| 三つすべて | 8 | 0 | 14 | 0 |
直したセル 22、壊したセル 0。 検証をオンにしたせいで悪くなったセルは一つもありませんでした。本当に確かめたかったのはこの結果で、冒頭のパーセントより重要です。8 セル直すかわりに 3 セルを黙って壊すような機能なら、平均が上がってもオンにすべきではないからです。
直った 8 つの値の正体。 8 件すべて表記(区切り文字)の問題でした — 納品日 delivery_date 3 件、小計 subtotal 2 件、合計 total 2 件、明細行の単価 unit_price 1 件。ページには 2025年09月05日、¥1,451 と印字されているのに、モデルが 2025-09-05、1451 に書き換えてしまったケースです。数字そのものは最初から合っていて、変わったのは表記だけ。ページの元の表記に戻す処理が安全なのは、条件を狭く絞っているからです — 数字の並びが完全に一致すること、数字以外の文字が両側ともホワイトリストにあること、値の途中に通貨記号が挟まっていたら拒否、カンマの桁区切りが自然であること。最初の適用時にはこの最後の条件が候補を 1 件はじきましたが、補正機能とはそうあるべきです。
フラグは本当の問題を指しているか
座標が付いたセルには、二つのフラグが一緒に返ります。text_verified は「ボックス内の OCR 文字とモデルの値が一致しているか」、needs_review は「人が見るべきか」。当然の疑問は、このフラグが本当に間違っているセルに付くのか、ということです。
まず問題セルを定義します — 値が人手の正解と違う、または、ボックス内の文字が値と一致しないセル。333 個中 39 個、全体の 11.7% です。
| シグナル | 測定の結果 |
|---|---|
text_verified: false | フラグの付いた 8 セル中 6 セル(75%)が本当に問題セル — 無作為に選んだ場合(11.7%)の 6.4 倍。二つのエンジンがボックスの中身で食い違ったら、4 回に 3 回はそこに本当に問題があります |
needs_review | 座標が間違っているセル 25 個のうち 10 個(40%)を捕捉 — 過去結果との比較では測ることすらできない数字です |
text_verified: false は精度重視の側です。付くセルは少なく、付いたら見る価値があります。needs_review はあえて広めに拾う側 — 黙って通すより多めに含める設計で、人のレビュー待ち行列に流す信号はそうあるべきです。
この検証が保証すること・しないこと
数字を正しく読むには、何を保証しているのかをはっきりさせる必要があります。この突き合わせが保証するのはボックスと文字が合っていること — 受け取った値が、座標の指すまさにその場所から読まれたことです。正しいフィールドを読んだか — 単価を隣の列から取っていないか、敬称が名前に混ざっていないか — は意味の問題で、それは構造化された出力の上でスキーマ検証と業務ルールが担う仕事です。二つの層は補完関係にあります。座標は値の一つひとつを目で確かめられるようにし、スキーマはレコード全体のつじつまを合わせます。本番のパイプラインは両方を回します。この二層目の一部は、いまではリクエストの宣言として掛けられます — required は missing、min/max は out_of_range、pattern と enum は pattern_mismatch、type は type_mismatch、near は near_mismatch として review.flagged に並びます(宣言はモデルには渡らないので、値そのものは変わらず、増えるのはレビュー信号だけです)。
規模感のための本番の数字
上の文書セットは小さく、わざと難しくしてあります — この仕組みの価値が出るのは実トラフィックです。本番リクエスト 2,165 件でフィールドが 9,685 個返り、97.2% に座標が付きました。フラグは振り分け役をきちんと果たしています。おおよそ 5 つに 4 つはレビュー信号なしで届いてそのまま自動受け入れでき、needs_review が残りの 1 つに人の目を集めます。応答時間は代表的な 1 日(262 件)で p50 7.2 秒、p90 10.5 秒 — 自社ログで観測した分布であって、保証値(SLA)ではありません。
同じ測定を自分のパイプラインで
持ち帰る価値があるのは私たちのパーセントではなく、測定の組み立て方です。
- 昨日の出力を正解にしない。 保存した過去結果と比べるテストは、何が変わったかを教えてくれるだけで、合っているかは教えてくれません。
- パイプラインが手を出せない正解を探す。 値は人が書き起こせば作れます。座標は — OCR 層がすでにページ全体の文字と位置を知っています。自分の出すすべてのボックスを無料で採点してくれる独立の審判なのに、そう使っている例はほとんどありません。
- 平均ではなくセル単位で採点する。 平均は、得たものと失ったものを塗りつぶします。「22 直して 0 壊した」は「+2.4 ポイント」よりはるかに強い文で、出してよいかを教えてくれるのもこちらです。
- 入力を固定して再生する。 Vision 出力とモデル応答を保存しておき、試したい段だけを切り替えます。LLM を含む段をライブで A/B すると、自分の変更ではなく LLM の出力ゆらぎを測ることになります。
- スキーマ検証と組み合わせる。 文字単位の突き合わせは出どころを — 値が座標の指す場所から来たことを — 保証します。合計が合う・日付が範囲内・必須フィールドが揃っている、といった意味の検証はレコードを保証します。本番で持ちこたえるパイプラインは両方を回しています。
この記事で扱ったシグナルを実際に見るには — 現行の POST /ocr/fields は同じシグナルを v2 の名前で返します。値ごとに data.cells[path] があり、box と quad(0–1000)、判定の verified、review(null または {reasons})、evidence(text_match・source・match_ratio・crop_verified など)が載り、フラグの付いたパスの一覧は data.review.flagged[{path, reasons}] です。アプリケーション側でこれを自動受け入れ/人手レビューの振り分けに使う方法はバウンディングボックスで OCR 結果を検証するにあります。
追記 — この測定装置が次に払ってくれたもの
測定の仕組みは、次のアイデアが来たときに元を取ります。この測定の少しあと、こんな提案が届きました。パイプラインが終わったら、返した座標を全部画像から切り出してもう一度読ませればいい — OCR はごちゃついた全面より小さな切り抜きのほうがよく読めるから、その二度目の読みを検証にもう一段重ねられる、と。ありそうな話ですが、退屈な別解も二つありえました。フィクスチャの OCR キャッシュが古いだけかもしれないし、切り抜きに付く余白のせいで採点が甘くなった錯覚かもしれない。だからパイプラインのコードを書く前に、上と同じやり方で切り分けました。画像のある回帰ケースからセル 94 個を三群に — 照合が確認できなかった 24、黙って間違っていた 10、正常な対照群 60。
切り抜きの効果は本物でした。再読で 24 個中 16 個(66.7%)が確認でき、交絡は両方とも消えています — 同じ余白領域を全面の単語で採点し直しても回復は 0、今日のモデルで全面を読み直しても 0/24 のまま。対照群は代償を教えてくれました。そのまま採点すると正常セル 60 個中 6 個(10%)を疑ってしまい、うち 4 個は一文字だけの極小切り抜きに対する空応答です。3 倍に拡大しても一つも戻りませんでした — 一文字に足りないのは画素ではなく文脈です。だから出荷した規則では空応答を「不一致」ではなく棄権として扱い、誤警報は 3.3% まで下がります。
この実験はクロップ再検証(エンジン v53)として出荷され、形は実測が決めました。送るのは照合が確認できなかったセルだけ — きれいなリクエストの追加読み取りはゼロ。切り抜きは画像ごとに 1 回のバッチ呼び出しにまとめます(1 枚ずつ読むより 6.8 倍速い)。確認できれば text_verified が true に昇格、食い違えば crop_mismatch というレビュー理由が付き、空応答なら何も書きません — 結果は応答の crop_verified に載ります。値の書き換えも座標の移動もしません。そしてこれは読み直す検査であって、どこを読んだかを検査するものではありません — 同じ文字列の別の出現に錨を下ろした値は定義上すり抜けます(黙って間違っていた 10 個中 8 個が通過しました)。そのクラスは引き続き出現位置ガードの担当です。但し書きはいつも通り:画像のある 7 ケース・94 セル・2026 年 7 月。
座標を保存済みの過去結果と比べてはいけないのですか?
「22 セル直して 0 セル壊した」とは具体的に?
text_verified: false が付いたら値が間違いということですか?
スキーマ検証や人のレビューの代わりになりますか?
上のすべての数字の出どころと時点: 自社回帰コーパス 11 ケース・人手で正解を付けた 333 セル・保存入力の再生・2026 年 7 月。本番の数字は 2026 年 7 月 15〜27 日のログ。クロップ再検証の数字は同じコーパスのうち画像のある 7 ケース・94 セル(2026 年 7 月)。公開ベンチマークではなく、他社のベンチマーク表とは比較できません。