blob: 998c9adc21fa281b1d8bb2185fd70b8a34e7a50c [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.
"""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.vector_search_result import DictBasedScoredIndexResult
from pypaimon.execution.native.table import (_native_table, _options_to_native)
from pypaimon.execution.native.read.builder import (_predicate_to_native)
from pypaimon.execution.native.runtime import (native_runtime_available)
from pypaimon.table.file_store_table import FileStoreTable
from pypaimon.execution.native.write.native_commit import _rest_catalog_supported
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
reject_search_under_query_auth(table)
if (not table.options.native_read_enabled()
or not table.options.row_tracking_enabled()
or table.is_primary_key_table
or table.options.file_format() != 'parquet'
or not native_runtime_available()
or not _rest_catalog_supported(table)):
return None
builder._validate_search()
if executor is not None and not table.options.data_evolution_enabled():
raise ValueError('Distributed search supports only data-evolution tables')
native = _native_table(table).new_hybrid_search_builder()
native.with_limit(builder._limit).with_ranker(builder._ranker)
for route in builder._routes:
options = _options_to_native(route.options)
if route.is_vector():
native.add_vector_route(route.field_name, route.vector, route.limit, route.weight, options)
else:
native.add_full_text_route(
route.field_name, route.full_text_query, route.limit, route.weight, 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
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()
else:
from pypaimon.execution.native.read.search_execution import native_task_executor
callback = native_task_executor(table, executor)
try:
result = native._execute_with_executor(callback)
finally:
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())