BM25 的 b 一动,短块的名次就换人 版权与内容来源声明本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容均在附表 A 中标注来源引用官方原文保持原样不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式也不对任何收益结果作任何承诺。转载请注明出处。先跑b 从 0 扫到 1前三名换了两次十个块混在一起最短 11 字、最长 163 字查「重排 延迟」b 扫五档前三名换了两次人NDCG3 从 0.468 爬到 1.000 又掉回 0.867。先把结果摆出来解释放在后面。b前三名NDCG3这一步谁被换掉了0.00d5 · d1 · d30.468——0.25d5 · d1 · d30.468无变化0.50d5 · d1 · d30.468无变化0.75d1 · d2 · d31.000d5 出局d2 补位1.00d2 · d1 · d90.867d3 出局d9 补位表里只有一件事值得看b 在 0 到 0.5 这段排第一的一直是 d5。这个块 143 字把「重排」说了四遍、「延迟」说了三遍读完却拿不到任何一个能用的结论——在我自己那套标注里它是 0 分。它靠的不是相关是词频高这两组词加起来出现七次另外还有五个「重」是从「重视」「重要」「重新」里借来的。b 抬到 0.75d5 直接掉出前三一个 11 字的块 d2 顶上来NDCG3 一次到顶。再把 b 推到 1.0一个 25 字的口号块 d9 又把 d3 挤了下去分数跌破满分。也就是说b 不是越大越好它有一个自己会过冲的点这个点在哪取决于这批块的块长分布。标注是我自己打的只代表这一批语料相关度分三档能直接拿来做决定的记 2只提供背景、还要再翻一页的记 1只是重复关键词、看完不知道下一步干什么的记 0。十个块、四条查询全部是我一个人读完全部块之后打的分没有第二个人交叉核对。所以这是小语料上的人工标注对照只能看方向不能外推绝对分。换一批语料、换一个标注的人具体名次会变但「b 让长块和短块的相对位置翻转」这条机制不会变因为它是公式里带出来的不是数据里凑出来的。四条查询里三条动了一条纹丝不动同一次扫描另外三条查询的反应完全不一样。「分块 策略」五档下来前三名一个没换「混合 检索 权重」在 0.75 换了一个「召回 长文」换了两次顺序但成员没变而且分数是往下走的b0 反而最贴人工标注。这四条查询放在一起才构成后面要给的判据b 动不动得了名次不取决于你设了多少取决于这批块的块长有多散、以及候选块之间的分差有多大。最小复现二十行 BM25只用标准库整段二十行含语料、含打印、含驱动循环复制过去就能跑出上面那张表。三十行是留了空行和注释的写法这里把空行全去掉——去掉之后反而更容易看出哪几行在真正做事。 实测环境Python 3.13.12 / macOSDarwin 27.0.0/ 仅标准库无第三方依赖无随机数、无计时函数importmath,re TXT(重排延迟二十毫秒重排只跑前二十条|重排延迟卡在候选条数上|召回后的延迟分三段逐条打分占大头重排每多一条候选就多一次前向延迟线性涨|切块长度直接决定召回粒度。块太短一句话被拆成两半实体和限定词落到不同块里块太长一块塞进三四个主题|重排很重要。重排决定了用户看到的前二十条重排做不好后面的环节都白费。延迟也很重要延迟高了用户就流失。所以要重视重排也要重视延迟把这两件事放在一起看别只盯其中一个指标也别指望调一个参数就把两边都照顾好。指标之间互相牵制动一个另一个就跟着动每次只改一个改完回测回测要跑同一批查询换一批就等于重新开始|混合检索把词法分和向量分加权求和权重不是固定的短查询给词法多一点长查询给向量多一点这个比例要在自己的语料上扫|重排和延迟是两笔账前者买准后者买快先想清楚这次要买哪个再决定候选条数给多少|长块的问题在于稀释。一块里混了定义、步骤、边界条件三段内容向量是这三段的平均查询只命中其中一段时相似度也被另外两段拉低。补救办法是重叠切分但重叠会把同一段内容算两遍召回列表里出现两条几乎一样的块还得再去一次重。重叠切分还会让块数翻倍索引体积跟着涨检索时要扫的候选也变多延迟又回去了所以长短之间不是选一个而是先按文档结构分段段内再按长度截|重排延迟都别自己猜先量一遍再调参数别拍脑袋定一个值|召回阶段用词法兜底长文里的编号和型号只有字面能命中)D[list(re.sub(r[^\u4e00-\u9fa5a-z0-9],,s))forsinTXT.split(|)]defbm25(D,q,b,k11.2):n,avg,dflen(D),sum(map(len,D))/len(D),lambdat:sum(tindfordinD)return[sum(math.log(1(n-df(t).5)/(df(t).5))*d.count(t)*(k11)/(d.count(t)k1*(1-bb*len(d)/avg))fortinqiftind)fordinD]defndcg(s,rel,k3):topsorted(range(len(s)),keylambdai:(-s[i],i))[:k]returnsum(rel.get(i,0)/math.log2(j2)forj,iinenumerate(top))/sum(v/math.log2(j2)forj,vinenumerate(sorted(rel.values(),reverseTrue)[:k]))Q{重排 延迟:{0:2,1:2,2:1,4:0,6:1,8:0,9:0},分块 策略:{3:2,5:1,7:2,9:1},混合 检索 权重:{5:2,3:1,7:1},召回 长文:{3:2,7:2,2:1,9:1}}print(块长,[len(d)fordinD],均值,round(sum(map(len,D))/len(D),1))forqs,relinQ.items():qlist(re.sub(r[^\u4e00-\u9fa5a-z0-9],,qs))print(qs,→,.join(q))forbin(0,.25,.5,.75,1):sbm25(D,q,b);topsorted(range(len(s)),keylambdai:(-s[i],i))[:3]print(f b{b:.2f} .join(fd{i1}foriintop)f NDCG3{ndcg(s,rel):.3f})原样输出一个字没改块长 [16, 11, 35, 47, 143, 54, 37, 163, 25, 25] 均值 55.6 重排 延迟 → 重排延迟 b0.00 d5 d1 d3 NDCG30.468 b0.25 d5 d1 d3 NDCG30.468 b0.50 d5 d1 d3 NDCG30.468 b0.75 d1 d2 d3 NDCG31.000 b1.00 d2 d1 d9 NDCG30.867 分块 策略 → 分块策略 b0.00 d8 d4 d3 NDCG30.867 b0.25 d8 d4 d3 NDCG30.867 b0.50 d8 d4 d3 NDCG30.867 b0.75 d8 d4 d3 NDCG30.867 b1.00 d8 d4 d3 NDCG30.867 混合 检索 权重 → 混合检索权重 b0.00 d6 d8 d5 NDCG30.840 b0.25 d6 d8 d5 NDCG30.840 b0.50 d6 d8 d5 NDCG30.840 b0.75 d6 d8 d1 NDCG30.840 b1.00 d6 d8 d1 NDCG30.840 召回 长文 → 召回长文 b0.00 d8 d10 d4 NDCG30.965 b0.25 d10 d8 d4 NDCG30.867 b0.50 d10 d8 d4 NDCG30.867 b0.75 d10 d4 d8 NDCG30.867 b1.00 d10 d4 d8 NDCG30.867两个默认值的原句抄在这里Elasticsearch 官方 reference 的《Similarity settings》页BM25 那一节把两个参数写得很短原句是k1Controls non-linear term frequency normalization (saturation). The default value is1.2.bControls to what degree document length normalizes tf values. The default value is0.75.同一页对 BM25 本身的描述里还有一句更贴题的话这套相似度「has built-in tf normalization and is supposed to work better for short fields (like names)」——它本来就偏向短字段b就是那个偏向的旋钮。Lucene 那边的BM25Similarityjavadoc 补了边界构造器在b不在[0..1]区间时会抛IllegalArgumentException。所以 1.0 就是顶格再往上没有档位可调——这一点在第五章给判据时会用到。语料为什么这么摆十个块的字数按这个序列摆11、16、25、25、35、37、47、54、143、163。极差接近 15 倍中间不断档。三条短块11、16、25、三条长块143、163 加上 47 那条偏长的剩下四条是中等长度当背景。查询四条全部是两字词组成的短语重排延迟、分块策略、混合检索权重、召回长文。每条查询我都在跑之前把十个块读了一遍打好分跑的时候没有回头看分数去改标注。这段代码里其实只有三件事切分、打分、评估。二十行里剩下的都是把这三件事串起来的胶水。第一件把块变成能数的一串字D那一行做了两件事——按|切开十个块再把每个块里除了汉字和字母数字以外的字符全删掉最后拆成单字列表。拆成单字是为了不引入分词器。代价很实在一个两字词被拆成两个几乎同时出现的字权重相当于翻了一倍而共享同一个字的词会互相蹭分比如「重视」里的「重」也会被算成命中「重排」。上面 d5 那个块只提了四次「重排」但因为「重视」「重要」「重新」都带「重」它的「重」计数被抬到了 9。这个偏差不影响本文的结论——本文只拿它隔离b的作用不评价召回质量本身。真要评召回得换成正经分词或者干脆按字二元组切。第二件一行公式四个部件打分就是那个列表推导式拆开来是idf × tf × (k11) ÷ (tf k1 × (1 - b b × 块长/平均块长))。分子里的tf × (k11)是词频的饱和项——一个词从出现 1 次到 2 次涨得很快从 9 次到 10 次几乎不涨。idf用 Lucene 的实现log(1 (docCount - docFreq 0.5)/(docFreq 0.5))只跟有多少块包含这个词有关跟块长无关。分母是tf k1 × 长度项。b只出现在长度项里而且是被乘在k1后面的——这一点决定了它能改多少第四章展开。顺带把k1和b的分工钉死k1站在分母里跟tf并排管的是「同一个词重复几次才开始饱和」b只乘在长度项上管的是「长块要不要打折」。两个参数都住在分母所以往上调都是压分区别是k1压所有块、b只压长块。这也是为什么同一档b对短块是加分、对长块是减分——加减的不是同一笔。第三件用一个 3 位的分数验名次NDCG3 的算法是取前三名按相关度 / log2(名次1)累加再除以理想排序的同一个累加值。结果落在 0 到 1 之间1 表示前三名的顺序和人工标注完全一致。用 NDCG 而不是「命中几条」是因为它同时惩罚两件事该进前三的没进以及进了前三但排错位置。上面表里 b0.75 拿到 1.000意思是 d1、d2、d3 这三个块既进对了也排对了。为什么会换人分母里的块长b唯一能动的东西是分母里那一项k1 × (1 - b b × 块长/平均块长)——而分母里已经先站着一个tf。tf越大长度项在分母里占的比例越小b就越使不上劲。这句话反过来读更有用b治得了「长但不重复」的块治不了「长且拼命重复」的块。块块长b0 得分b1 得分变化人工标注d51432.5941.906−27%0d1161.8442.72348%2d3351.8372.20620%1d2111.5502.75678%2d9251.5502.21543%0同一档 b短块涨得比长块掉得多把 b 从 0 拉到 1d2 涨了 78%d9 涨了 43%d1 涨了 48%d5 只掉了 27%。看起来方向对但幅度不对等——短块拿到的加成来自分母从tf 1.2缩到tf 1.2 × 0.20长块受到的惩罚来自分母从tf 1.2涨到tf 1.2 × 2.57。以tf 1为例b0 时分母是 2.200b0.75 时 16 字的块分母降到 1.559143 字的块升到 3.615。1.559 和 3.615 之间差 2.3 倍这个倍数就是b在这批块上能造出来的最大分差。而 d5 之所以还能在 b0.5 之前稳坐第一是因为它的tf本身就高——分母里tf站得稳长度项翻两倍也只把总分拉低四分之一。反过来看一条没换人的查询「分块 策略」五档全没动。原因不在b失效在分差太大d8163 字在 b0 时 4.307 分d447 字2.629 分前者领先 64%。b 拉到 1.0d8 掉 33% 到 2.894d4 只涨 3% 到 2.710——差距从 64% 压到 7%但没翻过去。7% 是这条查询能给的最好结果因为b到 1.0 就顶格了Lucene 的构造器不接受大于 1 的值。这就是一个很硬的信号如果 b1 都翻不过来继续在这条路上调参数是白费该回去动切块。本文两个实验的完整脚本与原始输出包括上面这段二十行 BM25、等长对照那份以及四条查询的逐档分数都放在资料包里扫码即可获取什么时候该调 b什么时候该改切块先看块长分布再看 b1 能不能翻过来——这两步能把「调 b」和「改切块」分开不用猜。把变量隔离全部补到一样长名次就死住了要证明「换人是块长差造成的」最直接的办法是把块长差消掉。下面这段把每个块补到 163 字填充字用「填」它不参与任何一条查询所以只改长度、不改词频。 实测环境Python 3.13.12 / macOSDarwin 27.0.0/ 仅标准库语料与上一段完全相同只多了一行补齐importmath,re TXT(重排延迟二十毫秒重排只跑前二十条|重排延迟卡在候选条数上|召回后的延迟分三段逐条打分占大头重排每多一条候选就多一次前向延迟线性涨|切块长度直接决定召回粒度。块太短一句话被拆成两半实体和限定词落到不同块里块太长一块塞进三四个主题|重排很重要。重排决定了用户看到的前二十条重排做不好后面的环节都白费。延迟也很重要延迟高了用户就流失。所以要重视重排也要重视延迟把这两件事放在一起看别只盯其中一个指标也别指望调一个参数就把两边都照顾好。指标之间互相牵制动一个另一个就跟着动每次只改一个改完回测回测要跑同一批查询换一批就等于重新开始|混合检索把词法分和向量分加权求和权重不是固定的短查询给词法多一点长查询给向量多一点这个比例要在自己的语料上扫|重排和延迟是两笔账前者买准后者买快先想清楚这次要买哪个再决定候选条数给多少|长块的问题在于稀释。一块里混了定义、步骤、边界条件三段内容向量是这三段的平均查询只命中其中一段时相似度也被另外两段拉低。补救办法是重叠切分但重叠会把同一段内容算两遍召回列表里出现两条几乎一样的块还得再去一次重。重叠切分还会让块数翻倍索引体积跟着涨检索时要扫的候选也变多延迟又回去了所以长短之间不是选一个而是先按文档结构分段段内再按长度截|重排延迟都别自己猜先量一遍再调参数别拍脑袋定一个值|召回阶段用词法兜底长文里的编号和型号只有字面能命中)D[list(re.sub(r[^\u4e00-\u9fa5a-z0-9],,s))forsinTXT.split(|)]Tmax(map(len,D));E[d[填]*(T-len(d))fordinD]defbm25(D,q,b,k11.2):n,avg,dflen(D),sum(map(len,D))/len(D),lambdat:sum(tindfordinD)return[sum(math.log(1(n-df(t).5)/(df(t).5))*d.count(t)*(k11)/(d.count(t)k1*(1-bb*len(d)/avg))fortinqiftind)fordinD]defndcg(s,rel,k3):topsorted(range(len(s)),keylambdai:(-s[i],i))[:k]returnsum(rel.get(i,0)/math.log2(j2)forj,iinenumerate(top))/sum(v/math.log2(j2)forj,vinenumerate(sorted(rel.values(),reverseTrue)[:k]))Q{重排 延迟:{0:2,1:2,2:1,4:0,6:1,8:0,9:0},分块 策略:{3:2,5:1,7:2,9:1},混合 检索 权重:{5:2,3:1,7:1},召回 长文:{3:2,7:2,2:1,9:1}}print(块长,[len(d)fordinD],→ 全部补到,T,字最短板补,T-min(map(len,D)),个填字)forqs,relinQ.items():qlist(re.sub(r[^\u4e00-\u9fa5a-z0-9],,qs));runs[bm25(E,q,b)forbin(0,.25,.5,.75,1)]tops[tuple(sorted(range(len(s)),keylambdai:(-s[i],i))[:3])forsinruns]print(qs,→,.join(q))forb,t,sinzip((0,.25,.5,.75,1),tops,runs):print(f b{b:.2f} .join(fd{i1}foriint)f NDCG3{ndcg(s,rel):.3f})print( 不同 top3 组合数,len(set(tops)),五档分数极差,max(max(r)-max(r2)forrinrunsforr2inruns))原样输出块长 [16, 11, 35, 47, 143, 54, 37, 163, 25, 25] → 全部补到 163 字最短板补 152 个填字 重排 延迟 → 重排延迟 b0.00 d5 d1 d3 NDCG30.468 b0.25 d5 d1 d3 NDCG30.468 b0.50 d5 d1 d3 NDCG30.468 b0.75 d5 d1 d3 NDCG30.468 b1.00 d5 d1 d3 NDCG30.468 不同 top3 组合数 1 五档分数极差 0.0 分块 策略 → 分块策略 b0.00 d8 d4 d3 NDCG30.867 b0.25 d8 d4 d3 NDCG30.867 b0.50 d8 d4 d3 NDCG30.867 b0.75 d8 d4 d3 NDCG30.867 b1.00 d8 d4 d3 NDCG30.867 不同 top3 组合数 1 五档分数极差 0.0 混合 检索 权重 → 混合检索权重 b0.00 d6 d8 d5 NDCG30.840 b0.25 d6 d8 d5 NDCG30.840 b0.50 d6 d8 d5 NDCG30.840 b0.75 d6 d8 d5 NDCG30.840 b1.00 d6 d8 d5 NDCG30.840 不同 top3 组合数 1 五档分数极差 0.0 召回 长文 → 召回长文 b0.00 d8 d10 d4 NDCG30.965 b0.25 d8 d10 d4 NDCG30.965 b0.50 d8 d10 d4 NDCG30.965 b0.75 d8 d10 d4 NDCG30.965 b1.00 d8 d10 d4 NDCG30.965 不同 top3 组合数 1 五档分数极差 0.0四条查询每条的五档前三名完全一样不同组合数都是 1五档分数极差 0.0——不是「差不多」是逐位相同。等长之后块长/平均块长恒等于 11 - b b × 1也就恒等于 1b被公式本身消掉了。所以b只在块长离散时才有作用。块长一样b就是死参数。一张表把处置定死把两次实验并排放最刺眼的不是分数是组合数。块长离散时「重排 延迟」跑出过三种不同的前三名「混合 检索 权重」两种「召回 长文」三种「分块 策略」一种补齐等长之后四条查询全部收敛到一种一个不剩。那条本来就只有一种的「分块 策略」说明的是另一半块长离散只是必要条件不是充分条件。它离散得比谁都厉害163 字对 35 字但候选之间的词频差太大b扫到头也翻不过来。所以「补齐等长后变成一种」和「离散时也只有一种」是两个不同的结论——前者证明b确实在起作用后者证明b不够用。你看到的现场先算这个数处置块长最长与最短相差不到 1.5 倍极差不动b动了也没反应块长极差大但 b0 时领先者只领先 40% 以内冠军与第二名的分差扫b取 0.5 到 0.75b0 时领先 60% 以上b1 时仍领先b1 下的分差b到顶也翻不过来回去改切块长块本身就是正确答案像「召回 长文」那条b0 与 b0.75 的 NDCG把b调小别默认 0.75第三行是最容易被忽略的很多人b从 0.75 调到 1.0 试了一遍没效果就以为这个参数没用。实际上 1.0 已经是上限翻不过来说明差距本来就不在长度上。第四行是反方向的陷阱。默认值 0.75 不是普适的它只是 Elasticsearch 给的一个起点如果你的语料里长块本来就该赢0.75 会把它们压下去我那条「召回 长文」的查询就是 b0 的 NDCG 最高。还有一条边界得说清本文只有十个块人工标注半小时能打完真实索引里块长分布更散标注成本也高得多。可行的做法是先抽几十条查询做小样本对照方向对了再全量扫别一上来就在生产索引上动参数。一句话判断拿到一个新索引先跑两件事把块长排一遍看极差再把b从 0 扫到 1 看前三名的组合数。组合数一直是 1b跟你没关系去改切块和重叠组合数大于 1才值得在 0.25 到 0.75 之间挑一个如果 b1 都翻不过来别继续调了那是块长之外的差距。BM25 打分公式的逐项拆解与四条查询的完整分数把上面三张表的原始数字、每个块的词频和分母实测值都列在一起了扫码即可获取附表 A本文引用事实与出处对照表事实出处本文位置k1控制非线性词频归一化饱和默认值1.2Elastic《Similarity settings》BM25 小节https://www.elastic.co/docs/reference/elasticsearch/index-settings/similarity第 2 章b控制文档长度对 tf 的归一化程度默认值0.75同上第 2、5 章BM25 内建 tf 归一化且「supposed to work better for short fields (like names)」同上BM25 小节首句第 2 章b必须落在[0..1]否则构造器抛IllegalArgumentExceptionLucene 10.0.0 core APIBM25Similarityjavadochttps://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/similarities/BM25Similarity.html第 2、5 章idf实现为log(1 (docCount - docFreq 0.5)/(docFreq 0.5))同上第 3 章十个块的块长、四条查询的逐档前三名与 NDCG3全部为本地实跑输出本文脚本e05_bm25.py、e05_equal.pyPython 3.13.12 / macOS仅标准库第 1、2、4、5 章相关度三档标注2/1/0由本文自定单人标注、未做交叉核对本文第 1 章写在最后这篇用到的资料写这篇文章时把相关的官方文档和源码又翻了一遍顺手也整理了几份配套的东西大模型学习路线图从零基础到能自己动手做 Agent按阶段说明每一步该学什么、哪些可以先跳过《LangChain LangGraph MCP 智能体开发实战》视频课7 个模块从私有化部署、EmbeddingRAG 到 MCPAgent 全流程AI 大模型知识库在线可查Agent Skills 从入门到落地、Claude Skills 完全指南等专题按目录浏览即可640 套 AI 大模型行业报告 经典 PDF 书籍看行业落地案例和别人怎么做的时候用得上大模型零基础到精通教学视频跟着敲一遍比只读文档快得多资料是我自己整理的放在下面这个码上扫码即可获取添加时备注「AI」优先通过。资料按「先路线、再动手、最后查漏」的顺序整理好了建议先看学习路线那一份照着它挑一条适合自己当前基础的路径再往下看。