Skip to content

Commit 4211dda

Browse files
authored
[core] Rerank primary-key vector candidates with exact distances (#550)
1 parent d73c150 commit 4211dda

6 files changed

Lines changed: 1637 additions & 58 deletions

File tree

crates/paimon/src/table/pk_vector_data_file_reader.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ impl DataFilePkVectorReaderFactory {
145145
/// `None`). The column must be a `FixedSizeList`/`List` of `Float32`; every
146146
/// non-null row's child slice must have exactly `dimension` elements. Mirrors
147147
/// the layout handling in `vector_search_builder`.
148-
fn append_batch_vectors(
148+
pub(crate) fn append_batch_vectors(
149149
batch: &arrow_array::RecordBatch,
150150
field_name: &str,
151151
dimension: usize,
@@ -206,6 +206,12 @@ fn append_batch_vectors(
206206
}
207207
let mut vector = Vec::with_capacity(dimension);
208208
for i in start..end {
209+
if values.is_null(i) {
210+
return Err(data_invalid(format!(
211+
"vector row {row} has a null element at index {}",
212+
i - start
213+
)));
214+
}
209215
vector.push(values.value(i));
210216
}
211217
out.push(Some(vector));
@@ -349,6 +355,35 @@ mod integration_tests {
349355
}
350356
}
351357

358+
/// A present (non-null) vector row whose child slice contains a NULL
359+
/// element must fail loud rather than silently defaulting the element to
360+
/// `0.0` and corrupting the distance.
361+
#[test]
362+
fn append_batch_vectors_fails_loud_on_null_element() {
363+
let field = vector_field();
364+
let read_fields = vec![field.clone()];
365+
let arrow_schema = build_target_arrow_schema(&read_fields).unwrap();
366+
367+
// Row is present, but element index 1 in its child slice is NULL: [1.0, null].
368+
let mut builder = FixedSizeListBuilder::new(Float32Builder::new(), 2).with_field(Arc::new(
369+
ArrowField::new("element", ArrowDataType::Float32, true),
370+
));
371+
builder.values().append_value(1.0);
372+
builder.values().append_null();
373+
builder.append(true);
374+
let vec_array = builder.finish();
375+
let batch = RecordBatch::try_new(arrow_schema, vec![Arc::new(vec_array)]).unwrap();
376+
377+
let mut out: Vec<Option<Vec<f32>>> = Vec::new();
378+
let err = append_batch_vectors(&batch, field.name(), 2, &mut out)
379+
.expect_err("null child element must fail loud");
380+
let msg = err.to_string();
381+
assert!(
382+
msg.contains("null") && msg.contains("element"),
383+
"got: {msg}"
384+
);
385+
}
386+
352387
/// Write a FixedSizeList<Float32, 2> vector column
353388
/// (`[1,2]`, NULL, `[3,4]`) as a parquet data file, build the factory over
354389
/// its split, preload via `create`, and assert the whole-file sequential

crates/paimon/src/table/pk_vector_indexed_split_read.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ pub(crate) struct PkVectorIndexedSplit {
6161
/// positions, validating bounds and ordering. Ranges must be non-empty, each
6262
/// within `[0, row_count)`, strictly ascending and non-overlapping (touching
6363
/// ranges allowed). Expansion is inclusive `from..=to`.
64-
fn expand_ranges(ranges: &[RowRange], row_count: i64) -> crate::Result<Vec<i64>> {
64+
pub(crate) fn expand_ranges(ranges: &[RowRange], row_count: i64) -> crate::Result<Vec<i64>> {
6565
if ranges.is_empty() {
6666
return Err(data_invalid("indexed split must select at least one row"));
6767
}

crates/paimon/src/table/pk_vector_orchestrator.rs

Lines changed: 108 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ pub(crate) struct PkVectorSearchSplit {
112112
/// the cross-bucket merge dimensions a lone `PkVectorSearchResult` lacks;
113113
/// `split_index` is the re-association handle back to
114114
/// `splits[split_index].data_split`.
115+
#[derive(Clone)]
115116
pub(crate) struct PkVectorCandidate {
116117
pub split_index: usize,
117118
pub partition: BinaryRow,
@@ -121,6 +122,14 @@ pub(crate) struct PkVectorCandidate {
121122
pub distance: f32,
122123
}
123124

125+
/// Best-first indexed (approximate) and exact-fallback candidate lists, each
126+
/// already globally bounded. The indexed list may be over-fetched for a later
127+
/// exact rerank; the exact list is bounded to the caller's final limit.
128+
pub(crate) struct OrchestratorSearchResult {
129+
pub(crate) indexed: Vec<PkVectorCandidate>,
130+
pub(crate) exact: Vec<PkVectorCandidate>,
131+
}
132+
124133
/// 5-level BEST_FIRST (smallest = best) key. Level 1 orders distance with
125134
/// `java_float_compare` so a NaN distance (e.g. from a non-finite stored vector
126135
/// under inner product) sorts last rather than winning Top-1. Level 2 uses the
@@ -145,6 +154,20 @@ fn global_top_k(mut candidates: Vec<PkVectorCandidate>, limit: usize) -> Vec<PkV
145154
candidates
146155
}
147156

157+
/// Merge already-bounded indexed and exact candidate lists into a single
158+
/// best-first list truncated to `limit`. The final global Top-K over the union
159+
/// is what guarantees the merged result is independent of how the two inputs
160+
/// were individually bounded.
161+
pub(crate) fn merge_candidates(
162+
indexed: Vec<PkVectorCandidate>,
163+
exact: Vec<PkVectorCandidate>,
164+
limit: usize,
165+
) -> Vec<PkVectorCandidate> {
166+
let mut all = indexed;
167+
all.extend(exact);
168+
global_top_k(all, limit)
169+
}
170+
148171
/// Group Top-K survivors by `(partition, bucket, data_file_name)`, re-associate
149172
/// each group's file to its real `DataFileMeta` + aligned deletion file in the
150173
/// source bucket split, and build one `PkVectorIndexedSplit` per file. Groups are
@@ -197,7 +220,15 @@ pub(crate) fn build_indexed_splits(
197220
}
198221

199222
// Re-associate the file to its DataFileMeta + aligned deletion file.
200-
let source = &splits[split_index].data_split;
223+
let source = &splits
224+
.get(split_index)
225+
.ok_or_else(|| {
226+
data_invalid(format!(
227+
"vector search hit references split index {split_index} out of range (splits: {})",
228+
splits.len()
229+
))
230+
})?
231+
.data_split;
201232
let file_idx = source
202233
.data_files()
203234
.iter()
@@ -301,10 +332,13 @@ impl PkVectorOrchestrator {
301332
}
302333

303334
/// Run the eager per-bucket search + cross-bucket global Top-K and return the
304-
/// best-first survivors (through the full 5-level tie-break, raw distance
305-
/// preserved). The exact-reader factory is split-scoped: it receives the
306-
/// current split index and split so a caller can build a reader keyed to the
307-
/// specific split/file. `skip_exact_fallback` forwards to `bucket_search`.
335+
/// indexed (approximate) and exact-fallback survivors as two separate
336+
/// best-first lists (each through the full 5-level tie-break, raw distance
337+
/// preserved). The indexed list is bounded to `indexed_limit` (over-fetched
338+
/// for a later exact rerank); the exact list is bounded to `limit`. The
339+
/// exact-reader factory is split-scoped: it receives the current split index
340+
/// and split so a caller can build a reader keyed to the specific split/file.
341+
/// `skip_exact_fallback` forwards to `bucket_search`.
308342
///
309343
/// `residual_by_split`, when present, carries one per-file allow-list of
310344
/// physical row positions per split (indexed parallel to `splits`): only
@@ -320,6 +354,7 @@ impl PkVectorOrchestrator {
320354
query: &[f32],
321355
metric: VectorSearchMetric,
322356
limit: usize,
357+
indexed_limit: usize,
323358
ann_searcher: Option<&dyn PkVectorAnnSearcher>,
324359
exact_reader_factory: &mut (dyn for<'s, 'f> FnMut(
325360
usize,
@@ -330,11 +365,14 @@ impl PkVectorOrchestrator {
330365
search_options: &HashMap<String, String>,
331366
skip_exact_fallback: bool,
332367
residual_by_split: Option<&[HashMap<String, RoaringTreemap>]>,
333-
) -> crate::Result<Vec<PkVectorCandidate>> {
368+
) -> crate::Result<OrchestratorSearchResult> {
334369
// Eager input-shape validation (Java checkArgument parity).
335370
if limit == 0 {
336371
return Err(data_invalid("vector search limit must be positive"));
337372
}
373+
if indexed_limit == 0 {
374+
return Err(data_invalid("vector indexed search limit must be positive"));
375+
}
338376
if query.is_empty() {
339377
return Err(data_invalid("vector search query must not be empty"));
340378
}
@@ -346,8 +384,9 @@ impl PkVectorOrchestrator {
346384
}
347385
}
348386

349-
// Eager per-bucket search -> tagged candidates.
350-
let mut candidates: Vec<PkVectorCandidate> = Vec::new();
387+
// Eager per-bucket search -> tagged candidates, kept split by path.
388+
let mut indexed_candidates: Vec<PkVectorCandidate> = Vec::new();
389+
let mut exact_candidates: Vec<PkVectorCandidate> = Vec::new();
351390
for (split_index, split) in splits.iter().enumerate() {
352391
let dvs = build_bucket_dv_map(&self.reader, split).await?;
353392
// Wrap the split-scoped factory into bucket_search's per-file signature.
@@ -357,38 +396,41 @@ impl PkVectorOrchestrator {
357396
exact_reader_factory(split_index, split, file)
358397
});
359398
let residual_ranges = residual_by_split.map(|per_split| &per_split[split_index]);
360-
let results = bucket_search(
399+
let result = bucket_search(
361400
ann_searcher,
362401
&split.ann_segments,
363402
&split.active_files,
364403
&dvs,
365404
&mut bucket_factory,
366405
query,
367406
metric,
407+
indexed_limit,
368408
limit,
369409
search_options,
370410
skip_exact_fallback,
371411
residual_ranges,
372412
)
373413
.await?;
374-
for PkVectorSearchResult {
414+
let tag = |PkVectorSearchResult {
415+
data_file_name,
416+
row_position,
417+
distance,
418+
}: PkVectorSearchResult| PkVectorCandidate {
419+
split_index,
420+
partition: split.data_split.partition().clone(),
421+
bucket: split.data_split.bucket(),
375422
data_file_name,
376423
row_position,
377424
distance,
378-
} in results
379-
{
380-
candidates.push(PkVectorCandidate {
381-
split_index,
382-
partition: split.data_split.partition().clone(),
383-
bucket: split.data_split.bucket(),
384-
data_file_name,
385-
row_position,
386-
distance,
387-
});
388-
}
425+
};
426+
indexed_candidates.extend(result.indexed.into_iter().map(&tag));
427+
exact_candidates.extend(result.exact.into_iter().map(&tag));
389428
}
390429

391-
Ok(global_top_k(candidates, limit))
430+
Ok(OrchestratorSearchResult {
431+
indexed: global_top_k(indexed_candidates, indexed_limit),
432+
exact: global_top_k(exact_candidates, limit),
433+
})
392434
}
393435
}
394436

@@ -547,6 +589,18 @@ mod tests {
547589
assert!(survivors.is_empty());
548590
}
549591

592+
#[test]
593+
fn merge_candidates_takes_global_top_k_over_union() {
594+
// indexed and exact each already bounded; the union's global Top-K must be
595+
// independent of how each side was bounded.
596+
let indexed = vec![cand(0, 0, "f", 0, 0.1), cand(0, 0, "f", 1, 0.4)];
597+
let exact = vec![cand(0, 0, "g", 0, 0.2)];
598+
let out = merge_candidates(indexed, exact, 2);
599+
assert_eq!(out.len(), 2);
600+
assert_eq!(out[0].distance, 0.1);
601+
assert_eq!(out[1].distance, 0.2); // 0.2 (exact) beats 0.4 (indexed)
602+
}
603+
550604
#[test]
551605
fn builds_two_splits_with_ascending_position_ordered_scores() {
552606
// One bucket, two files. file-a hits at global order [pos=10, pos=2];
@@ -628,6 +682,18 @@ mod tests {
628682
assert!(format!("{err:?}").contains("duplicate"), "got: {err:?}");
629683
}
630684

685+
#[test]
686+
fn build_indexed_splits_fails_loud_on_out_of_range_split_index() {
687+
// A malformed candidate whose split_index is beyond the splits slice must
688+
// fail loud, not panic on a bare slice index.
689+
let splits = vec![search_split(0, vec![data_file("f", 10)])];
690+
let survivors = vec![cand(5, 0, "f", 0, 1.0)];
691+
let err = build_indexed_splits(survivors, &splits, VectorSearchMetric::L2)
692+
.map(|_| ())
693+
.expect_err("out-of-range split index must error");
694+
assert!(format!("{err:?}").contains("out of range"), "got: {err:?}");
695+
}
696+
631697
#[test]
632698
fn rejects_same_group_key_from_different_splits() {
633699
// Two buckets share (partition, bucket, file_name) but sit at different
@@ -949,19 +1015,23 @@ mod e2e_tests {
9491015
// expects; the split index/split are unused here.
9501016
let mut wrapped =
9511017
as_split_factory(|_: usize, _: &PkVectorSearchSplit, f: &BucketActiveFile| factory(f));
952-
let survivors = orch
1018+
let result = orch
9531019
.search_candidates(
9541020
splits,
9551021
query,
9561022
metric,
9571023
limit,
1024+
limit,
9581025
ann,
9591026
&mut wrapped,
9601027
opts,
9611028
false,
9621029
None,
9631030
)
9641031
.await?;
1032+
// Merge the two bounded lists into the best-first survivors the
1033+
// materialization path expects.
1034+
let survivors = merge_candidates(result.indexed, result.exact, limit);
9651035
let indexed_splits = build_indexed_splits(survivors, splits, metric)?;
9661036
let mut out = Vec::new();
9671037
for indexed in indexed_splits {
@@ -991,6 +1061,7 @@ mod e2e_tests {
9911061
&[0.0, 0.0],
9921062
VectorSearchMetric::L2,
9931063
0,
1064+
0,
9941065
None,
9951066
&mut factory,
9961067
&opts,
@@ -1020,6 +1091,7 @@ mod e2e_tests {
10201091
&[],
10211092
VectorSearchMetric::L2,
10221093
5,
1094+
5,
10231095
None,
10241096
&mut factory,
10251097
&opts,
@@ -1360,12 +1432,13 @@ mod e2e_tests {
13601432
},
13611433
);
13621434
let opts = HashMap::new();
1363-
let cands = PkVectorOrchestrator::new(make_reader(file_io, table_path))
1435+
let result = PkVectorOrchestrator::new(make_reader(file_io, table_path))
13641436
.search_candidates(
13651437
&[split],
13661438
&[0.0, 0.0],
13671439
VectorSearchMetric::L2,
13681440
2,
1441+
2,
13691442
None,
13701443
&mut factory,
13711444
&opts,
@@ -1374,6 +1447,8 @@ mod e2e_tests {
13741447
)
13751448
.await
13761449
.unwrap();
1450+
// Merge the two bounded lists into the best-first survivors.
1451+
let cands = merge_candidates(result.indexed, result.exact, 2);
13771452
// Best-first: pos1 (d=1), pos2 (d=4).
13781453
assert_eq!(
13791454
cands
@@ -1432,12 +1507,13 @@ mod e2e_tests {
14321507
allowed.insert(2);
14331508
let residual_by_split = vec![HashMap::from([("r.mosaic".to_string(), allowed)])];
14341509
let opts = HashMap::new();
1435-
let cands = PkVectorOrchestrator::new(make_reader(file_io, table_path))
1510+
let result = PkVectorOrchestrator::new(make_reader(file_io, table_path))
14361511
.search_candidates(
14371512
&[split],
14381513
&[0.0, 0.0],
14391514
VectorSearchMetric::L2,
14401515
3,
1516+
3,
14411517
None,
14421518
&mut factory,
14431519
&opts,
@@ -1446,6 +1522,8 @@ mod e2e_tests {
14461522
)
14471523
.await
14481524
.unwrap();
1525+
// Merge the two bounded lists into the best-first survivors.
1526+
let cands = merge_candidates(result.indexed, result.exact, 3);
14491527
// Best-first among allowed positions: pos2 (d=4) then pos0 (d=9).
14501528
assert_eq!(
14511529
cands
@@ -1493,6 +1571,7 @@ mod e2e_tests {
14931571
&[0.0, 0.0],
14941572
VectorSearchMetric::L2,
14951573
3,
1574+
3,
14961575
None,
14971576
&mut factory,
14981577
&opts,
@@ -1533,12 +1612,13 @@ mod e2e_tests {
15331612
},
15341613
);
15351614
let opts = HashMap::new();
1536-
let cands = PkVectorOrchestrator::new(make_reader(file_io, table_path))
1615+
let result = PkVectorOrchestrator::new(make_reader(file_io, table_path))
15371616
.search_candidates(
15381617
&[split],
15391618
&[0.0, 0.0],
15401619
VectorSearchMetric::L2,
15411620
2,
1621+
2,
15421622
None,
15431623
&mut factory,
15441624
&opts,
@@ -1547,7 +1627,8 @@ mod e2e_tests {
15471627
)
15481628
.await
15491629
.unwrap();
1550-
assert!(cands.is_empty());
1630+
assert!(result.indexed.is_empty());
1631+
assert!(result.exact.is_empty());
15511632
}
15521633

15531634
#[test]

0 commit comments

Comments
 (0)