Skip to content

Commit 2607378

Browse files
JunRuiLeelijunrui
authored andcommitted
feat(table): expose batch primary-key vector search over a shared plan
Add a batch multi-query entry to the primary-key vector read: N query vectors share one snapshot/manifest plan, segment preload, per-file search closure and residual allow-list, then each query runs its own bucket search, exact rerank and merge before materializing into its own Arrow stream. The orchestrator gains search_candidates_batch; the builder factors the query-independent plan into a shared step reused across queries. BatchVectorSearchBuilder gains with_filter/with_projection and an execute_read returning one stream per query in input order (empty streams preserve arity, any query error fails the whole call); its scored execute stays fail-loud on the primary-key path, and a table without a primary-key vector index is rejected by the materialized read. The single-query entries become thin wrappers whose output is unchanged.
1 parent 31b5050 commit 2607378

5 files changed

Lines changed: 1663 additions & 460 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/paimon/src/table/pk_vector_orchestrator.rs

Lines changed: 281 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ use crate::table::pk_vector_indexed_split_read::PkVectorIndexedSplit;
3535
use crate::table::source::{DataSplit, DataSplitBuilder, RowRange};
3636
use crate::vindex::pkvector::ann::PkVectorAnnSearcher;
3737
use crate::vindex::pkvector::bucket::{
38-
bucket_search, BucketActiveFile, BucketAnnSegment, ExactFileSearchFuture,
38+
bucket_search_batch, BucketActiveFile, BucketAnnSegment, ExactFileSearchFuture,
3939
};
4040
use crate::vindex::pkvector::metric::{java_float_compare, VectorSearchMetric};
4141
use 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

Comments
 (0)