「自我校验」这句话,关掉再测一遍
对 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 —— 四个点,始终与 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 输出和模型响应,所以结果是确定的——跑十次是同一组数字。这是我们自己刻意收集难件的内部用例集,不是公开基准。下面每个数字都带着这个前提。
把校验逐项关掉
引擎里模型响应之后的校验、修复环节可以单独开关,也就是能在模型输出逐字节不变的前提下只摘掉某一项。这里关掉的是三项:
- 单词比对 —— 模型指出「这个值是从页面上这些单词读来的」,引擎把这些单词和真实 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 让整条记录自洽。生产流水线两层都跑。第二层里的一部分如今可以直接写进请求声明——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% 带坐标。标记确实起到了分流作用:大约五个字段里有四个不带任何复核信号,可以直接自动接受,needs_review 把人的注意力集中到剩下的那一个上。延迟在有代表性的一天(262 次请求)为 p50 7.2 秒、p90 10.5 秒——这是我们日志里观测到的分布,不是服务承诺(SLA)。
把这套测法搬到你的流水线上
值得带走的不是我们的百分比,而是测量的搭法:
- 别让昨天的输出定义什么是对的。 与历史运行比对的回归测试只能告诉你什么变了,无法告诉你是否正确。
- 去找流水线影响不到的正确答案。 值可以人工誊写。坐标——你的 OCR 层已经把整页文字和位置都给你了,那是给你输出的每一个框的免费独立裁判,但几乎没人这么用。
- 按单元格打分,不要只看平均。 平均会把得失抹平。「修好 22 个、弄坏 0 个」比「+2.4 个百分点」有力得多,能告诉你该不该发布的也是它。
- 固定输入再回放。 把 Vision 输出和模型响应保存下来,只切换被测的那一项。对含 LLM 的环节做线上 A/B,测到的多半是 LLM 的随机波动而不是你的改动。
- 和 schema 校验搭配使用。 字符级交叉核对保证出处——每个值确实来自坐标指向的位置;语义约束(合计对得上、日期在范围内、必填字段齐全)保证记录本身。经得起生产考验的流水线两样都跑。
想亲眼看到本文说的这些信号:现行的 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)的形式上线,形态由实测决定:只送交叉核验没确认的单元格——干净的请求零额外读取;裁片按图打包成一次批量调用(比逐张读快 6.8 倍);确认了就把 text_verified 升为 true,读出不同内容就标上 crop_mismatch 复核原因,空回应则什么都不写——结果放在响应的 crop_verified 里。它不改值、不挪框——而且它是重读检查,不是重新定位检查:值本身正确、只是锚到了同一段文字另一处出现的情况,按定义会通过它(悄悄错着的 10 个里有 8 个通过了),那一类仍归出现位置守卫管。前提照旧:带图像的 7 个用例、94 个单元格、2026 年 7 月。
为什么不拿保存的历史结果来给坐标打分?
「修好 22 个、弄坏 0 个」具体是什么意思?
带上 text_verified: false 标记就说明值错了吗?
它能取代 schema 校验或人工复核吗?
以上所有数字的来源与时点:自建回归语料 11 个用例、333 个人工标注单元格、缓存回放、2026 年 7 月。线上数字来自生产日志,2026 年 7 月 15–27 日。裁片复验的数字来自同一语料中带图像的 7 个用例、94 个单元格(2026 年 7 月)。不是公开基准,也无法与厂商的基准表相比。