blob: 9e994258277a516871ae81c9557cac5d8600f2e9 [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.
"""Ray execution of snapshot-pinned, single-vector queries."""
from contextlib import closing
import math
from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult
from pypaimon.table.source.vector_search_read import (
DataEvolutionVectorRead,
_filtered_raw_row_ranges,
_offer_score,
_scored_result,
)
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
def _execute_vector_search(builder, *, concurrency=None, ray_remote_args=None):
"""Execute the builder of an already snapshot-pinned ``VectorQuery``."""
concurrency, remote_args = _execution_options(concurrency, ray_remote_args)
reader = builder.new_vector_search_read()
if (type(reader) is not DataEvolutionVectorRead
or not reader._table.options.data_evolution_enabled()):
raise ValueError("Ray vector search supports only data-evolution tables.")
if not all(math.isfinite(float(value)) for value in reader._query_vector):
raise ValueError("Ray vector search requires a finite query vector.")
_require_ray()
plan = builder.new_vector_search_scan().scan()
return _RayVectorSearchRead(reader, concurrency, remote_args).read_plan(plan)
def _execution_options(concurrency, ray_remote_args):
concurrency = 4 if concurrency is None else concurrency
if isinstance(concurrency, bool) or not isinstance(concurrency, int) or concurrency < 1:
raise ValueError("concurrency must be a positive integer.")
remote_args = dict(ray_remote_args or {})
if "num_returns" in remote_args:
raise ValueError("Vector search manages num_returns; omit it from ray_remote_args.")
return concurrency, remote_args
def _require_ray():
try:
import ray # noqa: F401
except ModuleNotFoundError as error:
if error.name != "ray":
raise
raise ImportError("Ray vector search requires pypaimon[ray].") from error
class _RayVectorSearchRead(DataEvolutionVectorRead):
"""Replace split execution while retaining local merge/refinement semantics."""
def __init__(self, reader, concurrency, remote_args):
super().__init__(
reader._table, reader._limit, reader._vector_column, reader._query_vector,
reader._filter, reader._partition_filter, reader._options)
self._concurrency = concurrency
self._remote_args = remote_args
def _search_index_splits(self, splits, query, search_limit, pre_filters, batch=False):
# Filters are planned once on the driver, at the query snapshot. Each
# worker receives only its own include-row bitmap, not a table-wide set.
items = [(split, None if not pre_filters or pre_filters[i] is None
else pre_filters[i].serialize()) for i, split in enumerate(splits)]
context = (self._table, self._vector_column, query, search_limit, self._options)
with closing(_map_tasks(
_search_index_split, context, items, self._concurrency, self._remote_args, True)) as tasks:
for _, (metric, scores) in tasks:
if metric is not None:
self._set_index_metric(metric)
# Keep plan order, including duplicate-row precedence, regardless
# of worker completion order. Do not refine or truncate per worker.
yield DictBasedScoredIndexResult(scores)
del scores
def _read_raw_search(self, raw_row_ranges, pre_filter, query_vector,
index_type=None, include_filter=True,
score_candidates=None, snapshot=None):
ranges = _filtered_raw_row_ranges(raw_row_ranges, pre_filter)
if score_candidates is not None:
# Refine only the globally selected candidates. Intersect before
# planning so workers neither read nor score other rows.
ranges = _filtered_raw_row_ranges(ranges, score_candidates.to_range_list())
if not ranges:
return DictBasedScoredIndexResult({})
table_read, splits = self._plan_raw_read(ranges, include_filter, snapshot)
context = (table_read, self._vector_column, query_vector,
self._limit, self._search_metric(index_type))
heap = []
# Keep each planned data split intact: its files may be vertical column
# groups that must be read together to reconstruct a logical row.
with closing(_map_tasks(
_search_raw_split, context, splits, self._concurrency, self._remote_args)) as tasks:
for _, scores in tasks:
for row_id, score in scores.items():
_offer_score(heap, self._limit, row_id, score)
return _scored_result(heap)
def _search_index_split(context, item):
table, column, query, limit, options = context
split, include_bytes = item
reader = DataEvolutionVectorRead(table, limit, column, query, options=options)
include = None if include_bytes is None else RoaringBitmap64.deserialize(include_bytes)
result = reader._eval(
split.row_range_start, split.row_range_end,
split.vector_index_files, query, limit, include).result()
# The reader closes its native resources before returning a completed result.
# Return metric even for zero hits, so inconsistent indexes cannot be hidden
# by a scalar filter and raw fallback uses the persisted index metric.
return reader._index_metric, _scores(result)
def _search_raw_split(context, split):
from pypaimon.read.table_read import _ClosableArrowBatchReader
table_read, column, query, limit, metric = context
scorer = DataEvolutionVectorRead(table_read.table, limit, column, query)
reader, batches = table_read._new_arrow_batch_reader([split])
with _ClosableArrowBatchReader(reader, batches) as batch_reader:
return _scores(scorer._score_raw_batches(batch_reader, query, metric, reject_nan=True))
def _scores(result):
if result is None:
return {}
getter = result.score_getter()
scores = {row_id: getter(row_id) for row_id in result.results()}
if any(math.isnan(score) for score in scores.values()):
raise ValueError("Ray vector search cannot rank NaN scores.")
return scores
def _map_tasks(worker, context, items, concurrency, remote_args, ordered=False):
"""Bound in-flight work and, optionally, completed results awaiting plan order."""
import ray
if not items:
return
# Share only serializable planning state, never open readers or closures.
context_ref = ray.put(context)
remote = ray.remote(worker).options(**remote_args)
remaining = iter(enumerate(items))
pending = {}
buffered = {}
next_ordinal = 0
try:
while True:
while len(pending) + len(buffered) < concurrency:
item = next(remaining, None)
if item is None:
break
ordinal, split = item
pending[remote.remote(context_ref, split)] = ordinal
if not pending:
break
ready, _ = ray.wait(list(pending), num_returns=1)
ref = ready[0]
result = ray.get(ref)
ordinal = pending.pop(ref)
if ordered:
buffered[ordinal] = result
del result
while next_ordinal in buffered:
yield next_ordinal, buffered.pop(next_ordinal)
next_ordinal += 1
else:
yield ordinal, result
del result
finally:
for ref in pending:
try:
ray.cancel(ref)
except Exception:
# Preserve the search failure if the cluster is already gone.
pass