4、文本分块(Chunking):分块的重要性、固定大小分块、语义分块、递归分块、分块参数调优
文本分块,说白了就是把长文档切成小段。这一步看着简单,但我在项目里踩过的坑,十有八九都跟它有关。你想想看,RAG 系统的核心是「检索」+「生成」。检索这一步,如果块切得不好,神仙模型也救不回来。
核心观点:分块质量直接决定了检索召回率。块太大,噪声多;块太小,语义碎。找到那个「黄金分割点」,才是真功夫。
为什么分块这么重要?
我刚开始做 RAG 时,觉得随便切切就行。结果呢?用户问「苹果公司的创始人是谁」,我切出来的块是「...乔布斯在车库创立了苹果...」,但另一块是「...库克接任CEO后...」。两块都包含「苹果」,但语义完全不一样。检索模型把两块都召回了,LLM 一看,好家伙,到底谁才是创始人?
这就是分块没做好的典型后果。分块要解决三个问题:
- 语义完整性:一个块应该表达一个完整的意思
- 边界清晰性:块与块之间不要有语义重叠
- 长度可控性:块的长度要适配 embedding 模型的窗口
嗯,这里要注意,embedding 模型通常有最大 token 限制。比如 text-embedding-ada-002 是 8192 token。你切个 10000 token 的块进去,直接截断,信息就丢了。
固定大小分块:最朴素但最稳
固定大小分块,就是按字符数或 token 数硬切。比如每 512 个 token 切一块,重叠 128 个 token。
from langchain.text_splitter import CharacterTextSplitter
text_splitter = CharacterTextSplitter(
separator="\n\n",
chunk_size=512,
chunk_overlap=128,
length_function=len,
)
chunks = text_splitter.split_text(document)
我个人习惯用 token 数而不是字符数。为什么?因为 LLM 和 embedding 模型都是按 token 计费的,按 token 切更精准。字符数切出来的块,中文可能 200 字就 500 token 了,英文可能 800 字才 500 token。差别很大。
我的经验:chunk_overlap 设成 chunk_size 的 20%-30% 比较稳妥。重叠太少,边界信息容易丢;重叠太多,检索时重复内容太多,浪费 token。
固定分块的优点是简单、快、可预测。缺点是它不懂语义。我曾经处理一份法律合同,条款之间用空行分隔,固定分块把「违约责任」和「免责条款」切到了同一个块里。检索时用户问「什么情况可以免责」,结果召回了「违约责任」的内容。你说冤不冤?
语义分块:让模型帮你切
语义分块,就是让 NLP 模型来判断哪里该切。常用的方法有:
- 基于句子嵌入的相似度:计算相邻句子的 embedding 相似度,相似度突降的地方就是分块点
- 基于主题分割:用 TextTiling 或 C99 算法检测主题变化
- 基于 LLM 的分块:让 LLM 直接输出分块位置
我推荐用第一种,性价比最高。具体做法:
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer('all-MiniLM-L6-v2')
sentences = split_into_sentences(document)
embeddings = model.encode(sentences)
# 计算相邻句子的余弦相似度
similarities = []
for i in range(len(embeddings) - 1):
sim = np.dot(embeddings[i], embeddings[i+1]) / (
np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[i+1])
)
similarities.append(sim)
# 找到相似度低于阈值的位置
threshold = 0.3
split_points = [i for i, s in enumerate(similarities) if s < threshold]
你看,当两个句子的语义相似度突然降低时,大概率是话题切换了。我在处理技术文档时,这个方法效果特别好。比如「...以上是数据库配置方法。接下来我们讨论缓存策略...」,这两句的相似度肯定低,正好切开。
注意:语义分块的计算成本比固定分块高很多。如果你有百万级文档,建议先用固定分块做基线,再对检索效果差的文档做语义分块。别一上来就全量跑语义分块,服务器会哭的。
递归分块:兼顾结构与语义
递归分块是我现在最常用的方法。它的思路是:先按大结构切(比如章节),再按小结构切(比如段落),最后按固定大小兜底。
LangChain 的 RecursiveCharacterTextSplitter 就是典型实现:
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
separators=["\n\n", "\n", "。", "!", "?", " ", ""],
chunk_size=500,
chunk_overlap=100,
)
chunks = text_splitter.split_text(document)
这个 splitter 会先尝试用「\n\n」切,如果切出来的块太大,就换「\n」;还大,就换「。」;以此类推。直到所有块都小于 chunk_size。
我为什么喜欢它?因为它尊重文档的原始结构。比如 Markdown 文档,一级标题、二级标题、段落,天然就是分块的好边界。递归分块能最大程度保留这些边界。
举个例子,我处理过一份 50 页的产品手册,用固定分块切出来 120 块,用递归分块只切出 80 块。但检索准确率反而从 72% 提升到了 89%。为什么?因为递归分块没有把「安装步骤」和「故障排除」切到同一个块里。
分块参数调优:没有银弹
分块参数调优,说白了就是试。我总结了一套流程:
- 确定 chunk_size 范围:根据 embedding 模型的最大 token 数,取 50%-80%。比如模型支持 512 token,就试 256、384、512
- 确定 chunk_overlap 比例:固定 20%,先跑一轮
- 评估召回率:用一组测试问题,看每个问题能召回多少相关块
- 调整参数:召回率低就增大 chunk_size,噪声多就减小 chunk_size
- 重复 2-4 步:直到找到最优组合
我曾经在一个知识库项目上,花了整整两天调分块参数。最后发现,对于技术文档,chunk_size=384、overlap=96 效果最好;对于新闻文章,chunk_size=256、overlap=64 更优。没有万能参数,只有最适合你数据的参数。
调优建议:先做小规模实验。拿 100 篇文档、20 个测试问题,跑 10 组参数组合。哪个组合的 F1 分数高,就用哪个。别一上来就全量跑,浪费时间也浪费钱。
分块策略对比
| 策略 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 固定大小分块 | 简单、快速、可预测 | 破坏语义、边界生硬 | 数据量大、对精度要求不高的场景 |
| 语义分块 | 保留语义完整性 | 计算成本高、依赖模型质量 | 高质量检索、长文档处理 |
| 递归分块 | 兼顾结构与语义、灵活 | 参数调优复杂 | 结构化文档、通用场景 |
我个人建议,新手先从递归分块入手。它容错率高,大部分场景都能用。等你对数据有了感觉,再尝试语义分块做精细化调优。
避坑指南:我曾经把 chunk_overlap 设成 0,结果用户问「如何配置数据库连接」,我召回了「配置数据库」的块,但「连接字符串格式」的块因为刚好在边界上,没被召回。从那以后,我再也不敢把 overlap 设成 0 了。
最后说一句,分块不是一劳永逸的事。数据变了、模型换了、业务需求调整了,分块策略都得跟着变。保持迭代的心态,才是做好 RAG 的关键。