use roaring::RoaringBitmap;
use crate::hnsw::{HnswIndex, SearchResult};
pub struct FilterThresholds {
pub high_selectivity: f64,
pub extreme_selectivity: f64,
}
impl Default for FilterThresholds {
fn default() -> Self {
Self {
high_selectivity: 0.50,
extreme_selectivity: 0.95,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[non_exhaustive]
pub enum FilterStrategy {
PreFilter,
PostFilter { over_fetch_factor: usize },
BruteForceMatching,
}
pub fn estimate_selectivity(bitmap: &RoaringBitmap, total_vectors: usize) -> f64 {
if total_vectors == 0 {
return 0.0;
}
let matching = bitmap.len() as usize;
1.0 - (matching as f64 / total_vectors as f64)
}
pub fn select_strategy(selectivity: f64, thresholds: &FilterThresholds) -> FilterStrategy {
if selectivity >= thresholds.extreme_selectivity {
FilterStrategy::BruteForceMatching
} else if selectivity >= thresholds.high_selectivity {
FilterStrategy::PostFilter {
over_fetch_factor: 10,
}
} else {
FilterStrategy::PreFilter
}
}
pub fn adaptive_search(
index: &HnswIndex,
query: &[f32],
top_k: usize,
ef: usize,
bitmap: &RoaringBitmap,
thresholds: &FilterThresholds,
) -> Result<Vec<SearchResult>, crate::error::VectorError> {
crate::error::check_dim(index.dim(), query.len())?;
let total = index.len();
let selectivity = estimate_selectivity(bitmap, total);
let strategy = select_strategy(selectivity, thresholds);
match strategy {
FilterStrategy::PreFilter => index.search_filtered(query, top_k, ef, bitmap),
FilterStrategy::PostFilter { over_fetch_factor } => {
let fetch_k = top_k * over_fetch_factor;
let results = index.search(query, fetch_k, ef.max(fetch_k))?;
let mut filtered: Vec<SearchResult> = results
.into_iter()
.filter(|r| bitmap.contains(r.id))
.collect();
filtered.truncate(top_k);
Ok(filtered)
}
FilterStrategy::BruteForceMatching => {
let metric = index.params().metric;
let probe = index.probe(query);
let mut results: Vec<SearchResult> = bitmap
.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() > top_k {
results.select_nth_unstable_by(top_k, |a, b| {
a.distance
.partial_cmp(&b.distance)
.unwrap_or(std::cmp::Ordering::Equal)
});
results.truncate(top_k);
}
results.sort_by(|a, b| {
a.distance
.partial_cmp(&b.distance)
.unwrap_or(std::cmp::Ordering::Equal)
});
Ok(results)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::distance::DistanceMetric;
use crate::hnsw::{HnswIndex, HnswParams};
fn build_test_index() -> 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..1000 {
idx.insert(vec![i as f32, 0.0, 0.0]).unwrap();
}
idx
}
#[test]
fn low_selectivity_uses_prefilter() {
let thresholds = FilterThresholds::default();
let strategy = select_strategy(0.2, &thresholds);
assert_eq!(strategy, FilterStrategy::PreFilter);
}
#[test]
fn high_selectivity_uses_postfilter() {
let thresholds = FilterThresholds::default();
let strategy = select_strategy(0.8, &thresholds);
assert!(matches!(strategy, FilterStrategy::PostFilter { .. }));
}
#[test]
fn extreme_selectivity_uses_bruteforce() {
let thresholds = FilterThresholds::default();
let strategy = select_strategy(0.99, &thresholds);
assert_eq!(strategy, FilterStrategy::BruteForceMatching);
}
#[test]
fn adaptive_search_extreme_filter() {
let idx = build_test_index();
let thresholds = FilterThresholds::default();
let mut bitmap = RoaringBitmap::new();
for i in 500..510 {
bitmap.insert(i);
}
let results =
adaptive_search(&idx, &[505.0, 0.0, 0.0], 3, 64, &bitmap, &thresholds).unwrap();
assert_eq!(results.len(), 3);
for r in &results {
assert!(bitmap.contains(r.id), "got filtered-out id {}", r.id);
}
assert_eq!(results[0].id, 505);
}
#[test]
fn adaptive_search_low_filter() {
let idx = build_test_index();
let thresholds = FilterThresholds::default();
let mut bitmap = RoaringBitmap::new();
for i in 0..800 {
bitmap.insert(i);
}
let results =
adaptive_search(&idx, &[100.0, 0.0, 0.0], 5, 64, &bitmap, &thresholds).unwrap();
assert_eq!(results.len(), 5);
for r in &results {
assert!(bitmap.contains(r.id));
}
}
#[test]
fn selectivity_estimation() {
let mut bitmap = RoaringBitmap::new();
for i in 0..100 {
bitmap.insert(i);
}
let sel = estimate_selectivity(&bitmap, 1000);
assert!((sel - 0.9).abs() < 0.01);
}
#[test]
fn wrong_dimension_query_is_a_typed_error() {
let idx = build_test_index();
let thresholds = FilterThresholds::default();
for n in [5u32, 500, 1000] {
let bitmap: RoaringBitmap = (0..n).collect();
let result = adaptive_search(&idx, &[1.0, 0.0], 3, 64, &bitmap, &thresholds);
assert!(
matches!(
result,
Err(crate::error::VectorError::DimensionMismatch {
expected: 3,
got: 2
})
),
"{result:?}"
);
}
}
const SEED: u64 = 0x9E37_79B9_7F4A_7C15;
const FORCED: [FilterThresholds; 3] = [
FilterThresholds {
high_selectivity: 2.0,
extreme_selectivity: 2.0,
},
FilterThresholds {
high_selectivity: -1.0,
extreme_selectivity: 2.0,
},
BRUTE_FORCE,
];
const BRUTE_FORCE: FilterThresholds = FilterThresholds {
high_selectivity: -1.0,
extreme_selectivity: -1.0,
};
fn every_tenth(n: u32) -> RoaringBitmap {
(0..n).step_by(10).collect()
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn brute_force_after_graph_only_restore_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 bitmap = every_tenth(300);
let expected = adaptive_search(&src, &vectors[7], 5, 64, &bitmap, &BRUTE_FORCE).unwrap();
let got = adaptive_search(&restored, &vectors[7], 5, 64, &bitmap, &BRUTE_FORCE).unwrap();
assert_eq!(expected.len(), 5);
let pairs = |r: &[SearchResult]| -> Vec<(u32, f32)> {
r.iter().map(|h| (h.id, h.distance)).collect()
};
assert_eq!(pairs(got.as_slice()), pairs(expected.as_slice()));
}
#[test]
fn no_strategy_returns_a_node_without_a_vector() {
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);
let bitmap = every_tenth(300);
for thresholds in &FORCED {
let results = adaptive_search(&idx, &vectors[7], 5, 64, &bitmap, thresholds).unwrap();
assert!(results.is_empty(), "got {results:?}");
}
}
#[test]
fn brute_force_on_narrow_dtypes_matches_decoded_distances() {
use crate::distance::distance;
use crate::test_support::{seeded_index, seeded_vectors};
use nodedb_types::vector_dtype::VectorStorageDtype;
let vectors = seeded_vectors(300, 8, SEED);
let bitmap = every_tenth(300);
for dtype in [VectorStorageDtype::F16, VectorStorageDtype::BF16] {
let idx = seeded_index(&vectors, DistanceMetric::Cosine, dtype);
let encoded_query = crate::dtype::cast_from_f32(&vectors[7], dtype);
let query = crate::dtype::cast_to_f32(&encoded_query, dtype, 8).unwrap();
let mut reference: Vec<(u32, f32)> = bitmap
.iter()
.map(|id| {
let node = idx.materialize_vector(id).unwrap();
(id, distance(&query, &node, DistanceMetric::Cosine))
})
.collect();
reference.sort_by(|a, b| a.1.total_cmp(&b.1));
let got = adaptive_search(&idx, &vectors[7], 5, 64, &bitmap, &BRUTE_FORCE).unwrap();
assert_eq!(got.len(), 5, "dtype={dtype:?}");
for (hit, (id, d)) in got.iter().zip(&reference) {
assert_eq!(hit.id, *id, "dtype={dtype:?}: got {got:?}");
assert!(
(hit.distance - d).abs() < 1e-4,
"dtype={dtype:?}: {hit:?} vs {d}"
);
}
}
}
}