主题
文本切分Chunking
要点
- 切分质量决定检索上限——策略选错,后面调 embedding 模型、改 top-K 都救不回来
- 四种主流策略按内容结构选型:固定长度(流文本)、句子边界(均匀段落)、标题层级(结构化文档)、递归切分(混合内容)
- Overlap 防止关键信息被切断在 chunk 边界,经验值是 chunk size 的 10%-20%,太大引入噪声
- Chunk size 没有银弹:技术文档 800-1200 字符、FAQ 200-500、法律合同 500-800 是起点,不是终点
- 代码按语法结构切(函数/类),表格和列表保持完整不切断
- 切分质量无法直接打分,靠端到端检索测试反推;调参顺序是先 chunk size → 再策略 → 最后 overlap
1. 切分失效的三种现场
一份 5000 字的退款政策文档,用三种方式切分,检索效果天差地别。
固定 1000 字符、无 overlap:「退款到账时间」的完整回答被切成两半,前半段在 chunk 2 末尾,后半段在 chunk 3 开头。用户问「退款多久到账」,检索选中 chunk 3,但里面只有半句话——「一般在 3-5 个」,后面没了。
按段落切、chunk 太大(3000 字符):一个 chunk 里塞了适用范围、流程、时间、方式四块内容。向量编码的是「退款政策的平均语义」,用户问「怎么申请退款」,检索回来一整段,模型需要从混杂内容里自己定位答案,准确率明显下降。
按句子切、无元数据:检索命中了正确的 chunk,但不知道它来自哪份文档、哪个章节。回答无法溯源,用户也不知道该信哪条。
切分策略决定 RAG 检索的上限。后续优化 embedding 模型、调整 top-K、改进 Prompt 拼接,都弥补不了切分阶段引入的语义损失。
2. 切分为什么决定检索上限
切分直接影响向量的语义质量。Embedding 模型把一个 chunk 的文本编码成一个向量——如果 chunk 里混着多个主题,向量记录的是「平均语义」,检索时定位不到具体段落。
这带来两个核心约束:
语义纯度:一个 chunk 应该围绕一个主题。退款时间的 chunk 里不该混着退款流程的内容。纯度越高,向量指向越明确。
上下文完整性:一个关键信息不能被切断在两个 chunk 之间。「退款一般在 3-5 个工作日到账」如果被拆成两个 chunk,各自都不完整,向量都不够匹配用户查询。
好的切分:
chunk 1: 退款适用范围(800 字)→ 向量指向「什么能退」
chunk 2: 退款流程步骤(1000 字)→ 向量指向「怎么退」
chunk 3: 退款到账时间(600 字)→ 向量指向「多久到账」
用户问「退款多久到账」→ 精准命中 chunk 3差的切分:
chunk 1: 适用范围 + 流程前半段(1500 字)→ 向量指向「退款相关的什么都有一点」
chunk 2: 流程后半段 + 时间 + 方式(2000 字)→ 向量指向「平均语义」
用户问「退款多久到账」→ 命中 chunk 2,但里面混了流程和方式切分策略本质上是在控制语义纯度和上下文完整性之间的平衡。切太小,每个 chunk 信息不完整,需要多个 chunk 拼出答案;切太大,chunk 里混着多个主题,向量语义被平均化。
接下来看四种具体策略各自怎么处理这个平衡。
3. 四种切分策略
3.1 固定长度切分
最简单的实现:按固定字符数切分,段间留 overlap。
typescript
type ChunkOptions = {
chunkSize: number // 每段最大字符数
overlap: number // 相邻 chunk 重叠的字符数
}
function fixedSizeChunking(text: string, options: ChunkOptions): Chunk[] {
const { chunkSize, overlap } = options
const chunks: Chunk[] = []
let start = 0
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length)
chunks.push({
text: text.slice(start, end),
metadata: { startChar: start, endChar: end, chunkIndex: chunks.length },
})
start += chunkSize - overlap
}
return chunks
}适用场景:结构均匀的流文本、日志、数据管道输出。实现简单,确定性好,chunk 大小固定,适合批量处理和并行计算。
失败模式:在句子中间切断。「退款一般在 3-5 个」和「工作日到账」分属两个 chunk,前者语义不完整。检索命中后,模型拿到的是半句话。对自然语言文档,可读性差,语义碎片多。
3.2 按句子边界切分
改进思路:在句子边界处切分,避免切断句子。用正则匹配中英文句号、问号、感叹号作为切分点。
typescript
function sentenceBoundaryChunking(text: string, options: ChunkOptions): Chunk[] {
const sentences = text.match(/[^.!?\n。!?\n]+[.!?\n。!?\n]?/g) ?? []
const chunks: Chunk[] = []
let currentText = ''
let currentSentences: string[] = []
for (const sentence of sentences) {
if (currentText.length + sentence.length > options.chunkSize && currentText.length > 0) {
chunks.push({
text: currentText.trim(),
metadata: { sentences: currentSentences.length, chunkIndex: chunks.length },
})
// overlap:取最后 1-2 句作为下一段开头
const overlapText = currentSentences.slice(-2).join('')
currentText = overlapText.length <= options.overlap
? overlapText + sentence
: sentence
currentSentences = overlapText.length <= options.overlap
? currentSentences.slice(-2).concat([sentence])
: [sentence]
} else {
currentText += sentence
currentSentences.push(sentence)
}
}
if (currentText.trim()) {
chunks.push({
text: currentText.trim(),
metadata: { sentences: currentSentences.length, chunkIndex: chunks.length },
})
}
return chunks
}适用场景:句子长度均匀的自然语言文档——新闻、客服对话记录、FAQ。保持句子完整性,切出来的文本可读性好。
失败模式:chunk 大小不稳定。句子长度差异大的文档(法律文书、技术手册),有的 chunk 接近上限,有的只有几十个字符。长句多的文档,单个 chunk 可能被一句撑到远超目标大小。
3.3 按标题层级切分
文档有清晰标题结构(Markdown、HTML)时,按标题切分最自然。每个 section 就是一个 chunk,标题保留在元数据中。
typescript
function headingBasedChunking(md: string): Chunk[] {
const sections = parseMarkdownSections(md)
const chunks: Chunk[] = []
for (const section of sections) {
const fullText = `${'#'.repeat(section.level)} ${section.title}\n\n${section.content}`.trim()
if (fullText.length <= 2000) {
chunks.push({
text: fullText,
metadata: { sectionTitle: section.title, sectionLevel: section.level, chunkIndex: chunks.length },
})
} else {
// section 太长,用固定大小再切,但保留标题元数据
const subChunks = fixedSizeChunking(fullText, { chunkSize: 1000, overlap: 200 })
for (const sub of subChunks) {
chunks.push({
text: sub.text,
metadata: { ...sub.metadata, sectionTitle: section.title, sectionLevel: section.level, chunkIndex: chunks.length },
})
}
}
}
return chunks
}适用场景:结构清晰的文档——技术文档、产品手册、API 文档。保持章节完整性,每个 chunk 有自然主题,元数据自动携带标题信息。
失败模式:依赖文档有清晰标题。纯文本、聊天记录、邮件等没有标题结构的文档不适用。标题层级错误的文档(比如所有文字都是 h1)会导致切分退化成整个文档一个 chunk。
3.4 递归切分
综合多种策略:先尝试粗粒度分隔符(标题),不够再按细粒度(段落、句子)递归切分。LangChain 的 RecursiveCharacterTextSplitter 就是这个思路。
typescript
function recursiveChunking(text: string, options: ChunkOptions): Chunk[] {
// 分隔符优先级:从粗到细
const separators = ['\n## ', '\n### ', '\n\n', '\n', '。', '.', ' ']
return splitWithSeparators(text, separators, options)
}
function splitWithSeparators(text: string, separators: string[], options: ChunkOptions): Chunk[] {
if (text.length <= options.chunkSize) {
return [{ text: text.trim(), metadata: { chunkIndex: 0 } }]
}
const separator = separators.find((s) => text.includes(s)) ?? ''
const parts = separator ? text.split(separator) : [text]
const chunks: Chunk[] = []
let currentText = ''
for (const part of parts) {
const candidate = currentText ? currentText + separator + part : part
if (candidate.length <= options.chunkSize) {
currentText = candidate
} else {
if (currentText) {
chunks.push({ text: currentText.trim(), metadata: { chunkIndex: chunks.length } })
}
// 单个 part 超过 chunkSize,用下一级分隔符递归切
if (part.length > options.chunkSize && separators.length > 1) {
chunks.push(...splitWithSeparators(part, separators.slice(1), options))
currentText = ''
} else {
currentText = part
}
}
}
if (currentText.trim()) {
chunks.push({ text: currentText.trim(), metadata: { chunkIndex: chunks.length } })
}
return addOverlap(chunks, options.overlap)
}适用场景:混合内容——既有标题又有长段落还有代码块的文档。大多数知识库文档属于这类。
失败模式:分隔符优先级设置不当会导致退化。如果文档没有标题但有很多空行,会跳过标题分隔符直接按段落切,效果和句子边界类似。计算开销也更高,每次切分都要遍历多个分隔符。
3.5 四种策略横向对比
| 策略 | 适用内容 | 语义纯度 | chunk 稳定性 | 实现复杂度 | 典型失败 |
|---|---|---|---|---|---|
| 固定长度 | 流文本、日志 | 低 | 高 | 低 | 切断句子 |
| 句子边界 | 均匀段落 | 中 | 低 | 中 | chunk 大小波动 |
| 标题层级 | 结构化文档 | 高 | 中 | 中 | 无标题时退化 |
| 递归切分 | 混合内容 | 中高 | 中高 | 高 | 分隔符选择不当 |
没有通用最优策略。选型依据是内容结构,不是策略本身的「高级程度」。
4. 策略选型:看内容结构
拿到一批文档后,先快速扫描内容结构,再决定用哪种策略。
决策规则:
有清晰标题层级(Markdown
#、HTML<h1>-<h6>)→ 优先标题层级切分。文档作者已经帮你做了语义分组,直接复用。有段落但无标题(邮件、文章、FAQ)→ 按句子边界切分。段落是天然的语义单元,在句子边界处切能保持可读性。
纯流文本,无明显段落(日志、数据输出)→ 固定长度切分。没有语义边界可以利用,不如均匀切分后靠 overlap 兜底。
混合内容(既有标题又有代码块又有表格)→ 递归切分。按分隔符优先级逐层尝试,兼容多种结构。
常见误判:
- 用固定长度处理自然语言文档——切出来的 chunk 可读性差,检索命中后半句话无法独立回答
- 用标题层级处理没有标题的文档——退化成整个文档一个 chunk 或按默认大小硬切
- 用递归切分处理纯日志——分隔符遍历的计算开销白白浪费,效果不如固定长度
一个实用的判断方法:随机取 3-5 个 chunk,看每个 chunk 是否表达了一个完整的意思。如果不能,说明策略或参数需要调整。
5. 参数调优
策略选对之后,还需要调两个关键参数:overlap 和 chunk size。
5.1 Overlap:防止信息在边界被切断
Overlap 让相邻两个 chunk 共享一部分文本。它解决的是「关键句子刚好在两个 chunk 边界」的问题。
chunk 1: [退款一般在 3-5 个工作日到账。]
chunk 2: [个工作日到账。如有延迟请联系客服。]
^^^^^^^^^^^^^^^^
重叠区域没有 overlap,「退款一般在 3-5 个工作日到账」只完整存在于 chunk 1。如果检索选中 chunk 2,就丢失了「3-5 个工作日」这个关键信息。
Overlap 的经验值是 chunk size 的 10%-20%(chunkSize: 1000 → overlap: 100-200)。太小起不到兜底作用,太大会引入大量重复内容,增加存储和计算成本,还可能让向量编码更多噪声。
调优方法:固定 chunk size,分别测试 overlap 为 0%、10%、20% 的检索效果。如果 10% 和 20% 的差异不大,选 10%——节省存储和计算。
5.2 Chunk Size:语义单元的大小
| 场景 | 建议 chunk size | 原因 |
|---|---|---|
| FAQ / 短问答 | 200-500 字符 | 一问一答通常在一个短段落内完成 |
| 技术文档 | 800-1200 字符 | 一个概念通常需要一段解释才能说清楚 |
| 法律 / 合同 | 500-800 字符 | 条款通常独立、完整,不需要太多上下文 |
| 文章 / 博客 | 1000-1500 字符 | 段落更长,需要保留更多上下文 |
| 代码 | 按函数 / 类切分 | 不按字符数,按语法结构 |
这些是起点,不是终点。实际值需要结合 embedding 模型的 token 上限和检索效果调整。大多数 embedding 模型处理 512-8192 token,chunk size 应远小于模型上限,留出 query 和 system prompt 的空间。
判断 chunk size 是否合适的信号:
- 太大(检索效果下降):检索到的 chunk 里经常只有一部分内容和用户问题相关,模型需要从混杂内容中定位答案
- 太小(检索效果也下降):检索到的 chunk 信息不完整,需要看多个 chunk 才能拼出完整答案
一个实用的初始配置:chunkSize 800、overlap 150。对大多数技术文档和知识库,这是一个合理的起点。
6. 特殊内容处理
6.1 代码
代码不能按字符数或句子边界切分——一个函数被切成两半,两个 chunk 都无法编译,语义也不完整。
按语法结构切分:按函数、类、方法声明的边界切分。不同语言用不同的解析策略。
typescript
function chunkByFunction(code: string): Chunk[] {
// 按 function / class / const 声明分割
const parts = code.split(/(?=(?:export )?(?:async )?function |class |const \w+ = )/m)
return parts.filter(p => p.trim()).map((text, i) => ({
text,
metadata: { chunkIndex: i, type: 'code', language: 'typescript' },
}))
}如果单个函数超过 chunk size,可以在函数内部按逻辑块(变量声明、核心逻辑、返回值)切分,但要保持函数签名作为元数据。
6.2 表格
表格应该保持完整。切断表格意味着表头和数据行分离,模型看到一行数据但不知道每列是什么含义。
typescript
function preserveTableChunking(text: string): Chunk[] {
const tableRegex = /\|[^\n]+\|\n\|[-| :]+\|\n(?:\|[^\n]+\|\n?)+/g
const tables: Array<{ text: string; start: number; end: number }> = []
let match
while ((match = tableRegex.exec(text)) !== null) {
tables.push({ text: match[0], start: match.index, end: match.index + match[0].length })
}
// 表格整体作为一个 chunk
// 表格之间的文本按正常策略切分
// ...
}如果表格超过 chunk size,可以按行切分,但每行都要带上表头前缀。
6.3 列表
列表项之间语义关联紧密,通常不应该在列表中间切断。把列表和它前面的引导文本一起作为一个 chunk。
如果列表特别长(超过 chunk size),可以在列表项之间切分,但每个 chunk 都要包含列表的引导文本,否则模型不知道列表在说什么。
7. 效果评估与决策框架
7.1 评估方法
切分质量无法直接打分,只能靠端到端检索效果反推。
方法一:人工抽查。随机抽取 10-20 个 chunk,检查三个问题:每个 chunk 是否表达了一个完整的意思?有没有关键信息被切断?chunk 大小是否在合理范围内?
方法二:检索测试。准备一组(问题,期望命中 chunk 索引)测试对,验证检索是否能找到正确 chunk。
typescript
const testCases = [
{ question: '退款多久到账?', expectedChunkIndex: 2 },
{ question: '如何申请退款?', expectedChunkIndex: 1 },
{ question: '退款支持哪些支付方式?', expectedChunkIndex: 3 },
]
for (const tc of testCases) {
const queryVec = await embed(tc.question)
const results = await vectorDB.query(queryVec, { topK: 5 })
const topHit = results.matches[0].metadata.chunkIndex
console.log(`Q: ${tc.question} → Expected: ${tc.expectedChunkIndex}, Got: ${topHit}`)
}如果命中率低于 70%,切分策略需要调整。
7.2 调优优先级
当检索效果不理想时,按以下顺序调整:
先调 chunk size——这是影响最大的参数。太大则语义稀释,太小则信息不完整。每次调整 200 字符,观察检索效果变化。
再换切分策略——如果 chunk size 调了还是不行,可能策略不匹配内容结构。比如标题层级文档用了固定长度,换成标题切分后效果会显著提升。
最后调 overlap——overlap 的影响相对较小,通常在 chunk size 和策略确定后再微调。
每次只改一个变量,用检索测试的效果作为判断依据。
7.3 选型决策框架
拿到一批文档后,按以下顺序决策:
1. 扫描内容结构
├── 有标题层级? → 标题切分,chunk size 按 section 自然大小
├── 有段落无标题? → 句子边界切分,chunk size 800-1200
├── 纯流文本? → 固定长度,chunk size 按内容密度选
└── 混合内容? → 递归切分,配置分隔符优先级
2. 处理特殊内容
├── 代码 → 按语法结构切(函数/类)
├── 表格 → 保持完整,带表头
└── 列表 → 和引导文本一起作为 chunk
3. 设定初始参数
├── chunk size:按场景选经验值(见 5.2 表格)
└── overlap:chunk size 的 10%-15%
4. 验证并调优
├── 人工抽查 10-20 个 chunk
├── 检索测试(准备 10-20 个测试问题)
└── 按优先级调整:chunk size → 策略 → overlap没有一劳永逸的切分配置。内容在变,embedding 模型在变,用户查询模式也在变。每次上游变化后,重新跑一遍检索测试,确认切分策略仍然有效。
下一篇讲 Embedding 向量化——把切好的 chunk 转成向量,存入向量数据库。