1use lc_vector_stores::{Document, SearchResult};
7use std::collections::HashMap;
8
9#[derive(Debug)]
11#[non_exhaustive]
12pub enum RerankingError {
13 ScoringError(String),
15 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
30pub struct RerankingConfig {
32 pub top_n: usize,
34
35 pub min_score: Option<f32>,
37
38 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 pub fn new() -> Self {
55 Self::default()
56 }
57
58 pub fn with_top_n(mut self, n: usize) -> Self {
60 self.top_n = n;
61 self
62 }
63
64 pub fn with_min_score(mut self, score: f32) -> Self {
66 self.min_score = Some(score);
67 self
68 }
69
70 pub fn with_preserve_original_score(mut self, preserve: bool) -> Self {
72 self.preserve_original_score = preserve;
73 self
74 }
75}
76
77pub trait Reranker: Send + Sync {
79 fn score(&self, query: &str, documents: &[Document]) -> Result<Vec<f32>, RerankingError>;
81}
82
83pub struct KeywordReranker {
85 keyword_weights: HashMap<String, f32>,
87}
88
89impl KeywordReranker {
90 pub fn new() -> Self {
92 Self {
93 keyword_weights: HashMap::new(),
94 }
95 }
96
97 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
152pub struct RerankingExecutor {
154 reranker: Box<dyn Reranker>,
155 config: RerankingConfig,
156}
157
158impl RerankingExecutor {
159 pub fn new(reranker: Box<dyn Reranker>) -> Self {
161 Self {
162 reranker,
163 config: RerankingConfig::default(),
164 }
165 }
166
167 pub fn with_config(mut self, config: RerankingConfig) -> Self {
169 self.config = config;
170 self
171 }
172
173 pub fn with_top_n(mut self, n: usize) -> Self {
175 self.config.top_n = n;
176 self
177 }
178
179 pub fn with_min_score(mut self, score: f32) -> Self {
181 self.config.min_score = Some(score);
182 self
183 }
184
185 pub fn with_preserve_original_score(mut self, preserve: bool) -> Self {
187 self.config.preserve_original_score = preserve;
188 self
189 }
190
191 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 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 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
290pub struct BM25Reranker {
292 k1: f32,
293 b: f32,
294}
295
296impl BM25Reranker {
297 pub fn new() -> Self {
299 Self { k1: 1.5, b: 0.75 }
300 }
301
302 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 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 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 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 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 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 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}