面壁 MiniCPM-OCR 的读判分离:跟着一页发票走完五站,看那个「不吐字的裁判」在干什么
面壁 10 月 28 日开源了两个模型:1.3B 的 MiniCPM-OCR 1.0,六项基准拿下四项 SOTA;2B 的 MiniCPM-OCR Jev,一个字都不生成。
一个 OCR 新王,为什么要给自己配一个专职裁判?这件事的解释不在跑分表里,在数据流里。这篇不按技术点罗列,而是跟着一页发票走完五站,看每一站它在干什么、省了什么、卡在哪。
零、先说清楚那个反常之处
先把两个模型的角色摆出来:
| MiniCPM-OCR 1.0 | MiniCPM-OCR Jev | |
|---|---|---|
| 参数 | 1.3B | 2B |
| 干什么 | 读图,输出 Markdown / LaTeX / HTML 表格 | 只给候选结果打分,不输出任何文字 |
| 输出 | 结构化文本 + 坐标 | 一个 0–1 的分数 |
| 许可证 | Apache 2.0 | Apache 2.0 |
| 速度 | 1.217 页/秒(RTX 5090) | 毫秒级(一次前向) |
一个只判卷不答题的模型,为什么需要存在?
因为行业里有一个一直没被正面回答的问题。过去几年所有 OCR 厂商都在比"认得准",宣称 99%。但在财税、金融、核保这类场景里,99% 意味着:
100 张发票,错了 1 张,但你不知道是哪一张。你只能雇人把 100 张全部重看一遍。
于是出现了那个荒诞的场景:企业买了最前沿的 AI,转头又雇几十号人做流水线核对。不知道错在哪的 99 分,和 0 分一样,都要人类兜底。
Jev 要解决的就是这一件事:把"错在哪"标出来。 而它用的方法,是把"读"和"判"彻底拆成两个模型。
下面跟着数据走一遍,就明白为什么要拆。
第 0 站:版面切块——不是模型干的
这一站最容易被忽略,但它决定了后面所有环节的输入质量。
MiniCPM-OCR 的解析管线不是一个模型,是三部分:
PDF / PNG / JPG
↓
minicpmvocr SDK 读取文件,PDF 逐页栅格化成图
↓
PP-DocLayoutV3 检测文档区域 + 判定阅读顺序,切成 crop
↓
识别模型(1.3B) 逐个 crop 并发识别
↓
按阅读顺序拼装成 Markdown
两个部署上必须知道的细节:
细节一:SDK 和推理服务跑在两个不同的 Python 环境里,而且可以部署在不同机器上。 这不是设计洁癖,是实用考虑——SDK 侧要装 PDF 处理、图像栅格化的依赖(体积大、依赖冲突多),推理服务侧要装 GPU 推理栈。混在一个环境里,装依赖能折腾半天。分开之后,你可以在 A 机器跑 SDK、B 机器跑 GPU 推理。
细节二:区域类型是靠 prompt 指定的,不是自动判断的。
支持的五种:
Text Recognition: 正文识别
Table Recognition: 表格
Formula Recognition: 公式
Seal Recognition: 印章
Chart to Table: 图表转表格
这意味着什么?你必须知道自己要给模型看什么。 如果你的业务里有一批"发票右下角有个红章,需要单独读章上的税号",你得显式指定 Seal Recognition:。默认的正文识别不会主动去找章。
这是一个实用但常被忽略的能力——大多数 OCR 管线里,印章是干扰项(印泥糊住正文),但在这里它可以是一个独立的信息源。票面信息 + 章面信息两侧对照,核验真伪多了一道依据。
第 1 站:识别——1.3B 是怎么做到 1.2 页/秒的
这一站是唯一"正常"的一站:一个视觉语言模型看图,输出结构化文本。
架构拆开看:
| 组件 | 用的是什么 |
|---|---|
| 视觉编码器 | SigLIP |
| 语言主干 | Qwen3.5 混合注意力 |
| 底座来源 | 基于 MiniCPM-V 4.6 训练 |
| 定位 | MiniCPM-V 家族首个 OCR 专用模型 |
| 输出格式 | Markdown 正文 + LaTeX 公式 + HTML 表格(从 OTSL 转换)+ 带坐标的阅读顺序 |
“OCR 专用"这个定语是关键。过去做文档解析,通用的多模态模型也能干,但它是"什么都会一点”;专用模型是在同一个底座上,把能力全部压到"读懂版面"这一件事上。参数降到 1.3B,能力反而更强,靠的就是这个收窄。
速度靠两块技术:
| 技术 | 作用 |
|---|---|
| LLaVA-UHD v4 视觉分片 | 448 分辨率下做 4 倍视觉 token 压缩,最多 9 个切片。大图切块编码,把 token 数压到小模型能跑的量级 |
| DSpark 投机采样解码 | 端到端处理效率提升 55% |
这两块叠起来,才有这个结果——单张 RTX 5090,平均不到 1 秒解析一页。
同硬件、同测试条件下的吞吐对比:
| 模型 | 页/秒 | 相对值 |
|---|---|---|
| MiniCPM-OCR 1.0 | 1.217 | 1.00x |
| PaddleOCR-VL-1.6 | 0.818 | 0.67x |
| MinerU2.5-Pro-Advanced | 0.336 | 0.28x |
| TeleOCR | 0.141 | 0.12x |
单卡吞吐约为 TeleOCR 的 8.6 倍。
精度侧,六项基准拿四项 SOTA(均为厂商自测):
| 基准 | 得分 | 说明 |
|---|---|---|
| Real5-OmniDocBench | 93.68 | 专考手机随手拍、镜头畸变、反光翻拍 |
| Wild-OmniDocBench | 92.39 | 野生真实文档 |
| OCRBench v2 | 54.7 | 第二名 47.0 |
| CHAOS-Bench | 29.78 | 考忠实度,第二名的 1.4 倍以上 |
CHAOS-Bench 这一项值得单独看。 它测的不是"认得多准",是"在强语义先验干扰下,能不能忠实于图片识别"。换句话说:图上是 5.576M,模型会不会因为"这里应该是个金额"就自己脑补成 $5.576M。
这个基准的存在本身就说明:OCR 领域最大的问题已经不是"认不出字",而是"认出了它以为该有的东西"。
而这一站,恰好解决不了这个问题。
五类真实文档的实测表现
跑分的说服力有限,看具体场景更有用。官方放出的实测覆盖了五类真实文档,每一类都对应一种典型失败模式:
| 文档 | 难点 | 结果 | 这一步验证了什么 |
|---|---|---|---|
| 发票(红章压字) | 红色表格线 + 两枚压字的印章 + 密集数字 | 购买方/销售方/密码区左右分栏还原,合并单元格原样,18 位纳税人识别号、16 位银行账号逐位对上;红章被单独切出,没有糊进正文 | 版面分层能力:知道哪些笔画属于章、哪些属于正文 |
| 手写推导笔记 | 斜阳打光、半边泛橙;公式 + 手绘曲线 + 箭头批注 + 方框 | 整条推导链逐行还原成标准 LaTeX,带分数线的约分、上下标一步不乱;两张手绘示意图被单独切成图片保留 | 混合内容(文字/公式/图形)的区域判定 |
| 揉皱的英文教材复印页 | 纸张揉皱、满页矩阵和不等式 | 先自动摆正 2°,再逐行还原;分块矩阵 [A −I]、上标 x*、下标 x₁ 全部转成可渲染 LaTeX,4×4 矩阵一个数都没错 | 几何校正 + 数学结构识别 |
| 杂志涂鸦墙照片 | 几种字体大标题挤在一起,白字印在砖墙涂鸦上 | 背景极花的白色小字完整读出四个自然段,照片区域单独切图 | 复杂背景下的文字提取 |
| 整版中文日报 | 横排竖排标题混搭、窄栏正文、照片穿插、"下转第四版"跨版提示 | 按阅读顺序重排版面;连报头下的刊号、邮发代号、新闻热线、电子邮箱这些小字都没漏 | 阅读顺序判定(这是版面模型的核心价值) |
最后一条值得单独留意:"阅读顺序"才是报纸这一类文档的真正难点。 横排竖排混排的版面上,认得每个字不难,知道"先读哪一栏、再读哪一栏"才难。而这个能力来自第 0 站的 PP-DocLayoutV3,不是识别模型。
这就引出一个部署上的关键判断:
如果你换掉了 PP-DocLayoutV3,识别模型还是那个模型,但报纸、多栏文档、复杂表格的阅读顺序会直接崩掉。
这个管线是三个组件绑定的能力,不是一个模型的独角戏。换零件之前,先搞清楚每一段在管什么。
第 2 站:重新渲染回图——这一步是整个设计的枢纽
数据从识别模型输出后,如果不做任何事直接入库,就回到了开头那个困境:99% 正确,不知道错在哪。
所以 Jev 的输入不是"文本",是三元组:
① 原图 crop 这一块长什么样
② 候选解析文本 模型说它是什么
③ 由②重新渲染出的图 把模型说的话再"画"一遍
为什么要多此一举,把文本重新渲染成图?
因为这样比对才成立:
| 比对空间 | 比什么 | 能抓出什么错 |
|---|---|---|
| 视觉空间(① vs ③) | 版式结构 | 错列、漏行、表格行列错位、合并格没填 |
| 跨模态空间(① vs ②) | 字符语义 | 数字看错位、符号丢失、上下标退化 |
如果只给 Jev 文本,它能判断"这段话通不通顺",但判断不了"这段话和图对不对得上"。加上渲染回来的那张图,它就有了一把可以逐格对照的尺子。
这一步是整个"可信 OCR"概念的技术落点。它把"检查有没有认错"这个任务,从语义判断(容易脑补)转成了视觉比对(难以糊弄)。
第 3 站:判分——2B 模型,不生成一个字
Jev 的改造方式非常干脆:
从 Qwen3.5-2B 出发,保留视觉编码器和语言主干,把语言模型的输出头换成二分类头。
输出头一换,性质就变了:它不再是"生成模型",而是"判别模型"。一次前向,不生成任何文字,毫秒级给出 0–1 的分数。
这个"不生成"的设计,解决了两个问题:
问题一:速度。 如果让一个大模型做质检,它得字斟句酌生成一大段推理,延迟极高。你不可能为了质检让整条流水线等几秒。而一次前向是毫秒级的,可以卡在"识别"和"入库"之间当闸机。
问题二(更重要):自我评估的天然失效。
这是整篇文章我认为最值得记住的一段推理:
为什么不能让解析模型自己给自己打分?
因为自回归生成模型在逐字解码时有很强的语言先验。哪怕它把票据里的数量少看了一个零,或者把公式的上下标吞掉了,只要生成的文本在语法上通顺,模型内部的 token 预测概率依然很高。
它抓不出自己的事实错误,因为它的"自信"来自"这句话读起来对不对",不是"这张图上是这个吗"。
用一个类比:让大模型给自己打分,就像让考生自己判卷。它不是不认真,是它的大脑会自动脑补它以为该有的东西。 市面上一些 OCR 引擎附带的"置信度",本质上就是这种自己给自己的分。
而 Jev 的分数不一样——它是用大量真实人工标注数据做过统计校准的。不是模型"感觉对",是"在这类样本上,这个分数对应这个正确率"。
效果数据:
| 模型 | 参数 | AUROC(判别能力) |
|---|---|---|
| MiniCPM-OCR Jev | 2B | 93.6 |
| Qwen3.5 | 397B | 93.8 |
2B 几乎打平 397B。 差了 0.2 分,参数差了 198 倍。
这个结果说明一件事:"判断一个 OCR 结果对不对"和"生成一个 OCR 结果"是两种不同的能力,前者不需要那么大的模型。 把两种能力塞进一个巨无霸里,是过去行业的默认做法,但它不是唯一解法——而且是最贵的那种。
第 4 站:重试决策——三轮实测,无人干预
这是全文最有说服力的一段,因为它抓住了人类肉眼极易放过的一种错。
测试样本:DeepSeek-V3 技术报告里那张训练成本表(就是"557.6 万美元"那个数字的出处)。
第一轮:
| 项目 | 结果 |
|---|---|
| 数字 | 全对——2664K、119K、5K,连合计 5.576M 都对 |
| 符号 | 四个美元符号全被读成了公式标记 |
| 合计呈现 | 变成 )5.576M |
| Jev 判分 | 约 0.000001 |
注意这个失败模式:所有数字都是对的。 人扫一眼,看到 2664K、119K、5.576M 都在,很容易放过——但那个 ) 说明 LaTeX 解析已经错位,整张表的语义坏了。
这就是"99% 正确"的典型形态:错的那 1% 藏在符号里,不在数字里。 而人对数字更敏感,正好漏掉符号。
第二轮: 程序按策略处理——原图放大 2 倍、四周留白。结果:美元符号还是不对,Jev 分数纹丝不动。
这一轮的意义在于:它证明了 Jev 的分数是稳定的,不是随机波动。放大图片没有改善,它就诚实地不给分。如果分数会随无关操作漂移,这个质检机制就不可信了。
第三轮: 程序按预设策略换了一套识别流程。四个 $ 全部回来了,Jev 复判给 0.9692,重试停止,表格存成 JSON。
最终结果:表头、各阶段 GPU 小时数、美元金额,逐项对回原图全部一致。三轮识别、两次返工,中间没有人手工补一个符号,也没有人守着点"再试一次"。
这一站在工程上的意义是:它把"要不要重试"变成了一个可以写进代码的判断。
读 → 判 → 分数低于阈值 → 换策略重读 → 再判 → 过线入库
↓
仍不过线 → 标记出来交人复核
人类审核的角色,从"全量巡检"变成"异常块复核"。
第 5 站:入库——三种用法和一个必须防的陷阱
Jev 不绑定 MiniCPM-OCR。 它是高度解耦的独立决策模型,可以作为"置信度外挂"架在任何 OCR 之后:PaddleOCR、TeleOCR、商业 API、本地私有化微调的模型,都不用改识别底座。
三种标准用法:
| 用法 | 做法 | 适合 |
|---|---|---|
| 阈值自动放行 | 每个区域打分,过阈值的直接入库,剩下的推人工 | 大批量、人工复核成本高的场景 |
| 区域内 best-of-N | 多个解析器读同一个 crop,取最高分的那个 | 有多个现成解析器可调 |
| 跨块批选择 | 整份文档按阅读顺序从最佳候选中拼装 | 追求单份文档的最高质量 |
但这里有一个必须防的陷阱,它比分数本身更容易出事:
服务返回的是 verdict + score 两个值,只有
scored才代表模型真的跑过。Precheck、图像渲染、前向计算失败时,返回的是固定值——这些固定值不能被当成低分。
一个忽略 verdict 的流水线,会把"坏请求"静默当成"文档不合格"。
这是和前面提到的"静默失败"同一类问题,但更隐蔽:分数低,你会怀疑文档质量;verdict 异常,才该怀疑服务本身。 两者混在一起,你会花几天时间去查一个根本不存在的"识别精度问题",而真正的原因是一个渲染请求超时了。
入库前必须有的两行判断:
if verdict != "scored":
# 服务侧问题,不是文档问题。重试或告警,不要走质量判断分支
raise RuntimeError(f"Scoring did not run: {verdict}")
if score < THRESHOLD:
# 真实的低置信,走重试/人工复核
...
边界:Jev 做不到的三件事
第一,Jev 只能在"已有的候选"里选。
它的输入里必须包含候选文本。如果所有解析器都漏了某个表格单元格,那个单元格根本不在候选集里,Jev 无从打分,也无从补上。
这是判别模型的结构性局限:它能判断"这个答得对不对",不能发现"这里还有一道题没答"。 漏检(false negative on existence)这一类错误,仍然要靠其他手段——比如行列数校验、小计对齐。
第二,阈值调高和人工复核量是一对跷跷板。
阈值高,自动通过率降、准确率高、人工量大;阈值低,反之。这两个指标必须一起看,只报其中一个没有意义。上线前应该在自己的样本上扫一遍阈值曲线,找到"自动通过率 × 通过样本准确率"的平衡点。
第三,它的判断标准来自训练数据分布。
Jev 的分数经过统计校准,但校准的前提是"你的文档和校准数据长得像"。如果你的场景是某个垂直行业的特殊票据(特殊的表格线、特殊的字段布局),校准可能失效。上线前必须用自己的样本跑一遍 AUROC,不能直接套别人的 93.6。
把五站串起来:流水线骨架
前面的机制讲完了,落成代码其实很短。下面是一个把五站串起来的最小骨架(伪代码级,参数名按官方文档核对后再用):
# ---------- 第 0 站:版面切块 ----------
def stage0_layout(pdf_or_image):
"""PDF 逐页栅格化 → 版面检测 → 切 crop + 读阅读顺序"""
pages = sdk.load(pdf_or_image) # PDF 自动栅格化
regions = []
for page in pages:
detected = layout_model.detect(page) # PP-DocLayoutV3
for r in detected.regions: # 已按阅读顺序排序
regions.append({
"crop": r.crop, # 图块
"type": r.region_type, # text / table / formula / seal / chart
"order": r.reading_order,
})
return regions
# ---------- 第 1 站:识别 ----------
REGION_PROMPT = {
"text": "Text Recognition:",
"table": "Table Recognition:",
"formula": "Formula Recognition:",
"seal": "Seal Recognition:",
"chart": "Chart to Table:",
}
def stage1_recognize(region, strategy=0):
"""区域类型由 prompt 决定,不是自动判断;strategy 决定预处理/解析路径"""
prompt = REGION_PROMPT[region["type"]]
return ocr_model.generate(region["crop"], prompt=prompt,
strategy=strategy) # 输出 Markdown/LaTeX/HTML
# ---------- 第 2~4 站:判分 + 重试决策 ----------
def stage234_with_retry(region, max_attempts=3):
for attempt in range(max_attempts):
candidate = stage1_recognize(region, strategy=attempt) # 每轮换策略
verdict = jev.score(
image=region["crop"],
candidate_text=candidate, # ② 候选文本
# ③ 由候选文本重新渲染出的图由服务内部或 SDK 生成
)
# 关键:先看 verdict,再看 score
if verdict.status != "scored":
raise RuntimeError(f"Scoring did not run: {verdict.status}")
if verdict.score >= ACCEPT_THRESHOLD:
return {"text": candidate, "score": verdict.score, "attempts": attempt + 1}
# 三次都不过线 → 标记出来,交人复核,不静默丢弃
return {"text": candidate, "score": verdict.score,
"attempts": max_attempts, "needs_human": True}
# ---------- 第 5 站:入库 ----------
def stage5_assemble(regions):
results = [stage234_with_retry(r) for r in regions]
human_queue = [r for r in results if r.get("needs_human")]
markdown = "\n\n".join(
r["text"] for r in results if not r.get("needs_human")
)
return markdown, human_queue
这段骨架里有四个点,每一个都是踩过坑才写得出来的:
| 位置 | 为什么这么写 |
|---|---|
REGION_PROMPT 显式映射 |
区域类型靠 prompt 指定,不指定就按正文处理,印章/公式会被当普通文字糟蹋 |
strategy=attempt |
重试不是"再跑一遍同样的",而是换策略。原图放大、加留白、换解析路径,都是不同的 strategy |
verdict.status != "scored" 先判断 |
这是那个陷阱。先看 verdict 再看 score,顺序错了会把服务故障当文档质量问题 |
needs_human 而不是丢弃 |
三次不过线不等于"这份文档不能用",是"这块要人看一眼"。静默丢弃是最坏的选项 |
建议加的监控指标(这几个不出现在任何文档里,但决定上线后能不能睡好觉):
| 指标 | 看什么 | 异常意味着 |
|---|---|---|
| 一次通过率 | 第 1 轮就过阈值的比例 | 掉下来说明文档形态变了,或模型/预处理被改动过 |
| 平均重试次数 | 1.0 是理想,>1.5 要查 | 识别侧的某种退化,可能是输入质量或 prompt 配置 |
verdict != scored 占比 |
应接近于 0 | 这是服务健康度指标,不是质量指标,>1% 要立刻查 |
| 人工队列占比 | 业务能承受的上限 | 决定阈值该怎么调,也决定 ROI 算不算得过来 |
最后一条最容易被忽略:人工队列占比是这条流水线唯一真正的业务指标。 前面所有分数、AUROC、吞吐,最终都是为了把这个比例压到一个业务能接受的水平。
结论:这次发布值得抄的不是分数,是那个拆分
第一,它把一个做不了的承诺,换成了一个能做的承诺。
"我们的 OCR 准确率 99%"这个承诺,从技术上就无法兑现——因为生成模型有语言先验,它自己都不知道那 1% 在哪。
而"我们能标出哪里可能认错"这个承诺是可以兑现的:它不要求识别模型变完美,只要求有一个独立的判别器能稳定地给出可信度。
从"永远不出错"到"错了我知道",这是设计目标的降级,但也是工程可行性的升级。 在真实业务里,后者比前者有用得多。
第二,读判分离的代价很小,收益很大。
代价:多一个 2B 模型的推理(毫秒级、一次前向、可跨机部署)。
收益:一个有统计校准的可信度分数,让流水线能自动决定"放行还是重打"。
用参数规模看这个交易有多划算:2B 的判别器拿到 93.6 的 AUROC,397B 的通用模型是 93.8。 为了这 0.2 分用 198 倍的参数,在任何成本模型里都不成立。
第三,真正的行业信号是"参数缩水曲线"。
过去一年,文档模型参数一路往下走:3B → 1.2B → 0.9B → 0.6B。"认字"正在从技术稀缺品变成算力日用品。
当人人都能认字的时候,稀缺的东西就变了。不是"谁认得准",而是"谁的结果敢直接进数据库"。
这也解释了为什么这次发布把「可信 OCR」单独立成一个品类——它不是营销概念,它是在回答一个参数缩水后必然出现的新问题: 当模型便宜到可以随手部署,你的瓶颈就不再是识别能力,而是你敢不敢让它的输出无人值守地进生产库。
- 点赞
- 收藏
- 关注作者
评论(0)