blob: b7b4303a6f30a0acd5d69ee00daa61f9c4139269 [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 batch vector queries."""
from contextlib import closing
from pypaimon.globalindex.batch_vector_search import BatchVectorSearch
from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult
from pypaimon.ray.vector_search import _execution_options, _map_tasks, _require_ray
from pypaimon.table.source.vector_search_read import (
BatchVectorSearchReadImpl, _filtered_raw_row_ranges, _offer_score, _scored_result,
)
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
def _execute_batch_vector_search(builder, *, concurrency=None, ray_remote_args=None):
concurrency, remote_args = _execution_options(concurrency, ray_remote_args)
reader = builder.new_batch_vector_search_read()
if (type(reader) is not BatchVectorSearchReadImpl
or not reader._table.options.data_evolution_enabled()):
raise ValueError("Ray vector search supports only data-evolution tables.")
_require_ray()
plan = builder.new_vector_search_scan().scan()
return _RayBatchVectorSearchRead(reader, concurrency, remote_args).read_batch_plan(plan)
class _RayBatchVectorSearchRead(BatchVectorSearchReadImpl):
def __init__(self, reader, concurrency, remote_args):
super().__init__(reader._table, reader._limit, reader._vector_column, reader._query_vectors,
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):
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_batch_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)
# Retain plan order for duplicate IDs and global per-query selection.
yield [DictBasedScoredIndexResult(values) for values in scores]
del scores
def _read_raw_batch_search(self, raw_row_ranges, pre_filter, index_type=None, snapshot=None):
heaps = [[] for _ in self._query_vectors]
ranges = _filtered_raw_row_ranges(raw_row_ranges, pre_filter)
if not ranges:
return [_scored_result(heap) for heap in heaps]
table_read, splits = self._plan_raw_read(ranges, True, snapshot)
context = (table_read, self._vector_column, self._query_vectors,
self._limit, self._search_metric(index_type))
with closing(_map_tasks(
_search_batch_raw_split, context, splits, self._concurrency, self._remote_args)) as tasks:
for _, results in tasks:
for heap, scores in zip(heaps, results):
for row_id, score in scores.items():
_offer_score(heap, self._limit, row_id, score)
return [_scored_result(heap) for heap in heaps]
def _stream_rerank_candidates(self, candidates, union_candidates, query_vectors,
index_type, snapshot):
# Candidates have already been selected globally, separately per query.
queries_by_row = {}
for query_index, result in enumerate(candidates):
for row_id in result.results():
queries_by_row.setdefault(row_id, []).append(query_index)
table_read, splits = self._plan_raw_read(
union_candidates.to_range_list(), include_filter=False, snapshot=snapshot)
context = (table_read, self._vector_column, query_vectors, self._limit,
self._search_metric(index_type), queries_by_row)
heaps = [[] for _ in query_vectors]
with closing(_map_tasks(
_search_batch_refine_split, context, splits, self._concurrency, self._remote_args)) as tasks:
for _, results in tasks:
for heap, scores in zip(heaps, results):
for row_id, score in scores.items():
_offer_score(heap, self._limit, row_id, score)
return [_scored_result(heap) for heap in heaps]
def _search_batch_index_split(context, item):
table, column, queries, limit, options = context
split, include_bytes = item
scorer = BatchVectorSearchReadImpl(table, limit, column, queries, options=options)
include = None if include_bytes is None else RoaringBitmap64.deserialize(include_bytes)
reader, offset_reader = scorer._open_offset_reader(
split.vector_index_files, split.row_range_start, split.row_range_end)
try:
# Bound native query-matrix scratch to at most 64 queries / 4 MiB
# (or one vector), keeping the shard open across all query blocks.
block_size = max(1, min(64, (1 << 20) // max(1, len(queries[0]))))
results = []
for start in range(0, len(queries), block_size):
search = BatchVectorSearch(queries[start:start + block_size], limit, column.name,
include_row_ids=include, options=options)
results.extend(_scores(result) for result in offset_reader.visit_batch_vector_search(search).result())
return scorer._index_metric, results
finally:
reader.close()
def _search_batch_raw_split(context, split):
from pypaimon.read.table_read import _ClosableArrowBatchReader
table_read, column, queries, limit, metric = context
scorer = BatchVectorSearchReadImpl(table_read.table, limit, column, queries)
reader, batches = table_read._new_arrow_batch_reader([split])
with _ClosableArrowBatchReader(reader, batches) as batch_reader:
return [_scores(result) for result in scorer._score_raw_batch_queries(batch_reader, metric)]
def _search_batch_refine_split(context, split):
table_read, column, queries, limit, metric, queries_by_row = context
scorer = BatchVectorSearchReadImpl(table_read.table, limit, column, queries)
# Read the candidate union once, scoring each row only for its own queries.
return [_scores(result) for result in scorer._score_refine_splits(
table_read, [split], queries_by_row, queries, metric)]
def _scores(result):
if result is None:
return {}
getter = result.score_getter()
return {row_id: getter(row_id) for row_id in result.results()}