Skip to content

文本切分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. 策略选型:看内容结构

拿到一批文档后,先快速扫描内容结构,再决定用哪种策略。

决策规则

  1. 有清晰标题层级(Markdown #、HTML <h1>-<h6>)→ 优先标题层级切分。文档作者已经帮你做了语义分组,直接复用。

  2. 有段落但无标题(邮件、文章、FAQ)→ 按句子边界切分。段落是天然的语义单元,在句子边界处切能保持可读性。

  3. 纯流文本,无明显段落(日志、数据输出)→ 固定长度切分。没有语义边界可以利用,不如均匀切分后靠 overlap 兜底。

  4. 混合内容(既有标题又有代码块又有表格)→ 递归切分。按分隔符优先级逐层尝试,兼容多种结构。

常见误判

  • 用固定长度处理自然语言文档——切出来的 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 调优优先级

当检索效果不理想时,按以下顺序调整:

  1. 先调 chunk size——这是影响最大的参数。太大则语义稀释,太小则信息不完整。每次调整 200 字符,观察检索效果变化。

  2. 再换切分策略——如果 chunk size 调了还是不行,可能策略不匹配内容结构。比如标题层级文档用了固定长度,换成标题切分后效果会显著提升。

  3. 最后调 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 转成向量,存入向量数据库。

基于 MIT 协议开源