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%。为什么?因为递归分块没有把「安装步骤」和「故障排除」切到同一个块里。

分块参数调优:没有银弹

分块参数调优,说白了就是试。我总结了一套流程:

  1. 确定 chunk_size 范围:根据 embedding 模型的最大 token 数,取 50%-80%。比如模型支持 512 token,就试 256、384、512
  2. 确定 chunk_overlap 比例:固定 20%,先跑一轮
  3. 评估召回率:用一组测试问题,看每个问题能召回多少相关块
  4. 调整参数:召回率低就增大 chunk_size,噪声多就减小 chunk_size
  5. 重复 2-4 步:直到找到最优组合

我曾经在一个知识库项目上,花了整整两天调分块参数。最后发现,对于技术文档,chunk_size=384、overlap=96 效果最好;对于新闻文章,chunk_size=256、overlap=64 更优。没有万能参数,只有最适合你数据的参数。

调优建议:先做小规模实验。拿 100 篇文档、20 个测试问题,跑 10 组参数组合。哪个组合的 F1 分数高,就用哪个。别一上来就全量跑,浪费时间也浪费钱。

分块策略对比

策略 优点 缺点 适用场景
固定大小分块 简单、快速、可预测 破坏语义、边界生硬 数据量大、对精度要求不高的场景
语义分块 保留语义完整性 计算成本高、依赖模型质量 高质量检索、长文档处理
递归分块 兼顾结构与语义、灵活 参数调优复杂 结构化文档、通用场景

我个人建议,新手先从递归分块入手。它容错率高,大部分场景都能用。等你对数据有了感觉,再尝试语义分块做精细化调优。

避坑指南:我曾经把 chunk_overlap 设成 0,结果用户问「如何配置数据库连接」,我召回了「配置数据库」的块,但「连接字符串格式」的块因为刚好在边界上,没被召回。从那以后,我再也不敢把 overlap 设成 0 了。

最后说一句,分块不是一劳永逸的事。数据变了、模型换了、业务需求调整了,分块策略都得跟着变。保持迭代的心态,才是做好 RAG 的关键。

文本分块(Chunking)知识体系 文本分块 固定大小分块 按字符/token数硬切 语义分块 基于embedding相似度 递归分块 按结构逐级切分 chunk_size 调优 chunk_overlap 设置 句子嵌入相似度 主题分割算法 separators 优先级 递归兜底策略 核心原则:语义完整 + 边界清晰 + 长度可控 没有万能参数,只有最适合你数据的参数
专注资料整理