RAG 切块(Chunking)策略:影响检索质量的核心决策
RAG 切块(Chunking) 是把原始文档切成若干片段再向量化存储的过程。切块策略直接决定检索质量:切太短,单个 chunk 缺乏上下文;切太长,噪声淹没关键信息,embedding 能力下降。正确的策略因文档类型和查询模式而异。
如果你调过 RAG 就知道,切块这一步出问题时最隐蔽——生成阶段没报错、检索也返回了结果,但答案就是驴唇不对马嘴。排查半天最后发现,是检索到的 chunk 里关键句被从中间切断了,模型拿到的只有半句话。这类问题不会抛异常,只会让效果莫名其妙变差,所以切块参数值得你花时间较真,而不是随手抄一个 512 就完事。
四种主流切块策略对比
| 策略 | 原理 | 适用场景 | 主要缺点 |
|---|---|---|---|
| 固定长度切块 | 按 token/字符数硬切 | 快速原型 | 句子被截断,语义不完整 |
| 递归字符切块 | 按段落→句子→词逐级降级 | 通用文档 | 需要调参 |
| 语义切块 | 用 embedding 检测相似度骤降点 | 长篇叙述型文档 | 计算成本高 |
| 父子切块 | 大块存储+小块检索 | 需要上下文丰富时 | 实现复杂度高 |
策略一:递归字符切块(推荐默认方案)
LangChain 的 RecursiveCharacterTextSplitter 是工程上最常用的起点:
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 目标 token 数(按字符近似)
chunk_overlap=64, # 相邻 chunk 重叠,防止截断关键信息
separators=["\n\n", "\n", "。", "!", "?", " ", ""],
# 中文文档要把句号等加入分隔符列表
)
chunks = splitter.split_text(document_text)
这段代码看着简单,但有几个坑是新手必踩的。第一个坑是 chunk_size 的单位:RecursiveCharacterTextSplitter 默认按字符数切,不是 token 数。中文场景下一个汉字基本等于一个 token(用 tiktoken 编码器实测,常见中文字符大多是 1-2 个 token),英文单词却要拆成多个字符才对应一个 token。这意味着同样设 chunk_size=512,中文文档实际装的内容密度和英文文档完全不是一回事——如果你的 embedding 模型按 token 计费或有 token 上限,光看字符数会让你低估实际 token 消耗,严重时会撞到 embedding API 的输入长度限制而报错。稳妥做法是用目标 embedding 模型配套的 tokenizer 先估算一遍,再反推 chunk_size 该设多大。
第二个坑是分隔符的顺序。separators 列表是有优先级的:splitter 会先尝试用列表第一个分隔符切,如果切出来的块还是超长,才降级用下一个分隔符继续切,直到块小于等于 chunk_size,或者退化到按空字符串逐字硬切。所以列表顺序必须从”粒度粗”到”粒度细”排列——先按段落(\n\n)切,切不动再按换行(\n)切,再不行按句号切,最后兜底按字符切。如果顺序反了(比如把句号放在换行前面),会导致本该按段落保留的整体语义提前被句号打散,父子结构就乱了。中文文档必须显式把中文全角标点(。!?)加进去,因为默认的 separators 列表是给英文文档设计的,只认识英文句号和逗号,直接拿来切中文长文档基本不会在句子边界断开,效果和不切没差别。
第三个坑容易被忽略:chunk_overlap 不是越大越好。重叠区域的内容会在向量库里被存两份、检索两次、注入 prompt 两次,overlap 设到 chunk_size 的一半以上,等于拿双倍存储成本换取一点点连贯性提升,通常并不划算(下文”重叠窗口设计”一节给了具体建议值)。
chunk_size 选取经验值:
| embedding 模型 | 推荐 chunk_size | 说明 |
|---|---|---|
| text-embedding-3-small | 256-512 tokens | 短 chunk 精度更高 |
| bge-large-zh | 256-512 tokens | 同上 |
| text-embedding-ada-002 | 512-1024 tokens | 较宽容 |
这张表给的是起点,不是终点。真正靠谱的做法是自己拿业务文档跑一轮对比实验:同一批文档分别用 256/512/1024 三档切块入库,再拿 20-50 条真实用户问题去检索,人工核对 top-3 结果里有没有命中正确片段,算一个粗糙的 precision@3。这个实验成本不高,半天能跑完,但比直接抄经验值靠谱得多——因为不同业务的文档长度分布、句子密度、术语密集程度都不一样,别人的最优值未必是你的最优值。
策略二:语义切块
通过计算相邻句子 embedding 的余弦相似度,在”话题跳跃”处切块:
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("BAAI/bge-small-zh-v1.5")
def semantic_chunk(text: str, threshold: float = 0.3) -> list[str]:
sentences = text.split("。")
embeddings = model.encode(sentences)
chunks, current = [], [sentences[0]]
for i in range(1, len(sentences)):
# 计算相邻句子相似度
sim = np.dot(embeddings[i-1], embeddings[i]) / (
np.linalg.norm(embeddings[i-1]) * np.linalg.norm(embeddings[i])
)
if sim < threshold: # 相似度低说明话题切换,在此处切块
chunks.append("。".join(current))
current = [sentences[i]]
else:
current.append(sentences[i])
if current:
chunks.append("。".join(current))
return chunks
适合叙述型长文、法律条款、技术文档等话题分明的内容。
这段代码能跑通,但直接搬进生产环境会踩两个具体的坑。
第一个坑是 text.split("。") 这种朴素分句方式,对中文文本并不安全。遇到小数(“准确率提升了 0.3。“里的小数点不会误伤,但”版本 3.14。“这种带小数点的编号)、省略号、引号里嵌套的句号(“他说:‘我们完成了。‘然后离开了。”),简单的 split 会把一句话拆成语义不完整的碎片,送进 embedding 模型编码后向量本身就是”半句话”的向量,后续相似度判断自然失真。工程上更稳妥的方式是用正则先按标点边界分句、同时保护引号和数字内的标点不被误切,或者直接用现成的中文分句库(如 zh_core_web_sm 配合 spaCy 的句子边界检测,或者更轻量的按标点做前瞻断言的正则)。
第二个坑是成本。语义切块要对文档里每一个句子都跑一次 embedding 编码,再两两计算余弦相似度,这是 O(n) 次 embedding 调用(n 是句子数)外加 O(n) 次向量运算。一篇 5000 字的中文文档大概拆出 150-200 个句子,相当于每篇文档要多打 150-200 次 embedding API(如果你用的是本地小模型如示例里的 bge-small-zh-v1.5 还好,纯本地推理不计 API 费用;但如果你换成调用云端 embedding API 做语义切块,成本会比递归切块高一到两个数量级,因为递归切块是纯字符串操作,不产生任何 API 调用)。所以语义切块通常只用在”一次性建库、后续增量小”的场景,不建议对高频更新、大批量的文档流水线使用——那种场景老老实实用递归切块加合理的 overlap,性价比高得多。
策略三:父子切块(Parent-Child Chunking)
检索时用小 chunk(精准匹配),注入 prompt 时用大 chunk(丰富上下文):
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain.vectorstores import Chroma
# 父 splitter:大块,用于提供上下文
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)
# 子 splitter:小块,用于向量检索
child_splitter = RecursiveCharacterTextSplitter(chunk_size=256)
vectorstore = Chroma(embedding_function=embeddings)
store = InMemoryStore() # 存储父块
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=store,
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
retriever.add_documents(documents)
# 检索时:匹配小块,返回对应父块
results = retriever.get_relevant_documents("查询问题")
这个策略在问答场景中显著提升召回质量,但存储和检索开销增加约 2-3x。原理其实很朴素:检索这件事本质上是”精准匹配”和”上下文丰富”的对抗——chunk 越小,向量表示的语义越聚焦,检索命中率越高,但小 chunk 塞进 prompt 后模型能看到的上下文就越有限,容易”只见树木不见森林”;chunk 越大则反过来。父子切块相当于把这两个诉求拆开处理:拿子块(小)去做向量检索保证命中精度,命中后不直接把子块扔给模型,而是回溯到它所属的父块(大)再注入 prompt,两头都不用妥协。
代码里 InMemoryStore 只适合本地实验,生产环境父块存储量可能是原文档的数倍(因为切块本身有重叠),进程一重启数据就没了,必须换成持久化的 docstore(LangChain 提供了基于 Redis、文件系统的实现)。另外要注意,父子切块的检索开销之所以是 2-3x,不只是因为多存了一份数据,还因为查询链路变长了:先在向量库里做一次 ANN 检索拿到子块 ID,再回 docstore 查一次父块内容,等于一次检索拆成了两次 I/O。如果你的向量库和文档存储不在同一个网络区域,这两次往返的延迟叠加起来在高并发场景下会比较明显,建议把 docstore 放在离向量库尽量近的位置,或者加一层本地缓存命中率高的父块。
重叠窗口设计
chunk_overlap 是防止关键信息被截断的关键参数:
文档内容:[------- chunk 1 ------][------- chunk 2 ------]
重叠区域: [== overlap ==]
经验规则:
- overlap = chunk_size 的 10-20%
- 对代码文档可适当增大(函数头和实现可能跨 chunk)
- 对表格/列表型内容,优先按条目切而不是按长度切
这里有个实操细节容易被忽视:overlap 不是从 chunk 尾部”复制粘贴”这么简单,它要求上一个 chunk 的结尾和下一个 chunk 的开头共享同一段原文。如果你自己手写切块逻辑(不用现成的 splitter),一个常见的 bug 是重叠区域算错了偏移量,导致相邻 chunk 之间要么有间隙(中间几个字丢了)、要么重叠部分对不齐(两个 chunk 里的重叠文本其实是错位的)。调试这类问题最直接的办法是把切出来的 chunk 首尾各打印 30 个字符,肉眼核对相邻两块是否首尾相接、重叠区域文字是否完全一致。别偷懒不做这步验证——这种偏移量错误不会报错,只会让检索结果里偶尔缺一句关键话,排查成本比当场核对高得多。
特殊文档类型的处理
# Markdown 文档:按标题层级切
from langchain.text_splitter import MarkdownHeaderTextSplitter
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=[
("#", "h1"), ("##", "h2"), ("###", "h3")
])
md_chunks = md_splitter.split_text(markdown_content)
# 代码文件:按函数/类切
from langchain.text_splitter import Language, RecursiveCharacterTextSplitter
code_splitter = RecursiveCharacterTextSplitter.from_language(
language=Language.PYTHON,
chunk_size=1000,
chunk_overlap=100,
)
批量入库时的并发与重试
文档切块之后要挨个调用 embedding API 转成向量,文档一多(比如一次性导入几千篇历史文章),串行调用会很慢,但无脑并发又容易把 API 打炸。真实场景里你大概率会遇到这几种报错:
- 429 Too Many Requests:QPS 超过了 embedding 服务的限额,尤其是云端 API 免费额度或低等级套餐更容易触发。
- 超时(timeout):单次请求打包的 chunk 数量太多、内容太长,或者网络本身不稳定。
- 400 Bad Request,提示输入长度超限:某个 chunk 切完之后仍然超过了 embedding 模型的最大输入长度(不同模型上限不同,务必提前查清楚你调用的模型规格)。
工程上的标准做法是限制并发数 + 指数退避重试,而不是遇到失败就整批放弃:
import asyncio
import random
async def embed_with_retry(client, chunk: str, max_retries: int = 5):
for attempt in range(max_retries):
try:
return await client.embed(chunk)
except RateLimitError:
# 指数退避 + 随机抖动,避免所有失败请求同一时刻重试造成新一轮拥堵
wait = (2 ** attempt) + random.uniform(0, 1)
await asyncio.sleep(wait)
except TimeoutError:
continue # 超时直接重试,不额外等待
raise RuntimeError(f"embedding 失败,已重试 {max_retries} 次")
async def embed_batch(client, chunks: list[str], concurrency: int = 8):
semaphore = asyncio.Semaphore(concurrency)
async def _worker(chunk):
async with semaphore:
return await embed_with_retry(client, chunk)
return await asyncio.gather(*[_worker(c) for c in chunks])
concurrency=8 这个数字不是拍脑袋定的,是从”服务商 QPS 限额 ÷ 预留安全余量”倒推出来的——如果你的 embedding 服务限额是每秒 10 次请求,并发设到 8 留出 20% 余量,能避免因为网络抖动导致的瞬时超发。批量入库几千篇文档时,先用小批量(比如 50 条)跑一遍确认没有报错、速率也稳定,再放开跑全量,别一上来就是几千并发糊上去,那样一旦触发限流你很难分清是哪批数据出的问题。
常见问题
chunk size 设多少最好? 没有通用答案,需要根据实际任务评估。建议用 RAGAS 或自建评估集,对 256/512/1024 三档做检索精度(precision@k)测试,选最优值。通常 512 是一个不错的起点。
切块之后 metadata 怎么保留?
每个 chunk 存储时附带 source(文件名/URL)、page(页码)、section(章节标题)等 metadata,检索到 chunk 后可以回溯来源,也方便按 metadata 过滤(如只检索某个文档)。
文档更新了,chunk 怎么同步? 以文档 ID 为粒度做增量更新:先删除该文档对应的所有 chunk,再重新切块入库。避免全量重建,减少向量库写入压力。
延伸阅读:大模型应用开发模式 · 应用模式 Hub · 结构化输出与 schema 校验 · RAG 完整指南