| # 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 heapq |
| 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 ( |
| DictBasedScoredIndexResult, |
| ScoredGlobalIndexResult, |
| ) |
| |
| RRF_RANKER = "rrf" |
| WEIGHTED_SCORE_RANKER = "weighted_score" |
| MRR_RANKER = "mrr" |
| _RRF_K = 60.0 |
| |
| |
| 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) |
| 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 plus its configured search builder.""" |
| |
| 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 vector routes.""" |
| 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: |
| """Execute hybrid index search locally.""" |
| route_builders = self.route_builders() |
| route_results = [] |
| for route_builder in route_builders: |
| route_results.append( |
| self.to_route_result( |
| route_builder, route_builder.execute_local())) |
| return self.rank(route_results) |
| |
| |
| 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 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]: |
| self._validate_search() |
| builders = [] |
| for route in self._routes: |
| if route.is_vector(): |
| builders.append( |
| HybridSearchRouteBuilder( |
| route, self._new_vector_search_builder(route))) |
| else: |
| builders.append( |
| HybridSearchRouteBuilder( |
| route, self._new_full_text_search_builder(route))) |
| 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: |
| 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 self._weighted_score(non_empty) |
| if self._ranker == MRR_RANKER: |
| return self._mrr(non_empty) |
| return self._rrf(non_empty) |
| |
| 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) |
| if self._filter is not None: |
| for route in self._routes: |
| if route.is_full_text(): |
| raise ValueError( |
| "Hybrid search with full-text routes does not support " |
| "non-partition filters because full-text indexes cannot " |
| "apply row-id pre-filters before top-k ranking.") |
| |
| 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) |
| return builder |
| |
| def _rrf(self, route_results): |
| scores = {} |
| for route_result in route_results: |
| 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 |
| return _top_k(scores, self._limit) |
| |
| def _mrr(self, route_results): |
| scores = {} |
| for route_result in route_results: |
| 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 |
| return _top_k(scores, self._limit) |
| |
| def _weighted_score(self, route_results): |
| scores = {} |
| for route_result in route_results: |
| result = route_result.result |
| weight = 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: (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 1.0) |
| scores[row_id] = scores.get(row_id, 0.0) + weight * normalized |
| return _top_k(scores, self._limit) |
| |
| 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]) |
| |
| |
| 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)) |
| |
| |
| 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: score for score, neg_row_id in heap} |
| return DictBasedScoredIndexResult(top_scores) |