深入浅出RAG——第5章:文档切分

140 阅读19分钟

📖 本章学习目标

  • 解释文档切分的必要性,以及切分粒度过粗或过细对检索质量的影响
  • 使用 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);

工作原理:

递归字符切分器的算法流程如下:

  1. 用第一个分隔符(\n\n,段落分隔符)切分文档
  2. 检查每个块是否超过 chunkSize
    • 如果不超过,保留
    • 如果超过,用下一个分隔符(\n,换行符)继续切分
  3. 重复步骤 2,直到所有块都小于 chunkSize,或用尽所有分隔符
  4. 如果仍有块超过 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% 以上

📚 延伸阅读