use std::cell::RefCell;
use std::sync::Arc;
use parking_lot::RwLock;
use tracing::instrument;
use usearch::{Index, IndexOptions, MetricKind, ScalarKind};
use crate::error::VectorError;
use issundb_core::{Graph, NodeId};
pub struct Hit {
pub node: NodeId,
pub distance: f32,
}
#[derive(Debug, Clone)]
pub struct VectorSearchOptions {
pub k: usize,
pub label: Option<String>,
pub properties: Option<std::collections::HashMap<String, serde_json::Value>>,
}
impl Default for VectorSearchOptions {
fn default() -> Self {
Self {
k: 10,
label: None,
properties: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VectorMetric {
#[default]
Cosine,
L2,
Dot,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum VectorQuantization {
#[default]
Float32,
Float16,
Int8,
}
impl std::str::FromStr for VectorMetric {
type Err = VectorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"cosine" => Ok(Self::Cosine),
"l2" => Ok(Self::L2),
"dot" | "ip" => Ok(Self::Dot),
other => Err(VectorError::InvalidConfig(format!(
"unknown metric '{other}' (expected 'cosine', 'l2', or 'dot')"
))),
}
}
}
impl std::str::FromStr for VectorQuantization {
type Err = VectorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"float32" => Ok(Self::Float32),
"float16" => Ok(Self::Float16),
"int8" => Ok(Self::Int8),
other => Err(VectorError::InvalidConfig(format!(
"unknown quantization '{other}' (expected 'float32', 'float16', or 'int8')"
))),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct VectorIndexOptions {
pub metric: VectorMetric,
pub quantization: VectorQuantization,
}
enum Inner {
Empty,
Ready { index: Index, dims: usize },
}
pub(crate) struct VectorIndex {
opts: VectorIndexOptions,
inner: RwLock<Inner>,
}
impl Default for VectorIndex {
fn default() -> Self {
Self::new()
}
}
impl VectorIndex {
pub fn new() -> Self {
Self::new_with_options(VectorIndexOptions::default())
}
pub fn new_with_options(opts: VectorIndexOptions) -> Self {
Self {
opts,
inner: RwLock::new(Inner::Empty),
}
}
pub fn upsert(&self, node: NodeId, v: &[f32]) -> Result<(), VectorError> {
let dims = v.len();
if dims == 0 {
return Err(VectorError::IndexFault(
"embedding must not be empty".into(),
));
}
let mut guard = self.inner.write();
match &mut *guard {
Inner::Empty => {
let opts = IndexOptions {
dimensions: dims,
metric: metric_to_usearch(self.opts.metric),
quantization: quantization_to_usearch(self.opts.quantization),
..Default::default()
};
let index =
Index::new(&opts).map_err(|e| VectorError::IndexFault(e.to_string()))?;
index
.reserve(64)
.map_err(|e| VectorError::IndexFault(e.to_string()))?;
index
.add(node, v)
.map_err(|e| VectorError::IndexFault(e.to_string()))?;
*guard = Inner::Ready { index, dims };
}
Inner::Ready { index, dims: d } => {
if dims != *d {
return Err(VectorError::DimensionMismatch {
expected: *d,
got: dims,
});
}
if index.contains(node) {
index
.remove(node)
.map_err(|e| VectorError::IndexFault(e.to_string()))?;
}
if index.size() >= index.capacity() {
let new_cap = (index.capacity() * 2).max(64);
index
.reserve(new_cap)
.map_err(|e| VectorError::IndexFault(e.to_string()))?;
}
index
.add(node, v)
.map_err(|e| VectorError::IndexFault(e.to_string()))?;
}
}
Ok(())
}
pub fn remove(&self, node: NodeId) -> Result<(), VectorError> {
let mut guard = self.inner.write();
if let Inner::Ready { index, .. } = &mut *guard {
if index.contains(node) {
index
.remove(node)
.map_err(|e| VectorError::IndexFault(e.to_string()))?;
}
}
Ok(())
}
pub fn search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError> {
let guard = self.inner.read();
match &*guard {
Inner::Empty => Ok(vec![]),
Inner::Ready { index, dims } => {
if q.len() != *dims {
return Err(VectorError::DimensionMismatch {
expected: *dims,
got: q.len(),
});
}
if k == 0 || index.size() == 0 {
return Ok(vec![]);
}
let actual_k = k.min(index.size());
let matches = index
.search::<f32>(q, actual_k)
.map_err(|e| VectorError::IndexFault(e.to_string()))?;
Ok(matches
.keys
.iter()
.zip(matches.distances.iter())
.map(|(&node, &distance)| Hit { node, distance })
.collect())
}
}
}
pub fn search_filtered<F>(
&self,
q: &[f32],
k: usize,
predicate: F,
) -> Result<Vec<Hit>, VectorError>
where
F: Fn(NodeId) -> bool,
{
let guard = self.inner.read();
match &*guard {
Inner::Empty => Ok(vec![]),
Inner::Ready { index, dims } => {
if q.len() != *dims {
return Err(VectorError::DimensionMismatch {
expected: *dims,
got: q.len(),
});
}
if k == 0 || index.size() == 0 {
return Ok(vec![]);
}
let actual_k = k.min(index.size());
let matches = index
.filtered_search::<f32, _>(q, actual_k, predicate)
.map_err(|e| VectorError::IndexFault(e.to_string()))?;
Ok(matches
.keys
.iter()
.zip(matches.distances.iter())
.map(|(&node, &distance)| Hit { node, distance })
.collect())
}
}
}
}
fn encode_vector(v: &[f32]) -> Result<Vec<u8>, VectorError> {
if v.is_empty() {
return Err(VectorError::IndexFault(
"embedding must not be empty".into(),
));
}
Ok(v.iter().flat_map(|f| f.to_le_bytes()).collect())
}
fn decode_vector(bytes: &[u8]) -> Result<Vec<f32>, VectorError> {
if bytes.len() % 4 != 0 {
return Err(VectorError::IndexFault(format!(
"stored embedding byte length must be divisible by 4, got {}",
bytes.len()
)));
}
let vector = bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect();
Ok(vector)
}
pub trait VectorGraphExt {
fn configure_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
fn reindex_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError>;
fn upsert_vector(&self, n: NodeId, v: &[f32]) -> Result<(), VectorError>;
fn remove_vector(&self, n: NodeId) -> Result<(), VectorError>;
fn vector_search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError>;
fn vector_search_with(
&self,
q: &[f32],
opts: &VectorSearchOptions,
) -> Result<Vec<Hit>, VectorError>;
}
struct VectorIndexCache(VectorIndex);
impl VectorGraphExt for Graph {
fn configure_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError> {
let current = load_config(self)?;
if current == Some(opts) {
return Ok(());
}
if !self.vector_bytes()?.is_empty() {
return Err(VectorError::AlreadyConfigured {
existing: format!("{:?}", current.unwrap_or_default()),
requested: format!("{opts:?}"),
});
}
self.put_vector_config(&encode_config(opts))?;
self.set_extension(Arc::new(VectorIndexCache(VectorIndex::new_with_options(
opts,
))));
Ok(())
}
fn reindex_vector_index(&self, opts: VectorIndexOptions) -> Result<(), VectorError> {
self.put_vector_config(&encode_config(opts))?;
let rebuilt = build_index(self, opts)?;
self.set_extension(Arc::new(VectorIndexCache(rebuilt)));
Ok(())
}
#[instrument(skip(self, v), fields(node = %n, dims = v.len()))]
fn upsert_vector(&self, n: NodeId, v: &[f32]) -> Result<(), VectorError> {
let bytes = encode_vector(v)?;
let arc = get_or_init_cache(self)?;
arc.0.upsert(n, v)?;
self.put_vector_bytes(n, &bytes)?;
Ok(())
}
fn remove_vector(&self, n: NodeId) -> Result<(), VectorError> {
self.delete_vector_bytes(n)?;
if let Some(arc) = self.get_extension::<VectorIndexCache>() {
arc.0.remove(n)?;
}
Ok(())
}
#[instrument(skip(self, q), fields(k = %k, dims = q.len()))]
fn vector_search(&self, q: &[f32], k: usize) -> Result<Vec<Hit>, VectorError> {
let arc = get_or_init_cache(self)?;
arc.0.search(q, k)
}
#[instrument(skip(self, q), fields(k = %opts.k, label = ?opts.label, dims = q.len()))]
fn vector_search_with(
&self,
q: &[f32],
opts: &VectorSearchOptions,
) -> Result<Vec<Hit>, VectorError> {
if opts.label.is_none() && opts.properties.is_none() {
return self.vector_search(q, opts.k);
}
let arc = get_or_init_cache(self)?;
let pred_err: RefCell<Option<VectorError>> = RefCell::new(None);
let matches_filters = |node: NodeId| -> Result<bool, VectorError> {
if let Some(label) = &opts.label {
if self.label_filter(&[node], label)?.is_empty() {
return Ok(false);
}
}
if let Some(filters) = &opts.properties {
for (key, want) in filters {
match self.node_prop_json(node, key)? {
Some(got) if &got == want => {}
_ => return Ok(false),
}
}
}
Ok(true)
};
let predicate = |node: NodeId| -> bool {
if pred_err.borrow().is_some() {
return false;
}
match matches_filters(node) {
Ok(keep) => keep,
Err(e) => {
*pred_err.borrow_mut() = Some(e);
false
}
}
};
let hits = arc.0.search_filtered(q, opts.k, predicate)?;
if let Some(e) = pred_err.into_inner() {
return Err(e);
}
Ok(hits)
}
}
fn get_or_init_cache(graph: &Graph) -> Result<Arc<VectorIndexCache>, VectorError> {
graph.get_or_init_extension_with(|| {
let opts = load_config(graph)?.unwrap_or_default();
Ok(Arc::new(VectorIndexCache(build_index(graph, opts)?)))
})
}
fn build_index(graph: &Graph, opts: VectorIndexOptions) -> Result<VectorIndex, VectorError> {
let idx = VectorIndex::new_with_options(opts);
for (node_id, bytes) in graph.vector_bytes()? {
let v = decode_vector(&bytes)?;
idx.upsert(node_id, &v)?;
}
Ok(idx)
}
fn load_config(graph: &Graph) -> Result<Option<VectorIndexOptions>, VectorError> {
match graph.get_vector_config()? {
Some(bytes) => Ok(Some(decode_config(&bytes)?)),
None => Ok(None),
}
}
fn encode_config(opts: VectorIndexOptions) -> [u8; 2] {
let metric = match opts.metric {
VectorMetric::Cosine => 0,
VectorMetric::L2 => 1,
VectorMetric::Dot => 2,
};
let quant = match opts.quantization {
VectorQuantization::Float32 => 0,
VectorQuantization::Float16 => 1,
VectorQuantization::Int8 => 2,
};
[metric, quant]
}
fn decode_config(bytes: &[u8]) -> Result<VectorIndexOptions, VectorError> {
let [metric, quant] = bytes.try_into().map_err(|_| {
VectorError::IndexFault(format!(
"vector config must be 2 bytes, got {}",
bytes.len()
))
})?;
let metric = match metric {
0 => VectorMetric::Cosine,
1 => VectorMetric::L2,
2 => VectorMetric::Dot,
other => {
return Err(VectorError::IndexFault(format!(
"unknown vector metric tag {other}"
)));
}
};
let quantization = match quant {
0 => VectorQuantization::Float32,
1 => VectorQuantization::Float16,
2 => VectorQuantization::Int8,
other => {
return Err(VectorError::IndexFault(format!(
"unknown vector quantization tag {other}"
)));
}
};
Ok(VectorIndexOptions {
metric,
quantization,
})
}
fn metric_to_usearch(m: VectorMetric) -> MetricKind {
match m {
VectorMetric::Cosine => MetricKind::Cos,
VectorMetric::L2 => MetricKind::L2sq,
VectorMetric::Dot => MetricKind::IP,
}
}
fn quantization_to_usearch(q: VectorQuantization) -> ScalarKind {
match q {
VectorQuantization::Float32 => ScalarKind::F32,
VectorQuantization::Float16 => ScalarKind::F16,
VectorQuantization::Int8 => ScalarKind::I8,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use tempfile::TempDir;
use super::*;
fn open_tmp() -> (TempDir, Graph) {
let dir = TempDir::new().unwrap();
let graph = Graph::open(dir.path(), 1).unwrap();
(dir, graph)
}
#[test]
fn metric_from_str_is_case_insensitive_with_alias() {
assert_eq!(
"cosine".parse::<VectorMetric>().unwrap(),
VectorMetric::Cosine
);
assert_eq!("L2".parse::<VectorMetric>().unwrap(), VectorMetric::L2);
assert_eq!("Dot".parse::<VectorMetric>().unwrap(), VectorMetric::Dot);
assert_eq!("ip".parse::<VectorMetric>().unwrap(), VectorMetric::Dot);
assert!("hamming".parse::<VectorMetric>().is_err());
}
#[test]
fn quantization_from_str_is_case_insensitive() {
assert_eq!(
"float32".parse::<VectorQuantization>().unwrap(),
VectorQuantization::Float32
);
assert_eq!(
"Float16".parse::<VectorQuantization>().unwrap(),
VectorQuantization::Float16
);
assert_eq!(
"INT8".parse::<VectorQuantization>().unwrap(),
VectorQuantization::Int8
);
assert!("b1".parse::<VectorQuantization>().is_err());
}
#[test]
fn upsert_vector_and_search_finds_nearest() {
let (_dir, graph) = open_tmp();
let a = graph.add_node("N", &json!({})).unwrap();
let b = graph.add_node("N", &json!({})).unwrap();
let c = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
graph.upsert_vector(c, &[0.0f32, 0.0, 1.0]).unwrap();
let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].node, a);
}
#[test]
fn vector_search_empty_index_returns_empty() {
let (_dir, graph) = open_tmp();
let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 5).unwrap();
assert!(hits.is_empty());
}
#[test]
fn vector_search_k_larger_than_index_returns_all() {
let (_dir, graph) = open_tmp();
let a = graph.add_node("N", &json!({})).unwrap();
let b = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
graph.upsert_vector(b, &[0.0f32, 1.0]).unwrap();
let hits = graph.vector_search(&[1.0f32, 0.0], 100).unwrap();
assert_eq!(hits.len(), 2);
}
#[test]
fn upsert_vector_overwrites_existing_embedding() {
let (_dir, graph) = open_tmp();
let a = graph.add_node("N", &json!({})).unwrap();
let b = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
graph.upsert_vector(a, &[0.0f32, 1.0, 0.0]).unwrap();
let hits = graph.vector_search(&[0.0f32, 1.0, 0.0], 1).unwrap();
assert_eq!(hits.len(), 1);
assert!(
(hits[0].distance).abs() < 1e-5,
"distance to query should be near zero"
);
}
#[test]
fn vector_index_rebuilds_from_lmdb_on_reopen() {
let dir = TempDir::new().unwrap();
let a = {
let graph = Graph::open(dir.path(), 1).unwrap();
let a = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
a
};
let graph = Graph::open(dir.path(), 1).unwrap();
let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].node, a);
}
#[test]
fn remove_vector_deletes_from_index_and_lmdb() {
let (_dir, graph) = open_tmp();
let a = graph.add_node("N", &json!({})).unwrap();
let b = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
graph.upsert_vector(b, &[0.0f32, 1.0, 0.0]).unwrap();
graph.remove_vector(a).unwrap();
let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 2).unwrap();
assert!(
hits.iter().all(|h| h.node != a),
"removed node must not appear in search results"
);
}
#[test]
fn vector_search_with_label_filter_excludes_other_labels() {
let (_dir, graph) = open_tmp();
let a = graph.add_node("Article", &json!({})).unwrap();
let b = graph.add_node("Person", &json!({})).unwrap();
let c = graph.add_node("Article", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
graph.upsert_vector(b, &[1.0f32, 0.0, 0.0]).unwrap(); graph.upsert_vector(c, &[0.9f32, 0.1, 0.0]).unwrap();
let opts = VectorSearchOptions {
k: 3,
label: Some("Article".into()),
properties: None,
};
let hits = graph
.vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
.unwrap();
assert!(
hits.iter().all(|h| h.node != b),
"Person node must be filtered out"
);
assert!(hits.len() <= 2);
assert!(hits.iter().any(|h| h.node == a));
}
#[test]
fn vector_search_with_selective_property_filter_finds_distant_matches() {
let (_dir, graph) = open_tmp();
for i in 0..200u32 {
let n = graph.add_node("N", &json!({ "team": "red" })).unwrap();
let jitter = (i as f32) * 1e-4;
graph.upsert_vector(n, &[1.0, jitter, 0.0]).unwrap();
}
let blue1 = graph.add_node("N", &json!({ "team": "blue" })).unwrap();
let blue2 = graph.add_node("N", &json!({ "team": "blue" })).unwrap();
graph.upsert_vector(blue1, &[0.6, 0.8, 0.0]).unwrap();
graph.upsert_vector(blue2, &[0.5, 0.85, 0.0]).unwrap();
let mut filters = std::collections::HashMap::new();
filters.insert("team".to_string(), json!("blue"));
let opts = VectorSearchOptions {
k: 2,
label: None,
properties: Some(filters),
};
let hits = graph
.vector_search_with(&[1.0f32, 0.0, 0.0], &opts)
.unwrap();
assert_eq!(hits.len(), 2, "both blue matches must be returned");
assert!(hits.iter().any(|h| h.node == blue1));
assert!(hits.iter().any(|h| h.node == blue2));
}
#[test]
fn rejected_upsert_does_not_persist_and_brick_reopen() {
let dir = TempDir::new().unwrap();
let a = {
let graph = Graph::open(dir.path(), 1).unwrap();
let a = graph.add_node("N", &json!({})).unwrap();
let b = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
let bad = graph.upsert_vector(b, &[1.0f32, 0.0]);
assert!(matches!(bad, Err(VectorError::DimensionMismatch { .. })));
a
};
let graph = Graph::open(dir.path(), 1).unwrap();
let hits = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].node, a);
}
#[test]
fn configure_vector_index_persists_metric_across_reopen() {
let dir = TempDir::new().unwrap();
let a = {
let graph = Graph::open(dir.path(), 1).unwrap();
graph
.configure_vector_index(VectorIndexOptions {
metric: VectorMetric::L2,
quantization: VectorQuantization::Float32,
})
.unwrap();
let a = graph.add_node("N", &json!({})).unwrap();
let b = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[0.0f32, 0.0]).unwrap();
graph.upsert_vector(b, &[5.0f32, 5.0]).unwrap();
a
};
let graph = Graph::open(dir.path(), 1).unwrap();
let hits = graph.vector_search(&[0.1f32, 0.1], 1).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(
hits[0].node, a,
"nearest under L2 must be the origin vector"
);
}
#[test]
fn configure_vector_index_idempotent_with_same_options() {
let (_dir, graph) = open_tmp();
let opts = VectorIndexOptions {
metric: VectorMetric::Dot,
quantization: VectorQuantization::Float16,
};
graph.configure_vector_index(opts).unwrap();
let a = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
graph.configure_vector_index(opts).unwrap();
}
#[test]
fn configure_vector_index_rejects_change_after_vectors_exist() {
let (_dir, graph) = open_tmp();
graph
.configure_vector_index(VectorIndexOptions {
metric: VectorMetric::Cosine,
quantization: VectorQuantization::Float32,
})
.unwrap();
let a = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0]).unwrap();
let changed = graph.configure_vector_index(VectorIndexOptions {
metric: VectorMetric::L2,
quantization: VectorQuantization::Float32,
});
assert!(matches!(
changed,
Err(VectorError::AlreadyConfigured { .. })
));
}
#[test]
fn reindex_vector_index_switches_metric_on_populated_graph() {
let dir = TempDir::new().unwrap();
let (a, b) = {
let graph = Graph::open(dir.path(), 1).unwrap();
let a = graph.add_node("N", &json!({})).unwrap();
let b = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[0.0f32, 0.0]).unwrap();
graph.upsert_vector(b, &[5.0f32, 5.0]).unwrap();
let refused = graph.configure_vector_index(VectorIndexOptions {
metric: VectorMetric::L2,
quantization: VectorQuantization::Float32,
});
assert!(matches!(
refused,
Err(VectorError::AlreadyConfigured { .. })
));
graph
.reindex_vector_index(VectorIndexOptions {
metric: VectorMetric::L2,
quantization: VectorQuantization::Float32,
})
.unwrap();
(a, b)
};
let graph = Graph::open(dir.path(), 1).unwrap();
let hits = graph.vector_search(&[0.1f32, 0.1], 2).unwrap();
assert_eq!(hits[0].node, a, "origin is nearest under L2");
assert!(hits.iter().any(|h| h.node == b));
}
#[test]
fn vector_cache_is_reused_across_searches() {
let (_dir, graph) = open_tmp();
let a = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
let h1 = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
let h2 = graph.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
assert_eq!(h1.len(), 1);
assert_eq!(h2.len(), 1);
assert_eq!(h1[0].node, h2[0].node);
}
#[test]
fn test_concurrent_vector_searches() {
let (_dir, graph) = open_tmp();
let a = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0f32, 0.0, 0.0]).unwrap();
let graph = Arc::new(graph);
let mut handles = vec![];
for _ in 0..10 {
let g = Arc::clone(&graph);
let target_node = a;
handles.push(std::thread::spawn(move || {
let hits = g.vector_search(&[1.0f32, 0.0, 0.0], 1).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].node, target_node);
}));
}
for h in handles {
h.join().unwrap();
}
}
#[test]
fn vector_search_with_int8_quantization_finds_nearest() {
let (_dir, graph) = open_tmp();
graph
.configure_vector_index(VectorIndexOptions {
metric: VectorMetric::Cosine,
quantization: VectorQuantization::Int8,
})
.unwrap();
let a = graph.add_node("N", &json!({})).unwrap();
let b = graph.add_node("N", &json!({})).unwrap();
let c = graph.add_node("N", &json!({})).unwrap();
graph.upsert_vector(a, &[1.0, 0.0, 0.0]).unwrap();
graph.upsert_vector(b, &[0.0, 1.0, 0.0]).unwrap();
graph.upsert_vector(c, &[0.0, 0.0, 1.0]).unwrap();
let hits = graph.vector_search(&[1.0, 0.0, 0.0], 1).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].node, a);
}
#[test]
fn vector_search_with_multiple_property_filters_requires_all() {
let (_dir, graph) = open_tmp();
let near = graph
.add_node("N", &json!({ "team": "blue", "role": "ic" }))
.unwrap();
let far = graph
.add_node("N", &json!({ "team": "blue", "role": "lead" }))
.unwrap();
graph.upsert_vector(near, &[1.0, 0.0, 0.0]).unwrap();
graph.upsert_vector(far, &[0.9, 0.1, 0.0]).unwrap();
let mut filters = std::collections::HashMap::new();
filters.insert("team".to_string(), json!("blue"));
filters.insert("role".to_string(), json!("lead"));
let opts = VectorSearchOptions {
k: 2,
label: None,
properties: Some(filters),
};
let hits = graph.vector_search_with(&[1.0, 0.0, 0.0], &opts).unwrap();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].node, far);
}
}