1use std::cell::RefCell;
2use std::sync::Arc;
3
4use crate::backend::{VectorBackend, new_backend};
5use parking_lot::RwLock;
6use tracing::instrument;
7
8use crate::error::VectorError;
9use issundb_core::{Graph, NodeId};
10
11#[derive(Debug)]
13pub struct Hit {
14 pub node: NodeId,
15 pub distance: f32,
16}
17
18#[derive(Debug, Clone)]
20pub struct VectorSearchOptions {
21 pub k: usize,
23 pub label: Option<String>,
25 pub properties: Option<std::collections::HashMap<String, serde_json::Value>>,
27 pub rescore_factor: Option<usize>,
37}
38
39impl Default for VectorSearchOptions {
40 fn default() -> Self {
41 Self {
42 k: 10,
43 label: None,
44 properties: None,
45 rescore_factor: None,
46 }
47 }
48}
49
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum VectorMetric {
53 #[default]
55 Cosine,
56 L2,
58 Dot,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
64pub enum VectorQuantization {
65 #[default]
67 Float32,
68 Float16,
70 Int8,
72}
73
74impl std::str::FromStr for VectorMetric {
75 type Err = VectorError;
76
77 fn from_str(s: &str) -> Result<Self, Self::Err> {
81 match s.to_lowercase().as_str() {
82 "cosine" => Ok(Self::Cosine),
83 "l2" => Ok(Self::L2),
84 "dot" | "ip" => Ok(Self::Dot),
85 other => Err(VectorError::InvalidConfig(format!(
86 "unknown metric '{other}' (expected 'cosine', 'l2', or 'dot')"
87 ))),
88 }
89 }
90}
91
92impl std::str::FromStr for VectorQuantization {
93 type Err = VectorError;
94
95 fn from_str(s: &str) -> Result<Self, Self::Err> {
98 match s.to_lowercase().as_str() {
99 "float32" => Ok(Self::Float32),
100 "float16" => Ok(Self::Float16),
101 "int8" => Ok(Self::Int8),
102 other => Err(VectorError::InvalidConfig(format!(
103 "unknown quantization '{other}' (expected 'float32', 'float16', or 'int8')"
104 ))),
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
111pub struct VectorIndexOptions {
112 pub metric: VectorMetric,
113 pub quantization: VectorQuantization,
114}
115
116enum Inner {
117 Empty,
118 Ready {
119 index: Box<dyn VectorBackend>,
120 dims: usize,
121 },
122}
123
124pub(crate) struct VectorIndex {
132 opts: VectorIndexOptions,
133 inner: RwLock<Inner>,
134}
135
136impl Default for VectorIndex {
137 fn default() -> Self {
138 Self::new()
139 }
140}
141
142impl VectorIndex {
143 pub fn new() -> Self {
145 Self::new_with_options(VectorIndexOptions::default())
146 }
147
148 pub fn new_with_options(opts: VectorIndexOptions) -> Self {
150 Self {
151 opts,
152 inner: RwLock::new(Inner::Empty),
153 }
154 }
155
156 pub fn upsert(&self, node: NodeId, v: &[f32]) -> Result<(), VectorError> {
162 let dims = v.len();
163 if dims == 0 {
164 return Err(VectorError::IndexFault(
165 "embedding must not be empty".into(),
166 ));
167 }
168 let mut guard = self.inner.write();
169 match &mut *guard {
170 Inner::Empty => {
171 let mut index = new_backend(dims, &self.opts)?;
172 index.upsert(node, v)?;
173 *guard = Inner::Ready { index, dims };
174 }
175 Inner::Ready { index, dims: d } => {
176 if dims != *d {
177 return Err(VectorError::DimensionMismatch {
178 expected: *d,
179 got: dims,
180 });
181 }
182 index.upsert(node, v)?;
183 }
184 }
185 Ok(())
186 }
187
188 pub fn is_empty(&self) -> bool {
190 match &*self.inner.read() {
191 Inner::Empty => true,
192 Inner::Ready { index, .. } => index.len() == 0,
193 }
194 }
195
196 pub fn remove(&self, node: NodeId) -> Result<(), VectorError> {
198 let mut guard = self.inner.write();
199 if let Inner::Ready { index, .. } = &mut *guard {
200 index.remove(node)?;
201 }
202 Ok(())
203 }
204
205 pub fn search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError> {
211 let guard = self.inner.read();
212 match &*guard {
213 Inner::Empty => Ok(vec![]),
214 Inner::Ready { index, .. } => index.search(q, k),
215 }
216 }
217
218 pub fn search_filtered<F>(
225 &self,
226 q: &[f32],
227 k: usize,
228 predicate: F,
229 ) -> Result<Vec<Hit>, VectorError>
230 where
231 F: Fn(NodeId) -> bool,
232 {
233 let guard = self.inner.read();
234 match &*guard {
235 Inner::Empty => Ok(vec![]),
236 Inner::Ready { index, .. } => index.search_filtered(q, k, &predicate),
237 }
238 }
239}
240
241fn encode_vector(v: &[f32]) -> Result<Vec<u8>, VectorError> {
242 if v.is_empty() {
243 return Err(VectorError::IndexFault(
244 "embedding must not be empty".into(),
245 ));
246 }
247 if let Some(position) = v.iter().position(|f| !f.is_finite()) {
251 return Err(VectorError::IndexFault(format!(
252 "embedding component {position} is not finite ({})",
253 v[position]
254 )));
255 }
256 Ok(v.iter().flat_map(|f| f.to_le_bytes()).collect())
257}
258
259fn decode_vector(bytes: &[u8]) -> Result<Vec<f32>, VectorError> {
260 if bytes.len() % 4 != 0 {
261 return Err(VectorError::IndexFault(format!(
262 "stored embedding byte length must be divisible by 4, got {}",
263 bytes.len()
264 )));
265 }
266 let vector = bytes
267 .chunks_exact(4)
268 .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
269 .collect();
270 Ok(vector)
271}
272
273pub trait VectorGraphExt {
275 fn configure_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
292
293 fn reindex_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
304
305 fn upsert_vector(&self, n: NodeId, v: &[f32]) -> Result<(), VectorError>;
307
308 fn remove_vector(&self, n: NodeId) -> Result<(), VectorError>;
310
311 fn vector_search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError>;
318
319 fn vector_search_with(
332 &self,
333 q: &[f32],
334 opts: &VectorSearchOptions,
335 ) -> Result<Vec<Hit>, VectorError>;
336
337 fn node_vector(&self, n: NodeId) -> Result<Option<Vec<f32>>, VectorError>;
341
342 fn vector_distance(&self, a: &[f32], b: &[f32]) -> Result<f32, VectorError>;
347}
348
349struct VectorIndexCache(VectorIndex);
351
352struct VectorMutationLock {
368 lock: parking_lot::Mutex<()>,
369 #[cfg(test)]
374 upsert_pause: parking_lot::Mutex<Option<Box<dyn Fn() + Send>>>,
375}
376
377impl VectorMutationLock {
378 fn new() -> Self {
379 Self {
380 lock: parking_lot::Mutex::new(()),
381 #[cfg(test)]
382 upsert_pause: parking_lot::Mutex::new(None),
383 }
384 }
385
386 #[cfg(test)]
387 fn pause_after_index_update(&self) {
388 let hook = self.upsert_pause.lock().take();
392 if let Some(hook) = hook {
393 hook();
394 }
395 }
396}
397
398fn mutation_lock(graph: &Graph) -> Arc<VectorMutationLock> {
400 let lock: Result<_, std::convert::Infallible> =
401 graph.get_or_init_extension_with(|| Ok(Arc::new(VectorMutationLock::new())));
402 match lock {
403 Ok(lock) => lock,
404 Err(never) => match never {},
405 }
406}
407
408impl VectorGraphExt for Graph {
409 fn configure_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError> {
410 let lock = mutation_lock(self);
414 let _guard = lock.lock.lock();
415 let effective = load_config(self)?.unwrap_or_default();
420 if effective == opts {
421 return Ok(());
422 }
423 if !self.vector_bytes()?.is_empty() {
427 return Err(VectorError::AlreadyConfigured {
428 existing: format!("{effective:?}"),
429 requested: format!("{opts:?}"),
430 });
431 }
432 self.put_vector_config(&encode_config(opts))?;
433 self.set_extension(Arc::new(VectorIndexCache(VectorIndex::new_with_options(
436 opts,
437 ))));
438 Ok(())
439 }
440
441 fn reindex_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError> {
442 let lock = mutation_lock(self);
446 let _guard = lock.lock.lock();
447 let rebuilt = build_index(self, opts)?;
455 self.put_vector_config(&encode_config(opts))?;
456 self.set_extension(Arc::new(VectorIndexCache(rebuilt)));
457 Ok(())
458 }
459
460 #[instrument(skip(self, v), fields(node = %n, dims = v.len()))]
461 fn upsert_vector(&self, n: NodeId, v: &[f32]) -> Result<(), VectorError> {
462 let lock = mutation_lock(self);
474 let _guard = lock.lock.lock();
475 if !self.node_exists(n)? {
476 return Err(VectorError::NodeNotFound(n));
477 }
478 let bytes = encode_vector(v)?;
479 let arc = get_or_init_cache(self)?;
488 arc.0.upsert(n, v)?;
489 #[cfg(test)]
490 lock.pause_after_index_update();
491 self.put_vector_bytes(n, &bytes)?;
492 Ok(())
493 }
494
495 fn remove_vector(&self, n: NodeId) -> Result<(), VectorError> {
496 let lock = mutation_lock(self);
504 let _guard = lock.lock.lock();
505 let arc = get_or_init_cache(self)?;
506 arc.0.remove(n)?;
507 self.delete_vector_bytes(n)?;
508 Ok(())
509 }
510
511 #[instrument(skip(self, q), fields(k = %k, dims = q.len()))]
512 fn vector_search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError> {
513 let opts = VectorSearchOptions {
514 k,
515 ..Default::default()
516 };
517 self.vector_search_with(q, &opts)
518 }
519
520 #[instrument(skip(self, q), fields(k = %opts.k, label = ?opts.label, dims = q.len()))]
521 fn vector_search_with(
522 &self,
523 q: &[f32],
524 opts: &VectorSearchOptions,
525 ) -> Result<Vec<Hit>, VectorError> {
526 let arc = get_or_init_cache(self)?;
527
528 if arc.0.is_empty() {
532 return Err(VectorError::EmptyIndex);
533 }
534
535 let index_quantization = arc.0.opts.quantization;
536 let backend_quantizes =
542 cfg!(feature = "hnsw") && index_quantization != VectorQuantization::Float32;
543 let rescore_factor = opts
544 .rescore_factor
545 .unwrap_or(if backend_quantizes { 2 } else { 1 });
546
547 let fetch_k = if rescore_factor > 1 {
548 opts.k.saturating_mul(rescore_factor)
549 } else {
550 opts.k
551 };
552
553 let has_filters =
560 opts.label.is_some() || opts.properties.as_ref().is_some_and(|m| !m.is_empty());
561 let hits = if has_filters {
562 let pred_err: RefCell<Option<VectorError>> = RefCell::new(None);
573 let matches_filters = |node: NodeId| -> Result<bool, VectorError> {
574 if let Some(label) = &opts.label {
575 if self.label_filter(&[node], label)?.is_empty() {
576 return Ok(false);
577 }
578 }
579 if let Some(filters) = &opts.properties {
580 for (key, want) in filters {
581 match self.node_prop_json(node, key)? {
582 Some(got) if &got == want => {}
583 _ => return Ok(false),
584 }
585 }
586 }
587 Ok(true)
588 };
589 let predicate = |node: NodeId| -> bool {
590 if pred_err.borrow().is_some() {
591 return false;
592 }
593 match matches_filters(node) {
594 Ok(keep) => keep,
595 Err(e) => {
596 *pred_err.borrow_mut() = Some(e);
597 false
598 }
599 }
600 };
601
602 let results = arc.0.search_filtered(q, fetch_k, predicate)?;
603 if let Some(e) = pred_err.into_inner() {
604 return Err(e);
605 }
606 results
611 } else {
612 let mut want = fetch_k.max(1);
619 loop {
620 let raw = arc.0.search(q, want)?;
621 let raw_len = raw.len();
622 let live: Vec<Hit> = self.view(|txn| {
623 let mut live = Vec::with_capacity(raw_len);
624 for hit in raw {
625 if txn.get_node(hit.node)?.is_some() {
626 live.push(hit);
627 }
628 }
629 Ok(live)
630 })?;
631 if live.len() >= fetch_k || raw_len < want {
632 break live;
633 }
634 want = want.saturating_mul(2);
635 }
636 };
637
638 let mut final_hits = if rescore_factor > 1 && !hits.is_empty() {
639 let byte_rows: Vec<(Hit, Option<Vec<u8>>)> = self.view(|txn| {
643 hits.into_iter()
644 .map(|hit| {
645 let bytes = txn.get_vector_bytes(hit.node)?;
646 Ok((hit, bytes))
647 })
648 .collect()
649 })?;
650 let mut rescored = Vec::with_capacity(byte_rows.len());
651 for (hit, bytes) in byte_rows {
652 rescored.push(match bytes {
653 Some(b) => Hit {
654 node: hit.node,
655 distance: exact_distance(q, &decode_vector(&b)?, arc.0.opts.metric),
656 },
657 None => hit,
658 });
659 }
660 rescored.sort_unstable_by(|a, b| {
667 a.distance.total_cmp(&b.distance).then(a.node.cmp(&b.node))
668 });
669 rescored
670 } else {
671 hits
672 };
673
674 final_hits.truncate(opts.k);
675 Ok(final_hits)
676 }
677
678 fn node_vector(&self, n: NodeId) -> Result<Option<Vec<f32>>, VectorError> {
679 let bytes = self.view(|txn| txn.get_vector_bytes(n))?;
680 match bytes {
681 Some(b) => Ok(Some(decode_vector(&b)?)),
682 None => Ok(None),
683 }
684 }
685
686 fn vector_distance(&self, a: &[f32], b: &[f32]) -> Result<f32, VectorError> {
687 if a.len() != b.len() {
688 return Err(VectorError::DimensionMismatch {
689 expected: a.len(),
690 got: b.len(),
691 });
692 }
693 let metric = load_config(self)?.unwrap_or_default().metric;
694 Ok(exact_distance(a, b, metric))
695 }
696}
697
698pub(crate) fn exact_distance(q: &[f32], v: &[f32], metric: VectorMetric) -> f32 {
703 match metric {
704 VectorMetric::Cosine => {
705 let mut dot = 0.0;
706 let mut norm_q = 0.0;
707 let mut norm_v = 0.0;
708 for (&qi, &vi) in q.iter().zip(v.iter()) {
709 dot += qi * vi;
710 norm_q += qi * qi;
711 norm_v += vi * vi;
712 }
713 if norm_q > 0.0 && norm_v > 0.0 {
714 (1.0 - (dot / (norm_q.sqrt() * norm_v.sqrt()))).max(0.0)
716 } else {
717 1.0
718 }
719 }
720 VectorMetric::L2 => {
721 let mut sum = 0.0;
722 for (&qi, &vi) in q.iter().zip(v.iter()) {
723 let diff = qi - vi;
724 sum += diff * diff;
725 }
726 sum
727 }
728 VectorMetric::Dot => {
729 let mut dot = 0.0;
730 for (&qi, &vi) in q.iter().zip(v.iter()) {
731 dot += qi * vi;
732 }
733 1.0 - dot
734 }
735 }
736}
737
738fn get_or_init_cache(graph: &Graph) -> Result<Arc<VectorIndexCache>, VectorError> {
741 graph.get_or_init_extension_with(|| {
746 let opts = load_config(graph)?.unwrap_or_default();
747 Ok(Arc::new(VectorIndexCache(build_index(graph, opts)?)))
748 })
749}
750
751fn build_index(graph: &Graph, opts: VectorIndexOptions) -> Result<VectorIndex, VectorError> {
755 let idx = VectorIndex::new_with_options(opts);
756 for (node_id, bytes) in graph.vector_bytes()? {
757 let v = decode_vector(&bytes)?;
758 idx.upsert(node_id, &v)?;
759 }
760 Ok(idx)
761}
762
763fn load_config(graph: &Graph) -> Result<Option<VectorIndexOptions>, VectorError> {
766 match graph.get_vector_config()? {
767 Some(bytes) => Ok(Some(decode_config(&bytes)?)),
768 None => Ok(None),
769 }
770}
771
772fn encode_config(opts: VectorIndexOptions) -> [u8; 2] {
774 let metric = match opts.metric {
775 VectorMetric::Cosine => 0,
776 VectorMetric::L2 => 1,
777 VectorMetric::Dot => 2,
778 };
779 let quant = match opts.quantization {
780 VectorQuantization::Float32 => 0,
781 VectorQuantization::Float16 => 1,
782 VectorQuantization::Int8 => 2,
783 };
784 [metric, quant]
785}
786
787fn decode_config(bytes: &[u8]) -> Result<VectorIndexOptions, VectorError> {
789 let [metric, quant] = bytes.try_into().map_err(|_| {
790 VectorError::IndexFault(format!(
791 "vector config must be 2 bytes, got {}",
792 bytes.len()
793 ))
794 })?;
795 let metric = match metric {
796 0 => VectorMetric::Cosine,
797 1 => VectorMetric::L2,
798 2 => VectorMetric::Dot,
799 other => {
800 return Err(VectorError::IndexFault(format!(
801 "unknown vector metric tag {other}"
802 )));
803 }
804 };
805 let quantization = match quant {
806 0 => VectorQuantization::Float32,
807 1 => VectorQuantization::Float16,
808 2 => VectorQuantization::Int8,
809 other => {
810 return Err(VectorError::IndexFault(format!(
811 "unknown vector quantization tag {other}"
812 )));
813 }
814 };
815 Ok(VectorIndexOptions {
816 metric,
817 quantization,
818 })
819}
820
821#[cfg(test)]
822mod tests {
823 use serde_json::json;
824 use tempfile::TempDir;
825
826 use super::*;
827
828 fn open_tmp() -> (TempDir, Graph) {
829 let dir = TempDir::new().unwrap();
830 let graph = Graph::open(dir.path(), 1).unwrap();
831 (dir, graph)
832 }
833
834 #[test]
840 fn a_vector_for_a_node_that_does_not_exist_is_refused() {
841 let (_dir, graph) = open_tmp();
842 let alice = graph
843 .add_node("Person", &json!({ "name": "Alice" }))
844 .unwrap();
845 graph.upsert_vector(alice, &[1.0, 0.0]).unwrap();
846
847 let future = alice + 1;
849 let err = graph.upsert_vector(future, &[0.0, 1.0]).unwrap_err();
850 assert!(
851 matches!(err, VectorError::NodeNotFound(id) if id == future),
852 "expected NodeNotFound, got {err:?}"
853 );
854
855 let bob = graph.add_node("Person", &json!({ "name": "Bob" })).unwrap();
857 assert_eq!(bob, future);
858 let hits = graph.vector_search(&[0.0, 1.0], 5).unwrap();
859 assert!(
860 hits.iter().all(|h| h.node != bob),
861 "a node that was never embedded must not appear in a vector search: {hits:?}"
862 );
863 }
864
865 #[test]
869 fn a_refused_vector_reaches_neither_the_index_nor_storage() {
870 let (_dir, graph) = open_tmp();
871 let real = graph.add_node("N", &json!({})).unwrap();
872 graph.upsert_vector(real, &[1.0, 0.0]).unwrap();
873
874 assert!(graph.upsert_vector(real + 99, &[0.0, 1.0]).is_err());
875 let stored = graph.vector_bytes().unwrap();
876 assert!(
877 stored.iter().all(|(id, _)| *id != real + 99),
878 "the refused vector must not be in storage: {:?}",
879 stored.iter().map(|(id, _)| *id).collect::<Vec<_>>()
880 );
881 let hits = graph.vector_search(&[0.0, 1.0], 5).unwrap();
882 assert_eq!(hits.len(), 1, "only the one real embedding: {hits:?}");
883 assert_eq!(hits[0].node, real);
884 }
885
886 #[test]
889 fn removing_a_vector_for_a_missing_node_is_not_an_error() {
890 let (_dir, graph) = open_tmp();
891 let node = graph.add_node("N", &json!({})).unwrap();
892 graph.upsert_vector(node, &[1.0, 0.0]).unwrap();
893 graph.delete_node(node).unwrap();
894 graph.remove_vector(node).unwrap();
895 }
896
897 #[test]
898 fn metric_from_str_is_case_insensitive_with_alias() {
899 assert_eq!(
900 "cosine".parse::<VectorMetric>().unwrap(),
901 VectorMetric::Cosine
902 );
903 assert_eq!("L2".parse::<VectorMetric>().unwrap(), VectorMetric::L2);
904 assert_eq!("Dot".parse::<VectorMetric>().unwrap(), VectorMetric::Dot);
905 assert_eq!("ip".parse::<VectorMetric>().unwrap(), VectorMetric::Dot);
906 assert!("hamming".parse::<VectorMetric>().is_err());
907 }
908
909 #[test]
910 fn quantization_from_str_is_case_insensitive() {
911 assert_eq!(
912 "float32".parse::<VectorQuantization>().unwrap(),
913 VectorQuantization::Float32
914 );
915 assert_eq!(
916 "Float16".parse::<VectorQuantization>().unwrap(),
917 VectorQuantization::Float16
918 );
919 assert_eq!(
920 "INT8".parse::<VectorQuantization>().unwrap(),
921 VectorQuantization::Int8
922 );
923 assert!("b1".parse::<VectorQuantization>().is_err());
924 }
925
926 #[test]
927 fn upsert_vector_and_search_finds_nearest() {
928 let (_dir, graph) = open_tmp();
929 let a = graph.add_node("N", &json!({})).unwrap();
930 let b = graph.add_node("N", &json!({})).unwrap();
931 let c = graph.add_node("N", &json!({})).unwrap();
932
933 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
934 graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
935 graph.upsert_vector(c, &[0.0f32, 0.0, 1.0]).unwrap();
936
937 let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
938 assert_eq!(hits.len(), 1);
939 assert_eq!(hits[0].node, a);
940 }
941
942 #[test]
943 fn vector_search_empty_index_is_an_error() {
944 let (_dir, graph) = open_tmp();
945 let err = graph.vector_search(&[1.0f32, 0.0, 0.0], 5).unwrap_err();
946 assert!(matches!(err, VectorError::EmptyIndex), "got {err:?}");
947 }
948
949 #[test]
950 fn vector_search_after_removing_all_vectors_is_an_error() {
951 let (_dir, graph) = open_tmp();
952 let a = graph.add_node("N", &json!({})).unwrap();
953 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
954 graph.remove_vector(a).unwrap();
955 let err = graph.vector_search(&[1.0f32, 0.0, 0.0], 5).unwrap_err();
956 assert!(matches!(err, VectorError::EmptyIndex), "got {err:?}");
957 }
958
959 #[test]
962 fn vector_search_excludes_deleted_nodes() {
963 let (_dir, graph) = open_tmp();
964 let a = graph.add_node("N", &json!({})).unwrap();
965 let b = graph.add_node("N", &json!({})).unwrap();
966 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
967 graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
968
969 graph.delete_node(a).unwrap();
972
973 let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 5).unwrap();
974 assert!(
975 hits.iter().all(|h| h.node != a),
976 "deleted node must not appear in vector_search results"
977 );
978 assert!(
979 hits.iter().any(|h| h.node == b),
980 "the surviving node is still searchable"
981 );
982 }
983
984 #[test]
990 fn vector_search_with_empty_filters_excludes_deleted_nodes() {
991 let (_dir, graph) = open_tmp();
992 let a = graph.add_node("N", &json!({})).unwrap();
993 let b = graph.add_node("N", &json!({})).unwrap();
994 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
995 graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
996 graph.delete_node(a).unwrap();
997
998 let opts = VectorSearchOptions {
999 k: 5,
1000 properties: Some(std::collections::HashMap::new()),
1001 ..Default::default()
1002 };
1003 let hits = graph
1004 .vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
1005 .unwrap();
1006 assert!(
1007 hits.iter().all(|h| h.node != a),
1008 "deleted node must not appear under an empty filter set"
1009 );
1010 assert!(
1011 hits.iter().any(|h| h.node == b),
1012 "the surviving node is still searchable"
1013 );
1014 }
1015
1016 #[test]
1020 fn vector_search_returns_k_live_hits_despite_deleted_top_ranked() {
1021 let (_dir, graph) = open_tmp();
1022 let close = [
1023 graph.add_node("N", &json!({})).unwrap(),
1024 graph.add_node("N", &json!({})).unwrap(),
1025 graph.add_node("N", &json!({})).unwrap(),
1026 ];
1027 graph.upsert_vector(close[0], &[1.0f32, 0.0]).unwrap();
1028 graph.upsert_vector(close[1], &[1.0f32, 0.1]).unwrap();
1029 graph.upsert_vector(close[2], &[1.0f32, 0.2]).unwrap();
1030 let far = [
1031 graph.add_node("N", &json!({})).unwrap(),
1032 graph.add_node("N", &json!({})).unwrap(),
1033 graph.add_node("N", &json!({})).unwrap(),
1034 ];
1035 graph.upsert_vector(far[0], &[1.0f32, 1.0]).unwrap();
1036 graph.upsert_vector(far[1], &[0.5f32, 1.0]).unwrap();
1037 graph.upsert_vector(far[2], &[0.0f32, 1.0]).unwrap();
1038
1039 for n in close {
1042 graph.delete_node(n).unwrap();
1043 }
1044
1045 let hits = graph.vector_search(&[1.0f32, 0.0], 3).unwrap();
1046 assert_eq!(
1047 hits.len(),
1048 3,
1049 "must backfill past deleted nodes to return k live hits"
1050 );
1051 assert!(
1052 hits.iter().all(|h| far.contains(&h.node)),
1053 "only the live (far) nodes are returned"
1054 );
1055 }
1056
1057 #[test]
1061 fn configure_default_after_upsert_is_noop() {
1062 let (_dir, graph) = open_tmp();
1063 let a = graph.add_node("N", &json!({})).unwrap();
1064 graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1065 assert!(
1068 graph
1069 .configure_vector_index(VectorIndexOptions::default())
1070 .is_ok()
1071 );
1072 let other = VectorIndexOptions {
1074 metric: VectorMetric::L2,
1075 ..VectorIndexOptions::default()
1076 };
1077 assert!(graph.configure_vector_index(other).is_err());
1078 }
1079
1080 #[test]
1081 fn vector_search_k_larger_than_index_returns_all() {
1082 let (_dir, graph) = open_tmp();
1083 let a = graph.add_node("N", &json!({})).unwrap();
1084 let b = graph.add_node("N", &json!({})).unwrap();
1085 graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1086 graph.upsert_vector(b, &[0.0f32, 1.0]).unwrap();
1087
1088 let hits = graph.vector_search(&[1.0f32, 0.0], 100).unwrap();
1089 assert_eq!(hits.len(), 2);
1090 }
1091
1092 #[test]
1095 fn upsert_vector_rejects_a_non_finite_component() {
1096 let (_dir, graph) = open_tmp();
1097 let n = graph.add_node("N", &json!({})).unwrap();
1098 for bad in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
1099 let err = graph.upsert_vector(n, &[1.0, bad]).unwrap_err();
1100 assert!(
1101 err.to_string().contains("not finite"),
1102 "expected a non-finite rejection, got {err}"
1103 );
1104 }
1105 graph.upsert_vector(n, &[1.0, 2.0]).unwrap();
1106 }
1107
1108 #[test]
1109 fn upsert_vector_overwrites_existing_embedding() {
1110 let (_dir, graph) = open_tmp();
1111 let a = graph.add_node("N", &json!({})).unwrap();
1112 let b = graph.add_node("N", &json!({})).unwrap();
1113
1114 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1115 graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
1116 graph.upsert_vector(a, &[0.0f32, 1.0, 0.0]).unwrap();
1117
1118 let hits = graph.vector_search(&[0.0f32, 1.0, 0.0], 1).unwrap();
1119 assert_eq!(hits.len(), 1);
1120 assert!(
1121 (hits[0].distance).abs() < 1e-5,
1122 "distance to query should be near zero"
1123 );
1124 }
1125
1126 #[cfg(feature = "lmdb")]
1130 #[test]
1131 fn vector_index_rebuilds_from_lmdb_on_reopen() {
1132 let dir = TempDir::new().unwrap();
1133 let a = {
1134 let graph = Graph::open(dir.path(), 1).unwrap();
1135 let a = graph.add_node("N", &json!({})).unwrap();
1136 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1137 a
1138 };
1139
1140 let graph = Graph::open(dir.path(), 1).unwrap();
1141 let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1142 assert_eq!(hits.len(), 1);
1143 assert_eq!(hits[0].node, a);
1144 }
1145
1146 #[test]
1147 fn remove_vector_deletes_from_index_and_lmdb() {
1148 let (_dir, graph) = open_tmp();
1149 let a = graph.add_node("N", &json!({})).unwrap();
1150 let b = graph.add_node("N", &json!({})).unwrap();
1151 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1152 graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
1153
1154 graph.remove_vector(a).unwrap();
1155
1156 let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 2).unwrap();
1157 assert!(
1158 hits.iter().all(|h| h.node != a),
1159 "removed node must not appear in search results"
1160 );
1161 }
1162
1163 #[test]
1164 fn vector_search_with_label_filter_excludes_other_labels() {
1165 let (_dir, graph) = open_tmp();
1166 let a = graph.add_node("Article", &json!({})).unwrap();
1167 let b = graph.add_node("Person", &json!({})).unwrap();
1168 let c = graph.add_node("Article", &json!({})).unwrap();
1169 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1170 graph.upsert_vector(b, &[1.0f32, 0.0, 0.0]).unwrap(); graph.upsert_vector(c, &[0.9f32, 0.1, 0.0]).unwrap();
1172
1173 let opts = VectorSearchOptions {
1174 k: 3,
1175 label: Some("Article".into()),
1176 properties: None,
1177 rescore_factor: None,
1178 };
1179 let hits = graph
1180 .vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
1181 .unwrap();
1182 assert!(
1184 hits.iter().all(|h| h.node != b),
1185 "Person node must be filtered out"
1186 );
1187 assert!(hits.len() <= 2);
1188 assert!(hits.iter().any(|h| h.node == a));
1189 }
1190
1191 #[test]
1192 fn vector_search_with_selective_property_filter_finds_distant_matches() {
1193 let (_dir, graph) = open_tmp();
1199 for i in 0..200u32 {
1201 let n = graph.add_node("N", &json!({ "team": "red" })).unwrap();
1202 let jitter = (i as f32) * 1e-4;
1203 graph.upsert_vector(n, &[1.0, jitter, 0.0]).unwrap();
1204 }
1205 let blue1 = graph.add_node("N", &json!({ "team": "blue" })).unwrap();
1207 let blue2 = graph.add_node("N", &json!({ "team": "blue" })).unwrap();
1208 graph.upsert_vector(blue1, &[0.6, 0.8, 0.0]).unwrap();
1209 graph.upsert_vector(blue2, &[0.5, 0.85, 0.0]).unwrap();
1210
1211 let mut filters = std::collections::HashMap::new();
1212 filters.insert("team".to_string(), json!("blue"));
1213 let opts = VectorSearchOptions {
1214 k: 2,
1215 label: None,
1216 properties: Some(filters),
1217 rescore_factor: None,
1218 };
1219 let hits = graph
1220 .vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
1221 .unwrap();
1222
1223 assert_eq!(hits.len(), 2, "both blue matches must be returned");
1224 assert!(hits.iter().any(|h| h.node == blue1));
1225 assert!(hits.iter().any(|h| h.node == blue2));
1226 }
1227
1228 #[cfg(feature = "lmdb")]
1232 #[test]
1233 fn rejected_upsert_does_not_persist_and_brick_reopen() {
1234 let dir = TempDir::new().unwrap();
1238 let a = {
1239 let graph = Graph::open(dir.path(), 1).unwrap();
1240 let a = graph.add_node("N", &json!({})).unwrap();
1241 let b = graph.add_node("N", &json!({})).unwrap();
1242 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1243 let bad = graph.upsert_vector(b, &[1.0f32, 0.0]);
1245 assert!(matches!(bad, Err(VectorError::DimensionMismatch { .. })));
1246 a
1247 };
1248
1249 let graph = Graph::open(dir.path(), 1).unwrap();
1251 let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1252 assert_eq!(hits.len(), 1);
1253 assert_eq!(hits[0].node, a);
1254 }
1255
1256 #[cfg(feature = "lmdb")]
1260 #[test]
1261 fn configure_vector_index_persists_metric_across_reopen() {
1262 let dir = TempDir::new().unwrap();
1263 let a = {
1264 let graph = Graph::open(dir.path(), 1).unwrap();
1265 graph
1266 .configure_vector_index(VectorIndexOptions {
1267 metric: VectorMetric::L2,
1268 quantization: VectorQuantization::Float32,
1269 })
1270 .unwrap();
1271 let a = graph.add_node("N", &json!({})).unwrap();
1272 let b = graph.add_node("N", &json!({})).unwrap();
1273 graph.upsert_vector(a, &[0.0f32, 0.0]).unwrap();
1274 graph.upsert_vector(b, &[5.0f32, 5.0]).unwrap();
1275 a
1276 };
1277
1278 let graph = Graph::open(dir.path(), 1).unwrap();
1280 let hits = graph.vector_search(&[0.1f32, 0.1], 1).unwrap();
1281 assert_eq!(hits.len(), 1);
1282 assert_eq!(
1283 hits[0].node, a,
1284 "nearest under L2 must be the origin vector"
1285 );
1286 }
1287
1288 #[test]
1289 fn configure_vector_index_idempotent_with_same_options() {
1290 let (_dir, graph) = open_tmp();
1291 let opts = VectorIndexOptions {
1292 metric: VectorMetric::Dot,
1293 quantization: VectorQuantization::Float16,
1294 };
1295 graph.configure_vector_index(opts).unwrap();
1296 let a = graph.add_node("N", &json!({})).unwrap();
1297 graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1298 graph.configure_vector_index(opts).unwrap();
1300 }
1301
1302 #[test]
1303 fn configure_vector_index_rejects_change_after_vectors_exist() {
1304 let (_dir, graph) = open_tmp();
1305 graph
1306 .configure_vector_index(VectorIndexOptions {
1307 metric: VectorMetric::Cosine,
1308 quantization: VectorQuantization::Float32,
1309 })
1310 .unwrap();
1311 let a = graph.add_node("N", &json!({})).unwrap();
1312 graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
1313
1314 let changed = graph.configure_vector_index(VectorIndexOptions {
1315 metric: VectorMetric::L2,
1316 quantization: VectorQuantization::Float32,
1317 });
1318 assert!(matches!(
1319 changed,
1320 Err(VectorError::AlreadyConfigured { .. })
1321 ));
1322 }
1323
1324 #[cfg(feature = "lmdb")]
1328 #[test]
1329 fn reindex_vector_index_switches_metric_on_populated_graph() {
1330 let dir = TempDir::new().unwrap();
1331 let (a, b) = {
1332 let graph = Graph::open(dir.path(), 1).unwrap();
1333 let a = graph.add_node("N", &json!({})).unwrap();
1335 let b = graph.add_node("N", &json!({})).unwrap();
1336 graph.upsert_vector(a, &[0.0f32, 0.0]).unwrap();
1337 graph.upsert_vector(b, &[5.0f32, 5.0]).unwrap();
1338
1339 let refused = graph.configure_vector_index(VectorIndexOptions {
1341 metric: VectorMetric::L2,
1342 quantization: VectorQuantization::Float32,
1343 });
1344 assert!(matches!(
1345 refused,
1346 Err(VectorError::AlreadyConfigured { .. })
1347 ));
1348
1349 graph
1351 .reindex_vector_index(VectorIndexOptions {
1352 metric: VectorMetric::L2,
1353 quantization: VectorQuantization::Float32,
1354 })
1355 .unwrap();
1356 (a, b)
1357 };
1358
1359 let graph = Graph::open(dir.path(), 1).unwrap();
1361 let hits = graph.vector_search(&[0.1f32, 0.1], 2).unwrap();
1362 assert_eq!(hits[0].node, a, "origin is nearest under L2");
1363 assert!(hits.iter().any(|h| h.node == b));
1364 }
1365
1366 #[test]
1367 fn vector_cache_is_reused_across_searches() {
1368 let (_dir, graph) = open_tmp();
1369 let a = graph.add_node("N", &json!({})).unwrap();
1370 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1371
1372 let h1 = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1374 let h2 = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1375 assert_eq!(h1.len(), 1);
1376 assert_eq!(h2.len(), 1);
1377 assert_eq!(h1[0].node, h2[0].node);
1378 }
1379
1380 #[test]
1381 fn test_concurrent_vector_searches() {
1382 let (_dir, graph) = open_tmp();
1383 let a = graph.add_node("N", &json!({})).unwrap();
1384 graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
1385
1386 let graph = Arc::new(graph);
1387 let mut handles = vec![];
1388 for _ in 0..10 {
1389 let g = Arc::clone(&graph);
1390 let target_node = a;
1391 handles.push(std::thread::spawn(move || {
1392 let hits = g.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
1393 assert_eq!(hits.len(), 1);
1394 assert_eq!(hits[0].node, target_node);
1395 }));
1396 }
1397
1398 for h in handles {
1399 h.join().unwrap();
1400 }
1401 }
1402
1403 #[test]
1410 fn concurrent_upsert_and_remove_leave_index_and_storage_agreeing() {
1411 let (_dir, graph) = open_tmp();
1412 let n = graph.add_node("N", &json!({})).unwrap();
1413 let graph = Arc::new(graph);
1414
1415 let mut handles = vec![];
1416 for t in 0..4u32 {
1417 let g = Arc::clone(&graph);
1418 handles.push(std::thread::spawn(move || {
1419 for i in 0..50u32 {
1420 if (t + i) % 5 == 0 {
1421 g.remove_vector(n).unwrap();
1422 } else {
1423 let angle = ((t * 50 + i) % 7) as f32 * 0.2;
1427 g.upsert_vector(n, &[angle.cos(), angle.sin()]).unwrap();
1428 }
1429 }
1430 }));
1431 }
1432 for h in handles {
1433 h.join().unwrap();
1434 }
1435
1436 match graph.node_vector(n).unwrap() {
1437 Some(stored) => {
1438 let hits = graph.vector_search(&stored, 1).unwrap();
1439 assert_eq!(hits.len(), 1);
1440 assert_eq!(hits[0].node, n);
1441 assert!(
1442 hits[0].distance < 1e-4,
1443 "the index must rank by the embedding storage holds, got distance {}",
1444 hits[0].distance
1445 );
1446 }
1447 None => match graph.vector_search(&[1.0f32, 0.0], 1) {
1448 Err(VectorError::EmptyIndex) => {}
1449 Ok(hits) => {
1450 panic!("the index holds an entry whose stored bytes were removed: {hits:?}")
1451 }
1452 Err(e) => panic!("unexpected error: {e:?}"),
1453 },
1454 }
1455 }
1456
1457 #[test]
1465 fn interleaved_upserts_for_one_node_leave_index_and_storage_agreeing() {
1466 use std::sync::mpsc;
1467
1468 let (_dir, graph) = open_tmp();
1469 let n = graph.add_node("N", &json!({})).unwrap();
1470 let graph = Arc::new(graph);
1471
1472 let lock = mutation_lock(&graph);
1473 let (parked_tx, parked_rx) = mpsc::channel::<()>();
1474 let (release_tx, release_rx) = mpsc::channel::<()>();
1475 *lock.upsert_pause.lock() = Some(Box::new(move || {
1476 parked_tx.send(()).unwrap();
1477 release_rx.recv().unwrap();
1478 }));
1479
1480 let v1 = [1.0f32, 0.0];
1481 let v2 = [0.0f32, 1.0];
1482
1483 let t1 = {
1484 let g = Arc::clone(&graph);
1485 std::thread::spawn(move || g.upsert_vector(n, &v1).unwrap())
1486 };
1487 parked_rx.recv().unwrap();
1488
1489 let t2 = {
1492 let g = Arc::clone(&graph);
1493 std::thread::spawn(move || g.upsert_vector(n, &v2).unwrap())
1494 };
1495 std::thread::sleep(std::time::Duration::from_millis(100));
1501 release_tx.send(()).unwrap();
1502 t1.join().unwrap();
1503 t2.join().unwrap();
1504
1505 let stored = graph.node_vector(n).unwrap().expect("bytes must exist");
1506 let hits = graph.vector_search(&stored, 1).unwrap();
1507 assert_eq!(hits.len(), 1);
1508 assert_eq!(hits[0].node, n);
1509 assert!(
1510 hits[0].distance < 1e-4,
1511 "the index ranks by a different embedding than storage holds, distance {}",
1512 hits[0].distance
1513 );
1514 }
1515
1516 #[test]
1517 fn vector_search_with_int8_quantization_finds_nearest() {
1518 let (_dir, graph) = open_tmp();
1521 graph
1522 .configure_vector_index(VectorIndexOptions {
1523 metric: VectorMetric::Cosine,
1524 quantization: VectorQuantization::Int8,
1525 })
1526 .unwrap();
1527 let a = graph.add_node("N", &json!({})).unwrap();
1528 let b = graph.add_node("N", &json!({})).unwrap();
1529 let c = graph.add_node("N", &json!({})).unwrap();
1530 graph.upsert_vector(a, &[1.0, 0.0, 0.0]).unwrap();
1531 graph.upsert_vector(b, &[0.0, 1.0, 0.0]).unwrap();
1532 graph.upsert_vector(c, &[0.0, 0.0, 1.0]).unwrap();
1533
1534 let hits = graph.vector_search(&[1.0, 0.0, 0.0], 1).unwrap();
1535 assert_eq!(hits.len(), 1);
1536 assert_eq!(hits[0].node, a);
1537 }
1538
1539 #[test]
1540 fn vector_search_with_multiple_property_filters_requires_all() {
1541 let (_dir, graph) = open_tmp();
1545 let near = graph
1546 .add_node("N", &json!({ "team": "blue", "role": "ic" }))
1547 .unwrap();
1548 let far = graph
1549 .add_node("N", &json!({ "team": "blue", "role": "lead" }))
1550 .unwrap();
1551 graph.upsert_vector(near, &[1.0, 0.0, 0.0]).unwrap();
1552 graph.upsert_vector(far, &[0.9, 0.1, 0.0]).unwrap();
1553
1554 let mut filters = std::collections::HashMap::new();
1555 filters.insert("team".to_string(), json!("blue"));
1556 filters.insert("role".to_string(), json!("lead"));
1557 let opts = VectorSearchOptions {
1558 k: 2,
1559 label: None,
1560 properties: Some(filters),
1561 rescore_factor: None,
1562 };
1563 let hits = graph.vector_search_with(&[1.0, 0.0, 0.0], &opts).unwrap();
1564
1565 assert_eq!(hits.len(), 1);
1567 assert_eq!(hits[0].node, far);
1568 }
1569
1570 #[test]
1571 fn vector_search_quantized_rescore() {
1572 let (_dir, graph) = open_tmp();
1573 graph
1574 .configure_vector_index(VectorIndexOptions {
1575 metric: VectorMetric::Cosine,
1576 quantization: VectorQuantization::Int8,
1577 })
1578 .unwrap();
1579
1580 let n1 = graph.add_node("N", &json!({})).unwrap();
1581 let n2 = graph.add_node("N", &json!({})).unwrap();
1582
1583 graph.upsert_vector(n1, &[0.9, 0.1]).unwrap();
1584 graph.upsert_vector(n2, &[0.95, 0.05]).unwrap();
1585
1586 let query = &[1.0, 0.0];
1587
1588 let opts = VectorSearchOptions {
1590 k: 2,
1591 rescore_factor: Some(2),
1592 ..Default::default()
1593 };
1594 let hits = graph.vector_search_with(query, &opts).unwrap();
1595 assert_eq!(hits.len(), 2);
1596 assert_eq!(hits[0].node, n2);
1597 assert_eq!(hits[1].node, n1);
1598
1599 assert!(hits[0].distance < hits[1].distance);
1600 }
1601}