Skip to main content

issundb_vector/
index.rs

1use std::cell::RefCell;
2use std::sync::Arc;
3
4use parking_lot::RwLock;
5use tracing::instrument;
6use usearch::{Index, IndexOptions, MetricKind, ScalarKind};
7
8use crate::error::VectorError;
9use issundb_core::{Graph, NodeId};
10
11/// A single result from vector search.
12pub struct Hit {
13    pub node: NodeId,
14    pub distance: f32,
15}
16
17/// Options for `vector_search_with`.
18#[derive(Debug, Clone)]
19pub struct VectorSearchOptions {
20    /// Maximum number of results to return.
21    pub k: usize,
22    /// If set, only nodes carrying this exact label are included in results.
23    pub label: Option<String>,
24    /// Optional property key-value filters. Only nodes matching all filters are returned.
25    pub properties: Option<std::collections::HashMap<String, serde_json::Value>>,
26    /// Rescore factor. When greater than 1, search fetches `k * rescore_factor`
27    /// candidates from the index and re-ranks them by exact distance against
28    /// the full-precision vectors stored in LMDB. Defaults to 2 on a quantized
29    /// index and 1 (no rescore) on a Float32 index. The default applies to
30    /// filtered searches too, where the over-fetch means the traversal must
31    /// find `k * rescore_factor` predicate-matching candidates; pass
32    /// `Some(1)` to disable rescoring for a selective filter.
33    pub rescore_factor: Option<usize>,
34}
35
36impl Default for VectorSearchOptions {
37    fn default() -> Self {
38        Self {
39            k: 10,
40            label: None,
41            properties: None,
42            rescore_factor: None,
43        }
44    }
45}
46
47/// Distance metric for the HNSW index.
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum VectorMetric {
50    /// Cosine similarity (default).
51    #[default]
52    Cosine,
53    /// Euclidean (L2) distance.
54    L2,
55    /// Inner product / dot product.
56    Dot,
57}
58
59/// Quantization format for in-memory vector storage.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum VectorQuantization {
62    /// Float32 quantization (default, full accuracy).
63    #[default]
64    Float32,
65    /// Float16 quantization (half memory footprint).
66    Float16,
67    /// Int8 quantization (quarter memory footprint).
68    Int8,
69}
70
71impl std::str::FromStr for VectorMetric {
72    type Err = VectorError;
73
74    /// Parse a metric name. Case-insensitive. Accepts `cosine`, `l2`, and `dot`
75    /// (with the alias `ip` for inner product). This is the one canonical
76    /// mapping every binding (CLI, REST, MCP, and Python) parses through.
77    fn from_str(s: &str) -> Result<Self, Self::Err> {
78        match s.to_lowercase().as_str() {
79            "cosine" => Ok(Self::Cosine),
80            "l2" => Ok(Self::L2),
81            "dot" | "ip" => Ok(Self::Dot),
82            other => Err(VectorError::InvalidConfig(format!(
83                "unknown metric '{other}' (expected 'cosine', 'l2', or 'dot')"
84            ))),
85        }
86    }
87}
88
89impl std::str::FromStr for VectorQuantization {
90    type Err = VectorError;
91
92    /// Parse a quantization name. Case-insensitive. Accepts `float32`,
93    /// `float16`, and `int8`. The one canonical mapping shared by every binding.
94    fn from_str(s: &str) -> Result<Self, Self::Err> {
95        match s.to_lowercase().as_str() {
96            "float32" => Ok(Self::Float32),
97            "float16" => Ok(Self::Float16),
98            "int8" => Ok(Self::Int8),
99            other => Err(VectorError::InvalidConfig(format!(
100                "unknown quantization '{other}' (expected 'float32', 'float16', or 'int8')"
101            ))),
102        }
103    }
104}
105
106/// Construction options for `VectorIndex`.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
108pub struct VectorIndexOptions {
109    pub metric: VectorMetric,
110    pub quantization: VectorQuantization,
111}
112
113enum Inner {
114    Empty,
115    Ready { index: Index, dims: usize },
116}
117
118/// An in-memory HNSW vector index using the `usearch` library.
119///
120/// Internal building block for the `VectorGraphExt` implementation on `Graph`.
121/// It holds no persistence of its own, so it is not part of the public surface;
122/// callers use the graph-backed `VectorGraphExt` methods instead.
123pub(crate) struct VectorIndex {
124    opts: VectorIndexOptions,
125    inner: RwLock<Inner>,
126}
127
128impl Default for VectorIndex {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl VectorIndex {
135    /// Construct a new empty vector index with default Cosine and Float32 options.
136    pub fn new() -> Self {
137        Self::new_with_options(VectorIndexOptions::default())
138    }
139
140    /// Construct a new empty vector index with custom metric and quantization.
141    pub fn new_with_options(opts: VectorIndexOptions) -> Self {
142        Self {
143            opts,
144            inner: RwLock::new(Inner::Empty),
145        }
146    }
147
148    /// Insert or replace the embedding for `node`.
149    ///
150    /// On the first call, the index is initialised with `v.len()` dimensions
151    /// using the metric and quantization from the construction options. Subsequent
152    /// calls with a different dimension count return `VectorError::DimensionMismatch`.
153    pub fn upsert(&self, node: NodeId, v: &[f32]) -> Result<(), VectorError> {
154        let dims = v.len();
155        if dims == 0 {
156            return Err(VectorError::IndexFault(
157                "embedding must not be empty".into(),
158            ));
159        }
160        let mut guard = self.inner.write();
161        match &mut *guard {
162            Inner::Empty => {
163                let opts = IndexOptions {
164                    dimensions: dims,
165                    metric: metric_to_usearch(self.opts.metric),
166                    quantization: quantization_to_usearch(self.opts.quantization),
167                    ..Default::default()
168                };
169                let index =
170                    Index::new(&opts).map_err(|e| VectorError::IndexFault(e.to_string()))?;
171                index
172                    .reserve(64)
173                    .map_err(|e| VectorError::IndexFault(e.to_string()))?;
174                index
175                    .add(node, v)
176                    .map_err(|e| VectorError::IndexFault(e.to_string()))?;
177                *guard = Inner::Ready { index, dims };
178            }
179            Inner::Ready { index, dims: d } => {
180                if dims != *d {
181                    return Err(VectorError::DimensionMismatch {
182                        expected: *d,
183                        got: dims,
184                    });
185                }
186                if index.contains(node) {
187                    index
188                        .remove(node)
189                        .map_err(|e| VectorError::IndexFault(e.to_string()))?;
190                }
191                if index.size() >= index.capacity() {
192                    let new_cap = (index.capacity() * 2).max(64);
193                    index
194                        .reserve(new_cap)
195                        .map_err(|e| VectorError::IndexFault(e.to_string()))?;
196                }
197                index
198                    .add(node, v)
199                    .map_err(|e| VectorError::IndexFault(e.to_string()))?;
200            }
201        }
202        Ok(())
203    }
204
205    /// Remove the embedding for `node` from the index.
206    pub fn remove(&self, node: NodeId) -> Result<(), VectorError> {
207        let mut guard = self.inner.write();
208        if let Inner::Ready { index, .. } = &mut *guard {
209            if index.contains(node) {
210                index
211                    .remove(node)
212                    .map_err(|e| VectorError::IndexFault(e.to_string()))?;
213            }
214        }
215        Ok(())
216    }
217
218    /// Return the `k` approximate nearest neighbors to `q` by cosine distance.
219    ///
220    /// Returns an empty slice when the index has no vectors or `k == 0`.
221    /// `k` is silently clamped to the number of indexed vectors.
222    pub fn search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError> {
223        let guard = self.inner.read();
224        match &*guard {
225            Inner::Empty => Ok(vec![]),
226            Inner::Ready { index, dims } => {
227                if q.len() != *dims {
228                    return Err(VectorError::DimensionMismatch {
229                        expected: *dims,
230                        got: q.len(),
231                    });
232                }
233                if k == 0 || index.size() == 0 {
234                    return Ok(vec![]);
235                }
236                let actual_k = k.min(index.size());
237                let matches = index
238                    .search::<f32>(q, actual_k)
239                    .map_err(|e| VectorError::IndexFault(e.to_string()))?;
240                Ok(matches
241                    .keys
242                    .iter()
243                    .zip(matches.distances.iter())
244                    .map(|(&node, &distance)| Hit { node, distance })
245                    .collect())
246            }
247        }
248    }
249
250    /// Return up to `k` approximate nearest neighbors to `q` that satisfy
251    /// `predicate`.
252    ///
253    /// The predicate is evaluated during the HNSW traversal, so the search keeps
254    /// expanding until it has `k` matching neighbors or exhausts the reachable
255    /// graph. Unlike post-filtering a fixed over-fetch, this does not silently
256    /// truncate the result set when the filter is selective.
257    pub fn search_filtered<F>(
258        &self,
259        q: &[f32],
260        k: usize,
261        predicate: F,
262    ) -> Result<Vec<Hit>, VectorError>
263    where
264        F: Fn(NodeId) -> bool,
265    {
266        let guard = self.inner.read();
267        match &*guard {
268            Inner::Empty => Ok(vec![]),
269            Inner::Ready { index, dims } => {
270                if q.len() != *dims {
271                    return Err(VectorError::DimensionMismatch {
272                        expected: *dims,
273                        got: q.len(),
274                    });
275                }
276                if k == 0 || index.size() == 0 {
277                    return Ok(vec![]);
278                }
279                let actual_k = k.min(index.size());
280                let matches = index
281                    .filtered_search::<f32, _>(q, actual_k, predicate)
282                    .map_err(|e| VectorError::IndexFault(e.to_string()))?;
283                Ok(matches
284                    .keys
285                    .iter()
286                    .zip(matches.distances.iter())
287                    .map(|(&node, &distance)| Hit { node, distance })
288                    .collect())
289            }
290        }
291    }
292}
293
294fn encode_vector(v: &[f32]) -> Result<Vec<u8>, VectorError> {
295    if v.is_empty() {
296        return Err(VectorError::IndexFault(
297            "embedding must not be empty".into(),
298        ));
299    }
300    Ok(v.iter().flat_map(|f| f.to_le_bytes()).collect())
301}
302
303fn decode_vector(bytes: &[u8]) -> Result<Vec<f32>, VectorError> {
304    if bytes.len() % 4 != 0 {
305        return Err(VectorError::IndexFault(format!(
306            "stored embedding byte length must be divisible by 4, got {}",
307            bytes.len()
308        )));
309    }
310    let vector = bytes
311        .chunks_exact(4)
312        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
313        .collect();
314    Ok(vector)
315}
316
317/// Vector search operations for `Graph`.
318pub trait VectorGraphExt {
319    /// Set the metric and quantization for this graph's vector index.
320    ///
321    /// The choice is persisted, so reopening the graph rebuilds the index with
322    /// the same configuration. Call this before upserting the first vector. The
323    /// HNSW graph is built per-metric, so the configuration cannot change once
324    /// vectors exist: a call that would change the persisted metric or
325    /// quantization while embeddings are present returns
326    /// `VectorError::AlreadyConfigured`. Re-applying the identical configuration
327    /// is a no-op. When no graph configuration is set, the index defaults to
328    /// `Cosine` and `Float32`.
329    fn configure_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
330
331    /// Change the metric and quantization and rebuild the index from the
332    /// persisted embeddings under the new configuration.
333    ///
334    /// Unlike `configure_vector_index`, this accepts a change after vectors
335    /// exist. The raw f32 embeddings are stored in LMDB independently of the
336    /// metric, so they are re-indexed under `opts`; switching back to `Float32`
337    /// recovers full precision from storage. This rebuilds the entire in-memory
338    /// HNSW index, so it is O(n) in the number of stored vectors and is intended
339    /// as an administrative operation, not a concurrent one: running it while
340    /// other threads upsert may drop an in-flight write from the snapshot, which
341    /// the next `Graph::open` rebuild reconciles.
342    fn reindex_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
343
344    /// Persist `v` under `n`.
345    fn upsert_vector(&self, n: NodeId, v: &[f32]) -> Result<(), VectorError>;
346
347    /// Remove the embedding for `n` from the index and from persistent storage.
348    fn remove_vector(&self, n: NodeId) -> Result<(), VectorError>;
349
350    /// Return the `k` approximate nearest neighbors to `q` by cosine distance.
351    fn vector_search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError>;
352
353    /// Return the `opts.k` approximate nearest neighbors that satisfy the label
354    /// and property filters in `opts`.
355    ///
356    /// When neither `opts.label` nor `opts.properties` is set the call is
357    /// identical to `vector_search(q, opts.k)`. When a filter is set, it is
358    /// applied during the HNSW traversal through a predicate, so the search
359    /// keeps expanding until it has `opts.k` matching neighbors rather than
360    /// post-filtering a fixed over-fetch (which silently under-returns for
361    /// selective filters). A node matches when it carries `opts.label` (if set)
362    /// and every entry in `opts.properties` (if set) equals the node's value for
363    /// that property. Fewer than `opts.k` results are returned only when the
364    /// index genuinely contains fewer matching nodes.
365    fn vector_search_with(
366        &self,
367        q: &[f32],
368        opts: &VectorSearchOptions,
369    ) -> Result<Vec<Hit>, VectorError>;
370
371    /// Return the full-precision embedding stored for `n`, or `None` when the
372    /// node has no embedding. This is a point lookup against LMDB and does not
373    /// build or consult the in-memory HNSW index.
374    fn node_vector(&self, n: NodeId) -> Result<Option<Vec<f32>>, VectorError>;
375
376    /// Distance between two vectors under this graph's configured metric
377    /// (default Cosine). The convention matches `vector_search`: squared L2 for
378    /// `L2` and `1 - dot` for `Dot`. Returns `DimensionMismatch` when the two
379    /// vectors differ in length.
380    fn vector_distance(&self, a: &[f32], b: &[f32]) -> Result<f32, VectorError>;
381}
382
383/// Key type used to store the persistent HNSW cache in `Graph::extensions`.
384struct VectorIndexCache(VectorIndex);
385
386impl VectorGraphExt for Graph {
387    fn configure_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError> {
388        let current = load_config(self)?;
389        if current == Some(opts) {
390            return Ok(());
391        }
392        // The HNSW graph is built per-metric. Changing the metric or
393        // quantization once embeddings exist would silently reinterpret them on
394        // the next cold-start rebuild, so refuse it while vectors are present.
395        if !self.vector_bytes()?.is_empty() {
396            return Err(VectorError::AlreadyConfigured {
397                existing: format!("{:?}", current.unwrap_or_default()),
398                requested: format!("{opts:?}"),
399            });
400        }
401        self.put_vector_config(&encode_config(opts))?;
402        // Replace any lazily built default cache so later upserts use the new
403        // configuration. Safe because no vectors exist yet.
404        self.set_extension(Arc::new(VectorIndexCache(VectorIndex::new_with_options(
405            opts,
406        ))));
407        Ok(())
408    }
409
410    fn reindex_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError> {
411        // Persist the new configuration, rebuild the index from the stored raw
412        // embeddings, then swap the cache atomically. The build runs before the
413        // swap so a mid-rebuild failure leaves the previous cache in place.
414        self.put_vector_config(&encode_config(opts))?;
415        let rebuilt = build_index(self, opts)?;
416        self.set_extension(Arc::new(VectorIndexCache(rebuilt)));
417        Ok(())
418    }
419
420    #[instrument(skip(self, v), fields(node = %n, dims = v.len()))]
421    fn upsert_vector(&self, n: NodeId, v: &[f32]) -> Result<(), VectorError> {
422        let bytes = encode_vector(v)?;
423        // Validate against (and update) the in-memory index BEFORE persisting to
424        // LMDB. `upsert` rejects empty or dimension-mismatched embeddings, so
425        // doing it first guarantees a rejected vector never reaches durable
426        // storage. If it did, the cold-start rebuild on the next `Graph::open`
427        // would hit the mismatch and fail to build the index, bricking every
428        // subsequent search. The reverse failure (index updated, LMDB write
429        // fails) only drops an in-memory entry that the next reopen rebuilds
430        // consistently, so it is the safe ordering.
431        let arc = get_or_init_cache(self)?;
432        arc.0.upsert(n, v)?;
433        self.put_vector_bytes(n, &bytes)?;
434        Ok(())
435    }
436
437    fn remove_vector(&self, n: NodeId) -> Result<(), VectorError> {
438        self.delete_vector_bytes(n)?;
439        // Remove from in-memory HNSW index if the cache has been built.
440        if let Some(arc) = self.get_extension::<VectorIndexCache>() {
441            arc.0.remove(n)?;
442        }
443        Ok(())
444    }
445
446    #[instrument(skip(self, q), fields(k = %k, dims = q.len()))]
447    fn vector_search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError> {
448        let opts = VectorSearchOptions {
449            k,
450            ..Default::default()
451        };
452        self.vector_search_with(q, &opts)
453    }
454
455    #[instrument(skip(self, q), fields(k = %opts.k, label = ?opts.label, dims = q.len()))]
456    fn vector_search_with(
457        &self,
458        q: &[f32],
459        opts: &VectorSearchOptions,
460    ) -> Result<Vec<Hit>, VectorError> {
461        let arc = get_or_init_cache(self)?;
462
463        let index_quantization = arc.0.opts.quantization;
464        let rescore_factor =
465            opts.rescore_factor
466                .unwrap_or(if index_quantization != VectorQuantization::Float32 {
467                    2
468                } else {
469                    1
470                });
471
472        let fetch_k = if rescore_factor > 1 {
473            opts.k.saturating_mul(rescore_factor)
474        } else {
475            opts.k
476        };
477
478        let hits = if opts.label.is_some() || opts.properties.is_some() {
479            // Evaluate the label and property filters during the HNSW traversal via
480            // a predicate, so the search keeps expanding until it has `opts.k`
481            // matching neighbors instead of post-filtering a fixed over-fetch, which
482            // silently under-returns when the filter is selective. The predicate
483            // reads through the core accessors (`label_filter` point lookup and the
484            // in-memory property columns via `node_prop_json`) rather than decoding
485            // raw node records, to respect the crate boundary. A storage error
486            // cannot travel through the `Fn(NodeId) -> bool` callback, so it is
487            // captured and surfaced after the search; once set, the predicate
488            // rejects every remaining candidate to end the traversal promptly.
489            let pred_err: RefCell<Option<VectorError>> = RefCell::new(None);
490            let matches_filters = |node: NodeId| -> Result<bool, VectorError> {
491                if let Some(label) = &opts.label {
492                    if self.label_filter(&[node], label)?.is_empty() {
493                        return Ok(false);
494                    }
495                }
496                if let Some(filters) = &opts.properties {
497                    for (key, want) in filters {
498                        match self.node_prop_json(node, key)? {
499                            Some(got) if &got == want => {}
500                            _ => return Ok(false),
501                        }
502                    }
503                }
504                Ok(true)
505            };
506            let predicate = |node: NodeId| -> bool {
507                if pred_err.borrow().is_some() {
508                    return false;
509                }
510                match matches_filters(node) {
511                    Ok(keep) => keep,
512                    Err(e) => {
513                        *pred_err.borrow_mut() = Some(e);
514                        false
515                    }
516                }
517            };
518
519            let results = arc.0.search_filtered(q, fetch_k, predicate)?;
520            if let Some(e) = pred_err.into_inner() {
521                return Err(e);
522            }
523            results
524        } else {
525            arc.0.search(q, fetch_k)?
526        };
527
528        let mut final_hits = if rescore_factor > 1 && !hits.is_empty() {
529            // One read transaction covers every stored-vector lookup. A hit
530            // whose stored bytes are absent keeps its approximate distance,
531            // so a vacuous index entry degrades the estimate, not the call.
532            let byte_rows: Vec<(Hit, Option<Vec<u8>>)> = self.view(|txn| {
533                hits.into_iter()
534                    .map(|hit| {
535                        let bytes = txn.get_vector_bytes(hit.node)?;
536                        Ok((hit, bytes))
537                    })
538                    .collect()
539            })?;
540            let mut rescored = Vec::with_capacity(byte_rows.len());
541            for (hit, bytes) in byte_rows {
542                rescored.push(match bytes {
543                    Some(b) => Hit {
544                        node: hit.node,
545                        distance: exact_distance(q, &decode_vector(&b)?, arc.0.opts.metric),
546                    },
547                    None => hit,
548                });
549            }
550            rescored.sort_unstable_by(|a, b| {
551                a.distance
552                    .partial_cmp(&b.distance)
553                    .unwrap_or(std::cmp::Ordering::Equal)
554            });
555            rescored
556        } else {
557            hits
558        };
559
560        final_hits.truncate(opts.k);
561        Ok(final_hits)
562    }
563
564    fn node_vector(&self, n: NodeId) -> Result<Option<Vec<f32>>, VectorError> {
565        let bytes = self.view(|txn| txn.get_vector_bytes(n))?;
566        match bytes {
567            Some(b) => Ok(Some(decode_vector(&b)?)),
568            None => Ok(None),
569        }
570    }
571
572    fn vector_distance(&self, a: &[f32], b: &[f32]) -> Result<f32, VectorError> {
573        if a.len() != b.len() {
574            return Err(VectorError::DimensionMismatch {
575                expected: a.len(),
576                got: b.len(),
577            });
578        }
579        let metric = load_config(self)?.unwrap_or_default().metric;
580        Ok(exact_distance(a, b, metric))
581    }
582}
583
584/// Full-precision distance between `q` and a stored vector, matching the
585/// distance convention `usearch` reports for the same metric (squared L2,
586/// `1 - dot` for inner product) so rescored and approximate distances stay
587/// comparable.
588fn exact_distance(q: &[f32], v: &[f32], metric: VectorMetric) -> f32 {
589    match metric {
590        VectorMetric::Cosine => {
591            let mut dot = 0.0;
592            let mut norm_q = 0.0;
593            let mut norm_v = 0.0;
594            for (&qi, &vi) in q.iter().zip(v.iter()) {
595                dot += qi * vi;
596                norm_q += qi * qi;
597                norm_v += vi * vi;
598            }
599            if norm_q > 0.0 && norm_v > 0.0 {
600                // Clamped at zero: rounding can push the ratio past 1.
601                (1.0 - (dot / (norm_q.sqrt() * norm_v.sqrt()))).max(0.0)
602            } else {
603                1.0
604            }
605        }
606        VectorMetric::L2 => {
607            let mut sum = 0.0;
608            for (&qi, &vi) in q.iter().zip(v.iter()) {
609                let diff = qi - vi;
610                sum += diff * diff;
611            }
612            sum
613        }
614        VectorMetric::Dot => {
615            let mut dot = 0.0;
616            for (&qi, &vi) in q.iter().zip(v.iter()) {
617                dot += qi * vi;
618            }
619            1.0 - dot
620        }
621    }
622}
623
624/// Return the cached `VectorIndexCache` for this Graph, building it from LMDB
625/// if it has not been initialised yet.
626fn get_or_init_cache(graph: &Graph) -> Result<Arc<VectorIndexCache>, VectorError> {
627    // Cold start: load all vectors from LMDB into a fresh HNSW index, built with
628    // the graph's persisted metric and quantization (default Cosine and Float32
629    // when never configured). The initializer runs without the extensions lock
630    // held, so reading from storage here cannot deadlock against it.
631    graph.get_or_init_extension_with(|| {
632        let opts = load_config(graph)?.unwrap_or_default();
633        Ok(Arc::new(VectorIndexCache(build_index(graph, opts)?)))
634    })
635}
636
637/// Build a fresh in-memory HNSW index from every embedding persisted in LMDB,
638/// using `opts` for the metric and quantization. The stored vectors are raw
639/// f32 and metric-agnostic, so this re-indexes them correctly under any metric.
640fn build_index(graph: &Graph, opts: VectorIndexOptions) -> Result<VectorIndex, VectorError> {
641    let idx = VectorIndex::new_with_options(opts);
642    for (node_id, bytes) in graph.vector_bytes()? {
643        let v = decode_vector(&bytes)?;
644        idx.upsert(node_id, &v)?;
645    }
646    Ok(idx)
647}
648
649/// Load and decode this graph's persisted vector index configuration, or
650/// `None` when the graph has never been configured.
651fn load_config(graph: &Graph) -> Result<Option<VectorIndexOptions>, VectorError> {
652    match graph.get_vector_config()? {
653        Some(bytes) => Ok(Some(decode_config(&bytes)?)),
654        None => Ok(None),
655    }
656}
657
658/// Encode the index configuration as two stable tag bytes: `[metric, quantization]`.
659fn encode_config(opts: VectorIndexOptions) -> [u8; 2] {
660    let metric = match opts.metric {
661        VectorMetric::Cosine => 0,
662        VectorMetric::L2 => 1,
663        VectorMetric::Dot => 2,
664    };
665    let quant = match opts.quantization {
666        VectorQuantization::Float32 => 0,
667        VectorQuantization::Float16 => 1,
668        VectorQuantization::Int8 => 2,
669    };
670    [metric, quant]
671}
672
673/// Decode the two-byte index configuration written by `encode_config`.
674fn decode_config(bytes: &[u8]) -> Result<VectorIndexOptions, VectorError> {
675    let [metric, quant] = bytes.try_into().map_err(|_| {
676        VectorError::IndexFault(format!(
677            "vector config must be 2 bytes, got {}",
678            bytes.len()
679        ))
680    })?;
681    let metric = match metric {
682        0 => VectorMetric::Cosine,
683        1 => VectorMetric::L2,
684        2 => VectorMetric::Dot,
685        other => {
686            return Err(VectorError::IndexFault(format!(
687                "unknown vector metric tag {other}"
688            )));
689        }
690    };
691    let quantization = match quant {
692        0 => VectorQuantization::Float32,
693        1 => VectorQuantization::Float16,
694        2 => VectorQuantization::Int8,
695        other => {
696            return Err(VectorError::IndexFault(format!(
697                "unknown vector quantization tag {other}"
698            )));
699        }
700    };
701    Ok(VectorIndexOptions {
702        metric,
703        quantization,
704    })
705}
706
707fn metric_to_usearch(m: VectorMetric) -> MetricKind {
708    match m {
709        VectorMetric::Cosine => MetricKind::Cos,
710        VectorMetric::L2 => MetricKind::L2sq,
711        VectorMetric::Dot => MetricKind::IP,
712    }
713}
714
715fn quantization_to_usearch(q: VectorQuantization) -> ScalarKind {
716    match q {
717        VectorQuantization::Float32 => ScalarKind::F32,
718        VectorQuantization::Float16 => ScalarKind::F16,
719        VectorQuantization::Int8 => ScalarKind::I8,
720    }
721}
722
723#[cfg(test)]
724mod tests {
725    use serde_json::json;
726    use tempfile::TempDir;
727
728    use super::*;
729
730    fn open_tmp() -> (TempDir, Graph) {
731        let dir = TempDir::new().unwrap();
732        let graph = Graph::open(dir.path(), 1).unwrap();
733        (dir, graph)
734    }
735
736    #[test]
737    fn metric_from_str_is_case_insensitive_with_alias() {
738        assert_eq!(
739            "cosine".parse::<VectorMetric>().unwrap(),
740            VectorMetric::Cosine
741        );
742        assert_eq!("L2".parse::<VectorMetric>().unwrap(), VectorMetric::L2);
743        assert_eq!("Dot".parse::<VectorMetric>().unwrap(), VectorMetric::Dot);
744        assert_eq!("ip".parse::<VectorMetric>().unwrap(), VectorMetric::Dot);
745        assert!("hamming".parse::<VectorMetric>().is_err());
746    }
747
748    #[test]
749    fn quantization_from_str_is_case_insensitive() {
750        assert_eq!(
751            "float32".parse::<VectorQuantization>().unwrap(),
752            VectorQuantization::Float32
753        );
754        assert_eq!(
755            "Float16".parse::<VectorQuantization>().unwrap(),
756            VectorQuantization::Float16
757        );
758        assert_eq!(
759            "INT8".parse::<VectorQuantization>().unwrap(),
760            VectorQuantization::Int8
761        );
762        assert!("b1".parse::<VectorQuantization>().is_err());
763    }
764
765    #[test]
766    fn upsert_vector_and_search_finds_nearest() {
767        let (_dir, graph) = open_tmp();
768        let a = graph.add_node("N", &json!({})).unwrap();
769        let b = graph.add_node("N", &json!({})).unwrap();
770        let c = graph.add_node("N", &json!({})).unwrap();
771
772        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
773        graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
774        graph.upsert_vector(c, &[0.0f32, 0.0, 1.0]).unwrap();
775
776        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
777        assert_eq!(hits.len(), 1);
778        assert_eq!(hits[0].node, a);
779    }
780
781    #[test]
782    fn vector_search_empty_index_returns_empty() {
783        let (_dir, graph) = open_tmp();
784        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 5).unwrap();
785        assert!(hits.is_empty());
786    }
787
788    #[test]
789    fn vector_search_k_larger_than_index_returns_all() {
790        let (_dir, graph) = open_tmp();
791        let a = graph.add_node("N", &json!({})).unwrap();
792        let b = graph.add_node("N", &json!({})).unwrap();
793        graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
794        graph.upsert_vector(b, &[0.0f32, 1.0]).unwrap();
795
796        let hits = graph.vector_search(&[1.0f32, 0.0], 100).unwrap();
797        assert_eq!(hits.len(), 2);
798    }
799
800    #[test]
801    fn upsert_vector_overwrites_existing_embedding() {
802        let (_dir, graph) = open_tmp();
803        let a = graph.add_node("N", &json!({})).unwrap();
804        let b = graph.add_node("N", &json!({})).unwrap();
805
806        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
807        graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
808        graph.upsert_vector(a, &[0.0f32, 1.0, 0.0]).unwrap();
809
810        let hits = graph.vector_search(&[0.0f32, 1.0, 0.0], 1).unwrap();
811        assert_eq!(hits.len(), 1);
812        assert!(
813            (hits[0].distance).abs() < 1e-5,
814            "distance to query should be near zero"
815        );
816    }
817
818    #[test]
819    fn vector_index_rebuilds_from_lmdb_on_reopen() {
820        let dir = TempDir::new().unwrap();
821        let a = {
822            let graph = Graph::open(dir.path(), 1).unwrap();
823            let a = graph.add_node("N", &json!({})).unwrap();
824            graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
825            a
826        };
827
828        let graph = Graph::open(dir.path(), 1).unwrap();
829        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
830        assert_eq!(hits.len(), 1);
831        assert_eq!(hits[0].node, a);
832    }
833
834    #[test]
835    fn remove_vector_deletes_from_index_and_lmdb() {
836        let (_dir, graph) = open_tmp();
837        let a = graph.add_node("N", &json!({})).unwrap();
838        let b = graph.add_node("N", &json!({})).unwrap();
839        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
840        graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
841
842        graph.remove_vector(a).unwrap();
843
844        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 2).unwrap();
845        assert!(
846            hits.iter().all(|h| h.node != a),
847            "removed node must not appear in search results"
848        );
849    }
850
851    #[test]
852    fn vector_search_with_label_filter_excludes_other_labels() {
853        let (_dir, graph) = open_tmp();
854        let a = graph.add_node("Article", &json!({})).unwrap();
855        let b = graph.add_node("Person", &json!({})).unwrap();
856        let c = graph.add_node("Article", &json!({})).unwrap();
857        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
858        graph.upsert_vector(b, &[1.0f32, 0.0, 0.0]).unwrap(); // same direction as a
859        graph.upsert_vector(c, &[0.9f32, 0.1, 0.0]).unwrap();
860
861        let opts = VectorSearchOptions {
862            k: 3,
863            label: Some("Article".into()),
864            properties: None,
865            rescore_factor: None,
866        };
867        let hits = graph
868            .vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
869            .unwrap();
870        // Only Article nodes a and c must appear; Person node b must be absent.
871        assert!(
872            hits.iter().all(|h| h.node != b),
873            "Person node must be filtered out"
874        );
875        assert!(hits.len() <= 2);
876        assert!(hits.iter().any(|h| h.node == a));
877    }
878
879    #[test]
880    fn vector_search_with_selective_property_filter_finds_distant_matches() {
881        // Regression guard: a selective property filter must not silently
882        // under-return. Many non-matching nodes sit nearest the query, and the
883        // matching nodes rank far below them. A post-filter over a fixed
884        // over-fetch would discard every candidate and return nothing; the
885        // predicate-driven traversal keeps expanding until it finds them.
886        let (_dir, graph) = open_tmp();
887        // 200 "red" decoys, all nearer the query than any "blue" node.
888        for i in 0..200u32 {
889            let n = graph.add_node("N", &json!({ "team": "red" })).unwrap();
890            let jitter = (i as f32) * 1e-4;
891            graph.upsert_vector(n, &[1.0, jitter, 0.0]).unwrap();
892        }
893        // 2 "blue" matches, farther from the query in cosine distance.
894        let blue1 = graph.add_node("N", &json!({ "team": "blue" })).unwrap();
895        let blue2 = graph.add_node("N", &json!({ "team": "blue" })).unwrap();
896        graph.upsert_vector(blue1, &[0.6, 0.8, 0.0]).unwrap();
897        graph.upsert_vector(blue2, &[0.5, 0.85, 0.0]).unwrap();
898
899        let mut filters = std::collections::HashMap::new();
900        filters.insert("team".to_string(), json!("blue"));
901        let opts = VectorSearchOptions {
902            k: 2,
903            label: None,
904            properties: Some(filters),
905            rescore_factor: None,
906        };
907        let hits = graph
908            .vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
909            .unwrap();
910
911        assert_eq!(hits.len(), 2, "both blue matches must be returned");
912        assert!(hits.iter().any(|h| h.node == blue1));
913        assert!(hits.iter().any(|h| h.node == blue2));
914    }
915
916    #[test]
917    fn rejected_upsert_does_not_persist_and_brick_reopen() {
918        // A dimension-mismatched upsert must not leave bytes in LMDB. If it did,
919        // the cold-start rebuild on the next `Graph::open` would fail to decode
920        // a consistent index and brick every subsequent search.
921        let dir = TempDir::new().unwrap();
922        let a = {
923            let graph = Graph::open(dir.path(), 1).unwrap();
924            let a = graph.add_node("N", &json!({})).unwrap();
925            let b = graph.add_node("N", &json!({})).unwrap();
926            graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
927            // Wrong dimension count: must be rejected and must not persist.
928            let bad = graph.upsert_vector(b, &[1.0f32, 0.0]);
929            assert!(matches!(bad, Err(VectorError::DimensionMismatch { .. })));
930            a
931        };
932
933        // Reopen: the rebuild must succeed and search must still work.
934        let graph = Graph::open(dir.path(), 1).unwrap();
935        let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
936        assert_eq!(hits.len(), 1);
937        assert_eq!(hits[0].node, a);
938    }
939
940    #[test]
941    fn configure_vector_index_persists_metric_across_reopen() {
942        let dir = TempDir::new().unwrap();
943        let a = {
944            let graph = Graph::open(dir.path(), 1).unwrap();
945            graph
946                .configure_vector_index(VectorIndexOptions {
947                    metric: VectorMetric::L2,
948                    quantization: VectorQuantization::Float32,
949                })
950                .unwrap();
951            let a = graph.add_node("N", &json!({})).unwrap();
952            let b = graph.add_node("N", &json!({})).unwrap();
953            graph.upsert_vector(a, &[0.0f32, 0.0]).unwrap();
954            graph.upsert_vector(b, &[5.0f32, 5.0]).unwrap();
955            a
956        };
957
958        // Reopen: the persisted L2 metric must be used by the cold-start rebuild.
959        let graph = Graph::open(dir.path(), 1).unwrap();
960        let hits = graph.vector_search(&[0.1f32, 0.1], 1).unwrap();
961        assert_eq!(hits.len(), 1);
962        assert_eq!(
963            hits[0].node, a,
964            "nearest under L2 must be the origin vector"
965        );
966    }
967
968    #[test]
969    fn configure_vector_index_idempotent_with_same_options() {
970        let (_dir, graph) = open_tmp();
971        let opts = VectorIndexOptions {
972            metric: VectorMetric::Dot,
973            quantization: VectorQuantization::Float16,
974        };
975        graph.configure_vector_index(opts).unwrap();
976        let a = graph.add_node("N", &json!({})).unwrap();
977        graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
978        // Re-applying the identical configuration after vectors exist is a no-op.
979        graph.configure_vector_index(opts).unwrap();
980    }
981
982    #[test]
983    fn configure_vector_index_rejects_change_after_vectors_exist() {
984        let (_dir, graph) = open_tmp();
985        graph
986            .configure_vector_index(VectorIndexOptions {
987                metric: VectorMetric::Cosine,
988                quantization: VectorQuantization::Float32,
989            })
990            .unwrap();
991        let a = graph.add_node("N", &json!({})).unwrap();
992        graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
993
994        let changed = graph.configure_vector_index(VectorIndexOptions {
995            metric: VectorMetric::L2,
996            quantization: VectorQuantization::Float32,
997        });
998        assert!(matches!(
999            changed,
1000            Err(VectorError::AlreadyConfigured { .. })
1001        ));
1002    }
1003
1004    #[test]
1005    fn reindex_vector_index_switches_metric_on_populated_graph() {
1006        let dir = TempDir::new().unwrap();
1007        let (a, b) = {
1008            let graph = Graph::open(dir.path(), 1).unwrap();
1009            // Default Cosine configuration.
1010            let a = graph.add_node("N", &json!({})).unwrap();
1011            let b = graph.add_node("N", &json!({})).unwrap();
1012            graph.upsert_vector(a, &[0.0f32, 0.0]).unwrap();
1013            graph.upsert_vector(b, &[5.0f32, 5.0]).unwrap();
1014
1015            // configure must refuse the change while vectors exist.
1016            let refused = graph.configure_vector_index(VectorIndexOptions {
1017                metric: VectorMetric::L2,
1018                quantization: VectorQuantization::Float32,
1019            });
1020            assert!(matches!(
1021                refused,
1022                Err(VectorError::AlreadyConfigured { .. })
1023            ));
1024
1025            // reindex accepts it and rebuilds from the stored embeddings.
1026            graph
1027                .reindex_vector_index(VectorIndexOptions {
1028                    metric: VectorMetric::L2,
1029                    quantization: VectorQuantization::Float32,
1030                })
1031                .unwrap();
1032            (a, b)
1033        };
1034
1035        // The new metric persists, and search reflects L2 geometry after reopen.
1036        let graph = Graph::open(dir.path(), 1).unwrap();
1037        let hits = graph.vector_search(&[0.1f32, 0.1], 2).unwrap();
1038        assert_eq!(hits[0].node, a, "origin is nearest under L2");
1039        assert!(hits.iter().any(|h| h.node == b));
1040    }
1041
1042    #[test]
1043    fn vector_cache_is_reused_across_searches() {
1044        let (_dir, graph) = open_tmp();
1045        let a = graph.add_node("N", &json!({})).unwrap();
1046        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1047
1048        // Both calls should return consistent results; the second uses the cached index.
1049        let h1 = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1050        let h2 = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1051        assert_eq!(h1.len(), 1);
1052        assert_eq!(h2.len(), 1);
1053        assert_eq!(h1[0].node, h2[0].node);
1054    }
1055
1056    #[test]
1057    fn test_concurrent_vector_searches() {
1058        let (_dir, graph) = open_tmp();
1059        let a = graph.add_node("N", &json!({})).unwrap();
1060        graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1061
1062        let graph = Arc::new(graph);
1063        let mut handles = vec![];
1064        for _ in 0..10 {
1065            let g = Arc::clone(&graph);
1066            let target_node = a;
1067            handles.push(std::thread::spawn(move || {
1068                let hits = g.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1069                assert_eq!(hits.len(), 1);
1070                assert_eq!(hits[0].node, target_node);
1071            }));
1072        }
1073
1074        for h in handles {
1075            h.join().unwrap();
1076        }
1077    }
1078
1079    #[test]
1080    fn vector_search_with_int8_quantization_finds_nearest() {
1081        // Int8 quantization is wired to usearch's ScalarKind::I8. Precision is
1082        // reduced, but well-separated vectors must still rank correctly.
1083        let (_dir, graph) = open_tmp();
1084        graph
1085            .configure_vector_index(VectorIndexOptions {
1086                metric: VectorMetric::Cosine,
1087                quantization: VectorQuantization::Int8,
1088            })
1089            .unwrap();
1090        let a = graph.add_node("N", &json!({})).unwrap();
1091        let b = graph.add_node("N", &json!({})).unwrap();
1092        let c = graph.add_node("N", &json!({})).unwrap();
1093        graph.upsert_vector(a, &[1.0, 0.0, 0.0]).unwrap();
1094        graph.upsert_vector(b, &[0.0, 1.0, 0.0]).unwrap();
1095        graph.upsert_vector(c, &[0.0, 0.0, 1.0]).unwrap();
1096
1097        let hits = graph.vector_search(&[1.0, 0.0, 0.0], 1).unwrap();
1098        assert_eq!(hits.len(), 1);
1099        assert_eq!(hits[0].node, a);
1100    }
1101
1102    #[test]
1103    fn vector_search_with_multiple_property_filters_requires_all() {
1104        // A property filter with several keys is an AND: only nodes matching
1105        // every key/value pair qualify. The nearest node matches one key but not
1106        // the other and must be excluded.
1107        let (_dir, graph) = open_tmp();
1108        let near = graph
1109            .add_node("N", &json!({ "team": "blue", "role": "ic" }))
1110            .unwrap();
1111        let far = graph
1112            .add_node("N", &json!({ "team": "blue", "role": "lead" }))
1113            .unwrap();
1114        graph.upsert_vector(near, &[1.0, 0.0, 0.0]).unwrap();
1115        graph.upsert_vector(far, &[0.9, 0.1, 0.0]).unwrap();
1116
1117        let mut filters = std::collections::HashMap::new();
1118        filters.insert("team".to_string(), json!("blue"));
1119        filters.insert("role".to_string(), json!("lead"));
1120        let opts = VectorSearchOptions {
1121            k: 2,
1122            label: None,
1123            properties: Some(filters),
1124            rescore_factor: None,
1125        };
1126        let hits = graph.vector_search_with(&[1.0, 0.0, 0.0], &opts).unwrap();
1127
1128        // `near` is closer but is role=ic, so only `far` satisfies both filters.
1129        assert_eq!(hits.len(), 1);
1130        assert_eq!(hits[0].node, far);
1131    }
1132
1133    #[test]
1134    fn vector_search_quantized_rescore() {
1135        let (_dir, graph) = open_tmp();
1136        graph
1137            .configure_vector_index(VectorIndexOptions {
1138                metric: VectorMetric::Cosine,
1139                quantization: VectorQuantization::Int8,
1140            })
1141            .unwrap();
1142
1143        let n1 = graph.add_node("N", &json!({})).unwrap();
1144        let n2 = graph.add_node("N", &json!({})).unwrap();
1145
1146        graph.upsert_vector(n1, &[0.9, 0.1]).unwrap();
1147        graph.upsert_vector(n2, &[0.95, 0.05]).unwrap();
1148
1149        let query = &[1.0, 0.0];
1150
1151        // Search with rescoring active
1152        let opts = VectorSearchOptions {
1153            k: 2,
1154            rescore_factor: Some(2),
1155            ..Default::default()
1156        };
1157        let hits = graph.vector_search_with(query, &opts).unwrap();
1158        assert_eq!(hits.len(), 2);
1159        assert_eq!(hits[0].node, n2);
1160        assert_eq!(hits[1].node, n1);
1161
1162        // Verify the exact distances are computed and ordered correctly
1163        assert!(hits[0].distance < hits[1].distance);
1164    }
1165}