Skip to main content

lc_rag/
reranking.rs

1// src/retrieval/reranking.rs
2//! Reranking implementation
3//!
4//! Re-ranks retrieval results with a scoring function, improving retrieval precision.
5
6use lc_vector_stores::{Document, SearchResult};
7use std::collections::HashMap;
8
9/// Reranking error type
10#[derive(Debug)]
11#[non_exhaustive]
12pub enum RerankingError {
13    /// Scoring error
14    ScoringError(String),
15    /// Invalid input error
16    InvalidInput(String),
17}
18
19impl std::fmt::Display for RerankingError {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        match self {
22            RerankingError::ScoringError(msg) => write!(f, "scoring error: {}", msg),
23            RerankingError::InvalidInput(msg) => write!(f, "invalid input: {}", msg),
24        }
25    }
26}
27
28impl std::error::Error for RerankingError {}
29
30/// Reranking configuration
31pub struct RerankingConfig {
32    /// Number of documents returned in the final result
33    pub top_n: usize,
34
35    /// Minimum score threshold (optional)
36    pub min_score: Option<f32>,
37
38    /// Whether to preserve the original score
39    pub preserve_original_score: bool,
40}
41
42impl Default for RerankingConfig {
43    fn default() -> Self {
44        Self {
45            top_n: 5,
46            min_score: None,
47            preserve_original_score: true,
48        }
49    }
50}
51
52impl RerankingConfig {
53    /// Creates a `RerankingConfig` with default configuration
54    pub fn new() -> Self {
55        Self::default()
56    }
57
58    /// Sets the number of documents returned in the final result
59    pub fn with_top_n(mut self, n: usize) -> Self {
60        self.top_n = n;
61        self
62    }
63
64    /// Sets the minimum score threshold
65    pub fn with_min_score(mut self, score: f32) -> Self {
66        self.min_score = Some(score);
67        self
68    }
69
70    /// Sets whether to preserve the original score
71    pub fn with_preserve_original_score(mut self, preserve: bool) -> Self {
72        self.preserve_original_score = preserve;
73        self
74    }
75}
76
77/// Reranking scorer trait
78pub trait Reranker: Send + Sync {
79    /// Scores the given document list, returning a score array that maps one-to-one to the documents
80    fn score(&self, query: &str, documents: &[Document]) -> Result<Vec<f32>, RerankingError>;
81}
82
83/// A simple keyword-matching Reranker
84pub struct KeywordReranker {
85    /// Keyword weights (optional)
86    keyword_weights: HashMap<String, f32>,
87}
88
89impl KeywordReranker {
90    /// Creates a default keyword Reranker
91    pub fn new() -> Self {
92        Self {
93            keyword_weights: HashMap::new(),
94        }
95    }
96
97    /// Sets the keyword-weight mapping
98    pub fn with_keyword_weights(mut self, weights: HashMap<String, f32>) -> Self {
99        self.keyword_weights = weights;
100        self
101    }
102
103    fn extract_keywords(&self, query: &str) -> Vec<String> {
104        query
105            .split_whitespace()
106            .filter(|w| w.len() > 1)
107            .map(|w| w.to_lowercase())
108            .collect()
109    }
110
111    fn count_keyword_matches(&self, keywords: &[String], document: &Document) -> f32 {
112        let doc_lower = document.content.to_lowercase();
113        let mut score = 0.0;
114
115        for keyword in keywords {
116            let count = doc_lower.matches(keyword).count() as f32;
117            let weight = self.keyword_weights.get(keyword).unwrap_or(&1.0);
118            score += count * weight;
119        }
120
121        score
122    }
123}
124
125impl Default for KeywordReranker {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl Reranker for KeywordReranker {
132    fn score(&self, query: &str, documents: &[Document]) -> Result<Vec<f32>, RerankingError> {
133        if documents.is_empty() {
134            return Ok(Vec::new());
135        }
136
137        let keywords = self.extract_keywords(query);
138
139        if keywords.is_empty() {
140            return Ok(documents.iter().map(|_| 0.0).collect());
141        }
142
143        let scores: Vec<f32> = documents
144            .iter()
145            .map(|doc| self.count_keyword_matches(&keywords, doc))
146            .collect();
147
148        Ok(scores)
149    }
150}
151
152/// Reranker executor
153pub struct RerankingExecutor {
154    reranker: Box<dyn Reranker>,
155    config: RerankingConfig,
156}
157
158impl RerankingExecutor {
159    /// Creates a reranker executor
160    pub fn new(reranker: Box<dyn Reranker>) -> Self {
161        Self {
162            reranker,
163            config: RerankingConfig::default(),
164        }
165    }
166
167    /// Sets the reranking configuration
168    pub fn with_config(mut self, config: RerankingConfig) -> Self {
169        self.config = config;
170        self
171    }
172
173    /// Sets the number of documents returned in the final result
174    pub fn with_top_n(mut self, n: usize) -> Self {
175        self.config.top_n = n;
176        self
177    }
178
179    /// Sets the minimum score threshold
180    pub fn with_min_score(mut self, score: f32) -> Self {
181        self.config.min_score = Some(score);
182        self
183    }
184
185    /// Sets whether to preserve the original score
186    pub fn with_preserve_original_score(mut self, preserve: bool) -> Self {
187        self.config.preserve_original_score = preserve;
188        self
189    }
190
191    /// Re-ranks the retrieval results, returning the reranked scored results
192    pub fn rerank(
193        &self,
194        query: &str,
195        results: Vec<SearchResult>,
196    ) -> Result<Vec<SearchResult>, RerankingError> {
197        if results.is_empty() {
198            return Ok(Vec::new());
199        }
200
201        let documents: Vec<Document> = results.iter().map(|r| r.document.clone()).collect();
202        let scores = self.reranker.score(query, &documents)?;
203
204        // Normalize scores to [0, 1] range before combining (H51)
205        let max_original = results
206            .iter()
207            .map(|r| r.score.abs())
208            .fold(0.0_f32, f32::max);
209        let max_rerank = scores.iter().map(|s| s.abs()).fold(0.0_f32, f32::max);
210
211        let mut reranked: Vec<SearchResult> = results
212            .iter()
213            .enumerate()
214            .map(|(idx, r)| {
215                let new_score = if self.config.preserve_original_score {
216                    let norm_original = if max_original > 0.0 {
217                        r.score / max_original
218                    } else {
219                        0.0
220                    };
221                    let norm_rerank = if max_rerank > 0.0 {
222                        scores[idx] / max_rerank
223                    } else {
224                        0.0
225                    };
226                    norm_original + norm_rerank
227                } else {
228                    scores[idx]
229                };
230
231                SearchResult {
232                    document: r.document.clone(),
233                    score: new_score,
234                }
235            })
236            .collect();
237
238        if let Some(min_score) = self.config.min_score {
239            reranked.retain(|r| r.score >= min_score);
240        }
241
242        reranked.sort_by(|a, b| {
243            b.score
244                .partial_cmp(&a.score)
245                .unwrap_or(std::cmp::Ordering::Equal)
246        });
247
248        reranked.truncate(self.config.top_n);
249
250        Ok(reranked)
251    }
252
253    /// Scores and re-ranks a document list directly, returning scored results
254    pub fn rerank_documents(
255        &self,
256        query: &str,
257        documents: Vec<Document>,
258    ) -> Result<Vec<SearchResult>, RerankingError> {
259        if documents.is_empty() {
260            return Ok(Vec::new());
261        }
262
263        let scores = self.reranker.score(query, &documents)?;
264
265        let mut results: Vec<SearchResult> = documents
266            .iter()
267            .enumerate()
268            .map(|(idx, doc)| SearchResult {
269                document: doc.clone(),
270                score: scores[idx],
271            })
272            .collect();
273
274        if let Some(min_score) = self.config.min_score {
275            results.retain(|r| r.score >= min_score);
276        }
277
278        results.sort_by(|a, b| {
279            b.score
280                .partial_cmp(&a.score)
281                .unwrap_or(std::cmp::Ordering::Equal)
282        });
283
284        results.truncate(self.config.top_n);
285
286        Ok(results)
287    }
288}
289
290/// BM25-style Reranker (simplified)
291pub struct BM25Reranker {
292    k1: f32,
293    b: f32,
294}
295
296impl BM25Reranker {
297    /// Creates a BM25 Reranker with default parameters
298    pub fn new() -> Self {
299        Self { k1: 1.5, b: 0.75 }
300    }
301
302    /// Sets the BM25 parameters k1 and b
303    pub fn with_params(mut self, k1: f32, b: f32) -> Self {
304        self.k1 = k1;
305        self.b = b;
306        self
307    }
308
309    fn tokenize(&self, text: &str) -> Vec<String> {
310        text.split_whitespace()
311            .filter(|w| w.len() > 1)
312            .map(|w| w.to_lowercase())
313            .collect()
314    }
315}
316
317impl Default for BM25Reranker {
318    fn default() -> Self {
319        Self::new()
320    }
321}
322
323impl Reranker for BM25Reranker {
324    fn score(&self, query: &str, documents: &[Document]) -> Result<Vec<f32>, RerankingError> {
325        if documents.is_empty() {
326            return Ok(Vec::new());
327        }
328
329        let query_terms = self.tokenize(query);
330
331        if query_terms.is_empty() {
332            return Ok(documents.iter().map(|_| 0.0).collect());
333        }
334
335        let n_docs = documents.len() as f32;
336        let avgdl = documents
337            .iter()
338            .map(|d| d.content.split_whitespace().count() as f32)
339            .sum::<f32>()
340            / n_docs;
341
342        // Per-query-term inverse document frequency. Standard BM25 uses
343        // IDF so that rare terms contribute more than common ones; the
344        // prior implementation had no IDF term (rare words were never
345        // boosted) and saturates term frequency twice, skewing rankings.
346        let lowercase: Vec<String> = documents.iter().map(|d| d.content.to_lowercase()).collect();
347        let idfs: Vec<f32> = query_terms
348            .iter()
349            .map(|term| {
350                // Document frequency: how many documents contain this term.
351                let df = lowercase
352                    .iter()
353                    .filter(|doc| doc.contains(term.as_str()))
354                    .count() as f32;
355                ((n_docs - df + 0.5) / (df + 0.5)).ln()
356            })
357            .collect();
358
359        let scores: Vec<f32> = documents
360            .iter()
361            .zip(&lowercase)
362            .map(|(doc, doc_lower)| {
363                let doc_len = doc.content.split_whitespace().count() as f32;
364                query_terms
365                    .iter()
366                    .zip(&idfs)
367                    .map(|(term, idf)| {
368                        let freq = doc_lower.matches(term.as_str()).count() as f32;
369                        let denom = freq + self.k1 * (1.0 - self.b + self.b * doc_len / avgdl);
370                        if denom <= 0.0 {
371                            0.0
372                        } else {
373                            idf * (freq * (1.0 + self.k1)) / denom
374                        }
375                    })
376                    .sum()
377            })
378            .collect();
379
380        Ok(scores)
381    }
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn test_reranking_config_default() {
390        let config = RerankingConfig::default();
391
392        assert_eq!(config.top_n, 5);
393        assert!(config.min_score.is_none());
394        assert!(config.preserve_original_score);
395    }
396
397    #[test]
398    fn test_reranking_config_custom() {
399        let config = RerankingConfig::new()
400            .with_top_n(10)
401            .with_min_score(0.5)
402            .with_preserve_original_score(false);
403
404        assert_eq!(config.top_n, 10);
405        assert_eq!(config.min_score, Some(0.5));
406        assert!(!config.preserve_original_score);
407    }
408
409    #[test]
410    fn test_keyword_reranker_basic() {
411        let reranker = KeywordReranker::new();
412
413        let query = "Rust programming";
414        let documents = vec![
415            Document::new("Rust is a programming language"),
416            Document::new("Python is also a programming language"),
417            Document::new("JavaScript for web"),
418        ];
419
420        let scores = reranker.score(query, &documents).unwrap();
421
422        assert_eq!(scores.len(), 3);
423        assert!(scores[0] > 0.0);
424        assert!(scores[1] > 0.0);
425    }
426
427    #[test]
428    fn test_keyword_reranker_empty_query() {
429        let reranker = KeywordReranker::new();
430
431        let documents = vec![Document::new("Some content")];
432
433        let scores = reranker.score("", &documents).unwrap();
434
435        assert_eq!(scores[0], 0.0);
436    }
437
438    #[test]
439    fn test_bm25_uses_idf_rare_term_outranks() {
440        // Standard BM25 must boost rare terms. "rare" appears in only one of
441        // four documents (positive IDF) while "the" appears in three
442        // (negative IDF). Without an IDF term, the doc matching only the
443        // common "the" would be judged by raw term frequency alone.
444        let reranker = BM25Reranker::new();
445
446        let query = "the rare";
447        let documents = vec![
448            Document::new("the the the"),
449            Document::new("the the"),
450            Document::new("the"),
451            Document::new("rare exotic uncommon phrase"),
452        ];
453
454        let scores = reranker.score(query, &documents).unwrap();
455
456        // The doc containing the rare term must score strictly positive and
457        // strictly higher than the docs matching only the common "the".
458        assert!(
459            scores[3] > 0.0,
460            "rare-term doc should be positive, got {scores:?}"
461        );
462        assert!(
463            scores[3] > scores[0] && scores[3] > scores[1] && scores[3] > scores[2],
464            "rare-term doc should outrank common-term docs, got {scores:?}"
465        );
466    }
467
468    #[test]
469    fn test_bm25_no_terms_scores_zero() {
470        let reranker = BM25Reranker::new();
471        let documents = vec![Document::new("some content")];
472
473        let scores = reranker.score("", &documents).unwrap();
474        assert_eq!(scores[0], 0.0);
475    }
476
477    #[test]
478    fn test_bm25_empty_documents() {
479        let reranker = BM25Reranker::new();
480        let scores = reranker.score("query", &[]).unwrap();
481        assert!(scores.is_empty());
482    }
483
484    #[test]
485    fn test_reranking_executor_basic() {
486        let reranker = Box::new(KeywordReranker::new());
487        let executor = RerankingExecutor::new(reranker).with_top_n(2);
488
489        let results = vec![
490            SearchResult {
491                document: Document::new("Rust programming language"),
492                score: 0.5,
493            },
494            SearchResult {
495                document: Document::new("Python scripting"),
496                score: 0.4,
497            },
498            SearchResult {
499                document: Document::new("JavaScript web"),
500                score: 0.3,
501            },
502        ];
503
504        let reranked = executor.rerank("Rust programming", results).unwrap();
505
506        assert_eq!(reranked.len(), 2);
507    }
508
509    #[test]
510    fn test_reranking_executor_min_score() {
511        let reranker = Box::new(KeywordReranker::new());
512        let executor = RerankingExecutor::new(reranker)
513            .with_top_n(5)
514            .with_min_score(1.0);
515
516        let results = vec![
517            SearchResult {
518                document: Document::new("Rust Rust Rust"),
519                score: 0.0,
520            },
521            SearchResult {
522                document: Document::new("No match"),
523                score: 0.0,
524            },
525        ];
526
527        let reranked = executor.rerank("Rust", results).unwrap();
528
529        assert!(reranked.len() <= 1);
530    }
531
532    #[test]
533    fn test_bm25_reranker_basic() {
534        let reranker = BM25Reranker::new();
535
536        // "quantum" appears in only one doc (positive IDF); the matching doc
537        // must beat the non-matching docs. With correct BM25, a doc whose only
538        // query terms are common across every candidate can legitimately score
539        // non-positive — order by relative relevance is what we assert here.
540        let query = "quantum";
541        let documents = vec![
542            Document::new("Rust quantum computing"),
543            Document::new("Python programming"),
544            Document::new("Web development"),
545        ];
546
547        let scores = reranker.score(query, &documents).unwrap();
548
549        assert_eq!(scores.len(), 3);
550        assert!(scores[0] > scores[1]);
551        assert!(scores[0] > scores[2]);
552    }
553
554    #[test]
555    fn test_bm25_reranker_params() {
556        // Custom k1/b must still produce a positive score because "test" is
557        // rare here (1 of 3 docs). A single-document corpus would force
558        // df == N and give negative IDF by construction.
559        let reranker = BM25Reranker::new().with_params(2.0, 0.5);
560
561        let documents = vec![
562            Document::new("test content"),
563            Document::new("other text"),
564            Document::new("more text"),
565        ];
566
567        let scores = reranker.score("test", &documents).unwrap();
568
569        assert!(scores[0] > 0.0);
570    }
571
572    #[test]
573    fn test_rerank_documents() {
574        let reranker = Box::new(KeywordReranker::new());
575        let executor = RerankingExecutor::new(reranker).with_top_n(2);
576
577        let documents = vec![
578            Document::new("Rust programming"),
579            Document::new("Python scripting"),
580            Document::new("JavaScript web"),
581        ];
582
583        let results = executor.rerank_documents("Rust", documents).unwrap();
584
585        assert_eq!(results.len(), 2);
586    }
587}