RAG 从入门到实战(三):PDF 解析与文本分块完全指南
我前两篇聊了 RAG 的整体架构和向量化流程,很多朋友看完后反馈说“原理懂了,一到处理真实文档就卡住”。尤其是 PDF,这玩意看着通用,实际处理起来全是坑:有的页面是文字层,有的本质是图片,有的表格混着多栏排版,还有一堆扫描件等着你做 OCR。如果你正卡在这一步,那这篇文章就是为你准备的。
这篇我用一个完整的实战视角,把 PDF 解析和文本分块这两件事彻底讲透。你会搞清楚:为什么解析和分块的质量直接决定 RAG 效果的上限;主流的 PDF 解析方案各自适合什么场景;分块策略里 chunk size 和 overlap 到底怎么调;以及我踩过的一些坑和对应的排查思路。所有代码示例都是可以直接拿去改的,不是 PPT 级别的那种。
1. 内容整体设计与思路拆解
1.1 为什么“数据入口”决定了 RAG 的天花板
先说一个很多人容易忽略的事实:RAG 的上限由“检索质量”决定,而检索质量的上限又由“索引数据质量”决定。你后面用多好的 embedding 模型、多强的重排模型,都救不回一个在源头就切碎、切错、丢信息的文档。
我见过太多项目,在模型选型和向量库调参上花了大把时间,最后效果不行,一排查发现是 PDF 解析阶段出了问题——要么文字全挤在一起,要么标题层级全丢了,要么表格被切得七零八落。这些问题不会在 parsing 阶段报错,它们是“静默失败”,直到你查 answer 质量时才暴露出来。
所以做 RAG,第一步不是急着调模型,而是先把文档解析和分块这层地基打牢。这个环节做的事情可以拆成四个递进层次:
- 内容提取完整性:文字有没有缺漏、乱码,页眉页脚有没有干扰正文,表格内容是否保留。
- 结构信息保留:标题、段落、列表、表格的层级关系有没有被带下来,这决定了后续分块能不能“按结构切”。
- 语义边界还原:不同主题的段落是否能自然分开,而不是被硬生生拼在一个块里。
- 可控性与稳定性:同样的解析方案换一批文档,结果是否稳定,异常文件不会让整个 pipeline 崩溃。
这四个层次,前两个靠解析方案解决,后两个靠分块策略解决。很多教程把两者混在一起讲,实际工程中它们各有各的难点,需要分开来设计和调优。
1.2 解析和分块在整个 RAG 流程中的位置
我习惯把 RAG 的离线数据构建流程画成一条流水线:文档加载 → 格式解析 → 清洗与结构化 → 分块 → 向量化 → 入库。
PDF 解析发生在“格式解析”这一步,文本分块发生在“分块”这一步。两者看起来是相邻环节,实际上有强依赖关系:如果你解析阶段把文档结构破坏了,分块阶段再怎么优化都只能“在垃圾上做精细切分”。反过来,如果解析做得很好但分块策略粗糙,比如一刀切固定 500 字,那么再好的结构信息也会在切分时被浪费掉。
所以这篇的实战思路是先把“解析”和“分块”当成一个整体来看待,先确定你要处理什么类型的 PDF,再决定用什么策略把解析结果变成适合 embedding 的文本块。这个顺序不能反,很多人一上来就选 chunk size,却对自己文档的类型和复杂度完全没有概念。
1.3 选型背后的核心考量:没有“最好”的方案,只有“最合适”
PDF 解析这个领域不存在一个通用的银弹方案。你选什么工具,取决于你的文档从哪来、长什么样、要拿来做多高精度的检索。
我自己的经验是先把文档分类,再决定工具:
- 纯文本型 PDF:比如论文预印本、网页导出的 PDF,文件体积小,复制文字无乱码,这类用轻量库就能处理。
- 复杂排版型 PDF:比如公司年报、产品手册,虽然带文字层,但分栏、表格、页眉页脚混在一起,轻量库处理效果很差,需要用带版面分析的解析器。
- 扫描件/图片型 PDF:比如老书扫描版、传真件,根本没有文字层,只能 OCR。
- 技术图纸/专业软件导出 PDF:比如 CAD 导出、3GPP 协议文档,这类往往包含大量特殊符号、公式或者固定排版,需要专业方案或者自定义后处理。
在后面的章节里,我会分别给出这些场景的解析方案,以及我自己用下来的真实感受。记住一个原则:先用最便宜的方式探测文档类型,再决定上什么方案,不要一上来就堆重型工具。
2. PDF 解析核心细节与实操要点
2.1 纯文本型 PDF:轻量库够用,但要注意“伪文本”
对于最常见的“从 Word 或 LaTeX 导出的 PDF”,文字层是完整的,直接用 Python 的几个轻量库就能提取。我最常用的三个:PyPDF2 / pypdf、pdfplumber、PyMuPDF(fitz)。它们各有特点,我列个表方便你选型。
| 库 | 提取速度 | 版面还原度 | 表格支持 | 适用场景 |
|---|---|---|---|---|
| pypdf(PyPDF2 继任者) | 快 | 差,纯按流式文本 | 无 | 快速抽取文字,不关心排版 |
| pdfplumber | 较慢 | 较好,基于坐标定位 | 支持表格线检测 | 需要提取表格或关注位置关系 |
| PyMuPDF(fitz) | 非常快 | 较好,可以拿块坐标 | 一般,需手写 | 大批量高性能解析,按块提取 |
这三者我都用过,分别说下实际体验。
pypdf 的提取结果是一大段连续文本,基本丢弃了版面信息。它的优点是代码简单,一个 extract_text() 就能拿到全文。但遇到多栏排版会出大问题——两栏的文字会被交叉拼在一起,左边一栏读到一半跳到右边一栏,语义全乱。如果你只是处理单栏、顺序明确的文档,pypdf 是性价比最高的选择。
pdfplumber 是我最常用的定位工具。它的特点是“知道每个字符在哪里”,可以拿到每个词的 x0、x1、top、bottom 坐标,所以你可以非常精准地按坐标区域切文本。比如只提取页面左上角的正文区域,跳过页眉页脚。劣势是速度慢,处理一个几百页的 PDF 可能要等好几分钟,不适合大规模批量场景。
PyMuPDF 是我在大批量场景下的主力。它的 get_text(“blocks”) 可以直接返回文本块的坐标和内容,比 pdfplumber 快一个数量级,而且保留了一定的版面顺序。如果你需要“速度和结构”的平衡,PyMuPDF 是目前综合体验最好的轻量方案。
注意:即使是纯文本型 PDF,也有一部分是“伪文本”。比如某些在线工具生成的 PDF,文字层其实是曲线路径,复制出来是乱码或空字符串。这时候你要么用 OCR 兜底,要么确认来源后走扫描件方案。
2.2 复杂排版 PDF:版面分析才是真正的分水岭
如果你处理的文档带多栏、表格、标题层级、页眉页脚,那上面这些轻量库就不够用了。原因在于:它们只负责“把文字抠出来”,不负责“理解版面结构”。而 RAG 分块恰恰需要结构,没有结构就只能瞎切。
复杂排版文档的正确打开方式是“版面分析 + 阅读顺序还原”。目前比较成熟的方案有几类:
- 基于规则的方案:用坐标、字体大小、间距等特征判断标题和正文。比如字体大于正文两倍且居中的块,判为一级标题。优点是可控、无需 GPU,缺点是规则写起来累,换一种版式就要调参。
- 基于深度学习版面检测的方案:如 LayoutLMv3、DocLayout-YOLO、PP-StructureV2 等模型,可以识别标题、段落、表格、图片、页眉页脚等区域,再按区域重组阅读顺序。效果明显更好,但需要一点模型部署经验。
- 商业/开源一站式解析库:如 PyMuPDF 的布局分析、Unstructured、MinerU(前身是 OpenDataLab 的 PDF 解析工具),开箱即用程度更高。
我现在的推荐顺序是:先试 MinerU 或 Unstructured,因为它们把版面检测、公式识别、表格转 Markdown 都封装好了,省心;如果效果不满意,再上 PP-StructureV2 做定制训练。尽量不要一上来就自己写规则,除非你的文档版式非常固定。
这一期因为是“从入门到实战”,我给一个可以直接落地的方案:用 PyMuPDF + pdfplumber 做轻量级版面分析,识别标题和正文分块,然后把结构化的文本块交给后续分块逻辑。深度学习方案的部署细节后面单独开篇讲,这里先把基础链路打通。
2.3 扫描件与 OCR:解析的最终兜底手段
扫描件没有文字层,只能走 OCR。目前主流的 OCR 方案:
- Tesseract:老牌开源,免费,中文效果一般,需要自己训练或调参。
- PaddleOCR:百度开源,中英文效果都很好,同时提供版面分析能力,是目前中文场景的首选。
- 商业 OCR(如云厂商 OCR):效果好省心,但涉及 API 费用和数据外发,敏感数据慎用。
PaddleOCR 是我目前的主力。它不仅识别文字,还能输出每个文本框的坐标和置信度。你可以同时用它做版面分析,识别标题、段落、表格区域。下面给一段最基础 PaddleOCR 的用法,用于快速摸底扫描件质量。
from paddleocr import PaddleOCR ocr = PaddleOCR(use_angle_cls=True, lang="ch", show_log=False) result = ocr.ocr("scan_page.png", cls=True) for idx, line in enumerate(result[0]): box = line[0] # 四个角坐标 text = line[1][0] # 识别结果 conf = line[1][1] # 置信度 print(idx, text, conf)这段代码会打印每行识别的文字、坐标框和置信度。OCR 结果天然是带坐标的,这意味着你可以拿到每个文本块的物理位置,非常方便后续做版面重组。
实操心得:OCR 不是万能的,遇到印章遮挡、强底色背景、手写批注,识别率会明显下降。我的经验是先跑一遍 OCR,按置信度过滤掉低分结果,再人工抽查 10% 的页面来估算整份文档的可信度。如果一份文档的 OCR 置信度中位数低于 0.85,就要考虑换更高精度的模型,或者对源文档做去噪、纠偏预处理。
2.4 解析后必做的清洗和结构化处理
不管用哪种方式解析 PDF,得到的原始结果都不能直接拿去分块。解析出来的是“文本”,不是“干净的结构化数据”。我总结了一套清洗流水线,你可以按顺序执行:
- 去页眉页脚:大多数 PDF 的页眉页脚每页重复,如果不去除,会被向量化成大量重复块,干扰检索。
- 去页码:页码本身没有语义,但经常被拼在正文文本里,需要按位置或正则剔除。
- 合并断行:PDF 每行文字可能被硬换行截断,需要根据行尾是否标点、首字母是否大写等规则,把同一段落的断行合并。
- 规范化空白字符:全角半角、多个空格、制表符统一处理。
- 保留结构化标记:如果解析器能输出标题层级、列表、表格的 Markdown 标记,尽量保留,这些标记对后续分块很有用。
清洗这一步看起来不起眼,但直接影响分块质量。同一个页面,提取出来的可能是“一段顺滑的正文”,也可能是“一堆被硬换行切碎的碎片”。分块的逻辑在下游接的可是 embedding 模型,喂给它的文本语义越连贯,向量表达越准确。
3. 实操过程与核心环节实现:完整跑通解析 + 分块
3.1 环境准备与技术选型
为了让你有一份“拿来即用”的参考,我这一节用一套完整代码把“PDF 解析 → 文本清洗 → 结构化 → 分块 → 输出”整条链路串起来。技术栈如下:
- PyMuPDF:负责快速提取文本块和基础结构信息
- pdfplumber:负责局部细节提取(比如表格和大段落)
- LangChain 的 RecursiveCharacterTextSplitter:负责文本分块(我们也可以用自定义分块,后面细说)
- 简单的 Python 正则和规则逻辑:负责清洗和标题识别
安装依赖只需要两行命令:
pip install pymupdf pdfplumber langchain-text-splitters pip install paddleocr paddlepaddle # 如果你需要 OCR 兜底注意:paddleocr 的依赖比较重,建议放在独立环境里,不要和主项目环境混在一起,否则容易产生版本冲突。
3.2 基于 PyMuPDF 的 PDF 解析与结构提取
先实现最基础的解析函数。我们的目标是:把 PDF 变成每页的“文本块列表”,每个块包含 text、bbox 坐标、块类型。
import fitz # PyMuPDF def extract_blocks_from_pdf(pdf_path: str): doc = fitz.open(pdf_path) page_blocks = [] for page_num in range(len(doc)): page = doc[page_num] blocks = page.get_text("blocks") page_blocks.append({ "page": page_num + 1, "blocks": blocks }) doc.close() return page_blocks这里返回的 blocks 是元组列表,每个元组包含四个角的坐标、文本内容、块 ID 等。你先跑一遍这个函数,看看输出的块数量、每块的字数是否均匀。这一步极其重要,我见过很多同学直接跳过“摸底”,一上来就用重型方案,最后花了一堆时间处理一个根本不需要重型方案的文件。
摸底之后,你需要做一个关键的判断:这个 PDF 的正文是不是“按块分好”的?如果每个块就是一个自然段落,后续分块会非常轻松;如果块和块之间是乱序的,或者一个段落被切成几十个小块,那你需要进一步处理“块合并”。
3.3 按坐标过滤页眉页脚与噪声块
页眉页脚、页码、水印都带有固定特征。最简单的方式是直接按页面区域的坐标过滤。比如 A4 页面(595 × 842 点数),页眉通常出现在 top < 50 的区域,页脚通常出现在 bottom > 800 的区域。
def filter_header_footer(blocks, page_height=842): filtered = [] for b in blocks: x0, y0, x1, y1, text, block_id, block_type = b # 忽略太靠上和太靠下的区域 if y0 < 50 or y1 > page_height - 50: continue # 忽略纯数字的页码 if text.strip().isdigit(): continue filtered.append(b) return filtered这种粗暴规则在版式固定的文档里非常有效。但注意,如果你的文档正文确实有内容在页眉页脚区域(比如表格的第一行),这套规则会误杀。更稳妥的做法是:对同一个 PDF 的前 10 页做统计,找出每页相同位置重复出现的文本,把它们统一标记为“重复区域”然后过滤。这种方法叫“跨页去重”,我推荐你在生产环境里实现一下。
3.4 断行合并与段落重建:把碎片粘成完整语义单元
PDF 提取出来的正文经常长这样:
这是一个关于文本分块的示例。 文本分块是 RAG 构建中的核心环节, 它直接影响向量检索的准确率。但正常阅读时,这三行应该是同一段话。断行合并的规则我总结成了四步:
- 如果当前行末尾不是句号、感叹号、问号、冒号等终止符;
- 并且下一行不是明显的标题(字体大小、段前距离判断);
- 并且下一行首字母不是大写(英文场景);
- 那么把当前行和下一行合并。
中文场景更简单,因为中文句末标点非常明确。你只需要判断行尾有没有句末标点,没有就继续拼下一行。
import re def merge_lines(text: str) -> str: lines = text.split("\n") merged = [] buffer = "" for line in lines: stripped = line.strip() if not stripped: continue if buffer and not re.search(r"[。!?;:.!?;:]$", buffer): buffer += stripped else: if buffer: merged.append(buffer) buffer = stripped if buffer: merged.append(buffer) return "\n".join(merged)这段代码维护了一个 buffer,当 buffer 以句末标点结束时,就“结算”成一段;否则继续累加下一行。如果你处理的文档里有些“标题行”也希望合并,需要再加判断条件,比如下一行不是独立标题块。
3.5 识别标题层级:让你的分块“按结构切”而不是“按字数切”
在合并段落之后,下一个目标是识别标题。标题识别的规则有很多,我根据落地难易度排序给你参考:
- 基于字体大小:PyMuPDF 可以拿 span 的 size 属性。正文大小设为基准,字体更大的判为标题。
- 基于字符特征:中文标题通常是短文本(不超过 50 字),且不带句末标点。
- 基于位置特征:标题通常有更大的段前距离,或者居中/靠左。
- 基于编号模式:形如“1.”、“1.1”、“第一章”、“一、“二、”的文本,优先判定为标题。
一个可落地的简化实现是用 PyMuPDF 的 dict 模式,然后基于字号分组:
def extract_headings(pdf_path, base_size=None): doc = fitz.open(pdf_path) headings = [] for page_num in range(len(doc)): page = doc[page_num] d = page.get_text("dict") for block in d["blocks"]: for line in block.get("lines", []): for span in line["spans"]: text = span["text"].strip() size = span["size"] font = span["font"] if not text: continue if base_size is None: base_size = size # 第一个 span 的 size 作为基准 if size > base_size * 1.2 and len(text) < 50: headings.append({ "page": page_num + 1, "level": "h1" if size > base_size * 1.5 else "h2", "text": text, "size": size, "font": font }) doc.close() return headings这段代码用“字号大于正文 1.2 倍”作为标题判据。实际使用中,你还可以结合字体是否加粗(font 名称里常带 Bold 字样)来提升精度。识别出标题后,每个标题和它之后的内容可以作为一个“语义章节”,在分块时优先按章节边界切割。
实操心得:标题识别不是让 RAG 效果变好的“必需项”,但它是让 RAG 效果变好的“加速项”。如果你切出的每个块都正好落在一个标题下面,embedding 的语义表达会干净很多。后面接重排或者摘要式检索时,效果提升会非常明显。
3.6 文本分块策略:从固定大小到结构感知
终于到了分块环节。我见到的最常见的错误就是一上来就问“chunk size 选 300 还是 500?”——这个问题本身没有标准答案,因为你还没搞清楚你的文档和检索场景。
分块的目标是:让每个块在语义上是内聚的,在检索时是容易被命中的。基于这个目标,我建议你先弄清楚三件事:
- 你后续用的 embedding 模型支持多大的输入 token?超了会被截断,信息就丢了。
- 你的用户提问一般是多长、多具体?问的是大主题还是小细节?
- 如果没有理想的“章节边界”,你的文本可以切到哪里而不破坏语义?
大部分情况下,我推荐用“重叠窗口 + 结构感知边界”的组合策略,而不是简单的固定长度硬切。LangChain 的 RecursiveCharacterTextSplitter 就是这么设计的,它按分隔符优先级递归切分,保住了代码和段落结构。
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=80, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], length_function=len, ) chunks = text_splitter.split_text(merged_text) print(len(chunks))这段代码里有两个关键参数:
- chunk_size:每个块的目标长度,我这里用字符数而不是 token 数(中文场景更直观)。如果按 token 数算,通常一个中文汉字约等于 1 到 1.5 个 token,你可以用 tiktoken 做精确统计。
- chunk_overlap:相邻块的重叠长度。作用是为了防止“刚好在分块边界上的关键信息”被切断后丢失。80 字符通常是一个还不错的起点,需要根据文本特性调整。
分隔符数组的设计也很有讲究:先按“段落”切(\n\n),再按“行”切(\n),再按“句号”切,最后如果还是太长才硬切。这种优先级保证了分块边界尽量落在语义完整的位置上。
3.7 分块参数怎么调:一个可复用的实验方法
很多教程喜欢给一个“推荐参数”,然后就没有然后了。这种推荐只对特定语料有效。我建议你自己搭一个非常简单的实验流程来调参:
- 挑出 30 个代表性问题和对应答案。
- 用当前分块参数构建向量库,跑一遍检索,记录每个问题是否有正确答案出现在 Top 5。
- 改动 chunk_size 和 chunk_overlap,重新构建索引,重复评测。
- 记录不同参数组合的命中率,选效果最好的那组。
这个流程看起来简单,但非常有效。注意,调参时要固定 embedding 和重排模型不变,否则你无法判断是“分块变好”还是“模型变好”。
我自己常用的一个起点是:chunk_size = 500,chunk_overlap = 80,separators 按上述顺序。对于技术文档、协议文档这类段落边界清晰的文档,这个组合通常能拿到不错的效果。如果文档是碎片化的短段落聚合,我会把 chunk_size 降到 250;如果是长论述类的书本章节,我会提高到 800。
注意:chunk_size 和 chunk_overlap 一旦太大,相邻块之间的重复内容过多,会造成向量库里面大量近重复向量,检索时多个几乎一样的块同时命中,浪费上下文窗口,甚至降低回答精度。一般 overlap 不要超过 chunk_size 的 20%。
3.8 结构化文档的进阶分块方案
如果你的文档是 HTML 转 PDF、Markdown 导出 PDF,或者你用了 MinerU 这类输出 Markdown 的解析器,那么你可以用“按标题层级切块”的方式,比纯文本递归切分更精准。
思路很简单:先按一级标题把文档切成多个章节,如果章节长度超过 chunk_size,再在二级标题处继续切,以此类推。如果没有子标题了,才回退到递归文本切分。这种“结构优先”的分块法,能把“学术论文”这种强层级文档的检索质量提升一大截。
def split_by_headings(markdown_text, max_chunk_size=500): sections = [] current_section = [] current_heading = None for line in markdown_text.splitlines(): if line.startswith("#"): if current_heading is not None: sections.append({ "heading": current_heading, "content": "\n".join(current_section) }) current_heading = line current_section = [] else: current_section.append(line) if current_heading is not None: sections.append({ "heading": current_heading, "content": "\n".join(current_section) }) # 超长 section 再递归切 chunks = [] for sec in sections: content = sec["content"] if len(content) > max_chunk_size: sub_splitter = RecursiveCharacterTextSplitter( chunk_size=max_chunk_size, chunk_overlap=50, separators=["\n\n", "\n", "。", ";", " ", ""] ) sub_chunks = sub_splitter.split_text(content) for i, sub in enumerate(sub_chunks): chunks.append(f"{sec['heading']}\n{sub}") else: if content.strip(): chunks.append(f"{sec['heading']}\n{content}") return chunks这段代码把标题作为块的“锚点”,每个块都以标题开头,这样检索到块时,上下文信息会丰富得多。你可能觉得“标题内容重复”会不会浪费 token,但实际效果表明,信息冗余的价值远大于 token 开销,尤其在做 Top-K 召回时。
3.9 一个完整的 RAG 定向流程示例
我把上面的模块串成一个最小可运行的流程,方便你照着改:
import fitz import re from langchain_text_splitters import RecursiveCharacterTextSplitter def pdf_to_chunks(pdf_path, chunk_size=500, chunk_overlap=80): # 1. 提取文本块 doc = fitz.open(pdf_path) all_text = [] for page in doc: blocks = page.get_text("blocks") for b in blocks: x0, y0, x1, y1, text, _, _ = b # 2. 过滤页眉页脚和页码 if y0 < 50 or y1 > 792 - 50: continue if text.strip().isdigit(): continue all_text.append(text.strip()) doc.close() # 3. 合并断行 full_text = "\n".join(all_text) full_text = merge_lines(full_text) # 4. 递归分块 splitter = RecursiveCharacterTextSplitter( chunk_size=chunk_size, chunk_overlap=chunk_overlap, separators=["\n\n", "\n", "。", "!", "?", ";", " ", ""], length_function=len, ) chunks = splitter.split_text(full_text) return chunks chunks = pdf_to_chunks("sample.pdf") for i, c in enumerate(chunks[:5]): print(f"--- chunk {i} ---") print(c[:200])这个流程可以直接跑通“解析 → 清洗 → 分块”的完整链路。你可以在此基础上继续叠加标题识别、表格提取、OCR 兜底等模块。
4. 常见问题与排查技巧实录
4.1 PDF 解析出来的文本乱序或乱码,怎么处理
乱序通常是多栏排版造成的。pypdf 这类流式提取工具无法识别栏,会把左栏和右栏交叉拼在一起。我的排查步骤:
- 先用 pdfplumber 按页面坐标提取文本,看左右两栏的坐标分布是否明显分离。通常左栏的 x1 和右栏的 x0 之间会有明显的间隙。
- 按坐标把页面切成左右两个区域,分别提取,再按需要的顺序拼接。
- 如果你的文档复杂度高,直接用 MinerU 这类带版面分析的解析器,一步到位。
乱码问题大多是编码不识别或字体子集化导致。你可以尝试:换 PyMuPDF 试试,它内置的字符映射比 pypdf 全;如果还不行,说明 PDF 字体映射有问题,只能 OCR 兜底。
4.2 表格被切得七零八落,如何保留表格语义
表格是 RAG 检索的难点,尤其是复杂表头跨行跨列的表格。我的经验是,表格优先走“整体识别”而不是“按行切分”。具体操作:
- 用 pdfplumber 的 extract_table() 提取表格结构,如果表格行列规则,效果很好。
- 把表格转换成 Markdown 格式(| 列1 | 列2 |),这样语义信息比纯文本更好。
- 如果表格太大,无法整体嵌入一个 chunk,至少保证“一行记录”不被切到两个块里。这里我通常会用“先表格转 Markdown,再按表格行递归切分”的策略。
- PaddleOCR 的 PP-StructureV2 也能直接输出表格的 HTML,适合扫描件表格。
我踩过的坑是:把表格按普通文本切分了,结果检索时“第三列内容”和“表头”被分到不同块里,导致召回结果虽然命中行内容,但缺失列名,大模型回答时只能靠猜。所以表格一定要做结构化处理,至少保留表头信息。
4.3 分块后检索命中率低,问题可能不在分块
如果调了很多分块参数召回还是差,先把问题从“分块”转移出去。我建议按以下顺序排查:
- 确认提问和文档的语言是否一致:中文文档用英文 embedding 模型,效果差是正常的。
- 确认每个块的语义是否完整:打印几个块肉眼看看,是否出现了“一句话被切掉一半”的情况。
- 确认向量库的 top_k 是否太小:比如 top_k=3,而正确答案排在第 5,你会误判为分块问题。
- 确认是否有重复文本块:页眉页脚没去掉的话,会制造大量重复块,挤占检索名额。
排查完这些再回头看分块参数,不要一上来就调 overlap。
4.4 处理超大 PDF 时内存和性能问题
几个 G 的 PDF 直接用 PyMuPDF 全量加载会把内存打爆。我的做法是分页流式处理:读取一页,处理一页,只保留最终 chunk 结果。另外,可以考虑用doc.subset()或者直接把 PDF 按页拆分成临时小 PDF,再并行处理,最后合并 chunk 列表。
并行处理时注意不要把“同一个文档的切分逻辑”直接并行,因为文档内页与页之间有上下文关联,需要保留顺序信息。我在每一页提取时会给文本块加上页码元数据,这样后续做重排或者检索溯源时能定位到具体页码。
4.5 如何验证解析和分块质量:三个快速指标
回到开头的问题:怎么知道解析和分块做得好不好?我推荐三个快速指标,你可以定期跑一下:
| 指标 | 含义 | 简单评测方法 |
|---|---|---|
| 文本覆盖率 | 提取文本字符数 / 原始文档预估字符数 | 对比提取结果和原文档页数,正常应在 90% 以上 |
| 块间重复率 | 相似块的数量占比 | 用 embedding 算块之间相似度,超过 0.95 的块删掉一个 |
| Top-K 命中率 | 标准问题是否命中在检索结果前 K 条 | 构建 20-30 个问答对,跑检索,看 Top-5 命中率 |
这三个指标不用做得很复杂,跑一次半小时之内能完成。效果不好时优先看“文本覆盖率”和“块间重复率”,一般能快速定位问题是出在解析还是分块。
5. 额外经验:RAG 指标评估与持续优化的方向
很多同学做完解析和分块就以为大功告成,其实这才走完 RAG 数据侧的一半。我在实测中发现,解析分块的优化必须配合 RAG 评估指标一起看,否则你只是在盲目调参。这里补充几个关键视角。
5.1 评估维度要拆开看
RAG 评估通常看两个核心维度:召回质量(有没有把正确答案找出来)和生成质量(大模型有没有基于召回内容给出正确答案)。解析和分块只影响前者。所以评估时必须把两者拆开:
- 召回质量看“命中率”和“重排后的命中率”;
- 生成质量看“答案相关度”和“忠实度”。
我习惯在还没接大模型前,先把召回质量测好。这样如果效果差,我能明确是数据侧的问题,而不是大模型“乱编”的问题。等召回稳定在 80% 以上再接入生成环节,调起来才有的放矢。
5.2 结构化信息检索优于纯文本检索
处理复杂的协议文档、法规文档时,纯文本解析往往不够用。比如 3GPP 协议文档,很多内容在表格和编号列表里,纯文本切分后,编号层级关系会丢失。我建议在解析阶段就输出“半结构化数据”,比如把表格转成 Markdown,把标题层级显式标记为 H1/H2/H3,把列表保留成 bullet 结构。这些标记不仅是给分块用的,也是给重排模型参考的上下文信号。
5.3 多模态文档的未来方向
现在很多文档不仅有文字,还有图表、示意图。纯文本 RAG 会丢掉图表的信息。如果你处理的文档里图片占比很高,可以考虑多模态 RAG,也就是把每张图片单独切出来,过图像描述模型生成文本描述,或者用多模态 embedding 直接向量化图片。这一块已经有现成方案,后面我也会单独写一篇展开。但需要注意的是,多模态方案对硬件和成本要求更高,中小项目建议先从“图片转文本描述”切入,性价比最高。
我的实际体会
写到这里,想说一些题外话。做 RAG 这一年多,我最大的感受是:效果好的 RAG 系统,往往不是模型最强,而是数据处理最扎实。我见过有人用最便宜的 embedding 模型,但解析和分块做得非常细致,效果吊打那些“gpt-4 级模型 + 糙处理”的方案。
所以不要觉得 PDF 解析和文本分块是脏活累活,不值得花时间。正是这些细节,决定了你的 RAG 是“演示可用”还是“生产可用”。
最后再分享一个我工作流里的小技巧:我们会在解析完每个 PDF 后,自动生成一份“Markdown 预览文件”,把它当作文档索引的一部分保存下来。这样不仅方便人工抽查解析质量,还能在检索命中的时候,直接返回给用户一个结构化的“原文片段”而不是纯文本,体验会好很多。你可以在自己的项目里试试,成本很低,但收益很直观。