@@ -35,7 +35,7 @@ use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplit;
3535use crate :: table:: source:: { DataSplit , DataSplitBuilder , RowRange } ;
3636use crate :: vindex:: pkvector:: ann:: PkVectorAnnSearcher ;
3737use crate :: vindex:: pkvector:: bucket:: {
38- bucket_search , BucketActiveFile , BucketAnnSegment , ExactFileSearchFuture ,
38+ bucket_search_batch , BucketActiveFile , BucketAnnSegment , ExactFileSearchFuture ,
3939} ;
4040use crate :: vindex:: pkvector:: metric:: { java_float_compare, VectorSearchMetric } ;
4141use crate :: vindex:: pkvector:: result:: PkVectorSearchResult ;
@@ -361,8 +361,14 @@ impl PkVectorOrchestrator {
361361 /// absent from its split's map (or mapped to an empty set) contributes no
362362 /// candidates. `None` applies no residual filtering. The slice must have the
363363 /// same length as `splits`.
364+ ///
365+ /// This is the single-query wrapper over
366+ /// [`search_candidates_batch`](Self::search_candidates_batch): it searches the
367+ /// one query and returns its sole result, so its output is byte-identical to
368+ /// the batch-of-one path.
364369 #[ allow( clippy:: too_many_arguments) ]
365370 #[ allow( clippy:: type_complexity) ]
371+ #[ cfg_attr( not( test) , allow( dead_code) ) ]
366372 pub ( crate ) async fn search_candidates (
367373 & self ,
368374 splits : & [ PkVectorSearchSplit ] ,
@@ -386,15 +392,77 @@ impl PkVectorOrchestrator {
386392 skip_exact_fallback : bool ,
387393 residual_by_split : Option < & [ HashMap < String , RoaringTreemap > ] > ,
388394 ) -> crate :: Result < OrchestratorSearchResult > {
395+ let mut results = self
396+ . search_candidates_batch (
397+ splits,
398+ & [ query] ,
399+ metric,
400+ limit,
401+ indexed_limit,
402+ ann_searcher,
403+ exact_file_search,
404+ search_options,
405+ skip_exact_fallback,
406+ residual_by_split,
407+ )
408+ . await ?;
409+ debug_assert_eq ! ( results. len( ) , 1 ) ;
410+ Ok ( results. remove ( 0 ) )
411+ }
412+
413+ /// Multi-query variant of [`search_candidates`](Self::search_candidates):
414+ /// share ONE per-bucket plan (splits, DV maps, opened readers) across all N
415+ /// queries and return one [`OrchestratorSearchResult`] per query (outer index
416+ /// aligned to `queries`). Per split, the bucket state is built once and
417+ /// `bucket_search_batch` fans all queries over the shared readers into
418+ /// per-query bounded heaps; after every split, each query's indexed/exact
419+ /// lists get their own cross-bucket global Top-K. No query's candidates bleed
420+ /// into another's (independent per-query heaps).
421+ ///
422+ /// The residual allow-list depends only on the filter and the plan, not the
423+ /// query vector, so the SAME `residual_by_split` slice is shared across every
424+ /// query. Input-shape validation (positive limits, non-empty query, residual
425+ /// count) is applied per query / once as appropriate. Kept a sequential
426+ /// per-split loop.
427+ #[ allow( clippy:: too_many_arguments) ]
428+ #[ allow( clippy:: type_complexity) ]
429+ pub ( crate ) async fn search_candidates_batch (
430+ & self ,
431+ splits : & [ PkVectorSearchSplit ] ,
432+ queries : & [ & [ f32 ] ] ,
433+ metric : VectorSearchMetric ,
434+ limit : usize ,
435+ indexed_limit : usize ,
436+ ann_searcher : Option < & dyn PkVectorAnnSearcher > ,
437+ exact_file_search : & ( dyn for <' s , ' a > Fn (
438+ usize ,
439+ & ' s PkVectorSearchSplit ,
440+ & ' a BucketActiveFile ,
441+ & ' a [ & ' a [ f32 ] ] ,
442+ VectorSearchMetric ,
443+ usize ,
444+ & ' a ( dyn Fn ( i64 ) -> bool + Sync ) ,
445+ ) -> ExactFileSearchFuture < ' a >
446+ + Send
447+ + Sync ) ,
448+ search_options : & HashMap < String , String > ,
449+ skip_exact_fallback : bool ,
450+ residual_by_split : Option < & [ HashMap < String , RoaringTreemap > ] > ,
451+ ) -> crate :: Result < Vec < OrchestratorSearchResult > > {
389452 // Eager input-shape validation (Java checkArgument parity).
453+ if queries. is_empty ( ) {
454+ return Err ( data_invalid ( "vector search requires at least one query" ) ) ;
455+ }
390456 if limit == 0 {
391457 return Err ( data_invalid ( "vector search limit must be positive" ) ) ;
392458 }
393459 if indexed_limit == 0 {
394460 return Err ( data_invalid ( "vector indexed search limit must be positive" ) ) ;
395461 }
396- if query. is_empty ( ) {
397- return Err ( data_invalid ( "vector search query must not be empty" ) ) ;
462+ for query in queries {
463+ if query. is_empty ( ) {
464+ return Err ( data_invalid ( "vector search query must not be empty" ) ) ;
465+ }
398466 }
399467 if let Some ( per_split) = residual_by_split {
400468 if per_split. len ( ) != splits. len ( ) {
@@ -404,9 +472,12 @@ impl PkVectorOrchestrator {
404472 }
405473 }
406474
407- // Eager per-bucket search -> tagged candidates, kept split by path.
408- let mut indexed_candidates: Vec < PkVectorCandidate > = Vec :: new ( ) ;
409- let mut exact_candidates: Vec < PkVectorCandidate > = Vec :: new ( ) ;
475+ // Eager per-bucket search -> per-query tagged candidates, kept split by
476+ // path. One inner Vec per query.
477+ let mut indexed_candidates: Vec < Vec < PkVectorCandidate > > =
478+ ( 0 ..queries. len ( ) ) . map ( |_| Vec :: new ( ) ) . collect ( ) ;
479+ let mut exact_candidates: Vec < Vec < PkVectorCandidate > > =
480+ ( 0 ..queries. len ( ) ) . map ( |_| Vec :: new ( ) ) . collect ( ) ;
410481 for ( split_index, split) in splits. iter ( ) . enumerate ( ) {
411482 let dvs = build_bucket_dv_map ( & self . reader , split) . await ?;
412483 // Adapt the split-scoped search closure to bucket_search's per-file
@@ -432,13 +503,13 @@ impl PkVectorOrchestrator {
432503 } ,
433504 ) ;
434505 let residual_ranges = residual_by_split. map ( |per_split| & per_split[ split_index] ) ;
435- let result = bucket_search (
506+ let per_query = bucket_search_batch (
436507 ann_searcher,
437508 & split. ann_segments ,
438509 & split. active_files ,
439510 & dvs,
440511 & bucket_search_closure,
441- query ,
512+ queries ,
442513 metric,
443514 indexed_limit,
444515 limit,
@@ -447,6 +518,13 @@ impl PkVectorOrchestrator {
447518 residual_ranges,
448519 )
449520 . await ?;
521+ if per_query. len ( ) != queries. len ( ) {
522+ return Err ( data_invalid ( format ! (
523+ "bucket search returned {} result lists for {} queries" ,
524+ per_query. len( ) ,
525+ queries. len( )
526+ ) ) ) ;
527+ }
450528 let tag = |PkVectorSearchResult {
451529 data_file_name,
452530 row_position,
@@ -459,14 +537,20 @@ impl PkVectorOrchestrator {
459537 row_position,
460538 distance,
461539 } ;
462- indexed_candidates. extend ( result. indexed . into_iter ( ) . map ( & tag) ) ;
463- exact_candidates. extend ( result. exact . into_iter ( ) . map ( & tag) ) ;
540+ for ( query_index, result) in per_query. into_iter ( ) . enumerate ( ) {
541+ indexed_candidates[ query_index] . extend ( result. indexed . into_iter ( ) . map ( & tag) ) ;
542+ exact_candidates[ query_index] . extend ( result. exact . into_iter ( ) . map ( & tag) ) ;
543+ }
464544 }
465545
466- Ok ( OrchestratorSearchResult {
467- indexed : global_top_k ( indexed_candidates, indexed_limit) ,
468- exact : global_top_k ( exact_candidates, limit) ,
469- } )
546+ Ok ( indexed_candidates
547+ . into_iter ( )
548+ . zip ( exact_candidates)
549+ . map ( |( indexed, exact) | OrchestratorSearchResult {
550+ indexed : global_top_k ( indexed, indexed_limit) ,
551+ exact : global_top_k ( exact, limit) ,
552+ } )
553+ . collect ( ) )
470554 }
471555}
472556
@@ -1771,4 +1855,187 @@ mod e2e_tests {
17711855 let err = validate_row_position ( "data-1" , 9 , 3 ) . unwrap_err ( ) ;
17721856 assert ! ( err. to_string( ) . contains( "out of range" ) && err. to_string( ) . contains( "data-1" ) ) ;
17731857 }
1858+
1859+ /// A split-scoped multi-query search closure running `exact_search` per query
1860+ /// over a fresh `ArrayReader` of `vectors`, returning one list per query. Used
1861+ /// by the batch orchestrator tests.
1862+ #[ allow( clippy:: type_complexity) ]
1863+ fn split_array_search_batch (
1864+ vectors : Vec < Option < Vec < f32 > > > ,
1865+ ) -> impl for <' s , ' a > Fn (
1866+ usize ,
1867+ & ' s PkVectorSearchSplit ,
1868+ & ' a BucketActiveFile ,
1869+ & ' a [ & ' a [ f32 ] ] ,
1870+ VectorSearchMetric ,
1871+ usize ,
1872+ & ' a ( dyn Fn ( i64 ) -> bool + Sync ) ,
1873+ ) -> ExactFileSearchFuture < ' a >
1874+ + Send
1875+ + Sync {
1876+ let vectors = Arc :: new ( vectors) ;
1877+ as_split_search (
1878+ move |_: usize ,
1879+ _: & PkVectorSearchSplit ,
1880+ file : & BucketActiveFile ,
1881+ queries : & [ & [ f32 ] ] ,
1882+ metric : VectorSearchMetric ,
1883+ exact_limit : usize ,
1884+ is_excluded : & ( dyn Fn ( i64 ) -> bool + Sync ) |
1885+ -> ExactFileSearchFuture < ' _ > {
1886+ let dimension = vectors
1887+ . first ( )
1888+ . and_then ( |v| v. as_ref ( ) )
1889+ . map_or ( 2 , |v| v. len ( ) ) ;
1890+ let file_name = file. file_name . clone ( ) ;
1891+ let owned_queries: Vec < Vec < f32 > > = queries. iter ( ) . map ( |q| q. to_vec ( ) ) . collect ( ) ;
1892+ let vectors = Arc :: clone ( & vectors) ;
1893+ Box :: pin ( async move {
1894+ let mut out = Vec :: with_capacity ( owned_queries. len ( ) ) ;
1895+ for query in & owned_queries {
1896+ let mut reader = ArrayReader :: new ( dimension, ( * vectors) . clone ( ) ) ;
1897+ out. push ( exact_search (
1898+ & file_name,
1899+ & mut reader,
1900+ query,
1901+ metric,
1902+ exact_limit,
1903+ is_excluded,
1904+ ) ?) ;
1905+ }
1906+ Ok ( out)
1907+ } )
1908+ } ,
1909+ )
1910+ }
1911+
1912+ #[ tokio:: test]
1913+ async fn search_candidates_batch_of_one_equals_single ( ) {
1914+ // A batch-of-one must equal the single-query `search_candidates` exactly.
1915+ let table_path = "memory:/pkvo_batch_one" ;
1916+ let bucket_path = format ! ( "{table_path}/bucket-0" ) ;
1917+ let file_io = FileIOBuilder :: new ( "memory" ) . build ( ) . unwrap ( ) ;
1918+ let meta = write_file ( & file_io, & bucket_path, "c.mosaic" , vec ! [ 1 , 2 , 3 ] ) . await ;
1919+ let make_split = || PkVectorSearchSplit {
1920+ data_split : DataSplitBuilder :: new ( )
1921+ . with_snapshot ( 1 )
1922+ . with_partition ( BinaryRow :: new ( 0 ) )
1923+ . with_bucket ( 0 )
1924+ . with_bucket_path ( bucket_path. clone ( ) )
1925+ . with_total_buckets ( 1 )
1926+ . with_data_files ( vec ! [ meta. clone( ) ] )
1927+ . build ( )
1928+ . unwrap ( ) ,
1929+ ann_segments : Vec :: new ( ) ,
1930+ active_files : vec ! [ active( "c.mosaic" , 3 ) ] ,
1931+ } ;
1932+ // pos0 {3,0} d=9, pos1 {1,0} d=1, pos2 {2,0} d=4.
1933+ let vectors = vec ! [
1934+ Some ( vec![ 3.0 , 0.0 ] ) ,
1935+ Some ( vec![ 1.0 , 0.0 ] ) ,
1936+ Some ( vec![ 2.0 , 0.0 ] ) ,
1937+ ] ;
1938+ let query: & [ f32 ] = & [ 0.0 , 0.0 ] ;
1939+ let opts = HashMap :: new ( ) ;
1940+
1941+ let single = PkVectorOrchestrator :: new ( make_reader ( file_io. clone ( ) , table_path) )
1942+ . search_candidates (
1943+ & [ make_split ( ) ] ,
1944+ query,
1945+ VectorSearchMetric :: L2 ,
1946+ 2 ,
1947+ 2 ,
1948+ None ,
1949+ & split_array_search_batch ( vectors. clone ( ) ) ,
1950+ & opts,
1951+ false ,
1952+ None ,
1953+ )
1954+ . await
1955+ . unwrap ( ) ;
1956+
1957+ let batch = PkVectorOrchestrator :: new ( make_reader ( file_io, table_path) )
1958+ . search_candidates_batch (
1959+ & [ make_split ( ) ] ,
1960+ & [ query] ,
1961+ VectorSearchMetric :: L2 ,
1962+ 2 ,
1963+ 2 ,
1964+ None ,
1965+ & split_array_search_batch ( vectors) ,
1966+ & opts,
1967+ false ,
1968+ None ,
1969+ )
1970+ . await
1971+ . unwrap ( ) ;
1972+ assert_eq ! ( batch. len( ) , 1 ) ;
1973+ assert_eq ! (
1974+ batch[ 0 ]
1975+ . exact
1976+ . iter( )
1977+ . map( |c| ( c. row_position, c. distance) )
1978+ . collect:: <Vec <_>>( ) ,
1979+ single
1980+ . exact
1981+ . iter( )
1982+ . map( |c| ( c. row_position, c. distance) )
1983+ . collect:: <Vec <_>>( )
1984+ ) ;
1985+ }
1986+
1987+ #[ tokio:: test]
1988+ async fn search_candidates_batch_per_query_results_independent ( ) {
1989+ // Two queries over one exact file; each query's Top-K comes from its own
1990+ // heap with no cross-query bleed. q0 nearest pos1 (x=1); q1 nearest pos0
1991+ // (x=3).
1992+ let table_path = "memory:/pkvo_batch_indep" ;
1993+ let bucket_path = format ! ( "{table_path}/bucket-0" ) ;
1994+ let file_io = FileIOBuilder :: new ( "memory" ) . build ( ) . unwrap ( ) ;
1995+ let meta = write_file ( & file_io, & bucket_path, "c.mosaic" , vec ! [ 1 , 2 , 3 ] ) . await ;
1996+ let split = PkVectorSearchSplit {
1997+ data_split : DataSplitBuilder :: new ( )
1998+ . with_snapshot ( 1 )
1999+ . with_partition ( BinaryRow :: new ( 0 ) )
2000+ . with_bucket ( 0 )
2001+ . with_bucket_path ( bucket_path)
2002+ . with_total_buckets ( 1 )
2003+ . with_data_files ( vec ! [ meta] )
2004+ . build ( )
2005+ . unwrap ( ) ,
2006+ ann_segments : Vec :: new ( ) ,
2007+ active_files : vec ! [ active( "c.mosaic" , 3 ) ] ,
2008+ } ;
2009+ // pos0 {3,0}, pos1 {1,0}, pos2 {2,0}.
2010+ let vectors = vec ! [
2011+ Some ( vec![ 3.0 , 0.0 ] ) ,
2012+ Some ( vec![ 1.0 , 0.0 ] ) ,
2013+ Some ( vec![ 2.0 , 0.0 ] ) ,
2014+ ] ;
2015+ let q0: & [ f32 ] = & [ 1.0 , 0.0 ] ; // nearest pos1 (dist 0)
2016+ let q1: & [ f32 ] = & [ 3.0 , 0.0 ] ; // nearest pos0 (dist 0)
2017+ let opts = HashMap :: new ( ) ;
2018+ let out = PkVectorOrchestrator :: new ( make_reader ( file_io, table_path) )
2019+ . search_candidates_batch (
2020+ & [ split] ,
2021+ & [ q0, q1] ,
2022+ VectorSearchMetric :: L2 ,
2023+ 1 ,
2024+ 1 ,
2025+ None ,
2026+ & split_array_search_batch ( vectors) ,
2027+ & opts,
2028+ false ,
2029+ None ,
2030+ )
2031+ . await
2032+ . unwrap ( ) ;
2033+ assert_eq ! ( out. len( ) , 2 ) ;
2034+ let m0 = merge_candidates ( out[ 0 ] . indexed . clone ( ) , out[ 0 ] . exact . clone ( ) , 1 ) ;
2035+ let m1 = merge_candidates ( out[ 1 ] . indexed . clone ( ) , out[ 1 ] . exact . clone ( ) , 1 ) ;
2036+ assert_eq ! ( m0. len( ) , 1 ) ;
2037+ assert_eq ! ( m0[ 0 ] . row_position, 1 ) ;
2038+ assert_eq ! ( m1. len( ) , 1 ) ;
2039+ assert_eq ! ( m1[ 0 ] . row_position, 0 ) ;
2040+ }
17742041}
0 commit comments