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 1871cc0383 [python] Use native primary-key full-text search (#10586)
1871cc0383 is described below
commit 1871cc0383079410e09dd0f457f47517c5132584
Author: Jingsong Lee <[email protected]>
AuthorDate: Thu Oct 15 18:43:55 2026 +0800
[python] Use native primary-key full-text search (#10586)
---
.../table/source/native_full_text_search.py | 17 +-
.../pypaimon/table/source/native_vector_search.py | 19 +-
.../table/source/primary_key_scored_result.py | 25 ++-
.../tests/native_pk_full_text_search_test.py | 192 +++++++++++++++++++++
.../pypaimon/tests/native_vector_search_test.py | 4 +-
.../tests/primary_key_index_definitions_test.py | 19 +-
6 files changed, 248 insertions(+), 28 deletions(-)
diff --git a/paimon-python/pypaimon/table/source/native_full_text_search.py
b/paimon-python/pypaimon/table/source/native_full_text_search.py
index 97a1484352..3665c11ebc 100644
--- a/paimon-python/pypaimon/table/source/native_full_text_search.py
+++ b/paimon-python/pypaimon/table/source/native_full_text_search.py
@@ -18,10 +18,12 @@
"""Local REST full-text search delegated to Rust core."""
from pypaimon.catalog.table_query_auth import reject_search_under_query_auth
+from pypaimon.common.options.core_options import GlobalIndexSearchMode
from pypaimon.globalindex.full_text.native_full_text_global_index_reader
import NativeFullTextIndexOptions
from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult
from pypaimon.read.native_plan import _native_table, _predicate_to_native,
native_method_available
from pypaimon.table.file_store_table import FileStoreTable
+from pypaimon.table.source.primary_key_scored_result import
PrimaryKeyScoredResult
from pypaimon.write.native_commit import _rest_catalog_supported
@@ -31,13 +33,17 @@ def try_native_full_text_search(builder):
reject_search_under_query_auth(table)
if (type(table) is not FileStoreTable
or not table.options.native_read_enabled()
- or not table.options.data_evolution_enabled()
- or not table.options.row_tracking_enabled()
- or table.is_primary_key_table
or table.options.file_format() != 'parquet'
or not native_method_available('Table',
'new_full_text_search_builder')
or not _rest_catalog_supported(table)):
return None
+ primary_key = builder._primary_key_full_text_definition() is not None
+ if not primary_key and (not table.options.data_evolution_enabled()
+ or not table.options.row_tracking_enabled()
+ or table.is_primary_key_table):
+ return None
+ if primary_key and table.options.full_text_index_search_mode() !=
GlobalIndexSearchMode.FAST:
+ raise NotImplementedError('Primary-key full-text search only supports
the FAST full-text index search mode')
if builder._limit is None or builder._limit <= 0:
raise ValueError('Limit must be positive, set via with_limit()')
# The full-text adapter accepts typed lists and booleans. Reuse its mapping
@@ -58,4 +64,7 @@ def try_native_full_text_search(builder):
# be represented by the strict typed predicate FFI.
return None
# Once execution starts, propagate errors instead of running a second
search.
- return DictBasedScoredIndexResult(native.execute_local().row_ids())
+ result = native.execute_local()
+ if primary_key:
+ return PrimaryKeyScoredResult._from_native(table, result)
+ return DictBasedScoredIndexResult(result.row_ids())
diff --git a/paimon-python/pypaimon/table/source/native_vector_search.py
b/paimon-python/pypaimon/table/source/native_vector_search.py
index b41d8f0ca7..b0522e4f2d 100644
--- a/paimon-python/pypaimon/table/source/native_vector_search.py
+++ b/paimon-python/pypaimon/table/source/native_vector_search.py
@@ -20,11 +20,9 @@
from pypaimon.catalog.table_query_auth import reject_search_under_query_auth
from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult
from pypaimon.read.native_plan import (
- _catalog_metastore, _native_table, _partition_fields, _predicate_to_native,
+ _catalog_metastore, _native_table, _predicate_to_native,
native_method_available)
-from pypaimon.read.split_serializer import deserialize_split_v1
-from pypaimon.table.source.primary_key_scored_result import (
- PrimaryKeyScoredResult, PrimaryKeySearchPosition)
+from pypaimon.table.source.primary_key_scored_result import
PrimaryKeyScoredResult
def try_native_vector_search(builder, queries, batch=False):
@@ -75,16 +73,5 @@ def try_native_vector_search(builder, queries, batch=False):
native.with_query_vector(queries)
result = native.execute_local()
if builder._is_primary_key_vector_search():
- return _NativePrimaryKeyScoredResult(table, result)
+ return PrimaryKeyScoredResult._from_native(table, result)
return DictBasedScoredIndexResult(result.row_ids())
-
-
-class _NativePrimaryKeyScoredResult(PrimaryKeyScoredResult):
- """Metadata view of the physical selections already built by Rust core."""
-
- def __init__(self, table, result):
- self._snapshot_id = result.snapshot_id() or 0
- self._positions = tuple(PrimaryKeySearchPosition(*position) for
position in result.positions())
- self._splits = tuple(deserialize_split_v1(
- split, _partition_fields(table), table.trimmed_primary_keys_fields)
- for split in result.splits())
diff --git a/paimon-python/pypaimon/table/source/primary_key_scored_result.py
b/paimon-python/pypaimon/table/source/primary_key_scored_result.py
index c0e1c2fe83..73c370f297 100644
--- a/paimon-python/pypaimon/table/source/primary_key_scored_result.py
+++ b/paimon-python/pypaimon/table/source/primary_key_scored_result.py
@@ -69,6 +69,20 @@ class PrimaryKeyScoredResult(GlobalIndexSplitResult):
def splits(self):
return self._splits
+ @classmethod
+ def _from_native(cls, table, result):
+ """Convert the selections already built by core without rebuilding
them."""
+ from pypaimon.read.native_plan import _partition_fields
+ from pypaimon.read.split_serializer import deserialize_split_v1
+
+ value = cls.__new__(cls)
+ value._snapshot_id = result.snapshot_id() or 0
+ value._positions = tuple(PrimaryKeySearchPosition(*position) for
position in result.positions())
+ value._splits = tuple(deserialize_split_v1(
+ split, _partition_fields(table), table.trimmed_primary_keys_fields)
+ for split in result.splits())
+ return value
+
def _materialize(source_splits, positions, snapshot_id):
sources = {}
@@ -121,11 +135,12 @@ def _ranges(positions):
def _partition_bytes(partition):
- to_bytes = getattr(partition, "to_bytes", None)
- if callable(to_bytes):
- return bytes(to_bytes())
- values = getattr(partition, "values", ())
- return repr(tuple(values)).encode("utf-8")
+ from pypaimon.table.row.generic_row import GenericRowSerializer
+
+ # Java PrimaryKeySearchPosition compares serialized BinaryRow partitions.
+ # Reuse the wire encoder for both GenericRow and BinaryRow, including the
+ # empty partition's arity prefix and eight-byte body.
+ return bytes(GenericRowSerializer.to_bytes(partition))
def _position_key(position):
diff --git a/paimon-python/pypaimon/tests/native_pk_full_text_search_test.py
b/paimon-python/pypaimon/tests/native_pk_full_text_search_test.py
new file mode 100644
index 0000000000..8617989848
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_pk_full_text_search_test.py
@@ -0,0 +1,192 @@
+# 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.
+
+"""Java primary-key full-text files searched and read through Rust core."""
+
+import shutil
+from contextlib import ExitStack
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from pypaimon import Schema
+from pypaimon.api.api_response import ErrorResponse
+from pypaimon.read.native_plan import _native_table, _predicate_to_native
+from pypaimon.table.source.full_text_search_builder import
FullTextSearchBuilderImpl
+from pypaimon.table.source.primary_key_scored_result import
PrimaryKeyScoredResult
+from pypaimon.tests import native_vector_search_test as vectors
+from pypaimon.tests.vector_search_filter_test import match_query
+
+rest_catalog = vectors.rest_catalog
+golden_catalog = vectors.golden_catalog
+pytestmark = pytest.mark.native_plan
+
+
[email protected]
+def source(rest_catalog, golden_catalog):
+ catalog, server = rest_catalog
+ golden = golden_catalog.get_table('default.test_pk_full_text_golden')
+ schema = golden.table_schema
+ catalog.create_table('default.pk_text', Schema(
+ schema.fields, schema.partition_keys, schema.primary_keys,
schema.options), False)
+ destination = Path(server.data_path) / server.warehouse / 'default' /
'pk_text'
+ for directory in ('bucket-0', 'index', 'manifest', 'snapshot'):
+ shutil.copytree(str(Path(golden.table_path) / directory),
str(destination / directory))
+ return catalog.get_table('default.pk_text'), server
+
+
+def _builder(table, text='Paimon', limit=10):
+ return table.new_full_text_search_builder().with_query('content',
match_query(text)).with_limit(limit)
+
+
+def _native(table, text='Paimon', limit=10):
+ return (_native_table(table).new_full_text_search_builder()
+ .with_query('content', match_query(text)).with_limit(limit))
+
+
+def _compare(table, text='Paimon', limit=10):
+ classic = table.copy({'read.native.enabled': 'false',
'scan.native-plan.enabled': 'false'})
+ native = table.copy({'read.native.enabled': 'true',
'scan.native-plan.enabled': 'true'})
+ expected = _builder(classic, text, limit).execute_local()
+ with patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
+ side_effect=AssertionError('Python full-text search
ran')):
+ actual = _builder(native, text, limit).execute_local()
+ assert isinstance(actual, PrimaryKeyScoredResult)
+ assert actual.snapshot_id == expected.snapshot_id
+ assert [(p.partition_bytes, p.bucket, p.data_file_name, p.row_position)
for p in actual.positions] == [
+ (p.partition_bytes, p.bucket, p.data_file_name, p.row_position) for p
in expected.positions]
+ assert [p.score for p in actual.positions] == pytest.approx([p.score for p
in expected.positions], abs=1e-6)
+ assert len(actual.splits) == len(expected.splits)
+ for actual_split, expected_split in zip(actual.splits, expected.splits):
+ assert len(actual_split.files) == 1
+ assert actual_split.row_ranges() == expected_split.row_ranges()
+ assert actual_split.scores() == pytest.approx(expected_split.scores(),
abs=1e-6)
+ rows = vectors._rows(native, actual)
+ assert rows.to_pylist() == vectors._rows(classic, expected).to_pylist()
+ return rows
+
+
[email protected]('snapshot', [None, 1, 5, 8])
[email protected]('text', ['Paimon', 'Flink', 'no match'])
[email protected]('limit', [1, 10])
+def test_java_pk_archives_preserve_positions_scores_and_snapshot(source,
snapshot, text, limit):
+ table, _ = source
+ if snapshot is not None:
+ table = table.copy({'scan.snapshot-id': str(snapshot)})
+ rows = _compare(table, text, limit)
+ if snapshot is None and text == 'Paimon' and limit == 10:
+ assert sorted(rows.column('id').to_pylist()) == [1, 3]
+
+
[email protected]('projection', [['id'], ['content', 'id'], []])
+def
test_core_result_keeps_scored_selections_and_materializes_only_the_projection(source,
projection):
+ table, _ = source
+ result = _native(table).execute_local()
+ assert result.snapshot_id() ==
table.snapshot_manager().get_latest_snapshot().id
+ with pytest.raises(Exception, match='physical file positions'):
+ result.row_ids()
+ positions = result.positions()
+ assert positions and len(positions) == len(result)
+ reader = result.new_read_builder().with_projection(projection)
+ batches = list(reader.read())
+ assert sum(batch.num_rows for batch in batches) == len(result)
+ assert all(batch.schema.names == projection + ['__paimon_search_score']
for batch in batches)
+ scores = [score for batch in batches for score in
batch.column('__paimon_search_score').to_pylist()]
+ assert scores == pytest.approx([p[4] for p in positions], abs=1e-6)
+ # The original result can be read again without scanning or searching
again.
+ assert sum(batch.num_rows for batch in reader.read()) == len(result)
+
+
+def
test_captured_plan_does_not_resolve_latest_again_and_accepts_another_query(source):
+ table, server = source
+ builder = _native(table)
+ plan = builder.new_full_text_scan().scan()
+ snapshot_id = plan.snapshot_id()
+ expected = _native(table, 'Flink', 1).execute_local().positions()
+ reader = _native(table, 'Flink', 1).new_full_text_read()
+ unavailable = server._mock_response(ErrorResponse('TABLE', 'pk_text',
'snapshot unavailable', 503), 503)
+ with patch.object(server, '_table_snapshot_handle',
return_value=unavailable):
+ result = reader.read_plan(plan)
+ assert result.snapshot_id() == snapshot_id
+ assert result.positions() == expected
+ assert sum(batch.num_rows for batch in
result.new_read_builder().with_projection(['id']).read()) == len(result)
+
+
+def test_search_returns_metadata_without_opening_user_parquet_files(source):
+ table, server = source
+ expected = _native(table).execute_local().positions()
+ files = list((Path(server.data_path) / server.warehouse / 'default' /
'pk_text' / 'bucket-0').glob('*.parquet'))
+ assert expected and files
+ with ExitStack() as restore:
+ for path in files:
+ hidden = path.with_suffix('.held')
+ path.rename(hidden)
+ restore.callback(hidden.rename, path)
+ result = _native(table).execute_local()
+ assert result.positions() == expected
+ with pytest.raises(Exception, match='NotFound|not found|No such
file|does not exist'):
+ result.new_read_builder().with_projection(['id']).read()
+
+
[email protected]('case', ['table', 'filter', 'kind', 'column'])
+def test_pk_plan_cannot_be_read_under_another_context(source, case):
+ table, _ = source
+ plan = _native(table).new_full_text_scan().scan()
+ if case == 'table':
+ catalog = table.catalog_environment.catalog_loader.load()
+ schema = table.table_schema
+ catalog.create_table('default.other_text', Schema(
+ schema.fields, schema.partition_keys, schema.primary_keys,
schema.options), False)
+ reader =
_native(catalog.get_table('default.other_text')).new_full_text_read()
+ with pytest.raises(Exception, match='different table, column or
filter'):
+ reader.read_plan(plan)
+ elif case == 'filter':
+ reader = _native(table)
+
reader.with_filter(_predicate_to_native(table.new_read_builder().new_predicate_builder().equal('id',
1)))
+ with pytest.raises(Exception, match='non-partition filters'):
+ reader.new_full_text_read()
+ elif case == 'kind':
+ changed = table.copy({'pk-full-text.index.columns': ''})
+ reader = _native(changed).new_full_text_read()
+ with pytest.raises(Exception, match='different table, column or
filter'):
+ reader.read_plan(plan)
+ else:
+ reader = (_native_table(table).new_full_text_search_builder()
+ .with_query('id', match_query('Paimon')).with_limit(10))
+ with pytest.raises(Exception, match='character string'):
+ reader.new_full_text_read()
+
+
[email protected]('mode', ['full', 'detail'])
[email protected]('native', [False, True])
+def test_pk_fast_only_contract_is_unchanged(source, mode, native):
+ table, _ = source
+ selected = table.copy({'read.native.enabled': str(native).lower(),
'full-text-index.search-mode': mode})
+ with pytest.raises(NotImplementedError, match='FAST'):
+ _builder(selected).execute_local()
+
+
+def test_native_pk_search_failure_does_not_retry_python(source):
+ table, server = source
+ table = table.copy({'read.native.enabled': 'true'})
+ unavailable = server._mock_response(ErrorResponse('TABLE', 'pk_text',
'snapshot unavailable', 503), 503)
+ with patch.object(server, '_table_snapshot_handle',
return_value=unavailable), \
+ patch.object(FullTextSearchBuilderImpl, 'new_full_text_scan',
+ side_effect=AssertionError('Python search retried')):
+ with pytest.raises(Exception, match='snapshot unavailable'):
+ _builder(table).execute_local()
diff --git a/paimon-python/pypaimon/tests/native_vector_search_test.py
b/paimon-python/pypaimon/tests/native_vector_search_test.py
index 5f078bf2cb..6ecd74e28a 100644
--- a/paimon-python/pypaimon/tests/native_vector_search_test.py
+++ b/paimon-python/pypaimon/tests/native_vector_search_test.py
@@ -203,8 +203,8 @@ def
test_java_pk_index_results_are_physical_selections(pk_vector_table, filtered
result = configure(native).execute_local()
assert isinstance(result, PrimaryKeyScoredResult)
assert result.snapshot_id == expected.snapshot_id
- assert [(p.bucket, p.data_file_name, p.row_position) for p in
result.positions] == [
- (p.bucket, p.data_file_name, p.row_position) for p in
expected.positions]
+ assert [(p.partition_bytes, p.bucket, p.data_file_name, p.row_position)
for p in result.positions] == [
+ (p.partition_bytes, p.bucket, p.data_file_name, p.row_position) for p
in expected.positions]
assert [p.score for p in result.positions] == pytest.approx([p.score for p
in expected.positions])
rows = _rows(native, result)
assert sorted(rows.column('id').to_pylist()) == sorted(_rows(classic,
expected).column('id').to_pylist())
diff --git a/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
b/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
index 1f22cd7d65..7e345f7094 100644
--- a/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
+++ b/paimon-python/pypaimon/tests/primary_key_index_definitions_test.py
@@ -173,7 +173,8 @@ class PrimaryKeyIndexDefinitionsTest(unittest.TestCase):
min_sequence_number=0, max_sequence_number=0,
schema_id=0, level=1, extra_files=[])
source = DataSplit([data_file], partition, 3)
- partition_bytes = repr(tuple(partition.values)).encode("utf-8")
+ from pypaimon.table.row.generic_row import GenericRowSerializer
+ partition_bytes = GenericRowSerializer.to_bytes(partition)
result = PrimaryKeyScoredResult(7, [source], [
PrimaryKeySearchPosition(partition_bytes, 3, "data.parquet", 1,
0.9),
@@ -187,6 +188,22 @@ class PrimaryKeyIndexDefinitionsTest(unittest.TestCase):
[(r.from_, r.to) for r in
result.splits[0].row_ranges()])
self.assertEqual([0.9, 0.8, 0.7], result.splits[0].scores())
+ def test_search_position_partitions_use_java_binary_row_bytes(self):
+ from pypaimon.table.row.binary_row import BinaryRow
+ from pypaimon.table.row.generic_row import GenericRowSerializer
+ from pypaimon.table.source.primary_key_scored_result import
_partition_bytes
+
+ self.assertEqual(bytes(12), _partition_bytes(GenericRow([], [])))
+ fields = [DataField(0, 'pt', AtomicType('INT'))]
+ for value in (None, 2, 10, -1):
+ generic = GenericRow([value], fields)
+ encoded = GenericRowSerializer.to_bytes(generic)
+ self.assertEqual(encoded, _partition_bytes(generic))
+ self.assertEqual(encoded, _partition_bytes(BinaryRow(encoded,
fields)))
+ # BinaryRow ordering is the Java physical-position tie breaker.
+ self.assertLess(_partition_bytes(GenericRow([2], fields)),
+ _partition_bytes(GenericRow([10], fields)))
+
def test_pk_vector_filter_is_applied_before_top_k(self):
split = SimpleNamespace(row_ranges_by_file={
"first.parquet": (Range(1, 2),),