1use lc_vector_stores::Document;
7use std::collections::HashMap;
8
9pub const RRF_K: usize = 60;
10
11fn doc_content_hash(doc: &Document) -> String {
17 use std::hash::{Hash, Hasher};
18 let mut hasher = fnv::FnvHasher::default();
19 doc.content.hash(&mut hasher);
20 format!("{:016x}", hasher.finish())
21}
22
23#[derive(Debug, Clone)]
25pub struct RetrievedDocument {
26 pub document: Document,
27 pub score: f64,
28 pub source: RetrievalSource,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq)]
33pub enum RetrievalSource {
34 BM25,
35 Vector,
36 Hybrid,
37}
38
39pub fn filter_by_score<T, S: PartialOrd>(scored: Vec<(T, S)>, min_score: S) -> Vec<(T, S)> {
46 scored.into_iter().filter(|(_, s)| *s > min_score).collect()
47}
48
49pub fn reciprocal_rank_fusion(
61 bm25_results: Vec<Document>,
62 vector_results: Vec<Document>,
63 k: usize,
64) -> Vec<RetrievedDocument> {
65 let mut rrf_scores: HashMap<String, (f64, Document)> = HashMap::new();
66
67 for (rank, doc) in bm25_results.iter().enumerate() {
69 let doc_id = doc.id.clone().unwrap_or_else(|| doc_content_hash(doc));
70 let rrf_contribution = 1.0 / (k as f64 + (rank + 1) as f64);
71
72 rrf_scores
73 .entry(doc_id.clone())
74 .and_modify(|(score, _existing_doc)| {
75 *score += rrf_contribution;
76 })
77 .or_insert((rrf_contribution, doc.clone()));
78 }
79
80 for (rank, doc) in vector_results.iter().enumerate() {
82 let doc_id = doc.id.clone().unwrap_or_else(|| doc_content_hash(doc));
83 let rrf_contribution = 1.0 / (k as f64 + (rank + 1) as f64);
84
85 rrf_scores
86 .entry(doc_id.clone())
87 .and_modify(|(score, _)| {
88 *score += rrf_contribution;
89 })
90 .or_insert((rrf_contribution, doc.clone()));
91 }
92
93 let mut results: Vec<RetrievedDocument> = rrf_scores
95 .into_iter()
96 .map(|(_, (score, doc))| RetrievedDocument {
97 document: doc,
98 score,
99 source: RetrievalSource::Hybrid,
100 })
101 .collect();
102
103 results.sort_by(|a, b| {
104 b.score
105 .partial_cmp(&a.score)
106 .unwrap_or(std::cmp::Ordering::Equal)
107 });
108
109 results
110}
111
112pub fn reciprocal_rank_fusion_with_scores(
116 bm25_results: Vec<(Document, f64)>,
117 vector_results: Vec<(Document, f64)>,
118 k: usize,
119) -> Vec<RetrievedDocument> {
120 let mut rrf_scores: HashMap<String, (f64, Document, Option<f64>, Option<f64>)> = HashMap::new();
121
122 for (rank, (doc, bm25_score)) in bm25_results.iter().enumerate() {
124 let doc_id = doc.id.clone().unwrap_or_else(|| doc_content_hash(doc));
125 let rrf_contribution = 1.0 / (k as f64 + (rank + 1) as f64);
126
127 rrf_scores
128 .entry(doc_id.clone())
129 .and_modify(|(score, _, bm25, _vector)| {
130 *score += rrf_contribution;
131 *bm25 = Some(*bm25_score);
132 })
133 .or_insert((rrf_contribution, doc.clone(), Some(*bm25_score), None));
134 }
135
136 for (rank, (doc, vector_score)) in vector_results.iter().enumerate() {
138 let doc_id = doc.id.clone().unwrap_or_else(|| doc_content_hash(doc));
139 let rrf_contribution = 1.0 / (k as f64 + (rank + 1) as f64);
140
141 rrf_scores
142 .entry(doc_id.clone())
143 .and_modify(|(score, _, _bm25, vector)| {
144 *score += rrf_contribution;
145 *vector = Some(*vector_score);
146 })
147 .or_insert((rrf_contribution, doc.clone(), None, Some(*vector_score)));
148 }
149
150 let mut results: Vec<RetrievedDocument> = rrf_scores
152 .into_iter()
153 .map(|(_, (score, doc, _, _))| RetrievedDocument {
154 document: doc,
155 score,
156 source: RetrievalSource::Hybrid,
157 })
158 .collect();
159
160 results.sort_by(|a, b| {
161 b.score
162 .partial_cmp(&a.score)
163 .unwrap_or(std::cmp::Ordering::Equal)
164 });
165
166 results
167}
168
169#[allow(dead_code)]
175#[deprecated(
176 note = "Use UnifiedHybridIndex instead (see crate::unified_hybrid::UnifiedHybridIndex)"
177)]
178pub struct HybridRetriever {
179 bm25_k: usize,
180 vector_k: usize,
181 rrf_k: usize,
182}
183
184#[allow(deprecated)] impl HybridRetriever {
186 pub fn new() -> Self {
187 Self {
188 bm25_k: 10,
189 vector_k: 10,
190 rrf_k: RRF_K,
191 }
192 }
193
194 pub fn with_top_k(bm25_k: usize, vector_k: usize) -> Self {
195 Self {
196 bm25_k,
197 vector_k,
198 rrf_k: RRF_K,
199 }
200 }
201
202 pub fn with_rrf_k(mut self, k: usize) -> Self {
203 self.rrf_k = k;
204 self
205 }
206
207 pub fn retrieve(
217 &self,
218 bm25_results: Vec<Document>,
219 vector_results: Vec<Document>,
220 ) -> Vec<RetrievedDocument> {
221 reciprocal_rank_fusion(bm25_results, vector_results, self.rrf_k)
222 }
223
224 pub fn retrieve_with_scores(
226 &self,
227 bm25_results: Vec<(Document, f64)>,
228 vector_results: Vec<(Document, f64)>,
229 ) -> Vec<RetrievedDocument> {
230 reciprocal_rank_fusion_with_scores(bm25_results, vector_results, self.rrf_k)
231 }
232}
233
234#[allow(deprecated)] impl Default for HybridRetriever {
236 fn default() -> Self {
237 Self::new()
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 #[test]
246 fn test_rrf_basic() {
247 let bm25_docs = vec![
248 Document::new("Rust系统编程").with_id("doc1"),
249 Document::new("Python数据科学").with_id("doc2"),
250 Document::new("Go并发编程").with_id("doc3"),
251 ];
252
253 let vector_docs = vec![
254 Document::new("Rust系统编程").with_id("doc1"),
255 Document::new("JavaScript前端").with_id("doc4"),
256 Document::new("Python数据科学").with_id("doc2"),
257 ];
258
259 let results = reciprocal_rank_fusion(bm25_docs, vector_docs, 60);
260
261 println!("RRF 融合结果:");
262 for (i, r) in results.iter().enumerate() {
263 println!(
264 " [{}] doc_id={}, score={:.4}",
265 i,
266 r.document.id.clone().unwrap_or_default(),
267 r.score
268 );
269 }
270
271 let first_doc_id = results[0].document.id.clone().unwrap_or_default();
273 println!("最高分文档: {}", first_doc_id);
274 }
275
276 #[test]
277 fn test_rrf_with_scores() {
278 let bm25_docs = vec![
279 (Document::new("Rust").with_id("doc1"), 3.5),
280 (Document::new("Python").with_id("doc2"), 2.1),
281 ];
282
283 let vector_docs = vec![
284 (Document::new("Rust").with_id("doc1"), 0.92),
285 (Document::new("Go").with_id("doc3"), 0.88),
286 ];
287
288 let results = reciprocal_rank_fusion_with_scores(bm25_docs, vector_docs, 60);
289
290 println!("带分数的 RRF 融合:");
291 for r in &results {
292 println!(
293 " doc_id={}, rrf_score={:.4}",
294 r.document.id.clone().unwrap_or_default(),
295 r.score
296 );
297 }
298 }
299
300 #[test]
303 fn test_filter_by_score() {
304 let scored = vec![("a", 0.9_f32), ("b", 0.2), ("c", -0.3), ("d", 0.0)];
305
306 let filtered = filter_by_score(scored.clone(), 0.0);
308 let ids: Vec<&str> = filtered.iter().map(|(id, _)| *id).collect();
309 assert_eq!(ids, vec!["a", "b"]);
310
311 let relaxed = filter_by_score(scored.clone(), -0.5);
313 assert_eq!(relaxed.len(), 4);
314
315 let strict = filter_by_score(scored.clone(), 0.5);
317 let ids: Vec<&str> = strict.iter().map(|(id, _)| *id).collect();
318 assert_eq!(ids, vec!["a"]);
319 }
320
321 #[test]
323 fn test_filter_by_score_f64() {
324 let scored = vec![("x", 0.8_f64), ("y", 0.0), ("z", -0.5)];
325 let filtered = filter_by_score(scored, 0.0);
326 let ids: Vec<&str> = filtered.iter().map(|(id, _)| *id).collect();
327 assert_eq!(ids, vec!["x"]);
328 }
329
330 #[allow(deprecated)] #[test]
332 fn test_hybrid_retriever() {
333 let retriever = HybridRetriever::new();
334
335 let bm25_docs = vec![
336 Document::new("机器学习").with_id("doc1"),
337 Document::new("深度学习").with_id("doc2"),
338 ];
339
340 let vector_docs = vec![
341 Document::new("机器学习").with_id("doc1"),
342 Document::new("自然语言处理").with_id("doc3"),
343 ];
344
345 let results = retriever.retrieve(bm25_docs, vector_docs);
346
347 println!("HybridRetriever 结果数: {}", results.len());
348 for r in &results {
349 println!(
350 " id={}, score={:.4}",
351 r.document.id.clone().unwrap_or_default(),
352 r.score
353 );
354 }
355 }
356
357 #[test]
360 fn test_doc_content_hash_stable() {
361 let content = "Rust 系统编程与并发";
362 let doc_a = Document::new(content.to_string());
363 let doc_b = Document::new(content.to_string());
364 let doc_c = Document::new("Python 数据科学");
365
366 let hash_a1 = doc_content_hash(&doc_a);
367 let hash_a2 = doc_content_hash(&doc_b);
368 assert_eq!(hash_a1, hash_a2, "相同内容应产生相同哈希");
369
370 let hash_c = doc_content_hash(&doc_c);
371 assert_ne!(hash_a1, hash_c, "不同内容应产生不同哈希");
372 assert_eq!(hash_a1.len(), 16, "应为 64 位哈希的 16 位十六进制表示");
373 }
374}