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

JingsongLi 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 c23edd8dbd [vector] Upgrade paimon-vector-index to 0.5.0 and support 
new build options (#10080)
c23edd8dbd is described below

commit c23edd8dbdf27c13784f80d109a0785245d0596d
Author: jerry <[email protected]>
AuthorDate: Wed Sep 23 11:26:56 2026 +0800

    [vector] Upgrade paimon-vector-index to 0.5.0 and support new build options 
(#10080)
---
 docs/docs/multimodal-table/global-index/vector.mdx |  30 ++++-
 .../java/org/apache/paimon/options/Options.java    |  49 ++++++--
 .../flink/globalindex/GenericIndexTopoBuilder.java |   2 +-
 paimon-python/dev/requirements-dev.txt             |   2 +-
 paimon-python/dev/run_mixed_tests.sh               |   6 +-
 .../pypaimon/globalindex/create_global_index.py    |   4 +-
 .../vindex/vindex_vector_global_index_reader.py    |   2 +-
 .../vindex/vindex_vector_index_writer.py           | 120 ++++++++++++++----
 .../pypaimon/tests/global_index_build_test.py      |  78 +++++++++++-
 paimon-python/setup.py                             |   2 +-
 paimon-vector/pom.xml                              |   2 +-
 .../index/NativeVectorGlobalIndexerFactory.java    | 137 ++++++++++++++++-----
 .../NativeVectorGlobalIndexerFactoryTest.java      | 133 +++++++++++++++++++-
 13 files changed, 489 insertions(+), 78 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index/vector.mdx 
b/docs/docs/multimodal-table/global-index/vector.mdx
index c17170e9f9..c191389b22 100644
--- a/docs/docs/multimodal-table/global-index/vector.mdx
+++ b/docs/docs/multimodal-table/global-index/vector.mdx
@@ -73,7 +73,7 @@ The build and search examples below use its three-dimensional 
`embedding` column
 For this small dataset, use `ivf-flat` with one cluster; production workloads 
should
 choose an index type and cluster count for their own data.
 
-For Python, install `pypaimon[vindex]` (or its matching `paimon-vindex==0.4.0`
+For Python, install `pypaimon[vindex]` (or its matching `paimon-vindex==0.5.0`
 dependency) before building or querying native vector indexes.
 
 <Tabs groupId="vector-build">
@@ -335,15 +335,43 @@ Supported paimon-vindex options:
 |---|---|---|
 | `<index-type>.dimension` | `128` | Vector dimension for `ARRAY<FLOAT>` 
columns. Ignored for `VECTOR<FLOAT, n>` columns. |
 | `<index-type>.distance.metric` | `inner_product` | Distance metric. 
Supported values: `l2`, `cosine`, `inner_product`. |
+| `<index-type>.ivf.coarse-assignment` | `auto` | Build-time list assignment 
for all IVF types. `auto` uses Vamana when `dimension × nlist ≥ 1,000,000` and 
exact assignment otherwise; `exact` always uses exact assignment. |
+| `<index-type>.ivf.train.max-points-per-centroid` | `256` | Positive coarse 
K-means training limit for all IVF types: at most `nlist × value` vectors. |
 | `<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` | Automatic | Number of clusters for the four IVF 
types. When omitted, paimon-vindex resolves it from the shard's non-null vector 
count. |
 | `<index-type>.pq.code-ratio` | `0.0625` | Relative PQ-code budget for 
`ivf-pq` and `diskann`. |
 | `<index-type>.pq.m` | Automatic | Expert override for the PQ sub-vector 
count used by `ivf-pq` and `diskann`. |
+| `ivf-pq.ivf.pq-encoding` | `auto` | IVF-PQ build-time encoding. `auto` 
selects an accelerated backend when supported; `canonical` uses the canonical 
encoder. |
+| `<index-type>.pq.train.max-points-per-centroid` | `256` | Positive PQ 
training limit for `ivf-pq` and `diskann`: at most `2^pq.bits × value` vectors 
per subquantizer. |
 | `ivf-pq.pq.use-opq` | Automatic | Explicitly enables or disables OPQ. 
Without an explicit value, a `target-recall` of at least `0.9` enables it. |
 | `ivf-rq.rq.bits` | `4` | Persisted IVF-RQ residual width. Supported values 
are `1` through `8`; changing it requires rebuilding the index. |
 | `<index-type>.target-recall` | Not set | Build-policy hint used by `ivf-pq` 
and `diskann`. Validate the resulting recall on held-out queries. |
 | `<index-type>.max-bytes-per-vector` | Not set | Storage objective and 
conservative preflight bound for `ivf-pq`, `ivf-rq`, and `diskann`. |
 
+Use the index-prefixed keys in table properties, SQL procedure options, and 
Python
+build options. For example, the following IVF-PQ build selects the non-default
+exact and canonical paths and lowers both training limits from `256` to `128`:
+
+```sql
+CALL sys.create_global_index(
+    table => 'db.model_embeddings',
+    index_column => 'embedding',
+    index_type => 'ivf-pq',
+    options => 
'ivf-pq.ivf.coarse-assignment=exact,ivf-pq.ivf.pq-encoding=canonical,ivf-pq.ivf.train.max-points-per-centroid=128,ivf-pq.pq.train.max-points-per-centroid=128'
+);
+```
+
+Omit these options to retain the defaults in the table above. The native names
+`ivf.coarse-assignment`, `ivf.pq-encoding`,
+`ivf.train.max-points-per-centroid`, and `pq.train.max-points-per-centroid` are
+also accepted directly; prefer index-prefixed names in table properties to 
avoid
+sharing a setting across index types. Use `fields.<field-name>.<option>` to
+override a table property for one vector column. Explicit procedure or Python
+options override table properties; within either source, field-prefixed options
+override index-prefixed options, which override bare native names. An
+inapplicable stored table property is ignored, while an explicitly supplied
+option that does not apply to the selected index type is rejected.
+
 Additional paimon-vindex DiskANN build options:
 
 | Option | Default | Description |
diff --git a/paimon-api/src/main/java/org/apache/paimon/options/Options.java 
b/paimon-api/src/main/java/org/apache/paimon/options/Options.java
index 178adc7545..523c7412ed 100644
--- a/paimon-api/src/main/java/org/apache/paimon/options/Options.java
+++ b/paimon-api/src/main/java/org/apache/paimon/options/Options.java
@@ -51,27 +51,38 @@ public class Options implements Serializable {
     /** Stores the concrete key/value pairs of this configuration object. */
     private final HashMap<String, String> data;
 
+    /** Options overriding the base map through setters or the two-map 
constructor. */
+    private HashMap<String, String> dynamicOptions;
+
     /** Creates a new empty configuration. */
     public Options() {
         this.data = new HashMap<>();
+        this.dynamicOptions = new HashMap<>();
     }
 
     /** Creates a new configuration that is initialized with the options of 
the given map. */
     public Options(Map<String, String> map) {
         this();
-        map.forEach(this::setString);
+        map.forEach(this.data::put);
     }
 
     /** Creates a new configuration that is initialized with the options of 
the given two maps. */
     public Options(Map<String, String> map1, Map<String, String> map2) {
-        this();
-        map1.forEach(this::setString);
-        map2.forEach(this::setString);
+        this(map1);
+        map2.forEach(this.data::put);
+        this.dynamicOptions.putAll(map2);
+    }
+
+    /** Merges a base map while preserving which options dynamically override 
it. */
+    public Options(Map<String, String> map, Options options) {
+        this(map, options.toMap());
+        this.dynamicOptions.clear();
+        this.dynamicOptions.putAll(options.dynamicOptions());
     }
 
     public Options(Iterable<Map.Entry<String, String>> map) {
         this();
-        map.forEach(entry -> setString(entry.getKey(), entry.getValue()));
+        map.forEach(entry -> data.put(entry.getKey(), entry.getValue()));
     }
 
     public static Options fromMap(Map<String, String> map) {
@@ -86,10 +97,12 @@ public class Options implements Serializable {
      */
     public synchronized void setString(String key, String value) {
         data.put(key, value);
+        setDynamicOption(key, value);
     }
 
     public synchronized void set(String key, String value) {
         data.put(key, value);
+        setDynamicOption(key, value);
     }
 
     public synchronized <T> Options set(ConfigOption<T> option, T value) {
@@ -150,8 +163,21 @@ public class Options implements Serializable {
         return data;
     }
 
+    /** Returns options supplied as dynamic overrides to the base map. */
+    public synchronized Map<String, String> dynamicOptions() {
+        Map<String, String> result = new HashMap<>();
+        if (dynamicOptions != null) {
+            dynamicOptions.keySet().stream()
+                    .filter(data::containsKey)
+                    .forEach(key -> result.put(key, data.get(key)));
+        }
+        return result;
+    }
+
     public synchronized Options removePrefix(String prefix) {
-        return new Options(convertToPropertiesPrefixKey(data, prefix));
+        return new Options(
+                convertToPropertiesPrefixKey(data, prefix),
+                convertToPropertiesPrefixKey(dynamicOptions(), prefix));
     }
 
     public synchronized String remove(String key) {
@@ -232,8 +258,17 @@ public class Options implements Serializable {
             if (canBePrefixMap) {
                 removePrefixMap(this.data, key);
             }
-            this.data.put(key, OptionsUtils.convertToString(value));
+            String stringValue = OptionsUtils.convertToString(value);
+            this.data.put(key, stringValue);
+            setDynamicOption(key, stringValue);
+        }
+    }
+
+    private void setDynamicOption(String key, String value) {
+        if (dynamicOptions == null) {
+            dynamicOptions = new HashMap<>();
         }
+        dynamicOptions.put(key, value);
     }
 
     private Optional<Object> getRawValueFromOption(ConfigOption<?> 
configOption) {
diff --git 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilder.java
 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilder.java
index 4740a3ea80..bb14cefa43 100644
--- 
a/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilder.java
+++ 
b/paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/globalindex/GenericIndexTopoBuilder.java
@@ -227,7 +227,7 @@ public class GenericIndexTopoBuilder {
         readColumns.add(SpecialFields.ROW_ID.name());
         RowType projectedRowType = 
SpecialFields.rowTypeWithRowId(rowType).project(readColumns);
 
-        Options mergedOptions = new Options(table.options(), 
userOptions.toMap());
+        Options mergedOptions = new Options(table.options(), userOptions);
         byte[] sourceMeta =
                 new 
DataEvolutionIndexSourceMeta(scanResult.scanSnapshotId()).serialize();
 
diff --git a/paimon-python/dev/requirements-dev.txt 
b/paimon-python/dev/requirements-dev.txt
index a122dd0bbe..711b0ee62a 100644
--- a/paimon-python/dev/requirements-dev.txt
+++ b/paimon-python/dev/requirements-dev.txt
@@ -36,6 +36,6 @@ datafusion>=52; python_version >= "3.10"
 # Lumina vector search (optional, for lumina index tests)
 lumina-data>=0.1.0
 # paimon-vindex vector search (optional, for vindex index tests)
-paimon-vindex==0.4.0; python_version >= "3.9"
+paimon-vindex==0.5.0; python_version >= "3.9"
 # paimon-ftindex full-text search (optional, for full-text index tests)
 paimon-ftindex==0.1.0; python_version >= "3.8"
diff --git a/paimon-python/dev/run_mixed_tests.sh 
b/paimon-python/dev/run_mixed_tests.sh
index 54fef2a4e1..319267b663 100755
--- a/paimon-python/dev/run_mixed_tests.sh
+++ b/paimon-python/dev/run_mixed_tests.sh
@@ -661,14 +661,14 @@ ensure_paimon_vindex() {
     fi
 
     echo "Installing Python paimon-vindex dependency..."
-    if python -m pip install 'paimon-vindex==0.4.0'; then
+    if python -m pip install 'paimon-vindex==0.5.0'; then
         return 0
     fi
 
     echo -e "${YELLOW}Direct pip install failed; installing paimon-vindex into 
a temporary target directory...${NC}"
     local target_dir="${TMPDIR:-/tmp}/paimon-vindex-site"
     rm -rf "$target_dir"
-    if python -m pip install --target "$target_dir" 'paimon-vindex==0.4.0'; 
then
+    if python -m pip install --target "$target_dir" 'paimon-vindex==0.5.0'; 
then
         export PYTHONPATH="$target_dir:${PYTHONPATH:-}"
         return 0
     fi
@@ -676,7 +676,7 @@ ensure_paimon_vindex() {
     if python -c "import numpy" >/dev/null 2>&1; then
         echo -e "${YELLOW}Dependency install failed but numpy is already 
available; retrying paimon-vindex without dependencies...${NC}"
         rm -rf "$target_dir"
-        if python -m pip install --target "$target_dir" --no-deps 
'paimon-vindex==0.4.0'; then
+        if python -m pip install --target "$target_dir" --no-deps 
'paimon-vindex==0.5.0'; then
             export PYTHONPATH="$target_dir:${PYTHONPATH:-}"
             return 0
         fi
diff --git a/paimon-python/pypaimon/globalindex/create_global_index.py 
b/paimon-python/pypaimon/globalindex/create_global_index.py
index 0671ab09e1..09b8a568f5 100644
--- a/paimon-python/pypaimon/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/globalindex/create_global_index.py
@@ -123,6 +123,7 @@ class GlobalIndexBuilder:
         self._index_type = index_type.lower().strip()
         self._partition_filter = partition_filter
         self._partitions = partitions
+        self._user_options = dict(options or {})
         self._options = _merged_options(table, options)
         self._core_options = CoreOptions(self._options)
 
@@ -464,8 +465,9 @@ class GlobalIndexBuilder:
                 index_path,
                 index_field.type,
                 self._index_type,
-                self._options.to_map(),
+                self._table.options.options.to_map(),
                 index_field.name,
+                self._user_options,
             )
         if self._index_type == FULL_TEXT_IDENTIFIER:
             return NativeFullTextIndexWriter(
diff --git 
a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
 
b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
index c3fc51f61a..a8e8aee60c 100644
--- 
a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
+++ 
b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_global_index_reader.py
@@ -205,7 +205,7 @@ class VindexVectorGlobalIndexReader(GlobalIndexReader):
             except ImportError as e:
                 raise ImportError(
                     "paimon-vindex is required to read vindex vector indexes. "
-                    "Install paimon-vindex==0.4.0 or pypaimon[vindex].") from e
+                    "Install paimon-vindex==0.5.0 or pypaimon[vindex].") from e
 
             file_path = (self._io_meta.external_path
                          if self._io_meta.external_path
diff --git 
a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py 
b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py
index 96443cca34..eb15565c49 100644
--- a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py
+++ b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py
@@ -44,6 +44,7 @@ class VindexVectorIndexWriter:
         index_type: str,
         options: Mapping[str, object],
         field_name: str,
+        user_options: Optional[Mapping[str, object]] = None,
     ):
         self.file_name = (
             "%s-%s-global-index-%s.index"
@@ -53,9 +54,9 @@ class VindexVectorIndexWriter:
         self._index_path = index_path.rstrip('/')
         self._index_type = index_type
         self._native_options = native_options(
-            data_type, options, index_type, field_name)
+            data_type, options, index_type, field_name, user_options)
         self._train_sample_ratio = train_sample_ratio(
-            options, index_type, field_name)
+            options, index_type, field_name, user_options)
         self._dimension = int(self._native_options["dimension"])
         self._row_count = 0
         self._vector_count = 0
@@ -148,7 +149,7 @@ class VindexVectorIndexWriter:
             except ImportError as e:
                 raise ImportError(
                     "paimon-vindex is required to build vindex vector indexes. 
"
-                    "Install paimon-vindex==0.4.0 or pypaimon[vindex].") from e
+                    "Install paimon-vindex==0.5.0 or pypaimon[vindex].") from e
 
             self._close_temp_files()
             self._file_io.check_or_mkdirs(self._index_path)
@@ -249,27 +250,19 @@ class VindexVectorIndexWriter:
 
 def native_options(
     data_type: DataType,
-    options: Mapping[str, object],
+    table_options: Mapping[str, object],
     index_type: str,
     field_name: str,
+    user_options: Optional[Mapping[str, object]] = None,
 ) -> Dict[str, str]:
-    result: Dict[str, str] = {}
-    option_prefix = "%s." % index_type
-    field_prefix = "fields.%s." % field_name
-
-    for key, value in options.items():
-        key = str(key)
-        if key.startswith(option_prefix):
-            native_key = _native_option_key(key[len(option_prefix):])
-            if native_key is not None:
-                result[native_key] = str(value)
+    if user_options is None:
+        user_options, table_options = table_options, {}
 
-    for key, value in options.items():
-        key = str(key)
-        if key.startswith(field_prefix):
-            native_key = _native_option_key(key[len(field_prefix):])
-            if native_key is not None:
-                result[native_key] = str(value)
+    result: Dict[str, str] = {}
+    _collect_native_options(
+        result, table_options, index_type, field_name, validate=False)
+    _collect_native_options(
+        result, user_options, index_type, field_name, validate=True)
 
     result["index.type"] = index_type.replace('-', '_')
     result["dimension"] = str(_dimension(data_type, result, index_type))
@@ -278,12 +271,19 @@ def native_options(
 
 
 def train_sample_ratio(
-    options: Mapping[str, object], index_type: str, field_name: str
+    table_options: Mapping[str, object],
+    index_type: str,
+    field_name: str,
+    user_options: Optional[Mapping[str, object]] = None,
 ) -> float:
+    sources = (table_options,) if user_options is None else (user_options, 
table_options)
     field_key = "fields.%s.train.sample-ratio" % field_name
     index_key = "%s.train.sample-ratio" % index_type
-    key = field_key if field_key in options else index_key
-    if key not in options:
+    for options in sources:
+        key = field_key if field_key in options else index_key
+        if key in options:
+            break
+    else:
         return 1.0
 
     value = options[key]
@@ -322,6 +322,42 @@ def validate_vector_type(data_type: DataType) -> None:
         % data_type)
 
 
+def _collect_native_options(
+    result, options, index_type, field_name, validate
+):
+    option_prefix = "%s." % index_type
+    field_prefix = "fields.%s." % field_name
+
+    for key, value in options.items():
+        key = str(key)
+        native_key = _native_option_key(key)
+        if key == native_key and _is_050_build_option(native_key):
+            _put_native_option(
+                result, key, key, value, index_type, validate)
+    for key, value in options.items():
+        key = str(key)
+        if key.startswith(option_prefix):
+            _put_native_option(
+                result,
+                key,
+                key[len(option_prefix):],
+                value,
+                index_type,
+                validate,
+            )
+    for key, value in options.items():
+        key = str(key)
+        if key.startswith(field_prefix):
+            _put_native_option(
+                result,
+                key,
+                key[len(field_prefix):],
+                value,
+                index_type,
+                validate,
+            )
+
+
 def _native_option_key(option_key: str) -> Optional[str]:
     if option_key in ("index.dimension", "dimension"):
         return "dimension"
@@ -330,6 +366,10 @@ def _native_option_key(option_key: str) -> Optional[str]:
     if option_key in (
         "nlist",
         "expected-vector-count",
+        "ivf.coarse-assignment",
+        "ivf.pq-encoding",
+        "ivf.train.max-points-per-centroid",
+        "pq.train.max-points-per-centroid",
         "pq.m",
         "pq.code-ratio",
         "pq.bits",
@@ -366,6 +406,40 @@ def _native_option_key(option_key: str) -> Optional[str]:
     return None
 
 
+def _put_native_option(
+    result, option_key, option_suffix, value, index_type, validate
+):
+    native_key = _native_option_key(option_suffix)
+    if native_key is None:
+        return
+    if (_is_050_build_option(native_key)
+            and not _is_allowed_050_build_option(native_key, index_type)):
+        if validate:
+            raise ValueError(
+                "Option '%s' is not supported for index type '%s'."
+                % (option_key, index_type)
+            )
+        return
+    result[native_key] = str(value)
+
+
+def _is_050_build_option(key):
+    return key in (
+        "ivf.coarse-assignment",
+        "ivf.pq-encoding",
+        "ivf.train.max-points-per-centroid",
+        "pq.train.max-points-per-centroid",
+    )
+
+
+def _is_allowed_050_build_option(key, index_type):
+    if key == "ivf.pq-encoding":
+        return index_type == "ivf-pq"
+    if key == "pq.train.max-points-per-centroid":
+        return index_type in ("ivf-pq", "diskann")
+    return index_type != "diskann"
+
+
 def _dimension(
     data_type: DataType, native_options_map: Mapping[str, str], index_type: str
 ) -> int:
diff --git a/paimon-python/pypaimon/tests/global_index_build_test.py 
b/paimon-python/pypaimon/tests/global_index_build_test.py
index 9876d486fd..d7f117ae93 100644
--- a/paimon-python/pypaimon/tests/global_index_build_test.py
+++ b/paimon-python/pypaimon/tests/global_index_build_test.py
@@ -1086,14 +1086,14 @@ class GlobalIndexBuildTest(
         }
 
         rq_result = native_options(
-            data_type, options, 'ivf-rq', 'embedding')
+            data_type, options, 'ivf-rq', 'embedding', {})
         self.assertEqual('ivf_rq', rq_result['index.type'])
         self.assertEqual('5', rq_result['rq.bits'])
         self.assertEqual('96', rq_result['max-bytes-per-vector'])
         self.assertEqual('inner_product', rq_result['metric'])
 
         diskann_result = native_options(
-            data_type, options, 'diskann', 'embedding')
+            data_type, options, 'diskann', 'embedding', {})
         self.assertEqual('diskann', diskann_result['index.type'])
         self.assertEqual(
             'balanced', diskann_result['diskann.build-preset'])
@@ -1101,6 +1101,71 @@ class GlobalIndexBuildTest(
         self.assertEqual(
             'f16', diskann_result['diskann.raw-vector-encoding'])
 
+    def test_vindex_native_options_support_050_build_options(self):
+        data_type = ArrayType(True, AtomicType('FLOAT'))
+        native_names = {
+            'ivf.coarse-assignment': 'auto',
+            'ivf.pq-encoding': 'auto',
+            'ivf.train.max-points-per-centroid': '32',
+            'pq.train.max-points-per-centroid': '64',
+        }
+
+        result = native_options(
+            data_type, native_names, 'ivf-pq', 'embedding')
+        self.assertEqual('auto', result['ivf.coarse-assignment'])
+        self.assertEqual('auto', result['ivf.pq-encoding'])
+        self.assertEqual('32', result['ivf.train.max-points-per-centroid'])
+        self.assertEqual('64', result['pq.train.max-points-per-centroid'])
+
+        prefixed_names = {
+            'ivf-pq.ivf.coarse-assignment': 'exact',
+            'fields.embedding.ivf.pq-encoding': 'canonical',
+            'ivf-pq.ivf.train.max-points-per-centroid': '16',
+            'fields.embedding.pq.train.max-points-per-centroid': '48',
+        }
+        result = native_options(
+            data_type, prefixed_names, 'ivf-pq', 'embedding')
+        self.assertEqual('exact', result['ivf.coarse-assignment'])
+        self.assertEqual('canonical', result['ivf.pq-encoding'])
+        self.assertEqual('16', result['ivf.train.max-points-per-centroid'])
+        self.assertEqual('48', result['pq.train.max-points-per-centroid'])
+
+        result = native_options(
+            data_type,
+            {'diskann.pq.train.max-points-per-centroid': '24'},
+            'diskann',
+            'embedding',
+        )
+        self.assertEqual('24', result['pq.train.max-points-per-centroid'])
+
+        table_options = {'fields.embedding.ivf.pq-encoding': 'auto'}
+        result = native_options(
+            data_type,
+            table_options,
+            'ivf-pq',
+            'embedding',
+            {'ivf.pq-encoding': 'canonical'},
+        )
+        self.assertEqual('canonical', result['ivf.pq-encoding'])
+        result = native_options(
+            data_type, table_options, 'ivf-flat', 'embedding', {})
+        self.assertNotIn('ivf.pq-encoding', result)
+
+        with self.assertRaisesRegex(ValueError, 'ivf-flat.ivf.pq-encoding'):
+            native_options(
+                data_type,
+                {'ivf-flat.ivf.pq-encoding': 'canonical'},
+                'ivf-flat',
+                'embedding',
+            )
+        with self.assertRaisesRegex(ValueError, 
'diskann.ivf.coarse-assignment'):
+            native_options(
+                data_type,
+                {'diskann.ivf.coarse-assignment': 'exact'},
+                'diskann',
+                'embedding',
+            )
+
     def test_vindex_training_sample_ratio(self):
         options = {
             'ivf-rq.train.sample-ratio': '0.5',
@@ -1110,6 +1175,15 @@ class GlobalIndexBuildTest(
             0.25, train_sample_ratio(options, 'ivf-rq', 'embedding'))
         self.assertEqual(
             0.5, train_sample_ratio(options, 'ivf-rq', 'other'))
+        self.assertEqual(
+            0.5,
+            train_sample_ratio(
+                {'fields.embedding.train.sample-ratio': '0.75'},
+                'ivf-rq',
+                'embedding',
+                {'ivf-rq.train.sample-ratio': '0.5'},
+            ),
+        )
 
         import numpy as np
         vectors = np.arange(20, dtype=np.float32).reshape(10, 2)
diff --git a/paimon-python/setup.py b/paimon-python/setup.py
index 1af50be1e5..7d732167b8 100644
--- a/paimon-python/setup.py
+++ b/paimon-python/setup.py
@@ -294,7 +294,7 @@ setup(
             'lumina-data>=0.1.0'
         ],
         'vindex': [
-            'paimon-vindex==0.4.0; python_version>="3.9"',
+            'paimon-vindex==0.5.0; python_version>="3.9"',
         ],
         'full-text': [
             'paimon-ftindex==0.1.0; python_version>="3.8"',
diff --git a/paimon-vector/pom.xml b/paimon-vector/pom.xml
index 4bedd7a8a4..058c0c934d 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.4.0</paimon-vector-index-java.version>
+        
<paimon-vector-index-java.version>0.5.0</paimon-vector-index-java.version>
     </properties>
 
     <dependencies>
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 0f28479fa0..ad11f4bf16 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
@@ -48,32 +48,9 @@ public abstract class NativeVectorGlobalIndexerFactory 
implements GlobalIndexerF
     static Map<String, String> nativeOptions(
             DataType fieldType, Options tableOptions, String identifier, 
String fieldName) {
         Map<String, String> nativeOptions = new LinkedHashMap<>();
-        String optionPrefix = identifier + ".";
-        String fieldPrefix = "fields." + fieldName + ".";
-        Map<String, String> tableOptionsMap = tableOptions.toMap();
-
-        // First collect index-type level options, e.g. <index-type>.xxx.
-        for (Map.Entry<String, String> entry : tableOptionsMap.entrySet()) {
-            String optionKey = entry.getKey();
-            if (optionKey.startsWith(optionPrefix)) {
-                String nativeKey = 
nativeOptionKey(optionKey.substring(optionPrefix.length()));
-                if (nativeKey != null) {
-                    nativeOptions.put(nativeKey, entry.getValue());
-                }
-            }
-        }
-
-        // Then collect field level options, e.g. fields.<field-name>.xxx, 
which take precedence
-        // over the index-type level options for this field.
-        for (Map.Entry<String, String> entry : tableOptionsMap.entrySet()) {
-            String optionKey = entry.getKey();
-            if (optionKey.startsWith(fieldPrefix)) {
-                String nativeKey = 
nativeOptionKey(optionKey.substring(fieldPrefix.length()));
-                if (nativeKey != null) {
-                    nativeOptions.put(nativeKey, entry.getValue());
-                }
-            }
-        }
+        collectNativeOptions(nativeOptions, tableOptions.toMap(), identifier, 
fieldName, false);
+        collectNativeOptions(
+                nativeOptions, tableOptions.dynamicOptions(), identifier, 
fieldName, true);
 
         nativeOptions.put("index.type", identifier.replace('-', '_'));
         nativeOptions.put(
@@ -83,14 +60,19 @@ public abstract class NativeVectorGlobalIndexerFactory 
implements GlobalIndexerF
     }
 
     static double trainSampleRatio(Options tableOptions, String identifier, 
String fieldName) {
-        Map<String, String> tableOptionsMap = tableOptions.toMap();
+        Map<String, String> source = tableOptions.dynamicOptions();
         String key =
-                resolveFieldOverriddenKey(
-                        tableOptionsMap, identifier, fieldName, 
TRAIN_SAMPLE_RATIO_OPTION);
+                resolveFieldOverriddenKey(source, identifier, fieldName, 
TRAIN_SAMPLE_RATIO_OPTION);
+        if (key == null) {
+            source = tableOptions.toMap();
+            key =
+                    resolveFieldOverriddenKey(
+                            source, identifier, fieldName, 
TRAIN_SAMPLE_RATIO_OPTION);
+        }
         if (key == null) {
             return DEFAULT_TRAIN_SAMPLE_RATIO;
         }
-        String value = tableOptionsMap.get(key);
+        String value = source.get(key);
 
         try {
             double parsed = Double.parseDouble(value.trim());
@@ -138,6 +120,54 @@ public abstract class NativeVectorGlobalIndexerFactory 
implements GlobalIndexerF
         return null;
     }
 
+    private static void collectNativeOptions(
+            Map<String, String> nativeOptions,
+            Map<String, String> options,
+            String identifier,
+            String fieldName,
+            boolean validate) {
+        String optionPrefix = identifier + ".";
+        String fieldPrefix = "fields." + fieldName + ".";
+
+        // Native names have the lowest precedence within each option source.
+        for (Map.Entry<String, String> entry : options.entrySet()) {
+            String nativeKey = nativeOptionKey(entry.getKey());
+            if (entry.getKey().equals(nativeKey) && 
is050BuildOption(nativeKey)) {
+                putNativeOption(
+                        nativeOptions,
+                        entry.getKey(),
+                        entry.getKey(),
+                        entry.getValue(),
+                        identifier,
+                        validate);
+            }
+        }
+        for (Map.Entry<String, String> entry : options.entrySet()) {
+            String optionKey = entry.getKey();
+            if (optionKey.startsWith(optionPrefix)) {
+                putNativeOption(
+                        nativeOptions,
+                        optionKey,
+                        optionKey.substring(optionPrefix.length()),
+                        entry.getValue(),
+                        identifier,
+                        validate);
+            }
+        }
+        for (Map.Entry<String, String> entry : options.entrySet()) {
+            String optionKey = entry.getKey();
+            if (optionKey.startsWith(fieldPrefix)) {
+                putNativeOption(
+                        nativeOptions,
+                        optionKey,
+                        optionKey.substring(fieldPrefix.length()),
+                        entry.getValue(),
+                        identifier,
+                        validate);
+            }
+        }
+    }
+
     private static String nativeOptionKey(String optionKey) {
         switch (optionKey) {
             case "index.dimension":
@@ -148,6 +178,10 @@ public abstract class NativeVectorGlobalIndexerFactory 
implements GlobalIndexerF
                 return "metric";
             case "nlist":
             case "expected-vector-count":
+            case "ivf.coarse-assignment":
+            case "ivf.pq-encoding":
+            case "ivf.train.max-points-per-centroid":
+            case "pq.train.max-points-per-centroid":
             case "pq.m":
             case "pq.code-ratio":
             case "pq.bits":
@@ -191,6 +225,49 @@ public abstract class NativeVectorGlobalIndexerFactory 
implements GlobalIndexerF
         }
     }
 
+    private static void putNativeOption(
+            Map<String, String> nativeOptions,
+            String optionKey,
+            String optionSuffix,
+            String value,
+            String identifier,
+            boolean validate) {
+        String nativeKey = nativeOptionKey(optionSuffix);
+        if (nativeKey == null) {
+            return;
+        }
+        if (is050BuildOption(nativeKey) && !isAllowed050BuildOption(nativeKey, 
identifier)) {
+            if (validate) {
+                throw new IllegalArgumentException(
+                        "Option '"
+                                + optionKey
+                                + "' is not supported for index type '"
+                                + identifier
+                                + "'.");
+            }
+            return;
+        }
+        nativeOptions.put(nativeKey, value);
+    }
+
+    private static boolean is050BuildOption(String key) {
+        return "ivf.coarse-assignment".equals(key)
+                || "ivf.pq-encoding".equals(key)
+                || "ivf.train.max-points-per-centroid".equals(key)
+                || "pq.train.max-points-per-centroid".equals(key);
+    }
+
+    private static boolean isAllowed050BuildOption(String key, String 
identifier) {
+        if ("ivf.pq-encoding".equals(key)) {
+            return 
IvfPqAlgorithmVectorGlobalIndexerFactory.IDENTIFIER.equals(identifier);
+        }
+        if ("pq.train.max-points-per-centroid".equals(key)) {
+            return 
IvfPqAlgorithmVectorGlobalIndexerFactory.IDENTIFIER.equals(identifier)
+                    || 
DiskAnnVectorGlobalIndexerFactory.IDENTIFIER.equals(identifier);
+        }
+        return 
!DiskAnnVectorGlobalIndexerFactory.IDENTIFIER.equals(identifier);
+    }
+
     private static int dimension(
             DataType fieldType, Map<String, String> nativeOptions, String 
identifier) {
         if (fieldType instanceof VectorType) {
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 6c30913491..c0e034b96f 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
@@ -26,6 +26,7 @@ import org.apache.paimon.types.VectorType;
 
 import org.junit.jupiter.api.Test;
 
+import java.util.HashMap;
 import java.util.Map;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -100,12 +101,13 @@ public class NativeVectorGlobalIndexerFactoryTest {
 
     @Test
     public void testNewVectorIndexOptions() {
-        Options options = new Options();
-        options.setString("ivf-rq.rq.bits", "5");
-        options.setString("ivf-rq.max-bytes-per-vector", "96");
-        options.setString("diskann.build-preset", "balanced");
-        options.setString("diskann.pq.code-ratio", "0.0625");
-        options.setString("diskann.raw-vector-encoding", "f16");
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put("ivf-rq.rq.bits", "5");
+        tableOptions.put("ivf-rq.max-bytes-per-vector", "96");
+        tableOptions.put("diskann.build-preset", "balanced");
+        tableOptions.put("diskann.pq.code-ratio", "0.0625");
+        tableOptions.put("diskann.raw-vector-encoding", "f16");
+        Options options = new Options(tableOptions);
 
         Map<String, String> rqOptions =
                 NativeVectorGlobalIndexerFactory.nativeOptions(
@@ -131,6 +133,114 @@ public class NativeVectorGlobalIndexerFactoryTest {
                 .containsEntry("diskann.raw-vector-encoding", "f16");
     }
 
+    @Test
+    public void test050BuildOptions() {
+        Options nativeNames = new Options();
+        nativeNames.setString("ivf.coarse-assignment", "auto");
+        nativeNames.setString("ivf.pq-encoding", "auto");
+        nativeNames.setString("ivf.train.max-points-per-centroid", "32");
+        nativeNames.setString("pq.train.max-points-per-centroid", "64");
+
+        Map<String, String> nativeOptions =
+                NativeVectorGlobalIndexerFactory.nativeOptions(
+                        new ArrayType(new FloatType()),
+                        nativeNames,
+                        IvfPqAlgorithmVectorGlobalIndexerFactory.IDENTIFIER,
+                        "vec");
+        assertThat(nativeOptions)
+                .containsEntry("ivf.coarse-assignment", "auto")
+                .containsEntry("ivf.pq-encoding", "auto")
+                .containsEntry("ivf.train.max-points-per-centroid", "32")
+                .containsEntry("pq.train.max-points-per-centroid", "64");
+
+        Options prefixedNames = new Options();
+        prefixedNames.setString("ivf-pq.ivf.coarse-assignment", "exact");
+        prefixedNames.setString("fields.vec.ivf.pq-encoding", "canonical");
+        prefixedNames.setString("ivf-pq.ivf.train.max-points-per-centroid", 
"16");
+        prefixedNames.setString("fields.vec.pq.train.max-points-per-centroid", 
"48");
+        assertThat(
+                        NativeVectorGlobalIndexerFactory.nativeOptions(
+                                new ArrayType(new FloatType()),
+                                prefixedNames,
+                                
IvfPqAlgorithmVectorGlobalIndexerFactory.IDENTIFIER,
+                                "vec"))
+                .containsEntry("ivf.coarse-assignment", "exact")
+                .containsEntry("ivf.pq-encoding", "canonical")
+                .containsEntry("ivf.train.max-points-per-centroid", "16")
+                .containsEntry("pq.train.max-points-per-centroid", "48");
+
+        Options diskAnn = new Options();
+        diskAnn.setString("diskann.pq.train.max-points-per-centroid", "24");
+        assertThat(
+                        NativeVectorGlobalIndexerFactory.nativeOptions(
+                                new ArrayType(new FloatType()),
+                                diskAnn,
+                                DiskAnnVectorGlobalIndexerFactory.IDENTIFIER,
+                                "vec"))
+                .containsEntry("pq.train.max-points-per-centroid", "24");
+
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put("fields.vec.ivf.pq-encoding", "auto");
+        Map<String, String> userOptions = new HashMap<>();
+        userOptions.put("ivf.pq-encoding", "canonical");
+        assertThat(
+                        NativeVectorGlobalIndexerFactory.nativeOptions(
+                                new ArrayType(new FloatType()),
+                                new Options(
+                                        new HashMap<>(), new 
Options(tableOptions, userOptions)),
+                                
IvfPqAlgorithmVectorGlobalIndexerFactory.IDENTIFIER,
+                                "vec"))
+                .containsEntry("ivf.pq-encoding", "canonical");
+        assertThat(
+                        NativeVectorGlobalIndexerFactory.nativeOptions(
+                                new ArrayType(new FloatType()),
+                                new Options(tableOptions),
+                                IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
+                                "vec"))
+                .doesNotContainKey("ivf.pq-encoding");
+    }
+
+    @Test
+    public void testRejectsInapplicable050BuildOptions() {
+        Options mutableOptions = new Options();
+        mutableOptions.setString("ivf-flat.ivf.pq-encoding", "canonical");
+        assertThatThrownBy(
+                        () ->
+                                NativeVectorGlobalIndexerFactory.nativeOptions(
+                                        new ArrayType(new FloatType()),
+                                        mutableOptions,
+                                        
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
+                                        "vec"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("ivf-flat.ivf.pq-encoding");
+
+        Map<String, String> flatUserOptions = new HashMap<>();
+        flatUserOptions.put("ivf-flat.ivf.pq-encoding", "canonical");
+        Options flatOptions = new Options(new HashMap<>(), flatUserOptions);
+        assertThatThrownBy(
+                        () ->
+                                NativeVectorGlobalIndexerFactory.nativeOptions(
+                                        new ArrayType(new FloatType()),
+                                        flatOptions,
+                                        
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
+                                        "vec"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("ivf-flat.ivf.pq-encoding");
+
+        Map<String, String> diskAnnUserOptions = new HashMap<>();
+        diskAnnUserOptions.put("diskann.ivf.coarse-assignment", "exact");
+        Options diskAnnOptions = new Options(new HashMap<>(), 
diskAnnUserOptions);
+        assertThatThrownBy(
+                        () ->
+                                NativeVectorGlobalIndexerFactory.nativeOptions(
+                                        new ArrayType(new FloatType()),
+                                        diskAnnOptions,
+                                        
DiskAnnVectorGlobalIndexerFactory.IDENTIFIER,
+                                        "vec"))
+                .isInstanceOf(IllegalArgumentException.class)
+                .hasMessageContaining("diskann.ivf.coarse-assignment");
+    }
+
     @Test
     public void testNativeOptionsUsesVectorTypeDimension() {
         Options options = new Options();
@@ -290,6 +400,17 @@ public class NativeVectorGlobalIndexerFactoryTest {
                         NativeVectorGlobalIndexerFactory.trainSampleRatio(
                                 options, 
IvfFlatVectorGlobalIndexerFactory.IDENTIFIER, "other"))
                 .isEqualTo(0.25);
+
+        Map<String, String> tableOptions = new HashMap<>();
+        tableOptions.put("fields.vec.train.sample-ratio", "0.75");
+        Map<String, String> userOptions = new HashMap<>();
+        userOptions.put("ivf-flat.train.sample-ratio", "0.5");
+        assertThat(
+                        NativeVectorGlobalIndexerFactory.trainSampleRatio(
+                                new Options(tableOptions, userOptions),
+                                IvfFlatVectorGlobalIndexerFactory.IDENTIFIER,
+                                "vec"))
+                .isEqualTo(0.5);
     }
 
     @Test

Reply via email to