This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new 12bb2ae313 [python] Bound full-text candidates while preserving score 
ties (#10583)
12bb2ae313 is described below

commit 12bb2ae313d592703a8df893ef840586be48e4ce
Author: chaoyang <[email protected]>
AuthorDate: Fri Oct 16 21:56:48 2026 +0800

    [python] Bound full-text candidates while preserving score ties (#10583)
---
 .../native_full_text_global_index_reader.py        |  37 +++--
 paimon-python/pypaimon/ray/full_text_search.py     |   6 +-
 .../pypaimon/table/source/full_text_read.py        |  36 +++--
 .../pypaimon/tests/full_text_top_k_test.py         | 152 +++++++++++++++++++++
 .../pypaimon/tests/vector_search_filter_test.py    |  20 +--
 5 files changed, 216 insertions(+), 35 deletions(-)

diff --git 
a/paimon-python/pypaimon/globalindex/full_text/native_full_text_global_index_reader.py
 
b/paimon-python/pypaimon/globalindex/full_text/native_full_text_global_index_reader.py
index 629dee6454..5d621e0efe 100644
--- 
a/paimon-python/pypaimon/globalindex/full_text/native_full_text_global_index_reader.py
+++ 
b/paimon-python/pypaimon/globalindex/full_text/native_full_text_global_index_reader.py
@@ -86,11 +86,12 @@ class PaimonFullTextInput:
 class NativeFullTextGlobalIndexReader(GlobalIndexReader):
     """Full-text global index reader using paimon-full-text."""
 
-    def __init__(self, file_io, index_path: str, io_metas: 
List[GlobalIndexIOMeta]):
+    def __init__(self, file_io, index_path: str, io_metas: 
List[GlobalIndexIOMeta], max_candidates=None):
         assert len(io_metas) == 1, "Expected exactly one index file per shard"
         self._file_io = file_io
         self._index_path = index_path
         self._io_meta = io_metas[0]
+        self._max_candidates = max_candidates
         self._searcher = None
         self._native_reader = None
         self._stream = None
@@ -107,13 +108,8 @@ class NativeFullTextGlobalIndexReader(GlobalIndexReader):
         filter_bytes = (
             None if include_row_ids is None else include_row_ids.serialize()
         )
-        row_ids, scores = self._native_reader.search(
-            full_text_search.query,
-            limit=limit,
-            filter_bytes=filter_bytes)
-        id_to_scores = dict(zip(row_ids, scores))
-        return _completed_future(
-            DictBasedScoredIndexResult(id_to_scores).top_k(limit))
+        return _completed_future(_search_top_k(
+            self._native_reader, full_text_search.query, limit, filter_bytes, 
self._max_candidates))
 
     def _ensure_loaded(self):
         if self._searcher is not None:
@@ -203,6 +199,31 @@ class NativeFullTextGlobalIndexReader(GlobalIndexReader):
             self._stream = None
 
 
+def _search_top_k(reader, query, limit, filter_bytes=None, 
max_candidates=None):
+    # The native collector breaks score ties by document ordinal, whereas
+    # Python ranks ties by row ID. Read beyond the boundary until every tied
+    # candidate is available, without fetching the whole corpus otherwise.
+    maximum = 2147483647 if max_candidates is None else min(max_candidates, 
2147483647)
+    if maximum == 0:
+        return DictBasedScoredIndexResult({})
+    fetch = min(limit + 1, maximum)
+    probes = 0
+    while True:
+        row_ids, scores = reader.search(query, limit=fetch, 
filter_bytes=filter_bytes)
+        selected = DictBasedScoredIndexResult(dict(zip(row_ids, 
scores))).top_k(limit)
+        getter = selected.score_getter()
+        if (len(row_ids) < fetch or len(row_ids) <= limit or fetch == maximum
+                or min(scores) < min(getter(row_id) for row_id in 
selected.results())):
+            # Do not retain a score getter closing over the larger candidate 
map.
+            return DictBasedScoredIndexResult({row_id: getter(row_id) for 
row_id in selected.results()})
+        probes += 1
+        # Every probe reruns the native query. After two small probes, fetch
+        # the known corpus bound once instead of repeatedly scanning a large
+        # tie group. Never use INT_MAX as a fallback without a known bound:
+        # the native wrapper allocates output buffers of the requested size.
+        fetch = maximum if max_candidates is not None and probes >= 2 else 
min(fetch * 2, maximum)
+
+
 def _read_fully(stream, length: int) -> bytes:
     buf = bytearray()
     remaining = length
diff --git a/paimon-python/pypaimon/ray/full_text_search.py 
b/paimon-python/pypaimon/ray/full_text_search.py
index 29114a0c36..57d945ef4c 100644
--- a/paimon-python/pypaimon/ray/full_text_search.py
+++ b/paimon-python/pypaimon/ray/full_text_search.py
@@ -25,7 +25,7 @@ from pypaimon.globalindex.global_index_result import 
GlobalIndexResult
 from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
 from pypaimon.ray.vector_search import _execution_options, _map_tasks, 
_require_ray, _scores, _shared_scalar_filters
 from pypaimon.table.source.full_text_read import (
-    DataEvolutionFullTextRead, _index_type, _raw_row_ranges, 
_split_search_splits,
+    DataEvolutionFullTextRead, _index_candidate_limit, _index_type, 
_raw_row_ranges, _split_search_splits,
 )
 from pypaimon.table.source import global_index_live_row_filter
 from pypaimon.utils.range import Range
@@ -68,9 +68,11 @@ class _RayFullTextRead(DataEvolutionFullTextRead):
     def _eval_column_query(self, splits_by_column, live_rows):
         items = splits_by_column.get(self._text_columns[0].name, [])
         merged = {}
+        reader = self._worker_reader()
+        reader._limit = _index_candidate_limit(items, self._limit)
         with _shared_scalar_filters(items, self._filter, self._remote_args,
                                     _prepare_scalar_filter) as prepare, 
closing(_map_tasks(
-                                        _search_index, self._worker_reader(), 
items,
+                                        _search_index, reader, items,
                                         self._concurrency, self._remote_args, 
True, prepare)) as tasks:
             for _, scores in tasks:
                 # Duplicate row IDs keep the first planned shard's score.
diff --git a/paimon-python/pypaimon/table/source/full_text_read.py 
b/paimon-python/pypaimon/table/source/full_text_read.py
index 21cc5a5cf9..def8fc754e 100644
--- a/paimon-python/pypaimon/table/source/full_text_read.py
+++ b/paimon-python/pypaimon/table/source/full_text_read.py
@@ -175,13 +175,15 @@ class DataEvolutionFullTextRead(FullTextRead):
         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(self._eval(
+            futures.append(reader._eval(
                 split.row_range_start, split.row_range_end,
                 split.full_text_index_files,
                 include_row_ids,
@@ -223,13 +225,13 @@ class DataEvolutionFullTextRead(FullTextRead):
 
         reader = _create_full_text_reader(
             index_type, file_io, index_path,
-            index_io_meta_list
+            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=_candidate_limit(row_range_start, row_range_end),
+            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)
@@ -251,7 +253,6 @@ class DataEvolutionFullTextRead(FullTextRead):
             raise ValueError(f"Unsupported full-text index type: 
'{index_type}'")
 
         row_range_start = raw_row_ranges[0].from_
-        row_range_end = raw_row_ranges[-1].to
         search_kwargs = {}
         if include_row_ids is not None:
             raw_rows = GlobalIndexResult.from_ranges(raw_row_ranges).results()
@@ -277,7 +278,8 @@ class DataEvolutionFullTextRead(FullTextRead):
             index_bytes,
             row_range_start,
             self._query,
-            _candidate_limit(row_range_start, row_range_end),
+            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)
 
@@ -329,12 +331,16 @@ class DataEvolutionFullTextRead(FullTextRead):
             writer.close()
 
 
-def _candidate_limit(row_range_start: int, row_range_end: int) -> int:
-    size = row_range_end - row_range_start + 1
-    return min(size, 2147483647)
+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):
+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.native_full_text_global_index_reader 
import (
         FULL_TEXT_IDENTIFIER,
@@ -343,7 +349,7 @@ def _create_full_text_reader(index_type, file_io, 
index_path, index_io_meta_list
         from 
pypaimon.globalindex.full_text.native_full_text_global_index_reader import (
             NativeFullTextGlobalIndexReader,
         )
-        return NativeFullTextGlobalIndexReader(file_io, index_path, 
index_io_meta_list)
+        return NativeFullTextGlobalIndexReader(file_io, index_path, 
index_io_meta_list, max_candidates)
     raise ValueError(f"Unsupported full-text index type: '{index_type}'")
 
 
@@ -383,10 +389,10 @@ def _index_type(index_splits):
     return None
 
 
-def _search_raw_full_text(index_bytes, row_range_start, query, limit, 
include_row_ids=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.globalindex.full_text.native_full_text_global_index_reader 
import (
-        PaimonFullTextInput,
+        PaimonFullTextInput, _search_top_k,
     )
 
     reader = FullTextIndexReader(PaimonFullTextInput(BytesIO(index_bytes)))
@@ -395,11 +401,11 @@ def _search_raw_full_text(index_bytes, row_range_start, 
query, limit, include_ro
         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()
-        row_ids, scores = reader.search(query, limit=limit, **search_kwargs)
+        result = _search_top_k(reader, query, limit, 
max_candidates=max_candidates, **search_kwargs)
         return DictBasedScoredIndexResult(
             {
-                row_range_start + row_id: score
-                for row_id, score in zip(row_ids, scores)
+                row_range_start + row_id: result.score_getter()(row_id)
+                for row_id in result.results()
             })
     finally:
         reader.close()
diff --git a/paimon-python/pypaimon/tests/full_text_top_k_test.py 
b/paimon-python/pypaimon/tests/full_text_top_k_test.py
new file mode 100644
index 0000000000..04b0854a93
--- /dev/null
+++ b/paimon-python/pypaimon/tests/full_text_top_k_test.py
@@ -0,0 +1,152 @@
+# 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.native_full_text_global_index_reader 
import (
+    NativeFullTextIndexOptions, PaimonFullTextInput, _search_top_k,
+)
+from pypaimon.globalindex.global_index_reader import _completed_future
+from pypaimon.globalindex.vector_search_result import 
DictBasedScoredIndexResult
+from pypaimon.table.source.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()
+
+
[email protected]("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()
+
+
[email protected]("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()
+
+
[email protected]("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()
+
+
[email protected]("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.table.source.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])
diff --git a/paimon-python/pypaimon/tests/vector_search_filter_test.py 
b/paimon-python/pypaimon/tests/vector_search_filter_test.py
index b64625dc27..6b68f94dc0 100644
--- a/paimon-python/pypaimon/tests/vector_search_filter_test.py
+++ b/paimon-python/pypaimon/tests/vector_search_filter_test.py
@@ -655,7 +655,7 @@ class NativeFullTextIndexOptionsTest(unittest.TestCase):
 
         self.assertEqual([7], sorted(list(result.results())))
         self.assertEqual(match_query("paimon"), calls[0][0])
-        self.assertEqual(10, calls[0][1])
+        self.assertEqual(11, calls[0][1])  # One lookahead hit detects score 
ties at top-k.
         self.assertIsNone(calls[0][2])
 
     def test_reader_delegates_boolean_query_json_to_native(self):
@@ -806,7 +806,7 @@ class FullTextSearchBuilderDslTest(unittest.TestCase):
         _patch_snapshot(self, [entry])
         captured_searches = []
 
-        def _fake_create(index_type, file_io, index_path, index_io_meta_list):
+        def _fake_create(index_type, file_io, index_path, index_io_meta_list, 
max_candidates=None):
             class _FakeReader:
                 def visit_full_text_search(self_inner, fts):
                     captured_searches.append(fts)
@@ -837,7 +837,7 @@ class FullTextSearchBuilderDslTest(unittest.TestCase):
         self.assertEqual(10, captured_searches[0].limit)
         self.assertEqual(match_query("paimon", "And"), 
captured_searches[0].query)
 
-    def 
test_full_text_builder_sends_leaf_candidate_limit_for_compound_queries(self):
+    def test_full_text_builder_sends_top_k_for_compound_queries(self):
         from pypaimon.table.source.full_text_search_builder import (
             FullTextSearchBuilderImpl,
         )
@@ -853,7 +853,7 @@ class FullTextSearchBuilderDslTest(unittest.TestCase):
         _patch_snapshot(self, [entry])
         captured_limits = []
 
-        def _fake_create(index_type, file_io, index_path, index_io_meta_list):
+        def _fake_create(index_type, file_io, index_path, index_io_meta_list, 
max_candidates=None):
             class _FakeReader:
                 def visit_full_text_search(self_inner, fts):
                     captured_limits.append(fts.limit)
@@ -882,7 +882,7 @@ class FullTextSearchBuilderDslTest(unittest.TestCase):
                 .read([split])
             )
 
-        self.assertEqual([100], captured_limits)
+        self.assertEqual([3], captured_limits)
 
     def test_full_text_scan_ignores_other_index_types_on_same_column(self):
         from pypaimon.table.source.full_text_scan import 
DataEvolutionFullTextScan
@@ -3252,7 +3252,7 @@ class FullTextSearchManySplitsTest(unittest.TestCase):
         captured_io_metas = []
 
         def _fake_create(index_type, file_io, index_path,
-                         index_io_meta_list):
+                         index_io_meta_list, max_candidates=None):
             captured_io_metas.append(list(index_io_meta_list))
 
             class _FakeReader:
@@ -3304,7 +3304,7 @@ class FullTextSearchManySplitsTest(unittest.TestCase):
         partition_filter = object()
         captured = []
 
-        def _fake_create(index_type, file_io, index_path, index_io_meta_list):
+        def _fake_create(index_type, file_io, index_path, index_io_meta_list, 
max_candidates=None):
             class _FakeReader:
                 def visit_full_text_search(self_inner, fts):
                     captured.append(fts.include_row_ids)
@@ -3370,7 +3370,7 @@ class FullTextSearchManySplitsTest(unittest.TestCase):
         )
         raw_split = RawFullTextSearchSplit([Range(5, 9)])
 
-        def _fake_create(index_type, file_io, index_path, index_io_meta_list):
+        def _fake_create(index_type, file_io, index_path, index_io_meta_list, 
max_candidates=None):
             class _FakeReader:
                 def visit_full_text_search(self_inner, fts):
                     return _completed_future(DictBasedScoredIndexResult({2: 
1.0}))
@@ -3402,7 +3402,7 @@ class FullTextSearchManySplitsTest(unittest.TestCase):
         self.assertEqual(([5, 6, 8], ["raw paimon", None, "raw full-text"], 5),
                          build_raw_index.call_args.args)
         search_raw.assert_called_once_with(
-            b"raw-index", 5, match_query("paimon"), 5)
+            b"raw-index", 5, match_query("paimon"), 5, max_candidates=3)
         self.assertEqual([2, 8], sorted(list(result.results())))
         self.assertEqual(1.0, result.score_getter()(2))
         self.assertEqual(9.0, result.score_getter()(8))
@@ -3428,7 +3428,7 @@ class FullTextSearchManySplitsTest(unittest.TestCase):
         _patch_snapshot(self, entries)
 
         def _fake_create(index_type, file_io, index_path,
-                         index_io_meta_list):
+                         index_io_meta_list, max_candidates=None):
             row_id = index_io_meta_list[0].file_name
             row_id = int(row_id.split("-")[1].split(".")[0])
 

Reply via email to