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 7befc15f18 [Python] Dispatch hybrid search phases to native execution
(#10769)
7befc15f18 is described below
commit 7befc15f18d81bc406679e14b48aa92cfe863335
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Oct 23 15:11:59 2026 +0800
[Python] Dispatch hybrid search phases to native execution (#10769)
---
.../global-index/hybrid-search.mdx | 24 +++
.../execution/native/read/native_hybrid_search.py | 67 ++++++-
.../execution/python/read/search/hybrid_search.py | 42 ++---
.../pypaimon/table/source/hybrid_search_builder.py | 37 ++--
.../pypaimon/table/source/search_dispatch.py | 33 +++-
.../tests/hybrid_search_phase_dispatch_test.py | 198 +++++++++++++++++++++
.../pypaimon/tests/hybrid_search_ranker_test.py | 51 ++++++
.../pypaimon/tests/native_hybrid_search_test.py | 149 +++++++++++++++-
8 files changed, 555 insertions(+), 46 deletions(-)
diff --git a/docs/docs/multimodal-table/global-index/hybrid-search.mdx
b/docs/docs/multimodal-table/global-index/hybrid-search.mdx
index 81bfae13e0..94625ab917 100644
--- a/docs/docs/multimodal-table/global-index/hybrid-search.mdx
+++ b/docs/docs/multimodal-table/global-index/hybrid-search.mdx
@@ -265,6 +265,30 @@ try (RecordReader<InternalRow> reader =
readBuilder.newRead().createReader(plan)
}
```
+To execute routes separately, use the same public builders and fuse their
results:
+
+```python
+builder = (
+ table.new_hybrid_search_builder()
+ .add_vector_route("title_embedding", [1.0, 0.0, 0.0], limit=50)
+ .with_limit(10)
+)
+routes = builder.route_builders()
+route_results = [
+ builder.to_route_result(route, route.execute_local())
+ for route in routes
+]
+result = builder.rank(route_results)
+```
+
+Route capture checks authorization and fixes one snapshot for all routes. Each
+`route.search_builder` is the builder retained by that route. Configuring it
changes
+subsequent `route.execute_local()` calls, and it supports its ordinary
scan/read
+methods. Ranking uses the supplied results and the builder's current limit and
+ranker; it does not read table metadata or execute queries again. Native
execution
+supports these phases as well as complete hybrid queries. Both backends use
Java's
+float32 scores, weights, and fusion arithmetic.
+
To search two vector indexes without a full-text route, add two vector routes:
```java
diff --git
a/paimon-python/pypaimon/execution/native/read/native_hybrid_search.py
b/paimon-python/pypaimon/execution/native/read/native_hybrid_search.py
index 660c59593a..56230dd9cf 100644
--- a/paimon-python/pypaimon/execution/native/read/native_hybrid_search.py
+++ b/paimon-python/pypaimon/execution/native/read/native_hybrid_search.py
@@ -15,7 +15,7 @@
# specific language governing permissions and limitations
# under the License.
-"""Transport an existing local hybrid query to one Rust core operation."""
+"""Transport hybrid queries, captured routes and weighted results to Rust
Core."""
from pypaimon.catalog.table_query_auth import reject_search_under_query_auth
from pypaimon.globalindex.full_text_options import NativeFullTextIndexOptions
@@ -27,8 +27,8 @@ from pypaimon.table.file_store_table import FileStoreTable
from pypaimon.execution.native.write.native_commit import
_rest_catalog_supported
-def try_native_hybrid_search(builder, executor=None):
- """Qualify before execution; core owns one snapshot, routes and fusion."""
+def _native_builder(builder, executor=None):
+ """Qualify the complete request before executing or capturing any route."""
table = builder._table
if type(table) is not FileStoreTable:
return None
@@ -64,6 +64,15 @@ def try_native_hybrid_search(builder, executor=None):
except (ValueError, NotImplementedError):
# Qualify strict literal support before executing any route or ranking.
return None
+ return table, native
+
+
+def try_native_hybrid_search(builder, executor=None):
+ """Core owns one snapshot, routes and fusion for the complete query."""
+ configured = _native_builder(builder, executor)
+ if configured is None:
+ return None
+ table, native = configured
# Propagate execution failures: retrying Python can select a newer
snapshot.
if executor is None:
result = native.execute()
@@ -76,3 +85,55 @@ def try_native_hybrid_search(builder, executor=None):
callback.cancel()
callback.raise_if_failed()
return DictBasedScoredIndexResult(result.row_ids())
+
+
+def try_native_hybrid_routes(builder):
+ configured = _native_builder(builder)
+ if configured is None:
+ return None
+ _, native = configured
+ # Core resolves the read view once. Retain ordinary public per-route
+ # builders so callers can still configure them and create scans and reads.
+ # Reconstructing these configurations must not resolve another snapshot.
+ from copy import copy
+ from pypaimon.common.json_util import JSON
+ from pypaimon.snapshot.snapshot import Snapshot
+ from pypaimon.table.source.hybrid_search_builder import
HybridSearchRouteBuilder
+
+ captured = native.route_builders()
+ routes = []
+ for route, native_route in zip(builder._routes, captured):
+ snapshot = native_route.snapshot()
+ snapshot = None if snapshot is None else
JSON.from_json(snapshot._to_json(), Snapshot)
+ execution = copy(builder)
+ execution._table = builder._table._copy_with_snapshot(snapshot)
+ search = (execution._new_vector_search_builder(route) if
route.is_vector() else
+ execution._new_full_text_search_builder(route))
+ routes.append(HybridSearchRouteBuilder(route, search))
+ return routes
+
+
+def rank_hybrid_results(builder, route_results):
+ """Fuse supplied scores without table access or validation of unused
queries."""
+ from pypaimon_rust.datafusion import (
+ GlobalIndexResult, HybridSearchRanker, HybridSearchWeightedResult,
+ )
+ from pypaimon.table.source.hybrid_search_builder import MRR_RANKER,
RRF_RANKER, WEIGHTED_SCORE_RANKER
+
+ ranker = {
+ RRF_RANKER: HybridSearchRanker.RRF,
+ MRR_RANKER: HybridSearchRanker.MRR,
+ WEIGHTED_SCORE_RANKER: HybridSearchRanker.WEIGHTED_SCORE,
+ }[builder._ranker]
+ weighted = []
+ for route_result in route_results:
+ result = route_result.result
+ rows = result.results()
+ if rows.is_empty():
+ scores = {}
+ else:
+ score = result.score_getter()
+ scores = {row_id: score(row_id) or 0.0 for row_id in rows}
+ weighted.append(HybridSearchWeightedResult(
+ GlobalIndexResult.from_scored_row_ids(scores),
route_result.route.weight))
+ return DictBasedScoredIndexResult(ranker.rank(weighted,
builder._limit).row_ids())
diff --git
a/paimon-python/pypaimon/execution/python/read/search/hybrid_search.py
b/paimon-python/pypaimon/execution/python/read/search/hybrid_search.py
index b53d7cd231..0183c67c6a 100644
--- a/paimon-python/pypaimon/execution/python/read/search/hybrid_search.py
+++ b/paimon-python/pypaimon/execution/python/read/search/hybrid_search.py
@@ -23,8 +23,9 @@ from concurrent.futures import FIRST_EXCEPTION,
ThreadPoolExecutor, as_completed
from threading import Event
from typing import List
+import numpy as np
+
from pypaimon.catalog.table_query_auth import reject_search_under_query_auth
-from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult, ScoredGlobalIndexResult
from pypaimon.table.source.hybrid_search_builder import (
HybridSearchRouteBuilder, HybridSearchRouteResult,
@@ -33,7 +34,7 @@ from pypaimon.table.source.hybrid_search_builder import (
_MAX_ROUTE_WORKERS = 4
-_RRF_K = 60.0
+_RRF_K = np.float32(60.0)
def execute(self, executor=None):
@@ -134,21 +135,6 @@ def route_builders(self) -> List[HybridSearchRouteBuilder]:
return builders
-def to_route_result(
- self,
- route_builder: HybridSearchRouteBuilder,
- result: GlobalIndexResult) -> HybridSearchRouteResult:
- if isinstance(result, ScoredGlobalIndexResult):
- scored = result
- elif result.results().is_empty():
- scored = ScoredGlobalIndexResult.create_empty()
- else:
- raise ValueError(
- "Hybrid search requires scored index results, but got: %s"
- % result.__class__.__name__)
- return HybridSearchRouteResult(route_builder.route, scored)
-
-
def rank(
self,
route_results: List[HybridSearchRouteResult]) ->
ScoredGlobalIndexResult:
@@ -167,18 +153,20 @@ def rank(
def _rrf(self, route_results):
scores = {}
for route_result in route_results:
+ weight = np.float32(route_result.route.weight)
for rank, row_id in enumerate(_ranked_row_ids(route_result.result)):
- contribution = route_result.route.weight / (_RRF_K + rank + 1.0)
- scores[row_id] = scores.get(row_id, 0.0) + contribution
+ contribution = weight / (_RRF_K + np.float32(rank) +
np.float32(1.0))
+ scores[row_id] = scores.get(row_id, np.float32(0.0)) + contribution
return _top_k(scores, self._limit)
def _mrr(self, route_results):
scores = {}
for route_result in route_results:
+ weight = np.float32(route_result.route.weight)
for rank, row_id in enumerate(_ranked_row_ids(route_result.result)):
- contribution = route_result.route.weight / (rank + 1.0)
- scores[row_id] = scores.get(row_id, 0.0) + contribution
+ contribution = weight / (np.float32(rank) + np.float32(1.0))
+ scores[row_id] = scores.get(row_id, np.float32(0.0)) + contribution
return _top_k(scores, self._limit)
@@ -186,7 +174,7 @@ def _weighted_score(self, route_results):
scores = {}
for route_result in route_results:
result = route_result.result
- weight = route_result.route.weight
+ weight = np.float32(route_result.route.weight)
score_getter = result.score_getter()
# Route score scales are heterogeneous (e.g. bounded vector similarity
@@ -195,7 +183,7 @@ def _weighted_score(self, route_results):
# weights -- not a route's numeric magnitude -- decide its influence on
# the fused score. This mirrors the Java HybridSearchRanker.
route_scores = {
- row_id: (score_getter(row_id) or 0.0)
+ row_id: np.float32(score_getter(row_id) or 0.0)
for row_id in result.results()
}
if not route_scores:
@@ -206,8 +194,8 @@ def _weighted_score(self, route_results):
# No spread within the route (single hit or all ties) carries no
# relative signal, so every hit maps to 1.0 rather than zeroed out.
normalized = (
- (raw - min_score) / score_range if score_range > 0.0 else 1.0)
- scores[row_id] = scores.get(row_id, 0.0) + weight * normalized
+ (raw - min_score) / score_range if score_range > 0.0 else
np.float32(1.0))
+ scores[row_id] = scores.get(row_id, np.float32(0.0)) + weight *
normalized
return _top_k(scores, self._limit)
@@ -215,7 +203,7 @@ def _ranked_row_ids(result):
score_getter = result.score_getter()
return sorted(
result.results(),
- key=lambda row_id: (-(score_getter(row_id) or 0.0), row_id))
+ key=lambda row_id: (-np.float32(score_getter(row_id) or 0.0), row_id))
def _top_k(scores, limit):
@@ -228,5 +216,5 @@ def _top_k(scores, limit):
heapq.heappush(heap, item)
elif item > heap[0]:
heapq.heapreplace(heap, item)
- top_scores = {-neg_row_id: score for score, neg_row_id in heap}
+ top_scores = {-neg_row_id: float(score) for score, neg_row_id in heap}
return DictBasedScoredIndexResult(top_scores)
diff --git a/paimon-python/pypaimon/table/source/hybrid_search_builder.py
b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
index 043765dc0c..00aa492778 100644
--- a/paimon-python/pypaimon/table/source/hybrid_search_builder.py
+++ b/paimon-python/pypaimon/table/source/hybrid_search_builder.py
@@ -17,6 +17,7 @@
"""Builder to build hybrid search."""
+import ctypes
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@@ -75,6 +76,7 @@ class HybridSearchRoute:
"Query cannot be None for full-text route")
if self.limit <= 0:
raise ValueError("Limit must be positive, got: %s" % self.limit)
+ self.weight = ctypes.c_float(float(self.weight)).value
if not math.isfinite(self.weight) or self.weight <= 0:
raise ValueError(
"Weight must be finite and positive, got: %s" % self.weight)
@@ -134,7 +136,11 @@ class HybridSearchRouteResult:
class HybridSearchRouteBuilder:
- """A route plus its configured search builder."""
+ """A route retaining the search builder used by every execute_local() call.
+
+ Reconfigure search_builder to change subsequent executions of this route.
+ The builder keeps the read view captured by route_builders().
+ """
def __init__(self, route, search_builder):
self.route = route
@@ -225,15 +231,15 @@ class HybridSearchBuilder(ABC):
pass
def execute_local(self) -> ScoredGlobalIndexResult:
- from pypaimon.execution.python.read.search.hybrid_search import
execute_local
- return execute_local(self)
+ from pypaimon.table.source.search_dispatch import execute_hybrid_search
+ return execute_hybrid_search(self)
def execute(self, executor=None) -> ScoredGlobalIndexResult:
"""Execute complete routes with an optional search-task scheduler."""
if executor is None:
return self.execute_local()
- from pypaimon.execution.python.read.search.hybrid_search import execute
- return execute(self, executor)
+ from pypaimon.table.source.search_dispatch import execute_hybrid_search
+ return execute_hybrid_search(self, executor)
class HybridSearchBuilderImpl(HybridSearchBuilder):
@@ -292,18 +298,27 @@ class HybridSearchBuilderImpl(HybridSearchBuilder):
return self
def route_builders(self) -> List[HybridSearchRouteBuilder]:
- from pypaimon.execution.python.read.search.hybrid_search import
route_builders
- return route_builders(self)
+ from pypaimon.table.source.search_dispatch import hybrid_route_builders
+ return hybrid_route_builders(self)
def to_route_result(
self, route_builder: HybridSearchRouteBuilder,
result: GlobalIndexResult) -> HybridSearchRouteResult:
- from pypaimon.execution.python.read.search.hybrid_search import
to_route_result
- return to_route_result(self, route_builder, result)
+ # This is public result-type validation, independent of execution. Keep
+ # scored objects intact, including custom score getters and metadata.
+ if isinstance(result, ScoredGlobalIndexResult):
+ scored = result
+ elif result.results().is_empty():
+ scored = ScoredGlobalIndexResult.create_empty()
+ else:
+ raise ValueError(
+ "Hybrid search requires scored index results, but got: %s"
+ % result.__class__.__name__)
+ return HybridSearchRouteResult(route_builder.route, scored)
def rank(self, route_results: List[HybridSearchRouteResult]) ->
ScoredGlobalIndexResult:
- from pypaimon.execution.python.read.search.hybrid_search import rank
- return rank(self, route_results)
+ from pypaimon.table.source.search_dispatch import rank_hybrid_results
+ return rank_hybrid_results(self, route_results)
def _validate_search(self):
if not self._routes:
diff --git a/paimon-python/pypaimon/table/source/search_dispatch.py
b/paimon-python/pypaimon/table/source/search_dispatch.py
index b174450264..166d073b83 100644
--- a/paimon-python/pypaimon/table/source/search_dispatch.py
+++ b/paimon-python/pypaimon/table/source/search_dispatch.py
@@ -72,7 +72,8 @@ def python_search_reader(reader):
def _native_requested(builder):
- enabled = getattr(getattr(builder._table, 'options', None),
'native_read_enabled', None)
+ table = getattr(builder, '_table', None)
+ enabled = getattr(getattr(table, 'options', None), 'native_read_enabled',
None)
return callable(enabled) and enabled()
@@ -133,7 +134,10 @@ def execute_full_text_search(builder):
def execute_hybrid_search(builder, executor=None):
- if _native_requested(builder):
+ from pypaimon.table.source.hybrid_search_builder import
HybridSearchBuilderImpl
+ # Custom implementations execute through their public route/rank hooks;
+ # only the default implementation owns the native configuration protocol.
+ if isinstance(builder, HybridSearchBuilderImpl) and
_native_requested(builder):
from pypaimon.execution.native.read.native_hybrid_search import
try_native_hybrid_search
result = (try_native_hybrid_search(builder) if executor is None else
try_native_hybrid_search(builder, executor=executor))
@@ -141,3 +145,28 @@ def execute_hybrid_search(builder, executor=None):
return result
from pypaimon.execution.python.read.search.hybrid_search import execute
return execute(builder, executor)
+
+
+def hybrid_route_builders(builder):
+ from pypaimon.catalog.table_query_auth import
reject_search_under_query_auth
+ # Authorization precedes runtime discovery and snapshot resolution, as in
+ # Java's routeBuilders(). Selecting a backend must not hide a rejection.
+ reject_search_under_query_auth(builder._table)
+ if _native_requested(builder):
+ from pypaimon.execution.native.read.native_hybrid_search import
try_native_hybrid_routes
+ routes = try_native_hybrid_routes(builder)
+ if routes is not None:
+ return routes
+ from pypaimon.execution.python.read.search.hybrid_search import
route_builders
+ return route_builders(builder)
+
+
+def rank_hybrid_results(builder, route_results):
+ builder._validate_search()
+ if _native_requested(builder):
+ from pypaimon.execution.native.runtime import native_runtime_available
+ if native_runtime_available():
+ from pypaimon.execution.native.read.native_hybrid_search import
rank_hybrid_results as rank
+ return rank(builder, route_results)
+ from pypaimon.execution.python.read.search.hybrid_search import rank
+ return rank(builder, route_results)
diff --git a/paimon-python/pypaimon/tests/hybrid_search_phase_dispatch_test.py
b/paimon-python/pypaimon/tests/hybrid_search_phase_dispatch_test.py
new file mode 100644
index 0000000000..c9b358eaf4
--- /dev/null
+++ b/paimon-python/pypaimon/tests/hybrid_search_phase_dispatch_test.py
@@ -0,0 +1,198 @@
+# 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.
+
+"""Backend selection and public contracts for independently executed hybrid
phases."""
+
+import pickle
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+import pytest
+import pyarrow as pa
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.common.json_util import JSON
+from pypaimon.globalindex.global_index_result import GlobalIndexResult
+from pypaimon.globalindex.vector_search_result import
DictBasedScoredIndexResult, ScoredGlobalIndexResult
+from pypaimon.snapshot.snapshot import Snapshot
+from pypaimon.table.source.hybrid_search_builder import (
+ HybridSearchBuilder, HybridSearchBuilderImpl, HybridSearchRoute,
HybridSearchRouteBuilder,
+)
+
+
[email protected]
+def search_table(tmp_path):
+ catalog = CatalogFactory.create({'warehouse': str(tmp_path / 'warehouse')})
+ catalog.create_database('default', True)
+ catalog.create_table('default.search', Schema.from_pyarrow_schema(
+ pa.schema([('embedding', pa.list_(pa.float32())), ('body',
pa.string())]),
+ options={'row-tracking.enabled': 'true', 'data-evolution.enabled':
'true',
+ 'read.native.enabled': 'true'}), False)
+ return catalog.get_table('default.search')
+
+
+def _builder(table):
+ return (HybridSearchBuilderImpl(table).with_limit(2)
+ .add_vector_route('embedding', [1.0, 0.0], 3,
options={'ivf.nprobe': '2'})
+ .add_full_text_route('body', 'query', 4, weight=2.0))
+
+
[email protected]('phase', ['routes', 'rank'])
+def test_native_phase_uses_dispatch_without_python_execution(search_table,
phase):
+ builder = _builder(search_table)
+ expected = object()
+ target = 'try_native_hybrid_routes' if phase == 'routes' else
'rank_hybrid_results'
+ with patch('pypaimon.execution.native.read.native_hybrid_search.' +
target, return_value=expected) as native, \
+
patch('pypaimon.execution.native.runtime.native_runtime_available',
return_value=True), \
+
patch('pypaimon.execution.python.read.search.hybrid_search.route_builders',
+ side_effect=AssertionError('Python route planning')), \
+ patch('pypaimon.execution.python.read.search.hybrid_search.rank',
+ side_effect=AssertionError('Python fusion')):
+ actual = builder.route_builders() if phase == 'routes' else
builder.rank([])
+ assert actual is expected
+ native.assert_called_once()
+
+
[email protected]('phase', ['routes', 'rank'])
+def test_native_phase_failure_is_not_replayed_in_python(search_table, phase):
+ failure = RuntimeError('selected Native phase failed')
+ target = 'try_native_hybrid_routes' if phase == 'routes' else
'rank_hybrid_results'
+ builder = _builder(search_table)
+ with patch('pypaimon.execution.native.read.native_hybrid_search.' +
target, side_effect=failure), \
+
patch('pypaimon.execution.native.runtime.native_runtime_available',
return_value=True), \
+
patch('pypaimon.execution.python.read.search.hybrid_search.route_builders') as
routes, \
+ patch('pypaimon.execution.python.read.search.hybrid_search.rank')
as rank:
+ with pytest.raises(RuntimeError) as caught:
+ builder.route_builders() if phase == 'routes' else builder.rank([])
+ assert caught.value is failure
+ routes.assert_not_called()
+ rank.assert_not_called()
+
+
+def
test_query_auth_rejects_route_capture_before_backend_discovery(search_table):
+ builder = _builder(search_table.copy({'query-auth.enabled': 'true'}))
+ with
patch('pypaimon.execution.native.read.native_hybrid_search.try_native_hybrid_routes')
as native, \
+
patch('pypaimon.execution.native.runtime.native_runtime_available') as
available:
+ with pytest.raises(ValueError, match='query-auth'):
+ builder.route_builders()
+ native.assert_not_called()
+ available.assert_not_called()
+
+
[email protected]('captured', [False, True])
+def
test_native_routes_keep_public_builders_and_captured_metadata(search_table,
captured):
+ from pypaimon.execution.native.read.native_hybrid_search import
try_native_hybrid_routes
+
+ builder = _builder(search_table)
+ predicate =
search_table.new_read_builder().new_predicate_builder().equal('body', 'keep')
+ builder.with_filter(predicate)
+ snapshot = Snapshot(3, 7, 0, 'base', 'delta', 2, 2, 'writer', 1, 'APPEND',
0) if captured else None
+ native_snapshot = None if snapshot is None else
SimpleNamespace(_to_json=lambda: JSON.to_json(snapshot))
+ native = Mock()
+ native.route_builders.return_value = [SimpleNamespace(snapshot=lambda:
native_snapshot) for _ in range(2)]
+ with
patch('pypaimon.execution.native.read.native_hybrid_search._native_builder',
+ return_value=(search_table, native)), \
+
patch('pypaimon.snapshot.time_travel_util.TimeTravelUtil.resolve_snapshot',
+ side_effect=AssertionError('Resolved a captured snapshot
again')):
+ routes = try_native_hybrid_routes(builder)
+ assert [route.route for route in routes] == builder._routes
+ assert all(route.search_builder._table._read_snapshot == snapshot for
route in routes)
+ assert routes[0].search_builder._vector_column.name == 'embedding'
+ assert routes[0].search_builder._query_vector == [1.0, 0.0]
+ assert routes[0].search_builder._options == {'ivf.nprobe': '2'}
+ assert routes[0].search_builder._limit == 3
+ assert routes[1].search_builder._limit == 4
+ assert all(route.search_builder._filter == predicate for route in routes)
+ assert callable(routes[0].search_builder.new_vector_search_scan)
+ assert callable(routes[1].search_builder.new_full_text_read)
+ restored = pickle.loads(pickle.dumps(routes))
+ assert [route.search_builder._table._read_snapshot for route in restored]
== [snapshot, snapshot]
+ assert '_read_snapshot' not in vars(search_table)
+ native.route_builders.assert_called_once_with()
+
+
+def test_result_conversion_is_shared_validation_without_selecting_a_backend():
+ builder = HybridSearchBuilderImpl(None) # No routes, valid limit, or
table is required.
+ route = HybridSearchRoute.full_text_route('unused', '', 1)
+ route_builder = HybridSearchRouteBuilder(route, None)
+ result = DictBasedScoredIndexResult({7: 0.5})
+ with patch('pypaimon.execution.native.runtime.native_runtime_available',
+ side_effect=AssertionError('Discovered an executor during
validation')):
+ converted = builder.to_route_result(route_builder, result)
+ empty = builder.to_route_result(route_builder,
GlobalIndexResult.create_empty())
+ with pytest.raises(ValueError, match='requires scored'):
+ builder.to_route_result(route_builder,
GlobalIndexResult.create(result.results()))
+ assert converted.route is route
+ assert converted.result is result
+ assert isinstance(empty.result, ScoredGlobalIndexResult)
+ assert empty.result.results().is_empty()
+
+
+def test_python_phases_do_not_load_native(search_table):
+ builder = _builder(search_table.copy({'read.native.enabled': 'false'}))
+ with
patch('pypaimon.execution.native.read.native_hybrid_search.try_native_hybrid_routes',
+ side_effect=AssertionError('Loaded disabled route backend')), \
+
patch('pypaimon.execution.native.read.native_hybrid_search.rank_hybrid_results',
+ side_effect=AssertionError('Loaded disabled fusion
backend')):
+ routes = builder.route_builders()
+ assert len(routes) == 2
+ ranked = builder.rank([builder.to_route_result(routes[0],
DictBasedScoredIndexResult({7: 1.0}))])
+ assert list(ranked.results()) == [7]
+
+
[email protected]('keep_table', [False, True])
[email protected]('entry_point', ['execute_local', 'execute'])
+def test_abstract_builder_executes_public_hooks(search_table, keep_table,
entry_point):
+ from pypaimon.execution.native.read import native_hybrid_search
+
+ result = DictBasedScoredIndexResult({7: 0.5})
+ calls = []
+
+ def execute_route():
+ calls.append('execute route')
+ return result
+
+ class CustomHybridSearch(HybridSearchBuilder):
+ def _configure(self, *args, **kwargs):
+ return self
+
+ with_limit = with_ranker = with_filter = with_partition_filter =
add_route = _configure
+
+ def route_builders(self):
+ calls.append('build routes')
+ return [HybridSearchRouteBuilder(None,
SimpleNamespace(execute_local=execute_route))]
+
+ def to_route_result(self, route, result):
+ calls.append('convert result')
+ return result
+
+ def rank(self, results):
+ calls.append('rank')
+ return results[0]
+
+ builder = CustomHybridSearch()
+ if keep_table:
+ # Keeping a native-enabled table does not opt a public implementation
+ # into HybridSearchBuilderImpl's private configuration protocol.
+ builder._table = search_table
+ with patch.object(native_hybrid_search, 'native_runtime_available',
return_value=True), \
+ patch.object(native_hybrid_search, '_rest_catalog_supported',
return_value=True), \
+ patch.object(native_hybrid_search, 'try_native_hybrid_search',
+ wraps=native_hybrid_search.try_native_hybrid_search)
as native:
+ assert getattr(builder, entry_point)() is result
+ native.assert_not_called()
+ assert calls == ['build routes', 'execute route', 'convert result', 'rank']
diff --git a/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
b/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
index 5c34c60c39..d98096856f 100644
--- a/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
+++ b/paimon-python/pypaimon/tests/hybrid_search_ranker_test.py
@@ -47,6 +47,57 @@ def _rank(limit, ranker, results):
class HybridSearchRankerTest(unittest.TestCase):
+ def test_rankers_compare_external_scores_as_java_floats(self):
+ # ScoreGetter returns float: these distinct Python values are equal in
+ # Java, so the smaller row ID wins every ranker's Top-K boundary tie.
+ route = _route_result(1.0, {1: 1000000000.0, 2: 1000000001.0})
+ for ranker, expected_score in (
+ (RRF_RANKER, 0.016393441706895828),
+ (WEIGHTED_SCORE_RANKER, 1.0),
+ (MRR_RANKER, 1.0)):
+ with self.subTest(ranker=ranker):
+ ranked = _rank(1, ranker, [route])
+ self.assertEqual({1}, set(ranked.results()))
+ self.assertEqual(expected_score, ranked.score_getter()(1))
+
+ def test_route_validates_weights_after_java_float_conversion(self):
+ for weight in (1e39, 1e-46, 3.4028236e38):
+ for full_text in (False, True):
+ with self.subTest(weight=weight, full_text=full_text):
+ with self.assertRaisesRegex(ValueError, "Weight must be
finite and positive"):
+ if full_text:
+ HybridSearchRoute.full_text_route('text', '{}', 1,
weight)
+ else:
+ HybridSearchRoute.vector_route('embedding', [1.0],
1, weight)
+
+ for weight, expected in (
+ (0.1, 0.10000000149011612),
+ (1e-45, 1.401298464324817e-45),
+ (3.4028235e38, 3.4028234663852886e38)):
+ with self.subTest(weight=weight):
+ vector = HybridSearchRoute.vector_route('embedding', [1.0], 1,
weight)
+ text = HybridSearchRoute.full_text_route('text', '{}', 1,
weight)
+ self.assertEqual(expected, vector.weight)
+ self.assertEqual(expected, text.weight)
+
+ def test_rankers_round_normalization_and_accumulation_as_java_floats(self):
+ routes = [_route_result(0.1, {1: 0.0, 2: 0.1, 3: 0.3}),
+ _route_result(0.2, {1: 0.3, 2: 0.2, 3: 0.1}),
+ _route_result(0.3, {1: 0.1, 2: 0.2, 3: 0.3})]
+ # Java float operations, including each per-route addition, determine
+ # these exact scores; rounding just the final Python sum is different.
+ for ranker, expected in (
+ (RRF_RANKER, {1: 0.009627895429730415, 2: 0.009677419438958168,
+ 3: 0.009731980040669441}),
+ (WEIGHTED_SCORE_RANKER, {1: 0.20000000298023224, 2:
0.28333330154418945,
+ 3: 0.4000000059604645}),
+ (MRR_RANKER, {1: 0.3333333432674408, 2: 0.30000001192092896,
+ 3: 0.46666669845581055})):
+ with self.subTest(ranker=ranker):
+ ranked = _rank(3, ranker, routes)
+ self.assertEqual(expected, {row_id:
ranked.score_getter()(row_id)
+ for row_id in ranked.results()})
+
def test_weighted_score_normalizes_heterogeneous_route_scales(self):
# Vector-like route (bounded ~[0, 1]) and BM25-like route (unbounded,
larger).
vector_route = _route_result(5.0, {1: 0.95, 2: 0.10})
diff --git a/paimon-python/pypaimon/tests/native_hybrid_search_test.py
b/paimon-python/pypaimon/tests/native_hybrid_search_test.py
index 2ecd100e81..b1ad029955 100644
--- a/paimon-python/pypaimon/tests/native_hybrid_search_test.py
+++ b/paimon-python/pypaimon/tests/native_hybrid_search_test.py
@@ -17,7 +17,7 @@
"""Whole-operation native hybrid execution against REST, vector and text
indexes."""
-from contextlib import contextmanager
+from contextlib import ExitStack, contextmanager
from unittest.mock import patch
import pyarrow as pa
@@ -31,6 +31,8 @@ from pypaimon.execution.native.read.builder import
(_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.globalindex.vector_search_result import
DictBasedScoredIndexResult, ScoredGlobalIndexResult
+from pypaimon.table.source.hybrid_search_builder import HybridSearchRoute,
HybridSearchRouteBuilder
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
@@ -269,10 +271,151 @@ 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)
+ with ExitStack() as stack:
+ if native:
+ for phase in ('route_builders', 'rank'):
+
stack.enter_context(patch('pypaimon.execution.python.read.search.hybrid_search.'
+ phase,
+ side_effect=AssertionError('Python
hybrid ' + phase)))
+ 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))
+
+
[email protected]('ranker', ['rrf', 'weighted_score', 'mrr'])
+def
test_rank_external_results_does_not_access_table_or_validate_unused_queries(rest_catalog,
ranker):
+ catalog, _ = rest_catalog
+ table = _table(catalog, indexed=False).copy({'query-auth.enabled': 'true'})
+ builder = _builder(table, ranker=ranker, limit=2)
+ # Route metadata is not a request to execute these queries. Both empty
+ # vectors and empty text queries are valid public route descriptions.
+ routes =
[HybridSearchRouteBuilder(HybridSearchRoute.vector_route('absent', [], 10,
weight=2.0), None),
+
HybridSearchRouteBuilder(HybridSearchRoute.full_text_route('absent', '', 10),
None)]
+ results = [builder.to_route_result(routes[0],
DictBasedScoredIndexResult({1: 10., 2: None, 3: 5.})),
+ builder.to_route_result(routes[1],
DictBasedScoredIndexResult({2: 50., 3: 50.}))]
+ classic = _builder(table.copy({'read.native.enabled': 'false'}),
ranker=ranker, limit=1)
+ with
patch('pypaimon.execution.native.read.native_hybrid_search._native_table',
+ side_effect=AssertionError('Rank accessed table metadata')), \
+ patch('pypaimon.execution.python.read.search.hybrid_search.rank',
+ side_effect=AssertionError('Native rank used Python
fusion')):
+ actual = _scores(builder.rank(results))
+ assert _scores(builder.rank([])) == {}
+ builder.with_limit(1)
+ reduced = _scores(builder.rank([results[1], results[0], results[0]]))
+ expected = classic.with_limit(2).rank(results)
+ assert actual == pytest.approx(_scores(expected), abs=1e-6)
+ classic.with_limit(1)
+ assert reduced == pytest.approx(_scores(classic.rank([results[1],
results[0], results[0]])), abs=1e-6)
+ assert all(isinstance(result.result, ScoredGlobalIndexResult) for result
in results)
+
+
+def
test_manual_routes_pin_one_snapshot_and_keep_public_scan_read_api(rest_catalog):
+ catalog, server = rest_catalog
+ table = _table(catalog)
+ builder = _builder(table)
+ with patch.object(server, '_table_snapshot_handle',
wraps=server._table_snapshot_handle) as latest, \
+
patch('pypaimon.execution.python.read.search.hybrid_search.route_builders',
+ side_effect=AssertionError('Python route capture')):
+ routes = builder.route_builders()
+ assert latest.call_count == 1
+ snapshots = [route.search_builder._table._read_snapshot for route in
routes]
+ assert snapshots[0].id == snapshots[1].id
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ with patch.object(server, '_table_snapshot_handle',
side_effect=AssertionError('Re-resolved captured view')):
+ vector = routes[0].search_builder
+ text = routes[1].search_builder
+ vector_result =
vector.new_vector_search_read().read_plan(vector.new_vector_search_scan().scan())
+ text_result =
text.new_full_text_read().read_plan(text.new_full_text_scan().scan())
+ results = [builder.to_route_result(route, result)
+ for route, result in zip(routes, [vector_result,
text_result])]
+ ranked =
_scores(builder.with_limit(4).with_weighted_score_ranker().rank(results))
+ assert ranked
+ assert set(ranked).issubset(set(range(8)))
+ assert 11 in _scores(_builder(table).execute_local())
+
+
+def test_manual_empty_routes_remain_empty_after_first_commit(rest_catalog):
+ catalog, server = rest_catalog
+ table = indexes._create(catalog, schema=SCHEMA,
options={'read.native.enabled': 'true'})
+ builder = _builder(table)
routes = builder.route_builders()
+ assert all(route.search_builder._table._read_snapshot is None for route in
routes)
+ indexes._append(table, _rows(), schema=SCHEMA)
+ with patch.object(server, '_table_snapshot_handle',
side_effect=AssertionError('Re-resolved empty view')):
+ results = [builder.to_route_result(route, route.execute_local()) for
route in routes]
+ assert _scores(builder.rank(results)) == {}
+ assert _scores(_builder(table).execute_local())
+
+
+def test_manual_routes_allow_completing_queries_after_capture(rest_catalog):
+ catalog, _ = rest_catalog
+ table = _table(catalog)
+ builder = (table.new_hybrid_search_builder().with_limit(16)
+ .add_vector_route('embedding', [], 16)
+ .add_full_text_route('content', '', 16))
+ with
patch('pypaimon.execution.python.read.search.hybrid_search.route_builders',
+ side_effect=AssertionError('Python incomplete route capture')):
+ routes = builder.route_builders()
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ routes[0].search_builder.with_query_vector([0., 0.])
+ routes[1].search_builder.with_query('content', match_query('paimon'))
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))
+ actual = _scores(builder.rank(results))
+ assert actual
+ assert set(actual).issubset(set(range(8)))
+
+
+def
test_sdk_route_mutations_affect_route_execute_and_keep_the_captured_view(rest_catalog):
+ catalog, _ = rest_catalog
+ table = _table(catalog, indexed=False)
+ table.create_global_index('content', index_type='full-text')
+ native = _native_table(table).new_hybrid_search_builder()
+ native.add_vector_route('embedding', [0., 0.], 16)
+ native.add_full_text_route('content', match_query('paimon'),
16).with_limit(16)
+ vector_route, text_route = native.route_builders()
+ vector, text = vector_route.search_builder(), text_route.search_builder()
+ assert vector is vector_route.search_builder()
+ assert text is text_route.search_builder()
+
+ # A commit after route capture must not enter either reconfigured query.
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ pb = table.new_read_builder().new_predicate_builder()
+ keep = _predicate_to_native(pb.greater_than('id', 4))
+ vector.with_query_vector([6., 0.]).with_limit(1).with_filter(keep)
+ text.with_query('content',
match_query('lake')).with_limit(1).with_filter(keep)
+ vector_result, text_result = vector_route.execute(), text_route.execute()
+ assert set(vector_result.row_ids()) == {6}
+ assert set(text_result.row_ids()) == {5}
+ assert vector_result.row_ids() == vector.execute().row_ids()
+ assert text_result.row_ids() == text.execute().row_ids()
+ assert vector_result.snapshot_id() == vector_route.snapshot().id()
+ assert text_result.snapshot_id() == text_route.snapshot().id()
+
+ # Repeated getters share the same state, including later reconfiguration.
+ vector_route.search_builder().with_limit(2)
+ assert set(vector_route.execute().row_ids()) == {5, 6}
+ ranked = native.rank([native.to_route_result(vector_route, vector_result),
+ native.to_route_result(text_route, text_result)])
+ batches = ranked.new_read_builder().with_projection(['id']).read()
+ assert sorted(pa.Table.from_batches(batches)['id'].to_pylist()) == [5, 6]
+
+
[email protected]('native', [False, True])
+def test_public_route_uses_mutated_search_builder(rest_catalog, native):
+ catalog, _ = rest_catalog
+ table = _table(catalog, {'read.native.enabled': str(native).lower()},
indexed=False)
+ table.create_global_index('content', index_type='full-text')
+ builder = _builder(table)
+ vector_route, text_route = builder.route_builders()
+ vector, text = vector_route.search_builder, text_route.search_builder
+ indexes._append(table, _rows(8, 4), schema=SCHEMA)
+ pb = table.new_read_builder().new_predicate_builder()
+ vector.with_query_vector([6.,
0.]).with_limit(1).with_filter(pb.greater_than('id', 4))
+ text.with_query('content',
match_query('lake')).with_limit(1).with_filter(pb.greater_than('id', 4))
+ assert set(_scores(vector_route.execute_local())) == {6}
+ assert set(_scores(text_route.execute_local())) == {5}
+ vector.with_limit(2)
+ assert set(_scores(vector_route.execute_local())) == {5, 6}
def _multimodal_query(table, catalog, **kwargs):