@@ -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
59138const K : f32 = 2.0 ;
60139const 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)
0 commit comments