Skip to content

recall: fix BM25 length-norm precision + host benchmark vs float BM25 (NDCG parity, faster, deterministic)#92

Open
bahadirarda wants to merge 5 commits into
mainfrom
recall-bench
Open

recall: fix BM25 length-norm precision + host benchmark vs float BM25 (NDCG parity, faster, deterministic)#92
bahadirarda wants to merge 5 commits into
mainfrom
recall-bench

Conversation

@bahadirarda

Copy link
Copy Markdown
Contributor

Summary

Research-grounded, benchmarked answer to "is Yuva's no-float verified recall actually good, and how does it stand against the people who do this?" — with an honest before/after and a real fix the benchmark surfaced.

Two commits:

  1. tb-encode/recall: length-normalization precision fix — a one-line arithmetic reorder in the verified leaf.
  2. tools/recall-bench: a host harness that benchmarks the actual Kani-proven leaf against the float BM25 implementations everyone else uses, same-box, on BEIR NFCorpus.

The bug the benchmark found

bm25_tf_norm evaluated the document-length ratio as BM25_B * (dl / avgl) — an integer-floored ratio. For any document shorter than average (dl < avgl, the common case) this floors to 0 and silently disables length normalization; in general it collapses a continuous ratio to a 3–4 step function. Reordering to (BM25_B * dl) / avgl (multiply-before-divide) keeps ~3 fractional digits while staying pure integer / no-float.

Identical for the single-token records the kernel feeds today (dl == avgl == 1), so the M40 boot KAT is unchanged; it only sharpens multi-token ranking (where Cogi's memory is heading).

Result — BEIR NFCorpus test, 323 queries, one WSL box

system NDCG@10 Recall@10 MAP QPS deterministic
bm25s (float, lucene — 2024 SOTA) 0.3052 0.1435 0.1164 ~3,250 ✗ float
rank-bm25 (float, okapi) 0.3056 0.1446 0.1166 ~840 ✗ float
Rust-float (ideal, same harness) 0.3052 0.1435 0.1164 ~15,900 ✗ float
Yuva no-float leaf (integer) 0.3051 0.1433 0.1164 ~19,400 ✓ bit-exact

Before the fix, the integer leaf scored NDCG@10 0.2887 (−5.4% vs float) with int-vs-float top-10 overlap 0.74 / top-1 52%. After: 0.3051 (float twin 0.3052), overlap 0.9996, top-1 92%.

So the verified integer leaf now matches float BM25 ranking quality, runs ~22–30% faster than the same-harness ideal-float (integer arithmetic, no FP division), and is the only implementation that is both bit-exact deterministic and formally machine-checked.

Why this comparison is fair (and honest)

  • All systems consume identical tokens (bm25s standard pipeline, stopwords=english, no stemmer); k1=1.2 b=0.75 everywhere; one canonical evaluator (pytrec_eval). The harness asserts the interchange's params match the leaf's frozen constants.
  • bm25s method='lucene' is Yuva's exact IDF twin (ln((N+1)/(df+0.5))), so the comparison isolates float vs integer fixed-point and nothing else. The in-harness ideal-float run reproduces bm25s NDCG@10 to 4 decimals — validating the harness.
  • Absolute QPS is hardware-specific; only the same-box figures here are comparable (not compared to any paper's numbers from other hardware).
  • The harness links the same tb_encode::recall::bm25_doc_score the kernel scores with — never a second BM25. Index / candidate-gen / top-k are harness plumbing (the kernel keeps those in tb-hal).

Literature grounding

BM25 stays competitive-to-superior out-of-domain and on precise-terminology corpora (BEIR; beats text-embedding-3-large on finance/code term matching). For an agent reading its own memory, exact-match lexical retrieval gives deterministic, interpretable, verifiable-text matches that embedding similarity does not (Beyond Semantic Similarity: Agentic Search via Direct Corpus Interaction, arXiv 2605.05242). Yuva's leaf is the retrieval primitive; QA-memory systems (Mem0, Letta, A-MEM) sit a layer above and could run on it.

Verification

  • 13 recall host tests pass; the 5 affected recall Kani harnesses (idf / tf_norm / term_score / absent-is-zero / doc-score-monotone) all VERIFICATION: SUCCESSFUL (the proven properties — denom>0, [0, TF_NORM_CEIL), non-negativity, monotonicity — are scale-agnostic).
  • tools/recall-bench is a nested workspace (tb-vmm / prov-signer precedent) — the kernel kbuild / -Zbuild-std lanes are untouched.
  • Reproduces from a fresh checkout: prep_bench.pycargo run --releaseeval_all.py (README has the steps + dataset fetch).

…-divide)

bm25_tf_norm computed the document-length ratio as BM25_B * (dl / avgl) -- an
integer-FLOORED ratio. For the common dl < avgl document this floors to 0 and
drops length normalization entirely; more generally it collapses the ratio to a
step function. Reordering to (BM25_B * dl) / avgl keeps ~3 fractional digits of
dl/avgl while staying pure integer.

Measured on BEIR NFCorpus (tools/recall-bench, next commit), this recovers full
float-BM25 ranking parity:
  * NDCG@10 0.2887 -> 0.3051  (ideal-float twin 0.3052; bm25s lucene 0.3052)
  * integer-vs-float top-10 overlap 0.7389 -> 0.9996, top-1 agree 52% -> 92%
at zero cost to determinism (bit-exact), verifiability, or the ~22% integer
speed edge. IDENTICAL for the single-token records the kernel feeds today
(dl == avgl == 1 => 250 + 750 either way), so the M40 boot KAT is unchanged.

Verified: 13 recall host tests pass; the 5 affected recall Kani harnesses
(idf / tf_norm / term_score / absent-is-zero / doc-score-monotone) all
VERIFICATION SUCCESSFUL (the proven properties are scale-agnostic).
A std, nested-workspace host harness (the tb-vmm / prov-signer precedent, so the
kernel kbuild + -Zbuild-std lanes are untouched) that benchmarks the verified
integer recall leaf against the float implementations everyone else uses, on
BEIR NFCorpus, same box. It links the SAME Kani-proven
tb_encode::recall::bm25_doc_score the kernel scores with -- never a second BM25
(the same-math rule); the index / candidate-gen / top-k are harness plumbing.

Fairness: all systems consume identical tokens (bm25s standard pipeline,
stopwords=english, no stemmer), k1=1.2 b=0.75 everywhere, one canonical evaluator
(pytrec_eval). bm25s method='lucene' is Yuva's EXACT IDF twin, so the comparison
isolates float vs integer fixed-point; the in-harness ideal-float run reproduces
bm25s NDCG@10 to 4 decimals, validating the harness.

Result (NFCorpus test, 323 q, one WSL box): the verified integer leaf matches
float BM25 ranking quality (NDCG@10 0.3051 vs float 0.3052 / bm25s 0.3052 /
rank-bm25 0.3056) while running ~22-30% faster than the same-harness ideal-float
and being the only implementation that is bit-exact deterministic AND formally
machine-checked. Absolute QPS is hardware-specific -- only the same-box figures
are comparable.

Includes prep_bench.py (tokenize + float baselines + interchange export) and
eval_all.py (canonical scoring) so the run reproduces from a fresh checkout;
README documents the steps + the literature grounding (BM25 vs dense out-of-
domain; agentic direct-corpus-interaction).
…aming

Parameterize the corpus (IRBENCH_DS, default nfcorpus) and record SciFact
alongside NFCorpus. SciFact confirms the robust claims and corrects an
overclaim the single-dataset table invited:

  * quality parity holds on BOTH sets (Yuva 0.6634 vs float 0.6625 NDCG@10 on
    SciFact; int-vs-float top-10 overlap 0.997 on both);
  * the length-norm fix's principled effect is FIDELITY (overlap 0.74->0.997 on
    both), which recovered -5.4% NDCG on NFCorpus but was NDCG-neutral on SciFact
    (floored 0.6667 vs fixed 0.6634 -- benign noise, not a gain -- stated plainly);
  * the durable SPEED win is vs the SAME-HARNESS float (~17-22%); bm25s's numpy
    vectorisation actually beats the leaf's scalar scoring on long-doc SciFact
    (~3,290 vs ~2,520 QPS) while the leaf wins on short-doc NFCorpus. No universal
    "faster than bm25s" claim.

Determinism + Kani-verification remain the leaf's unique, non-workload-dependent
advantages.
@bahadirarda

Copy link
Copy Markdown
Contributor Author

Update: added SciFact as a second dataset + honest corrections to the single-dataset framing.

SciFact (5,183 docs, 300 test q): Yuva no-float 0.6634 NDCG@10 vs float twin 0.6625 — parity holds on a second dataset (int-vs-float top-10 overlap 0.997 on both). The in-harness ideal-float again equals bm25s to 4 decimals, revalidating the harness.

Two things the single-dataset table over-implied, now stated plainly in the README:

  • Speed: the durable win is vs the same-harness ideal-float (~17–22%, both datasets). It is not universally faster than bm25s — bm25s's numpy vectorisation beats the leaf's scalar scoring on long-document SciFact (~3,290 vs ~2,520 QPS), while the leaf wins on short-document NFCorpus (~19,400 vs ~3,250). Determinism + Kani-verification are the leaf's workload-independent advantages.
  • The fix: its principled effect is fidelity to real BM25 (overlap 0.74→0.997 on both). That recovered −5.4% NDCG on NFCorpus; on SciFact NDCG was float-parity either way (floored 0.6667 vs fixed 0.6634 — benign noise, not a gain).

…'t accumulate

bm25_doc_score summed bm25_term_score values, each of which had ALREADY divided
idf*tf_norm by SCALE -- so one rounding error accrued per query term. On short
queries (NFCorpus, SciFact) this is invisible, but on long multi-term queries the
accumulated error visibly drifts the integer ranking away from real BM25.

Surfaced by adding BEIR ArguAna (queries are whole arguments, ~hundreds of terms)
to tools/recall-bench: int-vs-float top-10 fidelity was only 0.75, and the integer
leaf scored NDCG@10 0.3679 vs float 0.3551 -- NOT a win, just the divergence
landing favourably on ArguAna's judgments (the same benign-noise trap SciFact
showed before the length-norm fix).

Fix: accumulate the per-term idf*tf_norm products at full fixed-point scale and
divide by SCALE ONCE at the end (saturating throughout). Result across all three
datasets is now faithful float-BM25 parity:
  * ArguAna  fidelity 0.7535 -> 0.9962,  Yuva NDCG 0.3679 -> 0.3552 (float 0.3551)
  * NFCorpus fidelity 0.9996 -> 0.9992,  NDCG 0.3051 (float 0.3052) -- unchanged
  * SciFact  fidelity 0.9970,            NDCG 0.6634 (float 0.6625) -- unchanged

IDENTICAL to bm25_term_score for a single-term query (idf*tf_norm/SCALE), so the
kernel's single-token production path (which calls bm25_idf, not bm25_doc_score)
and the multi-term recall_selftest KAT -- which asserts RELATIONAL properties
(A>B, A>C, b_plus>=B), all robust to the <=1 per-score shift -- are unaffected.

Verified: 13 recall host tests pass; kani_recall_doc_score_accumulation_monotone +
term_score panic/absent harnesses VERIFICATION SUCCESSFUL (monotonicity holds:
per-term products are non-negative, saturating_add accumulates, integer /SCALE is
order-preserving); clippy clean.
…on fixes

Consolidated NDCG/fidelity/QPS table across NFCorpus, SciFact, ArguAna showing
float-BM25 parity + fidelity >=0.996 on all three after the two precision fixes,
and the honest speed picture (integer beats same-harness float everywhere;
bm25s's vectorisation wins as candidates-per-query grow, decisively on ArguAna).
@bahadirarda

Copy link
Copy Markdown
Contributor Author

Update 2: a THIRD dataset (ArguAna) surfaced a second precision bug — now fixed.

ArguAna's queries are whole arguments (~hundreds of terms). There the integer leaf's fidelity was only 0.75 and it scored NDCG@10 0.3679 vs float 0.3551 — which looks like beating float but is not: it's the same benign-noise trap (rounding divergence landing favourably), not skill.

Root cause: bm25_doc_score summed bm25_term_score values, each already divided by SCALE — so one rounding error accrued per query term, invisible on short queries but drifting long ones. Fix: accumulate the per-term idf*tf_norm at full scale and divide by SCALE once at the end (saturating throughout).

Final, all three datasets, after both fixes — faithful float-BM25 parity:

dataset Yuva NDCG@10 float fidelity
NFCorpus 0.3051 0.3052 0.999
SciFact 0.6634 0.6625 0.997
ArguAna 0.3552 0.3551 0.996

Identical to bm25_term_score for a single-term query, so the kernel's single-token path (which calls bm25_idf, not bm25_doc_score) and the multi-term recall_selftest KAT (relational asserts A>B, A>C, b_plus>=B — robust to the ≤1 per-score shift) are unaffected. kani_recall_doc_score_accumulation_monotone + host tests + clippy all green locally.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant