Skip to content

Commit a8d700b

Browse files
committed
Added levenshtein distance
1 parent 208a9bc commit a8d700b

3 files changed

Lines changed: 112 additions & 26 deletions

File tree

src/main.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -353,5 +353,4 @@ fn main() -> io::Result<()> {
353353
Ok(())
354354
}
355355

356-
// TODO: Synonym terms
357-
// TODO: Add levenstein distance or cosine similarity
356+
// TODO: Synonym terms / semantic expansion

src/model.rs

Lines changed: 104 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -55,24 +55,102 @@ fn compute_idf(term: &str, model: &InMemoryModel) -> f32 {
5555
f32::ln(((total_docs - doc_freq + 0.5) + 1f32) / (doc_freq + 0.5))
5656
}
5757

58+
/// Levenshtein edit distance between two strings.
59+
fn levenshtein_distance(a: &str, b: &str) -> usize {
60+
let a: Vec<char> = a.chars().collect();
61+
let b: Vec<char> = b.chars().collect();
62+
let (m, n) = (a.len(), b.len());
63+
if m == 0 { return n; }
64+
if n == 0 { return m; }
65+
let mut prev: Vec<usize> = (0..=n).collect();
66+
let mut curr = vec![0usize; n + 1];
67+
for i in 1..=m {
68+
curr[0] = i;
69+
for j in 1..=n {
70+
let cost = if a[i-1] == b[j-1] { 0 } else { 1 };
71+
curr[j] = (curr[j-1]+1).min(prev[j]+1).min(prev[j-1]+cost);
72+
}
73+
std::mem::swap(&mut prev, &mut curr);
74+
}
75+
prev[n]
76+
}
77+
78+
/// Expands a single stemmed/uppercased query token into a list of `(indexed_term, weight)`
79+
/// pairs drawn from the corpus's GTF, using:
80+
/// - Exact match → weight 1.0
81+
/// - Prefix overlap (≥4 ch) → weight ∝ overlap ratio × 0.85
82+
/// - Levenshtein distance → weight ∝ similarity × 0.75
83+
///
84+
/// Tokens shorter than 4 chars only allow exact matches to avoid noisy expansion.
85+
fn expand_query_token(query_token: &str, gtf: &GlobalTermFreq) -> Vec<(String, f32)> {
86+
let qlen = query_token.len();
87+
let max_dist: usize = match qlen {
88+
0..=3 => 0,
89+
4..=5 => 1,
90+
6..=7 => 1,
91+
_ => 2,
92+
};
93+
94+
let mut matches: HashMap<String, f32> = HashMap::new();
95+
96+
for term in gtf.keys() {
97+
let tlen = term.len();
98+
99+
// Exact match
100+
if term.as_str() == query_token {
101+
matches.insert(term.clone(), 1.0);
102+
continue;
103+
}
104+
105+
if max_dist == 0 { continue; }
106+
107+
// Prefix match: one token is a prefix of the other (min 4 chars)
108+
if qlen >= 4 && tlen >= 4 {
109+
if term.starts_with(query_token) || query_token.starts_with(term.as_str()) {
110+
let shorter = qlen.min(tlen) as f32;
111+
let longer = qlen.max(tlen) as f32;
112+
let weight = (shorter / longer) * 0.85;
113+
if weight >= 0.5 {
114+
matches.entry(term.clone())
115+
.and_modify(|w| *w = w.max(weight))
116+
.or_insert(weight);
117+
continue;
118+
}
119+
}
120+
}
121+
122+
// Levenshtein: skip pairs whose length difference already exceeds the budget
123+
if qlen.abs_diff(tlen) > max_dist { continue; }
124+
let dist = levenshtein_distance(query_token, term);
125+
if dist > 0 && dist <= max_dist {
126+
let similarity = 1.0 - (dist as f32 / qlen.max(tlen) as f32);
127+
let weight = similarity * 0.75;
128+
matches.entry(term.clone())
129+
.and_modify(|w| *w = w.max(weight))
130+
.or_insert(weight);
131+
}
132+
}
133+
134+
matches.into_iter().collect()
135+
}
136+
58137
// K and B are free parameters, usually chosen, in absence of an advanced optimization, as K = [1.2, 2.0] and B = 0.75
59138
const K: f32 = 2.0;
60139
const B: f32 = 0.75;
61-
fn bm25_score(query: &Vec<String>, doc: &Doc, model: &InMemoryModel, avgdl: f32) -> f32 {
140+
/// avgdl is pre-computed once per query. Each (term, weight) pair's contribution
141+
/// is scaled by `weight`, allowing fuzzy-matched tokens to contribute less than exact ones.
142+
fn bm25_score(query: &[(String, f32)], doc: &Doc, model: &InMemoryModel, avgdl: f32) -> f32 {
62143
// Ranking documents according to BM25 Algorithm: https://en.wikipedia.org/wiki/Okapi_BM25
63144
if avgdl == 0.0 { return 0.0; } // guard: no tokens in corpus means undefined avgdl
64145
let mut score = 0f32;
65146
let doc_length = doc.count as f32;
66147

67-
for term in query {
68-
// Number of times a term occurs in the document
69-
let tf = doc.ft.get(term).copied().unwrap_or(0) as f32;
148+
for (term, weight) in query {
149+
let tf = doc.ft.get(term.as_str()).copied().unwrap_or(0) as f32;
70150
let idf = compute_idf(term, model);
71-
72151
let denom = tf + K * (1f32 - B + B * doc_length / avgdl);
73-
// denom can only be zero if tf==0 and avgdl is infinite, guard to be safe
74152
if denom == 0.0 { continue; }
75-
score += idf * tf * (K + 1f32) / denom;
153+
score += weight * idf * tf * (K + 1f32) / denom;
76154
}
77155
score
78156
}
@@ -112,25 +190,37 @@ impl Model for InMemoryModel {
112190
fn search_query(&self, query: &[char], model: &InMemoryModel, rank_method: RankMethod) -> Result<Vec<(PathBuf, f32)>, ()> {
113191
let tokens = Lexer::new(query).collect::<Vec<_>>();
114192

193+
// Expand each query token into (indexed_term, weight) pairs via exact,
194+
// prefix, and Levenshtein fuzzy matching. If the same indexed term is
195+
// reached through multiple paths, keep the highest weight.
196+
let mut token_weights: HashMap<String, f32> = HashMap::new();
197+
for token in &tokens {
198+
for (matched_term, weight) in expand_query_token(token, &self.gtf) {
199+
token_weights
200+
.entry(matched_term)
201+
.and_modify(|w| *w = w.max(weight))
202+
.or_insert(weight);
203+
}
204+
}
205+
let expanded: Vec<(String, f32)> = token_weights.into_iter().collect();
206+
115207
// Compute avgdl once per query (O(1) with cached total_tokens).
116208
let avgdl = compute_avgdl(self);
117209

118210
let mut results = Vec::with_capacity(self.docs.len());
119211
for (path, doc) in &self.docs {
120212
let rank = if rank_method == RankMethod::Bm25 {
121-
// BM-25 Ranking — avgdl already computed above, not per-doc
122-
bm25_score(&tokens, doc, model, avgdl)
213+
// BM-25 Ranking — weighted fuzzy tokens, avgdl pre-computed
214+
bm25_score(&expanded, doc, model, avgdl)
123215
} else {
124-
// TF-IDF Ranking
125-
tokens.iter()
126-
.map(|token| tf(token, doc) * idf(token, model))
216+
// TF-IDF Ranking — weighted by fuzzy match quality
217+
expanded.iter()
218+
.map(|(token, weight)| tf(token, doc) * idf(token, model) * weight)
127219
.sum()
128220
};
129-
130221
results.push((path.to_owned(), rank));
131222
}
132223

133-
// Rank the files in desc order
134224
// partial_cmp returns None only for NaN; treat NaN as equal rather than panicking.
135225
results.sort_by(|(_, ra), (_, rb)| rb.partial_cmp(ra).unwrap_or(std::cmp::Ordering::Equal));
136226
Ok(results)

src/server.rs

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use tiny_http::{Header, Method, Request, Response, Server, StatusCode};
22
use std::{
33
self,
4-
cmp::Ordering,
54
fs::{self, File},
65
io::{self, ErrorKind},
76
path::{Path, PathBuf},
@@ -82,15 +81,13 @@ pub fn serve_api_search(mut request: Request, model: Arc<Mutex<InMemoryModel>>,
8281
Err(()) => return serve_500(request)
8382
};
8483

85-
let mut content= Vec::new();
86-
// Display document ranks (if rank turns 0 while iterating, stop from there)
87-
for (path, rank) in results.iter().take(10) {
88-
if rank.partial_cmp(&0f32) == Some(Ordering::Equal) {
89-
break;
90-
}
91-
println!(" {} => {}", path.display(), rank);
92-
content.push((path, rank));
93-
}
84+
// Collect top-10 results with rank > 0. Use filter (not break) in case
85+
// fuzzy results produce a non-zero score after a zero-score result.
86+
let content: Vec<_> = results.iter()
87+
.filter(|(_, rank)| *rank > 0.0)
88+
.take(10)
89+
.inspect(|(path, rank)| println!(" {} => {}", path.display(), rank))
90+
.collect();
9491

9592
let json = match serde_json::to_string(&content) {
9693
Ok(json) => json,

0 commit comments

Comments
 (0)