刚开始做企业法律 RAG 时,我对 PDF 解析的要求并不高。只要 PyMuPDF 能正常打开文件,get_text() 能把正文抽出来,我就觉得最难的一步已经过去了。真正开始检查合同、法条和工程类法律文件以后,我才发现这个判断完全错了:PDF 最麻烦的地方从来不是"有没有文字",而是"这些文字原来属于什么结构"。
这次问题最后没有表现成一个明确的报错,反而更难排。后端能启动、Parser 能返回文本、Embedding 也能生成向量,整个 RAG 链路从表面看都是通的,但人工抽查时不断出现页眉混入正文、页码碎片、跨页条款断裂和公式被拆散的情况。也就是说,系统不是"读不到 PDF",而是"读到了文字,却没有真正读懂文档"。

图 1:字符被成功提取,并不代表标题、页眉、页码、条款和公式结构也被正确恢复。
1. 我最开始把"文本抽取"当成了"文档解析"
PDF 和 DOCX 最大的区别之一,是 PDF 更接近一张按坐标绘制出来的数字画布。页面上看起来连续的一段文字,底层可能只是很多字符、字体和绘制位置的组合;同理,视觉上很明确的标题、页眉、页脚和表格,也不一定天然带着"我是标题""我是页脚"这样的结构标签。项目里的 PDF Parser 基于 PyMuPDF 1.28.2 构建,文本能够稳定读出来,但真正困难的是怎样把这些结果恢复成后续 RAG 可以依赖的结构。
这一点后来也和 PyMuPDF 官方文档里的描述对上了。PDF 内部文本顺序取决于文件是怎么生成的,内部顺序可能和人眼阅读顺序不一致,sort=True、block 坐标和 bbox 可以帮助恢复阅读顺序,但复杂多栏和特殊布局不存在一个通用的完美方案。对我来说,这意味着 Parser 的目标不能再只是返回一个 str,而必须保留位置、页码、类型和来源信息,让后续还有继续判断的空间。
2. 第一类问题:竖排页眉被解析成单字碎片
最典型的一次出现在一份《民法典》公报版本里。页面侧边有一条竖排的"中华人民共和国全国人民代表大会常务委员会公报",人眼看一秒钟就知道这是每页重复出现的页眉,但 Parser 输出里却会变成"中 / 华 / 人 / 民 / 共 / 和 / 国......"这样的单字碎片流。单看文本内容,这些字符和正文里的汉字没有任何本质区别。
最开始我也想过用规则直接删,比如"长度等于 1 的 Block 就视为噪声"。很快我就放弃了,因为法律合同里"甲""乙""一""□""√"这些单字符完全可能有业务含义,甚至可能属于表单字段或条款层级。如果为了清一个竖排页眉,把真正的法律证据一起删掉,后面的召回再准也没有意义。
最后这个问题没有被包装成"Parser 已经自动解决"。在 Gold 标注里,对应的 U029 最终被判为 DROP_BOILERPLATE,但这个结论依赖人工回看原 PDF,确认它确实是每页重复的侧边页眉,而不是 Parser 自己从纯文本中推断出来的。它也因此被保留为 PDF Parser 的一个已知 P2_MINOR 问题,而不是为了追求漂亮的 PASS 报告强行藏掉。
3. 第二类问题:页码和页脚没有统一长相
页码看起来比竖排页眉简单,但实际处理起来同样麻烦。我在不同 PDF 里见过 --- 1 ---、第 1 页、单独的 1,也见过页码和"共十页"之类的文字粘在一起。纯文本正则能覆盖一部分格式,却很难证明不会误伤正文中的数字、条号和编号。
这个问题让我开始保留 page、block_order、block_type、source_file 和 parser provenance,而不是清洗完只剩下一串文本。因为"它长得像页码"只是一条证据,"它连续出现在每一页底部相似位置"才是更可靠的结构证据。很多 PDF 清洗问题,真正有用的判断信息其实根本不在字符串本身,而在字符串周围的布局和重复模式里。
4. 第三类问题:PDF metadata 居然混进了正文
一份商品房合同里还出现过一个比较隐蔽的问题。Parser 自动注入的 metadata block 中包含类似 Administrator.DESKTOP-QD1UEI6 的 pdf_author 信息,这显然不应该进入法律正文,更不应该参与 Embedding。可是同一个区域里又混着 GF---2025---0151 这样的合同示范文本编号,而这个编号恰恰是有价值的检索和溯源信息。
如果采用"metadata 全删"的策略,会把有价值的合同身份一起丢掉;如果全部留下,又会把机器用户名、作者字段等噪声塞进向量。最后我把"正文"和"文档级元数据"拆开处理,Cleaner 不只有 KEEP 和 DROP,而是增加 MOVE_TO_METADATA、REVIEW 等动作。这样无关属性可以退出 Embedding 文本,但合同编号、来源和标题仍然可以保留在 metadata 里用于过滤和引用。
5. 最棘手的问题不是噪声,而是跨页结构断裂
页眉和页码至少还能看出是"多出来的东西",跨页条款的问题却更隐蔽。法律文件常见"章 → 节 → 条 → 款 → 项"的层级,一条完整条款很可能在第一页写到一半,下一页继续;当前 Parser 主要按页内文本行构建 Block,不会自动理解"下一页开头其实还是上一条的后半段"。结果就是一个完整法律证据在进入 Chunking 之前已经被拆成了两三个弱语义 Block。
如果后面的 Chunker 再按固定字符数切一次,损伤会继续放大。最终你看到的现象可能只是"这个问题为什么召回不到正确条款",很容易把锅甩给 Embedding、TopK 或 Reranker,但真正的错误其实早在 Parser 阶段就发生了。模型拿到的从一开始就不是完整证据,再强的向量模型也只能在一堆残缺输入里寻找最接近的片段。

图 2:Parser 的结构损伤会继续传递给 Cleaner、Chunking 和 Retrieval,最终表现成看似"检索模型不够强"的问题。
6. 建设工程合同里的公式让我彻底放弃"只看文本是否非空"
这次排查里让我印象最深的是一份建设工程合同。第 45 页有价格调整公式,视觉上是一条正常的数学表达式,但 Parser 输出后只剩下 t1 / t2 / t3 / tn / P / A / B / ∆ / = / + / × 这样的变量和符号碎片。每个字符其实都"提取成功"了,但公式本身的数学关系已经消失。
如果测试只写成 assert extracted_text is not empty,这个 Case 会毫无问题地 PASS。可一旦把它放进 RAG,这些碎片既不能稳定表示原公式,也无法判断它们是不是应该参与法律证据检索。最后这个 Gold Unit U032 被放进 REVIEW,而不是强行判成 KEEP 或 DROP,这也成了整个 Cleaner 设计里"允许系统承认不确定"的典型案例。
这件事后来改变了我对 Parser 测试的理解。解析正确性不能只看字符有没有掉,还要看结构是不是仍然具有业务意义;特别是公式、表格和跨页条款,字符级正确和语义级正确完全可能是两回事。对于法律 RAG 来说,错误保留一段看似正常但已经失真的证据,有时比直接报错更危险。
7. 我也踩过一个和 PDF 内容无关的环境坑
Parser 本身稳定以后,我还遇到过一次很难第一眼看出来的环境问题。Conda 环境里明明装着正确的 PyMuPDF,但 Python 进程实际 import 到了 user-site 里的另一份旧包,最后根因被定为 USER_SITE_SHADOW_REINTRODUCED。如果只看 pip list,很容易误以为当前运行环境完全正常。
后来这类问题我不会只确认"安装了哪个版本",还会直接打印模块真实路径,例如 print(fitz.__file__),确认当前 Python 进程到底加载了谁。项目运行时最终通过 PYTHONNOUSERSITE=1 隔离 user-site 污染。这个坑看起来和文档理解无关,但 Parser 一旦发生依赖漂移,输出结构的变化会继续影响 Block、Chunk ID、Embedding 和离线评测,所以它同样属于解析链路的一部分。
8. 后来我不再让 Parser 只返回一大段字符串
这些问题集中出现以后,我最终把 PDF、DOCX、HTML 都收敛到统一的 ParsedDocument V2。每个 Block 不只保存 block_text,还保留 block_type、original_block_order、page、source_file、source_sha256、parser_name、parser_version、表格摘要和场景标签。Block 类型至少区分 HEADING / PARAGRAPH / LIST_ITEM / TABLE / METADATA / UNKNOWN。
这样做真正有价值的地方,是下游终于知道自己正在处理什么。HEADING 会影响 section path 和 chunk 边界,TABLE 不能像普通段落那样随便截断,METADATA 可以退出 Embedding 正文但继续保留给检索过滤,普通 PARAGRAPH 才进入正文清洗和结构化切块。如果 Parser 第一层就把所有信息压成一个字符串,这些结构后面基本不可能可靠地补回来。
Source PDF
↓
PyMuPDF extraction
↓
ParsedDocument V2 / Block IR
↓
Cleaner V2
↓
Metadata Normalizer
↓
Quality Gate
↓
Structure-Aware Chunking
↓
Embedding / Retrieval
9. 为什么最后我没有继续疯狂堆正则
解决 PDF 噪声最容易走向的方向,就是不断增加 is_page_number()、looks_like_header()、len(text) <= 2 之类的规则。短期看命中率会越来越高,但规则越多,我越担心它把真正的法律证据一并删除。法律场景里最大的风险不是多保留几个页眉,而是把责任条款、合同编号、表单字段或者结构标题误判成垃圾。
所以后来我把职责拆开:Parser 尽量忠实恢复结构,Cleaner 负责 KEEP / DROP / MOVE / REVIEW,Metadata Normalizer 负责统一 canonical 字段,Quality Gate 负责阻断真正严重的 Parser 错误和 ContractViolation,Chunker 等结构稳定以后再切。这套拆分看起来比"一个函数把 PDF 清洗完"麻烦很多,但每一步都更容易解释、评估和回归。
10. 最终结果并不是"PDF Parser 完美支持"
这轮收口时,PDF Parser 的正式状态不是 PASS,而是 CLOSED_WITH_KNOWN_MINOR_ISSUE。人工缺陷审查里 P0_CRITICAL = 0、P1_MAJOR = 0、P2_MINOR = 2、P3_DISPLAY_ONLY = 0,其中 FIX-006 的竖排页眉碎片就是明确留下的已知问题。对我来说,这种结论反而比一句"支持所有 PDF"更可信。
后续 Cleaner lineage 全量复现时,49 份文档一共有 18,755 个原始 Block,清洗后剩 18,317 个,DROP / DEDUPLICATE 共 438 个;其中按格式统计,PDF 贡献了 225 个被删除或去重的 Block。更重要的是,所有决定 KEEP 的 Block 都保持原文不变,retained_block_text_changed = 0。也就是说,清洗可以删除噪声和调整结构,但不能在用户不知道的情况下偷偷改写法律原文。
11. 如果现在重新设计 PDF Parser,我会往 layout-aware 走
当前这套 Parser 本质上仍然是 text-first,所以它在正常文本型 PDF 上够用,但遇到复杂视觉布局时能力边界很明确。如果继续升级,我不会再把主要精力放在补字符串正则上,而会把 bbox、字体、方向和页面区域真正纳入 Layout Analysis,先区分 Header / Footer / Body / Table / Formula / Figure,再进入统一 Block IR。扫描版 PDF 则单独走 OCR 分支,而不是和可复制文本 PDF 混用同一套路径。
这个方向和现在成熟文档解析工具的思路是一致的。Docling 的统一文档模型会显式区分正文、表格、图片以及 header/footer,并保存层级、reading order、bbox 和 provenance;PyMuPDF 自身也已经提供 blocks、words、bbox、文字方向和 OCR 等底层能力。区别只在于,底层工具能给出信号,真正面向业务的 Parser 仍然要决定这些信号如何变成可靠的文档结构。
12. 这次 PDF 排查真正让我学到的东西
以前遇到 RAG 检索不准,我很自然会先看 Embedding、TopK 和 Reranker。做完 PDF Parser 以后,我会先往上游倒查:原始 PDF 有没有被正确解析,标题和条款结构有没有保留,Cleaner 有没有误删,Chunk 是不是在一个已经破碎的 Block 上继续切。因为数据一旦在最前面损坏,后面的模型只能在错误输入上继续优化。
所以我现在更愿意把 PDF 解析定义成:在尽量不损失证据的前提下,把页面恢复成可检索、可切分、可引用、可追溯的结构化语义单元。 "能把文字抽出来"只是这件事的第一步,不是完成标准。很多看起来发生在 Retrieval 层的问题,真正的根因可能早在 Parser 阶段就已经埋下了。
并且,如果后续文档中出现扫描件,必须得采用OCR进行辅助识别,国产paddle ocr是个不错的选择,对中文适配性很强。