做RAG项目最容易被低估的,其实不是模型选型,也不是向量库性能,而是最前面的两道预处理步骤——文档加载和文本分割。很多人辛辛苦苦调prompt、换embedding,检索效果还是稀烂,回头一查,往往是加载器读进来一堆乱码,或者分割器把语义完整的段落切得七零八落。
这篇文章我就围绕LangChain里的TextLoader和RecursiveCharacterTextSplitter,展开讲讲我在实际RAG开发里的用法、参数调优经验,以及那些文档里不会明说的坑。适合正在搭建RAG流水线、或者刚接触LangChain想把预处理环节做扎实的开发者参考。
1. RAG流水线最前端的两道关卡:加载和分割为什么决定检索上限
先给还不熟悉RAG的读者快速对齐一下上下文。RAG(检索增强生成)的完整链路大致是:加载文档 → 切分文本 → 向量化 → 存入向量库 → 检索 → 注入Prompt → 大模型生成。很多人把注意力集中在向量化模型选哪个、向量库用Milvus还是pgvector,却忽略了最前面的加载和分割环节——而恰恰是这两个环节,决定了后续检索质量的天花板。
为什么这么说?因为向量检索的本质是语义相似度匹配,而语义相似度依赖的是文本片段的完整性。如果文本被切得支离破碎,一段话的前半句在chunk A、后半句在chunk B,那么用户查询时无论检索到哪一块,都拿不到完整上下文。同样,如果加载器把PDF里的文字读成一堆乱码或者丢失了换行结构,再好的分割器也无能为力。这就是我常说的“垃圾进,垃圾出”在RAG里的典型体现。
另一个容易被忽视的点是:加载和分割不仅影响检索精度,还直接影响索引规模和token消耗。chunk_size设得太小,文档被切成成千上万个小片段,向量库膨胀、检索耗时上升;设得太大,chunk里混入大量无关信息,向量表示被稀释,检索召回的相关性也会下降。这就像切菜——切太碎炒出来一团糊,切太大块又不好入味,火候和刀工都得拿捏。
所以我在搭建RAG系统时,会把加载和分割单独当作一个环节来测试和调优,而不是草草写几行代码带过。下面先从加载器说起。
2. TextLoader实战:不仅仅是读文件,而是处理真实世界的文档格式
2.1 LangChain文本加载器家族盘点:什么时候该用哪个
LangChain里处理纯文本最基础的就是TextLoader,它做的事情很简单:把指定路径的文件读成字符串,再交给后续的分割器。但实际项目里的文档格式五花八门,只靠TextLoader远远不够。我在项目里通常会根据文件类型选择不同的Loader,大致如下:
| 文件类型 | 推荐Loader | 适用场景 |
|---|---|---|
| 纯文本.txt | TextLoader | 日志、笔记、纯文本知识库 |
| PyPDFLoader / PyMuPDFLoader | 论文、报告、书籍扫描件(带OCR) | |
| Word文档 | Docx2txtLoader | 产品文档、合同、制度文件 |
| Markdown | UnstructuredMarkdownLoader | 技术文档、博客文章 |
| 目录下批量文件 | DirectoryLoader | 批量加载文件夹内所有支持的文档 |
| CSV / Excel | CSVLoader / PandasLoader | 表格数据、导出记录 |
这里特别说一下DirectoryLoader,它是个非常实用但很多人不知道的组件。它支持指定目录和通配符,自动批量加载目录下所有匹配的文件,还能通过loader_cls参数指定每类文件用哪个加载器。比如我处理一个混合了PDF和Word文档的项目档案目录,只需要写一个DirectoryLoader配置,就能自动识别并调用对应的解析器,省去了手写文件遍历的麻烦。
不过要提醒的是:LangChain的Loader并不是万能的,对于扫描版PDF(图片型PDF),任何Loader都提取不到文字,必须先用OCR处理成文本才行。遇到这种情况,我通常先用OCR工具把PDF转成带文字的PDF或纯文本,再走LangChain的加载流程,而不是在Loader层面死磕。
2.2 TextLoader核心参数与真实项目里的编码血泪史
TextLoader的常用参数不多,但每一个都在真实场景里踩过坑:
from langchain_community.document_loaders import TextLoader # 基础用法 loader = TextLoader("./data/knowledge_base/产品手册.txt", encoding="utf-8") docs = loader.load()第一个坑是编码问题。Windows环境下很多文档其实是GBK/GB2312编码,直接用默认的UTF-8读取会抛UnicodeDecodeError。我第一版写RAG的时候,在同事的Windows机器上跑就炸了。解决方案有两种:一是显式指定encoding="gbk",二是更稳妥的办法——先探测文件编码再决定用什么编码读取:
import chardet from langchain_community.document_loaders import TextLoader def load_text_with_auto_encoding(file_path): with open(file_path, "rb") as f: raw = f.read() detected = chardet.detect(raw) encoding = detected.get("encoding", "utf-8") loader = TextLoader(file_path, encoding=encoding) return loader.load()这个函数我在多个RAG项目里反复用,基本能覆盖90%以上的乱码场景。当然chardet不是100%准确,遇到编码识别错误导致乱码的情况,我会手动指定编码重跑一遍。
第二个坑是加载后的元数据缺失。TextLoader返回的Document对象只有page_content和metadata两个字段,默认的metadata只有文件来源路径。这意味着如果你后续要按文档来源过滤检索结果,就得手动在metadata里补充信息。我在加载时通常会这样处理:
docs = loader.load() for doc in docs: doc.metadata["source_file"] = file_path doc.metadata["category"] = "product_manual" # 自定义分类第三个坑藏在TextLoader的行为细节里:它默认会保留原始文本中的所有空白和换行。如果源文件是爬虫抓下来的网页文本,里面大量无意义的换行和空格会被带进分割环节,干扰分割器的判断,导致chunk边界乱跳。对于这类文本,我一般会先做一轮简单的文本清洗,合并多余空白,再交给Loader或分割器处理。
2.3 从单一文件到批量文档:DirectoryLoader配置示例
单个文件加载只是起步,真实项目里通常要处理几十上百个文档。DirectoryLoader的效率优势在这个场景才真正体现出来:
from langchain_community.document_loaders import DirectoryLoader, TextLoader # 批量加载文件夹下所有 .txt 文件 loader = DirectoryLoader( "./data/knowledge_base", glob="**/*.txt", # 递归匹配所有子目录下的txt文件 loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, show_progress=True, # 文件多了以后,进度条真的很省心 ) docs = loader.load()这里有个关键参数glob,它控制匹配模式。"**/*.txt"会递归匹配所有子目录,"*.txt"只匹配当前目录。我见过不少人在这个参数上漏掉前面的**/,结果子目录下的文件一个都没加载进来,索引白白少了一大半数据。
还有个更进阶的用法是loader_cls配合自定义加载函数,实现“根据文件扩展名选用不同Loader”的混合加载策略。比如.txt用TextLoader、.md用UnstructuredMarkdownLoader、.pdf用PyPDFLoader:
from langchain_community.document_loaders import DirectoryLoader, TextLoader, PyPDFLoader def load_pdf(file_path): return PyPDFLoader(file_path).load() loader = DirectoryLoader( "./data", glob="**/*", loader_cls=TextLoader, # 默认用TextLoader loader_kwargs={"encoding": "utf-8"} ) # 配合文件扩展名判断自行实现路由逻辑这里我坦白说一句:LangChain的DirectoryLoader虽然方便,但如果项目里的文档结构非常复杂,比如PDF里混着表格、图片、多级目录,我反而倾向于自己写一个加载模块来做格式路由和清洗,再统一输出给分割器。这样控制力更强,也方便写单元测试。工具是服务于场景的,不必为了用LangChain而强上DirectoryLoader。
3. RecursiveCharacterTextSplitter拆解:为什么它是默认的也是好用的
3.1 从“切字符串”到“保语义”:分割器的设计演进
加载器产出的原始文本往往是几万字甚至几十万字的长文档,直接整篇向量化既不现实也不合理——向量模型对输入长度有限制,而且超长文本的向量表示会严重稀释局部语义。所以必须做文本分割,而分割方式的优劣直接决定后续检索的效果。
最早期的分割方式最简单粗暴:按固定字符数硬切(比如每512个字符一刀切)。这种方式速度快,但缺点极其明显——它完全不关心语义边界,可能把一个句子的主谓宾拆散,也可能把列表项从中间截断。我用这种方式做过实验,检索出来的chunk经常是半截话,大模型拿到这种上下文,生成质量自然拉胯。
随后出现了按分隔符分割的思路,比如CharacterTextSplitter按\n\n切、按句号切。这比固定长度切进步了很多,但仍有局限:一个文档里往往存在多种粒度的分隔符——段落间是双换行,段落内是单换行,句子间是句号,句子内可能还有逗号。单一分隔符很难兼顾所有情况。
RecursiveCharacterTextSplitter的设计正好解决了这个问题。它的核心思想是:维护一个分隔符列表,按优先级从高到低依次尝试分割。先用最高优先级的分隔符(通常是双换行)把文本切成分段,如果切出来的片段仍然超过设定的chunk_size,再用下一个优先级的分隔符(单换行)继续切,以此类推,直到所有片段都满足大小要求。
separators = ["\n\n", "\n", "。", "!", "?", ",", " ", ""]这个设计妙在“递归”二字:它不是一次到位,而是像剥洋葱一样一层一层往里切,每一层都优先保住在当前粒度下语义最完整的块。最终效果是:大段的语义单元(段落、小节)被保留,实在超长的连续文本才被迫进一步细分。这也解释了为什么很多RAG教程默认用它、LangChain官方也把它列为推荐的分割器——因为它在“控制块大小”和“保留语义完整性”之间取得了比较好的平衡。
3.2 核心参数逐一拆解:chunk_size、chunk_overlap、separators与length_function
RecursiveCharacterTextSplitter的核心参数不多,但每个参数都直接影响分割结果质量,值得逐一弄清楚。
chunk_size(块大小):每个文本块的最大字符数(默认按字符计,也可以通过length_function改成按token计)。这个值决定了一个chunk能装多少内容,直接影响向量表示的粒度。设置太大,chunk内噪声增多,检索精度下降;设置太小,语义被切碎,检索召回的信息不完整。我后面会专门讲这个参数的实际调法。
chunk_overlap(块重叠):相邻两个chunk之间重叠的字符数。为什么要设置重叠?因为语义上下文经常跨越分割边界——一个句子的结尾可能被切到下一个chunk的开头,如果不做重叠,检索到后半句时缺少前半句的语境。设置合理的重叠可以缓解边界上下文丢失的问题。一般经验值是chunk_size的10%~20%。
separators(分隔符列表):这是RecursiveCharacterTextSplitter区别于其他分割器的灵魂参数,可以自定义传入。不同语言、不同文档类型的文本结构差异很大,默认分隔符列表(双换行、单换行、句号、问号、感叹号、逗号、空格、空字符串)对中文和英文都适用,但对于代码文档、日志文本、JSON数据,我会自定义分隔符列表,把代码块标记、缩进、引号等作为优先级较高的分隔符。
length_function(长度计算函数):默认len()按字符数计算块大小。但对中文文档来说这个值偏宽松,因为一个中文词组的语义密度远高于几个英文字母。我更推荐按token数计算——尤其当后续接入的大模型和embedding模型按token计费、有上下文长度限制时,按token切更可控。LangChain提供了tiktoken等工具来做token级长度计算。
来看一个实际配置示例:
from langchain_text_splitters import RecursiveCharacterTextSplitter text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, length_function=len, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], ) chunks = text_splitter.split_documents(docs)3.3 可视化一次分割过程:分隔符优先级的实际行为
只看文档里的参数说明,很难直观感受“递归分割”到底怎么工作。我拿一小段模拟文本演示一下分割路径,读者可以更好地理解内部逻辑。
假设有这么一段文本:
### 引言 智能客服系统是RAG技术最成熟的应用场景之一。它结合了检索与生成两大能力。 在构建过程中,我们首先需要处理知识库文档。当chunk_size=100时,分割过程大致如下:
- 第一轮:用最高优先级的
\n\n尝试分割,文本被切成"### 引言"和"智能客服系统是RAG技术最成熟的应用场景之一。它结合了检索与生成两大能力。\n在构建过程中,我们首先需要处理知识库文档。"两块。前者长度远小于chunk_size,符合要求,不再处理。 - 第二轮:第二块长度超过100,于是用第二优先级的
\n继续分割,变成"智能客服系统是RAG技术最成熟的应用场景之一。它结合了检索与生成两大能力。"和"在构建过程中,我们首先需要处理知识库文档。"。 - 第三轮:第一句仍然超过100,继续用句号分隔符分割。最终产出若干个较小的chunk。
注意一个关键细节:RecursiveCharacterTextSplitter在做重叠处理时,如果某一段文本长度恰好小于chunk_size但大于chunk_overlap,它的合并策略会很微妙——它倾向于让chunk_overlap由“下一个chunk的开头部分”承担,而不是在前一个chunk末尾补充。理解这个细节对精细调参很重要,如果读者在生产环境里发现chunk边界不符合预期,多半就是对这个行为理解不够。
4. 参数调优的实战路线图:块大小、重叠度与验证方法
4.1 chunk_size的选择逻辑:按文档类型和检索场景分档设置
很多初学者问我的第一个问题就是:“chunk_size设多少合适?”说实话,这个问题没有标准答案,但我可以根据多年的实践给出几个稳妥的档位和建议。
| 文档类型 | 推荐chunk_size | 推荐overlap | 说明 |
|---|---|---|---|
| 说明书/操作手册 | 200-400 | 20-40 | 步骤类内容语义密度高,小块减少噪声 |
| 技术文章/博客 | 300-500 | 30-50 | 兼顾段落语义与检索精度 |
| 论文/研究报告 | 500-800 | 50-100 | 摘要、结论等段落较长,需要留足上下文 |
| FAQ问答对 | 100-200 | 0-20 | 本身就是独立语义单元,不需要太大块 |
| 代码仓库文档 | 300-500 | 50 | 需要保留函数签名和上下文注释 |
这里背后的逻辑是:chunk的大小应该跟文档里“语义完整单元”的平均长度匹配。FAQ的每个问答对通常一两百字,块设太大反而把不相干的内容混进来;技术论文的段落经常五六百字,块设太小则把一段完整论证切碎了。
还有一类场景需要单独说——接入了长上下文大模型(比如现在主流模型的128K、1M上下文)。有人觉得“反正模型上下文够长,chunk设大点无所谓”,但这个问题不能只看生成端,还要看检索端:embedding模型对文本长度的表示能力有限,超过几百个token的文本向量化后,语义特征会被平均化,检索召回精度显著下降。即使大模型能容纳长上下文,你检索到的chunk也未必是真正相关的那个。所以我的建议是:chunk_size的上限更多由embedding模型决定,而不是生成模型决定。
4.2 overlap该设多少:为什么固定比例比固定值更实用
chunk_overlap的设置原则是:在不显著增加向量库存量成本的前提下,尽量消除语义断点。我通常按chunk_size的10%~15%来设,而不是用固定值。原因很简单:块越大,边界跨越的上下文也越多,固定值可能不足以覆盖;块小,固定值又浪费存储。
不过有一个反直觉的坑:overlap过大会造成检索冗余。用户提问时,如果多个带有大量重叠内容的chunk都命中了,向量库会返回一堆几乎重复的结果,既浪费token,又干扰大模型判断。我在调优时会把top_k检索到的chunk去重检查一遍,如果发现重复内容占比过高,就会适当调低overlap。
对于需要精确定位的检索场景(比如“根据知识库回答某个条款的具体内容”),我会把重叠设小一点,避免chunk之间的边界模糊;对于开放式的问答场景(比如“总结这份文档里关于某方案的所有要点”),重叠可以适当加大,保证跨段落的语义衔接。
4.3 用可量化的方法验证分割参数:从检索命中率到生成质量
很多人在调参时凭感觉,设一组参数跑一次,看看结果“还行”就完事了。我的建议是建立一套简单的验证流程,用数据说话。
第一步,准备一个小的评测集。找10~30个来自知识库的典型问题,标注好每个问题对应的标准答案片段在哪个chunk里(这一步可以借助关键词定位帮助标注)。
第二步,跑一轮检索评估。用不同的chunk_size和chunk_overlap组合建立索引,对评测集逐个查询,统计两个指标:召回命中率(标准答案所在chunk是否出现在top_k结果里)和上下文完整性(命中的chunk是否包含了回答问题所需的全部关键信息)。
第三步,用大模型做生成质量抽检。选几个代表性的问题,让大模型基于检索结果回答,人工打分评估回答的完整性和准确性。
我在一个文档问答项目里用这套流程跑了一轮测试,结果很有意思:chunk_size从256调到512,召回命中率从78%提升到91%;再调到1024,命中率反而回落到85%。这说明对那批技术白皮书来说,512左右的chunk是甜点区。每个项目的最优值都不一样,但验证方法是可以复用的。
5. 实测对比与避坑记录:分割质量差导致的检索问题修复全过程
5.1 一次生产事故:PDF表格被切碎后的检索灾难
去年做一个设备维修知识库RAG项目时,我踩过一个特别典型的坑。知识库里有一批PDF格式的设备故障诊断手册,里面大量使用表格来描述“故障现象→原因分析→处理方法”的对应关系。
第一版流水线用的是PyPDFLoader加默认的RecursiveCharacterTextSplitter参数(chunk_size=1000、chunk_overlap=200、默认分隔符)。上线后发现一个怪现象:用户询问“设备A出现故障代码E123怎么处理”时,系统检索到的chunk里经常只有表格的表头或者半截描述,回答内容牛头不对马嘴。
排查链路是这样的:
- 先dump出分割后的chunks,发现PDF里表格的文本被
PyPDFLoader提取后,所有单元格内容挤在一行,原来的列结构完全丢失。表格里“故障代码”和“处理方法”之间的关联关系已经被加载环节破坏了。 - 再查
chunk_size=1000这个参数,对一个没有任何段落标记的长表格文本,RecursiveCharacterTextSplitter只能用最低优先级的分隔符(逗号、空格)硬切,切出来的chunk基本是表格行的随机拼接,语义完全错乱。
这个问题从根上就出在加载器对PDF表格的结构化提取能力不足。单纯调分割参数是救不回来的——即使把chunk_size调小,也只是把原来无序的拼接变得更碎而已。最终我换了一种思路:PDF表格先用专门的结构化解析工具提取成Markdown表格或API结构,再送入分割器。文本按行保留表格行结构,这样RecursiveCharacterTextSplitter按换行符分割时,至少能保证每个chunk包含语义上相关的若干行。
5.2 中文文本分割的独特挑战:分词粒度与现代文言的混排问题
中文和英文在分割上的核心差异,在于中文没有天然的空格分词边界。RecursiveCharacterTextSplitter默认的分隔符列表里包含空格,这对英文友好,但对中文来说空格几乎不出现,等于这个分隔符完全失效。好在双换行和句号、问号对中文也是有效的,因此默认配置对中文大体可用,但有几个边角场景需要特别注意。
第一个场景是古文或半文言文。这类文本的句子不以现代标点分隔,句号应用不规范,导致一个“句子”可能非常长,超过了chunk_size后只能用最低优先级分隔符硬切,结果把文言的连贯语义切断。我处理古籍类文档时,会考察是否用句读(古代标点)作为额外的分隔符。
第二个场景是中英文混排。像技术文档里“请注意,the timeout parameter should be set to 30 seconds”这种句子,中文部分靠句号分隔,英文部分靠空格分隔。默认分隔符对英文有作用,但优先级的顺序可能导致英文短语被中文语义单元包裹,无法单独切出。遇到这种情况,我会在自定义分隔符里同时加入中英文标点和空格,并手动检查分割结果。
还有一个高频问题:语言模型token计算与chunk_size的错配。默认length_function=len按字符数算,但中文一个字符大概消耗1~2个token,英文一个单词约1.3个token。如果别处的代码按token限制(比如embedding模型max_seq_length=512),而这里按字符数设chunk_size=1000,实际产出的chunk换算成token可能严重超出模型限制,导致向量化时被截断报错。建议在正式项目里这样配置:
import tiktoken from langchain_text_splitters import RecursiveCharacterTextSplitter # 用 cl100k_base 编码计算token数量,与OpenAI的embedding模型保持一致 token_encoder = tiktoken.get_encoding("cl100k_base") def token_length(text: str) -> int: return len(token_encoder.encode(text)) token_splitter = RecursiveCharacterTextSplitter( chunk_size=500, # 这里表示token数,不再是字符数 chunk_overlap=50, length_function=token_length, )5.3 排查指南:分割结果不符合预期时该从哪里入手
如果你发现RAG的检索结果总是不对劲,怀疑分割环节有问题,这里有一个按优先级排的诊断思路。
第一,先看原始chunks的转储输出。把分割后的结果连同metadata一起打印或落盘,肉眼检查每个chunk是否语义完整、边界是否合理。这一步能过滤掉一大半问题,尤其能看到“切在句子中间”“把列表拆散”“表格文本挤成一坨”这类直观问题。
第二,确认分隔符是否生效。如果分割结果里出现大量超长chunk,说明高优先级分隔符(如\n\n)在文本中根本不存在;如果出现大量碎片chunk,说明分隔符匹配过于频繁。可以统计一下输入文本里各分隔符的出现频率,和分割结果对比,很快能定位是哪个层级出了问题。
第三,检查重叠区域的文本内容。如果发现重叠部分都是无意义的连接词(“的”“了”“并且”),说明chunk_size和chunk_overlap的匹配可能不合适,或者chunk本身切得太碎,重叠区域被无关内容占据了。
第四,复现用户查询命中错误的chunk。拿一个失败案例的查询文本,单独在向量库里检索,把命中的chunk内容打印出来,对照原始文档查看上下文。很多时候问题不在分割参数,而在chunk本身就不包含那个信息——这时候要回溯到加载环节,是不是PDF解析丢了内容、或者源文档本身信息不完整。
这套排查链路我基本每次都能定位到根因。需要提醒的是,不要一上来就怀疑分割器——相当比例的所谓“分割问题”,根源都在加载环节或者源数据质量上。先验证输入,再调分割参数,顺序不要搞反。
5.4 自定义分割策略与正则表达式:高级场景的处理思路
在某些专业场景里,光调参数已经不够,需要自定义分割逻辑。LangChain提供了Splitter类的扩展接口,但更实用的做法是结合正则表达式做前处理,把特殊结构提取成独立文本块,再喂给标准分割器。
举一个我处理法律条款文档的例子。合同条文往往有“第九条 违约责任”这样的层级结构,以及“(一)(二)(三)”这样的小条款标记。直接扔给RecursiveCharacterTextSplitter,条款标题和正文容易被打散。我的做法是先用正则表达式把每一条完整提取出来作为独立chunk:
import re contract_text = "第九条 违约责任...(一)...(二)..." # 按“第X条”拆出条款块 pattern = r"(第[一二三四五六七八九十百千0-9]+条[^\n]*)" sections = re.split(pattern, contract_text) for i in range(1, len(sections), 2): title = sections[i] content = sections[i+1] if i+1 < len(sections) else "" # 每条作为一个Document,再视长度决定是否二次分割这个思路可以扩展到很多结构化文档:日志文件按时间戳分块、规章制度按章节号分块、商品信息按SKU字段分块。关键原则是:能用领域先验知识识别出“天然语义单元”的场景,就别让通用分割器自动发挥。
高级场景里还可以用MarkdownHeaderTextSplitter这类针对特定格式优化的分割器,或者把LangChain的split_documents换成split_text再配合自己的Document组装逻辑。灵活性的核心在于理解分割器的工作原理,而不是死记API。
6. 分割结果的质量评估:建立你的“体检清单”
有了参数和调试方法,最后留一个我每次上线RAG之前都会过的体检清单,可以帮读者快速验证自己的分割配置是否合格。
| 检查项 | 合格标准 | 检查方法 |
|---|---|---|
| chunk长度分布 | 绝大多数chunk长度在设定的chunk_size区间内,没有异常超长或超短 | 统计最大、最小、平均chunk长度 |
| 语义边界完整性 | 句子完整居多,没有大量半句话 | 随机抽10个chunk,肉眼判断 |
| 段落结构保留 | 标题和正文能分离,列表项未被从中间截断 | 对比原始文档的结构层级 |
| 重叠合理性 | 重叠文本不包含大量无意义连接词 | 打印相邻chunk的overlap部分 |
| 特殊格式兼容性 | 表格、代码块、时间戳等结构未被破坏 | 针对特殊文档做专项抽检 |
| 向量化token长度 | 所有chunk的token数在embedding模型限制内 | 用tiktoken统计最长的chunk |
这套清单我在多个项目里用过,每次都能在正式上线前发现几个潜在问题——有时候是某个PDF的特殊排版导致chunk畸形,有时候是某些超长段落被切出了破句。提前发现这些问题,远比上线后被用户反馈“检索结果差”再来排查要高效得多。
最后还有一个实用建议:把分割参数设计成配置项而不是硬编码常量。我在代码里通常用一个配置文件集中管理,方便切换不同业务场景时快速调整:
# splitter_config.py SPLITTER_CONFIGS = { "contract": {"chunk_size": 400, "chunk_overlap": 40, "separators": ["\n\n", "\n", "第.*条", "。", "!", "?"]}, "manual": {"chunk_size": 300, "chunk_overlap": 30, "separators": ["\n\n", "\n", "。", "!", "?", ";"]}, "paper": {"chunk_size": 700, "chunk_overlap": 100, "separators": ["\n\n", "\n", "。", "!", "?"]}, }这样不同来源的文档可以用不同策略切割,上线后也可以动态调整而不用改核心代码。配置化的好处在跑评测集调优时尤其明显——可以批量跑多组参数组合,快速找到最优解。让我用一个小脚本演示如何批量实验:
import json from langchain_text_splitters import RecursiveCharacterTextSplitter def evaluate_splitter(candidates, documents, eval_results_path): results = [] for cfg in candidates: splitter = RecursiveCharacterTextSplitter(**cfg) chunks = splitter.split_documents(documents) # 统计chunk数量和长度分布 lengths = [len(c.page_content) for c in chunks] avg_length = sum(lengths) / len(lengths) max_length = max(lengths) results.append({ "config": cfg, "chunk_count": len(chunks), "avg_length": round(avg_length, 1), "max_length": max_length, }) with open(eval_results_path, "w", encoding="utf-8") as f: json.dump(results, f, ensure_ascii=False, indent=2)跑完这轮实验,就能用数据说话,选出一组适合当前知识库的分割配置了。分割环节在RAG里看似简单,但值得花点时间打磨——它是整个链路上投入产出比最高的调优点之一。