Skip to content

Commit ffd459e

Browse files
committed
Optimize BM25 scoring by caching total token count for O(1) avgdl calculation
1 parent 160a780 commit ffd459e

1 file changed

Lines changed: 28 additions & 18 deletions

File tree

src/model.rs

Lines changed: 28 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,17 @@ pub type Docs = HashMap::<PathBuf, Doc>;
3434

3535
#[derive(Default, Deserialize, Serialize)]
3636
pub struct InMemoryModel {
37-
pub gtf: GlobalTermFreq,
38-
pub docs: Docs,
37+
pub gtf: GlobalTermFreq,
38+
pub docs: Docs,
39+
/// Cached sum of all doc.count values. Kept in sync by add_document /
40+
/// remove_document so that avgdl can be computed in O(1) at query time.
41+
pub total_tokens: usize,
3942
}
4043

44+
/// O(1) — uses the cached total_tokens field instead of iterating all docs.
4145
fn compute_avgdl(model: &InMemoryModel) -> f32 {
42-
let total: usize = model.docs.values().map(|doc| doc.count).sum();
43-
total as f32 / model.docs.len() as f32
46+
if model.docs.is_empty() { return 0.0; }
47+
model.total_tokens as f32 / model.docs.len() as f32
4448
}
4549

4650
fn compute_idf(term: &str, model: &InMemoryModel) -> f32 {
@@ -56,16 +60,16 @@ fn compute_idf(term: &str, model: &InMemoryModel) -> f32 {
5660
// K and B are free parameters, usually chosen, in absence of an advanced optimization, as K = [1.2, 2.0] and B = 0.75
5761
const K: f32 = 2.0;
5862
const B: f32 = 0.75;
59-
fn bm25_score(query: &Vec<String>, doc: &Doc, model: &InMemoryModel) -> f32 {
63+
/// avgdl is passed in so it can be computed once per query rather than once per document.
64+
fn bm25_score(query: &Vec<String>, doc: &Doc, model: &InMemoryModel, avgdl: f32) -> f32 {
6065
// Ranking documents according to BM25 Algorithm: https://en.wikipedia.org/wiki/Okapi_BM25
61-
let mut score= 0f32;
62-
let avgdl = compute_avgdl(model);
66+
let mut score = 0f32;
6367
let doc_length = doc.count as f32;
6468

6569
for term in query {
6670
// Number of times a term occurs in the document
6771
let tf = doc.ft.get(term).copied().unwrap_or(0) as f32;
68-
let idf = compute_idf(&term, model);
72+
let idf = compute_idf(term, model);
6973

7074
let denom = tf + K * (1f32 - B + B * doc_length / avgdl);
7175
score += idf * tf * (K + 1f32) / denom;
@@ -88,8 +92,9 @@ fn idf(term: &str, model: &InMemoryModel) -> f32 {
8892

8993
impl InMemoryModel {
9094
fn remove_document(&mut self, file_path: &Path) {
91-
// Remove the doc from docs
9295
if let Some(doc) = self.docs.remove(file_path) {
96+
// Keep the cached total in sync
97+
self.total_tokens = self.total_tokens.saturating_sub(doc.count);
9398
for term in doc.ft.keys() {
9499
// Update the GlobalTermFrequency table
95100
if let Some(freq) = self.gtf.get_mut(term) {
@@ -103,13 +108,16 @@ impl InMemoryModel {
103108
use crate::RankMethod;
104109
impl Model for InMemoryModel {
105110
fn search_query(&self, query: &[char], model: &InMemoryModel, rank_method: RankMethod) -> Result<Vec<(PathBuf, f32)>, ()> {
106-
let tokens = Lexer::new(&query).collect::<Vec<_>>();
107-
111+
let tokens = Lexer::new(query).collect::<Vec<_>>();
112+
113+
// Compute avgdl once per query (O(1) with cached total_tokens).
114+
let avgdl = compute_avgdl(self);
115+
108116
let mut results = Vec::with_capacity(self.docs.len());
109117
for (path, doc) in &self.docs {
110118
let rank = if rank_method == RankMethod::Bm25 {
111-
// BM-25 Ranking
112-
bm25_score(&tokens, doc, model)
119+
// BM-25 Ranking — avgdl already computed above, not per-doc
120+
bm25_score(&tokens, doc, model, avgdl)
113121
} else {
114122
// TF-IDF Ranking
115123
tokens.iter()
@@ -136,16 +144,19 @@ impl Model for InMemoryModel {
136144
ft.entry(token.clone()).and_modify(|x| *x += 1).or_insert(1);
137145
}
138146

139-
// Total count of terms in FreqTable
140-
let term_count = ft.iter().map(|(_, c)| *c).sum();
147+
// Total count of terms in FreqTable
148+
let term_count: usize = ft.values().sum();
141149

142150
// Update global term frequency
143151
for term in ft.keys() {
144152
self.gtf.entry(term.to_owned()).and_modify(|x| *x += 1).or_insert(1);
145153
}
146154

155+
// Keep the cached total in sync
156+
self.total_tokens += term_count;
157+
147158
// Update the Docs table
148-
self.docs.insert(file_path, Doc { count: term_count, ft: ft , last_modified: last_modified});
159+
self.docs.insert(file_path, Doc { count: term_count, ft, last_modified });
149160
Ok(())
150161
}
151162

@@ -157,5 +168,4 @@ impl Model for InMemoryModel {
157168
}
158169
}
159170

160-
// TODO: BM25 is very slow, optimize it
161-
// TODO: Implement a efficient sqlite Model with parellel processing support
171+
// TODO: Implement an efficient sqlite Model with parallel processing support

0 commit comments

Comments
 (0)