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 35c461e Convert API to batched vector index training (#52)
35c461e is described below
commit 35c461e15fab82312b709b60ce2fce3e4e55ac84
Author: jerry <[email protected]>
AuthorDate: Tue Jun 30 10:43:00 2026 +0800
Convert API to batched vector index training (#52)
---
.github/workflows/ci.yml | 14 +-
README.md | 61 ++++--
c/test_vindex.c | 48 +++--
core/benches/ivfhnswsq_filter_bench.rs | 20 +-
core/src/index.rs | 146 ++++++++++++---
cpp/test_vindex.cpp | 11 +-
ffi/src/lib.rs | 161 ++++++++++++++--
include/paimon_vindex.hpp | 144 +++++++++++---
.../paimon/index/vector/VectorIndexNative.java | 16 +-
...torIndexWriter.java => VectorIndexTrainer.java} | 69 ++++---
.../paimon/index/vector/VectorIndexTraining.java | 81 ++++++++
.../paimon/index/vector/VectorIndexWriter.java | 24 +--
.../index/vector/VectorIndexJavaApiTest.java | 58 +++++-
.../vector/VectorIndexNativeHandleSafetyTest.java | 5 +-
.../vector/VectorIndexNativePanicBoundaryTest.java | 48 +++--
.../vector/VectorIndexNativeValidationTest.java | 190 +++++++++++++++++--
jni/src/lib.rs | 207 +++++++++++++++++++--
python/paimon_vindex/__init__.py | 158 ++++++++++++++--
python/paimon_vindex/_ffi.py | 27 ++-
python/tests/test_vindex.py | 19 +-
20 files changed, 1257 insertions(+), 250 deletions(-)
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index d1e5275..4dbb1bd 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -254,7 +254,7 @@ jobs:
python - <<'PY'
import io
import numpy as np
- from paimon_vindex import VectorIndexReader, VectorIndexWriter
+ from paimon_vindex import VectorIndexReader, VectorIndexTrainer,
VectorIndexWriter
class Input:
def __init__(self, data):
@@ -266,8 +266,9 @@ jobs:
data = np.array([[0.0, 0.0], [0.1, 0.0], [10.0, 10.0], [10.1,
10.0]], dtype=np.float32)
ids = np.array([1, 2, 3, 4], dtype=np.int64)
output = io.BytesIO()
- writer = VectorIndexWriter({"index.type": "ivf_flat", "dimension":
"2", "nlist": "2", "metric": "l2"})
- writer.train(data)
+ options = {"index.type": "ivf_flat", "dimension": "2", "nlist": "2",
"metric": "l2"}
+ training = VectorIndexTrainer.train(options, data)
+ writer = VectorIndexWriter(training)
writer.add_vectors(ids, data)
writer.write(output)
writer.close()
@@ -291,7 +292,7 @@ jobs:
python -c @'
import io
import numpy as np
- from paimon_vindex import VectorIndexReader, VectorIndexWriter
+ from paimon_vindex import VectorIndexReader, VectorIndexTrainer,
VectorIndexWriter
class Input:
def __init__(self, data):
@@ -303,8 +304,9 @@ jobs:
data = np.array([[0.0, 0.0], [0.1, 0.0], [10.0, 10.0], [10.1,
10.0]], dtype=np.float32)
ids = np.array([1, 2, 3, 4], dtype=np.int64)
output = io.BytesIO()
- writer = VectorIndexWriter({"index.type": "ivf_flat", "dimension":
"2", "nlist": "2", "metric": "l2"})
- writer.train(data)
+ options = {"index.type": "ivf_flat", "dimension": "2", "nlist": "2",
"metric": "l2"}
+ training = VectorIndexTrainer.train(options, data)
+ writer = VectorIndexWriter(training)
writer.add_vectors(ids, data)
writer.write(output)
writer.close()
diff --git a/README.md b/README.md
index 82bd6f1..f04a838 100644
--- a/README.md
+++ b/README.md
@@ -87,7 +87,7 @@ use std::fs::File;
use paimon_vindex_core::distance::MetricType;
use paimon_vindex_core::hnsw::HnswBuildParams;
use paimon_vindex_core::index::{
- VectorIndexConfig, VectorIndexReader, VectorIndexWriter,
VectorSearchParams,
+ VectorIndexConfig, VectorIndexReader, VectorIndexTrainer,
VectorIndexWriter, VectorSearchParams,
};
use paimon_vindex_core::io::PosWriter;
@@ -98,8 +98,8 @@ let config = VectorIndexConfig::IvfHnswSq {
hnsw: HnswBuildParams::default(),
};
-let mut writer = VectorIndexWriter::new(config)?;
-writer.train(&training_vectors, training_count)?;
+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")?;
@@ -150,9 +150,14 @@ to `include/paimon_vindex.h`.
const char *keys[] = {"index.type", "dimension", "nlist", "metric"};
const char *values[] = {"ivf_flat", "128", "1024", "l2"};
-PaimonVindexWriterHandle *writer =
- paimon_vindex_writer_open(keys, values, 4);
-paimon_vindex_writer_train(writer, training_vectors, training_count);
+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);
@@ -189,8 +194,9 @@ std::vector<std::pair<std::string, std::string>> options = {
{"metric", "l2"},
};
-paimon::vindex::Writer writer(options);
-writer.train(training_vectors.data(), training_count);
+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);
@@ -203,30 +209,55 @@ auto result = reader.search(query.data(), 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.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", "128");
+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 (VectorIndexWriter writer = new VectorIndexWriter(options)) {
- writer.train(trainingVectors, trainingCount);
+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();
@@ -236,12 +267,12 @@ try (VectorIndexReader reader = new
VectorIndexReader(vectorIndexInput)) {
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 writer is created.
+and validates the options when the trainer is created.
### Python
```python
-from paimon_vindex import VectorIndexReader, VectorIndexWriter
+from paimon_vindex import VectorIndexReader, VectorIndexTrainer,
VectorIndexWriter
class VectorIndexInput:
@@ -261,8 +292,8 @@ options = {
"hnsw.ef-construction": "150",
"hnsw.max-level": "7",
}
-writer = VectorIndexWriter(options)
-writer.train(training_vectors)
+training = VectorIndexTrainer.train(options, training_vectors)
+writer = VectorIndexWriter(training)
writer.add_vectors(row_ids, vectors)
writer.write(output)
diff --git a/c/test_vindex.c b/c/test_vindex.c
index b08660e..99f3f63 100644
--- a/c/test_vindex.c
+++ b/c/test_vindex.c
@@ -176,15 +176,15 @@ static void run_roundtrip(
uint32_t expected_index_type,
uintptr_t expected_pq_m,
uintptr_t expected_hnsw_m) {
- PaimonVindexWriterHandle *writer =
- paimon_vindex_writer_open(keys, values, num_options);
- if (writer == NULL) {
- fail_ffi("writer open failed");
+ PaimonVindexTrainerHandle *trainer =
+ paimon_vindex_trainer_open(keys, values, num_options);
+ if (trainer == NULL) {
+ fail_ffi("trainer open failed");
}
uintptr_t dimension = 0;
- if (paimon_vindex_writer_dimension(writer, &dimension) != 0) {
- fail_ffi("writer dimension failed");
+ if (paimon_vindex_trainer_dimension(trainer, &dimension) != 0) {
+ fail_ffi("trainer dimension failed");
}
ASSERT_EQ_I64(dimension, 2);
@@ -194,9 +194,20 @@ static void run_roundtrip(
ASSERT_TRUE(ids != NULL);
fill_roundtrip_data(data, ids);
- if (paimon_vindex_writer_train(writer, data, ROUNDTRIP_VECTOR_COUNT) != 0)
{
- fail_ffi("writer train failed");
+ if (paimon_vindex_trainer_add_training_vectors(trainer, data,
ROUNDTRIP_VECTOR_COUNT) != 0) {
+ fail_ffi("trainer add training vectors failed");
+ }
+ PaimonVindexTrainingHandle *training =
paimon_vindex_trainer_finish(trainer);
+ if (training == NULL) {
+ fail_ffi("trainer finish failed");
+ }
+ paimon_vindex_trainer_free(trainer);
+
+ PaimonVindexWriterHandle *writer = paimon_vindex_writer_open(training);
+ if (writer == NULL) {
+ fail_ffi("writer open failed");
}
+ paimon_vindex_training_free(training);
if (paimon_vindex_writer_add_vectors(writer, ids, data,
ROUNDTRIP_VECTOR_COUNT) != 0) {
fail_ffi("writer add failed");
}
@@ -269,16 +280,27 @@ static void run_roundtrip(
static PaimonVindexWriterHandle *new_trained_flat_writer(void) {
const char *keys[] = {"index.type", "dimension", "nlist", "metric"};
const char *values[] = {"ivf_flat", "1", "1", "l2"};
- PaimonVindexWriterHandle *writer = paimon_vindex_writer_open(keys, values,
4);
- if (writer == NULL) {
- fail_ffi("writer open failed");
+ PaimonVindexTrainerHandle *trainer = paimon_vindex_trainer_open(keys,
values, 4);
+ if (trainer == NULL) {
+ fail_ffi("trainer open failed");
}
const float data[] = {0.0f, 1.0f};
const int64_t ids[] = {1, 2};
- if (paimon_vindex_writer_train(writer, data, 2) != 0) {
- fail_ffi("writer train failed");
+ if (paimon_vindex_trainer_add_training_vectors(trainer, data, 2) != 0) {
+ fail_ffi("trainer add training vectors failed");
+ }
+ PaimonVindexTrainingHandle *training =
paimon_vindex_trainer_finish(trainer);
+ if (training == NULL) {
+ fail_ffi("trainer finish failed");
+ }
+ paimon_vindex_trainer_free(trainer);
+
+ PaimonVindexWriterHandle *writer = paimon_vindex_writer_open(training);
+ if (writer == NULL) {
+ fail_ffi("writer open failed");
}
+ paimon_vindex_training_free(training);
if (paimon_vindex_writer_add_vectors(writer, ids, data, 2) != 0) {
fail_ffi("writer add failed");
}
diff --git a/core/benches/ivfhnswsq_filter_bench.rs
b/core/benches/ivfhnswsq_filter_bench.rs
index d49a92a..ab82310 100644
--- a/core/benches/ivfhnswsq_filter_bench.rs
+++ b/core/benches/ivfhnswsq_filter_bench.rs
@@ -18,7 +18,7 @@
use paimon_vindex_core::distance::MetricType;
use paimon_vindex_core::hnsw::HnswBuildParams;
use paimon_vindex_core::index::{
- VectorIndexConfig, VectorIndexReader, VectorIndexWriter,
VectorSearchParams,
+ VectorIndexConfig, VectorIndexReader, VectorIndexTrainer,
VectorIndexWriter, VectorSearchParams,
};
use paimon_vindex_core::io::PosWriter;
use roaring::RoaringTreemap;
@@ -34,13 +34,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let ids: Vec<i64> = (0..cfg.n as i64).collect();
let start = Instant::now();
- let mut writer = VectorIndexWriter::new(VectorIndexConfig::IvfHnswSq {
- dimension: cfg.d,
- nlist: cfg.nlist,
- metric: MetricType::L2,
- hnsw: cfg.hnsw_params(),
- })?;
- writer.train(&data, cfg.n)?;
+ let training = VectorIndexTrainer::train(
+ VectorIndexConfig::IvfHnswSq {
+ dimension: cfg.d,
+ nlist: cfg.nlist,
+ metric: MetricType::L2,
+ hnsw: cfg.hnsw_params(),
+ },
+ &data,
+ cfg.n,
+ )?;
+ let mut writer = VectorIndexWriter::new(training);
writer.add_vectors(&ids, &data, cfg.n)?;
let mut index_bytes = Vec::new();
writer.write(&mut PosWriter::new(&mut index_bytes))?;
diff --git a/core/src/index.rs b/core/src/index.rs
index c0b890c..25ea689 100644
--- a/core/src/index.rs
+++ b/core/src/index.rs
@@ -333,6 +333,75 @@ pub struct VectorIndexMetadata {
pub hnsw: Option<HnswBuildParams>,
}
+pub struct VectorIndexTrainer {
+ writer: VectorIndexWriter,
+ training_data: Vec<f32>,
+ training_vector_count: usize,
+}
+
+impl VectorIndexTrainer {
+ pub fn new(config: VectorIndexConfig) -> io::Result<Self> {
+ Ok(Self {
+ writer: VectorIndexWriter::from_config(config)?,
+ training_data: Vec::new(),
+ training_vector_count: 0,
+ })
+ }
+
+ pub fn train(
+ config: VectorIndexConfig,
+ data: &[f32],
+ n: usize,
+ ) -> io::Result<VectorIndexTraining> {
+ Self::new(config)?.add_training_vectors(data, n)?.finish()
+ }
+
+ pub fn dimension(&self) -> usize {
+ self.writer.dimension()
+ }
+
+ pub fn add_training_vectors(mut self, data: &[f32], n: usize) ->
io::Result<Self> {
+ self.add_training_vectors_mut(data, n)?;
+ Ok(self)
+ }
+
+ pub fn add_training_vectors_mut(&mut self, data: &[f32], n: usize) ->
io::Result<&mut Self> {
+ validate_vectors(data, n, self.dimension(), "training data")?;
+ let training_vector_count =
self.training_vector_count.checked_add(n).ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidInput,
+ "training vector count overflows usize",
+ )
+ })?;
+ self.training_data.extend_from_slice(data);
+ self.training_vector_count = training_vector_count;
+ Ok(self)
+ }
+
+ pub fn finish(mut self) -> io::Result<VectorIndexTraining> {
+ if self.training_vector_count == 0 || self.training_data.is_empty() {
+ return Err(invalid_input("no training vectors added"));
+ }
+ self.writer
+ .train_internal(&self.training_data, self.training_vector_count)?;
+ Ok(VectorIndexTraining { inner: self.writer })
+ }
+}
+
+pub struct VectorIndexTraining {
+ inner: VectorIndexWriter,
+}
+
+impl VectorIndexTraining {
+ pub fn index_type(&self) -> IndexType {
+ self.inner.index_type()
+ }
+
+ pub fn dimension(&self) -> usize {
+ self.inner.dimension()
+ }
+}
+
pub enum VectorIndexWriter {
IvfFlat(IVFFlatIndex),
IvfPq(IVFPQIndex),
@@ -341,7 +410,11 @@ pub enum VectorIndexWriter {
}
impl VectorIndexWriter {
- pub fn new(config: VectorIndexConfig) -> io::Result<Self> {
+ pub fn new(training: VectorIndexTraining) -> Self {
+ training.inner
+ }
+
+ fn from_config(config: VectorIndexConfig) -> io::Result<Self> {
validate_config(&config)?;
Ok(match config {
VectorIndexConfig::IvfFlat {
@@ -399,8 +472,8 @@ impl VectorIndexWriter {
}
}
- pub fn train(&mut self, data: &[f32], n: usize) -> io::Result<()> {
- validate_vectors(data, n, self.dimension(), "training data")?;
+ fn train_internal(&mut self, data: &[f32], n: usize) -> io::Result<()> {
+ debug_assert_eq!(Some(data.len()), n.checked_mul(self.dimension()));
match self {
Self::IvfFlat(index) => index.train(data, n),
Self::IvfPq(index) => index.train(data, n),
@@ -813,9 +886,8 @@ mod tests {
let data = generate_clustered_data(n, d, nlist);
let ids = (0..n as i64).collect::<Vec<_>>();
- let mut writer = VectorIndexWriter::new(config.clone()).unwrap();
+ let mut writer = build_writer(config.clone(), &data, n);
assert_eq!(writer.index_type(), config.index_type());
- writer.train(&data, n).unwrap();
writer.add_vectors(&ids, &data, n).unwrap();
let mut buf = Vec::new();
@@ -842,8 +914,7 @@ mod tests {
let data = generate_clustered_data(n, d, nlist);
let ids = (0..n as i64).collect::<Vec<_>>();
- let mut writer = VectorIndexWriter::new(config).unwrap();
- writer.train(&data, n).unwrap();
+ let mut writer = build_writer(config, &data, n);
writer.add_vectors(&ids, &data, n).unwrap();
let mut buf = Vec::new();
@@ -852,13 +923,15 @@ mod tests {
}
fn build_ivfflat_reader() -> VectorIndexReader<Cursor<Vec<u8>>> {
- let mut writer = VectorIndexWriter::new(VectorIndexConfig::IvfFlat {
- dimension: 1,
- nlist: 1,
- metric: MetricType::L2,
- })
- .unwrap();
- writer.train(&[0.0, 1.0], 2).unwrap();
+ let mut writer = build_writer(
+ VectorIndexConfig::IvfFlat {
+ dimension: 1,
+ nlist: 1,
+ metric: MetricType::L2,
+ },
+ &[0.0, 1.0],
+ 2,
+ );
writer.add_vectors(&[1, 2], &[0.0, 1.0], 2).unwrap();
let mut bytes = Vec::new();
@@ -866,6 +939,11 @@ mod tests {
VectorIndexReader::open(Cursor::new(bytes)).unwrap()
}
+ fn build_writer(config: VectorIndexConfig, data: &[f32], n: usize) ->
VectorIndexWriter {
+ let training = VectorIndexTrainer::train(config, data, n).unwrap();
+ VectorIndexWriter::new(training)
+ }
+
fn assert_invalid_input_contains(result: io::Result<()>, expected: &str) {
let err = result.expect_err("invalid input should be rejected");
assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
@@ -968,7 +1046,7 @@ mod tests {
#[test]
fn unified_config_rejects_invalid_pq_m() {
- let err = match VectorIndexWriter::new(VectorIndexConfig::IvfPq {
+ let err = match VectorIndexTrainer::new(VectorIndexConfig::IvfPq {
dimension: 10,
nlist: 4,
m: 3,
@@ -1038,7 +1116,7 @@ mod tests {
}
#[test]
- fn unified_writer_rejects_non_finite_training_data() {
+ fn unified_trainer_rejects_non_finite_training_data() {
for (value, expected) in [
(
f32::NAN,
@@ -1053,13 +1131,19 @@ mod tests {
"training data contains non-finite value at offset 0: -inf",
),
] {
- let mut writer = VectorIndexWriter::new(VectorIndexConfig::IvfFlat
{
- dimension: 1,
- nlist: 1,
- metric: MetricType::L2,
- })
- .unwrap();
- assert_invalid_input_contains(writer.train(&[value, 1.0], 2),
expected);
+ assert_invalid_input_contains(
+ VectorIndexTrainer::train(
+ VectorIndexConfig::IvfFlat {
+ dimension: 1,
+ nlist: 1,
+ metric: MetricType::L2,
+ },
+ &[value, 1.0],
+ 2,
+ )
+ .map(|_| ()),
+ expected,
+ );
}
}
@@ -1130,13 +1214,15 @@ mod tests {
"vector data contains non-finite value at offset 0: -inf",
),
] {
- let mut writer = VectorIndexWriter::new(VectorIndexConfig::IvfFlat
{
- dimension: 1,
- nlist: 1,
- metric: MetricType::L2,
- })
- .unwrap();
- writer.train(&[0.0, 1.0], 2).unwrap();
+ let mut writer = build_writer(
+ VectorIndexConfig::IvfFlat {
+ dimension: 1,
+ nlist: 1,
+ metric: MetricType::L2,
+ },
+ &[0.0, 1.0],
+ 2,
+ );
assert_invalid_input_contains(writer.add_vectors(&[1, 2], &[value,
1.0], 2), expected);
}
}
diff --git a/cpp/test_vindex.cpp b/cpp/test_vindex.cpp
index 96bd2dd..ec1b5e3 100644
--- a/cpp/test_vindex.cpp
+++ b/cpp/test_vindex.cpp
@@ -110,12 +110,15 @@ static void run_roundtrip(
uint32_t expected_index_type,
size_t expected_pq_m,
size_t expected_hnsw_m) {
- paimon::vindex::Writer writer(options);
- ASSERT_EQ(writer.dimension(), 2);
-
std::vector<float> data = roundtrip_data();
std::vector<int64_t> ids = roundtrip_ids();
- writer.train(data.data(), kRoundtripVectorCount);
+ paimon::vindex::Trainer trainer(options);
+ ASSERT_EQ(trainer.dimension(), 2);
+ paimon::vindex::Training training =
+ trainer.add_training_vectors(data.data(),
kRoundtripVectorCount).finish_training();
+
+ paimon::vindex::Writer writer(std::move(training));
+ ASSERT_EQ(writer.dimension(), 2);
writer.add_vectors(ids.data(), data.data(), kRoundtripVectorCount);
MemBuffer buf;
diff --git a/ffi/src/lib.rs b/ffi/src/lib.rs
index 5e58f92..1e719e3 100644
--- a/ffi/src/lib.rs
+++ b/ffi/src/lib.rs
@@ -19,8 +19,8 @@
use paimon_vindex_core::distance::MetricType;
use paimon_vindex_core::index::{
- VectorIndexConfig, VectorIndexMetadata, VectorIndexReader,
VectorIndexWriter,
- VectorSearchParams,
+ VectorIndexConfig, VectorIndexMetadata, VectorIndexReader,
VectorIndexTrainer,
+ VectorIndexTraining, VectorIndexWriter, VectorSearchParams,
};
use paimon_vindex_core::io::{ReadRequest, SeekRead, SeekWrite};
use std::cell::RefCell;
@@ -214,6 +214,14 @@ pub struct PaimonVindexMetadata {
pub hnsw_max_level: usize,
}
+pub struct PaimonVindexTrainerHandle {
+ inner: Option<VectorIndexTrainer>,
+}
+
+pub struct PaimonVindexTrainingHandle {
+ inner: Option<VectorIndexTraining>,
+}
+
pub struct PaimonVindexWriterHandle {
inner: VectorIndexWriter,
}
@@ -301,6 +309,36 @@ unsafe fn writer_mut<'a>(
}
}
+unsafe fn trainer_mut<'a>(
+ handle: *mut PaimonVindexTrainerHandle,
+) -> Result<&'a mut PaimonVindexTrainerHandle, String> {
+ if handle.is_null() {
+ Err("null trainer handle".to_string())
+ } else {
+ Ok(unsafe { &mut *handle })
+ }
+}
+
+unsafe fn trainer_ref<'a>(
+ handle: *const PaimonVindexTrainerHandle,
+) -> Result<&'a PaimonVindexTrainerHandle, String> {
+ if handle.is_null() {
+ Err("null trainer handle".to_string())
+ } else {
+ Ok(unsafe { &*handle })
+ }
+}
+
+unsafe fn training_mut<'a>(
+ handle: *mut PaimonVindexTrainingHandle,
+) -> Result<&'a mut PaimonVindexTrainingHandle, String> {
+ if handle.is_null() {
+ Err("null training handle".to_string())
+ } else {
+ Ok(unsafe { &mut *handle })
+ }
+}
+
unsafe fn reader_mut<'a>(
handle: *mut PaimonVindexReaderHandle,
) -> Result<&'a mut PaimonVindexReaderHandle, String> {
@@ -385,27 +423,28 @@ fn copy_search_result(
Ok(())
}
-// ======================== Writer ========================
+// ======================== Trainer / Writer ========================
#[no_mangle]
-pub unsafe extern "C" fn paimon_vindex_writer_open(
+pub unsafe extern "C" fn paimon_vindex_trainer_open(
keys: *const *const c_char,
values: *const *const c_char,
num_options: usize,
-) -> *mut PaimonVindexWriterHandle {
+) -> *mut PaimonVindexTrainerHandle {
ffi_ptr(|| {
let options = unsafe { options_from_raw(keys, values, num_options) }?;
let config = VectorIndexConfig::from_options(&options)
.map_err(|e| format!("invalid vector index options: {}", e))?;
- let writer = VectorIndexWriter::new(config).map_err(|e|
format!("create writer: {}", e))?;
- Ok(Box::into_raw(Box::new(PaimonVindexWriterHandle {
- inner: writer,
+ let trainer =
+ VectorIndexTrainer::new(config).map_err(|e| format!("create
trainer: {}", e))?;
+ Ok(Box::into_raw(Box::new(PaimonVindexTrainerHandle {
+ inner: Some(trainer),
})))
})
}
#[no_mangle]
-pub unsafe extern "C" fn paimon_vindex_writer_free(handle: *mut
PaimonVindexWriterHandle) {
+pub unsafe extern "C" fn paimon_vindex_trainer_free(handle: *mut
PaimonVindexTrainerHandle) {
if !handle.is_null() {
unsafe {
drop(Box::from_raw(handle));
@@ -414,36 +453,118 @@ pub unsafe extern "C" fn
paimon_vindex_writer_free(handle: *mut PaimonVindexWrit
}
#[no_mangle]
-pub unsafe extern "C" fn paimon_vindex_writer_dimension(
- handle: *const PaimonVindexWriterHandle,
+pub unsafe extern "C" fn paimon_vindex_trainer_dimension(
+ handle: *const PaimonVindexTrainerHandle,
out: *mut usize,
) -> c_int {
ffi_status(|| {
if out.is_null() {
return Err("out pointer is null".to_string());
}
- let handle = unsafe { writer_ref(handle) }?;
+ let handle = unsafe { trainer_ref(handle) }?;
+ let trainer = handle
+ .inner
+ .as_ref()
+ .ok_or_else(|| "trainer has already finished".to_string())?;
unsafe {
- *out = handle.inner.dimension();
+ *out = trainer.dimension();
}
Ok(())
})
}
#[no_mangle]
-pub unsafe extern "C" fn paimon_vindex_writer_train(
- handle: *mut PaimonVindexWriterHandle,
+pub unsafe extern "C" fn paimon_vindex_trainer_add_training_vectors(
+ handle: *mut PaimonVindexTrainerHandle,
data: *const f32,
vector_count: usize,
) -> c_int {
ffi_status(|| {
- let handle = unsafe { writer_mut(handle) }?;
- let len = checked_len(vector_count, handle.inner.dimension(),
"training data")?;
+ let handle = unsafe { trainer_mut(handle) }?;
+ let trainer = handle
+ .inner
+ .as_mut()
+ .ok_or_else(|| "trainer has already finished".to_string())?;
+ let len = checked_len(vector_count, trainer.dimension(), "training
data")?;
let data = unsafe { const_slice(data, len, "data") }?;
- handle
+ trainer
+ .add_training_vectors_mut(data, vector_count)
+ .map(|_| ())
+ .map_err(|e| format!("add training vectors: {}", e))
+ })
+}
+
+/// Finishes training and consumes the trainer's internal state, but does not
free `handle`.
+/// Callers must still call `paimon_vindex_trainer_free(handle)` after this
returns.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vindex_trainer_finish(
+ handle: *mut PaimonVindexTrainerHandle,
+) -> *mut PaimonVindexTrainingHandle {
+ ffi_ptr(|| {
+ let handle = unsafe { trainer_mut(handle) }?;
+ let trainer = handle
.inner
- .train(data, vector_count)
- .map_err(|e| format!("train: {}", e))
+ .take()
+ .ok_or_else(|| "trainer has already finished".to_string())?;
+ let training = trainer
+ .finish()
+ .map_err(|e| format!("finish training: {}", e))?;
+ Ok(Box::into_raw(Box::new(PaimonVindexTrainingHandle {
+ inner: Some(training),
+ })))
+ })
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vindex_training_free(handle: *mut
PaimonVindexTrainingHandle) {
+ if !handle.is_null() {
+ unsafe {
+ drop(Box::from_raw(handle));
+ }
+ }
+}
+
+/// Opens a writer by consuming the training state inside `training`, but does
not free the handle.
+/// Callers must still call `paimon_vindex_training_free(training)` after this
returns.
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vindex_writer_open(
+ training: *mut PaimonVindexTrainingHandle,
+) -> *mut PaimonVindexWriterHandle {
+ ffi_ptr(|| {
+ let training = unsafe { training_mut(training) }?;
+ let training = training
+ .inner
+ .take()
+ .ok_or_else(|| "training has already been consumed".to_string())?;
+ Ok(Box::into_raw(Box::new(PaimonVindexWriterHandle {
+ inner: VectorIndexWriter::new(training),
+ })))
+ })
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vindex_writer_free(handle: *mut
PaimonVindexWriterHandle) {
+ if !handle.is_null() {
+ unsafe {
+ drop(Box::from_raw(handle));
+ }
+ }
+}
+
+#[no_mangle]
+pub unsafe extern "C" fn paimon_vindex_writer_dimension(
+ handle: *const PaimonVindexWriterHandle,
+ out: *mut usize,
+) -> c_int {
+ ffi_status(|| {
+ if out.is_null() {
+ return Err("out pointer is null".to_string());
+ }
+ let handle = unsafe { writer_ref(handle) }?;
+ unsafe {
+ *out = handle.inner.dimension();
+ }
+ Ok(())
})
}
diff --git a/include/paimon_vindex.hpp b/include/paimon_vindex.hpp
index b0ab679..1216835 100644
--- a/include/paimon_vindex.hpp
+++ b/include/paimon_vindex.hpp
@@ -114,14 +114,44 @@ struct SearchResult {
std::vector<float> distances;
};
-class Writer {
+class Training {
+public:
+ explicit Training(PaimonVindexTrainingHandle* handle = nullptr) :
handle_(handle) {}
+
+ Training(const Training&) = delete;
+ Training& operator=(const Training&) = delete;
+
+ Training(Training&& other) noexcept : handle_(other.handle_) {
+ other.handle_ = nullptr;
+ }
+
+ Training& operator=(Training&& other) noexcept {
+ if (this != &other) {
+ if (handle_) paimon_vindex_training_free(handle_);
+ handle_ = other.handle_;
+ other.handle_ = nullptr;
+ }
+ return *this;
+ }
+
+ ~Training() {
+ if (handle_) paimon_vindex_training_free(handle_);
+ }
+
+private:
+ friend class Writer;
+
+ PaimonVindexTrainingHandle* handle_ = nullptr;
+};
+
+class Trainer {
public:
- Writer(const char* const* keys, const char* const* values, size_t
num_options) {
- handle_ = paimon_vindex_writer_open(keys, values, num_options);
- if (!handle_) throw Error("failed to open vector index writer");
+ Trainer(const char* const* keys, const char* const* values, size_t
num_options) {
+ handle_ = paimon_vindex_trainer_open(keys, values, num_options);
+ if (!handle_) throw Error("failed to open vector index trainer");
}
- explicit Writer(const std::vector<std::pair<std::string, std::string>>&
options) {
+ explicit Trainer(const std::vector<std::pair<std::string, std::string>>&
options) {
option_keys_.reserve(options.size());
option_values_.reserve(options.size());
key_ptrs_.reserve(options.size());
@@ -134,16 +164,15 @@ public:
key_ptrs_.push_back(option_keys_[i].c_str());
value_ptrs_.push_back(option_values_[i].c_str());
}
- handle_ = paimon_vindex_writer_open(key_ptrs_.data(),
value_ptrs_.data(), options.size());
- if (!handle_) throw Error("failed to open vector index writer");
+ handle_ = paimon_vindex_trainer_open(key_ptrs_.data(),
value_ptrs_.data(), options.size());
+ if (!handle_) throw Error("failed to open vector index trainer");
}
- Writer(const Writer&) = delete;
- Writer& operator=(const Writer&) = delete;
+ Trainer(const Trainer&) = delete;
+ Trainer& operator=(const Trainer&) = delete;
- Writer(Writer&& other) noexcept
+ Trainer(Trainer&& other) noexcept
: handle_(other.handle_),
- output_(std::move(other.output_)),
option_keys_(std::move(other.option_keys_)),
option_values_(std::move(other.option_values_)),
key_ptrs_(std::move(other.key_ptrs_)),
@@ -151,11 +180,10 @@ public:
other.handle_ = nullptr;
}
- Writer& operator=(Writer&& other) noexcept {
+ Trainer& operator=(Trainer&& other) noexcept {
if (this != &other) {
- if (handle_) paimon_vindex_writer_free(handle_);
+ if (handle_) paimon_vindex_trainer_free(handle_);
handle_ = other.handle_;
- output_ = std::move(other.output_);
option_keys_ = std::move(other.option_keys_);
option_values_ = std::move(other.option_values_);
key_ptrs_ = std::move(other.key_ptrs_);
@@ -165,6 +193,86 @@ public:
return *this;
}
+ ~Trainer() {
+ if (handle_) paimon_vindex_trainer_free(handle_);
+ }
+
+ size_t dimension() const {
+ size_t out = 0;
+ check(paimon_vindex_trainer_dimension(handle_, &out));
+ return out;
+ }
+
+ Trainer& add_training_vectors(const float* data, size_t vector_count) {
+ check(paimon_vindex_trainer_add_training_vectors(handle_, data,
vector_count));
+ return *this;
+ }
+
+ // C ABI note: finish consumes the trainer state but leaves the trainer
handle owned by caller.
+ // This RAII wrapper frees the trainer handle after a successful finish.
+ Training finish_training() {
+ PaimonVindexTrainingHandle* training =
paimon_vindex_trainer_finish(handle_);
+ if (!training) {
+ const char* err = paimon_vindex_last_error();
+ throw Error(err ? err : "failed to finish vector index training");
+ }
+ paimon_vindex_trainer_free(handle_);
+ handle_ = nullptr;
+ return Training(training);
+ }
+
+ static Training train(
+ const std::vector<std::pair<std::string, std::string>>& options,
+ const float* data,
+ size_t vector_count) {
+ Trainer trainer(options);
+ trainer.add_training_vectors(data, vector_count);
+ return trainer.finish_training();
+ }
+
+private:
+ PaimonVindexTrainerHandle* handle_ = nullptr;
+ std::vector<std::string> option_keys_;
+ std::vector<std::string> option_values_;
+ std::vector<const char*> key_ptrs_;
+ std::vector<const char*> value_ptrs_;
+};
+
+class Writer {
+public:
+ explicit Writer(Training&& training) {
+ if (!training.handle_) throw Error("training has already been
consumed");
+ PaimonVindexTrainingHandle* training_handle = training.handle_;
+ training.handle_ = nullptr;
+ // C ABI note: writer_open consumes the training state but leaves the
handle owned by caller.
+ // This RAII wrapper frees the consumed training handle after opening
the writer.
+ handle_ = paimon_vindex_writer_open(training_handle);
+ paimon_vindex_training_free(training_handle);
+ if (!handle_) {
+ const char* err = paimon_vindex_last_error();
+ throw Error(err ? err : "failed to open vector index writer");
+ }
+ }
+
+ Writer(const Writer&) = delete;
+ Writer& operator=(const Writer&) = delete;
+
+ Writer(Writer&& other) noexcept
+ : handle_(other.handle_),
+ output_(std::move(other.output_)) {
+ other.handle_ = nullptr;
+ }
+
+ Writer& operator=(Writer&& other) noexcept {
+ if (this != &other) {
+ if (handle_) paimon_vindex_writer_free(handle_);
+ handle_ = other.handle_;
+ output_ = std::move(other.output_);
+ other.handle_ = nullptr;
+ }
+ return *this;
+ }
+
~Writer() {
if (handle_) paimon_vindex_writer_free(handle_);
}
@@ -175,10 +283,6 @@ public:
return out;
}
- void train(const float* data, size_t vector_count) {
- check(paimon_vindex_writer_train(handle_, data, vector_count));
- }
-
void add_vectors(const int64_t* ids, const float* data, size_t
vector_count) {
check(paimon_vindex_writer_add_vectors(handle_, ids, data,
vector_count));
}
@@ -196,10 +300,6 @@ public:
private:
PaimonVindexWriterHandle* handle_ = nullptr;
std::shared_ptr<OutputFile> output_;
- std::vector<std::string> option_keys_;
- std::vector<std::string> option_values_;
- std::vector<const char*> key_ptrs_;
- std::vector<const char*> value_ptrs_;
};
class Reader {
diff --git
a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexNative.java
b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexNative.java
index 4adf3c9..e7cf220 100644
--- a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexNative.java
+++ b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexNative.java
@@ -21,11 +21,19 @@ final class VectorIndexNative {
private VectorIndexNative() {}
- static native long createWriter(String[] optionKeys, String[]
optionValues);
+ static native long createTrainer(String[] optionKeys, String[]
optionValues);
- static native int writerDimension(long ptr);
+ static native int trainerDimension(long ptr);
+
+ static native void trainerAddTrainingVectors(long ptr, float[] data, int
n);
+
+ static native long trainerFinishTraining(long ptr);
+
+ static native void freeTrainer(long ptr);
- static native void train(long ptr, float[] data, int n);
+ static native long createWriter(long trainingPtr);
+
+ static native int writerDimension(long ptr);
static native void addVectors(long ptr, long[] ids, float[] data, int n);
@@ -33,6 +41,8 @@ final class VectorIndexNative {
static native void freeWriter(long ptr);
+ static native void freeTraining(long ptr);
+
static native long openReader(Object streamInput);
static native VectorIndexMetadata metadata(long ptr);
diff --git
a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexWriter.java
b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexTrainer.java
similarity index 56%
copy from
java/src/main/java/org/apache/paimon/index/vector/VectorIndexWriter.java
copy to
java/src/main/java/org/apache/paimon/index/vector/VectorIndexTrainer.java
index 0dda2a3..1eec2e3 100644
--- a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexWriter.java
+++ b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexTrainer.java
@@ -19,13 +19,27 @@ package org.apache.paimon.index.vector;
import java.util.Map;
-public final class VectorIndexWriter implements AutoCloseable {
+/**
+ * Builds a trained vector-index state from one or more training batches.
+ *
+ * <p>Staging batches avoids requiring one large Java {@code float[]} and its
array length limit,
+ * but all batches are accumulated in native memory until {@link
#finishTraining()}; this does not
+ * reduce native peak training memory.
+ */
+public final class VectorIndexTrainer implements AutoCloseable {
private final Object nativeHandleLock = new Object();
private long nativePtr;
private Thread nativeHandleOwner;
- public VectorIndexWriter(Map<String, String> options) {
+ private VectorIndexTrainer(long nativePtr) {
+ this.nativePtr = nativePtr;
+ }
+
+ public static VectorIndexTrainer create(Map<String, String> options) {
+ if (options == null) {
+ throw new NullPointerException("options");
+ }
String[] keys = new String[options.size()];
String[] values = new String[options.size()];
int index = 0;
@@ -34,60 +48,59 @@ public final class VectorIndexWriter implements
AutoCloseable {
values[index] = entry.getValue();
index++;
}
- this.nativePtr = VectorIndexNative.createWriter(keys, values);
+ return new VectorIndexTrainer(VectorIndexNative.createTrainer(keys,
values));
}
- private VectorIndexWriter(long nativePtr) {
- this.nativePtr = nativePtr;
+ public static VectorIndexTraining train(
+ Map<String, String> options, float[] data, int vectorCount) {
+ try (VectorIndexTrainer trainer = create(options)) {
+ return trainer.addTrainingVectors(data,
vectorCount).finishTraining();
+ }
}
- static VectorIndexWriter fromNativePointerForTesting(long nativePtr) {
- return new VectorIndexWriter(nativePtr);
+ static VectorIndexTrainer fromNativePointerForTesting(long nativePtr) {
+ return new VectorIndexTrainer(nativePtr);
}
public int dimension() {
- return VectorIndexNative.writerDimension(requireOpen());
- }
-
- public void train(float[] data, int vectorCount) {
- if (data == null) {
- throw new NullPointerException("data");
- }
synchronized (nativeHandleLock) {
enterNativeHandle();
try {
- VectorIndexNative.train(requireOpen(), data, vectorCount);
+ return VectorIndexNative.trainerDimension(requireOpen());
} finally {
exitNativeHandle();
}
}
}
- public void addVectors(long[] ids, float[] data, int vectorCount) {
- if (ids == null) {
- throw new NullPointerException("ids");
- }
+ public VectorIndexTrainer addTrainingVectors(float[] data, int
vectorCount) {
if (data == null) {
throw new NullPointerException("data");
}
synchronized (nativeHandleLock) {
enterNativeHandle();
try {
- VectorIndexNative.addVectors(requireOpen(), ids, data,
vectorCount);
+ VectorIndexNative.trainerAddTrainingVectors(requireOpen(),
data, vectorCount);
+ return this;
} finally {
exitNativeHandle();
}
}
}
- public void writeIndex(Object output) {
- if (output == null) {
- throw new NullPointerException("output");
- }
+ /**
+ * Finishes training and returns a trained state for creating a {@link
VectorIndexWriter}.
+ *
+ * <p>This method consumes this trainer on both success and failure. After
it returns or throws,
+ * this trainer is closed and cannot accept more training batches; create
a new trainer to retry.
+ */
+ public VectorIndexTraining finishTraining() {
synchronized (nativeHandleLock) {
enterNativeHandle();
try {
- VectorIndexNative.writeIndex(requireOpen(), output);
+ long ptr = requireOpen();
+ nativePtr = 0L;
+ return new
VectorIndexTraining(VectorIndexNative.trainerFinishTraining(ptr));
} finally {
exitNativeHandle();
}
@@ -102,7 +115,7 @@ public final class VectorIndexWriter implements
AutoCloseable {
long ptr = nativePtr;
nativePtr = 0L;
if (ptr != 0L) {
- VectorIndexNative.freeWriter(ptr);
+ VectorIndexNative.freeTrainer(ptr);
}
} finally {
exitNativeHandle();
@@ -112,7 +125,7 @@ public final class VectorIndexWriter implements
AutoCloseable {
private long requireOpen() {
if (nativePtr == 0L) {
- throw new IllegalStateException("VectorIndexWriter is closed");
+ throw new IllegalStateException("VectorIndexTrainer is closed");
}
return nativePtr;
}
@@ -120,7 +133,7 @@ public final class VectorIndexWriter implements
AutoCloseable {
private void enterNativeHandle() {
Thread current = Thread.currentThread();
if (nativeHandleOwner == current) {
- throw new IllegalStateException("VectorIndexWriter native handle
is already in use");
+ throw new IllegalStateException("VectorIndexTrainer native handle
is already in use");
}
nativeHandleOwner = current;
}
diff --git
a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexTraining.java
b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexTraining.java
new file mode 100644
index 0000000..65780e7
--- /dev/null
+++ b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexTraining.java
@@ -0,0 +1,81 @@
+// 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.
+
+package org.apache.paimon.index.vector;
+
+public final class VectorIndexTraining implements AutoCloseable {
+
+ private final Object nativeHandleLock = new Object();
+ private long nativePtr;
+ private Thread nativeHandleOwner;
+
+ VectorIndexTraining(long nativePtr) {
+ this.nativePtr = nativePtr;
+ }
+
+ static VectorIndexTraining fromNativePointerForTesting(long nativePtr) {
+ return new VectorIndexTraining(nativePtr);
+ }
+
+ long takeNativePointer() {
+ synchronized (nativeHandleLock) {
+ enterNativeHandle();
+ try {
+ long ptr = requireOpen();
+ nativePtr = 0L;
+ return ptr;
+ } finally {
+ exitNativeHandle();
+ }
+ }
+ }
+
+ @Override
+ public void close() {
+ synchronized (nativeHandleLock) {
+ enterNativeHandle();
+ try {
+ long ptr = nativePtr;
+ nativePtr = 0L;
+ if (ptr != 0L) {
+ VectorIndexNative.freeTraining(ptr);
+ }
+ } finally {
+ exitNativeHandle();
+ }
+ }
+ }
+
+ private long requireOpen() {
+ if (nativePtr == 0L) {
+ throw new IllegalStateException("VectorIndexTraining is closed");
+ }
+ return nativePtr;
+ }
+
+ private void enterNativeHandle() {
+ Thread current = Thread.currentThread();
+ if (nativeHandleOwner == current) {
+ throw new IllegalStateException("VectorIndexTraining native handle
is already in use");
+ }
+ nativeHandleOwner = current;
+ }
+
+ private void exitNativeHandle() {
+ nativeHandleOwner = null;
+ }
+}
diff --git
a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexWriter.java
b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexWriter.java
index 0dda2a3..1f1863b 100644
--- a/java/src/main/java/org/apache/paimon/index/vector/VectorIndexWriter.java
+++ b/java/src/main/java/org/apache/paimon/index/vector/VectorIndexWriter.java
@@ -17,24 +17,17 @@
package org.apache.paimon.index.vector;
-import java.util.Map;
-
public final class VectorIndexWriter implements AutoCloseable {
private final Object nativeHandleLock = new Object();
private long nativePtr;
private Thread nativeHandleOwner;
- public VectorIndexWriter(Map<String, String> options) {
- String[] keys = new String[options.size()];
- String[] values = new String[options.size()];
- int index = 0;
- for (Map.Entry<String, String> entry : options.entrySet()) {
- keys[index] = entry.getKey();
- values[index] = entry.getValue();
- index++;
+ public VectorIndexWriter(VectorIndexTraining training) {
+ if (training == null) {
+ throw new NullPointerException("training");
}
- this.nativePtr = VectorIndexNative.createWriter(keys, values);
+ this.nativePtr =
VectorIndexNative.createWriter(training.takeNativePointer());
}
private VectorIndexWriter(long nativePtr) {
@@ -46,17 +39,10 @@ public final class VectorIndexWriter implements
AutoCloseable {
}
public int dimension() {
- return VectorIndexNative.writerDimension(requireOpen());
- }
-
- public void train(float[] data, int vectorCount) {
- if (data == null) {
- throw new NullPointerException("data");
- }
synchronized (nativeHandleLock) {
enterNativeHandle();
try {
- VectorIndexNative.train(requireOpen(), data, vectorCount);
+ return VectorIndexNative.writerDimension(requireOpen());
} finally {
exitNativeHandle();
}
diff --git
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexJavaApiTest.java
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexJavaApiTest.java
index 6fd37a6..5d75836 100644
---
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexJavaApiTest.java
+++
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexJavaApiTest.java
@@ -28,6 +28,8 @@ public class VectorIndexJavaApiTest {
testBatchResultCopiesArraysAndSlicesRows();
testMetadata();
testClosedReaderRejectsOperations();
+ testClosedTrainerRejectsOperations();
+ testClosedTrainingRejectsOperations();
testClosedWriterRejectsOperations();
testReaderAndWriterApiCompile();
}
@@ -156,7 +158,7 @@ public class VectorIndexJavaApiTest {
assertThrows(IllegalStateException.class, new ThrowingRunnable() {
@Override
public void run() {
- writer.train(new float[] {0.0f, 1.0f}, 1);
+ writer.dimension();
}
});
assertThrows(IllegalStateException.class, new ThrowingRunnable() {
@@ -173,6 +175,44 @@ public class VectorIndexJavaApiTest {
});
}
+ private static void testClosedTrainerRejectsOperations() {
+ final VectorIndexTrainer trainer =
VectorIndexTrainer.fromNativePointerForTesting(0L);
+ trainer.close();
+ trainer.close();
+
+ assertThrows(IllegalStateException.class, new ThrowingRunnable() {
+ @Override
+ public void run() {
+ trainer.dimension();
+ }
+ });
+ assertThrows(IllegalStateException.class, new ThrowingRunnable() {
+ @Override
+ public void run() {
+ trainer.addTrainingVectors(new float[] {0.0f, 1.0f}, 1);
+ }
+ });
+ assertThrows(IllegalStateException.class, new ThrowingRunnable() {
+ @Override
+ public void run() {
+ trainer.finishTraining();
+ }
+ });
+ }
+
+ private static void testClosedTrainingRejectsOperations() {
+ final VectorIndexTraining training =
VectorIndexTraining.fromNativePointerForTesting(0L);
+ training.close();
+ training.close();
+
+ assertThrows(IllegalStateException.class, new ThrowingRunnable() {
+ @Override
+ public void run() {
+ new VectorIndexWriter(training);
+ }
+ });
+ }
+
private static void testReaderAndWriterApiCompile() {
Map<String, String> options = ivfPqOptions(2, 4, 1);
VectorIndexReader closedReader =
VectorIndexReader.fromNativePointerForTesting(0L);
@@ -200,10 +240,22 @@ public class VectorIndexJavaApiTest {
reader.searchBatch(
new float[] {0.0f, 1.0f, 2.0f, 3.0f}, 2, 10, 4, 32, new
byte[] {1, 2});
- VectorIndexWriter writer = new VectorIndexWriter(options);
- writer.train(new float[] {0.0f, 1.0f, 2.0f, 3.0f}, 2);
+ VectorIndexTraining training =
+ VectorIndexTrainer.train(options, new float[] {0.0f, 1.0f,
2.0f, 3.0f}, 2);
+ VectorIndexWriter writer = new VectorIndexWriter(training);
+ writer.dimension();
writer.addVectors(new long[] {1L, 2L}, new float[] {0.0f, 1.0f,
2.0f, 3.0f}, 2);
writer.writeIndex(new Object());
+
+ VectorIndexTrainer trainer = VectorIndexTrainer.create(options);
+ trainer.dimension();
+ VectorIndexTraining stagedTraining =
+ trainer.addTrainingVectors(new float[] {0.0f, 1.0f}, 1)
+ .addTrainingVectors(new float[] {2.0f, 3.0f}, 1)
+ .finishTraining();
+ VectorIndexWriter stagedWriter = new
VectorIndexWriter(stagedTraining);
+ stagedWriter.addVectors(new long[] {1L}, new float[] {0.0f, 1.0f},
1);
+ stagedWriter.writeIndex(new Object());
}
}
diff --git
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeHandleSafetyTest.java
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeHandleSafetyTest.java
index efcfdcb..3609cdd 100644
---
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeHandleSafetyTest.java
+++
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeHandleSafetyTest.java
@@ -75,8 +75,9 @@ public class VectorIndexNativeHandleSafetyTest {
}
private static VectorIndexWriter newPopulatedWriter() {
- VectorIndexWriter writer = new VectorIndexWriter(ivfFlatOptions());
- writer.train(new float[] {0.0f, 1.0f}, 2);
+ VectorIndexWriter writer =
+ new VectorIndexWriter(
+ VectorIndexTrainer.train(ivfFlatOptions(), new float[]
{0.0f, 1.0f}, 2));
writer.addVectors(new long[] {1L, 2L}, new float[] {0.0f, 1.0f}, 2);
return writer;
}
diff --git
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativePanicBoundaryTest.java
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativePanicBoundaryTest.java
index 739defe..963b26f 100644
---
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativePanicBoundaryTest.java
+++
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativePanicBoundaryTest.java
@@ -30,32 +30,36 @@ public class VectorIndexNativePanicBoundaryTest {
System.load(args[0]);
- testVoidEntrypointPanicBecomesRuntimeException();
+ testVoidEntrypointErrorBecomesRuntimeException();
testObjectEntrypointPanicBecomesRuntimeException();
- VectorIndexWriter survivor = new VectorIndexWriter(ivfFlatOptions());
+ VectorIndexTrainer survivor =
VectorIndexTrainer.create(ivfFlatOptions());
survivor.close();
}
- private static void testVoidEntrypointPanicBecomesRuntimeException() {
- final VectorIndexWriter writer = new
VectorIndexWriter(ivfFlatOptions());
+ private static void testVoidEntrypointErrorBecomesRuntimeException() {
+ final VectorIndexTrainer trainer =
VectorIndexTrainer.create(ivfFlatOptions());
try {
- assertThrows(RuntimeException.class, new ThrowingRunnable() {
- @Override
- public void run() {
- writer.addVectors(new long[] {1L}, new float[] {1.0f}, 1);
- }
- });
+ assertThrowsMessage(
+ RuntimeException.class,
+ "training data length 1 does not match vector count *
dimension 2",
+ new ThrowingRunnable() {
+ @Override
+ public void run() {
+ trainer.addTrainingVectors(new float[] {1.0f}, 2);
+ }
+ });
} finally {
- writer.close();
+ trainer.close();
}
}
private static void testObjectEntrypointPanicBecomesRuntimeException() {
ByteArrayPositionOutputStream output = new
ByteArrayPositionOutputStream();
- VectorIndexWriter writer = new VectorIndexWriter(ivfFlatOptions());
+ VectorIndexWriter writer =
+ new VectorIndexWriter(
+ VectorIndexTrainer.train(ivfFlatOptions(), new float[]
{0.0f, 1.0f}, 2));
try {
- writer.train(new float[] {0.0f, 1.0f}, 2);
writer.addVectors(new long[] {1L, 2L}, new float[] {0.0f, 1.0f},
2);
writer.writeIndex(output);
} finally {
@@ -117,6 +121,24 @@ public class VectorIndexNativePanicBoundaryTest {
throw new AssertionError("expected " + expected.getName());
}
+ private static void assertThrowsMessage(
+ Class<? extends Throwable> expected, String expectedMessage,
ThrowingRunnable runnable) {
+ try {
+ runnable.run();
+ } catch (Throwable t) {
+ if (!expected.isInstance(t)) {
+ throw new AssertionError(
+ "expected " + expected.getName() + " but got " +
t.getClass().getName(), t);
+ }
+ String message = t.getMessage();
+ if (message == null || !message.contains(expectedMessage)) {
+ throw new AssertionError("unexpected exception message: " +
message, t);
+ }
+ return;
+ }
+ throw new AssertionError("expected " + expected.getName());
+ }
+
private static void corruptFirstIvfFlatVector(byte[] indexBytes, float
value) {
int dimension = readIntLe(indexBytes, 8);
int nlist = readIntLe(indexBytes, 12);
diff --git
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeValidationTest.java
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeValidationTest.java
index 320f2ec..07db0ca 100644
---
a/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeValidationTest.java
+++
b/java/src/test/java/org/apache/paimon/index/vector/VectorIndexNativeValidationTest.java
@@ -37,13 +37,15 @@ public class VectorIndexNativeValidationTest {
testWriterValidationComesFromCore();
testWriterRejectsNonFiniteValues();
+ testStagedTrainingRoundtrip();
+ testStagedTrainingStateValidation();
testReaderValidationComesFromCore();
testReaderRejectsNonFiniteQueries();
testSupportedIndexRoundtrips();
}
private static void testWriterValidationComesFromCore() {
- final VectorIndexWriter writer = new
VectorIndexWriter(ivfFlatOptions());
+ final VectorIndexTrainer trainingDataTrainer =
VectorIndexTrainer.create(ivfFlatOptions());
try {
assertThrowsMessage(
RuntimeException.class,
@@ -51,29 +53,42 @@ public class VectorIndexNativeValidationTest {
new ThrowingRunnable() {
@Override
public void run() {
- writer.train(new float[] {0.0f, 1.0f}, 1);
+ trainingDataTrainer.addTrainingVectors(new float[]
{0.0f, 1.0f}, 1);
}
});
+ } finally {
+ trainingDataTrainer.close();
+ }
+
+ final VectorIndexWriter addVectorWriter =
+ newWriter(ivfFlatOptions(), new float[] {0.0f, 1.0f}, 2);
+ try {
assertThrowsMessage(
RuntimeException.class,
"ids length 2 does not match vector count 1",
new ThrowingRunnable() {
@Override
public void run() {
- writer.addVectors(new long[] {1L, 2L}, new float[]
{0.0f}, 1);
+ addVectorWriter.addVectors(new long[] {1L, 2L},
new float[] {0.0f}, 1);
}
});
+ } finally {
+ addVectorWriter.close();
+ }
+
+ final VectorIndexTrainer vectorCountTrainer =
VectorIndexTrainer.create(ivfFlatOptions());
+ try {
assertThrowsMessage(
RuntimeException.class,
"vector count must be greater than 0",
new ThrowingRunnable() {
@Override
public void run() {
- writer.train(new float[0], 0);
+ vectorCountTrainer.addTrainingVectors(new
float[0], 0);
}
});
} finally {
- writer.close();
+ vectorCountTrainer.close();
}
}
@@ -114,7 +129,7 @@ public class VectorIndexNativeValidationTest {
}
private static void testWriterRejectsNonFiniteValues() {
- final VectorIndexWriter trainingWriter = new
VectorIndexWriter(ivfFlatOptions());
+ final VectorIndexTrainer trainingTrainer =
VectorIndexTrainer.create(ivfFlatOptions());
try {
assertThrowsMessage(
RuntimeException.class,
@@ -122,16 +137,16 @@ public class VectorIndexNativeValidationTest {
new ThrowingRunnable() {
@Override
public void run() {
- trainingWriter.train(new float[] {Float.NaN,
1.0f}, 2);
+ trainingTrainer.addTrainingVectors(new float[]
{Float.NaN, 1.0f}, 2);
}
});
} finally {
- trainingWriter.close();
+ trainingTrainer.close();
}
- final VectorIndexWriter vectorWriter = new
VectorIndexWriter(ivfFlatOptions());
+ final VectorIndexWriter vectorWriter =
+ newWriter(ivfFlatOptions(), new float[] {0.0f, 1.0f}, 2);
try {
- vectorWriter.train(new float[] {0.0f, 1.0f}, 2);
assertThrowsMessage(
RuntimeException.class,
"vector data contains non-finite value at offset 0: inf",
@@ -149,6 +164,108 @@ public class VectorIndexNativeValidationTest {
}
}
+ private static void testStagedTrainingRoundtrip() {
+ runStagedTrainingRoundtrip(
+ "ivf_flat", ivfFlatOptions(ROUNDTRIP_DIMENSION,
ROUNDTRIP_NLIST), 0, 0);
+ runStagedTrainingRoundtrip(
+ "ivf_pq", ivfPqOptions(ROUNDTRIP_DIMENSION, ROUNDTRIP_NLIST,
1), 1, 0);
+ runStagedTrainingRoundtrip(
+ "ivf_hnsw_flat",
+ ivfHnswOptions("ivf_hnsw_flat", ROUNDTRIP_DIMENSION,
ROUNDTRIP_NLIST),
+ 0,
+ 4);
+ runStagedTrainingRoundtrip(
+ "ivf_hnsw_sq",
+ ivfHnswOptions("ivf_hnsw_sq", ROUNDTRIP_DIMENSION,
ROUNDTRIP_NLIST),
+ 0,
+ 4);
+ }
+
+ private static void testStagedTrainingStateValidation() {
+ final VectorIndexTrainer emptyTrainer =
VectorIndexTrainer.create(ivfFlatOptions());
+ try {
+ assertThrowsMessage(
+ RuntimeException.class,
+ "no training vectors added",
+ new ThrowingRunnable() {
+ @Override
+ public void run() {
+ emptyTrainer.finishTraining();
+ }
+ });
+ assertThrowsMessage(
+ IllegalStateException.class,
+ "VectorIndexTrainer is closed",
+ new ThrowingRunnable() {
+ @Override
+ public void run() {
+ emptyTrainer.addTrainingVectors(new float[]
{0.0f}, 1);
+ }
+ });
+ } finally {
+ emptyTrainer.close();
+ }
+
+ final VectorIndexTrainer stagedTrainer =
VectorIndexTrainer.create(ivfFlatOptions());
+ final VectorIndexTraining stagedTraining;
+ try {
+ stagedTraining =
+ stagedTrainer.addTrainingVectors(new float[] {0.0f}, 1)
+ .addTrainingVectors(new float[] {1.0f}, 1)
+ .finishTraining();
+ assertThrowsMessage(
+ IllegalStateException.class,
+ "VectorIndexTrainer is closed",
+ new ThrowingRunnable() {
+ @Override
+ public void run() {
+ stagedTrainer.addTrainingVectors(new float[]
{2.0f}, 1);
+ }
+ });
+ } finally {
+ stagedTrainer.close();
+ }
+
+ final VectorIndexWriter writer = new VectorIndexWriter(stagedTraining);
+ try {
+ assertThrowsMessage(
+ IllegalStateException.class,
+ "VectorIndexTraining is closed",
+ new ThrowingRunnable() {
+ @Override
+ public void run() {
+ new VectorIndexWriter(stagedTraining);
+ }
+ });
+ } finally {
+ writer.close();
+ }
+
+ final VectorIndexTrainer invalidBatchTrainer =
VectorIndexTrainer.create(ivfFlatOptions());
+ try {
+ assertThrowsMessage(
+ RuntimeException.class,
+ "training data length 2 does not match vector count *
dimension 1",
+ new ThrowingRunnable() {
+ @Override
+ public void run() {
+ invalidBatchTrainer.addTrainingVectors(new float[]
{0.0f, 1.0f}, 1);
+ }
+ });
+ assertThrowsMessage(
+ RuntimeException.class,
+ "vector count must be greater than 0",
+ new ThrowingRunnable() {
+ @Override
+ public void run() {
+ invalidBatchTrainer.addTrainingVectors(new
float[0], 0);
+ }
+ });
+ } finally {
+ invalidBatchTrainer.close();
+ }
+ }
+
private static void testReaderRejectsNonFiniteQueries() {
VectorIndexReader reader =
new VectorIndexReader(new
ByteArraySeekableInputStream(buildIndexBytes()));
@@ -202,6 +319,19 @@ public class VectorIndexNativeValidationTest {
byte[] indexBytes =
buildIndexBytes(
options, roundtripData(), roundtripIds(),
ROUNDTRIP_VECTOR_COUNT);
+ assertRoundtrip(indexType, indexBytes, expectedPqM, expectedHnswM);
+ }
+
+ private static void runStagedTrainingRoundtrip(
+ String indexType, Map<String, String> options, int expectedPqM,
int expectedHnswM) {
+ byte[] indexBytes =
+ buildStagedIndexBytes(
+ options, roundtripData(), roundtripIds(),
ROUNDTRIP_VECTOR_COUNT);
+ assertRoundtrip(indexType, indexBytes, expectedPqM, expectedHnswM);
+ }
+
+ private static void assertRoundtrip(
+ String indexType, byte[] indexBytes, int expectedPqM, int
expectedHnswM) {
VectorIndexReader reader =
new VectorIndexReader(new
ByteArraySeekableInputStream(indexBytes));
try {
@@ -237,10 +367,9 @@ public class VectorIndexNativeValidationTest {
private static byte[] buildIndexBytes(
Map<String, String> options, float[] data, long[] ids, int
vectorCount) {
- VectorIndexWriter writer = new VectorIndexWriter(options);
+ VectorIndexWriter writer = newWriter(options, data, vectorCount);
ByteArrayPositionOutputStream output = new
ByteArrayPositionOutputStream();
try {
- writer.train(data, vectorCount);
writer.addVectors(ids, data, vectorCount);
writer.writeIndex(output);
return output.toByteArray();
@@ -249,6 +378,43 @@ public class VectorIndexNativeValidationTest {
}
}
+ private static byte[] buildStagedIndexBytes(
+ Map<String, String> options, float[] data, long[] ids, int
vectorCount) {
+ VectorIndexTrainer trainer = VectorIndexTrainer.create(options);
+ VectorIndexWriter writer = null;
+ ByteArrayPositionOutputStream output = new
ByteArrayPositionOutputStream();
+ int dimension = data.length / vectorCount;
+ try {
+ int offset = 0;
+ while (offset < vectorCount) {
+ int batchCount = Math.min(ROUNDTRIP_PER_LIST / 2, vectorCount
- offset);
+ trainer.addTrainingVectors(
+ copyVectors(data, dimension, offset, batchCount),
batchCount);
+ offset += batchCount;
+ }
+ writer = new VectorIndexWriter(trainer.finishTraining());
+ writer.addVectors(ids, data, vectorCount);
+ writer.writeIndex(output);
+ return output.toByteArray();
+ } finally {
+ trainer.close();
+ if (writer != null) {
+ writer.close();
+ }
+ }
+ }
+
+ private static VectorIndexWriter newWriter(
+ Map<String, String> options, float[] data, int vectorCount) {
+ return new VectorIndexWriter(VectorIndexTrainer.train(options, data,
vectorCount));
+ }
+
+ private static float[] copyVectors(float[] data, int dimension, int
offset, int count) {
+ float[] copy = new float[count * dimension];
+ System.arraycopy(data, offset * dimension, copy, 0, copy.length);
+ return copy;
+ }
+
private static Map<String, String> ivfFlatOptions() {
return ivfFlatOptions(1, 1);
}
diff --git a/jni/src/lib.rs b/jni/src/lib.rs
index 186fa84..82f92c7 100644
--- a/jni/src/lib.rs
+++ b/jni/src/lib.rs
@@ -21,8 +21,8 @@ use jni::objects::{JByteArray, JClass, JFloatArray,
JLongArray, JObject, JValue}
use jni::sys::{jint, jlong, jobject, jobjectArray};
use jni::JNIEnv;
use paimon_vindex_core::index::{
- VectorIndexConfig, VectorIndexMetadata, VectorIndexReader,
VectorIndexWriter,
- VectorSearchParams,
+ VectorIndexConfig, VectorIndexMetadata, VectorIndexReader,
VectorIndexTrainer,
+ VectorIndexTraining, VectorIndexWriter, VectorSearchParams,
};
use std::any::Any;
use std::collections::HashMap;
@@ -63,11 +63,75 @@ fn throw_panic_and_return<T: Default>(env: &mut JNIEnv,
payload: &(dyn Any + Sen
throw_and_return(env, &format!("Rust panic in JNI call: {}", payload))
}
-fn deref_writer(ptr: jlong) -> Option<&'static mut VectorIndexWriter> {
+struct JniVectorIndexTrainer {
+ trainer: Option<VectorIndexTrainer>,
+}
+
+impl JniVectorIndexTrainer {
+ fn new(trainer: VectorIndexTrainer) -> Self {
+ Self {
+ trainer: Some(trainer),
+ }
+ }
+
+ fn trainer_mut(&mut self) -> Result<&mut VectorIndexTrainer, String> {
+ self.trainer
+ .as_mut()
+ .ok_or_else(|| "trainer has already finished".to_string())
+ }
+
+ fn take(&mut self) -> Result<VectorIndexTrainer, String> {
+ self.trainer
+ .take()
+ .ok_or_else(|| "trainer has already finished".to_string())
+ }
+}
+
+struct JniVectorIndexTraining {
+ training: Option<VectorIndexTraining>,
+}
+
+impl JniVectorIndexTraining {
+ fn new(training: VectorIndexTraining) -> Self {
+ Self {
+ training: Some(training),
+ }
+ }
+
+ fn take(&mut self) -> Result<VectorIndexTraining, String> {
+ self.training
+ .take()
+ .ok_or_else(|| "training has already been consumed".to_string())
+ }
+}
+
+struct JniVectorIndexWriter {
+ writer: VectorIndexWriter,
+}
+
+impl JniVectorIndexWriter {
+ fn new(writer: VectorIndexWriter) -> Self {
+ Self { writer }
+ }
+
+ fn dimension(&self) -> usize {
+ self.writer.dimension()
+ }
+}
+
+fn deref_trainer(ptr: jlong) -> Option<&'static mut JniVectorIndexTrainer> {
if ptr == 0 {
None
} else {
- Some(unsafe { &mut *(ptr as *mut VectorIndexWriter) })
+ Some(unsafe { &mut *(ptr as *mut JniVectorIndexTrainer) })
+ }
+}
+
+fn deref_writer(ptr: jlong) -> Option<&'static mut JniVectorIndexWriter> {
+ if ptr == 0 {
+ None
+ } else {
+ Some(unsafe { &mut *(ptr as *mut JniVectorIndexWriter) })
}
}
@@ -170,6 +234,9 @@ fn read_byte_array(env: &mut JNIEnv, array: JByteArray) ->
Result<Vec<u8>, Strin
}
fn read_float_array(env: &mut JNIEnv, array: &JFloatArray, name: &str) ->
Result<Vec<f32>, String> {
+ if array.as_raw().is_null() {
+ return Err(format!("{} float array is null", name));
+ }
let len = env
.get_array_length(array)
.map_err(|e| format!("get_array_length({}): {}", name, e))? as usize;
@@ -180,6 +247,9 @@ fn read_float_array(env: &mut JNIEnv, array: &JFloatArray,
name: &str) -> Result
}
fn read_long_array(env: &mut JNIEnv, array: &JLongArray, name: &str) ->
Result<Vec<i64>, String> {
+ if array.as_raw().is_null() {
+ return Err(format!("{} long array is null", name));
+ }
let len = env
.get_array_length(array)
.map_err(|e| format!("get_array_length({}): {}", name, e))? as usize;
@@ -311,10 +381,10 @@ fn search_params(k: jint, nprobe: jint, ef_search: jint)
-> Option<VectorSearchP
}
}
-// --- Unified Writer API ---
+// --- Unified Trainer / Writer API ---
#[no_mangle]
-pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_createWriter(
+pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_createTrainer(
env: JNIEnv,
_class: JClass,
keys: jobjectArray,
@@ -326,16 +396,35 @@ pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_cre
None => return 0,
};
- let writer = match VectorIndexWriter::new(config) {
- Ok(writer) => writer,
- Err(e) => return throw_and_return(env, &format!("create writer:
{}", e)),
+ let trainer = match VectorIndexTrainer::new(config) {
+ Ok(trainer) => trainer,
+ Err(e) => return throw_and_return(env, &format!("create trainer:
{}", e)),
+ };
+ Box::into_raw(Box::new(JniVectorIndexTrainer::new(trainer))) as jlong
+ })
+}
+
+#[no_mangle]
+pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_trainerDimension(
+ env: JNIEnv,
+ _class: JClass,
+ ptr: jlong,
+) -> jint {
+ jni_call(env, |env| {
+ let trainer = match deref_trainer(ptr) {
+ Some(trainer) => trainer,
+ None => return throw_and_return(env, "null native pointer (trainer
already freed?)"),
};
- Box::into_raw(Box::new(writer)) as jlong
+ let trainer = match trainer.trainer_mut() {
+ Ok(trainer) => trainer,
+ Err(e) => return throw_and_return(env, &e),
+ };
+ trainer.dimension() as jint
})
}
#[no_mangle]
-pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_train(
+pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_trainerAddTrainingVectors(
env: JNIEnv,
_class: JClass,
ptr: jlong,
@@ -343,10 +432,6 @@ pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_tra
n: jint,
) {
jni_call_void(env, |env| {
- let writer = match deref_writer(ptr) {
- Some(writer) => writer,
- None => return throw_and_return(env, "null native pointer (writer
already freed?)"),
- };
if n < 0 {
return throw_and_return(env, &format!("invalid vector count: {}",
n));
}
@@ -355,8 +440,90 @@ pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_tra
Ok(buf) => buf,
Err(e) => return throw_and_return(env, &e),
};
- if let Err(e) = writer.train(&data_buf, n) {
- throw_and_return::<()>(env, &format!("train: {}", e));
+ let trainer = match deref_trainer(ptr) {
+ Some(trainer) => trainer,
+ None => return throw_and_return(env, "null native pointer (trainer
already freed?)"),
+ };
+ let trainer = match trainer.trainer_mut() {
+ Ok(trainer) => trainer,
+ Err(e) => return throw_and_return(env, &e),
+ };
+ if let Err(e) = trainer.add_training_vectors_mut(&data_buf, n) {
+ throw_and_return::<()>(env, &e.to_string());
+ }
+ })
+}
+
+#[no_mangle]
+pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_trainerFinishTraining(
+ env: JNIEnv,
+ _class: JClass,
+ ptr: jlong,
+) -> jlong {
+ jni_call(env, |env| {
+ if ptr == 0 {
+ return throw_and_return(env, "null native pointer (trainer already
freed?)");
+ }
+ let mut trainer_handle = unsafe { Box::from_raw(ptr as *mut
JniVectorIndexTrainer) };
+ let trainer = match trainer_handle.take() {
+ Ok(trainer) => trainer,
+ Err(e) => return throw_and_return(env, &e),
+ };
+ let training = match trainer.finish() {
+ Ok(training) => training,
+ Err(e) => return throw_and_return(env, &format!("finishTraining:
{}", e)),
+ };
+ Box::into_raw(Box::new(JniVectorIndexTraining::new(training))) as jlong
+ })
+}
+
+#[no_mangle]
+pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_freeTrainer(
+ env: JNIEnv,
+ _class: JClass,
+ ptr: jlong,
+) {
+ jni_call_void(env, |_env| {
+ if ptr != 0 {
+ unsafe {
+ drop(Box::from_raw(ptr as *mut JniVectorIndexTrainer));
+ }
+ }
+ })
+}
+
+#[no_mangle]
+pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_createWriter(
+ env: JNIEnv,
+ _class: JClass,
+ training_ptr: jlong,
+) -> jlong {
+ jni_call(env, |env| {
+ if training_ptr == 0 {
+ return throw_and_return(env, "null native pointer (training
already freed?)");
+ }
+ let mut training_handle =
+ unsafe { Box::from_raw(training_ptr as *mut
JniVectorIndexTraining) };
+ let training = match training_handle.take() {
+ Ok(training) => training,
+ Err(e) => return throw_and_return(env, &e),
+ };
+ let writer = VectorIndexWriter::new(training);
+ Box::into_raw(Box::new(JniVectorIndexWriter::new(writer))) as jlong
+ })
+}
+
+#[no_mangle]
+pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_freeTraining(
+ env: JNIEnv,
+ _class: JClass,
+ ptr: jlong,
+) {
+ jni_call_void(env, |_env| {
+ if ptr != 0 {
+ unsafe {
+ drop(Box::from_raw(ptr as *mut JniVectorIndexTraining));
+ }
}
})
}
@@ -402,7 +569,7 @@ pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_add
Ok(buf) => buf,
Err(e) => return throw_and_return(env, &e),
};
- if let Err(e) = writer.add_vectors(&id_buf, &data_buf, n) {
+ if let Err(e) = writer.writer.add_vectors(&id_buf, &data_buf, n) {
throw_and_return::<()>(env, &format!("add_vectors: {}", e));
}
})
@@ -431,7 +598,7 @@ pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_wri
};
let mut output = JniOutputStream::new(jvm, global_ref);
- if let Err(e) = writer.write(&mut output) {
+ if let Err(e) = writer.writer.write(&mut output) {
throw_and_return::<()>(env, &format!("write index: {}", e));
}
})
@@ -446,7 +613,7 @@ pub extern "system" fn
Java_org_apache_paimon_index_vector_VectorIndexNative_fre
jni_call_void(env, |_env| {
if ptr != 0 {
unsafe {
- drop(Box::from_raw(ptr as *mut VectorIndexWriter));
+ drop(Box::from_raw(ptr as *mut JniVectorIndexWriter));
}
}
})
diff --git a/python/paimon_vindex/__init__.py b/python/paimon_vindex/__init__.py
index ddaedfa..f66c47c 100644
--- a/python/paimon_vindex/__init__.py
+++ b/python/paimon_vindex/__init__.py
@@ -105,37 +105,94 @@ def _bytes_buffer(value, name):
return buf, len(data), data
-class VectorIndexWriter:
+def _option_arrays(options: Mapping[str, str]):
+ option_items = list(options.items())
+ key_bytes = []
+ value_bytes = []
+ for key, value in option_items:
+ if not isinstance(key, str) or not isinstance(value, str):
+ raise ValueError("options must be a mapping of str to str")
+ key_bytes.append(key.encode("utf-8"))
+ value_bytes.append(value.encode("utf-8"))
+ keys = (ctypes.c_char_p * len(key_bytes))(*key_bytes)
+ values = (ctypes.c_char_p * len(value_bytes))(*value_bytes)
+ return option_items, key_bytes, value_bytes, keys, values
+
+
+class VectorIndexTraining:
+ def __init__(self, handle):
+ self._closed = False
+ self._handle = handle
+
+ def _require_open(self):
+ if self._closed or not self._handle:
+ raise RuntimeError("VectorIndexTraining is closed")
+
+ def _take_handle(self):
+ self._require_open()
+ handle = self._handle
+ self._handle = None
+ self._closed = True
+ return handle
+
+ def close(self):
+ if self._handle:
+ lib.paimon_vindex_training_free(self._handle)
+ self._handle = None
+ self._closed = True
+
+ def __enter__(self):
+ self._require_open()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+ return False
+
+ def __del__(self):
+ try:
+ self.close()
+ except Exception:
+ pass
+
+
+class VectorIndexTrainer:
def __init__(self, options: Mapping[str, str]):
self._closed = False
- option_items = list(options.items())
- self._key_bytes = []
- self._value_bytes = []
- for key, value in option_items:
- if not isinstance(key, str) or not isinstance(value, str):
- raise ValueError("options must be a mapping of str to str")
- self._key_bytes.append(key.encode("utf-8"))
- self._value_bytes.append(value.encode("utf-8"))
- self._keys = (ctypes.c_char_p * len(self._key_bytes))(*self._key_bytes)
- self._values = (ctypes.c_char_p *
len(self._value_bytes))(*self._value_bytes)
- self._handle = lib.paimon_vindex_writer_open(
+ (
+ option_items,
+ self._key_bytes,
+ self._value_bytes,
+ self._keys,
+ self._values,
+ ) = _option_arrays(options)
+ self._handle = lib.paimon_vindex_trainer_open(
self._keys,
self._values,
len(option_items),
)
if not self._handle:
- _check_error("failed to open writer")
+ _check_error("failed to open trainer")
self._dimension = self._read_dimension()
+ @classmethod
+ def create(cls, options: Mapping[str, str]):
+ return cls(options)
+
+ @classmethod
+ def train(cls, options: Mapping[str, str], data):
+ with cls(options) as trainer:
+ return trainer.add_training_vectors(data).finish_training()
+
def _require_open(self):
if self._closed or not self._handle:
- raise RuntimeError("VectorIndexWriter is closed")
+ raise RuntimeError("VectorIndexTrainer is closed")
def _read_dimension(self):
out = ctypes.c_size_t(0)
- rc = lib.paimon_vindex_writer_dimension(self._handle,
ctypes.byref(out))
+ rc = lib.paimon_vindex_trainer_dimension(self._handle,
ctypes.byref(out))
if rc != 0:
- _check_error("writer dimension failed")
+ _check_error("trainer dimension failed")
return out.value
@property
@@ -143,7 +200,7 @@ class VectorIndexWriter:
self._require_open()
return self._dimension
- def train(self, data):
+ def add_training_vectors(self, data):
self._require_open()
data = _float32_matrix(data, "data")
if data.shape[1] != self._dimension:
@@ -151,13 +208,74 @@ class VectorIndexWriter:
f"training data length {data.size} does not match vector count
"
f"* dimension {data.shape[0] * self._dimension}"
)
- rc = lib.paimon_vindex_writer_train(
+ rc = lib.paimon_vindex_trainer_add_training_vectors(
self._handle,
data.ctypes.data_as(ctypes.POINTER(ctypes.c_float)),
data.shape[0],
)
if rc != 0:
- _check_error("train failed")
+ _check_error("add training vectors failed")
+ return self
+
+ def finish_training(self):
+ self._require_open()
+ handle = self._handle
+ training = lib.paimon_vindex_trainer_finish(handle)
+ lib.paimon_vindex_trainer_free(handle)
+ self._handle = None
+ self._closed = True
+ if not training:
+ _check_error("finish training failed")
+ return VectorIndexTraining(training)
+
+ def close(self):
+ if self._handle:
+ lib.paimon_vindex_trainer_free(self._handle)
+ self._handle = None
+ self._closed = True
+
+ def __enter__(self):
+ self._require_open()
+ return self
+
+ def __exit__(self, exc_type, exc_val, exc_tb):
+ self.close()
+ return False
+
+ def __del__(self):
+ try:
+ self.close()
+ except Exception:
+ pass
+
+
+class VectorIndexWriter:
+ def __init__(self, training: VectorIndexTraining):
+ if not isinstance(training, VectorIndexTraining):
+ raise TypeError("training must be a VectorIndexTraining")
+ self._closed = False
+ training_handle = training._take_handle()
+ self._handle = lib.paimon_vindex_writer_open(training_handle)
+ lib.paimon_vindex_training_free(training_handle)
+ if not self._handle:
+ _check_error("failed to open writer")
+ self._dimension = self._read_dimension()
+
+ def _require_open(self):
+ if self._closed or not self._handle:
+ raise RuntimeError("VectorIndexWriter is closed")
+
+ def _read_dimension(self):
+ out = ctypes.c_size_t(0)
+ rc = lib.paimon_vindex_writer_dimension(self._handle,
ctypes.byref(out))
+ if rc != 0:
+ _check_error("writer dimension failed")
+ return out.value
+
+ @property
+ def dimension(self):
+ self._require_open()
+ return self._dimension
def add_vectors(self, ids, data):
self._require_open()
@@ -415,5 +533,7 @@ class VectorIndexReader:
__all__ = [
"VectorIndexMetadata",
"VectorIndexReader",
+ "VectorIndexTrainer",
+ "VectorIndexTraining",
"VectorIndexWriter",
]
diff --git a/python/paimon_vindex/_ffi.py b/python/paimon_vindex/_ffi.py
index 72cc25c..38c0ea7 100644
--- a/python/paimon_vindex/_ffi.py
+++ b/python/paimon_vindex/_ffi.py
@@ -121,11 +121,33 @@ class PaimonVindexMetadata(Structure):
lib.paimon_vindex_last_error.argtypes = []
lib.paimon_vindex_last_error.restype = c_char_p
-lib.paimon_vindex_writer_open.argtypes = [
+lib.paimon_vindex_trainer_open.argtypes = [
POINTER(c_char_p),
POINTER(c_char_p),
c_size_t,
]
+lib.paimon_vindex_trainer_open.restype = c_void_p
+
+lib.paimon_vindex_trainer_free.argtypes = [c_void_p]
+lib.paimon_vindex_trainer_free.restype = None
+
+lib.paimon_vindex_trainer_dimension.argtypes = [c_void_p, POINTER(c_size_t)]
+lib.paimon_vindex_trainer_dimension.restype = c_int
+
+lib.paimon_vindex_trainer_add_training_vectors.argtypes = [
+ c_void_p,
+ POINTER(c_float),
+ c_size_t,
+]
+lib.paimon_vindex_trainer_add_training_vectors.restype = c_int
+
+lib.paimon_vindex_trainer_finish.argtypes = [c_void_p]
+lib.paimon_vindex_trainer_finish.restype = c_void_p
+
+lib.paimon_vindex_training_free.argtypes = [c_void_p]
+lib.paimon_vindex_training_free.restype = None
+
+lib.paimon_vindex_writer_open.argtypes = [c_void_p]
lib.paimon_vindex_writer_open.restype = c_void_p
lib.paimon_vindex_writer_free.argtypes = [c_void_p]
@@ -134,9 +156,6 @@ lib.paimon_vindex_writer_free.restype = None
lib.paimon_vindex_writer_dimension.argtypes = [c_void_p, POINTER(c_size_t)]
lib.paimon_vindex_writer_dimension.restype = c_int
-lib.paimon_vindex_writer_train.argtypes = [c_void_p, POINTER(c_float),
c_size_t]
-lib.paimon_vindex_writer_train.restype = c_int
-
lib.paimon_vindex_writer_add_vectors.argtypes = [
c_void_p,
POINTER(c_int64),
diff --git a/python/tests/test_vindex.py b/python/tests/test_vindex.py
index 8380c84..a6e79bc 100644
--- a/python/tests/test_vindex.py
+++ b/python/tests/test_vindex.py
@@ -20,7 +20,7 @@ import io
import numpy as np
import pytest
-from paimon_vindex import VectorIndexReader, VectorIndexWriter
+from paimon_vindex import VectorIndexReader, VectorIndexTrainer,
VectorIndexWriter
class VectorIndexInput:
@@ -44,8 +44,8 @@ def build_index(options, d, n=512):
data = clustered_data(n, d, int(options.get("nlist", "4")))
ids = np.arange(n, dtype=np.int64)
output = io.BytesIO()
- with VectorIndexWriter(options) as writer:
- writer.train(data)
+ training = VectorIndexTrainer.train(options, data)
+ with VectorIndexWriter(training) as writer:
writer.add_vectors(ids, data)
writer.write(output)
return output.getvalue(), data
@@ -151,15 +151,16 @@ def test_python_ffi_delegates_validation():
"pq.m": "4",
"metric": "l2",
}
- writer = VectorIndexWriter(options)
- with pytest.raises(RuntimeError, match="training data length 17"):
- writer.train(np.zeros((1, 17), dtype=np.float32))
+ with VectorIndexTrainer.create(options) as trainer:
+ with pytest.raises(RuntimeError, match="training data length 17"):
+ trainer.add_training_vectors(np.zeros((1, 17), dtype=np.float32))
data = np.zeros((1, 16), dtype=np.float32)
ids = np.array([1, 2], dtype=np.int64)
- with pytest.raises(RuntimeError, match="ids length 2 does not match vector
count 1"):
- writer.add_vectors(ids, data)
- writer.close()
+ training = VectorIndexTrainer.train(options, data)
+ with VectorIndexWriter(training) as writer:
+ with pytest.raises(RuntimeError, match="ids length 2 does not match
vector count 1"):
+ writer.add_vectors(ids, data)
index_bytes, data = build_index(options, 16)
with reader_from_bytes(index_bytes) as reader: