14.05-文本切分Chunking
要点
- 切分策略直接影响检索质量——切太大语义稀释,切太小丢失上下文
- 四种主流策略:固定长度、按段落/句子、按标题层级、递归切分
- 重叠(overlap)很重要——防止关键信息被切断在两个 chunk 之间
- Chunk size 的选择没有银弹,800-1200 字符是经验值,需要按场景调整
- 元数据保留:每个 chunk 要记住自己来自哪个文档、哪个位置
- 切分质量很难自动评估,通常靠端到端检索效果来反推
1. 为什么需要切分
一篇文档可能几千上万字。把整篇文档转成一个向量,语义会被「平均化」——一个关于退款流程的文档里混杂了发货、售后、投诉等内容,向量代表的是「平均语义」,检索时定位不到具体段落。
更好的做法:把文档切成小段(chunk),每段独立算向量。检索时找到最相关的几个 chunk,拼进 Prompt。
文档:5000 字的退款政策
├── chunk 1: 退款适用范围(800 字)
├── chunk 2: 退款流程步骤(1000 字)
├── chunk 3: 退款时间(600 字)
├── chunk 4: 退款方式(700 字)
└── chunk 5: 特殊情况处理(900 字)
用户问:「退款一般多久到账?」
→ 检索找到 chunk 3(退款时间)
→ 只把 chunk 3 拼进 Prompt
2. 切分策略
策略一:固定长度切分
最简单的方式:按固定字符数切分,段之间留重叠。
// src/services/rag/chunking/fixed-size.ts
type ChunkOptions = {
chunkSize: number // 每段最大字符数
overlap: number // 段之间重叠的字符数
}
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)
const chunkText = text.slice(start, end)
chunks.push({
text: chunkText,
metadata: {
startChar: start,
endChar: end,
chunkIndex: chunks.length,
},
})
start += chunkSize - overlap
}
return chunks
}
// 使用
const chunks = fixedSizeChunking(text, { chunkSize: 1000, overlap: 200 })优点:简单、确定性好。 缺点:可能在句子中间切断,读起来不通顺。
策略二:按句子边界切分
改进版:在句子边界处切分,避免切断句子。
// src/services/rag/chunking/sentence-boundary.ts
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) {
// 当前 chunk 已满
chunks.push({
text: currentText.trim(),
metadata: {
sentences: currentSentences.length,
chunkIndex: chunks.length,
},
})
// 保留 overlap:取最后几句作为下一段的开头
const overlapText = currentSentences.slice(-2).join('')
if (overlapText.length <= options.overlap) {
currentText = overlapText + sentence
currentSentences = currentSentences.slice(-2).concat([sentence])
} else {
currentText = sentence
currentSentences = [sentence]
}
} else {
currentText += sentence
currentSentences.push(sentence)
}
}
if (currentText.trim()) {
chunks.push({
text: currentText.trim(),
metadata: { sentences: currentSentences.length, chunkIndex: chunks.length },
})
}
return chunks
}优点:保持句子完整性,切出来的文本可读性好。 缺点:句子长度不均,chunk 大小不稳定。
策略三:按标题层级切分
如果文档有清晰的标题结构(Markdown、HTML),按标题切分最自然。
// src/services/rag/chunking/heading-based.ts
type Section = {
level: number
title: string
content: string
}
function headingBasedChunking(text: string, format: 'markdown' | 'html'): Chunk[] {
const sections = extractSections(text, format)
// 每个 section 是一个 chunk(如果太长,再按大小切分)
const chunks: Chunk[] = []
for (const section of sections) {
const fullText = `${'#'.repeat(section.level)} ${section.title}\n\n${section.content}`.trim()
if (fullText.length <= 2000) {
// 整个 section 作为一个 chunk
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
}
function extractSections(text: string, format: 'markdown' | 'html'): Section[] {
if (format === 'markdown') {
return extractMarkdownSections(text)
}
return extractHTMLSections(text)
}
function extractMarkdownSections(md: string): Section[] {
const sections: Section[] = []
let current: Section | null = null
for (const line of md.split('\n')) {
const match = line.match(/^(#{1,6})\s+(.+)$/)
if (match) {
if (current) sections.push(current)
current = { level: match[1].length, title: match[2], content: '' }
} else if (current) {
current.content += line + '\n'
}
}
if (current) sections.push(current)
return sections
}优点:保持章节完整性,每个 chunk 有自然的主题。 缺点:依赖文档有清晰的标题结构。没有标题的文档不适用。
策略四:递归切分
综合多种策略,先尝试按大粒度切(标题),不够再按小粒度切(段落、句子)。
// src/services/rag/chunking/recursive.ts
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) {
const subChunks = splitWithSeparators(part, separators.slice(1), options)
chunks.push(...subChunks)
currentText = ''
} else {
currentText = part
}
}
}
if (currentText.trim()) {
chunks.push({
text: currentText.trim(),
metadata: { chunkIndex: chunks.length },
})
}
// 添加 overlap
return addOverlap(chunks, options.overlap)
}LangChain 的 RecursiveCharacterTextSplitter 就是这个思路。
3. 重叠(Overlap)的作用
Overlap 是指相邻两个 chunk 之间共享的字符数。
chunk 1: [abcdefghij klmnop]
chunk 2: [klmnop qrstuv wxyz]
^^^^^^
重叠区域
Overlap 解决的是「信息被切断」的问题。如果一个关键句子刚好在两个 chunk 的边界,没有 overlap 的话,这个句子只完整存在于其中一个 chunk。检索时如果选了另一个 chunk,就丢失了这个关键信息。
Overlap 的经验值:chunk size 的 10%-20%。
function addOverlap(chunks: Chunk[], overlap: number): Chunk[] {
if (overlap <= 0 || chunks.length <= 1) return chunks
const result: Chunk[] = [chunks[0]]
for (let i = 1; i < chunks.length; i++) {
const prev = chunks[i - 1]
const current = chunks[i]
// 取前一个 chunk 的末尾作为当前 chunk 的开头
const overlapText = prev.text.slice(-overlap)
const merged = (overlapText + ' ' + current.text).trim()
result.push({
text: merged.slice(0, current.text.length + overlap),
metadata: { ...current.metadata, hasOverlap: true },
})
}
return result
}4. Chunk size 怎么选
没有银弹,但有经验值:
| 场景 | 建议 chunk size | 原因 |
|---|---|---|
| FAQ / 短问答 | 200-500 字符 | 问题通常对应一小段答案 |
| 技术文档 | 800-1200 字符 | 一个概念通常需要一段解释 |
| 法律 / 合同 | 500-800 字符 | 条款通常独立、完整 |
| 文章 / 博客 | 1000-1500 字符 | 段落更长,保留上下文 |
| 代码 | 按函数 / 类切分 | 不按字符数,按语法结构 |
判断 chunk size 是否合适的信号:
- 太大(> 2000 字符):检索到的 chunk 里经常只有部分内容和用户问题相关
- 太小(< 200 字符):检索到的 chunk 信息不完整,需要看多个 chunk 才能拼出完整答案
5. 元数据保留
每个 chunk 必须记住自己的来源信息。
type Chunk = {
text: string
metadata: {
// 来源追踪
documentId: string
documentTitle: string
chunkIndex: number // 在文档中的序号
startChar?: number // 原文起始位置
endChar?: number // 原文结束位置
// 结构信息
sectionTitle?: string // 所属章节标题
sectionLevel?: number // 章节层级
// 访问控制
userId?: string // 所属用户
tenantId?: string // 所属租户
permissions?: string[] // 权限标签
}
}这些元数据在检索时可以用来做过滤、溯源、权限控制。
6. 特殊内容的切分
代码
代码不能按字符数切。应该按语法结构(函数、类、模块)切分。
function chunkCode(code: string, language: string): Chunk[] {
// 简单策略:按函数 / 方法边界切分
if (language === 'typescript' || language === 'javascript') {
return chunkByFunction(code)
}
if (language === 'python') {
return chunkByPythonDef(code)
}
// 回退:按固定大小切分
return fixedSizeChunking(code, { chunkSize: 800, overlap: 100 })
}
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' },
}))
}表格
表格应该保持完整,不要切断。
function preserveTableChunking(text: string): Chunk[] {
// 检测 Markdown 表格
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,表格之间的文本按正常策略切分
// ...
}7. 切分效果评估
切分质量很难直接评估,通常靠端到端效果反推。
方法一:人工检查
随机抽样 10-20 个 chunk,人工检查:
- 每个 chunk 是否表达了一个完整的意思?
- 有没有关键信息被切断?
- chunk 大小是否合理?
方法二:检索测试
准备一组(问题,期望答案来源 chunk)测试对,看检索是否能找到正确的 chunk。
const testCases = [
{ question: '退款多久到账?', expectedChunk: 2 }, // 期望找到第 3 个 chunk
{ question: '如何申请退款?', expectedChunk: 1 },
// ...
]
for (const tc of testCases) {
const queryVec = await embed(tc.question)
const results = await vectorDB.query(queryVec, { topK: 5 })
const foundChunk = results.matches[0].metadata.chunkIndex
console.log(`Q: ${tc.question}, Expected: ${tc.expectedChunk}, Got: ${foundChunk}`)
}如果经常找不到正确的 chunk,切分策略需要调整。
总结
回顾这一节的要点:
- 切分策略直接影响检索质量
- 四种策略:固定长度、按句子边界、按标题层级、递归切分
- 重叠(overlap)防止关键信息被切断
- Chunk size 没有银弹,800-1200 字符是经验值
- 元数据保留很重要——来源追踪、访问控制、溯源
- 代码和表格需要特殊处理
- 切分质量靠端到端检索效果反推
下一篇讲 Embedding 向量化——怎么把文本 chunk 转成向量。