摘要
调 RAG 几乎人人都改过 chunk_size,但很少有人回答:分块粒度买到的到底是什么。本文用纯标准库实现字符 bigram 分词 + BM25(k1=1.5、b=0.75),在 21 段语料、14 条查询(8 单跳 + 6 多跳)上实测 5 种切法:固定 50 字、固定 500 字、50/15 重叠滑窗、段落聚合 ≤90 与 ≤180 字,指标含 recall@1、recall@3 与每查询 top3 送进模型的 token 成本。结论:重叠的增益主要落在 recall@1(0.357→0.571);多跳吃的是块长——固定 50 字合并 top3 只 0.643,500 字大块 1.000,但成本 113→748。粒度不是玄学,是量得出来的交换比。
一、问题:分块的"粒度"其实是三个旋钮
很多教程把分块说成一个参数(chunk_size),实际它同时动了三件事:
- 检索单元的大小——BM25/向量检索打分的对象,决定相关词密度;
- 上下文的完整性——一个事实会不会被拦腰切断;
- 索引与成本——块数影响检索开销,块长影响送进模型的 token。
这三个旋钮在"固定块长"里是绑死在一起的:调一个就全变。所以下文故意把实验排成三组对照——同块长比"有无重叠"(旋钮 2)、同策略比"聚合上限"(旋钮 3)、极小块比极大块(旋钮 1),让每个旋钮的影响能单独读出来。
(Mermaid 若平台不渲染,等价文字:切块策略 → 索引 → top-k → 上下文 → 质量与成本,三个策略只在前段分叉。)
二、实验设计
- 语料:21 个段落,7 个主题 × 3 段,主题间故意设置近义干扰(如"缓存"同时出现在 KV 缓存、前缀缓存、语义缓存三个主题里)。
- 查询:14 条 = 8 条单跳(答案在某一个主题里)+ 6 条多跳(top3 合并必须覆盖 2 个主题才算命中,缺一个记未命中)。
- 检索器:BM25 标准公式,中文用相邻字符 bigram 免分词库,英文/数字整串成词。全部本地计算,不调任何线上 API。
- 判定:块是否"属于"某主题,按段落 bigram 在块中的覆盖率 ≥0.6。
核心评测代码(完整见demos/chunking_retrieval_eval.py,约 190 行):
defchunk_fixed(paras,size):"""固定长度、无重叠:句子会被拦腰切断"""text="".join(pfor_,pinparas)return[text[i:i+size]foriinrange(0,len(text),size)]defchunk_overlap(paras,size,overlap):"""固定长度 + 重叠滑窗:跨边界句子至少完整出现在某一块"""text="".join(pfor_,pinparas)step=size-overlapreturn[text[i:i+size]foriinrange(0,len(text),step)]defchunk_para(paras,max_size):"""段落边界聚合:连续段落拼到接近上限,绝不在句子中间切"""out,cur=[],""for_,pinparas:ifcurandlen(cur)+len(p)>max_size:out.append(cur);cur=pelse:cur+=pifcur:out.append(cur)returnoutdefevaluate(name,chunks,paras,queries,k=3):idx=BM25(chunks)ct=[chunk_topics(c,paras)forcinchunks]# 每块覆盖的主题集合r1,rk,cost=[],[],0forgolds,qinqueries:top=idx.topk(q,k)union=set().union(*(ct[i]foriintop))r1.append(1ifct[top[0]]>=set(golds)else0)# 首位命中rk.append(1ifunion>=set(golds)else0)# 多跳:合并覆盖全部金标准主题cost+=sum(len(tokenize(chunks[i]))foriintop)returnr1,rk,cost/len(queries)三、实测结果(Python 3.11.5 / Windows,3 轮)
| 分块策略 | 块数 | 平均块长(字) | recall@1 | recall@3 | top3 平均 token |
|---|---|---|---|---|---|
| fixed(50) | 20 | 47.8 | 0.357 | 0.643 | 113.4 |
| fixed(500) | 2 | 477.5 | 0.857 | 1.000 | 748.0 |
| overlap(50/15) | 28 | 48.4 | 0.571 | 0.714 | 117.5 |
| para(≤90) | 15 | 63.7 | 0.571 | 0.786 | 152.6 |
| para(≤180) | 7 | 136.4 | 0.571 | 0.929 | 341.5 |
该实现完全确定性(无随机、无网络),3 轮结果一致,故区间为单点;策略间差异即实验变量本身。
三个可读出来的结论:
- 重叠买的是 recall@1。同样是 50 字窗口,加 15 字重叠后首位命中率 0.357 → 0.571(+60%),而每查询成本只从 113.4 涨到 117.5(+3.6%)。因为被切断的句子至少能在一块里完整出现,首位不再总把"半个答案"排第一。
- 多跳买的是块长,但价格是线性的。fixed(50) 的 recall@3 只有 0.643——6 条多跳查询里近一半没法在 top3 内凑齐两个主题;块长到 500 后 recall@3=1.000,但 top3 平均 token 从 113 涨到 748,6.6 倍。这正是父文档检索(小块命中、大块送上下文)想解耦的东西:本实验里 para(≤180) 用 341.5 的中间成本换到 0.929。
- 段落边界对 recall@1 没有额外加成(与 overlap 同为 0.571),它的价值在 recall@3(0.786/0.929 > 0.714):不切断句子让"相关但不完整"的块少了,合并覆盖更稳。
- 收益随参数不是单调堆积的。从 para(≤90) 到 para(≤180),recall@3 涨 0.143(0.786→0.929),成本涨 189 token(152.6→341.5);从 fixed(50) 到 fixed(500),recall@3 涨 0.357,成本涨 635 token。把五档排开就能看到一条"边际成本/边际收益"曲线,肘部出现在 para(≤180) 附近——这就是选块长该看的图,而不是单独的某个 recall。
四、避坑指南
- 拿通用基准的"最优块长"直接抄。本实验里 recall@1 从 0.357 到 0.857 全部由切法产生,同一语料换个主题分布结论就变。块长必须在你自己的语料 + 自己的查询集上量,别抄博客里的 512。
- 词法检索的结论直接套到向量检索。本文用 BM25 是因为它可完全本地复现;bigram 词法对"字面重合"敏感,向量对"语义相近"敏感,两者的块长最优值不同。但"重叠修 recall@1、块长修多跳"这个机制是通用的。
- 只报 recall@3 不报成本。top3 全命中但每查询送 748 token 的策略,可能不如送 341 token、0.929 的策略——多出来的命中率是用每次推理的上下文窗口和费用买的,报告要成对出现。
- 多跳查询不单独统计。把单跳和多跳混在一个总 recall 里,你会看不见"小块策略在多跳上系统性失败"这件事——本实验里这正是 fixed(50) 与 fixed(500) 差距最大之处。
- 忘了块还有"索引成本"这一维。overlap(50/15) 块数 20→28(+40%),线上向量库的存储与检索开销同步膨胀;收益表里没体现,是因为词法检索这项几乎免费。
- 父文档检索落地时把重叠内容重复拼进上下文。用"小块命中、父块送上下文"策略时,两个相邻小块常命中同一个父块,朴素拼接会让同一段文字出现两遍——召回分数没涨,token 白付。装配阶段必须先按父块 ID 去重、再按命中分数排序截断。
五、复现方法与读数口径
- 运行:
python demos/chunking_retrieval_eval.py(Python 3.11.5 / Windows,零第三方依赖),脚本自带 3 轮重复与区间汇总,留档输出在chunking_retrieval_eval.transcript.txt。 - 为什么敢给"区间为单点":这套实现没有随机数、没有网络、没有计时器,三轮结果必然逐位一致——不确定性全部来自策略本身,这正是词法检索适合做 A/B 的原因。注意:换成语义(向量)检索后同一查询会有抖动,届时必须给真实验的置信区间,不能沿用单点写法。
- 迁移到你的语料:只需替换
CORPUS(每段标注主题)与QUERIES(金标准主题集合 + 查询文本)。多跳查询的判定规则保持不变:top3 合并必须覆盖全部金标准主题,缺一即 0。 - 读数注意:本文的 recall@1 统计"首位块是否足以回答单跳查询",recall@3 统计"前 3 块合并(含多跳双主题)"。两个指标一个看排序质量、一个看上下文装配质量,混报会掩盖问题。
六、结论
分块粒度不是玄学,是两个可以量出来的交换比:重叠窗口用约 4% 的上下文成本修 recall@1(本实验 0.357→0.571);块长修多跳合并覆盖(0.643→1.000),但成本随块长近似线性上涨(113→748 token/查询)。工程上别在"一个 chunk_size"里做取舍——检索粒度用小块(保首位命中),上下文粒度用父块/聚合块(保多跳),本次实测 para(≤180) 恰好处在成本-召回的肘部(0.929 / 341.5)。所有数字来自本机 3 轮实跑,换到你的语料上请重跑同一套脚本再下结论。