Skip to main content

toolhub_recommender/
search.rs

1use std::collections::HashMap;
2
3pub fn cosine(a: &[f32], b: &[f32]) -> f32 {
4    let n = a.len().min(b.len());
5    let dot: f32 = (0..n).map(|i| a[i] * b[i]).sum();
6    let na: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
7    let nb: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
8    if na == 0.0 || nb == 0.0 {
9        0.0
10    } else {
11        dot / (na * nb)
12    }
13}
14
15#[derive(Debug, Clone)]
16pub struct Hit {
17    pub tool_id: String,
18    pub score: f32,
19}
20
21pub fn top_k(query: &[f32], catalog: &[(String, Vec<f32>)], k: usize) -> Vec<Hit> {
22    let mut hits: Vec<Hit> = catalog
23        .iter()
24        .map(|(id, v)| Hit {
25            tool_id: id.clone(),
26            score: cosine(query, v),
27        })
28        .collect();
29    hits.sort_by(|a, b| {
30        b.score
31            .partial_cmp(&a.score)
32            .unwrap_or(std::cmp::Ordering::Equal)
33    });
34    hits.truncate(k);
35    hits
36}
37
38/// Hybrid FTS5 BM25 + cosine combine, weighted (PLAN §7: 0.4 BM25 / 0.6 cosine).
39///
40/// `fts_hits` maps `tool_id` to its raw BM25 score (negative — closer to 0 = better).
41/// Tools missing from the FTS hits get an FTS contribution of 0.
42///
43/// Both score lists are min-max normalized to [0, 1] before combining so the
44/// disparate scales (cosine ≈ [0, 1], BM25 ≈ [-50, -2]) play nicely.
45pub fn hybrid_top_k(
46    query_emb: &[f32],
47    catalog: &[(String, Vec<f32>)],
48    fts_hits: &HashMap<String, f32>,
49    k: usize,
50    cos_w: f32,
51    fts_w: f32,
52) -> Vec<Hit> {
53    if catalog.is_empty() {
54        return Vec::new();
55    }
56
57    let cosines: Vec<f32> = catalog.iter().map(|(_, v)| cosine(query_emb, v)).collect();
58    let (cmin, cmax) = min_max(&cosines);
59    let cspan = (cmax - cmin).max(1e-6);
60
61    // BM25 is negative; use -bm25 as similarity (bigger = better).
62    let fts_sims: Vec<f32> = catalog
63        .iter()
64        .map(|(id, _)| fts_hits.get(id).map(|b| -b).unwrap_or(0.0))
65        .collect();
66    let present: Vec<f32> = catalog
67        .iter()
68        .filter_map(|(id, _)| fts_hits.get(id).map(|b| -b))
69        .collect();
70    let (fmin, fmax) = if present.is_empty() {
71        (0.0, 1.0)
72    } else {
73        min_max(&present)
74    };
75    let fspan = (fmax - fmin).max(1e-6);
76
77    let mut hits: Vec<Hit> = catalog
78        .iter()
79        .enumerate()
80        .map(|(i, (id, _))| {
81            let cos_n = (cosines[i] - cmin) / cspan;
82            let fts_n = if fts_hits.contains_key(id) {
83                (fts_sims[i] - fmin) / fspan
84            } else {
85                0.0
86            };
87            Hit {
88                tool_id: id.clone(),
89                score: cos_w * cos_n + fts_w * fts_n,
90            }
91        })
92        .collect();
93
94    hits.sort_by(|a, b| {
95        b.score
96            .partial_cmp(&a.score)
97            .unwrap_or(std::cmp::Ordering::Equal)
98    });
99    hits.truncate(k);
100    hits
101}
102
103/// Hybrid combine from two pre-computed score maps. Use when scores already
104/// come from the DB (vec0 distance + FTS BM25) and we don't want to recompute
105/// cosine over the whole catalog. `vec_sims` should already be similarity
106/// (e.g. `1.0 - cosine_distance`); `fts_hits` carries raw BM25 (more-negative
107/// = better — the function negates internally).
108pub fn hybrid_from_score_maps(
109    vec_sims: &HashMap<String, f32>,
110    fts_hits: &HashMap<String, f32>,
111    k: usize,
112    cos_w: f32,
113    fts_w: f32,
114) -> Vec<Hit> {
115    let mut all_ids: std::collections::HashSet<String> = vec_sims.keys().cloned().collect();
116    all_ids.extend(fts_hits.keys().cloned());
117
118    let vec_vals: Vec<f32> = vec_sims.values().copied().collect();
119    let (vmin, vmax) = if vec_vals.is_empty() {
120        (0.0, 1.0)
121    } else {
122        min_max(&vec_vals)
123    };
124    let vspan = (vmax - vmin).max(1e-6);
125
126    let fts_sims: Vec<f32> = fts_hits.values().map(|b| -b).collect();
127    let (fmin, fmax) = if fts_sims.is_empty() {
128        (0.0, 1.0)
129    } else {
130        min_max(&fts_sims)
131    };
132    let fspan = (fmax - fmin).max(1e-6);
133
134    let mut hits: Vec<Hit> = all_ids
135        .into_iter()
136        .map(|id| {
137            let vec_n = vec_sims.get(&id).map(|v| (v - vmin) / vspan).unwrap_or(0.0);
138            let fts_n = fts_hits
139                .get(&id)
140                .map(|b| (-b - fmin) / fspan)
141                .unwrap_or(0.0);
142            Hit {
143                tool_id: id,
144                score: cos_w * vec_n + fts_w * fts_n,
145            }
146        })
147        .collect();
148
149    hits.sort_by(|a, b| {
150        b.score
151            .partial_cmp(&a.score)
152            .unwrap_or(std::cmp::Ordering::Equal)
153    });
154    hits.truncate(k);
155    hits
156}
157
158fn min_max(xs: &[f32]) -> (f32, f32) {
159    let mut lo = f32::INFINITY;
160    let mut hi = f32::NEG_INFINITY;
161    for x in xs {
162        if *x < lo {
163            lo = *x;
164        }
165        if *x > hi {
166            hi = *x;
167        }
168    }
169    (lo, hi)
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn cosine_orthogonal_is_zero() {
178        let a = [1.0, 0.0, 0.0];
179        let b = [0.0, 1.0, 0.0];
180        assert!(cosine(&a, &b).abs() < 1e-6);
181    }
182
183    #[test]
184    fn cosine_identical_is_one() {
185        let a = [1.0, 2.0, 3.0];
186        assert!((cosine(&a, &a) - 1.0).abs() < 1e-6);
187    }
188
189    #[test]
190    fn cosine_opposite_is_negative_one() {
191        let a = [1.0, 0.0];
192        let b = [-1.0, 0.0];
193        assert!((cosine(&a, &b) + 1.0).abs() < 1e-6);
194    }
195
196    #[test]
197    fn top_k_orders_by_descending_score_and_truncates() {
198        let q = vec![1.0, 0.0];
199        let catalog = vec![
200            ("c".into(), vec![0.5, 0.5]),
201            ("a".into(), vec![1.0, 0.0]),
202            ("b".into(), vec![0.0, 1.0]),
203            ("d".into(), vec![0.9, 0.05]),
204        ];
205        let hits = top_k(&q, &catalog, 2);
206        assert_eq!(hits.len(), 2);
207        assert_eq!(hits[0].tool_id, "a");
208        assert_eq!(hits[1].tool_id, "d");
209    }
210
211    #[test]
212    fn hybrid_promotes_tool_present_in_both_signals() {
213        // q strongly cosine-favours "a" but "b" gets perfect cosine *and* BM25.
214        let q = vec![1.0, 0.0];
215        let catalog = vec![
216            ("a".into(), vec![1.0, 0.0]),
217            ("b".into(), vec![0.95, 0.31]),
218            ("c".into(), vec![0.0, 1.0]),
219        ];
220        // SQLite bm25() returns more-negative for better matches; hybrid_top_k
221        // negates so the more-negative value normalizes to the highest similarity.
222        let mut fts = HashMap::new();
223        fts.insert("b".into(), -10.0); // strong match
224        fts.insert("c".into(), -2.0); // weak match
225        let hits = hybrid_top_k(&q, &catalog, &fts, 3, 0.6, 0.4);
226        assert_eq!(hits.len(), 3);
227        // "b" should top "a" because BM25 bonus tips the scales.
228        assert_eq!(hits[0].tool_id, "b");
229    }
230
231    #[test]
232    fn hybrid_from_score_maps_combines_both_signals() {
233        let mut vec_sims = HashMap::new();
234        vec_sims.insert("a".into(), 1.00);
235        vec_sims.insert("b".into(), 0.95);
236        vec_sims.insert("c".into(), 0.10);
237        let mut fts_hits = HashMap::new();
238        fts_hits.insert("b".into(), -10.0); // strong BM25 (more negative = better)
239        fts_hits.insert("c".into(), -2.0);
240        let hits = hybrid_from_score_maps(&vec_sims, &fts_hits, 3, 0.6, 0.4);
241        assert_eq!(hits.len(), 3);
242        assert_eq!(hits[0].tool_id, "b"); // BM25 boost lifts above pure-cosine "a"
243    }
244
245    #[test]
246    fn hybrid_falls_back_to_cosine_when_fts_empty() {
247        let q = vec![1.0, 0.0];
248        let catalog = vec![("a".into(), vec![1.0, 0.0]), ("b".into(), vec![0.0, 1.0])];
249        let fts = HashMap::new();
250        let hits = hybrid_top_k(&q, &catalog, &fts, 2, 0.6, 0.4);
251        assert_eq!(hits[0].tool_id, "a");
252    }
253}