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 核心配置对比
| 维度 | 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吃的第一口饭不能是馊的
更多推荐


所有评论(0)