做知识库问答(RAG)这类活干久了,我有个特别深的体会:大家最爱聊的是模型选型、embedding调优、向量库对比这些听着就高级的话题,但真正决定一个RAG项目能不能落地、答得准不准的,其实是前期那步最不起眼的文档解析和切片。标题说“地基打歪了,后面全白搭”,这话一点不夸张——我在实际项目里见过太多反面教材了:有人连夜调prompt、换了好几个embedding模型,最后发现问题是PDF表格解析出来全是乱码;还有人觉得“切片嘛,不就是按字数切一下”,结果检索回来的内容全是半句话,大模型再聪明也补不全上下文。
这篇就专门聊文档解析与切片。我默认你在做RAG、企业知识库、AI问答助手这类应用,手里有一堆PDF、Word、Markdown、网页正文要处理。内容会从解析方案选型、切片参数设计一直讲到可落地的代码流水线,最后附上我踩坑多年整理的排查清单。不管你是刚入门的新手,还是已经被线上问题折磨过的工程师,这文都值得花十分钟读完——因为这一步做对了,后面搭什么都不会歪。
1. 为什么说解析和切片是RAG的地基
很多人对RAG的直觉是:把文档丢进去,向量化,然后检索。这个理解对,但不完整。检索的效果上限,其实在解析和切片这一步就被锁死了——后续所有环节,包括embedding、召回、重排序,都只能在后端向前端的处理结果上做“锦上添花”,没法“起死回生”。把这个逻辑理清楚,你会发现很多线上问题根本不是模型问题,而是源头数据就没弄干净。
1.1 解析失败:从字面到语义全线崩盘
文档解析的目标很简单:把原始文件中的文字、表格、标题层级,尽可能无损地变成模型能读的纯文本。听着简单,实际做起来坑很深。我先说一个线上真实案例。我接过一个合同审查的RAG项目,客户用的是扫描版PDF,就是那种直接在打印机上扫出来、整页都是图片的格式。一开始没上OCR,直接拿PDF解析库去读,结果读出来的“文本”全是空字符,检索上来的根本是垃圾。后来换了OCR管线,但表格里那些数字串仍然被拆得七零八落——比如对公账户号被识别成“6228 4801 2344 5678”这种带空格的格式,下游问答模型直接把这个当错误号码给了用户。
解析失败的链条就是这样的:原始文档提取不全 -> 文本内容残缺 -> 切片切在断裂处 -> 语义不连续 -> embedding向量失真 -> 检索召回不对 -> LLM生成错误答案。每一级都在放大上一级的误差。你要是问我凭什么说这步是“地基”,我的依据就是这个误差传递路径。前端只要丢了10%的信息,后端无论做得多精细,用户感受到的准确率大概能掉到一半以下。
1.2 切片不合理:检索系统的“近视眼”
解析过关了,接着的切片也会要人命。切片做的是什么?说白了就是“把长文本拆成检索时能直接命中、又保留足够上下文的单元”。这个平衡很难找。切的块太小,比如一两百个token,检索时看着命中率很高,但召回回来的片段往往只有一句没头没尾的结论;切的块太大,比如两三千token,又容易把好几种无关话题搅在一起,embedding向量被“平均”成了四不像,检索精度反而暴跌。我习惯把这个问题叫“检索近视”:小块看字面命中,大块看语义模糊,哪个都不好使。
还有overlap的坑——就是相邻切片之间预留的重叠区域。有些人图省事,overlap设为0,结果检索命中一个词正好卡在切片边界,那这个词关联的上下文全在另一个切片里,召回就废了。所以这个环节看起来只是“工整切割”,本质却是在做信息架构的取舍。你切的不是文字,是知识的边界。边界切不对,大模型答错题的锅,到最后还是得你来背。
2. 文档解析:把“图片型PDF”干成纯文本
解析这个环节,首要任务是识货——得知道手里的文件是什么出身:是天生带文本层的电子版PDF,还是压根没有文本层的扫描件,是Word排版的复杂文档,还是结构清晰但样式花哨的HTML。不同的出身对应不同的工具组合,选错了,后面精调再多都是白费。
2.1 解析方案怎么选:常规库与OCR的边界
先聊工具。日常接触最多的解析对象就是PDF。轻量项目我用三个库就够了:PyMuPDF(也叫fitz)、pdfplumber 和 pypdf。它们各有侧重:
- PyMuPDF:解析速度快到离谱,适合大批量处理,而且对样式还原度还可以,能正常提取文字块坐标和链接信息。缺点是表格结构提取一般。
- pdfplumber:表格提取能力在纯Python库里算能打的,能保留单元格坐标,方便做结构化还原,但速度偏慢,五六百页的大文件跑起来急死人。
- pypdf:功能上更基础,胜在体积小、依赖少,适合做简单的文字合并,处理带复杂嵌套的表格就力不从心了。
至于扫描件和图片型PDF,上述常规库全部无效,必须上OCR。中文场景我强烈建议PaddleOCR,识别精度在中文上明显好于Tesseract,特别是带倾斜、模糊、水印的页面。Tesseract也不是不能用来兜底,但如果你的文档里满是中文小字号表格字,那识别结果基本没法看。这里有个经验:能不上OCR就不上,因为OCR识别率做不到100%,遇到小数点、账户号、合同条款里的“第二十三条”这种内容经常出错。但真躲不开了,优先用PaddleOCR的版面分析能力把表格区域和正文区域分开识别,再拼回原结构。
核心技术点我稍微展开一下:PDF解析库的原理其实都是解析PDF内部的对象结构——PDF文件里本身就存有文本对象的坐标和字体信息,PyMuPDF这类库干的活就是把这些对象按阅读顺序抽出来。但PDF的尴尬在于,它是“排版优先”的格式,阅读顺序和底层对象顺序不一定一致,特别是多栏版面、图文混排时,抽出来的文本经常跳行。所以解析代码写完后,第一件事不是接下游,而是人工抽查几十页,看看文字顺序对不对。这块没有捷径,一次抽查能帮你省掉后面三次Debug。
2.2 表格提取:Markdown比CSV更香
表格是解析里的硬骨头,也是我踩坑最多的区域。早期我做表格提取,直接输出CSV,结果检索时模型根本看不出表格里哪一行是哪一列的数据,语义关系全丢了。后来换成Markdown表格语法,情况立刻好转。原因不复杂:对大模型来说,Markdown表格天然保留了列头、行、单元格的相对关系,embedding模型在训练时大量见过这种格式,语义编码更准确。这就好比给人看报表,给一个带表头的对齐表格肯定比给“用逗号分隔的字段”更容易看懂。
具体操作时,我会先用pdfplumber识别页面里的表格线框,拿到每行每列的坐标后,把坐标范围内的文字抽取出来,再按坐标映射到二维数组。最后,把这个数组拼接成标准的Markdown表格。需要注意:如果表格本身跨页了,记得做跨页合并,不然检索出来的表只有一半。实现上,用pdfplumber的extract_table()方法拿到结构之后,写一个简单的转换函数就能完成Markdown输出,并不复杂。
2.3 解析质量自检清单
解析这步有没有做好的客观标准,我整理了一个自检清单,基本每条都可以人工抽检或脚本断言:
- 文本顺序是否符合阅读顺序,多栏页面有没有先右后左之类的倒序问题。
- 段落是否完整,有没有一句话被硬生生拆在页眉、页脚之间。
- 表格单元格内容是否连续,数字和单位有没有被拆分。
- 页眉页脚、页码、水印是否被错误混入正文。
- 字体信息里隐藏的注释、批注有没有被误提取成正文。
- OCR文本里是否存在明显识别错误,比如“0”识别成“O”。
这些点在开发初期看着都是小事,但线上跑起来之后,任何一个都会变成“为什么搜索出来答非所问”的根因。我之前维护的一套知识库,曾经被页眉污染了将近300个文档块,排查了整整一天才定位到是页眉被切进了正文,直接带偏了一大片检索结果。
3. 切片策略:从Python切片思想到文档切片实践
聊完解析,进入切片。切片这个动作严格来说分两层:一层是代码层面的Python数组切片、列表切片这些基本功,另一层才是RAG项目里的文档切片策略。你搜“python数组切片”相关热词,大概率是想弄明白怎么用代码切文本——但其实文档切片的思想,正是从Python切片操作延伸出来的工业级应用,理解了后者,前者也就顺了。
3.1 先讲透Python切片这个基本功
Python里对列表、字符串做切片是再常见不过的操作。语法很简单:seq[start:stop:step],start是起始索引,stop是结束索引(不包含),step是步长。三个参数都可以省略,省略start默认从0开始,省略stop默认一直切到末尾,step为负则倒着取。举个例子:
text = "文档解析与切片是地基" print(text[0:4]) # 文档解析 print(text[3:]) # 解析与切片是地基 print(text[::-1]) # 基地的...(倒序)这类操作看起来基础,但在写解析和切片代码时几乎处处用到。比如我要从一段OCR结果里去掉前两行的页眉信息,直接lines[2:]就搞定了;想按每段500字切分,就用text[i : i + 500]循环。真正要注意的是Python切片的一个特性:切片结果永远是新对象,不会修改原对象,而且索引越界不会报错、自动截断到边界。这两点特性在批量处理长文时很省心,但如果你靠索引值做偏移计算,得小心stop参数的位置是“开区间”——我经常在算overlap边界时多写了一个字符,导致重叠区域多了或少了。
3.2 文档切片的三驾马车:chunk_size、overlap、separator
把Python切片本质弄明白之后,开始设计文档切片策略。工业级实现里,核心参数就三个:chunk_size(切片长度)、overlap(重叠长度)、separator(分隔符优先级)。
chunk_size的选择,不能拍脑袋定一个固定中文字数,得跟embedding模型支持的token上限对齐。绝大多数开源embedding模型把输入上限设在512 token,部分新模型能到1024。所以我的建议是:先用模型的分词器把文本切成token序列,再按token数计算分块边界。中文场景有个粗略换算公式:1个token约等于0.6到0.7个汉字。512 token大约对应350个汉字。这个数,说实话作为单块检索单元有点短,所以我一般把chunk_size设置在700到1000 token之间,同时配合overlap使用,以便在长文本场景下把上下文接续起来。
overlap的设置,常规建议是chunk_size的10%到20%。为什么要有它?因为语言是连续的,信息在边界处往往有指代关系。比如“该方案”这种代词,前面那一句很可能落在上一个切片里。10%-20%的重叠能兜住大多数这类情况。但也不是越大越好,重叠太大会造成信息冗余,同一个内容被检索出来两三次,重排序时还得花时间去重。
separator指切分时的边界优先级。必须按照“段落级别优先,句子级别兜底,字符级别保底”的层级来递归切分。举个例子:先按\n\n切段落,段落太长再按\n切行,行还太长按中文句号、感叹号、问号切句子,再长就按逗号、顿号切分,最后实在不行才按固定长度硬切。这种层级递归的思路,能最大限度保证每个切片内部语义完整。
3.3 固定窗口切片和语义切片怎么取舍
市面上的RAG框架都自带一套固定窗口切片逻辑,用起来确实省事。但固定窗口的问题在于它对“语义边界”完全不敏感。打个比方:你有一本讲运维的书,某一页前半讲CPU优先级的调度逻辑,后半突然引出了服务降级的案例,固定窗口很可能把这两块内容切到同一个切片里。检索时问“服务降级”,结果召回的向量里可能掺了CPU调度的信息,语义被污染,排名就不稳定。
语义切片是补救思路。它通过句子向量之间的相似度变化来识别“语义拐点”,在拐点处断开。实现上没有听着的那么玄乎:先给每个句子做embedding,计算相邻句子向量的余弦相似度,相似度明显掉到阈值以下的位置,就认为是话题转折点,在这里切一刀。优点是可解释性强、切出来的块更有独立性;缺点是速度慢,对长文档来说embedding开销较大。我的经验是:在预算和技术储备允许的时候,做“固定长度切片 + 小的语义优化”——先按固定窗口切,再用简单规则把明显语义断裂的切片二次拆开,效果稳定且成本可控。
4. 实操:从零搭一套解析切片流水线
理论讲再多,不如把一套能跑通的脚本放在面前。我下面分享一套我目前还在生产环境用的轻量级流水线,整体流程是:PDF解析 -> 文本清洗 -> 按语义层级切分 -> 带索引切块。代码偏Python,依赖少,你在自己的机器上装好库之后基本能直接跑。
4.1 环境准备与最小实现
先装基础依赖:
pip install pymupdf pdfplumber langchain-text-splitters tiktoken这里解释下为什么我特别偏爱langchain-text-splitters。很多人一听LangChain就觉得重,但实际它有一个独立的轻量库,只包含文本分割器,不引那些框架依赖,非常适合只想用切分算法、不想背整个框架的团队。它内置的就是我上面提到的递归字符分割器,按separator优先级依次尝试,正好对上第三章的设计思路。
解析PDF并切片的流程,核心代码大约是这样:
import fitz # PyMuPDF from langchain_text_splitters import RecursiveCharacterTextSplitter # 第一步:解析PDF为纯文本 def extract_pdf_text(pdf_path): doc = fitz.open(pdf_path) text = "" for page in doc: text += page.get_text("text") + "\n" return text # 第二步:配置切片器 splitter = RecursiveCharacterTextSplitter( chunk_size=800, chunk_overlap=120, separators=["\n\n", "\n", "。", "!", "?", ",", ",", " ", ""] ) # 第三步:执行切片并打印结果 text = extract_pdf_text("sample.pdf") chunks = splitter.split_text(text) print(f"分割完成,共 {len(chunks)} 个切片")这里有个细节值得展开:chunk_size参数在LangChain的文本分割器里,默认是按Python字符长度计算的,不是token数。而不同的模型对token的敏感度差很多。所以我通常会先切,再统一做token校准:
import tiktoken enc = tiktoken.get_encoding("cl100k_base") def filter_long_chunks(chunks, max_tokens=1000): result = [] for chunk in chunks: if len(enc.encode(chunk)) > max_tokens: # 对超长的再递归切一次,或者标记出来人为处理 sub = splitter.split_text(chunk) result.extend(sub) else: result.append(chunk) return result生产环境我一般不会让超长切片静默通过,因为这说明源头文档的段落结构可能有问题,比如有个超长的表格被直接连成一整块文本了。这里宁可慢一点,也要把超长块标记出来回去查解析是不是漏了换行。
4.2 token是怎么算的,参数别拍脑袋
很多初学者上来就按“字符数”设置chunk_size,这是个挺普遍的误区。同样512字符,英文只有约128个token,中文则能到340左右,模型窗口的压力完全是两个量级。所以只要条件允许,一律按token数做块大小对齐。
token的计算也不复杂。OpenAI系的模型直接通用tiktoken,但如果你用的是开源的BGE、M3E这类中文embedding模型,它们各自用的分词器不同。务实点的做法是:先用模型普遍的“中文字符:token = 1:0.6”经验比估算,跑一批真实文档后,统计每个切片的实际token分布,把chunk_size调到中间位数为目标值的位置。这套流程比任何理论公式都可靠——因为文档本身风格差异很大,技术手册和客服对话的token密度完全不同。
还有一点:chunk_size设置如果过大,不仅影响embedding精度,还直接推高向量存储成本。假设你有100万段文本,chunk平均长度从500涨到1000,向量数量不变但embedding输入变长,计算开销和时间都翻倍。说实话,很多中小项目的成本失控,往往就是从盲目调大chunk_size开始的。
4.3 怎么判断“切得好不好”
切片质量没有唯一的客观分数,但有几个能落地的观测手段。
第一个是回归测试集。挑50到100个典型的用户问题,人工标注出每个问题对应的原文片段。然后跑完整检索流程,看改切片参数前后,召回内容里是否包含标注片段。召回率提升,说明切片在往正确方向走。这个方法土,但比任何“玄学调参”都靠谱。
第二个是可视化检查边界。我把切完的切片按ID输出到一份文档里,随机抽几个相邻切片,读一下边界处的上下文是否连贯。如果切片A尾部说“基于以上分析”,切片B开头却在讲一个完全无关的目录项,那说明分隔符优先级设置得不对,段落拆早了。
第三个是观察检索结果的冗余度。如果用户问一个问题,检索返回5条结果里有4条内容高度重复,说明overlap可能太大,或者切片粒度不够细,同一信息分散到了多个块里。此时适当缩小overlap,或者调整separator优先级,一般能明显改善。
5. 常见问题与排查技巧实录
这部分是我在多个项目里攒下来的问题清单,按出现频率排了个序。每一条都是踩过坑之后才总结出来的,直接上干货。
| 现象 | 根因 | 排查思路与解法 |
|---|---|---|
| PDF解析出来全是空串或乱码 | 扫描件无文本层,被普通解析库硬解 | 先用PyMuPDF看page.get_text()返回是否有内容,空则切换OCR管线,别硬调解析库 |
| 表格里的数字串带空格、被拆分 | OCR或pdfplumber的行列映射错位 | 表格提取后做单元格文本内空格规整;检查表格线的识别参数,必要时按坐标手工合并 |
| 切片经常把一句话截断 | separator没配中文标点,固定阈值硬切 | 按“段落-行-句-分句”的递归层级配置分隔符,并把中文标点加进去 |
| 检索召回重复率超高 | overlap过大或切片粒度过粗 | 先调小overlap到8%-12%,再看是不是表格类内容被整块保留导致容器过大 |
| embedding结果都是“平均脸”,答非所问 | chunk_size过大,切片混合多个话题 | 缩小chunk_size到500-800 token,或用语义切分在话题转折处断开 |
| 页眉页脚混入正文,污染召回 | 解析时未过滤重复区域 | 开发期统计高频文本片段,把页眉页脚文本列表做黑名单,分配置过滤 |
| 同一个答案需要跨多个切片才能拼全 | 切片边界恰好切在信息依赖链上 | 提升overlap比率,或对包含“综上所述”“具体来说”等接续词的段落做合并处理 |
| 中文文档切出来的块偏碎 | 中文标点密度高,句子太短 | 提高段落级separator权重,别用英文句号做中文切分边界 |
这里面最难排查的其实是页眉页脚污染。因为从单块切片看,内容都“像”正文,只有当你按文档ID把同一篇文档的所有切片列出来横向对比,才会发现每一页都有一段完全重复的文本。我自己排查这个问题时,就是写了个小脚本统计所有切片的重复片段,把出现频次超过页面数的文本自动打标,效果立竿见影。
还有一个小坑:很多人喜欢在解析之后做“清洗”,比如去掉所有换行符。这个操作极其危险。表格单元格之间的换行是有语义的,直接全局替换会把列表结构、段落结构全部揉成一大坨,切片时就没法按结构切了。正确的做法是保留原始换行结构,清洗只针对空白字符、OCR误识别的特殊符号,而不是粗暴地去换行。这一点虽小,但在下游切片时带来的差别非常大。
6. 一点个人体会
这篇文章写到这里,我最大的体会其实是:解析和切片,看起来技术含量不如模型训练高,但它真正决定了项目能不能交付。我见过太多团队把大量精力花在调模型能力上,回头一查知识库,源头数据根本没整理好。这就好比盖楼,楼顶装修得再豪华,地基是歪的,早晚出事。
但要真做扎实,也不难。核心就是守住三个原则:解析阶段要求文本无损、顺序正确;切片阶段要求边界语义完整、按token对齐;上线之前一定要用回归测试集验证。把这三条贯彻到日常开发流程里,知识库的准确率基本就有了底线保障。
最后分享一个我一直在用的小技巧:每个切片在写入向量库前,我都会给它附加一段元数据,里面存好来源PDF的文件名、页码、章节路径和切片序号。这串元数据看着不起眼,但它让所有下游问题都有迹可循——用户指出某个回答不对,我直接能定位到是哪一个切片提供的依据,进而判断是解析的问题、切片的问题,还是模型本身的问题。如果你的知识库现在还在被各种“答非所问”折磨,不妨从这一步开始排查,大概率会有惊喜。