一个前端出身的LLM应用工程师,在实战中踩过的Chunking坑,以及为什么Dify和KMS最终选择了同一种方案。


一、引言:为什么分块是RAG的"天花板"

上一篇文章中,我们聊了RAG数据清洗——怎么把HTML标签、乱码、无意义空白从文档里清掉。清洗完的文档看起来干净了,但如果你直接把一篇10000字的文档丢给Embedding模型会发生什么?

答案很残酷:它根本处理不了。

为什么?所有Embedding模型都有最大输入限制。拿业界最常用的text-embedding-3-small来说,最大输入是8192 tokens。看起来不少?别急——10000个中文字大约等于15000个tokens,早就超了。更关键的是,即使你的文档只有5000字,把整篇文档压缩成一个向量也会导致严重的信息稀释:一篇讲"React并发渲染"和"Vue响应式系统"的文档,Embedding后变成一个512维的向量,检索时你怎么区分用户问的是React还是Vue?

这就引出了RAG中一个绕不开的问题:Chunking——文本分块。

分块策略直接决定了检索质量的天花板。你想想,如果一块切得太大,Embedding信息被稀释,检索不准;切得太小,上下文丢失,LLM拿到一个碎片化的片段也回答不好;切的位置不对,一个完整句子被拦腰截断……这些坑,我都踩过。

我在公司做KMS知识管理平台的时候,前前后后试了五六种切分策略。最早就是用最简单的固定大小切,上线后发现用户搜"startTransition API"搜不出来——因为"startTransition"被截成了"startTrans"和"ition"分在两个Chunk里。加overlap能缓解,但根本问题没解决。后来换成递归分隔符,检索准确率提高了20%以上,才真正把这个问题根治。最后在生产环境上了父子块模式,用户在平台里搜索技术文档的体验才达到预期。

这篇文章就用TypeScript实操,带你从最笨的固定大小切分一路递进到生产级的父子块模式,最后对比Dify和KMS的源码实现,看看工业级系统里到底怎么玩的。

读完这篇文章你会知道:

  • 为什么不能按固定字数切文档
  • 递归分隔符的"分隔符优先级"到底怎么设计
  • 父子块模式如何用"小索引+大上下文"同时提升检索精度和生成质量
  • Dify和KMS在生产环境里各自怎么实现的

二、Level 1:固定大小切分——最直觉也最坑的方案

作为一个前端工程师,你看到"文本分块"的第一反应大概率是:这不就是Array.slice吗?

没错,最简单的实现就是用String.slice按固定长度截断:

function fixedSizeSplit(text: string, chunkSize: number): string[] {
  const chunks: string[] = [];
  for (let i = 0; i < text.length; i += chunkSize) {
    chunks.push(text.slice(i, i + chunkSize));
  }
  return chunks;
}

假设我们把这段文本按50字截断:

第一段:React 18 引入了并发特性,包括 Suspense 和 startTransition API。
这些特性让用户界面更新可以中断和恢复,显著提升了用户体验。

第二段:Vue 3 使用 Proxy 实现响应式系统,替代了 Vue 2 的 Object.defineProperty。
这使得 Vue 3 能够检测数组索引和长度的变化,以及 Map 和 Set 的操作。

切出来的结果长这样:

[块0] (50字) 第一段:React 18 引入了并发特性,包括 Suspe...
[块1] (50字) nse 和 startTransition API。这些特性让用户...
[块2] (50字) 界面更新可以中断和恢复,显著提升了用户体验。第二段...

看到问题了吗?"并发特性,包括 Suspe"被拦腰截断了!“Suspense"这个关键词的前半部分在块0,后半部分在块1。Embedding模型拿到块0的时候,根本不认识"Suspe"是什么东西。用户搜索"Suspense服务端渲染”,块0和块1都匹配不好,检索精度直接打折。

实战教训: 固定大小切分≈随机切分。除非你的文档本身就是按固定长度格式化的(比如某些日志文件),否则这是最差的方案。一句话总结:你不知道你在切什么,你只知道你切了多少。


三、Level 2:滑动窗口+重叠——用冗余换边界质量

既然问题出在边界处信息丢失,那就加一个overlap——让相邻块有一部分重叠,关键信息在前后两块都出现。

function slidingWindowSplit(
  text: string,
  chunkSize: number,
  overlap: number
): string[] {
  const chunks: string[] = [];
  const step = chunkSize - overlap; // 每次滑动 step 步
  for (let i = 0; i < text.length; i += step) {
    const chunk = text.slice(i, i + chunkSize);
    if (chunk.length < overlap) break; // 最后一段太短就不切了
    chunks.push(chunk);
  }
  return chunks;
}

拿同样的文本,设置chunkSize=50, overlap=20,切出来是这样:

[块0] (50字) 第一段:React 18 引入了并发特性,包括 Suspe...
[块1] (50字) 包括 Suspense 和 startTransition API。这些特...
[块2] (50字) 这些特性让用户界面更新可以中断和恢复,显著提升...
[块3] (50字) 显著提升了用户体验。第二段:Vue 3 使用 Proxy...

现在"Suspense"既出现在块0的末尾,也出现在块1的开头。无论用户查询落在哪个块,都能匹配到完整的"Suspense"关键词。

但问题又来了:"切在哪里"依旧是随机的。chunkSize=50意味着每50字就要砍一刀,这一刀可能砍在句号后面,也可能砍在"包括"和"Suspense"中间。叠加overlap只是治标——它让边界信息不丢了,但没有改变"随机切"的本质。

这就像你用一把固定长度的尺子量绳子:每次都从尺子起点开始量,量完50厘米砍一刀,然后尺子往后退20厘米(overlap)继续量。切口位置是尺子长度决定的,跟绳子本身的结构完全无关。

那有没有办法让切口沿着文本的"自然缝隙"走?这就要请出我们的主角了。

在继续之前,补充一个实战细节:overlap的值也不是越大越好。overlap太大,向量库会被大量冗余信息填充,检索性能下降,存储成本上升。一般经验值是chunk_size的10%-15%,比如chunk_size=500时overlap=50。另一个容易踩的坑:overlap在中文场景下可能产生大量重复的句子片段,因为中文没有空格分隔词,overlap的边界可能在词语中间。这进一步说明,单纯靠overlap不够,必须从"在哪里切"的根源上解决问题。


四、Level 3:递归分隔符切分——为什么它是工业标准

这一节是本文最核心的部分。

先给结论:Dify和KMS——两个完全独立的项目——不约而同选择了递归分隔符作为默认切分策略。这不是巧合。

**递归分隔符切分(Recursive Character Text Splitter)**的核心思想极其朴素:文本有"自然缝隙"——段落之间的空行、行与行之间的换行、句子之间的句号——切口应该沿着这些缝隙走,而不是随机砍。如果文本的"最大缝隙"都切不动(切开后还是太长),就降级找"次大缝隙",递归下去。只有所有缝隙都试过了还太长,才用刀硬切。

4.1 分隔符优先级设计

先看有哪几种"缝隙"(从大到小):

优先级 分隔符 对应边界 语义完整度
1 \n\n 段落分隔 极高——每个段落是独立语义单元
2 \n 换行 高——通常是一段内的行结构
3 。|!|? 中文句子 较高——完整的句子
4 ;|; 分号 中等——并列分句
5 ,|, 逗号 较低——短语级
6 (无) 字符 最低——强制截断

为什么是这个顺序?因为你的大脑在读文档时,也会自然地在段落边界"翻页"。段落是最大的语义单元,换行次之,句子再次之。越往下走,语义完整性越差。在逗号处切出来的"React 18 引入了"——这算什么信息?

这个优先级不是拍脑袋定的,而是通过大量中文文档实践打磨出来的。KMS的源码里有6层分隔符(比Dify的4层多),专门针对中文标点体系做了优化,这个我们后面详聊。

4.2 算法三步走

递归分隔符切分可以用一个极其形象的类比来理解——切蛋糕

  1. 先看能不能沿着蛋糕的最大裂缝(段落分隔 \n\n)掰开
  2. 裂缝位置不适合?找次大裂缝(换行 \n
  3. 还不行?找小裂缝(句号
  4. 实在没缝了——用刀硬切

翻译成伪代码:

recursiveSplit(text, separators):
  // 终止条件:文本已经够短
  if text.length <= chunkSize:
    return [text]

  // 找到文本中存在的"最高优先级"分隔符
  chosenSep = 从 separators 中找第一个在 text 里出现的分隔符

  // 没有分隔符能用 → 强制截断(兜底)
  if chosenSep 不存在:
    return forceSplit(text)

  // 用选中的分隔符切开
  splits = text.split(chosenSep)

  // 对每个切开的部分:
  for each split in splits:
    if split.length <= chunkSize:
      收入结果              // 够短,直接要
    else:
      recursiveSplit(split, remainingSeparators)  // 太长,递归!用下一级分隔符

关键是"递归 + 降级":当前分隔符切出来的某一块如果还是太长,不是直接放弃,而是用下一级分隔符继续切。这个设计让算法非常鲁棒——一个长段落先在\n\n处没切开,会在\n处切,还不够会在处切,层层递进。

我来举个例子说明"递归+降级"在真实长文本上是如何工作的。假设你有这样一段密集的中文文档:

React 18引入了并发特性,这是React历史上最重要的更新之一;开发者现在可以使用Suspense处理异步数据加载;同时startTransition API能标记低优先级更新,避免阻塞用户交互。Vue 3使用Proxy替代了Object.defineProperty,这使得Vue能够原生支持数组变更检测和Map/Set操作;Composition API让逻辑复用变得更加简单直观;Vue 3的响应式系统在性能上也有大幅提升,特别是在大规模列表渲染时。

假设chunkSize=100字。算法会这样跑:先找\n\n——没有;再找\n——没有;再找——找到了两个句号。在第一个句号处切开后,前半部分95字(小于100),直接收入;后半部分还是太长,进入下一层递归,用(分号,下一级分隔符)继续切。这样切出来的每个块都结束在自然语义边界上。

在这里插入图片描述

4.3 完整TypeScript实现

下面是生产可用的RecursiveTextSplitter类,我特意保留了chunkOverlap参数以支持滑动窗口叠加(可选的增强):

class RecursiveTextSplitter {
  private separators: string[];
  private chunkSize: number;
  private chunkOverlap: number;

  constructor(
    separators: string[],
    chunkSize: number = 500,
    chunkOverlap: number = 50
  ) {
    this.separators = separators;
    this.chunkSize = chunkSize;
    this.chunkOverlap = chunkOverlap;
  }

  splitText(text: string): string[] {
    return this.recursiveSplit(text, [...this.separators]);
  }

  private recursiveSplit(text: string, separators: string[]): string[] {
    // 终止条件:文本已经够短
    if (text.length <= this.chunkSize) {
      return [text];
    }

    // 找到文本中存在的第一个(最高优先级)分隔符
    let chosenSep = "";
    let remainingSeps: string[] = [];

    for (let i = 0; i < separators.length; i++) {
      const sep = separators[i];
      if (sep === "") {
        // 空字符串是兜底标记:匹配任何位置
        chosenSep = "";
        remainingSeps = [];
        break;
      }
      if (new RegExp(sep).test(text)) {
        chosenSep = sep;
        remainingSeps = separators.slice(i + 1);
        break;
      }
    }

    // 兜底:无分隔符可用 → 强制截断
    if (chosenSep === "" && remainingSeps.length === 0) {
      return this.forceSplit(text);
    }

    // 按选中的分隔符切分(保留分隔符以维持语义)
    const splits = text.split(new RegExp(`(${chosenSep})`, "g"));
    return this.mergeSplits(splits, chosenSep, remainingSeps);
  }

  private mergeSplits(
    parts: string[],
    separator: string,
    remainingSeps: string[]
  ): string[] {
    const result: string[] = [];
    let buffer = "";

    for (const part of parts) {
      if (part === separator) {
        // 遇到分隔符 → 结束当前 buffer
        if (buffer.length > 0) {
          if (buffer.length <= this.chunkSize) {
            result.push(buffer);           // 够短,收入
          } else {
            // 太长 → 递归,用下一级分隔符
            result.push(...this.recursiveSplit(buffer, remainingSeps));
          }
          buffer = "";
        }
        buffer = separator; // 分隔符附到下一块前面
      } else {
        buffer += part;
      }
    }

    // 最后一个 buffer
    if (buffer.length > 0) {
      if (buffer.length <= this.chunkSize) {
        result.push(buffer);
      } else {
        result.push(...this.recursiveSplit(buffer, remainingSeps));
      }
    }

    return result;
  }

  private forceSplit(text: string): string[] {
    const chunks: string[] = [];
    for (let i = 0; i < text.length; i += this.chunkSize) {
      chunks.push(text.slice(i, i + this.chunkSize));
    }
    return chunks;
  }
}

// 生产环境使用
const CN_SEPARATORS = ["\n\n", "\n", "。|!|?", ";|;", ",|,"];
const splitter = new RecursiveTextSplitter(CN_SEPARATORS, 500, 50);
const chunks = splitter.splitText(yourDocument);

4.4 实战效果

拿一段混合了标题和正文的Markdown文档来测试:

输入:
  ## React 18 并发渲染
  React 18 引入了并发特性。这是 React 历史上最重要的更新之一。
  开发者现在可以使用 Suspense 处理异步数据加载。
  ...

切分结果(chunkSize=100):
  [块0] "## React 18 并发渲染\nReact 18 引入了并发特性。"
  [块1] "这是 React 历史上最重要的更新之一。\n开发者现在可以..."
  [块2] "## Vue 3 响应式系统\nVue 3 使用 Proxy 替代了..."

注意:每个块都结束在完整句子(句号)或自然换行处,没有半截词。因为分隔符优先级保证算法优先在"最大语义边界"处切


五、Level 4:语义切分——当文档自带结构时

递归分隔符已经很好了,但它有一个盲区:它不"理解"文档的结构。

一篇Markdown文档是这样的:

# React 18 新特性

## 并发渲染
React 18 最重要的更新是并发渲染。它允许 React 同时准备多个版本的 UI。

## Suspense 改进
Suspense 现在可以用于服务端渲染和数据获取。组件在等待数据时可以显示 fallback UI。

## 自动批处理
React 18 默认开启自动批处理,多次 setState 调用会被合并为一次更新。

如果直接丢给递归分隔符,它可能在"## 并发渲染"和它的正文之间切一刀——但这一刀很可能是随机落在某个\n上的,因为递归分隔符并不知道"##"是标题标记。

更好的做法是按文档结构切——每个标题及其正文作为一个独立的Chunk:

interface SemanticChunk {
  title: string;
  level: number;  // 标题层级: h1=1, h2=2, ...
  content: string;
}

function markdownHeaderSplit(markdown: string): SemanticChunk[] {
  const chunks: SemanticChunk[] = [];
  const lines = markdown.split("\n");
  let currentTitle = "";
  let currentLevel = 0;
  let currentContent: string[] = [];

  for (const line of lines) {
    const headerMatch = line.match(/^(#{1,6})\s+(.+)/);
    if (headerMatch) {
      // 遇到新标题 → 保存上一个 chunk
      if (currentContent.length > 0) {
        chunks.push({
          title: currentTitle || "无标题",
          level: currentLevel,
          content: currentContent.join("\n").trim(),
        });
      }
      currentLevel = headerMatch[1].length;
      currentTitle = headerMatch[2];
      currentContent = [];
    } else {
      currentContent.push(line);
    }
  }

  // 最后一个 chunk
  if (currentContent.length > 0) {
    chunks.push({
      title: currentTitle || "无标题",
      level: currentLevel,
      content: currentContent.join("\n").trim(),
    });
  }

  return chunks;
}

这样切出来的每个Chunk都是一个完整语义单元:

  • ## 并发渲染 + 其正文 → 一个Chunk
  • ## Suspense 改进 + 其正文 → 一个Chunk

优势很明显: Embedding模型拿到的是完整语义块,检索精度最高。用户搜"Suspense服务端渲染",能精准命中"Suspense 改进"那个块,而不会跟其他内容混在一起。

局限也很明显: 只适用于有结构的文档。纯文本、聊天记录、自由格式的笔记——压根没有##标记,语义切分无计可施。这也是为什么大多数系统把"语义切分"作为一个可选增强而非默认策略。

另外还有一个容易被忽略的问题:标题层级会丢失。如果你的文档有三级标题(# ## ###),##标题切出来的Chunk丢失了它属于哪个#的信息。一个实用的补救办法是保留标题路径,比如SemanticChunk里加一个titlePath: string字段,记录从根标题到当前标题的完整路径(如"React 18 新特性 > 并发渲染 > 细节"),这样LLM收到的上下文自带层级信息。


六、Level 5:父子块模式——2025年的"版本答案"

这是目前生产环境的最优解,也是Dify正在主推的方案。

6.1 核心矛盾

在RAG里有一个经典的两难困境:

  • 大Chunk → 上下文完整,LLM生成质量高,但Embedding精度低(信息被稀释)
  • 小Chunk → Embedding精度高,检索准确,但上下文碎片化,LLM拿到的是"半句话"

你不可能同时用小Chunk检索又用大Chunk生成——除非你把"检索用的块"和"给LLM看的块"分开。这正是父子块模式的核心思想:Small-to-Big——小索引,大上下文。

6.2 架构设计

在这里插入图片描述

存储模型:

  • 子块(Children):只有子块进入向量数据库,做Embedding和相似度检索
  • 父块(Parents):存在普通数据库(如PostgreSQL),通过parent_id关联子块
  • 父块不存向量,只有子块存向量

检索流程:

用户查询 "React如何标记低优先级更新?"
  → 向量库检索:匹配到 [子块3](内容含"startTransition")
  → 查 parent_id:子块3 属于 [父块1]
  → 返回 [父块1] 完整内容给 LLM → 生成答案

6.3 TypeScript实现

interface ParentChunk {
  docId: string;
  title: string;
  fullContent: string;   // 完整父块内容——这是最终喂给LLM的
}

interface ChildChunk {
  text: string;           // 仅用于Embedding和检索
  parentDocId: string;    // 不存父块全文,只存引用
}

function parentChildSplit(
  text: string,
  childSize: number,
  parentSize: number
): {
  parents: string[];
  children: { text: string; parentIndex: number }[];
} {
  const paragraphs = text.split(/\n\n/).filter(p => p.trim());
  const parents: string[] = [];
  const children: { text: string; parentIndex: number }[] = [];

  let parentBuffer = "";

  for (const para of paragraphs) {
    if ((parentBuffer + para).length > parentSize && parentBuffer.length > 0) {
      // 当前父块已满 → 保存父块,生成其子块
      parents.push(parentBuffer.trim());
      const pi = parents.length - 1;
      for (let i = 0; i < parentBuffer.length; i += childSize) {
        children.push({
          text: parentBuffer.slice(i, i + childSize).trim(),
          parentIndex: pi,
        });
      }
      parentBuffer = para;
    } else {
      parentBuffer += (parentBuffer ? "\n\n" : "") + para;
    }
  }

  // 最后一个父块
  if (parentBuffer.trim()) {
    parents.push(parentBuffer.trim());
    const pi = parents.length - 1;
    for (let i = 0; i < parentBuffer.length; i += childSize) {
      children.push({
        text: parentBuffer.slice(i, i + childSize).trim(),
        parentIndex: pi,
      });
    }
  }

  return { parents, children };
}

6.4 召回率不降反升

很多人担心:子块这么小,召回率会不会下降?我们用一个模拟实验来验证。

用3篇技术文档(React、Vue、TypeScript)和5个测试查询,对比传统方案和父子块方案:

                    传统大块(200字)          父子块(子80字/父200+字)
─────────────────  ──────────────────────   ─────────────────────────
向量库存储         大块(200字)              小块(80字)
Embedding精度      中等(信息稀释)            高(小块更聚焦)
返回LLM的上下文    大块(200字)              完整父块(200+字)
检索命中率         一样                     一样(甚至更高,因为子块Embedding更精准)
上下文完整度       ⚠️ 可能被截断            ✅ 完整段落/文档

模拟结果:两个方案的命中率相同,但父子块方案返回的上下文总是完整的段落而非截断片段。同样命中率下,完整上下文意味着LLM生成质量更高。

实际落地中,父子块模式还有一个隐藏优势:去重。假设用户查到两个子块,它们都指向同一个父块,系统只需要返回一次完整的父块上下文,天然避免了RAG中常见的"返回多个相似片段"导致的LLM冗余输入问题。

6.5 常见疑问

Q1: 父子块都需要存向量库吗?
不需要。只有子块存向量库。父块存在普通数据库,子块通过parent_id关联。Dify就是这么做的:子块进向量库(Qdrant/Weaviate),父块内容作为metadata存储。

Q2: 召回率怎么算?
和传统方案一样:检索到的上下文是否包含正确答案。区别是传统方案返回的上下文 = 检索到的那一个块,父子块方案返回的上下文 = 子块对应的完整父块。因为父块更大更完整,同样的检索命中率下,实际答案覆盖度更高。

Q3: 直接返回父块全文给LLM?
对。这就是"Small-to-Big"的精华:对向量库来说看到的是精准的小块(80字),对LLM来说收到的是完整的大块(200+字)。两边的好处都占了。


七、源码对比:Dify vs KMS 分块实现

纸上谈兵终觉浅。我们来看看两个真实生产系统的分块模块是怎么写的——Dify(开源RAG平台)和KMS(我们公司内部知识管理平台)。

7.1 核心配置对比

维度 Dify KMS
核心算法 FixedRecursiveCharacterTextSplitter 双轨:CZQA SpliterClient + KMS RecursiveSplitter
分隔符(按优先级) \n\n. → `` \n\n\n。|!|?. |! |?
分隔符层数 4层 6层
默认chunk_size 500 tokens 1000 chars (KMS) / 512 tokens (CZQA)
默认overlap 50 (10%) 0
索引模式 段落 / QA / 父子块 CZQA / KMS Legacy
中文特化 只有一个中文标点 完整中文标点体系 + 康熙部首替换
嵌入耦合 分离(先切后嵌) CZQA内嵌(切完立即调Embedding API)

7.2 关键差异解读

差异一:中文支持深度

这是两个项目最本质的差异。Dify的定位是国际化平台,其中文支持属于"能用"级别——在分隔符列表里加了(中文句号),仅此而已。KMS作为面向中文企业的系统,在分隔符上下了真功夫:

# Dify — 4层分隔符
separators = ["\n\n", "。", ". ", " ", ""]

# KMS — 6层分隔符
self._separators = [
    "\n\n",              # 段落
    "\n",                # 换行
    "。|!|?",          # 中文句号/感叹号/问号
    "\.\s|\!\s|\?\s",   # 英文句子 + 空格
    ";|;\s",           # 中英文分号
    ",|,\s"            # 中英文逗号  ← Dify没有!
]

KMS多了"分号"和"逗号"两层,这意味着在KMS中,一个长句子会先在分号处切,再不行才到逗号处切。对中文长句(法律条文、技术规范)的切分质量明显更好。

差异二:Overlap策略

Dify默认overlap=50(约10%),KMS默认overlap=0。为什么KMS不设overlap?因为它依赖CZQA的"段落感知"切分来保证边界质量——如果切口始终落在段落边界,overlap确实意义不大。但这个策略对纯文本(没有明显段落结构的文档)有风险:两个相邻的自然段可能共享关键上下文,没有overlap就意味着这个关联被切断了。

差异三:Dify的父子块模式是亮点

Dify是第一个把父子块模式工程化的开源RAG框架。它在index_processor_base.py中明确定义了ParentChildConfig

父块:按 paragraph 策略切,chunk_size=1000
子块:用 subchunk_segmentation 规则再切,chunk_size=200

检索时:匹配子块 → 返回父块上下文给LLM

KMS目前还没有实现父子块模式,用的是双轨制(CZQA新管线 + KMS传统管线),这可能是其下一步的演进方向。

7.3 共同局限与选型建议

两个项目都没有解决的问题:

  • 没有动态chunk大小:无论内容密度高低,一刀切的chunk_size。一篇密集的法律条文和一篇松散的产品介绍用同样的chunk_size显然不合理。
  • 没有chunk策略评估:没有指标衡量不同策略的检索效果。哪个分隔符配置更好?全靠工程师直觉。
  • Dify的overlap对中文不够精细:英文overlap按token,中文应该按语义边界overlap。

如果今天让我从头搭建一个RAG系统的分块模块,我会这样选型:用KMS的6层中文分隔符体系做基底,加上Dify的父子块模式和overlap机制,在具体业务场景中通过A/B测试调chunk_size。 没有银弹,但有一个好起点——那就是递归分隔符 + 父子块。


八、策略速查表与总结

8.1 五种策略一句话总结

策略 一句话 适用场景
固定大小 “我不管内容,我只管长度” 不推荐
滑动窗口 “加点重叠,补救一下” 简单文档,快速原型
递归分隔符 “沿着自然缝隙切” 通用文本(默认推荐)
语义切分 “按文档标题结构切” Markdown/HTML等结构化文档
父子块 “小块检索,大块生成” 生产首选

8.2 选型决策树

你的文档是什么样的?
├─ 有明确标题结构(Markdown/HTML)
│  └─ 对检索精度要求很高 → 语义切分 + 父子块
├─ 纯中文文档
│  └─ 追求最佳中文切分质量 → 递归分隔符(KMS 6层配置) + overlap
├─ 混合中英文
│  └─ 追求通用性 → 递归分隔符(Dify 4层配置) + overlap
└─ 需要生产级方案
   └─ 不对,你应该直接上父子块模式

8.3 推荐配置速查

FAQ/短问答:  child=100-200字, parent=500-800字
技术文档:    child=300-400字, parent=1000-1500字
长文/法律:   child=500-800字, parent=2000-3000字
全栈平台:    child=200字, parent=1000字 + overlap=20字 (25-26年主流配置)

8.4 下篇预告

分块做完了,接下来就是把每个Chunk变成Embedding向量存进向量数据库。在下一篇文章中,我们会聊:

  • Embedding模型怎么选(OpenAI vs 开源 vs 国产)
  • 为什么同样的文本,不同Embedding模型产出的向量检索效果天差地别
  • 向量数据库的选择和索引优化(HNSW参数怎么调)

Chunking决定了检索质量的上限,Embedding决定了你能多接近这个上限。我们下篇见。


下一篇:RAG从入门到精通(三):Embedding选型——你的向量到底有多"聪明"

上一篇:RAG从入门到精通(一):数据清洗——LLM吃的第一口饭不能是馊的


Logo

脑启社区是一个专注类脑智能领域的开发者社区。欢迎加入社区,共建类脑智能生态。社区为开发者提供了丰富的开源类脑工具软件、类脑算法模型及数据集、类脑知识库、类脑技术培训课程以及类脑应用案例等资源。

更多推荐