上一篇(demo-21)讲了如何用 Loader 把不同格式的文档读成 Document。但 Loader 只是第一步,读出来的文档往往太长,不能直接拿去做 Embedding 和检索。
这一篇专门讲文档切分(Text Splitting):为什么要切、有哪些策略、以及怎么用 LangChain 实现。
代码基于 JavaScript 版
langchainv1,切分器来自@langchain/textsplitters。
一、为什么要切分
RAG 的第一步不是向量化,而是把文档切成合适的文本块。原因有四个。
1.1 上下文窗口有限
大模型的上下文长度再大,也有限度。把整本书塞进提示词既不现实,也不经济。
1.2 Embedding 模型有最大输入长度
Embedding 模型通常只能处理几百到几千 token。超长文本会被截断、报错,或者只保留前一部分,导致向量无法代表全文。
1.3 检索精度会下降
向量检索的基本单位是文本块。如果一块里混了多个主题,向量会被「平均」掉:
- 用户问 A,块里同时有 A、B、C,相似度被稀释;
- 命中之后,拼进提示词的还有大量无关内容;
- 模型容易被噪声干扰,答案质量下降。
1.4 成本和延迟
文本块越大,拼进提示词的 Token 越多,调用成本越高、延迟越大。切分让检索结果更聚焦,减少无效上下文。
1.5 切分不是越小越好
| 块太大 | 块太小 | |
|---|---|---|
| 语义 | 完整,但主题混杂 | 容易断章取义 |
| 检索 | 噪声多,相似度被稀释 | 命中片段但缺少上下文 |
| 成本 | Token 消耗高 | 块数量多,索引开销大 |
| 可引用性 | 定位不精确 | 引用碎片化 |
切分的目标不是把文本切小,而是让每个块尽量表达一个完整、独立的意思。
二、切分的核心参数
LangChain 的 TextSplitter 有几个通用参数:
| 参数 | 作用 |
|---|---|
chunkSize | 每个块的最大长度 |
chunkOverlap | 相邻块的重叠长度 |
separators | 切分边界,如段落、换行、句号、空格 |
lengthFunction | 如何计算长度(字符数或 token 数) |
keepSeparator | 是否在块中保留分隔符 |
2.1 chunkSize
chunkSize 决定块的粒度:
- 太大:块内主题混杂,检索噪声大;
- 太小:语义不完整,模型拿不到足够上下文。
chunkSize 的单位取决于切分器:
- 字符切分器:字符数;
- token 切分器:token 数。
2.2 chunkOverlap
chunkOverlap 让相邻块保留一段重叠,避免关键句正好落在边界上。
比如固定窗口 60 字符、重叠 20 字符:
第 1 块:... 息,模型需要从噪声中寻找答案。文档切得太
第 2 块:息,模型需要从噪声中寻找答案。文档切得太短,单块内容 ...
两块开头有 20 个字符完全一致,关键句不会被彻底切断。
重叠不宜过大,否则会产生大量重复内容,浪费存储和 Token。一般取 chunkSize 的 10%-20%。
2.3 separators
separators 是切分边界。中英文的分隔符不同:
// 英文
["\n\n", "\n", " ", ""]
// 中文
["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""]
如果只用英文默认分隔符切中文,可能只能按空格和字符切,效果很差。
2.4 metadata 会继承
用 splitDocuments 切分时,原 Document 的 metadata 会复制到每个块:
const doc = new Document({
pageContent: text,
metadata: { source: "long-article.txt", category: "demo" },
});
const chunks = await splitter.splitDocuments([doc]);
// chunks[0].metadata -> { source: "long-article.txt", category: "demo" }
这为后续引用溯源提供了基础。
三、策略一:CharacterTextSplitter(固定分隔符)
CharacterTextSplitter 只按一个固定分隔符切分。
import { CharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new CharacterTextSplitter({
separator: "\n\n",
chunkSize: 120,
chunkOverlap: 20,
});
const chunks = await splitter.splitText(text);
本 demo 的实际输出(原文 441 字):
块数:5
[1] 85 字符 | 文档切分是 RAG 流程中非常关键的一步……
[2] 78 字符 | 切分的目标不是把文本切得越小越好……
[3] 89 字符 | 一个常见的起点是按段落切分……
[4] 106 字符 | 切分大小通常用字符数或 token 数来衡量……
[5] 74 字符 | 相邻文本块之间可以保留一定重叠……
优点:
- 简单、可预测、速度快;
- 分隔符选得准,块边界就比较自然。
缺点:
- 只认一个分隔符,段落内部不会再拆;
- 单个段落超过
chunkSize时,会保留超长块并给出警告; - 不理解句子和标题结构。
适合:格式非常规整、段落长度可控的文本。
四、策略二:RecursiveCharacterTextSplitter(推荐默认)
RecursiveCharacterTextSplitter 按分隔符优先级逐级尝试:
先按段落切
-> 段落还太长,就按句子切
-> 句子还太长,就按逗号切
-> 最后退回按字符切
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 80,
chunkOverlap: 10,
keepSeparator: false,
separators: ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
});
const chunks = await splitter.splitDocuments([document]);
本 demo 的实际输出:
块数:8
[1] 54 字符 | 文档切分是 RAG 流程中非常关键的一步……
[2] 29 字符 | 文档切得太短,单块内容可能缺少完整语义……
[3] 78 字符 | 切分的目标不是把文本切得越小越好……
[4] 67 字符 | 一个常见的起点是按段落切分……
[5] 20 字符 | 这种从大到小逐级尝试的思路,就是递归切分。
...
为什么推荐它作为默认:
- 优先保留段落和句子边界;
- 超长内容会继续降级切分;
- 对中英文都比较友好;
- 参数直观,容易调。
中文场景记得把 。!?;, 加进 separators,否则容易只按空格和字符切。
keepSeparator: false 会把分隔符从块里去掉,避免块以「。」开头;设为 true 会保留分隔符,但在部分实现中它可能出现在下一块的开头,使用时要看实际输出。
五、策略三:TokenTextSplitter(按 token 计数)
字符数不等于 token 数。中英文混合、代码、标点的 token 比例都不同。
当你要严格控制模型上下文或成本时,应该按 token 切分:
import { TokenTextSplitter } from "@langchain/textsplitters";
const splitter = new TokenTextSplitter({
chunkSize: 40, // 40 个 token
chunkOverlap: 8,
encodingName: "o200k_base",
});
const chunks = await splitter.splitText(text);
本 demo 的实际输出(每块最多 40 token):
块数:10
[1] 50 字符 | 文档切分是 RAG 流程中非常关键的一步……
[2] 49 字符 | ,模型需要从噪声中寻找答案。文档切得太短……
[3] 52 字符 | 断章取义。切分的目标不是把文本切得越小越好……
...
要点:
chunkSize的单位是 token,不是字符;encodingName要和目标模型匹配,例如cl100k_base、o200k_base;- 中文建议选对中文支持更好的编码;
- token 切分可能把词或句子切断,通常用于严格长度限制,或者和递归切分组合使用。
本 demo 使用
o200k_base。默认的gpt2编码在切中文时容易产生替换字符,实际使用时要根据模型选择编码。
六、策略四:结构化切分(Markdown / Code)
结构化的文档应该优先按结构切分,而不是按固定长度硬切。
6.1 Markdown
MarkdownTextSplitter 会优先在标题、列表、段落等边界切分:
import { MarkdownTextSplitter } from "@langchain/textsplitters";
const splitter = new MarkdownTextSplitter({
chunkSize: 120,
chunkOverlap: 20,
});
const chunks = await splitter.splitText(markdownText);
本 demo 的实际输出:
块数:5
[1] 106 字符 | # RAG 文档切分指南 ## 为什么需要切分……
[2] 113 字符 | ## 切分的基本参数 chunkSize 控制……
[3] 104 字符 | ## 常见切分策略 固定长度切分实现简单……
[4] 76 字符 | ## 结构化切分 Markdown 可以按标题……
[5] 70 字符 | ## 切分后的处理 每个文本块都应该……
每个块都保留了标题,检索结果更容易理解,也方便把标题路径写进 metadata。
6.2 代码
代码可以按函数、类、代码块切分:
import { RecursiveCharacterTextSplitter } from "@langchain/textsplitters";
const splitter = RecursiveCharacterTextSplitter.fromLanguage("js", {
chunkSize: 140,
chunkOverlap: 0,
});
const chunks = await splitter.splitText(code);
本 demo 的输出:
块数:5
[1] 53 字符 | import { Document } from "@langchain/core/documents";
[2] 90 字符 | export function splitByBlankLine(text) { ... }
[3] 87 字符 | export function splitBySentence(text) { ... }
[4] 68 字符 | export function countCharacters(text) { ... }
[5] 118 字符 | export function toDocument(content, source) { ... }
fromLanguage 支持的语言:
cpp、go、java、js、php、proto、python、rst、ruby、rust、
scala、swift、markdown、latex、html、sol
注意:它不是语法解析器,只是为不同语言准备了一套更合适的分隔符。chunkSize 仍然可能触发降级切分。
七、策略五:语义切分(概念)
语义切分(Semantic Chunking)不按固定分隔符,而是按「意思」切:
- 先把文本拆成句子;
- 计算相邻句子或句组的 embedding;
- 相似度高就合并,相似度骤降就切开。
句子 1 + 句子 2 + 句子 3 -> 同一主题,合并
句子 4 与前面相似度骤降 -> 在这里切分
优点:
- 块内语义更集中,检索效果通常更好;
- 不依赖标题、段落等显式结构。
代价:
- 需要 embedding 模型,索引阶段成本更高;
- 阈值难调,文本很短时不稳定;
- JavaScript 版 LangChain 目前没有内置 SemanticChunker, 需要自己实现或使用外部服务。
Python 侧有 langchain_experimental.text_splitter.SemanticChunker 可以直接用。
实践建议:
- 大多数项目先用
RecursiveCharacterTextSplitter; - 文档结构清晰时优先用 Markdown / 代码切分;
- 只有检索效果不理想时,再考虑语义切分;
- 还可以用小到大检索:索引小块,命中后返回更大的父块。
八、策略对比与参数选择
| 策略 | 边界依据 | 适用场景 |
|---|---|---|
CharacterTextSplitter | 单一分隔符 | 格式规整的纯文本 |
RecursiveCharacterTextSplitter | 分隔符优先级 | 通用默认 |
TokenTextSplitter | token 数量 | 严格控制长度 / 成本 |
MarkdownTextSplitter | Markdown 结构 | 文档、知识库 |
fromLanguage("js") | 代码结构 | 源码、配置文件 |
| Semantic Chunking | 语义相似度 | 高质量检索 |
8.1 参数怎么定
- 先看 embedding 模型的最大输入长度,别超限;
- 再按内容类型选边界:Markdown 用标题,代码用函数;
chunkSize从 200-800 字符左右开始试,用真实问题评估召回;chunkOverlap一般取chunkSize的 10%-20%;- 不要凭感觉调参,保留一批问题做 Recall@K 对比。
这些数字不是标准答案,最终要由你的数据、问题和 embedding 模型决定。
8.2 常见坑
- 只按英文字符切中文:中文没有空格,容易切出大量碎片;
- 块太小:检索命中但模型缺少上下文;
- 块太大:检索噪声多,Token 成本高;
- overlap 过大:重复内容多,索引膨胀;
- 丢掉 metadata:无法溯源、无法按权限过滤;
- 把表格和代码硬切:结构被破坏,检索效果差;
- PDF 页眉页脚重复:每个块都带着同样的噪声;
- 没有评估:调了参数但不知道效果有没有变好。
8.3 工程实践
- 切分后保留
source、标题路径、页码、行号等 metadata; - 过滤空块、重复块、过短的块;
- 文档更新时只重建受影响的分块;
- 检索命中后可以返回父块,兼顾精度和上下文;
- 用真实问题集评估 Recall@K,而不是只看块数。
九、Python 对应写法
Python 侧使用 langchain_text_splitters:
from langchain_text_splitters import (
CharacterTextSplitter,
RecursiveCharacterTextSplitter,
TokenTextSplitter,
MarkdownTextSplitter,
Language,
)
9.1 固定分隔符
splitter = CharacterTextSplitter(
separator="\n\n",
chunk_size=120,
chunk_overlap=20,
)
chunks = splitter.split_text(text)
9.2 递归字符切分
splitter = RecursiveCharacterTextSplitter(
chunk_size=80,
chunk_overlap=10,
separators=["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
)
docs = splitter.split_documents(documents)
9.3 Token 切分
splitter = TokenTextSplitter(
chunk_size=40,
chunk_overlap=8,
encoding_name="o200k_base",
)
chunks = splitter.split_text(text)
9.4 Markdown 与代码
md_splitter = MarkdownTextSplitter(
chunk_size=120,
chunk_overlap=20,
)
md_chunks = md_splitter.split_text(markdown_text)
code_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=140,
chunk_overlap=0,
)
code_chunks = code_splitter.split_text(code)
9.5 语义切分
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
splitter = SemanticChunker(
OpenAIEmbeddings(model="text-embedding-3-small"),
)
chunks = splitter.split_text(text)
十、小结
- 为什么要切分:上下文窗口、embedding 上限、检索精度、成本与延迟。
- 核心参数:
chunkSize、chunkOverlap、separators、lengthFunction。 - 常用策略:
- 固定分隔符 →
CharacterTextSplitter; - 逐级递归 →
RecursiveCharacterTextSplitter(默认推荐); - 按 token →
TokenTextSplitter; - 按结构 →
MarkdownTextSplitter/fromLanguage; - 按语义 → Semantic Chunking(JS 需自行实现)。
- 固定分隔符 →
- 统一入口:
splitter.splitText(text);splitter.splitDocuments(documents)。
- 选择顺序:结构化切分 > 递归字符切分 > 固定切分;有严格长度要求时再叠加 token 计数。
一句话:先找语义边界,再控制块大小;先保证块内完整,再追求块间精准。