Skip to main content

lc_rag/
hybrid.rs

1// src/retrieval/hybrid.rs
2//! 混合检索模块
3//!
4//! 结合 BM25 关键词检索 + 向量语义检索
5
6use lc_vector_stores::Document;
7use std::collections::HashMap;
8
9pub const RRF_K: usize = 60;
10
11/// Generate a stable document ID from content hash to avoid collisions (H46).
12///
13/// P2-3: 用 FNV-1a 64 替代 `DefaultHasher`。`DefaultHasher` 的算法是 std 内部
14/// 实现细节,不保证跨进程/跨版本稳定;FNV-1a 是完全指定的确定性哈希,同一
15/// 内容的 `doc.id` 缺失时融合去重不会漂移。
16fn 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/// 检索结果(带分数)
24#[derive(Debug, Clone)]
25pub struct RetrievedDocument {
26    pub document: Document,
27    pub score: f64,
28    pub source: RetrievalSource,
29}
30
31/// 检索来源
32#[derive(Debug, Clone, Copy, PartialEq)]
33pub enum RetrievalSource {
34    BM25,
35    Vector,
36    Hybrid,
37}
38
39/// 按最小分数过滤检索结果(P1-2)。
40///
41/// 消除 `score > 0.0` 幽灵阈值在 `unified_hybrid` / `chunked_hybrid` /
42/// `graph_rag::matcher` 三处的重复实现。`min_score` 在**原始分数尺度**上比较:
43/// 默认 0.0 保持旧行为(只保留正相似度)。余弦相似度范围 [-1,1],
44/// 非归一化嵌入模型下相关文档的余弦可能为负,不同模型可自行调低阈值。
45pub 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
49/// RRF 融合算法
50///
51/// 公式: RRF_score(d) = Σ 1/(k + rank(d))
52///
53/// 参数:
54/// - bm25_results: BM25 检索结果,按分数降序排列
55/// - vector_results: 向量检索结果,按相似度降序排列
56/// - k: RRF 参数,通常为 60
57///
58/// 返回:
59/// - 融合后的文档列表,按 RRF 分数降序排列
60pub 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    // BM25 结果处理
68    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    // 向量结果处理
81    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    // 按 RRF 分数排序
94    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
112/// 带原始分数的 RRF 融合
113///
114/// 保留 BM25 和 Vector 的原始分数信息
115pub 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    // BM25 结果处理
123    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    // 向量结果处理
137    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    // 按 RRF 分数排序
151    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/// 混合检索器(已废弃)
170///
171/// 旧版 BM25 + 向量 RRF 融合检索器。请迁移到
172/// [`UnifiedHybridIndex`](crate::unified_hybrid::UnifiedHybridIndex),
173/// 后者统一了索引、自带向量存储并实现 `RetrieverTrait`。
174#[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)] // 已弃用类型的内部实现仍需引用自身字段
185impl 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    /// 执行混合检索
208    ///
209    /// 参数:
210    /// - query: 查询文本
211    /// - bm25_results: BM25 检索结果
212    /// - vector_results: 向量检索结果
213    ///
214    /// 返回:
215    /// - 融合后的 top-k 结果
216    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    /// 执行混合检索(带原始分数)
225    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)] // 已弃用类型的 Default 实现
235impl 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        // doc1 在两个列表都出现,分数应该最高
272        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    /// P1-2: 共享 filter_by_score 工具函数——默认 0.0 只保留正相似度,
301    /// 调低阈值可保留负相似度文档(非归一化嵌入模型下相关文档余弦可为负)。
302    #[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        // 默认阈值 0.0: 严格大于才保留(与旧 `score > 0.0` 行为一致)
307        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        // 调低阈值可保留负相似度
312        let relaxed = filter_by_score(scored.clone(), -0.5);
313        assert_eq!(relaxed.len(), 4);
314
315        // 调高阈值更严格
316        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    /// P1-2: filter_by_score 对 f64 分数同样适用。
322    #[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)] // 已弃用类型的兼容性测试
331    #[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    /// P2-3: `doc_content_hash` 为确定性哈希——同内容多次调用结果一致,
358    /// 不同内容结果不同。FNV-1a 完全指定,跨进程/跨版本不漂移。
359    #[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}