欢迎光临
我们一直在努力

RAG从入门到精通(二):文本分块——递归分隔符凭什么成为工业标准

一个前端出身的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 算法三步走

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

  • 先看能不能沿着蛋糕的最大裂缝(段落分隔 \\n\\n)掰开
  • 裂缝位置不适合?找次大裂缝(换行 \\n)
  • 还不行?找小裂缝(句号 。)
  • 实在没缝了——用刀硬切
  • 翻译成伪代码:

    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 核心配置对比

    维度DifyKMS
    核心算法 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吃的第一口饭不能是馊的


    赞(0)
    未经允许不得转载:171主机测评 » RAG从入门到精通(二):文本分块——递归分隔符凭什么成为工业标准
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址