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 转成向量。