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 a34c8a1ff8 [python] Support vindex global index build (#8398)
a34c8a1ff8 is described below

commit a34c8a1ff81939e7d5db301695f2e9a51968729e
Author: Jingsong Lee <[email protected]>
AuthorDate: Tue Jun 30 23:09:57 2026 +0800

    [python] Support vindex global index build (#8398)
    
    Builds on #8393 to add PyPaimon support for building paimon-vindex
    global vector indexes from Python, with behavior aligned to the Java
    default global index builder. Also updates the global index docs with
    SQL/Python SDK TabItem examples for BTree and vector index lifecycle
    APIs.
---
 docs/docs/multimodal-table/global-index.mdx        |   5 +-
 docs/docs/multimodal-table/global-index/btree.mdx  |  75 +++++
 docs/docs/multimodal-table/global-index/vector.mdx | 130 +++++++++
 .../pypaimon/common/options/core_options.py        |  10 +
 .../pypaimon/globalindex/create_global_index.py    | 275 +++++++++++++++++-
 .../pypaimon/globalindex/vindex/__init__.py        |  16 +-
 .../vindex/vindex_vector_index_writer.py           | 300 ++++++++++++++++++++
 .../pypaimon/tests/global_index_build_test.py      | 306 ++++++++++++++++++++-
 8 files changed, 1102 insertions(+), 15 deletions(-)

diff --git a/docs/docs/multimodal-table/global-index.mdx 
b/docs/docs/multimodal-table/global-index.mdx
index a163390e0b..6ee0882707 100644
--- a/docs/docs/multimodal-table/global-index.mdx
+++ b/docs/docs/multimodal-table/global-index.mdx
@@ -157,8 +157,9 @@ added_files = table.create_global_index(
 
 </Tabs>
 
-PyPaimon global index build currently supports single-column BTree indexes on
-tables with row tracking enabled.
+PyPaimon global index build currently supports single-column BTree indexes and
+single-column paimon-vindex IVF vector indexes on tables with row tracking
+enabled.
 
 Drop index files:
 
diff --git a/docs/docs/multimodal-table/global-index/btree.mdx 
b/docs/docs/multimodal-table/global-index/btree.mdx
index ee50db0aed..afc953ebe5 100644
--- a/docs/docs/multimodal-table/global-index/btree.mdx
+++ b/docs/docs/multimodal-table/global-index/btree.mdx
@@ -48,6 +48,10 @@ For keyword-style text retrieval, use [Full-Text 
Index](./full-text) instead.
 
 ## Build BTree Index
 
+<Tabs groupId="btree-build">
+
+<TabItem value="sql" label="SQL">
+
 ```sql
 -- Create BTree index on 'name' column
 CALL sys.create_global_index(
@@ -68,6 +72,77 @@ CALL sys.create_global_index(
 );
 ```
 
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+table = catalog.get_table("db.my_table")
+
+# Create BTree index on 'name' column.
+added_files = table.create_global_index("name", index_type="btree")
+print(added_files)
+```
+
+The API returns the number of committed index files. You can pass BTree build
+options and restrict the build to selected partitions:
+
+```python
+added_files = table.create_global_index(
+    "name",
+    index_type="btree",
+    partitions=[{"dt": "2026-06-18"}, {"dt": "2026-06-19"}],
+    options={"sorted-index.records-per-range": "10000000"},
+)
+print(added_files)
+```
+
+</TabItem>
+
+</Tabs>
+
+## Drop BTree Index
+
+<Tabs groupId="btree-drop">
+
+<TabItem value="sql" label="SQL">
+
+```sql
+CALL sys.drop_global_index(
+    table => 'db.my_table',
+    index_column => 'name',
+    index_type => 'btree'
+);
+```
+
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+table = catalog.get_table("db.my_table")
+
+dropped_files = table.drop_global_index("name", index_type="btree")
+print(dropped_files)
+```
+
+You can also restrict the drop to selected partitions, or count matched files
+without committing:
+
+```python
+matched_files = table.drop_global_index(
+    "name",
+    index_type="btree",
+    partitions=[{"dt": "2026-06-18"}, {"dt": "2026-06-19"}],
+    dry_run=True,
+)
+print(matched_files)
+```
+
+</TabItem>
+
+</Tabs>
+
 ## BTree Options
 
 | Option | Default | Description |
diff --git a/docs/docs/multimodal-table/global-index/vector.mdx 
b/docs/docs/multimodal-table/global-index/vector.mdx
index 1fa560645e..9e25b8f983 100644
--- a/docs/docs/multimodal-table/global-index/vector.mdx
+++ b/docs/docs/multimodal-table/global-index/vector.mdx
@@ -52,6 +52,10 @@ Choose the index type based on the trade-off you want:
 
 ## Build Vector Index
 
+<Tabs groupId="vector-build">
+
+<TabItem value="sql" label="SQL">
+
 ```sql
 -- Create IVF-PQ vector index on 'embedding' column
 CALL sys.create_global_index(
@@ -70,6 +74,52 @@ CALL sys.create_global_index(
 );
 ```
 
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+table = catalog.get_table("db.my_table")
+
+# Create IVF-PQ vector index on 'embedding' column.
+added_files = table.create_global_index(
+    "embedding",
+    index_type="ivf-pq",
+    options={
+        "ivf-pq.dimension": "768",
+        "ivf-pq.distance.metric": "cosine",
+        "ivf-pq.nlist": "256",
+        "ivf-pq.pq.m": "16",
+    },
+)
+print(added_files)
+```
+
+You can also build only selected partitions or tune the row-id shard size used
+by non-sorted global indexes:
+
+```python
+added_files = table.create_global_index(
+    "embedding",
+    index_type="ivf-flat",
+    partitions=[{"dt": "2026-06-18"}, {"dt": "2026-06-19"}],
+    options={
+        "ivf-flat.dimension": "768",
+        "global-index.row-count-per-shard": "100000",
+    },
+)
+print(added_files)
+```
+
+PyPaimon vector index build currently supports the paimon-vindex IVF 
identifiers:
+`ivf-flat`, `ivf-pq`, `ivf-hnsw-flat`, and `ivf-hnsw-sq`. Use SQL to build
+Lumina indexes. Install `paimon-vindex` or `pypaimon[vindex]` before building
+or querying IVF vector indexes from Python.
+
+</TabItem>
+
+</Tabs>
+
 Use `index_type => 'lumina'` for new Lumina indexes. The legacy 
`lumina-vector-ann` identifier is
 kept only so existing tables can still load old indexes.
 
@@ -78,6 +128,48 @@ IVF indexes or `lumina.index.dimension` for Lumina indexes. 
For `VECTOR<FLOAT>`
 the dimension from the column type. When `lumina.index.dimension` is 
explicitly set for a
 `VECTOR<FLOAT>` column, it must match the vector type length.
 
+## Drop Vector Index
+
+<Tabs groupId="vector-drop">
+
+<TabItem value="sql" label="SQL">
+
+```sql
+CALL sys.drop_global_index(
+    table => 'db.my_table',
+    index_column => 'embedding',
+    index_type => 'ivf-pq'
+);
+```
+
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+table = catalog.get_table("db.my_table")
+
+dropped_files = table.drop_global_index("embedding", index_type="ivf-pq")
+print(dropped_files)
+```
+
+You can also restrict the drop to selected partitions, or count matched files
+without committing:
+
+```python
+matched_files = table.drop_global_index(
+    "embedding",
+    index_type="ivf-pq",
+    partitions=[{"dt": "2026-06-18"}, {"dt": "2026-06-19"}],
+    dry_run=True,
+)
+print(matched_files)
+```
+
+</TabItem>
+
+</Tabs>
+
 Supported IVF vector index options:
 
 | Option | Default | Description |
@@ -123,6 +215,10 @@ as `<field-name>`. Field-level vector options do not 
include the index-type pref
 use `fields.image_embedding.nlist` to override the shared `ivf-pq.nlist` 
option for
 `image_embedding`:
 
+<Tabs groupId="vector-per-field-options">
+
+<TabItem value="sql" label="SQL">
+
 ```sql
 CREATE TABLE my_table (
     id INT,
@@ -142,6 +238,40 @@ CREATE TABLE my_table (
 );
 ```
 
+</TabItem>
+
+<TabItem value="python-sdk" label="Python SDK">
+
+```python
+import pyarrow as pa
+
+from pypaimon import Schema
+
+schema = Schema.from_pyarrow_schema(
+    pa.schema([
+        pa.field("id", pa.int32()),
+        pa.field("title_embedding", pa.list_(pa.float32())),
+        pa.field("image_embedding", pa.list_(pa.float32())),
+    ]),
+    options={
+        "bucket": "-1",
+        "row-tracking.enabled": "true",
+        "data-evolution.enabled": "true",
+        "global-index.enabled": "true",
+        "fields.title_embedding.dimension": "768",
+        "fields.image_embedding.dimension": "512",
+        "ivf-pq.nlist": "256",
+        "fields.image_embedding.nlist": "512",
+    },
+)
+
+catalog.create_table("db.my_table", schema, ignore_if_exists=False)
+```
+
+</TabItem>
+
+</Tabs>
+
 With the properties above, `title_embedding` is indexed with `nlist=256` while 
`image_embedding`
 uses `nlist=512`.
 
diff --git a/paimon-python/pypaimon/common/options/core_options.py 
b/paimon-python/pypaimon/common/options/core_options.py
index 9ae32c187b..d76785198b 100644
--- a/paimon-python/pypaimon/common/options/core_options.py
+++ b/paimon-python/pypaimon/common/options/core_options.py
@@ -667,6 +667,13 @@ class CoreOptions:
         )
     )
 
+    GLOBAL_INDEX_ROW_COUNT_PER_SHARD: ConfigOption[int] = (
+        ConfigOptions.key("global-index.row-count-per-shard")
+        .long_type()
+        .default_value(100000)
+        .with_description("Row count per shard for global index.")
+    )
+
     GLOBAL_INDEX_COLUMN_UPDATE_ACTION: 
ConfigOption[GlobalIndexColumnUpdateAction] = (
         ConfigOptions.key("global-index.column-update-action")
         .enum_type(GlobalIndexColumnUpdateAction)
@@ -1197,6 +1204,9 @@ class CoreOptions:
     def global_index_thread_num(self) -> Optional[int]:
         return self.options.get(CoreOptions.GLOBAL_INDEX_THREAD_NUM)
 
+    def global_index_row_count_per_shard(self) -> int:
+        return self.options.get(CoreOptions.GLOBAL_INDEX_ROW_COUNT_PER_SHARD)
+
     def btree_index_fallback_scan_max_size(self) -> int:
         return self.options.get(
             CoreOptions.BTREE_INDEX_FALLBACK_SCAN_MAX_SIZE
diff --git a/paimon-python/pypaimon/globalindex/create_global_index.py 
b/paimon-python/pypaimon/globalindex/create_global_index.py
index 3aa3bb919f..b1c577005b 100644
--- a/paimon-python/pypaimon/globalindex/create_global_index.py
+++ b/paimon-python/pypaimon/globalindex/create_global_index.py
@@ -31,6 +31,12 @@ from pypaimon.globalindex.btree.btree_index_writer import (
 )
 from pypaimon.globalindex.global_index_meta import GlobalIndexMeta
 from pypaimon.globalindex.key_serializer import create_serializer
+from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import (
+    VINDEX_IDENTIFIERS,
+)
+from pypaimon.globalindex.vindex.vindex_vector_index_writer import (
+    VindexVectorIndexWriter,
+)
 from pypaimon.index.index_file_meta import IndexFileMeta
 from pypaimon.manifest.index_manifest_entry import IndexManifestEntry
 from pypaimon.read.split import DataSplit
@@ -76,7 +82,7 @@ def create_global_index(
 
 
 class GlobalIndexBuilder:
-    """Small Python builder for sorted global indexes."""
+    """Small Python builder for global indexes."""
 
     def __init__(
         self,
@@ -95,15 +101,15 @@ class GlobalIndexBuilder:
         self._options = _merged_options(table, options)
         self._core_options = CoreOptions(self._options)
 
-        if self._index_type != BTREE_IDENTIFIER:
+        if self._index_type != BTREE_IDENTIFIER and self._index_type not in 
VINDEX_IDENTIFIERS:
             raise ValueError(
-                "Python global index build currently supports only '%s', got 
'%s'."
-                % (BTREE_IDENTIFIER, index_type)
+                "Python global index build currently supports '%s' and %s, got 
'%s'."
+                % (BTREE_IDENTIFIER, VINDEX_IDENTIFIERS, index_type)
             )
         if len(self._index_columns) != 1:
             raise ValueError(
-                "Python '%s' global index build currently supports one column, 
got %s."
-                % (BTREE_IDENTIFIER, self._index_columns)
+                "Python global index build currently supports one column, got 
%s."
+                % self._index_columns
             )
         if not self._table.options.row_tracking_enabled():
             raise ValueError(
@@ -116,6 +122,8 @@ class GlobalIndexBuilder:
                     "Column '%s' does not exist in table '%s'."
                     % (column, self._table.identifier)
                 )
+        if self._index_type in VINDEX_IDENTIFIERS:
+            self._validate_vindex_table()
 
     def build(self) -> List[CommitMessage]:
         read_builder = self._table.new_read_builder()
@@ -129,11 +137,11 @@ class GlobalIndexBuilder:
             return []
 
         index_field = self._table.field_dict[self._index_columns[0]]
-        key_serializer = create_serializer(index_field.type)
-        block_size = self._core_options.btree_index_block_size()
-        records_per_range = self._core_options.sorted_index_records_per_range()
-        if records_per_range <= 0:
-            raise ValueError("sorted-index.records-per-range must be 
positive.")
+        if self._index_type in VINDEX_IDENTIFIERS:
+            splits = _filter_non_indexable_splits(
+                self._table, splits, self._index_columns)
+            if not splits:
+                return []
 
         read_type = [index_field, SpecialFields.ROW_ID]
         from pypaimon.read.table_read import TableRead
@@ -146,6 +154,19 @@ class GlobalIndexBuilder:
         index_path_factory = 
self._table.path_factory().global_index_path_factory()
         index_path = index_path_factory.global_index_root_path()
 
+        if self._index_type == BTREE_IDENTIFIER:
+            return self._build_btree(splits, index_field, table_read, 
index_path)
+        return self._build_vindex(splits, index_field, table_read, index_path)
+
+    def _build_btree(
+        self, splits, index_field, table_read, index_path: str
+    ) -> List[CommitMessage]:
+        key_serializer = create_serializer(index_field.type)
+        block_size = self._core_options.btree_index_block_size()
+        records_per_range = self._core_options.sorted_index_records_per_range()
+        if records_per_range <= 0:
+            raise ValueError("sorted-index.records-per-range must be 
positive.")
+
         messages = []
         for split in _split_by_contiguous_row_range(splits):
             row_range = _calc_row_range(split)
@@ -192,6 +213,77 @@ class GlobalIndexBuilder:
                 )
         return messages
 
+    def _build_vindex(
+        self, splits, index_field, table_read, index_path: str
+    ) -> List[CommitMessage]:
+        rows_per_shard = self._core_options.global_index_row_count_per_shard()
+        if rows_per_shard <= 0:
+            raise ValueError(
+                "Option 'global-index.row-count-per-shard' must be greater 
than 0."
+            )
+
+        messages = []
+        for split, row_range in _split_by_global_index_shard(
+            splits, rows_per_shard
+        ):
+            table = table_read.to_arrow([split])
+            if table is None or table.num_rows == 0:
+                continue
+
+            writer = VindexVectorIndexWriter(
+                self._table.file_io,
+                index_path,
+                index_field.type,
+                self._index_type,
+                self._options.to_map(),
+                index_field.name,
+            )
+            try:
+                for vector, row_id in _extract_vector_rows(
+                    table,
+                    self._index_columns[0],
+                    SpecialFields.ROW_ID.name,
+                    row_range,
+                ):
+                    writer.write(vector, row_id - row_range.from_)
+
+                index_adds = _to_index_manifest_entries(
+                    self._table,
+                    split.partition,
+                    row_range,
+                    index_field.id,
+                    self._index_type,
+                    writer.finish(),
+                )
+            finally:
+                writer.close()
+            if index_adds:
+                messages.append(
+                    CommitMessage(
+                        partition=tuple(split.partition.values),
+                        bucket=0,
+                        new_files=[],
+                        index_adds=index_adds,
+                    )
+                )
+        return messages
+
+    def _validate_vindex_table(self) -> None:
+        bucket = self._core_options.bucket()
+        if bucket != -1:
+            raise ValueError(
+                "Generic global index only supports unaware-bucket tables "
+                "(bucket = -1), but table '%s' has bucket = %s."
+                % (self._table.identifier, bucket)
+            )
+        if self._core_options.deletion_vectors_enabled():
+            raise ValueError(
+                "Generic global index does not support tables with deletion "
+                "vectors enabled. Table '%s' has "
+                "'deletion-vectors.enabled' = true."
+                % self._table.identifier
+            )
+
     def _resolve_partition_filter(self, read_builder) -> Optional[Predicate]:
         if self._partition_filter is not None:
             return self._partition_filter
@@ -259,6 +351,148 @@ def _split_by_contiguous_row_range(splits):
     return result
 
 
+def _filter_non_indexable_splits(table, splits, index_columns):
+    boundary = _find_min_non_indexable_row_id(
+        table.schema_manager,
+        [file for split in splits for file in split.files],
+        index_columns,
+    )
+    if boundary is None:
+        return splits
+
+    result = []
+    for split in splits:
+        files = [
+            file for file in split.files
+            if file.row_id_range() is not None
+            and file.row_id_range().from_ < boundary
+        ]
+        if files:
+            result.append(_copy_split_with_files(split, files))
+    return result
+
+
+def _find_min_non_indexable_row_id(schema_manager, files, index_columns):
+    schema_contains_columns = {}
+    index_column_set = set(index_columns)
+    boundary = None
+    for file in files:
+        row_range = file.row_id_range()
+        if row_range is None:
+            continue
+
+        schema_id = file.schema_id
+        if schema_id not in schema_contains_columns:
+            schema = schema_manager.get_schema(schema_id)
+            schema_field_names = {field.name for field in schema.fields}
+            schema_contains_columns[schema_id] = (
+                index_column_set.issubset(schema_field_names)
+            )
+        if not schema_contains_columns[schema_id]:
+            if boundary is None or row_range.from_ < boundary:
+                boundary = row_range.from_
+    return boundary
+
+
+def _split_by_global_index_shard(splits, rows_per_shard):
+    if rows_per_shard <= 0:
+        raise ValueError(
+            "Option 'global-index.row-count-per-shard' must be greater than 0."
+        )
+
+    groups = {}
+    for split in splits:
+        key = (_partition_key(split.partition), split.bucket)
+        if key not in groups:
+            groups[key] = {
+                "partition": split.partition,
+                "bucket": split.bucket,
+                "files": [],
+            }
+        for file in split.files:
+            if file.row_id_range() is None:
+                continue
+            groups[key]["files"].append(file)
+
+    result = []
+    for group in groups.values():
+        files_by_shard = {}
+        for file in group["files"]:
+            file_range = file.row_id_range()
+            start_shard = file_range.from_ // rows_per_shard
+            end_shard = file_range.to // rows_per_shard
+            for shard_id in range(start_shard, end_shard + 1):
+                shard_start = shard_id * rows_per_shard
+                files_by_shard.setdefault(shard_start, []).append(file)
+
+        for shard_start in sorted(files_by_shard):
+            shard_end = shard_start + rows_per_shard - 1
+            shard_files = sorted(
+                files_by_shard[shard_start],
+                key=lambda file: file.row_id_range().from_,
+            )
+            current_group = []
+            current_group_end = None
+            for file in shard_files:
+                file_range = file.row_id_range()
+                if not current_group:
+                    current_group.append(file)
+                    current_group_end = file_range.to
+                elif file_range.from_ <= current_group_end + 1:
+                    current_group.append(file)
+                    current_group_end = max(current_group_end, file_range.to)
+                else:
+                    _append_shard_split(
+                        result,
+                        current_group,
+                        shard_start,
+                        shard_end,
+                        group["partition"],
+                        group["bucket"],
+                    )
+                    current_group = [file]
+                    current_group_end = file_range.to
+
+            if current_group:
+                _append_shard_split(
+                    result,
+                    current_group,
+                    shard_start,
+                    shard_end,
+                    group["partition"],
+                    group["bucket"],
+                )
+
+    return result
+
+
+def _partition_key(partition):
+    values = getattr(partition, "values", None)
+    return tuple(values) if values is not None else partition
+
+
+def _append_shard_split(
+    result,
+    files,
+    shard_start,
+    shard_end,
+    partition,
+    bucket,
+):
+    group_start = min(file.row_id_range().from_ for file in files)
+    group_end = max(file.row_id_range().to for file in files)
+    row_range = Range(max(group_start, shard_start), min(group_end, shard_end))
+    result.append((
+        DataSplit(
+            files=list(files),
+            partition=partition,
+            bucket=bucket,
+            raw_convertible=False,
+        ),
+        row_range,
+    ))
+
+
 def _split_one_by_contiguous_row_range(split):
     for file in split.files:
         if file.row_id_range() is None:
@@ -342,6 +576,25 @@ def _extract_sorted_rows(
     return sorted(rows, key=cmp_to_key(compare))
 
 
+def _extract_vector_rows(
+    table: pa.Table,
+    index_column: str,
+    row_id_column: str,
+    row_range: Optional[Range] = None,
+):
+    vectors = table.column(index_column).to_pylist()
+    row_ids = table.column(row_id_column).to_pylist()
+    rows = []
+    for vector, row_id in zip(vectors, row_ids):
+        if row_id is None:
+            raise ValueError("Cannot build global index because _ROW_ID is 
null.")
+        row_id = int(row_id)
+        if row_range is not None and not row_range.contains(row_id):
+            continue
+        rows.append((vector, row_id))
+    return rows
+
+
 def _chunks(rows, size):
     for start in range(0, len(rows), size):
         yield rows[start:start + size]
diff --git a/paimon-python/pypaimon/globalindex/vindex/__init__.py 
b/paimon-python/pypaimon/globalindex/vindex/__init__.py
index 76373df86c..2ef60be3b8 100644
--- a/paimon-python/pypaimon/globalindex/vindex/__init__.py
+++ b/paimon-python/pypaimon/globalindex/vindex/__init__.py
@@ -15,4 +15,18 @@
 # specific language governing permissions and limitations
 # under the License.
 
-"""paimon-vindex based global index readers."""
+"""paimon-vindex based global index readers and writers."""
+
+from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import (
+    VINDEX_IDENTIFIERS,
+    VindexVectorGlobalIndexReader,
+)
+from pypaimon.globalindex.vindex.vindex_vector_index_writer import (
+    VindexVectorIndexWriter,
+)
+
+__all__ = [
+    'VINDEX_IDENTIFIERS',
+    'VindexVectorGlobalIndexReader',
+    'VindexVectorIndexWriter',
+]
diff --git 
a/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py 
b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py
new file mode 100644
index 0000000000..2a6ed8670e
--- /dev/null
+++ b/paimon-python/pypaimon/globalindex/vindex/vindex_vector_index_writer.py
@@ -0,0 +1,300 @@
+# 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.
+
+"""paimon-vindex global index writer."""
+
+import math
+import os
+import tempfile
+import uuid
+from array import array
+from typing import BinaryIO, Dict, List, Mapping, Optional
+
+from pypaimon.globalindex.result_entry import ResultEntry
+from pypaimon.schema.data_types import ArrayType, AtomicType, DataType, 
VectorType
+
+
+FILE_NAME_PREFIX = "vector"
+DEFAULT_DIMENSION = 128
+ADD_BATCH_SIZE = 10000
+
+
+class VindexVectorIndexWriter:
+    """Writer for one paimon-vindex global index file."""
+
+    def __init__(
+        self,
+        file_io,
+        index_path: str,
+        data_type: DataType,
+        index_type: str,
+        options: Mapping[str, object],
+        field_name: str,
+    ):
+        self.file_name = (
+            "%s-%s-global-index-%s.index"
+            % (FILE_NAME_PREFIX, index_type, uuid.uuid4())
+        )
+        self._file_io = file_io
+        self._index_path = index_path.rstrip('/')
+        self._index_type = index_type
+        self._native_options = native_options(
+            data_type, options, index_type, field_name)
+        self._dimension = int(self._native_options["dimension"])
+        self._row_count = 0
+        self._vector_count = 0
+        self._row_id_temp: Optional[BinaryIO] = None
+        self._vector_temp: Optional[BinaryIO] = None
+        self._row_id_temp_path: Optional[str] = None
+        self._vector_temp_path: Optional[str] = None
+        self._closed = False
+
+        validate_vector_type(data_type)
+
+    @property
+    def native_options(self) -> Dict[str, str]:
+        return dict(self._native_options)
+
+    def write(self, vector, relative_row_id: int) -> None:
+        if self._closed:
+            raise RuntimeError("VindexVectorIndexWriter is already closed.")
+
+        self._row_count += 1
+        if vector is None:
+            return
+
+        materialized = _materialize_vector(
+            vector, self._dimension, relative_row_id)
+        self._ensure_temp_files()
+        self._row_id_temp.write(array("q", [int(relative_row_id)]).tobytes())
+        self._vector_temp.write(array("f", materialized).tobytes())
+        self._vector_count += 1
+
+    def finish(self) -> List[ResultEntry]:
+        if self._closed:
+            raise RuntimeError("VindexVectorIndexWriter is already closed.")
+        self._closed = True
+
+        file_path = self._file_path()
+        try:
+            if self._vector_count == 0:
+                return []
+
+            try:
+                import numpy as np
+                from paimon_vindex import VectorIndexWriter
+            except ImportError as e:
+                raise ImportError(
+                    "paimon-vindex is required to build vindex vector indexes. 
"
+                    "Install paimon-vindex==0.1.0 or pypaimon[vindex].") from e
+
+            self._close_temp_files()
+            self._file_io.check_or_mkdirs(self._index_path)
+            vectors = np.fromfile(
+                self._vector_temp_path,
+                dtype=np.float32,
+                count=self._vector_count * self._dimension,
+            ).reshape(self._vector_count, self._dimension)
+            with VectorIndexWriter(self._native_options) as writer:
+                writer.train(vectors)
+                del vectors
+                self._add_vectors_in_batches(np, writer)
+                with self._file_io.new_output_stream(file_path) as 
output_stream:
+                    writer.write(output_stream)
+        except Exception:
+            self._file_io.delete_quietly(file_path)
+            raise
+        finally:
+            self._delete_temp_files()
+
+        return [ResultEntry(self.file_name, self._row_count, b"{}")]
+
+    def _file_path(self) -> str:
+        return "%s/%s" % (self._index_path, self.file_name)
+
+    def close(self) -> None:
+        if not self._closed:
+            self._closed = True
+        self._delete_temp_files()
+
+    def _ensure_temp_files(self) -> None:
+        if self._row_id_temp is not None:
+            return
+
+        row_id_temp = tempfile.NamedTemporaryFile(
+            prefix="paimon-vindex-row-ids-", suffix=".bin", delete=False)
+        vector_temp = tempfile.NamedTemporaryFile(
+            prefix="paimon-vindex-vectors-", suffix=".bin", delete=False)
+        self._row_id_temp = row_id_temp
+        self._vector_temp = vector_temp
+        self._row_id_temp_path = row_id_temp.name
+        self._vector_temp_path = vector_temp.name
+
+    def _close_temp_files(self) -> None:
+        for temp_file in (self._row_id_temp, self._vector_temp):
+            if temp_file is not None and not temp_file.closed:
+                temp_file.flush()
+                temp_file.close()
+        self._row_id_temp = None
+        self._vector_temp = None
+
+    def _delete_temp_files(self) -> None:
+        self._close_temp_files()
+        for path in (self._row_id_temp_path, self._vector_temp_path):
+            if path is not None and os.path.exists(path):
+                try:
+                    os.remove(path)
+                except OSError:
+                    pass
+        self._row_id_temp_path = None
+        self._vector_temp_path = None
+
+    def _add_vectors_in_batches(self, np, writer) -> None:
+        with open(self._row_id_temp_path, "rb") as row_id_file, open(
+            self._vector_temp_path, "rb"
+        ) as vector_file:
+            remaining = self._vector_count
+            while remaining > 0:
+                batch_size = min(ADD_BATCH_SIZE, remaining)
+                row_ids = np.fromfile(
+                    row_id_file, dtype=np.int64, count=batch_size)
+                vectors = np.fromfile(
+                    vector_file,
+                    dtype=np.float32,
+                    count=batch_size * self._dimension,
+                ).reshape(batch_size, self._dimension)
+                writer.add_vectors(row_ids, vectors)
+                remaining -= batch_size
+
+
+def native_options(
+    data_type: DataType,
+    options: Mapping[str, object],
+    index_type: str,
+    field_name: str,
+) -> 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)
+
+    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["index.type"] = index_type.replace('-', '_')
+    result["dimension"] = str(_dimension(data_type, result, index_type))
+    return result
+
+
+def validate_vector_type(data_type: DataType) -> None:
+    if isinstance(data_type, VectorType):
+        element_type = data_type.element
+        if _is_float_type(element_type):
+            return
+        raise ValueError(
+            "Vector index requires float vector, but got: %s" % element_type)
+
+    if isinstance(data_type, ArrayType):
+        element_type = data_type.element
+        if _is_float_type(element_type):
+            return
+        raise ValueError(
+            "Vector index requires float array, but got: %s" % element_type)
+
+    raise ValueError(
+        "Vector index requires VectorType or ArrayType<FLOAT>, but got: %s"
+        % data_type)
+
+
+def _native_option_key(option_key: str) -> Optional[str]:
+    if option_key in ("index.dimension", "dimension"):
+        return "dimension"
+    if option_key in ("distance.metric", "metric"):
+        return "metric"
+    if option_key in (
+        "nlist",
+        "pq.m",
+        "hnsw.m",
+        "hnsw.ef-construction",
+        "hnsw.max-level",
+    ):
+        return option_key
+    if option_key in ("pq.use-opq", "use-opq"):
+        return "use-opq"
+    return None
+
+
+def _dimension(
+    data_type: DataType, native_options_map: Mapping[str, str], index_type: str
+) -> int:
+    if isinstance(data_type, VectorType):
+        return data_type.length
+
+    dimension = native_options_map.get("dimension")
+    value = DEFAULT_DIMENSION if dimension is None else int(dimension)
+    if value <= 0:
+        raise ValueError(
+            "Invalid value for '%s.dimension': %s. Must be a positive integer."
+            % (index_type, value)
+        )
+    return value
+
+
+def _is_float_type(data_type: DataType) -> bool:
+    return (
+        isinstance(data_type, AtomicType)
+        and data_type.type.upper() == "FLOAT"
+    )
+
+
+def _materialize_vector(
+    value, dimension: int, relative_row_id: int
+) -> List[float]:
+    if hasattr(value, "as_py"):
+        value = value.as_py()
+    if hasattr(value, "tolist"):
+        value = value.tolist()
+    if not isinstance(value, (list, tuple)):
+        raise ValueError("Unsupported vector value: %s" % type(value).__name__)
+    if len(value) != dimension:
+        raise ValueError(
+            "Vector dimension mismatch: expected %d, but got %d"
+            % (dimension, len(value))
+        )
+
+    vector = []
+    for index, element in enumerate(value):
+        if element is None:
+            raise ValueError("Vector element at index %d is null" % index)
+        element = float(element)
+        if not math.isfinite(element):
+            raise ValueError(
+                "Vector element at rowId=%d, index=%d is %s"
+                % (relative_row_id, index, element)
+            )
+        vector.append(element)
+    return vector
diff --git a/paimon-python/pypaimon/tests/global_index_build_test.py 
b/paimon-python/pypaimon/tests/global_index_build_test.py
index 15fd3bfd39..00279d01d7 100644
--- a/paimon-python/pypaimon/tests/global_index_build_test.py
+++ b/paimon-python/pypaimon/tests/global_index_build_test.py
@@ -19,13 +19,21 @@ import unittest
 from datetime import date, datetime
 from decimal import Decimal
 import os
+import sys
+import types
 
 import pyarrow as pa
 
 from pypaimon.globalindex.create_global_index import (
+    _filter_non_indexable_splits,
+    _split_by_global_index_shard,
     _split_one_by_contiguous_row_range,
 )
 from pypaimon.globalindex.key_serializer import create_serializer
+from pypaimon.globalindex.vindex.vindex_vector_index_writer import (
+    VindexVectorIndexWriter,
+    native_options,
+)
 from pypaimon.globalindex.global_index_scanner import GlobalIndexScanner
 from pypaimon.index.index_file_handler import IndexFileHandler
 from pypaimon.schema.data_types import ArrayType, AtomicType, RowType
@@ -39,12 +47,15 @@ from pypaimon.utils.range import Range
 
 class _FakeFile:
 
-    def __init__(self, file_name, first_row_id, row_count):
+    def __init__(self, file_name, first_row_id, row_count, schema_id=0):
         self.file_name = file_name
         self.first_row_id = first_row_id
         self.row_count = row_count
+        self.schema_id = schema_id
 
     def row_id_range(self):
+        if self.first_row_id is None:
+            return None
         return Range(self.first_row_id,
                      self.first_row_id + self.row_count - 1)
 
@@ -58,6 +69,52 @@ class _FakeSplit:
         self.raw_convertible = False
 
 
+class _FakeVectorIndexWriter:
+    instances = []
+
+    def __init__(self, options):
+        self.options = dict(options)
+        self.trained = None
+        self.added_ids = None
+        self.added_vectors = None
+        self.closed = False
+        _FakeVectorIndexWriter.instances.append(self)
+
+    def train(self, data):
+        self.trained = data.tolist()
+
+    def add_vectors(self, ids, data):
+        self.added_ids = ids.tolist()
+        self.added_vectors = data.tolist()
+
+    def write(self, file):
+        file.write(b"fake-vindex")
+
+    def close(self):
+        self.closed = True
+
+    def __enter__(self):
+        return self
+
+    def __exit__(self, exc_type, exc_val, exc_tb):
+        self.close()
+        return False
+
+
+class _FakeSchemaManager:
+
+    def __init__(self, fields_by_schema_id):
+        self.fields_by_schema_id = fields_by_schema_id
+
+    def get_schema(self, schema_id):
+        return types.SimpleNamespace(
+            fields=[
+                types.SimpleNamespace(name=name)
+                for name in self.fields_by_schema_id[schema_id]
+            ]
+        )
+
+
 class GlobalIndexBuildTest(
         BatchModeMixin, DataEvolutionTestBase, unittest.TestCase):
 
@@ -245,6 +302,168 @@ class GlobalIndexBuildTest(
         for column in ['flag', 'amount', 'dt', 'ts']:
             self.assertEqual(1, table.create_global_index(column))
 
+    def test_create_vindex_global_index_from_python(self):
+        schema = pa.schema([
+            ('id', pa.int32()),
+            ('embedding', pa.list_(pa.float32())),
+        ])
+        table = self._create_table(pa_schema=schema, 
options=self.table_options)
+        vectors = pa.array(
+            [[1.0, 0.0], [0.0, 1.0], None],
+            type=pa.list_(pa.float32()),
+        )
+        self._write_arrow(table, pa.table(
+            {'id': [1, 2, 3], 'embedding': vectors},
+            schema=schema,
+        ))
+
+        old_module = sys.modules.get("paimon_vindex")
+        sys.modules["paimon_vindex"] = types.SimpleNamespace(
+            VectorIndexWriter=_FakeVectorIndexWriter)
+        _FakeVectorIndexWriter.instances = []
+        try:
+            added = table.create_global_index(
+                'embedding',
+                index_type='ivf-flat',
+                options={
+                    'ivf-flat.dimension': '2',
+                    'ivf-flat.distance.metric': 'l2',
+                    'ivf-flat.nlist': '1',
+                },
+            )
+        finally:
+            if old_module is None:
+                sys.modules.pop("paimon_vindex", None)
+            else:
+                sys.modules["paimon_vindex"] = old_module
+
+        self.assertEqual(1, added)
+        self.assertEqual(1, len(_FakeVectorIndexWriter.instances))
+        fake_writer = _FakeVectorIndexWriter.instances[0]
+        self.assertEqual('ivf_flat', fake_writer.options['index.type'])
+        self.assertEqual('2', fake_writer.options['dimension'])
+        self.assertEqual('l2', fake_writer.options['metric'])
+        self.assertEqual('1', fake_writer.options['nlist'])
+        self.assertEqual([[1.0, 0.0], [0.0, 1.0]], fake_writer.trained)
+        self.assertEqual([0, 1], fake_writer.added_ids)
+        self.assertEqual([[1.0, 0.0], [0.0, 1.0]], fake_writer.added_vectors)
+        self.assertTrue(fake_writer.closed)
+
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        entries = IndexFileHandler(table).scan(snapshot)
+        self.assertEqual(1, len(entries))
+        entry = entries[0]
+        self.assertEqual('ivf-flat', entry.index_file.index_type)
+        self.assertEqual(3, entry.index_file.row_count)
+        self.assertEqual(b'{}', 
bytes(entry.index_file.global_index_meta.index_meta))
+        self.assertTrue(table.file_io.exists(
+            table.path_factory().global_index_path_factory().to_path(
+                entry.index_file.file_name)))
+
+    def test_create_vindex_global_index_respects_row_count_per_shard(self):
+        schema = pa.schema([
+            ('id', pa.int32()),
+            ('embedding', pa.list_(pa.float32())),
+        ])
+        table = self._create_table(pa_schema=schema, 
options=self.table_options)
+        vectors = pa.array(
+            [[1.0, 0.0], [0.0, 1.0], [0.5, 0.5], [0.2, 0.8], [0.9, 0.1]],
+            type=pa.list_(pa.float32()),
+        )
+        self._write_arrow(table, pa.table(
+            {'id': [1, 2, 3, 4, 5], 'embedding': vectors},
+            schema=schema,
+        ))
+
+        old_module = sys.modules.get("paimon_vindex")
+        sys.modules["paimon_vindex"] = types.SimpleNamespace(
+            VectorIndexWriter=_FakeVectorIndexWriter)
+        _FakeVectorIndexWriter.instances = []
+        try:
+            added = table.create_global_index(
+                'embedding',
+                index_type='ivf-flat',
+                options={
+                    'global-index.row-count-per-shard': '2',
+                    'ivf-flat.dimension': '2',
+                },
+            )
+        finally:
+            if old_module is None:
+                sys.modules.pop("paimon_vindex", None)
+            else:
+                sys.modules["paimon_vindex"] = old_module
+
+        self.assertEqual(3, added)
+        self.assertEqual(3, len(_FakeVectorIndexWriter.instances))
+        self.assertEqual(
+            [[0, 1], [0, 1], [0]],
+            [writer.added_ids for writer in _FakeVectorIndexWriter.instances],
+        )
+
+        snapshot = table.snapshot_manager().get_latest_snapshot()
+        entries = sorted(
+            IndexFileHandler(table).scan(snapshot),
+            key=lambda entry: 
entry.index_file.global_index_meta.row_range_start,
+        )
+        self.assertEqual(
+            [(0, 1, 2), (2, 3, 2), (4, 4, 1)],
+            [
+                (
+                    entry.index_file.global_index_meta.row_range_start,
+                    entry.index_file.global_index_meta.row_range_end,
+                    entry.index_file.row_count,
+                )
+                for entry in entries
+            ],
+        )
+
+    def 
test_create_vindex_global_index_rejects_generic_unsupported_tables(self):
+        schema = pa.schema([
+            ('id', pa.int32()),
+            ('embedding', pa.list_(pa.float32())),
+        ])
+        bucket_options = dict(self.table_options)
+        bucket_options['bucket'] = '1'
+        bucket_table = self._create_table(pa_schema=schema, 
options=bucket_options)
+        with self.assertRaisesRegex(ValueError, 'unaware-bucket'):
+            bucket_table.create_global_index(
+                'embedding',
+                index_type='ivf-flat',
+                options={'ivf-flat.dimension': '2'},
+            )
+
+        dv_options = dict(self.table_options)
+        dv_options['deletion-vectors.enabled'] = 'true'
+        dv_table = self._create_table(pa_schema=schema, options=dv_options)
+        with self.assertRaisesRegex(ValueError, 'deletion vectors'):
+            dv_table.create_global_index(
+                'embedding',
+                index_type='ivf-flat',
+                options={'ivf-flat.dimension': '2'},
+            )
+
+    def test_vindex_native_options_follow_java_mapping(self):
+        data_type = ArrayType(True, AtomicType('FLOAT'))
+        options = {
+            'ivf-pq.dimension': '128',
+            'ivf-pq.distance.metric': 'cosine',
+            'ivf-pq.nlist': '256',
+            'ivf-pq.pq.m': '16',
+            'fields.embedding.dimension': '64',
+            'fields.embedding.nlist': '512',
+            'fields.embedding.pq.use-opq': 'true',
+        }
+
+        result = native_options(data_type, options, 'ivf-pq', 'embedding')
+
+        self.assertEqual('ivf_pq', result['index.type'])
+        self.assertEqual('64', result['dimension'])
+        self.assertEqual('cosine', result['metric'])
+        self.assertEqual('512', result['nlist'])
+        self.assertEqual('16', result['pq.m'])
+        self.assertEqual('true', result['use-opq'])
+
     def test_split_by_contiguous_row_range_matches_java_builder(self):
         split = _FakeSplit([
             _FakeFile('a', 0, 2),
@@ -260,6 +479,91 @@ class GlobalIndexBuildTest(
             [[file.file_name for file in s.files] for s in splits],
         )
 
+    def test_split_by_global_index_shard_matches_java_default_builder(self):
+        split = _FakeSplit([
+            _FakeFile('a', 0, 3),
+            _FakeFile('b', 3, 2),
+            _FakeFile('c', 6, 3),
+        ])
+
+        shards = _split_by_global_index_shard([split], 4)
+
+        self.assertEqual(
+            [
+                (['a', 'b'], 0, 3),
+                (['b'], 4, 4),
+                (['c'], 6, 7),
+                (['c'], 8, 8),
+            ],
+            [
+                ([file.file_name for file in shard.files], row_range.from_, 
row_range.to)
+                for shard, row_range in shards
+            ],
+        )
+
+    def test_split_by_global_index_shard_skips_files_without_row_ids(self):
+        split = _FakeSplit([
+            _FakeFile('no-row-id', None, 2),
+            _FakeFile('indexed', 4, 2),
+        ])
+
+        shards = _split_by_global_index_shard([split], 4)
+
+        self.assertEqual(
+            [(['indexed'], 4, 5)],
+            [
+                ([file.file_name for file in shard.files], row_range.from_, 
row_range.to)
+                for shard, row_range in shards
+            ],
+        )
+
+    def test_filter_non_indexable_splits_matches_java_generic_builder(self):
+        split = _FakeSplit([
+            _FakeFile('indexable-before', 0, 2, schema_id=0),
+            _FakeFile('non-indexable', 2, 2, schema_id=1),
+            _FakeFile('indexable-after-boundary', 4, 2, schema_id=0),
+        ])
+        table = types.SimpleNamespace(
+            schema_manager=_FakeSchemaManager({
+                0: ['id', 'embedding'],
+                1: ['id'],
+            })
+        )
+
+        splits = _filter_non_indexable_splits(
+            table, [split], ['embedding'])
+
+        self.assertEqual(1, len(splits))
+        self.assertEqual(
+            ['indexable-before'],
+            [file.file_name for file in splits[0].files],
+        )
+
+    def test_vindex_writer_close_cleans_temp_files(self):
+        schema = pa.schema([
+            ('id', pa.int32()),
+            ('embedding', pa.list_(pa.float32())),
+        ])
+        table = self._create_table(pa_schema=schema, 
options=self.table_options)
+        writer = VindexVectorIndexWriter(
+            table.file_io,
+            
table.path_factory().global_index_path_factory().global_index_root_path(),
+            ArrayType(True, AtomicType('FLOAT')),
+            'ivf-flat',
+            {'ivf-flat.dimension': '2'},
+            'embedding',
+        )
+        writer.write([1.0, 0.0], 0)
+        row_id_temp_path = writer._row_id_temp_path
+        vector_temp_path = writer._vector_temp_path
+        self.assertTrue(os.path.exists(row_id_temp_path))
+        self.assertTrue(os.path.exists(vector_temp_path))
+
+        writer.close()
+
+        self.assertFalse(os.path.exists(row_id_temp_path))
+        self.assertFalse(os.path.exists(vector_temp_path))
+
     def test_java_scalar_key_serializers_round_trip(self):
         cases = [
             ('BOOLEAN', True),


Reply via email to