PDF 明明能抽出文字,为什么我的 RAG 还是检索得一塌糊涂?一次法律文档解析踩坑复盘?
做 RAG 之前,我对 PDF 解析的理解其实很简单:
PDF
↓
提取文本
↓
切块
↓
Embedding
↓
Milvus
只要 extract_text() 能把字拿出来,我就默认"解析完成了"。
真正拿合同、法律文件跑起来之后,我才发现这件事远没有这么简单。
PDF 里最麻烦的问题,往往不是"有没有文字",而是:
这些文字原本是什么结构?它是正文、标题、页眉、页码、表格,还是公式?
如果这一层判断错了,后面的 Cleaner、Chunking、Embedding、Rerank 都只能在错误的输入上继续加工。
这篇文章记录的是我在企业法律 RAG 项目里处理 PDF 时实际遇到的一些问题,以及我最后为什么把"PDF 转文字"改造成了一套 Block 化、可追溯、可审计的解析链路。
1. 最开始的问题:PDF 能读出来,不代表读对了
我的项目里有 PDF、DOCX、HTML 等多种法律文档。为了统一后续清洗和切块,我最终把它们都转换成同一种中间结构:
Source Document
↓
Parser
↓
ParsedDocument / Block IR
↓
Cleaner
↓
Metadata Normalizer
↓
Quality Gate
↓
Chunking
↓
Embedding / Retrieval
真正让我重新认识 PDF 的,是人工检查 Parser 输出时看到的一些结果。
从视觉上看完全正常的一页 PDF,提取出来以后可能变成:
中
华
人
民
共
和
国
...
第一条
为了规范......
人眼一看就知道左边那一列是竖排页眉,但 Parser 并不知道。
它只知道:
这里有一些字符,而且这些字符确实存在于 PDF 里。
这就是我后来一直强调的一点:
Text Extraction 和 Document Parsing 根本不是一回事。
前者解决"字在哪里",后者要解决"这些字是什么意思、属于什么结构"。
2. 第一个坑:竖排页眉被拆成一堆单字
在一份《民法典》公报版本的 PDF 里,每一页侧边都有竖排页眉。
视觉上类似:
中华人民共和国
全国人民代表大会
常务委员会公报
但是 text-first 的解析方式拿到的可能是:
中
华
人
民
共
和
国
全
国
人
民
...
如果直接把这些文本送去切块,会出现两个问题。
第一,它们可能混进正文。
第二,因为这些字每页重复出现,会制造大量没有检索价值的重复 token。
更麻烦的是,我不能简单写一条规则:
if len(text) == 1:
drop()
因为法律文件里真的可能存在有意义的单字符内容、序号、符号甚至表单项。
最后这类内容没有在 Parser 阶段被"自信地删除",而是交给后面的 Cleaner,根据页面位置、重复模式、场景标签和人工 Gold 数据决定是否属于 boilerplate。
这件事让我意识到:
Parser 最危险的行为不是少做,而是自以为正确地删东西。
法律 RAG 和普通网页知识库不太一样。少一个页眉问题不大,但如果误删了一句责任条款,后面召回、生成和引用都会一起出问题。
所以我的处理原则逐渐变成:
高置信度结构 → 自动处理
不确定内容 → 保留 / REVIEW
明确噪声 → DROP
而不是"看起来像垃圾就删掉"。
3. 第二个坑:页码不是永远长成"第 1 页"
页码也是一个很容易低估的问题。
我遇到过的 PDF 里同时存在:
--- 1 ---
第 1 页
1
第1页 共10页
甚至页码还会和附近的文字粘在一起。
如果只靠正则:
第\s*\d+\s*页
能解决一部分,但不可能覆盖所有版式。
PDF 的麻烦就在这里:同样是"页码",它在文件内部未必拥有任何 page_number 语义,只是恰好画在页面底部的一串字符。
后来我不再把"文本长什么样"作为唯一判断条件,而是开始保留:
-
page number
-
block order
-
block type
-
source file
-
parser version
-
block provenance
这样 Cleaner 才能结合"它在哪一页、处于什么位置、是否跨页重复"继续判断,而不是面对一串已经丢失上下文的纯文本。
4. 第三个坑:PDF metadata 也会污染 RAG
还有一个问题挺有代表性。
某份合同的 PDF metadata 里出现过类似:
Administrator.DESKTOP-XXXX
这种信息对法律问答没有任何意义。
如果把它直接拼进正文,再送去 Embedding,就相当于主动给向量加噪声。
但麻烦的是,同一个 metadata block 里又可能混着有价值的信息,比如:
GF---2025---0151
这其实是合同示范文本编号。
所以这里不能粗暴地:
metadata block = 全删
更合理的做法是把"正文"和"文档级元数据"分开。
我后来给 Cleaner 设计了类似这样的动作语义:
KEEP
MOVE_METADATA
DROP
DEDUPLICATE
REVIEW
也就是说:
Administrator.DESKTOP-XXXX
这种内容可以从正文 Embedding 输入中移走;
而合同编号、文档标题、来源等信息可以保留到 metadata 中,供过滤、引用和溯源使用。
这一步对 RAG 很重要,因为很多检索问题其实同时包含两类信号:
语义信号:解除合同的条件是什么?
身份信号:某某示范合同 / 某个合同编号
如果所有东西都塞进 embedding text,容易污染语义;
如果全扔掉,又失去了精确过滤和文档定位能力。
5. 第四个坑:跨页以后,一个完整条款可能被 Parser 拆成三块
合同和法律文件经常有这种情况:
第十一条 违约责任
......
第一页结束
----------------
下一页继续
......
对人来说,这显然还是同一条。
但当前 text-first Parser 的主要工作单位是 page,页与页之间天然存在边界。
于是可能得到:
Block 101:
第十一条 违约责任......
前半段
Block 102:
后半段......
Block 103:
下一条......
这时候如果下游继续按固定长度切块,结果可能更糟:
原本一个完整法律条款
↓
PDF page boundary 切一次
↓
Parser block 切一次
↓
512 chars 再切一次
↓
最终检索只召回其中一小段
很多时候我们会误以为:
召回效果不好,是不是 Embedding 模型不够强?
但其实问题早在 Embedding 之前就发生了。**模型收到的压根不是一个语义完整的证据单元。**这也是后来我不再只关注 chunk size,而开始做 Structure-Aware Chunking 的原因。
Chunking 要尽量尊重:
Heading
Chapter
Section
Article
Table
Paragraph
而不是只尊重:
每 512 字切一次
6. 最让我印象深刻的问题:公式被解析成字符碎片
项目里有一份建设工程合同,其中包含价格调整公式。视觉上它是一个正常的数学公式。
Parser 输出以后,却变成了类似:
t1
t2
t3
tn
P
A
B
∆
=
+
×
每个变量和操作符都"提取成功"了。但整个公式已经死了。这是 PDF 解析里非常典型的一类问题:**字符级 extraction 是正确的,语义级 extraction 是错误的。**如果我只做自动测试:
assert extracted_text is not empty
它甚至会完美 PASS。但人一看就知道,这些字符根本不能作为可靠的法律证据。当时我没有为了让测试好看强行把它归类成 DROP,也没有假装已经解决公式识别。最终这个单元进入了 REVIEW。这反而成了我后来设计 Cleaner 的一个重要原则:
不确定的时候允许系统承认"不确定"。
比起错误地删除证据或者错误地把碎片当成正文,我更愿意让它进入人工复核。
7. 为什么我没有继续堆正则
遇到这些问题后,一个很自然的思路是疯狂补规则:
if is_page_number(text):
drop()
if looks_like_header(text):
drop()
if looks_like_author(text):
move_metadata()
if len(text) <= 2:
drop()
刚开始确实有效。但规则越加越多,我越来越不放心。因为类似:
第一条
一、......
甲
乙
√
□
在法律合同里都可能是真正文。这类系统最大的风险不是"漏删一点垃圾",而是:**把真正有法律意义的证据当垃圾删掉。**所以我后来把整个预处理链路拆开了:
Parser
负责:尽可能忠实地恢复 Block
Cleaner
负责:判断 Block 是正文、噪声、metadata 还是需要 review
Metadata Normalizer
负责:统一字段,不允许 Cleaner 随便改 canonical metadata
Quality Gate
负责:出现严重 Parser 错误或 ContractViolation 时阻断
Chunker
负责:在结构已经稳定以后再切
每一层只做自己的事情。
8. 我最终没有直接输出"一大坨 text",而是统一成 Block IR
后来 PDF、DOCX、HTML 都不再直接返回字符串,而是进入统一的 ParsedDocument。简化以后,一个 Block 大概有这些信息:
block_type
block_text
original_block_order
page
source_file
source_sha256
parser_name
parser_version
table_summary
scenario tags
block_type 至少区分:
HEADING
PARAGRAPH
LIST_ITEM
TABLE
METADATA
UNKNOWN
这样做的价值不是"代码看起来更高级"。真正的好处是后面每一步终于知道自己处理的是什么。例如:
HEADING
↓
影响 section_path 和 chunk 边界
TABLE
↓
不能像普通 paragraph 一样随便截断
METADATA
↓
可以退出 embedding text,但继续保留在检索 metadata
PARAGRAPH
↓
进入正文清洗和结构化切块
如果 Parser 一开始就只返回:
str
这些信息后面基本不可能可靠补回来。
9. 还有一个很隐蔽的坑:Python 环境把正确的 PyMuPDF 给 shadow 了
解析器本身稳定以后,我还遇到过一次非常"环境工程"的问题。项目环境里明明安装了正确版本的 PyMuPDF,但 Python 运行时又加载到了 user-site 中另一份包。结果就是:
项目环境里的依赖看起来没问题
实际 import 到的却不是那一份
最后确认属于 user-site shadow,运行时需要隔离 user-site:
PYTHONNOUSERSITE=1
这个问题很有意思,因为如果只看:
pip list
很容易以为环境是正常的。
后来我排这类问题会额外确认:
import fitz
print(fitz.__file__)
本质上不是只看"装了哪个版本",而是看:
当前这个 Python 进程到底 import 了谁。
这类环境污染对于 Parser 很危险,因为解析结果可能发生细微变化,而这种变化又会继续传到 chunk ID、embedding 和离线评测里。
10. 怎么判断 PDF Parser 到底能不能上线?
做完这轮以后,我彻底放弃了:
能跑 = PASS
这种验收方式。
我更关心的是:
有没有 P0_CRITICAL?
有没有 P1_MAJOR?
有没有 ContractViolation?
正文有没有被错误删除?
Provenance 能不能回到原始 Block?
同样输入重跑结果是否稳定?
当时 PDF Parser 最终的状态不是"完美解析",而是:
CLOSED_WITH_KNOWN_MINOR_ISSUE
人工缺陷等级里:
P0_CRITICAL = 0
P1_MAJOR = 0
P2_MINOR = 2
P3_DISPLAY_ONLY = 0
已知的小问题就明确保留在工程记录里,而不是为了宣称"100% 支持 PDF"把它藏掉。
我现在反而觉得这种状态比一句:
PDF Parser = PASS
更可信。
11. 后来 Cleaner 的统计也证明:PDF 噪声不是少数情况
后续对完整语料做 Cleaner lineage 恢复时,一共复现了:
原始 blocks:18,755
clean blocks:18,317
DROP / DEDUPLICATE:438
其中按格式统计,被删除或去重的 Block 里:
DOCX:85
Markdown:128
PDF:225
PDF 只有语料的一部分,但贡献了相当多需要治理的 Block。
同时我特意保留了一条很重要的验证:
retained_block_text_changed = 0
也就是说,对决定保留的 Block,Cleaner 不偷偷修改正文内容。它可以:
删除
去重
移动 metadata
重新编号
但不能一边说"KEEP",一边悄悄把法律原文改掉。这对于法律 RAG 的可审计性非常重要。
12. 这些问题最终会怎么影响检索?
很多 Parser 问题看起来只是"文档预处理细节",但最后都会反映到 Retrieval。比如页眉没有删除:
重复页眉
↓
进入大量 chunk
↓
高频无意义 token 增多
↓
Embedding / BM25 都受到污染
跨页条款断裂:
完整证据
↓
被拆成多个弱语义 Block
↓
Chunk 继续分裂
↓
正确证据 Rank 下滑
metadata 混进正文:
作者名 / 文件属性 / 机器用户名
↓
进入 embedding
↓
增加无关语义
标题和章节结构丢失:
正确正文还在
↓
但不知道它属于哪一章、哪一条
↓
Parent Context 和 Citation 质量下降
所以后来我越来越认同一句话:
RAG 的很多"检索问题",其实是数据问题;很多"数据问题",其实早在 Parser 阶段就已经发生了。
13. 为什么 PyMuPDF 本身没有错
这里也要替 PyMuPDF 说句话。我后来专门看了官方文档,PyMuPDF 自己也明确说明过:PDF 内部保存的文本顺序不一定等于人眼看到的自然阅读顺序。它可以提供:
text
blocks
words
bbox
sort
这些能力帮助我们恢复阅读顺序,但复杂多栏、特殊排版、页眉页脚、公式等情况并不存在一个通用算法可以百分之百恢复。
所以问题不是:
PyMuPDF 不行。
而是:
我一开始把"PDF 文本抽取库"当成了"文档理解系统"。
这是两个完全不同的能力层级。
14. 如果今天重新设计,我会怎么做?
如果继续升级这套 PDF Parser,我不会再继续无限堆 text regex,而会往 layout-aware 的方向走。大概会拆成:
PDF
│
├── 可提取文本页
│ ↓
│ text + bbox + font
│
├── 扫描页
│ ↓
│ OCR
│
└── 复杂布局页
↓
Layout Analysis
↓
Header / Footer / Body / Table / Formula / Figure
↓
统一 Block IR
表格、公式、扫描件分别走自己的分支。这个方向和现在主流文档解析框架的设计其实是一致的。Unstructured 会针对 PDF 提供 fast、hi_res、ocr_only 等不同策略;复杂布局和表格需要更高分辨率的布局分析,而不是所有 PDF 都走同一个纯文本流程。Docling 的 Document Model 也会显式区分 text、table、picture,并通过树结构保留 reading order、parent/child 和正文之外的 header/footer 等 furniture。这也是我认为生产 RAG 和 Demo RAG 很大的区别:Demo 关注:
能不能把 PDF 塞进向量库?
生产环境更关心:
塞进去的到底是什么?
结构有没有丢?
证据还能不能追溯?
解析错误能不能被发现?
以后重新跑还能不能得到同样结果?
15. 最后总结
这轮 PDF Parser 改造以后,我最大的收获并不是学会了更多 PyMuPDF API。而是彻底改掉了一个错误认知:
PDF 解析不是"把文字拿出来"。
对于 RAG 来说,更准确的定义应该是:
在尽量不损失证据的前提下,把 PDF 恢复成可检索、可切分、可引用、可追溯的结构化语义单元。
所以现在再遇到 RAG 检索异常,我不会第一时间问:
是不是该换 Embedding?
是不是 TopK 太小?
是不是 Reranker 不行?
我会先往上游看:
原始文档
↓
Parser
↓
Cleaner
↓
Chunk
↓
Embedding
↓
Retrieval
因为只要 Parser 在最开始把一条完整法律证据拆坏了,后面再强的模型,也只是在努力理解一份已经损坏的输入。
延伸阅读
如果你也在做 PDF RAG,可以重点看这几类资料:
-
PyMuPDF:Text Extraction / Reading Order / Blocks
-
Unstructured:PDF Partitioning Strategies
-
Docling:Document Model 与结构化 PDF Parsing
它们共同指向一个很实际的结论:
复杂 PDF 的核心问题从来都不只是 OCR 或 extract text,而是 layout、structure 和 provenance。