
做RAG知识库的人,大多经历过这种时刻:检索器换了好几种,embedding模型从base一路升到large,prompt工程更是翻来覆去地调,可一问到文档里的细节,模型回答还是驴唇不对马嘴。最后耐着性子把整条管道一层层扒开才发现,问题根本不在检索,也不在生成,而是出在最底下的两个环节——文档解析和切片。表格被拆得七零八落、双栏文章左右两栏搅成一锅粥、扫描件压根没过OCR,放进向量库的全是残缺噪音。这种时候,再多花哨的召回策略和提示词技巧都是白搭。地基打歪了,上面盖什么都是危楼。这个系列走到第二篇,正好轮到文档解析和切片这两个最不起眼、又最能决定成败的环节。我准备把真实项目里踩过的坑、验证过的方法、最终沉淀下来的流水线,掰开揉碎讲一遍。适合正在被召回效果折磨、又不知道从哪下手排查的人,也适合刚搭RAG、想从源头把数据做扎实的新手。注意,这篇不聊高深的检索模型,只聊你最可能忽略的脏活累活。1. 文档解析与切片在RAG管道里的位置:脏数据进,必然脏结果出先把整条链路摆出来:原始文档 → 解析 → 清洗 → 切片 → 向量化 → 检索 → 排序 → 生成。很多人一上来就盯着向量化和检索这两个最“性感”的部分,把大部分精力花在调embedding、调TopK、加rerank上。但要知道,检索模型的天花板是被输入chunk的质量决定的。embedding模型再强,也编不出一个它没见过的完整语义;rerank模型再准,也只是在已有的候选集里排序。一个被切坏的chunk,比如把“退款政策如下:1. 7日内可无条件退货”拦腰切成“退款政策如下:1. 7日内可无条件”,那么无论用什么embedding模型,这段话的语义都是残缺的,相似度计算自然跑偏。一个被解析错误的表格,比如“地区/销售额”被拍平成“地区 华东 华北 销售额 100万 200万”,向量化之后,模型根本答不出“华北的销售额是多少”。这些错误会顺着管道一路传播到检索和生成,而你往往只看到最末端的坏结果,却把锅甩给模型。这就是文档解析和切片被称为地基的原因。它们产出的不是“文本”,而是“最小可检索单元”。每个单元的质量、边界和上下文完整度,直接决定了后续所有环节的上限。一个实际经验法则是:检索效果不佳时,优先检查数据端而不是模型端。我见过太多团队花了两周调模型,最后发现是切片的overlap参数设成了0,大量跨块语义被拦腰截断,模型一直在和一个残缺的地基较劲。再往深里说,解析和切片影响的远不止召回。生成阶段对上下文窗口的利用、引用来源的标注、知识库的权限控制、后续的更新同步和去重合并,全都依赖chunk携带的元信息。如果解析阶段没保留页码、章节和标题路径,那么就算检索命中了,你也无法告诉用户“答案来自第三章第2节”,只能在黑盒里碰运气。地基决定建筑高度的说法,放在这里毫不夸张。2. PDF解析的暗坑:表格、双栏与扫描件是怎样毁掉后链路的文档解析最大的问题不是“读不出来”,而是“读出来的东西失去了原来的逻辑结构”。PDF尤其典型。PDF本质上是面向打印的格式,记录的是“哪一页哪个坐标画了一段字符”,而不是“哪段文字属于哪个标题、哪个单元格属于哪一列”。所以解析PDF的过程,本质上是从视觉坐标里重新推断逻辑结构,这一步做不好,后面全完。2.1 表格被拍平:行与列的关联直接丢失用pdfplumber或PyPDF2做简单文本抽取时,表格会被拍平成一行行文本,行列对应关系直接丢失。比如一个两列销售表“地区/销售额”会被抽成“地区 华东 华北 销售额 100万 200万”。切片阶段如果固定按长度切,sentence了“华北”和“200万”之间,这条信息连人工都难以拼回来。检索“华北销售额”时,模型很难把“华北”和“200万”关联起来。我的建议是:表格必须单独走表格抽取逻辑。用pdfplumber的extract_table或专门的表格解析工具拿到二维结构,再序列化成“键:值”平铺文本,比如“地区:华东;销售额:100万;地区:华北;销售额:200万”。这样每个单元格的语义关系还在,切片时也不容易切断一个完整的记录。2.2 双栏与多栏排版:乱序文本最难排查学术论文、杂志、宣传册特别喜欢用双栏。按视觉顺序从上到下抽取文本时,左栏读到一半直接跳到右栏上部,读出来的字符串完全乱序。这种乱序文本最隐蔽,因为肉眼扫一眼可能看不出大问题,但向量化后语义距离被拉得乱七八糟。处理思路有两条。一是用版面分析模型,比如unstructured的layout检测、docling或PyMuPDF的blocks信息,先识别栏的位置,再按栏顺序拼接。二是退而求其次,把整页按坐标切成左右两块分别读取,再按“先左后右”的顺序拼接。不管用哪种,下游效果都会明显改善。同时,在用pdfplumber抽取时,注意调整x_tolerance参数——它决定了同一行字符间多大间距才算“断开”,双栏场景下这个值尤其敏感,默认值经常把同一行的两个栏混在一起。2.3 扫描件、页眉页脚:OCR之后还有一道工序扫描件和图片型PDF没有文本层,直接抽取只会得到空字符串,必须上OCR。但OCR不是跑一遍PaddleOCR或Tesseract就完事,还要做两个后处理:一是按识别位置坐标重排文本,保证阅读顺序正确;二是处理置信度低的区域,必要时人工补录。OCR产生的错字对embedding的影响是潜移默化的,比如“退款”被识别成“推款”,用户query里写“退款”就怎么也召不回。页眉页脚和页码则是另一个不起眼但杀伤力巨大的坑。它们在每个chunk里反复出现,造成大量重复文本,挤占向量库容量,检索时还容易喧宾夺主。清洗阶段的常规做法是用规则或版面分析把页眉页脚区域直接丢弃。我现在的习惯是,先自动识别文档类型,再走不同解析路径:纯文本PDF、扫描PDF、带复杂表格的财报PDF,绝不用同一套参数。把“解析方案选择”做成管道里的一步,而不是写死,这也是unstructured这类框架受欢迎的原因。3. 切片策略的本质:固定长度、结构感知、重叠窗口与语义边界切片的目标是产出“语义完整、粒度合适、便于检索”的chunk。但大多数人的第一反应是固定长度,比如512字符一刀。这个方案不是不能用,前提是你的文档语义单元本来就比较均匀,比如日志、键值对、表格行。一旦面对自然语言文章,固定长度就会不断制造语义截断。3.1 固定长度切片的致命缺陷拿一句话举例:“根据公司规定,退款政策适用范围包括线上渠道和线下门店,其中线上渠道需在购买后7日内申请,线下门店需保留原始小票。”如果切成两个固定块,很可能在“其中线上渠道需在”这里截断,第一个chunk的语义是“退款适用范围包括……需”,第二个chunk开头是“在购买后7日内申请……”,两个chunk都不自洽。embedding算出来的向量,自然偏向字面碎片而不是完整语义。更麻烦的是,固定长度切出来的块往往出现高频噪声词。中文里如果刀口正好落在实体名词中间,比如“人民法”和“院判决书”,检索“人民法院”时,两个chunk的相似度都会被拉低。这类问题没法靠提升模型解决,只能回到切片策略上去。3.2 三种更靠谱的切片思路:结构边界、重叠窗口与句级聚合我实践中最常用的三种思路,按优先级排序:一是结构感知切片。如果文档有天然结构,比如Markdown标题、HTML标签、Word标题样式、PDF里能被识别出的章节标题,就优先用结构做边界。按章节和段落切,最大程度保证语义闭环。用LangChain的RecursiveCharacterTextSplitter时,就是先试标题分隔符,再逐级往里找。二是固定大小加重叠窗口。这是最通用的兜底方案。设定chunk上限,一般由embedding模型的上下文长度决定,再设定一个overlap,通常10%到20%。overlap的意义在于:哪怕一刀切断了语义,下一个chunk也会带上上一刀尾巴的上下文,检索时更容易命中完整窗口。举个例子,一句话有30个token,窗口500 token、重叠100 token,那么这句话无论如何都不会被完全割裂,总有某个chunk包含它的完整文本。三是句级聚合。先用句号、问号、感叹号以及分号把文档断成句子,然后把句子依次填进chunk,直到接近上限。它比固定长度智能,比结构感知通用,而且实现不复杂。我个人的默认起点就是“句级聚合加结构感知修正”,再叠加一个小比例重叠。3.3 语义切片和硬约束:参数不是拍脑袋定的语义切片是另一种思路,用embedding模型对文本窗口算相似度,在相似度出现明显下降的地方切一刀。它对长文档、主题切换明显的文档效果最好,但计算成本高,边界容易抖动。我在生产环境里很少全量使用,只在特定高价值文档集上做增强。选型时还要记住两个硬约束:chunk上限不能超过embedding模型的最大输入长度,否则多出来的token会被静默截断,等于白切;chunk下限也别太短,太短的块信息密度和上下文不足,检索出来也回答不了问题,还会把向量库变成碎片堆。中文场景下我用过比较稳的一组参数是:目标块600到1000字,重叠10%到15%。英文场景按token算,800到1200 token比较常见。具体数值要根据你的文档类型和测试问题集去微调,而不是照抄任何模板参数。4. Python列表切片与文本切片的映射:语法、边界与常见误区网上搜“python数组切片”的相关内容一直很多,因为这个语法看着简单,实际边界特别容易搞混。文本切片恰恰是最好的应用场景。先把Python切片语法讲透:对一个列表或字符串s,s[start:end:step]返回从start开始、end前一个元素结束、按step步长取出的子序列。start缺省默认从0开始,end缺省到最后,step为负数表示反向遍历。真正容易错的两点:end位置的元素取不到;负数索引从-1开始倒数。比如sentences [我, 们, 讨, 论, 文, 档],sentences[1:4]拿到的是“们、讨、论”,最后一个下标4对应“文”不在结果里。这个“取左不取右”的规则和直觉容易拧,写重叠窗口的循环时,我习惯先打印切片边界验证一遍,免得错位。4.1 文本切片的正确打开方式:先拆列表,再切窗口把列表切片用到文本切片上,核心思路是:先把文档拆成句子或段落的列表,然后把这个列表当作数组,用下标范围取连续片段。假定doc_sentences是一个句子列表,chunk_size10,overlap2,那么窗口可以写成doc_sentences[i : i chunk_size],下一个窗口起点就是i i chunk_size - overlap。每次slice返回的新列表,就是我们要的chunk。这里有个容易忽略的细节:对字符串直接做切片是按字符切的。中文按字符切没毛病,Python 3的字符串下标就是字符位置,不会像Python 2那样按字节。但按字符切的问题是语义边界完全不受控。所以我强调“先拆成句子列表,再对列表做切片”,而不是直接对长字符串按字符下标截取。前者保留了语言边界,后者只是暴力切割。4.2 切片是浅拷贝:别踩引用陷阱列表切片返回的是浅拷贝。如果元素是字符串(不可变),没问题;如果元素是可变对象,新列表里的元素仍然指向原对象。文本切片场景下,元素基本都是字符串,所以这个坑影响不大,但要心里有数。另外,如果文本解析后得到的列表很大,一次性切片所有chunk会占不少内存。生产级代码里我更偏向把切片逻辑封装成generator,边生成块边处理,不把所有块都放内存。再补一个和热搜词相关的实操技巧:做语料预处理时,经常需要去掉开头和结尾的噪声行,或者抽样巡查切片质量。这种时候利用步长参数很高效,比如sentences[::10]直接做等间距抽样,标记出需要人工复核的chunk。但注意,抽样巡查永远替代不了用测试问题集做端到端验证。5. 切片质量的验证方法:用召回率反推边界问题切片做得好不好,不能靠肉眼看两个chunk说了算。量化的办法很多,我用得最顺手的是“测试问题集加召回命中分析”。5.1 两个观测指标:命中率与跨块率准备工作是挑20到50个真实用户可能问的问题,每个问题都要能对应到文档里的一段确定文本,最好答案是一整段句子或一个表格单元。然后逐个问题跑检索,记录两件事。第一,正确答案对应的文本是否在召回的Top3里;第二,正确答案是否跨了多个chunk。第一件事反映召回能力。如果命中率低,而embedding模型和检索器又没有太多可调空间,大概率是切片把关键句切碎了。第二件事更直接:一个问题对应的标准答案如果跨在两个相邻chunk之间,说明切片边界正好压在语义中央。处理方式通常是调整overlap,或者把边界规则改成“只在句子结束符之后切”,再跑一轮观察。5.2 借助chunk元信息定位病根诊断时我还会看chunk的元信息,前提是chunk里带了id、来源页码、标题路径这些字段。检索命中后,把命中的chunk标题路径打印出来,对照原文结构看命中位置。如果一堆命中的chunk都来自页眉页脚的重复文本,说明清洗没做彻底;如果命中的chunk标题指向章节开头,但答案在章节末尾,可能是切片粒度太粗或overlap不足。我自己踩过的一个典型坑:做企业合同库时,“违约责任”相关问题的召回率特别低。用元信息定位后才发现,合同里的违约责任条款是一大段带编号的长文本,被固定长度切片拦腰切断,两个子块里各有一半责任类型。检索“逾期交付违约金是多少”时,只会命中包含“违约金”字样的另一半,答案永远缺一半信息。后来给合同类文档单独设置了结构感知加句级聚合,把这种条款作为完整块保留,问题立刻消失。5.3 小步调参:一次只改一个变量调参时我坚持小步快跑,一次只改一个变量。比如从512字改到700字,跑一遍测试集看命中率;再从700字加overlap 100字,再跑一遍。记录成一张表,记下块大小、overlap、命中率、无效命中率、平均检索耗时。几次迭代下来,你就能找到当前文档集的最优参数区间。多数情况下不需要每篇文档单独调参,按文档类型分两到三组参数就够了。调参时也要留意,Markdown表格、嵌套列表、法律法规的“第X条”这类强结构化文本,容易出现“结构很碎”的问题——每个列表项单独成块。这种时候不要在一个通用规则里疯狂打补丁,单独给这类文本写边界规则,比在通用规则里加异常分支更干净。6. 一套务实可复用的解析与切片流水线:核心代码与元信息设计最后给一套可以直接拿去改的流水线。我用Python写,核心依赖pdfplumber和pypdf,切片逻辑自己实现,不依赖LangChain的封装,方便在生产环境里按需定制。思路分四步:解析PDF拿到页面文本和坐标信息;清洗,去掉页眉页脚、合并断行;把文本拆成句子列表,按“句级聚合加重叠窗口”切块;把每块连同元信息写成JSONL。6.1 解析与清洗:从PDF到干净文本import re import pdfplumber from dataclasses import dataclass, asdict import json def extract_page_texts(pdf_path): page_texts [] with pdfplumber.open(pdf_path) as pdf: for page_no, page in enumerate(pdf.pages): text page.extract_text(x_tolerance1, y_tolerance1) page_texts.append((page_no, text or )) return page_texts def clean_text(text: str) - str: # 去掉常见页眉页脚,这里给出项目里用过的两条规则示意 text re.sub(r第\s*\d\s*页.*?$, , text) text re.sub(r©\s*\d{4}.*?公司, , text) # 合并断行:中文字符结尾的换行直接去掉,英文场景可能要加空格 text re.sub(r\n(?[\u4e00-\u9fff]), , text) # 压缩多余空白 text re.sub(r\s, , text).strip() return text注意pdfplumber的extract_text可能返回None,先判空再处理。x_tolerance和y_tolerance这两个参数要按文档类型调整:双栏排版时x_tolerance默认值容易把左右两栏混在一起,复杂表格可能需要增大x_tolerance来保持行内结构。表格解析建议单独走extract_table,不要和正文混在一起。6.2 切块与输出:JSONL格式与必备字段def split_sentences(text: str): parts re.split(r(?[。!?;]), text) return [p.strip() for p in parts if p.strip()] def chunk_sentences(sentences, chunk_size10, overlap2): chunks [] i 0 n len(sentences) while i n: window sentences[i : i chunk_size] chunks.append(window) if i chunk_size n: break i chunk_size - overlap return chunks dataclass class ChunkRecord: chunk_id: str doc_id: str page_no: list heading_path: str text: str token_count: int def write_chunks(windows, meta, out_path): records [] for idx, window in enumerate(windows): text .join(window) records.append(ChunkRecord( chunk_idf{meta[doc_id]}-{idx:04d}, doc_idmeta[doc_id], page_nometa.get(page_no, []), heading_pathmeta.get(heading_path, ), texttext, token_countlen(text) )) with open(out_path, w, encodingutf-8) as f: for r in records: f.write(json.dumps(asdict(r), ensure_asciiFalse) \n)这段代码不是生产级的完全形态,但有两个设计我觉得值得强调。第一是heading_path:它不是在切片阶段才有的东西,而是在解析阶段靠版面分析或文档大纲提取出来的。检索阶段可以用它做过滤,比如“只搜合同里的违约责任章节”;排查阶段可以根据它一眼定位问题chunk在原文哪个位置。第二是token_count:向量库支持按token数过滤时,它能派上用场,调参时也能用它统计块长分布。这两个字段加上doc_id和page_no,基本保证了后续可以刷线、去重、定位、审计。很多团队把chunk简单存成一个列表,出了问题连来源都找不到,最后重做数据管线等于再来一遍。所以我强烈建议从第一天就把元信息存完整,哪怕现在用不上。6.3 上线前的最后一道质检流水线里最后一步是自动质检:跑一遍测试问题集,把命中率记下来。第一次搭建时,建议先拿50页样本文档全流程跑通,再扩大到全量。不要一上来就灌十万个文档,万一解析方案错了,清洗和切片的返工成本会让人怀疑人生。从我的实际项目经验看,解析和切片这层做扎实之后,向量库的检索调试会变得很轻松:TopK稍微调一下就有肉眼可见的变化,prompt里的引用也更容易给对。反过来,如果地基没打牢,你花大价钱优化的模型和检索器,本质上都是在跟噪声较劲。先把数据端的问题解决,再谈算法端,这个顺序不能反。