Skip to main content

lora_store/memory/
vector_index.rs

1//! VECTOR index storage and query backend.
2//!
3//! Phase 1 of the vector-indexing extension: introduce a backend
4//! abstraction so the procedure layer no longer scans the property
5//! store directly. Today only the `Flat` brute-force backend is
6//! implemented; the [`VectorBackend`] enum exists so Phase 2 can drop
7//! in an HNSW arm without disturbing the registry shape or the
8//! maintenance hooks.
9//!
10//! Unlike the TEXT / POINT / SORTED registries — which key by
11//! `(label, property)` because the underlying structure is shared
12//! across catalog entries with the same scope — vector indexes are
13//! keyed by **index name**. Two vector indexes on the same
14//! `(label, property)` can coexist with different similarity functions
15//! (e.g. one cosine, one euclidean), each owning its own backend.
16
17use std::collections::{BTreeMap, BTreeSet};
18use std::sync::Arc;
19
20use serde::{Deserialize, Serialize};
21
22use crate::{
23    cosine_similarity_bounded, dot_product, euclidean_similarity, manhattan_distance, LoraVector,
24};
25
26use super::hnsw::{seed_from_name, HnswBackend, HnswParams, HnswSnapshot};
27use super::id_map::CowIdMap;
28use super::index_catalog::{IndexConfigValue, StoredIndexEntity};
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31pub enum VectorSimilarity {
32    Cosine,
33    Euclidean,
34    /// Raw dot product. Higher is more similar; unbounded above and
35    /// below. The right choice for embeddings already L2-normalized
36    /// (cosine reduces to dot in that case, and dot skips one
37    /// reciprocal-sqrt per pair).
38    Dot,
39    /// L1-derived: `1 / (1 + d_L1)`. Same higher-is-better shape as
40    /// `Euclidean`; useful for quantized vectors where L1 is the
41    /// natural metric.
42    Manhattan,
43}
44
45impl VectorSimilarity {
46    pub fn parse(s: &str) -> Option<Self> {
47        if s.eq_ignore_ascii_case("cosine") {
48            Some(VectorSimilarity::Cosine)
49        } else if s.eq_ignore_ascii_case("euclidean") {
50            Some(VectorSimilarity::Euclidean)
51        } else if s.eq_ignore_ascii_case("dot") || s.eq_ignore_ascii_case("dot_product") {
52            Some(VectorSimilarity::Dot)
53        } else if s.eq_ignore_ascii_case("manhattan") {
54            Some(VectorSimilarity::Manhattan)
55        } else {
56            None
57        }
58    }
59
60    pub fn score(self, a: &LoraVector, b: &LoraVector) -> Option<f64> {
61        if a.dimension != b.dimension {
62            return None;
63        }
64        match self {
65            VectorSimilarity::Cosine => cosine_similarity_bounded(a, b),
66            VectorSimilarity::Euclidean => euclidean_similarity(a, b),
67            VectorSimilarity::Dot => dot_product(a, b),
68            VectorSimilarity::Manhattan => manhattan_distance(a, b).map(|d| 1.0 / (1.0 + d)),
69        }
70    }
71
72    /// Resolve `vector.similarity_function` from a catalog `OPTIONS`
73    /// map. Returns `None` when the key is missing or unrecognised;
74    /// DDL validation has already rejected invalid values, so a
75    /// `None` here only occurs on a malformed snapshot/WAL payload —
76    /// the caller picks a default in that case.
77    pub(super) fn from_options(options: &BTreeMap<String, IndexConfigValue>) -> Option<Self> {
78        match options.get("vector.similarity_function")? {
79            IndexConfigValue::String(s) => Self::parse(s),
80            _ => None,
81        }
82    }
83}
84
85/// Brute-force backend: store every vector, score them all per query.
86/// Ascending id order gives deterministic iteration, which keeps
87/// score-tie ordering stable across runs (matches the legacy
88/// `score_entities` contract).
89///
90/// Copy-on-write like the other indexes: a staged write clones the
91/// graph, and its first change here copies one chunk of `Arc` pointers
92/// (see [`CowIdMap`]) instead of every vector.
93#[derive(Debug, Default, Clone)]
94pub(super) struct FlatBackend {
95    pub(super) items: CowIdMap<Arc<LoraVector>>,
96}
97
98impl FlatBackend {
99    fn insert(&mut self, id: u64, vector: LoraVector) {
100        self.items.insert(id, Arc::new(vector));
101    }
102
103    fn remove(&mut self, id: u64) {
104        self.items.remove(&id);
105    }
106
107    fn query(
108        &self,
109        query: &LoraVector,
110        similarity: VectorSimilarity,
111        restrict_to: Option<&BTreeSet<u64>>,
112    ) -> Vec<(u64, f64)> {
113        let mut out = Vec::with_capacity(self.items.len());
114        for (&id, v) in self.items.iter() {
115            if let Some(set) = restrict_to {
116                if !set.contains(&id) {
117                    continue;
118                }
119            }
120            if let Some(score) = similarity.score(v, query) {
121                out.push((id, score));
122            }
123        }
124        out
125    }
126
127    #[cfg(test)]
128    fn len(&self) -> usize {
129        self.items.len()
130    }
131}
132
133/// Selector for which backend powers a given index. Surfaced via the
134/// `vector.indexProvider` index option; defaults to `Flat`.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum VectorIndexProvider {
137    Flat,
138    Hnsw,
139}
140
141impl VectorIndexProvider {
142    pub fn parse(s: &str) -> Option<Self> {
143        if s.eq_ignore_ascii_case("flat") {
144            Some(VectorIndexProvider::Flat)
145        } else if s.eq_ignore_ascii_case("hnsw") {
146            Some(VectorIndexProvider::Hnsw)
147        } else {
148            None
149        }
150    }
151
152    /// Resolve `vector.indexProvider` from a catalog `OPTIONS` map.
153    /// `'flat'` and `'hnsw'` are accepted; anything else returns
154    /// `None` and the caller falls back to the safe default.
155    pub(super) fn from_options(options: &BTreeMap<String, IndexConfigValue>) -> Option<Self> {
156        match options.get("vector.indexProvider")? {
157            IndexConfigValue::String(s) => Self::parse(s),
158            _ => None,
159        }
160    }
161}
162
163/// Backend dispatch. The Hnsw arm owns its own similarity (it
164/// internalizes scoring during graph construction); the Flat arm
165/// takes similarity per-query because it has no precomputed work to
166/// pin to a single metric.
167#[derive(Debug, Clone)]
168pub(super) enum VectorBackend {
169    Flat(FlatBackend),
170    Hnsw(HnswBackend),
171}
172
173impl VectorBackend {
174    fn insert(&mut self, id: u64, vector: LoraVector) {
175        match self {
176            VectorBackend::Flat(b) => b.insert(id, vector),
177            VectorBackend::Hnsw(b) => b.insert(id, vector),
178        }
179    }
180
181    fn remove(&mut self, id: u64) {
182        match self {
183            VectorBackend::Flat(b) => b.remove(id),
184            VectorBackend::Hnsw(b) => b.remove(id),
185        }
186    }
187
188    /// `similarity` and `k` are only honored by some arms:
189    /// - Flat: scores every point with `similarity`, returns all
190    ///   matching (id, score). The caller sorts + truncates to k.
191    /// - Hnsw: ignores `similarity` (configured at construction),
192    ///   uses `k` to size the result set inside the graph walk.
193    ///
194    /// `restrict_to` is a hard filter: only ids in the set may
195    /// appear in the result. HNSW still traverses through other
196    /// nodes for routing — recall against a very selective filter
197    /// degrades; callers facing tight filters should raise
198    /// `vector.hnsw.ef_search`.
199    fn query(
200        &self,
201        query: &LoraVector,
202        similarity: VectorSimilarity,
203        k: usize,
204        restrict_to: Option<&BTreeSet<u64>>,
205    ) -> Vec<(u64, f64)> {
206        match self {
207            VectorBackend::Flat(b) => b.query(query, similarity, restrict_to),
208            VectorBackend::Hnsw(b) => b.query(query, k, restrict_to),
209        }
210    }
211
212    #[cfg(test)]
213    fn len(&self) -> usize {
214        match self {
215            VectorBackend::Flat(b) => b.len(),
216            VectorBackend::Hnsw(b) => b.len(),
217        }
218    }
219}
220
221/// One installed VECTOR index, plus the resolved metadata the
222/// maintenance hook needs to decide whether a given property change
223/// applies (without having to re-read the catalog).
224#[derive(Debug, Clone)]
225pub(super) struct VectorIndexEntry {
226    pub label: String,
227    pub property: String,
228    pub similarity: VectorSimilarity,
229    pub backend: VectorBackend,
230}
231
232/// Per-entity-kind registry of vector indexes. Keyed by index name.
233#[derive(Debug, Default, Clone)]
234pub(super) struct VectorIndexRegistry {
235    pub(super) by_name: BTreeMap<String, VectorIndexEntry>,
236}
237
238impl VectorIndexRegistry {
239    pub(super) fn register(
240        &mut self,
241        name: String,
242        label: String,
243        property: String,
244        similarity: VectorSimilarity,
245        provider: VectorIndexProvider,
246        hnsw: HnswParams,
247    ) {
248        let backend = match provider {
249            VectorIndexProvider::Flat => VectorBackend::Flat(FlatBackend::default()),
250            VectorIndexProvider::Hnsw => {
251                let seed = seed_from_name(&name);
252                VectorBackend::Hnsw(HnswBackend::new(similarity, hnsw, seed))
253            }
254        };
255        self.by_name.insert(
256            name,
257            VectorIndexEntry {
258                label,
259                property,
260                similarity,
261                backend,
262            },
263        );
264    }
265
266    pub(super) fn deregister(&mut self, name: &str) {
267        self.by_name.remove(name);
268    }
269
270    /// Whether an index covers `property` on any of `labels`. Checked
271    /// through a read guard before maintenance takes the (copy-on-write)
272    /// write guard, which copies every index of a shared graph.
273    pub(super) fn covers_any<'a>(
274        &self,
275        labels: impl IntoIterator<Item = &'a str>,
276        property: &str,
277    ) -> bool {
278        labels.into_iter().any(|label| {
279            self.by_name
280                .values()
281                .any(|entry| entry.label == label && entry.property == property)
282        })
283    }
284
285    /// Insert `vector` for `entity_id` into every index whose
286    /// `(label, property)` matches. Used by both the initial backfill
287    /// from `activate_vector_index` and per-mutation maintenance.
288    pub(super) fn insert_for(
289        &mut self,
290        label: &str,
291        property: &str,
292        entity_id: u64,
293        vector: &LoraVector,
294    ) {
295        for entry in self.by_name.values_mut() {
296            if entry.label == label && entry.property == property {
297                entry.backend.insert(entity_id, vector.clone());
298            }
299        }
300    }
301
302    /// Drop `entity_id` from every index whose `(label, property)`
303    /// matches.
304    pub(super) fn remove_for(&mut self, label: &str, property: &str, entity_id: u64) {
305        for entry in self.by_name.values_mut() {
306            if entry.label == label && entry.property == property {
307                entry.backend.remove(entity_id);
308            }
309        }
310    }
311
312    /// Run a top-k scan against a named index, optionally
313    /// restricting results to the given id set. Returns `(id,
314    /// score)` pairs from the backend; the caller applies the
315    /// canonical `sort_by_desc(score) then asc(id)` + truncate
316    /// post-step that matches the legacy `scored_rows` contract.
317    /// The flat arm returns all matching entities; the HNSW arm
318    /// caps at k internally.
319    pub(super) fn query(
320        &self,
321        name: &str,
322        query: &LoraVector,
323        k: usize,
324        restrict_to: Option<&BTreeSet<u64>>,
325    ) -> Option<Vec<(u64, f64)>> {
326        let entry = self.by_name.get(name)?;
327        Some(entry.backend.query(query, entry.similarity, k, restrict_to))
328    }
329
330    /// Capture a serializable snapshot of every HNSW backend in this
331    /// registry. Flat backends are skipped because their state is
332    /// reconstructible from the property store at zero cost — only
333    /// HNSW pays the O(n log n) rebuild penalty that justifies
334    /// shipping graph topology through the snapshot pipeline.
335    pub(super) fn to_snapshots(&self, entity: StoredIndexEntity) -> Vec<VectorIndexSnapshot> {
336        let mut out = Vec::new();
337        for (name, entry) in &self.by_name {
338            if let VectorBackend::Hnsw(b) = &entry.backend {
339                out.push(VectorIndexSnapshot {
340                    name: name.clone(),
341                    entity,
342                    label: entry.label.clone(),
343                    property: entry.property.clone(),
344                    data: VectorBackendSnapshot::Hnsw(b.to_snapshot(entry.similarity)),
345                });
346            }
347        }
348        out
349    }
350
351    /// Replace the backend for `snapshot.name` with one rebuilt from
352    /// the snapshot data. No-op if the index isn't registered, the
353    /// snapshot kind doesn't match, or the scope (label/property)
354    /// diverges — all signals that the catalog and the snapshot are
355    /// out of step, in which case we fall back to the property-store
356    /// backfill.
357    pub(super) fn restore_snapshot(&mut self, snapshot: VectorIndexSnapshot) -> bool {
358        let Some(entry) = self.by_name.get_mut(&snapshot.name) else {
359            return false;
360        };
361        if entry.label != snapshot.label || entry.property != snapshot.property {
362            return false;
363        }
364        match snapshot.data {
365            VectorBackendSnapshot::Hnsw(snap) => {
366                if !matches!(entry.backend, VectorBackend::Hnsw(_)) {
367                    return false;
368                }
369                entry.similarity = snap.similarity;
370                entry.backend = VectorBackend::Hnsw(HnswBackend::from_snapshot(snap));
371                true
372            }
373        }
374    }
375}
376
377/// Snapshot of one vector index, carried through the snapshot
378/// pipeline. Only HNSW backends are persisted today (see
379/// [`VectorIndexRegistry::to_snapshots`]); the enum is open for a
380/// future Flat arm if pre-built flat backends become expensive to
381/// rebuild for some workload.
382#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
383pub struct VectorIndexSnapshot {
384    pub name: String,
385    pub entity: StoredIndexEntity,
386    pub label: String,
387    pub property: String,
388    pub data: VectorBackendSnapshot,
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
392pub enum VectorBackendSnapshot {
393    Hnsw(HnswSnapshot),
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399    use crate::{RawCoordinate, VectorCoordinateType};
400
401    fn vec(values: &[f32]) -> LoraVector {
402        let coords: Vec<RawCoordinate> = values
403            .iter()
404            .map(|v| RawCoordinate::Float(*v as f64))
405            .collect();
406        LoraVector::try_new(coords, values.len() as i64, VectorCoordinateType::Float32).unwrap()
407    }
408
409    fn register_flat(
410        reg: &mut VectorIndexRegistry,
411        name: &str,
412        label: &str,
413        prop: &str,
414        sim: VectorSimilarity,
415    ) {
416        reg.register(
417            name.into(),
418            label.into(),
419            prop.into(),
420            sim,
421            VectorIndexProvider::Flat,
422            HnswParams::default(),
423        );
424    }
425
426    #[test]
427    fn register_and_query_returns_scores() {
428        let mut reg = VectorIndexRegistry::default();
429        register_flat(&mut reg, "vidx", "V", "e", VectorSimilarity::Cosine);
430        reg.insert_for("V", "e", 1, &vec(&[1.0, 0.0, 0.0]));
431        reg.insert_for("V", "e", 2, &vec(&[0.0, 1.0, 0.0]));
432        let scored = reg.query("vidx", &vec(&[1.0, 0.0, 0.0]), 10, None).unwrap();
433        // Two entries; entity 1 (identical to query) scores 1.0.
434        assert_eq!(scored.len(), 2);
435        let by_id: BTreeMap<u64, f64> = scored.into_iter().collect();
436        assert!((by_id[&1] - 1.0).abs() < 1e-9);
437        assert!(by_id[&2] < by_id[&1]);
438    }
439
440    #[test]
441    fn remove_drops_from_backend() {
442        let mut reg = VectorIndexRegistry::default();
443        register_flat(&mut reg, "vidx", "V", "e", VectorSimilarity::Cosine);
444        reg.insert_for("V", "e", 1, &vec(&[1.0, 0.0]));
445        reg.insert_for("V", "e", 2, &vec(&[0.0, 1.0]));
446        reg.remove_for("V", "e", 1);
447        let scored = reg.query("vidx", &vec(&[1.0, 0.0]), 10, None).unwrap();
448        assert_eq!(scored.len(), 1);
449        assert_eq!(scored[0].0, 2);
450    }
451
452    #[test]
453    fn unrelated_scope_is_skipped() {
454        let mut reg = VectorIndexRegistry::default();
455        register_flat(
456            &mut reg,
457            "movie_emb",
458            "Movie",
459            "embedding",
460            VectorSimilarity::Cosine,
461        );
462        // Wrong label — must not be picked up.
463        reg.insert_for("Other", "embedding", 99, &vec(&[1.0, 0.0]));
464        let scored = reg.query("movie_emb", &vec(&[1.0, 0.0]), 10, None).unwrap();
465        assert!(scored.is_empty());
466    }
467
468    #[test]
469    fn two_indexes_on_same_scope_with_different_metrics() {
470        let mut reg = VectorIndexRegistry::default();
471        register_flat(&mut reg, "by_cos", "V", "e", VectorSimilarity::Cosine);
472        register_flat(&mut reg, "by_euc", "V", "e", VectorSimilarity::Euclidean);
473        reg.insert_for("V", "e", 1, &vec(&[1.0, 0.0]));
474        reg.insert_for("V", "e", 2, &vec(&[0.0, 1.0]));
475        let cos = reg.query("by_cos", &vec(&[1.0, 0.0]), 10, None).unwrap();
476        let euc = reg.query("by_euc", &vec(&[1.0, 0.0]), 10, None).unwrap();
477        assert_eq!(cos.len(), 2);
478        assert_eq!(euc.len(), 2);
479        // Distinct metrics → distinct second backends populated.
480        for entry in reg.by_name.values() {
481            assert_eq!(entry.backend.len(), 2);
482        }
483    }
484
485    #[test]
486    fn a_registry_clone_shares_vectors_and_diverges_on_write() {
487        for provider in [VectorIndexProvider::Flat, VectorIndexProvider::Hnsw] {
488            let mut reg = VectorIndexRegistry::default();
489            reg.register(
490                "v".into(),
491                "V".into(),
492                "e".into(),
493                VectorSimilarity::Cosine,
494                provider,
495                HnswParams::default(),
496            );
497            for i in 0..300u64 {
498                let x = i as f32 / 300.0;
499                reg.insert_for("V", "e", i, &vec(&[x, 1.0 - x, 0.5]));
500            }
501            let q = vec(&[1.0, 0.0, 0.5]);
502            // Flat returns every match unsorted; rank as the caller does.
503            let top = |r: &VectorIndexRegistry| {
504                let mut hits = r.query("v", &q, 10, None).unwrap();
505                hits.sort_by(|a, b| b.1.total_cmp(&a.1).then(a.0.cmp(&b.0)));
506                hits.truncate(10);
507                hits
508            };
509            let before = top(&reg);
510
511            let mut staged = reg.clone();
512            staged.insert_for("V", "e", 1_000, &vec(&[1.0, 0.0, 0.5]));
513            staged.remove_for("V", "e", 299);
514            let after = top(&staged);
515            assert_eq!(after[0].0, 1_000, "{provider:?}");
516            assert!(after.iter().all(|(id, _)| *id != 299), "{provider:?}");
517            assert_eq!(top(&reg), before, "{provider:?}");
518            assert_eq!(before[0].0, 299, "{provider:?}");
519
520            // Untouched vectors are shared between the two, not copied.
521            let shared = |r: &VectorIndexRegistry| match &r.by_name["v"].backend {
522                VectorBackend::Flat(b) => Arc::clone(b.items.get(&3).unwrap()),
523                VectorBackend::Hnsw(b) => Arc::clone(&b.nodes.get(&3).unwrap().vector),
524            };
525            assert!(Arc::ptr_eq(&shared(&reg), &shared(&staged)), "{provider:?}");
526        }
527    }
528
529    #[test]
530    fn hnsw_provider_returns_top_k() {
531        let mut reg = VectorIndexRegistry::default();
532        reg.register(
533            "vh".into(),
534            "V".into(),
535            "e".into(),
536            VectorSimilarity::Cosine,
537            VectorIndexProvider::Hnsw,
538            HnswParams::default(),
539        );
540        for i in 0..50u64 {
541            let v = vec(&[(i as f32) / 50.0, 1.0 - (i as f32) / 50.0]);
542            reg.insert_for("V", "e", i, &v);
543        }
544        let hits = reg.query("vh", &vec(&[1.0, 0.0]), 5, None).unwrap();
545        assert_eq!(hits.len(), 5);
546        // Closest two should be the high-i (≈[1, 0]) end of the line.
547        let ids: Vec<u64> = hits.iter().map(|(id, _)| *id).collect();
548        assert!(ids.contains(&49) || ids.contains(&48), "got {ids:?}");
549    }
550}