文档分块策略
文档分块(Chunking)是 RAG 系统中最关键也最容易被忽视的环节。分块质量直接影响检索精度:分块太大,噪音多、语义混杂;分块太小,上下文缺失、信息碎片化。本章将深入分析各种分块策略的原理和适用场景。
为什么需要分块?
直接将整篇文档作为检索单位存在几个问题:
上下文长度限制:大模型的上下文窗口有限,无法一次性处理所有文档。
假设知识库有 100 篇文档,每篇 5000 字
总字符数 = 500,000 字 ≈ 125,000 tokens
远超大多数 LLM 的上下文限制
检索精度问题:长文档包含多个主题,检索时难以精确定位。
查询: "公司的年假制度"
未分块: 返回整本《员工手册》50页
用户需要自己翻找相关信息
分块后: 只返回"年假"相关的 3 个段落
直接回答用户问题
嵌入质量下降:长文本的嵌入向量会"稀释"关键信息。
短文本嵌入: "年假制度规定员工每年享有5-15天假期"
→ 向量聚焦于"年假"主题
长文本嵌入: "公司简介...产品介绍...年假制度...财务制度..."
→ 向量是多个主题的平均,检索时不够精确
分块的核心参数
Chunk Size(块大小)
块大小决定每个分块包含的文本量。选择时需要平衡:
| 块大小 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 小(100-300字) | 语义单一、检索精确 | 上下文不足、碎片化 | 精确问答、定义查询 |
| 中(500-1000字) | 平衡性好 | 可能截断语义 | 通用场景 |
| 大(1500-3000字) | 上下文完整 | 噪音多、检索粗略 | 需要完整上下文的场景 |
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 小块
small_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=50
)
# 中等块(推荐)
medium_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=200
)
# 大块
large_splitter = RecursiveCharacterTextSplitter(
chunk_size=2000,
chunk_overlap=400
)
Chunk Overlap(块重叠)
重叠确保相邻块之间有上下文延续,避免信息断裂。
文档: "公司规定,员工入职满一年后可享受年假。年假天数根据工龄计算,
工龄1-5年享5天,5-10年享10天,10年以上享15天。"
无重叠分块:
块1: "公司规定,员工入职满一年后可享受年假。"
块2: "年假天数根据工龄计算,工龄1-5年享5天..."
问题: 检索到块2时,不知道"年假天数"指什么
有重叠分块:
块1: "公司规定,员工入职满一年后可享受年假。"
块2: "可享受年假。年假天数根据工龄计算,工龄1-5年..."
解决: 块2包含上下文,知道讨论的是年假
重叠比例建议:通常设置为块大小的 10%-20%。
# 重叠示例
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200 # 20% 重叠
)
分块策略分类
1. 固定大小分块
按固定字符数或 token 数切分,最简单但不考虑语义边界。
from langchain.text_splitter import CharacterTextSplitter
splitter = CharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200,
separator="\n" # 只在换行处切分
)
chunks = splitter.split_text(text)
优点:实现简单、速度快 缺点:可能切断句子或段落,破坏语义完整性 适用:格式统一的文档、快速原型
2. 递归字符分块
按优先级尝试不同的分隔符,尽可能在语义边界处切分。
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 默认分隔符优先级(适用于英文)
# ["\n\n", "\n", " ", ""]
# 中文优化分隔符
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
separators=[
"\n\n", # 段落
"\n", # 行
"。", # 句号
"!", # 感叹号
"?", # 问号
";", # 分号
",", # 逗号
" ", # 空格
"" # 字符
]
)
chunks = splitter.split_text(chinese_text)
工作原理:
输入文本 → 尝试按"\n\n"切分 → 块太大?
↓ 是
尝试按"\n"切分 → 块太大?
↓ 是
尝试按"。"切分 → ...
优点:保留语义边界、通用性强 缺点:仍可能在不太理想的位置切分 适用:大多数文本场景
3. 语义分块
基于语义相似度动态确定分块边界,相关性高的句子聚在一起。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai.embeddings import OpenAIEmbeddings
splitter = SemanticChunker(
OpenAIEmbeddings(),
breakpoint_threshold_type="percentile" # 断点阈值类型
)
chunks = splitter.split_text(text)
原理:
句子序列: S1 → S2 → S3 → S4 → S5 → S6
计算相邻句子的语义距离:
S1-S2: 0.1 (相似)
S2-S3: 0.15 (相似)
S3-S4: 0.8 (不相似) ← 断点
S4-S5: 0.12 (相似)
S5-S6: 0.1 (相似)
结果分块:
块1: S1, S2, S3
块2: S4, S5, S6
优点:语义完整性强 缺点:计算开销大、需要嵌入模型 适用:对语义完整性要求高的场景
4. 文档结构分块
根据文档的结构元素(标题、章节、列表)进行分块。
from langchain.text_splitter import MarkdownHeaderTextSplitter
markdown_document = """
# 公司制度
## 考勤管理
员工应按时上下班,迟到超过30分钟视为缺勤。
## 年假制度
入职满一年后可享受年假,天数根据工龄计算。
### 工龄与年假对应表
- 1-5年:5天
- 5-10年:10天
"""
headers_to_split_on = [
("#", "header_1"),
("##", "header_2"),
("###", "header_3"),
]
splitter = MarkdownHeaderTextSplitter(
headers_to_split_on=headers_to_split_on
)
chunks = splitter.split_text(markdown_document)
# 结果示例:
# {
# "header_1": "公司制度",
# "header_2": "考勤管理",
# "content": "员工应按时上下班..."
# }
优点:保留文档结构、元数据丰富 缺点:依赖文档格式、实现复杂 适用:结构化文档(Markdown、HTML、PDF)
5. 父文档分块
索引小块以提高检索精度,检索时返回包含该小块的大块(父文档)。
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain_community.vectorstores import Chroma
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 小块分割器(用于索引)
child_splitter = RecursiveCharacterTextSplitter(
chunk_size=400,
chunk_overlap=50
)
# 大块分割器(用于返回)
parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=2000,
chunk_overlap=400
)
vectorstore = Chroma(embedding_function=embeddings)
docstore = InMemoryStore()
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=docstore,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# 索引文档
retriever.add_documents(docs)
# 检索时:先匹配小块,返回对应的大块
results = retriever.invoke("年假制度")
优点:检索精确 + 上下文完整 缺点:需要额外存储空间 适用:需要完整上下文的问答场景
6. 滑动窗口分块
以固定步长移动窗口,生成大量重叠的分块。
def sliding_window_chunks(text, window_size=500, stride=100):
"""
window_size: 窗口大小
stride: 步长(越小重叠越多)
"""
chunks = []
start = 0
while start < len(text):
end = start + window_size
chunk = text[start:end]
chunks.append(chunk)
start += stride
if end >= len(text):
break
return chunks
# 示例
chunks = sliding_window_chunks(text, window_size=500, stride=100)
# 产生大量重叠的分块,提高召回率
优点:高召回率、不遗漏信息 缺点:索引量大、存储开销高 适用:高精度要求、存储不敏感场景
分块策略选择指南
按文档类型选择
| 文档类型 | 推荐策略 | 说明 |
|---|---|---|
| 技术文档 | 递归分块 + 父文档 | 需要完整代码上下文 |
| FAQ/问答对 | 按问答对分块 | 天然的语义单元 |
| 法律文档 | 结构分块 | 按条款切分 |
| 新闻文章 | 语义分块 | 保持故事完整性 |
| 代码文件 | 按函数/类分块 | 保持代码块完整 |
| 聊天记录 | 按时间窗口分块 | 保持对话上下文 |
按查询类型选择
| 查询类型 | 推荐块大小 | 说明 |
|---|---|---|
| 定义查询 | 小(200-400字) | "什么是XX" |
| 事实查询 | 中(400-800字) | "XX的具体规定" |
| 推理查询 | 大(1000-2000字) | "为什么XX" |
| 摘要查询 | 大或全文 | "总结XX的主要内容" |
代码示例:多策略结合
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_community.document_loaders import PyPDFLoader
def smart_chunk_documents(file_path, doc_type="general"):
"""智能分块:根据文档类型选择策略"""
# 加载文档
loader = PyPDFLoader(file_path)
docs = loader.load()
if doc_type == "technical":
# 技术文档:中等块 + 大重叠
splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=200,
separators=["\n\n", "\n", "。", " "]
)
elif doc_type == "faq":
# FAQ:小块 + 元数据
splitter = RecursiveCharacterTextSplitter(
chunk_size=300,
chunk_overlap=0
)
elif doc_type == "legal":
# 法律文档:按条款
splitter = RecursiveCharacterTextSplitter(
chunk_size=1500,
chunk_overlap=300,
separators=["第.*条", "\n\n", "\n", "。"]
)
else:
# 通用:默认设置
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100
)
return splitter.split_documents(docs)
分块元数据管理
每个分块应保留丰富的元数据,支持后续过滤和追溯。
from langchain_core.documents import Document
# 分块时添加元数据
def chunk_with_metadata(docs, splitter, source_info):
chunks = []
for doc in docs:
doc_chunks = splitter.split_documents([doc])
for i, chunk in enumerate(doc_chunks):
# 添加元数据
chunk.metadata.update({
"source": source_info["filename"],
"page": doc.metadata.get("page", 0),
"chunk_index": i,
"total_chunks": len(doc_chunks),
"doc_type": source_info["type"],
"created_at": datetime.now().isoformat()
})
chunks.append(chunk)
return chunks
分块质量评估
内部指标
语义完整性:分块是否包含完整的思想单元。
def evaluate_chunk_completeness(chunks):
"""评估分块的语义完整性"""
scores = []
for chunk in chunks:
text = chunk.page_content
# 检查是否以完整句子结束
ends_with_punctuation = text[-1] in "。!?.!?"
# 检查开头是否是句子中间
starts_with_lower = text[0].islower()
# 计算分数
score = 1.0
if not ends_with_punctuation:
score -= 0.3
if starts_with_lower:
score -= 0.2
scores.append(score)
return sum(scores) / len(scores)
外部评估
通过检索效果评估分块质量:
# 使用测试集评估
test_cases = [
{"question": "年假如何计算?", "relevant_chunks": ["chunk_id_1", "chunk_id_3"]},
{"question": "报销流程是什么?", "relevant_chunks": ["chunk_id_5"]},
]
def evaluate_retrieval(test_cases, retriever):
hits = 0
for case in test_cases:
results = retriever.invoke(case["question"])
retrieved_ids = [r.metadata["chunk_id"] for r in results]
if any(rid in case["relevant_chunks"] for rid in retrieved_ids):
hits += 1
return hits / len(test_cases)
常见问题与解决方案
问题 1:分块切断重要信息
原文: "公司年假制度规定:员工入职满一年后可享受年假。
年假天数根据工龄计算:工龄1-5年享5天,5-10年享10天。"
问题分块:
块1: "公司年假制度规定:员工入职满一年后可享受"
块2: "年假。年假天数根据工龄计算:工龄1-5年..."
问题: 块1不完整,块2开头突兀
解决方案:增加重叠或使用语义分块。
问题 2:分块大小差异过大
文档结构:
- 简介:50字
- 第一章:5000字
- 第二章:300字
问题:
- 简介分块太小,嵌入向量不稳定
- 第一章分块太大,噪音多
解决方案:设置最小和最大分块大小。
splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=100,
length_function=len,
# 合并小于最小大小的块
merge_chunks=True,
min_chunk_size=100
)
问题 3:代码块被切分
原文:
```python
def calculate_vacation(years):
if years < 5:
return 5
elif years < 10:
return 10
return 15
问题分块(在代码中间切断): 块1: "def calculate_vacation(years):\n if years" 块2: "< 5:\n return 5..."
**解决方案**:识别代码块并保持完整。
```python
import re
def split_preserving_code(text, chunk_size):
"""保持代码块完整的分块"""
# 提取代码块
code_pattern = r'```[\s\S]*?```'
code_blocks = re.findall(code_pattern, text)
# 替换代码块为占位符
text_with_placeholders = re.sub(code_pattern, '<<CODE_BLOCK>>', text)
# 分块
chunks = recursive_split(text_with_placeholders, chunk_size)
# 恢复代码块
for i, chunk in enumerate(chunks):
while '<<CODE_BLOCK>>' in chunk and code_blocks:
chunk = chunk.replace('<<CODE_BLOCK>>', code_blocks.pop(0), 1)
chunks[i] = chunk
return chunks
最佳实践总结
- 从递归分块开始:这是最通用、性价比最高的方法
- 块大小 500-1000 字:适合大多数场景的起点
- 重叠 10-20%:确保上下文连续性
- 保留元数据:来源、页码、标题等信息
- 评估和迭代:用实际数据验证分块效果
- 特殊文档特殊处理:代码、表格、列表需要专门策略
小结
分块是 RAG 系统的基础工程,直接影响检索质量。关键要点:
- 分块大小和重叠是核心参数
- 递归分块是通用选择
- 结构化文档应利用其结构
- 父文档分块平衡精度和上下文
- 必须保留丰富的元数据