blob: 530642a16a936996b0a8095c473db41a0b773d14 [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.
from pypaimon.table.source.search_types import (FullTextRead)
"""Full-text read to read index files."""
from copy import copy
from concurrent.futures import wait
from io import BytesIO
from typing import Dict, List
from pypaimon.common.options.core_options import CoreOptions
from pypaimon.common.options.options import Options
from pypaimon.globalindex.full_text_search import FullTextSearch
from pypaimon.globalindex.global_index_meta import GlobalIndexIOMeta
from pypaimon.globalindex.global_index_result import GlobalIndexResult
from pypaimon.execution.python.globalindex.offset_global_index_reader import OffsetGlobalIndexReader
from pypaimon.globalindex.vector_search_result import (
DictBasedScoredIndexResult,
ScoredGlobalIndexResult,
)
from pypaimon.execution.python.read.search import global_index_live_row_filter
from pypaimon.table.source.full_text_search_split import (
FullTextSearchSplit,
IndexFullTextSearchSplit,
RawFullTextSearchSplit,
)
from pypaimon.execution.python.read.search.full_text_scan import FullTextScanPlan
from pypaimon.utils.range import Range
from pypaimon.utils.roaring_bitmap import RoaringBitmap64
_UNSET = object()
class DataEvolutionFullTextRead(FullTextRead):
"""Implementation for FullTextRead."""
def __init__(
self,
table: 'FileStoreTable',
limit: int,
text_column,
query: str,
partition_filter=None,
filter_=None,
):
self._table = table
self._limit = limit
self._text_columns = text_column if isinstance(text_column, list) else [text_column]
if len(self._text_columns) != 1:
raise ValueError(
"Full-text search expects exactly one text column, got: %s"
% self._text_columns)
self._query = query
self._partition_filter = partition_filter
self._filter = filter_
def _with_executor(self, executor):
from pypaimon.execution.python.read.search.distributed_search import create_distributed_reader
return create_distributed_reader(self, executor)
def read_plan(self, plan: FullTextScanPlan, executor=None) -> GlobalIndexResult:
reader = copy(self if executor is None else self._with_executor(executor))
reader._table = global_index_live_row_filter.table_at_snapshot(self._table, plan.snapshot())
reader._snapshot = plan.snapshot()
return reader.read(plan.splits())
def read(self, splits: List[FullTextSearchSplit]) -> GlobalIndexResult:
index_splits, raw_splits = _split_search_splits(splits)
if not index_splits and not raw_splits:
return GlobalIndexResult.create_empty()
splits_by_column: Dict[str, List[IndexFullTextSearchSplit]] = {}
for split in index_splits:
splits_by_column.setdefault(split.column_name, []).append(split)
live_rows = global_index_live_row_filter.live_rows(
self._table, self._partition_filter)
matched = self._matched_indexed_rows(index_splits, live_rows)
indexed_result = self._eval_column_query(splits_by_column, matched if self._filter is not None else live_rows)
raw_ranges = _raw_row_ranges(raw_splits)
if self._filter is None:
raw_result = self._read_raw_search(raw_ranges, _index_type(index_splits))
else:
candidates = GlobalIndexResult.from_ranges(raw_ranges).results()
if live_rows is not None:
candidates = RoaringBitmap64.and_(candidates, live_rows)
matched = self._matching_rows(candidates)
raw_result = self._read_raw_search(raw_ranges, _index_type(index_splits), matched)
return indexed_result.or_(raw_result).top_k(self._limit)
def _matching_rows(self, candidates):
from pypaimon.execution.python.read.search.global_index_row_filter import matching_rows
table = self._table.copy({'scalar-index.search-mode': 'full'})
return matching_rows(table, self._filter, candidates, self._partition_filter)
def _matched_indexed_rows(self, splits, live_rows, scalar_result=_UNSET):
if self._filter is None:
return live_rows
from pypaimon.common.options.core_options import GlobalIndexSearchMode
from pypaimon.execution.python.globalindex.data_evolution_global_index_coverage import (
DataEvolutionGlobalIndexCoverage,
)
from pypaimon.snapshot.time_travel_util import TimeTravelUtil
from pypaimon.execution.python.read.search.global_index_row_filter import is_exact
covered = RoaringBitmap64()
files = {}
for split in splits:
covered.add_range(split.row_range_start, split.row_range_end)
files.update((file.file_name, file) for file in split.scalar_index_files)
if live_rows is not None:
covered = RoaringBitmap64.and_(covered, live_rows)
if covered.is_empty():
return covered
snapshot = getattr(self, '_snapshot', None)
if snapshot is None:
snapshot = TimeTravelUtil.resolve_snapshot(self._table)
mode = self._table.options.scalar_index_search_mode()
scalar_files = list(files.values())
coverage = DataEvolutionGlobalIndexCoverage(self._table, snapshot, self._partition_filter, scalar_files)
raw_ranges = coverage.unindexed_ranges(self._table.fields, self._filter, mode)
unindexed = RoaringBitmap64.and_(GlobalIndexResult.from_ranges(raw_ranges).results(), covered)
decided = RoaringBitmap64.remove_all(covered, unindexed)
matched = RoaringBitmap64()
if not decided.is_empty():
if scalar_result is _UNSET:
scalar_result = self._scalar_index_result(scalar_files, snapshot)
if scalar_result is not None:
candidates = RoaringBitmap64.and_(scalar_result.results(), decided)
if is_exact(self._filter, scalar_result):
matched = candidates
elif self._table.options.global_index_filter_refine_from_data():
matched = self._matching_rows(candidates)
elif mode != GlobalIndexSearchMode.FAST:
unindexed = RoaringBitmap64.or_(unindexed, decided)
return RoaringBitmap64.or_(matched, self._matching_rows(unindexed))
def _scalar_index_result(self, scalar_files, snapshot):
from pypaimon.execution.python.globalindex.data_evolution_global_index_scanner import (
DataEvolutionGlobalIndexScanner,
)
scanner = DataEvolutionGlobalIndexScanner.create(
self._table, index_files=scalar_files, snapshot=snapshot, partition_filter=self._partition_filter)
if scanner is None:
return None
try:
evaluation = scanner.scan_with_coverage(self._filter)
return None if evaluation is None else evaluation.result
finally:
scanner.close()
def _eval_column_query(
self,
splits_by_column: Dict[str, List[IndexFullTextSearchSplit]],
live_rows
) -> ScoredGlobalIndexResult:
column = self._text_columns[0].name
splits = splits_by_column.get(column, [])
if not splits:
return ScoredGlobalIndexResult.create_empty()
reader = copy(self)
reader._limit = _index_candidate_limit(splits, self._limit)
futures = []
for split in splits:
include_row_ids = global_index_live_row_filter.for_range(
live_rows, split.row_range_start, split.row_range_end)
if include_row_ids is not None and include_row_ids.is_empty():
continue
futures.append(reader._eval(
split.row_range_start, split.row_range_end,
split.full_text_index_files,
include_row_ids,
))
if not futures:
return ScoredGlobalIndexResult.create_empty()
wait(futures)
merged_scores = {}
for future in futures:
split_result = future.result()
if split_result is not None:
score_getter = split_result.score_getter()
for row_id in split_result.results():
if row_id not in merged_scores:
merged_scores[row_id] = score_getter(row_id)
return DictBasedScoredIndexResult(merged_scores).top_k(self._limit)
def _eval(self, row_range_start, row_range_end, full_text_index_files,
include_row_ids):
index_io_meta_list = []
for index_file in full_text_index_files:
meta = index_file.global_index_meta
assert meta is not None
index_io_meta_list.append(
GlobalIndexIOMeta(
file_name=index_file.file_name,
file_size=index_file.file_size,
metadata=meta.index_meta,
external_path=index_file.external_path,
)
)
index_type = full_text_index_files[0].index_type
index_path = self._table.path_factory().global_index_path_factory().index_path()
file_io = self._table.file_io
reader = _create_full_text_reader(
index_type, file_io, index_path,
index_io_meta_list, row_range_end - row_range_start + 1
)
full_text_search = FullTextSearch(
field_name=self._text_columns[0].name,
query=self._query,
limit=min(self._limit, row_range_end - row_range_start + 1),
)
if include_row_ids is not None:
full_text_search = full_text_search.with_include_row_ids(include_row_ids)
offset_reader = OffsetGlobalIndexReader(reader, row_range_start, row_range_end)
try:
future = offset_reader.visit_full_text_search(full_text_search)
except BaseException:
reader.close()
raise
future.add_done_callback(lambda _: reader.close())
return future
def _read_raw_search(self, raw_row_ranges, index_type, include_row_ids=None):
raw_row_ranges = Range.sort_and_merge_overlap(raw_row_ranges, True)
if not raw_row_ranges:
return DictBasedScoredIndexResult({})
if index_type is not None and not _supports_full_text_search(index_type):
raise ValueError(f"Unsupported full-text index type: '{index_type}'")
row_range_start = raw_row_ranges[0].from_
search_kwargs = {}
if include_row_ids is not None:
raw_rows = GlobalIndexResult.from_ranges(raw_row_ranges).results()
include_row_ids = RoaringBitmap64.and_(include_row_ids, raw_rows)
if include_row_ids.is_empty():
return DictBasedScoredIndexResult({})
search_kwargs["include_row_ids"] = include_row_ids
# Build the same corpus as an unfiltered query so BM25 statistics stay
# unchanged. Filter row IDs in the native search, before selecting hits.
table = self._read_raw_rows(raw_row_ranges)
if table is None or table.num_rows == 0:
return DictBasedScoredIndexResult({})
from pypaimon.table.special_fields import SpecialFields
row_ids = table.column(SpecialFields.ROW_ID.name).to_pylist()
texts = table.column(self._text_columns[0].name).to_pylist()
index_bytes = self._build_raw_index(row_ids, texts, row_range_start)
if index_bytes is None:
return DictBasedScoredIndexResult({})
return _search_raw_full_text(
index_bytes,
row_range_start,
self._query,
min(self._limit, sum(row_range.count() for row_range in raw_row_ranges)),
max_candidates=table.num_rows,
**search_kwargs,
).top_k(self._limit)
def _read_raw_rows(self, raw_row_ranges):
read_table = self._table.copy_without_time_travel({
'read.native.enabled': 'false', 'scan.native-plan.enabled': 'false'})
read_builder = read_table.new_read_builder()
if self._partition_filter is not None:
read_builder = read_builder.with_partition_filter(self._partition_filter)
from pypaimon.table.special_fields import SpecialFields
projection = [self._text_columns[0].name, SpecialFields.ROW_ID.name]
read_builder = read_builder.with_projection(projection)
plan = read_builder.new_scan().with_row_ranges(raw_row_ranges).plan()
return read_builder.new_read().to_arrow(plan.splits())
def _build_raw_index(self, row_ids, texts, row_range_start):
try:
from paimon_ftindex import FullTextIndexWriter
except ImportError as e:
raise ImportError(
"paimon-ftindex is required to search uncovered full-text "
"row ranges. Install paimon-ftindex==0.1.0 or "
"pypaimon[full-text]."
) from e
from pypaimon.globalindex.full_text_options import NativeFullTextIndexOptions
writer = FullTextIndexWriter(
NativeFullTextIndexOptions.from_options(
_raw_search_options(self._table, self._text_columns[0].name)).to_native_options())
indexed = False
try:
for row_id, text in zip(row_ids, texts):
if text is None:
continue
writer.add_document(
int(row_id) - row_range_start,
_materialize_text(text),
)
indexed = True
if not indexed:
return None
output = BytesIO()
writer.write(output)
return output.getvalue()
finally:
writer.close()
def _index_candidate_limit(splits, limit):
ranges = sorted((split.row_range_start, split.row_range_end) for split in splits)
if any(start <= previous_end for (_, previous_end), (start, _) in zip(ranges, ranges[1:])):
# Overlapping shards may assign different scores to the same row.
# Preserve first-shard precedence before truncating the global result.
return min(max(end - start + 1 for start, end in ranges), 2147483647)
return limit
def _create_full_text_reader(index_type, file_io, index_path, index_io_meta_list, max_candidates=None):
"""Create a global index reader for full-text search."""
from pypaimon.globalindex.full_text_options import FULL_TEXT_IDENTIFIER
if index_type == FULL_TEXT_IDENTIFIER:
from pypaimon.execution.python.read.native_full_text_global_index_reader import NativeFullTextGlobalIndexReader
return NativeFullTextGlobalIndexReader(file_io, index_path, index_io_meta_list, max_candidates)
raise ValueError(f"Unsupported full-text index type: '{index_type}'")
def _supports_full_text_search(index_type):
from pypaimon.globalindex.full_text_options import FULL_TEXT_IDENTIFIER
return index_type == FULL_TEXT_IDENTIFIER
def _split_search_splits(splits):
index_splits = []
raw_splits = []
for split in splits:
if isinstance(split, IndexFullTextSearchSplit):
index_splits.append(split)
elif isinstance(split, RawFullTextSearchSplit):
raw_splits.append(split)
else:
raise ValueError(
"Unsupported full-text search split: %s"
% type(split).__name__)
return index_splits, raw_splits
def _raw_row_ranges(raw_splits):
ranges = []
for split in raw_splits:
ranges.extend(split.row_ranges)
return Range.sort_and_merge_overlap(ranges, True)
def _index_type(index_splits):
for split in index_splits:
if split.full_text_index_files:
return split.full_text_index_files[0].index_type
return None
def _search_raw_full_text(index_bytes, row_range_start, query, limit, include_row_ids=None, max_candidates=None):
from paimon_ftindex import FullTextIndexReader
from pypaimon.execution.python.read.native_full_text_global_index_reader import (
PaimonFullTextInput,
_search_top_k,
)
reader = FullTextIndexReader(PaimonFullTextInput(BytesIO(index_bytes)))
try:
search_kwargs = {}
if include_row_ids is not None:
relative_ids = GlobalIndexResult.create(include_row_ids).offset(-row_range_start).results()
search_kwargs["filter_bytes"] = relative_ids.serialize()
result = _search_top_k(reader, query, limit, max_candidates=max_candidates, **search_kwargs)
return DictBasedScoredIndexResult(
{
row_range_start + row_id: result.score_getter()(row_id)
for row_id in result.results()
})
finally:
reader.close()
def _raw_search_options(table, column):
table_options = getattr(getattr(table, "options", None), "options", None)
if table_options is not None:
options = dict(table_options.to_map())
else:
options = dict(getattr(getattr(table, "table_schema", None), "options", {}) or {})
resolved = CoreOptions(Options(options)).global_index_options("full-text", column).to_map()
resolved["full-text.searcher-pool.max-size"] = "0"
return resolved
def _materialize_text(value) -> str:
if hasattr(value, "as_py"):
value = value.as_py()
if not isinstance(value, str):
raise ValueError("Unsupported field type: %s" % type(value).__name__)
return value