分块真正要定的是三件事:切在哪、切多大、切完之后每一块自己还看得懂多少。工程上花力气最多的是第一件(语义分块、LLM 分块),但它对召回的边际贡献最小;第二和第三件几乎没人做实验,却直接决定了你塞进 context window 的内容里有多少是废话。两条看似打架的证据其实指向同一个结论:Chroma 在 2024 年 7 月 3 日发布的技术报告 Evaluating Chunking Strategies for Retrieval 说不同分块策略之间召回最多能差 9 个百分点;而发表在 NAACL 2025 Findings 的 Is Semantic Chunking Worth the Computational Cost?(Qu、Tu、Bao,pp. 2155–2177)说语义分块的算力开销换不来稳定收益。差出来的那几个点,主要来自 chunk 大小和 overlap,不是来自「语义」。

三种切法,三种失败模式

固定窗口(token):按 tokenizer 数够 N 个 token 就切。失败模式是切在句中甚至词中,一个定义被拦腰砍断,两半都不足以被检索命中。好处是 token 预算完全可控——你知道 top-k 之后 prompt 里会进多少 token,这在做成本核算和裁剪时是唯一靠得住的性质。

递归分隔符:按优先级列表逐层往下切,切不动了再降级。LangChain 的 RecursiveCharacterTextSplitter 默认分隔符是 ["\n\n", "\n", " ", ""],Chroma 报告里用的是加了句末标点的扩展版 ["\n\n", "\n", ".", "?", "!", " ", ""]。失败模式很隐蔽:它退化的时候不报错。源码里没有分隔符命中率的可观测性,一份没有空行、全是长句的 PDF 抽取文本,会一路降级到按空格切,最后跟固定窗口没区别,而你的日志里什么都不会出现。如果你在生产里用递归分块,把「实际命中到第几层分隔符」按文档埋点记下来,这一个计数器能提前抓到很多语料质量退化。

语义分块:对相邻句组求嵌入,用余弦距离的分布找断点。LlamaIndex SemanticSplitterNodeParser 的默认是 buffer_size=1breakpoint_percentile_threshold=95langchain_experimentalSemanticChunker 提供四种阈值口径,默认值分别是 percentile=95standard_deviation=3interquartile=1.5gradient=95(gradient 模式把百分位施加在距离序列的梯度上而不是原始距离上)。这里有个必须点破的性质:百分位阈值是相对于当前文档的距离分布定义的,意味着无论文档结构如何,你总会切出约 5% 的断点。一份主题高度一致的 API 参考手册和一份大杂烩会议纪要,会拿到同样比例的断点,而 chunk 长度完全不受控。想控长度就得同时设 min_chunk_sizenumber_of_chunks,而这两个参数默认都是 None

默认值是最大的坑

先看几个真实的默认值,都来自源码而不是文档:

  • langchain_text_splitters 的基类 TextSplitter 默认 chunk_size=4000chunk_overlap=200,并且 length_function=len这个 4000 是 4000 个字符,不是 token。 英文语料下大约 1000 token,中文语料下大约 2000+ token——同一份配置在多语种语料上会切出体量差两倍的块,而嵌入模型对不同长度的表现并不线性。构造器会校验 chunk_overlap <= chunk_size 并抛 ValueError,但它永远不会告诉你单位搞错了。
  • LlamaIndex 的 llama_index.core.constantsDEFAULT_CHUNK_SIZE = 1024DEFAULT_CHUNK_OVERLAP = 20;但 SentenceSplitter 用的是另一个常量 SENTENCE_CHUNK_OVERLAP = 200。同一个库里两个默认重叠差 10 倍,取决于你实例化了哪个类。
  • SentenceSplitterparagraph_separator 默认是 "\n\n\n"(三个换行)。Markdown 分段用的是两个换行,所以这个分隔符在绝大多数 Markdown 语料上根本不会命中,实际生效的是 secondary_chunking_regex

Chroma 的报告则直接点名了 OpenAI 文档里 vector store 默认的 800/400 配置(50% 重叠):在 text-embedding-3-large、取回 5 个 chunk 的设定下,它的召回 87.9%,但 precision 和 IoU 都只有 1.4%。

结论很直接:任何一套框架默认值都不是为你的语料调的,把它当成起点而不是答案。

大小与重叠的实测影响

Chroma 报告在同一设定(text-embedding-3-large,n=5)下的几个代表性数字:

策略 大小 重叠 Recall Precision IoU
Recursive 200 0 88.1% 7.0% 6.9%
ClusterSemantic 200 0 87.3% 8.0% 8.0%
LLMSemantic 变长 0 91.9% 3.9% 3.9%
TokenText 800 400 87.9% 1.4% 1.4%

注意这里的 recall/precision 是 token 级的:召回衡量的是「相关 token 被取回的比例」,precision 衡量的是「取回的 token 里有多少是相关的」。这个口径比 chunk 级的命中率诚实得多——chunk 级指标会把一个 800 token 的块里只有 30 token 有用记成一次完美命中。如果你现在只看 chunk 级命中率(该换成哪几个逐查询指标见RAG 检索评估实战),你对大块的成本一侧是结构性失明的。

读这张表要读出三件事。第一,召回的差距被夸大了,四种策略挤在 87%–92% 之间;只看召回,分块看起来就像个无关紧要的决策。第二,precision 和 IoU 差了 5 倍以上,这才是分块决策真正影响的东西——它决定了 LLM 要在多少噪声里找答案,也决定了你为噪声付多少 token 的钱。第三,重叠接近纯损耗:800/400 意味着索引里每段文字存了两份,取回 5 个块时,重复内容最多可占到接近一半的 token,而这些重复内容还占掉了同一批 k 个名额。报告明确写了重叠这一条:降低重叠会改善 IoU,因为这个指标会惩罚冗余信息;而小块(200 token)在 precision 和 IoU 上更好,是从上面这张表读出来的——报告本身并没有给出一份推荐配置。

重叠唯一站得住的理由是「防止关键句被切断」,但这是在用一个全局的 2 倍成本,去修一个局部的边界问题。更省的做法是把边界修好(结构分块),或者让每块自带上下文(下一节)。真要留重叠,留 chunk 大小的 10–15%,不是 50%,而且它得在实验里自己挣出来。

语义分块什么时候值得

NAACL 2025 那篇论文在文档检索、证据检索、检索式答案生成三类任务上都没能证明语义分块稳定优于固定大小分块。把它和 Chroma 的结果放在一起并不矛盾,而弄清楚为什么不矛盾直接决定你该怎么选:Chroma 里表现最好的 ClusterSemanticChunker 用的是动态规划最大化块内相似度并约束目标长度LLMSemanticChunker 则是让模型直接预测切分点;两者都不是「按余弦距离百分位找断点」那种朴素语义分块。而后者恰恰是各框架里默认能用到的那一种。

所以立场是:朴素的百分位阈值语义分块别用。它要多跑一遍全量嵌入,产出长度不可控的块,换来的收益在公开实验里立不住。真正该优先做的是结构分块——Markdown 标题层级、HTML 语义标签、代码的 AST 边界、法条的条款号、记录稿的发言人轮次。这些边界是作者亲手标出来的,比你用余弦距离猜的准,而且免费。只有当文档确实没有任何结构标记(OCR 出来的连续文本、语音转录稿)时,语义分块才值得进入候选集,而且是作为实验的一个分支,不是默认选项。

比调 chunk 大小更划算的两件事

给每个 chunk 补上下文。Anthropic 在 2024 年 9 月 19 日发布的 Contextual Retrieval 做法是:把整篇文档和当前 chunk 一起给模型,让它生成一段定位这个 chunk 的简短说明,拼在 chunk 前面再索引。公布的数字是 top-20 检索失败率从 5.7% 降到 3.7%(-35%);叠加 contextual BM25 到 2.9%(-49%,向量与全文两路在库里怎么融合见MongoDB 混合检索实战);再叠加重排到 1.9%(-67%)。每个 chunk 的成本是额外 50–100 token。这个收益量级远大于你在 chunk 大小上反复试出来的那 1–2 个点。顺带一提,Anthropic 官方并没有给出推荐的 chunk 大小或 overlap,只说三者都会影响检索性能——考虑到答案高度依赖语料,这是诚实的写法。

Late chunkingarXiv:2409.04701(Günther 等,2024 年 9 月 7 日提交,v3 更新于 2025 年 7 月 7 日)的做法是先让整篇长文过一遍 transformer,在 mean pooling 之前才做切分,于是每个块的嵌入天然带着全文上下文,且不需要额外训练。硬约束要看清楚:必须是长上下文且用 mean pooling 的嵌入模型。用 CLS token 出句向量的模型,句向量并不由 token 级表示聚合而来,也就没有可以按段落切开再池化的对象,这条路直接走不通,做技术选型前先去确认你的模型用的是哪种 pooling。

这两件事的共同点是:它们不改变边界,而是改变每个块被嵌入时能看见多少。这正是朴素语义分块想解决却解决不了的问题。

怎么把选参数变成可复现实验

第一,别用公开 benchmark 选你的分块参数。 Chroma 2025 年 4 月 7 日的 Generative Benchmarking(Hong、Troynikov、Huber,与 Weights & Biases 的 McGuire 合作)给出的证据是:jina-embeddings-v3 在 MTEB 上稳定优于 text-embedding-3-large,但在 WandBot 的真实生产数据上反过来。方法是两步——先用与人工判断对齐的 LLM judge 过滤文档(他们把 13,319 篇筛到 8,490 篇),再带着领域上下文和真实查询样例生成查询;带上下文与示例生成的查询,其 query-document 余弦相似度分布与真实查询的 KL 散度是 0.159,朴素生成是 0.207。

第二,用 token 级标注。 Chroma 报告配套的 chunking_evaluation 包可以直接装(pip install git+https://github.com/brandonstarxel/chunking_evaluation.git),GeneralEvaluation.run(chunker, embedding_function) 返回 IoU 和 recall 的均值与标准差,自定义切分器继承 BaseChunker 即可接入。它还带一条 SyntheticEvaluation 流水线,从你自己的语料生成查询和对应的原文片段。依赖里包含 tiktoken、chromadb 以及 OpenAI / Anthropic 客户端,预算里要算上 API 成本。

第三,网格必须正交。 一次只动一个维度,其余全部冻结:

# 冻结项:嵌入模型、top-k、重排器、tokenizer、随机种子
GRID = [(200, 0), (400, 0), (400, 60), (600, 0), (800, 0), (800, 400)]

rows = []
for size, overlap in GRID:
    chunker = MyChunker(chunk_size=size, chunk_overlap=overlap)  # 继承 BaseChunker
    r = evaluation.run(chunker, embedding_function)
    rows.append({
        "size": size, "overlap": overlap,
        "recall": r["recall_mean"], "recall_std": r["recall_std"],
        "iou": r["iou_mean"], "iou_std": r["iou_std"],
    })

最常见的错误是同时换了切分器和嵌入模型,然后把差异归给切分器。

第四,报方差,做配对检验。 Chroma 报 mean 和 std 是有原因的:查询之间的方差通常远大于配置之间的差异。两个配置差 1 个百分点而每查询标准差有 15 个点,这就是噪声,下一季换一批语料就翻盘。用 ir-measurespytrec_eval 拿逐查询分数做配对检验(pytrec_eval 仓库里就带了一个比较两个 run 显著性的示例,用 scipy.stats.ttest_rel 做配对 t 检验),别只看平均数排序。

第五,三样元数据必须记进结果表:切分器实现的版本、tokenizer 名称、嵌入模型版本。chunk_size=400 在按字符计和按 token 计的两个实现下是完全不同的实验,半年后没人能凭结果表分辨。

一个可以直接抄的起点

结构优先的递归分块(有 Markdown/HTML/AST 边界就先用),目标 400–600 token、overlap 为 0,每块前面拼一段生成的上下文说明,检索时 top-k 取 10 再重排到 3–5 条(放大 top-k 的召回代价取决于索引参数,见HNSW 调参与量化取舍)。这套配置里的每一项都有上面的证据支撑,而不是惯例。然后按顺序做三组实验:先扫 chunk 大小(200/400/600/800,overlap 固定 0),这里才是 precision 差异所在;再验证 overlap 是否真的带来了超过噪声的收益(多半没有,没有就删掉);最后才把 late chunking 或带长度约束的语义切分器当候选推上去评。

先量,再调。chunk 大小和 overlap 是你能测的,语义分块是你在猜的。