做RAG项目,很多人一上来就调embedding、换向量库、研究重排序,结果效果就是上不去。我见过太多团队把检索结果的锅甩给模型,最后定位到源头,发现是文档在进入向量库之前就已经被切碎了。这次要聊的,正是RAG开发里最基础、也最容易被低估的两个环节:文本加载器TextLoader,以及默认主力分割器递归字符文本分割器RecursiveCharacterTextSplitter。它们不炫技,但直接决定你的知识库是不是“入库即残废”。
这篇就围绕这两个组件展开,把它们的原理、参数、坑一次讲透,最后给出一套可以直接抄的代码模板。适合刚接触RAG的开发者,也适合那些已经跑通流程但检索效果忽高忽低、想回头排查“源头问题”的人。
1. 先把RAG链路捋清楚:文档加载与分割处于什么位置
1.1 一条简化但完整的RAG链路
RAG的完整流程可以拆成五段:文档加载、文档分割、向量化、检索、生成。很多人会把“文档加载”和“文档分割”合并成一步,叫作“文档预处理”,但实际工程里它们是完全不同的环节,承担的任务也不一样。
- 文档加载:把硬盘上的PDF、Word、TXT、网页抓取结果转成统一的文本表示,也就是LangChain里的Document对象。
- 文档分割:把已经加载出来的长文本切成若干有语义边界的小块,也就是chunk,后续每个chunk会单独embedding并入库。
- 向量化:用Embedding模型把每个chunk变成向量。
- 检索:用户提问时把query转成向量,在向量库里做相似度查找。
- 生成:把检索到的chunk塞进Prompt,交给大模型生成答案。
这个流程看起来没什么稀奇,但有一个容易被忽略的点:向量化、检索、生成这三步都有相对成熟的优化手段,比如换更强的Embedding模型、加重排序、调Prompt模板。唯独文档加载和分割,看起来太“傻”了,没人愿意花时间研究。可恰恰是这两步的粗糙程度,决定了后面所有步骤的上限。
1.2 初看最不起眼,实际决定检索上限的环节
我说一个真实的排查经历,你就明白加载和分割的影响有多大了。
某项目要做合同条款问答,原始文档是扫描PDF转出来的纯文本。加载之后,程序按固定字符长度500直接切成小块,没有指定任何分隔符。结果很多块的开头是半句话,结尾也是半句话,更夸张的是,一块里同时装了两条不同条款的首尾。用户问“验收不合格怎么处理”,检索召回了几个chunk,但每个chunk里都没有完整的验收条款,生成模型只能靠上下文硬猜,答得当然不对。
这个案例里,问题表面上是“检索不准”,但真正的病根在分割策略太粗糙。你可以把分割想象成切豆腐:豆腐本身切碎了,后面再怎么小心夹、怎么配调料,都救不回来。所以我现在做RAG项目,一定会把加载和分割阶段的检查放到和模型选型同等重要的位置。
2. TextLoader实操:纯文本加载的写法与三个高频坑
2.1 基础用法:三条指令完成加载
TextLoader是LangChain里最简单的文档加载器,专门负责把纯文本文件加载成Document对象。基础用法非常直白:
from langchain_community.document_loaders import TextLoader loader = TextLoader("report.txt", encoding="utf-8") docs = loader.load() print(len(docs)) print(type(docs[0]))运行结果里,docs是一个列表,列表里每个元素都是一个Document对象。Document对象有两个核心属性:page_content保存文本内容,metadata保存来源信息。默认情况下,TextLoader会把文件路径写入metadata的source字段,这在后面做引用溯源时很有用。
第一次用的时候,我建议你顺手打印一下docs[0].page_content[:200],确认文本真的读进来了。这一步看起来多余,但能帮你把“加载问题”和“分割问题”快速分开,省掉很多无谓的调试时间。
2.2 源码级拆解:Loader到底帮我们做了什么
TextLoader内部做的事情不复杂,说穿了就三步:打开文件、按编码读完、构造Document对象。真正值得注意的地方有两个。
第一个是编码参数。TextLoader的构造函数支持encoding参数,你不传它就用系统默认编码。在中文Windows环境里,默认编码很可能是GBK,而很多现代文本文件是UTF-8保存的,结果就是读出来全是乱码。这件事我踩过不止一次,而且乱码的文本进到向量库之后,检索效果会变得非常诡异,因为那些乱码也被当成正常文本做了embedding。
第二个是读取方式。TextLoader是一口气把整个文件都读进内存的,不是按行流式处理。对于几十MB甚至几百MB的大文件,内存占用会直线上升,拖慢整个预处理流程。我处理过一个1.2GB的日志文件,直接用TextLoader加载,程序直接卡死。后来只能先在外面用流式脚本把文件按大小拆成多个小文件,再逐个加载。
2.3 比TextLoader更省事的加载方式
等你需要加载的文件多起来之后,一个文件一个loader地写就太累了。LangChain里有一个DirectoryLoader,可以按目录批量加载:
from langchain_community.document_loaders import DirectoryLoader loader = DirectoryLoader( "./docs/", glob="*.txt", loader_cls=TextLoader, loader_kwargs={"encoding": "utf-8"}, ) docs = loader.load()这个用法适合把整个目录的txt文件一次性导入,配合glob参数还可以只挑特定前缀或后缀的文件。
另外提醒一句:TextLoader面对二进制文件会直接报错或者读出一堆不可读字符。Word、PDF不能指望它处理。如果你要做完整的RAG知识库,PDF要用专用的PDF loader,Word要用Word loader,HTML也要先解析成纯文本再加载。很多人直接把PDF路径传给TextLoader,然后发现page_content是空的,那不是TextLoader的问题,是文件格式本身就不支持。
3. 文档分割器对比:为什么默认方案是递归字符文本分割器
3.1 市面上常见的分割器各有什么擅场
LangChain里有一整套分割器家族,名字看着都差不多,实际策略完全不同。我把常用的几个列在一起对比,你一眼就能看出区别。
| 分割器 | 分割依据 | 适用场景 |
|---|---|---|
| CharacterTextSplitter | 固定字符长度 | 没有任何结构的纯文本,不推荐优先选择 |
| RecursiveCharacterTextSplitter | 按分隔符优先级递归切分 | 通用场景,默认首选 |
| TokenTextSplitter | 按token数量切分 | 要精确控制模型上下文时使用 |
| MarkdownHeaderTextSplitter | 按Markdown标题结构切分 | 技术文档、README、Wiki |
| CodeSplitter | 按代码语义结构切分 | Python、Java等代码文件 |
CharacterTextSplitter最容易理解,就是数够一定字符数就切一刀。它的问题是完全不考虑语义边界,经常把一句话、一个概念拦腰截断,所以实际项目里我基本不用它。MarkdownHeaderTextSplitter很聪明,会优先保留标题层级,但前提是你的文档本身结构良好。CodeSplitter专门处理代码块,懂函数、类、import语句,适合做代码库问答。
3.2 递归字符文本分割器的核心策略
RecursiveCharacterTextSplitter能成为默认主力,关键在于“递归”两个字。
它手里有一张分隔符优先级表,默认是这样的:先按段落切,再按换行切,再按空格切,最后实在不行就按字符切。具体执行过程是这样的:
- 把整个文本按最高优先级的分隔符切成若干片断。
- 逐个检查每个片断的长度。
- 如果某个片断还是超过chunk_size,就换下一个优先级的分隔符继续切。
- 切到只剩空格和单个字符时,直接硬截断,保证不无限递归。
这个过程像什么?像用一套线锯拆包裹:先用最粗的锯路顺着纸箱封口切,切不开的地方换细锯,再不行就用刀割,最后怎么都能拆开。它尽可能让每一块都落在段落、句子这些自然边界上,而不是机械地按字符数截断。
默认分隔符列表是["\n\n", "\n", " ", ""],注意最后一个空字符串,它代表“兜底”。也就是说,就算文本里没有任何分隔符,它也会在字符级别硬切,确保每个chunk不超过上限。这个兜底机制保证了程序不会死循环,但也意味着某些极端文本会被切成没有语义的碎片。
3.3 关键参数逐个拆解
RecursiveCharacterTextSplitter的主要参数就五个,我把每个参数的职责和注意事项一次说清楚。
chunk_size:单个chunk的上限,默认单位是字符数。很多人以为它是token数,这是个很大的误区。中英文混排下,字符数和token数差异非常大。chunk_overlap:相邻chunk之间的重叠字符数。它的作用是保留上下文衔接,防止某个信息点刚好被切到边界上。length_function:计算长度的函数,默认是len()。你可以换成token计数函数,后面我会给示例。separators:分隔符优先级列表。默认是["\n\n", "\n", " ", ""],不同类型文档可以定制。keep_separator:是否在切出来的chunk里保留分隔符。默认False,如果处理Markdown标题,建议设为True,这样标题会留在下一个chunk的开头。
这几个参数看着简单,实际组合起来能玩出很多花样。下面我用一个完整示例演示怎么把一份txt切开,并告诉你如何判断切得好不好。
4. 手把手跑通:加载一份txt并产出高质量chunk
4.1 环境准备与依赖安装
先装依赖,老版本LangChain和社区版loader在不同包里,这里以当前稳定版为例:
pip install langchain langchain-community langchain-text-splitters装完之后,加载器类从langchain_community.document_loaders导入,分割器从langchain_text_splitters导入。如果你用的是比较老的LangChain版本,可能从langchain.document_loaders和langchain.text_splitter导入也可以,但新项目我建议直接用新包结构,避免后续升级踩坑。
4.2 完整示例:加载→分割→预检
假设我手里有一份产品说明rag_demo.txt,内容是企业知识问答平台的项目介绍,大概1500字。完整流程如下:
from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader = TextLoader("rag_demo.txt", encoding="utf-8") docs = loader.load() splitter = RecursiveCharacterTextSplitter( chunk_size=200, chunk_overlap=20, length_function=len, separators=["\n\n", "\n", " ", ""], ) chunks = splitter.split_documents(docs) print(f"原文长度: {len(docs[0].page_content)} 字符") print(f"切分结果: {len(chunks)} 个chunk") for i, chunk in enumerate(chunks[:3]): print(f"\n--- chunk {i} ---") print(chunk.page_content) print(f"[长度: {len(chunk.page_content)}]")运行之后,输出会类似这样:
原文长度: 1548 字符 切分结果: 9 个chunk --- chunk 0 --- 本系统是一套企业级知识问答平台。 系统采用RAG架构,整体分为文档加载、文档分割、向量化、检索与生成五个模块。 [长度: 74] --- chunk 1 --- 系统采用RAG架构,整体分为文档加载、文档分割、向量化、检索与生成五个模块。 平台支持多用户并发访问,知识库支持增量更新。 [长度: 66]注意看chunk 0和chunk 1,第二句话重复出现了。这不是bug,是chunk_overlap=20在起作用:上一块末尾20个字符被带到了下一块开头。这个重复能让边界处的语义连接得更紧密,避免信息出现断层。
这里还有一个细节:为什么chunk 0的长度是74,远小于200?因为文档里段落之间有空行,文本在\n\n处就断了,这种短块是正常现象。如果所有chunk都接近200,说明文本没有明显的段落边界,属于硬切出来的,你要重新考虑分隔符配置。
4.3 参数调整的标准动作:先粗后细
我发现很多人在调分割参数时没有章法,一上来就随机改数字。我自己的固定做法是先粗后细,分三步走。
第一步,决定chunk_size。这个值一般不拍脑袋定,而是看你的检索预期。如果业务上每个问题只希望召回一小段精准内容,比如FAQ问答,chunk_size可以小一点,比如200到300。如果希望召回完整知识片段,比如技术教程某节介绍,chunk_size可以放大到500甚至800。
第二步,决定overlap。我常用的经验值是chunk_size的10%到20%。chunk_size=200时overlap取20到40都合理。overlap太小等于没有,太大则会让chunk之间重复度过高,浪费向量库的存储空间,还可能导致检索结果里连续几个chunk内容高度相似,回答时信息冗余。
第三步,调试分隔符。默认分隔符处理普通文档够了,但如果你发现切出来的chunk仍然在句子中间断开,就去调整separators。比如把["\n\n", "。", "!", "?", "\n", " ", ""]这样的顺序传进去,让中文字符的句号也参与优先级切分。这不是标准答案,而是针对中文文本盲区的补丁。
5. RecursiveCharacterTextSplitter参数速查与调优建议
5.1 一张表看懂主要参数的影响
| 参数 | 默认值 | 作用 | 注意事项 |
|---|---|---|---|
| chunk_size | 400 | 单个chunk的上限 | 单位是字符数,不是token数 |
| chunk_overlap | 50 | 相邻chunk的重叠字符数 | 建议取chunk_size的10%-20% |
| length_function | len | 计算文本长度的函数 | 换成token计数函数可以按token切分 |
| separators | ["\n\n", "\n", " ", ""] | 分隔符优先级 | 可根据文档类型自定义 |
| keep_separator | False | 是否保留分隔符 | 处理Markdown标题时建议True |
| add_start_index | False | 记录chunk在原文中的起始位置 | 调试溯源时非常有用 |
这六个参数里,我最想强调的是add_start_index。把它设为True之后,每个chunk的metadata里会多一个start_index字段,记录这个chunk在原始文档中的起始偏移量。排查“答案来源在哪”的时候,能直接定位到原始文本的第几个字符开始,不用自己写代码去字符串查找。
5.2 按文档类型给一套默认值
不同文档类型对分割的要求差别很大。这里给出一套我实测过、相对靠谱的起点配置,大家可以直接抄,再按实际效果微调。
| 文档类型 | chunk_size | chunk_overlap | separators建议 |
|---|---|---|---|
| 技术教程/Markdown | 500 | 80 | ["\n## ", "\n\n", "\n", " ", ""] |
| 小说/散文 | 400 | 50 | ["\n\n", "。", "!", "?", "\n", " ", ""] |
| 合同/法律条款 | 300 | 60 | ["\n\n", "\n条", "\n", " ", ""] |
| HTML抓取文本 | 500 | 50 | 先转纯文本,再用默认配置 |
| 代码文件 | 300 | 30 | CodeSplitter更合适 |
合同类文档我特别提醒一句:条款通常按“第X条”划分,分割器默认不认识这种结构。如果能把"\n第"加进separators,切出来的chunk会大概率保持一条完整条款,检索质量提升非常明显。
5.3 进阶:让chunk_size按token而不是字符计算
前面说过,chunk_size默认按字符数计算。但这在接入大模型时有一个隐患:大模型的上下文窗口、Embedding模型的输入上限,全部按token计,不按字符计。一段500字符的中文文本,拆成token可能是300多token;同样的500字符英文,可能只有不到100token。这里的偏差,会直接影响检索时的性价比。
如果项目里用的是主流闭源大模型的Embedding接口,我建议直接用对应的BPE分词库来计算长度,让chunk_size精确落在token数上:
from langchain_text_splitters import RecursiveCharacterTextSplitter import tiktoken enc = tiktoken.get_encoding("cl100k_base") def tiktoken_len(text): return len(enc.encode(text)) splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=50, length_function=tiktoken_len, )这段代码把长度计算函数从len换成了token计数,之后分割器会把每个chunk控制在500个token以内。注意:tiktoken的编码名称要和实际使用的模型匹配,不同模型不一定用同一套词表。如果你用的是某个本地开源Embedding模型,直接用len就行,别迷信token。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
| 现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 加载后中文全是乱码 | 文件不是UTF-8,或代码没指定encoding | 显式传encoding="utf-8";实在不行试gb18030 |
| chunk内容大量重复 | overlap设置过大 | 把overlap比例降到10%以下 |
| 切分出来的chunk不按段落 | separators里没有\n\n | 手动传默认分隔符列表 |
| chunk长度始终卡在最大值 | 文本没有可用的自然边界 | 检查是否有极端格式,考虑先清洗再切 |
| 每个chunk开头带一个奇怪的字符 | UTF-8文件带BOM | 加载后用.strip()或替换掉\ufeff |
| Windows下段落切分混乱 | 文件用的是\r\n换行 | 在separators里加入"\r\n"和"\r" |
| PDF加载后文本为空 | PDF是扫描件,没有内嵌文本层 | 先用OCR工具提取文字,再交给分割器 |
| 加载1GB以上文件时卡死 | TextLoader一次性读入内存 | 先用脚本拆分成小文件,或改用流式处理 |
这张表覆盖了我平时被问得最多的一批问题。你会发现很多问题表面在分割环节,源头在加载阶段;也有很多问题是文件格式不干净导致的,跟LangChain本身无关。
6.2 三个没人告诉你的实战细节
第一个细节:split_documents和split_text不要混着用。split_text接收字符串,返回字符串列表;split_documents接收Document对象,返回Document列表,并且保留metadata。如果你先用split_text切完了,再自己手动包装Document,metadata里的source信息就丢了,后面做回答溯源会非常难受。直接用split_documents,一步到位。
第二个细节:调参前先打印前五个chunk,逐句读一遍。这个方法成本极低,但效果比写任何测试代码都好。你只要看每个chunk的开头和结尾是不是完整的句子,心里马上就有判断。我有个项目当时检索效果不好,本以为要换Embedding模型,结果打印chunk一看,大量chunk以“和”“的”“,”结尾——那是分隔符优先级配置的问题,跟模型一点关系都没有。
第三个细节:分割之前先做轻量清洗。爬虫抓来的文档、导出的合同、网页转的文本,往往带页眉、页脚、广告尾巴、重复导航。这些噪音如果不清洗,会被当成正文切进chunk里。清洗方式不复杂:把明显的广告位文本、版权信息、页脚水分按规则替换掉,或者在加载之后对page_content做一次正则过滤。很多团队觉得这一步耽误时间,但实际节省的是后面无数轮检索调试的时间。
6.3 把切分结果可视化预览的小习惯
最后再分享一个我长期在用的习惯:所有文档切完以后,不要只看chunk数量和平均长度,先把前五个chunk完整打出来,逐字看一遍开头和结尾。
这个方法不高级,但能最快暴露问题。我有一个模拟项目X,切分时觉得参数没什么问题,正儿八经打出来一看,才发现第2个chunk的结尾停在半句话上,而第3个chunk的开头又是同一句话的前半段,中间还隔了一个表格的换行符。根源是文档里表格格式没有清洗干净,段落分隔符被表格标签挡住了。花两分钟多看几个chunk,比反复调Embedding模型管用得多。
这个习惯我一直保留到现在,每次切分策略改动之后都会跑一遍。说到底,RAG的效果就是由这些看起来不起眼的预处理细节堆出来的,加载干净、切分合理,后面每一步都会轻松很多。