☰
LangChain文档加载与递归切分:RAG与Agent应用的关键一步
2026/10/20 8:35:34 网站建设 项目流程

最近在做大模型RAG与Agent智能体实战练习,一套教程跟到第27节,Debug了一整天最后发现不是模型的问题,也不是提示词写得不好,而是最前面的文档加载和切分环节没做好。这次把LangChain里的TextLoader和RecursiveCharacterTextSplitter单独拎出来聊透。

如果你正在做知识库问答、RAG应用或者Agent工具开发,大概率会遇到这样的问题:文档已经喂进去了,检索结果却答非所问;明明原文里有答案,却因为切块太碎或者太整而找不到。这篇内容就是围绕这类问题展开的,从文档加载到递归切分,再到与Agent工作流的衔接,都会覆盖到。后面我写的代码和参数都是自己实际试过的,可以直接拿去改,不用再翻文档一头雾水。

1. RAG开发的第一关:先让文档进入程序

1.1 为什么我会单独写文档加载

见过不少同学,包括我自己刚接触RAG时,上来就找向量数据库、研究Embedding模型,结果数据源头连加载都没做对。文本加载这件事看起来简单,其实坑不少。一个知识库可能同时有TXT、PDF、Word、网页,每种格式对应的加载器不同,加载结果还会直接影响后续切分质量和检索效果。

TextLoader是LangChain里最基础的纯文本加载器,专门用来读取TXT、Markdown这类文本文件,输出给下游的是标准的Document对象。你只有先把文件变成内存里的文本,才能做分割、向量化这些操作。把加载这层弄明白,后面换成PDF加载器、网页加载器时,处理思路完全一样。

我自己后来习惯把所有加载器封装成一个统一的函数,不管输入什么格式,都返回list[Document]。这样做的好处是,下游切分和向量化代码不需要跟着格式变来变去。TextLoader是这套流程的起点,也是最好调试的起点。

1.2 TextLoader是怎么工作的,以及它的坑

TextLoader的用法非常直接。先看一段最常见的代码:

from langchain_community.document_loaders import TextLoader loader = TextLoader("notes/langchain_demo.txt", encoding="utf-8") docs = loader.load() print(type(docs)) print(len(docs)) print(docs[0].page_content[:200]) print(docs[0].metadata)

load()会返回一个列表,列表里是Document对象。Document有两个核心字段:page_content保存文本正文,metadata保存来源信息,比如source路径。TextLoader默认会把整个文件读成一个Document,也就是说metadata里通常只有source,没有章节、页码这些信息。

这里有一个非常常见的坑:如果你的文件是GBK编码,而没指定encoding="utf-8",load()会直接抛异常。还有,TextLoader只适合纯文本,你拿它加载PDF会得到一堆乱码,因为PDF本质上是排版格式,不是纯文本。

另外,大文件场景下,load()会把整个文件一次性读进内存,文件一大会很吃内存。LangChain也提供了lazy_load()方法,可以按迭代器逐条读取。虽然TextLoader一次通常只产生一个Document,但对于超大的日志类文件,写成这样更稳妥:

for doc in loader.lazy_load(): print(doc.page_content[:100])

我在实际项目里遇到过一个几十MB的文本文件,直接用load()时内存涨得很厉害,改成lazy_load()之后,处理流程就顺了很多。

1.3 实际项目里别只用TextLoader:常用加载器搭配

如果知识库里只有TXT和Markdown,TextLoader就够用了。但真实项目往往是混合格式,所以我把常用的加载器整理成了下面这张表:

文档格式推荐加载器需要注意的问题
纯文本 / MarkdownTextLoader无结构解析,需要配合分割器
PDFPyPDFLoader / PyMuPDFLoader表格和图片可能丢失,扫描件需要OCR
WordDocx2txtLoader需要安装对应依赖,旧版doc不支持
HTMLBSHTMLLoader / AsyncHtmlLoader需要先提取正文,过滤标签
CSVCSVLoader默认一行一个Document,方便做表格知识库

用PyPDFLoader加载PDF时,代码和TextLoader很像:

from langchain_community.document_loaders import PyPDFLoader loader = PyPDFLoader("docs/product_manual.pdf") docs = loader.load() print(len(docs)) print(docs[0].page_content[:200])

PDF加载器返回的是多个Document,通常一页一个。这样metadata里会带上页码,后续切分如果跨页,需要自己决定是保留页码还是按页切。我的经验是,页和页之间的语义常常是连续的,所以不要死板地按页切,最好先用页切分器把PDF变成多个Document,再交给文本分割器做精细切分。

Word文档里的表格同样容易丢。如果知识库大量依赖表格数据,建议不要依赖通用加载器,而是单独把表格抽取出来,每行作为一个知识单元。这一步看起来繁琐,但对后续检索的准确率影响特别大。

2. 文档分割器:想清楚切多碎,再动手切

2.1 不切和狠切的代价

很多人觉得切文档就是把大文本拆成小片段,越简单越好,实际上切分粒度直接决定检索质量。

如果你把一个十万字的说明书整体送入向量库,单条向量包含的信息太杂,用户问其中一个小功能的用法时,整篇文档和这句问话的相似度会被大量无关内容稀释,检索到的片段太粗,模型很难从中抽出准确答案。

反过来,如果你一股脑把文本按每50个字切,语义就会被拦腰打断。拿人的阅读来类比:一段话说的是一个完整观点,突然在中间断开,你读起来也很费劲。模型读检索回来的块也一样,它只看到半句话,自然无法给出完整回答。

所以分割器的目标是在语义完整性和单块尺寸之间做平衡。不是切得越碎越好,也不是保留越大越好,而是要让每个切块尽量对应一个相对完整的观点、描述或事件。

2.2 分割器选型:别一上来就盲选

LangChain里的文档分割器不少,下面几个属于最常用的:

  • CharacterTextSplitter:按固定字符数硬切,参数简单,但完全不考虑句子边界,很容易切断正文。
  • RecursiveCharacterTextSplitter:按分隔符列表递归切分,优先保持段落和句子完整,是大多数人首选的默认方案。
  • TokenTextSplitter:按token数量切分,使用BPE tokenizer计数,适合对LLM上下文长度有精确要求的场景。
  • MarkdownHeaderTextSplitter / HTMLHeaderTextSplitter:按文档标题结构切分,适合Markdown文档或带目录的网页。
  • 语义分割器:基于Embedding计算相邻句子的相似度来分段,效果更智能,但需要额外计算成本,配置也更重。

如果你刚开始做RAG,不要急着研究语义分割器,先用RecursiveCharacterTextSplitter把流程跑通。大部分场景下,调好分隔符和重叠窗口后,效果已经能满足需求。用语义分割器属于后续优化方向,成本高,收益不一定值。

2.3 RecursiveCharacterTextSplitter的递归逻辑

RecursiveCharacterTextSplitter是这类分割器里的代表组件。它接收一个分隔符列表,默认是["\n\n", "\n", " ", ""],也就是“段落、换行、空格、字符”的优先级顺序。

它的工作方式可以理解为:先把整个文本按第一个分隔符“\n\n”切成若干段。如果某一段仍然超过chunk_size,就继续用第二个分隔符“\n”去切这一段。如果还超,再按空格切。最后如果连空格都切不了,就干脆按字符硬切。

这个设计的巧妙之处在于,它尽力尊重原文结构,而不是无脑按固定字符数断句。它先把文本拆成自然段落,段落太长才拆句子,句子太长才拆词汇。这样的递归逻辑,让切出来的块在大多数情况下保持语义相对完整。

我经常把这个过程比作吃羊腿:先沿关节拆,关节拆不开再动刀,而不是一上来就乱砍。理解了这一层,你遇到分隔符调优问题时,就能猜到问题出在哪个优先级上了。

3. RecursiveCharacterTextSplitter实战:从加载到切块的完整流程

3.1 直接抄的代码:加载、切分、打印

下面这段代码是我在项目里的标准起步写法,把文本加载和递归切分串在一起:

from langchain_community.document_loaders import TextLoader from langchain_text_splitters import RecursiveCharacterTextSplitter loader = TextLoader("docs/knowledge_base.txt", encoding="utf-8") docs = loader.load() splitter = RecursiveCharacterTextSplitter( chunk_size=400, chunk_overlap=60, separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""], length_function=len, ) chunks = splitter.split_documents(docs) print(f"加载文档数: {len(docs)}") print(f"切分后块数: {len(chunks)}") for i, chunk in enumerate(chunks[:5]): print(f"### chunk {i}") print(chunk.page_content) print("metadata: ", chunk.metadata)

这里我用的是split_documents,而不是split_text。split_text只接受字符串并返回字符串列表,会把Document的metadata丢光。在RAG场景里,metadata记录着来源文件、页码、章节这些关键信息,如果丢失,后面做引用溯源时会非常痛苦。split_documents会保留metadata,所以只要输入是Document列表,就尽量用这个方法。

如果环境里没装langchain_text_splitters,也可以从langchain.text_splitter导入RecursiveCharacterTextSplitter,效果一样,只是不同版本的包路径不同,运行时留意一下报错提示就行。

3.2 chunk_size和chunk_overlap到底怎么设

这是新手问得最多的问题。先说结论:没有万能参数,只有适合当前语料的参数。

我的习惯是这样起步:

场景chunk_sizechunk_overlap说明
普通知识库问答400-60050-100块不太大,检索精准
长文档技术手册700-1000100-200尽量保留完整段落逻辑
对话式Agent工具300-50030-80要求快速返回,别追求大块
语义连贯性要求高600-800150-200overlap增大,减少上下文断层

chunk_overlap的作用是让相邻块之间保留一部分重叠内容,避免在切分边界处丢失上下文。比如chunk_size=400,overlap=60,意思是第1块结尾的60个字会在第2块开头重复出现。这能显著缓解边界语义断裂的问题。

但要特别注意,overlap不是越大越好。重叠过多会让相邻块内容大量重复,向量库里存储噪声增加,检索时可能返回多个几乎相同的块,浪费上下文窗口。

如果你更关心的是token数量而不是字符数,可以使用from_tiktoken_encoder:

from langchain_text_splitters import RecursiveCharacterTextSplitter splitter = RecursiveCharacterTextSplitter.from_tiktoken_encoder( model_name="gpt-4", chunk_size=500, chunk_overlap=50, )

这样切分器会用tiktoken计算token数,而不是用len函数数多少个字。英文文档效果很好。中文场景下,一个汉字可能对应一个或多个token,使用token计数能更精确地控制送入大模型的上下文长度。

不过也要说清楚:token计数会多一步编码计算,速度比len慢一点。如果语料不大,用len按字符数控制也足够。

3.3 中文文档的separators调优:这一步很容易被忽略

RecursiveCharacterTextSplitter的默认separators是["\n\n", "\n", " ", ""]。这套分隔符对英文友好,因为英文单词之间天然有空格。但中文文本里没有空格,如果直接使用默认参数,断句时大概率会从句子中间切,或者等段落很长时,只能退到空字符串一级,把句子从中硬生生切成两半。

我处理中文文档时,会重新指定separators:

separators = ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]

这个顺序很关键。它表示优先按段落拆,段落太长再按换行拆,然后按句号、叹号、问号、分号、逗号逐级切分。也就是说,切分器会尽量在完整句子的标点处断开,语义完整度会明显提升。

很多人容易犯的错误是把分割符列表写反,比如写成["", "。", "\n\n"]。空字符串放在第一位,会让切分器一开始就直接按字符切割,所有块都会变成单个字符,完全丧失语义。无论什么时候,空字符串都要放在最后,它是兜底选项,不是优先选项。

我自己调中文参数时,还会根据文本风格增加顿号、冒号这些标点。只要记住一个大原则:分隔符列表的顺序是从“大块到小块”,段落优先于句子,句子优先于词汇,最后一格一定是空字符串。

4. 分割器那些坑:从报错到效果差的排查实录

4.1 区分split_text和split_documents,别再搞混

这是我在社区答疑时遇到频率最高的误区。split_text输入字符串,输出字符串列表;split_documents输入Document列表,输出Document列表,并且会保留metadata。

如果你只需要测试切分效果,用split_text足够。但一旦进入知识库流程,需要记录来源、构建引用链,就一定要用split_documents。否则后面生成回答时,你想告诉用户“这个结论来自哪份文档的哪一页”,会发现根本拿不到信息,只能重做一遍。

一个简单的自查方法:打印chunks[0].metadata,如果为空或没有source字段,说明你用了错误的方法,或者Doc本身没带metadata。

4.2 我踩过的几个坑和出路

第一个坑,metadata丢失。早期我用split_text切分后喂给向量库,检索时发现回来的块完全没有来源信息,紧急补救了很久。后来统一改成split_documents,问题才解决。

第二个坑,大文件撑爆内存。遇到一个超大文本日志文件,loader.load()直接把内存占满,程序卡死。解决思路是先用lazy_load()逐条读取,或者先按章节拆分成多个小文件,再分别加载切分。

第三个坑,表格被拆得稀烂。知识库里有一份产品参数表,用普通文本切分后,一个单元格里的内容被拆到多个chunk,检索时字段值对不上。后来我把表格单独抽出来,每一行做成一个Document,问题就消失了。

第四个坑,分隔符顺序写反。我见过不少人的separators里第一项是空字符串,切出来全是乱码级碎片。这类问题其实看分割结果就能发现,如果切出的块长度普遍特别小,基本就是分隔符顺序出错了。

第五个坑,overlap设成0。有些问答场景里,答案刚好跨越两个相邻块的边界,由于没有重叠,检索时任何一个块都不够完整,模型就没法给出正确答案。适度增加overlap可以缓解这类问题,但也不能加太多。

4.3 一个可复用的分割质量检查方法

切完之后别急着向量化,我建议先做一个快速检查:

for i, chunk in enumerate(chunks[:20]): text = chunk.page_content print(f"chunk {i}: len={len(text)}") print(text) print("---")

重点看三件事:第一,是否出现了半个句子或半个完整语义;第二,是否存在大量长度远小于chunk_size的碎片块;第三,相邻块之间重叠的部分是否自然。

如果发现大量碎片块,可以把separators里的低频分隔符去掉,或者调大chunk_size。如果发现边界处频繁切断关键语义,就增加chunk_overlap。

做完抽样检查后,我再做一轮真实检索测试。选5个有代表性的人工问题,每个问题从库里检索Top-K,肉眼看一下返回块是否包含答案信息。如果答案内容恰好被切成两块,说明overlap太小;如果返回块里杂音太多,说明chunk_size过大,或者文档结构本身需要按标题切。

这一步花不了多少时间,但对最终RAG效果的影响非常大。很多项目上线后效果不行,回头排查,往往就卡在这里。

4.4 常见问题速查表

问题现象可能原因解决方案
中文块从句子中间切开默认separators不含中文标点添加“。!?;,”等分隔符
切出大量超短碎片块分隔符顺序错误或分隔符过碎调整优先级顺序,删掉低频分隔符
metadata消失用了split_text而不是split_documents改为split_documents
大文件加载卡死load()一次性读入,内存不足使用lazy_load()或先按章节拆分
检索结果答非所问chunk_size过大或overlap过小调小chunk_size,适当增加overlap
表格数据检索结果错乱表格被文本切分器拆散单独提取表格,每行作为Document
token超过模型上限用len计字符但没控制token数改用from_tiktoken_encoder或按token切分

5. 切好的块会流向哪里:文本分割在Agent工作流里的位置

5.1 RAG链路中的文本块生命周期

完整的RAG链路是:加载文档,分割文本,向量化,存储到向量库,检索,组装提示词给大模型,最后生成回答。文本分割处在加载和向量化之间,但它影响的是后面所有环节。

可以说,切好的每一个chunk,就是向量化与检索的“最小颗粒度”。向量库的一个条目本质上是一个chunk的向量表示,检索引擎比较用户问句和每条向量的相似度,返回相似的chunk。

在Agent工作流里,Agent会调用知识库工具完成检索,工具内部执行相似性搜索,返回几个chunk作为上下文。这些chunk会被塞进提示词,和用户问题一起交给模型。如果chunk本身不完整,提示词上下文就是残缺的,模型能力再强也补不出原文档里没有的信息。

所以提示词工程很重要,但它建立在检索质量之上。检索回来的材料本身就是“半句话”,提示词设计得再花哨也白搭。很多开发者把精力全部放在提示词上,忽略了更前面的文本切分,方向就跑偏了。

5.2 Agent场景下的分割策略调整

Agent和普通RAG有一点不同:Agent可能为了完成一个任务多次调用知识库工具,比如先查文档目录,再查某个章节,再查详细参数。这意味着每一次检索都要尽量准确,因为Agent的下一步决策依赖于返回的内容。

在这种场景下,我给每个chunk的metadata里额外写入文档名、章节号、段落序号,这样Agent在引用时可以知道自己查的是哪部分内容。如果同一份文档多次命中,还可以通过metadata中的段落序号把多个chunk按原文顺序重新拼回一段更完整的上下文,再交给模型。

另外,当Agent面对不同类型的用户问题时,可以注册多个检索工具。比如短问题用chunk_size较小的工具,快速命中关键词答案;长文档综合分析则用chunk_size较大的工具,保留更完整上下文。这种动态切换思路比固定参数来得灵活,后续调优空间也更大。

不过我不建议一上来就搞复杂。先把一个检索工具跑通,再逐步拆分成多个工具,每加一个工具都要验证它对整体任务是否有真实增益,不然Agent的工具调用链路会变得又长又难定位问题。

5.3 给新手的几个实在建议

不要把教程里的参数当成圣旨。文档结构变,语言变,甚至用户提问习惯变,分割参数都要跟着调整。我自己的习惯是,拿到一批新语料后,先用默认参数跑一遍,打印前20个chunk,人工读一遍。这个过程能发现大部分问题。

其次,把加载和切分操作封装成独立函数。后续换加载器、换切分策略时,只改一个函数就行,不用动向量化和检索逻辑。我前几个项目就是没有这样做,每次换文档格式都要翻半天旧代码,后来统一封装后省了很多事。

最后,做RAG和Agent项目时,不要指望一次跑通就没有问题。文本切分没有标准答案,只有适合当前数据的最优解。快速迭代、多观察切分结果、用真实检索验证,这个流程比背参数可靠得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询