use std::collections::HashSet;
use roaring::RoaringBitmap;
use crate::error::{VectorError, check_dim};
use crate::hnsw::graph::{Candidate, HnswIndex, Probe};
use crate::navix::beam::Beam;
use crate::navix::selectivity::{NavixHeuristic, local_selectivity_at, pick_heuristic};
#[derive(Debug, Clone)]
pub struct SearchResult {
pub id: u32,
pub distance: f32,
}
pub struct NavixSearchOptions {
pub k: usize,
pub ef_search: usize,
pub allowed: RoaringBitmap,
pub brute_force_threshold: f64,
}
impl Default for NavixSearchOptions {
fn default() -> Self {
Self {
k: 10,
ef_search: 64,
allowed: RoaringBitmap::new(),
brute_force_threshold: 0.001,
}
}
}
pub fn navix_search(
index: &HnswIndex,
query: &[f32],
options: &NavixSearchOptions,
metric: nodedb_types::vector_distance::DistanceMetric,
) -> Result<Vec<SearchResult>, VectorError> {
check_dim(index.dim(), query.len())?;
if index.is_empty() || options.allowed.is_empty() || options.k == 0 {
return Ok(Vec::new());
}
let probe = index.probe(query);
let total = index.len();
let global_sel = options.allowed.len() as f64 / total as f64;
if global_sel < options.brute_force_threshold {
return Ok(brute_force_on_allowed(
index,
&probe,
options.k,
&options.allowed,
metric,
));
}
let Some(ep) = index.entry_point() else {
return Ok(Vec::new());
};
let mut current_ep = ep;
for layer in (1..=index.max_layer()).rev() {
let results = unfiltered_search_layer(index, &probe, current_ep, 1, layer, metric);
if let Some(nearest) = results.first() {
current_ep = nearest.id;
}
}
let ef = options.ef_search.max(options.k);
let results = navix_search_layer_0(index, &probe, current_ep, ef, &options.allowed, metric);
Ok(results
.into_iter()
.take(options.k)
.map(|c| SearchResult {
id: c.id,
distance: c.dist,
})
.collect())
}
fn brute_force_on_allowed(
index: &HnswIndex,
probe: &Probe<'_>,
k: usize,
allowed: &RoaringBitmap,
metric: nodedb_types::vector_distance::DistanceMetric,
) -> Vec<SearchResult> {
let mut results: Vec<SearchResult> = allowed
.iter()
.filter(|&id| !index.is_deleted(id))
.filter_map(|id| {
let distance = index.dist_to_node(probe, id, metric)?;
Some(SearchResult { id, distance })
})
.collect();
if results.len() > k {
results.select_nth_unstable_by(k, |a, b| {
a.distance
.partial_cmp(&b.distance)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(k);
}
results.sort_by(|a, b| {
a.distance
.partial_cmp(&b.distance)
.unwrap_or(std::cmp::Ordering::Equal)
});
results
}
fn admitted(index: &HnswIndex, allowed: &RoaringBitmap, id: u32) -> bool {
!index.is_deleted(id) && allowed.contains(id)
}
fn unfiltered_search_layer(
index: &HnswIndex,
probe: &Probe<'_>,
entry_point: u32,
ef: usize,
layer: usize,
metric: nodedb_types::vector_distance::DistanceMetric,
) -> Vec<Candidate> {
let mut visited: HashSet<u32> = HashSet::new();
visited.insert(entry_point);
let mut beam = Beam::new(ef);
beam.offer(
entry_point,
index.dist_to_node(probe, entry_point, metric),
!index.is_deleted(entry_point),
);
while let Some(current) = beam.pop_frontier() {
for &nb in index.neighbors_at(current.id, layer) {
if !visited.insert(nb) {
continue;
}
beam.offer(
nb,
index.dist_to_node(probe, nb, metric),
!index.is_deleted(nb),
);
}
}
beam.into_sorted()
}
fn navix_search_layer_0(
index: &HnswIndex,
probe: &Probe<'_>,
entry_point: u32,
ef: usize,
allowed: &RoaringBitmap,
metric: nodedb_types::vector_distance::DistanceMetric,
) -> Vec<Candidate> {
let mut visited: HashSet<u32> = HashSet::new();
visited.insert(entry_point);
let mut beam = Beam::new(ef);
beam.offer(
entry_point,
index.dist_to_node(probe, entry_point, metric),
admitted(index, allowed, entry_point),
);
while let Some(current) = beam.pop_frontier() {
let neighbors_1hop = index.neighbors_at(current.id, 0);
let args = ExpandArgs {
index,
probe,
neighbors_1hop,
allowed,
metric,
visited: &mut visited,
beam: &mut beam,
};
match pick_heuristic(local_selectivity_at(neighbors_1hop, allowed)) {
NavixHeuristic::Standard => expand_standard(args),
NavixHeuristic::Directed => expand_directed(args),
NavixHeuristic::Blind => expand_blind(args),
}
}
beam.into_sorted()
}
struct ExpandArgs<'a> {
index: &'a HnswIndex,
probe: &'a Probe<'a>,
neighbors_1hop: &'a [u32],
allowed: &'a RoaringBitmap,
metric: nodedb_types::vector_distance::DistanceMetric,
visited: &'a mut HashSet<u32>,
beam: &'a mut Beam,
}
fn expand_standard(args: ExpandArgs<'_>) {
let ExpandArgs {
index,
probe,
neighbors_1hop,
allowed,
metric,
visited,
beam,
} = args;
for &nb in neighbors_1hop {
if !visited.insert(nb) {
continue;
}
beam.offer(
nb,
index.dist_to_node(probe, nb, metric),
admitted(index, allowed, nb),
);
}
}
fn expand_directed(args: ExpandArgs<'_>) {
let ExpandArgs {
index,
probe,
neighbors_1hop,
allowed,
metric,
visited,
beam,
} = args;
let mut best_allowed: Option<(u32, f32)> = None;
for &nb in neighbors_1hop {
if !visited.insert(nb) {
continue;
}
let d = index.dist_to_node(probe, nb, metric);
let admit = admitted(index, allowed, nb);
if admit
&& let Some(d) = d
&& best_allowed.is_none_or(|(_, bd)| d < bd)
{
best_allowed = Some((nb, d));
}
beam.offer(nb, d, admit);
}
if let Some((best_id, _)) = best_allowed {
for &nb2 in index.neighbors_at(best_id, 0) {
if !visited.insert(nb2) {
continue;
}
beam.offer(
nb2,
index.dist_to_node(probe, nb2, metric),
admitted(index, allowed, nb2),
);
}
}
}
fn expand_blind(args: ExpandArgs<'_>) {
let ExpandArgs {
index,
probe,
neighbors_1hop,
allowed,
metric,
visited,
beam,
} = args;
for &nb1 in neighbors_1hop {
visited.insert(nb1);
for &nb2 in index.neighbors_at(nb1, 0) {
if !visited.insert(nb2) || !admitted(index, allowed, nb2) {
continue;
}
beam.offer(nb2, index.dist_to_node(probe, nb2, metric), true);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::distance::{DistanceMetric, distance};
use crate::hnsw::{HnswIndex, HnswParams};
fn build_index(n: usize) -> HnswIndex {
let mut idx = HnswIndex::with_seed(
3,
HnswParams {
m: 8,
m0: 16,
ef_construction: 50,
metric: DistanceMetric::L2,
dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32,
},
42,
);
for i in 0..n {
idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
}
idx
}
fn all_allowed(n: u32) -> RoaringBitmap {
let mut b = RoaringBitmap::new();
for i in 0..n {
b.insert(i);
}
b
}
#[test]
fn full_allowed_matches_unfiltered() {
let idx = build_index(20);
let query = [10.0f32, 0.0, 0.0];
let allowed = all_allowed(20);
let opts = NavixSearchOptions {
k: 5,
ef_search: 64,
allowed,
brute_force_threshold: 0.001,
};
let navix_res = navix_search(&idx, &query, &opts, DistanceMetric::L2).unwrap();
let hnsw_res = idx.search(&query, 5, 64).unwrap();
assert!(!navix_res.is_empty());
assert_eq!(navix_res[0].id, hnsw_res[0].id);
}
#[test]
fn single_allowed_id_returned() {
let idx = build_index(20);
let query = [5.0f32, 0.0, 0.0];
let mut allowed = RoaringBitmap::new();
allowed.insert(15);
let opts = NavixSearchOptions {
k: 5,
ef_search: 64,
allowed,
brute_force_threshold: 0.001,
};
let res = navix_search(&idx, &query, &opts, DistanceMetric::L2).unwrap();
assert!(res.len() <= 1);
if let Some(r) = res.first() {
assert_eq!(r.id, 15);
}
}
#[test]
fn half_allowed_results_in_allowed() {
let idx = build_index(20);
let query = [10.0f32, 0.0, 0.0];
let mut allowed = RoaringBitmap::new();
for i in (0..20u32).step_by(2) {
allowed.insert(i); }
let opts = NavixSearchOptions {
k: 3,
ef_search: 64,
allowed: allowed.clone(),
brute_force_threshold: 0.001,
};
let res = navix_search(&idx, &query, &opts, DistanceMetric::L2).unwrap();
assert!(!res.is_empty());
for r in &res {
assert!(
allowed.contains(r.id),
"got disallowed id {} in results",
r.id
);
}
}
#[test]
fn brute_force_fallback_matches_manual() {
let idx = build_index(20);
let query = [8.0f32, 0.0, 0.0];
let mut allowed = RoaringBitmap::new();
allowed.insert(3);
allowed.insert(7);
allowed.insert(12);
let opts = NavixSearchOptions {
k: 5,
ef_search: 64,
allowed: allowed.clone(),
brute_force_threshold: 0.5,
};
let res = navix_search(&idx, &query, &opts, DistanceMetric::L2).unwrap();
let mut manual: Vec<(u32, f32)> = allowed
.iter()
.map(|id| {
let v = idx.get_vector(id).unwrap();
let d = distance(&query, v, DistanceMetric::L2);
(id, d)
})
.collect();
manual.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap());
assert_eq!(res.len(), manual.len().min(opts.k));
for (r, (mid, _)) in res.iter().zip(manual.iter()) {
assert_eq!(r.id, *mid, "brute-force result mismatch");
}
}
#[test]
fn empty_index_returns_empty() {
let idx = HnswIndex::new(
3,
HnswParams {
m: 8,
m0: 16,
ef_construction: 50,
metric: DistanceMetric::L2,
dtype: nodedb_types::vector_dtype::VectorStorageDtype::F32,
},
);
let mut allowed = RoaringBitmap::new();
allowed.insert(0);
let opts = NavixSearchOptions {
k: 5,
ef_search: 64,
allowed,
brute_force_threshold: 0.001,
};
let res = navix_search(&idx, &[1.0, 0.0, 0.0], &opts, DistanceMetric::L2).unwrap();
assert!(res.is_empty());
}
#[test]
fn empty_allowed_returns_empty() {
let idx = build_index(10);
let opts = NavixSearchOptions {
k: 5,
ef_search: 64,
allowed: RoaringBitmap::new(),
brute_force_threshold: 0.001,
};
let res = navix_search(&idx, &[5.0, 0.0, 0.0], &opts, DistanceMetric::L2).unwrap();
assert!(res.is_empty());
}
#[test]
fn wrong_dimension_query_is_a_typed_error() {
let idx = build_index(20);
let opts = NavixSearchOptions {
k: 3,
allowed: (0..20u32).collect(),
..NavixSearchOptions::default()
};
let result = navix_search(&idx, &[1.0, 0.0], &opts, DistanceMetric::L2);
assert!(
matches!(
result,
Err(crate::error::VectorError::DimensionMismatch {
expected: 3,
got: 2
})
),
"{result:?}"
);
}
const SEED: u64 = 0x9E37_79B9_7F4A_7C15;
const BRUTE_FORCE: f64 = 2.0;
const GRAPH: f64 = 0.0;
fn allowed_sets() -> Vec<RoaringBitmap> {
[2usize, 3, 50]
.into_iter()
.map(|step| (0..300u32).step_by(step).collect())
.collect()
}
fn options(allowed: RoaringBitmap, brute_force_threshold: f64) -> NavixSearchOptions {
NavixSearchOptions {
k: 5,
ef_search: 64,
allowed,
brute_force_threshold,
}
}
fn pairs(results: &[SearchResult]) -> Vec<(u32, f32)> {
results.iter().map(|r| (r.id, r.distance)).collect()
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn graph_only_restore_with_backing_matches_the_source_index() {
use crate::test_support::{graph_only_restore_with_backing, seeded_index, seeded_vectors};
use nodedb_types::vector_dtype::VectorStorageDtype;
let vectors = seeded_vectors(300, 8, SEED);
let src = seeded_index(&vectors, DistanceMetric::L2, VectorStorageDtype::F32);
let restored = graph_only_restore_with_backing(&src, &vectors);
let mut graph_hits = 0;
for metric in [DistanceMetric::L2, DistanceMetric::Cosine] {
for (threshold, brute) in [(BRUTE_FORCE, true), (GRAPH, false)] {
for allowed in allowed_sets() {
let opts = options(allowed, threshold);
let expected = navix_search(&src, &vectors[7], &opts, metric).unwrap();
let got = navix_search(&restored, &vectors[7], &opts, metric).unwrap();
assert!(!brute || expected.len() == 5, "{metric:?}: {expected:?}");
assert_eq!(pairs(&got), pairs(&expected), "{metric:?} brute={brute}");
if !brute {
graph_hits += got.len();
}
}
}
}
assert!(graph_hits > 0, "graph traversal must return hits");
}
#[test]
fn graph_only_restore_without_backing_returns_no_hits() {
use crate::test_support::{graph_only_restore, seeded_index, seeded_vectors};
use nodedb_types::vector_dtype::VectorStorageDtype;
let vectors = seeded_vectors(300, 8, SEED);
let src = seeded_index(&vectors, DistanceMetric::L2, VectorStorageDtype::F32);
let idx = graph_only_restore(&src);
for threshold in [BRUTE_FORCE, GRAPH] {
for allowed in allowed_sets() {
let opts = options(allowed, threshold);
let res = navix_search(&idx, &vectors[7], &opts, DistanceMetric::L2).unwrap();
assert!(res.is_empty(), "threshold={threshold}: got {res:?}");
}
}
}
#[test]
fn caller_metric_overrides_the_build_metric_for_every_dtype() {
use crate::test_support::{seeded_index, seeded_vectors};
use nodedb_types::vector_dtype::VectorStorageDtype;
let vectors = seeded_vectors(300, 8, SEED);
let allowed: RoaringBitmap = (0..300u32).step_by(3).collect();
for dtype in [
VectorStorageDtype::F32,
VectorStorageDtype::F16,
VectorStorageDtype::BF16,
] {
let idx = seeded_index(&vectors, DistanceMetric::L2, dtype);
let encoded = crate::dtype::cast_from_f32(&vectors[7], dtype);
let query = crate::dtype::cast_to_f32(&encoded, dtype, 8).unwrap();
let cosine_to = |id: u32| {
let node = idx.materialize_vector(id).unwrap();
distance(&query, &node, DistanceMetric::Cosine)
};
let mut reference: Vec<(u32, f32)> =
allowed.iter().map(|id| (id, cosine_to(id))).collect();
reference.sort_by(|a, b| a.1.total_cmp(&b.1));
for (threshold, brute) in [(BRUTE_FORCE, true), (GRAPH, false)] {
let opts = options(allowed.clone(), threshold);
let res = navix_search(&idx, &vectors[7], &opts, DistanceMetric::Cosine).unwrap();
assert_eq!(res.len(), 5, "dtype={dtype:?} brute={brute}");
for hit in &res {
let want = cosine_to(hit.id);
assert!(
(hit.distance - want).abs() < 1e-4,
"dtype={dtype:?} brute={brute}: {hit:?} vs cosine {want}"
);
}
if brute {
let ids: Vec<u32> = res.iter().map(|r| r.id).collect();
let want: Vec<u32> = reference.iter().take(5).map(|r| r.0).collect();
assert_eq!(ids, want, "dtype={dtype:?}");
}
}
}
}
}