解决方案
RAG 切分与检索基准

RAG 切分与 Retriever 基准:chunk_size、overlap 与 MMR 实测

中文 RAG 应用调切分参数,常见做法是照抄英文教程里的 chunk_size=1000, chunk_overlap=200,然后凭感觉改。问题是:默认的分隔符不认识中文标点,参数的效果又取决于你的文本,不量一量就不知道改的是什么。

本页写给正在给中文文档做切分和检索的开发者:用一份可复现的合成语料,把三件事量出来,分别是分隔符怎么影响答案句是否被切断,chunk_size 怎么影响召回和送进提示词的上下文长度,overlap 和 MMR 各自什么时候有用、什么时候互相抵消。

适用版本与验证信息

项目内容
验证日期2026-10-26
Python3.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 对数的平均值衡量检索结果里的重复内容

先说结论

  1. 中文必须自己设分隔符。 默认列表 ["\n\n", "\n", " ", ""] 里没有中文标点,切不开句子时只能按字符硬切:chunk_size=60 时,有 50% 的答案句被切断。官方文档也说明,默认分隔符在中文这类没有词边界的文字里会把词切开,建议把中文标点加进分隔符(实验 1)。
  2. 加了中文分隔符还要设 keep_separator="end"。 默认设置会把标点留在下一个 chunk 的开头,65 个 chunk 里有 29 个以“。”开头,答案句仍有 30% 被切断;设成 "end" 之后两项都变成 0(实验 1)。
  3. chunk_size 过了答案句长度之后,继续加大只增加成本。 30 个字符时 65% 的答案句被切断,召回只有 35%;从 100 到 800,recall@3 都是 100%,但每个问题送进提示词的上下文从 208 个字符涨到 1855 个,recall@1 反而从 100% 降到 85% 到 95%(实验 2)。
  4. overlap 是兜底,不是调参的第一步。 分隔符不合适时,overlap=40 把被切断的比例从 50% 降到 15%,recall@3 从 50% 升到 85%,代价是每个问题的前 3 个结果里平均有 0.8 对重叠 chunk;分隔符合适时,overlap 几乎没有收益,只增加重复(实验 3)。
  5. MMR 会抵消 overlap 的补救作用。 MMR 把重叠对降到 0,但默认分隔符加 overlap=40 时 recall@3 从 85% 掉到 50%,因为补全答案的恰恰是那个相邻的重叠 chunk(实验 3)。
  6. 建议的调参顺序: 先看 broken,修分隔符;再选 chunk_size;最后才考虑 overlap 和 MMR,并且每一步之后重新测。

公共函数

三个实验共用同一组函数:切分、建索引、计算上面四个指标。检索使用 InMemoryVectorStore,k 是实验里指定的值。

benchmark.py
"""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:

splitter_boundaries.py
"""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:

chunk_size_sweep.py
"""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):

overlap_and_mmr.py
"""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。

相关阅读

来源

以上来源均于 2026-10-26 核对。本页所有输出来自站点仓库 labs/rag-chunking-benchmark/ 在上表版本下的实际运行。

维护信息

  • 编辑:LangChain 中文网编辑部(内容标准见编辑与纠错说明);
  • 最近验证:2026-10-26;
  • 复现:先执行 labs/setup-envs.sh v1c 重建环境,再执行 python3 labs/run.py --lab rag-chunking-benchmark;
  • 发现错误请按联系我们反馈。