朕喜技术(ZhenxiJS)
2026.10.06
1 分钟阅读

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.envdemo.py rc=3、health rc=3
DBM25_IDF_MODE=tfidf[FATAL] 契约 BM25_IDF_MODE 非法:'tfidf'、rc=3
E把 idf.dumplings 改成 0.123456789health rc=2,报 词 dumplings 的 idf 索引值 0.123456789 ≠ 契约口径 1.2809338454620642
F候选集改回「所有文档」health rc=2、探针 RESULT: BAD(rc=1)

六、经验与避坑

6.1 通用教训

  1. log(0) / /0 / 空列表的兜底 = 悄悄换了一套口径。 兜底常数与输入无关,一旦生效,公式就不再决定结果。 正确做法是让公式对边界输入有定义(okapi 的 df=0 就有), 而不是 if 异常输入: return 常数。
  2. 写入侧与读取侧必须用同一个函数。 索引里存了按契约算好的 idf, 检索侧却又自己算一套 —— 这就是「算了但没用」。 自查:grep 一下同一个公式是不是出现在两个文件里。
  3. 健康检查的判据要落在能被外部独立复核的事实上。issues: [] 是手写的空清单、self_consistent: True 是写死的常量、 return 0 是无条件的 —— 这类「自述字段自证」比没有检查更危险, 因为它静默地骗人。
  4. 「判不了」≠「没问题」。 拿不到契约 / 索引 / 报告时, 默认动作必须是拒绝(fail-closed),不是跳过。
  5. 「稳定」不等于「正确」。 rounds_stable = True 可能是 「每轮把同一份文档换个理由再选一遍」。反馈/迭代类逻辑的自查: 这一轮的输入和上一轮真的不同吗?
  6. 每一条命中都要能说出「凭什么被选出来」。 候选集必须是「真的命中查询词」的文档,而不是「分数最高的文档」; 命中里要带 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 自查三问

  1. 你的排序/打分里,权重(idf / 相似度 / 归一化系数)是不是只有一个口径来源? 写入侧与读取侧是不是同一个函数算的? 代码里有没有 if 除零: return 常数、except: return 默认值 这类 与输入无关的兜底?
  2. 「没见过的输入」(OOV 词、新用户、空字段、零样本类目) 在打分函数里走的是哪条分支?是由契约/公式决定的确定取值, 还是一个写死的常数?这个常数换个数据集还成立吗?
  3. 你的健康检查判据取的是自己的计数器还是被检对象的事实 (能不能拿语料 + 契约自己重算一遍)?判据表是不是空的? main() 是不是无条件 return 0?「判不了」时是放行还是拒绝? 返回的每一条命中,你能不能说出它凭什么被选出来?

6.4 同类影响面

  • 检索 / 推荐 / 排序:BM25 / TF-IDF / 向量召回; sklearn.TfidfVectorizer(smooth_idf=...) 与自研实现口径不一致; Elasticsearch similarity: 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 恒定退出码。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-10-06-0800-bm25-idf-df0-top1

分享这篇文章