blob: 00aa492778b004417e82937354da593382fa8cd6 [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.
"""Builder to build hybrid search."""
import ctypes
import math
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Dict, List, Optional
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.globalindex.vector_search_result import (
ScoredGlobalIndexResult,
)
RRF_RANKER = "rrf"
WEIGHTED_SCORE_RANKER = "weighted_score"
MRR_RANKER = "mrr"
def _check_full_text_options(options: Dict[str, str]):
if options:
raise ValueError(
"Full-text hybrid route options are not supported yet.")
def _normalize_ranker(ranker: Optional[str]) -> str:
if ranker is None or not ranker.strip():
return RRF_RANKER
normalized = ranker.strip().lower()
if normalized not in (RRF_RANKER, WEIGHTED_SCORE_RANKER, MRR_RANKER):
raise ValueError("Unsupported hybrid ranker: %s" % ranker)
return normalized
@dataclass
class HybridSearchRoute:
"""A single route in a hybrid search."""
route_type: str
field_name: str
limit: int
weight: float = 1.0
vector: Optional[List[float]] = None
full_text_query: Optional[str] = None
options: Dict[str, str] = field(default_factory=dict)
VECTOR = "vector"
FULL_TEXT = "full_text"
def __post_init__(self):
if self.route_type not in (self.VECTOR, self.FULL_TEXT):
raise ValueError("Unsupported hybrid route type: %s" % self.route_type)
if not self.field_name:
raise ValueError("Field name cannot be None or empty")
if self.route_type == self.VECTOR and self.vector is None:
raise ValueError("Search vector cannot be None for vector route")
if self.route_type == self.FULL_TEXT and self.full_text_query is None:
raise ValueError(
"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)
self.options = dict(self.options or {})
if self.route_type == self.FULL_TEXT:
_check_full_text_options(self.options)
@classmethod
def vector_route(
cls,
field_name: str,
vector: List[float],
limit: int,
weight: float = 1.0,
options: Optional[Dict[str, str]] = None) -> 'HybridSearchRoute':
return cls(
route_type=cls.VECTOR,
field_name=field_name,
vector=vector,
limit=limit,
weight=weight,
options=dict(options or {}),
)
@classmethod
def full_text_route(
cls,
field_name: str,
query: str,
limit: int,
weight: float = 1.0,
options: Optional[Dict[str, str]] = None) -> 'HybridSearchRoute':
_check_full_text_options(options or {})
return cls(
route_type=cls.FULL_TEXT,
field_name=field_name,
full_text_query=query,
limit=limit,
weight=weight,
options=dict(options or {}),
)
def is_vector(self) -> bool:
return self.route_type == self.VECTOR
def is_full_text(self) -> bool:
return self.route_type == self.FULL_TEXT
def columns(self) -> List[str]:
return [self.field_name]
@dataclass
class HybridSearchRouteResult:
route: HybridSearchRoute
result: ScoredGlobalIndexResult
class HybridSearchRouteBuilder:
"""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
self.search_builder = search_builder
def execute_local(self):
return self.search_builder.execute_local()
class HybridSearchBuilder(ABC):
"""Builder to build hybrid search."""
@abstractmethod
def with_limit(self, limit: int) -> 'HybridSearchBuilder':
"""The final top k ranked results to return."""
pass
@abstractmethod
def with_ranker(self, ranker: str) -> 'HybridSearchBuilder':
"""Ranker for combining route results."""
pass
def with_rrf_ranker(self) -> 'HybridSearchBuilder':
"""Use reciprocal rank fusion to combine route results."""
return self.with_ranker(RRF_RANKER)
def with_weighted_score_ranker(self) -> 'HybridSearchBuilder':
"""Use weighted score to combine route results."""
return self.with_ranker(WEIGHTED_SCORE_RANKER)
@abstractmethod
def with_filter(self, predicate) -> 'HybridSearchBuilder':
"""Scalar predicate used to pre-filter every route before Top-K."""
pass
@abstractmethod
def with_partition_filter(self, partition_filter) -> 'HybridSearchBuilder':
"""Partition predicate used to prune index manifest entries."""
pass
@abstractmethod
def add_route(self, route: HybridSearchRoute) -> 'HybridSearchBuilder':
"""Add a hybrid-search route."""
pass
def add_vector_route(
self,
vector_column: str,
vector: List[float],
limit: int,
weight: float = 1.0,
options: Optional[Dict[str, str]] = None) -> 'HybridSearchBuilder':
"""Add a vector-search route."""
return self.add_route(
HybridSearchRoute.vector_route(
vector_column, vector, limit, weight, options))
def add_full_text_route(
self,
field_name: str,
query: str,
limit: int,
weight: float = 1.0,
options: Optional[Dict[str, str]] = None) -> 'HybridSearchBuilder':
"""Add a full-text-search route."""
return self.add_route(
HybridSearchRoute.full_text_route(
field_name, query, limit, weight, options))
@abstractmethod
def route_builders(self) -> List[HybridSearchRouteBuilder]:
"""Create one search builder for each route."""
pass
@abstractmethod
def to_route_result(
self,
route_builder: HybridSearchRouteBuilder,
result: GlobalIndexResult) -> HybridSearchRouteResult:
"""Convert a search result into a weighted route result."""
pass
@abstractmethod
def rank(
self,
route_results: List[HybridSearchRouteResult]) -> ScoredGlobalIndexResult:
"""Rank route results."""
pass
def execute_local(self) -> ScoredGlobalIndexResult:
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.table.source.search_dispatch import execute_hybrid_search
return execute_hybrid_search(self, executor)
class HybridSearchBuilderImpl(HybridSearchBuilder):
"""Implementation for HybridSearchBuilder."""
def __init__(self, table):
self._table = table
self._routes = []
self._limit = 0
self._ranker = RRF_RANKER
self._filter = None
self._partition_filter = None
def execute_local(self) -> ScoredGlobalIndexResult:
from pypaimon.table.source.search_dispatch import execute_hybrid_search
return execute_hybrid_search(self)
def execute(self, executor=None) -> ScoredGlobalIndexResult:
if executor is None:
return self.execute_local()
from pypaimon.table.source.search_dispatch import execute_hybrid_search
return execute_hybrid_search(self, executor)
def with_limit(self, limit: int) -> 'HybridSearchBuilder':
self._limit = limit
return self
def with_ranker(self, ranker: str) -> 'HybridSearchBuilder':
self._ranker = _normalize_ranker(ranker)
return self
def with_filter(self, predicate) -> 'HybridSearchBuilder':
if predicate is None:
return self
partition_filter, data_filter = self._split_partition_filter(predicate)
if partition_filter is not None:
self._add_partition_filter(partition_filter)
if data_filter is not None:
if self._filter is None:
self._filter = data_filter
else:
self._filter = PredicateBuilder.and_predicates(
[self._filter, data_filter])
return self
def with_partition_filter(self, partition_filter) -> 'HybridSearchBuilder':
if partition_filter is not None:
self._add_partition_filter(
self._validate_and_rebuild_partition_filter(partition_filter))
return self
def add_route(self, route: HybridSearchRoute) -> 'HybridSearchBuilder':
if route is None:
raise ValueError("Route cannot be None")
self._routes.append(route)
return self
def route_builders(self) -> List[HybridSearchRouteBuilder]:
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:
# 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.table.source.search_dispatch import rank_hybrid_results
return rank_hybrid_results(self, route_results)
def _validate_search(self):
if not self._routes:
raise ValueError("Routes cannot be empty")
if self._limit <= 0:
raise ValueError("Limit must be positive, got: %s" % self._limit)
def _new_vector_search_builder(self, route):
builder = (
self._table.new_vector_search_builder()
.with_vector_column(route.field_name)
.with_query_vector(route.vector)
.with_limit(route.limit)
.with_options(route.options)
)
if self._partition_filter is not None:
builder.with_partition_filter(self._partition_filter)
if self._filter is not None:
builder.with_filter(self._filter)
return builder
def _new_full_text_search_builder(self, route):
builder = (
self._table.new_full_text_search_builder()
.with_query(route.field_name, route.full_text_query)
.with_limit(route.limit)
)
if self._partition_filter is not None:
builder.with_partition_filter(self._partition_filter)
if self._filter is not None:
builder.with_filter(self._filter)
return builder
def _split_partition_filter(self, predicate):
partition_keys = list(self._table.partition_keys or [])
if not partition_keys:
return None, predicate
from pypaimon.read.push_down_utils import _get_all_fields, _split_and
partition_key_set = set(partition_keys)
pk_to_idx = {name: idx for idx, name in enumerate(partition_keys)}
partition_parts = []
data_parts = []
for part in _split_and(predicate):
if _get_all_fields(part).issubset(partition_key_set):
partition_parts.append(
self._rebuild_leaf_indices_by_name(part, pk_to_idx))
else:
data_parts.append(part)
return (
PredicateBuilder.and_predicates(partition_parts),
PredicateBuilder.and_predicates(data_parts),
)
def _validate_and_rebuild_partition_filter(self, partition_filter):
partition_keys = list(self._table.partition_keys or [])
if not partition_keys:
raise ValueError(
"with_partition_filter called on a non-partitioned table")
from pypaimon.read.push_down_utils import _get_all_fields
referenced = _get_all_fields(partition_filter)
extras = referenced - set(partition_keys)
if extras:
raise ValueError(
"Partition filter must reference only partition keys "
"(%s); got non-partition field(s): %s"
% (partition_keys, sorted(extras)))
return self._rebuild_leaf_indices_by_name(
partition_filter,
{name: idx for idx, name in enumerate(partition_keys)})
def _add_partition_filter(self, partition_filter):
if self._partition_filter is None:
self._partition_filter = partition_filter
else:
self._partition_filter = PredicateBuilder.and_predicates(
[self._partition_filter, partition_filter])
@classmethod
def _rebuild_leaf_indices_by_name(cls, predicate, name_to_idx):
if predicate.method in ('and', 'or'):
return predicate.new_literals(
[cls._rebuild_leaf_indices_by_name(c, name_to_idx)
for c in (predicate.literals or [])])
return predicate.new_index(name_to_idx[predicate.field])