一、先把问题定义清楚:文档理解不是 OCR

团队接需求时最常说的一句话是"我们要读懂这些 PDF"。这句话落不到工程上,因为它把四种失败模式完全不同的子任务混成了一个交付物,而这四种子任务在成本结构、可替代方案和出错后的排查路径上几乎不重叠。你真正要交付的从来不是"这张图上写了什么字",而是"这份文件里有哪些结构化事实、每条事实的来源坐标在哪里、可信度有多高、哪些区域我们根本没读懂"。这四个问题才构成系统契约。

1.1 被混为一谈的四类子任务

第一类是字符识别,把像素变成字符序列。它是一个封闭问题:有标注数据、有明确的指标、优化空间基本被吃干了。第二类是版面理解(layout understanding),回答"哪些字符属于同一个逻辑块、块与块之间的几何与包含关系是什么、页面的阅读顺序是什么"。它是一个结构问题,最容易出灾难性错误,因为识别率哪怕到 98%,错了的两个字符如果落在金额或编号字段上,整条记录就是错的。

第三类是元素语义判定,把几何块翻译成业务概念:这是标题还是正文,这是表格还是被误检成表格的带框段落,这是图注还是脚注,这是页眉还是文档标题。第四类是跨元素结构推理,比如一个表格的表头在上一页而数据行在下一页,或者一段正文里插入了一个需要先解引用才知道含义的公式编号。这四类里,只有第一类在过去的很多年里被当作"核心能力"来投资,而后三类往往被当成"预处理"或者"后处理",这是文档管线事故的主要来源。

1.2 版面理解具体包含哪些职责

把版面理解拆开,至少有四件事要分别做对。第一是区域定位:页面上有哪些块,每块的矩形范围是什么。第二是类型判定:每个块属于标题、正文、列表、表格、图、图注、页眉、页脚、公式、参考文献中的哪一类。第三是结构关系:块之间的父子关系(一个表格单元格属于某个表)、顺序关系(阅读顺序)、邻接与包含关系。第四是跨页与跨栏的续接关系:这一页底部和下一页顶部是不是同一个表格、是不是同一段落的分栏延续。

这四件事里,只有第一件事有相对成熟的现成方案,后面三件都是"看起来简单、实际上错了之后上层完全无感知"的部分。一个把正文块和图注块顺序连反的管线,输出的文本读起来仍然通顺,你不会有任何告警,但下游检索会持续地把图注里的图号当成正文实体来召回。这类错误必须靠版面级指标才能发现。

子任务典型输入失败时的外部表现单环节失败是否可见
字符识别裁剪后的文本行图像错字、串行、公式退化为普通字符有指标,容易发现
版面结构整页位图或矢量图元阅读顺序错乱、表格被打散、跨页断裂常常不可见
元素语义版面块及其几何属性图注当正文、页眉混入正文、表格被当段落不可见
结构推理上述全部结果加文档元数据表头丢失、单元格错位、指代解析失败不可见

这张表的最后两列是我想强调的重点:越是靠上的环节,出错时越容易被自动指标捕获;越是靠下的环节,出错时输出的文本"看起来是对的",只能靠端到端业务指标或者人工抽检发现。工程投入应当按这个倒序来分配,而不是按技术新颖度。

1.3 阅读顺序为什么不能当成后处理

很多实现里,版面检测先跑出所有块,然后把块按左上角坐标排序,y 优先、x 次之,就当作阅读顺序了。这个做法在单栏、单行、块之间留白清晰的页面上能凑合,在下面几种真实版式上会直接崩:双栏论文的左右栏在 y 上大量交错,按坐标排序会把左右栏的行混着排;带侧边栏的杂志版式,侧栏文字的 y 恰好落在正文两段之间;表格里单元格文本的坐标关系和段落完全不同,直接混排会把表格内容插进正文中间。

正确的做法是把阅读顺序建模为一个显式的结构问题而不是排序问题:先做区域分组(哪些块属于同一栏、同一表格、同一图),再在每个区域内部做顺序,最后按区域本身的版面语义排区域。这个顺序不能颠倒,因为"哪些块属于同一区域"本身就需要几何与语义联合判断。一个更实际的折中是让端到端模型直接输出"按阅读顺序组织好的 Markdown 或 JSON",把顺序问题下沉给模型,但这要求你接受它的稳定性不如显式几何管线,且要付出更高的调用成本。

二、版面理解要分别做对的四件事

2.1 区域定位:先把噪声挡在门外

区域定位这一步真正的难点不在检测精度,而在噪声过滤。扫描件上的装订阴影、纸张折痕、传真噪点、页边的批注笔迹、骑缝章,都会产生低置信度的"伪块"。这些伪块如果被放行,后续每一个环节都要为它们付出代价:识别引擎要跑一遍无意义的多行文本,版面类型分类器会给出一个错误标签,语义抽取环节可能把它当成页脚丢掉——也可能当成正文拼进结果里。

实操上有几条便宜的规则值得先做:按面积和长宽比过滤掉极小碎片;按置信度阈值加一个下界,宁可漏检也不要误检,因为漏检会在下游表现为"这段文字不存在"而很容易被发现,误检表现为"多了一些内容"而很难被发现;对颜色方差和局部对比度做一次快速判定,把明显是图片区域的块直接标注为图而不是交给后续环节。这一步的判断标准不是准确率,而是"过滤后的伪阳性率是否低到可以忽略"。

2.2 类型判定:歧义来自版面自身的表达习惯

类型判定看上去是一个十来个类别的分类问题,实际困难在于真实文档里同一视觉形态承担着多种语义。居中一行、加粗、字号较大,可能是文档标题,也可能是章节标题,也可能只是一个装饰性分隔条里的文字。带边框的矩形区域,可能是表格,也可能是代码框、图片占位框、甚至是一个只有一行的提示框。图下方的小字,可能是图注,也可能是正文里的一句话恰好排版较小。

能真正提高准确率的,不是换一个更大的分类模型,而是引入版面之外的证据。文档级的元信息(章节结构、书签、目录树、页码位置)可以给块类型提供强先验:目录页上居中的大字更可能是章节名;出现在每页同一纵向位置、且内容跨页高度重复的块,几乎必然是页眉或页脚;出现在页底且带有"图 X""表 X"前缀模式的短文本,更可能是图注。把这些先验显式写进规则层,通常比让模型从像素里自己猜要稳得多,而且成本几乎为零。

2.3 结构关系:把块变成树或图

块的集合本身没有意义,必须建立结构。多数实现会把版面建模为两类关系之一:层级树,或者带标注的关系图。层级树的优点是天然带阅读顺序(先序遍历即可),表达"这张图下面有图注""这张表属于这个章节"这类关系很自然;缺点是表格这种需要二维结构的元素很难塞进树里,通常只能退化成"表格单元格列表"这种扁平结构丢掉行列信息。关系图更灵活,可以同时表达"属于同一表格""是上一块的图注""与左邻块并列为双栏"等多种边,但需要你自己定义遍历顺序,复杂度明显更高。

一个务实的折中是把文档建模为"两层的混合结构":顶层是一棵由区域组成的树(章节、段落组、双栏区、表格、图),每个区域内部再挂自己的局部结构(表格内部是二维网格,段落内部是行序列)。这样阅读顺序由顶层树决定,而表格的行列信息在局部结构里保全,不会因为全局排序而被破坏。这个混合结构在后面的表格还原、检索切分两个环节都能直接复用,属于投入产出比很高的一处设计。

2.4 跨页与跨栏续接:最容易漏掉的一环

跨页续接有两个识别信号,实践中建议同时使用。第一个是几何信号:下一页顶部存在一个与本页底部表格在列数、列宽、表格线样式上高度相似的区域,且本页底部表格明显没有收尾(最后一行只有部分单元格有内容,或者缺少底边框)。第二个是内容信号:两页表格的表头文本相同或高度相似,而第一页里没有出现表头。两个信号同时命中才做续接,比只看其中一个可靠得多。

续接之后要处理两个额外问题。一是列宽不完全对齐的情况,扫描偏移会让同一列在两页上宽度略有差异,强行按像素对齐会把内容错列;按表头文本与列序做逻辑对齐通常更稳。二是表格中间插入了页眉页脚的情况,某些排版会在续接时重复一次表头,需要在合并前显式去重。跨栏续接相对简单但更容易被忽略:双栏排版的一段话在栏末断开、在下一栏顶部继续,如果按页作为处理单元,两栏会被拆成两段独立文本,导致句子被切断,进而在检索侧被切成两个低质量片段。

三、传统管线与端到端方案的分界线在哪里

3.1 传统四段式管线

传统方案是把文档处理拆成若干级联环节:先做页面预处理(旋转矫正、去阴影、二值化),再做版面检测与分类,然后对每个文本块调用光学字符识别(Optical Character Recognition,OCR),接着做表格结构还原,最后才是语义抽取与字段填充。这套方案的好处是每一级都可替换、可缓存、可灰度:识别模型换了不影响版面检测,版面规则改了不需要重跑昂贵的识别;每一级都能独立评测定位问题;单页处理成本低,吞吐可以做得非常高。

它的坏处同样明确:级联误差会放大。版面漏检一个块,这个块的文字永远不会进入结果,而且不会有任何报错;表格结构还原错一行,下游字段抽取就会把这一行的数字挂到错误的列名上,而且字段抽取本身看起来完全正常。这类"静默错误"是传统管线在企业场景里最大的隐性成本,往往要等到业务方发现某个季度的报表数字对不上才会回溯。

3.2 端到端方案:用多模态模型直接读图

端到端方案的形态是:把整页渲染成图像,直接交给视觉语言模型,要求它输出该页的文本内容、结构标签甚至表格的 HTML 表示。这一路线近年来的最大变化来自视觉文档模型(vision document model)的能力提升:它们在表格还原、阅读顺序、图注关联这些任务上的表现,已经从"演示惊艳、生产不可用"变成了"多数标准场景可用、复杂场景仍需兜底"。具体能做到什么程度取决于模型版本、分辨率预算和提示策略,不宜用单一结论概括。

端到端的结构性优势是它同时处理了几何与语义两类信息。传统管线必须先做版面分类才能决定后续动作,而一个足够强的视觉模型可以从整体上下文推断"这块小字应该是图注",即便它的几何外形和正文完全一样。它也不需要维护规则集,遇到没见过的版式时行为更平滑。代价有三条:单页成本显著高于传统管线;错误往往不可解释,模型把一个两行表格读成段落时你拿不到中间状态;稳定性差于几何管线,同样的输入在模型版本更新或提示词变动后可能得到不同质量的输出,这对需要可复现的审计场景是硬伤。

3.3 两条路线的失败模式对照

把失败模式摆在一起看,路线选择的逻辑就清楚了。传统管线的典型失败是"缺失"与"错位":某段文字整体丢失、某行数据挂错列;它的特点是错误稳定可复现,一旦出现就能通过补规则修掉。端到端的典型失败是"过度自信地平滑":把不存在的结构补全、把模糊字替换成语境合理的词、把两个不相干的表格合并叙述;它的特点是错误不稳定,只能靠约束输出和多次采样缓解。

维度传统四段式管线端到端读图
单页成本低,可本地部署,吞吐高高,依赖模型服务,受速率限制
错误可解释性强,中间状态可见,可定位到具体环节弱,只有最终输出,难以定位
版式泛化弱,新版式要补规则或补训练数据强,行为随上下文变化,更平滑
输出稳定性强,确定性输入产出确定性结果受版本、提示、采样参数影响
表格二维结构依赖专门的结构还原算法,复杂表易错能直接输出表格表示,但同样会错配
长文档全局天然分页,可增量处理常受单页或单图上下文限制

有一点值得单独指出:端到端模型并不天然擅长"超长文档的全局一致性"。它擅长的是一页之内的理解,而跨页的表头继承、章节编号连续性、指代消解,仍然需要显式的文档级结构层来承接。把单页理解能力和文档级组装能力混为一谈,是端到端方案在生产落地时最常见的误判。

3.4 生产环境里更常见的是混合形态

真正上线跑得稳的系统,很少是纯粹的某一路线,而是明确划分了"谁做定位、谁做理解"的边界。一种常见且有效的切分是:几何类工作(去阴影、旋转矫正、页眉页脚剥离、栏检测、跨页表格配对)交给确定性的图像与几何处理,因为这些任务规则清晰、可缓存、错误可修;语义类工作(元素类型判定、表格语义还原、字段抽取、问答)交给多模态模型,因为这些任务规则难穷举、上下文依赖强。中间的识别环节可按区域选择性调用:文本块走 OCR,表格区域、图区域直接送模型看原图,避免"先 OCR 再让模型理解 OCR 结果"这种两级损失叠加。

另一种切分是双通道并行:传统管线产出一个基线结构化结果,端到端模型产出另一个结果,再用一个轻量的仲裁层决定采信谁。仲裁可以基于置信度,也可以基于一致性:如果两个通道对某个字段的抽取结果不一致,就把该字段标为低置信并送人工队列,而不是强行二选一。这种做法把端到端的泛化优势和传统管线的可解释性结合起来了,代价是需要维护仲裁逻辑,并且要接受一定比例的人工复核量。对于合规要求高、错一行的成本极大的场景,这条路值得投入;对于批量、低价值的场景,一个端到端通道加抽样质检就够了。

四、表格:文档管线里最难的一环

4.1 第一层难点:单元格边界是几何问题,不是识别问题

表格还原的第一步是确定网格。经典做法是从表格区域图像里检测横线与竖线,投影成行边界与列边界,再通过相邻边界是否贯通来推断单元格范围。这个方法在印刷规整、线条清晰的电子导出表格上效果很好,但在真实文档里有两个常见破局点。一是半边框表格,很多报表只画外框和横线、不画竖线,此时列边界完全没有视觉依据,只能靠内容的水平对齐来推断,一旦某列内容长度分布很偏,列边界就会整体偏移。二是无线条表格,也就是靠空白间距分隔的表格,此时连行边界都要靠间距聚类,行高不一致或有跨行内容时聚类直接失效。

对这两类退化结构,一种稳妥的补强策略是"几何优先、内容兜底":先用线条给出高置信度的行列锚点,再用单元格文本的中心点分布和空白间隙去补那些没有锚点的边界,最后用行列数在文档模板层面做一致性检查(同一份模板的报表,不同页的表格通常行列数一致或存在固定的分组关系,一旦某页推出的列数与文档级众数不符,就应触发重算或降级)。这一层的一致性检查能拦下大量静默错列。

4.2 第二层难点:合并单元格与嵌套表头

合并单元格(merged cell)处理不当,是列名错配的直接原因。常见形态有两类:横向合并(第一行一个跨三列的"营业收入"标题,第二行才是三个具体科目)和纵向合并(第一列的"2024"跨两行,后两列分别是营收和成本)。如果把它们当作普通单元格处理,表格会退化成一个带空洞的二维数组,列名丢失,语义抽取时模型或规则就不知道"2024 营收"这个数值该用哪个标签。

正确的建模方式是保留合并关系本身:不要急于把网格压平成一个规则矩形,而是先输出一个包含"合并跨度"信息的中间结构,每个单元格记录自己的行起止、列起止和文本。然后再从这个结构里派生出面向业务的视图,比如按行展开成"年份 + 科目 + 数值"的记录列表。这样即使表头是多层嵌套的,展开出来的记录仍然带有完整的上位路径。对于嵌套表头,还有两类特殊结构要单独识别:一是表头本身跨了逻辑页(上一页是"分季度数据"的总表头,本页只有季度列),二是表头里同时存在维度列和度量列(度量列通常在底部有合计行,维度列则没有),这两类信息决定了后面切片时哪些列该转成维度、哪些该转成度量。

4.3 从表格像素到结构化数据的三条路线

把网格、文本和合并关系合成最终结构,大致有三条路线,各有明确的适用边界。第一条是纯几何加规则:在检测出的网格上,遍历每个单元格,把文字块按包含关系填进格子,合并单元格通过"无竖线贯通"或"横线贯通"规则识别。这条路线快、稳、可复现,但遇到单元格内换行、单元格内嵌子表格、无框线表格就明显吃力。第二条是几何加目标检测:先检测每个文本行的矩形框,再用"属于哪个网格交点"的归属规则填表,同时专门训练一个模型识别合并跨度。这条路线在复杂表格上更准,代价是需要针对你的表格样式准备标注,跨模板迁移能力有限。第三条是直接交给多模态模型,让它输出表格的 HTML 表示再解析。这条路线对无线条表格和嵌套表头很省事,但它把几何精度问题转移成了模型生成问题,一旦模型输出的单元格数量与视觉上的行数不符,错误会以非常隐蔽的方式进入下游。

实务上我更倾向"几何给边界、模型补语义"的组合:让几何管线产出一个带合并信息的网格,把网格连同裁剪后的单元格图像一起送进模型,让模型只做一件事——为每个单元格填上语义标签(科目名、期间、金额、单位、是否合计)和单位归一化。这样模型不需要重新推断几何,幻觉的空间被压缩到了标签层面,而标签层面恰恰是它最擅长的。下面是一个简化的实现骨架。

class TableStructurer:
#输入是页面位图与版面树中的表格区域节点
    def restore(self, page_image, region):
        grid = self.grid_detector.infer(page_image, region.box)
#线条缺失时用空白间隙补边界,并对不上文档级列数时重算
        grid = self.gap_fallback.fill(grid, page_image, region.box)
        grid = self.template_check.reconcile(grid, region.doc_profile)
        cells = self.text_filler.assign(page_image, grid)
#合并跨度在这里被保留,不压平
        spans = self.span_detector.detect(page_image, grid, cells)
        return Grid(cells=cells, spans=spans, doc_profile=region.doc_profile)

    def semanticize(self, grid, page_image):
#只让模型填标签与单位,几何结果不交给模型重推
        crops = grid.crop_each(page_image)
        raw = self.vlm.label_batch(crops, prompt=self.LABEL_PROMPT)
        labels = self.schema_validator.repair(raw, allowed=self.dimension_hint)
        return grid.to_records(labels)

这里有两个容易被忽略但很重要的细节。第一是单位归一化:表头写"万元"而数值是"1234.5",如果不在这个环节归一,后续所有跨表比较都会错,而且这种错误在单表内部完全看不出来。第二是合计行的处理:合计行通常应该被排除在明细记录之外,但它同时是最有价值的校验锚点——如果明细求和与合计行对不上,说明某些单元格读错了或漏了,这个校验应该作为强制的输出质量门禁写进管线,而不是靠人看。

五、图表:先判断该不该读

5.1 两条路线:转数据还是走问答

图表处理有两条性质完全不同的路线。第一条是图表转数据(chart-to-table),目标是把折线、柱状、饼图里的数值抽取成一张数据表。这条路线的价值在于产出物是结构化数值,可以被计算、聚合、对比,进入检索系统后能被精确命中。但它的技术难度很高:坐标轴的刻度与单位的换算、堆叠柱的高度分解、多系列的颜色与图例对应、误差棒与置信区间的处理、截断坐标轴导致的比例失真,每一项都需要专门处理,任何一项错了整列数值就是错的。

第二条路线是图表问答(chart question answering),直接把图表和自然语言问题一起交给多模态模型,让它回答"哪一年营收最高""这个趋势说明了什么"。这条路线对趋势、比较、极值这类判断类问题效果好,因为模型本身就在做视觉推理,不需要你显式还原数值。它明显不适合的场景是需要精确数值的场景,比如"把所有点的数值列出来求和",模型给出的数字在视觉上无法自校验,误差可能达到几个百分点且无从察觉。

5.2 可判定的路由信号

实践中不要靠人肉判断每个图表走哪条路,而是构造一组可自动提取的路由信号。最强的信号是图表类型:柱状图和折线图在数值可读性上相对友好,饼图和环形图的比例可以估计但精确值困难,散点图和箱线图本质上不适合精确转数据,多系列堆叠图难度最高。第二个信号是坐标轴信息:如果坐标轴有明确的刻度标签且从零开始起始,数值换算的可靠性高;如果坐标轴被截断、没有刻度标签、或者轴标签是相对指标(增长率、占比),转数据的风险显著上升。

第三个信号是下游用途,这个信号往往比前两个更重要。如果下游只是给人做一个概览,或者作为检索召回时的上下文补充,那就走问答路线,产出自然语言描述即可;如果下游要做数值计算、跨图表对齐、或者要进入数据仓库,那就必须走转数据路线,而且要接受较高的单图成本与一定比例的转录失败。把这三个信号写成一个路由函数,输出"转数据 / 走问答 / 降级为图片描述"三档结果,比统一用一条策略稳得多。特别是"降级为图片描述"这一档必须存在——它保证了遇到实在无法可靠处理的图表时,管线能明确地把它标为低价值而不是产出一个看起来可信的错数。

5.3 图注与正文的关系不要丢

图表之外的元素同样需要正确归属。图注(图注文字)通常是有编号的短句,包含图表类型、时间范围、统计口径、单位和来源。这几项信息是数据可用的前提:口径不同的时间序列不能直接拼接,来源缺失的数值在合规场景里不可用。因此图注不应该被当作普通正文拼进段落里,而应该与对应的图表绑定成一个检索单元,让"图表区域 + 图注文本"一起被索引和召回。这一点在 RAG 场景里价值很高:用户问"2024 年各区域的销售占比",召回的应该是那个带"图 3、各区域销售占比(2024 年,含税,单位:万元)"图注的图块,而不是正文里恰好提到"占比"的某句话。

图注归属还有一个容易出错的边界情况:同一个页面上有多个图表时,图注可能出现在图的上方而不是下方,且当两个图并排时,图注可能只紧贴其中一个。判断归属时,"就近 + 版面方向 + 编号模式"三个条件结合使用比单纯按距离更可靠。而当编号缺失(图注里没有"图 X"这类编号)时,文本内容就成了主要信号,此时更容易配错,属于需要抽样人工核对的场景。

六、扫描件与手机照片:管线在哪一环先崩

6.1 退化是分级的,不是渐变的

处理真实输入时最重要的一个认知是:图像质量的退化不是连续谱,而是几个离散的"台阶"。一份扫描件可能整体很干净但整页倾斜五度,也可能分辨率很高但有一条贯穿全页的装订阴影。不同类型的退化会打到管线的不同环节,逐级加重,直到某一环彻底失效。把所有退化当成一个"总体质量分"来处理,或者用一个单一的预处理流水线去应付所有情况,是效果不稳定的主要原因。

6.2 逐类退化与它们的崩溃点

倾斜和透视变形首先打的是版面检测。文字行不水平时,基于水平投影的空白间隙检测会失效,行边界被错误切分;同时倾斜会让相邻行的字形互相接近,识别阶段的行内切分也会出错。旋转矫正应该在识别之前做,且要区分"整页旋转"和"局部透视":前者用全局角度估计即可,后者在手机拍摄的合同、票据上很常见,需要透视变换,而且变换后的边距裁剪要保守,否则会切掉页边内容。

阴影和光照不均打的是识别阈值。二值化如果用全局阈值,阴影区域的笔画会被抹平甚至整块变白。常见对策是在二值化之前做背景估计与归一化,或者干脆对阴影区域采用自适应阈值。更实用的做法是:如果管线不需要做形态学分析和线条检测(这些都是表格结构的依赖),可以考虑跳过二值化,直接把原图送入识别引擎——因为现代识别引擎通常内部就做了足够的归一化,手工二值化反而可能帮倒忙。这是一个常被忽略的简化点。

低分辨率打的是小字与表格。低于某个阈值之后,识别错误率不是缓慢上升而是快速崩溃,尤其是密排小字表格、页脚注释、多位数编号。这时的正确动作不是硬着头皮跑完全流程,而是先做一次分辨率与字号估计,对预测不可靠的区域提前标记为"需人工",避免把垃圾结果当成有效数据进入下游。宁可显式失败,也不要静默产出错数。

手写内容打的是识别模型本身。多数在印刷体上训练的识别引擎在手写体上会大幅退化,尤其是连笔、潦草、混排(手写批注插在印刷正文中)的情况。手写混排的识别准确率取决于具体实现,手写体专用模型的可用性和成本差异也很大,因此更现实的做法是在版面阶段就把手写区域识别出来并单独路由,不要指望同一个引擎兼顾。

多语言混排打的是语言识别与分词假设。识别引擎内部通常隐含了"整块文本是同一种语言"的假设,遇到中文段落里嵌入英文术语、或者整页中英日韩混杂时,容易出现形近字符互串(例如全角半角、大小写相近的拉丁字母)。这类错误在语义上可能仍然说得通,因此在下游几乎不可见。有效对策是分块级做语言判定,并对混排块用支持多语种的引擎重新处理,同时在输出里保留语言标记,供后续环节选择分词与检索策略。

6.3 降级阶梯:把失败变成可观测的状态

每类退化都应该有一条对应的降级路径,而不是共用一条"质量不够就扔掉"的路径。一个可用的降级阶梯通常是这样设计的:首选完整几何管线加识别加结构还原;几何结果置信度不足时,退到由多模态模型直接输出页面结构;模型输出校验失败时,退到纯文本识别加顺序拼接,并显式标注"阅读顺序不可靠";识别置信度过低时,不再降级,直接输出空结果加问题区域坐标,送人工队列。

这个阶梯的核心不是"总能出结果",而是"总能出可解释的结果"。每一级降级都要在输出里留下痕迹:用了哪条路径、哪些区域降级了、为什么降级。这些元数据后面会极其有用——它让"检索召回了一段错误的文字"这类问题能够被追溯到具体的页面和具体的降级决策,而不是变成无法排查的玄学。没有降级标记的管线,在出问题时只能整体重跑,整体重跑又很贵,于是大家会选择不重跑。

6.4 一份实用的前置质量门禁

在进入正式处理前做一次廉价的门禁检查,收益通常很高。检查项包括:页面尺寸是否在合理范围内(过小可能是缩略图,过大可能是插入了高分辨率扫描);文本行的估算密度是否异常(过低可能是空白页或纯图页,过高可能是选择了照片模式导致摩尔纹);是否存在大面积纯黑或纯白区域(可能是拍摄失误或扫描仪盖板未合);页面是否存在明显的八度以上倾斜。这几项都不需要模型,纯图像统计即可完成,耗时可以忽略。把它们做成批处理前置过滤器,能挡掉相当比例的无效任务,节省的算力往往超过这些检查本身的上千倍开销。

七、输出形态:结构化输出比长文本更有价值

7.1 为什么不要只输出一个长字符串

很多文档理解管线最终只产出一个字符串,看起来简单,实则把大量信息压扁了。压扁的代价有三个。一是溯源丢失,用户或系统无法从结果回到原始页面,"这句结论是第几页的哪一块说的"这个最基本的问题答不上来。二是重算不可能,一旦下游发现某类字段抽错了,只能从原始文件重跑整条管线。三是检索质量下降,同一段文本里混着标题、正文、图注、表格内容,纯文本切分只能按长度硬切,切出来的片段往往在语义上不完整。

更有价值的产出是一个元素级的结构化对象集合:每个元素记录自己的类型、在页面上的坐标、所属的章节路径、文本内容、置信度、以及与其他元素的关系。一个文档最终是这些元素的集合加上它们之间的结构关系。有了坐标就有了溯源和人工复核的入口;有了类型就有了按类型差异化检索的可能;有了关系就有了跨页续接和表格还原的载体。

7.2 结构化输出约束解码的结合点

用模型做文档抽取时,最大的痛点是格式不稳定:字段偶尔缺失、类型偶尔漂移、多余的字段偶尔冒出来,进而让下游解析崩溃。结构化输出约束解码(constrained decoding)把输出语法变成解码过程的硬约束,模型只能在合法结构里选 token,从机制上消除了格式漂移。它与文档解析的结合点在于:约束的对象不是一段自由文本,而是一份符合文档模型的结构定义——元素类型取自你定义的版面类型枚举,坐标取自你划分的页面网格,章节路径取自文档树上的合法节点。这三个约束正好对应文档理解最容易出错也最需要校验的三处。

值得注意的是约束解码会改变模型的输出分布。在语法约束之外再叠加语义约束(例如要求"图注"类型的元素其文本长度不超过某个上限、要求表格元素必须携带列数信息),会进一步压缩非法组合,但也可能让模型在少数边缘样本上过早收敛到一个勉强合法的输出。所以配套的校验层不可省:校验应该在解码之后独立跑一遍,把"格式合法但语义不合理"的样本挑出来。下面是一个输出契约与校验的骨架。

DOC_SCHEMA = {
"type": "object",
"required": ["page", "elements"],
"properties": {
"page": {"type": "integer"},
"elements": {"type": "array", "items": {
"type": "object",
"required": ["kind", "bbox", "text"],
"properties": {
"kind": {"enum": ["heading", "para", "table", "figure", "caption",
"header", "footer", "formula"]},
"bbox": {"type": "array", "items": {"type": "integer"}, "minItems": 4},
"text": {"type": "string"},
"confidence": {"type": "number"}
}}}}}

def extract(doc, page_image, vlm, validator):
#语法层由解码器保证,这里不再解析自由文本
    obj = vlm.generate(image=page_image, schema=DOC_SCHEMA)
    issues = validator.check(obj)
#语义层独立校验:长度、坐标合理性、覆盖率
    issues += check_coverage(obj, page_image)
    issues += check_context(obj, doc.chapter_tree)
    for el in obj["elements"]:
        el["route"] = pick_route(el, issues)
    return obj

7.3 让坐标成为一等公民

把坐标留在一等位置,价值会在很多环节兑现。人工复核界面可以直接跳到出错的区域;差异对比可以告诉你这一版管线比上一版多识别了什么、少识别了什么;置信度可以按区域加权而不是整页一刀切;渲染回原图做可视化抽检时,不需要额外的坐标映射工作。这些能力都是免费从结构化输出里得到的,前提是你在一开始就把坐标字段留出来——后期补是补不上的,因为补坐标意味着要重跑识别。

还有一个容易被忽略的用途:坐标是判断"模型是否真的在读图"的诊断信号。如果一个视觉模型的输出里坐标分布严重集中、或者完全覆盖整页,说明它很可能没有在做版面感知,而是把问题当成了文本生成在猜。把坐标的可信度作为一个独立的评测维度来监控,能在模型升级时及时发现能力回退。

八、接检索:解析结果如何变成可召回的单元

8.1 切分单元不等于固定长度窗口

文档解析的产出如果直接按固定长度切分塞进索引,检索质量会很差。原因有三:段落被从中间截断会让片段语义不完整;表格被切开后行头和行数据分离,片段失去含义;标题层级丢失导致片段失去上下文主题。同样的内容,按语义单元切分的结果要明显好于按长度切分,这也是把解析做扎实最主要的收益来源之一。

推荐的切分粒度是"章节路径 + 完整段落或完整表格"。具体做法是让每个片段携带它在文档树上的完整路径(比如"第三章 成本分析 > 3.2 单位成本 > 某段正文"),片段正文只保留段落本身。检索命中时把路径一起拼进上下文,模型就能知道自己在回答哪一节的问题。对表格,优先按"表头 + 若干行"切分,并保证每行都带上列名的展开形式,这样片段在脱离原始表格后仍然自解释。对图,片段是"图注 + 一段由模型生成的图像描述",而不是图像本身。

8.2 混合检索:为什么文档场景尤其需要 BM25

文档场景和闲聊场景在检索需求上有一个显著差异:文档里有大量专有名词、编号、型号、条款号、法条引用这类"精确 token"。向量检索在语义相近上很强,但在精确匹配上会失手——用户搜"A320-200 型号第 3.3 条",向量检索可能召回一堆语义相关但编号不对的段落。BM25 这类基于词项匹配的检索方法在精确匹配上表现稳定,两者互补,这就是混合检索(hybrid retrieval)在文档场景里几乎必需的原因。

混合检索的融合方式有几种,最简单也最常用的是加权求和:对向量召回和关键词召回的结果按排名倒数加权累加。稍微讲究一点的会用倒数排名融合(reciprocal rank fusion),它只依赖排名而不依赖分数分布,因此对不同检索器的分数标定不敏感,实践中更稳。还有一种做法是先做查询改写,把查询扩成多个视角分别召回再聚合,在多语言混排文档上尤其有效,因为查询语言和文档语言可能不一致。融合权重的选择依赖具体语料,不宜照搬别人的经验值,但一个有用的起点是关键词权重略高,因为文档查询里精确 token 的比例通常高于自然语言提问。

def hybrid_search(query, index, w_vec=0.45, w_kw=0.55, topk=50):
vec_hits = index.knn(query.embed(), topk)
kw_hits = index.bm25(query.analyze(), topk)
score = {}
for rank, h in enumerate(vec_hits, 1):
        score[h.id] = score.get(h.id, 0.0) + w_vec / (60 + rank)
for rank, h in enumerate(kw_hits, 1):
        score[h.id] = score.get(h.id, 0.0) + w_kw / (60 + rank)
#片段自带路径与表格展开,命中即可拼上下文
    return sorted(score.items(), key=lambda x: -x[1])[:topk]

8.3 混合检索之上要做的事

召回质量的天花板往往不在检索算法,而在索引构建时的几个细节,而这些细节全部依赖解析质量。第一是元数据过滤:让检索能按文档类型、章节、时间、置信度过滤,这需要解析阶段就产出可靠的类型和章节信息。低置信度片段可以选择不进索引或降权,宁可召回少,不要召回错。第二是片段自包含:片段必须带上足够的上下文(章节路径、表头展开、图注),否则召回后拼进提示词,模型看到的仍是一堆无主语的碎片。第三是表格的数值列要能被检索命中,如果某个片段里的金额被格式化成带千分位和货币符号的字符串,精确检索会命中但语义检索可能理解不了,两种表示可以同时存一份。

第四点常被忽略:文档里那些"重要但不成句"的内容,比如附录里的参数表、脚注里的限制说明、表格下方的"注:以上数据不含税",它们对答案的正确性影响极大,却往往因为块太小或没有句读而在切分时被丢掉。解析阶段应当给这类内容一个显式的标签,让它们要么与所属段落合并,要么作为独立的短片段保留下来。

8.4 从检索到答案的可溯源性

既然解析阶段已经保留了坐标和章节路径,就应该把它贯通到最终答案上。做法是让生成环节强制引用片段来源(页码加章节路径),前端据此生成可点击的溯源链接,用户点击后直接跳到原文档对应区域并高亮。这不只是体验优化——它给了错误答案一个快速被发现和纠正的通道,也让"系统错了"和"模型理解错了"这两类问题可以区分开。在合规场景里,可溯源往往是硬要求而不是加分项。

九、评测:单看抽取准确率为什么会骗人

9.1 评测集要从真实失败案例反推

文档理解评测最容易走偏的地方,是从一份干净的、由自己挑选的样本文档开始做标注。这类评测集会得出一个好看但没有决策价值的数字:它无法告诉你该优化哪一环,也无法告诉你这次改动是不是真的有用。更有效的做法是从生产流量里反推评测集——把已经被下游质疑过的、被人工复核标为错误的、被检索系统召回后判为不相关的案例全部捞出来,让业务方参与挑选其中代表性的一批做精细标注。用真实失败案例构建的评测集,样本量可能只有一百份,但每一次指标变动都能支撑一个明确的优化决策。

评测集还需要覆盖"分层抽样"而不是"随机抽样"。随机抽一百份文档,很可能九十份是同一种模板,你测出的指标其实只反映了对这一个模板的适配程度。合理的分层维度包括:来源渠道(电子导出、手机拍照、扫描归档、第三方系统推送)、版式复杂度(单栏、双栏、多栏、报纸式混排)、内容类型(合同、财报、说明书、票据、论文、表单)、质量等级(清晰、倾斜、有阴影、低分辨率)。每一层单独报指标,才能看出短板在哪一层。

9.2 版面级指标各自测什么

版面级指标的价值在于定位,但它们和最终效果的相关性有限,需要成组使用。区域定位指标可以基于几何重叠度计算,这类指标的提升往往与业务价值不成正比——把版面块切得更细会让几何重叠度变好看,但切得太细反而会破坏句子完整性。类型判定指标(分类的精确率与召回率)要看逐类的混淆情况,重点盯住几类容易混的:图注与正文、页眉与正文、表格与带框段落。阅读顺序指标要么人工标注顺序再算一致性,要么用一个下游代理指标来间接反映,比如把输出切成句子后计算句子完整率。这个句子完整率指标虽然粗糙,但对阅读顺序问题的敏感度很高,值得作为常规监控项。

表格结构指标要分两层。单元格归属准确率衡量文本有没有被放错格子,结构还原正确率衡量合并关系和行列数是否正确。实践中后者比前者更能预测下游故障:很多错位是靠格子数一致但行列映射错了造成的,只看单元格归属很难发现。跨页续接则要看续接正确率和错误续接率,后者更关键——把两页不相干的表格强行合并,产出的是一份看起来完整但彻底错误的数据。

9.3 端到端指标:回答问题而不是比对字符

端到端指标测的是"这份文档能不能支撑业务问题"。形态有几种:问答对上的正确率(给定问题和参考答案,看系统输出是否包含正确答案)、字段抽取上的字段级精确率与召回率(针对结构化业务字段)、以及检索侧的相关性指标(针对给定查询,正确片段是否被召回以及排名如何)。这三类指标各有定位,不能互相替代。一个抽取准确率很高的管线,可能因为切分方式导致检索完全召不回正确片段;一个问答正确率很高的管线,可能只是在少量简单文档上表现好。

端到端指标的构造方式也很关键。同一个文档配多个不同难度的问题,比每个文档只配一个问题更能区分系统强弱;只问事实性问题会奖励"什么都敢答"的系统,应当同时包含无答案的问题来测拒答能力;针对数值型内容,问题应当设计成需要计算才能回答的(比如"第三个季度比第二个季度增长了多少"),因为这类问题会同时暴露识别错误和抽取错误,而这个错误如果只问"第三季度数值是多少"就可能被模型的记忆或上下文补全掩盖。

9.4 为什么字符级相似度是弱指标

用编辑距离或者 BLEU 这类字符级指标去评文档解析,本质上是在用一个不适合的目标函数做优化。这种指标会把"表格的列顺序错了但字符都在"判为高分,也会把"正确地把一张表转成了结构化记录"判为低分,因为它无法处理结构差异。更要命的是它会诱导优化方向错误——团队会倾向于优化那些影响字符相似度的环节(比如识别字形),而忽略真正影响业务的结构环节。要衡量"读得对不对",测的应该是结构与语义,而不是字符序列的接近程度。

9.5 一套可操作的评测看板

如果要落地一个最小的评测体系,我会建议按这个顺序建:先建端到端问答集与检索相关性集,因为这两个直接对应业务价值;再建版面级指标作为诊断面板,用来回答"这次端到端掉了,是掉在哪一环";最后建立置信度校准评估,即模型给出的置信度与实际正确率之间是否吻合。置信度校准常被忽略但极其重要——如果置信度不校准,你就无法用它做降级路由、人工复核优先级或者按需抽检,整个基于置信度的工程设计都建立在沙子上。一个粗略的观察是,同一模型在不同文档类型上的置信度分布差异很大,通常在手写和低分辨率输入上会明显虚高,这正是需要人工复核队列优先处理的部分。

十、工程化:吞吐、重试、缓存与成本

10.1 按页并行,但不要按文件并行

最容易被忽略的并行设计问题是任务的切分粒度。文档级并行看起来最自然,但代价明显:一百页的报告会被一个任务持有,需要等它跑完才能出结果;任何一页失败整份重跑;不同任务耗时差异极大时负载严重不均。按页并行粒度更细,天然适合并行调度和失败隔离,也支持流式出结果。唯一的额外工作是跨页续接的处理——按页并行意味着续接逻辑要在一个更高层的协调器里做,先把所有页的中间结果收齐再执行合并。这个协调器的实现复杂度可控,因为它只依赖"表格区域候选"和"表头指纹"这两类轻量特征,不需要看到整份文档的内容。

调度的现实约束往往不是 CPU,而是外部模型服务的速率限制。所以并行度控制要分两层:几何与识别这类本地任务可以按 CPU 核数加一定余量来开;调用视觉模型的任务必须按服务的配额来限流,并且需要有独立的失败重试与退避。建议把两类任务放进不同的队列而不是共用一个池,因为它们的资源画像完全不同,混在一起会导致本地任务被外部服务的配额卡住,或者本地任务把进程内存吃满导致模型调用失败。

10.2 幂等、缓存与增量更新

文档管线的重跑成本很高,因此幂等性和缓存必须在一开始就设计好,否则后面补的代价会很大。基本要求有三条。第一是内容寻址:用文件哈希加解析配置版本共同作为处理键,这样配置一改就自动触发全量重算,而单纯的失败重试会命中缓存。第二是阶段级缓存:每一级的中间结果单独落盘并单独带版本号,这样更换识别模型只需重跑识别之后的环节,更换版面规则只需重跑版面之后的环节。第三是稳定输出:相同输入必须产生逐字节一致的输出,避免因为遍历顺序或时间戳导致缓存永远不命中。

增量更新的难点在于"文档变了"这个判断并不简单。文档的元数据修改时间可能因为格式转换而变化,但内容没有变;一份文档可能只是修订日期变了一处,但结论、金额、表头全都变了。稳妥的做法是分层判断:先比内容哈希,命中就直接复用;未命中时做一次轻量差异定位(比如按页比对渲染指纹),只对变化页及其相邻页重跑;如果文档结构复杂(比如涉及跨页表格),把范围扩大到整个相关章节,而不是只重跑那一页。增量更新的收益在文档定期更新的场景里非常可观,但要做对,必须先把阶段级缓存设计好,否则无从判断哪些环节可以跳过。

CREATE TABLE parse_stage_cache(
doc_hash       text NOT NULL,
pipeline_ver   text NOT NULL,
page_no        integer NOT NULL,
stage          text NOT NULL,
stage_ver      text NOT NULL,
payload        bytea,
conf_avg       real,
degraded       boolean DEFAULT false,
primary key(doc_hash, pipeline_ver, page_no, stage, stage_ver));

CREATE TABLE parse_doc_state(
doc_hash       text primary key,
source_version text,
doc_version    integer,
parse_ver      text,
status         text,
updated_at     timestamptz);
--重跑前先按页比对渲染指纹,只重算变化页及其邻居

10.3 失败重试:区分可重试与不可重试

把所有失败都当作可重试是常见的浪费。可重试的失败包括外部服务超时、限流、临时性的资源不足,这类应当用指数退避重试并设置最大次数。不可重试的失败包括图像损坏、格式无法解析、页面内容为空,这类重试多少次都不会成功,应当立即标记失败并进入异常队列。还有一类最麻烦的,是"表面成功但结果不可信"的失败,比如识别引擎返回了空文本但没有报错、模型返回了结构合法但语义空的元素。这类不能靠重试解决,只能靠输出校验发现并触发降级或人工介入。所以重试策略必须和输出校验配套:重试解决的是"没跑完",校验解决的是"跑完了但不能用"。

10.4 成本控制:按页分级调度

成本控制最有效的手段不是压缩单页成本,而是让不同页面走不同成本的路径。一个可落地的分级是:质量门禁判定为清晰、版式简单的页面,走完整几何管线加本地识别,全程不调用视觉模型,接近零边际成本;版式复杂但图像清晰的页面,跳过几何管线直接调用视觉模型读图,成本高但准确率高;质量退化的页面,先做一次几何预处理提升可用性,再决定走哪条路;无论哪条路,最终都需要把高价值页面(合同首页、财务报表页、条款密集页)标记为高优先,走全量冗余处理,比如双通道并行加人工确认。

缓存策略要和分级配套。清晰页面的几何加识别结果是高度可复用的,应当长期缓存;视觉模型的调用结果缓存价值相对低一些(因为贵,所以即使命中率不高也要缓存),但要给它设一个较短的有效期,避免模板变化后一直返回旧结果。另外一个常被忽略的成本项是日志与调试产物的存储:渲染页面图、中间掩码、标注框可视化文件很容易成为存储大头,它们对在线服务没有价值,应该在质检抽样后按保留期清理,只保留样本量很小但有代表性的那些。

十一、2026 年的进展与能力边界

11.1 视觉文档模型这几年的实际进展方向

如果抛开具体的产品宣传,观察视觉文档模型的能力演进,能看到几条清晰的方向。第一是文档专用训练:这类模型在版面密集、表格密集的语料上做了专门训练,因此在表格输出和阅读顺序上比通用视觉语言模型更可靠。第二是分辨率与视觉 token 预算的调度:不再是暴力提升输入分辨率,而是让模型自适应决定看哪些区域、用多大倍率看,这个方向对文档场景非常契合,因为一页里表格区域需要高倍率而页眉页脚几乎不需要。第三是原生文档输入:部分模型支持直接输入文档页面而非先渲染成位图,从而保留矢量文字层的信息,这类输入在处理细线表格和数学公式上有优势。第四是与结构化输出约束解码的深度结合,让模型输出可以直接被下游消费而无需脆弱的正则解析。

还有一条方向容易被低估:文档级而非页面级的输入。前面讲过单页理解的局限,而近年的做法是把一个章节或若干页作为输入,让模型在更长的上下文里保持结构一致性。这能缓解跨页续接和章节编号连续性的问题,但对成本和上下文长度都有明显要求,实践中通常只在高价值文档上启用。

11.2 能力边界:什么情况下仍然不该交给模型

尽管进步明显,有几类任务的边界仍然清晰,不建议完全交给模型。一是需要精确数值的批量抽取,尤其是财务报表、票据这类对错一个数字代价很高的场景,模型可以辅助定位和交叉校验,但最终入库的数值应该来自有几何依据和明确单位的结构化路径。二是需要可复现和可审计的全过程,模型的中间推理不可复现,一旦输出有争议无法举证。三是极端版式,比如手写为主的历史档案、装订严重变形的老旧扫描件,几何管线的失败是可预期且可修复的,而模型在这里的失败往往是无声的。

另一个边界是格式不规范的半结构化内容,比如从聊天截图、邮件转发、社交媒体截图里提取任务清单。这类输入没有稳定版式,传统管线无从下手,模型倒是很擅长,但这属于另一个问题域(界面理解与信息抽取),它的评测方法和失败模式跟文档解析完全不同,不要用同一套管线假设去套。判断标准很简单:如果"页"这个概念不存在、元素靠视觉分组而非排版结构划分,那它就不是文档理解问题。

11.3 约束解码与文档解析的合流

结构化输出约束解码这几年从"生成格式合法"走向了"生成结构自洽",这个合流对文档解析的意义在于把一部分质量保证从离线校验挪到了解码过程内。文档模型可以先预测区域的类型与层级,再在这个区域内生成内容,结构与内容不再是两个独立步骤。这种做法理论上能减少"格式对但内容错位"的错误,因为它把归属关系和文本生成耦合在了一起。不过要保持清醒:约束解码保证的是"符合结构定义",不是"符合现实"。它能让输出永远可解析,但无法保证输出的事实正确。把约束解码当成质量保证手段而不是格式手段,是使用这类技术时最需要避免的认知偏差。

11.4 模型替换时的评估方法

模型升级通常是这套系统里收益最大、风险也最高的一次变更,因为下游所有指标都会动,而动的原因是隐式的。稳妥的做法是:在固定评测集上同时跑旧模型和新模型,比较端到端指标与分层诊断指标,不只看总分是否提升;重点看能力有没有发生迁移,比如总分上升但手写层大幅下降,这类情况在总分上完全看不出来;检查置信度校准是否仍然成立,因为新模型的置信度分布通常和旧模型完全不同,直接沿用旧的阈值会让路由逻辑全部失效;最后做一次人工盲评抽样,因为自动指标对"错得很自信"这类新引入的失败模式往往不敏感。

十二、人工复核闭环:让错误可被发现、可归因

12.1 复核队列怎么排

人工复核是文档管线的一部分,不是一个附加环节。但"把不确定的都丢给人工"是不可行的,必须靠置信度和降级标记把复核量控制在一个可运营的范围内。复核队列的排序优先级通常由几个信号决定:置信度最低的优先;被标记为降级路径处理的优先(因为降级本身就意味着主路径失败);涉及高价值字段(金额、编号、日期)的优先;下游已经产生质疑的优先——这个信号最有价值,因为它代表真实的业务影响,而不是模型的自我怀疑。剩下的是按随机抽样填充,用来估计整体准确率,随机抽样和定向复核要分开统计,两者的准确率差异本身就是质量问题的度量。

12.2 复核界面决定复核效率

复核界面的设计直接决定单位复核成本。最低要求是三件事:原图与解析结果的并排显示,可点击联动;出问题的区域能高亮定位;修改后的结果能直接回流成新的标注数据。一个设计得好的复核界面,复核员处理一页的时间可以压缩到纯看图的一半以内,而复核速度直接决定了你能覆盖多少样本。复核产出的标注数据是评测集的持续来源,也是版面与识别模型微调的直接语料——如果这些标注没有结构化回流到训练与评测流程里,人工复核就退化成了昂贵的日志查看器。设计复核界面时把"结构化回流"当成一等需求来做,比事后想办法补救要省力得多。

12.3 用错误分类驱动优化优先级

复核数据最有价值的用法不是统计准确率,而是做错误归因并排序。建议维护一个错误分类体系,每个复核确认的错误归入一类,比如几何错位、字符误识、类型误判、跨页续接错误、图表数值错误、模型幻觉补全。每类记录影响范围(涉及多少文档、哪些模板)。优化优先级应当按"影响面 × 单类修复成本"排序,而不是按当前出现频次排序。这里有个反直觉的结论:一个只出现在某个特定模板上、但修复只需要补一条规则的问题,可能比一个出现在全站、但需要重新标注训练数据的识别问题更值得先做。定期重排这个优先级表,比盲目追逐平均准确率更能持续改善实际体验。

十三、上线前的检查清单与常见故障定位

13.1 一份可落地的检查清单

按这个顺序核对,能挡掉大部分上线后的返工。解析层:是否保留了每个元素的类型与坐标;跨页表格是否有续接逻辑并有测试用例覆盖"续接"和"错误续接"两种场景;图注是否与图绑定而非并入正文;页眉页脚剥离是否有跨页一致性验证(同一份文档里被剥离的页眉文本应当高度重复,如果不同页剥离出不同内容,说明规则不稳)。结构层:表格是否保留了合并跨度信息而不是压平;数值是否做了单位归一化;合计行校验是否作为门禁执行。输出层:低置信度与降级区域是否被显式标记;空结果与失败是否被区分开;同一输入两次运行是否产出完全一致的输出。

检索层:片段是否携带章节路径;表格片段是否带表头展开;片段长度分布是否正常(如果绝大多数片段都很短或都很长,说明切分逻辑有问题);数值型内容是否同时存了两种表示。成本层:是否按页分级调度;阶段级缓存是否就位;限流与退避策略是否配置;日志与中间产物的保留期是否设定。运维层:能否按文档哈希加配置版本复现任意一次解析结果;能否只重跑某几页;模型版本变更是否有回归评测流程。

13.2 常见故障的定位路径

几个高频故障有比较固定的排查路径。"检索召回了语义相关但完全不对的片段",先看该片段的类型标签和置信度,如果低置信度片段被放进了索引,那是过滤规则的问题;如果片段类型是图注或页眉被当成了正文,那是元素分类或归属的问题。"字段抽取某个数字总是错",先确认该数字所在表格的列名是否正确展开,如果列名都挂对了再看该单元格的合并跨度,如果列名错位那就是表格结构还原的问题,不是抽取的问题——这个顺序能省掉大量在模型提示词上的无效折腾。"整份文档大面积乱序",先排查是不是双栏或多栏版式,再排查旋转矫正是否生效,最后才怀疑模型。"只有某些客户的历史文档解析质量差",基本可以直接锁定为质量退化路径,需要单独为这类输入配置降级策略和人工复核比例。

13.3 什么时候该换架构而不是继续调参

有三种情况应该停下来考虑架构层面的调整。第一,同一类错误反复出现且无法用规则或数据修复,说明当前的表示方式承载不了这类信息,需要改输出结构。第二,优化某类文档导致另一类明显退化,说明现有的单一表示在不同版式间存在冲突,应当考虑按文档类型分流管线。第三,端到端指标长期卡在一个水平而各环节诊断指标都正常,这通常意味着问题是跨环节的组合效应(比如识别正确但顺序错,或者顺序正确但切分把语义切碎了),这时候应该去看中间产物而不是继续优化任何单一环节。最重要的一条判断标准是:如果每次优化都要在多处改动并且互相影响,就该重新审视中间表示的设计了。

最后回到最开始那句话:文档理解的难点不在模型,而在契约。你需要清楚地说出这份文档里有哪些结构化事实、每条事实的坐标与可信度、以及解析失败时系统如何表现。把这三件事定义清楚,剩下的都是工程量。

十四、键值抽取:表单、票据与合同条款

14.1 键值对抽取的三类版式

除了表格和图表,键值对抽取(key-value extraction)是文档理解在企业中最高频的任务:发票上的购买方与销售方、报销单上的金额与事由、合同首页的甲乙方信息与标的、申请表里的申请人字段与申请事项。它们的版式大致分三类,抽取策略完全不同。第一类是右对齐标签型,标签在左、值在右,靠空白或下划线分隔,识别出标签文本后按几何关系向右找值即可,这是最简单也最可靠的一类。第二类是标签内嵌型,值跟在标签后面用冒号、顿号分隔,有时一行里还有多个键值对,需要按标点切分后再按语言与位置判断归属。第三类是框线嵌套型,值被放在一个带边框的单元格里,标签可能在框外也可能被画进框内,最容易出现"值被当成标签"或"标签被当成值"的对调错误。

14.2 为什么键值抽取特别容易出现对调错误

对调错误是键值抽取最典型也最难发现的错误,因为抽出来的字段往往都是格式合法的:日期对到了日期字段,金额对到了金额字段,只是所属实体错了。产生对调的原因通常是几何关系判断过于宽松,比如"同一行内所有文本块按 x 排序后,左边是标签右边是值"这个假设,在一行里有两个键值对时会直接失效;又比如标签和值之间隔了很宽的空白,超过了原定的距离阈值,值就没被认领,退化成"下一个孤儿文本块"。有效对策有三层:优先用标签词表做锚定(正则或词典匹配出已知标签,再按几何就近认领值),对认领不到值的标签显式标记为空而不是随便抓一个;认领到的值要做类型校验(日期格式、金额格式、证件号校验位),类型不匹配的认领直接判为失败;最后对整行做一次重复键检查,同一字段在一份文档里出现两次时保留两个值并标记冲突,而不是静默取第一个。

14.3 合同条款这类长文本的难点不在抽取而在定位

合同的条款抽取看起来简单——找到"违约责任"这一节,把里面的文字取出来——但在实现上有个常见的结构性误解:真正困难的往往不是"抽哪一段",而是"什么算一段"。一份合同的条款通常由标题、若干段正文、若干个子项、若干个例外说明组成,标题层级用编号和缩进表示但不一定有视觉上的字体差异。粗暴地按空行切分会把一个逻辑条款切成三段,按固定长度切分会把例外说明和主条款混在一起。更稳妥的做法是利用编号模式与缩进层级来重建条款树,编号识别在合同类文档里通常比版面检测更可靠,因为编号是规范排版的。当编号缺失时,缩进量和标点模式("其中""除本条另有约定外"这类转折开头)可以作为补充信号。

14.4 票据与表单:规则的价值高于模型

发票、火车票、身份证、营业执照这类高度标准化的票据,是传统规则性价比最高的场景。原因很简单:版式几乎固定,字段位置可预测,监管与行业规范往往还对版面有明确要求。此时最有效的做法是先把版面锚定下来(用几何或少量关键点检测定位票据类型),再用模板规则抽取字段,模型只作为规则失败时的兜底以及对非标准变体的泛化。与之相对,从零开始的自由版式抽取才更依赖模型。把这两类输入混在一条管线里用同样的策略处理,是常见的设计失误。

票据场景还有一个容易被忽略的强校验:不同票种对金额有算术约束,比如价税合计应当等于不含税金额与税额之和,校验位可以被重新计算验证。这类校验能在不依赖任何模型的情况下抓出大量识别与抽取错误,成本近乎为零,而且误报极少。对于合同类文档,同样存在交叉约束可以拿来做门禁,比如签署日期应当不早于生效日期、金额大小写应当一致。把这类业务约束写成校验器集合,是提升文档管线可靠性投入产出比最高的一步,往往比换模型有效得多。

十五、数据契约:让解析结果可以被长期消费

15.1 解析结果是数据资产,需要版本

解析产物一旦进了下游索引和数据仓库,它就成了数据资产,而不是某个管线的临时输出。这意味着一旦发布,就需要考虑版本问题:格式字段含义变化(某个字段从字符串变成了对象)、字段废弃、字段新增、枚举值扩充,都会影响所有消费方。缺少版本管理的解析结果,升级管线时只能整库重跑并祈祷没有下游因此出错。因此从第一天起就应当把"结构版本"和"内容版本"分开管理:结构版本随格式定义变化递增,内容版本随解析结果更新递增,两者独立。这样"这次重跑只是因为修正了两个字段值"和"这次重跑是因为结构升级"就有了明确区分。

15.2 字段定义要写清来源与置信

每个对外暴露的字段都应该携带三样东西:它来自文档的哪个位置(页码加坐标或者区块标识)、它是怎么得到的(几何规则、识别加规则,还是模型抽取)、以及它的可信度等级。可信度等级不必很精确,但至少要能区分"有几何依据且通过了业务校验"和"由模型生成且未经校验"两类。这三样信息的成本很低,但决定了下游在出问题时的排查路径。一个只有值的字段让人无法判断该不该信;带来源和可信度的字段让人可以在几秒内定位到问题区域。把这类元数据当成一等字段而不是放在附带的日志里,是解析产物能否长期可用的分水岭。

15.3 结构变更的迁移策略

结构变更时,稳妥的做法是双写与渐进迁移,而不是一次性切换。具体来说,新增字段先只写不读,读侧逻辑在兼容新旧两种结构之后再切换;字段语义变化时不要原地改语义,应当新增字段并在一个过渡期内双写,等下游全部切换后再废弃旧字段;枚举扩充属于低风险变更,可以直接发布,但需要在下游确认它能容忍未知值出现,否则会引发解析异常。字段废弃则必须等所有下游确认不再依赖后才能执行,并且保留足够长的回滚窗口。这套流程看起来繁琐,但它把"一次变更炸掉全部下游"的高风险操作,变成了一串可观测、可回滚的小操作。

15.4 对外接口应当暴露不确定性

很多系统把解析接口设计成"给一个值就返回一个值",把不确定性完全藏起来。这种接口设计的问题在于,它把"是否需要人工介入"的判断责任完全推给了下游,而下游并不了解文档质量的分层,最终要么全信、要么全不信。更合理的接口应当把低置信、降级、失败这三种状态作为一等的返回状态暴露出来,并且允许调用方按自己的风险偏好设置阈值。这样同一套解析服务才能同时支撑"批量灌库、可容忍少量错误"和"合规归档、错误必须拦截"两类完全不同的场景,而不需要维护两套系统。

十六、召回之后:重排、去重与上下文组装

16.1 召回不等于能用

混合检索产出的是一个候选集合,从候选到可用之间还有三步需要显式设计。第一步是重排:用交叉编码器(cross-encoder)这类更强的模型对候选做逐对精细打分,把真正相关的片段推到前面。文档场景下重排的收益通常很明显,因为很多相关片段在双向量召回阶段因为切分粒度或者表述差异被排在十名开外,但重排能把它捞上来。第二步是去重:同一份文档的相邻段落往往内容高度相似,会挤占多个召回位,用文本指纹或者语义相似度做冗余剔除,把位置留给不同来源的片段。第三步是上下文组装:按得分顺序拼接,同时遵守上下文长度预算和结构约束。

16.2 上下文组装里的三个决策

组装阶段最关键的一个决策是"要不要给邻接内容"。用户问一个具体数字时,只给命中片段,用户追问时往往会需要相邻的上下文;而自动补齐相邻片段又会挤占预算。这里有个实用折中:按需补齐——只有当命中片段明显不完整(比如表格片段缺表头、段落片段以指代词开头、没有章节路径)时才补相邻内容,否则不补。这个判断可以基于片段元数据自动完成,不需要模型参与。

第二个决策是同文档片段的排序顺序。如果召回了同一份文档的多个片段,应当按文档内的位置顺序而非相关度顺序拼接,因为人读文档也是按顺序读,顺序混乱会让模型误判段落之间的关系。第三个决策是冲突内容的处理:同一问题在不同文档里有不同答案时,不要靠排名让模型自己选,而是显式保留多份来源并标注,让模型回答时同时给出两者和各自的来源,否则生成的答案会显得确定而实际上不可靠。这三点是很多 RAG 系统"能召回但答不好"的主要来源。

16.3 引用与回溯

组装时就应该把引用信息一起准备好:每个片段带上文档标题、页码、章节路径和坐标,写入片段的隐藏字段里,并在提示模板里要求模型在生成时引用。实践中一个常见的问题是引用格式不稳定,模型有时编造页码。更稳的做法是不要让模型自己写页码,而是把引用编号事先编好塞进片段开头,让模型只需要引用编号,这是约束解码思路在引用问题上的又一次应用——把"生成引用"从自由生成变成选择已有编号,可靠性显著提高。

16.4 离线评估召回层比评估生成层更值钱

如果只能做一件事,建议先把召回层的离线评估做起来:准备一批真实查询,标注每条查询的正确片段,然后持续监控召回率与排名指标。原因是召回层的问题可以定位、可以量化、优化路径明确,而生成层的问题经常是"答案不对"但说不清是召回错了还是生成没读懂。把召回层的指标长期监控起来,还有一个额外收益:当接入新的文档模板或新的解析管线版本时,能立刻看到检索质量的变化,而不需要等业务方来反馈"最近搜东西好像不准了"。

十七、多语言文档:识别与检索的双重挑战

17.1 文档级语言与块级语言

多语言文档的难点首先在于语言判定粒度。整份文档一种语言的情况相对好处理,但真实语料里更常见的是文档级语言与块级语言不一致:中文报告里嵌入英文术语和代码片段、英文合同里附中文签署页、日文文档里夹一段英文产品型号。此时把整页交给一个单语假设的识别引擎就会出现形近字符互串,而这类错误在下游极难发现,因为它产生的文本在语义上仍然通顺。务实的做法是在版面阶段先切出文本块,再做块级语言判定,对语言混杂的块单独路由到对应的识别通道,并在输出里保留语言标记。语言标记看起来只是个附加字段,但它决定了后面分词、检索、乃至摘要用什么策略,缺了它后面每一步都只能用一个平均化的策略去应付差异极大的内容。

17.2 检索侧的语言不一致

文档语言与查询语言不一致时,纯向量检索仍然能工作(多语言向量模型会把语义相近的跨语言文本映射到相近位置),但关键词检索几乎完全失效——用户用中文搜"违约责任",BM25 在一份英文合同里找不到任何词项匹配。这种情况下有两条补救路径。一是查询侧扩展,用翻译或多语言改写把查询扩成多种语言的表述,分别召回再融合,成本是额外的编码调用。二是索引侧处理,为每份文档额外存一份机器翻译的代理文本用于关键词索引,同时保留原文用于展示和引用。两条路径各有代价:前者便宜但依赖翻译质量,后者在多语言文档上会显著放大索引体积和成本,选哪条取决于你的查询语言分布——如果查询语言相对集中,查询侧扩展更划算;如果文档语言极其分散,索引侧处理更稳定。

17.3 编号、代码与专名的检索适配

最后要单独提醒一类容易被检索系统系统性低估的内容:编号、型号、条款号、法条引用、邮箱与网址。这些内容的共同特点是用户几乎总是精确搜索它们,而向量检索对它们基本无效甚至有害——因为"第 3.3 条"和"第 3.5 条"在语义空间里几乎重合,但在事实上完全不同。对这类内容,关键词通道是唯一可靠的。工程上的建议有三点:入库时保留原始书写形式,不要把编号规范化成一个更"整齐"的格式,因为用户的查询用的是原始形式;不要对编号类 token 做过度激进的分词,也不要把它们当停用词或低频词过滤掉;在切分时不要把一个编号和它修饰的对象分开,比如"依据 GB/T XXXX 规定"里的编号和标准名应当保持在同一个片段里,否则精确匹配会命中一半内容却失去语义。

这类内容还有一个检索侧的配套问题:命中之后展示什么。用户搜一个编号时想看到的往往就是这个编号出现的上下文而不是整章内容,因此当命中片段的主要价值是提供了一个定位坐标时,接口应当直接返回坐标而不是把整段塞进上下文,让上层自己去原文档里跳转到对应位置。识别与检索在这里又一次汇合:版面阶段保留的坐标,最终变成了检索结果里的溯源能力。