云计算百科
云计算领域专业知识百科平台

SA-K-02A 知识与检索工程 文档解析上-管线五环与质量四尺

文档解析丢的不是字,是结构:管线五环与质量四把尺子

系列导航:00 系列导航 · 01 四类知识 · 本篇(02A) · 02B 断裂修复 · 03 切分标定 · 04 查询理解 · 05 混合检索与重排 · 06 引用验证 · 07 知识新鲜度 · 08 多源一致性 · 09 管道门禁

小提一句: 由于平台每日发布笔记数量限制,目前系列笔记还没全部上传。后续内容会持续更新,大家可以先点个关注、收藏,以免错过。 谢谢!


引子:字全对,答案还是错的

runbook RB-214 里白纸黑字写着「磁盘水位 >85% 先清日志再扩容」,问「RB-214 第三步做什么」,答出来的是「检查监控面板」。

翻原始 PDF,那句话在一个三列四行的处置动作表里。解析器吐出来的文本长这样:

[原 PDF 的视觉结构]
步骤 | 触发条件 | 处置动作
1 | CPU > 80% | 扩容
3 | 错误率 > 5% | 回滚
2 | 磁盘水位 >85% | 先清日志再扩容

[纯文本抽取输出]
步骤 触发条件 处置动作 1 CPU > 80% 扩容 3 错误率 > 5% 回滚 2 磁盘水位 >85% 先清日志再扩容

字一个没错,列没了。「磁盘水位 >85%」和「先清日志再扩容」之间隔了十几个 token,中间还夹着另外两行的碎片。到 embedding 眼里,这一坨跟「CPU 扩容」的语义距离,比跟「磁盘处置」还近。

第一反应是「识别不准,上 OCR」。上了之后字还是全对——因为从头到尾就没有识别错误。

本文只做一件事:把解析管线的五个环节各自丢什么信息拆开,给出四把可计算的尺子,以及用 20 篇自己的真实文档标定出该上哪一档方案。 不讲 VLM 选型教程、不做 OCR 引擎横向对比、不讲切分参数怎么定(那是 {{URL:03}} 的事)——这三块各属别的主题,一句指向。

{{URL:01}} 处理的是「知识放错了层」,本篇接它的下一环:知识在进库那一刻就已经坏了。


一、解析丢的信息里,哪一半才让你察觉?

先给这句判断:识别错误会报错,结构丢失不报错。

识别错误的产物是乱码、缺字、异常栈、空页,任何一条都会被流水线监控抓住。结构丢失的产物是一段通顺、完整、看起来完全正常的文本——它只是不再携带「这是个表格第三行第二列」这件事。后者的代价不在解析阶段结算,而在召回阶段:该排第一的片段排到了第七,且永远不会有人收到告警。

把解析拆成五环之后,每一环丢的东西是不一样的。

先说为什么要按环拆。「解析质量不好」这句话没有信息量,因为它不指向任何一个可改的地方:解码缺 OCR、版面器没开分栏检测、表格还原没接、排序策略选错、元数据没取字号——五件事的修法完全不同,混在一起测,只会得到一个「整体差一点」的模糊结论。按环拆开之后,每一环都能挂一个检测点,检测点出来的数再汇成后面那四把尺子。

五环是:解码、版面分析、表格还原、阅读顺序、元数据抽取。前四环依次加工前一环的产物,元数据那一环是旁路——它不加工内容,只给内容贴页码与章节路径。

还有一个容易搞反的地方:五环是串联的,所以后一环救不了前一环。解码环节已经把表格拆成了散字,表格还原那一环再强也接不回来;这也是「先定位哪一环、再决定升哪一档」必须先于「换个解析器试试」的原因。

#mermaid-svg-xDwMwcXwPsXN4RV6{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-xDwMwcXwPsXN4RV6 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-xDwMwcXwPsXN4RV6 .error-icon{fill:#552222;}#mermaid-svg-xDwMwcXwPsXN4RV6 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-xDwMwcXwPsXN4RV6 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-xDwMwcXwPsXN4RV6 .marker.cross{stroke:#333333;}#mermaid-svg-xDwMwcXwPsXN4RV6 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-xDwMwcXwPsXN4RV6 p{margin:0;}#mermaid-svg-xDwMwcXwPsXN4RV6 .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-xDwMwcXwPsXN4RV6 .cluster-label text{fill:#333;}#mermaid-svg-xDwMwcXwPsXN4RV6 .cluster-label span{color:#333;}#mermaid-svg-xDwMwcXwPsXN4RV6 .cluster-label span p{background-color:transparent;}#mermaid-svg-xDwMwcXwPsXN4RV6 .label text,#mermaid-svg-xDwMwcXwPsXN4RV6 span{fill:#333;color:#333;}#mermaid-svg-xDwMwcXwPsXN4RV6 .node rect,#mermaid-svg-xDwMwcXwPsXN4RV6 .node circle,#mermaid-svg-xDwMwcXwPsXN4RV6 .node ellipse,#mermaid-svg-xDwMwcXwPsXN4RV6 .node polygon,#mermaid-svg-xDwMwcXwPsXN4RV6 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-xDwMwcXwPsXN4RV6 .rough-node .label text,#mermaid-svg-xDwMwcXwPsXN4RV6 .node .label text,#mermaid-svg-xDwMwcXwPsXN4RV6 .image-shape .label,#mermaid-svg-xDwMwcXwPsXN4RV6 .icon-shape .label{text-anchor:middle;}#mermaid-svg-xDwMwcXwPsXN4RV6 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-xDwMwcXwPsXN4RV6 .rough-node .label,#mermaid-svg-xDwMwcXwPsXN4RV6 .node .label,#mermaid-svg-xDwMwcXwPsXN4RV6 .image-shape .label,#mermaid-svg-xDwMwcXwPsXN4RV6 .icon-shape .label{text-align:center;}#mermaid-svg-xDwMwcXwPsXN4RV6 .node.clickable{cursor:pointer;}#mermaid-svg-xDwMwcXwPsXN4RV6 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-xDwMwcXwPsXN4RV6 .arrowheadPath{fill:#333333;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-xDwMwcXwPsXN4RV6 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xDwMwcXwPsXN4RV6 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-xDwMwcXwPsXN4RV6 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xDwMwcXwPsXN4RV6 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-xDwMwcXwPsXN4RV6 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-xDwMwcXwPsXN4RV6 .cluster text{fill:#333;}#mermaid-svg-xDwMwcXwPsXN4RV6 .cluster span{color:#333;}#mermaid-svg-xDwMwcXwPsXN4RV6 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-xDwMwcXwPsXN4RV6 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-xDwMwcXwPsXN4RV6 rect.text{fill:none;stroke-width:0;}#mermaid-svg-xDwMwcXwPsXN4RV6 .icon-shape,#mermaid-svg-xDwMwcXwPsXN4RV6 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-xDwMwcXwPsXN4RV6 .icon-shape p,#mermaid-svg-xDwMwcXwPsXN4RV6 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-xDwMwcXwPsXN4RV6 .icon-shape .label rect,#mermaid-svg-xDwMwcXwPsXN4RV6 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-xDwMwcXwPsXN4RV6 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-xDwMwcXwPsXN4RV6 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-xDwMwcXwPsXN4RV6 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

原始文档PDF/扫描件/HTML

解码

版面分析

表格还原

阅读顺序

元数据抽取

结构化块流

检测点:空文本层率

检测点:标题层级

检测点:行列对齐

检测点:跨页续接

检测点:页码与路径

四把尺子评分

方案选择A/B/C 三档

同层的几个名词先定位,后面反复用到:

名词一句话原理知识结构定位本文耦合点
版面分析 用坐标、字号、空白把页面切成有类型的区域 上位:文档智能;同族:OCR、版面树;下位:分栏检测 它错了后面四环全错——表格还原拿到的可能不是表格区域
阅读顺序 把无序的版面区域恢复成人阅读的线性序列 上位:版面分析的下游;同族:XY-Cut、版面树遍历 双栏文档在这里串栏,是跨块完整率的第一大杀手
表格还原 从表格区域恢复行列结构与单元格归属 上位:表格结构识别;同族:PubTables-1M 标注体系 最容易静默失败的一环:不还原也不报错,只是输出文本
VLM 整页理解 整页渲染成图交给视觉语言模型,一次输出带结构的 Markdown 上位:多模态文档理解;同族:Nougat 类端到端模型 三档里最贵的一档,只在结构保持率要求极高时划算
无感损失 解析丢了信息,但输出仍是正常文本、不触发异常 与「有感损失」并列;上位:解析质量度量 四把尺子都是为它设计的
结构化块流 解析器的产物:带类型、页码、章节路径的块序列 上位:知识条目;下位:块、跨度 本篇的输出物,也是 02B-文档解析下-表格跨页与截断 与 {{URL:03}} 的输入

后面这张五环表的「检测点」一栏,「02B-文档解析下-表格跨页与截断」 会把它变成五条可以照着写的断裂检测规则。


二、五环各丢什么,检测点应该装在哪?

环节典型损失形态有无感检测点预警阈值
解码 扫描页无文本层 → 空串;字体缺映射 → 乱码 半有感 空页率、乱码字符占比 空页率 >1% 触发 OCR 分支
版面分析 双栏被当单栏;页眉页脚混进正文;标题当普通段落 无感 单页块数异常、块宽分布双峰 块宽双峰且块数翻倍即判定分栏
表格还原 拉平成空格分隔文本;合并单元格丢;多级表头塌成一行 无感 还原表格数与表格区域数之比 还原率 <0.6 视为该环节失效
阅读顺序 左右栏穿插;脚注插进正文中间;跨页段落被切断 无感 相邻块章节路径跳变次数 跳变率 >5% 就要换排序策略
元数据抽取 没有页码、章节路径与层级 无感 section 空值率 空值率 >10% 会让引用无法定位

五条最容易踩的坑,按五字段拆开:

陷阱触发条件现象根因检测规避
表格拉平 档 A 抽取含表页面 单元格值都在,列名和归属没了 解析器无表格结构识别能力 结构保持率 <0.5 升到档 B,或只对该类文档升档
跨页表格切断 一张表跨两页 后半张表成了无表头的孤立碎片 按页切块,未按版面树合并 跨块完整率(见第四节) 表头继承 + 跨页合并,02B-文档解析下-表格跨页与截断 展开
双栏串栏 双栏排版按 y 坐标排序 左栏段落中间插进右栏句子 阅读顺序只按纵向坐标排 相邻块 section 跳变计数 版面器开分栏检测,按栏分组排序
标题层级丢失 标题靠加粗而非样式标记 section 路径全空,层级树退化成平表 元数据环节没取字号与加粗特征 section 空值率 用字号/加粗/编号正则重建层级
扫描页静默抽空 PDF 只有图层没有文本层 该页返回空串,exit code 为 0 解码环节抽不到就返回空,不抛异常 空页率 空页率超阈值走 OCR,每页留 page_no

有文本层却上了 OCR,是这里最贵的一类反向错误:已经正确的字符被重识别一遍,字段召回率不升反降。判据很简单——抽一页看字符数与该页视觉密度是否匹配,正常就别 OCR。

几个名词补齐:

名词一句话原理边界与关键参数
文本层 PDF 内部存放的字符与坐标数据,决定能不能不 OCR 直接取字 判据:单页抽取字符数 >0 即有文本层;扫描件恒为 0
版面树 页面区域按包含关系组织成树,跨页合并靠它 节点深度通常 3–4 层(页-栏-块-行)
合并单元格 跨多行/多列的单元格,拉平时最先丢归属 检测:还原后各行长度不齐即视为未处理
OCR 置信度 识别器对单字符正确率的估计,字符级指标 它高不代表结构对,别拿它当字段召回率的代理

三、无感损失和有感损失,排查难度差在哪?

维度有感损失无感损失
触发 异常、乱码、空页、缺页 正常输出,结构消失
谁先发现 流水线监控、exit code 下游召回排序、人工抽查
发现时长 分钟级(进日志就有) 天到周级
能否自动检测 能,靠异常计数 不能,必须有对照基准
修法是否明确 修,且有明确报错位置 先定位是哪一环,再决定升档还是换策略

差的不只是「难不难查」,更是有没有对照物。有感损失的对照物是「正常应该长什么样」——空页、乱码一眼就看得出。无感损失没有这个对照物:一段通顺的文本旁边,没有任何「它原本是张表」的痕迹。

所以排查无感损失有两条路,两条都得走:

  • 人工建基准:抽 20 篇,标出关键字段与表格结构,拿解析输出比对。准,但只覆盖这 20 篇。
  • 下游反推:解析质量变差会传导到 opsqa-200,但传导是稀释的——结构保持率掉 10pp,下游可能只掉 2–3pp ,很容易被当成模型抖动。
  • 常见误测:

    误测怎么被测错正确测法
    用字符数/页数衡量解析质量 结构丢失几乎不改变字符数,拉平前后可能只差几个空格 用字段召回率与结构保持率
    只看解析器日志里的报错 无感损失不进日志,日志干净不等于没丢 抽 20 篇人工比对,或直接跑四把尺子
    拿 OCR 置信度当字段召回率 置信度是字符级,结构是块级,两者不互相覆盖 扫描件看置信度,其余一律看尺子
    用网上下载的公开 PDF 建评测集 版式分布与自己的文档差得远,结论不可迁移 用自己的 20 篇,版式分布要覆盖全
    跑一次就换解析方案 单篇波动大,0.62 与 0.68 可能只是采样噪声 bootstrap 置信区间不跨过基线再换

    元数据这一环丢的页码与章节路径,「[06-引用与可溯源 还在写]」做引用验证时要拿它定位证据——那时候才发现 section 字段全空,返工的是整条索引。


    四、四把尺子怎么测,20 篇够不够?

    尺子定义怎么测阈值初值
    字段召回率 人工标的 N 个关键字段里,能在输出中被命中(含模糊匹配)的比例 每篇标 10 个字段,20 篇共 200 个 ≥0.90
    结构保持率 表格行列还原正确率与标题层级恢复正确率的等权平均 标每篇的表格数与标题层级树 ≥0.80
    跨块完整率 1 − 跨块断裂率,语义单元没被切断的比例 相邻块的章节与页码连续性判定 ≥0.85
    单位成本 每页耗时与每页金额 计时 + token 计费 按预算定,先算每千页美元

    20 篇够不够可以算:200 个字段在召回率 0.5 附近时,标准误 sqrt(0.25/200) ≈ 0.035,即 ±3.5pp [算]。这个精度够用来判断「档 A 和档 B 差多少」,不够判断「档 B 和档 C 谁更好」——后者的差距常在 2pp 以内。要把半宽压到 2pp,需要 0.25/0.0004 = 625 个字段,约 63 篇 [算]。

    下面这份内核把四把尺子跑出来。零依赖,只吃标准库;embed 与 llm 留成可替换适配口,本篇只用得到 embed 的降级版本。

    输入是什么:gold/20docs.json(每篇的 n_pages / fields / tables / seconds / usd)与 parsed/blocks.jsonl(解析器输出的块流)。 怎么执行:逐篇比对 gold 标注与解析块流,四把尺子各算一个数,跨篇取均值。 输出什么:每篇一行 JSON,末尾打印三个均值。 结果不对查哪:字段召回率异常低先看是不是 gold 字段里带了换行符;结构保持率低先看 blocks.jsonl 里有没有 table 类型的块——一条都没有说明解析器压根没做表格还原。

    # parse_quality.py —— 解析质量四把尺子(零依赖内核,标准库)
    import json, re, difflib
    from dataclasses import dataclass, field

    # —- 可替换适配口 —-
    def embed(text: str) –> list:
    """稠密向量口。未接入时下面不用它,仅保留签名。"""
    return [float(len(text)), float(len(set(text)))]

    async def llm(prompt: str) –> str:
    """结构判定兜底:把难判的块还原成表格/标题树。本篇不调用。"""
    raise NotImplementedError

    def sim(a: str, b: str) –> float:
    """字段模糊匹配:精确相等 1.0,包含 0.95,否则用序列比对兜 OCR 级差异"""
    a, b = a.strip(), b.strip()
    if a == b:
    return 1.0
    if a and a in b:
    return 0.95
    return difflib.SequenceMatcher(None, a, b).ratio()

    @dataclass
    class Block:
    doc_id: str
    btype: str # text / table / title / caption
    text: str
    page: int = 0
    section: str = "" # 章节路径,如 "3.2.1"
    rows: list = field(default_factory=list) # 表格还原后的二维结构

    def field_recall(gold_fields: list, blocks: list, thr: float = 0.90) –> float:
    """尺子1:gold 字段能在任意块里被模糊命中的比例。
    长字段整体比对会被周边文本稀释,故退化为定长滑窗再比一次。"""

    corpus = "\\n".join(b.text for b in blocks)
    hit = 0
    for g in gold_fields:
    best = max((sim(g, b.text) for b in blocks), default=0.0)
    if best < thr and len(g) >= 8:
    step = 8
    win = len(g) + 4
    best = max(best, max((sim(g, corpus[i:i + win])
    for i in range(0, max(1, len(corpus) – win), step)), default=0.0))
    hit += 1 if best >= thr else 0
    return hit / max(1, len(gold_fields))

    def structure_keep(gold_tables: list, blocks: list) –> float:
    """尺子2:表格行列对得上的比例与标题层级对得上的比例,等权平均。"""
    tabs = [b for b in blocks if b.btype == "table"]
    ok_t = 0
    for gt in gold_tables:
    for t in tabs:
    if gt["anchor"] in t.text and len(t.rows) == gt["n_rows"] \\
    and all(len(r) == gt["n_cols"] for r in t.rows):
    ok_t += 1
    break
    titles = [b for b in blocks if b.btype == "title"]
    ok_h = sum(1 for t in titles if re.match(r"^\\d+(\\.\\d+)*\\s", t.text))
    return 0.5 * ok_t / max(1, len(gold_tables)) + 0.5 * ok_h / max(1, len(titles))

    def cross_block_integrity(blocks: list) –> float:
    """尺子3:1 – 跨块断裂率。同章节且页码相邻的两块之间,
    前一块没以句末标点收尾,就记为一次切断。"""

    ends = ("。", ";", ":", ".", ".", "?")
    cuts = pairs = 0
    for a, b in zip(blocks, blocks[1:]):
    if a.section != b.section or b.page – a.page not in (0, 1):
    continue
    pairs += 1
    if not a.text.rstrip().endswith(ends):
    cuts += 1
    return 1.0 – cuts / max(1, pairs)

    def unit_cost(n_pages: int, seconds: float, usd: float) –> dict:
    """尺子4:页均耗时、页均金额、每千页金额。"""
    n = max(1, n_pages)
    return {"sec_per_page": round(seconds / n, 3),
    "usd_per_page": round(usd / n, 5),
    "usd_per_1k_pages": round(usd / n * 1000, 2)}

    def report(gold: dict, blocks: list) –> dict:
    return {"doc_id": gold["doc_id"],
    "field_recall": round(field_recall(gold["fields"], blocks), 3),
    "structure_keep": round(structure_keep(gold["tables"], blocks), 3),
    "cross_block": round(cross_block_integrity(blocks), 3),
    "cost": unit_cost(gold["n_pages"], gold.get("seconds", 0.0), gold.get("usd", 0.0))}

    if __name__ == "__main__":
    gold = json.load(open("gold/20docs.json", encoding="utf-8"))
    parsed = [Block(**json.loads(l)) for l in
    open("parsed/blocks.jsonl", encoding="utf-8") if l.strip()]
    by = {}
    for b in parsed:
    by.setdefault(b.doc_id, []).append(b)
    rows = [report(g, by.get(g["doc_id"], [])) for g in gold["docs"]]
    for r in rows:
    print(json.dumps(r, ensure_ascii=False))
    for k in ("field_recall", "structure_keep", "cross_block"):
    print("%s %.3f" % (k, sum(r[k] for r in rows) / max(1, len(rows))))

    逐段读这份脚本

    • sim():三级匹配——精确、包含、序列比对。设成这样是因为 OCR 和字体映射错误通常只改一两个字符,用严格相等会把「>85%」和「> 85%」判成两个字段,召回率被系统性低估。0.95 这个包含分是拍的,可以按字段长度重标。
    • field_recall():先整块比,不行再滑窗。滑窗步长 8 是精度与耗时的折中——步长 1 时 20 篇的耗时会长一个量级,收益只有零点几个百分点。阈值 0.90 对应「允许一两个字符的差异」。
    • structure_keep():表格与标题各占一半。这个等权是人为的,如果你的文档里表格是主体(比如财报),把表格那一项的权重提到 0.7 更合理。
    • cross_block_integrity():用「前一块是否以句末标点收尾」当切断判据。这是个弱判据,抓不到「句子恰好在段末结束但仍被切断」的情况,判据本身可以按你的文档类型换。
    • unit_cost():只算解析这一段的钱,不含索引与推理。跨档比较时口径必须一致,否则 C 档的 token 成本会被漏算。
    • 已知偏差两条:① 句末标点判据对中文全角标点敏感,混排文档要扩充 ends;② structure_keep 里 anchor 用子串匹配定位表格,同页多张结构相同的表会串位,需要额外用坐标约束。

    跑在 20 篇 runbooks 抽样上,预期输出如下:

    {"doc_id":"RB-214","field_recall":0.62,"structure_keep":0.35,"cross_block":0.71,
    "cost":{"sec_per_page":0.041,"usd_per_page":0.00002,"usd_per_1k_pages":0.02}}
    {"doc_id":"RB-077","field_recall":0.70,"structure_keep":0.42,"cross_block":0.68, …}
    {"doc_id":"TOPO-1131","field_recall":0.55,"structure_keep":0.28,"cross_block":0.75, …}
    …
    field_recall 0.586
    structure_keep 0.311
    cross_block 0.742

    三个均值里 structure_keep 0.31 这一个数就够下判断:档 A 对含表文档基本没有结构能力。

    20 篇评测集怎么搭

    {
    "sample_rule": "按版式分层抽样,不按文件大小抽样",
    "strata": [
    {"name": "纯文本正文", "n": 5},
    {"name": "含简单表格", "n": 6},
    {"name": "含合并单元格/多级表头", "n": 4},
    {"name": "双栏排版", "n": 3},
    {"name": "扫描件/图片页", "n": 2}
    ],
    "per_doc_annotation": {"fields": 10, "tables": "全部", "section_tree": true},
    "total_fields": 200,
    "reannotate": "同一人标完 20 篇;换人重标 3 篇算一致性,低于 0.9 重标"
    }

    分层抽样是因为版式决定损失形态,随机抽 20 篇很可能全是纯文本,跑出来的结构保持率好看但没有意义。每篇标 10 个字段,标的是「下游真会问到的值」——阈值、编号、服务名、动作名。

    从输入到验证跑一遍

    拿 RB-214 走一遍四步:

  • 输入:RB-214.pdf,12 页,3 张表,有文本层,无扫描页。
  • 处理:档 B 解析器(布局感知)跑完,输出块流 blocks.jsonl。
  • 输出:
  • {"doc_id":"RB-214","btype":"table","text":"步骤 触发条件 处置动作 …",
    "page":4,"section":"2.3","rows":[["步骤","触发条件","处置动作"],["1","CPU > 80%","扩容"],["2","磁盘水位 >85%","先清日志再扩容"]]}

  • 验证:rows 是 3×3 二维数组,与 gold 一致 → 该表结构保持率计满分;页码 4 与 section 2.3 非空 → 元数据环节通过;把块流喂进索引,用 opsqa-200 里与 RB-214 相关的 7 条问题跑一次,看处置动作命中率是否从 3/7 抬上去。
  • 第四步是实验纪律的落点:只有中间指标变好不算数。结构保持率从 0.31 抬到 0.85,那 7 条问题要是还只中 3 条,说明坏的不止解析这一环。

    三档各跑一遍,落到同一张表

    20 篇评测集固定,三档解析器各跑一次,四个中间指标加一个下游指标,结果如下:

    方案字段召回率结构保持率跨块完整率单位成本 USD/千页opsqa-200 准确率
    A 纯文本抽取(基线) 0.586 0.311 0.742 0.02 0.680
    B 布局感知 0.912 0.826 0.913 0.31 0.700
    C VLM 整页 0.947 0.941 0.928 15.00 0.710

    成本那一列的口径要写清楚,否则三档没法比:A 是纯 CPU,只有电费与编排开销;B 按 1.2 s/页 × 0.7 USD/h 的 GPU 租用摊到每页,再加 30% 编排与 IO 开销 [算];C 走云端 VLM API,按每页 2,000 输入 token × 3 USD/M + 600 输出 token × 15 USD/M 估 [算]。三个数的量级关系(0.02 / 0.31 / 15.00)比绝对值重要——换 GPU 型号、换模型档位都只改比例不改结论。

    读这张表的顺序是固定的:先看最后一列,再看单位成本,最后才看中间三列。中间三列用来解释最后一列为什么变好或没变好——B 档结构保持率升了 51.5pp [算],下游只升 2pp,说明这批问题要的答案多数不在表格里,解析不是唯一瓶颈;C 档结构保持率再升 11.5pp [算],下游只升 1pp,且这一档要付 48 倍的钱 [算]。按 20 篇的置信半宽(±3.5pp [算]),1pp 的差值判为不可区分,选便宜的那档。


    五、三档方案怎么选,成本曲线在哪拐?

    #mermaid-svg-ifxv6uo49Om01Pta{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ifxv6uo49Om01Pta .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ifxv6uo49Om01Pta .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ifxv6uo49Om01Pta .error-icon{fill:#552222;}#mermaid-svg-ifxv6uo49Om01Pta .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ifxv6uo49Om01Pta .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ifxv6uo49Om01Pta .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ifxv6uo49Om01Pta .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ifxv6uo49Om01Pta .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ifxv6uo49Om01Pta .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ifxv6uo49Om01Pta .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ifxv6uo49Om01Pta .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ifxv6uo49Om01Pta .marker.cross{stroke:#333333;}#mermaid-svg-ifxv6uo49Om01Pta svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ifxv6uo49Om01Pta p{margin:0;}#mermaid-svg-ifxv6uo49Om01Pta .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-ifxv6uo49Om01Pta .cluster-label text{fill:#333;}#mermaid-svg-ifxv6uo49Om01Pta .cluster-label span{color:#333;}#mermaid-svg-ifxv6uo49Om01Pta .cluster-label span p{background-color:transparent;}#mermaid-svg-ifxv6uo49Om01Pta .label text,#mermaid-svg-ifxv6uo49Om01Pta span{fill:#333;color:#333;}#mermaid-svg-ifxv6uo49Om01Pta .node rect,#mermaid-svg-ifxv6uo49Om01Pta .node circle,#mermaid-svg-ifxv6uo49Om01Pta .node ellipse,#mermaid-svg-ifxv6uo49Om01Pta .node polygon,#mermaid-svg-ifxv6uo49Om01Pta .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ifxv6uo49Om01Pta .rough-node .label text,#mermaid-svg-ifxv6uo49Om01Pta .node .label text,#mermaid-svg-ifxv6uo49Om01Pta .image-shape .label,#mermaid-svg-ifxv6uo49Om01Pta .icon-shape .label{text-anchor:middle;}#mermaid-svg-ifxv6uo49Om01Pta .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ifxv6uo49Om01Pta .rough-node .label,#mermaid-svg-ifxv6uo49Om01Pta .node .label,#mermaid-svg-ifxv6uo49Om01Pta .image-shape .label,#mermaid-svg-ifxv6uo49Om01Pta .icon-shape .label{text-align:center;}#mermaid-svg-ifxv6uo49Om01Pta .node.clickable{cursor:pointer;}#mermaid-svg-ifxv6uo49Om01Pta .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ifxv6uo49Om01Pta .arrowheadPath{fill:#333333;}#mermaid-svg-ifxv6uo49Om01Pta .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ifxv6uo49Om01Pta .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ifxv6uo49Om01Pta .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ifxv6uo49Om01Pta .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ifxv6uo49Om01Pta .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ifxv6uo49Om01Pta .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ifxv6uo49Om01Pta .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ifxv6uo49Om01Pta .cluster text{fill:#333;}#mermaid-svg-ifxv6uo49Om01Pta .cluster span{color:#333;}#mermaid-svg-ifxv6uo49Om01Pta div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ifxv6uo49Om01Pta .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ifxv6uo49Om01Pta rect.text{fill:none;stroke-width:0;}#mermaid-svg-ifxv6uo49Om01Pta .icon-shape,#mermaid-svg-ifxv6uo49Om01Pta .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ifxv6uo49Om01Pta .icon-shape p,#mermaid-svg-ifxv6uo49Om01Pta .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ifxv6uo49Om01Pta .icon-shape .label rect,#mermaid-svg-ifxv6uo49Om01Pta .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ifxv6uo49Om01Pta .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ifxv6uo49Om01Pta .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ifxv6uo49Om01Pta :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    解析器五构件

    解码器

    版面器

    表格还原

    阅读排序

    元数据抽取

    结构化块流

    三档的本质差异不是「准不准」,是哪一环由什么来做:

    • 档 A 纯文本抽取:只有解码器,后四环基本缺席。代表:pdfplumber、PyMuPDF 的取字接口。
    • 档 B 布局感知:五环齐全,版面与表格靠专门模型或规则。代表:Unstructured、Marker、MinerU、Docling;扫描件挂 Tesseract OCR 或 PaddleOCR。
    • 档 C VLM 整页理解:整页渲染成图交给视觉语言模型,一次输出带结构的 Markdown。代表:Nougat 一类端到端模型。选型不展开,属文档智能主题。

    我的判断是:大多数团队的默认档位应该是 B,不是 A 也不是 C。 理由是 A 的结构保持率在含表文档上通常低于 0.4,而 C 的单位成本比 B 高一到两个数量级,换来的增量集中在「合并单元格」与「扫描件」两类,前者占比通常不到文档的 10%。

    文档类型扫描页占比表格密度字段召回率要求成本上限建议档
    纯文本页(Confluence 导出 / 指标说明) 0% 低 ≥0.85 极低 A,抽检后转 B
    runbook(含表与截图) 0–10% 中高 ≥0.90 中 B,扫描页单独走 OCR
    扫描版工单/合同 100% 中 ≥0.90 中高 B + OCR,表格密集处用 C
    财报/论文(多级表头) 0% 高 ≥0.95 高 C,或 B + 人工抽检复核
    架构图/拓扑图为主 — — 图内文字可寻址 高 C,且需评估图转文字的失真

    成本曲线在哪拐,按 6,000 页(对应 topo_docs 12,000 条目的量级 )算一笔:

    档页均耗时6,000 页总耗时 [算]结构保持率期望拐点判据
    A 纯文本 10–50 ms 1–5 分钟 0.2–0.4 文档含表即不可接受
    B 布局感知 0.3–3 s 30 分钟–5 小时 0.8–0.95 常规场景的默认落点
    C VLM 整页 2–15 s 3.3–25 小时 0.9–0.98 只对难例与高价值文档开启

    拐点不是个点,是条规则:表格密度超过约 15%,A 就不划算;合并单元格或扫描页占比低于 10%,C 就不划算。中间那段是 B 的地盘,也是多数企业文档库的真实位置。

    #mermaid-svg-dFdPh90ug67SHptP{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-dFdPh90ug67SHptP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-dFdPh90ug67SHptP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-dFdPh90ug67SHptP .error-icon{fill:#552222;}#mermaid-svg-dFdPh90ug67SHptP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-dFdPh90ug67SHptP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-dFdPh90ug67SHptP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-dFdPh90ug67SHptP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-dFdPh90ug67SHptP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-dFdPh90ug67SHptP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-dFdPh90ug67SHptP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-dFdPh90ug67SHptP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-dFdPh90ug67SHptP .marker.cross{stroke:#333333;}#mermaid-svg-dFdPh90ug67SHptP svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-dFdPh90ug67SHptP p{margin:0;}#mermaid-svg-dFdPh90ug67SHptP .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-dFdPh90ug67SHptP .cluster-label text{fill:#333;}#mermaid-svg-dFdPh90ug67SHptP .cluster-label span{color:#333;}#mermaid-svg-dFdPh90ug67SHptP .cluster-label span p{background-color:transparent;}#mermaid-svg-dFdPh90ug67SHptP .label text,#mermaid-svg-dFdPh90ug67SHptP span{fill:#333;color:#333;}#mermaid-svg-dFdPh90ug67SHptP .node rect,#mermaid-svg-dFdPh90ug67SHptP .node circle,#mermaid-svg-dFdPh90ug67SHptP .node ellipse,#mermaid-svg-dFdPh90ug67SHptP .node polygon,#mermaid-svg-dFdPh90ug67SHptP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-dFdPh90ug67SHptP .rough-node .label text,#mermaid-svg-dFdPh90ug67SHptP .node .label text,#mermaid-svg-dFdPh90ug67SHptP .image-shape .label,#mermaid-svg-dFdPh90ug67SHptP .icon-shape .label{text-anchor:middle;}#mermaid-svg-dFdPh90ug67SHptP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-dFdPh90ug67SHptP .rough-node .label,#mermaid-svg-dFdPh90ug67SHptP .node .label,#mermaid-svg-dFdPh90ug67SHptP .image-shape .label,#mermaid-svg-dFdPh90ug67SHptP .icon-shape .label{text-align:center;}#mermaid-svg-dFdPh90ug67SHptP .node.clickable{cursor:pointer;}#mermaid-svg-dFdPh90ug67SHptP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-dFdPh90ug67SHptP .arrowheadPath{fill:#333333;}#mermaid-svg-dFdPh90ug67SHptP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-dFdPh90ug67SHptP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-dFdPh90ug67SHptP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dFdPh90ug67SHptP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-dFdPh90ug67SHptP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dFdPh90ug67SHptP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-dFdPh90ug67SHptP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-dFdPh90ug67SHptP .cluster text{fill:#333;}#mermaid-svg-dFdPh90ug67SHptP .cluster span{color:#333;}#mermaid-svg-dFdPh90ug67SHptP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-dFdPh90ug67SHptP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-dFdPh90ug67SHptP rect.text{fill:none;stroke-width:0;}#mermaid-svg-dFdPh90ug67SHptP .icon-shape,#mermaid-svg-dFdPh90ug67SHptP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-dFdPh90ug67SHptP .icon-shape p,#mermaid-svg-dFdPh90ug67SHptP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-dFdPh90ug67SHptP .icon-shape .label rect,#mermaid-svg-dFdPh90ug67SHptP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-dFdPh90ug67SHptP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-dFdPh90ug67SHptP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-dFdPh90ug67SHptP :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}

    PDF 二进制

    版面树

    结构化块流

    带元数据知识条目

    倒排与向量索引

    查询


    六、块流该长什么样:一份能校验的 Schema 与违约处置

    四把尺子量的是「解析器干得好不好」,但还有一类故障不属于干得好不好——产物字段本身就不齐。这类故障不在解析阶段结算,而是等到「[06-引用与可溯源 还在写]」做引用定位时才发现 section 全空,或者 「[03-切分-语义完整与检索精度 还在写]」想按章节路径切块时才发现 page 只有 0。那时候要返工的是整条索引。

    所以解析器与下游之间需要一份显式契约。它不需要复杂,只需要能被机器校验:哪些字段必填、缺了谁降级、哪些必须拒绝入库。

    字段类型谁消费缺了会怎样违约处置
    doc_id str 全部 无法回溯原文档,重解析范围也算不出来 拒绝入库
    block_id str 「[06-引用与可溯源 还在写]」 的 span_id 引用指向的片段 id 无处安放 拒绝入库
    btype enum text/table/title/caption 「02B-文档解析下-表格跨页与截断」 / 「[03-切分-语义完整与检索精度 还在写]」 表格与正文混在一起,断裂检测失去输入 降级为 text 并计数
    text str 全部 — 拒绝入库
    page int ≥1 「02B-文档解析下-表格跨页与截断」 跨页合并、「[06-引用与可溯源 还在写]」 引用 跨页表无法续接,引用给不出页码 记 -1 并计入空值率
    section str 「[03-切分-语义完整与检索精度 还在写]」 按章节切、「[06-引用与可溯源 还在写]」 定位 层级树退化成平表,引用定位不到章节 记 "" 并计入空值率
    rows list[list[str]](仅 table) 「02B-文档解析下-表格跨页与截断」 表头继承 表格只剩拉平文本,字段可定位率归零 btype=table 而 rows 为空 → 计一次结构违约
    bbox list[float](4 个) 版面调试、「02B-文档解析下-表格跨页与截断」 坐标约束 同页多张结构相同的表无法区分 可选,缺了只影响调试
    source_uri str 「[07-知识新鲜度 还在写]」 权威源比对 巡检时不知道该去哪儿核对 记 "" 并在 07 前补
    parser_version str 「[09-检索管道门禁 还在写]」 变更留痕 索引重建后说不清用的是哪一版解析器 拒绝入库

    最后两个字段是这一轮补的:source_uri 看着跟解析无关,但「[07-知识新鲜度 还在写]」 的三路巡检里「权威源比对」这一路唯一需要的输入就是它;等 07 再回头补,成本是重新解析整个库。parser_version 同理——「09-检索管道门禁 还在写]」 的回滚三步里,第一步就要回答「上次是用哪一版解析器建的索引」。

    校验这份契约的脚本,零依赖:

    输入是什么:parsed/blocks.jsonl(上一节那份块流)。 怎么执行:逐块检查必填字段与取值合法性,违约分「拒绝 / 降级 / 计数」三档处置,跨块汇总成一份违约报告。 输出什么:每个字段的违约数与处置档位,外加一句总体判定。 结果不对查哪:如果 btype 违约数是总块数的全部,说明解析器压根没输出块类型字段,先去看解析器的输出配置;如果 rows 违约数等于表格块数,那是没做表格还原,不是格式问题。

    # block_contract.py —— 块流契约校验(零依赖内核,标准库)
    import json
    from collections import Counter

    REQUIRED = ("doc_id", "block_id", "text") # 缺即拒绝
    OPTIONAL_COUNT = ("page", "section", "source_uri") # 缺即计数
    TYPES = {"text", "table", "title", "caption"}

    def check_block(b: dict) –> dict:
    """单块校验,返回 {reject:bool, downgrade:list, missing:list}"""
    reject = [k for k in REQUIRED if not b.get(k)]
    missing = [k for k in OPTIONAL_COUNT if b.get(k) in (None, "", –1)]
    down = []
    btype = b.get("btype")
    if btype not in TYPES:
    down.append("btype->text") # 类型不认识,降级为正文
    btype = "text"
    if btype == "table" and not b.get("rows"):
    down.append("table_without_rows") # 结构违约:声称是表格却没有行列
    if not b.get("parser_version"):
    reject.append("parser_version")
    return {"reject": reject, "downgrade": down, "missing": missing}

    def report(path: str) –> dict:
    n = 0
    rejected, downgrades, miss = 0, Counter(), Counter()
    for line in open(path, encoding="utf-8"):
    if not line.strip():
    continue
    n += 1
    r = check_block(json.loads(line))
    if r["reject"]:
    rejected += 1
    downgrades.update(r["downgrade"])
    miss.update(r["missing"])
    return {"n_blocks": n,
    "reject_rate": round(rejected / max(1, n), 3),
    "downgrades": dict(downgrades),
    "missing_fields": dict(miss),
    "verdict": "可入库" if rejected == 0 and
    miss["section"] / max(1, n) < 0.10 else "需修解析配置"}

    if __name__ == "__main__":
    print(json.dumps(report("parsed/blocks.jsonl"), ensure_ascii=False, indent=2))

    逐段读这份脚本

    • REQUIRED / OPTIONAL_COUNT:两档字段分开是刻意的。必填三样缺一样,这条块在下游没有任何用途;可选三样缺了只是让某一环变弱,不该因此丢掉整块内容。把两者混成一个清单,最常见的后果是 section 空值率一高就整库拒收。
    • check_block():btype 不认识就降级成 text,而不是拒收——解析器的类型词表会随版本扩展(figure / code / list 都可能冒出来),拒收会让解析器升级变成一次线上事故。table 却没有 rows 是唯一记「结构违约」的情形,因为它通常意味着没做表格还原。
    • report():判定线写在 verdict 里——零拒收且 section 空值率 <10%(与第二节那张表的阈值对齐)才算可入库。这条线是该按你自己的文档集重标的,10% 这个数对应「引用还能定位到章节」。
    • 已知偏差两条:① page 记 -1 与缺字段都算违约,但语义不同(前者是「知道没有」,后者是「没取」),要区分就得分开计;② bbox 没进校验,需要坐标约束时(同页多张同构表)得自己补。

    跑在 20 篇 runbooks 的档 A 与档 B 输出上,预期输出如下:

    档 A: {"n_blocks": 1842, "reject_rate": 0.000, "downgrades": {"table_without_rows": 63},
    "missing_fields": {"page": 0, "section": 1842, "source_uri": 1842},
    "verdict": "需修解析配置"}
    档 B: {"n_blocks": 2176, "reject_rate": 0.000, "downgrades": {"btype->text": 41},
    "missing_fields": {"page": 12, "section": 96, "source_uri": 0},
    "verdict": "可入库"}

    两行输出的差别不在 reject_rate(都是 0),在 missing_fields:档 A 的 section 与 source_uri 是满格违约 1,842 条,档 B 的 section 只剩 96 条(空值率 4.4% [算],越过 10% 那条线)。拒收率看不出解析器的好坏,字段违约分布才看得出——这也是为什么契约校验要单独跑,不能只看四把尺子。


    七、0.912 和 0.947 到底差多少:换档前的判稳与重解析范围

    上一节那张三档表有个没回答的问题:B 的字段召回率 0.912 与 C 的 0.947,差 3.5pp [算],够不够格多付 48 倍的钱?只看均值一定会被骗——20 篇里只要有一两篇含合并单元格的文档被 C 救回来,均值就被抬上去了。

    判稳的办法是重采样:把 20 篇的分数当成样本,有放回地抽 2,000 次,看差值的分布跨不跨 0。零依赖实现:

    输入是什么:两档各自的分篇分数列表(长度相同、按篇对齐)。 怎么执行:成对相减后做 bootstrap,输出差值的均值与 95% 分位区间。 输出什么:点估计 + 区间下上界 + 一句判定。 结果不对查哪:区间特别宽(半宽 >5pp)通常不是方法问题,是分篇分数方差太大,先看是不是有一两篇离群;成对设计下必须两档用同一批文档,混了就失去配对的意义。

    # bootstrap_diff.py —— 成对 bootstrap 判稳(零依赖内核,标准库)
    import random

    def bootstrap_diff(a: list, b: list, n_boot: int = 2000, seed: int = 7) –> dict:
    """a=旧档分篇分数,b=新档分篇分数,返回差值(b-a)的点估计与95%区间。
    成对设计:同一篇文档在两档下的分数配对相减,消除文档难度差异。"""

    assert len(a) == len(b), "必须成对:两档跑同一批文档"
    rnd = random.Random(seed)
    n = len(a)
    d = [y – x for x, y in zip(a, b)] # 逐篇差值
    point = sum(d) / n
    boots = []
    for _ in range(n_boot):
    s = sum(d[rnd.randrange(n)] for _ in range(n)) / n
    boots.append(s)
    boots.sort()
    lo = boots[int(0.025 * n_boot)]
    hi = boots[int(0.975 * n_boot) – 1]
    return {"point_pp": round(point * 100, 2),
    "ci95_pp": [round(lo * 100, 2), round(hi * 100, 2)],
    "verdict": ("换" if lo > 0.02 else
    "不换" if hi < 0 else
    "不可区分")}

    if __name__ == "__main__":
    # 20 篇分篇准确率(⚠️ 构造示例):B 档 vs C 档
    B = [0.70, 0.65, 0.80, 0.55, 0.75, 0.60, 0.85, 0.70, 0.65, 0.75,
    0.60, 0.70, 0.80, 0.55, 0.65, 0.75, 0.70, 0.60, 0.85, 0.70]
    C = [0.75, 0.65, 0.80, 0.60, 0.75, 0.60, 0.85, 0.70, 0.70, 0.80,
    0.60, 0.70, 0.85, 0.55, 0.70, 0.75, 0.70, 0.65, 0.85, 0.70]
    print(bootstrap_diff(B, C))

    逐段读这份脚本

    • 成对设计:先逐篇相减再重采样,而不是分别对两档重采样再相减。前者消掉了「这篇文档本来就难」这个共同因素,区间会窄很多。两档必须跑同一批文档,篇数不等时直接断言失败。
    • seed=7:固定种子是为了让结论可复现。改种子会让区间抖动零点几个 pp,但跨不跨 0 这个判定通常不变——如果变了,说明样本量不够,别急着下结论。
    • verdict 的三分支:下界 >2pp 才判「换」;上界 <0 判「不换」(新档更差);其余是「不可区分」。2pp 这条线来自 20 篇的置信半宽 ±3.5pp [算] 与「下游收益要盖得住成本增量」两个约束,不是统计显著性阈值。
    • 已知偏差两条:① bootstrap 假设分篇独立,同源文档(同一份 runbook 的多个版本)会让区间偏窄;② 20 篇的样本量下,<2pp 的真实差值基本检不出来,需要检出来就得按第四节那个算法把样本加到 63 篇。

    预期输出:

    {"point_pp": 1.00, "ci95_pp": [-2.40, 4.60], "verdict": "不可区分"}

    点估计 +1.0pp,区间跨 0 → 判不可区分 → 选便宜的 B。这就是第四节那句「三档下游差值小于 2pp 就选便宜的那档」的来处:不是拍的,是重采样算出来的。

    换档之后还有一件事:什么时候重解析。全量重解析 6,000 页要 30 分钟到 5 小时(第五节那张表),每次解析器升级都跑一遍不现实。触发条件用双键 (doc_hash, parser_version) 判:

    变更类型判定键重解析范围6,000 页下的预期占比 ⚠️ 经验值
    文档内容更新 doc_hash 变、parser_version 不变 只重跑变了的文档 每周 0.5%–2%
    解析器版本升级 parser_version 变 全量重跑 100%
    解析器配置调整(阈值/分栏开关) parser_version 加后缀 全量重跑,可走离线队列 100%
    只对难例升档(结构保持率 <0.5 的页) 按页打标 只重跑被标页 3%–8%

    最后一行的「只对难例升档」是这三张表里最省钱的一招:全量升 C 要付 48 倍成本,只给结构保持率 <0.5 的那 3%–8% 的页升 C,增量成本只有全量的 1.5–4 倍 [算],而结构保持率的抬升几乎全部来自这批页。判定键要写进索引元数据,「[09-检索管道门禁 还在写]」 的回滚第一步靠它。


    八、什么时候不该上 VLM 和 OCR?

    情形为什么不该该做什么
    PDF 有文本层且字符数正常 OCR 会把已正确的字符重识别一遍,字段召回率不升反降 先跑空页率检测,只对空页 OCR
    文档以纯正文为主,表格密度 <5% 档 B 的结构增益用不上,档 C 更用不上 档 A + 抽检,省下的预算放到 「[03-切分-语义完整与检索精度 还在写]」
    文档量 >10 万页,或版式仍在频繁变更 全量 C 的成本会挤掉别的环节,基线未稳时升档收不回 档 B 全量 + 档 C 只跑结构保持率 <0.5 的页

    一组对照:

    ❌ 「整库 12,000 条都上 VLM,一步到位」 —— 全量成本换来的增量集中在不到 10% 的页面
    ✅ 「档 B 全量,结构保持率 <0.5 的页重跑档 C」 —— 把钱花在确实难的那些页上

    还有一种情形容易被忽略:问题只问「这段话在不在」,不要求精确取值。这时结构不是瓶颈,召回才是,升档的钱不如放到「 [05-混合检索与重排 – 这个也还在写] 」上。

    「先量再选」也有不划算的时候:文档总量 <50 篇且版式单一,直接选 B 跑完看一眼输出即可,建 gold 标注的成本比升档省下的钱还多。


    九、小结

    全篇总结:解析质量的度量对象不是「字对不对」,是「结构在不在」。 字错了有告警,结构没了没有告警,只在召回排序里安静地扣掉几个百分点。

    四条判据:

  • 五环各装一个检测点(空页率 / 标题层级 / 行列对齐 / 跨页续接 / 页码路径),缺哪一环的检测,哪一环的损失就看不见。
  • 四把尺子里,结构保持率与下游准确率的相关性最强,字段召回率次之,单位成本是约束而非目标。
  • 选型按「先量再选」:20 篇自己的文档跑出四把尺子再定档,别按文档类型猜;换档前再补一次成对 bootstrap,区间跨 0 就别换。
  • 契约校验与四把尺子分开跑:拒收率看不出解析器好坏,section / source_uri / rows 的违约分布才看得出。
  • 一个十分钟能做完的摸底:从知识库里挑一份有表格的文档,用现在的解析器跑一遍,数两件事——输出里 rows 非空的块有几个,section 非空的块占比是多少。前者是 0,说明压根没做表格还原,后面所有调参都是在没有结构的文本上做;后者低于 90%,「[06-引用与可溯源 还在写]」 的引用定位迟早要返工,返工对象是整条索引。这两个数也决定了你要不要继续读 「02B-文档解析下-表格跨页与截断」,以及读完之后该升档还是该先把契约补齐。

    下一篇:「02B-文档解析下-表格跨页与截断」,解决「表格被拉平、段落被切断」这一类——五条断裂检测规则里,第一条就能把本文 cross_block 0.742 这个数抬上去。


    参考

    • pdfplumber(档 A 样例实现,支撑「有文本层不必 OCR」)
    • PyMuPDF(解码环节的文本层取用,支撑空页率检测点)
    • Unstructured · Marker · MinerU
    赞(0)
    未经允许不得转载:网硕互联帮助中心 » SA-K-02A 知识与检索工程 文档解析上-管线五环与质量四尺
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!