1. 为什么 RAG 项目总在 PDF 解析这一步翻车
做过 RAG 的人大概都有过这种体验:向量库搭好了,检索链路跑通了,大模型也接上了,Demo 演示时效果惊艳,结果一上真实文档就原形毕露。问它合同里的违约金比例,它给你编一个;问它财报里的营收数字,它把表格里的两列数据串成一行读出来。排查半天,发现问题根本不在检索环节,也不在生成环节,而是最前面那一步——PDF 压根就没被正确地读成文本。
PDF 这个格式从诞生之初就不是为了“被机器理解”而设计的。它的核心目标是“在任何设备上看起来都一样”,所以它更像是一张张精确排版的画布,文字、线条、图片被放在固定的坐标上,至于这些内容之间的语义关系,PDF 本身并不关心。这就导致了一个很尴尬的局面:人眼看着排版清晰的文档,程序读出来却是一团乱麻。标题和正文混在一起,表格的单元格顺序全乱,页眉页脚被当成正文塞进 chunk,双栏排版的论文被按行交错拼接,读起来像精神分裂。
pdf-inspector这个工具就是冲着这个痛点来的。它不是又一个“PDF 转文本”的轮子,而是一个专门为 RAG 场景设计的 PDF 结构解析与质量检查工具。你可以把它理解成 PDF 解析流水线上的质检员加调度员:一方面它能帮你把 PDF 里的内容按照语义结构抽出来,输出成对 RAG 友好的 Markdown;另一方面它能告诉你这份 PDF 到底“好不好啃”,哪些页面是扫描件需要走 OCR,哪些页面是原生文本可以直接抽,表格和图片分别分布在哪些位置。
这篇文章适合两类人看。一类是正在做 RAG 知识库、被 PDF 解析折磨得死去活来的工程师,另一类是想搞清楚 PDF 解析到底难在哪、该怎么选型的技术负责人。我会从整体设计思路讲到具体实操,把踩过的坑和验证过的参数都摊开来说,尽量让你看完就能上手,而不是又看了一篇“原理介绍”。
2. 整体设计思路:为什么不能直接上 OCR 一把梭
2.1 PDF 的两副面孔:原生文本层与扫描图像层
很多人对 PDF 有个误解,觉得 PDF 就是图片,所以直接上 OCR 就完事了。这个思路在部分场景下没错,但用在 RAG 上会吃大亏。PDF 实际上分两种:一种是“数字原生 PDF”,由 Word、LaTeX、排版软件导出,里面每一行文字都有真实的字符编码和坐标信息;另一种是“扫描 PDF”,本质是一堆图片,文字信息完全不存在,只能靠 OCR 识别。
这两者的处理成本和准确率天差地别。原生 PDF 抽文本几乎是零成本、接近百分之百准确;扫描 PDF 走 OCR 不仅慢,还会引入识别错误,尤其是中文、公式、特殊符号,错一个字符可能就让检索命中率掉一大截。所以一个成熟的解析流程,第一步永远是判断“这一页有没有文本层”,而不是无脑 OCR。
pdf-inspector在这件事上的价值就体现出来了。它会在解析前先做一次“体检”,逐页统计可提取字符数、图片覆盖率、字体信息等指标,给出一个页面类型的判断。你可以据此决定哪些页走快速文本抽取,哪些页走 OCR,哪些页干脆标记为“低质量、建议人工复核”。这个分流动作看起来简单,但它能帮你省下大量的算力和调试时间。
2.2 为什么输出格式选 Markdown 而不是纯文本
纯文本抽取是最省事的,但也是最坑 RAG 的。原因在于纯文本丢掉了所有结构信息:标题和正文没有层级区分,表格变成一堆用空格对齐的数字,列表项的层级关系消失。当你的切分器(splitter)按固定长度切 chunk 时,它根本不知道自己在切的是标题还是正文,结果就是检索出来的片段语义不完整。
Markdown 的好处是它用极轻量的语法保留了结构:#表示标题层级,|表示表格,-表示列表,>表示引用。这些符号对 RAG 的切分器来说是极其宝贵的信号。你可以按标题层级切分,保证每个 chunk 都在一个语义单元内;你也可以识别表格块,单独做结构化处理而不是硬切。pdf-inspector把输出对齐到 Markdown,本质上是在为下游的切分和检索铺路。
这里有个实操细节值得说:Markdown 的表格语法对 RAG 特别友好,因为它是“行列对齐”的。一个财务表格转成 Markdown 后,每一行是一条记录,表头在顶部,检索时模型能清楚知道“这一列是营收、那一列是成本”。而如果转成纯文本,表格往往变成“营收 成本 利润 100 80 20”这样一串数字,模型只能靠猜。
2.3 解析、检查、分流三位一体的设计
把pdf-inspector单纯当成一个转换器是低估它了。它的设计思路其实是三位一体的:解析负责把内容抽出来,检查负责评估质量,分流负责决定后续处理路径。这三件事在真实项目里是强耦合的。
举个实际场景:你收到一批 500 份 PDF 合同,不可能每份都人工看一遍。你需要先跑一遍检查,看看有多少是扫描件、多少是原生件、多少是混合的。然后对原生件直接抽文本,对扫描件排队走 OCR,对混合件按页分流。这个过程如果没有工具支撑,纯靠脚本拼凑,维护成本会非常高。pdf-inspector把这条链路收敛到一个工具里,你只需要配置好阈值和输出格式,剩下的它帮你判断。
提示:不要一上来就追求“全自动完美解析”。真实项目里,先跑一遍检查、拿到质量报告、再决定处理策略,比盲目全量 OCR 要高效得多,也更省钱。
3. 核心细节解析:PDF 解析里那些要命的坑
3.1 阅读顺序:双栏论文为什么读起来像乱码
学术论文、杂志、产品手册大量使用多栏排版。PDF 里这些栏的文字是按坐标存放的,如果你简单地按“从上到下、从左到右”抽取,就会把左栏第一行、右栏第一行、左栏第二行、右栏第二行这样交错拼起来,读出来完全不通顺。
正确的做法是基于文本块的坐标做“分栏聚类”,先识别出页面有几栏,再按栏的顺序逐栏抽取。pdf-inspector在处理这类文档时,会先分析文本块的横向分布,找到栏与栏之间的空白间隙,把文本块归到不同的栏里,再按栏内顺序输出。这个逻辑听起来简单,但阈值调不好就会出错:间隙设太小,单栏文档被误判成多栏;设太大,多栏文档又识别不出来。
我的经验是,对于 A4 幅面的文档,栏间距通常在 20 到 40 像素之间(按 72dpi 计算)。你可以先用默认阈值跑一遍,看看输出的阅读顺序对不对,再针对性调整。如果文档来源比较杂,建议按来源分组处理,而不是用一套参数打天下。
3.2 表格还原:RAG 里最容易丢分的地方
表格是 RAG 的重灾区。原因很简单:表格的信息密度极高,一个单元格错了,整条记录的语义就变了。而 PDF 里的表格往往没有真正的“表格结构”,只有一堆线条和文字块。解析器需要根据线条位置、文字对齐方式反推出行列关系。
这里有个关键判断:表格是“有线表”还是“无线表”。有线表有明确的边框线,解析相对容易,靠线条交点就能定位单元格。无线表只有文字对齐,需要靠列与列之间的空白间隙来推断,难度大很多。pdf-inspector会尝试识别这两种情况,并在输出 Markdown 表格时尽量保持行列对应。
实操中我踩过的一个坑是合并单元格。PDF 里的合并单元格在视觉上是一个大格子,但解析出来往往变成“第一行有值、下面几行空着”,或者值被重复填充。对于 RAG 来说,前者会导致信息丢失,后者会导致信息冗余。处理这类表格,我的建议是:如果表格结构复杂(有大量合并单元格),不要指望自动解析完美,而是把表格区域单独截出来,用多模态模型或人工方式补充描述,再作为独立 chunk 入库。
| 表格类型 | 识别难度 | 推荐处理方式 | RAG 友好度 |
|---|---|---|---|
| 有线规则表 | 低 | 直接解析为 Markdown | 高 |
| 无线对齐表 | 中 | 解析后人工抽检 | 中 |
| 含合并单元格 | 高 | 截图加描述 | 中低 |
| 跨页表格 | 高 | 按页解析后拼接 | 中 |
3.3 页眉页脚与页码:噪声是怎么污染检索的
页眉页脚是 RAG 里最隐蔽的噪声源。它们出现在每一页的固定位置,内容往往是公司名、文档标题、页码、保密声明。如果你不做过滤,这些内容会被重复切进每一个 chunk,导致检索时大量无关片段被召回,稀释了真正有用的信息。
pdf-inspector的检查环节会统计每页顶部和底部区域的文本重复率。如果某段文字在超过 80% 的页面同一位置出现,基本可以判定为页眉页脚,自动剔除。这个逻辑比硬编码坐标范围要靠谱,因为不同文档的页边距不一样。
但要注意一个例外:有些文档的页眉里包含章节标题,这个信息其实是有用的。我的做法是,把页眉页脚单独抽出来存到一个元数据字段里,而不是直接丢弃。这样既不影响正文检索,又保留了文档的结构信息,需要时可以回溯。
3.4 图片与公式:RAG 知识库到底能不能存图片
热词里有个问题问得很实在:“RAG 知识库能存储图片嘛”。答案是能,但要看你怎么存。直接把图片二进制塞进向量库没有意义,因为向量库检索的是文本语义。正确的做法是给图片生成文本描述,把描述文本向量化,图片本身存到对象存储,通过 ID 关联。
对于 PDF 里的图片,pdf-inspector会提取图片的位置和尺寸信息,你可以据此判断哪些图片是“有信息量的”(比如流程图、架构图、数据图表),哪些是“装饰性的”(比如 logo、分隔线)。有信息量的图片建议走多模态模型生成描述,装饰性的直接跳过。
公式是另一个难点。PDF 里的公式如果是用 LaTeX 排版的,可能保留了一些结构信息;如果是图片形式的,就只能靠 OCR 或专门的公式识别模型。对于技术类文档,公式往往承载核心信息,建议单独处理,转成 LaTeX 或 MathML 后作为独立 chunk 入库,并在描述里说明公式的上下文。
4. 实操过程:从零跑通一条 PDF 解析流水线
4.1 环境准备与依赖安装
先把基础环境搭起来。pdf-inspector通常依赖几个底层库:处理 PDF 结构的、做 OCR 的、做图像分析的。我建议用虚拟环境隔离,避免版本冲突。
python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install pdf-inspector如果你需要 OCR 能力,还要额外装 OCR 引擎。中文场景我推荐 PaddleOCR,识别率和速度都比较均衡;英文场景 Tesseract 够用。注意 OCR 引擎的版本要和你的系统架构匹配,尤其是 ARM 机器上,有些预编译包不一定能用。
pip install paddleocr paddlepaddle装完之后先跑一个最小验证,确认工具能正常导入、能读到一个 PDF 文件。这一步别省,很多问题在环境阶段就暴露出来比在流水线里排查要容易得多。
4.2 第一步:先做体检,别急着解析
拿到一批 PDF,第一件事不是解析,而是体检。跑一遍检查,看看每份文档的页面类型分布。
from pdf_inspector import Inspector inspector = Inspector() report = inspector.inspect("contract_sample.pdf") print(report.summary()) print(report.page_types()) # 每页是 text / scanned / mixed print(report.quality_score()) # 0-100 的质量分体检报告里我重点看三个指标:文本覆盖率、图片覆盖率、字体嵌入情况。文本覆盖率低于 10% 的基本是扫描件,直接走 OCR;覆盖率在 10% 到 60% 之间的往往是混合件,需要按页分流;高于 60% 的原生件直接抽文本。字体嵌入情况能反映文档是否被特殊处理过,有些加密或子集化的字体会导致抽取乱码,这种要提前标记。
注意:体检阶段不要跳过任何一份文档。我见过太多项目因为“觉得这批文档都是原生件”而跳过检查,结果混进去几份扫描件,导致检索时那部分内容完全查不到,排查了半天才发现。
4.3 第二步:按页面类型分流处理
体检完就进入分流环节。原生文本页走快速抽取,扫描页走 OCR,混合页按区域判断。这里的关键是“按页”而不是“按文档”分流,因为很多 PDF 是图文混排的,整份文档走同一条路径会浪费算力或损失质量。
from pdf_inspector import Parser, OCRRouter router = OCRRouter(text_threshold=0.1) parser = Parser(router=router) result = parser.parse("contract_sample.pdf", output_format="markdown") with open("contract_sample.md", "w", encoding="utf-8") as f: f.write(result.markdown)text_threshold这个参数控制“一页里文本占比低于多少就判定为扫描页”。默认 0.1 是个比较稳的值,但如果你处理的文档普遍是图文混排的,可以适当调高到 0.2,让更多页面走 OCR 以保证完整性。反过来,如果文档很干净,调到 0.05 能省不少 OCR 开销。
4.4 第三步:输出 Markdown 并做后处理
解析出来的 Markdown 不能直接入库,还要做几件后处理的事。第一是清理多余空行和断行,PDF 抽取经常把一句话拆成好几行,需要合并。第二是修复表格,检查行列数是否一致,不一致的标记出来人工复核。第三是补充元数据,把页码、章节标题、来源文件名写进 chunk 的 metadata 里。
import re def clean_markdown(md: str) -> str: # 合并被硬换行拆断的句子 md = re.sub(r'(?<![。!?.!?])\n(?![\n#|\-])', '', md) # 压缩连续空行 md = re.sub(r'\n{3,}', '\n\n', md) return md.strip() cleaned = clean_markdown(result.markdown)这个合并逻辑要小心,别把列表项和表格行也合并了。所以正则里排除了以#、|、-开头的行。实际用的时候建议先在小样本上验证,确认不会误伤再批量跑。
4.5 第四步:切分与入库的衔接
解析完的 Markdown 怎么切分,直接决定了检索质量。我的建议是按标题层级切,一级标题作为大 chunk 的边界,二级标题作为子 chunk 的边界,如果某个子 chunk 还是太长,再按段落切。这样能保证每个 chunk 都在一个完整的语义单元内。
from langchain.text_splitter import MarkdownHeaderTextSplitter headers_to_split_on = [ ("#", "h1"), ("##", "h2"), ("###", "h3"), ] splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on) chunks = splitter.split_text(cleaned)每个 chunk 的 metadata 里会带上它所属的标题路径,检索时你可以根据这个路径做过滤或加权。比如用户问的是“第三章的付款条款”,你可以优先召回 h1 为“第三章”的 chunk。这个技巧在长文档场景下特别管用,能显著提升命中率。
5. 常见问题与排查技巧实录
5.1 中文乱码与字体缺失
中文 PDF 抽取乱码是最常见的问题之一,根源通常是字体没有正确嵌入或使用了子集化字体。表现是抽出来的文字变成方框、问号或者完全无关的字符。排查方法是先看体检报告里的字体嵌入信息,如果显示“未嵌入”或“子集化”,基本可以确定是字体问题。
解决办法有两个:一是走 OCR 兜底,把这一页当扫描页处理;二是用字体映射表做修复,但这个需要知道原始字体是什么,成本较高。我的经验是,对于中文文档,如果原生抽取乱码率超过 5%,直接整份走 OCR 反而更省心,因为 OCR 对中文的识别已经相当成熟了。
5.2 OCR 识别率上不去的几个原因
OCR 识别率低,先别怪引擎,八成是图像质量的问题。常见原因有:扫描分辨率太低(低于 200dpi)、图像倾斜、对比度不足、有噪点或水印干扰。处理前先做图像预处理,灰度化、二值化、去噪、纠偏,这几步能把识别率拉高十几个百分点。
另一个容易被忽略的点是语言设置。PaddleOCR 默认是中英文混合,如果你处理的是纯韩文或纯日文文档,要显式指定语言,否则识别率会惨不忍睹。热词里有人提到“以下 OCR 代码识别不了韩文”,大概率就是没设语言参数。
from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang='korean') # 韩文要显式指定 result = ocr.ocr('korean_doc.png', cls=True)5.3 表格跨页断裂怎么处理
跨页表格是解析里的一大难题。一个表格从第 3 页底部延伸到第 4 页顶部,解析出来会变成两个独立的表格,而且第二个表格往往没有表头。处理思路是:先识别出“疑似跨页表格”,然后根据列数、列宽、内容连续性判断是否要拼接,拼接时把第一页的表头复制到第二页。
判断是否跨页,可以看前一页最后一个表格和后一页第一个表格的列数是否一致、列宽是否接近。如果一致,大概率是同一个表格。拼接时要注意去掉重复的表头行,避免数据冗余。
| 常见问题 | 典型表现 | 排查方向 | 解决手段 |
|---|---|---|---|
| 中文乱码 | 方框、问号 | 字体嵌入情况 | OCR 兜底或字体映射 |
| OCR 识别率低 | 错字多、漏字 | 图像质量、语言设置 | 预处理加指定语言 |
| 表格错位 | 行列不对应 | 有线/无线、合并单元格 | 单独处理或人工复核 |
| 阅读顺序乱 | 双栏交错 | 分栏聚类阈值 | 调整栏间距参数 |
| 页眉页脚污染 | 重复内容入 chunk | 重复率统计 | 自动剔除加元数据保留 |
5.4 解析速度太慢怎么优化
全量 OCR 是性能杀手。一份 100 页的扫描 PDF,单线程 OCR 可能要跑好几分钟。优化方向有三个:一是分流,能走文本抽取的绝不走 OCR;二是并行,把页面分片后多进程处理;三是缓存,同一份文档解析过一次就存结果,别重复跑。
并行处理时要注意 OCR 引擎的线程安全问题,有些引擎不支持多线程调用,需要用进程池而不是线程池。另外,GPU 加速能显著提升 OCR 速度,如果条件允许,尽量用 GPU 跑。
from concurrent.futures import ProcessPoolExecutor def parse_page(page_num): return parser.parse_page("doc.pdf", page_num) with ProcessPoolExecutor(max_workers=4) as executor: results = list(executor.map(parse_page, range(total_pages)))5.5 几个我踩过的坑和对应技巧
第一个坑是“以为所有 PDF 都一样”。我早期做项目时用一套参数处理所有文档,结果学术论文的阅读顺序全乱,合同文档的表格全错位。后来改成按文档来源分组,每组单独调参,效果立刻好转。PDF 解析没有万能参数,只有针对性的配置。
第二个坑是“忽略元数据”。一开始我只存正文文本,后来发现检索时无法区分内容来自哪份文档、哪一页、哪个章节,排查问题特别困难。现在我会把文件名、页码、标题路径、解析时间都写进 metadata,检索时可以按这些字段过滤,调试效率高很多。
第三个坑是“过度追求自动化”。有些复杂表格和公式,自动解析确实做不到完美。与其花大量时间调参,不如接受“80% 自动加 20% 人工”的模式,把自动解析搞不定的部分标记出来,人工补充描述。这样整体效率反而更高,质量也更可控。
提示:解析质量报告要留档。每次批量解析后把质量分、异常页面列表存下来,后续检索效果不好时,可以快速定位是不是解析环节的问题,而不是盲目怀疑检索或模型。
6. 把解析质量纳入 RAG 的持续监控
RAG 项目上线不是终点,而是起点。用户的问题千奇百怪,检索效果会随着文档更新而波动。我的做法是把 PDF 解析质量纳入持续监控:每次文档更新后跑一遍体检,记录质量分变化;定期抽样检查检索结果,看是否有因为解析错误导致的答非所问;把解析失败的页面单独建一个队列,定期人工复核。
这套机制跑下来,最大的收获是“问题可定位”。以前检索效果不好,只能猜是切分问题、embedding 问题还是模型问题;现在有了解析质量数据,能快速排除或确认解析环节的嫌疑。对于做 RAG 的人来说,这种可观测性比任何单点优化都重要。
pdf-inspector这类工具的价值,不在于它能把 PDF 转得多完美,而在于它把 PDF 解析从“黑盒”变成了“白盒”。你知道每一页是什么类型、质量如何、哪里可能出问题,就能有针对性地处理,而不是把一堆脏数据丢进向量库然后祈祷模型能理解。RAG 的上限很大程度上取决于数据质量的下限,把解析这一步做扎实,后面的检索和生成才有意义。