Skip to main content

llm_kernel/embedding/
vector_index.rs

1//! Vector index trait for compressed approximate nearest neighbor search.
2//!
3//! Defines the abstract interface that concrete implementations (e.g.,
4//! `llm-kernel-vector-index` with TurboQuant) must satisfy. This module has
5//! **zero external dependencies** — implementations live in separate crates.
6//!
7//! ```
8//! use llm_kernel::embedding::vector_index::SearchHit;
9//!
10//! let hit = SearchHit { id: 42, score: 0.95 };
11//! assert_eq!(hit.id, 42);
12//! ```
13
14use std::path::Path;
15
16use crate::error::Result;
17
18/// A single search hit from vector index lookup.
19///
20/// Sorts by **descending** score (highest similarity first). Ties are broken
21/// by ascending ID for deterministic ordering.
22#[derive(Debug, Clone, Copy, PartialEq)]
23pub struct SearchHit {
24    /// External identifier for the matched vector.
25    pub id: u64,
26    /// Similarity score (higher = more similar).
27    pub score: f32,
28}
29
30impl PartialOrd for SearchHit {
31    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
32        // f32 is not Ord, so we use total_cmp for a total ordering.
33        // Reverse score order: highest score first, then ascending ID.
34        Some(
35            other
36                .score
37                .total_cmp(&self.score)
38                .then_with(|| self.id.cmp(&other.id)),
39        )
40    }
41}
42
43/// How to combine several ranked [`SearchHit`] lists into one — the join point
44/// for hybrid retrieval (e.g. a dense branch plus a sparse/lexical branch).
45///
46/// Choose by whether the branches' scores are comparable. [`Rrf`](Fusion::Rrf)
47/// looks only at ranks, so it is safe when the branches use different
48/// similarity measures (cosine vs inner product) or wildly different scales;
49/// [`Weighted`](Fusion::Weighted) sums raw scores and therefore assumes the
50/// branches are already on a shared scale.
51///
52/// # Which to use for dense + sparse
53///
54/// A dense branch scores by **cosine similarity** (range roughly `0.0`–`1.0`)
55/// while a learned-lexical sparse branch scores by **inner product** (BGE-M3
56/// sparse weights commonly sum to the tens or hundreds). Their scales do not
57/// agree, so fusing them with [`Weighted`](Fusion::Weighted) directly lets the
58/// sparse branch dominate regardless of the chosen weights. Prefer
59/// [`Rrf`](Fusion::Rrf) when the branches disagree on scale — it is the
60/// default for exactly this reason. If you need [`Weighted`](Fusion::Weighted),
61/// **normalize each branch to a shared scale first** (e.g. min-max to `[0,1]`,
62/// or softmax over the branch's scores).
63///
64/// Scores are carried in [`SearchHit`] as `f32`. Inner-product scores are large
65/// enough that the `f64 → f32` narrowing in the backends can lose precision in
66/// the low digits; this does not affect [`Rrf`](Fusion::Rrf) (rank-only) but is
67/// a further reason to normalize before [`Weighted`](Fusion::Weighted).
68#[derive(Debug, Clone, PartialEq)]
69pub enum Fusion {
70    /// Reciprocal Rank Fusion: `score(d) = Σ 1 / (k + rank_i(d))` over the
71    /// lists containing `d`, with 1-based ranks. Larger `k` flattens the
72    /// advantage of top ranks; 60 is the conventional default.
73    Rrf {
74        /// Rank damping constant — larger values flatten the advantage held by
75        /// top-ranked hits. 60 is the conventional default.
76        k: u32,
77    },
78    /// Convex combination of raw scores, one weight per input list. A list
79    /// that does not contain a hit contributes nothing for it.
80    ///
81    /// **All input lists must share a score scale.** Do not fuse a cosine
82    /// branch with a raw inner-product branch without normalizing first — see
83    /// the type-level docs and [`Rrf`](Fusion::Rrf) for the scale-safe option.
84    Weighted {
85        /// One weight per input list, in the same order as the lists passed to
86        /// [`fuse`](Fusion::fuse).
87        weights: Vec<f32>,
88    },
89}
90
91impl Fusion {
92    /// Reciprocal Rank Fusion with the conventional `k = 60`.
93    ///
94    /// This is the recommended default for hybrid dense + sparse retrieval:
95    /// being rank-only, it is unaffected by the mismatched score scales of
96    /// cosine and inner product. See the [`Fusion`] type docs.
97    pub fn rrf() -> Self {
98        Fusion::Rrf { k: 60 }
99    }
100
101    /// Fuse ranked lists into a single list, best first.
102    ///
103    /// Every input list is expected to be sorted best-first already — RRF
104    /// derives its ranks from that order. The result uses the same ordering as
105    /// [`SearchHit`] itself: descending score, then ascending id.
106    ///
107    /// # Panics
108    ///
109    /// [`Weighted`](Fusion::Weighted) panics when the number of weights differs
110    /// from the number of lists, mirroring `search::weighted_sum_fuse`: a
111    /// mismatch means a branch would be silently dropped from the ranking.
112    pub fn fuse(&self, hit_sets: &[Vec<SearchHit>]) -> Vec<SearchHit> {
113        use std::collections::HashMap;
114        let mut scores: HashMap<u64, f32> = HashMap::new();
115        match self {
116            Fusion::Rrf { k } => {
117                let k = *k as f32;
118                for set in hit_sets {
119                    for (rank, hit) in set.iter().enumerate() {
120                        *scores.entry(hit.id).or_insert(0.0) += 1.0 / (k + rank as f32 + 1.0);
121                    }
122                }
123            }
124            Fusion::Weighted { weights } => {
125                assert_eq!(
126                    hit_sets.len(),
127                    weights.len(),
128                    "Fusion::Weighted: hit_sets.len() ({}) must equal weights.len() ({})",
129                    hit_sets.len(),
130                    weights.len(),
131                );
132                for (set, w) in hit_sets.iter().zip(weights) {
133                    for hit in set {
134                        *scores.entry(hit.id).or_insert(0.0) += w * hit.score;
135                    }
136                }
137            }
138        }
139        let mut fused: Vec<SearchHit> = scores
140            .into_iter()
141            .map(|(id, score)| SearchHit { id, score })
142            .collect();
143        fused.sort_unstable_by(|a, b| b.score.total_cmp(&a.score).then_with(|| a.id.cmp(&b.id)));
144        fused
145    }
146}
147
148/// Trait for compressed vector indexes.
149///
150/// Implementations provide approximate nearest neighbor search with
151/// quantization-based compression. Follows the same pattern as
152/// [`EmbeddingProvider`](crate::embedding::EmbeddingProvider).
153///
154/// The trait is defined here with zero dependencies. Concrete implementations
155/// live in separate crates (e.g., `llm-kernel-vector-index` with TurboQuant).
156///
157/// This trait is fully object-safe — `load` is intentionally not included
158/// because it requires `Self: Sized`. Concrete types provide their own
159/// `load` inherent methods instead.
160pub trait VectorIndex: Send + Sync {
161    /// Add vectors with auto-assigned sequential IDs.
162    fn add(&mut self, vectors: &[Vec<f32>]) -> Result<()>;
163
164    /// Add vectors with explicit external IDs.
165    fn add_with_ids(&mut self, vectors: &[Vec<f32>], ids: &[u64]) -> Result<()>;
166
167    /// Remove vectors by their external IDs.
168    ///
169    /// IDs that do not exist in the index are silently ignored.
170    /// Passing an empty slice is a no-op.
171    fn remove(&mut self, ids: &[u64]) -> Result<()>;
172
173    /// Search for the `k` nearest neighbors of `query`.
174    fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>>;
175
176    /// Search restricted to an allowlist of candidate IDs.
177    ///
178    /// Useful for hybrid retrieval: first narrow candidates via BM25 or
179    /// metadata filter, then dense-rerank within that set.
180    fn search_filtered(&self, query: &[f32], k: usize, allowlist: &[u64])
181    -> Result<Vec<SearchHit>>;
182
183    /// Number of vectors currently indexed.
184    fn len(&self) -> usize;
185
186    /// Whether the index is empty.
187    fn is_empty(&self) -> bool;
188
189    /// Vector dimensionality.
190    fn dim(&self) -> usize;
191
192    /// Persist the index to disk.
193    fn save(&self, path: &Path) -> Result<()>;
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[test]
201    fn search_hit_fields() {
202        let hit = SearchHit {
203            id: 42,
204            score: 0.95,
205        };
206        assert_eq!(hit.id, 42);
207        assert!((hit.score - 0.95).abs() < f32::EPSILON);
208    }
209
210    #[test]
211    fn search_hit_copy() {
212        let hit = SearchHit { id: 1, score: 0.5 };
213        let copied = hit; // Copy semantics — no .clone() needed
214        assert_eq!(copied.id, hit.id);
215        assert_eq!(copied.score, hit.score);
216    }
217
218    #[test]
219    fn search_hit_sort_descending_by_score() {
220        let mut hits = [
221            SearchHit { id: 1, score: 0.3 },
222            SearchHit { id: 2, score: 0.9 },
223            SearchHit { id: 3, score: 0.5 },
224        ];
225        hits.sort_by(|a, b| a.partial_cmp(b).unwrap());
226        assert_eq!(hits[0].id, 2); // highest score first
227        assert_eq!(hits[1].id, 3);
228        assert_eq!(hits[2].id, 1);
229    }
230
231    #[test]
232    fn search_hit_tie_break_by_id() {
233        let mut hits = [
234            SearchHit { id: 30, score: 0.5 },
235            SearchHit { id: 10, score: 0.5 },
236            SearchHit { id: 20, score: 0.5 },
237        ];
238        hits.sort_by(|a, b| a.partial_cmp(b).unwrap());
239        assert_eq!(hits[0].id, 10);
240        assert_eq!(hits[1].id, 20);
241        assert_eq!(hits[2].id, 30);
242    }
243
244    fn hit(id: u64, score: f32) -> SearchHit {
245        SearchHit { id, score }
246    }
247
248    #[test]
249    fn rrf_default_uses_k_60() {
250        assert_eq!(Fusion::rrf(), Fusion::Rrf { k: 60 });
251    }
252
253    #[test]
254    fn rrf_rewards_agreement_across_branches() {
255        // 20 is only 2nd in the dense branch but appears in both, so it must
256        // outrank 10, which is 1st in only one branch.
257        let dense = vec![hit(10, 0.99), hit(20, 0.80)];
258        let sparse = vec![hit(20, 12.0), hit(30, 9.0)];
259        let fused = Fusion::rrf().fuse(&[dense, sparse]);
260        assert_eq!(fused[0].id, 20);
261        assert_eq!(fused.len(), 3);
262    }
263
264    #[test]
265    fn rrf_ignores_raw_score_scale() {
266        // Sparse inner-product scores dwarf cosine scores; RRF must not care.
267        let dense = vec![hit(1, 0.9), hit(2, 0.8)];
268        let sparse = vec![hit(1, 900.0), hit(2, 800.0)];
269        let a = Fusion::rrf().fuse(&[dense.clone(), sparse]);
270        let sparse_small = vec![hit(1, 0.009), hit(2, 0.008)];
271        let b = Fusion::rrf().fuse(&[dense, sparse_small]);
272        assert_eq!(
273            a.iter().map(|h| h.id).collect::<Vec<_>>(),
274            b.iter().map(|h| h.id).collect::<Vec<_>>()
275        );
276    }
277
278    #[test]
279    fn weighted_sums_scores_per_branch() {
280        let dense = vec![hit(1, 1.0)];
281        let sparse = vec![hit(2, 1.0)];
282        let fused = Fusion::Weighted {
283            weights: vec![1.0, 2.0],
284        }
285        .fuse(&[dense, sparse]);
286        // id 2 carries the heavier branch weight.
287        assert_eq!(fused[0].id, 2);
288        assert!((fused[0].score - 2.0).abs() < 1e-6);
289        assert!((fused[1].score - 1.0).abs() < 1e-6);
290    }
291
292    #[test]
293    #[should_panic(expected = "must equal weights.len()")]
294    fn weighted_panics_on_weight_count_mismatch() {
295        Fusion::Weighted { weights: vec![1.0] }.fuse(&[vec![hit(1, 1.0)], vec![hit(2, 1.0)]]);
296    }
297
298    #[test]
299    fn fuse_of_nothing_is_empty() {
300        assert!(Fusion::rrf().fuse(&[]).is_empty());
301    }
302}