use std::cell::RefCell;
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
use nodedb_types::hnsw::HnswParams;
use nodedb_types::vector_dtype::VectorStorageDtype;
use crate::hnsw::arena::BeamSearchArena;
use super::super::ARENA_INITIAL_CAPACITY;
use super::super::types::{Node, Xorshift64};
pub struct HnswIndex {
pub(crate) params: HnswParams,
pub(crate) dim: usize,
pub(crate) nodes: Vec<Node>,
pub(crate) entry_point: Option<u32>,
pub(crate) max_layer: usize,
pub(crate) rng: Xorshift64,
pub(crate) flat_neighbors: Option<crate::hnsw::flat_neighbors::FlatNeighborStore>,
#[cfg(not(target_arch = "wasm32"))]
pub(crate) backing: Option<Arc<dyn crate::segment_backing::VectorSegmentBacking>>,
pub(crate) arena: RefCell<BeamSearchArena>,
}
impl HnswIndex {
pub fn new(dim: usize, params: HnswParams) -> Self {
Self::seeded(dim, params, 42)
}
pub fn with_seed(dim: usize, params: HnswParams, seed: u64) -> Self {
Self::seeded(dim, params, seed)
}
fn seeded(dim: usize, params: HnswParams, seed: u64) -> Self {
let initial_capacity = params.ef_construction.max(ARENA_INITIAL_CAPACITY);
Self {
dim,
nodes: Vec::new(),
entry_point: None,
max_layer: 0,
rng: Xorshift64::new(seed),
flat_neighbors: None,
arena: RefCell::new(BeamSearchArena::new(initial_capacity)),
params,
#[cfg(not(target_arch = "wasm32"))]
backing: None,
}
}
pub fn metric(&self) -> crate::distance::DistanceMetric {
self.params.metric
}
pub fn params(&self) -> &HnswParams {
&self.params
}
pub fn dim(&self) -> usize {
self.dim
}
pub fn dtype(&self) -> VectorStorageDtype {
self.params.dtype
}
pub fn entry_point(&self) -> Option<u32> {
self.entry_point
}
pub fn max_layer(&self) -> usize {
self.max_layer
}
pub fn rng_state(&self) -> u64 {
self.rng.0
}
pub fn memory_usage_bytes(&self) -> usize {
let vector_bytes = self.nodes.len() * self.params.dtype.bytes_for_dim(self.dim);
let neighbor_bytes: usize = self
.nodes
.iter()
.map(|n| {
n.neighbors
.iter()
.map(|layer| layer.len() * 4)
.sum::<usize>()
})
.sum();
let node_overhead = self.nodes.len() * std::mem::size_of::<Node>();
vector_bytes + neighbor_bytes + node_overhead
}
}
#[cfg(test)]
mod tests {
#[cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
use super::{HnswIndex, HnswParams};
use crate::distance::DistanceMetric;
use crate::error::VectorError;
use nodedb_types::vector_dtype::VectorStorageDtype;
fn make_params(dtype: VectorStorageDtype) -> HnswParams {
HnswParams {
m: 4,
m0: 8,
ef_construction: 32,
metric: DistanceMetric::L2,
dtype,
}
}
#[test]
fn create_empty_index() {
let idx = HnswIndex::new(3, HnswParams::default());
assert_eq!(idx.len(), 0);
assert!(idx.is_empty());
assert!(idx.entry_point().is_none());
}
#[test]
fn params_default() {
let p = HnswParams::default();
assert_eq!(p.m, 16);
assert_eq!(p.m0, 32);
assert_eq!(p.ef_construction, 200);
assert_eq!(p.metric, DistanceMetric::Cosine);
assert_eq!(p.dtype, VectorStorageDtype::F32);
}
#[test]
fn candidate_ordering() {
let a = crate::hnsw::graph::types::Candidate { dist: 0.1, id: 1 };
let b = crate::hnsw::graph::types::Candidate { dist: 0.5, id: 2 };
assert!(a < b);
}
#[test]
fn f32_default_unchanged() {
let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F32), 1);
assert_eq!(idx.dtype(), VectorStorageDtype::F32);
for i in 0..10u32 {
idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
}
let v = idx.get_vector(3).unwrap();
assert_eq!(v[0], 3.0_f32);
assert_eq!(idx.get_vector_bytes(3).unwrap().len(), 12); }
#[test]
fn f16_insert_search_smoke() {
let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F16), 42);
assert_eq!(idx.dtype(), VectorStorageDtype::F16);
for i in 0..10u32 {
idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
}
let results = idx.search(&[5.0, 0.0, 0.0], 3, 32).unwrap();
assert_eq!(results.len(), 3);
for w in results.windows(2) {
assert!(
w[0].distance <= w[1].distance,
"results not sorted: {:?}",
results
);
}
}
#[test]
fn bf16_insert_search_smoke() {
let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::BF16), 42);
assert_eq!(idx.dtype(), VectorStorageDtype::BF16);
for i in 0..10u32 {
idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
}
let results = idx.search(&[5.0, 0.0, 0.0], 3, 32).unwrap();
assert_eq!(results.len(), 3);
for w in results.windows(2) {
assert!(
w[0].distance <= w[1].distance,
"results not sorted: {:?}",
results
);
}
}
#[test]
fn get_vector_returns_none_on_non_f32_dtype() {
let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F16), 1);
idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
assert!(idx.get_vector_bytes(0).is_some());
#[cfg(not(debug_assertions))]
assert!(idx.get_vector(0).is_none());
}
#[test]
fn materialize_vector_decodes_every_dtype() {
for dtype in [
VectorStorageDtype::F32,
VectorStorageDtype::F16,
VectorStorageDtype::BF16,
] {
let mut idx = HnswIndex::with_seed(3, make_params(dtype), 1);
idx.insert(vec![1.0, 2.0, 4.0]).unwrap();
let v = idx
.materialize_vector(0)
.unwrap_or_else(|e| panic!("dtype={dtype:?} must materialize, got {e}"));
assert_eq!(v.len(), 3, "dtype={dtype:?}");
assert_eq!(v, vec![1.0, 2.0, 4.0], "dtype={dtype:?}");
}
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn get_vector_or_backing_serves_narrow_dtypes() {
for dtype in [VectorStorageDtype::F16, VectorStorageDtype::BF16] {
let mut idx = HnswIndex::with_seed(3, make_params(dtype), 1);
idx.insert(vec![1.0, 2.0, 4.0]).unwrap();
let v = idx
.get_vector_or_backing(0)
.unwrap_or_else(|| panic!("dtype={dtype:?} must serve a rerank vector"));
assert_eq!(&*v, &[1.0, 2.0, 4.0][..], "dtype={dtype:?}");
}
}
#[test]
fn extractors_reject_an_index_with_no_vector_source() {
let mut idx = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F32), 1);
idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
let graph_only = idx.graph_checkpoint_to_bytes().unwrap();
let restored = HnswIndex::from_checkpoint(&graph_only)
.unwrap()
.expect("graph checkpoint must be recognized");
assert_eq!(restored.len(), 1, "graph topology must survive");
assert!(
matches!(
restored.materialize_vector(0),
Err(VectorError::VectorUnavailable { id: 0 })
),
"materialize_vector must report the missing vector source"
);
assert!(
restored.export_vectors().is_err(),
"export_vectors must not yield empty placeholder vectors"
);
assert!(
restored.extract_vectors_and_surrogates().is_err(),
"extract_vectors_and_surrogates must not yield empty placeholder vectors"
);
assert!(
restored.checkpoint_to_bytes().is_err(),
"a full checkpoint cannot be written from an index with no vector data"
);
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn with_backing_refuses_a_backing_that_cannot_serve_the_index() {
use crate::segment_backing::VectorSegmentBacking;
struct LyingBacking {
len: usize,
dim: usize,
served: Vec<Vec<f32>>,
}
impl VectorSegmentBacking for LyingBacking {
fn len(&self) -> usize {
self.len
}
fn dim(&self) -> usize {
self.dim
}
fn get_vector(&self, id: u32) -> Option<&[f32]> {
self.served.get(id as usize).map(Vec::as_slice)
}
fn get_surrogate(&self, _id: u32) -> Option<u64> {
None
}
}
let mut src = HnswIndex::with_seed(3, make_params(VectorStorageDtype::F32), 1);
src.insert(vec![1.0, 2.0, 3.0]).unwrap();
src.insert(vec![4.0, 5.0, 6.0]).unwrap();
let graph_only = src.graph_checkpoint_to_bytes().unwrap();
let restore = || {
HnswIndex::from_checkpoint(&graph_only)
.unwrap()
.expect("graph checkpoint must be recognized")
};
let mut idx = restore();
assert!(
idx.with_backing(Arc::new(LyingBacking {
len: 2,
dim: 3,
served: Vec::new(),
}))
.is_err(),
"a backing that serves no vectors must be refused"
);
let mut idx = restore();
assert!(
idx.with_backing(Arc::new(LyingBacking {
len: 1,
dim: 3,
served: vec![vec![1.0, 2.0, 3.0]],
}))
.is_err(),
"a backing shorter than the node count must be refused"
);
let mut idx = restore();
assert!(
matches!(
idx.with_backing(Arc::new(LyingBacking {
len: 2,
dim: 4,
served: vec![vec![0.0; 4], vec![0.0; 4]],
})),
Err(VectorError::StoredDimensionMismatch { .. })
),
"a backing with the wrong dim must be refused"
);
let mut idx = restore();
idx.with_backing(Arc::new(LyingBacking {
len: 2,
dim: 3,
served: vec![vec![1.0, 2.0, 3.0], vec![4.0, 5.0, 6.0]],
}))
.expect("a complete backing must attach");
assert_eq!(idx.materialize_vector(0).unwrap(), vec![1.0, 2.0, 3.0]);
assert_eq!(idx.materialize_vector(1).unwrap(), vec![4.0, 5.0, 6.0]);
}
#[test]
fn search_before_with_backing_returns_no_vectorless_hits() {
use crate::test_support::{graph_only_restore, seeded_index, seeded_vectors};
let vectors = seeded_vectors(50, 3, 0x9E37_79B9_7F4A_7C15);
let src = seeded_index(&vectors, DistanceMetric::L2, VectorStorageDtype::F32);
let idx = graph_only_restore(&src);
assert_eq!(idx.len(), 50, "graph topology must survive");
let filter: roaring::RoaringBitmap = (0..50u32).collect();
let unfiltered = idx.search(&vectors[0], 5, 64).unwrap();
let filtered = idx.search_filtered(&vectors[0], 5, 64, &filter).unwrap();
assert!(unfiltered.is_empty(), "got {unfiltered:?}");
assert!(filtered.is_empty(), "got {filtered:?}");
}
#[test]
fn get_vector_bytes_works_for_all_dtypes() {
for (dtype, expected_byte_len) in [
(VectorStorageDtype::F32, 12usize), (VectorStorageDtype::F16, 6usize), (VectorStorageDtype::BF16, 6usize), ] {
let mut idx = HnswIndex::with_seed(3, make_params(dtype), 1);
idx.insert(vec![1.0, 2.0, 3.0]).unwrap();
let bytes = idx.get_vector_bytes(0).expect("must be Some for valid id");
assert_eq!(
bytes.len(),
expected_byte_len,
"wrong byte len for dtype={dtype:?}"
);
}
}
}