This is an automated email from the ASF dual-hosted git repository.

jerry-024 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 0e22fa8851 [Feature] Support batched vector index training with 
sampling ratio (#8350)
0e22fa8851 is described below

commit 0e22fa8851bb845223254ab2244380dc3ebffc84
Author: jerry <[email protected]>
AuthorDate: Wed Jul 8 17:40:32 2026 +0800

    [Feature] Support batched vector index training with sampling ratio (#8350)
---
 docs/docs/multimodal-table/global-index/vector.mdx |   7 +-
 paimon-vector/pom.xml                              |   2 +-
 .../index/NativeVectorGlobalIndexWriter.java       | 180 ++++++++++++++++++---
 .../vector/index/NativeVectorGlobalIndexer.java    |  25 ++-
 .../index/NativeVectorGlobalIndexerFactory.java    |  61 ++++++-
 .../vector/index/NativeVectorGlobalIndexTest.java  |  52 +++++-
 .../NativeVectorGlobalIndexerFactoryTest.java      |  75 +++++++++
 7 files changed, 375 insertions(+), 27 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index/vector.mdx 
b/docs/docs/multimodal-table/global-index/vector.mdx
index 9e25b8f983..6399d3c258 100644
--- a/docs/docs/multimodal-table/global-index/vector.mdx
+++ b/docs/docs/multimodal-table/global-index/vector.mdx
@@ -176,6 +176,7 @@ Supported IVF vector index options:
 |---|---|---|
 | `<index-type>.dimension` | `128` | Vector dimension for `ARRAY<FLOAT>` 
columns. Ignored for `VECTOR<FLOAT>` columns. |
 | `<index-type>.distance.metric` | `inner_product` | Distance metric. 
Supported values: `l2`, `cosine`, `inner_product`. |
+| `<index-type>.train.sample-ratio` | `1.0` | Ratio of vectors sampled for 
native index training. Must be greater than `0` and less than or equal to `1`. 
Lower values reduce training memory and build cost, but may reduce index 
quality. |
 | `<index-type>.nlist` | `256` | Number of IVF clusters used during index 
build. Higher values create more partitions and can improve recall for large 
datasets, but may increase build cost. |
 | `<index-type>.pq.m` | `16` | Number of PQ sub-vectors for `ivf-pq`. The 
vector dimension must be divisible by this value. Higher values usually improve 
recall with larger index files. |
 | `<index-type>.pq.use-opq` | `false` | Whether to enable OPQ for `ivf-pq`. |
@@ -234,7 +235,9 @@ CREATE TABLE my_table (
     'fields.image_embedding.dimension' = '512',
     -- shared by every ivf-pq column, overridden only for 'image_embedding'
     'ivf-pq.nlist' = '256',
-    'fields.image_embedding.nlist' = '512'
+    'fields.image_embedding.nlist' = '512',
+    -- per-column training sample ratio
+    'fields.image_embedding.train.sample-ratio' = '0.5'
 );
 ```
 
@@ -273,7 +276,7 @@ catalog.create_table("db.my_table", schema, 
ignore_if_exists=False)
 </Tabs>
 
 With the properties above, `title_embedding` is indexed with `nlist=256` while 
`image_embedding`
-uses `nlist=512`.
+uses `nlist=512` and trains with half of the non-null vectors.
 
 Lumina uses the same field-level convention. For example, 
`fields.image_embedding.distance.metric`
 overrides `lumina.distance.metric` for `image_embedding`, and
diff --git a/paimon-vector/pom.xml b/paimon-vector/pom.xml
index 5cf123ef59..fbe31c2fd5 100644
--- a/paimon-vector/pom.xml
+++ b/paimon-vector/pom.xml
@@ -32,7 +32,7 @@ under the License.
     <name>Paimon : Vector Index</name>
 
     <properties>
-        
<paimon-vector-index-java.version>0.1.0</paimon-vector-index-java.version>
+        
<paimon-vector-index-java.version>0.2.0</paimon-vector-index-java.version>
     </properties>
 
     <dependencies>
diff --git 
a/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexWriter.java
 
b/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexWriter.java
index 89aa004bee..6ff6843f00 100644
--- 
a/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexWriter.java
+++ 
b/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexWriter.java
@@ -24,6 +24,8 @@ import org.apache.paimon.fs.PositionOutputStream;
 import org.apache.paimon.globalindex.GlobalIndexSingleColumnWriter;
 import org.apache.paimon.globalindex.ResultEntry;
 import org.apache.paimon.globalindex.io.GlobalIndexFileWriter;
+import org.apache.paimon.index.vector.VectorIndexTrainer;
+import org.apache.paimon.index.vector.VectorIndexTraining;
 import org.apache.paimon.index.vector.VectorIndexWriter;
 import org.apache.paimon.types.ArrayType;
 import org.apache.paimon.types.DataType;
@@ -62,11 +64,15 @@ public class NativeVectorGlobalIndexWriter implements 
GlobalIndexSingleColumnWri
 
     private static final int IO_BUFFER_SIZE = 8 * 1024 * 1024;
     private static final int ADD_BATCH_SIZE = 10000;
+    private static final int TRAIN_BATCH_SIZE = 4096;
+    static final int MAX_FLOAT_ARRAY_LENGTH = Integer.MAX_VALUE - 8;
+    private static final long TRAIN_MEMORY_WARNING_BYTES = 4L * 1024 * 1024 * 
1024;
 
     private final GlobalIndexFileWriter fileWriter;
     private final String identifier;
     private final Map<String, String> nativeOptions;
     private final int dim;
+    private final double trainSampleRatio;
 
     private File tempVectorFile;
     private FileChannel writeChannel;
@@ -84,11 +90,34 @@ public class NativeVectorGlobalIndexWriter implements 
GlobalIndexSingleColumnWri
             DataType fieldType,
             Map<String, String> options,
             String identifier) {
+        this(
+                fileWriter,
+                fieldType,
+                options,
+                identifier,
+                NativeVectorGlobalIndexerFactory.DEFAULT_TRAIN_SAMPLE_RATIO);
+    }
+
+    public NativeVectorGlobalIndexWriter(
+            GlobalIndexFileWriter fileWriter,
+            DataType fieldType,
+            Map<String, String> options,
+            String identifier,
+            double trainSampleRatio) {
         this.fileWriter = fileWriter;
         this.identifier = identifier;
         validateFieldType(fieldType);
         this.nativeOptions = options;
         this.dim = Integer.parseInt(options.get("dimension"));
+        if (Double.isNaN(trainSampleRatio)
+                || Double.isInfinite(trainSampleRatio)
+                || trainSampleRatio <= 0
+                || trainSampleRatio > 1) {
+            throw new IllegalArgumentException(
+                    "trainSampleRatio must be greater than 0 and less than or 
equal to 1: "
+                            + trainSampleRatio);
+        }
+        this.trainSampleRatio = trainSampleRatio;
         this.count = 0;
         this.closed = false;
         this.recordSizeInBytes = checkedRecordSize(dim, IO_BUFFER_SIZE);
@@ -224,12 +253,11 @@ public class NativeVectorGlobalIndexWriter implements 
GlobalIndexSingleColumnWri
         long buildStart = System.currentTimeMillis();
 
         NativeVectorIndexLoader.loadJni();
-        try (VectorIndexWriter writer = new VectorIndexWriter(nativeOptions)) {
-
-            // Phase 1: Train
-            long phaseStart = System.currentTimeMillis();
-            LOG.info("{} train phase started", identifier);
-            trainFromTempFile(writer);
+        // Phase 1: Train
+        long phaseStart = System.currentTimeMillis();
+        LOG.info("{} train phase started", identifier);
+        try (VectorIndexTraining training = trainFromTempFile();
+                VectorIndexWriter writer = new VectorIndexWriter(training)) {
             LOG.info(
                     "{} train phase done in {} ms",
                     identifier,
@@ -271,31 +299,66 @@ public class NativeVectorGlobalIndexWriter implements 
GlobalIndexSingleColumnWri
         return FILE_NAME_PREFIX + "-" + identifier;
     }
 
-    private void trainFromTempFile(VectorIndexWriter writer) throws 
IOException {
-        int trainCount = (int) count;
-        float[] trainData = new float[trainCount * dim];
+    private VectorIndexTraining trainFromTempFile() throws IOException {
+        int trainCount = trainingVectorCount(count, trainSampleRatio);
+        int trainBatchSize = vectorBatchSize(TRAIN_BATCH_SIZE, dim);
+        float[] batchVectors = new float[trainBatchSize * dim];
+        logTrainingMemoryEstimate(trainCount);
 
-        try (RandomAccessFile raf = new RandomAccessFile(tempVectorFile, "r");
+        try (VectorIndexTrainer trainer = 
VectorIndexTrainer.create(nativeOptions);
+                RandomAccessFile raf = new RandomAccessFile(tempVectorFile, 
"r");
                 FileChannel channel = raf.getChannel()) {
             ByteBuffer readBuf = ByteBuffer.allocateDirect(IO_BUFFER_SIZE);
             readBuf.order(ByteOrder.nativeOrder());
             readBuf.limit(0);
 
-            for (int i = 0; i < trainCount; i++) {
+            long selected = 0;
+            long nextSampleIndex = 0;
+            int batchCount = 0;
+
+            for (long recordIndex = 0;
+                    recordIndex < count && selected < trainCount;
+                    recordIndex++) {
                 ensureAvailable(readBuf, channel, recordSizeInBytes);
                 readBuf.getLong(); // skip rowId
-                for (int d = 0; d < dim; d++) {
-                    trainData[i * dim + d] = readBuf.getFloat();
+                if (recordIndex == nextSampleIndex) {
+                    for (int d = 0; d < dim; d++) {
+                        batchVectors[batchCount * dim + d] = 
readBuf.getFloat();
+                    }
+                    selected++;
+                    batchCount++;
+                    if (batchCount == trainBatchSize) {
+                        trainer.addTrainingVectors(batchVectors, batchCount);
+                        batchCount = 0;
+                    }
+                    if (selected < trainCount) {
+                        nextSampleIndex = sampleIndex(selected, count, 
trainCount);
+                    }
+                } else {
+                    readBuf.position(readBuf.position() + dim * Float.BYTES);
                 }
             }
-        }
 
-        writer.train(trainData, trainCount);
+            if (batchCount > 0) {
+                trainer.addTrainingVectors(
+                        Arrays.copyOf(batchVectors, batchCount * dim), 
batchCount);
+            }
+            if (selected != trainCount) {
+                throw new IOException(
+                        "Expected to select "
+                                + trainCount
+                                + " training vectors, but selected "
+                                + selected);
+            }
+
+            return trainer.finishTraining();
+        }
     }
 
     private void addVectorsFromTempFile(VectorIndexWriter writer) throws 
IOException {
-        long[] batchIds = new long[ADD_BATCH_SIZE];
-        float[] batchVectors = new float[ADD_BATCH_SIZE * dim];
+        int addBatchSize = vectorBatchSize(ADD_BATCH_SIZE, dim);
+        long[] batchIds = new long[addBatchSize];
+        float[] batchVectors = new float[addBatchSize * dim];
 
         try (RandomAccessFile raf = new RandomAccessFile(tempVectorFile, "r");
                 FileChannel channel = raf.getChannel()) {
@@ -307,7 +370,7 @@ public class NativeVectorGlobalIndexWriter implements 
GlobalIndexSingleColumnWri
             int lastLoggedPercent = -1;
 
             while (remaining > 0) {
-                int thisBatch = (int) Math.min(ADD_BATCH_SIZE, remaining);
+                int thisBatch = (int) Math.min(addBatchSize, remaining);
                 for (int i = 0; i < thisBatch; i++) {
                     ensureAvailable(readBuf, channel, recordSizeInBytes);
                     batchIds[i] = readBuf.getLong();
@@ -315,7 +378,7 @@ public class NativeVectorGlobalIndexWriter implements 
GlobalIndexSingleColumnWri
                         batchVectors[i * dim + d] = readBuf.getFloat();
                     }
                 }
-                if (thisBatch == ADD_BATCH_SIZE) {
+                if (thisBatch == addBatchSize) {
                     writer.addVectors(batchIds, batchVectors, thisBatch);
                 } else {
                     writer.addVectors(
@@ -386,6 +449,85 @@ public class NativeVectorGlobalIndexWriter implements 
GlobalIndexSingleColumnWri
         return (int) recordSize;
     }
 
+    static int trainingVectorCount(long vectorCount, double trainSampleRatio) {
+        if (vectorCount <= 0) {
+            return 0;
+        }
+        if (Double.isNaN(trainSampleRatio)
+                || Double.isInfinite(trainSampleRatio)
+                || trainSampleRatio <= 0
+                || trainSampleRatio > 1) {
+            throw new IllegalArgumentException(
+                    "trainSampleRatio must be greater than 0 and less than or 
equal to 1: "
+                            + trainSampleRatio);
+        }
+        long trainCount = (long) Math.ceil(vectorCount * trainSampleRatio);
+        trainCount = Math.max(1L, Math.min(vectorCount, trainCount));
+        if (trainCount > Integer.MAX_VALUE) {
+            throw new IllegalStateException(
+                    "Training vector count "
+                            + trainCount
+                            + " exceeds Java integer capacity. Reduce 
train.sample-ratio.");
+        }
+        return (int) trainCount;
+    }
+
+    static int vectorBatchSize(int requestedBatchSize, int dim) {
+        if (requestedBatchSize <= 0) {
+            throw new IllegalArgumentException(
+                    "requestedBatchSize must be a positive integer: " + 
requestedBatchSize);
+        }
+        if (dim <= 0) {
+            throw new IllegalArgumentException("dim must be a positive 
integer: " + dim);
+        }
+        int maxBatchSize = MAX_FLOAT_ARRAY_LENGTH / dim;
+        if (maxBatchSize <= 0) {
+            throw new IllegalStateException(
+                    "Vector dimension " + dim + " exceeds Java float array 
capacity");
+        }
+        return Math.min(requestedBatchSize, maxBatchSize);
+    }
+
+    private void logTrainingMemoryEstimate(int trainCount) {
+        long rawBytes = saturatedMultiply(saturatedMultiply(trainCount, dim), 
Float.BYTES);
+        long estimatedPeakBytes = saturatedMultiply(rawBytes, 2);
+        if (estimatedPeakBytes >= TRAIN_MEMORY_WARNING_BYTES) {
+            LOG.warn(
+                    "{} training uses {} samples out of {} vectors (dim={}). 
Estimated native "
+                            + "training peak is at least {} bytes (~{} GiB) 
before OPQ and "
+                            + "temporary buffers.",
+                    identifier,
+                    trainCount,
+                    count,
+                    dim,
+                    estimatedPeakBytes,
+                    String.format("%.2f", estimatedPeakBytes / 1024.0 / 1024.0 
/ 1024.0));
+        } else {
+            LOG.info(
+                    "{} training uses {} samples out of {} vectors (dim={})",
+                    identifier,
+                    trainCount,
+                    count,
+                    dim);
+        }
+    }
+
+    private static long saturatedMultiply(long left, long right) {
+        if (left == 0 || right == 0) {
+            return 0;
+        }
+        if (left > Long.MAX_VALUE / right) {
+            return Long.MAX_VALUE;
+        }
+        return left * right;
+    }
+
+    static long sampleIndex(long sampleOrdinal, long vectorCount, int 
trainCount) {
+        long quotient = vectorCount / trainCount;
+        long remainder = vectorCount % trainCount;
+        return sampleOrdinal * quotient + sampleOrdinal * remainder / 
trainCount;
+    }
+
     @Override
     public void close() {
         if (!closed) {
diff --git 
a/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexer.java
 
b/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexer.java
index f45a97d34a..3293d1620e 100644
--- 
a/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexer.java
+++ 
b/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexer.java
@@ -39,17 +39,40 @@ public class NativeVectorGlobalIndexer implements 
VectorGlobalIndexer {
     private final DataType fieldType;
     private final Map<String, String> options;
     private final String identifier;
+    private final double trainSampleRatio;
 
     public NativeVectorGlobalIndexer(
             DataType fieldType, Map<String, String> options, String 
identifier) {
+        this(
+                fieldType,
+                options,
+                identifier,
+                NativeVectorGlobalIndexerFactory.DEFAULT_TRAIN_SAMPLE_RATIO);
+    }
+
+    public NativeVectorGlobalIndexer(
+            DataType fieldType,
+            Map<String, String> options,
+            String identifier,
+            double trainSampleRatio) {
         this.fieldType = fieldType;
         this.options = Objects.requireNonNull(options, "options must not be 
null");
         this.identifier = Objects.requireNonNull(identifier, "identifier must 
not be null");
+        if (Double.isNaN(trainSampleRatio)
+                || Double.isInfinite(trainSampleRatio)
+                || trainSampleRatio <= 0
+                || trainSampleRatio > 1) {
+            throw new IllegalArgumentException(
+                    "trainSampleRatio must be greater than 0 and less than or 
equal to 1: "
+                            + trainSampleRatio);
+        }
+        this.trainSampleRatio = trainSampleRatio;
     }
 
     @Override
     public GlobalIndexWriter createWriter(GlobalIndexFileWriter fileWriter) {
-        return new NativeVectorGlobalIndexWriter(fileWriter, fieldType, 
options, identifier);
+        return new NativeVectorGlobalIndexWriter(
+                fileWriter, fieldType, options, identifier, trainSampleRatio);
     }
 
     @Override
diff --git 
a/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexerFactory.java
 
b/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexerFactory.java
index 8e4daa030f..c351697518 100644
--- 
a/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexerFactory.java
+++ 
b/paimon-vector/src/main/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexerFactory.java
@@ -32,6 +32,8 @@ import java.util.Map;
 public abstract class NativeVectorGlobalIndexerFactory implements 
GlobalIndexerFactory {
 
     private static final int DEFAULT_DIMENSION = 128;
+    static final String TRAIN_SAMPLE_RATIO_OPTION = "train.sample-ratio";
+    static final double DEFAULT_TRAIN_SAMPLE_RATIO = 1.0;
 
     @Override
     public GlobalIndexer create(DataField field, Options options) {
@@ -39,7 +41,8 @@ public abstract class NativeVectorGlobalIndexerFactory 
implements GlobalIndexerF
         return new NativeVectorGlobalIndexer(
                 field.type(),
                 nativeOptions(field.type(), options, identifier, field.name()),
-                identifier);
+                identifier,
+                trainSampleRatio(options, identifier, field.name()));
     }
 
     static Map<String, String> nativeOptions(
@@ -78,6 +81,62 @@ public abstract class NativeVectorGlobalIndexerFactory 
implements GlobalIndexerF
         return nativeOptions;
     }
 
+    static double trainSampleRatio(Options tableOptions, String identifier, 
String fieldName) {
+        Map<String, String> tableOptionsMap = tableOptions.toMap();
+        String key =
+                resolveFieldOverriddenKey(
+                        tableOptionsMap, identifier, fieldName, 
TRAIN_SAMPLE_RATIO_OPTION);
+        if (key == null) {
+            return DEFAULT_TRAIN_SAMPLE_RATIO;
+        }
+        String value = tableOptionsMap.get(key);
+
+        try {
+            double parsed = Double.parseDouble(value.trim());
+            if (!Double.isNaN(parsed) && !Double.isInfinite(parsed) && parsed 
> 0 && parsed <= 1) {
+                return parsed;
+            }
+            throw invalidTrainSampleRatio(key, value);
+        } catch (NumberFormatException e) {
+            throw invalidTrainSampleRatio(key, value);
+        }
+    }
+
+    private static IllegalArgumentException invalidTrainSampleRatio(String 
key, String value) {
+        return new IllegalArgumentException(
+                "Invalid value for '"
+                        + key
+                        + "': "
+                        + value
+                        + ". Must be greater than 0 and less than or equal to 
1.");
+    }
+
+    /**
+     * Resolves a single option key that supports index-level ({@code 
<index-type>.<option>}) and
+     * field-level ({@code fields.<field-name>.<option>}) forms, where the 
field-level key overrides
+     * the index-level key. Returns the winning fully-qualified key, or {@code 
null} if neither is
+     * set.
+     *
+     * <p>This is the same index/field precedence applied in bulk by {@link 
#nativeOptions}; the
+     * difference is that this helper resolves a single option so it can stay 
local (for example
+     * {@code train.sample-ratio}) instead of being forwarded to the native 
writer.
+     */
+    private static String resolveFieldOverriddenKey(
+            Map<String, String> tableOptionsMap,
+            String identifier,
+            String fieldName,
+            String option) {
+        String fieldKey = "fields." + fieldName + "." + option;
+        if (tableOptionsMap.containsKey(fieldKey)) {
+            return fieldKey;
+        }
+        String indexKey = identifier + "." + option;
+        if (tableOptionsMap.containsKey(indexKey)) {
+            return indexKey;
+        }
+        return null;
+    }
+
     private static String nativeOptionKey(String optionKey) {
         switch (optionKey) {
             case "index.dimension":
diff --git 
a/paimon-vector/src/test/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexTest.java
 
b/paimon-vector/src/test/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexTest.java
index 72303f5bf0..82b345b799 100644
--- 
a/paimon-vector/src/test/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexTest.java
+++ 
b/paimon-vector/src/test/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexTest.java
@@ -77,14 +77,14 @@ public class NativeVectorGlobalIndexTest {
             options.setInteger("ivf-flat.dimension", 2);
             options.setString("ivf-flat.metric", "l2");
             options.setInteger("ivf-flat.nlist", 1);
-            try (org.apache.paimon.index.vector.VectorIndexWriter ignored =
-                    new org.apache.paimon.index.vector.VectorIndexWriter(
+            try (org.apache.paimon.index.vector.VectorIndexTrainer ignored =
+                    org.apache.paimon.index.vector.VectorIndexTrainer.create(
                             NativeVectorGlobalIndexerFactory.nativeOptions(
                                     new ArrayType(new FloatType()),
                                     options,
                                     
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
                                     "vec"))) {
-                // Closed immediately; constructing the writer is enough to 
validate JNI loading.
+                // Closed immediately; constructing the trainer is enough to 
validate JNI loading.
             }
             return true;
         } catch (Throwable t) {
@@ -180,6 +180,52 @@ public class NativeVectorGlobalIndexTest {
         assertThat(results).isEmpty();
     }
 
+    @Test
+    public void testTrainingVectorCountUsesConfiguredSampleRatio() {
+        assertThat(NativeVectorGlobalIndexWriter.trainingVectorCount(10_000L, 
1.0))
+                .isEqualTo(10_000);
+        assertThat(NativeVectorGlobalIndexWriter.trainingVectorCount(10_000L, 
0.25))
+                .isEqualTo(2_500);
+        assertThat(NativeVectorGlobalIndexWriter.trainingVectorCount(3L, 
0.01)).isEqualTo(1);
+        assertThat(NativeVectorGlobalIndexWriter.trainingVectorCount(0L, 
0.25)).isEqualTo(0);
+
+        assertThatThrownBy(() -> 
NativeVectorGlobalIndexWriter.trainingVectorCount(10L, 0))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("greater than 0");
+        assertThatThrownBy(() -> 
NativeVectorGlobalIndexWriter.trainingVectorCount(10L, 1.1))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("less than or equal to 1");
+    }
+
+    @Test
+    public void testSampleIndexSequenceIsUniform() {
+        assertThat(
+                        new long[] {
+                            NativeVectorGlobalIndexWriter.sampleIndex(0, 10, 
4),
+                            NativeVectorGlobalIndexWriter.sampleIndex(1, 10, 
4),
+                            NativeVectorGlobalIndexWriter.sampleIndex(2, 10, 
4),
+                            NativeVectorGlobalIndexWriter.sampleIndex(3, 10, 4)
+                        })
+                .containsExactly(0L, 2L, 5L, 7L);
+    }
+
+    @Test
+    public void testVectorBatchSizeProtectsSingleJavaArrayAllocation() {
+        assertThat(NativeVectorGlobalIndexWriter.vectorBatchSize(4096, 
128)).isEqualTo(4096);
+        assertThat(NativeVectorGlobalIndexWriter.vectorBatchSize(10000, 
128)).isEqualTo(10000);
+        assertThat(
+                        NativeVectorGlobalIndexWriter.vectorBatchSize(
+                                4096, 
NativeVectorGlobalIndexWriter.MAX_FLOAT_ARRAY_LENGTH))
+                .isEqualTo(1);
+        assertThat(
+                        NativeVectorGlobalIndexWriter.vectorBatchSize(
+                                10000, 
NativeVectorGlobalIndexWriter.MAX_FLOAT_ARRAY_LENGTH))
+                .isEqualTo(1);
+        assertThatThrownBy(() -> 
NativeVectorGlobalIndexWriter.vectorBatchSize(4096, 0))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("positive integer");
+    }
+
     @Test
     public void testMetaSerializationIsEmptyMap() throws IOException {
         VectorIndexMeta meta = new VectorIndexMeta();
diff --git 
a/paimon-vector/src/test/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexerFactoryTest.java
 
b/paimon-vector/src/test/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexerFactoryTest.java
index 92c56b6485..856a38185f 100644
--- 
a/paimon-vector/src/test/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexerFactoryTest.java
+++ 
b/paimon-vector/src/test/java/org/apache/paimon/vector/index/NativeVectorGlobalIndexerFactoryTest.java
@@ -217,4 +217,79 @@ public class NativeVectorGlobalIndexerFactoryTest {
                 .containsEntry("nlist", "256")
                 .doesNotContainKey("aggregate-function");
     }
+
+    @Test
+    public void testTrainSampleRatioDefaultAndOverrides() {
+        Options options = new Options();
+
+        assertThat(
+                        NativeVectorGlobalIndexerFactory.trainSampleRatio(
+                                options, 
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER, "vec"))
+                
.isEqualTo(NativeVectorGlobalIndexerFactory.DEFAULT_TRAIN_SAMPLE_RATIO);
+
+        options.setString("ivf-flat.train.sample-ratio", "0.25");
+        assertThat(
+                        NativeVectorGlobalIndexerFactory.trainSampleRatio(
+                                options, 
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER, "vec"))
+                .isEqualTo(0.25);
+
+        options.setString("fields.vec.train.sample-ratio", "0.5");
+        assertThat(
+                        NativeVectorGlobalIndexerFactory.trainSampleRatio(
+                                options, 
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER, "vec"))
+                .isEqualTo(0.5);
+
+        assertThat(
+                        NativeVectorGlobalIndexerFactory.trainSampleRatio(
+                                options, 
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER, "other"))
+                .isEqualTo(0.25);
+    }
+
+    @Test
+    public void testInvalidTrainSampleRatio() {
+        Options options = new Options();
+        options.setString("ivf-flat.train.sample-ratio", "0");
+
+        assertThatThrownBy(
+                        () ->
+                                
NativeVectorGlobalIndexerFactory.trainSampleRatio(
+                                        options,
+                                        
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
+                                        "vec"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("ivf-flat.train.sample-ratio")
+                .hasMessageContaining("greater than 0");
+
+        options.setString("fields.vec.train.sample-ratio", "bad");
+        assertThatThrownBy(
+                        () ->
+                                
NativeVectorGlobalIndexerFactory.trainSampleRatio(
+                                        options,
+                                        
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
+                                        "vec"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("fields.vec.train.sample-ratio")
+                .hasMessageContaining("less than or equal to 1");
+    }
+
+    @Test
+    public void testTrainSampleRatioIsNotNativeOption() {
+        Options options = new Options();
+        options.setString("ivf-flat.dimension", "32");
+        options.setString("ivf-flat.nlist", "128");
+        options.setString("ivf-flat.train.sample-ratio", "0.25");
+        options.setString("fields.vec.train.sample-ratio", "0.5");
+
+        Map<String, String> nativeOptions =
+                NativeVectorGlobalIndexerFactory.nativeOptions(
+                        new ArrayType(new FloatType()),
+                        options,
+                        IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
+                        "vec");
+
+        assertThat(nativeOptions)
+                .containsEntry("dimension", "32")
+                .containsEntry("nlist", "128")
+                .doesNotContainKey("train.sample-ratio");
+    }
 }

Reply via email to