Skip to content

Commit 2185e80

Browse files
committed
Implement BM25 Algorithm for better Document Ranking
1 parent 55897bf commit 2185e80

3 files changed

Lines changed: 65 additions & 24 deletions

File tree

src/main.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ fn entry() -> Result<(), ()> {
268268
})?.chars().collect::<Vec<char>>();
269269

270270
let model = fetch_model(&index_path)?;
271-
for (path, rank) in model.search_query(&prompt)?.iter().take(20) {
271+
for (path, rank) in model.search_query(&prompt, &model)?.iter().take(20) {
272272
println!("{path} - {rank}", path = path.display());
273273
}
274274

@@ -343,4 +343,3 @@ fn main() -> io::Result<()> {
343343

344344
// TODO: Synonym terms
345345
// TODO: Add levenstein distance or cosine similarity
346-
// TODO: Add better document ranker specifically "Okapi BM-25"

src/model.rs

Lines changed: 63 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@ use std::{
55
use std::collections::HashMap;
66
use serde::{Deserialize, Serialize};
77
use std::default::Default;
8+
89
use super::lexer::*;
910

1011
pub trait Model {
11-
fn search_query(&self, query: &[char]) -> Result<Vec<(PathBuf, f32)>, ()>;
12+
fn search_query(&self, query: &[char], model: &InMemoryModel) -> Result<Vec<(PathBuf, f32)>, ()>;
1213
fn add_document(&mut self, path: PathBuf, content: &[char], last_modified: SystemTime) -> Result<(), ()>;
1314
fn requires_reindexing(&mut self, path: &Path, last_modified: SystemTime) -> Result<bool, ()>;
1415
}
@@ -19,7 +20,7 @@ pub trait Model {
1920
pub type FreqTable = HashMap::<String, usize>;
2021

2122
/* Answers how frequently a term occurs in all documents.
22-
Map of term with frequency of occurence in all corpus of documents.*/
23+
Map of term with frequency of occurence in all corpus of documents. */
2324
pub type GlobalTermFreq = HashMap::<String, usize>;
2425

2526
#[derive(Serialize, Deserialize)]
@@ -37,16 +38,51 @@ pub struct InMemoryModel {
3738
pub docs: Docs,
3839
}
3940

40-
fn compute_tf(term: &str, doc: &Doc) -> f32 {
41-
let n = doc.ft.get(term).cloned().unwrap_or(0) as f32; // Number of times term occured in document
42-
let d = doc.count as f32; // Total number of terms present in document
43-
n / d
41+
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
4444
}
4545

4646
fn compute_idf(term: &str, model: &InMemoryModel) -> f32 {
47-
let n = model.docs.len() as f32; // Number of documents in corpus
48-
// NOTE: Can lead to division by 0 if term is not in Document Corpus, Set Denominator to 1 if turns to 0
49-
let d = model.gtf.get(term).cloned().unwrap_or(1) as f32; // Number of times term occured in document corpus
47+
// Number of documents in corpus
48+
let total_docs = model.docs.len() as f32;
49+
50+
// Number of documents containing the unique 'term'
51+
let doc_freq = model.docs.values().filter(|doc| doc.ft.contains_key(term)).count() as f32;
52+
53+
f32::ln(((total_docs - doc_freq + 0.5) + 1f32) / (doc_freq + 0.5))
54+
}
55+
56+
// K and B are free parameters, usually chosen, in absence of an advanced optimization, as K = [1.2, 2.0] and B = 0.75
57+
const K: f32 = 2.0;
58+
const B: f32 = 0.75;
59+
fn bm25_score(query: &Vec<String>, doc: &Doc, model: &InMemoryModel) -> f32 {
60+
// Ranking documents according to BM25 Algorithm: https://en.wikipedia.org/wiki/Okapi_BM25
61+
let mut score= 0f32;
62+
let avgdl = compute_avgdl(model);
63+
let doc_length = doc.count as f32;
64+
65+
for term in query {
66+
// Number of times a term occurs in the document
67+
let tf = doc.ft.get(term).copied().unwrap_or(0) as f32;
68+
let idf = compute_idf(&term, model);
69+
70+
let denom = tf + K * (1f32 - B + B * doc_length / avgdl);
71+
score += idf * tf * (K + 1f32) / denom;
72+
}
73+
score
74+
}
75+
76+
// For TF-IDF Ranking
77+
fn tf(term: &str, doc: &Doc) -> f32 {
78+
let n = doc.ft.get(term).cloned().unwrap_or(0) as f32; // Number of times term occured in document
79+
let d = doc.count as f32; // Total number of terms present in document
80+
n / d
81+
}
82+
// For TF-IDF Ranking
83+
fn idf(term: &str, model: &InMemoryModel) -> f32 {
84+
let n = model.docs.len() as f32;
85+
let d = model.gtf.get(term).cloned().unwrap_or(1) as f32;
5086
f32::log10(n / d)
5187
}
5288

@@ -57,30 +93,35 @@ impl InMemoryModel {
5793
for term in doc.ft.keys() {
5894
// Update the GlobalTermFrequency table
5995
if let Some(freq) = self.gtf.get_mut(term) {
60-
*freq -= 1;
96+
*freq -= freq.saturating_sub(1);
6197
}
6298
}
6399
}
64100
}
65101
}
66102

103+
static USE_BM25: bool = true;
67104
impl Model for InMemoryModel {
68-
fn search_query(&self, query: &[char]) -> Result<Vec<(PathBuf, f32)>, ()> {
69-
let mut results = Vec::new();
70-
// Cache all the tokens and don't retokenize on each query
105+
fn search_query(&self, query: &[char], model: &InMemoryModel) -> Result<Vec<(PathBuf, f32)>, ()> {
71106
let tokens = Lexer::new(&query).collect::<Vec<_>>();
107+
108+
let mut results = Vec::with_capacity(self.docs.len());
72109
for (path, doc) in &self.docs {
73-
let mut rank = 0f32;
74-
for token in &tokens {
75-
// Rank is value of tf-idf => tf * idf
76-
rank += compute_tf(&token, &doc) * compute_idf(&token, &self);
77-
}
110+
let rank = if USE_BM25 {
111+
// BM-25 Ranking
112+
bm25_score(&tokens, doc, model)
113+
} else {
114+
// TF-IDF Ranking
115+
tokens.iter()
116+
.map(|token| tf(token, doc) * idf(token, model))
117+
.sum()
118+
};
119+
78120
results.push((path.to_owned(), rank));
79121
}
80122

81123
// Rank the files in desc order
82-
results.sort_by(|(_, ra), (_, rb)| ra.partial_cmp(rb).expect("Compared with NaN values"));
83-
results.reverse();
124+
results.sort_by(|(_, ra), (_, rb)| rb.partial_cmp(ra).expect("Compared with NaN values"));
84125
Ok(results)
85126
}
86127

@@ -92,7 +133,7 @@ impl Model for InMemoryModel {
92133
let tokens = Lexer::new(&content).collect::<Vec<_>>();
93134
let mut ft = FreqTable::new();
94135
for token in &tokens {
95-
ft.entry(token.to_owned()).and_modify(|x| *x += 1).or_insert(1);
136+
ft.entry(token.clone()).and_modify(|x| *x += 1).or_insert(1);
96137
}
97138

98139
// Total count of terms in FreqTable
@@ -116,4 +157,5 @@ impl Model for InMemoryModel {
116157
}
117158
}
118159

160+
// TODO: BM25 is very slow, optimize it
119161
// TODO: Implement a efficient sqlite Model with parellel processing support

src/server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ pub fn serve_api_search(mut request: Request, model: Arc<Mutex<InMemoryModel>>)
6363
println!("Recieved Query: \'{}\'", body.iter().collect::<String>().bright_blue());
6464

6565
let model = model.lock().unwrap();
66-
let results = match model.search_query(&body) {
66+
let results = match model.search_query(&body, &model) {
6767
Ok(results) => results,
6868
Err(()) => return serve_500(request)
6969
};

0 commit comments

Comments
 (0)