Skip to content

Commit 907072d

Browse files
committed
feat: add IVF-PQ L2 distance range search
1 parent 643b4ed commit 907072d

11 files changed

Lines changed: 1679 additions & 34 deletions

File tree

‎core/README.md‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,28 @@
2222
`paimon-vindex-core` contains the Rust implementations and seek-based readers
2323
for IVF-FLAT, IVF-SQ, IVF-PQ, IVF-RQ, and DiskANN.
2424

25-
The Rust reader supports distance range search for IVF-FLAT, IVF-RQ, and IVF-SQ with
25+
The Rust reader supports distance range search for IVF-FLAT, IVF-RQ, IVF-SQ, and IVF-PQ with
2626
squared L2, using `DistanceBand`, `VectorRangeSearchParams`, and CSR
27-
`RangeSearchResult` buffers. All three families support single and batch queries,
27+
`RangeSearchResult` buffers. All four families support single and batch queries,
2828
with or without a serialized Roaring allow-list, and a fixed positive `nprobe`.
2929
IVF-FLAT tests exact distances; IVF-RQ tests its one-bit or full multi-bit
30-
estimated distances; IVF-SQ tests scalar-quantized estimates. Results are uncapped
31-
and unordered. Probing every list removes the IVF coverage gap, but not the
32-
quantization error of IVF-RQ or IVF-SQ. The range path does not change top-K
30+
estimated distances; IVF-SQ tests scalar-quantized estimates; IVF-PQ tests
31+
floating-point ADC estimates for 4-bit and
32+
8-bit codes, with optional residual encoding and OPQ. Results are uncapped and
33+
unordered. Probing every list removes the IVF coverage gap, but not the
34+
compressed families' quantization error. The range path does not change top-K
3335
search or the v1 storage format.
3436

37+
PQ range uses direct squared-L2 subvector lookup tables and sums their selected
38+
entries in subquantizer order. It does not use top-K's u8 FastScan tables or
39+
precomputed norm identities; membership is independent of list size, batch
40+
size, and `optimize_for_search`. Finite estimates can therefore differ from
41+
top-K's distances. Every filter-eligible row is fully evaluated, with no early
42+
abandonment. Non-finite consumed estimates, rotated queries, or coarse distances
43+
return `InvalidData`, including overflow and distances to unselected centroids.
44+
Unique non-empty lists are read once per call, with oversized lists streamed
45+
through the existing bounded reader. DiskANN range remains unsupported.
46+
3547
See the [range search guide](../docs/range-search.html) for membership,
3648
validation, filtering, and statistics. C/JNI range bindings are not included.
3749

@@ -61,7 +73,7 @@ Large lists stream in bounded chunks; scan scratch is reused, and a finite upper
6173
allows entire SQ blocks to stop after their partial distances reach that cut.
6274
Result memory still grows with the number of hits. Cache hits are excluded from
6375
`call_stats().list_reads()`. Range support does not extend to other metrics,
64-
IVF-PQ, DiskANN, or language bindings in this change.
76+
DiskANN, or language bindings.
6577

6678
The crate ships its [normative v1 storage-format specification](STORAGE_FORMAT.md)
6779
and byte-exact fixtures. Project documentation, language bindings, and

‎core/src/collect.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ pub(crate) trait Collector {
6464
fn cutoff(&self) -> f32;
6565

6666
/// Delivers one row, with the value the family's scan computed for it. For
67-
/// IVF-Flat that value is an exact distance; for IVF-RQ and IVF-SQ it is an
67+
/// IVF-Flat that value is an exact distance; for IVF-RQ, IVF-SQ and IVF-PQ it is an
6868
/// estimate.
6969
///
7070
/// Fallible because a collector may own a resource the scan cannot see: the

‎core/src/index.rs‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1867,8 +1867,9 @@ impl<R: SeekRead> VectorIndexReader<R> {
18671867

18681868
/// Distance range search. For the contract see
18691869
/// [`IVFFlatIndexReader::range_search`] (exact distances),
1870-
/// [`IVFRQIndexReader::range_search`] (RQ estimates), and
1871-
/// [`IVFSQIndexReader::range_search`] (SQ estimates). Only L2 is supported.
1870+
/// [`IVFRQIndexReader::range_search`] (RQ estimates),
1871+
/// [`IVFSQIndexReader::range_search`] (SQ estimates), and
1872+
/// [`IVFPQIndexReader::range_search`] (PQ estimates). Only L2 is supported.
18721873
///
18731874
/// The empty-band short-circuit lives **inside each family's reader**, so a
18741875
/// family that cannot do range search at all still fails loud for every
@@ -1884,15 +1885,16 @@ impl<R: SeekRead> VectorIndexReader<R> {
18841885
Self::IvfFlat(reader) => reader.range_search(query, params),
18851886
Self::IvfRq(reader) => reader.range_search(query, params),
18861887
Self::IvfSq(reader) => reader.range_search(query, params),
1887-
Self::IvfPq(_) => Err(range_unsupported("ivf_pq")),
1888+
Self::IvfPq(reader) => reader.range_search(query, params),
18881889
Self::DiskAnn(_) => Err(range_unsupported("diskann")),
18891890
}
18901891
}
18911892

18921893
/// Range search restricted to a serialized Roaring allow-list. For the
18931894
/// contract see [`IVFFlatIndexReader::range_search_with_roaring_filter`],
1894-
/// [`IVFRQIndexReader::range_search`], and
1895-
/// [`IVFSQIndexReader::range_search_with_roaring_filter`].
1895+
/// [`IVFRQIndexReader::range_search`],
1896+
/// [`IVFSQIndexReader::range_search_with_roaring_filter`], and
1897+
/// [`IVFPQIndexReader::range_search_with_roaring_filter`].
18961898
pub fn range_search_with_roaring_filter(
18971899
&mut self,
18981900
query: &[f32],
@@ -1909,7 +1911,7 @@ impl<R: SeekRead> VectorIndexReader<R> {
19091911
Self::IvfFlat(reader) => reader.range_search_with_filter(query, params, Some(&filter)),
19101912
Self::IvfRq(reader) => reader.range_search_with_filter(query, params, Some(&filter)),
19111913
Self::IvfSq(reader) => reader.range_search_with_filter(query, params, Some(&filter)),
1912-
Self::IvfPq(_) => Err(range_unsupported("ivf_pq")),
1914+
Self::IvfPq(reader) => reader.range_search_with_filter(query, params, Some(&filter)),
19131915
Self::DiskAnn(_) => Err(range_unsupported("diskann")),
19141916
}
19151917
}
@@ -1928,7 +1930,7 @@ impl<R: SeekRead> VectorIndexReader<R> {
19281930
Self::IvfFlat(reader) => reader.range_search_batch(queries, query_count, params),
19291931
Self::IvfRq(reader) => reader.range_search_batch(queries, query_count, params),
19301932
Self::IvfSq(reader) => reader.range_search_batch(queries, query_count, params),
1931-
Self::IvfPq(_) => Err(range_unsupported("ivf_pq")),
1933+
Self::IvfPq(reader) => reader.range_search_batch(queries, query_count, params),
19321934
Self::DiskAnn(_) => Err(range_unsupported("diskann")),
19331935
}
19341936
}
@@ -1954,7 +1956,9 @@ impl<R: SeekRead> VectorIndexReader<R> {
19541956
Self::IvfSq(reader) => {
19551957
reader.range_search_batch_with_filter(queries, query_count, params, Some(&filter))
19561958
}
1957-
Self::IvfPq(_) => Err(range_unsupported("ivf_pq")),
1959+
Self::IvfPq(reader) => {
1960+
reader.range_search_batch_with_filter(queries, query_count, params, Some(&filter))
1961+
}
19581962
Self::DiskAnn(_) => Err(range_unsupported("diskann")),
19591963
}
19601964
}

‎core/src/io.rs‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -934,6 +934,17 @@ impl<R: SeekRead> IVFPQIndexReader<R> {
934934
&mut self,
935935
list_id: usize,
936936
mut consume: impl FnMut(&ProductQuantizer, &[i64], &[u8]),
937+
) -> io::Result<()> {
938+
self.try_for_each_streamed_list_chunk(list_id, |pq, ids, codes| {
939+
consume(pq, ids, codes);
940+
Ok(())
941+
})
942+
}
943+
944+
pub(crate) fn try_for_each_streamed_list_chunk(
945+
&mut self,
946+
list_id: usize,
947+
mut consume: impl FnMut(&ProductQuantizer, &[i64], &[u8]) -> io::Result<()>,
937948
) -> io::Result<()> {
938949
self.ensure_loaded()?;
939950
let count = self.list_counts[list_id] as usize;
@@ -1004,7 +1015,7 @@ impl<R: SeekRead> IVFPQIndexReader<R> {
10041015
.pread(&mut [ReadRequest::new(chunk_offset, payload.codes_mut())])?;
10051016
}
10061017
let row_end = row_start + chunk_rows;
1007-
consume(&self.pq, &ids[row_start..row_end], payload.codes());
1018+
consume(&self.pq, &ids[row_start..row_end], payload.codes())?;
10081019
row_start = row_end;
10091020
}
10101021
Ok(())

0 commit comments

Comments
 (0)