Skip to content

Commit 0abb703

Browse files
magdalendobsonMagdalen Manohar
andauthored
Clean up range search (#1253)
While working on #1228, I noticed that there were some issues with our current implementation of range search and its testing. The main issue with testing is that there were no tests ensuring the `max_results` parameter was respected. I have added two tests that ensure this now. The main code had several issues with how `max_results` was handled: 1. The `max_results` parameter was allowed to be less than the initial L_search. This is a conceptual issue because the user expects `max_results` to stop the search from continuing for too long, and the compute used in the initial search will always be controlled by `initial_search_l`. 2. A `max_results` check was not enforced before deciding to continue to the second round search. This meant that if the max results was reached via the initial search, it might not be respected. 3. The second round search was not terminated when `max_results` was reached, meaning it would continue to perform unnecessary work. This PR fixes these issues by adding additional checks of `max_results` at the correct points in the code. --------- Co-authored-by: Magdalen Manohar <mmanohar@microsoft.com>
1 parent 3c8728c commit 0abb703

4 files changed

Lines changed: 204 additions & 3 deletions

File tree

diskann/src/graph/search/range_search.rs

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ pub enum RangeSearchError {
3636
RangeSearchSlackValueError,
3737
#[error("inner_radius must be less than or equal to radius")]
3838
InnerRadiusValueError,
39+
#[error("max_returned must be greater than or equal to starting_l")]
40+
MaxReturnedLessThanInitialL,
3941
}
4042

4143
impl From<RangeSearchError> for ANNError {
@@ -91,6 +93,11 @@ impl Range {
9193
if starting_l == 0 {
9294
return Err(RangeSearchError::LZero);
9395
}
96+
if let Some(max) = max_returned
97+
&& max < starting_l
98+
{
99+
return Err(RangeSearchError::MaxReturnedLessThanInitialL);
100+
}
94101
if !(0.0..=1.0).contains(&initial_slack) {
95102
return Err(RangeSearchError::StartingListSlackValueError);
96103
}
@@ -197,7 +204,10 @@ where
197204

198205
let mut in_range = Vec::with_capacity(self.starting_l().into_usize());
199206

200-
for neighbor in scratch.best.iter().take(self.starting_l().into_usize()) {
207+
let starting_l = self.starting_l().into_usize();
208+
let max_returned = self.max_returned().unwrap_or(usize::MAX);
209+
210+
for neighbor in scratch.best.iter().take(starting_l) {
201211
if neighbor.distance <= self.radius() {
202212
in_range.push(neighbor);
203213
}
@@ -211,7 +221,8 @@ where
211221
scratch.in_range = in_range;
212222

213223
let stats = if scratch.in_range.len()
214-
>= ((self.starting_l() as f32) * self.initial_slack()) as usize
224+
>= ((starting_l as f32) * self.initial_slack()) as usize
225+
&& scratch.in_range.len() < max_returned
215226
{
216227
// Move to range search
217228
let range_stats = range_search_internal(
@@ -336,7 +347,7 @@ where
336347

337348
let max_returned = search_params.max_returned().unwrap_or(usize::MAX);
338349

339-
while !scratch.range_frontier.is_empty() {
350+
while !scratch.range_frontier.is_empty() && scratch.in_range.len() < max_returned {
340351
scratch.beam_nodes.clear();
341352

342353
// In this loop we are going to find the beam_width number of remaining nodes within the radius
@@ -401,6 +412,9 @@ mod tests {
401412

402413
// Invalid inner radius > radius
403414
assert!(Range::with_options(None, 100, None, 0.5, Some(1.0), 1.0, 1.0).is_err());
415+
416+
// Invalid max_results < initial_l_search
417+
assert!(Range::with_options(Some(50), 100, None, 0.5, None, 1.0, 1.0).is_err());
404418
}
405419

406420
#[test]

diskann/src/graph/test/cases/range_search.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -284,3 +284,120 @@ fn empty_results() {
284284
"empty results shouldn't trigger a second round"
285285
);
286286
}
287+
288+
#[test]
289+
fn max_results_respected_means_no_second_round() {
290+
let rt = current_thread_runtime();
291+
let mut test_root = root();
292+
let mut path = test_root.path();
293+
let name = path.push("max_results_respected_means_no_second_round");
294+
295+
let grid_size = 5;
296+
let (index, query) = setup_grid_index_and_default_query(grid_size, Grid::Three);
297+
let radius = 1.0e9; // every point will be in range with this radius
298+
let starting_l = 4; // small set to trigger multiple rounds
299+
let max_results = 4; // max_returned = starting_l, so second round should not be triggered
300+
301+
let range_search =
302+
Range::with_options(Some(max_results), starting_l, None, radius, None, 1.0, 1.0).unwrap();
303+
let mut results: Vec<Neighbor<u32>> = Vec::new();
304+
305+
let stats = rt
306+
.block_on(index.search(
307+
range_search,
308+
&test_provider::Strategy::new(),
309+
&test_provider::Context::new(),
310+
query.as_slice(),
311+
&mut results,
312+
))
313+
.unwrap();
314+
315+
let baseline = RangeSearchBaseline {
316+
grid_size,
317+
query: query.clone(),
318+
radius,
319+
inner_radius: None,
320+
starting_l,
321+
results: results.iter().map(|n| (n.id, n.distance)).collect(),
322+
comparisons: stats.cmps as usize,
323+
hops: stats.hops as usize,
324+
result_count: results.len(),
325+
range_search_second_round: stats.range_search_second_round,
326+
};
327+
328+
let expected = get_or_save_test_results(&name, &baseline);
329+
assert_eq_verbose!(expected, baseline);
330+
331+
assert!(
332+
results.len() <= max_results,
333+
"result count {} exceeds max_results {}",
334+
results.len(),
335+
max_results
336+
);
337+
338+
assert!(
339+
!stats.range_search_second_round,
340+
"If max_results is respected, a second round should not be triggered"
341+
);
342+
assert_range_invariants(&results, radius, None);
343+
assert_no_duplicates(&results);
344+
}
345+
346+
#[test]
347+
fn max_results_respected_and_second_round_triggered() {
348+
let rt = current_thread_runtime();
349+
let mut test_root = root();
350+
let mut path = test_root.path();
351+
let name = path.push("max_results_respected_and_second_round_triggered");
352+
353+
let grid_size = 5;
354+
let (index, query) = setup_grid_index_and_default_query(grid_size, Grid::Three);
355+
let radius = 1.0e9; // every point will be in range with this radius
356+
let starting_l = 4; // small set to trigger multiple rounds
357+
let max_results = 5; // max_returned greater than starting_l, so second round should be triggered
358+
359+
let range_search =
360+
Range::with_options(Some(max_results), starting_l, None, radius, None, 1.0, 1.0).unwrap();
361+
let mut results: Vec<Neighbor<u32>> = Vec::new();
362+
363+
let stats = rt
364+
.block_on(index.search(
365+
range_search,
366+
&test_provider::Strategy::new(),
367+
&test_provider::Context::new(),
368+
query.as_slice(),
369+
&mut results,
370+
))
371+
.unwrap();
372+
373+
let baseline = RangeSearchBaseline {
374+
grid_size,
375+
query: query.clone(),
376+
radius,
377+
inner_radius: None,
378+
starting_l,
379+
results: results.iter().map(|n| (n.id, n.distance)).collect(),
380+
comparisons: stats.cmps as usize,
381+
hops: stats.hops as usize,
382+
result_count: results.len(),
383+
range_search_second_round: stats.range_search_second_round,
384+
};
385+
386+
let expected = get_or_save_test_results(&name, &baseline);
387+
assert_eq_verbose!(expected, baseline);
388+
389+
assert!(
390+
results.len() <= max_results,
391+
"result count {} exceeds max_results {}",
392+
results.len(),
393+
max_results
394+
);
395+
396+
assert!(
397+
stats.range_search_second_round,
398+
"If max_results is respected, a second round should be triggered"
399+
);
400+
401+
assert_range_invariants(&results, radius, None);
402+
assert_no_duplicates(&results);
403+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"file": "diskann/src/graph/test/cases/range_search.rs",
3+
"test": "graph/test/cases/range_search/max_results_respected_and_second_round_triggered",
4+
"payload": {
5+
"comparisons": 11,
6+
"grid_size": 5,
7+
"hops": 12,
8+
"inner_radius": null,
9+
"query": [
10+
5.0,
11+
5.0,
12+
5.0
13+
],
14+
"radius": 1000000000.0,
15+
"range_search_second_round": true,
16+
"result_count": 4,
17+
"results": [
18+
[
19+
124,
20+
3.0
21+
],
22+
[
23+
123,
24+
6.0
25+
],
26+
[
27+
119,
28+
6.0
29+
],
30+
[
31+
99,
32+
6.0
33+
]
34+
],
35+
"starting_l": 4
36+
}
37+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
{
2+
"file": "diskann/src/graph/test/cases/range_search.rs",
3+
"test": "graph/test/cases/range_search/max_results_respected_means_no_second_round",
4+
"payload": {
5+
"comparisons": 11,
6+
"grid_size": 5,
7+
"hops": 5,
8+
"inner_radius": null,
9+
"query": [
10+
5.0,
11+
5.0,
12+
5.0
13+
],
14+
"radius": 1000000000.0,
15+
"range_search_second_round": false,
16+
"result_count": 3,
17+
"results": [
18+
[
19+
124,
20+
3.0
21+
],
22+
[
23+
123,
24+
6.0
25+
],
26+
[
27+
119,
28+
6.0
29+
]
30+
],
31+
"starting_l": 4
32+
}
33+
}

0 commit comments

Comments
 (0)