BM25 检索 idf 兜底常数把「没见过的词」顶成 top1:df=0 的 log(0) 除零兜底如何静默毁掉相关性排序
关键词:BM25、okapi BM25、idf 逆文档频率、df=0、OOV out-of-vocabulary、 log(0) 除零兜底、
if df == 0: return 常数、默认参数静默生效、 相关性排序错乱、无依据的命中、hit_without_term_match、 健康检查假绿、退出码恒 0、fail-closed、自述字段自证、AttributeError: 'Ranker' object has no attribute 'frozen_idf'技术栈:Python 3.11(标准库 math/json)、Docker Compose、全文检索 / 相关性排序 可复现:
scenario/2026-10-06-0800,分支2026-10-06-0800
一、问题背景
一个自研全文检索服务(8 篇短文语料),跑完一轮检索后:
- 每个查询都在把一份跟查询毫无关系的文档当第一名;
- 「语料里没见过的词」查询
zqxunseen返回了 3 篇文档,分数全是0.0; - 「排名稳定」(
rounds_stable = True); - 自检
{"ok": true, "self_consistent": true}、健康检查{"degraded": false, "issues": []}、退出码 0 —— 四个绿灯全亮。
但外部独立探针(只读契约 + 语料 + 索引)报 RESULT: BAD:
hit_without_term_match ——「这条命中在语料里一个查询词都没命中」。
== 2. 索引里按契约口径算好的 idf vs 检索侧真正用的权重 ==
the 契约口径(索引) idf=0.492476 | 检索侧实际用=1.405465 ← 口径就不一样
dumplings 契约口径(索引) idf=1.280934 | 检索侧实际用=2.098612 ← 口径就不一样
zqxunseen 契约口径(索引) idf=0.000000 | 检索侧实际用=1.000000 ← 兜底常数
== 4. 每轮真正参与打分的查询词(决定性证据) ==
round1 查询词=['zqxunseen'] 命中=[('d01.txt', 0.0), ('d02.txt', 0.0), ('d03.txt', 0.0)]
↑ zqxunseen 在语料里一个词都没命中(df = 0),
这份文档凭什么被选出来?
契约唯一来源 conf/bm25.env:
BM25_K1=1.2
BM25_B=0.75
BM25_IDF_MODE=okapi # idf 口径 = okapi:ln(1 + (N - df + 0.5)/(df + 0.5))
二、复现步骤
cd scenario/2026-10-06-0800
docker compose up -d --build
# 一键复现(问题态)
docker compose exec -T search bash ops/repro.sh
# 验收(问题态应为 PASS=3 FAIL=7 / RESULT: BAD)
docker compose exec -T search bash ops/verify.sh
问题态验收输出节选:
== A. 权重与契约口径不一致的词数 == [FAIL] (期望=0 实际=95)
== B. 没见过的词 zqxunseen 的权重 == [FAIL] (期望=2.890371758 实际=1.000000000)
== B. 没见过的词权重 ≥ 语料内所有词 == [FAIL] (期望=yes 实际=no)
== C. 无依据的命中条数 == [FAIL] (期望=0 实际=3)
== E. 口径失效时 health 退出码 == [FAIL] (期望=2 实际=0)
== G. 删掉 conf/bm25.env 后 health 退出码 ==[FAIL] (期望=3 实际=0)
== H. 外部探针判定 == [FAIL] (期望=OK 实际=BAD)
PASS=3 FAIL=7
RESULT: BAD
三、根本原因
3.1 完整报错 / 现象
| 现象 | 含义 |
|---|---|
zqxunseen 查询返回 3 篇文档,score=0.0 | 一条记录都没匹配上,却榜上有名 |
idf 索引值 0.492476 vs 检索侧 1.405465 | 写入侧与检索侧两套口径 |
df=0 的词权重 = 1.000000 | 契约口径被兜底常数顶替 |
rounds_stable=True | 反馈「收敛」= 每轮把同一份文档换个理由再选一遍 |
health 退出码 0 | 判据是常量,main() 无条件 return 0 |
AttributeError: 'Ranker' object has no attribute 'frozen_idf' | 修复后诊断脚本反而崩了(见 §5 坑 1) |
3.2 根因一:权重自己另算一套口径
契约登记的 idf 口径是 okapi:
idf_okapi(w) = ln( 1 + (N - df + 0.5) / (df + 0.5) )
检索侧却写了算术口径 ln((N+1)/(df+1)) + 1:
term df 问题态(算术) 契约(okapi)
the 5 1.405465 0.492476
hot 1 2.504077 1.791759
dumplings 2 2.098612 1.280934
两套刻度完全不同,排序自然全乱。而 build_index() 早把契约口径的
idf 冻结进了索引 payload["idf"]["idf.<term>"] —— 算了但从来不用。
3.3 根因二(题眼):df = 0 兜底成常数
def _idf(df, total):
if df <= 0:
return 1.0 # ← 兜底常数
return math.log((total + 1) / (df + 1)) + 1.0
df = 0 时算术口径要算 ln(0)(无定义),代码直接 return 1.0。
zqxunseen (df=0):契约 okapi = ln(18) ≈ 2.890372(全局最大)
问题态兜底 = 1.000000
兜底常数破坏了 okapi 口径「df 越小 idf 越大」的单调性:
zqxunseen(1.0) 反而比常见词 the(1.405) 还小。
okapi 口径对 df=0 本来就有确定取值,根本不需要兜底分支。
log(0) / /0 / 空列表 都是这类「与输入无关的兜底」的高发点。
3.4 根因三:命中没有依据
search() 把所有文档当候选,只要「算出来的分最高」就返回:
scored = sorted(((-self._score(query_terms, d), d) for d in self.order), ...)
zqxunseen 在 8 篇语料里一个都没命中 → freq = 0 → 该词贡献恒为 0.0
→ 8 篇文档得分全是 0.0 → 排序退化成「按文档名排」→ d01.txt 凭字典序当了 top1。
铁证就是 score=0.0:一条记录都没匹配上,却榜上有名。
hits 里也不带 matched_terms,外部无从复核。
3.5 根因四:四个假绿灯
| 位置 | 问题 |
|---|---|
run() | rounds_verified = rounds_total(自己数自己) |
run_round() | self_consistent / ok 写死 True |
health.py | 判据是写死的常量,不读索引/语料/契约;main() 无条件 return 0 |
健康检查 的问题态实现:
def main(argv=None) -> int:
print(json.dumps({"degraded": False, "issues": [],
"self_consistent": True, "ok": True}, ...))
return EXIT_OK # 退出码恒 0
「判不了」在这份代码里根本不存在 —— 它从来不去判。
四、解决办法
只改
app/ranker.py与app/health.py的逻辑;conf/三份契约、corpus/、client/、ops/verify.sh、ops/negative.sh未动。
4.1 权重只有契约一个来源,df=0 不兜底
self._frozen = {k[4:]: v for k, v in (payload.get("idf") or {}).items()
if k.startswith("idf.")}
def weight(self, term: str) -> float:
"""检索侧真正用来排序的权重 —— 契约口径的唯一实现。"""
self.idf_calls += 1
frozen = self._frozen.get(term)
if frozen is not None:
return float(frozen)
# 语料里没有这个词(df=0):okapi 口径照样有确定取值(全局最大)
return bm25.idf(0, self.total, self.idf_mode)
语料内取索引冻结值,语料外走同一个 bm25.idf();没有任何
if df == 0: return 常数 的分支。
4.2 候选必须先真的命中查询词
def search(self, query_terms):
# 只有在文档里**真的命中查询词**的文档才是候选
candidates = [d for d in self.order
if any(t in self.docs[d] for t in query_terms)]
if not candidates:
return []
...
hits.append({"doc": doc, "score": -neg,
"matched_terms": sorted(t for t in set(query_terms)
if t in self.docs[doc])})
4.3 自述字段算出来,不写死
if hits and all(h["matched_terms"] for h in hits):
self.rounds_verified += 1 # 逐轮累加,不是抄总数
...
"self_consistent": out["rounds_verified"] == out["rounds_total"] and not out["errors"],
"ok": not out["errors"],
4.4 健康检查:判据读事实,判不了就 fail-closed
BAD_FIELDS = (("ok", True), ("rounds_total", None)) # 空表 = 关掉整个健康检查
# ① 索引里的冻结 idf 必须等于契约口径重算的结果(含 df=0 的词)
want = bm25.idf(df, loaded["total_docs"], bm25_conf["idf_mode"])
if abs(float(frozen[term]) - want) > 1e-9:
issues.append("词 %s 的 idf 索引值 %r ≠ 契约口径 %r" % (term, got, want))
# ② 每一条命中都必须真的命中查询词
if not any(t in loaded["docs"][doc] for t in terms):
issues.append("无依据的命中:查询=%s 第 %d 轮 文档=%s 无一查询词命中" % ...)
# ③ 索引登记的文档清单必须与语料目录一致
「拿不到数据」(索引缺失 / 报告缺失 / 契约缺失)= 判不了 = 不通过:
except contract.ContractError as exc: return EXIT_CONTRACT # 3
except index_store.IndexMissing as exc: return EXIT_BAD # 2
退出码分层:0 正常 / 2 有真问题或拿不到数据 / 3 契约不可用或自相矛盾。
五、验证
5.1 修复后验收 —— 全绿
docker compose exec -T search bash ops/apply_fix.sh --apply
docker compose exec -T search bash ops/verify.sh
[PASS] 权重与契约口径不一致的词数(0=一致) (期望=0 实际=0)
[PASS] 没见过的词 zqxunseen 的权重(契约 df=0 口径) (期望=2.890371758 实际=2.890371758)
[PASS] 没见过的词权重 ≥ 语料内所有词(okapi 口径的自然结果) (期望=yes 实际=yes)
[PASS] 无依据的命中条数(0=每条都真的命中查询词) (期望=0 实际=0)
[PASS] top1 与契约口径重算不一致的轮次数(0=一致) (期望=0 实际=0)
[PASS] 口径失效时 health 退出码(非 0=抓到问题) (期望=2 实际=2)
[PASS] 自述被改坏但索引/语料完好时不应误报(0=不误报,仅作参考) (期望=0 实际=0)
[PASS] 删掉 conf/bm25.env 后 health 退出码 (期望=3 实际=3)
[PASS] 删掉 conf/bm25.env 后 demo.py 退出码 (期望=3 实际=3)
[PASS] 外部探针判定 (期望=OK 实际=OK)
PASS=10 FAIL=0
RESULT: OK
修复后 repro.sh 的关键对比:
== 2. 索引 vs 检索侧权重 ==
the 契约口径(索引) idf=0.492476 | 检索侧实际用=0.492476 ← 一致
dumplings 契约口径(索引) idf=1.280934 | 检索侧实际用=1.280934 ← 一致
zqxunseen 契约口径(索引) idf=0.000000 | 检索侧实际用=2.890372 ← 契约 df=0 取值
== 4. round1 查询词=['zqxunseen'] 命中=[] ← 零交集不再进 hits
== 6. RESULT: OK ← 探针退出码 0
5.2 负向验证 —— 证明闸门真的有牙
docker compose exec -T search bash ops/negative.sh
| 负向 | 注入 | 结果 |
|---|---|---|
| 基线 | 参考答案 | PASS=10 FAIL=0 RESULT: OK |
| A | 权重改回算术口径 + df=0 兜底常数 | PASS=7 FAIL=3 RESULT: BAD |
| B | 健康检查判据表清空、不读索引/语料 | health 退出码 0(闸门失效,实测复现) |
| C | 删掉 conf/bm25.env | demo.py rc=3、health rc=3 |
| D | BM25_IDF_MODE=tfidf | [FATAL] 契约 BM25_IDF_MODE 非法:'tfidf'、rc=3 |
| E | 把 idf.dumplings 改成 0.123456789 | health rc=2,报 词 dumplings 的 idf 索引值 0.123456789 ≠ 契约口径 1.2809338454620642 |
| F | 候选集改回「所有文档」 | health rc=2、探针 RESULT: BAD(rc=1) |
六、经验与避坑
6.1 通用教训
log(0)//0/ 空列表的兜底 = 悄悄换了一套口径。 兜底常数与输入无关,一旦生效,公式就不再决定结果。 正确做法是让公式对边界输入有定义(okapi 的df=0就有), 而不是if 异常输入: return 常数。- 写入侧与读取侧必须用同一个函数。 索引里存了按契约算好的 idf,
检索侧却又自己算一套 —— 这就是「算了但没用」。
自查:
grep一下同一个公式是不是出现在两个文件里。 - 健康检查的判据要落在能被外部独立复核的事实上。
issues: []是手写的空清单、self_consistent: True是写死的常量、return 0是无条件的 —— 这类「自述字段自证」比没有检查更危险, 因为它静默地骗人。 - 「判不了」≠「没问题」。 拿不到契约 / 索引 / 报告时, 默认动作必须是拒绝(fail-closed),不是跳过。
- 「稳定」不等于「正确」。
rounds_stable = True可能是 「每轮把同一份文档换个理由再选一遍」。反馈/迭代类逻辑的自查: 这一轮的输入和上一轮真的不同吗? - 每一条命中都要能说出「凭什么被选出来」。
候选集必须是「真的命中查询词」的文档,而不是「分数最高的文档」;
命中里要带
matched_terms之类的可复核证据。
6.2 踩坑记录
坑 1:诊断脚本依赖被测代码的专有 API,会在修好之后先坏给你看。
修复后跑 ops/repro.sh 直接崩:
== 2. 索引里按契约口径算好的 idf vs 检索侧真正用的权重 ==
Traceback (most recent call last):
File "<string>", line 10, in <module>
AttributeError: 'Ranker' object has no attribute 'frozen_idf'
原因:repro.sh 调用 r.frozen_idf(t),而这个方法只存在于问题态
(参考答案删掉了「算了但不用」的它)。
修法是从索引 payload 取冻结 idf(两种状态下都存在):
-from app import contract, index_store, ranker
+from app import contract, index_store, ranker, reports
r=ranker.Ranker(p, sc)
+frozen=reports.idf_table(p) # 索引里冻结的契约口径 idf
for t in ('the','dumplings','zqxunseen'):
- print(... % (t, r.frozen_idf(t) or 0.0, r.weight(t)))
+ print(... % (t, frozen.get(t) or 0.0, r.weight(t)))
教训:诊断 / 验收脚本不要依赖被测对象的内部 API,否则它会在修好之后先坏给你看。
坑 2:复现退出码要用和验收脚本一样的干净 shell。
我第一版直接在 docker compose exec bash -c 里把「移动文件」和「跑 demo」
写在同一条命令里测退出码,得到 rc=0,一度以为验收期望值写错了。
按 verify.sh 的干净流程重跑:
docker compose exec -T search bash -c '
cd /srv
mv conf/bm25.env conf/bm25.env.bak
python3 app/demo.py; echo "rc=$?" # rc=3 [FATAL] 契约不可用
mv conf/bm25.env.bak conf/bm25.env'
→ 退出码 3,与期望一致。前一条命令的副作用会串味。
坑 3:docker compose exec 在非交互环境必须加 -T。
否则报 the input device is not a TTY。CI / 流水线里务必:
docker compose exec -T search bash ops/verify.sh
坑 4:ops/negative.sh / ops/apply_fix.sh 按源码文本注入缺陷。
negative.sh 用 assert old in s 做逐字字符串替换,
所以 app/ 里最终必须与 ops/expected-fixed/ 一致,
否则会报「注入点未找到」。这是脚手架的设计,注释与代码结构也是契约。
坑 5:运行期产物别提交。
index/*.json、state/*.json、app/__pycache__/ 都是运行期产物,
各自目录下已有 .gitignore。切换修复态/问题态前清一次:
docker compose exec -T search bash -c 'cd /srv && \
rm -f index/*.json state/*.json && rm -rf app/__pycache__'
坑 6:app/、conf/、client/、ops/ 都是主机挂载,改完立刻生效。
不需要 docker compose build;但旧字节码 __pycache__ 会掩盖改动,记得清。
6.3 自查三问
- 你的排序/打分里,权重(idf / 相似度 / 归一化系数)是不是只有一个口径来源?
写入侧与读取侧是不是同一个函数算的?
代码里有没有
if 除零: return 常数、except: return 默认值这类 与输入无关的兜底? - 「没见过的输入」(OOV 词、新用户、空字段、零样本类目) 在打分函数里走的是哪条分支?是由契约/公式决定的确定取值, 还是一个写死的常数?这个常数换个数据集还成立吗?
- 你的健康检查判据取的是自己的计数器还是被检对象的事实
(能不能拿语料 + 契约自己重算一遍)?判据表是不是空的?
main()是不是无条件return 0?「判不了」时是放行还是拒绝? 返回的每一条命中,你能不能说出它凭什么被选出来?
6.4 同类影响面
- 检索 / 推荐 / 排序:BM25 / TF-IDF / 向量召回;
sklearn.TfidfVectorizer(smooth_idf=...)与自研实现口径不一致; Elasticsearchsimilarity: BM25的k1/b与自研打分并存; OOV 词向量;冷启动用户的默认特征。 - 一切「公式里有除零 / 对数 / 开方」的打分:
log(df)、1/df、sqrt(0)、x/avg(平均值为 0)—— 兜底成常数 = 悄悄换了一套口径。 - 一切「默认参数静默生效」:
dict.get(k, 默认值)掩盖配置缺失、or 默认值吃掉合法的 0 / 空字符串、框架默认值与业务契约不一致。 - 一切「口径开关」型契约:
BM25_IDF_MODE/CHAIN_PREV_MODE这类 配置读不到或写错时,是大声失败还是静默按某一种认。 - 一切「无依据的命中」:缓存命中却没校验 key、 特征平台返回了没被请求的特征、推荐结果不在候选池里。
- 一切「自述字段自证」的健康检查:
self_consistent: true手写常量、issues: []手写空清单、BAD_FIELDS = ()空判据表、return 0恒定退出码。
