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

lxy-9602 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-cpp.git


The following commit(s) were added to refs/heads/main by this push:
     new fff5a580 fix(global-index): apply global top-k to full-text search 
across shards (#431)
fff5a580 is described below

commit fff5a580e6dad7c55c256eb7fb121d8c28f6d976
Author: Nicholas Jiang <[email protected]>
AuthorDate: Mon Oct 19 17:01:18 2026 +0700

    fix(global-index): apply global top-k to full-text search across shards 
(#431)
---
 docs/source/user_guide/global_index.rst            |   9 +-
 include/paimon/global_index/global_index_reader.h  |   9 +-
 include/paimon/predicate/full_text_search.h        |  24 +-
 .../global_index/offset_global_index_reader.cpp    |   9 +-
 .../global_index/offset_global_index_reader.h      |   8 +-
 .../offset_global_index_reader_test.cpp            |  67 +++--
 .../global_index/union_global_index_reader.cpp     | 179 ++++++++++++-
 .../global_index/union_global_index_reader.h       |  27 ++
 .../union_global_index_reader_test.cpp             | 277 +++++++++++++++++++--
 .../common/predicate/full_text_search_test.cpp     |  24 ++
 .../core/global_index/global_index_scan_impl.cpp   |  26 +-
 test/inte/global_index_test.cpp                    | 138 ++++++++++
 12 files changed, 712 insertions(+), 85 deletions(-)

diff --git a/docs/source/user_guide/global_index.rst 
b/docs/source/user_guide/global_index.rst
index 956d28db..eed71866 100644
--- a/docs/source/user_guide/global_index.rst
+++ b/docs/source/user_guide/global_index.rst
@@ -168,9 +168,12 @@ index shard, the engine ranks matching rows by BM25 score 
and returns at most ``
 the highest scores. ``include_row_ids`` holds global row ids and restricts the 
rows that are
 ranked; an empty set matches no rows.
 
-For both ``full-text`` and ``lucene-fts``, searches across multiple shards 
currently return the
-union of the shard candidates, which can exceed ``limit``. Applying a final 
global top-k is tracked
-in `issue #402 <https://github.com/apache/paimon-cpp/issues/402>`_.
+For both ``full-text`` and ``lucene-fts``, a search across multiple index 
shards keeps the
+``limit`` highest-scoring rows returned by the shards, breaking ties by 
ascending row id. Which rows
+tied at a shard's cutoff score the shard returns is up to its engine. A row in 
overlapping index
+ranges is ranked only by the range that starts first, or ends first if both 
start at the same row.
+A shard ignores the ``include_row_ids`` outside the rows it ranks and is not 
searched if none
+remain. The result iterates rows by ascending row id, not by score.
 
 **Configuration:**
 
diff --git a/include/paimon/global_index/global_index_reader.h 
b/include/paimon/global_index/global_index_reader.h
index 6155759b..94d9ecef 100644
--- a/include/paimon/global_index/global_index_reader.h
+++ b/include/paimon/global_index/global_index_reader.h
@@ -45,9 +45,12 @@ class PAIMON_EXPORT GlobalIndexReader : public 
FunctionVisitor<std::shared_ptr<G
     virtual Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitVectorSearch(
         const std::shared_ptr<VectorSearch>& vector_search) = 0;
 
-    /// VisitFullTextSearch performs full text search and returns row ids with 
relevance scores.
-    /// Each index shard returns at most `full_text_search->limit` rows. Union 
readers currently
-    /// retain all shard candidates and can return more than that limit.
+    /// VisitFullTextSearch performs full text search and returns at most 
`full_text_search->limit`
+    /// row ids with the highest relevance scores, iterated by ascending row 
id rather than score.
+    /// A search over multiple index shards keeps the `limit` highest-scoring 
rows returned by the
+    /// shards, breaking ties by ascending row id; which rows tied at a 
shard's cutoff score the
+    /// shard returns is up to its index engine. A row in overlapping index 
ranges is ranked only by
+    /// the range that starts first, or ends first if both start at the same 
row.
     /// @warning `VisitFullTextSearch` may return error status when it is 
incorrectly invoked (e.g.,
     /// BTreeGlobalIndexReader call `VisitFullTextSearch`) or the query is 
invalid.
     virtual Result<std::shared_ptr<ScoredGlobalIndexResult>> 
VisitFullTextSearch(
diff --git a/include/paimon/predicate/full_text_search.h 
b/include/paimon/predicate/full_text_search.h
index 1adb0875..46e8024d 100644
--- a/include/paimon/predicate/full_text_search.h
+++ b/include/paimon/predicate/full_text_search.h
@@ -17,6 +17,7 @@
  */
 
 #pragma once
+#include <algorithm>
 #include <cstdint>
 #include <functional>
 #include <map>
@@ -49,9 +50,23 @@ struct PAIMON_EXPORT FullTextSearch {
         }
         const RoaringBitmap64& global_row_ids = include_row_ids.value();
         RoaringBitmap64 local_row_ids;
-        for (auto iter = global_row_ids.EqualOrLarger(from), end = 
global_row_ids.End();
-             iter != end && *iter <= to; ++iter) {
-            local_row_ids.Add(*iter - from);
+        auto iter = global_row_ids.EqualOrLarger(from);
+        auto end = global_row_ids.End();
+        if (iter != end && *iter <= to) {
+            // Shift one run of consecutive row ids as a range, so that 
converting a range of rows
+            // to rank does not visit each row.
+            int64_t first = *global_row_ids.Begin();
+            int64_t last = first + (global_row_ids.Cardinality() - 1);
+            if (last == RoaringBitmap64::MAX_VALUE ||
+                global_row_ids.EqualOrLarger(last + 1) == end) {
+                int64_t local_to = std::min(last, to) - from;
+                local_row_ids.AddRange(*iter - from, local_to);
+                local_row_ids.Add(local_to);
+            } else {
+                for (; iter != end && *iter <= to; ++iter) {
+                    local_row_ids.Add(*iter - from);
+                }
+            }
         }
         return std::make_shared<FullTextSearch>(field_name, query, limit, 
std::move(local_row_ids));
     }
@@ -83,7 +98,8 @@ struct PAIMON_EXPORT FullTextSearch {
     /// and rejects `boost` queries and a `fuzziness` other than 0. An invalid 
query is reported as
     /// an error `Status`.
     std::string query;
-    /// Maximum number of highest-scoring rows requested from each index 
shard; must be positive.
+    /// Maximum number of highest-scoring rows to return; must be positive. 
Each index shard is
+    /// asked for this many rows before the shard results are merged.
     int32_t limit;
     /// The row ids to search within, aligned with Java `includeRowIds`. The 
row id domain is
     /// determined by the reader: global row ids for GlobalIndexReader and 
physical file-local row
diff --git a/src/paimon/common/global_index/offset_global_index_reader.cpp 
b/src/paimon/common/global_index/offset_global_index_reader.cpp
index 70b6afc6..b66a29b4 100644
--- a/src/paimon/common/global_index/offset_global_index_reader.cpp
+++ b/src/paimon/common/global_index/offset_global_index_reader.cpp
@@ -24,8 +24,8 @@
 namespace paimon {
 
 
OffsetGlobalIndexReader::OffsetGlobalIndexReader(std::shared_ptr<GlobalIndexReader>&&
 wrapped,
-                                                 int64_t offset)
-    : wrapped_(std::move(wrapped)), offset_(offset) {}
+                                                 int64_t offset, int64_t to)
+    : wrapped_(std::move(wrapped)), offset_(offset), to_(to) {}
 
 Result<std::shared_ptr<GlobalIndexResult>> 
OffsetGlobalIndexReader::VisitIsNotNull() {
     PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<GlobalIndexResult> result, 
wrapped_->VisitIsNotNull());
@@ -137,11 +137,10 @@ Result<std::shared_ptr<ScoredGlobalIndexResult>> 
OffsetGlobalIndexReader::VisitV
 
 Result<std::shared_ptr<ScoredGlobalIndexResult>> 
OffsetGlobalIndexReader::VisitFullTextSearch(
     const std::shared_ptr<FullTextSearch>& full_text_search) {
-    // Convert global include row ids to local row ids. The shard end is 
unknown here, so row ids
-    // past the shard are kept; they match no local row.
+    // Convert global include row ids to local row ids, dropping those outside 
[offset_, to_].
     std::shared_ptr<FullTextSearch> local_search = full_text_search;
     if (full_text_search && full_text_search->include_row_ids) {
-        local_search = full_text_search->OffsetRange(offset_, 
RoaringBitmap64::MAX_VALUE);
+        local_search = full_text_search->OffsetRange(offset_, to_);
     }
     PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<ScoredGlobalIndexResult> result,
                            wrapped_->VisitFullTextSearch(local_search));
diff --git a/src/paimon/common/global_index/offset_global_index_reader.h 
b/src/paimon/common/global_index/offset_global_index_reader.h
index 08229466..de86fdee 100644
--- a/src/paimon/common/global_index/offset_global_index_reader.h
+++ b/src/paimon/common/global_index/offset_global_index_reader.h
@@ -34,8 +34,11 @@ class OffsetGlobalIndexReader : public GlobalIndexReader {
     /// Constructs an OffsetGlobalIndexReader.
     /// @param wrapped The inner reader to delegate queries to. Its results 
are expected to
     ///                contain local row ids.
-    /// @param offset  The offset to add to each row id in the results.
-    OffsetGlobalIndexReader(std::shared_ptr<GlobalIndexReader>&& wrapped, 
int64_t offset);
+    /// @param offset  The first global row id covered by the wrapped reader, 
added to each row id
+    ///                in the results.
+    /// @param to      The last global row id (inclusive) covered by the 
wrapped reader.
+    OffsetGlobalIndexReader(std::shared_ptr<GlobalIndexReader>&& wrapped, 
int64_t offset,
+                            int64_t to);
 
     Result<std::shared_ptr<GlobalIndexResult>> VisitIsNotNull() override;
     Result<std::shared_ptr<GlobalIndexResult>> VisitIsNull() override;
@@ -78,6 +81,7 @@ class OffsetGlobalIndexReader : public GlobalIndexReader {
  private:
     std::shared_ptr<GlobalIndexReader> wrapped_;
     int64_t offset_;
+    int64_t to_;
 };
 
 }  // namespace paimon
diff --git a/src/paimon/common/global_index/offset_global_index_reader_test.cpp 
b/src/paimon/common/global_index/offset_global_index_reader_test.cpp
index 39fec0b1..83d6079b 100644
--- a/src/paimon/common/global_index/offset_global_index_reader_test.cpp
+++ b/src/paimon/common/global_index/offset_global_index_reader_test.cpp
@@ -19,6 +19,7 @@
 
 #include "paimon/common/global_index/offset_global_index_reader.h"
 
+#include <limits>
 #include <string>
 #include <vector>
 
@@ -65,7 +66,7 @@ TEST_F(OffsetGlobalIndexReaderTest, TestVisitEqualWithOffset) 
{
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({0, 1, 3});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100, /*to=*/199);
 
     Literal literal_5(5);
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitEqual(literal_5));
@@ -77,7 +78,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitIsNotNullWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({0, 2, 4, 6});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 50);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 50, /*to=*/149);
 
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitIsNotNull());
     // row ids {0, 2, 4, 6} + offset 50 -> {50, 52, 54, 56}
@@ -88,7 +89,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitIsNullWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({1, 3});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 200);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 200, /*to=*/299);
 
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitIsNull());
     // row ids {1, 3} + offset 200 -> {201, 203}
@@ -99,7 +100,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitLessThanWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({0, 1});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 10);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 10, /*to=*/109);
 
     Literal literal_5(5);
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitLessThan(literal_5));
@@ -111,7 +112,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitGreaterOrEqualWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({3, 4, 5});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 1000);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 1000, /*to=*/1099);
 
     Literal literal_10(10);
     ASSERT_OK_AND_ASSIGN(auto result, 
offset_reader->VisitGreaterOrEqual(literal_10));
@@ -123,7 +124,7 @@ TEST_F(OffsetGlobalIndexReaderTest, TestVisitInWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({0, 2});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 5);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 5, /*to=*/104);
 
     std::vector<Literal> literals = {Literal(1), Literal(3)};
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitIn(literals));
@@ -135,7 +136,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitNotInWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({1, 3, 5});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 20);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 20, /*to=*/119);
 
     std::vector<Literal> literals = {Literal(2)};
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitNotIn(literals));
@@ -147,7 +148,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitNotEqualWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({0, 2, 4});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 7);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 7, /*to=*/106);
 
     Literal literal_1(1);
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitNotEqual(literal_1));
@@ -159,7 +160,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitLessOrEqualWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({0, 1, 2});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 30);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 30, /*to=*/129);
 
     Literal literal_3(3);
     ASSERT_OK_AND_ASSIGN(auto result, 
offset_reader->VisitLessOrEqual(literal_3));
@@ -171,7 +172,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitGreaterThanWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({4, 5});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 15);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 15, /*to=*/114);
 
     Literal literal_3(3);
     ASSERT_OK_AND_ASSIGN(auto result, 
offset_reader->VisitGreaterThan(literal_3));
@@ -183,7 +184,7 @@ TEST_F(OffsetGlobalIndexReaderTest, TestZeroOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({0, 1, 2});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 0);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 0, /*to=*/99);
 
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitIsNotNull());
     // row ids {0, 1, 2} + offset 0 -> {0, 1, 2}
@@ -194,7 +195,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestEmptyResultWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100, /*to=*/199);
 
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitIsNotNull());
     // empty result + offset 100 -> still empty
@@ -203,14 +204,14 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestEmptyResultWithOffset) {
 
 TEST_F(OffsetGlobalIndexReaderTest, TestIsThreadSafeDelegated) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100, /*to=*/199);
     // MockGlobalIndexReader returns true for IsThreadSafe
     ASSERT_TRUE(offset_reader->IsThreadSafe());
 }
 
 TEST_F(OffsetGlobalIndexReaderTest, TestGetIndexTypeDelegated) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100, /*to=*/199);
     ASSERT_EQ(offset_reader->GetIndexType(), "fake");
 }
 
@@ -218,7 +219,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitFullTextSearchWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetScoredResult({0, 3, 5}, {0.5f, 0.9f, 0.1f});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 10);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 10, /*to=*/109);
 
     ASSERT_OK_AND_ASSIGN(auto result, 
offset_reader->VisitFullTextSearch(nullptr));
     // row ids {0, 3, 5} + offset 10 -> {10, 13, 15}
@@ -227,23 +228,23 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitFullTextSearchWithOffset) {
 
 TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitFullTextSearchConvertsIncludeRowIds) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 10);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 10, /*to=*/19);
     {
         auto search =
             std::make_shared<FullTextSearch>("f0", 
R"({"match":{"query":"paimon"}})", /*limit=*/7,
-                                             RoaringBitmap64::From({5l, 10l, 
13l, 15l}));
+                                             RoaringBitmap64::From({5l, 10l, 
13l, 19l, 20l, 150l}));
         ASSERT_OK(offset_reader->VisitFullTextSearch(search));
         auto captured = fake_reader->CapturedFullTextSearch();
         ASSERT_TRUE(captured);
         ASSERT_EQ(captured->field_name, "f0");
         ASSERT_EQ(captured->query, R"({"match":{"query":"paimon"}})");
         ASSERT_EQ(captured->limit, 7);
-        ASSERT_EQ(captured->include_row_ids, RoaringBitmap64::From({0l, 3l, 
5l}));
-        ASSERT_EQ(search->include_row_ids, RoaringBitmap64::From({5l, 10l, 
13l, 15l}));
+        ASSERT_EQ(captured->include_row_ids, RoaringBitmap64::From({0l, 3l, 
9l}));
+        ASSERT_EQ(search->include_row_ids, RoaringBitmap64::From({5l, 10l, 
13l, 19l, 20l, 150l}));
     }
     {
-        auto search = std::make_shared<FullTextSearch>("f0", 
R"({"match":{"query":"paimon"}})",
-                                                       /*limit=*/7, 
RoaringBitmap64::From({5l}));
+        auto search = std::make_shared<FullTextSearch>(
+            "f0", R"({"match":{"query":"paimon"}})", /*limit=*/7, 
RoaringBitmap64::From({5l, 20l}));
         ASSERT_OK(offset_reader->VisitFullTextSearch(search));
         ASSERT_TRUE(fake_reader->CapturedFullTextSearch()->include_row_ids);
         
ASSERT_TRUE(fake_reader->CapturedFullTextSearch()->include_row_ids->IsEmpty());
@@ -254,13 +255,25 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitFullTextSearchConvertsIncludeRowIds
         ASSERT_OK(offset_reader->VisitFullTextSearch(search));
         ASSERT_EQ(fake_reader->CapturedFullTextSearch(), search);
     }
+    {
+        constexpr int64_t kMax = std::numeric_limits<int64_t>::max();
+        auto last_reader = std::make_shared<MockGlobalIndexReader>();
+        auto last_offset_reader =
+            std::make_shared<OffsetGlobalIndexReader>(last_reader, kMax - 9, 
/*to=*/kMax);
+        auto search =
+            std::make_shared<FullTextSearch>("f0", 
R"({"match":{"query":"paimon"}})", /*limit=*/7,
+                                             RoaringBitmap64::From({kMax - 5, 
kMax - 2, kMax}));
+        ASSERT_OK(last_offset_reader->VisitFullTextSearch(search));
+        ASSERT_EQ(last_reader->CapturedFullTextSearch()->include_row_ids,
+                  RoaringBitmap64::From({4l, 7l, 9l}));
+    }
 }
 
 TEST_F(OffsetGlobalIndexReaderTest, TestVisitVectorSearchWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetScoredResult({0, 2, 5}, {0.9f, 0.7f, 0.3f});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 100, /*to=*/199);
 
     ASSERT_OK_AND_ASSIGN(auto result, 
offset_reader->VisitVectorSearch(nullptr));
     // row ids {0, 2, 5} + offset 100 -> {100, 102, 105}, scores unchanged
@@ -269,7 +282,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitVectorSearchWithOffset) {
 
 TEST_F(OffsetGlobalIndexReaderTest, TestVisitVectorSearchNotSupported) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 10);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 10, /*to=*/109);
     // Propagate the wrapped reader's unsupported-search error.
     fake_reader->SetReturnError("vector search not supported");
     ASSERT_NOK_WITH_MSG(offset_reader->VisitVectorSearch(nullptr), "vector 
search not supported");
@@ -279,7 +292,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitStartsWithWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({0, 1, 4});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 50);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 50, /*to=*/149);
 
     Literal prefix(FieldType::STRING, "abc", 3);
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitStartsWith(prefix));
@@ -291,7 +304,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitEndsWithWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({2, 3});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 25);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 25, /*to=*/124);
 
     Literal suffix(FieldType::STRING, "xyz", 3);
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitEndsWith(suffix));
@@ -303,7 +316,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitContainsWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({1, 5, 6});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 300);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 300, /*to=*/399);
 
     Literal literal(FieldType::STRING, "foo", 3);
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitContains(literal));
@@ -315,7 +328,7 @@ TEST_F(OffsetGlobalIndexReaderTest, 
TestVisitLikeWithOffset) {
     auto fake_reader = std::make_shared<MockGlobalIndexReader>();
     fake_reader->SetDefaultResult({0, 3, 7});
 
-    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 40);
+    auto offset_reader = 
std::make_shared<OffsetGlobalIndexReader>(fake_reader, 40, /*to=*/139);
 
     Literal pattern(FieldType::STRING, "ab%", 3);
     ASSERT_OK_AND_ASSIGN(auto result, offset_reader->VisitLike(pattern));
diff --git a/src/paimon/common/global_index/union_global_index_reader.cpp 
b/src/paimon/common/global_index/union_global_index_reader.cpp
index 27966f6b..bb11cae3 100644
--- a/src/paimon/common/global_index/union_global_index_reader.cpp
+++ b/src/paimon/common/global_index/union_global_index_reader.cpp
@@ -19,16 +19,98 @@
 
 #include "paimon/common/global_index/union_global_index_reader.h"
 
+#include <algorithm>
+#include <cassert>
 #include <future>
+#include <unordered_set>
 #include <utility>
 
 #include "paimon/common/executor/future.h"
+#include "paimon/common/utils/fields_comparator.h"
+#include "paimon/global_index/bitmap_scored_global_index_result.h"
+#include "paimon/predicate/full_text_search.h"
+#include "paimon/utils/roaring_bitmap64.h"
 
 namespace paimon {
+namespace {
+std::shared_ptr<ScoredGlobalIndexResult> EmptyScoredResult() {
+    return std::make_shared<BitmapScoredGlobalIndexResult>(RoaringBitmap64(), 
std::vector<float>());
+}
+
+RoaringBitmap64 RowIdsOf(const Range& range) {
+    // `range.to` may be the largest int64 value, so add it apart from the 
half-open range.
+    RoaringBitmap64 row_ids;
+    row_ids.AddRange(range.from, range.to);
+    row_ids.Add(range.to);
+    return row_ids;
+}
+
+/// Returns the row ids of `row_ids` within `range`. Trimming a copy costs 
time in proportion to
+/// the containers of `row_ids`, not to the rows or the span of `range`.
+RoaringBitmap64 RowIdsWithin(const RoaringBitmap64& row_ids, const Range& 
range) {
+    RoaringBitmap64 result = row_ids;
+    result.RemoveRange(0, range.from);
+    if (range.to < RoaringBitmap64::MAX_VALUE) {
+        // RemoveRange leaves out its end, so the largest int64 value is 
removed on its own.
+        result.RemoveRange(range.to + 1, RoaringBitmap64::MAX_VALUE);
+        result -= RoaringBitmap64::From({RoaringBitmap64::MAX_VALUE});
+    }
+    return result;
+}
+
+/// Returns whether `left` ranks before `right`: a higher score first, then a 
lower row id. Scores
+/// are ordered as -infinity < -0.0 < +0.0 < +infinity < NaN.
+bool RanksBefore(const std::pair<int64_t, float>& left, const 
std::pair<int64_t, float>& right) {
+    int32_t score_order = FieldsComparator::CompareFloatingPoint(left.second, 
right.second);
+    if (score_order != 0) {
+        return score_order > 0;
+    }
+    return left.first < right.first;
+}
+
+/// Merges scored results and keeps the `limit` rows that rank first. A row id 
in more than one
+/// result keeps its first score.
+Result<std::shared_ptr<ScoredGlobalIndexResult>> MergeTopK(
+    const std::vector<std::shared_ptr<ScoredGlobalIndexResult>>& results, 
int32_t limit) {
+    std::unordered_set<int64_t> merged_row_ids;
+    std::vector<std::pair<int64_t, float>> rows;
+    for (const auto& result : results) {
+        
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ScoredGlobalIndexResult::ScoredIterator> 
iter,
+                               result->CreateScoredIterator());
+        while (iter->HasNext()) {
+            std::pair<int64_t, float> row = iter->NextWithScore();
+            if (merged_row_ids.insert(row.first).second) {
+                rows.push_back(row);
+            }
+        }
+    }
+    auto top_k = static_cast<size_t>(limit);
+    if (rows.size() > top_k) {
+        std::nth_element(rows.begin(), rows.begin() + top_k, rows.end(), 
RanksBefore);
+        rows.resize(top_k);
+    }
+    std::sort(rows.begin(), rows.end());
+    RoaringBitmap64 bitmap;
+    std::vector<float> scores;
+    scores.reserve(rows.size());
+    for (const auto& [row_id, score] : rows) {
+        bitmap.Add(row_id);
+        scores.push_back(score);
+    }
+    return std::make_shared<BitmapScoredGlobalIndexResult>(std::move(bitmap), 
std::move(scores));
+}
+}  // namespace
+
 UnionGlobalIndexReader::UnionGlobalIndexReader(
-    std::vector<std::shared_ptr<GlobalIndexReader>>&& readers,
-    const std::shared_ptr<Executor>& executor)
-    : readers_(std::move(readers)), executor_(executor) {}
+    std::vector<std::shared_ptr<GlobalIndexReader>>&& readers, 
std::vector<Range>&& row_ranges,
+    std::vector<std::optional<Range>>&& search_ranges, const 
std::shared_ptr<Executor>& executor)
+    : readers_(std::move(readers)),
+      executor_(executor),
+      row_ranges_(std::move(row_ranges)),
+      search_ranges_(std::move(search_ranges)) {
+    assert(row_ranges_.size() == search_ranges_.size());
+    assert(search_ranges_.empty() || search_ranges_.size() == readers_.size());
+}
 
 Result<std::shared_ptr<GlobalIndexResult>> 
UnionGlobalIndexReader::VisitIsNotNull() {
     return Union(
@@ -133,9 +215,75 @@ Result<std::shared_ptr<ScoredGlobalIndexResult>> 
UnionGlobalIndexReader::VisitVe
 
 Result<std::shared_ptr<ScoredGlobalIndexResult>> 
UnionGlobalIndexReader::VisitFullTextSearch(
     const std::shared_ptr<FullTextSearch>& full_text_search) {
-    return UnionScored([full_text_search](const 
std::shared_ptr<GlobalIndexReader>& reader) {
-        return reader->VisitFullTextSearch(full_text_search);
-    });
+    if (!full_text_search) {
+        return Status::Invalid("VisitFullTextSearch: null FullTextSearch 
pointer");
+    }
+    if (full_text_search->limit <= 0) {
+        return Status::Invalid("full-text search requires a positive limit");
+    }
+    // If the readers rank disjoint rows, the top `limit` rows of each reader 
include all of its
+    // rows scoring above the `limit`-th highest score of all readers, so 
merging them loses none
+    // of those rows. Rows tied at that score may already have been cut by a 
reader's engine.
+    auto reader_searches = 
std::make_shared<std::vector<std::shared_ptr<FullTextSearch>>>();
+    reader_searches->reserve(readers_.size());
+    for (size_t index = 0; index < readers_.size(); ++index) {
+        reader_searches->push_back(FullTextSearchForReader(index, 
full_text_search));
+    }
+    auto results = 
ExecuteAllReadersWithIndex<Result<std::shared_ptr<ScoredGlobalIndexResult>>>(
+        [reader_searches](size_t index, const 
std::shared_ptr<GlobalIndexReader>& reader)
+            -> Result<std::shared_ptr<ScoredGlobalIndexResult>> {
+            const std::shared_ptr<FullTextSearch>& reader_search = 
(*reader_searches)[index];
+            if (!reader_search) {
+                return EmptyScoredResult();
+            }
+            return reader->VisitFullTextSearch(reader_search);
+        });
+    std::vector<std::shared_ptr<ScoredGlobalIndexResult>> scored_results;
+    scored_results.reserve(results.size());
+    for (auto& result_or_status : results) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<ScoredGlobalIndexResult> result,
+                               std::move(result_or_status));
+        if (result != nullptr) {
+            scored_results.push_back(std::move(result));
+        }
+    }
+    // No reader answered the search, which differs from a search that matches 
no rows.
+    if (scored_results.empty()) {
+        return std::shared_ptr<ScoredGlobalIndexResult>();
+    }
+    return MergeTopK(scored_results, full_text_search->limit);
+}
+
+std::shared_ptr<FullTextSearch> 
UnionGlobalIndexReader::FullTextSearchForReader(
+    size_t index, const std::shared_ptr<FullTextSearch>& full_text_search) 
const {
+    if (search_ranges_.empty()) {
+        return full_text_search;
+    }
+    const std::optional<Range>& search_range = search_ranges_[index];
+    if (!search_range) {
+        return nullptr;
+    }
+    bool ranks_all_rows = search_range.value() == row_ranges_[index];
+    if (!full_text_search->include_row_ids) {
+        if (ranks_all_rows) {
+            return full_text_search;
+        }
+        return std::make_shared<FullTextSearch>(full_text_search->field_name,
+                                                full_text_search->query, 
full_text_search->limit,
+                                                
RowIdsOf(search_range.value()));
+    }
+    const RoaringBitmap64& include_row_ids = 
full_text_search->include_row_ids.value();
+    auto first = include_row_ids.EqualOrLarger(search_range->from);
+    if (first == include_row_ids.End() || *first > search_range->to) {
+        return nullptr;
+    }
+    // The reader drops include row ids outside its rows, so they need no 
clipping here.
+    if (ranks_all_rows) {
+        return full_text_search;
+    }
+    return std::make_shared<FullTextSearch>(full_text_search->field_name, 
full_text_search->query,
+                                            full_text_search->limit,
+                                            RowIdsWithin(include_row_ids, 
search_range.value()));
 }
 
 Result<std::shared_ptr<ScoredGlobalIndexResult>> 
UnionGlobalIndexReader::UnionScored(
@@ -198,11 +346,20 @@ bool UnionGlobalIndexReader::IsThreadSafe() const {
 template <typename R>
 std::vector<R> UnionGlobalIndexReader::ExecuteAllReaders(
     const std::function<R(const std::shared_ptr<GlobalIndexReader>&)>& action) 
{
+    return ExecuteAllReadersWithIndex<R>(
+        [action](size_t, const std::shared_ptr<GlobalIndexReader>& reader) -> 
R {
+            return action(reader);
+        });
+}
+
+template <typename R>
+std::vector<R> UnionGlobalIndexReader::ExecuteAllReadersWithIndex(
+    const std::function<R(size_t, const std::shared_ptr<GlobalIndexReader>&)>& 
action) {
     if (executor_ == nullptr || readers_.size() == 1) {
         std::vector<R> results;
         results.reserve(readers_.size());
-        for (const auto& reader : readers_) {
-            results.push_back(action(reader));
+        for (size_t index = 0; index < readers_.size(); ++index) {
+            results.push_back(action(index, readers_[index]));
         }
         return results;
     }
@@ -210,8 +367,10 @@ std::vector<R> UnionGlobalIndexReader::ExecuteAllReaders(
     // Parallel: submit all tasks to executor, then collect results in 
submission order
     std::vector<std::future<R>> futures;
     futures.reserve(readers_.size());
-    for (const auto& reader : readers_) {
-        futures.push_back(Via(executor_.get(), [action, reader]() -> R { 
return action(reader); }));
+    for (size_t index = 0; index < readers_.size(); ++index) {
+        futures.push_back(Via(executor_.get(), [action, index, reader = 
readers_[index]]() -> R {
+            return action(index, reader);
+        }));
     }
     return CollectAll(futures);
 }
diff --git a/src/paimon/common/global_index/union_global_index_reader.h 
b/src/paimon/common/global_index/union_global_index_reader.h
index 24992860..811e2462 100644
--- a/src/paimon/common/global_index/union_global_index_reader.h
+++ b/src/paimon/common/global_index/union_global_index_reader.h
@@ -21,11 +21,13 @@
 
 #include <functional>
 #include <memory>
+#include <optional>
 #include <string>
 #include <vector>
 
 #include "paimon/executor.h"
 #include "paimon/global_index/global_index_reader.h"
+#include "paimon/utils/range.h"
 
 namespace paimon {
 /// A GlobalIndexReader that combines results from multiple readers by 
performing a union
@@ -35,7 +37,14 @@ namespace paimon {
 /// collected in submission order. Otherwise, sub-readers are evaluated 
sequentially.
 class UnionGlobalIndexReader : public GlobalIndexReader {
  public:
+    /// Constructs a UnionGlobalIndexReader.
+    /// @param row_ranges    The global row ids covered by each reader, in the 
order of `readers`.
+    /// @param search_ranges The global row ids each reader ranks in a 
full-text search, within its
+    ///                      row range, in the order of `readers`; 
std::nullopt if it ranks none.
+    ///                      If both are empty, each reader ranks all of its 
rows.
     UnionGlobalIndexReader(std::vector<std::shared_ptr<GlobalIndexReader>>&& 
readers,
+                           std::vector<Range>&& row_ranges,
+                           std::vector<std::optional<Range>>&& search_ranges,
                            const std::shared_ptr<Executor>& executor);
 
     Result<std::shared_ptr<GlobalIndexResult>> VisitIsNotNull() override;
@@ -58,6 +67,11 @@ class UnionGlobalIndexReader : public GlobalIndexReader {
     Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitVectorSearch(
         const std::shared_ptr<VectorSearch>& vector_search) override;
 
+    /// Merges the scored results of all readers and keeps the 
`full_text_search->limit` rows with
+    /// the highest scores among them, breaking ties by ascending row id. A 
row id returned by more
+    /// than one reader keeps the score from the first of those readers. 
Returns nullptr if no
+    /// reader returns a result, e.g. if the readers do not support full-text 
search; a reader that
+    /// ranks none of the rows to search is not visited and counts as 
returning an empty result.
     Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitFullTextSearch(
         const std::shared_ptr<FullTextSearch>& full_text_search) override;
 
@@ -81,13 +95,26 @@ class UnionGlobalIndexReader : public GlobalIndexReader {
     /// Executes the given action on all readers and merges the scored results 
with Union.
     Result<std::shared_ptr<ScoredGlobalIndexResult>> 
UnionScored(ScoredReaderAction action);
 
+    /// Returns the full-text search to run on the reader at `index`, or 
nullptr if the reader
+    /// ranks none of the rows to search.
+    std::shared_ptr<FullTextSearch> FullTextSearchForReader(
+        size_t index, const std::shared_ptr<FullTextSearch>& full_text_search) 
const;
+
     /// Executes the given action on all readers (parallel or sequential) and 
collects results.
     template <typename R>
     std::vector<R> ExecuteAllReaders(
         const std::function<R(const std::shared_ptr<GlobalIndexReader>&)>& 
action);
 
+    /// Executes the given action on each reader and its index (parallel or 
sequential) and
+    /// collects results in reader order.
+    template <typename R>
+    std::vector<R> ExecuteAllReadersWithIndex(
+        const std::function<R(size_t, const 
std::shared_ptr<GlobalIndexReader>&)>& action);
+
     std::vector<std::shared_ptr<GlobalIndexReader>> readers_;
     std::shared_ptr<Executor> executor_;
+    std::vector<Range> row_ranges_;
+    std::vector<std::optional<Range>> search_ranges_;
 };
 
 }  // namespace paimon
diff --git a/src/paimon/common/global_index/union_global_index_reader_test.cpp 
b/src/paimon/common/global_index/union_global_index_reader_test.cpp
index 5399073b..8b83e134 100644
--- a/src/paimon/common/global_index/union_global_index_reader_test.cpp
+++ b/src/paimon/common/global_index/union_global_index_reader_test.cpp
@@ -19,18 +19,22 @@
 
 #include "paimon/common/global_index/union_global_index_reader.h"
 
+#include <limits>
 #include <map>
 #include <memory>
+#include <optional>
 #include <queue>
 #include <stdexcept>
 #include <string>
 #include <vector>
 
 #include "gtest/gtest.h"
+#include "paimon/common/global_index/offset_global_index_reader.h"
 #include "paimon/common/global_index/sorted_file_global_index_reader.h"
 #include "paimon/executor.h"
 #include "paimon/global_index/bitmap_global_index_result.h"
 #include "paimon/global_index/bitmap_scored_global_index_result.h"
+#include "paimon/predicate/full_text_search.h"
 #include "paimon/predicate/literal.h"
 #include "paimon/testing/mock/mock_global_index_reader.h"
 #include "paimon/testing/utils/testharness.h"
@@ -145,6 +149,10 @@ class UnionGlobalIndexReaderTest : public ::testing::Test {
             << ", expected=" << 
RoaringBitmap64::From(expected_row_ids).ToString();
         ASSERT_EQ(typed_result->GetScores(), expected_scores);
     }
+
+    static std::shared_ptr<FullTextSearch> MakeFullTextSearch(int32_t limit) {
+        return std::make_shared<FullTextSearch>("f0", 
R"({"match":{"query":"apple"}})", limit);
+    }
 };
 
 TEST_F(UnionGlobalIndexReaderTest, TestSingleReaderUnion) {
@@ -153,7 +161,8 @@ TEST_F(UnionGlobalIndexReaderTest, TestSingleReaderUnion) {
     auto executor = std::make_shared<DeferAfterFirstExecutor>();
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader};
-    UnionGlobalIndexReader union_reader(std::move(readers), executor);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, executor);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitIsNotNull());
     CheckResult(result, {1, 2, 3});
@@ -170,7 +179,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestMultipleReadersUnionSequential) {
     reader3->SetDefaultResult({5});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2, reader3};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitIsNotNull());
     // {1,2} OR {3,4} OR {5} -> {1,2,3,4,5}
@@ -187,7 +197,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestMultipleReadersUnionOverlappingIds) {
     reader2->SetDefaultResult({2, 3, 4});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitIsNotNull());
     // {1,2,3} OR {2,3,4} -> {1,2,3,4}
@@ -204,7 +215,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestMultipleReadersUnionWithExecutor) {
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2, reader3};
     std::shared_ptr<Executor> executor = CreateDefaultExecutor();
-    UnionGlobalIndexReader union_reader(std::move(readers), executor);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, executor);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitIsNotNull());
     CheckResult(result, {10, 20, 30});
@@ -215,7 +227,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestMultipleReadersUnionWithExecutor) {
 
 TEST_F(UnionGlobalIndexReaderTest, TestEmptyReaderList) {
     std::vector<std::shared_ptr<GlobalIndexReader>> readers;
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     // No readers means no results to merge -> nullptr
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitIsNotNull());
@@ -229,7 +242,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestAllReadersReturnNullptr) {
     reader2->SetReturnNullptr();
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitIsNotNull());
     // All readers return nullptr -> merged result is nullptr
@@ -245,7 +259,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestPartialReadersReturnNullptr) {
     reader3->SetReturnNullptr();
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2, reader3};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitIsNotNull());
     // A missing result from any reader makes the whole union unsupported.
@@ -259,7 +274,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestPartialReadersReturnNullptrWithExecutor)
     reader2->SetReturnNullptr();
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), 
CreateDefaultExecutor());
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, 
CreateDefaultExecutor());
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitIsNotNull());
     ASSERT_FALSE(result);
@@ -292,7 +308,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestErrorPropagationSequential) {
     reader2->SetReturnError("Unknown error for reader2");
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_NOK_WITH_MSG(union_reader.VisitIsNotNull(), "Unknown error for 
reader2");
 }
@@ -305,7 +322,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestErrorPropagationWithExecutor) {
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
     std::shared_ptr<Executor> executor = CreateDefaultExecutor();
-    UnionGlobalIndexReader union_reader(std::move(readers), executor);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, executor);
 
     ASSERT_NOK_WITH_MSG(union_reader.VisitIsNotNull(), "Unknown error for 
reader2");
 }
@@ -318,7 +336,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestDeferredTaskOwnsActionAfterEarlierFutureT
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = 
{throwing_reader, deferred_reader};
     auto executor = std::make_shared<DeferAfterFirstExecutor>();
-    UnionGlobalIndexReader union_reader(std::move(readers), executor);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, executor);
     ASSERT_THROW(
         { [[maybe_unused]] auto result = union_reader.VisitIsNotNull(); }, 
std::runtime_error);
     ASSERT_EQ(deferred_reader->InvocationCount(), 0);
@@ -334,7 +353,8 @@ TEST_F(UnionGlobalIndexReaderTest, TestVisitEqualUnion) {
     reader2->SetDefaultResult({2});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     Literal literal_42(42);
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitEqual(literal_42));
@@ -348,7 +368,8 @@ TEST_F(UnionGlobalIndexReaderTest, TestVisitNotEqualUnion) {
     reader2->SetDefaultResult({2});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     Literal literal_42(42);
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitNotEqual(literal_42));
@@ -362,7 +383,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestVisitRangeQueriesUnion) {
     reader2->SetDefaultResult({2});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     Literal literal_5(5);
     ASSERT_OK_AND_ASSIGN(auto lt, union_reader.VisitLessThan(literal_5));
@@ -382,7 +404,8 @@ TEST_F(UnionGlobalIndexReaderTest, TestVisitInUnion) {
     reader2->SetDefaultResult({2, 4});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     std::vector<Literal> literals = {Literal(10), Literal(20)};
     ASSERT_OK_AND_ASSIGN(auto in_result, union_reader.VisitIn(literals));
@@ -399,7 +422,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestVisitStringQueriesUnion) {
     reader2->SetDefaultResult({2});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     Literal literal_str(FieldType::STRING, "abc", 3);
     ASSERT_OK_AND_ASSIGN(auto starts, 
union_reader.VisitStartsWith(literal_str));
@@ -419,7 +443,8 @@ TEST_F(UnionGlobalIndexReaderTest, TestVisitIsNullUnion) {
     reader2->SetDefaultResult({300});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitIsNull());
     CheckResult(result, {100, 200, 300});
@@ -433,12 +458,202 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestVisitFullTextSearchUnion) {
     reader3->SetScoredResult({2, 6}, {0.8f, 0.6f});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2, reader3};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
-    ASSERT_OK_AND_ASSIGN(auto result, 
union_reader.VisitFullTextSearch(nullptr));
+    ASSERT_OK_AND_ASSIGN(auto result, 
union_reader.VisitFullTextSearch(MakeFullTextSearch(10)));
     CheckScoredResult(result, {1, 2, 5, 6}, {0.9f, 0.8f, 0.5f, 0.6f});
 }
 
+TEST_F(UnionGlobalIndexReaderTest, 
TestVisitFullTextSearchKeepsTopKAcrossReaders) {
+    auto reader1 = std::make_shared<MockGlobalIndexReader>();
+    auto reader2 = std::make_shared<MockGlobalIndexReader>();
+    reader1->SetScoredResult({100, 110, 120}, {0.9f, 0.5f, 0.1f});
+    reader2->SetScoredResult({0, 10, 20}, {0.5f, 0.8f, 0.3f});
+
+    std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, executor);
+
+    ASSERT_OK_AND_ASSIGN(auto result, 
union_reader.VisitFullTextSearch(MakeFullTextSearch(3)));
+    CheckScoredResult(result, {0, 10, 100}, {0.5f, 0.8f, 0.9f});
+}
+
+TEST_F(UnionGlobalIndexReaderTest, 
TestVisitFullTextSearchDuplicateRowIdKeepsFirstScore) {
+    auto reader1 = std::make_shared<MockGlobalIndexReader>();
+    auto reader2 = std::make_shared<MockGlobalIndexReader>();
+    reader1->SetScoredResult({1, 2}, {0.6f, 0.3f});
+    reader2->SetScoredResult({2, 3}, {0.9f, 0.5f});
+
+    std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, executor);
+
+    ASSERT_OK_AND_ASSIGN(auto result, 
union_reader.VisitFullTextSearch(MakeFullTextSearch(10)));
+    CheckScoredResult(result, {1, 2, 3}, {0.6f, 0.3f, 0.5f});
+    ASSERT_OK_AND_ASSIGN(result, 
union_reader.VisitFullTextSearch(MakeFullTextSearch(2)));
+    CheckScoredResult(result, {1, 3}, {0.6f, 0.5f});
+}
+
+TEST_F(UnionGlobalIndexReaderTest, TestVisitFullTextSearchOrdersSpecialScores) 
{
+    auto reader1 = std::make_shared<MockGlobalIndexReader>();
+    auto reader2 = std::make_shared<MockGlobalIndexReader>();
+    reader1->SetScoredResult({1, 2, 3}, {-0.0f, 
std::numeric_limits<float>::quiet_NaN(), 1.0f});
+    reader2->SetScoredResult({4, 5}, {0.0f, 
std::numeric_limits<float>::infinity()});
+
+    std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
+
+    std::vector<std::pair<int32_t, std::vector<int64_t>>> limit_and_row_ids = {
+        {1, {2}}, {2, {2, 5}}, {3, {2, 3, 5}}, {4, {2, 3, 4, 5}}};
+    for (const auto& [limit, expected_row_ids] : limit_and_row_ids) {
+        ASSERT_OK_AND_ASSIGN(auto result,
+                             
union_reader.VisitFullTextSearch(MakeFullTextSearch(limit)));
+        auto typed_result = 
std::dynamic_pointer_cast<BitmapScoredGlobalIndexResult>(result);
+        ASSERT_TRUE(typed_result);
+        ASSERT_OK_AND_ASSIGN(const RoaringBitmap64* bitmap, 
typed_result->GetBitmap());
+        ASSERT_EQ(*bitmap, RoaringBitmap64::From(expected_row_ids)) << 
"limit=" << limit;
+    }
+}
+
+TEST_F(UnionGlobalIndexReaderTest, TestVisitFullTextSearchAllNullptr) {
+    auto reader1 = std::make_shared<MockGlobalIndexReader>();
+    auto reader2 = std::make_shared<MockGlobalIndexReader>();
+
+    std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
+
+    ASSERT_OK_AND_ASSIGN(auto result, 
union_reader.VisitFullTextSearch(MakeFullTextSearch(10)));
+    ASSERT_FALSE(result);
+}
+
+TEST_F(UnionGlobalIndexReaderTest, TestVisitFullTextSearchErrors) {
+    auto reader1 = std::make_shared<MockGlobalIndexReader>();
+    auto reader2 = std::make_shared<MockGlobalIndexReader>();
+    reader1->SetScoredResult({1}, {0.9f});
+    reader2->SetReturnError("full-text search failure");
+
+    std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
+
+    ASSERT_NOK_WITH_MSG(union_reader.VisitFullTextSearch(nullptr), "null 
FullTextSearch pointer");
+    
ASSERT_NOK_WITH_MSG(union_reader.VisitFullTextSearch(MakeFullTextSearch(0)),
+                        "full-text search requires a positive limit");
+    
ASSERT_NOK_WITH_MSG(union_reader.VisitFullTextSearch(MakeFullTextSearch(-1)),
+                        "full-text search requires a positive limit");
+    ASSERT_EQ(reader1->InvocationCount(), 0);
+    ASSERT_EQ(reader2->InvocationCount(), 0);
+    
ASSERT_NOK_WITH_MSG(union_reader.VisitFullTextSearch(MakeFullTextSearch(10)),
+                        "full-text search failure");
+}
+
+TEST_F(UnionGlobalIndexReaderTest, TestVisitFullTextSearchWithinSearchRanges) {
+    auto reader1 = std::make_shared<MockGlobalIndexReader>();
+    auto reader2 = std::make_shared<MockGlobalIndexReader>();
+    auto reader3 = std::make_shared<MockGlobalIndexReader>();
+    reader1->SetScoredResult({0, 1, 2}, {0.2f, 0.5f, 0.9f});
+    reader2->SetScoredResult({1}, {1.0f});
+    reader3->SetScoredResult({3}, {0.7f});
+
+    std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2, reader3};
+    std::shared_ptr<Executor> executor = CreateDefaultExecutor();
+    UnionGlobalIndexReader union_reader(std::move(readers),
+                                        /*row_ranges=*/{Range(0, 2), Range(1, 
1), Range(1, 3)},
+                                        /*search_ranges=*/{Range(0, 2), 
std::nullopt, Range(3, 3)},
+                                        executor);
+
+    auto search = MakeFullTextSearch(2);
+    ASSERT_OK_AND_ASSIGN(auto result, 
union_reader.VisitFullTextSearch(search));
+    CheckScoredResult(result, {2, 3}, {0.9f, 0.7f});
+    ASSERT_EQ(reader1->CapturedFullTextSearch(), search);
+    ASSERT_EQ(reader2->InvocationCount(), 0);
+    ASSERT_EQ(reader3->CapturedFullTextSearch()->include_row_ids, 
RoaringBitmap64::From({3l}));
+
+    constexpr int64_t kMax = std::numeric_limits<int64_t>::max();
+    search->include_row_ids = RoaringBitmap64::From({1, 2, 3, 5, kMax});
+    ASSERT_OK(union_reader.VisitFullTextSearch(search));
+    ASSERT_EQ(reader1->CapturedFullTextSearch(), search);
+    ASSERT_EQ(reader2->InvocationCount(), 0);
+    ASSERT_EQ(reader3->CapturedFullTextSearch()->include_row_ids, 
RoaringBitmap64::From({3l}));
+    ASSERT_EQ(search->include_row_ids, RoaringBitmap64::From({1, 2, 3, 5, 
kMax}));
+
+    search->include_row_ids = RoaringBitmap64::From({5});
+    ASSERT_OK_AND_ASSIGN(result, union_reader.VisitFullTextSearch(search));
+    CheckScoredResult(result, {}, {});
+    ASSERT_EQ(reader1->InvocationCount(), 2);
+    ASSERT_EQ(reader2->InvocationCount(), 0);
+    ASSERT_EQ(reader3->InvocationCount(), 2);
+}
+
+TEST_F(UnionGlobalIndexReaderTest, 
TestVisitFullTextSearchSparseIncludeInLargeRanges) {
+    constexpr int64_t kMax = std::numeric_limits<int64_t>::max();
+    auto reader1 = std::make_shared<MockGlobalIndexReader>();
+    auto reader2 = std::make_shared<MockGlobalIndexReader>();
+    std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
+    UnionGlobalIndexReader union_reader(
+        std::move(readers), /*row_ranges=*/{Range(0, kMax / 2), Range(10, 
kMax)},
+        /*search_ranges=*/{Range(0, kMax / 2), Range(kMax / 2 + 1, kMax)}, 
/*executor=*/nullptr);
+
+    auto search = MakeFullTextSearch(10);
+    search->include_row_ids = RoaringBitmap64();
+    ASSERT_OK_AND_ASSIGN(auto result, 
union_reader.VisitFullTextSearch(search));
+    CheckScoredResult(result, {}, {});
+    ASSERT_EQ(reader1->InvocationCount(), 0);
+    ASSERT_EQ(reader2->InvocationCount(), 0);
+
+    search->include_row_ids = RoaringBitmap64::From({20, kMax / 2 + 1, kMax});
+    ASSERT_OK(union_reader.VisitFullTextSearch(search));
+    ASSERT_EQ(reader1->CapturedFullTextSearch(), search);
+    ASSERT_EQ(reader2->CapturedFullTextSearch()->include_row_ids,
+              RoaringBitmap64::From({kMax / 2 + 1, kMax}));
+
+    search->include_row_ids = RoaringBitmap64::From({20l});
+    ASSERT_OK(union_reader.VisitFullTextSearch(search));
+    ASSERT_EQ(reader1->InvocationCount(), 2);
+    ASSERT_EQ(reader2->InvocationCount(), 1);
+}
+
+TEST_F(UnionGlobalIndexReaderTest, 
TestVisitFullTextSearchOverlappingRangesThroughOffsetReaders) {
+    constexpr int64_t kMax = std::numeric_limits<int64_t>::max();
+    const int64_t to = int64_t{1} << 32;
+    std::vector<std::shared_ptr<MockGlobalIndexReader>> wrapped_readers;
+    std::vector<std::shared_ptr<GlobalIndexReader>> readers;
+    std::vector<Range> row_ranges = {Range(0, 99), Range(50, to), Range(kMax - 
9, kMax - 3),
+                                     Range(kMax - 5, kMax)};
+    std::vector<std::optional<Range>> search_ranges = {
+        Range(0, 99), Range(100, to), Range(kMax - 9, kMax - 3), Range(kMax - 
2, kMax)};
+    for (const Range& row_range : row_ranges) {
+        wrapped_readers.push_back(std::make_shared<MockGlobalIndexReader>());
+        
readers.push_back(std::make_shared<OffsetGlobalIndexReader>(wrapped_readers.back(),
+                                                                    
row_range.from, row_range.to));
+    }
+    UnionGlobalIndexReader union_reader(std::move(readers), 
std::move(row_ranges),
+                                        std::move(search_ranges), 
/*executor=*/nullptr);
+
+    auto search = MakeFullTextSearch(1);
+    ASSERT_OK(union_reader.VisitFullTextSearch(search));
+    ASSERT_EQ(wrapped_readers[0]->CapturedFullTextSearch(), search);
+    RoaringBitmap64 local_row_ids;
+    local_row_ids.AddRange(50, to - 49);
+    ASSERT_EQ(wrapped_readers[1]->CapturedFullTextSearch()->include_row_ids, 
local_row_ids);
+    ASSERT_EQ(wrapped_readers[2]->CapturedFullTextSearch(), search);
+    ASSERT_EQ(wrapped_readers[3]->CapturedFullTextSearch()->include_row_ids,
+              RoaringBitmap64::From({3l, 4l, 5l}));
+
+    RoaringBitmap64 include_row_ids;
+    include_row_ids.AddRange(0, to + 1);
+    search->include_row_ids = include_row_ids;
+    ASSERT_OK(union_reader.VisitFullTextSearch(search));
+    ASSERT_EQ(wrapped_readers[1]->CapturedFullTextSearch()->include_row_ids, 
local_row_ids);
+    ASSERT_EQ(wrapped_readers[2]->InvocationCount(), 1);
+    ASSERT_EQ(wrapped_readers[3]->InvocationCount(), 1);
+}
+
 TEST_F(UnionGlobalIndexReaderTest, TestVisitVectorSearchAllNullptr) {
     auto reader1 = std::make_shared<MockGlobalIndexReader>();
     auto reader2 = std::make_shared<MockGlobalIndexReader>();
@@ -447,7 +662,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestVisitVectorSearchAllNullptr) {
     reader2->SetDefaultResult({2});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitVectorSearch(nullptr));
     ASSERT_FALSE(result);
@@ -458,7 +674,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestVisitVectorSearchSingleReader) {
     reader->SetScoredResult({1, 3, 5}, {0.9f, 0.7f, 0.5f});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitVectorSearch(nullptr));
     CheckScoredResult(result, {1, 3, 5}, {0.9f, 0.7f, 0.5f});
@@ -471,7 +688,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestVisitVectorSearchMultipleReadersUnion) {
     reader2->SetScoredResult({2, 4}, {0.8f, 0.6f});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitVectorSearch(nullptr));
     // {1,3} OR {2,4} -> {1,2,3,4}, scores merged in row id order
@@ -488,7 +706,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestVisitVectorSearchPartialNullptr) {
     reader3->SetScoredResult({5, 6}, {0.5f, 0.4f});
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2, reader3};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_OK_AND_ASSIGN(auto result, union_reader.VisitVectorSearch(nullptr));
     // reader2 nullptr is skipped, {1,2} OR {5,6} -> {1,2,5,6}
@@ -502,7 +721,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestVisitVectorSearchErrorPropagation) {
     reader2->SetReturnError("vector search failure");
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_NOK_WITH_MSG(union_reader.VisitVectorSearch(nullptr), "vector 
search failure");
 }
@@ -512,7 +732,8 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestIsThreadSafeReturnsTrueWhenAllReadersAreS
     auto reader2 = std::make_shared<MockGlobalIndexReader>();
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_TRUE(union_reader.IsThreadSafe());
 }
@@ -523,14 +744,16 @@ TEST_F(UnionGlobalIndexReaderTest, 
TestIsThreadSafeReturnsFalseWhenAnyReaderIsNo
     reader2->SetThreadSafe(false);
 
     std::vector<std::shared_ptr<GlobalIndexReader>> readers = {reader1, 
reader2};
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_FALSE(union_reader.IsThreadSafe());
 }
 
 TEST_F(UnionGlobalIndexReaderTest, TestGetIndexTypeReturnsUnion) {
     std::vector<std::shared_ptr<GlobalIndexReader>> readers;
-    UnionGlobalIndexReader union_reader(std::move(readers), nullptr);
+    UnionGlobalIndexReader union_reader(std::move(readers), /*row_ranges=*/{},
+                                        /*search_ranges=*/{}, nullptr);
 
     ASSERT_EQ(union_reader.GetIndexType(), "union");
 }
diff --git a/src/paimon/common/predicate/full_text_search_test.cpp 
b/src/paimon/common/predicate/full_text_search_test.cpp
index 2e14dbd3..d5489aed 100644
--- a/src/paimon/common/predicate/full_text_search_test.cpp
+++ b/src/paimon/common/predicate/full_text_search_test.cpp
@@ -60,4 +60,28 @@ TEST(FullTextSearchTest, TestOffsetRangeWithIncludeRowIds) {
               RoaringBitmap64::From({3l, 7l}));
 }
 
+TEST(FullTextSearchTest, TestOffsetRangeWithIncludeRowIdRun) {
+    auto row_ids_of = [](int64_t from, int64_t to) {
+        RoaringBitmap64 row_ids;
+        row_ids.AddRange(from, to);
+        row_ids.Add(to);
+        return row_ids;
+    };
+    FullTextSearch search("f0", R"({"match":{"query":"paimon"}})", /*limit=*/5,
+                          row_ids_of(100, 199));
+    ASSERT_EQ(search.OffsetRange(150, 299)->include_row_ids, row_ids_of(0, 
49));
+    ASSERT_EQ(search.OffsetRange(0, 149)->include_row_ids, row_ids_of(100, 
149));
+    ASSERT_EQ(search.OffsetRange(120, 130)->include_row_ids, row_ids_of(0, 
10));
+    ASSERT_EQ(search.OffsetRange(200, 299)->include_row_ids, 
RoaringBitmap64());
+    ASSERT_EQ(search.include_row_ids, row_ids_of(100, 199));
+
+    constexpr int64_t kMax = RoaringBitmap64::MAX_VALUE;
+    FullTextSearch last_search("f0", R"({"match":{"query":"paimon"}})", 
/*limit=*/5,
+                               row_ids_of(kMax - 9, kMax));
+    ASSERT_EQ(last_search.OffsetRange(kMax - 5, kMax)->include_row_ids, 
row_ids_of(0, 5));
+    FullTextSearch max_search("f0", R"({"match":{"query":"paimon"}})", 
/*limit=*/5,
+                              RoaringBitmap64::From({kMax}));
+    ASSERT_EQ(max_search.OffsetRange(kMax - 5, kMax)->include_row_ids, 
RoaringBitmap64::From({5l}));
+}
+
 }  // namespace paimon::test
diff --git a/src/paimon/core/global_index/global_index_scan_impl.cpp 
b/src/paimon/core/global_index/global_index_scan_impl.cpp
index c38c5037..eabc10e0 100644
--- a/src/paimon/core/global_index/global_index_scan_impl.cpp
+++ b/src/paimon/core/global_index/global_index_scan_impl.cpp
@@ -18,6 +18,9 @@
  */
 #include "paimon/core/global_index/global_index_scan_impl.h"
 
+#include <algorithm>
+#include <limits>
+#include <optional>
 #include <string>
 #include <thread>
 #include <utility>
@@ -184,8 +187,21 @@ Result<std::vector<std::shared_ptr<GlobalIndexReader>>> 
GlobalIndexScanImpl::Cre
             continue;
         }
         std::vector<std::shared_ptr<GlobalIndexReader>> union_readers;
+        std::vector<Range> row_ranges;
+        std::vector<std::optional<Range>> search_ranges;
         union_readers.reserve(range_to_metas.size());
+        row_ranges.reserve(range_to_metas.size());
+        search_ranges.reserve(range_to_metas.size());
+        // Assign each full-text row to one reader before requesting 
per-reader top-k: a row is
+        // ranked by the first range in (from, to) order that covers it, which 
leaves each range
+        // only its rows after the largest `to` of earlier ones.
+        int64_t covered_to = std::numeric_limits<int64_t>::min();
         for (const auto& [range, metas] : range_to_metas) {
+            std::optional<Range> search_range;
+            if (covered_to < range.to) {
+                search_range = Range(std::max(range.from, covered_to + 1), 
range.to);
+            }
+            covered_to = std::max(covered_to, range.to);
             if (row_range_index && !row_range_index->Intersects(range.from, 
range.to)) {
                 continue;
             }
@@ -199,14 +215,16 @@ Result<std::vector<std::shared_ptr<GlobalIndexReader>>> 
GlobalIndexScanImpl::Cre
             PAIMON_ASSIGN_OR_RAISE(
                 std::shared_ptr<GlobalIndexReader> index_reader,
                 indexer->CreateReader(&c_arrow_schema, index_file_manager_, 
index_io_metas, pool_));
-            union_readers.push_back(
-                
std::make_shared<OffsetGlobalIndexReader>(std::move(index_reader), range.from));
+            union_readers.push_back(std::make_shared<OffsetGlobalIndexReader>(
+                std::move(index_reader), range.from, range.to));
+            row_ranges.push_back(range);
+            search_ranges.push_back(search_range);
         }
         if (union_readers.empty()) {
             continue;
         }
-        readers.push_back(
-            std::make_shared<UnionGlobalIndexReader>(std::move(union_readers), 
executor_));
+        readers.push_back(std::make_shared<UnionGlobalIndexReader>(
+            std::move(union_readers), std::move(row_ranges), 
std::move(search_ranges), executor_));
     }
     return readers;
 }
diff --git a/test/inte/global_index_test.cpp b/test/inte/global_index_test.cpp
index e6c1abc5..191d4d1a 100644
--- a/test/inte/global_index_test.cpp
+++ b/test/inte/global_index_test.cpp
@@ -16,6 +16,7 @@
  * limitations under the License.
  */
 
+#include <algorithm>
 #include <atomic>
 #include <limits>
 
@@ -74,6 +75,7 @@ class CountingGlobalIndexFileManager : public 
GlobalIndexFileManager {
 
 }  // namespace
 
+using ScoredRows = std::vector<std::pair<int64_t, float>>;
 // string: FileFormat, bool: UseSpecificFileSystem
 using ParamType = std::tuple<std::string, bool>;
 /// This is a sdk end-to-end test for global index.
@@ -185,6 +187,102 @@ class GlobalIndexTest : public ::testing::Test, public 
::testing::WithParamInter
         return Commit(table_path, {index_commit_msg});
     }
 
+    static Result<ScoredRows> SearchFullText(
+        const std::shared_ptr<GlobalIndexReader>& reader, const std::string& 
query, int32_t limit,
+        const std::optional<RoaringBitmap64>& include_row_ids) {
+        PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<ScoredGlobalIndexResult> result,
+                               
reader->VisitFullTextSearch(std::make_shared<FullTextSearch>(
+                                   "f0", query, limit, include_row_ids)));
+        if (!result) {
+            return Status::Invalid("full-text search returned no result");
+        }
+        
PAIMON_ASSIGN_OR_RAISE(std::unique_ptr<ScoredGlobalIndexResult::ScoredIterator> 
iter,
+                               result->CreateScoredIterator());
+        ScoredRows rows;
+        while (iter->HasNext()) {
+            rows.push_back(iter->NextWithScore());
+        }
+        return rows;
+    }
+
+    static void CheckFullTextTopK(const std::shared_ptr<GlobalIndexReader>& 
reader,
+                                  const std::string& query,
+                                  const std::optional<RoaringBitmap64>& 
include_row_ids,
+                                  const std::vector<int64_t>& 
expected_row_ids) {
+        ASSERT_OK_AND_ASSIGN(ScoredRows all_rows,
+                             SearchFullText(reader, query, /*limit=*/100, 
include_row_ids));
+        std::vector<int64_t> row_ids;
+        for (const auto& [row_id, score] : all_rows) {
+            row_ids.push_back(row_id);
+        }
+        ASSERT_EQ(row_ids, expected_row_ids);
+        ScoredRows ranked_rows = all_rows;
+        std::sort(
+            ranked_rows.begin(), ranked_rows.end(),
+            [](const std::pair<int64_t, float>& left, const std::pair<int64_t, 
float>& right) {
+                return left.second != right.second ? left.second > right.second
+                                                   : left.first < right.first;
+            });
+        for (size_t limit = 1; limit < ranked_rows.size(); ++limit) {
+            ScoredRows expected_rows(ranked_rows.begin(), ranked_rows.begin() 
+ limit);
+            std::sort(expected_rows.begin(), expected_rows.end());
+            ASSERT_OK_AND_ASSIGN(
+                ScoredRows rows,
+                SearchFullText(reader, query, static_cast<int32_t>(limit), 
include_row_ids));
+            ASSERT_EQ(rows, expected_rows) << "limit=" << limit;
+        }
+    }
+
+    void CheckSearchOverlappingIndexRanges(const std::string& index_type,
+                                           const std::map<std::string, 
std::string>& index_options,
+                                           const std::string& query) {
+        arrow::FieldVector fields = {arrow::field("f0", arrow::utf8()),
+                                     arrow::field("f1", arrow::int32())};
+        auto schema = arrow::schema(fields);
+        std::map<std::string, std::string> options = {{Options::FILE_FORMAT, 
file_format_},
+                                                      {Options::FILE_SYSTEM, 
"local"},
+                                                      
{Options::ROW_TRACKING_ENABLED, "true"},
+                                                      
{Options::DATA_EVOLUTION_ENABLED, "true"}};
+        CreateTable(/*partition_keys=*/{}, schema, options);
+        std::string table_path = PathUtil::JoinPath(dir_->Str(), "foo.db/bar");
+
+        auto src_array = 
arrow::ipc::internal::json::ArrayFromJSON(arrow::struct_(fields), R"([
+["This is an test document.", 0],
+["This is an new document document document.", 1],
+["Document document document document test.", 2],
+["Document document document document document.", 3]
+    ])")
+                             .ValueOrDie();
+        ASSERT_OK_AND_ASSIGN(auto commit_msgs,
+                             WriteArray(table_path, schema->field_names(), 
src_array));
+        ASSERT_OK(Commit(table_path, commit_msgs));
+        ASSERT_OK(WriteIndex(table_path, /*partition_filters=*/{}, "f0", 
index_type, index_options,
+                             Range(0, 2)));
+        ASSERT_OK(WriteIndex(table_path, /*partition_filters=*/{}, "f0", 
index_type, index_options,
+                             Range(1, 1)));
+        ASSERT_OK(WriteIndex(table_path, /*partition_filters=*/{}, "f0", 
index_type, index_options,
+                             Range(1, 3)));
+
+        ASSERT_OK_AND_ASSIGN(std::shared_ptr<GlobalIndexScan> 
global_index_scan,
+                             GlobalIndexScan::Create(table_path, 
/*snapshot_id=*/std::nullopt,
+                                                     
/*partitions=*/std::nullopt, /*options=*/{},
+                                                     fs_, 
/*executor=*/nullptr, pool_));
+        ASSERT_OK_AND_ASSIGN(
+            auto readers, global_index_scan->CreateReaders("f0", 
/*row_range_index=*/std::nullopt));
+        ASSERT_EQ(readers.size(), 1u);
+        CheckFullTextTopK(readers[0], query, std::nullopt, {0, 1, 2, 3});
+        ASSERT_OK_AND_ASSIGN(ScoredRows top_rows,
+                             SearchFullText(readers[0], query, /*limit=*/1, 
std::nullopt));
+        ASSERT_EQ(top_rows.size(), 1u);
+        ASSERT_EQ(top_rows[0].first, 3);
+        CheckFullTextTopK(readers[0], query, RoaringBitmap64::From({1, 2, 3}), 
{1, 2, 3});
+
+        ASSERT_OK_AND_ASSIGN(RowRangeIndex row_range_index, 
RowRangeIndex::Create({Range(3, 3)}));
+        ASSERT_OK_AND_ASSIGN(readers, global_index_scan->CreateReaders("f0", 
row_range_index));
+        ASSERT_EQ(readers.size(), 1u);
+        CheckFullTextTopK(readers[0], query, std::nullopt, {3});
+    }
+
     Result<std::shared_ptr<Plan>> ScanGlobalIndexAndData(
         const std::string& table_path, const std::shared_ptr<Predicate>& 
predicate,
         const std::map<std::string, std::string>& options = {},
@@ -2803,6 +2901,28 @@ TEST_P(GlobalIndexTest, 
TestWriteCommitScanReadLuceneIndexWithPartition) {
         ASSERT_OK_AND_ASSIGN(RowRangeIndex row_range_index, 
RowRangeIndex::Create({Range(2, 3)}));
         scan_and_check_result({{"f1", "20"}}, row_range_index, filter, "row 
ids: {2}");
     }
+    {
+        ASSERT_OK_AND_ASSIGN(
+            std::shared_ptr<GlobalIndexScan> global_index_scan,
+            GlobalIndexScan::Create(table_path, /*snapshot_id=*/std::nullopt,
+                                    /*partitions=*/std::nullopt, 
lucene_options, fs_,
+                                    /*executor=*/nullptr, pool_));
+        ASSERT_OK_AND_ASSIGN(
+            auto readers, global_index_scan->CreateReaders("f0", 
/*row_range_index=*/std::nullopt));
+        ASSERT_EQ(readers.size(), 1u);
+        std::string query = 
R"({"match":{"query":"document","operator":"And"}})";
+        CheckFullTextTopK(readers[0], query, std::nullopt, {0, 1, 2});
+        CheckFullTextTopK(readers[0], query, RoaringBitmap64::From({1, 2, 3}), 
{1, 2});
+    }
+}
+
+TEST_P(GlobalIndexTest, TestLuceneSearchOverlappingIndexRanges) {
+    auto tmp_dir = paimon::test::UniqueTestDirectory::Create();
+    ASSERT_TRUE(tmp_dir);
+    CheckSearchOverlappingIndexRanges("lucene-fts",
+                                      
{{"lucene-fts.write.omit-term-freq-and-position", "false"},
+                                       {"lucene-fts.write.tmp.directory", 
tmp_dir->Str()}},
+                                      
R"({"match":{"query":"document","operator":"And"}})");
 }
 #endif
 
@@ -2932,6 +3052,24 @@ TEST_P(GlobalIndexTest, 
TestWriteCommitScanReadFullTextIndexWithPartition) {
         ASSERT_OK_AND_ASSIGN(RowRangeIndex row_range_index, 
RowRangeIndex::Create({Range(2, 3)}));
         scan_and_check_result({{"f1", "20"}}, row_range_index, std::nullopt, 
"{2}");
     }
+    {
+        ASSERT_OK_AND_ASSIGN(
+            std::shared_ptr<GlobalIndexScan> global_index_scan,
+            GlobalIndexScan::Create(table_path, /*snapshot_id=*/std::nullopt,
+                                    /*partitions=*/std::nullopt, 
/*options=*/{}, fs_,
+                                    /*executor=*/nullptr, pool_));
+        ASSERT_OK_AND_ASSIGN(
+            auto readers, global_index_scan->CreateReaders("f0", 
/*row_range_index=*/std::nullopt));
+        ASSERT_EQ(readers.size(), 1u);
+        std::string query = R"({"match":{"query":"document"}})";
+        CheckFullTextTopK(readers[0], query, std::nullopt, {0, 1, 2});
+        CheckFullTextTopK(readers[0], query, RoaringBitmap64::From({1, 2, 3}), 
{1, 2});
+    }
+}
+
+TEST_P(GlobalIndexTest, TestFullTextSearchOverlappingIndexRanges) {
+    CheckSearchOverlappingIndexRanges("full-text", {{"full-text.tokenizer", 
"default"}},
+                                      R"({"match":{"query":"document"}})");
 }
 #endif
 

Reply via email to