RAG 切分与 Retriever 基准:chunk_size、overlap 与 MMR 实测
中文 RAG 应用调切分参数,常见做法是照抄英文教程里的 chunk_size=1000, chunk_overlap=200,然后凭感觉改。问题是:默认的分隔符不认识中文标点,参数的效果又取决于你的文本,不量一量就不知道改的是什么。
本页写给正在给中文文档做切分和检索的开发者:用一份可复现的合成语料,把三件事量出来,分别是分隔符怎么影响答案句是否被切断,chunk_size 怎么影响召回和送进提示词的上下文长度,overlap 和 MMR 各自什么时候有用、什么时候互相抵消。
适用版本与验证信息
| 项目 | 内容 |
|---|---|
| 验证日期 | 2026-10-26 |
| Python | 3.12.12 |
| 包版本 | langchain==1.4.7、langchain-core==1.7.0、langchain-text-splitters==1.1.3、numpy==2.5.4(环境 v1c) |
| 嵌入模型 | 本地确定性的“哈希字符 n-gram 向量”,不是真实的语义嵌入模型,不联网、不需要 API Key;实现见 RAG 检索评测与回归门禁 使用的同一个模块 |
| 语料 | 虚构的云存储产品手册:4 篇文档共 2455 个字符、36 个段落,另有 20 道题,每道题标注了一个必须出现的“答案句”;语料见站点仓库 labs/rag-chunking-benchmark/corpus.py |
| 官方来源 | Splitting recursively (opens in a new tab)、构建语义搜索引擎 (opens in a new tab),对照官方文档仓库 langchain-ai/docs (opens in a new tab) 提交 b8d4ced(2026-10-16) |
| 实验代码 | 站点仓库 labs/rag-chunking-benchmark/,依赖锁定在 labs/requirements/v1c.txt |
怎么理解这些数字: 嵌入是词面级别的,语料很小,答案句最长 42 个字符、多数在 30 到 38 个字符之间。所以本页的数值只能用来比较配置之间的相对变化,不能当作任何真实模型或真实文档的绝对水平。真实文档的最优参数可能和这里不同;可复用的是度量方法。
四个指标的定义
| 指标 | 含义 | 为什么看它 |
|---|---|---|
broken | 没有任何一个 chunk 完整包含答案句的问题占比,是切分阶段的指标,与检索无关 | 答案句被切断,后面检索再好也拿不到完整答案 |
recall@k | 检索出的前 k 个 chunk 中,至少有一个完整包含答案句的问题占比 | 衡量“完整答案有没有送到提示词里” |
context_chars@k | 每个问题的前 k 个 chunk 的平均总字符数 | 提示词的上下文成本的代理指标 |
overlapping_pairs@k | 前 k 个 chunk 中相互重叠的 chunk 对数的平均值 | 衡量检索结果里的重复内容 |
先说结论
- 中文必须自己设分隔符。 默认列表
["\n\n", "\n", " ", ""]里没有中文标点,切不开句子时只能按字符硬切:chunk_size=60时,有 50% 的答案句被切断。官方文档也说明,默认分隔符在中文这类没有词边界的文字里会把词切开,建议把中文标点加进分隔符(实验 1)。 - 加了中文分隔符还要设
keep_separator="end"。 默认设置会把标点留在下一个 chunk 的开头,65 个 chunk 里有 29 个以“。”开头,答案句仍有 30% 被切断;设成"end"之后两项都变成 0(实验 1)。 chunk_size过了答案句长度之后,继续加大只增加成本。 30 个字符时 65% 的答案句被切断,召回只有 35%;从 100 到 800,recall@3都是 100%,但每个问题送进提示词的上下文从 208 个字符涨到 1855 个,recall@1反而从 100% 降到 85% 到 95%(实验 2)。overlap是兜底,不是调参的第一步。 分隔符不合适时,overlap=40把被切断的比例从 50% 降到 15%,recall@3从 50% 升到 85%,代价是每个问题的前 3 个结果里平均有 0.8 对重叠 chunk;分隔符合适时,overlap几乎没有收益,只增加重复(实验 3)。- MMR 会抵消
overlap的补救作用。 MMR 把重叠对降到 0,但默认分隔符加overlap=40时recall@3从 85% 掉到 50%,因为补全答案的恰恰是那个相邻的重叠 chunk(实验 3)。 - 建议的调参顺序: 先看
broken,修分隔符;再选chunk_size;最后才考虑overlap和 MMR,并且每一步之后重新测。
公共函数
三个实验共用同一组函数:切分、建索引、计算上面四个指标。检索使用 InMemoryVectorStore,k 是实验里指定的值。
"""Chunking benchmark helpers: build an index and measure what the retrieved context contains."""
import sys
from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "rag-evaluation-regression"))
from corpus import DOCS, QUESTIONS
from hashing_embeddings import HashingEmbeddings
from langchain_core.vectorstores import InMemoryVectorStore
from langchain_text_splitters import RecursiveCharacterTextSplitter
ZH_SEPARATORS = ["\n\n", "\n", "。", ";", ",", ""]
@dataclass(frozen=True)
class Config:
chunk_size: int
chunk_overlap: int = 0
separators: tuple | None = None # None keeps the library default
keep_separator: object = "end"
def split(cfg: Config):
"""Split every document and return (doc_id, start, text) triples."""
kwargs = {"keep_separator": cfg.keep_separator}
if cfg.separators is not None:
kwargs["separators"] = list(cfg.separators)
splitter = RecursiveCharacterTextSplitter(
chunk_size=cfg.chunk_size, chunk_overlap=cfg.chunk_overlap, add_start_index=True, **kwargs
)
chunks = []
for doc_id, text in DOCS.items():
for doc in splitter.create_documents([text], metadatas=[{"doc_id": doc_id}]):
chunks.append((doc_id, doc.metadata["start_index"], doc.page_content))
return chunks
def split_rate(chunks) -> float:
"""Share of gold sentences that no single chunk contains in full."""
broken = 0
for _, doc_id, sentence in QUESTIONS:
if not any(c_doc == doc_id and sentence in text for c_doc, _, text in chunks):
broken += 1
return broken / len(QUESTIONS)
def build_store(chunks, dim: int = 512):
store = InMemoryVectorStore(HashingEmbeddings(dim))
store.add_texts(
[text for _, _, text in chunks],
metadatas=[{"doc_id": d, "start": s, "length": len(t)} for d, s, t in chunks],
)
return store
def overlaps(a, b) -> bool:
return a["doc_id"] == b["doc_id"] and a["start"] < b["start"] + b["length"] and b["start"] < a["start"] + a["length"]
def measure(chunks, k: int, mmr: bool = False) -> dict:
"""Retrieve top-k chunks per question and summarize the retrieved context."""
store = build_store(chunks)
recalled = 0
context_chars = 0
overlapping_pairs = 0
for question, doc_id, sentence in QUESTIONS:
if mmr:
hits = store.max_marginal_relevance_search(question, k=k, fetch_k=k * 4, lambda_mult=0.3)
else:
hits = store.similarity_search(question, k=k)
context_chars += sum(len(h.page_content) for h in hits)
if any(h.metadata["doc_id"] == doc_id and sentence in h.page_content for h in hits):
recalled += 1
metas = [h.metadata for h in hits]
overlapping_pairs += sum(overlaps(metas[i], metas[j]) for i in range(len(metas)) for j in range(i + 1, len(metas)))
n = len(QUESTIONS)
return {"recall": recalled / n, "context_chars": context_chars / n, "overlap_pairs": overlapping_pairs / n}
def average_length(chunks) -> float:
return sum(len(t) for _, _, t in chunks) / len(chunks)实验 1:分隔符决定句子会不会被切断
先用一个段落、chunk_size=40 直观看三种分隔符设置的切法,再在整个语料上、chunk_size=60 统计被切断的答案句和以标点开头的 chunk:
"""Where does the recursive splitter cut Chinese text? Three separator setups, same chunk size."""
from benchmark import ZH_SEPARATORS, Config, split, split_rate
from corpus import DOCS
from langchain_text_splitters import RecursiveCharacterTextSplitter
SAMPLE = DOCS["admin"].split("\n\n")[1]
PUNCTUATION = "。,;、"
SETUPS = [
("default separators", Config(chunk_size=40)),
("Chinese separators, default keep_separator", Config(chunk_size=40, separators=tuple(ZH_SEPARATORS), keep_separator=True)),
("Chinese separators, keep_separator=end", Config(chunk_size=40, separators=tuple(ZH_SEPARATORS), keep_separator="end")),
]
print("one paragraph, chunk_size=40")
for label, cfg in SETUPS:
kwargs = {"keep_separator": cfg.keep_separator}
if cfg.separators is not None:
kwargs["separators"] = list(cfg.separators)
pieces = RecursiveCharacterTextSplitter(chunk_size=40, chunk_overlap=0, **kwargs).split_text(SAMPLE)
print(f"[{label}]")
for piece in pieces:
print(f" {piece!r}")
print()
print("whole corpus, chunk_size=60")
for label, cfg in SETUPS:
cfg = Config(chunk_size=60, separators=cfg.separators, keep_separator=cfg.keep_separator)
chunks = split(cfg)
starts_with_punctuation = sum(1 for _, _, text in chunks if text[0] in PUNCTUATION)
print(
f"{label:<44} chunks={len(chunks):<3} gold_sentences_broken={split_rate(chunks):.0%} "
f"chunks_starting_with_punctuation={starts_with_punctuation}"
)one paragraph, chunk_size=40
[default separators]
'邀请成员时,管理员输入对方的邮箱地址,系统会发送有效期为七天的邀请链接。邀请链接'
'过期后需要重新发送。同一个邮箱同时只能有一个有效邀请。'
[Chinese separators, default keep_separator]
'邀请成员时,管理员输入对方的邮箱地址,系统会发送有效期为七天的邀请链接'
'。邀请链接过期后需要重新发送。同一个邮箱同时只能有一个有效邀请。'
[Chinese separators, keep_separator=end]
'邀请成员时,管理员输入对方的邮箱地址,系统会发送有效期为七天的邀请链接。'
'邀请链接过期后需要重新发送。同一个邮箱同时只能有一个有效邀请。'
whole corpus, chunk_size=60
default separators chunks=63 gold_sentences_broken=50% chunks_starting_with_punctuation=2
Chinese separators, default keep_separator chunks=65 gold_sentences_broken=30% chunks_starting_with_punctuation=29
Chinese separators, keep_separator=end chunks=64 gold_sentences_broken=0% chunks_starting_with_punctuation=0- 默认分隔符:第一个 chunk 以“邀请链接”结尾,第二个以“过期后需要重新发送”开头,一个完整的句子“邀请链接过期后需要重新发送”被切成了两半。整个语料上,50% 的答案句被切断;
- 中文分隔符,默认
keep_separator:句子边界对了,但标点“。”被放到了下一个 chunk 的开头,29 个 chunk 以标点开头,答案句仍有 30% 被切断; - 中文分隔符加
keep_separator="end":每个 chunk 都以完整的句子结尾,没有以标点开头的 chunk,答案句全部完整。
实验 2:chunk_size 扫描
固定使用实验 1 里表现最好的分隔符设置,不设 overlap,把 chunk_size 从 30 扫到 800,同时看 k=1 和 k=3:
"""How does chunk_size change what reaches the prompt? Chinese separators, no overlap, k=1 and k=3."""
from benchmark import ZH_SEPARATORS, Config, average_length, measure, split, split_rate
SIZES = [30, 60, 100, 200, 400, 800]
print("chunk_size chunks avg_len broken recall@1 recall@3 context_chars@3")
for size in SIZES:
chunks = split(Config(chunk_size=size, separators=tuple(ZH_SEPARATORS)))
at1 = measure(chunks, k=1)
at3 = measure(chunks, k=3)
print(
f"{size:<11} {len(chunks):<7} {average_length(chunks):<8.0f} {split_rate(chunks):<7.0%} "
f"{at1['recall']:<9.0%} {at3['recall']:<9.0%} {at3['context_chars']:.0f}"
)chunk_size chunks avg_len broken recall@1 recall@3 context_chars@3
30 118 20 65% 35% 35% 64
60 64 37 0% 90% 95% 115
100 36 66 0% 100% 100% 208
200 17 143 0% 90% 100% 457
400 8 306 0% 85% 100% 938
800 4 614 0% 95% 100% 1855- 30:小于大多数答案句的长度,65% 被切断,召回只有 35%,chunk 数量是 60 的将近两倍;
- 60:超过了最长的答案句(42 个字符),被切断降到 0,
recall@1为 90%,recall@3为 95%,每个问题送进提示词的上下文约 115 个字符; - 100:这份语料上最好的点,
recall@1和recall@3都是 100%,上下文 208 个字符; - 200 到 800:
recall@3保持 100%,上下文从 457 涨到 1855 个字符,recall@1降到 85% 至 95%。chunk 变大后包含的无关句子变多,向量更“稀释”,排第一的未必是正确的那个; - 800:这份语料里一篇文档才 600 多个字符,所以 800 等于把整篇文档当作一个 chunk。
注意方向:在这个实验里,chunk_size 的最佳点由答案句的长度决定。实际文档里,把 chunk_size 设成略大于你关心的最长关键句,通常比盲目取 1000 更合理;官方文档说明,这个参数默认按字符数计量,如果你的上限是模型的 token,需要换成按 token 计量的切分器,本页没有验证。
实验 3:overlap 与 MMR
分别在“默认分隔符”和“中文分隔符”两种设置下,固定 chunk_size=60,扫 overlap,同时对比普通相似度检索和 MMR(lambda_mult=0.3):
"""Does overlap help, and what does it cost? chunk_size=60, k=3, similarity search versus MMR."""
from benchmark import ZH_SEPARATORS, Config, split, split_rate, measure
SETUPS = [
("default separators", None, True),
("Chinese separators", tuple(ZH_SEPARATORS), "end"),
]
print("separators overlap search chunks broken recall@3 overlapping_pairs@3 context_chars@3")
for label, separators, keep in SETUPS:
for overlap in (0, 20, 40):
chunks = split(Config(chunk_size=60, chunk_overlap=overlap, separators=separators, keep_separator=keep))
for mmr in (False, True):
result = measure(chunks, k=3, mmr=mmr)
print(
f"{label:<19} {overlap:<8} {'mmr' if mmr else 'similarity':<11} {len(chunks):<7} "
f"{split_rate(chunks):<7.0%} {result['recall']:<9.0%} {result['overlap_pairs']:<20.2f} "
f"{result['context_chars']:.0f}"
)separators overlap search chunks broken recall@3 overlapping_pairs@3 context_chars@3
default separators 0 similarity 64 50% 50% 0.00 133
default separators 0 mmr 64 50% 45% 0.00 120
default separators 20 similarity 64 45% 55% 0.50 142
default separators 20 mmr 64 45% 50% 0.00 139
default separators 40 similarity 67 15% 85% 0.80 156
default separators 40 mmr 67 15% 50% 0.00 159
Chinese separators 0 similarity 64 0% 95% 0.00 115
Chinese separators 0 mmr 64 0% 90% 0.00 110
Chinese separators 20 similarity 64 0% 95% 0.10 117
Chinese separators 20 mmr 64 0% 90% 0.00 112
Chinese separators 40 similarity 65 0% 95% 0.20 123
Chinese separators 40 mmr 65 0% 90% 0.00 115默认分隔符(句子会被硬切):
overlap从 0 到 40,被切断比例从 50% 降到 15%,recall@3从 50% 升到 85%,每个问题的上下文从 133 增加到 156 个字符,前 3 个结果里的重叠 chunk 对从 0 增加到 0.8;- 这时换成 MMR,重叠对确实变成 0,但
recall@3在overlap=40时从 85% 掉到 50%。这与 MMR 的设计是一致的:它会压低与已选结果相似的 chunk,而补全答案的相邻 chunk 恰好与已选结果重叠;本页没有进一步拆解每个问题的选择过程。
中文分隔符(句子完整):
- 被切断比例从一开始就是 0,
overlap带来的只有冗余:重叠对从 0 增加到 0.2,上下文从 115 增加到 123 个字符,recall@3一直是 95%; - 此时用 MMR 没有好处,
recall@3从 95% 降到 90%。
这个对比的含义是:overlap 和中文分隔符都在解决“句子被切断”这同一个问题,分隔符能解决时,overlap 是纯成本;overlap 只有在你无法改切分方式时才值得用。
按场景选配置:决策表
| 场景 | 建议的起点 | 依据 |
|---|---|---|
| 中文文档,句子比较短 | 中文标点分隔符加 keep_separator="end",chunk_size 略大于最长的关键句 | 实验 1、2 |
| 切分流程无法修改分隔符 | 用 overlap 兜底,取值接近关键句的长度,并接受重复内容变多 | 实验 3(默认分隔符行) |
| 前几个结果里经常有重复内容 | 先降低 overlap;仍然重复再试 MMR,并重新测召回 | 实验 3 |
| 上下文太长、成本高 | 减小 chunk_size 或 k,先确认 broken 仍然是 0 | 实验 2 |
| 排第一的结果经常不对,但前三里有 | 减小 chunk_size,让向量更聚焦 | 实验 2(recall@1 的变化) |
排障对照表
| 现象 | 最可能的原因 | 怎么验证 | 处理 |
|---|---|---|---|
| 答案明明在文档里,检索结果里却只有半句话 | 默认分隔符在中文里按字符硬切 | 统计 broken,直接打印 chunk 的边界 | 加中文标点分隔符并设 keep_separator="end"(实验 1) |
| 很多 chunk 以“。”或“,”开头 | 设置了中文分隔符,但 keep_separator 保持默认 | 统计以标点开头的 chunk 数 | 设置 keep_separator="end"(实验 1) |
调大 chunk_size 之后召回不涨,成本涨了 | 已经超过答案句的长度,上下文只是变长 | 对比 context_chars 和 recall@k | 回退到刚好覆盖关键句的大小(实验 2) |
| 前几个检索结果几乎是同一段话 | overlap 造成相邻 chunk 内容重复 | 统计 top-k 里的重叠对数 | 降低 overlap,或者修分隔符后去掉 overlap(实验 3) |
| 打开 MMR 之后召回下降 | MMR 排斥了与已选结果相似、但补全答案的 chunk | 同一批问题对比开关 MMR 的 recall@k | 关闭 MMR,或改用较大的 lambda_mult 并重新测(实验 3) |
| 换了切分参数之后线上效果变差,离线没发现 | 没有评测集,或评测集只看汇总指标 | 用检索评测与回归门禁的逐题门禁对比 | 先建评测集和门禁,再改参数 |
失败与回滚
- 切分参数变化意味着重建索引。
chunk_size、overlap、分隔符任何一个改变,所有 chunk 的边界和内容都变了,需要重新计算向量。上线前在副本索引上跑评测,通过再切换; - 回滚就是切回旧索引。保留旧索引直到新索引稳定运行;
- 配置要进版本控制,并把
broken、recall@k、context_chars@k这几个指标纳入你的回归检查,参见检索评测与回归门禁; - 局限:语料只有 4 篇、20 道题,答案句都很短;嵌入是词面级别的,不能反映语义嵌入模型对长 chunk 的敏感度;没有评估生成质量;没有验证按 token 计量的切分器、Markdown 或代码切分器;MMR 只试了一个
lambda_mult。
相关阅读
- 切分参数改完之后怎么证明没有变差:RAG 检索评测与回归门禁;
- 旧版 RAG 链迁移:LangChain 从 v0.x 升级到 v1;
- 线上报错对号入座:LangChain v1 与 LangGraph 生产故障诊断中心。
来源
- Splitting recursively (opens in a new tab):默认分隔符列表、
chunk_size与chunk_overlap的含义、按字符数计量,以及对没有词边界的语言的分隔符建议; - 构建语义搜索引擎 (opens in a new tab):官方指出整页文档对检索来说往往太粗,需要进一步切分,让相关段落不被周围文字稀释;并建议设置
add_start_index=True保留字符偏移。
以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/rag-chunking-benchmark/ 在上表版本下的实际运行。