Skip to content

Commit f5d497d

Browse files
committed
feat(vindex): search multiple primary-key vector queries in one pass
Add a batch entry to the bucket-local vector search so N query vectors share one pass over each ANN segment and each uncovered data file. The ANN searcher gains search_batch: it builds the live-row mask once (query-independent), opens one reader per segment, and drives visit_batch_vector_search, mapping results per query. The bucket search gains bucket_search_batch with per-query bounded heaps; a single query short-circuits to the existing single-query path so its result stays identical. The exact-fallback per-file search already scored all queries in one stream pass. Single-query wrappers are retained throughout.
1 parent 7456b23 commit f5d497d

5 files changed

Lines changed: 811 additions & 64 deletions

File tree

crates/paimon/src/table/pk_vector_data_file_reader.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -519,6 +519,60 @@ mod integration_tests {
519519
assert_eq!(streamed[0][1].row_position, 3);
520520
}
521521

522+
/// A multi-query `search_file` returns independent per-query Top-K lists: the
523+
/// slot for a query in a batch is identical to that query searched alone (no
524+
/// cross-query bleed), and a shared `is_excluded` applies to every query.
525+
#[tokio::test]
526+
async fn search_file_multi_query_returns_independent_per_query_top_k() {
527+
let rows = vec![
528+
Some(vec![0.0, 0.0]),
529+
Some(vec![1.0, 0.0]),
530+
Some(vec![2.0, 0.0]),
531+
Some(vec![3.0, 0.0]),
532+
];
533+
let (factory, file_name) =
534+
build_factory(&rows, rows.len() as i64, "memory:/pkvdfr_multiquery").await;
535+
let active = BucketActiveFile {
536+
file_name,
537+
row_count: rows.len() as i64,
538+
};
539+
// Exclude physical position 1 for all queries (shared predicate).
540+
let is_excluded = |pos: i64| pos == 1;
541+
let q0 = [0.0f32, 0.0]; // nearest is pos 0
542+
let q1 = [3.0f32, 0.0]; // nearest is pos 3
543+
544+
let batch = factory
545+
.search_file(
546+
&active,
547+
&[&q0, &q1],
548+
VectorSearchMetric::L2,
549+
2,
550+
&is_excluded,
551+
)
552+
.await
553+
.unwrap();
554+
assert_eq!(batch.len(), 2, "one result list per query");
555+
556+
// Each query searched alone must equal its slot in the batch.
557+
let only_q0 = factory
558+
.search_file(&active, &[&q0], VectorSearchMetric::L2, 2, &is_excluded)
559+
.await
560+
.unwrap();
561+
let only_q1 = factory
562+
.search_file(&active, &[&q1], VectorSearchMetric::L2, 2, &is_excluded)
563+
.await
564+
.unwrap();
565+
assert_eq!(batch[0], only_q0[0]);
566+
assert_eq!(batch[1], only_q1[0]);
567+
568+
// Sanity: distinct nearest neighbours, and the excluded position is absent.
569+
assert_eq!(batch[0][0].row_position, 0);
570+
assert_eq!(batch[1][0].row_position, 3);
571+
assert!(batch
572+
.iter()
573+
.all(|list| list.iter().all(|r| r.row_position != 1)));
574+
}
575+
522576
/// A `DataFileMeta.row_count` larger than the file's real row count means the
523577
/// stream ends early; the search must fail loud rather than return a short
524578
/// result.

crates/paimon/src/table/pk_vector_orchestrator.rs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1100,18 +1100,18 @@ mod e2e_tests {
11001100
hits: Vec<PkVectorSearchResult>,
11011101
}
11021102
impl PkVectorAnnSearcher for FakeAnn {
1103-
fn search(
1103+
fn search_batch(
11041104
&self,
11051105
_segment: &BucketAnnSegment,
1106-
_query: &[f32],
1106+
queries: &[&[f32]],
11071107
_metric: VectorSearchMetric,
11081108
_limit: usize,
11091109
_active_source_files: &HashSet<String>,
11101110
_dvs: &HashMap<String, Arc<DeletionVector>>,
11111111
_opts: &HashMap<String, String>,
11121112
_residual_ranges: Option<&HashMap<String, roaring::RoaringTreemap>>,
1113-
) -> crate::Result<Vec<PkVectorSearchResult>> {
1114-
Ok(self.hits.clone())
1113+
) -> crate::Result<Vec<Vec<PkVectorSearchResult>>> {
1114+
Ok(queries.iter().map(|_| self.hits.clone()).collect())
11151115
}
11161116
}
11171117

crates/paimon/src/table/vector_search_builder.rs

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -493,7 +493,9 @@ impl<'a> VectorSearchBuilder<'a> {
493493
);
494494

495495
// Real ANN scorer: preload each segment's bytes (keyed by resolved,
496-
// globally unique path) and drive the vindex reader from memory.
496+
// globally unique path) and drive the vindex reader from memory. The reader
497+
// is opened once per segment and every query in the batch is searched
498+
// against it, mirroring the shared-reader batch search.
497499
let segment_bytes = preload_segment_bytes(self.table.file_io(), &plan.splits).await?;
498500
// Fail loud on a config/segment metric mismatch before scoring, mirroring
499501
// Java `PkVectorAnnSegmentSearcher.search`.
@@ -505,8 +507,8 @@ impl<'a> VectorSearchBuilder<'a> {
505507
};
506508
let search_options = options.clone();
507509
let field_name = pk_col.to_string();
508-
let scorer: crate::vindex::pkvector::ann::Scorer =
509-
Box::new(move |segment: &BucketAnnSegment, search: &VectorSearch| {
510+
let scorer: crate::vindex::pkvector::ann::BatchScorer = Box::new(
511+
move |segment: &BucketAnnSegment, searches: &[VectorSearch]| {
510512
let data = segment_bytes
511513
.get(&segment.path)
512514
.ok_or_else(|| crate::Error::DataInvalid {
@@ -523,15 +525,16 @@ impl<'a> VectorSearchBuilder<'a> {
523525
VectorIndexBackend::Lumina => {
524526
let mut reader =
525527
LuminaVectorGlobalIndexReader::new(io_meta, options.clone());
526-
reader.visit_vector_search(search, |_| Ok(Cursor::new(data)))
528+
reader.visit_batch_vector_search(searches, |_| Ok(Cursor::new(data)))
527529
}
528530
VectorIndexBackend::Vindex => {
529531
let mut reader =
530532
VindexVectorGlobalIndexReader::new(io_meta, options.clone());
531-
reader.visit_vector_search(search, |_| Ok(Cursor::new(data)))
533+
reader.visit_batch_vector_search(searches, |_| Ok(Cursor::new(data)))
532534
}
533535
}
534-
});
536+
},
537+
);
535538
let ann_searcher = VindexAnnSearcher::new(field_name, scorer);
536539

537540
// Residual (post-recall) filtering: for each candidate file, re-read its

0 commit comments

Comments
 (0)