「自我校验」这句话,关掉再测一遍
对 OCR 校验流水线做的对照实验:把校验与修复功能逐项关掉来测效果。在 333 个人工标注单元格上,全部关掉后值准确率从 93.7% 降到 91.3%,10 个单元格失去坐标。修好 22 个,弄坏 0 个——外加一套可以搬到你自己流水线上的测量方法。
「会自我校验的 OCR」是一句主张,而无法关掉验证的主张不算测量。所以我们把它关掉了。
这篇文章就是结果:把 space-ocr 引擎里的校验与修复功能一项一项关掉,用两个与引擎无关的正确答案来源打分。数字、打分方法、以及每个信号究竟保证什么,都写在这里。如果你正在 LLM 之上搭建抽取流水线,方法比我们的数字更有用——最后一节讲的就是方法。
「和昨天一样就算对」的陷阱
多数 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 输出和模型响应,所以结果是确定的——跑十次是同一组数字。这是我们自己刻意收集难件的内部用例集,不是公开基准。下面每个数字都带着这个前提。
把校验逐项关掉
引擎里模型响应之后的校验、修复环节可以单独开关,也就是能在模型输出逐字节不变的前提下只摘掉某一项。这里关掉的是三项:
- 单词比对 —— 模型指出「这个值是从页面上这些单词读来的」,引擎把这些单词和真实 OCR 单词核对、确认确实拼得出这个值,才采用其位置
- 坐标提示 —— 由另一次模型调用给出的「值大概在这附近」的粗略位置,用来在同一个值出现多次时挑出正确的那一个
- 写法还原 —— 页面上印的是
2025年09月05日,模型却改写成2025-09-05时,把 Vision 读到的原文原样还原
| 配置 | 值准确率(对照人工答案) | 带坐标的单元格 | 框内文字与值一致 |
|---|---|---|---|
| 完整流水线 | 312/333 = 93.7% | 317/333 | 292/317 = 92.1% |
| 全部关闭 | 304/333 = 91.3% | 307/333 | 278/307 = 90.6% |
总量会掩盖方向——「+2.4 个百分点」既可能是修好二十个、悄悄弄坏十个,也可能一个都没弄坏。所以逐单元格对照:
| 关掉的环节 | 救回的值 | 弄坏的值 | 救回的坐标 | 弄坏的坐标 |
|---|---|---|---|---|
| 单词比对 | 0 | 0 | 10 | 0 |
| 坐标提示 | 0 | 0 | 2 | 0 |
| 写法还原 | 8 | 0 | 2 | 0 |
| 三项全关 | 8 | 0 | 14 | 0 |
修好 22 个单元格,弄坏 0 个。 整个文档集里没有任何一个单元格因为开启校验而变差。这才是我们真正想确认的结果,也比开头的百分比更有分量:一个修好八个、又悄悄弄坏三个的修复功能,即使均值上升也不该开。
修好的八个值是怎么回事。 八件全是写法(分隔符)问题——交货日期 delivery_date 三件、小计 subtotal 两件、合计 total 两件、明细行单价 unit_price 一件。页面上印的是 2025年09月05日、¥1,451,模型却写成了 2025-09-05、1451。数字本身从头到尾是对的,变的只是写法。把页面原文还原回去之所以安全,是因为条件卡得很窄:数字序列必须完全一致、两侧的非数字字符都要在白名单里、值中间夹着货币符号一律拒绝、逗号分组要合理。最初上线时最后这道关卡挡掉了一个候选——修复功能本就该这样。
标记指向的是真问题吗
每个带框的单元格都会带回两个标记:text_verified(框内的 OCR 文字和模型给的值是否一致)和 needs_review(是否需要人看一眼)。自然要问:这些标记会不会真的落在出错的单元格上。
先定义问题单元格:值与人工答案不符,或框内文字与值对不上。333 个里有 39 个,占 11.7%。
| 信号 | 测量结果 |
|---|---|
text_verified: false | 带此标记的 8 个单元格里 6 个(75%)确实是问题单元格——是随机抽取(11.7%)的 6.4 倍。两个引擎对框里的内容各执一词时,四次里有三次那里真的有问题 |
needs_review | 捞起了 40% 的坐标错误(10/25)——这是靠历史结果打分根本无法测量的数字 |
text_verified: false 是精准的那一个:落上的单元格少,一旦落上就值得看。needs_review 则有意撒得更宽——宁可多装也不沉默,喂给人工审核队列的信号本该如此。
这套校验保证什么、不保证什么
要正确读这些数字,得先说清它保证什么。交叉核对保证的是框和文字对得上——你拿到的值确实是从坐标指向的那个位置读出来的。至于读的是不是正确的字段——单价有没有拿成隔壁列、称谓算不算名字的一部分——那是语义问题,应该交给结构化输出之上的 schema 校验和业务规则。两层是互补的:坐标让每个值都能被亲眼核对,schema 让整条记录自洽。生产流水线两层都跑。
用线上数字看规模
上面的文档集又小又刻意偏难——这套信号的价值要在真实流量里才看得出来。2,165 次生产请求共返回 9,685 个字段,97.2% 带坐标。标记确实起到了分流作用:大约五个字段里有四个不带任何复核信号,可以直接自动接受,needs_review 把人的注意力集中到剩下的那一个上。延迟在有代表性的一天(262 次请求)为 p50 7.2 秒、p90 10.5 秒——这是我们日志里观测到的分布,不是服务承诺(SLA)。
把这套测法搬到你的流水线上
值得带走的不是我们的百分比,而是测量的搭法:
- 别让昨天的输出定义什么是对的。 与历史运行比对的回归测试只能告诉你什么变了,无法告诉你是否正确。
- 去找流水线影响不到的正确答案。 值可以人工誊写。坐标——你的 OCR 层已经把整页文字和位置都给你了,那是给你输出的每一个框的免费独立裁判,但几乎没人这么用。
- 按单元格打分,不要只看平均。 平均会把得失抹平。「修好 22 个、弄坏 0 个」比「+2.4 个百分点」有力得多,能告诉你该不该发布的也是它。
- 固定输入再回放。 把 Vision 输出和模型响应保存下来,只切换被测的那一项。对含 LLM 的环节做线上 A/B,测到的多半是 LLM 的随机波动而不是你的改动。
- 和 schema 校验搭配使用。 字符级交叉核对保证出处——每个值确实来自坐标指向的位置;语义约束(合计对得上、日期在范围内、必填字段齐全)保证记录本身。经得起生产考验的流水线两样都跑。
想亲眼看到本文说的这些信号:POST /ocr/fields 会在每个值上返回 bbox、vertices、bbox_source、text_verified 和 needs_review,并附上列出被标记字段的 review_summary。在应用里怎么用它们分流自动接受与人工复核,见用边界框校验 OCR 结果。
为什么不拿保存的历史结果来给坐标打分?
「修好 22 个、弄坏 0 个」具体是什么意思?
带上 text_verified: false 标记就说明值错了吗?
它能取代 schema 校验或人工复核吗?
以上所有数字的来源与时点:自建回归语料 11 个用例、333 个人工标注单元格、缓存回放、2026 年 7 月。线上数字来自生产日志,2026 年 7 月 15–27 日。不是公开基准,也无法与厂商的基准表相比。