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}