夜雨聆风学习资料网

ARTICLE · 1301972

小米把文档解析压到 0.8B:小模型的高分靠什么

小米把文档解析压到 0.8B:小模型的高分靠什么

先说一个不太常见的时间线。10 月 30 日到 31 日,中文科技媒体集中报道「小米开源 0.8B 文档理解模型 Xiaomi-OCR-0」,配的是「0.8B 吊打更大模型」的标题。

但模型的更新日志写的是:2026-09-30 技术报告发布、模型权重开源。arXiv 上的 2609.36136 首次提交是 9 月 28 日,11 月 2 日凌晨刚更新过一版;Hugging Face 仓库的创建时间是 9 月 29 日。也就是说,媒体报道比实际开源晚了整整一个月,这轮报道更像是一次二次传播。

这不是要挑媒体的错,而是提醒一件事:看到「今天开源」的新闻,先去模型卡看 changelog。差一个月意味着你本可以早一个月在自己的文档流水线上试它,也意味着现在网上流传的评测结论,有一部分是在没跑过仓库里那份复现脚本的情况下写的。

下面是我按模型卡、config.json、evaluation_pipeline/README_zh.md 和 postprocess/ 逐项核过的内容。

一、0.873B 是怎么装的

从 model.safetensors 的元数据看,权重是 873,438,784 个参数,BF16 存储,也就是 0.873B。官方叫它 0.8B,没问题。

config.json 里的结构值得单独看一眼:

• 架构 Qwen3_5ForConditionalGeneration,基座是 Qwen3.5-0.8B,所以它本质上是给一个通用小 VLM 做了 OCR 方向的继续预训练,不是从零训的专用模型。

• 文本塔 24 层,hidden 1024,FFN 3584,8 个注意力头、2 个 KV 头(GQA),head_dim 256。

• 层型是混的:18 层线性注意力 + 6 层全注意力,full_attention_interval: 4,也就是每 4 层插一层全注意力。这是 Qwen3.5 那一代的标配,也是它敢把上下文开到 max_position_embeddings: 262144 的原因——线性注意力层把长上下文的开销压下来了。

• 视觉塔 12 层、hidden 768,out_hidden_size 对齐到 1024;图像 token 从 image_token_id: 248056 进去。

这个参数预算决定了它能干什么、不能干什么:24 层、1B 以下的文本塔,做单页结构化输出够用,做跨页推理、长文档全局理解就不够了。所以官方给的用法也是「一页一页解析,再拼」,而不是「把 200 页 PDF 直接丢进去」。

训练侧的三个关键词,模型卡写得很省:

• 约 1.7 亿条 OCR 中心样本——注意是「OCR 中心」,不是通用图文对。这个量级对一个 0.87B 的模型来说是过训练的,好处是版式和字符的先验非常强,坏处是通用推理能力会被挤。

• Q-Mask 文本锚定:把文本区域作为锚点约束对齐,让模型知道「这一段输出对应图上的哪一块」。这是结构化输出(表格、公式)能对齐的前提。

• CPT + Mix-RL:继续预训练打底,再用混合任务强化学习收尾。模型卡没有公开 Mix-RL 的奖励设计细节,只有「混合任务」这四个字,这部分属于未披露,别把社区猜测当官方说法。

二、一个模型干三件事,输出格式是重点

官方把能力归成三类:

1. 文档解析:整页图片进,结构化 Markdown 出。表格输出 OTSL,再由后处理转成 HTML 表格;公式输出 LaTeX。

2. 关键信息抽取(KIE):给字段名,返回 JSON。发票、回单、合同这类场景用的就是这个。

3. OCR 视觉问答(VQA):问图上写了什么、某个字段是多少。

工程上真正重要的是第一类的输出格式。很多文档模型给你一段「看起来像 Markdown」的自由文本,表格是空格对齐的,你没法直接用。Xiaomi-OCR-0 选择输出 OTSL 再转换,等于把「表格结构」显式编码进了 token 流——这也是为什么它的 table TEDS 能到 92 分以上,而纯自由文本输出的模型在这一项上通常掉得很厉害。

对比一下传统 OCR 管线会更清楚省在哪。老方案至少要四段:版面检测(PP-DocLayout)→ 文本检测(DBNet 一类)→ 文本识别(CRNN / SVTR)→ 表格结构识别(专门的结构模型),四段各自有模型、各自有阈值、错误逐段累积,公式和手写还得再加模型。Xiaomi-OCR-0 把这些压成一个自回归模型:图进、Markdown 出,中间不需要你调四个阈值。代价是可控性变差——传统管线里你可以单独换识别模型、单独调检测阈值,而统一模型只能整体换;出了问题也难定位是哪一段坏了,只能靠 prompt 和区域模式缩小范围。

仓库里 postprocess/ 就是干这件事的:document.py 做整页数学公式归一化、重复尾串清理、OTSL 转 HTML;region.py 处理区域模式的标签过滤、公式编号合并、文本拼接和项目符号。MCP 服务和浏览器 Demo 用的都是同一套函数,所以你在 Demo 里看到的输出和评测脚本里的输出是一致的——这一点比很多「Demo 一套、评测一套」的项目靠谱。

三、榜单:第 2 名,第 1 名比它大一半

模型卡给的数字(文档解析三项,都是 Overall):

模型 参数量 OmniDocBench v1.6 ↑ Real5 ↑ Wild ↑
Xiaomi-OCR-0 0.8B 96.83 95.24 87.94
TeleOCR 1.2B 96.87 — 88.53
OvisOCR2 0.8B 96.58 92.29 87.91
PaddleOCR-VL-1.6 0.9B 96.33 93.19 87.36
MinerU2.5-Pro 1.2B 95.75 88.94 87.33
GLM-OCR 0.9B 95.22 90.32 85.08

几个能读出来的东西:

• OmniDocBench 上它不是第一,TeleOCR 用 1.2B 拿了 96.87,高 0.04 分。官方自己写的是「🥈 排名第 2」,这个表述是诚实的。

• Real5(真实拍摄文档)它确实是第一,95.24,比第二名 OvisOCR2 高 3 分。Real5 考的是扫描、弯曲、屏幕拍摄、光照、倾斜五种真实退化,这一项赢说明它的鲁棒性训练做得多——也符合「1.7 亿条 OCR 样本」的取向。

• Wild 上输给 TeleOCR 0.6 分,基本在同一档。

• 另外两项官方单独列了:UniMER-Test 公式识别 97.84,PubTabNet 表格识别 92.27,历史手写书法(隶、草、行、楷)73 分,对标 Seed 2.0 Pro 的 72 分。书法这一项 73 分不算高,但「和闭源大模型持平」这个结论对古籍数字化场景有实际意义。

• VQA 五项平均 83.2,比自家底座 Qwen3.5-0.8B 的 72.9 高 10 分,也超过了 Qwen3.5-2B 的 80.9。这个对比是它能证明「专业化训练有效」的最干净证据——同样的底座,只换了训练数据和方法。

四、这张分数表最该看的一列,官方放在了附录

这是我这篇文章最想说的部分。

模型卡顶部写的是「96.83 on OmniDocBench v1.6」,第 2 名。但模型卡脚注里有一句很关键的话:三个文档解析基准都是用两阶段(2stage)方法评测的。

两阶段是什么意思?不是「模型看一眼整页直接出」,而是:

1. 先用 **PP-DocLayoutV3(PaddleX 版)**做版面检测,阈值 0.25,把页面切成区域;

2. 再让 Xiaomi-OCR-0 逐区域识别;

3. 最后按阅读顺序把区域结果拼回整页 Markdown。

也就是说,榜单上的 96.83 分是「0.8B 模型 + 一个外挂版面检测器」的组合分,不是这个模型单打独斗的分。

仓库的 evaluation_pipeline/README_zh.md 把两种路线的复现结果都列出来了,这组对比才是真正的信息量所在:

基准 端到端(e2e) 两阶段(2stage) 差值
Real5-OmniDocBench Overall 93.45 95.24 +1.79
OmniDocBench v1.6 Overall 94.629 96.474 +1.85
Wild-OmniDocBench Overall 83.910 83.948 +0.04

再看细项,结论更有意思:

• OmniDocBench 上,两阶段把表格 TEDS 从 91.51 提到 94.40,公式 CDM 从 96.33 提到 98.34——版面切分对表格和公式的增益最大,这符合直觉:整页识别时模型要自己猜「这个表格从哪到哪」,切成区域后它只需要认。

• 但 Wild 上,两阶段把表格 TEDS 从 77.74 提到 85.48 的同时,公式 CDM 从 87.78 掉到 78.98,掉了近 9 分。原因也能猜到:Wild 是真实手机拍摄,公式常常跨区域、贴边、被裁,版面检测器一切就把公式切成两半,模型认不出来。

所以「两阶段更好」不是无条件成立的。版面规整的文档,两阶段明显赚;拍摄质量差、版式不规则的文档,两阶段的两次错误会叠加。仓库 README 里其实给了同样的建议:印刷、版式规整的文档用区域解析(可以并发处理区域),场景文字、手写笔记、书法、古籍和版式不规则的材料用整页解析,避免区域边界切错后丢上下文。

同一份配置,三个数字对不上

把模型卡和复现表并排放,会发现三处口径不一致:

基准 模型卡宣称 仓库复现(两阶段) 差值
Real5 95.24 95.24 0
OmniDocBench v1.6 96.83 96.474 -0.36
Wild 87.94 83.948 -3.99

Real5 完全对上,说明复现流程和榜单用的是同一套;但另外两项,尤其 Wild 差了将近 4 分,官方没有解释差在哪(可能是榜单提交时的配置、也可能是后处理版本)。我的处理方式是引用时用保守值:OmniDocBench 按 96.5 说,Wild 按 84 说,并注明「模型卡与复现脚本口径不一致」。

这类「官方文档自己打架」的情况在小团队的开源项目里很常见,不是造假,但会影响你判断该不该换模型。换之前一定在自己的一百页真文档上跑一遍,榜单上的 4 分差距,在你自己那批发票或年报上可能完全反过来。

五、OTSL 到 HTML:为什么必须自己写后处理

模型输出的表格是 OTSL,不是 Markdown。这套标记只有六种 token:

<fcel> 从这里开始一个带文字的单元格

<ecel> 从这里开始一个空单元格

<lcel> 延续左边的单元格 -> colspan

<ucel> 延续上面的单元格 -> rowspan

<xcel> 同时延续左边和上面 -> rowspan + colspan

<nl> 行结束

关键在于「延续」也是占位符:一行里有几个 token,就有几个可见列。这样模型不需要输出 rowspan="2" 这种属性,只需要输出位置关系,合并信息由占位符隐含。这个设计的代价是下游必须有一个正确的转换器——占位符算错一格,整张表就错位。

下面是我自己写的一个最小实现,纯标准库,能处理 colspan/rowspan/xcel:

import re

TAG = re.compile(r"<(fcel|ecel|lcel|ucel|xcel|nl)>")

def parse(otsl):

    tokens = TAG.split(otsl) # text, tag, text, tag, ...

    rows, row, i = [], [], 1

    while i < len(tokens):

        tag = tokens[i]

        text = tokens[i + 1].strip() if i + 1 < len(tokens) else ""

        i += 2

        if tag == "nl":

            if row:

                rows.append(row)

            row = []

        else:

            row.append((tag, text))

    if row:

        rows.append(row)

    return rows

def to_html(otsl):

    owner, span, text = {}, {}, {} # owner[(r,c)] -> 锚点坐标或 None

    for r, cells in enumerate(parse(otsl)):

        c = 0

        for tag, txt in cells:

            while (r, c) in owner: # 跳过已被合并占用的格子

                c += 1

            if tag in ("fcel", "ecel"):

                owner[(r, c)] = (r, c)

                span[(r, c)] = [1, 1]

                text[(r, c)] = txt

            elif tag == "lcel":

                cc = c - 1

                while cc >= 0 and owner.get((r, cc)) is None:

                    cc -= 1

                span[owner[(r, cc)]][0] += 1

                owner[(r, c)] = None

            elif tag == "ucel":

                rr = r - 1

                while rr >= 0 and owner.get((rr, c)) is None:

                    rr -= 1

                span[owner[(rr, c)]][1] += 1

                owner[(r, c)] = None

            elif tag == "xcel":

                rr, cc = r - 1, c - 1

                while rr >= 0 and cc >= 0 and owner.get((rr, cc)) is None:

                    rr, cc = rr - 1, cc - 1

                key = owner[(rr, cc)]

                span[key][0] += 1

                span[key][1] += 1

                owner[(r, c)] = None

            c += 1

    nrows = max(r for r, _ in owner) + 1

    ncols = max(c for _, c in owner) + 1

    out = ["<table>"]

    for r in range(nrows):

        out.append("<tr>")

        for c in range(ncols):

            if owner.get((r, c)) != (r, c):

                continue

            cs, rs = span[(r, c)]

            attr = ""

            if cs > 1:

                attr += f' colspan="{cs}"'

            if rs > 1:

                attr += f' rowspan="{rs}"'

            out.append(f"<td{attr}>{text[(r, c)]}</td>")

        out.append("</tr>")

    out.append("</table>")

    return "".join(out)

if __name__ == "__main__":

    sample = (

        "<fcel>分组<lcel><fcel>值<nl>"

        "<fcel>A<fcel>收入<fcel>420<nl>"

        "<ucel><fcel>成本<fcel>300<nl>"

        "<fcel>B<fcel>收入<fcel>510<nl>"

    )

    print(to_html(sample))

输出(本机实跑):

<table><tr><td colspan="2">分组</td><td>值</td></tr><tr><td rowspan="2">A</td><td>收入</td><td>420</td></tr><tr><td>成本</td><td>300</td></tr><tr><td>B</td><td>收入</td><td>510</td></tr></table>

分组 正确地跨了两列,A 正确地跨了两行,<ucel> 那一行没有多出一个空 <td>——这是最容易写错的地方,很多简易实现会在这里多吐一个空单元格,导致整张表右移一格。

这段代码我只处理了「向上找锚点不跨越已占用格」这一种情况,够覆盖常见报表;如果表格里有嵌套合并(xcel 连成一片),生产环境建议直接用仓库里的 postprocess/otsl.py,它还有转换失败时保留原始输出的兜底。

六、跑起来:三条命令

官方给的两条服务化路线都是 OpenAI 兼容接口,--context-length 16384 是必须的(整页图片的视觉 token 很容易把默认上下文打爆)。

# 路线一:SGLang

python -m sglang.launch_server --model-path SeerRay-Lab/Xiaomi-OCR-0 \

  --host 127.0.0.1 --port 8000 --context-length 16384

# 路线二:vLLM(需要支持 Qwen3_5ForConditionalGeneration 的版本)

vllm serve SeerRay-Lab/Xiaomi-OCR-0 \

  --host 127.0.0.1 --port 8000 --max-model-len 16384

起来之后就是一个标准 chat completions 端点:

import base64, json, urllib.request

img = base64.b64encode(open("invoice.jpg", "rb").read()).decode()

payload = {

    "model": "SeerRay-Lab/Xiaomi-OCR-0",

    "messages": [{

        "role": "user",

        "content": [

            {"type": "image_url",

             "image_url": {"url": f"data:image/jpeg;base64,{img}"}},

            {"type": "text",

             "text": "把这张单据解析成 Markdown,表格用 OTSL 输出"},

        ],

    }],

    "max_tokens": 8192,

    "temperature": 0.0,

}

req = urllib.request.Request(

    "http://127.0.0.1:8000/v1/chat/completions",

    data=json.dumps(payload).encode(),

    headers={"Content-Type": "application/json"},

)

print(json.load(urllib.request.urlopen(req))["choices"][0]["message"]["content"])

想先不装依赖试一下,社区做了 GGUF 的 BF16 包,ollama pull longwayxu/xiaomi-ocr-0:bf16,大约 1.8 GB。但模型卡有一句提醒很重要:只拉模型不会自动配上对齐检查用的图像预处理、非思考提示词和解码设置,OCR 场景下这些细节对结果影响很大,要用它的 MCP 服务或 Demo 才会自动套上。这条限制意味着「随手 ollama run 一下」得到的分数会很差,别据此下结论。

七、四个落地坑

坑一:两阶段不是免费的午餐。 多一次版面检测就多一份延迟和一份错误来源,而且 Wild 上公式分掉 9 分就是证据。做法是「按文档类型分流」:规整 PDF 走区域并发,拍照件走整页。

坑二:阈值要独立跑,不能离线过滤。 复现文档里专门写了一条:PaddleX 的原生后处理导致「高阈值的结果不一定是低阈值结果按 score 过滤后的子集」,所以比较 0.25 和 0.3 必须各跑一遍,不能拿一次输出筛。这是版面检测器后处理非单调的典型问题,很多人在这里得出错误结论。

坑三:OTSL 转换失败要保留原始输出。 表格越复杂,占位符越容易越界(比如 ucel 找不到上方锚点)。仓库的做法是转换失败就保留 OTSL 原文,让下游人工或另一个模型兜底——比直接丢掉整张表好。

坑四:别指望它做跨页推理。 24 层、0.87B 的文本塔,单页结构化是它的甜点区;「把 50 页年报的营收趋势总结出来」这种事,它只能给你 50 页准确的 Markdown,推理还是交给大模型。正确的用法是它做前端 OCR/版面,大模型做后端理解,这样 token 成本比把整页图片喂给大模型低一个数量级。

总结

• Xiaomi-OCR-0 的实际权重是 0.873B BF16,基座 Qwen3.5-0.8B,24 层(18 线性注意力 + 6 全注意力)、hidden 1024、上下文 262144,视觉塔 12 层。许可证 Apache 2.0。

• 权重 2026-09-30 就开源了,技术报告 9 月 28 日上 arXiv(11 月 2 日更新);媒体 10 月 30 日才集中报道,是二次传播。

• 官方成绩:Real5 95.24(第 1)、OmniDocBench v1.6 96.83(第 2,第一是 1.2B 的 TeleOCR 96.87)、Wild 87.94(第 2)、公式 UniMER 97.84、表格 PubTabNet 92.27、OCR-VQA 五项平均 83.2(底座 Qwen3.5-0.8B 是 72.9)。

• 最重要的一个事实:榜单分数是「模型 + PP-DocLayoutV3 两阶段」的组合分。同一仓库的复现表显示,端到端模式 OmniDocBench 只有 94.63,Wild 上两阶段甚至让公式 CDM 掉了 9 分。

• 模型卡与仓库复现表在 OmniDocBench(96.83 vs 96.47)和 Wild(87.94 vs 83.95)上对不上,Real5 则完全一致。引用时取保守值,换模型前务必用自己的文档跑一遍。

• 表格输出是 OTSL,必须自己写转换器;文中七十来行的实现可以直接用,坑在 ucel 的锚点查找和多吐空单元格。

参考与致谢:

• xiaomi-research/Xiaomi-OCR-0 模型卡(Apache 2.0)与 SeerRay-Lab/Xiaomi-OCR-0 仓库 README_zh.md、evaluation_pipeline/README_zh.md、postprocess/README.md

• Xin Chen 等,《Xiaomi-OCR-0 Technical Report》,arXiv:2609.36136(2026-09-28 首次提交,2026-11-02 更新)

• 参数量、层型、上下文长度来自 config.json 与 safetensors 元数据实测;OTSL 转换器为本机实跑代码

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料