文本切分策略
文本切分(Chunking)是 RAG 系统中最关键的步骤之一。切分策略直接影响检索精度和回答质量。
为什么需要切分?
graph TB
A[为什么需要切分] --> B[Embedding 模型有 Token 限制]
A --> C[长文本语义稀释]
A --> D[LLM 上下文窗口有限]
A --> E[检索精度要求]
B --> B1[大多数模型限制 512-8192 tokens]
C --> C1[过长的文本向量
无法准确表达局部语义] D --> D1[传入过多文本
增加成本和延迟] E --> E1[更小的块
更精确的匹配] style A fill:#e3f2fd,stroke:#1976d2,stroke-width:3px
无法准确表达局部语义] D --> D1[传入过多文本
增加成本和延迟] E --> E1[更小的块
更精确的匹配] style A fill:#e3f2fd,stroke:#1976d2,stroke-width:3px
切分策略对比
| 策略 | 原理 | 优点 | 缺点 | 推荐场景 |
|---|---|---|---|---|
| 固定大小 | 按字符数切分 | 简单、快速 | 可能截断句子 | 非结构化文本 |
| 句子切分 | 按句号切分 | 保持句子完整 | 块大小不均 | 短文档 |
| 段落切分 | 按段落切分 | 语义完整 | 段落大小差异大 | 博客、文章 |
| 递归切分 | 多级分隔符递归 | 灵活、语义好 | 实现较复杂 | 通用场景推荐 |
| 语义切分 | 按语义相似度 | 语义最佳 | 速度慢、成本高 | 高精度需求 |
| 结构化切分 | 按文档结构 | 利用文档格式 | 格式依赖 | Markdown/HTML |
实现各种切分策略
1. 固定大小切分
"""
固定大小切分器
最简单的切分方式,按字符数切分
"""
class FixedSizeChunker:
"""固定大小切分"""
def __init__(self, chunk_size: int = 500, overlap: int = 50):
"""
Args:
chunk_size: 每个块的最大字符数
overlap: 相邻块之间的重叠字符数
"""
self.chunk_size = chunk_size
self.overlap = overlap
def split(self, text: str) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = start + self.chunk_size
# 如果不是最后一块,尝试在句号处截断
if end < len(text):
# 在 chunk_size 范围内找最后一个句号
last_period = text.rfind("。", start, end)
if last_period == -1:
last_period = text.rfind(".", start, end)
if last_period > start:
end = last_period + 1
chunks.append(text[start:end].strip())
start = end - self.overlap
return [c for c in chunks if c] # 去除空块
# 使用
chunker = FixedSizeChunker(chunk_size=500, overlap=50)
text = "这是一段很长的文本..." * 100
chunks = chunker.split(text)
print(f"原文长度: {len(text)} 字符")
print(f"切分为 {len(chunks)} 个块")
print(f"平均块大小: {sum(len(c) for c in chunks) / len(chunks):.0f} 字符")
2. 递归字符切分(推荐)
"""
递归字符切分器(LangChain 默认策略)
按照多级分隔符递归切分,优先保持语义完整性
"""
class RecursiveChunker:
"""递归切分器"""
def __init__(
self,
chunk_size: int = 500,
overlap: int = 50,
separators: list[str] = None
):
self.chunk_size = chunk_size
self.overlap = overlap
# 分隔符优先级:段落 > 换行 > 句子 > 逗号 > 空格
self.separators = separators or [
"\n\n", # 段落
"\n", # 换行
"。", # 中文句号
".", # 英文句号
"!", # 中文感叹号
"?", # 中文问号
";", # 中文分号
",", # 中文逗号
",", # 英文逗号
" ", # 空格
"", # 单字符
]
def split(self, text: str) -> list[str]:
"""递归切分文本"""
return self._split_text(text, self.separators)
def _split_text(self, text: str, separators: list[str]) -> list[str]:
"""递归切分核心逻辑"""
final_chunks = []
# 找到最合适的分隔符
separator = separators[-1]
for sep in separators:
if sep in text:
separator = sep
break
# 按分隔符切分
if separator:
splits = text.split(separator)
else:
splits = list(text)
# 合并小块
current_chunk = ""
for split in splits:
piece = split if not separator else split + separator
if len(current_chunk) + len(piece) <= self.chunk_size:
current_chunk += piece
else:
if current_chunk:
final_chunks.append(current_chunk.strip())
# 如果单个片段超过限制,递归处理
if len(piece) > self.chunk_size:
remaining_seps = separators[separators.index(separator) + 1:]
if remaining_seps:
sub_chunks = self._split_text(piece, remaining_seps)
final_chunks.extend(sub_chunks)
current_chunk = ""
else:
current_chunk = piece
else:
current_chunk = piece
if current_chunk.strip():
final_chunks.append(current_chunk.strip())
# 添加重叠
return self._add_overlap(final_chunks)
def _add_overlap(self, chunks: list[str]) -> list[str]:
"""为相邻块添加重叠"""
if self.overlap == 0 or len(chunks) <= 1:
return chunks
result = [chunks[0]]
for i in range(1, len(chunks)):
# 取上一块的末尾作为重叠
prev_tail = chunks[i - 1][-self.overlap:]
result.append(prev_tail + chunks[i])
return result
# 使用
chunker = RecursiveChunker(chunk_size=300, overlap=30)
text = """
# Python 设计模式
## 单例模式
单例模式确保一个类只有一个实例。这在数据库连接池、配置管理器等场景中非常有用。
实现方式有很多种:使用 __new__ 方法、使用装饰器、使用元类等。
## 工厂模式
工厂模式将对象的创建和使用分离。当你需要创建复杂对象时,工厂模式可以简化代码。
工厂模式分为简单工厂、工厂方法和抽象工厂三种。
""".strip()
chunks = chunker.split(text)
for i, chunk in enumerate(chunks):
print(f"\n--- 块 {i + 1} ({len(chunk)} 字符) ---")
print(chunk[:100] + "..." if len(chunk) > 100 else chunk)
3. Markdown 结构化切分
"""
Markdown 结构化切分器
利用 Markdown 的标题结构进行智能切分
"""
import re
class MarkdownChunker:
"""基于 Markdown 结构的切分器"""
def __init__(self, chunk_size: int = 1000, overlap: int = 0):
self.chunk_size = chunk_size
self.overlap = overlap
def split(self, text: str) -> list[dict]:
"""
按 Markdown 标题切分
返回包含元数据的切分结果
"""
# 按标题分割
sections = self._split_by_headers(text)
chunks = []
for section in sections:
content = section["content"]
if len(content) <= self.chunk_size:
chunks.append(section)
else:
# 超长段落用递归切分器处理
sub_chunker = RecursiveChunker(
chunk_size=self.chunk_size,
overlap=self.overlap
)
sub_chunks = sub_chunker.split(content)
for i, sub in enumerate(sub_chunks):
chunks.append({
"content": sub,
"headers": section["headers"],
"part": i + 1
})
return chunks
def _split_by_headers(self, text: str) -> list[dict]:
"""按标题层级分割文档"""
lines = text.split("\n")
sections = []
current_headers = {}
current_content = []
for line in lines:
header_match = re.match(r"^(#{1,6})\s+(.+)$", line)
if header_match:
# 保存当前段落
if current_content:
content = "\n".join(current_content).strip()
if content:
sections.append({
"content": content,
"headers": dict(current_headers)
})
current_content = []
# 更新标题层级
level = len(header_match.group(1))
title = header_match.group(2)
current_headers[f"h{level}"] = title
# 清除更低层级的标题
for l in range(level + 1, 7):
current_headers.pop(f"h{l}", None)
current_content.append(line)
else:
current_content.append(line)
# 最后一段
if current_content:
content = "\n".join(current_content).strip()
if content:
sections.append({
"content": content,
"headers": dict(current_headers)
})
return sections
# 使用
chunker = MarkdownChunker(chunk_size=500)
md_text = """
# RAG 系统指南
## 第一章:基础概念
### 什么是 RAG
RAG 是检索增强生成的缩写。它结合了信息检索和文本生成两种技术。
### 为什么需要 RAG
LLM 有知识截止日期的限制,RAG 可以解决这个问题。
## 第二章:实战
### 环境搭建
首先安装必要的依赖包。
""".strip()
chunks = chunker.split(md_text)
for i, chunk in enumerate(chunks):
print(f"\n--- 块 {i + 1} ---")
print(f"标题路径: {chunk['headers']}")
print(f"内容: {chunk['content'][:80]}...")
Chunk 大小选择
graph TB
A{Chunk 大小选择} --> B[小块 100-300 字符]
A --> C[中块 300-800 字符]
A --> D[大块 800-2000 字符]
B --> B1[精确匹配]
B --> B2[适合问答]
B --> B3[可能缺少上下文]
C --> C1[平衡选择]
C --> C2[适合多数场景]
C --> C3[推荐起始值]
D --> D1[保持完整性]
D --> D2[适合长文摘要]
D --> D3[检索精度可能下降]
style C fill:#c8e6c9,stroke:#388e3c,stroke-width:3px
style C3 fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
经验法则
| 应用场景 | 推荐 Chunk 大小 | 重叠大小 | 理由 |
|---|---|---|---|
| 精确问答 | 200-400 字符 | 20-50 | 问题通常指向具体段落 |
| 通用知识库 | 400-800 字符 | 50-100 | 平衡精度和上下文 |
| 文章摘要 | 800-1500 字符 | 100-200 | 需要更完整的段落 |
| 代码文档 | 按函数/类切分 | 0 | 代码有天然边界 |
| 法律文档 | 按条款切分 | 50 | 条款是天然的语义单元 |
本章小结
- 文本切分是 RAG 系统中最影响效果的步骤之一
- 递归字符切分是最通用的切分策略(推荐起步)
- Markdown 结构化切分适合有标题结构的文档
- Chunk 大小推荐 400-800 字符,重叠 50-100 字符
- 不同场景需要不同的切分策略,需要实验调优
下一章:我们将学习如何构建向量索引并存储到向量数据库中。