文档解析的实现:从 PDF、Word 到可用于检索的文本

文档解析的实现:从 PDF、Word 到可用于检索的文本

RAG 效果不好,很多人的第一反应是去调 embedding、换重排模型、改 prompt。但有些问题根本不在那一层------进去的文本本来就是乱的。

表格被抽成一行行碎片,双栏 PDF 被读成左右交错,每页的页眉页脚混在正文里,扫描件抽出来一片空白。这些文本进了库,后面无论怎么检索、怎么重排,都捞不出正确的内容,因为那段话从来没有以完整的形式存在过。

这种活不体面,但决定了整条链路的天花板。这篇按处理顺序讲:先判断文档类型,再重建版面,然后单独对付表格和扫描件,最后讲怎么验。

一、先判断类型:看有没有文本层,别只看扩展名

同一个 .pdf,可能是 Word 导出的电子版,也可能是复印件扫出来的图。这两类的处理路线完全不同,判断错了整条流程白跑。

判断办法很直接:抽几页看能不能取到文本。

判断项 电子版(有文本层) 扫描件(无文本层)
抽取出的字符数 与页数大致匹配 接近 0,或只有零星字符
字符内容 正常可读 乱码、私人使用区字符、方框
页面图像 文字清晰但由字符绘制 整页是一张位图
处理路线 坐标抽取 + 版面重建 图像预处理 + OCR + 后处理
主要误差来源 阅读顺序、页眉页脚 识别错误、结构丢失

还有一种混合情况:正文是文本层,插图和部分页是扫描图。这时候要按页判断,而不是按文件判断。

哦,原来是这样:解析策略不该由文件扩展名决定,该由"这一页有没有文本层"决定。按扩展名分流,是这类流水线最常见的第一处错误。

二、电子版 PDF:文本抽取的三个坑

要理解 PDF 为什么难,得先知道它里面存的是什么。

PDF 里没有"段落""表格""标题"这些概念,只有指令:在某个坐标,用什么字体、多大字号,画某个字。所以你抽出来的本质是一堆带坐标的字符,阅读顺序得自己重建。

坑一:阅读顺序。 双栏甚至三栏排版的文档,按坐标从上到下、从左到右读,会把两栏的内容交错成一段谁也读不懂的话。得先按横坐标把字符聚成栏,再在每栏内部按纵向排序。

坑二:页眉页脚和水印。 页码、公司名、文档标题在每一页重复出现。它们单看无害,进了知识库就是噪声------"第 3 页"这种串会在大量 chunk 里重复出现,把逆文档频率打乱,检索时反而占权重。处理办法是统计跨页重复出现的短文本,超过一定页数比例就判为页眉页脚,剔除。

坑三:断行与连字符。 PDF 里换行是版面行为,不是段落结束。行尾断开的句子被当成两段,切分时就断了。英文还有行尾连字符断词(informa- / tion),要拼回去。

python 复制代码
def rebuild_lines(chars):
    """
    按坐标把字符聚成行,再按行还原文本。

    :param chars: [(x, y, size, char), ...],来自 PDF 文本层
    :return: [(y, text, size), ...],按阅读顺序排列的行
    """
    rows = defaultdict(list)
    for x, y, size, ch in chars:
        rows[round(y, 1)].append((x, ch, size))   # 同一基线的字归为一行

    lines = []
    for y in sorted(rows, reverse=True):          # PDF 坐标原点在左下,y 大在上
        items = sorted(rows[y])                    # 行内按 x 从左到右
        text = "".join(ch for _, ch, _ in items)
        size = max(s for _, _, s in items)         # 行内最大字号,用于识别标题
        lines.append((y, text, size))
    return lines

有了行、字号、纵坐标间距,才能推断标题层级(字号变大、上下留白变大),也才能把正文和页脚分开(页脚通常在页面固定高度、字号偏小)。

哦,原来是这样 :PDF 解析的本质是版面重建,不是"读文字"。文字早就在那儿了,缺的是顺序和层级。不重建这两样,后面的切分和检索全歪。

三、表格:最难的一块,且不能当普通文本切

表格是解析里最容易毁掉信息的部分。原因很简单:表格没有统一表示,有的是线框,有的是空白对齐,有的是背景色分隔。

识别方式大致分两类:有框线的,靠检测横竖线还原格子;无框线的,靠列对齐和间距推断列边界。两条路都可能出错,合并单元格、跨页表格尤其麻烦------一张表从上一页延续到下一页,中间隔着页眉,很容易被当成两张不相关的表。

抽成什么形式,比怎么识别更影响下游:

输出形式 优点 问题
纯文本(按行拼) 简单 表头与单元格的对应关系丢失
Markdown 表格 可读、模型熟悉 合并单元格无法表达,超宽表难读
HTML 表格 能表达合并单元格 体积大,占上下文
单元格坐标列表 信息最全 需要额外拼装才能给模型

不管选哪种,有一条原则必须守:表格要作为一个整体处理,不要按字符数切碎。

一张表被切到两个 chunk 里,后半段只剩一堆数字,没有表头。模型看到"3 | 1280 | 已支付"根本不知道这三列分别是什么。常见做法是表格整体作为一个 chunk(超长时按行切,但每段都重复表头),或者干脆转成键值对文本:

text 复制代码
订单号:A1001,金额:1280 元,状态:已支付
订单号:A1002,金额:360 元,状态:待发货

键值对形式会损失一些结构,但每一条都自带字段名,切成什么样都不至于看不懂。对以问答为主的场景,这比保留完整表格更实用。

哦,原来是这样:表格的价值全在"表头与单元格的对应关系"上。一旦切开,剩下的只是数字,而不是数据。

四、Word、HTML、Markdown:相对好办,坑在细节

docx 本质是个压缩包里的 XML,段落、样式、表格都有明确结构,比 PDF 好处理得多。按段落样式映射标题层级就行(Heading 1 → 一级标题)。

坑在于:不少人用加粗和加大字号模拟标题,没有用真正的标题样式。这类文档按样式解析,会得到一篇没有层级的长文,切分时只能按长度硬切。可以补一条规则:字号明显大于正文且独占一行的段落,按标题处理。

其他要留意的:文本框里的内容(顺序容易乱)、批注和修订痕迹(要决定是否保留)、页眉页脚(docx 里也是独立节点,记得排除)。

HTML 的重点是剔除噪声:导航、侧边栏、广告、脚本、评论区。一般用正文提取算法(比如按文本密度判断主体区域)把正文框出来,再转成结构化块。直接 strip_tags 会把整页的菜单和页脚一起吃进去。

Markdown 最省事,因为结构本来就是显式的。但要注意代码块不能被切分、不能被分词处理------一个函数被切成两半,语义就没了。列表也一样:有序列表的编号要保留,否则"第 3 步"变成"第 步",步骤顺序就丢失了。

五、扫描件与 OCR:产出的是"有错误的字"

扫描件只能走 OCR,而 OCR 的产出和你预期的不是一回事:不是"把图变成准确的文字",而是"把图变成带有一定错误率的文字"。

要降低错误,通常分三段做:

  1. 图像预处理:去噪、纠偏(页面倾斜会让整页识别变差)、二值化。这一步对最终识别率影响很大,成本却很低。
  2. 识别:交给 OCR 引擎,同时拿到每块文本的置信度。
  3. 后处理:用业务词典纠正常见错字、校验数字格式、把明显不合法的编号标出来。

OCR 的错误有规律:形近字(0 和 O、1 和 l、已 和 己)、小数点丢失、表格行列错位、竖排文字顺序错乱。

关键的一点是别把 OCR 结果当成和电子版一样可信。至少要做两件事:保留每块的置信度,低置信度的块在检索时可以降权,或在拼上下文时标注来源;记录这段文本来自 OCR,出问题时能快速定位是识别错了还是原文就这样。

哦,原来是这样:OCR 交付的不是"文本",是"带不确定性的文本"。下游如果不知道哪些地方可能错,就没法做任何补救。

六、解析结果应该长什么样

很多流水线的中间产物是一大坨纯文本,所有结构都在这步丢掉了。后面想按标题切、想保住表格、想在回答里标页码,都没依据。

更合适的中间格式是块(block)列表,每块带类型和来源信息:

json 复制代码
{
  "doc_id": "manual-2024",
  "blocks": [
    {"type": "heading", "level": 2, "text": "3.2 退款流程", "page": 12},
    {"type": "paragraph", "text": "用户提交退款申请后......", "page": 12},
    {"type": "table", "html": "<table>...</table>", "page": 13, "rows": 8},
    {"type": "list", "ordered": true, "items": ["...", "..."], "page": 13}
  ]
}
字段 用途
type 决定切分策略:标题处切、表格整体保留、代码块不切
level 还原标题层级,支撑按标题切分
page 回答里可以标"见第 13 页",也方便人工回查
source(推荐) 区分文本层 / OCR,便于按可信度降权

有了这个结构,切分就从"按字符数硬切"变成"按语义边界切":在标题处断开,表格整体保留,段落不跨块。这也是知识库构建里按标题切分能成立的前提------前提是解析阶段把标题识别出来了。

七、完整流程

graph TD A[文档输入] --> B{该页有文本层?} B -->|是| C[坐标抽取 字符流] B -->|否| D[图像预处理 纠偏去噪] D --> E[OCR 识别 带置信度] C --> F[版面重建 分栏 排序 识别标题] E --> F F --> G[表格单独识别 整体保留] G --> H[剔除页眉页脚与重复内容] H --> I[输出结构化块 带类型与页码] I --> J[按语义边界切分] J --> K[入库 供检索]

顺序上有个细节:先重建、后清洗。页眉页脚的判断依赖跨页统计,得先有完整的页和行信息;反过来先按行清洗,容易把正文里正常出现的公司名也删掉。

八、解析质量怎么验

"跑通了"不等于"解析对了"。验收至少三层:

层次 怎么做 异常信号
字符层 统计每页字符数、乱码比例 某些页字符数异常低(没抽到)或异常高(混入噪声)
结构层 抽查标题层级、表格数量、列表编号 标题全平、表格数为 0、编号丢失
效果层 用固定问题集测 Recall@N 已知答案的文档检索不到

第一层能自动跑,适合放进流水线做体检;第二层抽样人工看,二十篇就能发现系统性错误;第三层最贵也最准,解析逻辑一改就要重跑------因为它影响的正是"文档能不能被召回",而这件事只有检索指标能反映。

还有几个便宜的检查:页眉页脚残留率(统计高频重复短串)、表格识别条数与人工抽看的差异、断行导致的句子碎片比例。

哦,原来是这样:解析的验收标准不是"文本看起来正常",而是"检索能不能命中"。看起来通顺的文本,照样可能缺了最关键的那张表。

九、另一类硬问题:字体编码与乱码

还有一种失败长得很奇怪:文本层明明存在,抽出来却是一堆方块、\ufffd 替换符,或者看着像英文乱码、复制出来完全对不上原文的字。

原因在 PDF 的存储方式。PDF 里的文字不是按 Unicode 存的,而是按字形编号 (glyph id)存的,要靠字体里自带的 ToUnicode 映射表才能翻译回字符。生成这份 PDF 的工具如果没写这张表、或者写错了,抽取时就只能拿到编号,翻译不回来。

典型出现在这几种文档:早期排版软件导出的中文 PDF、字体做了子集化嵌入的 PDF、扫描件后面又叠加了一层来路不明的文本层。

处理有固定顺序,因为成本从低到高:

  1. 先确认是不是编码问题。抽一页看字符分布:大量落在私人使用区、或者满是替换符,基本可以确定。
  2. 换一个解析库重试 。不同库对 ToUnicode 缺失时的处理策略不一样,有的会退回按字形名猜测,换一个常常就好了。这是最省事的一步,先试。
  3. 读字体文件反查 。字体里的 cmap 表记录了字符与字形的对应,可以自己反查还原。
  4. 都失败就走 OCR。把这一页当扫描件重新读一遍,虽然慢、有识别误差,但至少能拿到可用文本。
graph TD A[抽取文本] --> B{大量替换符或方块?} B -->|否| OK[进入版面重建] B -->|是| C[换解析库重试抽取] C --> D{仍然乱码?} D -->|否| OK D -->|是| E[读字体 cmap 反查字形] E --> F{能还原?} F -->|是| OK F -->|否| G[该页按扫描件走 OCR] G --> OK

要特别提醒一句:别试图用正则或词典去"修"乱码。编码映射丢了就是丢了,你看到的那些符号和原文之间没有可推导的关系,硬修只会产出看起来通顺、实际错误的内容------这比直接报错更糟,因为没人会发现。

哦,原来是这样:乱码不是文本质量问题,是编码映射缺失。再怎么清洗都修不出来,只能换一条路把这段内容重新读一遍。

十、常见坑

  • 按扩展名分流:同一个 PDF 有电子版也有扫描版,判断错了后面全错。
  • 直接按坐标从上到下读:双栏文档被读成交错文本。
  • 不清页眉页脚:高频重复串打乱逆文档频率,检索被噪声带偏。
  • 把换行当段落结束:句子被切成两段,切分时语义断裂。
  • 表格按字符数切:后半段只剩数字,模型看不懂列含义。
  • OCR 结果直接当准确文本:不存置信度,错了也无法定位。
  • 中间格式只有纯文本:丢掉标题与页码,后面想按语义切分都没依据。
  • 先清洗后重建:页眉页脚统计不准,误删正文。
  • 只在开发时抽几篇看:上线后遇到新排版就崩,没有自动体检。

十一、什么时候不用自己写解析

自己搭解析器成本不低,尤其是表格和扫描件。以下几种情况可以直接用现成的解析服务或成熟库:文档量小、格式单一(比如全是自家系统导出的 docx)、对表格精度要求不高。

但只要出现这两条,就值得自己把控:文档是核心资产 (解析质量直接决定产品效果),或者格式杂、来源不可控(用户上传的 PDF 什么排版都有)。这时候至少要自己定义中间格式、自己做质量体检,把识别部分交给第三方,验收部分留在自己手里。

收尾

文档解析这条链路,一句话概括:先判断有没有文本层,再重建版面顺序,表格整体保留,扫描件带着置信度用,最后输出带类型的结构化块。

它不产生任何"AI 效果",但决定了后面所有环节的上限。一份被读乱的手册,换再强的模型也答不对;一份结构完整的手册,用普通检索就能答得不错。

所以当 RAG 效果不达预期时,先别急着调模型。抽十篇原文,把解析结果和原文对一遍,很多问题在那儿就能看见。