@@ -7,8 +7,9 @@ use indicatif::ProgressBar;
77use qdrant_client:: Qdrant ;
88use qdrant_client:: qdrant:: shard_key:: Key ;
99use 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
1415use 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\n requests:\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\n requests:\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