---
url: /rag/chunking.md
description: 从检索单元的本质出发，讲清固定长度切分、滑动窗口重叠、按 Markdown 结构切分的取舍，并给出表格与代码的特殊处理方式和切片粒度的评估方法。
---

# RAG 切片策略：文档怎么切才不掉链子

这篇解决一个常见问题：知识库里明明躺着答案，提问却召不回来，或者召回的是半句话。多数时候不是模型不行，而是切片（chunking，把长文档切成一段段可检索单元）没切对。切片决定了检索的最小单位，也就决定了这套 RAG 的上限。

## 切片的本质：决定检索的最小单位

RAG（检索增强生成）的链路是：文档切片 → 每片算向量（embedding）入库 → 提问时召回最相似的几片喂给大模型。一片就是一个检索单位，模型看不到没被召回的内容。所以切片直接定义了什么算「一段知识」：切得碎，语义被拆散；切得粗，一片里混了几个主题，向量被稀释，谁都匹配不上。

## 固定长度切：能用，但别当终点

最省事的做法是按字符数硬切。LangChain 的 RecursiveCharacterTextSplitter 会按 `["\n\n", "\n", " ", ""]` 的优先级递归降级找切点，默认 chunk\_overlap 是 200 个字符，chunk\_size 在较新版本里默认 4000（早期文档和示例里常见 1000），实际以官方文档和你装的版本为准。

优点是简单、每片长度可控，方便按 token 估算成本。缺点是它不认识语义，一刀下去可能正好把「因为……所以……」拆到两片里，所以适合当兜底，不适合当唯一策略。

## overlap 为什么必要

滑动窗口重叠（overlap）是给硬切打的补丁：相邻两片共享一段尾巴和开头。没有它，一句结论落在切点另一侧就彻底消失；有了它，至少一片能保留完整上下文。代价是向量总数和存储变大，一般取 chunk\_size 的 10%~20%，别超过三分之一，否则召回里全是重复内容。

## 按结构切通常更好

Markdown 标题、代码块、表格本身就是天然边界——作者用 `##` 就告诉了你「这里换主题了」。按标题切出的片，语义完整度通常远高于按字数硬切。LangChain 的 MarkdownHeaderTextSplitter 默认会把用来切分的标题行从正文里去掉（`strip_headers=True`），我一般选择保留标题，或把标题路径写进元数据。

```ts
// 按 Markdown 标题层级切分的示意实现
type Chunk = { text: string; path: string[] };

export function splitByHeading(md: string): Chunk[] {
  const chunks: Chunk[] = [];
  const stack: string[] = []; // 当前标题路径，如 ["Guide", "Install"]
  let buf: string[] = [];

  const flush = () => {
    const text = buf.join("\n").trim();
    if (text) chunks.push({ text, path: [...stack] });
    buf = [];
  };

  for (const line of md.split("\n")) {
    const h = /^(#{1,6})\s+(.*)$/.exec(line);
    if (h) {
      flush();                             // 遇到标题先结算上一片
      stack.length = h[1].length - 1;      // 截断到当前层级
      stack[h[1].length - 1] = h[2].trim();
    } else {
      buf.push(line);
    }
  }
  flush();
  return chunks; // 再把 path 拼进正文或元数据
}
```

## 一个问题横跨两段怎么缓解

这是切片天生的缺陷，只能缓解：一是 overlap，用重叠区兜住跨界的句子；二是父子块（small-to-big），用小片做向量检索保证命中精度，命中后把它的父级大块喂给模型；三是把标题路径拼进每片正文开头，补回上下文的「坐标」；四是同一份文档用两种粒度各建一次索引，多路召回再合并去重。组合起来最稳。

## 切片大小与上下文预算的关系

embedding 模型有硬上限：text-embedding-3-small 单条输入最多 8191 token，默认 1536 维（可用 dimensions 参数降到 256）；bge-m3 支持到 8192 token、1024 维。但「能塞进去」不等于「该塞进去」：向量是所有内容的平均，塞得越长主题越糊。真正该算的是：召回 k 片 × 每片长度 = prompt 预算，留足给问题和回答，这才是 chunk\_size 的落点。

## 表格与代码怎么特殊处理

表格别按行切，整表一片，并在片头重复表头列名，否则「3.2 秒」单独出现毫无意义。代码别按行切，按函数或类边界切，每片带上文件路径、语言和一个说明用途的注释；注释里往往藏着最好检索的关键词，值得保留。

## 粒度怎么评估

建个二十来条的小测试集，覆盖「答案是一整段」和「答案是表格里一格」两类问题，跑一遍看召回率。召回不中先怀疑切片，而不是先换模型或调 top-k。把答错的问题逐条拉出来，确认召回的 chunk 里到底有没有答案：有答案但答错是 prompt 的问题，压根没召回含答案的片，就是切片粒度和边界的问题。

::: warning 常见坑
按字数硬切却把 overlap 设成 0，跨切点的句子直接丢失；chunk\_size 设得比 embedding 模型上限还大，超出部分会被静默截断，你以为入库了其实没有；只用小片检索却不做上下文回补，模型拿到的是没有主语的残句；把所有文档塞进同一个集合、共用一套切分规则，质量自然参差。
:::
