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 9c9a78670c [Python] Delegate local vindex builds to native Rust 
(#10488)
9c9a78670c is described below

commit 9c9a78670c03c188dd878e5569731b2f52ec14e0
Author: Jingsong Lee <[email protected]>
AuthorDate: Sat Oct 10 17:41:41 2026 +0800

    [Python] Delegate local vindex builds to native Rust (#10488)
---
 .../pypaimon/globalindex/native_index_build.py     |  20 +-
 .../tests/native_generic_index_build_test.py       |  18 --
 .../tests/native_vector_index_build_test.py        | 254 +++++++++++++++++++++
 3 files changed, 271 insertions(+), 21 deletions(-)

diff --git a/paimon-python/pypaimon/globalindex/native_index_build.py 
b/paimon-python/pypaimon/globalindex/native_index_build.py
index 48ecf421e3..ad7e6f404a 100644
--- a/paimon-python/pypaimon/globalindex/native_index_build.py
+++ b/paimon-python/pypaimon/globalindex/native_index_build.py
@@ -29,9 +29,8 @@ def build_native_global_index(builder, partition_filter):
     Build errors propagate: a failed native build must not start a second 
build.
     """
     table = builder._table
-    # The Rust vector pipeline requires dense, non-null vectors. Keep Python
-    # vector construction until it supports Java's sparse relative row IDs.
-    if (builder._index_type not in ('btree', 'bitmap', 'full-text')
+    from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import 
VINDEX_IDENTIFIERS
+    if (builder._index_type not in ('btree', 'bitmap', 'full-text') + 
tuple(VINDEX_IDENTIFIERS)
             or not table.options.native_write_enabled()
             or not table.options.data_evolution_enabled()
             or not table.options.global_index_enabled()
@@ -53,6 +52,21 @@ def build_native_global_index(builder, partition_filter):
         from 
pypaimon.globalindex.full_text.native_full_text_global_index_reader import 
NativeFullTextIndexOptions
         options.update({'full-text.' + key: value for key, value in
                         
NativeFullTextIndexOptions.from_options(builder._options.to_map()).to_native_options().items()})
+    elif builder._index_type in VINDEX_IDENTIFIERS:
+        from pypaimon.globalindex.vindex.vindex_vector_index_writer import 
native_options, train_sample_ratio
+        column = builder._index_columns[0]
+        # Reuse the Java-compatible option mapping; Rust's low-level builder 
also
+        # accepts native aliases which Python's public API deliberately 
ignores.
+        mapped = native_options(table.field_dict[column].type, 
table.options.options.to_map(),
+                                builder._index_type, column, 
builder._user_options)
+        options = {key: value for key, value in options.items()
+                   if key in ('global-index.row-count-per-shard', 
'global-index.build.parallelism',
+                              'vindex.build.granule.enabled')
+                   and (key != 'vindex.build.granule.enabled' or 
builder._index_type != 'diskann')}
+        options.update({'fields.' + column + '.' + key: value for key, value 
in mapped.items()
+                        if key != 'index.type'})
+        options['fields.' + column + '.train.sample-ratio'] = 
str(train_sample_ratio(
+            table.options.options.to_map(), builder._index_type, column, 
builder._user_options))
     native.with_options(options)
     if partition_filter is not None:
         native.with_partition_filter(_predicate_to_native(partition_filter))
diff --git a/paimon-python/pypaimon/tests/native_generic_index_build_test.py 
b/paimon-python/pypaimon/tests/native_generic_index_build_test.py
index b700ae53c5..425f92ee3c 100644
--- a/paimon-python/pypaimon/tests/native_generic_index_build_test.py
+++ b/paimon-python/pypaimon/tests/native_generic_index_build_test.py
@@ -155,24 +155,6 @@ def 
test_python_full_text_build_delegates_to_common_native_entry(rest_catalog):
     index_tests._commit(table, messages)
 
 
-def test_python_nullable_vector_build_keeps_sparse_row_ids(rest_catalog):
-    catalog, _ = rest_catalog
-    schema = pa.schema([('id', pa.int32()), ('name', pa.string()),
-                        ('embedding', pa.list_(pa.float32())), ('pt', 
pa.int32())])
-    table = index_tests._create(catalog, schema=schema)
-    rows = [dict(ROWS[0]), dict(ROWS[1]), dict(ROWS[2])]
-    rows[1]['embedding'] = None
-    index_tests._append(table, rows, schema=schema)
-    with patch.object(GlobalIndexBuilder, '_build_generic_index',
-                      wraps=GlobalIndexBuilder(table, 'embedding', 
index_type='ivf-flat',
-                                               
options=_options('ivf-flat'))._build_generic_index) as build:
-        messages = GlobalIndexBuilder(table, 'embedding', 
index_type='ivf-flat',
-                                      options=_options('ivf-flat')).build()
-    assert build.call_count == 1
-    assert sum(file.row_count for file in 
index_tests._message_files(messages)) == 3
-    index_tests._commit(table, messages)
-
-
 @pytest.mark.parametrize('option_source', ['build', 'table'])
 @pytest.mark.parametrize('stop_words', [['paimon', 'apache'], 'paimon;apache'])
 def test_native_full_text_options_match_python_list_encoding(rest_catalog, 
option_source, stop_words):
diff --git a/paimon-python/pypaimon/tests/native_vector_index_build_test.py 
b/paimon-python/pypaimon/tests/native_vector_index_build_test.py
new file mode 100644
index 0000000000..4e6531150d
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_vector_index_build_test.py
@@ -0,0 +1,254 @@
+# 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.
+
+"""REST vector builds delegated to Rust preserve Java writer semantics."""
+
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
+from pypaimon.globalindex.vindex.vindex_vector_global_index_reader import (
+    PaimonVindexInput, VINDEX_IDENTIFIERS,
+)
+from pypaimon.tests import native_generic_index_build_test as generic
+from pypaimon.tests import native_sorted_index_build_test as indexes
+from pypaimon.read.native_plan import _native_table
+
+rest_catalog = indexes.rest_catalog
+pytestmark = generic.pytestmark
+SCHEMA = pa.schema([('id', pa.int32()), ('embedding', pa.list_(pa.float32())), 
('pt', pa.int32())])
+
+
+def _rows(vectors, start=0, partition=0):
+    return [{'id': start + i, 'embedding': vector, 'pt': partition} for i, 
vector in enumerate(vectors)]
+
+
+def _table(catalog, vectors, options=None, partitioned=False):
+    settings = {'vector-index.search-mode': 'fast'}
+    settings.update(options or {})
+    table = indexes._create(catalog, schema=SCHEMA, options=settings, 
partitioned=partitioned)
+    indexes._append(table, _rows(vectors), schema=SCHEMA)
+    return table
+
+
+def _build(table, kind, options=None, **kwargs):
+    with patch.object(GlobalIndexBuilder, '_build_generic_index',
+                      side_effect=AssertionError('Python vector rows were 
materialized')):
+        return GlobalIndexBuilder(table, 'embedding', index_type=kind, 
options=options, **kwargs).build()
+
+
+def _metadata(table, file):
+    from paimon_vindex import VectorIndexReader
+    with table.file_io.new_input_stream(indexes._path(table, file)) as stream:
+        adapter = PaimonVindexInput(stream)
+        try:
+            with VectorIndexReader(adapter) as reader:
+                return reader.metadata()
+        finally:
+            adapter.close()
+
+
+def _bytes(table, file):
+    with table.file_io.new_input_stream(indexes._path(table, file)) as stream:
+        return stream.read()
+
+
+def _assert_ids(table, expected, kind):
+    # Both readers must observe original IDs rather than a compacted 0..N 
sequence.
+    search_options = {'diskann.l_search': '64'} if kind == 'diskann' else 
{'ivf.nprobe': '64'}
+    classic = table.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    result = 
(classic.new_vector_search_builder().with_vector_column('embedding')
+              .with_query_vector([1., 
1.]).with_limit(64).with_options(search_options).execute_local())
+    assert sorted(result.results()) == expected
+    result = 
(_native_table(table).new_vector_search_builder().with_vector_column('embedding')
+              .with_query_vector([1., 
1.]).with_limit(64).with_options(search_options).execute_local())
+    assert sorted(result.row_ids()) == expected
+
+
[email protected]('kind', VINDEX_IDENTIFIERS)
[email protected]('granule', [False, True])
+def 
test_nullable_vectors_preserve_row_ids_and_logical_cardinality(rest_catalog, 
kind, granule):
+    catalog, _ = rest_catalog
+    vectors = [None, [1., 0.], None, [0., 1.], None, None, [1., 1.], None]
+    table = _table(catalog, vectors)
+    options = generic._options(kind)
+    options[kind + '.train.sample-ratio'] = '0.5'
+    if kind != 'diskann':
+        options['vindex.build.granule.enabled'] = str(granule).lower()
+    before = table.snapshot_manager().get_latest_snapshot().id
+    messages = _build(table, kind, options)
+    files = indexes._message_files(messages)
+    assert len(files) == 1
+    assert files[0].row_count == 8
+    assert files[0].global_index_meta.row_range_start == 0
+    assert files[0].global_index_meta.row_range_end == 7
+    assert _metadata(table, files[0]).total_vectors == 3
+    assert table.snapshot_manager().get_latest_snapshot().id == before
+    indexes._commit(table, messages)
+    _assert_ids(table, [1, 3, 6], kind)
+    assert _build(table, kind, options) == []
+
+
[email protected]('kind', VINDEX_IDENTIFIERS)
+def test_all_null_shards_create_no_file_or_snapshot(rest_catalog, kind):
+    catalog, _ = rest_catalog
+    table = _table(catalog, [None] * 9)
+    before = table.snapshot_manager().get_latest_snapshot().id
+    assert _build(table, kind, generic._options(kind, 4)) == []
+    assert table.create_global_index('embedding', index_type=kind, 
options=generic._options(kind, 4)) == 0
+    assert table.snapshot_manager().get_latest_snapshot().id == before
+    assert indexes._files(table) == []
+    root = 
table.path_factory().global_index_path_factory().global_index_root_path()
+    assert not table.file_io.exists(root) or table.file_io.list_status(root) 
== []
+
+
[email protected]('kind', VINDEX_IDENTIFIERS)
[email protected]('native_commit', [False, True])
+def 
test_empty_shard_does_not_shift_next_shard_or_incremental_ids(rest_catalog, 
kind, native_commit):
+    catalog, _ = rest_catalog
+    table = _table(catalog, [None] * 4 + [None, [1., 0.], None, [0., 1.]],
+                   {'commit.native.enabled': str(native_commit).lower()})
+    options = generic._options(kind, 4)
+    messages = _build(table, kind, options)
+    files = indexes._message_files(messages)
+    assert len(files) == 1
+    assert files[0].row_count == 4
+    assert files[0].global_index_meta.row_range_start == 4
+    assert files[0].global_index_meta.row_range_end == 7
+    indexes._commit(table, messages)
+    _assert_ids(table, [5, 7], kind)
+    indexes._append(table, _rows([None, [1., 1.], None], 8), schema=SCHEMA)
+    messages = _build(table, kind, options)
+    files = indexes._message_files(messages)
+    assert len(files) == 1
+    assert files[0].global_index_meta.row_range_start == 8
+    assert files[0].row_count == 3
+    indexes._commit(table, messages)
+    _assert_ids(table, [5, 7, 9], kind)
+
+
[email protected]('kind', VINDEX_IDENTIFIERS)
+def test_sparse_full_spill_bytes_match_python_writer(rest_catalog, kind):
+    catalog, _ = rest_catalog
+    vectors = [None if i % 5 == 0 else [float(i % 7), float(i // 7)] for i in 
range(97)]
+    table = _table(catalog, vectors, {'read.batch-size': '7'})
+    options = generic._options(kind)
+    options[kind + '.train.sample-ratio'] = ' 0.37 '
+    if kind != 'diskann':
+        options['vindex.build.granule.enabled'] = 'false'
+    native = _build(table, kind, options)
+    classic = GlobalIndexBuilder(table.copy({'write.native.enabled': 
'false'}), 'embedding',
+                                 index_type=kind, options=options).build()
+    native_files, classic_files = indexes._message_files(native), 
indexes._message_files(classic)
+    assert len(native_files) == len(classic_files) == 1
+    assert native_files[0].row_count == classic_files[0].row_count == 
len(vectors)
+    assert _bytes(table, native_files[0]) == _bytes(table, classic_files[0])
+
+
[email protected]('kind', VINDEX_IDENTIFIERS)
[email protected]('budget', [None, 4096])
+def 
test_default_training_config_matches_python_without_fixed_nlist_or_pq_m(rest_catalog,
 kind, budget):
+    catalog, _ = rest_catalog
+    table = _table(catalog, [None, [1., 0.], None, [0., 1.], [1., 1.]])
+    options = {kind + '.dimension': '2'}
+    if budget is not None:
+        options[kind + '.max-bytes-per-vector'] = str(budget)
+    native = indexes._message_files(_build(table, kind, options))
+    classic_builder = GlobalIndexBuilder(table.copy({'write.native.enabled': 
'false'}),
+                                         'embedding', index_type=kind, 
options=options)
+    classic = indexes._message_files(classic_builder.build())
+    assert _bytes(table, native[0]) == _bytes(table, classic[0])
+    assert _metadata(table, native[0]).total_vectors == 3
+
+
[email protected]('kind', ['ivf-flat', 'ivf-pq'])
+def test_field_aliases_and_build_options_override_table_options(rest_catalog, 
kind):
+    catalog, _ = rest_catalog
+    table = _table(catalog, [None, [1., 0.], [0., 1.]],
+                   {kind + '.dimension': '4', 
'fields.embedding.distance.metric': 'inner_product'})
+    options = {kind + '.index.dimension': '2', 'fields.embedding.metric': 'l2',
+               kind + '.expected-vector-count': '32', 'ivf.coarse-assignment': 
'exact',
+               'global-index.build.parallelism': ' 2 ', 'metric': 'invalid', 
'dimension': '999',
+               kind + '.ignored-option': 'ignored',
+               'fields.embedding.ivf.train.max-points-per-centroid': '16'}
+    if kind == 'ivf-pq':
+        options.update({'fields.embedding.pq.code-ratio': '0.25', kind + 
'.ivf.pq-encoding': 'canonical',
+                        kind + '.use-opq': 'true'})
+    native = indexes._message_files(_build(table, kind, options))
+    classic_builder = GlobalIndexBuilder(table.copy({'write.native.enabled': 
'false'}),
+                                         'embedding', index_type=kind, 
options=options)
+    classic = indexes._message_files(classic_builder.build())
+    assert _bytes(table, native[0]) == _bytes(table, classic[0])
+    meta = _metadata(table, native[0])
+    assert meta.dimension == 2
+    assert meta.metric == 'l2'
+
+
[email protected]('vector', [[1.], [1., None]])
+def test_invalid_non_null_vectors_fail_without_python_retry(rest_catalog, 
vector):
+    catalog, _ = rest_catalog
+    table = _table(catalog, [[1., 0.], None, vector])
+    before = table.snapshot_manager().get_latest_snapshot().id
+    with pytest.raises(Exception, match='dimension|null'):
+        _build(table, 'ivf-flat', generic._options('ivf-flat', 1))
+    assert table.snapshot_manager().get_latest_snapshot().id == before
+    assert indexes._files(table) == []
+    root = 
table.path_factory().global_index_path_factory().global_index_root_path()
+    assert not table.file_io.exists(root) or table.file_io.list_status(root) 
== []
+
+
[email protected]('value', ['0', '-1', 'invalid'])
+def 
test_invalid_build_parallelism_is_rejected_before_writing_files(rest_catalog, 
value):
+    catalog, _ = rest_catalog
+    table = _table(catalog, [[1., 0.]])
+    options = generic._options('ivf-flat')
+    options['global-index.build.parallelism'] = value
+    before = table.snapshot_manager().get_latest_snapshot().id
+    with pytest.raises(Exception, match='parallelism'):
+        _build(table, 'ivf-flat', options)
+    assert table.snapshot_manager().get_latest_snapshot().id == before
+    assert indexes._files(table) == []
+
+
[email protected]('kind', VINDEX_IDENTIFIERS)
+def test_all_null_build_does_not_instantiate_native_training(rest_catalog, 
kind):
+    catalog, _ = rest_catalog
+    table = _table(catalog, [None] * 4)
+    options = {kind + '.dimension': '2', kind + '.distance.metric': 'invalid'}
+    if kind != 'diskann':
+        options[kind + '.nlist'] = '0'
+    assert _build(table, kind, options) == []
+    classic = table.copy({'write.native.enabled': 'false'})
+    assert GlobalIndexBuilder(classic, 'embedding', index_type=kind, 
options=options).build() == []
+    assert indexes._files(table) == []
+
+
+def test_automatic_pq_budget_uses_real_non_null_cardinality(rest_catalog):
+    catalog, _ = rest_catalog
+    vectors = [None if i % 3 == 0 else [float(i % 7), float(i // 7)] for i in 
range(1024)]
+    table = _table(catalog, vectors)
+    options = {'ivf-pq.dimension': '2', 'ivf-pq.max-bytes-per-vector': '64',
+               'global-index.row-count-per-shard': '2048'}
+    native = indexes._message_files(_build(table, 'ivf-pq', options))
+    classic_table = table.copy({'write.native.enabled': 'false'})
+    classic_builder = GlobalIndexBuilder(classic_table, 'embedding', 
index_type='ivf-pq', options=options)
+    classic = indexes._message_files(classic_builder.build())
+    assert _bytes(table, native[0]) == _bytes(table, classic[0])
+    assert native[0].row_count == 1024
+    assert _metadata(table, native[0]).total_vectors == 682

Reply via email to