blob: a949786fb0f7f266666aab0b99f3c4f3b6781eef [file] [view]
<!--
~ Licensed to the Apache Software Foundation (ASF) under one
~ or more contributor license agreements. See the NOTICE file
~ distributed with this work for additional information
~ regarding copyright ownership. The ASF licenses this file
~ to you under the Apache License, Version 2.0 (the
~ "License"); you may not use this file except in compliance
~ with the License. You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing,
~ software distributed under the License is distributed on an
~ "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
~ KIND, either express or implied. See the License for the
~ specific language governing permissions and limitations
~ under the License.
-->
# Apache Paimon Vector Index &emsp; [![Build Status]][actions]
[Build Status]: https://img.shields.io/github/actions/workflow/status/apache/paimon-vector-index/ci.yml
[actions]: https://github.com/apache/paimon-vector-index/actions?query=branch%3Amain
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. Index readers use
seek-based positional I/O so query execution can read only the parts of an index
file needed by the selected IVF lists.
The project is no longer limited to IVF-PQ. The unified writer and reader APIs
support multiple index families across Rust, C FFI, Java/JNI, and Python:
| Index type | Summary | Best fit |
| --- | --- | --- |
| `IVF_FLAT` | IVF partitioning with uncompressed vectors. | Baseline recall and simple storage. |
| `IVF_PQ` | IVF with product quantization and optional OPQ rotation. | Compact indexes with fast approximate scans. |
| `IVF_RQ` | IVF with 1-bit RaBitQ-style rotated residual quantization. | Very compact high-dimensional indexes with low training cost. |
| `IVF_HNSW_FLAT` | IVF partitioning with an HNSW graph inside each list over raw vectors. | Higher recall within probed IVF lists. |
| `IVF_HNSW_SQ` | IVF partitioning with per-list HNSW and scalar-quantized vectors. | HNSW-style search with smaller vector storage. |
All index types share:
- `L2`, inner product, and cosine metrics.
- Training, vector add, serialization, metadata, single-query search, and batch
search APIs.
- Reader-side index type detection from the file header.
- Optional row-id prefiltering with serialized 64-bit Roaring bitmaps.
## Workspace
The repository contains four public integration layers:
- `core`: Rust implementation and benchmark suite.
- `ffi`: C ABI bindings backed by the Rust core.
- `jni`: Java classes plus JNI bindings backed by the Rust core.
- `python`: Python ctypes bindings backed by the C FFI library.
The top-level Cargo workspace includes `core`, `ffi`, and `jni`. The Python
package under `python` loads the shared FFI library at runtime.
## Unified API
Writers are created from typed configs, while readers detect the index type from
the serialized file header. The same search parameters are used across index
types:
- `top_k`: number of nearest neighbors to return.
- `nprobe`: number of IVF lists to probe.
- `ef_search`: optional HNSW search breadth for `IVF_HNSW_FLAT` and
`IVF_HNSW_SQ`. A value of `0` uses the default.
- `rq.query_bits` / `query_bits`: optional `IVF_RQ` query quantization bits.
Supported values are `0`, `4`, and `8`; `0` keeps the default float-query
byte-LUT path.
`IVF_RQ` currently writes 1-bit codes by default. Its v1 on-disk header already
records `num_bits`, `rotation_type`, `factor_layout`, and optional-section
flags for future multi-bit codes, but current readers intentionally reject
reserved multi-bit payloads until the corresponding scanner is implemented.
Readers also expose an optional search warm-up API. Call
`optimize_for_search` in Rust, C++, and Python,
`paimon_vindex_reader_optimize_for_search` in the C ABI, or
`optimizeForSearch` in Java after opening a reader and before repeated
searches. The call builds in-memory search caches and does not change the
serialized index format or search results. Currently `IVF_PQ` builds residual
L2 precomputed tables for repeated PQ searches, `IVF_HNSW_SQ` builds SQ decode
LUTs for filtered-search SQ scan and fallback paths, and `IVF_RQ` plus other
index types preload metadata. The `IVF_HNSW_SQ` LUTs are not expected to speed up the
normal unfiltered HNSW graph-search path.
### Rust
```rust
use std::fs::File;
use paimon_vindex_core::distance::MetricType;
use paimon_vindex_core::hnsw::HnswBuildParams;
use paimon_vindex_core::index::{
VectorIndexConfig, VectorIndexReader, VectorIndexTrainer, VectorIndexWriter, VectorSearchParams,
};
use paimon_vindex_core::io::PosWriter;
let config = VectorIndexConfig::IvfHnswSq {
dimension: 128,
nlist: 1024,
metric: MetricType::L2,
hnsw: HnswBuildParams::default(),
};
let training = VectorIndexTrainer::train(config, &training_vectors, training_count)?;
let mut writer = VectorIndexWriter::new(training);
writer.add_vectors(&row_ids, &vectors, vector_count)?;
let mut file = File::create("vectors.pvindex")?;
let mut out = PosWriter::new(&mut file);
writer.write(&mut out)?;
let file = File::open("vectors.pvindex")?;
let mut reader = VectorIndexReader::open(file)?;
reader.optimize_for_search()?;
let params = VectorSearchParams::with_ef_search(10, 16, 80);
let (ids, distances) = reader.search(&query, params)?;
let rq_params = VectorSearchParams {
top_k: 10,
nprobe: 16,
ef_search: 0,
query_bits: 4,
};
let (ids, distances) = reader.search(&query, rq_params)?;
```
Other Rust configs follow the same shape:
```rust
VectorIndexConfig::IvfFlat {
dimension: 128,
nlist: 1024,
metric: MetricType::L2,
};
VectorIndexConfig::IvfPq {
dimension: 128,
nlist: 1024,
m: 16,
metric: MetricType::L2,
use_opq: false,
};
VectorIndexConfig::IvfHnswFlat {
dimension: 128,
nlist: 1024,
metric: MetricType::L2,
hnsw: HnswBuildParams::default(),
};
```
### C FFI
The C ABI follows the same layout as Apache Paimon Mosaic: the `ffi` crate
builds `libpaimon_vindex_ffi`, and `cbindgen` writes the generated public header
to `include/paimon_vindex.h`.
```c
#include "paimon_vindex.h"
const char *keys[] = {"index.type", "dimension", "nlist", "metric"};
const char *values[] = {"ivf_flat", "128", "1024", "l2"};
PaimonVindexTrainerHandle *trainer =
paimon_vindex_trainer_open(keys, values, 4);
paimon_vindex_trainer_add_training_vectors(trainer, training_vectors, training_count);
PaimonVindexTrainingHandle *training = paimon_vindex_trainer_finish(trainer);
paimon_vindex_trainer_free(trainer);
PaimonVindexWriterHandle *writer = paimon_vindex_writer_open(training);
paimon_vindex_training_free(training);
paimon_vindex_writer_add_vectors(writer, row_ids, vectors, vector_count);
paimon_vindex_writer_write_index(writer, output_file);
paimon_vindex_writer_free(writer);
PaimonVindexReaderHandle *reader = paimon_vindex_reader_open(input_file);
PaimonVindexMetadata metadata;
paimon_vindex_reader_metadata(reader, &metadata);
paimon_vindex_reader_optimize_for_search(reader);
int64_t ids[10];
float distances[10];
PaimonVindexSearchParams search_params = {
.top_k = 10,
.nprobe = 16,
.ef_search = 80,
.query_bits = 0,
};
paimon_vindex_reader_search(
reader, query, search_params, ids, distances, 10);
paimon_vindex_reader_free(reader);
```
`PaimonVindexOutputFile` and `PaimonVindexInputFile` are callback structs. C
callers own result buffers and pass their capacity to search calls. Functions
return `0` on success and `-1` on error; `paimon_vindex_last_error()` returns a
thread-local error string.
### C++
The C++ header `include/paimon_vindex.hpp` is a small RAII wrapper over the C
ABI, following the same pattern as Apache Paimon Mosaic's C++ facade.
```cpp
#include "paimon_vindex.hpp"
std::vector<std::pair<std::string, std::string>> options = {
{"index.type", "ivf_flat"},
{"dimension", "128"},
{"nlist", "1024"},
{"metric", "l2"},
};
paimon::vindex::Training training =
paimon::vindex::Trainer::train(options, training_vectors.data(), training_count);
paimon::vindex::Writer writer(std::move(training));
writer.add_vectors(row_ids.data(), vectors.data(), vector_count);
writer.write_index(output_file);
paimon::vindex::Reader reader(input_file);
auto metadata = reader.metadata();
reader.optimize_for_search();
auto result = reader.search(query.data(), paimon::vindex::SearchParams{10, 16, 80});
```
### Java/JNI
```java
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import org.apache.paimon.index.vector.VectorIndexInput;
import org.apache.paimon.index.vector.VectorIndexMetadata;
import org.apache.paimon.index.vector.VectorIndexReader;
import org.apache.paimon.index.vector.VectorSearchParams;
import org.apache.paimon.index.vector.VectorIndexTrainer;
import org.apache.paimon.index.vector.VectorIndexTraining;
import org.apache.paimon.index.vector.VectorSearchResult;
import org.apache.paimon.index.vector.VectorIndexWriter;
int dimension = 128;
Map<String, String> options = new HashMap<>();
options.put("index.type", "ivf_hnsw_sq");
options.put("dimension", Integer.toString(dimension));
options.put("nlist", "1024");
options.put("metric", "l2");
options.put("hnsw.m", "20");
options.put("hnsw.ef-construction", "150");
options.put("hnsw.max-level", "7");
try (VectorIndexTraining training =
VectorIndexTrainer.train(options, trainingVectors, trainingCount);
VectorIndexWriter writer = new VectorIndexWriter(training)) {
writer.addVectors(rowIds, vectors, vectorCount);
writer.writeIndex(vectorIndexOutput);
}
// Large training sets can avoid one large Java float[] by staging batches in trainer-owned
// native memory. The batches are accumulated natively and released after finishTraining()
// returns a trained state. This reduces JVM heap pressure and avoids the Java array length
// limit; it does not reduce native peak training memory.
int firstBatchCount = trainingCount / 2;
float[][] trainingBatches = {
Arrays.copyOfRange(trainingVectors, 0, firstBatchCount * dimension),
Arrays.copyOfRange(trainingVectors, firstBatchCount * dimension, trainingCount * dimension),
};
try (VectorIndexTrainer trainer = VectorIndexTrainer.create(options)) {
for (float[] batch : trainingBatches) {
trainer.addTrainingVectors(batch, batch.length / dimension);
}
try (VectorIndexTraining training = trainer.finishTraining();
VectorIndexWriter writer = new VectorIndexWriter(training)) {
writer.addVectors(rowIds, vectors, vectorCount);
writer.writeIndex(vectorIndexOutput);
}
}
try (VectorIndexReader reader = new VectorIndexReader(vectorIndexInput)) {
VectorIndexMetadata metadata = reader.metadata();
reader.optimizeForSearch();
VectorSearchResult result = reader.search(query, new VectorSearchParams(10, 16, 80, 0));
}
```
The Java package is `org.apache.paimon.index.vector`, and the API surface uses
string options so it maps directly to Paimon table/index properties. Rust parses
and validates the options when the trainer is created.
### Python
```python
from paimon_vindex import SearchParams, VectorIndexReader, VectorIndexTrainer, VectorIndexWriter
class VectorIndexInput:
def __init__(self, data: bytes):
self.data = data
def pread_many(self, ranges):
return [self.data[pos : pos + length] for pos, length in ranges]
options = {
"index.type": "ivf_hnsw_sq",
"dimension": "128",
"nlist": "1024",
"metric": "l2",
"hnsw.m": "20",
"hnsw.ef-construction": "150",
"hnsw.max-level": "7",
}
training = VectorIndexTrainer.train(options, training_vectors)
writer = VectorIndexWriter(training)
writer.add_vectors(row_ids, vectors)
writer.write(output)
reader = VectorIndexReader(VectorIndexInput(index_bytes))
reader.optimize_for_search()
ids, distances = reader.search(query, SearchParams(top_k=10, nprobe=16, ef_search=80))
ids, distances = reader.search(query, SearchParams(top_k=10, nprobe=16, query_bits=4))
```
The Python package is pure Python and uses `ctypes` to load
`libpaimon_vindex_ffi`.
`search` returns one-dimensional NumPy arrays for a single query, while
`search_batch` accepts a two-dimensional query array and returns arrays shaped
as `(query_count, top_k)`.
## Metadata Filter Pushdown
The vector index accepts a serialized 64-bit Roaring bitmap of allowed row IDs
during reader search. This lets the Paimon query layer evaluate metadata
predicates with table/scalar indexes first, then pass the matching row-id set
into vector search as an ANN prefilter.
Bindings expose the same wire format:
- Rust core: `VectorIndexReader::search_with_roaring_filter` and
`VectorIndexReader::search_batch_with_roaring_filter`
- C FFI: `paimon_vindex_reader_search_with_roaring_filter` and
`paimon_vindex_reader_search_batch_with_roaring_filter`
- Java/JNI: `VectorIndexReader.search(..., byte[])` and
`VectorIndexReader.searchBatch(..., byte[])`
- Python: `VectorIndexReader.search(..., filter_bytes=...)` and
`VectorIndexReader.search_batch(..., filter_bytes=...)`
Row IDs must be non-negative to map directly into `RoaringTreemap`'s `u64` domain.
## ANN Benchmark
The core crate includes an ANN-style benchmark for comparing Paimon's
`IVF_PQ`, `IVF_RQ`, `IVF_HNSW_FLAT`, and `IVF_HNSW_SQ` implementations. It reports build
time, reader open/load time, first-query latency, batch query throughput, and
serialized index size:
```bash
cargo bench -p paimon-vindex-core --bench ann_bench -- --nocapture
```
The benchmark is configured with environment variables:
```bash
ANN_N=100000 ANN_NQ=1000 ANN_D=128 ANN_K=10 ANN_NLIST=256 ANN_NPROBE=16 \
ANN_PQ_M=16 ANN_HNSW_M=20 ANN_HNSW_EF_CONSTRUCTION=150 ANN_HNSW_EF_SEARCH=80 \
ANN_RQ_QUERY_BITS=0 \
cargo bench -p paimon-vindex-core --bench ann_bench -- --nocapture
```
Benchmark rows report `disk_scope=index_bytes`, which is the serialized vector
index file.
`IVF_HNSW_SQ` filtered-search fallback can be profiled separately with a
filter-heavy benchmark. It compares filtered batch search before and after
`optimize_for_search` and verifies that both paths return the same results:
```bash
cargo bench -p paimon-vindex-core --bench ivfhnswsq_filter_bench -- --nocapture
```
Useful knobs include:
```bash
FILTER_BENCH_N=50000 FILTER_BENCH_NQ=500 FILTER_BENCH_D=128 \
FILTER_BENCH_NLIST=64 FILTER_BENCH_NPROBE=32 FILTER_BENCH_EF_SEARCH=80 \
FILTER_BENCH_FILTER_STRIDES=1,4,16,64 \
cargo bench -p paimon-vindex-core --bench ivfhnswsq_filter_bench -- --nocapture
```
## Development
Common Rust commands:
```bash
cargo fmt --all
cargo test --workspace
cargo clippy --workspace --all-targets
```
C FFI tests build the shared library and compile a C smoke test against the
generated header:
```bash
cargo build --release -p paimon-vindex-ffi
cmake -S c -B c/build
cmake --build c/build
LD_LIBRARY_PATH=target/release c/build/test_vindex
```
C++ tests use the same shared library and the RAII header:
```bash
cargo build --release -p paimon-vindex-ffi
cmake -S cpp -B cpp/build
cmake --build cpp/build
LD_LIBRARY_PATH=target/release cpp/build/test_vindex_cpp
```
Java API tests are run from the JNI Java module:
```bash
mvn -f java/pom.xml test
```
Python ctypes tests are run from the `python` package:
```bash
cargo build --release -p paimon-vindex-ffi
cd python
PAIMON_VINDEX_LIB_PATH=../target/release pip install -e ".[test]"
PAIMON_VINDEX_LIB_PATH=../target/release pytest -v
```
## Contributing
Apache Paimon Vector Index is an exciting project currently under active development. Whether you're looking to use it in your projects or contribute to its growth, there are several ways you can get involved:
- Follow the [Contributing Guide](CONTRIBUTING.md) to contribute.
- Create new [Issue](https://github.com/apache/paimon-vector-index/issues/new) for bug report or feature request.
- Start discussion thread at [dev mailing list](mailto:dev@paimon.apache.org) ([subscribe](<mailto:dev-subscribe@paimon.apache.org?subject=(send%20this%20email%20to%20subscribe)>) / [unsubscribe](<mailto:dev-unsubscribe@paimon.apache.org?subject=(send%20this%20email%20to%20unsubscribe)>) / [archives](https://lists.apache.org/list.html?dev@paimon.apache.org))
- Talk to community directly at [Slack #paimon channel](https://join.slack.com/t/the-asf/shared_invite/zt-2l9rns8pz-H8PE2Xnz6KraVd2Ap40z4g).
## Getting Help
Submit [issues](https://github.com/apache/paimon-vector-index/issues/new/choose) for bug report or asking questions in [discussion](https://github.com/apache/paimon-vector-index/discussions/new?category=q-a).
## License
Licensed under <a href="./LICENSE">Apache License, Version 2.0</a>.