先说结论:从PDF到可检索,中间那段路——解析和分块——才是决定整个RAG(检索增强生成)应用能不能用的核心环节。做知识库问答、企业文档检索、法律/学术资料库这类项目,方向基本绕不开三条线:PDF里有什么、想要的片段该切多大、用户问的时候系统能不能快速捞到。这篇文章我会按实际做项目的顺序展开,先拆PDF的结构特性,再讲解析工具怎么选,然后进入分块环节的策略对比,最后回到检索侧讲怎么把前面做的活儿真正用起来。全文基于我自己的项目复盘写,所有方案都是我实测过、验证过、也踩过坑的。
1. 为什么“PDF提取文本”不等于“文档可检索”
先说一个最容易被新入场的开发者忽略的问题:PDF这个东西,本质上不是给计算机读的,它是给打印机制造的。你用PDF阅读器看到的“某一页”,其实不是一页文字,而是一组坐标、字体、指令的集合。原生的文本型PDF虽然包含文本对象,但这些对象的排列完全是为了“视觉呈现”,不是为了“语义顺序”。
我见过太多团队卡在这件事上:辛辛苦苦把PDF里的字全抽出来了,结果一问系统就答非所问,以为是embedding(向量嵌入)模型选得不行,翻来覆去调参无效,最后才发现问题出在上游——PDF文本的读取顺序、表格结构、页眉页脚、多栏排版全乱套了。
1.1 PDF的“假电子文档”本质
拆开看,一份PDF文件内部其实有几个关键结构层:
- 页面对象(Page):每个页面有明确的宽高、坐标系统;
- 内容流(Content Stream):存的是绘图指令,比如“在(x,y)位置用14号字体画字符串‘你好’”,这个指令序列不代表阅读顺序;
- 字体对象(Font):记录了字符编码与实际字形(Glyph)的映射关系;
- 资源字典(Resources):存放图片、字体、颜色等被引用的资源。
这里的核心矛盾是:阅读器(比如Acrobat)靠坐标把字符渲染到页面上,你靠人眼阅读时自然按照视觉流扫描;但机器处理时如果直接按内容流的内部顺序取字符,拿到的很可能是乱序的碎片。特别是双栏排版、表格跨页、文字环绕图片三类场景,内容流的线性顺序和视觉阅读顺序几乎完全脱节。
1.2 检索系统要的是“语义定位”机器
做检索(不管是向量检索、BM25还是混合检索),最终目的都是:用户输入一个问题,系统从文档库里找出一段“与问题语义最相关、信息最完整的片段”。
这意味着系统需要的不只是“把字提出来”,而是:
- 一个片段内有相对完整的语义;
- 边界尽量贴合自然段落或逻辑小节;
- 不丢失表格的行列关系;
- 不被页眉页脚、水印、页码污染;
- 能从片段回溯到原文的具体页码,方便定位。
如果只做“文本提取”而不做“结构解析”,就会面临:段落被截断、表格变成一串无意义的数字流、页眉“技术白皮书”反复出现在每个片段里。整条链路的效果就毁在第一步。
1.3 解析任务的三个层级
实战中我习惯把“从PDF到可检索”划分为三个层级,每个层级各有自己的评估标准:
| 层级 | 做什么 | 评估标准 |
|---|---|---|
| 文本层 | 把PDF里的字符确实提出来,包括排版顺序 | 字符无遗漏、顺序接近阅读顺序 |
| 结构层 | 识别标题、段落、表格、页眉页脚、列表、图片 | 块级划分准确率、表格行列还原度 |
| 语义层 | 把结构块组合成适合检索的文档片段(chunk) | 片段语义完整度、检索命中率、问答准确率 |
很多人一上手就想直接搞定第三层,结果第一层都没过。这篇文章我先压低重心,把第一二层做扎实,第三层放进分块策略里重点讲。
2. 解析层工具选型:五类工具,各管一摊
PDF解析工具生态里没有“银弹”,选型的前提是知道每一类工具的擅长边界。我自己整理了一套“PDF五类难度划分法”,日常收到的PDF文件基本都能对号入座:
- 纯文本型PDF:如论文、说明书,文字可选可复制,字体是内嵌标准字体;
- 图文混排型:文字和图片穿插,有多个文本流;
- 表格密集型:财务报告、审批表、招标文件,信息encode在表格里;
- 扫描版/图片型:整页是扫描图片,没有文本对象;
- 表单型:政府表格、申请PDF,字段可交互或可填。
2.1 核心工具清单与对比
我实际项目中用到的工具,按用途分成五类:
| 工具 | 擅长场景 | 局限 | 我的使用场景 |
|---|---|---|---|
| PyMuPDF(fitz) | 文本提取、页面渲染为图片、文本坐标定位、链接提取 | 复杂表格结构还原能力一般 | 全流程主力,先跑它 |
| pdfplumber | 文本、表格的高精度提取,按坐标切分 | 性能较慢,超大文档吃力 | 需要精细表格和坐标时用 |
| Camelot | 规则型板式表格抽取为DataFrame | 对无边框表格、复杂嵌套表格效果差 | 表格为主的PDF优先试它 |
| Tesseract OCR | 扫描版文字识别 | 对清晰印刷体效果好,手写和低分辨率拉胯 | 扫描版兜底 |
| PaddleOCR | 中文识别、版面分析、表格还原 | 模型较重,部署起来有成本 | 中文扫描版主力OCR |
这只是一个大纲,实际选型不能光看表格。说几个我踩过的坑:
第一坑:PyMuPDF抽不出文字时不要直接上OCR。先查PDF是不是有“保护性加密”或者“字体子集化”。字体子集化是PDF压缩的常规操作,抽出的字符集是自定义编码,原生文本对象在技术上存在,但用fitz提取时会返回乱码或空。这种文件直接用OCR可以解决,但显卡显存和耗时成本远高于修复字体映射。能定位到字体映射的话,还是一条路子的。
第二坑:pdfplumber的表格识别不是万能的。它的表格识别逻辑基于“页面上画出来的线”,无边框表格它基本识别不了。我处理过一批企业年报,表格就没有任何竖线,pdfplumber直接把这些数据当普通文本流输出来,语义全乱了。这种场景要么用Camelot的lattice模式,要么在解析后加一层“基于Tab键值和坐标对齐”的启发式规则。
第三坑:OCR不是提取工具,它是最后的降级方案。文本型PDF几十毫秒能解析完一页,OCR一页要几百毫秒到几秒。全库扫描时这个成本差异是数量级的。所以最经济的路径永远是:先判定类型,再决定是否走OCR。
2.2 我做解析管线的默认组合
根据上面的工具对比,我默认的解析管线是这样的:
PDF文件 -> PyMuPDF 快速探测:页数、是否含文本对象、字体是否正常映射 -> 若含文本对象: -> PyMuPDF 按页面提取文本块(带坐标和字体信息) -> pdfplumber 补充表格结构(需要精细表格时启用) -> 后处理规则:去除页眉页脚/页码/水印,按坐标修复多栏顺序 -> 若无文本对象: -> PaddleOCR 整页OCR,输出带坐标的文字块 -> 坐标聚类,还原阅读顺序 -> 输出统一结构化的解析结果(JSON)这套管线在多个文档库上跑了一年多,F1(块的分类准确率加表格还原率加权后的综合评分)能稳定在90%上下。当然不可能一套吃遍天下,PDF变化多,后面单开一节说动态调整策略。
3. 文本型PDF的解析编排:从“全文本”到“结构块”
大部分项目第一步收到的PDF,文本型占多数。这一节把文本型PDF的解析编排讲透,扫描版和OCR放后面。
3.1 页眉页脚和页码怎么剔除最省事
页眉页脚污染检索片段的问题,比很多人想象得严重。一个商业白皮书的页眉“XX公司机密文件”如果在每个页面都出现,向量检索可能会把这个词和正文内容混在一起,导致检索权重被带偏。
我的做法不是简单的“找页眉删掉”,而是用“重复内容检测+坐标定位”双保险:
- 纵向看:同一个字符串出现在多页的相同/近似坐标区域,且内容完全相同,基本可以判定是页眉页脚;
- 横向看:页面顶部10%和底部8%的文本块,单独拎出来做重复度检测;
- 命中规则后,从正文块池子里剔除,但保留在元数据里(页面号、页眉是什么),方便后续需要时仍能追溯。
这一步做完,正文里不会再出现“第3页共9页”这种噪音,分块效果会立刻变好。
3.2 多栏PDF的文本顺序修复
多栏排版是解析顺序错误的第一大来源。解决思路不复杂,但工程上要细心:
- 用
fitz.Page.get_text("dict")拿每个文本块的bbox(边界框坐标); - 单页内按x坐标做聚类:文本块的中心点x值相近的归为一栏;
- 栏内按y坐标从上往下排序;
- 最后按“左栏全部读完后读右栏”的顺序输出文本流。
import fitz def fix_multi_column_order(page): blocks = page.get_text("dict")["blocks"] lines = [] for block in blocks: for line in block.get("lines", []): bbox = line["bbox"] x_center = (bbox[0] + bbox[2]) / 2 y_center = (bbox[1] + bbox[3]) / 2 text = "".join(span["text"] for span in line["spans"]) lines.append({"x": x_center, "y": y_center, "text": text}) # 按x聚类再用y排序,代码略这段话的理解要点:PDF阅读器显示文本时是按指令顺序画的,并不会自动做多栏分栏;我们靠坐标相似度把文本行聚成“栏”,再按阅读习惯重排。这个简单规则在双栏论文、杂志页面上的准确率很高,遇到三栏甚至四栏的报纸页面也能跑,只是聚类参数要调。
3.3 表格解析:Markdown化才是正道
表格处理我踩过的大坑是:把表格里的每个单元格当作独立文本块输出,结果检索时完全丢失了行和列的上下文。比如:
| 产品 | 价格 | 库存 |
|---|---|---|
| iPhone | 5999 | 200 |
| Android | 3999 | 150 |
如果拆成六个孤立的字符串“iPhone”“5999”“200”,检索“iPhone价格”的时候,传统的向量检索根本拼不回“iPhone=5999”的关系。
我的方案是:把表格整体提取出来后,强制转换成Markdown表格结构。步骤如下:
- 先用pdfplumber或Camelot把表格区域识别成二维矩阵(行、列、单元格);
- 再将二维矩阵序列化为Markdown表格;
- 然后将这个表格文本作为一个独立chunk(或一个chunk的独立部分)进入后续检索。
这样即使被embedding,表格的行列关系在文本里也是显式的,模型能够更好地把握语义。
表格还原时还有个小技巧:如果表格有表头(表格第一行是字段名),单独把表头提出来作为该表格chunk的metadata,后续做语义检索时可以让“问题”和“表头”先做一次匹配,超实用。
3.4 扫描版PDF降级方案
扫描版PDF没有文本层,必须OCR。我实际使用的流程是:
- 探测阶段:
fitz读出的文本块几乎没有有效字符,且页面有/XObject类型的图片对象; - 触发OCR:整页转成PNG图片,分辨率不低于200dpi,送入PaddleOCR;
- 输出处理:PaddleOCR的
ocr()接口会返回带坐标的文字框,再走一遍坐标分栏和表格还原; - 重要:OCR的识别文本保留“置信度”字段,低于阈值的字让模型做二次校对,不要直接入库。
这里必须提醒:OCR在大库场景的成本很高,一定要有缓存。同一个PDF解析一次后把结果存成JSON或Pickle,下次直接读取。我们系统里OCR结果缓存命中率超过70%,大量重复文档浪费的算力就是这样省下来的。
4. 分块策略实操:切片大小、重叠与metadata注入
解析做完,你手头是一堆“结构块”(标题、段落、表格、列表)。下一步是决定怎么把这些块切分成适合检索的单元。这一步做得好不好,直接影响用户搜索的质量。
4.1 分块的三个目标
分块不是简单按字数切,它要同时满足三个目标:
- 语义完整:一个chunk最好是一个完整的论点、一条流程、一段描述,而不是半截话;
- 长度可控:chunk太长,embedding的注意力会分散;太短,又缺少上下文。一般500-800词(中文约1200-2000字)是相对稳的范围;
- 可回溯:每个chunk要能反查到原文页码和所属章节,方便用户人工核对。
4.2 四种主流分块策略对比
| 策略 | 原理 | 优点 | 缺点 | 适合场景 |
|---|---|---|---|---|
| 固定窗口 | 按token数硬切,加overlap | 简单稳定 | 切碎语义,段落中途断裂 | 快速原型、内容格式统一的库 |
| 段落分段 | 按段落标记分块 | 语义完整度较好 | 段落太长或太散时不均衡 | 论文、规范、说明类文本 |
| 语义切分 | 用embedding相似度找句子间“断点” | 边界语义自适应 | 计算成本高,边界仍不稳定 | 语义跳跃大、主题分散的文档 |
| 父子块/多级分块 | 大块(父)负责检索,小块(子)负责回答 | 兼顾上下文和精准度 | 结构较复杂,需维护映射 | 长文档、多级标题结构明显的内容 |
我日常做知识库用得最多的是段落分段+父子块混合:按段落边界基本定位,标题结构再生成父级块作为索引,子级块作为回答引用。如果文档本身没有清晰的段落标题,就退化成固定窗口策略,窗口大小按embedding模型的最优输入长度设置。
4.3 工程参数:chunk_size、overlap、metadata注入
分享一组我反复调之后觉得比较稳的默认参数(以中文文本、OpenAI/ChatGLM等常用embedding模型为例):
| 参数 | 推荐值 | 说明 |
|---|---|---|
| chunk_size | 800-1200 token | token数比字符数更准确,中文一个token约为0.5-0.7个汉字 |
| overlap | 150-250 token | 保留前后文,避免关键句被硬生生切断 |
| 段落保持 | 尽量整段 | 如果段落超过chunk_size,按句子边界切 |
| 表格块 | 单独成块 | 不混合到文字段落里,避免噪声 |
| 标题块 | 作为父块 | 父块包含整章节,子块指向父块 |
metadata注入是很多人忽略的环节。一个高质量chunk的metadata至少包含:
- 来源文件名和版本号;
- 该片段在原文中的页码(或多个页面的起止区间);
- 所属章节路径(如“第3章/3.2节/表格3-1”);
- 创建时间、文档类型;
- 如果是表格,保留表头。
这里有个小技巧:不要把metadata加入到正文的embedding里,但一定要在向量检索后的后处理阶段可用。因为metadata里的“章节号”信息如果塞进正文,容易干扰语义;但检索结果做rerank时,metadata的路径信息对修复排序帮助极大。
常见的错误做法是直接把PDF解析结果怼进向量库,不带任何metadata,导致检索结果没办法定位到原文,用户看一眼就退出,体验极差。
5. 检索质量调优:让“前面的解析活”在检索侧发挥价值
解析和分块的最终目的,是让检索命中更准。这一节讲讲我在检索侧工作流里怎么使用前面解析出来的结构信息,可复现性很强。
5.1 先建评测集,再谈调优
不建评测集就调检索参数,等于闭眼开车。我的做法是:
- 从库里随机抽50-100个真实用户问题,加上10-20个我人为构造的“边角问题”(多栏、表格、专业术语简称);
- 人工标注每个问题对应的“正样本”原文片段(哪怕是一个段落级别的大块);
- 检索接口跑完,计算
Hit Rate@5(前5个结果里是否有正样本)和MRR(倒数排名均值)。
注意:这个评测集不用大,但要持续维护,每次调整参数后重跑一遍。没有数据支撑的“我觉得变好了”,都是幻觉。
我做过一次印象很深的实验:评测集上线后,我把分块的overlap从128调到256,Hit Rate@10直接提升8.6%。原因很朴素——一个250字关键段落之前如果正好被切掉了一半,128 token的overlap补不回来,256就刚好能兜住。
5.2 混合检索:向量+关键词,两条腿走路
做RAG应用久了就会明白:向量检索擅长“语义相关”,但面对“专有名词精确匹配”时容易拉胯。比如专利号“CN202310123456.7”,embedding之后和普通文本混在一起,语义检索找不全。这时候BM25就能精准命中关键词。
实际工程中最稳的检索组合:
- 向量检索Top 30;
- BM25关键词检索Top 30;
- 两路结果合并去重,按Reciprocal Rank Fusion(RRF)排序;
- 取Top 10进入重排序(rerank)阶段;
- 最后把Top 3-5个chunk拼起来喂给LLM生成回答。
RRF的公式不复杂:score = sum(1/(k + rank)),k通常取60。它不依赖得分归一化,直接把两条路的结果排名融合,工程上最省心。
5.3 查询改写:用户问的,不一定是他真正想问的
用户输入“多栏PDF怎么处理”这种问题,如果用原文“PDF双栏排版解析”去检索,词面上就差挺远,向量倒还能兜住,但BM25会直接拉胯。所以我的检索环节前加了一个轻量级的查询改写模块,用LLM把用户问题做三个处理:
- 去掉问句中的废话(“请问”“有没有”“呢”之类);
- 补全缺失的限定词(把“它”替换成前一句的实体名);
- 扩展同义词(“分栏”->“多栏/双栏/column layout”)
改写后的查询文本同时喂给向量检索和BM25,实测命中率提升非常明显,尤其对口语化提问、专业术语变体多的情况。
5.4 rerank的必要性
向量检索出来Top 30里,真正能被LLM使用的可能只有前5。中间夹了一堆“看起来相关但其实是背景介绍”的文本。
所以我的流程里必须有rerank:用一个交叉编码器(cross-encoder)替代双塔结构的回归打分,对候选chunk和查询做完整编码。这个模型虽然推理速度慢,但精度高,只对Top 30跑一遍完全没问题。对应工具库推荐FlagEmbedding的BGE-Reranker系列,或者MiniLM变体,都是社区验证过的开箱即用选项。
6. 拿过去就能用的落地细节:输出结构、参数与踩坑清单
到这节,全部核心思路已经讲完,接下来是最容易被直接“抄作业”的部分:解析结果的标准输出结构、分块参数速查表、以及实战中反复踩到的坑。
6.1 标准输出Schema参考
建议所有PDF解析结果统一用JSON结构存储,便于后续接向量库、做缓存、做审计:
{ "doc_id": "uuid", "source_file": "xxx.pdf", "page_count": 12, "parsed_pages": [ { "page_no": 1, "blocks": [ { "block_type": "heading|paragraph|table|image|list", "bbox": [0, 0, 595, 842], "text": "解析后的文本/表格Markdown", "table_data": "二维数组,非表格时为空", "confidence": 0.98 } ] } ], "chunks": [ { "chunk_id": "xxx", "page_start": 1, "page_end": 2, "parent_chunk_id": null, "text": "分块后的完整文本", "metadata": { "section": "第3章/3.2节", "table_header": "产品价格库存" } } ] }这个schema里,blocks是解析层的输出,chunks是分块层的输出,两者分开存。后续如果分块策略要改,不需要重新解析PDF,只要重新跑分块就行。
6.2 分块参数速查表
不同场景下的分块参数我整理如下,可直接套用:
| 文档类型 | 策略 | chunk_size | overlap | 备注 |
|---|---|---|---|---|
| 论文 | 段落分段+父子块 | 800-1000 | 150 | 父块用章节标题,子块按段落 |
| 合同 | 固定窗口+句子边界 | 600-800 | 128 | 关键词命中优先提升权重 |
| 产品手册 | 按“功能点”拆分 | 1000-1200 | 200 | 表格单独成块 |
| 年报 | 段落+表格混合 | 800 | 256 | 表格用Markdown化 |
| 扫描版历史档案 | OCR后固定窗口 | 500 | 100 | OCR置信度低于0.9的片段降权 |
6.3 容易反复踩的坑清单(浓缩版)
下面这些坑,每一个都是我或我的项目组在真实生产环境踩过、并且花了不少时间填的:
| 坑 | 现象 | 解决方案 |
|---|---|---|
| 字体子集化 | 文本型PDF提取出乱码 | 先检测字体映射,再决定是否走OCR |
| 多栏顺序错乱 | 提取文本语义断成碎片 | 用坐标聚类重排栏顺序 |
| 扫描版误判 | 有少量文本却局部是图 | 页面级文本量阈值+图片占比综合判断 |
| 页眉页脚污染 | chunk里全是“公司名称” | 重复性检测+坐标区域排除 |
| 表格拆碎 | 数字和表头对不上 | 表格强制Markdown化、独立成块 |
| chunk过长 | 检索结果包含无关信息 | chunk_size上限+句子边界 |
| metadata丢失 | 检索结果无法回溯页码 | 分块阶段强制写入页码和章节路径 |
| 评测缺失 | 调参像掷骰子 | 建小规模评测集,固定指标测算 |
6.4 最后分享一个我自己项目里的小技巧
这条技巧不是技术,是工程经验:在解析与分块阶段,永远优先保证“可回滚”和“可审计”。
也就是说,最初跑通一个最小可用的解析-分块-检索闭环后,先别急着堆大库,而是抽20份文档,人工逐页核对解析结果。很多PDF本身的“脏”程度远超过你初步抽样时看到的。多数情况你会发现:不是工具不行,而是PDF文件的排版本身太混乱,机器不可能百分百复现人眼阅读顺序。这一步人工核对后,再决定哪些规则需要正则、哪些需要坐标逻辑、哪些需要OCR。
我始终认为,PDF解析和分块的终点不是“把文本提出来”,而是“把文本变成计算机能理解、检索系统能用、用户能找到的东西”。
这个项目最大的收获,是我终于明白了一个简单的道理:文档解析做得好,后面的检索和生成都会变得异常轻松;解析做不好,再贵的模型也白搭。这套“解析+分块+检索”的闭环思路,我在很多项目和团队里复制过,核心就是先把上游做扎实,下游的效果就能水到渠成。