blob: 010a609fd4f983c7e4fba1f1620e0fd989b9570f [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 full-text tasks preserve corpus statistics and plan-ordered merging."""
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult
from pypaimon.execution.python.read.search.full_text_read import (
DataEvolutionFullTextRead, _index_candidate_limit, _index_type, _raw_row_ranges, _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.execution.python.read.search.distributed_vector_search import _index_tasks, _scores
from pypaimon.table.source.search_execution import SearchTask
from pypaimon.utils.range import Range
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
class DistributedFullTextRead(DataEvolutionFullTextRead):
def __init__(self, reader, executor):
super().__init__(reader._table, reader._limit, reader._text_columns, reader._query,
reader._partition_filter, reader._filter)
self._executor = executor
def _worker_reader(self):
reader = DataEvolutionFullTextRead(
self._table.copy({"global-index.enabled": "false"}), self._limit, self._text_columns, self._query,
self._partition_filter, self._filter)
reader._snapshot = self._snapshot
return reader
def read(self, splits):
# Keep bitmap construction and candidate data reads off the driver.
index_splits, raw_splits = _split_search_splits(splits)
splits_by_column = {}
for split in index_splits:
splits_by_column.setdefault(split.column_name, []).append(split)
indexed = self._eval_column_query(splits_by_column, None)
raw = self._read_raw_search(_raw_row_ranges(raw_splits), _index_type(index_splits))
return indexed.or_(raw).top_k(self._limit)
def _eval_column_query(self, splits_by_column, live_rows):
items = splits_by_column.get(self._text_columns[0].name, [])
if not items:
return DictBasedScoredIndexResult({})
merged = {}
reader = self._worker_reader()
reader._limit = _index_candidate_limit(items, self._limit)
work = _index_tasks(_search_index, reader, items, self._filter, _prepare_scalar_filter)
with closing_task_results(self._executor.map(work, ordered=True)) as tasks:
for _, scores in tasks:
# Duplicate row IDs keep the first planned shard's score.
for row_id, score in scores.items():
merged.setdefault(row_id, score)
return DictBasedScoredIndexResult(merged).top_k(self._limit)
def _read_raw_search(self, ranges, index_type, include_row_ids=None):
if not ranges:
return DictBasedScoredIndexResult({})
include = None if include_row_ids is None else include_row_ids.serialize()
# Splitting this corpus would change document frequencies and BM25.
# Keep the existing local corpus intact, but read/build/search on a worker.
work = [SearchTask(_search_raw, self._worker_reader(), (ranges, index_type, include))]
with closing_task_results(self._executor.map(work)) as tasks:
for _, scores in tasks:
return DictBasedScoredIndexResult(scores)
def _search_index(reader, split, scalar_filter=None):
live = global_index_live_row_filter.live_rows(
reader._table, reader._partition_filter, reader._snapshot,
row_ranges=[Range(split.row_range_start, split.row_range_end)])
if scalar_filter is None:
include = reader._matched_indexed_rows([split], live)
else:
bitmap, exact = scalar_filter
result = None if bitmap is None else GlobalIndexResult.create(
RoaringBitmap64.deserialize(bitmap), is_exact=exact)
include = reader._matched_indexed_rows([split], live, result)
if include is not None and include.is_empty():
return {}
return _scores(reader._eval(split.row_range_start, split.row_range_end,
split.full_text_index_files, include).result())
def _prepare_scalar_filter(reader, split):
result = reader._scalar_index_result(split.scalar_index_files, reader._snapshot)
return (None, False) if result is None else (result.results().serialize(), result.is_exact())
def _search_raw(reader, item):
ranges, index_type, include = item
include = None if include is None else RoaringBitmap64.deserialize(include)
if reader._filter is not None:
live = global_index_live_row_filter.live_rows(
reader._table, reader._partition_filter, reader._snapshot, row_ranges=ranges)
candidates = GlobalIndexResult.from_ranges(ranges).results()
if live is not None:
candidates = RoaringBitmap64.and_(candidates, live)
if include is not None:
candidates = RoaringBitmap64.and_(candidates, include)
include = reader._matching_rows(candidates)
return _scores(reader._read_raw_search(ranges, index_type, include))