blob: cc038dd470775396c916749851643dc21c19554e [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 io import BytesIO
from unittest.mock import patch
import pytest
from pypaimon.globalindex.full_text_options import NativeFullTextIndexOptions
from pypaimon.execution.python.read.native_full_text_global_index_reader import (
PaimonFullTextInput,
_search_top_k,
)
from pypaimon.globalindex.global_index_reader import _completed_future
from pypaimon.globalindex.vector_search_result import DictBasedScoredIndexResult
from pypaimon.execution.python.read.search.full_text_read import DataEvolutionFullTextRead, _search_raw_full_text
from pypaimon.table.source.full_text_search_split import IndexFullTextSearchSplit
from pypaimon.tests.vector_search_filter_test import _StubTable, _entry, _field, _bitmap, match_query
def _index(rows):
native = pytest.importorskip("paimon_ftindex")
writer = native.FullTextIndexWriter(NativeFullTextIndexOptions().to_native_options())
try:
for row_id, text in rows:
writer.add_document(row_id, text)
output = BytesIO()
writer.write(output)
return output.getvalue()
finally:
writer.close()
@pytest.mark.parametrize("filtered", [False, True])
def test_top_k_preserves_row_id_ties_and_raw_offsets(filtered):
data = _index([(row_id, "paimon") for row_id in [100, 50, 30, 1, 2, 3]])
include = _bitmap(150, 130, 102) if filtered else None
result = _search_raw_full_text(data, 100, match_query("paimon"), 1, include, max_candidates=6)
assert list(result.results()) == ([102] if filtered else [101])
def test_top_k_does_not_fetch_the_whole_index_without_boundary_ties():
native = pytest.importorskip("paimon_ftindex")
data = _index([(i, "paimon " + "lake " * i) for i in range(128)])
reader = native.FullTextIndexReader(PaimonFullTextInput(BytesIO(data)))
calls = []
class RecordingReader:
def search(self, query, **kwargs):
calls.append(kwargs["limit"])
return reader.search(query, **kwargs)
try:
ids, scores = reader.search(match_query("paimon"), limit=128)
expected = DictBasedScoredIndexResult(dict(zip(ids, scores))).top_k(3)
actual = _search_top_k(RecordingReader(), match_query("paimon"), 3, max_candidates=128)
assert list(actual.results()) == list(expected.results())
assert {i: actual.score_getter()(i) for i in actual.results()} == {
i: expected.score_getter()(i) for i in expected.results()}
assert calls[0] == 4
assert max(calls) < 128
finally:
reader.close()
@pytest.mark.parametrize("filtered", [False, True])
def test_large_boundary_ties_cap_native_query_repetitions(filtered):
native = pytest.importorskip("paimon_ftindex")
count = 1024
data = _index([(i, "paimon document" + str(i)) for i in reversed(range(count))])
reader = native.FullTextIndexReader(PaimonFullTextInput(BytesIO(data)))
include = _bitmap(*range(0, count, 3)).serialize() if filtered else None
calls = []
class RecordingReader:
def search(self, query, **kwargs):
calls.append(kwargs["limit"])
assert kwargs["filter_bytes"] == include
return reader.search(query, **kwargs)
try:
ids, scores = reader.search(match_query("paimon"), limit=count, filter_bytes=include)
expected = DictBasedScoredIndexResult(dict(zip(ids, scores))).top_k(10)
actual = _search_top_k(RecordingReader(), match_query("paimon"), 10, include, count)
assert {i: actual.score_getter()(i) for i in actual.results()} == {
i: expected.score_getter()(i) for i in expected.results()}
assert calls == [11, 22, count]
finally:
reader.close()
@pytest.mark.parametrize("bound", [None, 128])
def test_small_boundary_ties_finish_before_the_corpus_bound(bound):
native = pytest.importorskip("paimon_ftindex")
data = _index([(i, "paimon" if i < 3 else "paimon " + "lake " * i)
for i in reversed(range(128))])
reader = native.FullTextIndexReader(PaimonFullTextInput(BytesIO(data)))
calls = []
class RecordingReader:
def search(self, query, **kwargs):
calls.append(kwargs["limit"])
return reader.search(query, **kwargs)
try:
actual = _search_top_k(RecordingReader(), match_query("paimon"), 1, max_candidates=bound)
assert list(actual.results()) == [0]
assert calls == [2, 4]
finally:
reader.close()
@pytest.mark.parametrize("overlap", [False, True])
def test_per_shard_top_k_preserves_global_ranking_and_duplicate_precedence(overlap):
field = _field(0, "text", "STRING")
starts = [0, 0] if overlap else [0, 3]
entries = [_entry(None, 0, "full-text", str(i), start, start + 2)
for i, start in enumerate(starts)]
splits = [IndexFullTextSearchSplit("text", start, start + 2, [entry.index_file])
for start, entry in zip(starts, entries)]
scores = [{0: 1., 1: 3., 2: 2.}, {0: 10., 1: 4., 2: 2.}]
limits = []
class Reader:
def __init__(self, index):
self.index = index
def visit_full_text_search(self, search):
limits.append(search.limit)
return _completed_future(DictBasedScoredIndexResult(scores[self.index]).top_k(search.limit))
def close(self):
pass
with patch("pypaimon.execution.python.read.search.full_text_read._create_full_text_reader",
side_effect=lambda kind, io, path, metas, bound: Reader(int(metas[0].file_name))):
read = DataEvolutionFullTextRead(_StubTable([field], entries), 1, field, match_query("paimon"))
result = read._eval_column_query({"text": splits}, None)
assert list(result.results()) == ([1] if overlap else [3])
assert limits == ([3, 3] if overlap else [1, 1])