在构建 RAG(检索增强生成)应用时,文本切割(Text Splitting)是一个容易被忽视却至关重要的环节。切得太粗,语义不完整,检索结果就差;切得太细,上下文丢失,生成质量就低。本文带你深入理解 LangChain 中文本切割器的体系结构、核心参数以及不同切割策略的取舍。
为什么需要文本切割?
大模型的上下文窗口是有限的。无论是 GPT-4 的 128K tokens,还是更早期模型的 4K、8K,当我们的文档内容远超这个限制时,就不能把整个文档塞进去。RAG 的核心思路是:
- 把大文档切成小块(Chunk)
- 将这些块向量化存入向量数据库
- 用户提问时,检索最相关的块
- 将检索到的块 + 用户问题一起喂给大模型
所以切割的质量直接决定了检索的精准度。
Token 与字符数:不是一回事
在聊切割策略之前,先搞清楚一个基础概念:Token 数 ≠ 字符数。
大模型的计费和推理都是按 Token 计算的。来看一段代码:
javascript
import {
getEncodingNameForModel,
getEncoding,
} from 'js-tiktoken';
const modelName = "gpt-4";
const encodingName = getEncodingNameForModel(modelName);
const enc = getEncoding(encodingName);
console.log('apple', enc.encode('apple'), enc.encode('apple').length); // 1 token
console.log('pineapple', enc.encode('pineapple'), enc.encode('pineapple').length); // 1 token
console.log('苹果', enc.encode('苹果'), enc.encode('苹果').length); // 2 tokens
运行后你会发现:apple 是 1 个 token,pineapple 也只是 1 个 token,而中文 苹果 却是 2 个 token。字符数相同,token 数可以差很多。这就是为什么中文文本的 API 费用往往比英文更贵——同样的语义信息,中文需要更多的 token 来承载。
这个认知对切割策略的选择很有指导意义。
LangChain Splitter 的类体系
LangChain 的文本切割器是一套面向对象的体系,层次清晰:
TextSplitter(基类)
├── CharacterTextSplitter # 按字符直接切割
├── TokenTextSplitter # 按 Token 数量切割
└── RecursiveCharacterTextSplitter # 递归语义切割(推荐)
├── MarkdownTextSplitter # 针对 Markdown 格式
└── ...
基类 TextSplitter 只处理文本内容,所以 MP3、MP4 等多媒体文件不在它的能力范围内——这些需要先经过转录(ASR)转成文字,再交给 Splitter 处理。
三种核心切割策略详解
1. CharacterTextSplitter:简单直接
按指定的分隔符直接切割,逻辑最简单:
原文本 → 找到 separator → 在此处切断
优点是实现简单、速度快;缺点是语义保障弱,如果文本恰好在分隔符处语义不完整,切出来的块就会比较"奇怪"。
2. TokenTextSplitter:按算力计费单位切割
这个切割器以 Token 为单位控制块大小,是最"精确"的方式——因为大模型真正消耗的就是 Token。
javascript
import "dotenv/config";
import { TokenTextSplitter } from "@langchain/textsplitters";
import { Document } from "@langchain/core/documents";
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`
});
const logTextSplitter = new TokenTextSplitter({
chunkSize: 50, // 每个块最多 50 个 Token
chunkOverlap: 10, // 块之间重叠 10 个 Token
encodingName: 'cl100k_base', // GPT-4 / GPT-3.5 使用的编码
});
const splitDocuments = await logTextSplitter.splitDocuments([logDocument]);
const enc = getEncoding("cl100k_base");
splitDocuments.forEach(document => {
console.log(document);
console.log('character length:', document.pageContent.length);
console.log('token length:', enc.encode(document.pageContent).length);
});
cl100k_base 是 OpenAI GPT-4 和 GPT-3.5 系列使用的 BPE 编码方式。通过对比输出中的 character length 和 token length,你能直观感受到两者的差距。
TokenTextSplitter 的问题在于:它完全不关心语义,Token 切到哪算哪,一个句子可能在中间断掉。
3. RecursiveCharacterTextSplitter:兼顾语义的最优解
这是实际项目中最推荐使用的切割器,它的核心逻辑是优先级递归:
给定一组 separators,按照优先级从高到低依次尝试切割:
["。", "?", "!", ",", "\n", " ", ""]
优先在句号处切割,如果切出来的块还是超过 chunkSize,再递归尝试用问号、感叹号、逗号……直到切出足够小的块。
这样的好处是:尽可能在语义完整的边界处切割,句子不会被随意截断。
来看一个混合了英文日志和中文长句的真实场景:
javascript
import "dotenv/config";
import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters';
import { Document } from '@langchain/core/documents';
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: 系统开始执行大规模数据迁移任务,本次迁移涉及核心业务数据库中的用户表、订单表、商品库存表、物流信息表、支付记录表、评论数据表等共计十二个关键业务表,预计处理数据量约500万条记录,数据总大小预估为280GB,迁移过程将采用分批次增量更新策略以减少对生产环境的影响,同时启用双写机制确保数据一致性,任务预计总耗时约3小时15分钟,迁移完成后将自动触发全面的数据一致性校验流程以及性能基准测试,请相关运维人员和DBA团队密切关注系统资源使用情况、网络带宽占用率以及任务执行进度,如遇异常情况请立即启动应急预案并通知技术负责人
`
})
const logSplitter = new RecursiveCharacterTextSplitter({
separators: ['\n', '。', ','],
chunkSize: 200,
chunkOverlap: 20,
})
const logChunks = await logSplitter.splitDocuments([logDocument]);
const enc = getEncoding("cl100k_base");
logChunks.forEach(doc => {
console.log(doc);
console.log("character length:", doc.pageContent.length);
console.log("token length:", enc.encode(doc.pageContent).length);
})
运行这段代码,你会观察到:英文日志部分按 \n 切割,每行一个块,整洁清晰;而那段没有换行的中文长句,则会递归降级到按 , 切割,同时触发 chunkOverlap 机制来保留上下文。
三个核心参数深度解析
chunkSize
每个块的最大字符数(或 Token 数)。这是切割的"上限",不是"目标值"。Splitter 会尽量在语义完整的边界处切割,并保证每块不超过这个限制。
- 太小:上下文不足,检索时语义缺失
- 太大:向量化后区分度下降,检索精度降低
- 经验值:512 ~ 1024 字符,具体要结合文档类型调整
chunkOverlap
相邻块之间的重叠字符数,通常设为 chunkSize 的 10% 左右。
为什么需要重叠?考虑这个场景:
块 A:...用户登录系统后,系统会自动
块 B:记录登录时间和 IP 地址...
如果没有重叠,块 A 和块 B 单独看都是语义不完整的。加上 Overlap 后:
块 A:...用户登录系统后,系统会自动
块 B:系统会自动记录登录时间和 IP 地址...
重叠部分充当了"语义桥梁",牺牲了一点存储空间,换来了更好的检索召回率。
separators
RecursiveCharacterTextSplitter 特有的参数,是一个有序数组,代表切割的优先级:
javascript
separators: ['\n', '。', ',']
```
从左到右优先级递减:优先在换行符处切,切出来还太大就试句号,还不够就试逗号,以此类推。最后的兜底通常是空字符串 `""`,即逐字符切割(语义最差,尽量避免触发)。
---
## MarkdownTextSplitter 为什么是 Recursive 的子类?
这个设计很有意思。Markdown 有天然的层级结构:
```
# 一级标题
## 二级标题
### 三级标题
正文段落
MarkdownTextSplitter 的 separators 正是:
javascript
["# ", "## ", "### ", "#### ", "\n\n", "\n", " ", ""]
优先按大标题切,切不够小就按二级标题,再按三级……这不就是递归地按层级切割吗?所以它天然属于 RecursiveCharacterTextSplitter 的子类,逻辑完全复用。
切割策略选型总结
| 场景 | 推荐策略 |
|---|---|
| 纯文本、混合中英文 | RecursiveCharacterTextSplitter |
| 严格控制 Token 消耗 | TokenTextSplitter |
| Markdown 文档 | MarkdownTextSplitter |
| 简单均匀切割 | CharacterTextSplitter |
写在最后
文本切割看似简单,实则是 RAG 质量的地基。选错切割策略,后续无论 Embedding 模型多强、向量数据库多快,检索效果都会大打折扣。
三个参数记住了:separators 决定在哪切、chunkSize 决定切多大、chunkOverlap 决定保留多少上下文。理解了这三者的关系,RAG 的文档处理环节基本就掌握了。