Skip to main content

llm_kernel/embedding/
turbovec.rs

1//! TurboQuant-backed vector index implementation.
2
3use std::path::Path;
4
5use super::vector_index::{SearchHit, VectorIndex};
6use crate::error::{KernelError, Result};
7
8/// Compressed vector index backed by TurboQuant.
9///
10/// Wraps `turbovec::IdMapIndex` with dimension validation and a consistent
11/// error-handling layer. Supports online ingest (no training step),
12/// filtered search with allowlists, and persistence via `save`/`load`.
13pub struct TurbovecIndex {
14    inner: turbovec::IdMapIndex,
15    dim: usize,
16    bit_width: u8,
17    /// Persisted model/policy metadata. `None` for in-memory indices created
18    /// without it, or for loaded indices whose meta predates these fields.
19    meta: Option<IndexMeta>,
20}
21
22impl std::fmt::Debug for TurbovecIndex {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.debug_struct("TurbovecIndex")
25            .field("dim", &self.dim)
26            .field("bit_width", &self.bit_width)
27            .field("len", &self.inner.len())
28            .finish()
29    }
30}
31
32impl TurbovecIndex {
33    /// Create a new index for vectors of the given dimension.
34    ///
35    /// `bit_width` must be 2 or 4, controlling the quantization level:
36    /// - **2-bit**: 16x compression, lower recall at low k
37    /// - **4-bit**: 8x compression, higher recall (recommended default)
38    pub fn new(dim: usize, bit_width: u8) -> Result<Self> {
39        if bit_width != 2 && bit_width != 4 {
40            return Err(KernelError::Embedding(format!(
41                "bit_width must be 2 or 4, got {bit_width}"
42            )));
43        }
44        let inner = turbovec::IdMapIndex::new(dim, bit_width as usize)
45            .map_err(|e| KernelError::Embedding(format!("failed to create index: {e}")))?;
46        Ok(Self {
47            inner,
48            dim,
49            bit_width,
50            meta: None,
51        })
52    }
53
54    /// Create with model/policy metadata so it persists on [`save`](VectorIndex::save).
55    /// Consumers compare these on load to decide whether a rebuild is needed
56    /// (e.g. a prefix-policy change with the same `dim`).
57    pub fn with_meta(
58        dim: usize,
59        bit_width: u8,
60        model_id: Option<String>,
61        prefix_policy: Option<String>,
62        schema_version: Option<u32>,
63    ) -> Result<Self> {
64        let mut idx = Self::new(dim, bit_width)?;
65        idx.meta = Some(IndexMeta {
66            dim,
67            bit_width,
68            model_id,
69            prefix_policy,
70            schema_version,
71        });
72        Ok(idx)
73    }
74
75    /// Persisted metadata (model id, prefix policy, schema version), if any.
76    pub fn meta(&self) -> Option<&IndexMeta> {
77        self.meta.as_ref()
78    }
79
80    /// Quantization bit width (2 or 4).
81    pub fn bit_width(&self) -> u8 {
82        self.bit_width
83    }
84
85    /// Load a previously saved index from disk.
86    ///
87    /// This is an inherent method (not on the `VectorIndex` trait) so that the
88    /// trait remains fully object-safe. Callers must use the concrete type:
89    /// `TurbovecIndex::load(path)`.
90    pub fn load(path: &Path) -> Result<Self> {
91        let inner = turbovec::IdMapIndex::load(path)
92            .map_err(|e| KernelError::Embedding(format!("failed to load vector index: {e}")))?;
93        let meta_path = path.with_extension("meta.json");
94        let meta: IndexMeta = serde_json::from_str(&std::fs::read_to_string(&meta_path)?)
95            .map_err(KernelError::embedding)?;
96        if meta.bit_width != 2 && meta.bit_width != 4 {
97            return Err(KernelError::Embedding(format!(
98                "corrupted index meta: bit_width must be 2 or 4, got {}",
99                meta.bit_width
100            )));
101        }
102        if meta.dim == 0 {
103            return Err(KernelError::Embedding(
104                "corrupted index meta: dim must be positive, got 0".into(),
105            ));
106        }
107
108        // Cross-validate: loaded index vs sidecar metadata.
109        if let Some(inner_dim) = inner.dim_opt()
110            && inner_dim != meta.dim
111        {
112            return Err(KernelError::Embedding(format!(
113                "index-meta mismatch: index dim={inner_dim}, meta dim={}",
114                meta.dim
115            )));
116        }
117        let inner_bw = inner.bit_width();
118        if inner_bw != meta.bit_width as usize {
119            return Err(KernelError::Embedding(format!(
120                "index-meta mismatch: index bit_width={inner_bw}, meta bit_width={}",
121                meta.bit_width
122            )));
123        }
124
125        Ok(Self {
126            inner,
127            dim: meta.dim,
128            bit_width: meta.bit_width,
129            meta: Some(meta),
130        })
131    }
132
133    fn validate_dim(&self, v: &[f32]) -> Result<()> {
134        if v.len() != self.dim {
135            return Err(KernelError::Embedding(format!(
136                "vector dimension mismatch: expected {}, got {}",
137                self.dim,
138                v.len()
139            )));
140        }
141        Ok(())
142    }
143
144    fn validate_dims(&self, vectors: &[Vec<f32>]) -> Result<()> {
145        for v in vectors {
146            self.validate_dim(v)?;
147        }
148        Ok(())
149    }
150}
151
152impl VectorIndex for TurbovecIndex {
153    fn add(&mut self, vectors: &[Vec<f32>]) -> Result<()> {
154        if vectors.is_empty() {
155            return Ok(());
156        }
157        self.validate_dims(vectors)?;
158        let start_id = self.inner.len() as u64;
159        let ids: Vec<u64> = (start_id..start_id + vectors.len() as u64).collect();
160        // Skip validation — already checked above.
161        let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
162        self.inner
163            .add_with_ids_2d(&flat, self.dim, &ids)
164            .map_err(|e| KernelError::Embedding(format!("add failed: {e}")))?;
165        Ok(())
166    }
167
168    fn add_with_ids(&mut self, vectors: &[Vec<f32>], ids: &[u64]) -> Result<()> {
169        if vectors.len() != ids.len() {
170            return Err(KernelError::Embedding(format!(
171                "vectors ({} entries) and ids ({} entries) must have the same length",
172                vectors.len(),
173                ids.len()
174            )));
175        }
176        self.validate_dims(vectors)?;
177        let flat: Vec<f32> = vectors.iter().flat_map(|v| v.iter().copied()).collect();
178        self.inner
179            .add_with_ids_2d(&flat, self.dim, ids)
180            .map_err(|e| {
181                // Tag the failure kind so consumers can distinguish "id already
182                // present" (a routine re-ingest, not data loss) from a real
183                // backend failure, without brittle full-string matching.
184                let kind = if e.to_string().contains("already present") {
185                    "duplicate_id"
186                } else {
187                    "backend"
188                };
189                KernelError::Embedding(format!("add failed[{kind}]: {e}"))
190            })?;
191        Ok(())
192    }
193
194    fn remove(&mut self, ids: &[u64]) -> Result<()> {
195        for &id in ids {
196            self.inner.remove(id);
197        }
198        Ok(())
199    }
200
201    fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchHit>> {
202        self.validate_dim(query)?;
203        if self.inner.is_empty() {
204            return Ok(vec![]);
205        }
206        let (scores, ids) = self.inner.search(query, k);
207        Ok(scores
208            .into_iter()
209            .zip(ids)
210            .map(|(score, id)| SearchHit { id, score })
211            .collect())
212    }
213
214    fn search_filtered(
215        &self,
216        query: &[f32],
217        k: usize,
218        allowlist: &[u64],
219    ) -> Result<Vec<SearchHit>> {
220        self.validate_dim(query)?;
221        if self.inner.is_empty() || allowlist.is_empty() {
222            return Ok(vec![]);
223        }
224        let (scores, ids) = self
225            .inner
226            .search_with_allowlist(query, k, Some(allowlist))
227            .map_err(|e| KernelError::Embedding(format!("filtered search failed: {e}")))?;
228        Ok(scores
229            .into_iter()
230            .zip(ids)
231            .map(|(score, id)| SearchHit { id, score })
232            .collect())
233    }
234
235    fn len(&self) -> usize {
236        self.inner.len()
237    }
238
239    fn is_empty(&self) -> bool {
240        self.inner.is_empty()
241    }
242
243    fn dim(&self) -> usize {
244        self.dim
245    }
246
247    fn save(&self, path: &Path) -> Result<()> {
248        // Atomic save: write to temp files, fsync, then rename.
249        let tmp_index = path.with_extension("tvim.tmp");
250        let tmp_meta = path.with_extension("meta.tmp");
251
252        self.inner
253            .write(&tmp_index)
254            .map_err(|e| KernelError::Embedding(format!("failed to write vector index: {e}")))?;
255
256        let meta = self.meta.clone().unwrap_or(IndexMeta {
257            dim: self.dim,
258            bit_width: self.bit_width,
259            model_id: None,
260            prefix_policy: None,
261            schema_version: None,
262        });
263        let json = serde_json::to_string_pretty(&meta).map_err(KernelError::embedding)?;
264        std::fs::write(&tmp_meta, &json)?;
265
266        // Fsync temp files to ensure data is on disk.
267        if let Ok(f) = std::fs::File::open(&tmp_index) {
268            let _ = f.sync_all();
269        }
270        if let Ok(f) = std::fs::File::open(&tmp_meta) {
271            let _ = f.sync_all();
272        }
273
274        // Atomic rename — POSIX guarantees rename is atomic.
275        std::fs::rename(&tmp_meta, path.with_extension("meta.json"))?;
276        std::fs::rename(&tmp_index, path)?;
277
278        Ok(())
279    }
280}
281
282/// Persisted vector-index metadata sidecar.
283///
284/// Stored as `vectors.meta.json` next to the index file. Consumers compare these
285/// on load to decide whether a rebuild is needed (e.g. a prefix-policy change
286/// with the same `dim`).
287#[derive(Debug, serde::Serialize, serde::Deserialize, Clone)]
288pub struct IndexMeta {
289    /// Embedding dimensionality (must match the model).
290    pub dim: usize,
291    /// Quantization bit width (2 or 4).
292    pub bit_width: u8,
293    /// Model identifier (e.g. "intfloat/multilingual-e5-small"). `None` on
294    /// indices written before this field existed — consumers treat that as
295    /// "unknown, rebuild" so a prefix/policy change forces a rebuild even when
296    /// `dim` is unchanged.
297    #[serde(default, skip_serializing_if = "Option::is_none")]
298    pub model_id: Option<String>,
299    /// Embedding policy tag (e.g. "e5-query-doc-v1"). Lets callers detect that
300    /// the index was built with a different prefix scheme without bumping dim.
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub prefix_policy: Option<String>,
303    /// Caller-defined schema version for the vector store layout (chunking etc.).
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub schema_version: Option<u32>,
306}
307
308#[cfg(test)]
309mod tests {
310    use super::*;
311    use tempfile::TempDir;
312
313    fn make_index(dim: usize, bit_width: u8) -> TurbovecIndex {
314        TurbovecIndex::new(dim, bit_width).unwrap()
315    }
316
317    fn random_vector(dim: usize, seed: f32) -> Vec<f32> {
318        (0..dim).map(|i| (seed + i as f32 * 0.001).sin()).collect()
319    }
320
321    #[test]
322    fn new_valid_bit_widths() {
323        assert!(TurbovecIndex::new(128, 2).is_ok());
324        assert!(TurbovecIndex::new(128, 4).is_ok());
325    }
326
327    #[test]
328    fn new_invalid_bit_width() {
329        assert!(TurbovecIndex::new(128, 3).is_err());
330        assert!(TurbovecIndex::new(128, 8).is_err());
331        assert!(TurbovecIndex::new(128, 1).is_err());
332    }
333
334    #[test]
335    fn add_and_len() {
336        let mut idx = make_index(64, 4);
337        assert!(idx.is_empty());
338        idx.add(&[random_vector(64, 1.0), random_vector(64, 2.0)])
339            .unwrap();
340        assert_eq!(idx.len(), 2);
341    }
342
343    #[test]
344    fn add_empty() {
345        let mut idx = make_index(64, 4);
346        idx.add(&[]).unwrap();
347        assert!(idx.is_empty());
348    }
349
350    #[test]
351    fn add_with_explicit_ids() {
352        let mut idx = make_index(64, 4);
353        idx.add_with_ids(&[random_vector(64, 1.0)], &[42u64])
354            .unwrap();
355        assert_eq!(idx.len(), 1);
356    }
357
358    #[test]
359    fn add_dimension_mismatch() {
360        let mut idx = make_index(64, 4);
361        let result = idx.add(&[vec![0.0; 32]]);
362        assert!(result.is_err());
363        assert!(
364            result
365                .unwrap_err()
366                .to_string()
367                .contains("dimension mismatch")
368        );
369    }
370
371    #[test]
372    fn add_with_ids_length_mismatch() {
373        let mut idx = make_index(64, 4);
374        let result = idx.add_with_ids(&[random_vector(64, 1.0), random_vector(64, 2.0)], &[1u64]);
375        assert!(result.is_err());
376        assert!(result.unwrap_err().to_string().contains("same length"));
377    }
378
379    #[test]
380    fn search_empty_index() {
381        let idx = make_index(64, 4);
382        let hits = idx.search(&random_vector(64, 1.0), 5).unwrap();
383        assert!(hits.is_empty());
384    }
385
386    #[test]
387    fn search_returns_nearest() {
388        let mut idx = make_index(64, 4);
389        let target = random_vector(64, 3.0);
390        idx.add_with_ids(
391            &[
392                random_vector(64, 100.0),
393                target.clone(),
394                random_vector(64, 200.0),
395            ],
396            &[0u64, 1u64, 2u64],
397        )
398        .unwrap();
399        let hits = idx.search(&target, 1).unwrap();
400        assert_eq!(hits.len(), 1);
401        assert_eq!(hits[0].id, 1);
402    }
403
404    #[test]
405    fn search_dimension_mismatch() {
406        let mut idx = make_index(64, 4);
407        idx.add(&[random_vector(64, 1.0)]).unwrap();
408        let result = idx.search(&[0.0; 32], 1);
409        assert!(result.is_err());
410    }
411
412    #[test]
413    fn search_filtered_with_allowlist() {
414        let mut idx = make_index(64, 4);
415        idx.add_with_ids(
416            &[
417                random_vector(64, 1.0),
418                random_vector(64, 2.0),
419                random_vector(64, 3.0),
420            ],
421            &[10u64, 20u64, 30u64],
422        )
423        .unwrap();
424        let hits = idx
425            .search_filtered(&random_vector(64, 1.0), 10, &[20u64, 30u64])
426            .unwrap();
427        let ids: Vec<u64> = hits.iter().map(|h| h.id).collect();
428        assert!(ids.contains(&20));
429        assert!(ids.contains(&30));
430        assert!(!ids.contains(&10));
431    }
432
433    #[test]
434    fn search_filtered_empty_allowlist() {
435        let mut idx = make_index(64, 4);
436        idx.add(&[random_vector(64, 1.0)]).unwrap();
437        let hits = idx
438            .search_filtered(&random_vector(64, 1.0), 5, &[])
439            .unwrap();
440        assert!(hits.is_empty());
441    }
442
443    #[test]
444    fn save_load_roundtrip() {
445        let dir = TempDir::new().unwrap();
446        let path = dir.path().join("test.tvim");
447        let mut idx = make_index(64, 4);
448        idx.add_with_ids(
449            &[random_vector(64, 1.0), random_vector(64, 2.0)],
450            &[100u64, 200u64],
451        )
452        .unwrap();
453        idx.save(&path).unwrap();
454        let loaded = TurbovecIndex::load(&path).unwrap();
455        assert_eq!(loaded.dim(), 64);
456        assert_eq!(loaded.bit_width(), 4);
457        assert_eq!(loaded.len(), 2);
458    }
459
460    #[test]
461    fn load_rejects_corrupted_meta() {
462        let dir = TempDir::new().unwrap();
463        let path = dir.path().join("corrupt.tvim");
464        let mut idx = make_index(64, 4);
465        idx.add(&[random_vector(64, 1.0)]).unwrap();
466        idx.save(&path).unwrap();
467        let meta_path = path.with_extension("meta.json");
468        std::fs::write(&meta_path, r#"{"dim": 64, "bit_width": 7}"#).unwrap();
469        let result = TurbovecIndex::load(&path);
470        assert!(result.is_err());
471        assert!(result.unwrap_err().to_string().contains("bit_width"));
472    }
473
474    #[test]
475    fn load_rejects_zero_dim() {
476        let dir = TempDir::new().unwrap();
477        let path = dir.path().join("zero.tvim");
478        let mut idx = make_index(64, 4);
479        idx.add(&[random_vector(64, 1.0)]).unwrap();
480        idx.save(&path).unwrap();
481        let meta_path = path.with_extension("meta.json");
482        std::fs::write(&meta_path, r#"{"dim": 0, "bit_width": 4}"#).unwrap();
483        let result = TurbovecIndex::load(&path);
484        assert!(result.is_err());
485        assert!(result.unwrap_err().to_string().contains("dim"));
486    }
487
488    #[test]
489    fn dim_and_bit_width_accessors() {
490        let idx = make_index(128, 2);
491        assert_eq!(idx.dim(), 128);
492        assert_eq!(idx.bit_width(), 2);
493    }
494
495    #[test]
496    fn trait_object_compatibility() {
497        let mut idx: Box<dyn VectorIndex> = Box::new(make_index(64, 4));
498        idx.add(&[random_vector(64, 1.0)]).unwrap();
499        assert_eq!(idx.len(), 1);
500        assert!(!idx.is_empty());
501    }
502
503    #[test]
504    fn remove_existing_id() {
505        let mut idx = make_index(64, 4);
506        idx.add_with_ids(
507            &[
508                random_vector(64, 1.0),
509                random_vector(64, 2.0),
510                random_vector(64, 3.0),
511            ],
512            &[10u64, 20u64, 30u64],
513        )
514        .unwrap();
515        assert_eq!(idx.len(), 3);
516        idx.remove(&[20u64]).unwrap();
517        assert_eq!(idx.len(), 2);
518        let hits = idx.search(&random_vector(64, 2.0), 10).unwrap();
519        let ids: Vec<u64> = hits.iter().map(|h| h.id).collect();
520        assert!(!ids.contains(&20));
521    }
522
523    #[test]
524    fn remove_nonexistent_id() {
525        let mut idx = make_index(64, 4);
526        idx.add_with_ids(&[random_vector(64, 1.0)], &[1u64])
527            .unwrap();
528        idx.remove(&[999u64]).unwrap();
529        assert_eq!(idx.len(), 1);
530    }
531
532    #[test]
533    fn remove_empty_ids() {
534        let mut idx = make_index(64, 4);
535        idx.add(&[random_vector(64, 1.0)]).unwrap();
536        idx.remove(&[]).unwrap();
537        assert_eq!(idx.len(), 1);
538    }
539
540    #[test]
541    fn remove_via_trait_object() {
542        let mut idx: Box<dyn VectorIndex> = Box::new(make_index(64, 4));
543        idx.add_with_ids(&[random_vector(64, 1.0)], &[42u64])
544            .unwrap();
545        idx.remove(&[42u64]).unwrap();
546        assert!(idx.is_empty());
547    }
548
549    #[test]
550    fn load_detects_dim_mismatch() {
551        let dir = TempDir::new().unwrap();
552        let path = dir.path().join("mismatch.tvim");
553        let mut idx = make_index(64, 4);
554        idx.add(&[random_vector(64, 1.0)]).unwrap();
555        idx.save(&path).unwrap();
556        let meta_path = path.with_extension("meta.json");
557        std::fs::write(&meta_path, r#"{"dim": 128, "bit_width": 4}"#).unwrap();
558        let result = TurbovecIndex::load(&path);
559        assert!(result.is_err());
560        let msg = result.unwrap_err().to_string();
561        assert!(
562            msg.contains("mismatch") || msg.contains("dim"),
563            "expected mismatch error, got: {msg}"
564        );
565    }
566}