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 avgdl = documents
336            .iter()
337            .map(|d| d.content.split_whitespace().count() as f32)
338            .sum::<f32>()
339            / documents.len() as f32;
340
341        let scores: Vec<f32> = documents
342            .iter()
343            .map(|doc| {
344                let doc_len = doc.content.split_whitespace().count() as f32;
345                let doc_lower = doc.content.to_lowercase();
346                query_terms
347                    .iter()
348                    .map(|term| {
349                        let freq = doc_lower.matches(term.as_str()).count() as f32;
350                        let tf =
351                            freq / (freq + self.k1 * (1.0 - self.b + self.b * doc_len / avgdl));
352                        tf * (1.0 + self.k1)
353                            / (tf + self.k1 * (1.0 - self.b + self.b * doc_len / avgdl))
354                    })
355                    .sum()
356            })
357            .collect();
358
359        Ok(scores)
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn test_reranking_config_default() {
369        let config = RerankingConfig::default();
370
371        assert_eq!(config.top_n, 5);
372        assert!(config.min_score.is_none());
373        assert!(config.preserve_original_score);
374    }
375
376    #[test]
377    fn test_reranking_config_custom() {
378        let config = RerankingConfig::new()
379            .with_top_n(10)
380            .with_min_score(0.5)
381            .with_preserve_original_score(false);
382
383        assert_eq!(config.top_n, 10);
384        assert_eq!(config.min_score, Some(0.5));
385        assert!(!config.preserve_original_score);
386    }
387
388    #[test]
389    fn test_keyword_reranker_basic() {
390        let reranker = KeywordReranker::new();
391
392        let query = "Rust programming";
393        let documents = vec![
394            Document::new("Rust is a programming language"),
395            Document::new("Python is also a programming language"),
396            Document::new("JavaScript for web"),
397        ];
398
399        let scores = reranker.score(query, &documents).unwrap();
400
401        assert_eq!(scores.len(), 3);
402        assert!(scores[0] > 0.0);
403        assert!(scores[1] > 0.0);
404    }
405
406    #[test]
407    fn test_keyword_reranker_empty_query() {
408        let reranker = KeywordReranker::new();
409
410        let documents = vec![Document::new("Some content")];
411
412        let scores = reranker.score("", &documents).unwrap();
413
414        assert_eq!(scores[0], 0.0);
415    }
416
417    #[test]
418    fn test_reranking_executor_basic() {
419        let reranker = Box::new(KeywordReranker::new());
420        let executor = RerankingExecutor::new(reranker).with_top_n(2);
421
422        let results = vec![
423            SearchResult {
424                document: Document::new("Rust programming language"),
425                score: 0.5,
426            },
427            SearchResult {
428                document: Document::new("Python scripting"),
429                score: 0.4,
430            },
431            SearchResult {
432                document: Document::new("JavaScript web"),
433                score: 0.3,
434            },
435        ];
436
437        let reranked = executor.rerank("Rust programming", results).unwrap();
438
439        assert_eq!(reranked.len(), 2);
440    }
441
442    #[test]
443    fn test_reranking_executor_min_score() {
444        let reranker = Box::new(KeywordReranker::new());
445        let executor = RerankingExecutor::new(reranker)
446            .with_top_n(5)
447            .with_min_score(1.0);
448
449        let results = vec![
450            SearchResult {
451                document: Document::new("Rust Rust Rust"),
452                score: 0.0,
453            },
454            SearchResult {
455                document: Document::new("No match"),
456                score: 0.0,
457            },
458        ];
459
460        let reranked = executor.rerank("Rust", results).unwrap();
461
462        assert!(reranked.len() <= 1);
463    }
464
465    #[test]
466    fn test_bm25_reranker_basic() {
467        let reranker = BM25Reranker::new();
468
469        let query = "programming language";
470        let documents = vec![
471            Document::new("Rust is a programming language"),
472            Document::new("Python is a programming language too"),
473            Document::new("Web development"),
474        ];
475
476        let scores = reranker.score(query, &documents).unwrap();
477
478        assert_eq!(scores.len(), 3);
479        assert!(scores[0] > scores[2]);
480    }
481
482    #[test]
483    fn test_bm25_reranker_params() {
484        let reranker = BM25Reranker::new().with_params(2.0, 0.5);
485
486        let documents = vec![Document::new("test content")];
487
488        let scores = reranker.score("test", &documents).unwrap();
489
490        assert!(scores[0] > 0.0);
491    }
492
493    #[test]
494    fn test_rerank_documents() {
495        let reranker = Box::new(KeywordReranker::new());
496        let executor = RerankingExecutor::new(reranker).with_top_n(2);
497
498        let documents = vec![
499            Document::new("Rust programming"),
500            Document::new("Python scripting"),
501            Document::new("JavaScript web"),
502        ];
503
504        let results = executor.rerank_documents("Rust", documents).unwrap();
505
506        assert_eq!(results.len(), 2);
507    }
508}