📖 本章学习目标
- 解释文档切分的必要性,以及切分粒度过粗或过细对检索质量的影响
- 使用 TypeScript 实现四种主流切分策略,并理解各自的适用场景
- 调优 chunk_size 和 chunk_overlap 参数以获得更好的检索效果
- 针对不同文档类型(代码、Markdown、纯文本)选择合适的切分策略
在《第4章:文档加载》中,我们成功将各种异构格式的文档解析并加载为统一的 Document 对象。然而,这仅仅是 RAG 数据处理的起点。在实际工程中,直接将整篇长文档塞入大语言模型(LLM)不仅会迅速击穿上下文窗口的限制,更会导致模型在海量无关信息中迷失,产生严重的“大海捞针”效应。
关于 RAG 的宏观局限性,我们将在《第8章:RAG 的局限性及应对策略》中展开系统性探讨。本章将聚焦于 RAG 离线索引阶段最核心的“地基”环节——文档切分(Chunking) 。我们将深入剖析切分的底层逻辑,探讨如何在“检索精度”与“语义完整性”之间找到最优的动态平衡。
一、切分的核心矛盾与底层逻辑
文档切分绝非简单的按字数截断,它是决定 RAG 系统检索上限的第一变量。业界共识表明,多数 RAG 项目效果不佳的根源往往不在模型,而在于切分策略破坏了语义边界。对文档做合理拆分的必要性主要源于以下三个维度的工程约束。
1. 突破 LLM 上下文窗口的刚性约束
尽管 目前挺多先进模型的上下文窗口已达到百万级别的Token,但这在企业级知识库面前依然捉襟见肘。当知识库包含数千篇文档时,总 Token 量轻松突破百万级。受限于上下文窗口,我们不可能将所有文档一次性注入 Prompt。因此,将长文本切分转化为可被模型高效处理的离散单元,是跨越这一物理限制的唯一途径。
2. 提升检索信噪比与向量匹配精度
从《第2章:文本嵌入》我们已经知道,向量检索的本质是计算 Query 与文档片段(Chunk)之间的语义相似度。在这种处理下,如果以整篇文档作为检索单元,会带来两个致命问题:
(1)语义稀释:文档中的无关段落会严重稀释核心信息的特征,导致向量表示变得“模糊”,降低与用户 Query 的匹配精度。
(2)噪声干扰:即使成功召回了包含答案的长文档,LLM 也需要在数万 Token 中艰难定位关键信息。大量无关上下文不仅增加了模型“分心”的概率,还极易引发幻觉(Hallucination)。
合理的切分能够提纯信息密度,确保每个 Chunk 都是一个高内聚的语义单元,从而大幅提升 Top-K 召回的准确率。
3. 控制 Token 消耗与系统推理成本
LLM API 的调用成本与输入 Token 数呈严格的线性关系。在每次查询中传入大量无关的冗余上下文,会导致成本急剧攀升。通过精细化的切分,我们可以确保只将最相关、最精简的信息块注入 Prompt,在保证回答质量的同时,显著降低系统的整体运行成本。
4. 粒度困境:在“信息密度”与“语义完整”间走钢丝
切分策略还面临着一个根本性的工程权衡(Trade-off)问题,即切分的粒度与信息完整性之间的权衡。切分过粗(Chunk 过大) 时,虽然保留了完整的上下文,但信息密度极低。检索时容易召回大量无关内容,且大块的向量表示难以精确匹配用户的特定、细粒度问题;切分过细(Chunk 过小) 时,虽然检索匹配更精准,但极易破坏语义的连贯性。例如,用户询问“如何配置超时”,系统召回了“将 timeout 设为 30”,却丢失了“这是 Redis 缓存配置”这一关键前置语境,导致模型生成残缺或错误的回答。
为了直观理解这一困境,我们来看同一段文档在不同切分粒度下的表现:
// 粒度太粗:信息密度低,包含大量无关噪声,检索精度差
const too_coarse = "RAG 系统包含三个组件:检索器负责从向量数据库查找相关文档,"
+ "增强器负责组装 Prompt,生成器负责调用 LLM 生成答案。部署时需要配置"
+ "Pinecone API Key、OpenAI API Key,以及 Redis 缓存连接字符串...";
// 粒度太细:丢失上下文,检索到的片段缺乏自包含性,难以被模型理解
const too_fine = [
"将 timeout 设为 30",
"配置 Redis 缓存",
"设置 Pinecone API Key",
];
// 合理粒度:每个片段都是一个自包含、高内聚的语义单元
const just_right = [
"RAG 系统包含三个组件:检索器负责从向量数据库查找相关文档。",
"部署 RAG 系统时需要配置:Pinecone API Key(向量数据库)、OpenAI API Key(LLM 调用)、Redis 连接字符串(查询缓存)。",
];
那么应该如何确立最优粒度呢?关于这个问题,其实在工程实践中并没有放之四海而皆准的数字,但可以参考以下行业基准进行初始化:
- 技术文档/操作手册:推荐 200-500 Token,确保每个操作步骤或概念解释的完整性。
- FAQ/问答对:必须保持“问题+回答”作为一个不可分割的原子单元,严禁拆分。
- 法律条文/合规文档:严格按条款(如“第X条”)切分,保持法条逻辑的绝对完整。
- 代码文档:以函数(Function)或类(Class)为边界切分,保持代码块的语法完整性。
进阶提示:在《第12章:RAG 系统评估与指标体系》中,我们将介绍如何通过 Recall@K、Context Relevancy 等量化指标,结合 A/B 测试来科学地确定特定业务场景下的最优切分粒度。
5. Chunk Overlap(块重叠):修复边界截断的缓冲机制
无论采用何种切分策略,切分边界在物理上总是任意的。它极有可能恰好落在两个语义紧密相连的句子或逻辑链之间。通过引入 Chunk Overlap(让相邻的 Chunk 共享一部分内容)机制,我们可以有效缓解这一硬切断带来的语义丢失。简单理解就是在每一个切分的块的前后冗余一部分的文档,其的底层实现是滑动窗口。
原文总长:200 字符
分块1: [0 ─────────── 80)
↑ 重叠区(20字符)
分块2: [60 ────────── 140)
↑ 重叠区(20字符)
分块3: [120 ─────────── 200)
如上所示,在一段有200个字符的文中,每80个字符拆成一块(即chunk_size=80),冗余前面20个字符(即overlap=20)时,从第二块开始的开头都会多存储上一块最后的20个字符。这样,检索时重叠区域充当了语义的缓冲带,确保跨越边界的语义单元至少在一个完整的 Chunk 中被保留。
在实际的工程实践中,通常情况下Overlap 的大小建议设置为 Chunk Size 的 10% - 20% 。如果设置过小(如 < 5%)则起不到语义缓冲的作用,依然容易丢失跨句逻辑。如果设置过大过大(如 > 30%)则会导致向量库中存在大量重复内容,不仅浪费存储空间,还会增加 LLM 的 Token 消耗和检索时的计算开销。
二、四种主流切分策略
文档切分的策略有很多种,常见的有固定长度切片、语义切片、结构化切片、重叠切片、递归切片、分隔符切片以及组合以上多种策略的混合切片。
1. 固定大小切分
这是最简单的一种策略,按字符数或 Token 数机械切割,不考虑语义边界。
import { CharacterTextSplitter } from "langchain/text_splitter";
const splitter = new CharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
separator: "", // 空字符串 = 按字符切分
});
const chunks = await splitter.splitDocuments(docs);
基本的处理逻辑就是工作从文档开头开始,每 500 个字符切一刀,相邻块之间重叠 50 个字符。完全不考虑句子、段落等语义边界。
这种实现方式的优点和缺点都很明显。优点是实现简单,计算成本极低,块大小可控,便于估算 Token 消耗;且缺点是可能在句中切断,破坏语义完整性,这对于结构化文档(如 Markdown),可能切断标题和内容的关联。
适用于快速原型验证、文本结构统一、对检索精度要求不高的场景。或者作为切割的 baseline,与其他策略作对比。
2. 递归字符切分(推荐起步方案)
递归字符切分(Recursive Character Splitting) 是一种在固定大小的基础上,按分隔符优先级逐级尝试的切分策略,优先用“大”分隔符(如双换行符)切分以保留语义边界,仅对仍超限的块递归使用“更小”分隔符继续拆分,直至所有块满足大小限制。它是 LangChain 中 RecursiveCharacterTextSplitter 的默认实现,也是 RAG 工程实践中最常用的切分方式。
比如一个按照\n\n(段落)→ \n(换行)→ . (句子)→ (词)→ ``(字符)分隔符优先级顺序尝试的例子如下:
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
separators: ["\n\n", "\n", "。", ". ", " ", ""],
});
const chunks = await splitter.splitDocuments(docs);
工作原理:
递归字符切分器的算法流程如下:
- 用第一个分隔符(
\n\n,段落分隔符)切分文档 - 检查每个块是否超过 chunkSize
- 如果不超过,保留
- 如果超过,用下一个分隔符(
\n,换行符)继续切分
- 重复步骤 2,直到所有块都小于 chunkSize,或用尽所有分隔符
- 如果仍有块超过 chunkSize,按字符强制切分
graph TD
A[原始文档] --> B{用段落分隔符切分}
B -->|块 < 500| C[保留]
B -->|块 > 500| D{用换行分隔符切分}
D -->|块 < 500| C
D -->|块 > 500| E{用句子分隔符切分}
E -->|块 < 500| C
E -->|块 > 500| F{用 . 切分}
F -->|块 < 500| C
F -->|块 > 500| G[按字符强制切分]
每一步先尝试用优先级最高的分隔符切分;如果某些块仍然超过 chunkSize,降级到下一个分隔符继续切。这保证了尽可能在"自然语义边界"上断开。
这种切割方式可以尽可能保证在段落、句子边界切分,语义完整性好,适用于大多数文本类型。同时其实现成熟,是LangChain 默认推荐的方式。当然,其也有缺点,比如对于特殊格式(如代码、表格)效果一般,另外分隔符列表需要根据语言调整(中文用"。",英文用".")。
3. 基于文档结构的切分
对于有明确层级结构的文档(Markdown、代码),可以利用标题和代码块信息保持结构的完整性。
(1)Markdown 文档切分
import { MarkdownTextSplitter } from "langchain/text_splitter";
const splitter = new MarkdownTextSplitter({
chunkSize: 500,
chunkOverlap: 30,
});
const chunks = await splitter.splitDocuments(markdownDocs);
// 每个 chunk 的 metadata 中保留了所属的标题层级
MarkdownTextSplitter 的特殊之处在于它能识别 Markdown 标题(#、##、### 等),尽量在标题边界切分,保持章节完整性,并能够在 metadata 中记录标题路径,方便后续溯源。
假设有以下 Markdown 文档:
# 部署指南
## 预发环境
预发环境地址为 https://staging.example.com
### 访问方式
需要 VPN 才能访问预发环境。
## 生产环境
生产发布由 SRE 团队负责。
切分后可能得到:
chunks[0] = {
pageContent: "# 部署指南\n\n## 预发环境\n\n预发环境地址为 https://staging.example.com",
metadata: { headers: ["# 部署指南", "## 预发环境"] }
};
chunks[1] = {
pageContent: "### 访问方式\n\n需要 VPN 才能访问预发环境。",
metadata: { headers: ["# 部署指南", "## 预发环境", "### 访问方式"] }
};
(2)代码文档切分
import { Language } from "langchain/text_splitter";
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
const splitter = RecursiveCharacterTextSplitter.fromLanguage(
Language.TypeScript,
{
chunkSize: 500,
chunkOverlap: 50,
}
);
const chunks = await splitter.splitDocuments(codeDocs);
fromLanguage 方法会根据编程语言的特性自动调整分隔符列表。对于 TypeScript,它会优先在函数、类、代码块边界切分,而不是在任意位置切断。
支持的语言有Python、JavaScript、TypeScript、Java、C++、Go、Ruby 等主流语言,同时每种语言都有针对性的分隔符(如 Python 的 \ndef 、JavaScript 的 \nfunction )
基于文档结构的切分最大的优点就是可以保持文档结构完整性,同时可以在metadata 中包含标题信息,便于后续过滤和溯源,这对于技术文档和代码库效果显著。当然缺点也很明显,即仅适用于结构化文档,对于非标准 Markdown 或混合格式支持有限。
如果你是做技术文档、API 文档等 Markdown 形式的知识库、代码库问答系统或其它需要保持章节层级的场景,基于文档结构的切分是你的首选方案。
4. 语义切分
这是最智能也最耗资源的策略,它的基本原理是先计算相邻句子的 Embedding 相似度,在相似度"暴跌"的位置切分,因为相似度暴跌就说明这里是一个语义边界。
import { SemanticChunker } from "langchain/experimental/chunkers";
const chunker = new SemanticChunker({
embeddingFunction: getEmbedding, // Embedding 函数
breakpointThresholdType: "percentile",
breakpointThresholdAmount: 90, // 在相似度陡降的 90 分位处切分
});
const chunks = await chunker.splitDocuments(docs);
处理的步骤如下:将文档按句子切分 ->为每个句子生成 Embedding-> 计算相邻句子的余弦相似度 -> 找出相似度显著下降的位置(即语义边界)-> 在这些边界处切分文档
graph LR
A[句子1] -->|相似度 0.85| B[句子2]
B -->|相似度 0.82| C[句子3]
C -->|相似度 0.23| D[句子4]
D -->|相似度 0.78| E[句子5]
C -. 在此处切分 .-> D
在上图中,句子3和句子4之间的相似度只有 0.23,远低于其他相邻句子对,说明这里是一个语义边界,应该在此处切分。
与其它切分方式相比,语义切分具有语义完整性最好,切分位置符合人类直觉、不依赖文档格式,适用于任何文本、能捕捉到隐式的语义转换等优点。其缺点则是计算成本高(需要为每个句子生成 Embedding)、延迟高,不适合实时处理、参数调优复杂(阈值设置不当会导致过度切分或切分不足)
适用场景:
- 对检索精度要求高、文档结构不统一、且愿意为切分多花计算成本的场景
- 离线索引任务(可以接受较长的处理时间)
- 非结构化文本(如会议记录、对话 transcript)
四种策略对比
| 策略 | 语义完整性 | 计算成本 | 实现复杂度 | 适用场景 |
|---|---|---|---|---|
| 固定大小 | 低 | 极低 | 极低 | 快速原型、性能敏感场景 |
| 递归字符切分 | 中 | 低 | 低 | 通用场景(推荐起步方案) |
| 结构切分 | 中高 | 低 | 低 | Markdown/代码文档 |
| 语义切分 | 高 | 高 | 中 | 高精度要求、非结构化文本 |
选型建议:
- 开发阶段:从递归字符切分开始,快速验证整体架构
- 生产优化:根据文档类型选择结构切分或语义切分
- 混合策略:对不同格式的文档使用不同的切分策略(见下文)
三、切分策略的路由设计
在实际生产中,很少对所有文档使用同一种切分策略。更常见的做法是根据文档类型路由到不同的切分器。即通过一个统一的处理入口,根据不同的文档类型分发到不同的文档切割器中。
import { Document } from "@langchain/core/documents";
import { RecursiveCharacterTextSplitter, MarkdownTextSplitter } from "langchain/text_splitter";
// 创建不同的切分器
const markdownSplitter = new MarkdownTextSplitter({
chunkSize: 500,
chunkOverlap: 30,
});
const textSplitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
separators: ["\n\n", "\n", "。", ". ", " ", ""],
});
// 根据文档类型路由
async function smartSplit(docs: Document[]): Promise<Document[]> {
const allChunks: Document[] = [];
for (const doc of docs) {
const fileType = doc.metadata.fileType || "text";
let chunks: Document[];
if (fileType === "markdown") {
chunks = await markdownSplitter.splitDocuments([doc]);
} else if (fileType === "code") {
// 可以根据具体语言选择
const codeSplitter = RecursiveCharacterTextSplitter.fromLanguage(
Language.TypeScript,
{ chunkSize: 500, chunkOverlap: 50 }
);
chunks = await codeSplitter.splitDocuments([doc]);
} else {
chunks = await textSplitter.splitDocuments([doc]);
}
allChunks.push(...chunks);
}
return allChunks;
}
这种设计的优势在于可以将“识别类型”和“执行切分”解耦,对每种文档都用最适合的方式切分,符合单一职责原则。同时也易于扩展,新增文档类型时只需添加新的分支即可。解耦后,文档的类型则可以在加载阶段统一作标记。(见《第4章:文档加载》)
四、切分参数的实验对比方法
切分参数(chunk_size 和 chunk_overlap)没有绝对的万能参数,需要根据实际的场景找到合适的解。为了找到这个最优解,我们需要建立一套科学的实验对比流程,而不是凭直觉猜测。
通过设计一些实验核心指标(比如块数量、长度分布、重叠率),结合测试对比脚本,可以做一些简单的自动化评估。
import { RecursiveCharacterTextSplitter } from "langchain/text_splitter";
import { Document } from "@langchain/core/documents";
// 定义实验策略组
interface ChunkingStrategy {
name: string; // 策略名称,用于报告展示
chunkSize: number; // 块大小
overlap: number; // 重叠大小
}
async function compareChunking(
doc: Document,
strategies: ChunkingStrategy[]
) {
console.log(`=== 开始切分实验 ===`);
console.log(`文档总长度: ${doc.pageContent.length} 字符\n`);
for (const strategy of strategies) {
// 1. 初始化切分器
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: strategy.chunkSize,
chunkOverlap: strategy.overlap,
});
// 2. 执行切分
const chunks = await splitter.splitDocuments([doc]);
// 3. 统计分析
const lengths = chunks.map(c => c.pageContent.length);
const avgLength = lengths.reduce((a, b) => a + b, 0) / lengths.length;
const maxLength = Math.max(...lengths);
const minLength = Math.min(...lengths);
// 4. 输出报告
console.log(`--- 策略: ${strategy.name} ---`);
console.log(` 🧩 生成块数: ${chunks.length}`);
console.log(` 📏 平均长度: ${avgLength.toFixed(0)} 字符`);
console.log(` 📐 长度范围: [${minLength}, ${maxLength}]`);
// 简单的启发式评估:如果最小块太小,可能切分过细
if (minLength < strategy.chunkSize * 0.2) {
console.log(` ⚠️ 警告: 存在过短的块 (${minLength}),可能导致上下文缺失`);
}
console.log();
}
}
// --- 使用示例 ---
const doc = new Document({
pageContent: longText, // 这里填入你的长文档内容
metadata: { source: "example.md" },
});
await compareChunking(doc, [
{ name: "激进切分 (200/20)", chunkSize: 200, overlap: 20 },
{ name: "标准切分 (500/50)", chunkSize: 500, overlap: 50 },
{ name: "宽松切分 (1000/100)", chunkSize: 1000, overlap: 100 },
]);
可能输出:
文档总长度: 5000 字符
--- 策略: 激进切分 (200/20) ---
🧩 生成块数: 28
📏 平均长度: 178 字符
📐 长度范围: [50, 200]
⚠️ 警告: 存在过短的块 (50),可能导致上下文缺失
--- 策略: 标准切分 (500/50) ---
🧩 生成块数: 11
📏 平均长度: 455 字符
📐 长度范围: [380, 500]
--- 策略: 宽松切分 (1000/100) ---
🧩 生成块数: 6
📏 平均长度: 833 字符
📐 长度范围: [650, 1000]
这只是一种简单的评估方法,我们也不能仅凭块数和长度判断,需要结合检索效果。在《第12章:RAG 系统评估与指标体系》中,我们会介绍如何用 RAGAS 等工具做端到端评估,根据 MRR(Mean Reciprocal Rank)分数反推最优切分参数。
五、切分质量的手动检查方法
在自动化评估体系建立之前,或者在调试特定文档时,手动检查(Manual Inspection)是比较直接且有效的手段。
1. 随机抽样检查
不要试图检查所有数据。随机抽取 10-20 个 Chunk,进行人工阅读并评估。主要的检查点包括 自包含性、截断感、噪声。
- 自包含性:只看这个 Chunk,不看前后文,我能读懂它在说什么吗?
- 截断感:句子是否完整?是否以“因为...”开头,或以“所以”结尾却没有了下文?
- 噪声:是否混入了页眉、页脚或无关的导航文本?
2. 边界检查
我们可以通过编写简单的脚本,自动扫描所有 Chunk 的开头和结尾
function checkBoundaries(chunks: Document[]) {
const issues: string[] = [];
for (const [i, chunk] of chunks.entries()) {
const content = chunk.pageContent.trim();
// 检查是否以标点符号结尾
if (!/[。!?.!?\n]$/.test(content)) {
issues.push(`Chunk ${i} 不以标点结尾,可能被截断`);
}
// 检查是否以不完整句子开头
if (/^[a-z]/.test(content) && !/^https?:\/\//.test(content)) {
issues.push(`Chunk ${i} 以小写字母开头,可能从句子中间开始`);
}
}
return issues;
}
3. 重叠率检查
有时候我们设置了 chunk_overlap=50,但由于分隔符(如换行符)的强制切分,实际重叠可能远小于 50。我们需要验证重叠是否生效。
// 简单的字符串相似度计算(实际可用更复杂的算法如 Levenshtein)
function calculateOverlap(str1: string, str2: string): number {
// 这里简化为查找最长公共子串的长度
// 实际工程中建议使用 diff 算法
let maxOverlap = 0;
for (let i = 1; i <= str1.length; i++) {
const suffix = str1.slice(-i);
if (str2.startsWith(suffix)) {
maxOverlap = i;
}
}
return maxOverlap;
}
function checkOverlap(chunks: Document[], expectedOverlap: number) {
console.log(`正在验证重叠率 (预期: ${expectedOverlap})...`);
for (let i = 1; i < chunks.length; i++) {
// 取前一个块的尾部 和 当前块的头部进行比对
// 取 2 倍预期长度作为搜索窗口
const searchWindow = expectedOverlap * 2;
const prevEnd = chunks[i - 1].pageContent.slice(-searchWindow);
const currStart = chunks[i].pageContent.slice(0, searchWindow);
const actualOverlap = calculateOverlap(prevEnd, currStart);
// 如果实际重叠小于预期的 80%,发出警告
if (actualOverlap < expectedOverlap * 0.8) {
console.warn(`⚠️ 重叠不足: Chunk ${i-1} -> ${i} 实际重叠 ${actualOverlap} 字符`);
}
}
}
通过这套“手动+自动”的检查组合拳,你可以快速发现切分策略中的漏洞,并及时调整参数或分隔符策略。
FAQ
Q1:chunk_size 是算字符数还是 Token 数?
这取决于 Splitter 的实现。RecursiveCharacterTextSplitter 默认按字符数计算,但你可以设置 lengthFunction 来自定义。例如用 tiktoken 按 Token 数计算。Token 数在控制和 LLM 上下文的兼容性上更精确。
import { getEncoding } from "tiktoken";
const encoder = getEncoding("cl100k_base"); // GPT-4 使用的 encoding
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
lengthFunction: (text) => encoder.encode(text).length, // 按 Token 数计算
});
Q2:能不能不对所有文档用同样的切分参数?
可以,而且推荐这样做。技术文档(Markdown、代码)适合结构切分,政策文档(PDF 导出)适合递归字符切分。在加载阶段根据 metadata.fileType 路由到不同的切分策略(见上文"切分策略的路由设计")。
Q3:语义切分和递归切分能结合使用吗?
可以。先用结构切分保留文档层级,再用语义切分确定最终的断点位置。这需要自定义组合逻辑,但能显著提升切分质量。
// 伪代码示例
async function hybridChunking(doc: Document) {
// 第一步:用 Markdown 切分器按标题切分
const sections = await markdownSplitter.splitDocuments([doc]);
// 第二步:对每个 section 用语义切分器进一步切分
const allChunks: Document[] = [];
for (const section of sections) {
const semanticChunks = await semanticChunker.splitDocuments([section]);
allChunks.push(...semanticChunks);
}
return allChunks;
}
Q4:如何处理超短文档(< chunk_size)?
如果文档本身就很短(如一条 FAQ),不需要切分,直接作为一个 chunk 即可。可以在切分前检查文档长度。
async function smartChunk(doc: Document, minChunkSize: number = 100): Promise<Document[]> {
if (doc.pageContent.length < minChunkSize) {
return [doc]; // 文档太短,不切分
}
const splitter = new RecursiveCharacterTextSplitter({
chunkSize: 500,
chunkOverlap: 50,
});
return await splitter.splitDocuments([doc]);
}
Q5:切分后的 chunk 数量会影响检索性能吗?
会。chunk 数量越多:
- 向量数据库的索引越大,搜索延迟略增(但 HNSW 算法的对数复杂度使得影响不大)
- 存储空间增加
- 检索时可能需要增大
k值以覆盖更多相关内容
一般来说,百万级 chunk 数量对现代向量数据库来说不是问题。但如果达到千万级,需要考虑分片和优化索引参数。
练习
练习 1:对比不同 chunk_size 的检索效果
对同一份 5000 字的文档,分别用 chunk_size=200、500、1000 切分,然后用相同的查询做检索,对比三种参数下检索结果的相关性。
验证标准:
- 能够清晰描述三种参数下检索结果的差异
- 分析原因:小块更精准但缺乏上下文,大块上下文完整但噪声多
- 给出推荐参数及理由
提示:
- 准备 3-5 个测试问题
- 对每种切分参数执行检索
- 人工评估检索结果的相关性(1-5 分)
- 计算平均得分
练习 2:实现一个带标题级联的结构化切分器
对一份 Markdown 文档,编写自定义逻辑让每个 chunk 的 pageContent 自动拼接其所属的各级标题。例如 "# 部署指南 > ## 预发环境 > 预发环境地址为..."。
验证标准:
- 每个 chunk 的 content 开头包含完整的标题路径
- 标题路径与实际文档结构一致
- 能够处理多级标题嵌套
提示:
function addHeaderPath(chunk: Document, headers: string[]): Document {
const headerPath = headers.join(" > ");
chunk.pageContent = `${headerPath}\n\n${chunk.pageContent}`;
return chunk;
}
练习 3:实现自定义长度函数
编写一个基于 Token 数的长度函数,用 tiktoken 库计算文本的 Token 数量,并与基于字符数的切分结果对比。
验证标准:
- 能够正确计算中英文混合文本的 Token 数
- 对比两种长度函数的切分结果差异
- 分析哪种方式更适合控制 LLM 上下文窗口
练习 4:边界质量检测
编写一个函数,检测切分后的 chunk 是否存在以下问题:
- 在句子中间截断
- 缺少必要的上下文
- 重叠部分不足
统计有问题的 chunk 比例,并尝试调整参数降低问题比例。
验证标准:
- 能够识别至少 80% 的问题 chunk
- 调整后问题比例降低 50% 以上
📚 延伸阅读
- LangChain Text Splitters — LangChain 文本切分器完整文档,包含各种切分器的详细用法和参数说明
- Chunking Strategies for RAG — Pinecone 的 RAG 切分策略指南,介绍了不同场景下的最佳实践
- Semantic Chunking 论文思路 — 基于 Embedding 相似度的语义切分方法,想了解算法细节的读者可以阅读
- tiktoken Documentation — OpenAI 的 Tokenizer 库文档,用于精确计算 Token 数量
- RAGAS Evaluation Framework — RAG 系统评估框架,在第 12 章会详细介绍如何用 RAGAS 评估切分质量