写在前面
在构建检索增强生成(RAG)系统的过程中,分块(Chunking)是一个看似简单、实则决定系统成败的关键环节。很多开发者把大部分精力花在了 embedding 模型选型、向量数据库优化、prompt 工程上,却忽略了最基础的"数据怎么切"这个问题。
但事实是:分块的质量,直接决定了检索的精度,进而决定了最终答案的质量。
一个糟糕的分块策略,会让再强的 embedding 模型和 LLM 都无能为力——因为你喂给它们的数据本身就是残缺的、割裂的、丢失上下文的。
本文将全面、深入地解析 RAG 系统中的分块策略,从基础概念到前沿研究,从简单方法到复杂方案,帮你回答一个核心问题:面对我的文档,到底应该怎么切?
第一部分:为什么分块如此重要?
1.1 分块在 RAG 管线中的位置
在典型的 RAG 系统中,数据流向是这样的:
原始文档 → 分块 → Embedding → 向量存储 → 检索 → 上下文增强 → LLM 生成
分块处在整个管线的最前端。这意味着:
- 分块的错误会向下游传播,且无法被后续环节完全纠正
- 一旦切完,检索只能在已有的块中进行,无法"跨块"找回丢失的信息
1.2 分块问题的本质:两个相互矛盾的目标
分块面临的核心矛盾是:
| 追求 | 小块的诉求 | 大块的诉求 |
|---|---|---|
| 检索精度 | ✅ 小块更精准,噪音少 | ❌ 大块包含无关信息 |
| 上下文完整性 | ❌ 小块可能切散关键信息 | ✅ 大块信息更完整 |
| Token 限制 | ✅ 容易控制 | ❌ 容易超限 |
| 计算效率 | ✅ 更快 | ❌ 更慢 |
好的分块策略,就是在这两个目标之间找到最佳平衡点。
1.3 糟糕分块的真实代价
根据多项研究,分块不当会导致:
- 检索召回率下降 30-50%:因为信息被切散或切丢
- 答案完整性降低 40%+:LLM 看不到完整的上下文
- 幻觉率上升 2-3 倍:信息缺失时 LLM 更容易"编造"
- Token 浪费 50%+:大块中大量无关信息被塞入 prompt
第二部分:分块方法全景解析
我将按照"从简单到复杂、从通用到专用"的顺序,详细介绍每一种主流分块方法。
方法一:固定大小分块(Fixed-Size Chunking)
原理详解
这是最原始、最简单的分块方法。核心逻辑:
1. 设定一个固定的块大小(如 500 个字符或 200 个 token)
2. 从文档开头,每隔固定长度切一刀
3. 可选:设置重叠区域(overlap),让相邻块共享部分内容
重叠的作用:防止信息恰好落在切割边界上被"切散"。例如,如果有一个 30 个 token 的句子刚好跨在切割点,重叠可以让它完整地出现在两个块中。
代码实现
from langchain.text_splitter import CharacterTextSplitter
# 基本用法
text_splitter = CharacterTextSplitter(
chunk_size=500, # 每块 500 字符
chunk_overlap=50, # 重叠 50 字符
separator="\n", # 优先按换行切,否则按字符数切
)
chunks = text_splitter.split_text(long_document)
深入分析
优点:
- 实现极其简单:几行代码搞定
- 处理速度最快:不需要任何 NLP 分析
- 结果完全可预测:相同参数永远得到相同结果
- 成本最低:没有额外的 embedding 或 LLM 调用开销
缺点:
- 完全无视语义边界:一句话、一个段落、一个概念可能被硬生生切散
- 学术研究结论:“固定大小的分块在语义理解上效果最差”
- 不适合自然语言:人类写作是有结构的,固定切割破坏这种结构
参数选择指南:
| 文档类型 | 推荐 chunk_size | overlap | 原因 |
|---|---|---|---|
| 日志文件 | 200-500 字符 | 0 | 每行独立,不需要重叠 |
| 代码 | 1000-1500 字符 | 100-200 | 函数通常较长 |
| 客服对话 | 100-200 字符 | 20-30 | 单条消息较短 |
| 通用文本 | 400-600 字符 | 50-100 | 平衡点 |
适用场景:
- 日志文件、监控数据等结构规整的内容
- 快速原型验证(“先跑起来看看效果”)
- 对成本极度敏感的场景
- 文档本身没有明显结构时作为兜底方案
不适用场景:
- 需要理解长段落或跨句子关系的任务
- 叙事性内容(小说、故事)
- 学术论文、技术文档(有清晰结构)
方法二:递归字符分块(Recursive Character Splitting)
原理详解
这是 LangChain 等框架的默认分块方法,比固定大小分块"聪明"一些。
核心思想:尝试用自然边界(段落、句子、标点)来切,如果某个自然边界导致块太长,再在这个边界内部按更小的边界递归切割。
切割优先级(从高到低):
1. 双换行符(\n\n)—— 段落边界
2. 单换行符(\n)—— 行边界
3. 句号 + 空格(。 或 . )—— 句子边界
4. 逗号 + 空格(, 或 , )—— 子句边界
5. 空格 —— 单词边界
6. 字符 —— 最后的兜底
工作流程:
1. 尝试用最高优先级的分隔符切分
2. 检查切出来的每个片段:
- 如果片段长度 ≤ chunk_size → 接受
- 如果片段长度 > chunk_size → 用下一级分隔符递归切分这个片段
3. 重复直到所有片段都符合大小要求
代码实现
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500,
chunk_overlap=50,
separators=["\n\n", "\n", "。", ",", " ", ""], # 可自定义分隔符优先级
)
chunks = text_splitter.split_text(document)
深入分析
优点:
- 尊重文本结构:尽量保持段落、句子的完整性
- 比固定大小更"智能":不会在句子中间乱切
- 实现简单:LangChain 直接可用
- 泛化性好:适用于大部分文本文档
缺点:
- 依赖文档结构质量:如果文档本身没有清晰段落,效果退化
- 仍可能切散语义单元:一个长段落如果超过 chunk_size,还是会被递归切散
- 对列表、代码块处理不佳:枚举、表格可能被破坏
参数调优:
| 参数 | 典型值 | 说明 |
|---|---|---|
| chunk_size | 300-1000 字符 | 太小丢上下文,太大超 token 限制 |
| chunk_overlap | chunk_size 的 10-20% | 保证语义连续性 |
| separators | 根据文档语言调整 | 中英文标点不同 |
适用场景:
- 大多数 RAG 任务的默认起点
- 博客文章、新闻、报告等有自然结构的文档
- 当你不知道用什么方法时,先用这个试试
示例效果:
原文:
"今天是晴天。\n\n小明决定去公园。他带上了相机和背包。\n\n公园里有很多人。"
chunk_size=20 个字符时:
块1: "今天是晴天。"
块2: "小明决定去公园。他带上了相机和背包。"
块3: "公园里有很多人。"
(注意:保留了段落和句子边界)
方法三:语义分块(Semantic Chunking)
原理详解
这是近年来越来越流行的方法。核心洞察:切割应该基于"语义边界",而不是固定长度或标点符号。
核心思想:先对文本进行句子级别的 embedding(向量化),然后根据相邻句子向量的相似度来判断是否应该在它们之间切割。
相似度越高 → 语义越接近 → 越不应该切割
相似度越低 → 语义突变 → 越应该在这里切割
算法详解(Max-Min 语义分块算法)
这是最常用的语义分块算法,来自一篇学术论文:
初始化:第一个句子作为第一个块
对于后续每个句子:
1. 计算当前块中所有句子的 embedding
2. 找出块内句子之间的最小相似度(min_sim)
3. 计算新句子与块中每个句子的相似度,取最大值(max_sim)
4. 如果 max_sim ≥ min_sim:
新句子与当前块语义一致 → 加入当前块
否则:
新句子代表新的主题 → 结束当前块,新开一个块
直观理解:只要新句子与块内任何句子的相似度 ≥ 块内句子之间的最弱连接,就说明新句子"配得上"这个块。
代码实现(简化版)
import numpy as np
from sentence_transformers import SentenceTransformer
model = SentenceTransformer('all-MiniLM-L6-v2')
def semantic_chunking(sentences, similarity_threshold=0.7):
"""
sentences: 句子列表
similarity_threshold: 相似度阈值,低于此值则切分
"""
# 1. 计算所有句子的 embedding
embeddings = model.encode(sentences)
chunks = []
current_chunk = [sentences[0]]
for i in range(1, len(sentences)):
# 计算当前句与前一句的相似度
sim = np.dot(embeddings[i], embeddings[i-1]) /


1299

被折叠的 条评论
为什么被折叠?



