space ocr
指南文章价格文档
developer

「自我校验」这句话,关掉再测一遍

对 OCR 校验流水线做的对照实验:把校验与修复功能逐项关掉来测效果。在 333 个人工标注单元格上,全部关掉后值准确率从 93.7% 降到 91.3%,10 个单元格失去坐标。修好 22 个,弄坏 0 个——外加一套可以搬到你自己流水线上的测量方法。

9 分钟阅读· 2026-07-29

「会自我校验的 OCR」是一句主张,而无法关掉验证的主张不算测量。所以我们把它关掉了。

这篇文章就是结果:把 space-ocr 引擎里的校验与修复功能一项一项关掉,用两个与引擎无关的正确答案来源打分。数字、打分方法、以及每个信号究竟保证什么,都写在这里。如果你正在 LLM 之上搭建抽取流水线,方法比我们的数字更有用——最后一节讲的就是方法。

「和昨天一样就算对」的陷阱

多数 OCR 回归测试把「以前跑得好的那一次」保存下来,拿今天的结果和它对比打分。抓变化很好用,但当不了正确性的证据:如果昨天的输出就是正确的定义,今天的输出必然和它一致。我们自己的测试也会打出 bbox 318/318 = 1.000,这个数字在测试之外没有任何意义。我们在内部文档里把它标为循环指标,不对外引用。

要测校验是否真的起作用,正确答案必须来自流水线影响不到的地方。我们用了两个:

  • —— ground_truth.json,由人对着原图手工誊写的文本,它的产生与引擎输出无关。
  • 坐标 —— 页面自己的 Vision OCR 结果。把落在返回框内的单词连起来读,看是否和返回的值一字不差。全程不看任何历史结果。
坐标的裁判 —— 让页面来给流水线打分
1
2
3
4
5
6
7
8
9
10
11
12
# 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/333292/317 = 92.1%
全部关闭304/333 = 91.3%307/333278/307 = 90.6%

总量会掩盖方向——「+2.4 个百分点」既可能是修好二十个、悄悄弄坏十个,也可能一个都没弄坏。所以逐单元格对照:

关掉的环节救回的值弄坏的值救回的坐标弄坏的坐标
单词比对00100
坐标提示0020
写法还原8020
三项全关80140

修好 22 个单元格,弄坏 0 个。 整个文档集里没有任何一个单元格因为开启校验而变差。这才是我们真正想确认的结果,也比开头的百分比更有分量:一个修好八个、又悄悄弄坏三个的修复功能,即使均值上升也不该开。

✓ Verified

修好的八个值是怎么回事。 八件全是写法(分隔符)问题——交货日期 delivery_date 三件、小计 subtotal 两件、合计 total 两件、明细行单价 unit_price 一件。页面上印的是 2025年09月05日¥1,451,模型却写成了 2025-09-051451。数字本身从头到尾是对的,变的只是写法。把页面原文还原回去之所以安全,是因为条件卡得很窄:数字序列必须完全一致、两侧的非数字字符都要在白名单里、值中间夹着货币符号一律拒绝、逗号分组要合理。最初上线时最后这道关卡挡掉了一个候选——修复功能本就该这样。

标记指向的是真问题吗

每个带框的单元格都会带回两个标记: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)。

把这套测法搬到你的流水线上

值得带走的不是我们的百分比,而是测量的搭法:

  1. 别让昨天的输出定义什么是对的。 与历史运行比对的回归测试只能告诉你什么变了,无法告诉你是否正确。
  2. 去找流水线影响不到的正确答案。 值可以人工誊写。坐标——你的 OCR 层已经把整页文字和位置都给你了,那是给你输出的每一个框的免费独立裁判,但几乎没人这么用。
  3. 按单元格打分,不要只看平均。 平均会把得失抹平。「修好 22 个、弄坏 0 个」比「+2.4 个百分点」有力得多,能告诉你该不该发布的也是它。
  4. 固定输入再回放。 把 Vision 输出和模型响应保存下来,只切换被测的那一项。对含 LLM 的环节做线上 A/B,测到的多半是 LLM 的随机波动而不是你的改动。
  5. 和 schema 校验搭配使用。 字符级交叉核对保证出处——每个值确实来自坐标指向的位置;语义约束(合计对得上、日期在范围内、必填字段齐全)保证记录本身。经得起生产考验的流水线两样都跑。

想亲眼看到本文说的这些信号:POST /ocr/fields 会在每个值上返回 bboxverticesbbox_sourcetext_verifiedneeds_review,并附上列出被标记字段的 review_summary。在应用里怎么用它们分流自动接受与人工复核,见用边界框校验 OCR 结果

为什么不拿保存的历史结果来给坐标打分?
历史运行的快照把「和昨天一致」定义成正确。它能抓回归,但回答不了坐标对不对。用页面自己的 OCR 文字当裁判就没有这个循环——如果返回框里的文字连起来读和返回的值对不上,那么不管历史结果怎么说,这个框都是错的。
「修好 22 个、弄坏 0 个」具体是什么意思?
把完整流水线和关掉校验修复功能的流水线逐单元格对比,有 8 个值和 14 个坐标在开启时正确、关闭时错误。没有任何一个走向相反——不存在关掉反而正确、开着反而错误的单元格。测量于 2026 年 7 月,语料为自建 333 单元格。
带上 text_verified: false 标记就说明值错了吗?
不是。它表示框内的 OCR 文字与模型给出的值不一致。在我们的语料里这类单元格有 75% 确实值或坐标出了问题,所以它是很强的复核信号,但它是一份「两个引擎意见分歧」的报告,不是判决。
它能取代 schema 校验或人工复核吗?
不能——它决定人力该花在哪里。交叉核对保证每个值确实读自坐标指向的位置,标记把人的注意力集中到信号不一致的地方。合计对账、日期范围、必填字段这类语义规则,属于结构化输出之上的 schema 校验。生产流水线两层都跑。

以上所有数字的来源与时点:自建回归语料 11 个用例、333 个人工标注单元格、缓存回放、2026 年 7 月。线上数字来自生产日志,2026 年 7 月 15–27 日。不是公开基准,也无法与厂商的基准表相比。

相关文章