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
11pub struct Hit {
13 pub node: NodeId,
14 pub distance: f32,
15}
16
17#[derive(Debug, Clone)]
19pub struct VectorSearchOptions {
20 pub k: usize,
22 pub label: Option<String>,
24 pub properties: Option<std::collections::HashMap<String, serde_json::Value>>,
26 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
49pub enum VectorMetric {
50 #[default]
52 Cosine,
53 L2,
55 Dot,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
61pub enum VectorQuantization {
62 #[default]
64 Float32,
65 Float16,
67 Int8,
69}
70
71impl std::str::FromStr for VectorMetric {
72 type Err = VectorError;
73
74 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 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#[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
118pub(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 pub fn new() -> Self {
137 Self::new_with_options(VectorIndexOptions::default())
138 }
139
140 pub fn new_with_options(opts: VectorIndexOptions) -> Self {
142 Self {
143 opts,
144 inner: RwLock::new(Inner::Empty),
145 }
146 }
147
148 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 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 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 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
317pub trait VectorGraphExt {
319 fn configure_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
330
331 fn reindex_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
343
344 fn upsert_vector(&self, n: NodeId, v: &[f32]) -> Result<(), VectorError>;
346
347 fn remove_vector(&self, n: NodeId) -> Result<(), VectorError>;
349
350 fn vector_search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError>;
352
353 fn vector_search_with(
366 &self,
367 q: &[f32],
368 opts: &VectorSearchOptions,
369 ) -> Result<Vec<Hit>, VectorError>;
370
371 fn node_vector(&self, n: NodeId) -> Result<Option<Vec<f32>>, VectorError>;
375
376 fn vector_distance(&self, a: &[f32], b: &[f32]) -> Result<f32, VectorError>;
381}
382
383struct 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 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 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 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 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 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 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 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
584fn 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 (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
624fn get_or_init_cache(graph: &Graph) -> Result<Arc<VectorIndexCache>, VectorError> {
627 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
637fn 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
649fn 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
658fn 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
673fn 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(); 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 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 let (_dir, graph) = open_tmp();
887 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 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 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 let bad = graph.upsert_vector(b, &[1.0f32, 0.0]);
929 assert!(matches!(bad, Err(VectorError::DimensionMismatch { .. })));
930 a
931 };
932
933 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 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 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 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 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 graph
1027 .reindex_vector_index(VectorIndexOptions {
1028 metric: VectorMetric::L2,
1029 quantization: VectorQuantization::Float32,
1030 })
1031 .unwrap();
1032 (a, b)
1033 };
1034
1035 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 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 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 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 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 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 assert!(hits[0].distance < hits[1].distance);
1164 }
1165}