blob: 78ac0462cc16990d733f13c067b815e4a1094d2b [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.
"""Python search tasks and global result coordination for distributed executors."""
from collections import Counter
import math
from pypaimon.globalindex.batch_vector_search import BatchVectorSearch
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult
from pypaimon.execution.python.read.search.vector_search_read import (
BatchVectorSearchReadImpl, DataEvolutionVectorRead,
_filtered_raw_row_ranges, _offer_score, _scored_result, _split_search_splits,
)
from pypaimon.execution.python.read.search import global_index_live_row_filter
from pypaimon.execution.python.read.search.distributed_search import closing_task_results
from pypaimon.table.source.search_execution import SearchTask
from pypaimon.utils.range import Range
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
class DistributedVectorSearchRead(DataEvolutionVectorRead):
"""Replace split execution while retaining local merge/refinement semantics."""
def __init__(self, reader, executor):
super().__init__(
reader._table, reader._limit, reader._vector_column, reader._query_vector,
reader._filter, reader._partition_filter, reader._options)
self._executor = executor
def _prepare_search_splits(self, splits, snapshot):
# Scalar evaluation and candidate verification belong to the workers.
return _split_search_splits(splits)
def _search_index_splits(self, splits, query, search_limit, snapshot, batch=False):
if not splits:
return
# Send predicates and metadata, leaving each worker to build and consume
# its own filter. Batch queries reuse the same filter for every vector.
context = (self._table, self._vector_column, query, search_limit, self._options,
self._filter, self._partition_filter, snapshot)
work = _index_tasks(_search_index_split, context, splits, self._filter)
with closing_task_results(self._executor.map(work, ordered=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.
work = [SearchTask(_search_raw_split, context, split) for split in splits]
with closing_task_results(self._executor.map(work)) 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)
class DistributedBatchVectorSearchRead(BatchVectorSearchReadImpl):
def __init__(self, reader, executor):
super().__init__(reader._table, reader._limit, reader._vector_column, reader._query_vectors,
reader._filter, reader._partition_filter, reader._options)
self._executor = executor
def _prepare_search_splits(self, splits, snapshot):
# Scalar evaluation and candidate verification belong to the workers.
return _split_search_splits(splits)
def _search_index_splits(self, splits, query, search_limit, snapshot, batch=False):
if not splits:
return
# Send predicates and metadata, leaving each worker to build and consume
# its own filter. Batch queries reuse the same filter for every vector.
context = (self._table, self._vector_column, query, search_limit, self._options,
self._filter, self._partition_filter, snapshot)
work = _index_tasks(_search_batch_index_split, context, splits, self._filter)
with closing_task_results(self._executor.map(work, ordered=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))
work = [SearchTask(_search_batch_raw_split, context, split) for split in splits]
with closing_task_results(self._executor.map(work)) 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]
work = [SearchTask(_search_batch_refine_split, context, split) for split in splits]
with closing_task_results(self._executor.map(work)) 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_index_split(context, split, scalar_filter=None):
table, column, query, limit, options, filter_, partition_filter, snapshot = context
# Explicit shard indexes are evaluated below. Candidate verification must
# not rediscover and scan table-wide scalar indexes in every worker.
table = table.copy({"global-index.enabled": "false"})
reader = DataEvolutionVectorRead(
table, limit, column, query, filter_, partition_filter, options)
include = _worker_pre_filter(reader, split, snapshot, scalar_filter)
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 _worker_pre_filter(reader, split, snapshot, scalar_filter):
live_rows = global_index_live_row_filter.live_rows(
reader._table, reader._partition_filter, snapshot,
row_ranges=[Range(split.row_range_start, split.row_range_end)])
if scalar_filter is None:
matched = reader._scalar_matched_rows([split], snapshot)
else:
bitmap, exact = scalar_filter
result = None if bitmap is None else GlobalIndexResult.create(
RoaringBitmap64.deserialize(bitmap), is_exact=exact)
matched = reader._refine_scalar_result([split], result, snapshot)
filters = reader._combine_pre_filters([split], live_rows, matched)
return filters[0] if filters else None
def _prepare_scalar_filter(context, split):
table, column, query, limit, options, filter_, partition_filter, snapshot = context
reader = DataEvolutionVectorRead(table, limit, column, query, filter_, partition_filter, options)
result = reader._scalar_index_result([split], snapshot)
return (None, False) if result is None else (result.results().serialize(), result.is_exact())
def _index_tasks(operation, context, splits, predicate, scalar_operation=None):
"""Share identical scalar work while each shard verifies its own candidates."""
scalar_operation = scalar_operation or _prepare_scalar_filter
def key(split):
return tuple(sorted(file.file_name for file in split.scalar_index_files))
counts = Counter(key(split) for split in splits) if predicate is not None else {}
shared = {}
tasks = []
for split in splits:
files = key(split) if counts else None
dependencies = ()
if files and counts.get(files, 0) > 1:
if files not in shared:
shared[files] = SearchTask(scalar_operation, context, split)
dependencies = (shared[files],)
tasks.append(SearchTask(operation, context, split, dependencies))
return tasks
def _search_raw_split(context, split):
from pypaimon.read.read_support 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 _search_batch_index_split(context, split, scalar_filter=None):
table, column, queries, limit, options, filter_, partition_filter, snapshot = context
# Explicit shard indexes are evaluated below. Candidate verification must
# not rediscover and scan table-wide scalar indexes in every worker.
table = table.copy({"global-index.enabled": "false"})
scorer = BatchVectorSearchReadImpl(
table, limit, column, queries, filter_, partition_filter, options)
include = _worker_pre_filter(scorer, split, snapshot, scalar_filter)
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(_batch_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.read_support 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 [_batch_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 [_batch_scores(result) for result in scorer._score_refine_splits(
table_read, [split], queries_by_row, queries, metric)]
def _batch_scores(result):
if result is None:
return {}
getter = result.score_getter()
return {row_id: getter(row_id) for row_id in result.results()}