code wiki / _hdl_build / nx_bm25f.nx
nx_bm25f.nx source
↩ module page · 90 lines · 5166 B
1// nx_bm25f.nx -- BM25F: fielded BM25 (Robertson/Zaragoza "Simple BM25 extension to multiple weighted fields",
2// CIKM'04 -- cited sovereign-fetched lib_bm25.txt: "BM25F defines each type of field as a stream, applying a
3// per-stream weighting"). The honest relevance rung above plain BM25: a query term in the TITLE field should
4// outrank the same term buried in the BODY. The fix is exactly what nx_bm25's own header predicted -- "a thin
5// extension via a weight table". Two fields here (title, body); adding more (url/anchor/tags) = more rows in
6// the weight table, no formula change (Cardinal 11/22).
7//
8// SIMPLE BM25F (the correct form -- length-normalize + weight PER FIELD, then saturate ONCE over the combined
9// pseudo-frequency, so a title hit isn't double-counted):
10// pseudo_tf(t,d) = SUM_f w_f * tf(t,f,d) / ( 1 - b + b * dl_f/avgdl_f )
11// score(d,q) = SUM_t IDF(t) * pseudo_tf*(k1+1) / ( k1 + pseudo_tf )
12// Integer/fixed-point throughout (reuses nx_bm25's bm_idf_micro keystone), x1e6 scale like bm_score.
13// license_tier: ORIGINAL
14import "nx_bm25.nx" // bm_idf_micro / bm_token_count / re_count / bm_best (+ syscalls, re_*)
15
16const BMF_K1_X1000: i64 = 1200 // k1 = 1.2 (tf saturation)
17const BMF_B_X1000: i64 = 750 // b = 0.75 (length normalization)
18const BMF_W_TITLE: i64 = 3 // title-field boost inside the per-field pseudo-tf (weight table)
19const BMF_W_BODY: i64 = 1 // body-field boost
20// TITLE-MATCH topical bonus (additive, OUTSIDE the saturation): a query term that occurs in the page TITLE
21// signals the page is ABOUT that term, so it should not be swamped by a rival page that merely repeats one
22// rare body term many times. Without this, BM25 tf-saturation flattens magnitude so a single high-IDF body
23// term wins (e.g. "NIST STEM maturity": roadmap's body maturity x5 beat the nist_stem page whose TITLE is
24// literally "nist stem"). The bonus is IDF-weighted (a title hit on a rare term matters more) and additive,
25// so it can only LIFT a page whose title matches -- it never demotes an existing body-only winner.
26const BMF_TITLE_MATCH_MULT: i64 = 2 // each title-matched query term adds (BMF_TITLE_MATCH_MULT * IDF) to score
27
28// per-field weighted + length-normalized pseudo-tf contribution, x1000: w * tf / (1 - b + b*dl/avgdl)
29func bmf_contrib(w: i64, tf: i64, dl: i64, avgdl: i64) -> i64 {
30 if tf <= 0 { return 0 }
31 var avd: i64 = avgdl
32 if avd <= 0 { avd = 1 }
33 let norm_x1000: i64 = (1000 - BMF_B_X1000) + (BMF_B_X1000 * dl) / avd // (1-b)+b*dl/avgdl, x1000
34 if norm_x1000 <= 0 { return 0 }
35 return (w * tf * 1000 * 1000) / norm_x1000 // w*tf/(norm), as x1000
36}
37
38// document frequency of `term` across N docs, counting a doc if the term is in its TITLE OR BODY field.
39func bmf_df(titles: *i64, tlens: *i64, bodies: *i64, blens: *i64, N: i64, term: *u8) -> i64 {
40 var c: i64 = 0; var i: i64 = 0
41 while i < N {
42 var has: i64 = 0
43 if re_count(titles[i] as *u8, tlens[i], term) > 0 { has = 1 }
44 if re_count(bodies[i] as *u8, blens[i], term) > 0 { has = 1 }
45 if has == 1 { c = c + 1 }
46 i = i + 1
47 }
48 return c
49}
50
51// BM25F score of doc k (title+body fields) for an nq-term query. x1e6 scale.
52func bmf_score(titles: *i64, tlens: *i64, dlt: *i64,
53 bodies: *i64, blens: *i64, dlb: *i64,
54 N: i64, k: i64, avgt: i64, avgb: i64,
55 qterms: *i64, nq: i64) -> i64 {
56 var s: i64 = 0; var t: i64 = 0
57 while t < nq {
58 let term: *u8 = qterms[t] as *u8
59 let tf_t: i64 = re_count(titles[k] as *u8, tlens[k], term)
60 let tf_b: i64 = re_count(bodies[k] as *u8, blens[k], term)
61 let ptf_x1000: i64 = bmf_contrib(BMF_W_TITLE, tf_t, dlt[k], avgt) + bmf_contrib(BMF_W_BODY, tf_b, dlb[k], avgb)
62 if ptf_x1000 > 0 {
63 let df: i64 = bmf_df(titles, tlens, bodies, blens, N, term)
64 let idf: i64 = bm_idf_micro(N, df) // x1e6
65 let num_x1000: i64 = ((BMF_K1_X1000 + 1000) * ptf_x1000) / 1000 // (k1+1)*ptf, x1000
66 let den_x1000: i64 = BMF_K1_X1000 + ptf_x1000 // k1 + ptf, x1000
67 if den_x1000 > 0 {
68 let sat_x1000: i64 = (num_x1000 * 1000) / den_x1000 // x1000
69 s = s + (idf * sat_x1000) / 1000 // keep x1e6
70 }
71 // additive title-match topical bonus (IDF-weighted, outside saturation): the page is ABOUT this term.
72 if tf_t > 0 { s = s + BMF_TITLE_MATCH_MULT * idf }
73 }
74 t = t + 1
75 }
76 return s
77}
78
79// best-ranked doc index for the query under BM25F (-1 if nothing matches).
80func bmf_best(titles: *i64, tlens: *i64, dlt: *i64,
81 bodies: *i64, blens: *i64, dlb: *i64,
82 N: i64, avgt: i64, avgb: i64, qterms: *i64, nq: i64) -> i64 {
83 var best: i64 = 0 - 1; var bs: i64 = 0; var i: i64 = 0
84 while i < N {
85 let sc: i64 = bmf_score(titles, tlens, dlt, bodies, blens, dlb, N, i, avgt, avgb, qterms, nq)
86 if sc > bs { bs = sc; best = i }
87 i = i + 1
88 }
89 return best
90}