blob: 2aac2acd9d16c781aa039d6d9de8d785c229e54d [file]
# 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.
"""Python hybrid route execution and rank fusion."""
import heapq
from copy import copy
from concurrent.futures import FIRST_EXCEPTION, ThreadPoolExecutor, as_completed, wait
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.vector_search_result import DictBasedScoredIndexResult, ScoredGlobalIndexResult
from pypaimon.table.source.hybrid_search_builder import (
HybridSearchRouteBuilder, HybridSearchRouteResult,
WEIGHTED_SCORE_RANKER, MRR_RANKER,
)
from pypaimon.table.source.search_dispatch import _python_search_execution
_MAX_ROUTE_WORKERS = 4
_RRF_K = np.float32(60.0)
@_python_search_execution()
def _execute_route(route):
return route.execute_local()
@_python_search_execution()
def execute(self, executor=None):
if executor is None:
return execute_local(self)
executor = executor.with_concurrency(executor.concurrency)
# Pin once before route planning moves onto separate driver threads.
routes = self.route_builders()
workers = min(executor.concurrency, len(routes))
route_concurrency = executor.concurrency // max(1, workers)
@_python_search_execution()
def execute_route(route):
child = executor.with_concurrency(route_concurrency)
builder = route.search_builder
if route.route.is_vector():
plan = builder.new_vector_search_scan().scan()
return builder.new_vector_search_read().read_plan(plan, executor=child)
plan = builder.new_full_text_scan().scan()
return builder.new_full_text_read().read_plan(plan, executor=child)
if workers <= 1:
return self.rank([self.to_route_result(route, execute_route(route)) for route in routes])
cancelled = Event()
def run(route):
with executor.cancellation_scope(cancelled):
return execute_route(route)
results = [None] * len(routes)
with ThreadPoolExecutor(max_workers=workers) as pool:
futures = {}
try:
for ordinal, route in enumerate(routes):
futures[pool.submit(run, route)] = ordinal
for future in as_completed(futures):
ordinal = futures[future]
results[ordinal] = self.to_route_result(routes[ordinal], future.result())
finally:
cancelled.set()
for future in futures:
future.cancel()
return self.rank(results)
@_python_search_execution()
def execute_local(self) -> ScoredGlobalIndexResult:
"""Execute hybrid index search locally."""
routes = self.route_builders()
if len(routes) <= 1:
return self.rank([
self.to_route_result(
route_builder, _execute_route(route_builder))
for route_builder in routes
])
workers = min(len(routes), _MAX_ROUTE_WORKERS)
with ThreadPoolExecutor(
max_workers=workers,
thread_name_prefix="paimon-hybrid-search") as executor:
futures = [
executor.submit(_execute_route, route_builder)
for route_builder in routes
]
done, pending = wait(futures, return_when=FIRST_EXCEPTION)
failed = next(
(future for future in futures
if future in done and future.exception() is not None),
None,
)
if failed is not None:
for future in pending:
future.cancel()
failed.result()
route_results = [
self.to_route_result(route_builder, future.result())
for route_builder, future in zip(routes, futures)
]
return self.rank(route_results)
def route_builders(self) -> List[HybridSearchRouteBuilder]:
reject_search_under_query_auth(self._table)
self._validate_search()
from pypaimon.snapshot.time_travel_util import TimeTravelUtil
execution = copy(self)
execution._table = self._table._copy_with_snapshot(TimeTravelUtil.resolve_snapshot(self._table))
builders = []
for route in self._routes:
if route.is_vector():
builders.append(
HybridSearchRouteBuilder(
route, execution._new_vector_search_builder(route)))
else:
builders.append(
HybridSearchRouteBuilder(
route, execution._new_full_text_search_builder(route)))
return builders
def rank(
self,
route_results: List[HybridSearchRouteResult]) -> ScoredGlobalIndexResult:
self._validate_search()
non_empty = [
route_result for route_result in route_results
if not route_result.result.results().is_empty()
]
if self._ranker == WEIGHTED_SCORE_RANKER:
return _weighted_score(self, non_empty)
if self._ranker == MRR_RANKER:
return _mrr(self, non_empty)
return _rrf(self, non_empty)
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 = 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 = 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)
def _weighted_score(self, route_results):
scores = {}
for route_result in route_results:
result = route_result.result
weight = np.float32(route_result.route.weight)
score_getter = result.score_getter()
# Route score scales are heterogeneous (e.g. bounded vector similarity
# vs unbounded BM25), so raw scores are not comparable across routes.
# Min-max normalize each route into [0, 1] before weighting, so that
# weights -- not a route's numeric magnitude -- decide its influence on
# the fused score. This mirrors the Java HybridSearchRanker.
route_scores = {
row_id: np.float32(score_getter(row_id) or 0.0)
for row_id in result.results()
}
if not route_scores:
continue
min_score = min(route_scores.values())
score_range = max(route_scores.values()) - min_score
for row_id, raw in route_scores.items():
# 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 np.float32(1.0))
scores[row_id] = scores.get(row_id, np.float32(0.0)) + weight * normalized
return _top_k(scores, self._limit)
def _ranked_row_ids(result):
score_getter = result.score_getter()
return sorted(
result.results(),
key=lambda row_id: (-np.float32(score_getter(row_id) or 0.0), row_id))
def _top_k(scores, limit):
if not scores:
return ScoredGlobalIndexResult.create_empty()
heap = []
for row_id, score in scores.items():
item = (score, -row_id)
if len(heap) < limit:
heapq.heappush(heap, item)
elif item > heap[0]:
heapq.heapreplace(heap, item)
top_scores = {-neg_row_id: float(score) for score, neg_row_id in heap}
return DictBasedScoredIndexResult(top_scores)