为什么文本分块如此重要?
一个真实案例:分块不当导致的检索失败
某 RAG 系统在处理一份 API 文档时遇到了严重问题:用户询问"如何配置超时参数",系统要么找不到答案,要么返回不完整的配置说明。
问题根源:文档中的配置代码块被切分成了两个碎片:
text
碎片 A:timeout: 5000, // 请求超时时间(毫秒)
碎片 B:retry: 3, // 重试次数
当用户查询"超时参数"时,系统只能检索到碎片 A,但碎片 A 缺少上下文------它原本属于一个完整的配置对象。结果就是:回答要么找不到,要么不完整。
这就是分块策略不当的典型后果。
分块策略的核心作用
好的分块策略直接影响 RAG 系统的三个核心指标:
| 指标 | 说明 | 不当分块的影响 |
|---|---|---|
| 检索精度 | 找到真正相关的文档块 | 找到不相关的块(低精度)或遗漏相关块(低召回) |
| 生成质量 | LLM 基于检索内容生成的答案 | 上下文不完整 → 回答片面或错误 |
| Token 成本 | 每次检索投入的上下文长度 | 块过大浪费 token,过小则需多次检索 |
常用分块方法原理与实战
整体对比概览
| 分块方法 | 原理 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 固定分块 | 按固定字符数切分 | 简单、高效、可控 | 可能切断语义边界 | 日志、表格等结构化数据 |
| 递归分块 | 按分隔符优先级递归切分 | 保持语义边界 | 实现稍复杂 | 大多数场景首选 |
| 语义分块 | 利用 Embedding 计算语义相似度 | 语义完整性最佳 | 计算成本高、需调阈值 | 高质量内容、长文档 |
| 文档结构分块 | 基于 Markdown/HTML 标题结构 | 保持文档层次 | 依赖格式规范 | 技术文档、博客文章 |
1. 固定分块(Fixed-size Chunking)
最简单的分块方法:按固定字符数切分,不考虑语义边界。
typescript
// src/chunking/fixed-chunker.ts
interface FixedChunkConfig {
chunkSize: number; // 每块字符数
chunkOverlap: number; // 块间重叠字符数
}
export function fixedChunk(text: string, config: FixedChunkConfig): string[] {
const { chunkSize, chunkOverlap } = config;
const chunks: string[] = [];
if (text.length <= chunkSize) {
return [text];
}
let start = 0;
while (start < text.length) {
const end = Math.min(start + chunkSize, text.length);
chunks.push(text.slice(start, end));
start += chunkSize - chunkOverlap;
}
return chunks;
}
// 使用示例
const text = "这是一个很长的文档内容,用于测试固定分块策略的效果...";
const chunks = fixedChunk(text, { chunkSize: 10, chunkOverlap: 5 });
2. 递归分块(Recursive Chunking)------ 最常用
按分隔符优先级递归切分,优先保持段落和句子边界。
typescript
// src/chunking/recursive-chunker.ts
// 中文分隔符优先级(从高到低)
const SEPARATORS = [
"\n\n",
"\n",
"。",
"!",
"?",
";",
",",
" ",
"",
];
interface RecursiveChunkConfig {
chunkSize: number;
chunkOverlap: number;
separators?: string[];
}
export class RecursiveChunker {
private chunkSize: number;
private chunkOverlap: number;
private separators: string[];
constructor(config: RecursiveChunkConfig) {
this.chunkSize = config.chunkSize;
this.chunkOverlap = config.chunkOverlap;
this.separators = config.separators || SEPARATORS;
}
splitText(text: string): string[] {
if (text.length <= this.chunkSize) {
return [text];
}
const separator = this.separators[0];
if (!separator) {
return this.splitByChars(text);
}
const splits = text.split(separator);
const chunks: string[] = [];
let currentChunk: string[] = [];
let currentLength = 0;
for (const part of splits) {
const partLength = part.length;
const neededLength = currentLength + partLength + (currentChunk.length > 0 ? separator.length : 0);
if (neededLength > this.chunkSize && currentChunk.length > 0) {
const finalChunk = currentChunk.join(separator);
chunks.push(finalChunk);
// 从上一块末尾取 overlap 长度
const overlap = this.getOverlap(finalChunk);
currentChunk = overlap ? [overlap] : [];
currentLength = overlap?.length || 0;
}
currentChunk.push(part);
currentLength = currentChunk.join(separator).length;
}
if (currentChunk.length > 0) {
chunks.push(currentChunk.join(separator));
}
// 递归处理太大的块
const finalChunks: string[] = [];
for (const chunk of chunks) {
if (chunk.length > this.chunkSize) {
const subChunker = new RecursiveChunker({
chunkSize: this.chunkSize,
chunkOverlap: this.chunkOverlap,
separators: this.separators.slice(1),
});
finalChunks.push(...subChunker.splitText(chunk));
} else {
finalChunks.push(chunk);
}
}
return finalChunks;
}
// 根据 overlap 获取重叠片段
private getOverlap(text: string): string {
if (this.chunkOverlap <= 0) return "";
return text.slice(-this.chunkOverlap);
}
// 字符级保底切分
private splitByChars(text: string): string[] {
const chunks: string[] = [];
let start = 0;
while (start < text.length) {
const end = Math.min(start + this.chunkSize, text.length);
chunks.push(text.slice(start, end));
start += this.chunkSize - this.chunkOverlap;
}
return chunks;
}
}
// 使用示例
const chunker = new RecursiveChunker({
chunkSize: 10,
chunkOverlap: 5,
});
const text = "这是一段很长的中文文本,里面有各种各样的符号!用于测试按分隔符优先级递归切分的效果...";
const chunks = chunker.splitText(text);
3. 语义分块(Semantic Chunking)
利用 Embedding 计算句子间的相似度,在语义边界处切分。
typescript
// src/chunking/semantic-chunker.ts
import { OpenAIEmbeddings } from "@langchain/openai";
import dotenv from "dotenv";
dotenv.config();
interface SemanticChunkConfig {
similarityThreshold: number;
minChunkSize: number;
maxChunkSize: number;
}
export class SemanticChunker {
private embeddings: OpenAIEmbeddings;
private config: SemanticChunkConfig;
constructor(embeddings: OpenAIEmbeddings, config: SemanticChunkConfig) {
this.embeddings = embeddings;
this.config = config;
}
/**
* 切分句子(完全类型安全)
*/
private splitSentences(text: string): string[] {
if (!text || text.trim().length === 0) {
return [];
}
const regex = /([^。!?;]*[。!?;])/g;
const matches = text.match(regex);
if (!matches || matches.length === 0) {
return [text];
}
return matches.filter((sentence) => sentence.trim().length > 0);
}
/**
* 余弦相似度(严格类型 + 防除零)
*/
private calculateSimilarity(vec1: number[], vec2: number[]): number {
if (vec1.length === 0 || vec2.length === 0) return 0;
let dotProduct = 0;
let norm1 = 0;
let norm2 = 0;
for (let i = 0; i < vec1.length; i++) {
const v1 = vec1[i] ?? 0;
const v2 = vec2[i] ?? 0;
dotProduct += v1 * v2;
norm1 += v1 ** 2;
norm2 += v2 ** 2;
}
const mag1 = Math.sqrt(norm1);
const mag2 = Math.sqrt(norm2);
if (mag1 === 0 || mag2 === 0) return 0;
return dotProduct / (mag1 * mag2);
}
/**
* 语义分块(完全类型安全)
*/
async splitText(text: string): Promise<string[]> {
// 空文本直接返回
if (!text || text.trim() === "") return [];
const sentences = this.splitSentences(text);
if (sentences.length === 0) return [];
if (sentences.length === 1) return sentences;
// 批量向量化(一定返回 number[][])
const vectors = await this.embeddings.embedDocuments(sentences);
if (vectors.length !== sentences.length) return sentences;
const chunks: string[] = [];
let currentChunk = sentences[0];
for (let i = 1; i < sentences.length; i++) {
const prevVec = vectors[i - 1];
const currVec = vectors[i];
const currSentence = sentences[i];
// 类型安全:跳过异常数据
if (!prevVec || !currVec || !currSentence) continue;
const similarity = this.calculateSimilarity(prevVec, currVec);
const wouldExceedMax = (currentChunk?.length || 0) + currSentence.length > this.config.maxChunkSize;
const shouldSplit = similarity < this.config.similarityThreshold;
const currentIsValidSize = (currentChunk?.length || 0) >= this.config.minChunkSize;
if (wouldExceedMax) {
chunks.push(currentChunk as string);
currentChunk = currSentence;
} else if (shouldSplit && currentIsValidSize) {
chunks.push(currentChunk as string);
currentChunk = currSentence;
} else {
currentChunk += currSentence;
}
}
// 最后一块
if (currentChunk && currentChunk.trim().length > 0) {
chunks.push(currentChunk);
}
return chunks;
}
}
// 使用示例
const embeddings = new OpenAIEmbeddings({
apiKey: process.env.DASHSCOPE_API_KEY,
configuration: {
baseURL: process.env.DASHSCOPE_API_URL,
},
model: "text-embedding-v2",
});
const chunker = new SemanticChunker(embeddings, {
similarityThreshold: 0.7,
minChunkSize: 20,
maxChunkSize: 500,
});
const text = "这是第一句话。这是第二句话,和上一句语义相似。这是完全无关的第三句话!";
const chunks = await chunker.splitText(text);
console.log(chunks);
分块参数调优技巧
块大小(chunk_size)的选择
| 块大小 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 小(200-400) | 简单问答、FAQ | 精度高,噪声少 | 上下文不足,丢失关系 |
| 中(500-800) | 大多数场景首选 | 平衡性好 | 需要调参 |
| 大(1000-1500) | 长文档、复杂内容 | 上下文完整 | Token 成本高,噪声增加 |
重叠度(chunk_overlap)的设置
重叠的作用是保证语义完整性,避免关键信息被切断。
| 重叠度 | 适用场景 | 说明 |
|---|---|---|
| 10-15% | 短文档、问答类 | 基础保护 |
| 15-25% | 通用推荐 | 有效防止切断 |
| 25-30% | 代码、长文档 | 强保护 |
typescript
// 根据文档类型推荐参数
export function getRecommendedConfig(docType: 'article' | 'code' | 'conversation' | 'qa') {
switch (docType) {
case 'article':
return { chunkSize: 800, chunkOverlap: 120 }; // 15% 重叠
case 'code':
return { chunkSize: 600, chunkOverlap: 100 }; // 代码块更小
case 'conversation':
return { chunkSize: 1000, chunkOverlap: 200 }; // 保持对话连贯
case 'qa':
return { chunkSize: 400, chunkOverlap: 40 }; // 问答更短
default:
return { chunkSize: 800, chunkOverlap: 120 };
}
}
不同文档分块方案选型
文本类文档
typescript
// 适用于:技术文章、博客、产品说明
const textChunker = new RecursiveChunker({
chunkSize: 800,
chunkOverlap: 100,
separators: ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
});
代码类文档
代码分块的特殊性:
- 需要保留函数/类的完整结构
- 缩进和空行有语义含义
- 注释需要与其关联的代码保持在一起
typescript
// 适用于:TypeScript、Python、Java 代码
const codeChunker = new RecursiveChunker({
chunkSize: 600,
chunkOverlap: 80,
separators: [
"\n\n", // 函数/类之间的空行
"\n", // 代码行
" ", // 缩进
" ", // 空格
"", // 字符级
],
});
长文档(如书籍、论文)
typescript
// 适用于:书籍、技术手册、学术论文
const longDocChunker = new RecursiveChunker({
chunkSize: 1200,
chunkOverlap: 200,
separators: ["\n\n", "\n", "。", "!", "?", ";", ",", " ", ""],
});
// 额外保留目录/章节信息
function extractChapterMetadata(chunk: string, originalDoc: any) {
// 提取章节标题作为元数据
const chapterMatch = chunk.match(/^第[一二三四五六七八九十]+章/);
return {
...originalDoc.metadata,
chapter: chapterMatch ? chapterMatch[0] : null,
};
}
分块效果对比测试
测试代码实现
typescript
// src/test/chunking-benchmark.ts
import { RecursiveChunker } from '../chunking/recursive-chunker';
// 测试文本
const testText = `
# RAG 技术介绍
检索增强生成(Retrieval-Augmented Generation,简称 RAG)是一种结合检索和生成的技术方案。
## 核心优势
RAG 的主要优势包括:
1. 解决大模型幻觉问题
2. 支持私有知识接入
3. 知识可实时更新
## 应用场景
RAG 广泛应用于企业知识库、智能客服、代码助手等场景。
例如,某电商平台通过 RAG 技术,将客服问答准确率从 76% 提升到 92%。
`;
async function runBenchmark() {
const configs = [
{ name: "小块无重叠", chunkSize: 200, chunkOverlap: 0 },
{ name: "中块低重叠", chunkSize: 500, chunkOverlap: 50 },
{ name: "大块高重叠", chunkSize: 800, chunkOverlap: 150 },
];
for (const config of configs) {
const chunker = new RecursiveChunker(config);
const chunks = chunker.splitText(testText);
console.log(`\n📊 ${config.name}:`);
console.log(` 块数: ${chunks.length}`);
console.log(` 平均长度: ${Math.round(chunks.reduce((s, c) => s + c.length, 0) / chunks.length)} 字符`);
console.log(` 块内容预览:`);
chunks.slice(0, 3).forEach((chunk, i) => {
console.log(` 块 ${i + 1}: ${chunk.slice(0, 80)}...`);
});
}
}
测试结果对比
| 参数 | 块数 | 平均长度 | 语义完整性 | Token 成本 |
|---|---|---|---|---|
| 小块(200,0) | 8 | 185 | ⚠️ 较差 | 低 |
| 中块(500,50) | 4 | 420 | ✅ 良好 | 中 |
| 大块(800,150) | 3 | 780 | ✅ 优秀 | 高 |
检索效果对比(相同查询"RAG 的优势")
| 分块方案 | 检索结果示例 | 可用性 |
|---|---|---|
| 小块 | "1. 解决大模型幻觉问题" | ❌ 信息不完整 |
| 中块 | "RAG 的主要优势包括:1. 解决大模型幻觉问题 2. 支持私有知识接入 3. 知识可实时更新" | ✅ 完整 |
| 大块 | 整个章节(含部分冗余信息) | ⚠️ 稍显冗余 |
常见问题与解决方案
问题 1:关键信息被切断
现象:代码块、表格、长句子被切分成多个碎片
解决方案:
- 增加自定义分隔符(如代码关键字)
- 提高重叠度
- 使用语义分块
typescript
// 代码块保护:识别代码块边界,避免切断
function protectCodeBlocks(text: string): string {
const codeBlockRegex = /```[\s\S]*?```/g;
// 将代码块替换为占位符,分块后再还原
return text.replace(codeBlockRegex, (match) => `[CODE_BLOCK_${hash(match)}]`);
}
问题 2:分块过细导致上下文丢失
现象:检索到多个相关块,但每个块信息都不完整
解决方案:
- 增加块大小
- 使用父子文档策略:大块用于检索,小块用于生成
问题 3:计算资源消耗大(语义分块)
现象:处理大量文档时速度慢、成本高
解决方案:
- 使用批量处理
- 递归分块作为日常方案,语义分块用于高质量场景
问题 4:特殊格式文档处理困难
现象:Markdown 表格、JSON 数据被错误切分
解决方案:
- 为特定格式设计专用分块器
- 预处理:将特殊格式转换为结构化的纯文本
typescript
// Markdown 表格处理
function processMarkdownTable(tableText: string): string[] {
// 将表格按行切分,保留表头信息
const lines = tableText.split('\n');
const header = lines[0];
const chunks = [];
for (let i = 2; i < lines.length; i++) {
chunks.push(`${header}\n${lines[i]}`);
}
return chunks;
}
结语
通过这篇教程,我们系统学习了 RAG 系统中文本分块的策略和参数调优技巧。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!