fix(ann): deletions near the query no longer shrink search results

search() collected ef candidates, then filtered out soft-deleted nodes, then
took k. When the records nearest a query had been deleted, every candidate was
a tombstone and the search returned fewer than k results — 39 of 40 queries in
the new test, which deletes each query's 40 nearest neighbours.

search_layer takes an optional skip mask: a skipped node is still pushed onto
the candidate queue (a tombstone is a valid waypoint) but never into the
result heap, so the ef result slots hold live nodes only. Build and insert
pass no mask. Recall and speed without deletions are unchanged.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
osobh
2026-09-19 13:37:29 -07:00
co-authored by Claude Fable 5.1
parent db4a067fe8
commit 41db450c92
2 changed files with 77 additions and 9 deletions
+10
View File
@@ -1,5 +1,15 @@
# Changelog # Changelog
## Unreleased
### Search
- `clawhdf5-ann`: `HnswIndex::search` returned fewer than `k` results — often
none — when the records nearest the query had been deleted: it collected `ef`
candidates, *then* dropped the deleted ones, *then* took `k`. Deleted nodes
are now traversed as waypoints but never occupy a result slot, so a search
returns the `k` nearest live records. Matters for any store that deletes or
supersedes memories without compacting straight away.
## v2.4.0 (2026-09-19) ## v2.4.0 (2026-09-19)
### Upgrade Notes ### Upgrade Notes
+63 -5
View File
@@ -275,6 +275,7 @@ impl HnswIndex {
ep, ep,
ef_construction, ef_construction,
metric, metric,
None,
); );
let scored: Vec<(usize, f32)> = let scored: Vec<(usize, f32)> =
@@ -408,6 +409,7 @@ impl HnswIndex {
ep, ep,
self.ef_construction, self.ef_construction,
self.metric, self.metric,
None,
); );
let scored: Vec<(usize, f32)> = neighbors.iter().map(|c| (c.id, c.distance)).collect(); let scored: Vec<(usize, f32)> = neighbors.iter().map(|c| (c.id, c.distance)).collect();
let selected = select_neighbors(&self.vectors, &scored, max_conn, self.metric); let selected = select_neighbors(&self.vectors, &scored, max_conn, self.metric);
@@ -517,13 +519,21 @@ impl HnswIndex {
ep = greedy_closest(&self.vectors, &self.graph[layer], query, ep, self.metric); ep = greedy_closest(&self.vectors, &self.graph[layer], query, ep, self.metric);
} }
// Search layer 0 with ef candidates. Deleted nodes are still traversed // Search layer 0 for the ef nearest *live* nodes. Deleted nodes are
// (they remain valid graph waypoints) but are filtered from the result. // still traversed (they remain valid graph waypoints) but take no
let candidates = search_layer(&self.vectors, &self.graph[0], query, ep, ef, self.metric); // result slot, so deletions near the query don't shrink the answer.
let candidates = search_layer(
&self.vectors,
&self.graph[0],
query,
ep,
ef,
self.metric,
Some(&self.deleted),
);
candidates candidates
.into_iter() .into_iter()
.filter(|c| !self.deleted[c.id])
.take(k) .take(k)
.map(|c| (c.id, c.distance)) .map(|c| (c.id, c.distance))
.collect() .collect()
@@ -940,6 +950,14 @@ fn greedy_closest(
} }
/// Search a single layer for the ef closest nodes to `query`. /// Search a single layer for the ef closest nodes to `query`.
/// Best-first search of one layer, returning up to `ef` nodes by ascending
/// distance.
///
/// `skip` marks nodes that must not be *returned* (soft-deleted ones). They
/// are still traversed — a tombstone is a perfectly good waypoint — but they
/// never occupy one of the `ef` result slots. Filtering them out afterwards
/// instead meant a query whose neighbourhood had been deleted got back fewer
/// than `k` results, or none, however many live records were nearby.
fn search_layer( fn search_layer(
vectors: &[Vec<f32>], vectors: &[Vec<f32>],
layer: &[Vec<usize>], layer: &[Vec<usize>],
@@ -947,6 +965,7 @@ fn search_layer(
ep: usize, ep: usize,
ef: usize, ef: usize,
metric: DistanceMetric, metric: DistanceMetric,
skip: Option<&[bool]>,
) -> Vec<Candidate> { ) -> Vec<Candidate> {
let ep_dist = compute_distance(query, &vectors[ep], metric); let ep_dist = compute_distance(query, &vectors[ep], metric);
@@ -959,16 +978,18 @@ fn search_layer(
// Max-heap of current results (furthest first) // Max-heap of current results (furthest first)
let mut results = BinaryHeap::new(); let mut results = BinaryHeap::new();
if !skip.is_some_and(|s| s[ep]) {
results.push(FarCandidate { results.push(FarCandidate {
id: ep, id: ep,
distance: ep_dist, distance: ep_dist,
}); });
}
VISITED.with_borrow_mut(|visited| { VISITED.with_borrow_mut(|visited| {
visited.begin(vectors.len()); visited.begin(vectors.len());
visited.insert(ep); visited.insert(ep);
search_layer_visit( search_layer_visit(
vectors, layer, query, ef, metric, visited, candidates, results, vectors, layer, query, ef, metric, skip, visited, candidates, results,
) )
}) })
} }
@@ -1016,6 +1037,7 @@ fn search_layer_visit(
query: &[f32], query: &[f32],
ef: usize, ef: usize,
metric: DistanceMetric, metric: DistanceMetric,
skip: Option<&[bool]>,
visited: &mut Visited, visited: &mut Visited,
mut candidates: BinaryHeap<Candidate>, mut candidates: BinaryHeap<Candidate>,
mut results: BinaryHeap<FarCandidate>, mut results: BinaryHeap<FarCandidate>,
@@ -1039,6 +1061,9 @@ fn search_layer_visit(
id: neighbor, id: neighbor,
distance: d, distance: d,
}); });
if skip.is_some_and(|s| s[neighbor]) {
continue; // explore through it, but never return it
}
results.push(FarCandidate { results.push(FarCandidate {
id: neighbor, id: neighbor,
distance: d, distance: d,
@@ -1384,6 +1409,39 @@ mod tests {
assert!(recall >= 0.95, "incremental recall@10 = {recall}"); assert!(recall >= 0.95, "incremental recall@10 = {recall}");
} }
#[test]
fn deletions_near_the_query_do_not_shrink_or_degrade_results() {
let mut vectors = clustered(2040, 16, 20, 11);
let queries = vectors.split_off(2000);
let mut index = HnswIndex::build_with_metric(&vectors, 8, 40, DistanceMetric::L2);
let mut short = 0;
let mut hits = 0;
for q in &queries {
// Delete this query's 40 nearest neighbours: more than ef, so every
// candidate a plain search collects is a tombstone.
let mut exact: Vec<(usize, f32)> = vectors
.iter()
.enumerate()
.filter(|(i, _)| !index.is_deleted(*i))
.map(|(i, v)| (i, compute_distance(q, v, DistanceMetric::L2)))
.collect();
exact.sort_by(|a, b| a.1.total_cmp(&b.1));
for &(id, _) in &exact[..40] {
index.mark_deleted(id);
}
let want: Vec<usize> = exact[40..50].iter().map(|e| e.0).collect();
let got = index.search(q, 10, 32);
assert!(got.iter().all(|(id, _)| !index.is_deleted(*id)));
short += usize::from(got.len() < 10);
hits += got.iter().filter(|(id, _)| want.contains(id)).count();
}
assert_eq!(short, 0, "searches returned fewer than k live results");
let recall = hits as f64 / (10 * queries.len()) as f64;
assert!(recall >= 0.9, "recall@10 among live records = {recall}");
}
#[test] #[test]
fn graph_bytes_round_trip_gives_identical_searches() { fn graph_bytes_round_trip_gives_identical_searches() {
let mut vectors = clustered(1260, 16, 12, 9); let mut vectors = clustered(1260, 16, 12, 9);