Skip to content

Commit 3228ab3

Browse files
authored
Introduce --acorn and --acorn-max-selectivity for filtered HNSW search (#182)
* Introduce --acorn and --acorn-max-selectivity for filtered HNSW search
1 parent 7d4564e commit 3228ab3

3 files changed

Lines changed: 159 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,18 @@ prefetches from a dataset without it is refused, rather than pairing a query's
317317
sub-vectors with an unrelated vector — `prefetch.size` is then ignored, like the
318318
request's own `size`. A `file` source cannot drive a prefetch.
319319

320+
#### ACORN
321+
322+
`--acorn` asks Qdrant to use ACORN for filtered HNSW search, and
323+
`--acorn-max-selectivity` sets the selectivity above which it declines to (0.0 never,
324+
1.0 always; Qdrant's own default is 0.4). Selectivity is the estimated share of points a
325+
filter keeps, so a *lower* number means a *more* selective filter.
326+
327+
ACORN is a filtered-search path: with no filter Qdrant never takes it, and a run would
328+
report the ordinary path under ACORN's name. `--acorn` is therefore refused unless a
329+
request in the config carries `filters`, and refused with `--search-exact`, which skips the
330+
graph entirely. `--acorn-max-selectivity` is refused without `--acorn`.
331+
320332
The prefetch stage gets the run's search params (`--search-hnsw-ef`,
321333
`--search-exact`, quantization flags). `--prefetch` and `--search-quality` cannot
322334
be combined with a config that sets `prefetch`, and `prefetch.limit` must be at
@@ -745,6 +757,10 @@ Options:
745757
Delay between requests in milliseconds
746758
--indexed-only <INDEXED_ONLY>
747759
Skip un-indexed segments during search [possible values: true, false]
760+
--acorn
761+
Let Qdrant use ACORN for filtered HNSW search. Only meaningful with filters: Qdrant takes this path when a filter is selective enough
762+
--acorn-max-selectivity <ACORN_MAX_SELECTIVITY>
763+
Selectivity above which ACORN is not used (0.0 never, 1.0 always; Qdrant's own default is 0.4). Needs --acorn
748764
--sparse-vectors <SPARSITY>
749765
Whether to use sparse vectors and with how much sparsity
750766
--sparse-vectors-per-point <SPARSE_VECTORS_PER_POINT>

‎src/args/mod.rs‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,16 @@ pub struct Args {
427427
#[clap(long, global = true)]
428428
pub indexed_only: Option<bool>,
429429

430+
/// Let Qdrant use ACORN for filtered HNSW search. Only meaningful with filters:
431+
/// Qdrant takes this path when a filter is selective enough.
432+
#[clap(long, global = true)]
433+
pub acorn: bool,
434+
435+
/// Selectivity above which ACORN is not used (0.0 never, 1.0 always;
436+
/// Qdrant's own default is 0.4). Needs --acorn.
437+
#[clap(long, global = true)]
438+
pub acorn_max_selectivity: Option<f64>,
439+
430440
/// Whether to use sparse vectors and with how much sparsity
431441
#[clap(long, value_name = "SPARSITY")]
432442
pub sparse_vectors: Option<f64>,

‎src/search/from_config.rs‎

Lines changed: 133 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,9 @@ use indicatif::ProgressBar;
77
use qdrant_client::Qdrant;
88
use qdrant_client::qdrant::shard_key::Key;
99
use qdrant_client::qdrant::{
10-
IdfParamsBuilder, PrefetchQueryBuilder, QuantizationSearchParamsBuilder, Query,
11-
QueryBatchPointsBuilder, QueryPointsBuilder, SearchParams, SearchParamsBuilder, VectorInput,
10+
AcornSearchParamsBuilder, IdfParamsBuilder, PrefetchQueryBuilder,
11+
QuantizationSearchParamsBuilder, Query, QueryBatchPointsBuilder, QueryPointsBuilder,
12+
SearchParams, SearchParamsBuilder, VectorInput,
1213
};
1314

1415
use super::{SearchStats, compare_batch_search_results, recall_against_ground_truth};
@@ -57,6 +58,28 @@ impl ConfigSearchProcessor {
5758
);
5859
}
5960
}
61+
// ACORN is a filtered-search path: without filters Qdrant never takes it, so a run
62+
// with the flag on would report the ordinary path under ACORN's name.
63+
if args.acorn && config.requests.iter().all(|r| r.filters().is_empty()) {
64+
anyhow::bail!(
65+
"--acorn needs a config whose requests carry `filters`; ACORN is only used \
66+
for filtered search"
67+
);
68+
}
69+
// An exact search never touches the graph, so ACORN could not apply.
70+
if args.acorn && args.search_exact && !args.search_quality {
71+
anyhow::bail!(
72+
"--acorn does nothing with --search-exact: an exact search skips the graph"
73+
);
74+
}
75+
if args.acorn_max_selectivity.is_some() && !args.acorn {
76+
anyhow::bail!("--acorn-max-selectivity needs --acorn");
77+
}
78+
if let Some(max) = args.acorn_max_selectivity
79+
&& !(0.0..=1.0).contains(&max)
80+
{
81+
anyhow::bail!("--acorn-max-selectivity must be between 0.0 and 1.0, got {max}");
82+
}
6083
Ok(ConfigSearchProcessor {
6184
args: args.clone(),
6285
stopped,
@@ -71,6 +94,35 @@ impl ConfigSearchProcessor {
7194
})
7295
}
7396

97+
/// The run's search parameters, shared by the request and its prefetch stage.
98+
fn search_params(&self) -> SearchParamsBuilder {
99+
let mut quantization_params_builder = QuantizationSearchParamsBuilder::default()
100+
.rescore(self.args.quantization_rescore.unwrap_or_default());
101+
102+
if let Some(oversampling) = self.args.quantization_oversampling {
103+
quantization_params_builder = quantization_params_builder.oversampling(oversampling);
104+
}
105+
106+
let mut search_params = SearchParamsBuilder::default()
107+
.exact(self.args.search_exact && !self.args.search_quality)
108+
.quantization(quantization_params_builder)
109+
.indexed_only(self.args.indexed_only.unwrap_or_default());
110+
111+
if let Some(hnsw_ef) = self.args.search_hnsw_ef {
112+
search_params = search_params.hnsw_ef(hnsw_ef as u64);
113+
}
114+
115+
if self.args.acorn {
116+
let mut acorn = AcornSearchParamsBuilder::new(true);
117+
if let Some(max) = self.args.acorn_max_selectivity {
118+
acorn = acorn.max_selectivity(max);
119+
}
120+
search_params = search_params.acorn(acorn.build());
121+
}
122+
123+
search_params
124+
}
125+
74126
fn create_request_builder(
75127
&self,
76128
query_filter: Option<qdrant_client::qdrant::Filter>,
@@ -162,21 +214,7 @@ impl ConfigSearchProcessor {
162214

163215
let template_idx = self.generator.random_template_idx(&mut rng);
164216

165-
let mut quantization_params_builder = QuantizationSearchParamsBuilder::default()
166-
.rescore(self.args.quantization_rescore.unwrap_or_default());
167-
168-
if let Some(oversampling) = self.args.quantization_oversampling {
169-
quantization_params_builder = quantization_params_builder.oversampling(oversampling);
170-
}
171-
172-
let mut search_params = SearchParamsBuilder::default()
173-
.exact(self.args.search_exact && !self.args.search_quality)
174-
.quantization(quantization_params_builder)
175-
.indexed_only(self.args.indexed_only.unwrap_or_default());
176-
177-
if let Some(hnsw_ef) = self.args.search_hnsw_ef {
178-
search_params = search_params.hnsw_ef(hnsw_ef as u64);
179-
}
217+
let search_params = self.search_params();
180218

181219
// Materialize the whole batch up front so each query keeps its own
182220
// filter and (for dataset query sources) its ground-truth ids.
@@ -391,6 +429,19 @@ mod tests {
391429

392430
const YAML: &str = "collection:\n name: x\nrequests:\n - kind: dense\n using: colbert\n size: 8\n multivector: { count: 4 }\n prefetch: { using: dense, size: 6, limit: 50 }\n";
393431

432+
const FILTERED_YAML: &str = "collection:\n name: x\nrequests:\n - kind: dense\n size: 8\n filters:\n - name: color\n type: keyword\n source: { cardinality: 5 }\n";
433+
434+
fn filtered_processor(extra: &[&str]) -> anyhow::Result<ConfigSearchProcessor> {
435+
let mut argv = vec!["bfb"];
436+
argv.extend_from_slice(extra);
437+
ConfigSearchProcessor::new(
438+
Args::parse_from(argv),
439+
&parse(FILTERED_YAML, "test").unwrap(),
440+
Arc::new(AtomicBool::new(false)),
441+
vec![],
442+
)
443+
}
444+
394445
fn processor(extra: &[&str]) -> anyhow::Result<ConfigSearchProcessor> {
395446
let mut argv = vec!["bfb", "--search-hnsw-ef", "64"];
396447
argv.extend_from_slice(extra);
@@ -403,6 +454,71 @@ mod tests {
403454
)
404455
}
405456

457+
#[test]
458+
fn acorn_flags_reach_the_search_params() {
459+
let plain = filtered_processor(&[]).unwrap().search_params().build();
460+
assert!(plain.acorn.is_none());
461+
462+
let on = filtered_processor(&["--acorn"])
463+
.unwrap()
464+
.search_params()
465+
.build();
466+
let acorn = on.acorn.expect("acorn params");
467+
assert_eq!(acorn.enable, Some(true));
468+
assert_eq!(acorn.max_selectivity, None);
469+
470+
let tuned = filtered_processor(&["--acorn", "--acorn-max-selectivity", "0.25"])
471+
.unwrap()
472+
.search_params()
473+
.build();
474+
assert_eq!(tuned.acorn.unwrap().max_selectivity, Some(0.25));
475+
}
476+
477+
fn refusal(result: anyhow::Result<ConfigSearchProcessor>, what: &str) -> String {
478+
match result {
479+
Err(err) => err.to_string(),
480+
Ok(_) => panic!("accepted {what}"),
481+
}
482+
}
483+
484+
/// Qdrant only takes the ACORN path under a filter, so an unfiltered run with the flag
485+
/// on would report the ordinary path under ACORN's name.
486+
#[test]
487+
fn rejects_acorn_without_filters() {
488+
let err = refusal(processor(&["--acorn"]), "acorn without filters");
489+
assert!(
490+
err.contains("needs a config whose requests carry `filters`"),
491+
"{err}"
492+
);
493+
}
494+
495+
#[test]
496+
fn rejects_acorn_with_exact_search() {
497+
let err = refusal(
498+
filtered_processor(&["--acorn", "--search-exact"]),
499+
"acorn with an exact search",
500+
);
501+
assert!(err.contains("skips the graph"), "{err}");
502+
}
503+
504+
#[test]
505+
fn rejects_acorn_selectivity_misuse() {
506+
let no_flag = refusal(
507+
filtered_processor(&["--acorn-max-selectivity", "0.3"]),
508+
"a selectivity without --acorn",
509+
);
510+
assert!(no_flag.contains("needs --acorn"), "{no_flag}");
511+
512+
let out_of_range = refusal(
513+
filtered_processor(&["--acorn", "--acorn-max-selectivity", "1.5"]),
514+
"a selectivity above 1.0",
515+
);
516+
assert!(
517+
out_of_range.contains("between 0.0 and 1.0"),
518+
"{out_of_range}"
519+
);
520+
}
521+
406522
#[test]
407523
fn builds_prefetch_then_multivector_rescore() {
408524
let p = processor(&[]).unwrap();

0 commit comments

Comments
 (0)