Skip to main content

dynvec/
turbo_index.rs

1//! `turbovec`-backed approximate nearest-neighbour table.
2//!
3//! This module provides [`TurboTable`], a SIMD-search ANN
4//! container that drops in alongside [`crate::index::HnswIndex`]
5//! when a table's [`crate::encoding::Codec`] is one of the
6//! `Turbovec*` variants. The table holds:
7//!
8//! * a [`turbovec::TurboQuantIndex`] storing every vector in its
9//!   2/3/4-bit packed form (where the 8x to 16x compression and
10//!   SIMD scoring kernels live);
11//! * a slot-to-id and id-to-slot map so external [`NodeId`]
12//!   handles survive turbovec's positional layout;
13//! * a parallel `Vec<Option<NodeId>>` indexed by turbovec slot,
14//!   used to translate the search results' positional indices
15//!   back into stable ids.
16//!
17//! Concurrency mirrors the HNSW path: the storage layer holds a
18//! per-table `Mutex` across an insert / search call.
19//!
20//! # Distance handling
21//!
22//! turbovec returns an inner-product-style similarity score per
23//! candidate. To honour [`crate::distance::Distance`] semantics,
24//! [`TurboTable`] L2-normalises queries and stored vectors at
25//! ingest time when the table's metric is `Cosine` or
26//! `Euclidean`, so the SIMD score becomes an estimate of
27//! `cos(theta)`. The reported [`SearchResult::score`] is then
28//! mapped to the metric's smaller-is-closer convention so the
29//! result aligns with the rest of the engine.
30
31use std::collections::HashMap;
32
33use turbovec::TurboQuantIndex;
34
35use crate::distance::Distance;
36use crate::index::{IndexError, NodeId, SearchResult};
37
38/// Approximate-nearest-neighbour table backed by
39/// [`turbovec::TurboQuantIndex`].
40pub struct TurboTable {
41    bits: u8,
42    distance: Distance,
43    dim: u16,
44    /// turbovec index. Holds compressed packed codes, per-vector
45    /// scales and the per-table TQ+ calibration, plus the lazy
46    /// SIMD layout cache. Re-built fresh on rehydrate.
47    index: TurboQuantIndex,
48    /// Parallel to turbovec's positional slot index. `None` for a
49    /// soft-deleted slot whose adjacency we still own. Soft
50    /// deletes flag the slot here so the search filter can drop
51    /// the hit without disturbing turbovec's positional layout.
52    slots: Vec<Option<NodeId>>,
53    /// External-id lookup so `delete(NodeId)` is O(1).
54    id_to_slot: HashMap<NodeId, usize>,
55}
56
57impl TurboTable {
58    /// Build an empty turbovec-backed table.
59    ///
60    /// # Errors
61    ///
62    /// [`IndexError::Empty`] when `dim == 0`, or when `bits` is
63    /// not in `{2, 3, 4}`. [`IndexError::DimensionMismatch`]
64    /// when `dim` is not a positive multiple of 8 (turbovec's
65    /// only dimensional constraint); the `expected` field is
66    /// rounded up to the next multiple of 8 to give the caller
67    /// a workable suggestion.
68    pub fn new(distance: Distance, dim: u16, bits: u8) -> Result<Self, IndexError> {
69        if dim == 0 {
70            return Err(IndexError::Empty);
71        }
72        if !dim.is_multiple_of(8) {
73            return Err(IndexError::DimensionMismatch {
74                expected: ((dim / 8) + 1) * 8,
75                got: dim,
76            });
77        }
78        if !(2..=4).contains(&bits) {
79            return Err(IndexError::Empty);
80        }
81        let index = TurboQuantIndex::new(usize::from(dim), usize::from(bits))
82            .map_err(|_| IndexError::Empty)?;
83        Ok(Self {
84            bits,
85            distance,
86            dim,
87            index,
88            slots: Vec::new(),
89            id_to_slot: HashMap::new(),
90        })
91    }
92
93    /// Number of live (non-deleted) vectors.
94    #[must_use]
95    pub fn len(&self) -> usize {
96        self.slots.iter().filter(|s| s.is_some()).count()
97    }
98
99    /// `true` when there are no live vectors.
100    #[must_use]
101    pub fn is_empty(&self) -> bool {
102        self.len() == 0
103    }
104
105    /// Vector dimension.
106    #[must_use]
107    pub fn dim(&self) -> u16 {
108        self.dim
109    }
110
111    /// Distance metric this table was built with.
112    #[must_use]
113    pub fn distance(&self) -> Distance {
114        self.distance
115    }
116
117    /// Bit width handed to turbovec.
118    #[must_use]
119    pub fn bits(&self) -> u8 {
120        self.bits
121    }
122
123    /// Insert a vector under `id`.
124    ///
125    /// `vector` is taken in the application's `f32` form; the
126    /// turbovec encode pipeline handles rotation, calibration
127    /// and packing. When the table's metric is `Cosine` or
128    /// `Euclidean` the vector is L2-normalised before being
129    /// added, so turbovec's inner-product surrogate doubles as
130    /// a cosine estimate.
131    ///
132    /// # Errors
133    ///
134    /// [`IndexError::Empty`] for a zero-dim vector,
135    /// [`IndexError::DimensionMismatch`] when the vector's
136    /// dimension differs from the table's frozen dim, and
137    /// [`IndexError::Duplicate`] when `id` is already present.
138    pub fn insert(&mut self, id: NodeId, vector: Vec<f32>) -> Result<(), IndexError> {
139        if vector.is_empty() {
140            return Err(IndexError::Empty);
141        }
142        let got = u16::try_from(vector.len()).unwrap_or(u16::MAX);
143        if got != self.dim {
144            return Err(IndexError::DimensionMismatch {
145                expected: self.dim,
146                got,
147            });
148        }
149        if self.id_to_slot.contains_key(&id) {
150            return Err(IndexError::Duplicate(id));
151        }
152        let prepared = match self.distance {
153            Distance::Cosine | Distance::Euclidean => l2_normalise(&vector),
154            Distance::DotProduct => vector,
155        };
156        // turbovec rejects coordinates whose magnitude exceeds
157        // 1e16 or that are non-finite. Map both to
158        // `IndexError::Empty` so the storage layer reports them
159        // as a generic input-rejection; the encoder layer
160        // already filters NaN/Inf before reaching here in the
161        // typical path.
162        self.index
163            .add_2d(&prepared, usize::from(self.dim))
164            .map_err(|_| IndexError::Empty)?;
165        let slot = self.slots.len();
166        self.slots.push(Some(id));
167        self.id_to_slot.insert(id, slot);
168        Ok(())
169    }
170
171    /// Soft-delete the vector at `id`. The slot stays in the
172    /// turbovec index for positional integrity but is filtered
173    /// out of search results via the bool mask.
174    ///
175    /// Returns `true` when the id was present, `false`
176    /// otherwise.
177    pub fn delete(&mut self, id: NodeId) -> bool {
178        let Some(slot) = self.id_to_slot.remove(&id) else {
179            return false;
180        };
181        if slot < self.slots.len() {
182            self.slots[slot] = None;
183        }
184        true
185    }
186
187    /// `true` when `id` is currently a live vector.
188    #[must_use]
189    pub fn contains(&self, id: NodeId) -> bool {
190        self.id_to_slot.contains_key(&id)
191    }
192
193    /// Search for the `k` nearest neighbours of `query`. The
194    /// `_ef` argument is accepted for HNSW API parity and
195    /// ignored; turbovec scans every block and does not expose
196    /// a beam-width knob.
197    ///
198    /// # Errors
199    ///
200    /// [`IndexError::DimensionMismatch`] when the query
201    /// dimension does not match the table's frozen dim.
202    pub fn search(
203        &self,
204        query: &[f32],
205        k: usize,
206        _ef: Option<usize>,
207    ) -> Result<Vec<SearchResult>, IndexError> {
208        if query.is_empty() || self.slots.is_empty() {
209            return Ok(Vec::new());
210        }
211        let got = u16::try_from(query.len()).unwrap_or(u16::MAX);
212        if got != self.dim {
213            return Err(IndexError::DimensionMismatch {
214                expected: self.dim,
215                got,
216            });
217        }
218        let prepared = match self.distance {
219            Distance::Cosine | Distance::Euclidean => l2_normalise(query),
220            Distance::DotProduct => query.to_vec(),
221        };
222        let mask: Vec<bool> = self.slots.iter().map(Option::is_some).collect();
223        let allowed = mask.iter().filter(|b| **b).count();
224        if allowed == 0 {
225            return Ok(Vec::new());
226        }
227        let res = self.index.search_with_mask(&prepared, k, Some(&mask));
228        let mut out = Vec::with_capacity(res.k);
229        for i in 0..res.k {
230            let raw_idx = res.indices[i];
231            if raw_idx < 0 {
232                // turbovec pads the result row with -1 when
233                // fewer than `k` candidates survive the mask.
234                continue;
235            }
236            let Ok(slot) = usize::try_from(raw_idx) else {
237                continue;
238            };
239            let Some(Some(node_id)) = self.slots.get(slot) else {
240                continue;
241            };
242            let similarity = res.scores[i];
243            let score = match self.distance {
244                Distance::DotProduct => -similarity,
245                Distance::Cosine => 1.0 - similarity,
246                Distance::Euclidean => (2.0 - 2.0 * similarity).max(0.0).sqrt(),
247            };
248            out.push(SearchResult {
249                id: *node_id,
250                score,
251            });
252        }
253        // turbovec returns results sorted descending on
254        // similarity; the mappings above flip the sense for
255        // Cosine and Euclidean. A final sort enforces the
256        // smaller-is-closer convention used elsewhere in the
257        // engine.
258        out.sort_by(|a, b| {
259            a.score
260                .partial_cmp(&b.score)
261                .unwrap_or(std::cmp::Ordering::Equal)
262        });
263        out.truncate(k);
264        Ok(out)
265    }
266}
267
268fn l2_normalise(v: &[f32]) -> Vec<f32> {
269    let n2: f32 = v.iter().map(|x| x * x).sum();
270    let n = n2.sqrt();
271    if n <= 0.0 {
272        return v.to_vec();
273    }
274    v.iter().map(|x| x / n).collect()
275}
276
277#[cfg(test)]
278mod tests {
279    use super::*;
280
281    fn rand_vec(seed: u64, dim: usize) -> Vec<f32> {
282        let mut x = seed;
283        let mut v = Vec::with_capacity(dim);
284        for _ in 0..dim {
285            x ^= x << 13;
286            x ^= x >> 7;
287            x ^= x << 17;
288            let bits = (x >> 11) & ((1_u64 << 53) - 1);
289            #[allow(
290                clippy::cast_precision_loss,
291                clippy::cast_possible_truncation,
292                reason = "test fixture: PRNG narrowed to f32"
293            )]
294            let r = (((bits as f64) / ((1_u64 << 53) as f64)) * 2.0 - 1.0) as f32;
295            v.push(r);
296        }
297        v
298    }
299
300    #[test]
301    fn insert_and_search_returns_self_first() {
302        let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
303        let target = rand_vec(42, 64);
304        t.insert(0, target.clone()).unwrap();
305        for i in 1..50_u64 {
306            t.insert(i, rand_vec(i.wrapping_mul(1_000_003) + 1, 64))
307                .unwrap();
308        }
309        let res = t.search(&target, 3, None).unwrap();
310        assert!(!res.is_empty());
311        assert_eq!(res[0].id, 0);
312    }
313
314    #[test]
315    fn delete_excludes_from_search() {
316        let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
317        for i in 0..30_u64 {
318            t.insert(i, rand_vec(i + 1, 64)).unwrap();
319        }
320        let q = rand_vec(1, 64);
321        let before = t.search(&q, 5, None).unwrap();
322        let target = before[0].id;
323        assert!(t.delete(target));
324        let after = t.search(&q, 5, None).unwrap();
325        assert!(after.iter().all(|r| r.id != target));
326    }
327
328    #[test]
329    fn empty_table_search_is_empty() {
330        let t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
331        assert!(t.search(&rand_vec(0, 64), 5, None).unwrap().is_empty());
332    }
333
334    #[test]
335    fn duplicate_id_rejected() {
336        let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
337        t.insert(7, rand_vec(7, 64)).unwrap();
338        assert!(matches!(
339            t.insert(7, rand_vec(8, 64)),
340            Err(IndexError::Duplicate(7))
341        ));
342    }
343
344    #[test]
345    fn dimension_mismatch_rejected() {
346        let mut t = TurboTable::new(Distance::Cosine, 64, 4).unwrap();
347        assert!(matches!(
348            t.insert(0, vec![0.1; 32]),
349            Err(IndexError::DimensionMismatch { .. })
350        ));
351    }
352}