This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-vector-index.git
The following commit(s) were added to refs/heads/main by this push:
new f80e556 Support high-dimensional DiskANN vectors (#114)
f80e556 is described below
commit f80e5568dc476f7e0bd56c73c88a0b46bf8018ca
Author: jerry <[email protected]>
AuthorDate: Fri Oct 9 16:11:50 2026 +0800
Support high-dimensional DiskANN vectors (#114)
---
core/STORAGE_FORMAT.md | 2 +-
core/src/diskann.rs | 3 -
core/src/diskann_io.rs | 7 +-
core/src/diskann_search.rs | 228 +++++++++++++++++++++++++++++++++++++--------
core/src/index.rs | 29 +++++-
docs/diskann.html | 4 +-
6 files changed, 219 insertions(+), 54 deletions(-)
diff --git a/core/STORAGE_FORMAT.md b/core/STORAGE_FORMAT.md
index 27ec901..2ef8098 100644
--- a/core/STORAGE_FORMAT.md
+++ b/core/STORAGE_FORMAT.md
@@ -103,7 +103,7 @@ a v1 writer may emit requires version 2; it is not a
compatible v1 extension.
The following header invariants are part of v1 and are enforced symmetrically
by
the writer and reader:
-- `1 <= dimension <= 1024`;
+- `1 <= dimension <= u32::MAX`, and `dimension * element_size <= u32::MAX`;
- `metric` is `0`, `1`, or `2`;
- `1 <= vector_count <= u32::MAX` and `entry_node < vector_count`;
- `1 <= pq_m <= dimension`, and `pq_bits` is 4 or 8;
diff --git a/core/src/diskann.rs b/core/src/diskann.rs
index b668b59..2406c93 100644
--- a/core/src/diskann.rs
+++ b/core/src/diskann.rs
@@ -112,9 +112,6 @@ pub(crate) fn validate_diskann_format_configuration(
if dimension == 0 {
return Err(invalid_input("DiskANN dimension must be greater than 0"));
}
- if dimension > 1024 {
- return Err(invalid_input("DiskANN v1 dimension must be at most 1024"));
- }
if pq_m == 0 {
return Err(invalid_input("DiskANN pq.m must be greater than 0"));
}
diff --git a/core/src/diskann_io.rs b/core/src/diskann_io.rs
index b22a2e8..1ccf68b 100644
--- a/core/src/diskann_io.rs
+++ b/core/src/diskann_io.rs
@@ -2002,11 +2002,6 @@ impl DiskAnnHeader {
let vector_record_size = dimension_u32
.checked_mul(build.raw_vector_encoding.element_size() as u32)
.ok_or_else(|| invalid_input("DiskANN vector record size overflows
u32"))?;
- if vector_record_size == 0 || vector_record_size > DISKANN_PAGE_SIZE {
- return Err(invalid_input(
- "DiskANN raw vector does not fit in a logical page",
- ));
- }
if build.storage_layout == DiskAnnStorageLayout::Interleaved {
let maximum_adjacency_bytes = max_degree
.checked_mul(size_of::<u32>() as u32)
@@ -2190,7 +2185,7 @@ impl DiskAnnHeader {
}
fn validate_layout(&self) -> io::Result<()> {
- if self.dimension == 0 || self.dimension > 1024 {
+ if self.dimension == 0 {
return Err(invalid_data("invalid DiskANN dimension"));
}
if self.vector_count == 0 || self.vector_count > u32::MAX as u64 {
diff --git a/core/src/diskann_search.rs b/core/src/diskann_search.rs
index 959e8f0..7ed6aa8 100644
--- a/core/src/diskann_search.rs
+++ b/core/src/diskann_search.rs
@@ -21,8 +21,8 @@ use crate::diskann_io::{
DISKANN_PAGE_SIZE,
};
use crate::distance::{
- fvec_distance, fvec_l2sqr, pq_distance_four_codes, pq_distance_from_table,
preprocess_vectors,
- MetricType,
+ fvec_distance, fvec_inner_product, fvec_l2sqr, fvec_norm_l2sqr,
pq_distance_four_codes,
+ pq_distance_from_table, preprocess_vectors, MetricType,
};
use crate::index_io_util::decode_roaring_filter;
use crate::io::{ReadRequest, SeekRead};
@@ -3453,27 +3453,48 @@ fn metric_distance_from_f16_le_bytes(
"DiskANN f16 raw-vector record has invalid length",
));
}
- if query.len() > 1024 {
- return Err(invalid_data(
- "DiskANN f16 raw-vector dimension exceeds the v1 limit",
- ));
- }
let mut decoded = [0.0f32; 1024];
- for (slot, component) in decoded[..query.len()]
- .iter_mut()
- .zip(bytes.chunks_exact(size_of::<u16>()))
- {
- let value = half::f16::from_bits(u16::from_le_bytes(
- component
- .try_into()
- .expect("validated two-byte raw-vector component"),
- ));
- if !value.is_finite() {
- return Err(invalid_data("DiskANN raw vectors must be finite"));
+ let fits_stack = query.len() <= decoded.len();
+ let mut dot = 0.0;
+ let mut query_norm = 0.0;
+ let mut vector_norm = 0.0;
+ for (query_chunk, bytes_chunk) in
query.chunks(1024).zip(bytes.chunks(2048)) {
+ let values = &mut decoded[..query_chunk.len()];
+ for (slot, component) in values
+ .iter_mut()
+ .zip(bytes_chunk.chunks_exact(size_of::<u16>()))
+ {
+ let value = half::f16::from_bits(u16::from_le_bytes(
+ component
+ .try_into()
+ .expect("validated two-byte raw-vector component"),
+ ));
+ if !value.is_finite() {
+ return Err(invalid_data("DiskANN raw vectors must be finite"));
+ }
+ *slot = value.to_f32();
+ }
+ if fits_stack {
+ return Ok(fvec_distance(query, values, metric));
+ }
+ dot += fvec_inner_product(query_chunk, values);
+ if metric == MetricType::Cosine {
+ query_norm += fvec_norm_l2sqr(query_chunk);
+ vector_norm += fvec_norm_l2sqr(values);
}
- *slot = value.to_f32();
}
- Ok(fvec_distance(query, &decoded[..query.len()], metric))
+ Ok(match metric {
+ MetricType::InnerProduct => -dot,
+ MetricType::Cosine => {
+ let denominator = query_norm.sqrt() * vector_norm.sqrt();
+ if denominator > 0.0 {
+ 1.0 - dot / denominator
+ } else {
+ 1.0
+ }
+ }
+ MetricType::L2 => unreachable!("L2 uses the selected raw-vector
kernel"),
+ })
}
fn l2_distance_from_f16_le_bytes(query: &[f32], bytes: &[u8]) ->
io::Result<f32> {
@@ -3486,11 +3507,6 @@ fn l2_distance_from_f16_le_bytes(query: &[f32], bytes:
&[u8]) -> io::Result<f32>
"DiskANN f16 raw-vector record has invalid length",
));
}
- if query.len() > 1024 {
- return Err(invalid_data(
- "DiskANN f16 raw-vector dimension exceeds the v1 limit",
- ));
- }
#[cfg(all(target_endian = "little", target_arch = "aarch64"))]
if query.len() >= 4 {
// SAFETY: AArch64 guarantees NEON. The kernel uses unaligned loads,
@@ -3499,23 +3515,27 @@ fn l2_distance_from_f16_le_bytes(query: &[f32], bytes:
&[u8]) -> io::Result<f32>
return unsafe { l2_distance_from_f16_le_bytes_neon(query, bytes) };
}
let mut bits = [0u16; 1024];
- for (slot, component) in bits[..query.len()]
- .iter_mut()
- .zip(bytes.chunks_exact(size_of::<u16>()))
- {
- *slot = u16::from_le_bytes(
- component
- .try_into()
- .expect("validated two-byte raw-vector component"),
- );
- }
- let values = bits[..query.len()].reinterpret_cast::<half::f16>();
- if values.iter().any(|value| !value.is_finite()) {
- return Err(invalid_data("DiskANN raw vectors must be finite"));
- }
let mut decoded = [0.0f32; 1024];
- values.convert_to_f32_slice(&mut decoded[..query.len()]);
- Ok(fvec_l2sqr(query, &decoded[..query.len()]))
+ let mut distance = 0.0;
+ for (query_chunk, bytes_chunk) in
query.chunks(1024).zip(bytes.chunks(2048)) {
+ for (slot, component) in bits[..query_chunk.len()]
+ .iter_mut()
+ .zip(bytes_chunk.chunks_exact(size_of::<u16>()))
+ {
+ *slot = u16::from_le_bytes(
+ component
+ .try_into()
+ .expect("validated two-byte raw-vector component"),
+ );
+ }
+ let values = bits[..query_chunk.len()].reinterpret_cast::<half::f16>();
+ if values.iter().any(|value| !value.is_finite()) {
+ return Err(invalid_data("DiskANN raw vectors must be finite"));
+ }
+ values.convert_to_f32_slice(&mut decoded[..query_chunk.len()]);
+ distance += fvec_l2sqr(query_chunk, &decoded[..query_chunk.len()]);
+ }
+ Ok(distance)
}
#[cfg(all(target_endian = "little", target_arch = "aarch64"))]
@@ -4018,6 +4038,63 @@ mod tests {
);
}
+ #[test]
+ fn diskann_f16_high_dimension_distances_support_all_metrics() {
+ let query = vec![1.0f32; 2560];
+ let bytes = half::f16::ONE.to_bits().to_le_bytes().repeat(query.len());
+ for (metric, expected) in [
+ (MetricType::L2, 0.0),
+ (MetricType::InnerProduct, -(query.len() as f32)),
+ (MetricType::Cosine, 0.0),
+ ] {
+ assert_eq!(
+ raw_vector_distance(
+ &query,
+ &bytes,
+ DiskAnnRawVectorEncoding::F16,
+ metric,
+ RawVectorDistanceKernel::Scalar,
+ )
+ .unwrap(),
+ expected
+ );
+ }
+
+ let query = (0..2560)
+ .map(|index| (index % 17) as f32 - 8.0)
+ .collect::<Vec<_>>();
+ let vector = (0..2560)
+ .map(|index| ((index % 13) as f32 - 6.0) / 4.0)
+ .collect::<Vec<_>>();
+ let bytes = vector
+ .iter()
+ .flat_map(|&value|
half::f16::from_f32(value).to_bits().to_le_bytes())
+ .collect::<Vec<_>>();
+ for metric in [MetricType::InnerProduct, MetricType::Cosine] {
+ let expected = fvec_distance(&query, &vector, metric);
+ let actual = metric_distance_from_f16_le_bytes(&query, &bytes,
metric).unwrap();
+ assert!((actual - expected).abs() <= expected.abs().max(1.0) *
1.0e-5);
+ }
+ assert_eq!(
+ metric_distance_from_f16_le_bytes(
+ &query,
+ &vec![0; query.len() * 2],
+ MetricType::Cosine,
+ )
+ .unwrap(),
+ 1.0
+ );
+ let mut non_finite = bytes;
+ non_finite[2048 * 2..2048 * 2 + 2]
+ .copy_from_slice(&half::f16::INFINITY.to_bits().to_le_bytes());
+ assert_eq!(
+ metric_distance_from_f16_le_bytes(&query, &non_finite,
MetricType::InnerProduct)
+ .unwrap_err()
+ .kind(),
+ io::ErrorKind::InvalidData
+ );
+ }
+
#[test]
fn diskann_compact_f16_roundtrips_dense_vector_records() {
let dimension = 8;
@@ -4055,6 +4132,75 @@ mod tests {
assert_eq!(distances, vec![0.0]);
}
+ #[test]
+ fn diskann_compact_roundtrips_vectors_larger_than_a_page() {
+ let dimension = 2560;
+ let count = 16;
+ let data = (0..count)
+ .flat_map(|row| (0..dimension).map(move |column| (row * 7 + column
% 13) as f32))
+ .collect::<Vec<_>>();
+ let ids = (1000..1000 + count as i64).collect::<Vec<_>>();
+ for encoding in [DiskAnnRawVectorEncoding::F32,
DiskAnnRawVectorEncoding::F16] {
+ let mut index = DiskAnnIndex::with_pq_bits(
+ dimension,
+ MetricType::L2,
+ 16,
+ 4,
+ DiskAnnBuildParams {
+ max_degree: 4,
+ build_search_list_size: 8,
+ raw_vector_encoding: encoding,
+ ..DiskAnnBuildParams::default()
+ },
+ );
+ index.train(&data, count).unwrap();
+ index.add(&data, &ids);
+ let mut bytes = Vec::new();
+ write_diskann_index(&index, &mut PosWriter::new(&mut
bytes)).unwrap();
+ let header =
crate::diskann_io::DiskAnnHeader::decode(&bytes[..256]).unwrap();
+ assert!(header.vector_record_size >
crate::diskann_io::DISKANN_PAGE_SIZE);
+ let mut reader =
DiskAnnIndexReader::open(Cursor::new(bytes)).unwrap();
+ let (result_ids, distances) = reader.search(&data[..dimension], 1,
16).unwrap();
+ assert_eq!(result_ids, vec![ids[0]]);
+ assert_eq!(distances, vec![0.0]);
+ let (batch_ids, batch_distances) =
+ reader.search_batch(&data[..dimension * 2], 1, 16).unwrap();
+ assert_eq!(batch_ids, ids[..2]);
+ assert_eq!(batch_distances, vec![0.0; 2]);
+ }
+ }
+
+ #[test]
+ fn diskann_compact_f32_high_dimension_preserves_inner_product_distance() {
+ let dimension = 2560;
+ let count = 16;
+ let data = (0..count)
+ .flat_map(|row| (0..dimension).map(move |column| f32::from(column
== row)))
+ .collect::<Vec<_>>();
+ let ids = (1000..1000 + count as i64).collect::<Vec<_>>();
+ let mut index = DiskAnnIndex::with_pq_bits(
+ dimension,
+ MetricType::InnerProduct,
+ 16,
+ 4,
+ DiskAnnBuildParams {
+ max_degree: 4,
+ build_search_list_size: 8,
+ raw_vector_encoding: DiskAnnRawVectorEncoding::F32,
+ ..DiskAnnBuildParams::default()
+ },
+ );
+ index.train(&data, count).unwrap();
+ index.add(&data, &ids);
+ let mut bytes = Vec::new();
+ write_diskann_index(&index, &mut PosWriter::new(&mut bytes)).unwrap();
+ let mut reader = DiskAnnIndexReader::open(Cursor::new(bytes)).unwrap();
+
+ let (result_ids, distances) = reader.search(&data[..dimension], 1,
count).unwrap();
+ assert_eq!(result_ids, vec![ids[0]]);
+ assert_eq!(distances, vec![-1.0]);
+ }
+
#[test]
fn
diskann_interleaved_layout_searches_and_batches_without_a_vector_section() {
let dimension = 8;
diff --git a/core/src/index.rs b/core/src/index.rs
index 7640e3c..8e0a792 100644
--- a/core/src/index.rs
+++ b/core/src/index.rs
@@ -4276,7 +4276,6 @@ mod tests {
#[test]
fn diskann_config_rejects_invalid_shape_and_build_parameters() {
for (extra, expected) in [
- (vec![("dimension", "1025")], "at most 1024"),
(vec![("pq.m", "0")], "pq.m must be greater than 0"),
(vec![("pq.m", "129")], "must not exceed dimension"),
(vec![("pq.bits", "6")], "pq.bits must be 4 or 8"),
@@ -4334,6 +4333,34 @@ mod tests {
}
}
+ #[test]
+ fn diskann_config_accepts_high_dimension_compact_layout() {
+ let config = VectorIndexConfig::from_options(&options(&[
+ ("index.type", "diskann"),
+ ("dimension", "2560"),
+ ("metric", "inner_product"),
+ ("pq.bits", "8"),
+ ("pq.code-ratio", "0.0625"),
+ ("diskann.storage-layout", "compact"),
+ ("diskann.raw-vector-encoding", "f32"),
+ ]))
+ .unwrap();
+
+ let VectorIndexConfig::DiskAnn {
+ pq_m,
+ pq_bits,
+ build,
+ ..
+ } = config
+ else {
+ panic!("expected DiskANN config");
+ };
+ assert_eq!(pq_m, 640);
+ assert_eq!(pq_bits, 8);
+ assert_eq!(build.storage_layout, DiskAnnStorageLayout::Compact);
+ assert_eq!(build.raw_vector_encoding, DiskAnnRawVectorEncoding::F32);
+ }
+
#[test]
fn diskann_trainer_uses_configured_dimension() {
let trainer = VectorIndexTrainer::new(VectorIndexConfig::DiskAnn {
diff --git a/docs/diskann.html b/docs/diskann.html
index cf7ef12..4a1dbe7 100644
--- a/docs/diskann.html
+++ b/docs/diskann.html
@@ -153,7 +153,7 @@ ids, distances = reader.search(
<section class="article-section" id="build-parameters">
<h2>Build parameters</h2>
<div
class="table-wrap"><table><thead><tr><th>Parameter</th><th>Default /
validation</th><th>What it controls</th><th>When
increased</th></tr></thead><tbody>
- <tr><td><code>dimension</code></td><td>Inferred by Java/Python
one-shot training; required by streaming APIs;
<code>1..=1024</code></td><td>Raw vector width and PQ codebook
size.</td><td>Raw-vector payload and rerank work grow linearly.</td></tr>
+ <tr><td><code>dimension</code></td><td>Inferred by Java/Python
one-shot training; required by streaming APIs; positive integer</td><td>Raw
vector width and PQ codebook size. Interleaved records must still fit in a 4
KiB page.</td><td>Raw-vector payload and rerank work grow linearly.</td></tr>
<tr><td><code>metric</code></td><td>Required: <code>l2</code>,
<code>inner_product</code>, or <code>cosine</code></td><td>Distance used by
graph build, PQ navigation, and exact reranking. Inner-product scores are
negative dot products; cosine scores are <code>1 -
cosine</code>.</td><td>Semantic, not inferred. Cosine vectors and queries are
normalized internally; zero vectors retain distance 1.</td></tr>
<tr><td><code>target-recall</code></td><td>Optional;
<code>0..=1</code></td><td>Selects <code>fast_build</code> at ≤ 0.85,
<code>high_recall</code> at ≥ 0.97, and <code>balanced</code> between them when
no preset is pinned.</td><td>This is a deterministic starting policy, not a
measured guarantee; validate on held-out queries.</td></tr>
<tr><td><code>max-bytes-per-vector</code></td><td>Optional
positive persisted-size budget</td><td>Guides PQ width and may select 4-bit PQ
and F16 rerank records. Configuration is rejected before training when the
estimated row bytes plus amortized fixed data exceed the budget.</td><td>A
larger allowance preserves more PQ/raw precision. The check is conservative;
compression, headers, and alignment mean it remains a per-vector sizing bound
rather than an exact final-file-size [...]
@@ -254,7 +254,7 @@ ids, distances = reader.search(
<section class="article-section" id="boundaries">
<h2>Current implementation boundaries</h2>
<ul>
- <li>v1 supports squared L2, negative-dot-product inner product,
and <code>1 - cosine</code>, with dimensions up to 1024 and 4/8-bit PQ.</li>
+ <li>v1 supports squared L2, negative-dot-product inner product,
and <code>1 - cosine</code>, with positive dimensions and 4/8-bit PQ.
Interleaved records remain limited by the 4 KiB page.</li>
<li>The index is static. FreshDiskANN-style insert, delete,
consolidation, and graph repair are not implemented.</li>
<li>Serialization builds the complete graph in memory. Raw vectors
are stored as explicit F32 or F16 scalar records; there is no PQ-only result
mode.</li>
<li>Recall is sensitive to both PQ capacity and graph/search
budgets. On the public SIFT1M and GIST1M runs, the default ratio resolved to
<code>m=32</code> and <code>m=240</code>. DiskANN Recall@10 was 0.995 and 0.973
at <code>l_search=200</code>, while GIST rose to 0.991 at
<code>l_search=500</code>; use the public-corpus procedure rather than
synthetic vectors when validating a new ratio.</li>