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 305fb00010 [python] Execute local hybrid search in Rust (#10496)
305fb00010 is described below

commit 305fb000104ee28fd8992c84754fa63c96efee76
Author: Jingsong Lee <[email protected]>
AuthorDate: Sun Oct 11 08:41:22 2026 +0800

    [python] Execute local hybrid search in Rust (#10496)
---
 .../pypaimon/table/source/hybrid_search_builder.py |  10 +-
 .../pypaimon/table/source/native_hybrid_search.py  |  69 +++++
 .../pypaimon/tests/native_hybrid_search_test.py    | 337 +++++++++++++++++++++
 3 files changed, 415 insertions(+), 1 deletion(-)

diff --git a/paimon-python/pypaimon/table/source/hybrid_search_builder.py 
b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
index 42914c2f27..5fbd1232a4 100644
--- a/paimon-python/pypaimon/table/source/hybrid_search_builder.py
+++ b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
@@ -174,7 +174,7 @@ class HybridSearchBuilder(ABC):
 
     @abstractmethod
     def with_filter(self, predicate) -> 'HybridSearchBuilder':
-        """Scalar predicate used to pre-filter vector routes."""
+        """Scalar predicate used to pre-filter every route before Top-K."""
         pass
 
     @abstractmethod
@@ -278,6 +278,14 @@ class HybridSearchBuilderImpl(HybridSearchBuilder):
         self._filter = None
         self._partition_filter = None
 
+    def execute_local(self) -> ScoredGlobalIndexResult:
+        from pypaimon.table.source.native_hybrid_search import 
try_native_hybrid_search
+
+        result = try_native_hybrid_search(self)
+        if result is not None:
+            return result
+        return super().execute_local()
+
     def with_limit(self, limit: int) -> 'HybridSearchBuilder':
         self._limit = limit
         return self
diff --git a/paimon-python/pypaimon/table/source/native_hybrid_search.py 
b/paimon-python/pypaimon/table/source/native_hybrid_search.py
new file mode 100644
index 0000000000..a19391628f
--- /dev/null
+++ b/paimon-python/pypaimon/table/source/native_hybrid_search.py
@@ -0,0 +1,69 @@
+# 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.
+
+"""Transport an existing local hybrid query to one Rust core operation."""
+
+from pypaimon.catalog.table_query_auth import reject_search_under_query_auth
+from pypaimon.common.json_util import JSON
+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.write.native_commit import _rest_catalog_supported
+
+
+def try_native_hybrid_search(builder):
+    """Qualify before execution; core owns one snapshot, routes and fusion."""
+    table = builder._table
+    if type(table) is not FileStoreTable:
+        return None
+    reject_search_under_query_auth(table)
+    if (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_hybrid_search_builder')
+            or not _rest_catalog_supported(table)):
+        return None
+    builder._validate_search()
+    if any(route.is_full_text() for route in builder._routes):
+        # Preserve the same typed analyzer normalization used by native 
full-text.
+        options = {'full-text.' + key: value for key, value in 
NativeFullTextIndexOptions.from_options(
+            table.options.options.to_map()).to_native_options().items()}
+        table = table.copy(options)
+    native = _native_table(table).new_hybrid_search_builder()
+    if hasattr(table, '_read_snapshot'):
+        snapshot = table._read_snapshot
+        native.with_snapshot(JSON.to_json(snapshot) if snapshot is not None 
else None)
+    native.with_limit(builder._limit).with_ranker(builder._ranker)
+    for route in builder._routes:
+        if route.is_vector():
+            native.add_vector_route(route.field_name, route.vector, 
route.limit, route.weight, route.options)
+        else:
+            native.add_full_text_route(
+                route.field_name, route.full_text_query, route.limit, 
route.weight, route.options)
+    try:
+        if builder._partition_filter is not None:
+            
native.with_partition_filter(_predicate_to_native(builder._partition_filter))
+        if builder._filter is not None:
+            native.with_filter(_predicate_to_native(builder._filter))
+    except (ValueError, NotImplementedError):
+        # Qualify strict literal support before executing any route or ranking.
+        return None
+    # Propagate execution failures: retrying Python can select a newer 
snapshot.
+    return DictBasedScoredIndexResult(native.execute_local().row_ids())
diff --git a/paimon-python/pypaimon/tests/native_hybrid_search_test.py 
b/paimon-python/pypaimon/tests/native_hybrid_search_test.py
new file mode 100644
index 0000000000..a5bbabfe47
--- /dev/null
+++ b/paimon-python/pypaimon/tests/native_hybrid_search_test.py
@@ -0,0 +1,337 @@
+# 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.
+
+"""Whole-operation native hybrid execution against REST, vector and text 
indexes."""
+
+from contextlib import contextmanager
+from unittest.mock import patch
+
+import pyarrow as pa
+import pytest
+
+from pypaimon.api.api_response import ErrorResponse, GetTableSnapshotResponse
+from pypaimon.multimodal.table import MultimodalTable, text_route, vector_route
+from pypaimon.read.native_plan import _native_table, native_method_available, 
_predicate_to_native
+from pypaimon.snapshot.table_snapshot import TableSnapshot
+from pypaimon.snapshot.time_travel_util import TimeTravelUtil
+from pypaimon.table.source.hybrid_search_builder import HybridSearchBuilderImpl
+from pypaimon.tests import native_sorted_index_build_test as indexes
+from pypaimon.tests.native_full_text_search_test import _scores
+from pypaimon.tests.vector_search_filter_test import match_query, boost_query
+
+rest_catalog = indexes.rest_catalog
+pytestmark = [pytest.mark.native_plan, pytest.mark.skipif(
+    not native_method_available('Table', 'new_hybrid_search_builder'), 
reason='Rust hybrid API required')]
+SCHEMA = pa.schema([('id', pa.int32()), ('content', pa.string()),
+                    ('embedding', pa.list_(pa.float32())), ('label', 
pa.string()), ('pt', pa.int32())])
+
+
+def _rows(start=0, count=8):
+    return [{'id': i, 'content': None if i == 3 else ('paimon lake' if i % 2 
else 'paimon paimon paimon'),
+             'embedding': None if i == 3 else [float(i), 0.],
+             'label': 'keep' if i % 2 else 'drop', 'pt': i // 4} for i in 
range(start, start + count)]
+
+
+def _table(catalog, options=None, indexed=True, partial=False):
+    settings = {'read.native.enabled': 'true', 'vector-index.search-mode': 
'full',
+                'full-text-index.search-mode': 'full', 
'scalar-index.search-mode': 'full'}
+    settings.update(options or {})
+    table = indexes._create(catalog, schema=SCHEMA, partitioned=True, 
options=settings)
+    indexes._append(table, _rows(), schema=SCHEMA)
+    if indexed:
+        table.create_global_index('content', index_type='full-text')
+        table.create_global_index('embedding', index_type='ivf-flat', options={
+            'ivf-flat.dimension': '2', 'ivf-flat.nlist': '1', 
'ivf-flat.distance.metric': 'l2'})
+    if partial:
+        indexes._append(table, _rows(8, 4), schema=SCHEMA)
+    return table
+
+
+def _builder(table, ranker='rrf', limit=16, route_limit=16, routes='mixed', 
predicate=None, partition=None):
+    builder = 
table.new_hybrid_search_builder().with_limit(limit).with_ranker(ranker)
+    if routes in ('mixed', 'vector', 'many'):
+        builder.add_vector_route('embedding', [0., 0.], route_limit, 
weight=1.25,
+                                 options={'ivf.nprobe': '1'})
+    if routes in ('mixed', 'text', 'many'):
+        builder.add_full_text_route('content', match_query('paimon'), 
route_limit, weight=0.5)
+    if routes == 'many':
+        # Exceed the worker bound, with distinct route limits, weights and 
queries.
+        for i in range(5):
+            builder.add_vector_route('embedding', [float(i + 1), 0.], i + 1, 
weight=float(i + 1))
+    if predicate is not None:
+        builder.with_filter(predicate)
+    if partition is not None:
+        builder.with_partition_filter(partition)
+    return builder
+
+
+@contextmanager
+def _core_only():
+    # Block Python routing AND fusion: individual native routes alone do not 
satisfy this contract.
+    with patch.object(HybridSearchBuilderImpl, 'route_builders', 
side_effect=AssertionError('Python routing')), \
+            patch.object(HybridSearchBuilderImpl, 'rank', 
side_effect=AssertionError('Python fusion')):
+        yield
+
+
+def _compare(table, **kwargs):
+    classic = table.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    expected = _scores(_builder(classic, **kwargs).execute_local())
+    with _core_only():
+        actual = _scores(_builder(table, **kwargs).execute_local())
+    assert set(actual) == set(expected)
+    assert actual == pytest.approx(expected, abs=1e-6, rel=1e-6)
+    return actual
+
+
[email protected]('routes', ['vector', 'text', 'mixed', 'many'])
[email protected]('ranker', ['rrf', 'weighted_score', 'mrr'])
[email protected]('limit', [2, 16])
+def test_routes_weights_limits_and_fusion_match_existing_python(rest_catalog, 
routes, ranker, limit):
+    catalog, _ = rest_catalog
+    table = _table(catalog)
+    scores = _compare(table, ranker=ranker, routes=routes, limit=limit, 
route_limit=4)
+    assert len(scores) <= limit
+    assert scores
+    assert indexes._ids(table) == list(range(8))
+
+
[email protected]('mode', ['fast', 'full', 'detail'])
[email protected]('scalar_index', [None, 'btree', 'bitmap'])
+def test_partial_coverage_and_scalar_modes_apply_to_each_route(rest_catalog, 
mode, scalar_index):
+    catalog, _ = rest_catalog
+    table = _table(catalog, {'vector-index.search-mode': mode, 
'full-text-index.search-mode': mode,
+                             'scalar-index.search-mode': mode})
+    if scalar_index is not None:
+        table.create_global_index('label', index_type=scalar_index)
+    indexes._append(table, _rows(8, 4), schema=SCHEMA)
+    predicate = 
table.new_read_builder().new_predicate_builder().equal('label', 'keep')
+    scores = _compare(table, predicate=predicate)
+    # Vector FAST evaluates raw rows when no scalar index can supply a mask;
+    # full-text FAST excludes them. Hybrid preserves each route's behavior.
+    assert set(scores) == ({1, 5, 7} if mode == 'fast' else {1, 5, 7, 9, 11})
+    assert indexes._ids(table) == list(range(12))
+
+
[email protected]('index_type', ['btree', 'bitmap'])
[email protected]('refine', [False, True])
+def test_candidate_refinement_uses_java_semantics_in_both_routes(rest_catalog, 
index_type, refine):
+    catalog, _ = rest_catalog
+    table = _table(catalog, {'global-index.filter.refine-from-data': 
str(refine).lower()})
+    table.create_global_index('label', index_type=index_type)
+    indexes._append(table, _rows(8, 4), schema=SCHEMA)
+    pb = table.new_read_builder().new_predicate_builder()
+    predicate = pb.contains('label', 'eep')
+    assert set(_compare(table, predicate=predicate)) == ({1, 5, 7, 9, 11} if 
refine else {9, 11})
+    assert indexes._ids(table) == list(range(12))
+
+
+def 
test_accumulated_data_and_partition_filters_precede_route_top_k(rest_catalog):
+    catalog, _ = rest_catalog
+    table = _table(catalog)
+    pb = table.new_read_builder().new_predicate_builder()
+
+    def configure(table):
+        return (_builder(table, route_limit=1).with_filter(pb.equal('label', 
'keep'))
+                .with_filter(pb.and_predicates([pb.equal('pt', 1), 
pb.greater_than('id', 4)]))
+                .with_partition_filter(pb.less_than('pt', 
2)).with_filter(pb.less_than('id', 6)))
+
+    classic = table.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    expected = _scores(configure(classic).execute_local())
+    with _core_only():
+        actual = _scores(configure(table).execute_local())
+    assert actual == pytest.approx({5: 1.75 / 61.}, abs=1e-6)
+    assert actual == pytest.approx(expected, abs=1e-6)
+    # A second partition predicate accumulates rather than replacing the first.
+    with _core_only():
+        assert _scores(configure(table).with_partition_filter(pb.equal('pt', 
0)).execute_local()) == {}
+    assert indexes._ids(table) == list(range(8))
+
+
[email protected]('compound', ['and', 'or'])
+def test_compound_filters_and_empty_partition_results(rest_catalog, compound):
+    catalog, _ = rest_catalog
+    table = _table(catalog)
+    pb = table.new_read_builder().new_predicate_builder()
+    parts = [pb.equal('pt', 1), pb.equal('label', 'keep')]
+    predicate = pb.and_predicates(parts) if compound == 'and' else 
pb.or_predicates(parts)
+    assert set(_compare(table, predicate=predicate)) == ({5, 7} if compound == 
'and' else {1, 4, 5, 6, 7})
+    assert _compare(table, partition=pb.equal('pt', 99)) == {}
+    assert indexes._ids(table) == list(range(8))
+
+
[email protected]('mode', ['full', 'detail'])
+def test_raw_routes_and_boost_dsl_with_typed_analyzers(rest_catalog, mode):
+    catalog, _ = rest_catalog
+    table = _table(catalog, {'full-text-index.search-mode': mode, 
'full-text.tokenizer': ' NGRAM ',
+                             'full-text.ngram-min-size': ' 2 ', 
'full-text.ngram-max-size': ' 3 ',
+                             'full-text.ngram-prefix-only': ' TRUE ', 
'full-text.lowercase': ' TRUE '},
+                   indexed=False)
+    table.create_global_index('content', index_type='full-text')
+    indexes._append(table, _rows(8, 4), schema=SCHEMA)
+
+    def builder(table):
+        return 
(table.new_hybrid_search_builder().add_full_text_route('content', boost_query(
+            match_query('PAIMON'), match_query('LAKE'), 0.1), 
8).with_limit(8).with_weighted_score_ranker())
+
+    expected = _scores(builder(table.copy({'read.native.enabled': 'false',
+                                          'scan.native-plan.enabled': 
'false'})).execute_local())
+    assert expected and set(expected).intersection({8, 9, 10, 11})
+    with _core_only():
+        assert _scores(builder(table).execute_local()) == 
pytest.approx(expected, abs=1e-6)
+    assert indexes._ids(table) == list(range(12))
+
+
[email protected]('response', ['first', 'empty', 'error'])
+def 
test_one_authoritative_rest_snapshot_for_all_routes_without_retry(rest_catalog, 
response):
+    catalog, server = rest_catalog
+    table = _table(catalog)
+    first = table.snapshot_manager().get_latest_snapshot()
+    indexes._append(table, _rows(8, 4), schema=SCHEMA)
+    if response == 'error':
+        reply, code = ErrorResponse('TABLE', 'indexes', 'snapshot 
unavailable', 503), 503
+    else:
+        reply = (GetTableSnapshotResponse(TableSnapshot(first, 1, 0, 7, 
first.time_millis))
+                 if response == 'first' else GetTableSnapshotResponse())
+        code = 200
+    with patch.object(server, '_table_snapshot_handle', 
return_value=server._mock_response(reply, code)) as latest:
+        with _core_only():
+            if response == 'error':
+                with pytest.raises(Exception, match='snapshot unavailable'):
+                    _builder(table).execute_local()
+            else:
+                assert set(_scores(_builder(table, 
routes='many').execute_local())) == (
+                    {0, 1, 2, 4, 5, 6, 7} if response == 'first' else set())
+        # Includes the empty response; routes must not independently resolve 
latest.
+        assert latest.call_count == 1
+    assert indexes._ids(table) == list(range(12))
+
+
+def test_missing_index_errors_propagate_without_python_retry(rest_catalog):
+    catalog, _ = rest_catalog
+    table = _table(catalog)
+    text_file = next(file for file in indexes._files(table) if file.index_type 
== 'full-text')
+    table.file_io.delete(indexes._path(table, text_file))
+    with _core_only(), pytest.raises(Exception):
+        _builder(table).execute_local()
+    assert indexes._ids(table) == list(range(8))
+
+
+def 
test_core_binding_rejects_invalid_configuration_before_empty_search(rest_catalog):
+    catalog, _ = rest_catalog
+    table = indexes._create(catalog, schema=SCHEMA, partitioned=True, 
options={'read.native.enabled': 'true'})
+    native = _native_table(table)
+    with pytest.raises(Exception, match='Routes cannot be empty'):
+        native.new_hybrid_search_builder().with_limit(2).execute_local()
+    with pytest.raises(Exception, match='Limit must be positive'):
+        native.new_hybrid_search_builder().add_vector_route('embedding', [0., 
0.], 2).execute_local()
+    with pytest.raises(Exception, match='Unsupported hybrid ranker'):
+        native.new_hybrid_search_builder().with_ranker('unknown')
+    with pytest.raises(Exception, match='Limit must be positive'):
+        native.new_hybrid_search_builder().add_vector_route('embedding', [0., 
0.], 0)
+    with pytest.raises(Exception, match='Weight must be finite and positive'):
+        native.new_hybrid_search_builder().add_vector_route('embedding', [0., 
0.], 2, 0.)
+    with pytest.raises(Exception, match='options are not supported'):
+        (native.new_hybrid_search_builder()
+         .add_full_text_route('content', match_query('paimon'), 2, 
options={'unknown': '1'}))
+    pb = table.new_read_builder().new_predicate_builder()
+    with pytest.raises(Exception, match='Partition filter must reference only 
partition keys'):
+        
native.new_hybrid_search_builder().with_partition_filter(_predicate_to_native(pb.equal('id',
 1)))
+    assert indexes._ids(table) == []
+    # Java parses DSL only when evaluating a nonempty corpus.
+    indexes._append(table, _rows(), schema=SCHEMA)
+    table.create_global_index('content', index_type='full-text')
+    for query in ('plain text', '{bad json'):
+        with _core_only(), pytest.raises(Exception):
+            table.new_hybrid_search_builder().add_full_text_route('content', 
query, 2).with_limit(2).execute_local()
+    assert indexes._ids(table) == list(range(8))
+
+
[email protected]('native', [False, True])
+def test_manual_route_and_rank_api_remains_available(rest_catalog, native):
+    catalog, _ = rest_catalog
+    table = _table(catalog, {'read.native.enabled': str(native).lower()})
+    builder = _builder(table)
+    routes = builder.route_builders()
+    results = [builder.to_route_result(route, route.execute_local()) for route 
in routes]
+    assert _scores(builder.rank(results)) == 
pytest.approx(_scores(builder.execute_local()), abs=1e-6)
+    assert indexes._ids(table) == list(range(8))
+
+
+def _multimodal_query(table, catalog, **kwargs):
+    return (MultimodalTable(catalog, 'default.indexes', table).search_hybrid([
+        vector_route('embedding', [0., 0.]), text_route('paimon', 
column='content')], **kwargs)
+        .select(['id']).limit(16).with_score().order_by_score())
+
+
+def test_multimodal_empty_view_does_not_observe_later_commit(rest_catalog):
+    catalog, server = rest_catalog
+    table = _table(catalog)
+    # Model a commit landing after _for_execution resolved an empty view.
+    with patch.object(TimeTravelUtil, 'resolve_snapshot', return_value=None), 
_core_only(), \
+            patch.object(server, '_table_snapshot_handle', 
side_effect=AssertionError('Re-resolved empty view')):
+        rows = _multimodal_query(table, catalog).to_arrow()
+    assert rows.num_rows == 0
+    assert rows.column_names == ['id', '_score']
+    assert indexes._ids(table) == list(range(8))
+
+
[email protected]('view', ['snapshot', 'retained_tag'])
+def 
test_multimodal_search_and_row_lookup_share_resolved_metadata(rest_catalog, 
view):
+    catalog, server = rest_catalog
+    table = _table(catalog)
+    first = table.snapshot_manager().get_latest_snapshot()
+    table.tag_manager().create_tag(first, 'hybrid-before')
+    indexes._append(table, _rows(8, 4), schema=SCHEMA)
+    kwargs = {'snapshot_id': first.id} if view == 'snapshot' else {'tag_name': 
'hybrid-before'}
+    if view == 'retained_tag':
+        
table.file_io.delete(table.snapshot_manager().get_snapshot_path(first.id))
+    classic = table.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    expected = _multimodal_query(classic, catalog, **kwargs).to_arrow()
+    with _core_only(), patch.object(server, '_table_snapshot_handle',
+                                    side_effect=AssertionError('Re-resolved 
fixed view')):
+        actual = _multimodal_query(table, catalog, **kwargs).to_arrow()
+    assert sorted(actual.column('id').to_pylist()) == [0, 1, 2, 4, 5, 6, 7]
+    assert actual.column('id').to_pylist() == expected.column('id').to_pylist()
+    assert actual.column('_score').to_pylist() == 
pytest.approx(expected.column('_score').to_pylist(), abs=1e-6)
+    assert indexes._ids(table) == list(range(12))
+
+
+def test_unsupported_literal_qualifies_before_core_execution(rest_catalog):
+    catalog, _ = rest_catalog
+    table = _table(catalog)
+    predicate = table.new_read_builder().new_predicate_builder().equal('id', 
1.0)
+    classic = table.copy({'read.native.enabled': 'false', 
'scan.native-plan.enabled': 'false'})
+    expected = _scores(_builder(classic, predicate=predicate).execute_local())
+    original = HybridSearchBuilderImpl.route_builders
+    with patch.object(HybridSearchBuilderImpl, 'route_builders', 
autospec=True, side_effect=original) as fallback:
+        actual = _scores(_builder(table, predicate=predicate).execute_local())
+    fallback.assert_called_once()
+    assert set(actual) == {1}
+    assert actual == pytest.approx(expected, abs=1e-6)
+    assert indexes._ids(table) == list(range(8))
+
+
[email protected]('native', [False, True])
+def test_query_auth_rejects_hybrid_before_native_table_access(rest_catalog, 
native):
+    catalog, _ = rest_catalog
+    table = _table(catalog)
+    table = table.copy({'query-auth.enabled': 'true', 'read.native.enabled': 
str(native).lower()})
+    with patch('pypaimon.table.source.native_hybrid_search._native_table',
+               side_effect=AssertionError('Native table access before 
authorization')):
+        with pytest.raises(Exception, match='query-auth'):
+            _builder(table).execute_local()
+    assert indexes._ids(table.copy({'query-auth.enabled': 'false'})) == 
list(range(8))

Reply via email to