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
9/// RRF 融合算法中的 k 参数(默认 60)。
10pub const RRF_K: usize = 60;
11
12/// Generate a stable document ID from content hash to avoid collisions (H46).
13///
14/// P2-3: 用 FNV-1a 64 替代 `DefaultHasher`。`DefaultHasher` 的算法是 std 内部
15/// 实现细节,不保证跨进程/跨版本稳定;FNV-1a 是完全指定的确定性哈希,同一
16/// 内容的 `doc.id` 缺失时融合去重不会漂移。
17fn doc_content_hash(doc: &Document) -> String {
18    use std::hash::{Hash, Hasher};
19    let mut hasher = fnv::FnvHasher::default();
20    doc.content.hash(&mut hasher);
21    format!("{:016x}", hasher.finish())
22}
23
24/// 检索结果(带分数)
25#[derive(Debug, Clone)]
26pub struct RetrievedDocument {
27    /// 文档内容
28    pub document: Document,
29    /// 融合后的分数
30    pub score: f64,
31    /// 检索来源(BM25 / 向量 / 混合)
32    pub source: RetrievalSource,
33}
34
35/// 检索来源
36#[derive(Debug, Clone, Copy, PartialEq)]
37pub enum RetrievalSource {
38    /// 来自 BM25 关键词检索
39    BM25,
40    /// 来自向量语义检索
41    Vector,
42    /// 来自 RRF 融合结果
43    Hybrid,
44}
45
46/// 按最小分数过滤检索结果(P1-2)。
47///
48/// 消除 `score > 0.0` 幽灵阈值在 `unified_hybrid` / `graph_rag::matcher`
49/// 两处的重复实现。`min_score` 在**原始分数尺度**上比较:
50/// 默认 0.0 保持旧行为(只保留正相似度)。余弦相似度范围 [-1,1],
51/// 非归一化嵌入模型下相关文档的余弦可能为负,不同模型可自行调低阈值。
52pub fn filter_by_score<T, S: PartialOrd>(scored: Vec<(T, S)>, min_score: S) -> Vec<(T, S)> {
53    scored.into_iter().filter(|(_, s)| *s > min_score).collect()
54}
55
56/// RRF 融合算法
57///
58/// 公式: RRF_score(d) = Σ 1/(k + rank(d))
59///
60/// 参数:
61/// - bm25_results: BM25 检索结果,按分数降序排列
62/// - vector_results: 向量检索结果,按相似度降序排列
63/// - k: RRF 参数,通常为 60
64///
65/// 返回:
66/// - 融合后的文档列表,按 RRF 分数降序排列
67pub fn reciprocal_rank_fusion(
68    bm25_results: Vec<Document>,
69    vector_results: Vec<Document>,
70    k: usize,
71) -> Vec<RetrievedDocument> {
72    let mut rrf_scores: HashMap<String, (f64, Document)> = HashMap::new();
73
74    // BM25 结果处理
75    for (rank, doc) in bm25_results.iter().enumerate() {
76        let doc_id = doc.id.clone().unwrap_or_else(|| doc_content_hash(doc));
77        let rrf_contribution = 1.0 / (k as f64 + (rank + 1) as f64);
78
79        rrf_scores
80            .entry(doc_id.clone())
81            .and_modify(|(score, _existing_doc)| {
82                *score += rrf_contribution;
83            })
84            .or_insert((rrf_contribution, doc.clone()));
85    }
86
87    // 向量结果处理
88    for (rank, doc) in vector_results.iter().enumerate() {
89        let doc_id = doc.id.clone().unwrap_or_else(|| doc_content_hash(doc));
90        let rrf_contribution = 1.0 / (k as f64 + (rank + 1) as f64);
91
92        rrf_scores
93            .entry(doc_id.clone())
94            .and_modify(|(score, _)| {
95                *score += rrf_contribution;
96            })
97            .or_insert((rrf_contribution, doc.clone()));
98    }
99
100    // 按 RRF 分数排序
101    let mut results: Vec<RetrievedDocument> = rrf_scores
102        .into_iter()
103        .map(|(_, (score, doc))| RetrievedDocument {
104            document: doc,
105            score,
106            source: RetrievalSource::Hybrid,
107        })
108        .collect();
109
110    results.sort_by(|a, b| {
111        b.score
112            .partial_cmp(&a.score)
113            .unwrap_or(std::cmp::Ordering::Equal)
114    });
115
116    results
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_rrf_basic() {
125        let bm25_docs = vec![
126            Document::new("Rust系统编程").with_id("doc1"),
127            Document::new("Python数据科学").with_id("doc2"),
128            Document::new("Go并发编程").with_id("doc3"),
129        ];
130
131        let vector_docs = vec![
132            Document::new("Rust系统编程").with_id("doc1"),
133            Document::new("JavaScript前端").with_id("doc4"),
134            Document::new("Python数据科学").with_id("doc2"),
135        ];
136
137        let results = reciprocal_rank_fusion(bm25_docs, vector_docs, 60);
138
139        println!("RRF 融合结果:");
140        for (i, r) in results.iter().enumerate() {
141            println!(
142                "  [{}] doc_id={}, score={:.4}",
143                i,
144                r.document.id.clone().unwrap_or_default(),
145                r.score
146            );
147        }
148
149        // doc1 在两个列表都出现,分数应该最高
150        let first_doc_id = results[0].document.id.clone().unwrap_or_default();
151        println!("最高分文档: {}", first_doc_id);
152    }
153
154    /// P1-2: 共享 filter_by_score 工具函数——默认 0.0 只保留正相似度,
155    /// 调低阈值可保留负相似度文档(非归一化嵌入模型下相关文档余弦可为负)。
156    #[test]
157    fn test_filter_by_score() {
158        let scored = vec![("a", 0.9_f32), ("b", 0.2), ("c", -0.3), ("d", 0.0)];
159
160        // 默认阈值 0.0: 严格大于才保留(与旧 `score > 0.0` 行为一致)
161        let filtered = filter_by_score(scored.clone(), 0.0);
162        let ids: Vec<&str> = filtered.iter().map(|(id, _)| *id).collect();
163        assert_eq!(ids, vec!["a", "b"]);
164
165        // 调低阈值可保留负相似度
166        let relaxed = filter_by_score(scored.clone(), -0.5);
167        assert_eq!(relaxed.len(), 4);
168
169        // 调高阈值更严格
170        let strict = filter_by_score(scored.clone(), 0.5);
171        let ids: Vec<&str> = strict.iter().map(|(id, _)| *id).collect();
172        assert_eq!(ids, vec!["a"]);
173    }
174
175    /// P1-2: filter_by_score 对 f64 分数同样适用。
176    #[test]
177    fn test_filter_by_score_f64() {
178        let scored = vec![("x", 0.8_f64), ("y", 0.0), ("z", -0.5)];
179        let filtered = filter_by_score(scored, 0.0);
180        let ids: Vec<&str> = filtered.iter().map(|(id, _)| *id).collect();
181        assert_eq!(ids, vec!["x"]);
182    }
183
184    /// P2-3: `doc_content_hash` 为确定性哈希——同内容多次调用结果一致,
185    /// 不同内容结果不同。FNV-1a 完全指定,跨进程/跨版本不漂移。
186    #[test]
187    fn test_doc_content_hash_stable() {
188        let content = "Rust 系统编程与并发";
189        let doc_a = Document::new(content.to_string());
190        let doc_b = Document::new(content.to_string());
191        let doc_c = Document::new("Python 数据科学");
192
193        let hash_a1 = doc_content_hash(&doc_a);
194        let hash_a2 = doc_content_hash(&doc_b);
195        assert_eq!(hash_a1, hash_a2, "相同内容应产生相同哈希");
196
197        let hash_c = doc_content_hash(&doc_c);
198        assert_ne!(hash_a1, hash_c, "不同内容应产生不同哈希");
199        assert_eq!(hash_a1.len(), 16, "应为 64 位哈希的 16 位十六进制表示");
200    }
201}