code wiki / (root) / nx_bm25.nx

nx_bm25.nx source

↩ module page · 157 lines · 7806 B

1// nx_bm25.nx -- production-grade ranked retrieval: Okapi BM25 (Robertson & Sparck Jones), integer-only. 2// This is the HONEST upgrade over the team's first TF-IDF ranker, which lacked LENGTH NORMALIZATION: 3// a long document that mentions a term once would out-rank a short focused document with the same 4// per-length relevance. BM25 fixes that with (1) tf SATURATION (k1) -- the 10th occurrence adds less 5// than the 2nd -- and (2) LENGTH NORMALIZATION (b) -- penalize long docs by dl/avgdl. The point of 6// building the real standard (not a weak baseline) is to TRIANGULATE: nx_bm25_test asserts the team's 7// integer ranking reproduces a float reference BM25's ranking, so the "exceed" is measured against the 8// production formula, not cherry-picked. license_tier: ORIGINAL 9// 10// BM25(q,d) = sum_t idf(t) * tf(t,d)*(k1+1) / ( tf(t,d) + k1*(1 - b + b*dl/avgdl) ) 11// idf(t) = ln( 1 + (N - df + 0.5)/(df + 0.5) ) = ln( (2N+2)/(2df+1) ) [always positive] 12 13import "nx_research_extract.nx" // re_count / re_has / re_strlen 14import "nx_syscalls.nx" 15 16// standard BM25 parameters (Robertson/Sparck Jones); kept as named scaled constants, not magic numbers. 17const BM_K1_X1000: i64 = 1200 // k1 = 1.2 (term-frequency saturation) 18const BM_B_X1000: i64 = 750 // b = 0.75 (length-normalization strength) 19const BM_MICRO: i64 = 1000000 // fixed-point scale for ln / idf 20const BM_MILLI: i64 = 1000 // fixed-point scale for the saturation factor 21 22// number of whitespace-delimited tokens in text[0..n) -- the document length dl BM25 normalizes by. 23func bm_token_count(text: *u8, n: i64) -> i64 { 24 var c: i64 = 0; var i: i64 = 0; var in_tok: i64 = 0 25 while i < n { 26 let ch: i64 = text[i] 27 if ch == 32 { in_tok = 0 } else { if ch == 10 { in_tok = 0 } else { if ch == 13 { in_tok = 0 } else { if in_tok == 0 { c = c + 1; in_tok = 1 } } } } 28 i = i + 1 29 } 30 return c 31} 32 33// ln(num/den) in micro-units (x1e6) via the fast-converging atanh series: 34// ln(x) = 2*( z + z^3/3 + z^5/5 + ... ), z = (x-1)/(x+1) = (num-den)/(num+den). 35// num,den small positive ints; intermediate products stay well under i64. 36func bm_ln_micro(num: i64, den: i64) -> i64 { 37 if num <= 0 { return 0 } 38 if den <= 0 { return 0 } 39 let z: i64 = ((num - den) * BM_MICRO) / (num + den) // x1e6, signed 40 let zsq: i64 = (z * z) / BM_MICRO // x1e6 41 var sum: i64 = z 42 var zpow: i64 = z 43 var k: i64 = 3 44 while k <= 11 { 45 zpow = (zpow * zsq) / BM_MICRO // z^k, x1e6 46 sum = sum + zpow / k 47 k = k + 2 48 } 49 return 2 * sum 50} 51 52// BM25 idf in micro-units. df = document frequency (1..N). always positive (the +1 form). 53func bm_idf_micro(N: i64, df: i64) -> i64 { return bm_ln_micro(2 * N + 2, 2 * df + 1) } 54 55// the tf-saturation x length-normalization factor, in milli-units (x1000): 56// tf*(k1+1) / ( tf + k1*(1 - b + b*dl/avgdl) ) 57func bm_sat_milli(tf: i64, dl: i64, avgdl: i64) -> i64 { 58 if avgdl <= 0 { return 0 } 59 let norm_x1000: i64 = (BM_MILLI - BM_B_X1000) + (BM_B_X1000 * dl) / avgdl // (1-b)+b*dl/avgdl, x1000 60 let den_x1000: i64 = tf * BM_MILLI + (BM_K1_X1000 * norm_x1000) / BM_MILLI // tf + k1*norm, x1000 61 if den_x1000 <= 0 { return 0 } 62 let num_x1000: i64 = tf * (BM_K1_X1000 + BM_MILLI) // tf*(k1+1), x1000 63 return (num_x1000 * BM_MILLI) / den_x1000 // SAT x1000 64} 65 66// document frequency of `term` across the N-doc corpus (ptrs[i]=doc text as i64, lens[i]=its length). 67func bm_df(ptrs: *i64, lens: *i64, N: i64, term: *u8) -> i64 { 68 var c: i64 = 0; var i: i64 = 0 69 while i < N { if re_has(ptrs[i] as *u8, lens[i], term) == 1 { c = c + 1 } i = i + 1 } 70 return c 71} 72 73// BM25 score of doc k for an nq-term query (qterms[t] = a *u8 needle as i64). scale = x1e6. 74func bm_score(ptrs: *i64, lens: *i64, dls: *i64, N: i64, k: i64, avgdl: i64, qterms: *i64, nq: i64) -> i64 { 75 var s: i64 = 0; var t: i64 = 0 76 while t < nq { 77 let term: *u8 = qterms[t] as *u8 78 let tf: i64 = re_count(ptrs[k] as *u8, lens[k], term) 79 if tf > 0 { 80 let idf: i64 = bm_idf_micro(N, bm_df(ptrs, lens, N, term)) // x1e6 81 let sat: i64 = bm_sat_milli(tf, dls[k], avgdl) // x1e3 82 s = s + (idf * sat) / BM_MILLI // keep x1e6 83 } 84 t = t + 1 85 } 86 return s 87} 88 89// the best-ranked doc index for the query (-1 if nothing matches). 90func bm_best(ptrs: *i64, lens: *i64, dls: *i64, N: i64, avgdl: i64, qterms: *i64, nq: i64) -> i64 { 91 var best: i64 = 0 - 1; var bestscore: i64 = 0; var i: i64 = 0 92 while i < N { 93 let sc: i64 = bm_score(ptrs, lens, dls, N, i, avgdl, qterms, nq) 94 if sc > bestscore { bestscore = sc; best = i } 95 i = i + 1 96 } 97 return best 98} 99 100// nx_bm25_score -- the BATCH entry point FIVE shipped organs call and that was NEVER DEFINED: 101// nx_dms_search.nx:122 (rights-enforced DMS search), nx_rank_fused.nx:61, nx_research_digest.nx:53, 102// wiki/nx_wiki_bm25_search.nx:305, and _retired/nx_bm25_test.nx. The lib only ever exposed the 103// PER-DOCUMENT bm_score(); every caller wanted "score the whole corpus into an array", and the 104// layering violation (nx_bm25.nx stranded in _hdl_build/) meant none of them could compile far enough 105// to report the missing symbol. Fixing the layer is what made this visible. 106// CONTRACT PINNED BY THE EXISTING KAT (_retired/nx_bm25_test.nx), which asserts RELATIONAL invariants 107// rather than hand-computed constants -- the honest way to test a ranker: 108// present-term doc -> score > 0 109// absent-term doc -> score EXACTLY 0 (holds by construction: bm_score guards on tf > 0) 110// 3x term frequency at EQUAL length -> STRICTLY higher score (saturation must stay monotonic) 111// dls (token lengths) and avgdl are derived HERE because they are corpus-wide properties: a per-doc 112// caller cannot know avgdl, which is precisely why length normalization was missing from the old 113// TF-IDF ranker this lib replaced. Deriving them per call keeps the batch self-contained. 114// qlens is accepted for caller signature compatibility and intentionally UNUSED -- the underlying 115// scorer takes NUL-terminated needles (re_count/re_has), so term lengths are redundant. Kept in the 116// signature because five call sites already pass it; removing it would break every one of them. 117func nx_bm25_score(docs: **u8, dlens: *i64, n: i64, qterms: **u8, qlens: *i64, nq: i64, out: *i64) -> i64 { 118 if n <= 0 { return 0 } 119 let dls: *i64 = sys_mmap(n * 8) as *i64 120 var i: i64 = 0 121 var total: i64 = 0 122 while i < n { 123 dls[i] = bm_token_count(docs[i], dlens[i]) 124 total = total + dls[i] 125 i = i + 1 126 } 127 let avgdl: i64 = total / n 128 var k: i64 = 0 129 while k < n { 130 out[k] = bm_score(docs as *i64, dlens, dls, n, k, avgdl, qterms as *i64, nq) 131 k = k + 1 132 } 133 sys_munmap(dls as *u8, n * 8) 134 return 0 135} 136 137// the NAIVE tf*idf score (NO length normalization) -- the team's old ranker, kept to PROVE the flip. 138func bm_naive_tfidf(ptrs: *i64, lens: *i64, N: i64, k: i64, qterms: *i64, nq: i64) -> i64 { 139 var s: i64 = 0; var t: i64 = 0 140 while t < nq { 141 let term: *u8 = qterms[t] as *u8 142 let tf: i64 = re_count(ptrs[k] as *u8, lens[k], term) 143 if tf > 0 { s = s + tf * bm_idf_micro(N, bm_df(ptrs, lens, N, term)) } 144 t = t + 1 145 } 146 return s 147} 148 149func bm_naive_best(ptrs: *i64, lens: *i64, N: i64, qterms: *i64, nq: i64) -> i64 { 150 var best: i64 = 0 - 1; var bestscore: i64 = 0; var i: i64 = 0 151 while i < N { 152 let sc: i64 = bm_naive_tfidf(ptrs, lens, N, i, qterms, nq) 153 if sc > bestscore { bestscore = sc; best = i } 154 i = i + 1 155 } 156 return best 157}