深入理解 LangChain 文本分割器:为什么 RecursiveCharacterTextSplitter 是 RAG 的标配

0 阅读9分钟

深入理解 LangChain 文本分割器:为什么 RecursiveCharacterTextSplitter 是 RAG 的标配

在搭建 RAG(检索增强生成)系统时,我们经常需要处理 PDF、日志、Markdown 甚至 Word 文档。原始文档往往很长,直接喂给 LLM 既不经济,也容易超出上下文窗口。文本分割因此成为 RAG 流水线中极其关键的一环,它在“保持语义”和“控制长度”之间寻找精妙的平衡。

LangChain 提供了一整套文本分割器的面向对象体系。今天我们就从一个实际代码例子出发,深入拆解其中最常用、也最“人性化”的分割器——RecursiveCharacterTextSplitter,并理清它与其他分割器的关系和设计思想。

一、先看全貌:LangChain 文本分割器家族

在 LangChain 中,所有文本分割器都继承自一个共同的父类 TextSplitter。它约定:分割对象是“文本”。如果你面对的是 mp3、mp4 等二进制数据,这套工具就不适用了。

它的核心子类包括:

  • CharacterTextSplitter:最直白的分割器,按照用户指定的分隔符(如 \n)一刀切下去。
  • TokenSplitter:按 token 数量切割,直接从计量单位上控制长度。
  • RecursiveCharacterTextSplitter:我们今天的重点,通过递归尝试一组分隔符,努力保持语义完整性。
  • MarkdownTextSplitter:专门面向 Markdown 文档,是 RecursiveCharacterTextSplitter 的子类——它利用了 Markdown 的层级标题(###### 等)作为递归分离的优先级。

为什么 MarkdownTextSplitter 要作为 RecursiveCharacterTextSplitter 的子类?因为它的切割过程天然就是递归的:先尝试按一级标题 # 切分,分出来的块如果仍然太大,再尝试 ##,接着 ###……这和递归拆分器的思想完全一致,只需要把 separators 设置为 ["#", "##", "###", ...] 即可。

二、RecursiveCharacterTextSplitter:三参数驱动的“智慧切割”

它的核心理念是:先按语义符号切割,再考虑 chunk 大小,最后用 overlap 弥补信息断裂。

我们来看创建实例时的三个核心参数:

const splitter = new RecursiveCharacterTextSplitter({
    separators: ["\n", "。", ","],   // 优先级从高到低
    chunkSize: 200,                  // 每个文本块的目标最大长度
    chunkOverlap: 20,               // 相邻块之间的重叠长度
});

1. separators:语义优先级的“尚方宝剑”

separators 不是平行使用的,而是有明确的优先级顺序。分割器会先尝试首个分隔符进行切割(例如先按换行 \n 切分,因为这往往代表一个段落或一条日志)。如果切完后的某些块长度仍然超过 chunkSize,它不会立刻硬截断,而是降级尝试下一个分隔符(比如 “。”,代表句子结束),看看是否能切出更小的片段。如果所有分隔符都用尽,该块还是太长,才会在 chunkSize 附近直接按字符截断。

这种“递归降级”的模式,正是它名字中 Recursive 的由来——它不断用更低优先级的分隔符去嵌套拆分,尽最大努力让每一次截断都发生在换行、句号、逗号等自然停顿点。

2. chunkSize:向理想长度无限靠近

chunkSize 并不是“每个 chunk 精确等于这个长度”,而是一个目标上限。分割器会尽量让每个块的字符数接近但不超过这个值。因为我们优先保证分割发生在语义分隔符处,所以最终块的长度可能会略小于 chunkSize(比如刚好在一个句号后结束,就没有切成更小的逗号片段)。

3. chunkOverlap:牺牲空间换连续性

一旦文本被切开,相邻的两个 chunk 之间就失去了上下文衔接。为了缓解这个问题,我们特意让相邻块共享一小段内容(一般是 chunkSize 的 10% 左右)。在检索时,如果用户的提问正好跨在两个 chunk 的交界地带,这段重叠内容就能让相关的语义信息同时出现在两个块里,大大降低检索遗漏的概率。

执行顺序可以总结为:先按 character 切 → 再考虑 chunkSize 收敛 → 最后用 overlap 缝合上下文。

三、实战:用 RecursiveCharacterTextSplitter 处理日志文件

假设我们有一段混合了简短日志和长描述的企业应用日志:

import { Document } from "@langchain/core/documents";
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
import { getEncoding } from "js-tiktoken";

const logDocument = new Document({
    pageContent: 
   `[2024-01-15 10:00:00] INFO: Application started
    [2024-01-15 10:00:05] DEBUG: Loading configuration file
    [2024-01-15 10:00:10] INFO: Database connection established
    [2024-01-15 10:00:15] WARNING: Rate limit approaching
    [2024-01-15 10:00:20] ERROR: Failed to process request
    [2024-01-15 10:00:25] INFO: Retrying operation
    [2024-01-15 10:00:30] SUCCESS: Operation completed
    [2026-01-10 14:30:00] INFO: 系统开始执行大规模数据迁移任务,本次迁移涉及...预计总耗时约3小时15分钟……`
});

const logSplitter = new RecursiveCharacterTextSplitter({
    separators: ['\n', '。', ','],
    chunkSize: 200,
    chunkOverlap: 20,
});

const logChunks = await logSplitter.splitDocuments([logDocument]);

我们指定的 separators 优先级是:换行 \n → 中文句号 → 中文逗号

  • 对于前面那些一行为一条的简短日志,分割器会优先用 \n 切割,得到一个个格式整齐的单条日志块。
  • 对于最后那条包含多个句子、长度远超 200 的长迁移日志,\n 切不动,就会降级尝试用 切,把大段文字分成按句子聚集的小块。如果句子内部还是太长,再下沉到 继续细切。

这种策略能保证:每条短日志都保持完整;大段描述也被尽量切割在标点符号处,而不是硬生生被字符数腰斩。

看看实际产出

为了更精准地了解实际占用,我们还可以用 tiktoken 计算每个 chunk 占用的 token 数:

const enc = getEncoding("cl100k_base");
logChunks.forEach(doc => {
    console.log('Character length:', doc.pageContent.length);
    console.log('Token length:', enc.encode(doc.pageContent).length);
});

这里有一个值得注意的细节:我们设置 chunkSize: 200,指的是字符数约 200,并不是 token 数。而在大模型计费中,实际消耗的单位是 token。中文一个字符通常占 1.5~2 个 token 甚至更多,所以 200 个中文字符可能对应 300+ token。如果你需要精确控制 token 成本,可以结合 TokenSplitter 或在计算完 token 后动态调整 chunkSize

输出结果如图

image.png

四、与 CharacterTextSplitter 的对比:努力与“一刀切”

我们不妨做一个直观对比:

  • CharacterTextSplitter 按你给定的分隔符直接切割,如果某段文本里一个分隔符都没有,它就会直接整段输出,哪怕长度远超 chunkSize(除非你开启强制截断)。它很直白,但缺乏变通。
  • RecursiveCharacterTextSplitter 会不断尝试其他符号,哪怕最初的换行切不了,它还会用句号、逗号继续尝试,直到所有手段用尽才硬切。同时通过 overlap 挽回一些语义损失,显得更“人性化”。

在绝大多数自然语言场景下,RecursiveCharacterTextSplitter 都是更稳妥的选择,因为它能更大程度保持句子、段落的完整性。

五、RAG 全流程中的位置与最佳实践

在一个标准的 RAG 流程中,splitter 处在 Loader 之后、VectorStore 之前:

  1. Loader:加载不同格式的文档。PDF、DOCX、CSV 各自需要不同的 Loader(LangChain 社区版提供了大量现成 Loader)。
  2. Splitter:将大文档切割为语义完整的 chunks。
  3. Embedding & Store:对每个 chunk 进行向量化并存入向量数据库。
  4. Retrieval & Generation:用户提问后检索相关 chunks,与问题一起拼接成 prompt,交由 LLM 生成答案。

如果你的文档结构非常清晰(例如标准的 Markdown 或代码仓库),直接选用 MarkdownTextSplitter 或基于其思想自定义 separators,可以事半功倍。对于日志、合同、报告等半结构化文本,合理配置 separators 优先级就是在照顾下游检索的“情绪”。

六、总结

  • LangChain 的文本分割器具有良好的继承体系,核心都基于文本操作。
  • CharacterTextSplitter 直来直去,RecursiveCharacterTextSplitter 通过递归降级分隔符 + overlap 实现了语义友好的分割。
  • MarkdownTextSplitter 正是这一递归思想的特定场景应用。
  • 实战中,理解 分隔符优先级、chunkSize 目标上限、overlap 补丁三者的执行顺序,是调优 RAG 分割质量的关键。
  • 字符数与 token 数并不等价,生产环境中应结合 token 计数工具合理设定 chunkSize

附录

CharacterTextSplitter.mjs 代码

import 'dotenv/config'
import {
    // CharacterTextSplitter,
    RecursiveCharacterTextSplitter,
} from "@langchain/textsplitters"
import {Document} from "@langchain/core/documents"
import {
    getEncodingNameForModel,
    getEncoding
} from 'js-tiktoken'

// 日志文件
const logDocument=new Document({
        pageContent: 
   `[2024-01-15 10:00:00] INFO: Application started
    [2024-01-15 10:00:05] DEBUG: Loading configuration file
    [2024-01-15 10:00:10] INFO: Database connection established
    [2024-01-15 10:00:15] WARNING: Rate limit approaching
    [2024-01-15 10:00:20] ERROR: Failed to process request
    [2024-01-15 10:00:25] INFO: Retrying operation
    [2024-01-15 10:00:30] SUCCESS: Operation completed
    [2026-01-10 14:30:00] INFO: 系统开始执行大规模数据迁移任务,本次迁移涉及核心业务数据库中的用户表、订单表、商品库存表、物流信息表、支付记录表、评论数据表等共计十二个关键业务表,预计处理数据量约500万条记录,数据总大小预估为280GB,迁移过程将采用分批次增量更新策略以减少对生产环境的影响,同时启用双写机制确保数据一致性,任务预计总耗时约3小时15分钟,迁移完成后将自动触发全面的数据一致性校验流程以及性能基准测试,请相关运维人员和DBA团队密切关注系统资源使用情况、网络带宽占用率以及任务执行进度,如遇异常情况请立即启动应急预案并通知技术负责人`
})

const logSplitter=new RecursiveCharacterTextSplitter({
    separators:['\n',"。",","],
    chunkSize:200,
    chunkOverlap:20,
})

const logChunks=await logSplitter.splitDocuments([logDocument])

console.log(logChunks)

const enc=getEncoding("cl100k_base")
logChunks.forEach(doc=>{
    console.log(doc)
    console.log('character lenght',doc.pageContent.length)
    console.log('token length',enc.encode(doc.pageContent).length)
})

希望这篇文章能帮你在搭建 RAG 系统的文本分割环节,少踩一些“断章取义”的坑。如果你对特定文档类型的分割有更多疑问,欢迎在评论区交流!