news 2026/10/20 8:35:37

RAG预处理核心:TextLoader与递归字符文本分割器实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RAG预处理核心:TextLoader与递归字符文本分割器实战

做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能成为默认主力,关键在于“递归”两个字。

它手里有一张分隔符优先级表,默认是这样的:先按段落切,再按换行切,再按空格切,最后实在不行就按字符切。具体执行过程是这样的:

  1. 把整个文本按最高优先级的分隔符切成若干片断。
  2. 逐个检查每个片断的长度。
  3. 如果某个片断还是超过chunk_size,就换下一个优先级的分隔符继续切。
  4. 切到只剩空格和单个字符时,直接硬截断,保证不无限递归。

这个过程像什么?像用一套线锯拆包裹:先用最粗的锯路顺着纸箱封口切,切不开的地方换细锯,再不行就用刀割,最后怎么都能拆开。它尽可能让每一块都落在段落、句子这些自然边界上,而不是机械地按字符数截断。

默认分隔符列表是["\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_size400单个chunk的上限单位是字符数,不是token数
chunk_overlap50相邻chunk的重叠字符数建议取chunk_size的10%-20%
length_functionlen计算文本长度的函数换成token计数函数可以按token切分
separators["\n\n", "\n", " ", ""]分隔符优先级可根据文档类型自定义
keep_separatorFalse是否保留分隔符处理Markdown标题时建议True
add_start_indexFalse记录chunk在原文中的起始位置调试溯源时非常有用

这六个参数里,我最想强调的是add_start_index。把它设为True之后,每个chunk的metadata里会多一个start_index字段,记录这个chunk在原始文档中的起始偏移量。排查“答案来源在哪”的时候,能直接定位到原始文本的第几个字符开始,不用自己写代码去字符串查找。

5.2 按文档类型给一套默认值

不同文档类型对分割的要求差别很大。这里给出一套我实测过、相对靠谱的起点配置,大家可以直接抄,再按实际效果微调。

文档类型chunk_sizechunk_overlapseparators建议
技术教程/Markdown50080["\n## ", "\n\n", "\n", " ", ""]
小说/散文40050["\n\n", "。", "!", "?", "\n", " ", ""]
合同/法律条款30060["\n\n", "\n条", "\n", " ", ""]
HTML抓取文本50050先转纯文本,再用默认配置
代码文件30030CodeSplitter更合适

合同类文档我特别提醒一句:条款通常按“第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的效果就是由这些看起来不起眼的预处理细节堆出来的,加载干净、切分合理,后面每一步都会轻松很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 8:35:15

SQL分桶统计缺失补0:从LeetCode 1435到业务报表

刷题平台数据库题库里有一道编号 1435 的题,翻译过来叫“制作会话柱状图”。我第一眼看到这题觉得是送分题:把 session 的时长按分钟分个组,count 一下不就完事了?结果第一次提交就被几个测试用例教育了——当某个时间桶里压根没有…

作者头像 李华
网站建设 2026/10/20 8:35:01

SQL分桶统计补零:LeetCode 1435会话柱状图解析

做数据分析的人,应该对“按区间分桶统计”这类需求不陌生。拿到一张用户会话表,要统计每个时长区间里有多少记录,还要把没数据的区间也补成0,在SQL里确实能绊倒不少人。LeetCode的Database第1435题“制作会话柱状图”,…

作者头像 李华
网站建设 2026/10/20 8:34:46

ESP8266四足机器人DIY:硬件设计、舵机控制与步态算法实战

基于ESP8266的四足机器人的设计做硬件DIY的朋友,不管你是刚入门Arduino的新手,还是已经玩过几块开发板的老玩家,大概率都动过自己搞一台四足机器人的念头。为什么?因为四足机器人是性价比最高的运动机器人平台之一,它能…

作者头像 李华
网站建设 2026/10/20 8:33:59

主流AI大模型全景梳理与选型实战:从闭源API到开源本地部署

这两年AI大模型的迭代速度,说实话已经快到让人有点跟不上。前脚刚搞清楚“参数量越大越强”的说法不靠谱,后脚推理模型、多模态模型又成了新焦点;这边还在纠结用哪个API做业务,那边开源权重模型已经把本地部署的门槛拉到了单张消费…

作者头像 李华
网站建设 2026/10/20 8:33:56

长音频ASR模型部署与端侧AI胸针:端云协同语音识别实战

这两天我一直在等着看同一个技术方向的两条消息:一边是有大厂开源了专门面向长音频的语音识别模型 VibeVoice-ASR,官方说法是能直接支持一小时连续音频处理;另一边是某硬件厂商端出了一个只有追踪器大小的 AI 胸针,双摄像头加三颗…

作者头像 李华