diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 9a11ee6..064a8d9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -40,6 +40,14 @@ jobs:
steps:
- uses: actions/checkout@v4
+ - name: Set up minimum supported Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.9'
+
+ - name: Check benchmark tools on Python 3.9
+ run: python3 -m py_compile tools/convert_ann_benchmarks.py
+
- name: Validate .asf.yaml
run: pip install pyyaml -q && python3 tools/validate_asf_yaml.py
@@ -181,6 +189,8 @@ jobs:
"$(pwd)/target/release/libpaimon_vindex_jni.so"
java -cp java/target/test-classes:java/target/classes org.apache.paimon.index.vector.VectorIndexNativeHandleSafetyTest \
"$(pwd)/target/release/libpaimon_vindex_jni.so"
+ env:
+ RAYON_NUM_THREADS: 4
python-build:
runs-on: ubuntu-latest
@@ -278,7 +288,7 @@ jobs:
reader = VectorIndexReader(Input(output.getvalue()))
reader.optimize_for_search()
- result_ids, distances = reader.search(data[0], SearchParams(top_k=2, nprobe=2))
+ result_ids, distances = reader.search(data[0], SearchParams.ivf(top_k=2, nprobe=2))
assert result_ids[0] == 1, result_ids
assert np.isfinite(distances[0]), distances
reader.close()
@@ -316,7 +326,7 @@ jobs:
reader = VectorIndexReader(Input(output.getvalue()))
reader.optimize_for_search()
- result_ids, distances = reader.search(data[0], SearchParams(top_k=2, nprobe=2))
+ result_ids, distances = reader.search(data[0], SearchParams.ivf(top_k=2, nprobe=2))
assert result_ids[0] == 1, result_ids
assert np.isfinite(distances[0]), distances
reader.close()
diff --git a/Cargo.lock b/Cargo.lock
index 6ea5337..1c69535 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -659,9 +659,11 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "paimon-vindex-core"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"criterion",
+ "half",
+ "libc",
"matrixmultiply",
"nalgebra",
"rand",
@@ -671,7 +673,7 @@ dependencies = [
[[package]]
name = "paimon-vindex-ffi"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"cbindgen",
"paimon-vindex-core",
@@ -679,7 +681,7 @@ dependencies = [
[[package]]
name = "paimon-vindex-jni"
-version = "0.2.0"
+version = "0.3.0"
dependencies = [
"jni",
"paimon-vindex-core",
diff --git a/README.md b/README.md
index f385253..953c53e 100644
--- a/README.md
+++ b/README.md
@@ -24,21 +24,31 @@
Apache Paimon Vector Index is a pure Rust vector indexing library designed for
Apache Paimon and data lake storage such as S3, HDFS, and OSS. Its seek-based
-readers load only the IVF lists selected by a query.
-
-The library supports IVF-FLAT, IVF-PQ, IVF-RQ, IVF-HNSW-FLAT, and
-IVF-HNSW-SQ through shared Rust, C, C++, Java/JNI, and Python APIs.
+readers load only the index pages needed by each query.
+
+The library supports IVF-FLAT, IVF-SQ, IVF-PQ, IVF-RQ, and DiskANN through
+shared Rust, C, C++, Java/JNI, and Python APIs. IVF-RQ stores deterministic
+rotated residuals as 1–8 bit planes (4 by default), uses a bounded sign-plane
+coarse pass before full refinement, and exposes the persisted width as
+`rq_bits` metadata. DiskANN combines
+a Vamana graph, resident PQ codes, paged adjacency/raw vectors, and F16 or F32
+reranking for indexes queried from local SSD or object storage. Its parallel
+builder uses contiguous fixed-capacity adjacency, adaptive dense/sparse visited
+state, heap frontiers, and reusable prune scratch; query-local adjacency
+retention is bounded and the shared cold cache is sharded for concurrent hits.
## Documentation
- [Index selection and architecture](docs/index.html): compare all index
families and open the detailed page for each implementation.
+- [DiskANN positioning and tuning](docs/diskann.html): decide when to use
+ DiskANN and configure build, search, local-SSD, and object-store parameters.
- [API and language bindings](docs/api.html): lifecycle, query parameters,
warm-up, Rust, C, C++, Java, Python, and metadata filter pushdown.
- [Development and benchmarks](docs/development.html): workspace layout,
build and test commands, ANN benchmarks, and storage compatibility checks.
-- [Storage format specification](STORAGE_FORMAT.md): normative v1 binary layout
- and compatibility policy.
+- [Storage format specification](core/STORAGE_FORMAT.md): normative v1 binary
+ layout and compatibility policy.
GitHub shows committed HTML files as source. To view the styled documentation,
clone the repository and serve `docs/` from the repository root:
diff --git a/STORAGE_FORMAT.md b/STORAGE_FORMAT.md
index a24b084..0206a9b 100644
--- a/STORAGE_FORMAT.md
+++ b/STORAGE_FORMAT.md
@@ -19,301 +19,9 @@
# Vector Index Storage Format
-This document describes the v1 on-disk formats written by the Rust core
-library. Version 1 is the first release format. Pre-release layouts are not part
-of the compatibility contract.
+The normative storage-format specification is shipped with the Rust core crate:
+[core/STORAGE_FORMAT.md](core/STORAGE_FORMAT.md).
-## Compatibility Policy
-
-- All multi-byte integers and `f32` values are little-endian.
-- The unified reader dispatches by the first 4-byte magic value.
-- Magic names below show the `u32` constants in human-readable big-endian form.
- Because the fields are little-endian, the raw file bytes for those constants
- appear in reverse ASCII order.
-- Readers reject unknown magic values, unknown versions, unknown required flags,
- non-zero reserved bytes, invalid section sizes, negative counts, and malformed
- list payload metadata.
-- Incompatible on-disk changes require a new format version. Version 1 readers
- do not attempt to read future versions.
-- Reserved bytes are written as zero and must be read back as zero. Future
- extensions must use flags or a new format version rather than repurposing
- non-zero reserved bytes within v1.
-- Index files have no outer container, footer, checksum, compression envelope,
- or schema registry. The complete file starts at byte offset 0 with one of the
- headers below.
-- File integrity, including length and checksum validation, is guaranteed by
- the outer Paimon file/manifest layer rather than by an embedded index footer.
-- Roaring row-id filters are a query-time API payload. They are not embedded in
- any index file format.
-
-## Common Encodings
-
-### Delta-Varint IDs
-
-IVF-PQ, IVF-FLAT, and IVF-RQ v1 sort each non-empty list by signed row id before writing.
-The first id is stored as `base_id: i64`. The id stream then stores one unsigned
-LEB128 varint per id, including the first id's zero delta. Each delta is computed
-with wrapping unsigned subtraction from the previous signed id. Readers reject a
-decoded sequence that is not monotonically non-decreasing in signed order.
-
-### HNSW Graph Section
-
-IVF-HNSW-FLAT and IVF-HNSW-SQ store one graph section per non-empty list. The
-section starts with a fixed header, followed by a contiguous sequence of
-unsigned LEB128 varints. Neighbor ids within each adjacency group are sorted by
-local vector id and stored as unsigned deltas from the previous neighbor id,
-with an initial previous id of `0`:
-
-| Field | Count |
-| --- | --- |
-| `graph_magic` | 1 little-endian `u32`, `HWGR` (`0x48574752`) |
-| `graph_version` | 1 little-endian `u32`, currently `1` |
-| `graph_flags` | 1 little-endian `u32`; bit 0 delta-varint adjacency is required |
-| `graph_count` | 1 varint |
-| `entry_point` | 1 varint |
-| `max_observed_level` | 1 varint |
-| `level[node]` | `graph_count` varints |
-| `degree[node][level]` followed by neighbor id deltas | one group for each node level |
-
-Each node has levels `0..=level[node]`. A level-0 node may have at most `2 * m`
-neighbors, and higher levels may have at most `m` neighbors.
-
-## IVF-PQ v1
-
-Magic: `IVPQ` (`0x49565051`). Version: `1`. Header size: 64 bytes.
-
-| Offset | Size | Type | Field |
-| ---: | ---: | --- | --- |
-| 0 | 4 | `u32` | magic |
-| 4 | 4 | `u32` | version |
-| 8 | 4 | `i32` | dimension `d` |
-| 12 | 4 | `i32` | IVF list count `nlist` |
-| 16 | 4 | `i32` | PQ subquantizer count `m` |
-| 20 | 4 | `i32` | centroid count per subquantizer `ksub` |
-| 24 | 4 | `i32` | subvector dimension `dsub` |
-| 28 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
-| 32 | 8 | `i64` | total vector count |
-| 40 | 4 | `u32` | flags |
-| 44 | 20 | bytes | reserved |
-
-Flags:
-
-| Bit | Meaning |
-| ---: | --- |
-| 0 | OPQ rotation matrix is present |
-| 1 | PQ codes are trained/stored by residual |
-| 2 | delta-varint ids are used; required in v1 |
-| 3 | PQ codes are transposed by subquantizer; required in v1 |
-
-Sections after the header:
-
-1. Optional OPQ rotation matrix: `d * d` `f32` values when flag bit 0 is set.
-2. IVF coarse centroids: `nlist * d` `f32` values.
-3. PQ centroids: `m * ksub * dsub` `f32` values.
-4. Offset table: `nlist` entries of `(offset: i64, count: i32, id_bytes_len: i32)`.
-5. List payloads.
-
-For each non-empty list payload:
-
-| Field | Type | Notes |
-| --- | --- | --- |
-| `base_id` | `i64` | first sorted row id |
-| `id_bytes_len` | `i32` | byte length of encoded id stream |
-| `id_bytes` | bytes | delta-varint ids |
-| `codes` | bytes | transposed PQ codes |
-
-For 8-bit PQ, each vector has `m` code bytes and the stored code layout is
-`codes[sub][vector]`. For 4-bit PQ, each byte stores two subquantizers and the
-stored layout is `codes[pair][vector]`.
-
-## IVF-FLAT v1
-
-Magic: `IVFL` (`0x4956464C`). Version: `1`. Header size: 64 bytes.
-
-| Offset | Size | Type | Field |
-| ---: | ---: | --- | --- |
-| 0 | 4 | `u32` | magic |
-| 4 | 4 | `u32` | version |
-| 8 | 4 | `i32` | dimension `d` |
-| 12 | 4 | `i32` | IVF list count `nlist` |
-| 16 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
-| 20 | 8 | `i64` | total vector count |
-| 28 | 4 | `u32` | flags |
-| 32 | 32 | bytes | reserved |
-
-Flags:
-
-| Bit | Meaning |
-| ---: | --- |
-| 0 | delta-varint ids are used; required in v1 |
-
-Sections after the header:
-
-1. IVF coarse centroids: `nlist * d` `f32` values.
-2. Offset table: `nlist` entries of `(offset: i64, count: i32, id_bytes_len: i32)`.
-3. List payloads.
-
-For each non-empty list payload:
-
-| Field | Type | Notes |
-| --- | --- | --- |
-| `base_id` | `i64` | first sorted row id |
-| `id_bytes_len` | `i32` | byte length of encoded id stream |
-| `id_bytes` | bytes | delta-varint ids |
-| `vectors` | `count * d` `f32` | raw stored vectors |
-
-## IVF-RQ v1
-
-Magic: `IVRQ` (`0x49565251`). Version: `1`. Header size: 64 bytes.
-
-| Offset | Size | Type | Field |
-| ---: | ---: | --- | --- |
-| 0 | 4 | `u32` | magic |
-| 4 | 4 | `u32` | version |
-| 8 | 4 | `i32` | dimension `d` |
-| 12 | 4 | `i32` | IVF list count `nlist` |
-| 16 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
-| 20 | 4 | `u32` | flags |
-| 24 | 8 | `i64` | total vector count |
-| 32 | 8 | `u64` | deterministic rotation seed |
-| 40 | 4 | `u32` | deterministic rotation rounds |
-| 44 | 4 | `i32` | bytes per primary sign-code plane, `ceil(d / 8)` |
-| 48 | 4 | `u32` | RQ `num_bits`; currently `1`, reserved values `2`, `4`, and `8` |
-| 52 | 4 | `u32` | `rotation_type`; currently `1` for deterministic Kac rotation |
-| 56 | 4 | `u32` | `factor_layout`; currently `1` for three RaBitQ `f32` factors |
-| 60 | 4 | `u32` | RQ format flags |
-
-Flags:
-
-| Bit | Meaning |
-| ---: | --- |
-| 0 | delta-varint ids are used; required in v1 |
-
-RQ format flags:
-
-| Bit | Meaning |
-| ---: | --- |
-| 0 | optional `ex_codes` section is present |
-| 1 | optional `error_factor` section is present |
-
-Sections after the header:
-
-1. IVF coarse centroids: `nlist * d` `f32` values.
-2. Offset table: `nlist` entries of `(offset: i64, count: i32, id_bytes_len: i32)`.
-3. List payloads.
-
-For each non-empty list payload:
-
-| Field | Type | Notes |
-| --- | --- | --- |
-| `base_id` | `i64` | first sorted row id |
-| `id_bytes_len` | `i32` | byte length of encoded id stream |
-| `id_bytes` | bytes | delta-varint ids |
-| `codes` | `count * ceil(d / 8)` bytes | primary RaBitQ sign-code plane over rotated residuals |
-| `ex_codes` | optional bytes | reserved for additional code planes when `num_bits > 1`; present when RQ format flag bit 0 is set |
-| `factors` | `count * 3` `f32` | per-vector `(residual_norm_sqr, vector_norm_sqr, dp_multiplier)` correction factors |
-| `error_factor` | optional `count` `f32` | reserved per-vector refinement factor; present when RQ format flag bit 1 is set |
-
-The deterministic rotation is derived from `(d, rotation_seed, rotation_rounds)`
-and applied to both indexed residuals and query residuals. The rotation parameters
-are stored in the header so independently written files can use the same
-distance estimator after being read by any v1 reader.
-
-Current writers emit `num_bits=1`, `rotation_type=1`, `factor_layout=1`, and no
-optional RQ sections. Current readers validate these format fields and reject
-reserved multi-bit or optional-section encodings until the corresponding scanner
-is implemented.
-
-`query_bits` is a search-time IVF-RQ parameter and is not serialized. `0` uses
-the default float-query byte-LUT estimator; `4` and `8` quantize the rotated
-query residual into sign and magnitude bit planes for bitwise/popcount scanning.
-
-## IVF-HNSW-FLAT v1
-
-Magic: `IHFL` (`0x4948464C`). Version: `1`. Header size: 64 bytes.
-
-| Offset | Size | Type | Field |
-| ---: | ---: | --- | --- |
-| 0 | 4 | `u32` | magic |
-| 4 | 4 | `u32` | version |
-| 8 | 4 | `i32` | dimension `d` |
-| 12 | 4 | `i32` | IVF list count `nlist` |
-| 16 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
-| 20 | 8 | `i64` | total vector count |
-| 28 | 4 | `i32` | HNSW `m` |
-| 32 | 4 | `i32` | HNSW `ef_construction` |
-| 36 | 4 | `i32` | HNSW `max_level` |
-| 40 | 4 | `u32` | flags |
-| 44 | 20 | bytes | reserved |
-
-Flags:
-
-| Bit | Meaning |
-| ---: | --- |
-| 0 | sorted delta-varint ids are stored; required in v1 |
-| 1 | HNSW graph section uses the v1 delta-varint graph encoding; required in v1 |
-
-Sections after the header:
-
-1. IVF coarse centroids: `nlist * d` `f32` values.
-2. Offset table: `nlist` entries of
- `(offset: i64, count: i32, graph_bytes_len: i32, payload_bytes_len: i64)`.
-3. List payloads.
-
-For each non-empty list payload:
-
-| Field | Type | Notes |
-| --- | --- | --- |
-| `base_id` | `i64` | first sorted row id |
-| `id_bytes_len` | `i32` | byte length of encoded id stream |
-| `id_bytes` | bytes | delta-varint ids |
-| `vectors` | `count * d` `f32` | raw stored vectors |
-| `graph` | bytes | HNSW graph section |
-
-## IVF-HNSW-SQ v1
-
-Magic: `IHSQ` (`0x49485351`). Version: `1`. Header size: 64 bytes.
-
-| Offset | Size | Type | Field |
-| ---: | ---: | --- | --- |
-| 0 | 4 | `u32` | magic |
-| 4 | 4 | `u32` | version |
-| 8 | 4 | `i32` | dimension `d` |
-| 12 | 4 | `i32` | IVF list count `nlist` |
-| 16 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
-| 20 | 8 | `i64` | total vector count |
-| 28 | 4 | `i32` | HNSW `m` |
-| 32 | 4 | `i32` | HNSW `ef_construction` |
-| 36 | 4 | `i32` | HNSW `max_level` |
-| 40 | 4 | `f32` | global minimum SQ bound summary |
-| 44 | 4 | `f32` | global maximum SQ bound summary |
-| 48 | 4 | `u32` | flags |
-| 52 | 12 | bytes | reserved |
-
-Flags:
-
-| Bit | Meaning |
-| ---: | --- |
-| 0 | sorted delta-varint ids are stored; required in v1 |
-| 1 | HNSW graph section uses the v1 delta-varint graph encoding; required in v1 |
-
-Sections after the header:
-
-1. Global SQ min bounds: `d` `f32` values.
-2. Global SQ max bounds: `d` `f32` values.
-3. Per-list SQ bounds: for each list, `d` min `f32` values followed by `d`
- max `f32` values.
-4. IVF coarse centroids: `nlist * d` `f32` values.
-5. Offset table: `nlist` entries of
- `(offset: i64, count: i32, graph_bytes_len: i32, payload_bytes_len: i64)`.
-6. List payloads.
-
-For each non-empty list payload:
-
-| Field | Type | Notes |
-| --- | --- | --- |
-| `base_id` | `i64` | first sorted row id |
-| `id_bytes_len` | `i32` | byte length of encoded id stream |
-| `id_bytes` | bytes | delta-varint ids |
-| `codes` | bytes | scalar quantized residual codes, `count * d` bytes |
-| `graph` | bytes | HNSW graph section over decoded vectors |
+
+For the stable DiskANN layout, see
+[DiskANN v1](core/STORAGE_FORMAT.md#diskann-v1).
diff --git a/c/test_vindex.c b/c/test_vindex.c
index 1cb05cb..fbd1c93 100644
--- a/c/test_vindex.c
+++ b/c/test_vindex.c
@@ -196,7 +196,7 @@ static void run_roundtrip(
uintptr_t num_options,
uint32_t expected_index_type,
uintptr_t expected_pq_m,
- uintptr_t expected_hnsw_m) {
+ uintptr_t expected_pq_bits) {
PaimonVindexTrainerHandle *trainer =
paimon_vindex_trainer_open(keys, values, num_options);
if (trainer == NULL) {
@@ -262,61 +262,83 @@ static void run_roundtrip(
ASSERT_EQ_I64(metadata.index_type, expected_index_type);
ASSERT_EQ_I64(metadata.metric, PAIMON_VINDEX_METRIC_L2);
ASSERT_EQ_I64(metadata.dimension, ROUNDTRIP_DIMENSION);
- ASSERT_EQ_I64(metadata.nlist, 4);
+ ASSERT_EQ_I64(
+ metadata.nlist,
+ expected_index_type == PAIMON_VINDEX_INDEX_TYPE_DISKANN ? 1 : 4);
ASSERT_EQ_I64(metadata.total_vectors, ROUNDTRIP_VECTOR_COUNT);
ASSERT_EQ_I64(metadata.pq_m, expected_pq_m);
- ASSERT_EQ_I64(metadata.hnsw_m, expected_hnsw_m);
+ ASSERT_EQ_I64(metadata.pq_bits, expected_pq_bits);
+ ASSERT_EQ_I64(
+ metadata.rq_bits,
+ expected_index_type == PAIMON_VINDEX_INDEX_TYPE_IVF_RQ ? 5 : 0);
+ if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_DISKANN) {
+ ASSERT_EQ_I64(metadata.diskann_max_degree, 8);
+ ASSERT_EQ_I64(metadata.diskann_build_search_list_size, 16);
+ ASSERT_TRUE(fabsf(metadata.diskann_alpha - 1.2f) < 1e-6f);
+ struct PaimonVindexReadPlan read_plan = {0};
+ if (paimon_vindex_reader_read_plan(reader, &read_plan) != 0) {
+ fail_ffi("reader read plan failed");
+ }
+ ASSERT_TRUE(read_plan.window_bytes > 0);
+ ASSERT_EQ_I64(
+ read_plan.memory_budget_bytes,
+ (uintptr_t) 4 * 1024 * 1024 * 1024);
+ }
if (paimon_vindex_reader_optimize_for_search(reader) != 0) {
fail_ffi("reader optimize_for_search failed");
}
-
float query[ROUNDTRIP_DIMENSION];
fill_query(query, 0.0f);
+ if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_DISKANN) {
+ uintptr_t calibrated_width = 0;
+ if (paimon_vindex_reader_calibrate_search_width(
+ reader, query, 1, 2, &calibrated_width) != 0) {
+ fail_ffi("reader calibrate_search_width failed");
+ }
+ ASSERT_TRUE(
+ calibrated_width == 100 ||
+ calibrated_width == 200 ||
+ calibrated_width == 400);
+ }
int64_t result_ids[2] = {0};
float result_distances[2] = {0};
- struct PaimonVindexSearchParams search_params = {2, 4, 16, 0};
+ struct PaimonVindexSearchParams search_params = {
+ .top_k = 2,
+ .search_width = expected_index_type == PAIMON_VINDEX_INDEX_TYPE_DISKANN
+ ? PAIMON_VINDEX_SEARCH_WIDTH_AUTO
+ : PAIMON_VINDEX_SEARCH_WIDTH_IVF_NPROBE,
+ .width = expected_index_type == PAIMON_VINDEX_INDEX_TYPE_DISKANN ? 0 : 4};
if (paimon_vindex_reader_search(
reader, query, search_params, result_ids, result_distances, 2) != 0) {
fail_ffi("reader search failed");
}
+ if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_DISKANN &&
+ paimon_vindex_reader_warmup_queries(reader, query, 1, 32) != 0) {
+ fail_ffi("reader warmup_queries failed");
+ }
assert_id_in_cluster(result_ids[0], 0);
ASSERT_TRUE(isfinite(result_distances[0]));
if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_IVF_PQ) {
ASSERT_TRUE(buf.max_read_request_count > 1);
}
- if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_IVF_RQ) {
- search_params.query_bits = 4;
- if (paimon_vindex_reader_search(
- reader, query, search_params, result_ids, result_distances, 2) != 0) {
- fail_ffi("reader search with query bits failed");
- }
- assert_id_in_cluster(result_ids[0], 0);
- ASSERT_TRUE(isfinite(result_distances[0]));
- }
-
float queries[2 * ROUNDTRIP_DIMENSION];
fill_query(queries, 0.0f);
fill_query(queries + ROUNDTRIP_DIMENSION, 20.0f);
int64_t batch_ids[2] = {0};
float batch_distances[2] = {0};
- struct PaimonVindexSearchParams batch_params = {1, 4, 16, 0};
+ struct PaimonVindexSearchParams batch_params = {
+ .top_k = 1,
+ .search_width = expected_index_type == PAIMON_VINDEX_INDEX_TYPE_DISKANN
+ ? PAIMON_VINDEX_SEARCH_WIDTH_DISKANN_L_SEARCH
+ : PAIMON_VINDEX_SEARCH_WIDTH_IVF_NPROBE,
+ .width = expected_index_type == PAIMON_VINDEX_INDEX_TYPE_DISKANN ? 100 : 4};
if (paimon_vindex_reader_search_batch(
reader, queries, 2, batch_params, batch_ids, batch_distances, 2) != 0) {
fail_ffi("reader search batch failed");
}
assert_id_in_cluster(batch_ids[0], 0);
assert_id_in_cluster(batch_ids[1], 1);
- if (expected_index_type == PAIMON_VINDEX_INDEX_TYPE_IVF_RQ) {
- batch_params.query_bits = 8;
- if (paimon_vindex_reader_search_batch(
- reader, queries, 2, batch_params, batch_ids, batch_distances, 2) != 0) {
- fail_ffi("reader search batch with query bits failed");
- }
- assert_id_in_cluster(batch_ids[0], 0);
- assert_id_in_cluster(batch_ids[1], 1);
- }
-
paimon_vindex_reader_free(reader);
free(buf.data);
free(data);
@@ -410,48 +432,55 @@ static void test_supported_index_roundtrips(void) {
0,
0);
- const char *pq_keys[] = {"index.type", "dimension", "nlist", "metric", "pq.m"};
- const char *pq_values[] = {"ivf_pq", "8", "4", "l2", "4"};
+ const char *pq_keys[] = {"index.type", "dimension", "nlist", "metric"};
+ const char *pq_values[] = {"ivf_pq", "8", "4", "l2"};
run_roundtrip(
"ivf_pq_roundtrip",
pq_keys,
pq_values,
- 5,
- PAIMON_VINDEX_INDEX_TYPE_IVF_PQ,
4,
- 0);
+ PAIMON_VINDEX_INDEX_TYPE_IVF_PQ,
+ 2,
+ 8);
- const char *rq_keys[] = {"index.type", "dimension", "nlist", "metric"};
- const char *rq_values[] = {"ivf_rq", "8", "4", "l2"};
+ const char *rq_keys[] = {"index.type", "dimension", "nlist", "rq.bits", "metric"};
+ const char *rq_values[] = {"ivf_rq", "8", "4", "5", "l2"};
run_roundtrip(
"ivf_rq_roundtrip",
rq_keys,
rq_values,
- 4,
+ 5,
PAIMON_VINDEX_INDEX_TYPE_IVF_RQ,
0,
0);
- const char *hnsw_flat_keys[] = {"index.type", "dimension", "nlist", "metric", "hnsw.m"};
- const char *hnsw_flat_values[] = {"ivf_hnsw_flat", "8", "4", "l2", "4"};
+ const char *sq_keys[] = {"index.type", "dimension", "nlist", "metric"};
+ const char *sq_values[] = {"ivf_sq", "8", "4", "l2"};
run_roundtrip(
- "ivf_hnsw_flat_roundtrip",
- hnsw_flat_keys,
- hnsw_flat_values,
- 5,
- PAIMON_VINDEX_INDEX_TYPE_IVF_HNSW_FLAT,
+ "ivf_sq_roundtrip",
+ sq_keys,
+ sq_values,
+ 4,
+ PAIMON_VINDEX_INDEX_TYPE_IVF_SQ,
0,
- 4);
-
- const char *hnsw_sq_keys[] = {"index.type", "dimension", "nlist", "metric", "hnsw.m"};
- const char *hnsw_sq_values[] = {"ivf_hnsw_sq", "8", "4", "l2", "4"};
+ 8);
+
+ const char *diskann_keys[] = {
+ "index.type",
+ "dimension",
+ "metric",
+ "pq.m",
+ "pq.bits",
+ "diskann.max-degree",
+ "diskann.build-search-list-size"};
+ const char *diskann_values[] = {"diskann", "8", "l2", "4", "4", "8", "16"};
run_roundtrip(
- "ivf_hnsw_sq_roundtrip",
- hnsw_sq_keys,
- hnsw_sq_values,
- 5,
- PAIMON_VINDEX_INDEX_TYPE_IVF_HNSW_SQ,
- 0,
+ "diskann_roundtrip",
+ diskann_keys,
+ diskann_values,
+ 7,
+ PAIMON_VINDEX_INDEX_TYPE_DISKANN,
+ 4,
4);
}
diff --git a/core/Cargo.toml b/core/Cargo.toml
index ae44437..4732c86 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -17,12 +17,17 @@
[package]
name = "paimon-vindex-core"
-version = "0.2.0"
+version = "0.3.0"
edition = "2021"
description = "Apache Paimon Vector Index — core Rust library"
license = "Apache-2.0"
+readme = "README.md"
+repository = "https://github.com/apache/paimon-vector-index"
+homepage = "https://paimon.apache.org"
+documentation = "https://docs.rs/paimon-vindex-core"
[dependencies]
+half = "2"
nalgebra = "0.33"
rand = "0.8"
rayon = "1.10"
@@ -31,6 +36,7 @@ roaring = "0.11"
[dev-dependencies]
criterion = "0.5"
+libc = "0.2"
[[bench]]
name = "pq4_bench"
@@ -45,5 +51,5 @@ name = "ann_bench"
harness = false
[[bench]]
-name = "ivfhnswsq_filter_bench"
+name = "diskann_bench"
harness = false
diff --git a/core/README.md b/core/README.md
new file mode 100644
index 0000000..940d417
--- /dev/null
+++ b/core/README.md
@@ -0,0 +1,35 @@
+
+
+# Apache Paimon Vector Index Core
+
+`paimon-vindex-core` contains the Rust implementations and seek-based readers
+for IVF-FLAT, IVF-SQ, IVF-PQ, IVF-RQ, and DiskANN.
+
+The DiskANN and Vamana code is an independent Apache-licensed implementation
+based on the published algorithms and this project's existing storage
+abstractions. It does not incorporate source code from Microsoft's
+[MIT-licensed DiskANN repository](https://github.com/microsoft/DiskANN).
+The implementation supports L2, inner-product, and cosine search with the same
+lower-is-better distance semantics as the IVF indexes.
+
+The crate ships its [normative v1 storage-format specification](STORAGE_FORMAT.md)
+and byte-exact fixtures. Project documentation, language bindings, and
+contribution guidance live in the
+[Apache Paimon Vector Index repository](https://github.com/apache/paimon-vector-index).
diff --git a/core/STORAGE_FORMAT.md b/core/STORAGE_FORMAT.md
new file mode 100644
index 0000000..9616d59
--- /dev/null
+++ b/core/STORAGE_FORMAT.md
@@ -0,0 +1,455 @@
+
+
+# Vector Index Storage Format
+
+This document describes the v1 on-disk formats written by the
+`paimon-vindex-core` crate. Version 1 is the first release format. Pre-release
+layouts are not part of the compatibility contract.
+
+## Compatibility Policy
+
+- All multi-byte integers and `f32` values are little-endian.
+- The unified reader dispatches by the first 4-byte magic value.
+- Magic names below show the `u32` constants in human-readable big-endian form.
+ Because the fields are little-endian, the raw file bytes for those constants
+ appear in reverse ASCII order.
+- Readers reject unknown magic values, unknown versions, unknown required flags,
+ non-zero reserved bytes, invalid section sizes, negative counts, and malformed
+ list payload metadata.
+- Incompatible on-disk changes require a new format version. Version 1 readers
+ do not attempt to read future versions.
+- Reserved bytes are written as zero and must be read back as zero. They cannot
+ acquire meaning within v1. A new field, flag meaning, or reserved-byte use
+ requires a new format version unless that format explicitly defined the flag
+ as optional from its first v1 release.
+- Index files have no outer container, footer, checksum, compression envelope,
+ or schema registry. The complete file starts at byte offset 0 with one of the
+ headers below.
+- File integrity, including length and checksum validation, is guaranteed by
+ the outer Paimon file/manifest layer rather than by an embedded index footer.
+- Roaring row-id filters are a query-time API payload. They are not embedded in
+ any index file format.
+
+## Common Encodings
+
+### Delta-Varint IDs
+
+IVF-PQ, IVF-FLAT, IVF-RQ, and IVF-SQ v1 sort each non-empty list by signed row
+id before writing. The first id is stored as `base_id: i64`. The id stream then
+stores one unsigned LEB128 varint per id, including the first id's zero delta.
+Each delta is computed with wrapping unsigned subtraction from the previous
+signed id. Readers reject a decoded sequence that is not monotonically
+non-decreasing in signed order.
+
+## DiskANN v1
+
+Raw magic bytes: `DANN` (`u32` value `0x4E4E4144`). Version: `1`. Header size:
+256 bytes. DiskANN v1 is little-endian, supports L2, inner product, and cosine,
+uses dense `u32` internal node IDs, and applies one BFS locality permutation
+consistently to every section.
+
+| Offset | Size | Type | Field |
+| ---: | ---: | --- | --- |
+| 0 | 4 | `u32` | magic |
+| 4 | 4 | `u32` | version |
+| 8 | 4 | `u32` | header size, `256` |
+| 12 | 4 | `u32` | flags |
+| 16 | 4 | `u32` | dimension |
+| 20 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
+| 24 | 8 | `u64` | vector count |
+| 32 | 4 | `u32` | entry node |
+| 36 | 4 | `u32` | maximum graph degree `R` |
+| 40 | 4 | `u32` | build search-list size `Lbuild` |
+| 44 | 4 | `f32` | robust-prune alpha |
+| 48 | 8 | `u64` | build seed |
+| 56 | 4 | `u32` | PQ subquantizer count `m` |
+| 60 | 4 | `u32` | PQ bits, required `4` or `8` |
+| 64 | 4 | `u32` | logical page size, required `4096` |
+| 68 | 4 | `u32` | per-node adjacency locator payload size, required `4` |
+| 72 | 4 | `u32` | adjacency locator encoding, required `3` |
+| 76 | 4 | `u32` | raw-vector encoding: `1=f32`, `2=IEEE 754 binary16` |
+| 80 | 4 | `u32` | raw-vector record size, `dimension * element_size` |
+| 84 | 4 | `u32` | section count, required `7` |
+| 88 | 8 | `u64` | exact total file length |
+| 96 | 112 | seven `(offset: u64, length: u64)` pairs | sections in the order below |
+| 208 | 48 | bytes | reserved, required zero |
+
+Bits 0, 2, 3, and 4 are required: BFS layout, adaptive adjacency encoding, PQ
+codes, and row-ID order. Exactly one storage-layout bit is required: bit 1 for
+separate adjacency/vector sections or bit 5 for interleaved vector/adjacency
+records. Unknown flags are rejected. Consequently, adding any DiskANN flag that
+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`;
+- `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;
+- `1 <= R <= 1023`, `Lbuild >= R`, and `Lbuild <= u32::MAX`;
+- `alpha` is finite and at least 1;
+- `raw_vector_encoding` is `1` or `2`, and `vector_record_size` is exactly
+ `dimension * 4` or `dimension * 2`, respectively; and
+- every stored PQ centroid and decoded raw-vector component is finite. A writer
+ using binary16 additionally rejects finite `f32` inputs whose conversion
+ would overflow the finite binary16 range.
+
+Inner-product results use negative dot product so lower values remain better.
+Inner-product Vamana construction uses the metric-specific occluding prune rule
+rather than the L2 triangle-inequality rule. Cosine training and indexed vectors
+are normalized before PQ encoding and persistence; queries are normalized
+before graph traversal, and final distances retain the public `1 - cosine`
+semantics. Zero vectors remain zero and have cosine distance `1`.
+
+Let `E` be the raw-vector element size (`4` for `f32`, `2` for binary16). The
+interleaved writer requires `E * dimension + 4 * R <= 4096`. This
+content-independent bound guarantees that even a raw-`u32` maximum-degree
+adjacency list fits beside its vector without changing layout based on
+compression results.
+
+The seven sections are:
+
+1. Self-describing PQ codebook at absolute offset 4096, described below.
+2. Row IDs, adaptively encoded in dense node order as described below.
+3. PQ codes, `m` bytes per node for 8-bit or `ceil(m / 2)` bytes per node for 4-bit.
+ In 4-bit mode, each byte stores the earlier subquantizer in its low nibble
+ and the next subquantizer in its high nibble. When `m` is odd, the unused
+ high nibble of every final byte is required to be zero.
+4. Row-ID order, one `u32` node ID per node, sorted by `(row_id, node_id)`.
+5. Block-compressed adjacency index described below.
+6. 4096-byte-aligned adaptively encoded adjacency pages, optionally containing
+ the interleaved raw-vector records described below.
+7. Dense raw-vector records for the separate layout; a zero-length section
+ whose offset equals the file length for the interleaved layout.
+
+The PQ-codebook section starts with this 32-byte header:
+
+| Offset | Size | Type | Field |
+| ---: | ---: | --- | --- |
+| 0 | 4 | `u32` | magic `DPQ1` (`0x31515044`) |
+| 4 | 4 | `u32` | codebook version, required `1` |
+| 8 | 4 | `u32` | dimension, equal to the file header |
+| 12 | 4 | `u32` | subquantizer count `m`, equal to the file header |
+| 16 | 4 | `u32` | PQ bits, equal to the file header |
+| 20 | 4 | `u32` | centroid count per chunk, `1 << pq_bits` |
+| 24 | 4 | `u32` | chunk-offset count, `m + 1` |
+| 28 | 4 | bytes | reserved, required zero |
+
+It is followed by exactly `m + 1` little-endian `u32` component offsets. They
+start at zero, end at `dimension`, and are strictly increasing. The current
+writer creates balanced contiguous chunks: the first `dimension % m` chunks
+have `floor(dimension / m) + 1` components and the rest have
+`floor(dimension / m)`. Readers use the persisted offsets rather than
+re-deriving that policy.
+
+The remaining payload contains exactly `dimension * (1 << pq_bits)` finite
+little-endian `f32` centroid components. Its order is
+`centroid[chunk][code][component-within-chunk]`: components are contiguous,
+followed by code, followed by chunk. Chunk `s` begins at centroid-component
+offset `chunk_offsets[s] * (1 << pq_bits)`. The section has no trailing bytes.
+
+The row-ID section starts with this 32-byte header:
+
+| Offset | Size | Type | Field |
+| ---: | ---: | --- | --- |
+| 0 | 4 | `u32` | encoding: `0=raw i64`, `1=global FOR bit-pack` |
+| 4 | 4 | `u32` | bit width: raw requires `64`; FOR requires `0..63` |
+| 8 | 8 | `u64` | row-ID count, equal to vector count |
+| 16 | 8 | `i64` | base: raw requires `0`; FOR stores the minimum row ID |
+| 24 | 8 | bytes | reserved, required zero |
+
+Raw encoding appends exactly `8 * N` little-endian bytes. FOR appends exactly
+`ceil(N * bit_width / 8)` bytes containing unsigned `row_id - base` deltas in
+dense node order, least-significant bit first. Unused high bits in the final
+byte are zero. Width zero has no payload and maps every node to `base`. The
+writer selects the minimum width and falls back to raw only for a 64-bit span.
+The reader retains the packed payload and performs O(1) random row-ID lookup;
+it validates the exact length, metadata, tail bits, and decoded `i64` range.
+
+For `B = ceil(N / 16)`, the adjacency index contains three contiguous arrays:
+
+1. `B` little-endian `u64` block base offsets relative to the start of the
+ adjacency-page section;
+2. `N` little-endian `u16` byte offsets relative to the corresponding
+ 16-node block base; and
+3. `N` little-endian `u16` values whose bit 15 selects raw `u32` adjacency
+ encoding and whose bits 0–14 store degree.
+
+The section length is exactly `8 * B + 4 * N` bytes. Node `i` resolves to
+`block_base[i / 16] + relative_offset[i]`; division and remainder by 4096
+produce its page index and byte offset. The first relative offset in every
+block is zero. A 16-node block can advance at most 15 pages because every list
+is page-contained, so the largest valid relative offset is `65535`.
+
+The codebook, encoded row IDs, PQ codes, row-ID order, and adjacency index are
+contiguous. The reader probes the declared file tail, loads required resident
+sections directly into their final representations, and loads row-ID order
+lazily for sparse filtered queries. The adjacency section begins at the next
+4096-byte boundary. Writers fill the bytes from the 256-byte header to the
+codebook, and the alignment gap before adjacency, with zero. These alignment
+bytes carry no v1 semantics and readers ignore them; they cannot be repurposed
+without a new format version.
+Neighbor IDs are strictly increasing and packed by actual degree. Each list
+uses canonical unsigned delta LEB128 when that is strictly smaller than raw
+little-endian `u32`; otherwise bit 15 in its locator selects raw encoding. The
+first varint is the first absolute neighbor ID (a delta from zero), and later
+varints are positive deltas from the preceding ID. Empty lists use delta mode
+and no payload bytes. This adaptive choice guarantees that a list and the
+complete adjacency payload never exceed the fixed-`u32` representation.
+
+A list never crosses a logical-page boundary. In the separate layout, adjacent
+locator ranges are contiguous. In the interleaved layout, each page record is
+`[dimension * E raw-vector bytes][encoded adjacency bytes]`, and the locator
+points to the first adjacency byte; adjacent records are contiguous.
+Raw-vector bytes use the header encoding and little-endian scalar
+representation. The remaining adjacency-page tail is zero. The resident block
+bases, relative offsets, and degree/encoding values are structurally validated
+before graph search. `optimize_for_search` validates every preloaded page in
+parallel before publishing the hot prefix; a cold page is validated by shared
+single-flight work on first access. Payload validation decodes each list to
+establish its exact end and checks raw-vector finiteness, canonical varints, the
+uniquely minimal adaptive mode, neighbor IDs, contiguity, and the zero
+adjacency-page tail.
+
+In the separate layout, raw-vector record `i` begins at
+`vectors.offset + i * vector_record_size`; the section length is exactly
+`vector_count * vector_record_size`, with no record or page padding. A record
+contains `dimension` little-endian `f32` values when the encoding is `1`, or
+`dimension` little-endian IEEE 754 binary16 bit patterns when the encoding is
+`2`. Runtime readers group
+`max(1, floor(profile_window_bytes / vector_record_size))` complete consecutive
+records into one read window and clip the final window to the section end. A
+record may therefore cross a 4096-byte address boundary, but never crosses its
+runtime read window. Readers validate every consumed component before distance
+evaluation. The exact derived lengths, ordering, file length, locator bounds,
+degrees, neighbor IDs, duplicate/self edges, finite codebook/vector values,
+row-ID encoding, and permutation are validated by the reader.
+
+DiskANN v1 intentionally has no embedded checksum. The enclosing Paimon
+file/manifest contract owns object length and checksum validation; the DiskANN
+reader owns all structural and semantic checks described above.
+
+The `Memory`, `LocalStorage`, `RemoteStorage`, and `ObjectStore` profiles do not
+change this physical format. They group 4096-byte adjacency pages into runtime
+read windows of 4096, 16384, 32768, or 65536 bytes respectively. Separate raw
+vectors use the complete-record grouping described above with the same profile
+window-byte targets.
+
+## IVF-PQ v1
+
+Magic: `IVPQ` (`0x49565051`). Version: `1`. Header size: 64 bytes.
+
+| Offset | Size | Type | Field |
+| ---: | ---: | --- | --- |
+| 0 | 4 | `u32` | magic |
+| 4 | 4 | `u32` | version |
+| 8 | 4 | `i32` | dimension `d` |
+| 12 | 4 | `i32` | IVF list count `nlist` |
+| 16 | 4 | `i32` | PQ subquantizer count `m` |
+| 20 | 4 | `i32` | centroid count per subquantizer `ksub` |
+| 24 | 4 | `i32` | subvector dimension `dsub` |
+| 28 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
+| 32 | 8 | `i64` | total vector count |
+| 40 | 4 | `u32` | flags |
+| 44 | 20 | bytes | reserved |
+
+Flags:
+
+| Bit | Meaning |
+| ---: | --- |
+| 0 | OPQ rotation matrix is present |
+| 1 | PQ codes are trained/stored by residual |
+| 2 | delta-varint ids are used; required in v1 |
+| 3 | PQ codes are transposed by subquantizer; required in v1 |
+
+Sections after the header:
+
+1. Optional OPQ rotation matrix: `d * d` `f32` values when flag bit 0 is set.
+2. IVF coarse centroids: `nlist * d` `f32` values.
+3. PQ centroids: `m * ksub * dsub` `f32` values.
+4. Offset table: `nlist` entries of `(offset: i64, count: i32, id_bytes_len: i32)`.
+5. List payloads.
+
+For each non-empty list payload:
+
+| Field | Type | Notes |
+| --- | --- | --- |
+| `base_id` | `i64` | first sorted row id |
+| `id_bytes_len` | `i32` | byte length of encoded id stream |
+| `id_bytes` | bytes | delta-varint ids |
+| `codes` | bytes | transposed PQ codes |
+
+For 8-bit PQ, each vector has `m` code bytes and the stored code layout is
+`codes[sub][vector]`. For 4-bit PQ, each byte stores two subquantizers and the
+stored layout is `codes[pair][vector]`.
+
+## IVF-FLAT v1
+
+Magic: `IVFL` (`0x4956464C`). Version: `1`. Header size: 64 bytes.
+
+| Offset | Size | Type | Field |
+| ---: | ---: | --- | --- |
+| 0 | 4 | `u32` | magic |
+| 4 | 4 | `u32` | version |
+| 8 | 4 | `i32` | dimension `d` |
+| 12 | 4 | `i32` | IVF list count `nlist` |
+| 16 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
+| 20 | 8 | `i64` | total vector count |
+| 28 | 4 | `u32` | flags |
+| 32 | 32 | bytes | reserved |
+
+Flags:
+
+| Bit | Meaning |
+| ---: | --- |
+| 0 | delta-varint ids are used; required in v1 |
+
+Sections after the header:
+
+1. IVF coarse centroids: `nlist * d` `f32` values.
+2. Offset table: `nlist` entries of `(offset: i64, count: i32, id_bytes_len: i32)`.
+3. List payloads.
+
+For each non-empty list payload:
+
+| Field | Type | Notes |
+| --- | --- | --- |
+| `base_id` | `i64` | first sorted row id |
+| `id_bytes_len` | `i32` | byte length of encoded id stream |
+| `id_bytes` | bytes | delta-varint ids |
+| `vectors` | `count * d` `f32` | raw stored vectors |
+
+## IVF-RQ v1
+
+Magic: `IVRQ` (`0x49565251`). Version: `1`. Header size: 64 bytes.
+
+| Offset | Size | Type | Field |
+| ---: | ---: | --- | --- |
+| 0 | 4 | `u32` | magic |
+| 4 | 4 | `u32` | version |
+| 8 | 4 | `i32` | logical dimension `d` |
+| 12 | 4 | `i32` | `padded_d`, the next multiple of 64 |
+| 16 | 4 | `i32` | IVF list count `nlist` |
+| 20 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
+| 24 | 4 | `u32` | required layout flags |
+| 28 | 4 | `u32` | persisted RQ bit width, in `1..=8` |
+| 32 | 8 | `i64` | total vector count |
+| 40 | 8 | `u64` | deterministic rotation seed |
+| 48 | 4 | `u32` | deterministic rotation rounds; `4` |
+| 52 | 4 | `i32` | bytes per bit plane, `padded_d / 8` |
+| 56 | 4 | `u32` | `rotation_type`; `2` for sign + 64-wide normalized FHT + permutation |
+| 60 | 4 | `u32` | `factor_layout`; `3` for compact incremental coarse/full factors |
+
+Flags:
+
+| Bit | Meaning |
+| ---: | --- |
+| 0 | delta-varint ids are used; required in v1 |
+| 1 | codes are transposed within 32-vector blocks; required in v1 |
+| 2 | factors use structure-of-arrays layout within each block; required in v1 |
+
+Sections after the header:
+
+1. IVF coarse centroids: `nlist * d` `f32` values.
+2. Offset table: `nlist` entries of `(offset: i64, count: i32, id_bytes_len: i32)`.
+3. List payloads.
+
+For each non-empty list payload:
+
+| Field | Type | Notes |
+| --- | --- | --- |
+| `base_id` | `i64` | first sorted row id |
+| `id_bytes_len` | `i32` | byte length of encoded id stream |
+| `code_bytes_len` | `i32` | exact blocked-code byte length |
+| `id_bytes` | bytes | delta-varint ids |
+| `codes` | `count * bits * (padded_d / 8)` bytes | MSB-first bit planes; within every up-to-32-vector block the order is plane, byte position, lane |
+| `factors` | `count * fields` `f32` | block-SoA fields; 2 coarse fields for one bit, otherwise 3 coarse plus 2 full fields |
+
+The coarse factor fields are `(f_add, f_rescale, f_error)` when multiple bit
+planes require a deterministic reconstruction-error lower bound. A one-bit
+file stores only `(f_add, f_rescale)` because it has no later refinement stage.
+For multi-bit files, candidates that can still enter Top-K are refined with
+every plane and the full `(f_add, f_rescale)` estimate. The full
+reconstruction-error factor is intentionally omitted because the final stage
+does not compute another lower bound.
+
+The orthogonal transform is reconstructed from `(d, rotation_seed,
+rotation_rounds)`. It pads with zeros to `padded_d` and applies four rounds of
+random signs, normalized 64-wide FHT, and permutation. The Reader rotates each
+query once and reuses its byte LUT across every selected list.
+
+The pre-release one-bit/Kac/factor-layout-1 representation used the same magic
+and version but was never published. v1 Readers intentionally reject it through
+the required padded dimension, flags, rotation type, and factor layout checks.
+There is no query-side bit-width parameter; the file fixes the representation.
+
+## IVF-SQ v1
+
+Magic: `IVSQ` (`0x49565351`). Version: `1`. Header size: 64 bytes. IVF-SQ uses
+one unsigned 8-bit code per residual dimension and scans every code in each
+selected IVF list.
+
+| Offset | Size | Type | Field |
+| ---: | ---: | --- | --- |
+| 0 | 4 | `u32` | magic |
+| 4 | 4 | `u32` | version |
+| 8 | 4 | `i32` | dimension `d` |
+| 12 | 4 | `i32` | IVF list count `nlist` |
+| 16 | 4 | `u32` | metric (`0=L2`, `1=InnerProduct`, `2=Cosine`) |
+| 20 | 8 | `i64` | total vector count |
+| 28 | 4 | `u32` | SQ bits, required `8` |
+| 32 | 4 | `u32` | flags |
+| 36 | 4 | `f32` | global minimum SQ bound summary |
+| 40 | 4 | `f32` | global maximum SQ bound summary |
+| 44 | 20 | bytes | reserved |
+
+Flags:
+
+| Bit | Meaning |
+| ---: | --- |
+| 0 | sorted delta-varint ids are stored; required in v1 |
+| 1 | codes use 32-row blocked dimension-major layout; required in v1 |
+
+Sections after the header:
+
+1. Global SQ min bounds: `d` `f32` values.
+2. Global SQ max bounds: `d` `f32` values.
+3. Per-list SQ bounds: for each list, `d` min `f32` values followed by `d`
+ max `f32` values.
+4. IVF coarse centroids: `nlist * d` `f32` values.
+5. Offset table: `nlist` entries of
+ `(offset: i64, count: i32, id_bytes_len: i32)`.
+6. List payloads.
+
+For each non-empty list payload:
+
+| Field | Type | Notes |
+| --- | --- | --- |
+| `codes` | bytes | `count * d` scalar codes; within every up-to-32-row block the order is dimension, then row lane |
+| `base_id` | `i64` | first sorted row id |
+| `id_bytes_len` | `i32` | byte length of encoded id stream |
+| `id_bytes` | bytes | delta-varint ids |
+
+The global bounds provide a fallback for empty training lists. Non-empty lists
+normally use their own per-dimension residual bounds. A reader validates all
+offsets, counts, encoded-ID sizes, and SQ bounds before exposing the index.
+Putting blocked codes first lets the reader retain the list payload allocation
+as the scan buffer after decoding and truncating the trailing ID section.
diff --git a/core/benches/ann_bench.rs b/core/benches/ann_bench.rs
index 4f060e8..5d14976 100644
--- a/core/benches/ann_bench.rs
+++ b/core/benches/ann_bench.rs
@@ -15,163 +15,600 @@
// specific language governing permissions and limitations
// under the License.
-use paimon_vindex_core::distance::MetricType;
-use paimon_vindex_core::hnsw::HnswBuildParams;
-use paimon_vindex_core::io::{write_index, IVFPQIndexReader, PosWriter};
-use paimon_vindex_core::ivfhnswflat::IVFHNSWFlatIndex;
-use paimon_vindex_core::ivfhnswflat_io::{
- search_batch_ivfhnswflat_reader, write_ivfhnswflat_index, IVFHNSWFlatIndexReader,
+#[path = "support/ann_bench_support.rs"]
+mod ann_bench_support;
+
+use ann_bench_support::{
+ add_fixed_round_latency, default_training_vector_count, inspect_i32_records,
+ inspect_public_dataset, parse_storage_case_names, resolve_shape_value, should_isolate_indexes,
};
-use paimon_vindex_core::ivfhnswsq::IVFHNSWSQIndex;
-use paimon_vindex_core::ivfhnswsq_io::{
- search_batch_ivfhnswsq_reader, write_ivfhnswsq_index, IVFHNSWSQIndexReader,
+use paimon_vindex_core::diskann::{
+ DiskAnnBuildDistance, DiskAnnBuildParams, DiskAnnRawVectorEncoding,
};
-use paimon_vindex_core::ivfpq::{search_batch_reader, IVFPQIndex};
-use paimon_vindex_core::ivfrq::IVFRQIndex;
-use paimon_vindex_core::ivfrq_io::{
- search_batch_ivfrq_reader_with_query_bits, write_ivfrq_index, IVFRQIndexReader,
+use paimon_vindex_core::distance::{fvec_l2sqr, MetricType};
+use paimon_vindex_core::index::{
+ infer_pq_m, VectorIndexConfig, VectorIndexReader, VectorIndexReaderOptions, VectorIndexTrainer,
+ VectorIndexWriter, VectorSearchParams, DEFAULT_PQ_CODE_RATIO,
};
-use paimon_vindex_core::rq::is_supported_query_bits;
+use paimon_vindex_core::io::{PosWriter, ReadRequest, SeekRead, SeekReadCapabilities};
+use paimon_vindex_core::rq::{is_supported_rq_bits, DEFAULT_RQ_BITS};
+use rayon::prelude::*;
+use rayon::{ThreadPool, ThreadPoolBuilder};
use std::env;
-use std::fs;
-use std::io;
+use std::fs::{self, File};
+use std::io::{self, BufReader, Read, Write};
use std::path::{Path, PathBuf};
+use std::process::Command;
+use std::sync::atomic::{AtomicBool, Ordering};
+use std::sync::{Arc, Mutex};
+use std::thread;
use std::time::{Duration, Instant};
+const ISOLATED_CHILD_ENV: &str = "ANN_BENCH_ISOLATED_CHILD";
+const SUPPRESS_CSV_HEADER_ENV: &str = "ANN_BENCH_SUPPRESS_CSV_HEADER";
+
fn main() -> Result<(), Box> {
- let cfg = Config::from_env()?;
- let dataset = Dataset::clustered(cfg.n, cfg.nq, cfg.d, cfg.clusters, cfg.seed);
- let ids: Vec = (0..cfg.n as i64).collect();
- let workspace = prepare_workspace(&cfg.output_dir)?;
+ if run_isolated_public_indexes()? {
+ return Ok(());
+ }
+ run_benchmark()
+}
+
+fn run_isolated_public_indexes() -> Result> {
+ let dataset_paths = DatasetPaths::from_env()?;
+ let indexes = selected_indexes()?;
+ let is_child = env::var_os(ISOLATED_CHILD_ENV).is_some();
+ let reuses_index = env::var_os("ANN_REUSE_INDEX_PATH").is_some();
+ if !should_isolate_indexes(
+ dataset_paths.is_some(),
+ indexes.len(),
+ is_child,
+ reuses_index,
+ ) {
+ return Ok(false);
+ }
println!("{}", CsvRow::header());
- run_paimon_ivfpq(&cfg, &dataset, &ids, &workspace)?;
- run_paimon_ivfrq(&cfg, &dataset, &ids, &workspace)?;
- run_paimon_ivfhnswflat(&cfg, &dataset, &ids, &workspace)?;
- run_paimon_ivfhnswsq(&cfg, &dataset, &ids, &workspace)?;
+ io::stdout().flush()?;
+ let executable = env::current_exe()?;
+ for index in indexes {
+ eprintln!("running public benchmark in isolated process: {index}");
+ let status = Command::new(&executable)
+ .env("ANN_INDEXES", &index)
+ .env(ISOLATED_CHILD_ENV, "1")
+ .env(SUPPRESS_CSV_HEADER_ENV, "1")
+ .status()?;
+ if !status.success() {
+ return Err(
+ format!("isolated ANN benchmark process for {index} exited with {status}").into(),
+ );
+ }
+ }
+ Ok(true)
+}
+
+fn run_benchmark() -> Result<(), Box> {
+ let config = Config::from_env()?;
+ eprintln!(
+ "workload: n={} train_n={} d={} pq_m={} raw_bytes={} ({:.3} GiB), indexes={}, storage_cases={}",
+ config.n,
+ config.train_n,
+ config.d,
+ config.pq_m,
+ config.raw_dataset_bytes()?,
+ config.raw_dataset_bytes()? as f64 / GIB as f64,
+ config.indexes.join(","),
+ config
+ .storage_cases
+ .iter()
+ .map(|storage| storage.name)
+ .collect::>()
+ .join(",")
+ );
+ let dataset_started = Instant::now();
+ let dataset = Dataset::load(&config)?;
+ eprintln!(
+ "loaded dataset={} ({:.3} GiB) in {} ms",
+ config.dataset_name,
+ config.raw_dataset_bytes()? as f64 / GIB as f64,
+ dataset_started.elapsed().as_millis()
+ );
+ let ground_truth = if let Some(ground_truth) = dataset.ground_truth.clone() {
+ eprintln!(
+ "loaded published ground truth: {} queries, top-{}",
+ ground_truth.len(),
+ ground_truth.first().map_or(0, Vec::len)
+ );
+ ground_truth
+ } else {
+ let truth_started = Instant::now();
+ let ground_truth = exact_ground_truth(&dataset, config.k);
+ eprintln!(
+ "exact ground truth: {} queries in {} ms",
+ config.nq,
+ truth_started.elapsed().as_millis()
+ );
+ ground_truth
+ };
+ if env::var_os(SUPPRESS_CSV_HEADER_ENV).is_none() {
+ println!("{}", CsvRow::header());
+ }
+ if let Some(path) = &config.reuse_index_path {
+ let mut specs = index_specs(&config);
+ if specs.len() != 1 {
+ return Err("ANN_REUSE_INDEX_PATH requires exactly one ANN_INDEXES value".into());
+ }
+ let spec = specs.pop().expect("validated one ANN index specification");
+ let built = BuiltIndex {
+ name: spec.name,
+ path: path.clone(),
+ searches: spec.searches,
+ build_time_ms: 0,
+ train_time_ms: 0,
+ add_time_ms: 0,
+ write_time_ms: 0,
+ file_bytes: fs::metadata(path)?.len(),
+ peak_rss_bytes: peak_resident_set_bytes()?,
+ };
+ run_built_index(&config, &dataset, &ground_truth, &built)?;
+ return Ok(());
+ }
+
+ let ids = (0..config.n as i64).collect::>();
+ let workspace = prepare_workspace(&config.output_dir)?;
+ for spec in index_specs(&config) {
+ let built = build_index(&config, &dataset, &ids, &workspace, spec)?;
+ eprintln!(
+ "built {} in {} ms: train={} ms, add={} ms, write={} ms ({} bytes, peak RSS {} bytes)",
+ built.name,
+ built.build_time_ms,
+ built.train_time_ms,
+ built.add_time_ms,
+ built.write_time_ms,
+ built.file_bytes,
+ built.peak_rss_bytes
+ );
+ run_built_index(&config, &dataset, &ground_truth, &built)?;
+ if !config.keep_indexes {
+ fs::remove_file(&built.path)?;
+ }
+ }
+ Ok(())
+}
+
+fn run_built_index(
+ config: &Config,
+ dataset: &Dataset,
+ ground_truth: &[Vec],
+ built: &BuiltIndex,
+) -> Result<(), Box> {
+ for search in &built.searches {
+ for storage in &config.storage_cases {
+ run_query_case(config, dataset, ground_truth, built, *search, *storage)?;
+ }
+ }
Ok(())
}
+const GIB: u64 = 1024 * 1024 * 1024;
+const ALL_INDEX_NAMES: [&str; 5] = ["IVF_FLAT", "IVF_SQ", "IVF_PQ", "IVF_RQ", "DISKANN"];
+
#[derive(Clone)]
struct Config {
+ dataset_name: String,
+ dataset_paths: Option,
n: usize,
+ train_n: usize,
nq: usize,
d: usize,
k: usize,
nlist: usize,
nprobe: usize,
pq_m: usize,
- hnsw_m: usize,
- hnsw_ef_construction: usize,
- hnsw_ef_search: usize,
- rq_query_bits: usize,
+ diskann_l_searches: Vec,
+ diskann_memory_budget_bytes: usize,
+ diskann_build_distance: DiskAnnBuildDistance,
+ diskann_raw_vector_encoding: DiskAnnRawVectorEncoding,
+ rq_bits: usize,
clusters: usize,
+ noise_dimensions: usize,
seed: u64,
+ indexes: Vec,
+ storage_cases: Vec,
+ keep_indexes: bool,
+ reuse_index_path: Option,
output_dir: PathBuf,
}
impl Config {
fn from_env() -> Result> {
- let n = read_env("ANN_N", 10_000)?;
- let nq = read_env("ANN_NQ", 100)?;
- let d = read_env("ANN_D", 128)?;
+ let dataset_paths = DatasetPaths::from_env()?;
+ let public_shape = dataset_paths
+ .as_ref()
+ .map(|paths| inspect_public_dataset(&paths.base, &paths.queries, &paths.ground_truth))
+ .transpose()?;
+ let dataset_name = env::var("ANN_DATASET_NAME").unwrap_or_else(|_| {
+ if dataset_paths.is_some() {
+ "public-fvecs".to_string()
+ } else {
+ "clustered-synthetic".to_string()
+ }
+ });
+ if dataset_name.contains(',') || dataset_name.contains('\n') {
+ return Err("ANN_DATASET_NAME must not contain commas or newlines".into());
+ }
+ let d = resolve_shape_value(
+ "ANN_D",
+ read_optional_env("ANN_D")?,
+ public_shape.map(|shape| shape.dimension),
+ 64,
+ )?;
+ let n = match env::var("ANN_DATA_GIB") {
+ Ok(value) => {
+ if public_shape.is_some() {
+ return Err(
+ "ANN_DATA_GIB cannot be combined with public fvecs dataset paths".into(),
+ );
+ }
+ let gib = value.parse::()?;
+ if !gib.is_finite() || gib <= 0.0 {
+ return Err("ANN_DATA_GIB must be finite and greater than zero".into());
+ }
+ let requested_bytes = gib * GIB as f64;
+ if requested_bytes > usize::MAX as f64 {
+ return Err("ANN_DATA_GIB exceeds addressable memory".into());
+ }
+ let vector_bytes = d
+ .checked_mul(size_of::())
+ .ok_or("ANN_D byte size overflows usize")?;
+ (requested_bytes as usize) / vector_bytes
+ }
+ Err(env::VarError::NotPresent) => resolve_shape_value(
+ "ANN_N",
+ read_optional_env("ANN_N")?,
+ public_shape.map(|shape| shape.vector_count),
+ 20_000,
+ )?,
+ Err(error) => return Err(Box::new(error)),
+ };
+ let nq = resolve_shape_value(
+ "ANN_NQ",
+ read_optional_env("ANN_NQ")?,
+ public_shape.map(|shape| shape.query_count),
+ 64,
+ )?;
let k = read_env("ANN_K", 10)?;
let nlist = read_env("ANN_NLIST", 64)?;
let nprobe = read_env("ANN_NPROBE", 8)?;
- let pq_m = read_env("ANN_PQ_M", 16)?;
- let hnsw_m = read_env("ANN_HNSW_M", 20)?;
- let hnsw_ef_construction = read_env("ANN_HNSW_EF_CONSTRUCTION", 150)?;
- let hnsw_ef_search = read_env("ANN_HNSW_EF_SEARCH", 80)?;
- let rq_query_bits = read_env("ANN_RQ_QUERY_BITS", 0)?;
+ let train_n = match read_optional_env("ANN_TRAIN_N")? {
+ Some(train_n) => train_n,
+ None => default_training_vector_count(n, nlist)?,
+ };
+ if let Some(shape) = public_shape {
+ if shape.ground_truth_width < k {
+ return Err(format!(
+ "public ground-truth width {} is smaller than ANN_K={k}",
+ shape.ground_truth_width
+ )
+ .into());
+ }
+ }
+ let pq_m = match env::var("ANN_PQ_M") {
+ Ok(value) => value.parse()?,
+ Err(env::VarError::NotPresent) => {
+ let code_ratio = read_env("ANN_PQ_CODE_RATIO", DEFAULT_PQ_CODE_RATIO)?;
+ infer_pq_m(d, 8, code_ratio)?
+ }
+ Err(error) => return Err(Box::new(error)),
+ };
+ let diskann_l_search = read_env("ANN_DISKANN_L_SEARCH", 100)?;
+ let diskann_l_searches =
+ read_usize_list_env("ANN_DISKANN_L_SEARCHES", &[diskann_l_search])?;
+ let diskann_memory_budget_bytes = read_env(
+ "ANN_DISKANN_MEMORY_BUDGET_BYTES",
+ DiskAnnBuildParams::default().memory_budget_bytes,
+ )?;
+ let diskann_build_distance = match env::var("ANN_DISKANN_BUILD_DISTANCE")
+ .unwrap_or_else(|_| "product_quantized".to_string())
+ .to_ascii_lowercase()
+ .as_str()
+ {
+ "full_precision" | "full-precision" => DiskAnnBuildDistance::FullPrecision,
+ "product_quantized" | "product-quantized" | "pq" => {
+ DiskAnnBuildDistance::ProductQuantized
+ }
+ value => {
+ return Err(format!(
+ "ANN_DISKANN_BUILD_DISTANCE must be full_precision or product_quantized, got {value}"
+ )
+ .into())
+ }
+ };
+ let diskann_raw_vector_encoding = match env::var("ANN_DISKANN_RAW_VECTOR_ENCODING")
+ .unwrap_or_else(|_| {
+ match DiskAnnBuildParams::default().raw_vector_encoding {
+ DiskAnnRawVectorEncoding::F32 => "f32",
+ DiskAnnRawVectorEncoding::F16 => "f16",
+ }
+ .to_string()
+ })
+ .to_ascii_lowercase()
+ .as_str()
+ {
+ "f32" => DiskAnnRawVectorEncoding::F32,
+ "f16" => DiskAnnRawVectorEncoding::F16,
+ value => {
+ return Err(format!(
+ "ANN_DISKANN_RAW_VECTOR_ENCODING must be f32 or f16, got {value}"
+ )
+ .into())
+ }
+ };
+ let rq_bits = read_env("ANN_RQ_BITS", DEFAULT_RQ_BITS)?;
let clusters = read_env("ANN_CLUSTERS", 32)?;
+ let noise_dimensions = read_env("ANN_NOISE_DIMENSIONS", d)?;
let seed = read_env("ANN_SEED", 42)?;
+ let indexes = selected_indexes()?;
+ let storage_cases = selected_storage_cases()?;
+ let keep_indexes = read_bool_env("ANN_KEEP_INDEXES", false)?;
+ let reuse_index_path = env::var("ANN_REUSE_INDEX_PATH").ok().map(PathBuf::from);
let output_dir = env::var("ANN_OUTPUT_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| env::temp_dir().join("paimon-ann-bench"));
- if n == 0 || nq == 0 || d == 0 || k == 0 || nlist == 0 || nprobe == 0 {
+ if n == 0
+ || train_n == 0
+ || nq == 0
+ || d == 0
+ || k == 0
+ || nlist == 0
+ || nprobe == 0
+ || clusters == 0
+ || noise_dimensions == 0
+ {
return Err(
- "ANN_N, ANN_NQ, ANN_D, ANN_K, ANN_NLIST, and ANN_NPROBE must be > 0".into(),
+ "ANN_N/ANN_DATA_GIB, ANN_TRAIN_N, ANN_NQ, ANN_D, ANN_K, ANN_NLIST, ANN_NPROBE, ANN_CLUSTERS, and ANN_NOISE_DIMENSIONS must be > 0".into(),
);
}
+ if noise_dimensions > d {
+ return Err(format!(
+ "ANN_NOISE_DIMENSIONS ({noise_dimensions}) must be <= ANN_D ({d})"
+ )
+ .into());
+ }
+ if train_n > n {
+ return Err(format!("ANN_TRAIN_N ({train_n}) must be <= vector count ({n})").into());
+ }
if nlist > n {
- return Err(format!("ANN_NLIST ({}) must be <= ANN_N ({})", nlist, n).into());
+ return Err(format!("ANN_NLIST ({nlist}) must be <= ANN_N ({n})").into());
}
if nprobe > nlist {
- return Err(format!("ANN_NPROBE ({}) must be <= ANN_NLIST ({})", nprobe, nlist).into());
+ return Err(format!("ANN_NPROBE ({nprobe}) must be <= ANN_NLIST ({nlist})").into());
}
- if d % pq_m != 0 {
- return Err(format!("ANN_D ({}) must be divisible by ANN_PQ_M ({})", d, pq_m).into());
+ let needs_pq = indexes
+ .iter()
+ .any(|name| matches!(name.as_str(), "IVF_PQ" | "DISKANN"));
+ if needs_pq && pq_m == 0 {
+ return Err("ANN_PQ_M must be > 0 for IVF_PQ or DISKANN".into());
}
- if d % 8 != 0 {
- return Err(format!("ANN_D ({}) must be divisible by 8 for IVF_RQ", d).into());
+ if needs_pq && !d.is_multiple_of(pq_m) {
+ return Err(format!("ANN_D ({d}) must be divisible by ANN_PQ_M ({pq_m})").into());
}
- if !is_supported_query_bits(rq_query_bits) {
- return Err(format!(
- "ANN_RQ_QUERY_BITS ({}) must be one of 0, 4, or 8",
- rq_query_bits
- )
- .into());
+ if !is_supported_rq_bits(rq_bits) {
+ return Err(format!("ANN_RQ_BITS ({rq_bits}) must be in 1..=8").into());
}
Ok(Self {
+ dataset_name,
+ dataset_paths,
n,
+ train_n,
nq,
d,
k,
nlist,
nprobe,
pq_m,
- hnsw_m,
- hnsw_ef_construction,
- hnsw_ef_search,
- rq_query_bits,
+ diskann_l_searches,
+ diskann_memory_budget_bytes,
+ diskann_build_distance,
+ diskann_raw_vector_encoding,
+ rq_bits,
clusters,
+ noise_dimensions,
seed,
+ indexes,
+ storage_cases,
+ keep_indexes,
+ reuse_index_path,
output_dir,
})
}
- fn hnsw_params(&self) -> HnswBuildParams {
- HnswBuildParams {
- m: self.hnsw_m,
- ef_construction: self.hnsw_ef_construction,
- ..HnswBuildParams::default()
- }
- .sanitized()
+ fn raw_dataset_bytes(&self) -> Result> {
+ self.n
+ .checked_mul(self.d)
+ .and_then(|elements| elements.checked_mul(size_of::()))
+ .ok_or_else(|| "ANN raw dataset byte size overflows usize".into())
}
}
struct Dataset {
data: Vec,
queries: Vec,
+ dimension: usize,
+ ground_truth: Option>>,
}
impl Dataset {
- fn clustered(n: usize, nq: usize, d: usize, clusters: usize, seed: u64) -> Self {
- let mut rng = Lcg::new(seed);
- let mut centers = vec![0.0f32; clusters * d];
+ fn load(config: &Config) -> io::Result {
+ if let Some(paths) = &config.dataset_paths {
+ return Self::fvecs(config, paths);
+ }
+ Self::clustered(config)
+ }
+
+ fn fvecs(config: &Config, paths: &DatasetPaths) -> io::Result {
+ let (dimension, data) = read_fvecs(&paths.base)?;
+ let (query_dimension, queries) = read_fvecs(&paths.queries)?;
+ if dimension != config.d || query_dimension != config.d {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "ANN_D={} does not match base/query fvec dimensions {dimension}/{query_dimension}",
+ config.d
+ ),
+ ));
+ }
+ let n = data.len() / dimension;
+ let nq = queries.len() / dimension;
+ if n != config.n || nq != config.nq {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "ANN_N/ANN_NQ={}/{} do not match base/query fvec counts {n}/{nq}",
+ config.n, config.nq
+ ),
+ ));
+ }
+ let ground_truth = read_ivecs(&paths.ground_truth)?;
+ if ground_truth.len() != config.nq {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "ANN_NQ={} does not match ground-truth count {}",
+ config.nq,
+ ground_truth.len()
+ ),
+ ));
+ }
+ let truth_width = ground_truth.first().map_or(0, Vec::len);
+ if truth_width < config.k || ground_truth.iter().any(|row| row.len() != truth_width) {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "ground truth must have a fixed width of at least ANN_K={}",
+ config.k
+ ),
+ ));
+ }
+ if let Some(invalid) = ground_truth
+ .iter()
+ .flatten()
+ .find(|row_id| **row_id < 0 || **row_id as usize >= config.n)
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("ground-truth row ID {invalid} is outside [0, {})", config.n),
+ ));
+ }
+ Ok(Self {
+ data,
+ queries,
+ dimension,
+ ground_truth: Some(ground_truth),
+ })
+ }
+
+ fn clustered(config: &Config) -> io::Result {
+ let mut rng = Lcg::new(config.seed);
+ let center_elements = config
+ .clusters
+ .checked_mul(config.d)
+ .ok_or_else(|| io::Error::other("ANN center shape overflows usize"))?;
+ let mut centers = try_zeroed_f32(center_elements, "cluster centers")?;
for value in &mut centers {
- *value = rng.next_f32() * 30.0;
+ *value = rng.next_signed_f32() * 15.0;
}
- let mut data = vec![0.0f32; n * d];
- for i in 0..n {
- let cluster = i % clusters;
- for j in 0..d {
- data[i * d + j] = centers[cluster * d + j] + rng.next_f32();
+ let data_elements = config
+ .n
+ .checked_mul(config.d)
+ .ok_or_else(|| io::Error::other("ANN dataset shape overflows usize"))?;
+ let mut data = try_zeroed_f32(data_elements, "dataset")?;
+ for row in 0..config.n {
+ let cluster = row % config.clusters;
+ for component in 0..config.d {
+ let noise = if is_noise_dimension(component, config.d, config.noise_dimensions) {
+ rng.next_signed_f32()
+ } else {
+ 0.0
+ };
+ data[row * config.d + component] = centers[cluster * config.d + component] + noise;
}
}
- let mut queries = vec![0.0f32; nq * d];
- for qi in 0..nq {
- let source = (qi * 9973) % n;
- queries[qi * d..(qi + 1) * d].copy_from_slice(&data[source * d..(source + 1) * d]);
+ let query_elements = config
+ .nq
+ .checked_mul(config.d)
+ .ok_or_else(|| io::Error::other("ANN query shape overflows usize"))?;
+ let mut queries = try_zeroed_f32(query_elements, "queries")?;
+ for query in 0..config.nq {
+ let cluster = (query * 17 + 3) % config.clusters;
+ for component in 0..config.d {
+ let noise = if is_noise_dimension(component, config.d, config.noise_dimensions) {
+ rng.next_signed_f32()
+ } else {
+ 0.0
+ };
+ queries[query * config.d + component] =
+ centers[cluster * config.d + component] + noise;
+ }
+ }
+ Ok(Self {
+ data,
+ queries,
+ dimension: config.d,
+ ground_truth: None,
+ })
+ }
+}
+
+#[derive(Clone)]
+struct DatasetPaths {
+ base: PathBuf,
+ queries: PathBuf,
+ ground_truth: PathBuf,
+}
+
+impl DatasetPaths {
+ fn from_env() -> Result