use crate::core::entangled::EntangledHVec;
const BRUTE_FORCE_THRESHOLD: usize = 1000;
const HOPFIELD_MAX_PATTERNS: usize = 10_000;
const SPARSE_INDEX_THRESHOLD: usize = 4;
const EF_SEARCH_MIN: usize = 128;
const EF_SEARCH_MAX: usize = 512;
const N_PROBE_MIN: usize = 4;
const N_PROBE_MAX: usize = 64;
const INVERTED_SPARSITY_DENOM: usize = 32;
#[derive(Debug, PartialEq, Clone, Copy)]
#[expect(clippy::upper_case_acronyms, reason = "the index names")]
pub(crate) enum IndexRoute {
NSG,
Inverted,
IVF,
Hopfield,
BruteForce,
}
pub(crate) struct QueryPlan {
pub(crate) route: IndexRoute,
pub(crate) ef_search: usize,
pub(crate) n_probe: usize,
pub(crate) rationale: &'static str,
}
impl IndexRoute {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::NSG => "nsg",
Self::Inverted => "inverted",
Self::IVF => "ivf",
Self::Hopfield => "hopfield",
Self::BruteForce => "brute_force",
}
}
}
pub(crate) struct QueryPlanner {
pub(crate) nsg_available: bool,
pub(crate) inverted_available: bool,
pub(crate) ivf_available: bool,
pub(crate) vector_count: usize,
pub(crate) dimensions: usize,
}
impl QueryPlanner {
pub(crate) fn new(
nsg_available: bool,
inverted_available: bool,
ivf_available: bool,
vector_count: usize,
dimensions: usize,
) -> Self {
Self {
nsg_available,
inverted_available,
ivf_available,
vector_count,
dimensions,
}
}
pub(crate) fn plan(&self, query_vec: &EntangledHVec, k: u32) -> QueryPlan {
let n = self.vector_count;
let s = query_vec.indices.len();
let k_idx = k as usize;
let ef_search = (k_idx * 2).clamp(EF_SEARCH_MIN, EF_SEARCH_MAX);
let n_probe = (k_idx / 8).clamp(N_PROBE_MIN, N_PROBE_MAX);
if n < BRUTE_FORCE_THRESHOLD {
return QueryPlan {
route: IndexRoute::BruteForce,
ef_search,
n_probe,
rationale: "small collections are faster with an exact scan",
};
}
if self.inverted_available
&& (s <= SPARSE_INDEX_THRESHOLD || s < self.dimensions / INVERTED_SPARSITY_DENOM)
{
return QueryPlan {
route: IndexRoute::Inverted,
ef_search,
n_probe,
rationale: "exact inverted scoring is exact and fast for sparse queries",
};
}
if self.nsg_available {
return QueryPlan {
route: IndexRoute::NSG,
ef_search,
n_probe,
rationale: "a dense query with a trained NSG index uses graph retrieval",
};
}
if self.ivf_available {
return QueryPlan {
route: IndexRoute::IVF,
ef_search,
n_probe,
rationale: "a dense query with a trained IVF index uses IVF retrieval",
};
}
if n <= HOPFIELD_MAX_PATTERNS {
return QueryPlan {
route: IndexRoute::Hopfield,
ef_search,
n_probe,
rationale: "mid-sized unindexed collections use associative retrieval",
};
}
QueryPlan {
route: IndexRoute::BruteForce,
ef_search,
n_probe,
rationale: "no trained index is available, so HMS falls back to exact search",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plan_routes_at_each_threshold() {
use IndexRoute::*;
const ALL: [bool; 3] = [true; 3];
const NONE: [bool; 3] = [false; 3];
let (exact, hopfield) = (BRUTE_FORCE_THRESHOLD, HOPFIELD_MAX_PATTERNS);
let sparse = SPARSE_INDEX_THRESHOLD;
let cases = [
("exact - 1", ALL, exact - 1, 16384, 64, BruteForce),
("exact", ALL, exact, 16384, 64, Inverted),
("exact + 1", ALL, exact + 1, 16384, 64, Inverted),
("1e5 at D/256", ALL, 100_000, 16384, 64, Inverted),
("1e6 at D/256", ALL, 1_000_000, 16384, 64, Inverted),
("sparse - 1", ALL, 5000, 64, sparse - 1, Inverted),
("sparse", ALL, 5000, 64, sparse, Inverted),
("sparse + 1", ALL, 5000, 64, sparse + 1, NSG),
("D/32 - 1", ALL, 5000, 1000, 1000 / 32 - 1, Inverted),
("D/32", ALL, 5000, 1000, 1000 / 32, NSG),
("no NSG", [false, true, true], 5000, 1000, 40, IVF),
("no graph", [false, true, false], 5000, 1000, 40, Hopfield),
("hopfield - 1", NONE, hopfield - 1, 1000, 10, Hopfield),
("hopfield", NONE, hopfield, 1000, 10, Hopfield),
("hopfield + 1", NONE, hopfield + 1, 1000, 10, BruteForce),
];
for (name, [nsg, inverted, ivf], vectors, dimensions, active, route) in cases {
let query = EntangledHVec::from_indices((0..active as u32).collect(), dimensions);
let planner = QueryPlanner::new(nsg, inverted, ivf, vectors, dimensions);
assert_eq!(planner.plan(&query, 10).route, route, "{name}");
}
}
#[test]
fn graph_beam_covers_k_up_to_its_cap() {
let planner = QueryPlanner::new(true, true, true, 5000, 1000);
let query = EntangledHVec::from_indices((0..40).collect(), 1000);
let cap = EF_SEARCH_MAX as u32;
for k in [1, 63, 64, 65, 255, 256, 257, cap, cap + 1] {
let plan = planner.plan(&query, k);
assert_eq!(plan.route, IndexRoute::NSG, "k = {k}");
assert!(
(k.min(cap) as usize..=EF_SEARCH_MAX).contains(&plan.ef_search),
"k = {k}: ef_search = {}",
plan.ef_search
);
}
}
}