Skip to content

Commit 5f9fe12

Browse files
committed
fix(global-index): apply global top-k to full-text search across shards
UnionGlobalIndexReader::VisitFullTextSearch ORed shard results, so a search with limit k could return up to shards x k rows, and it failed when two shards returned the same row id. It now merges shard results, where a duplicate row id keeps its first score, and keeps only the k highest-scoring rows. Scores are ordered with NaN highest and -0.0 below +0.0, and ties among merged rows are broken by ascending row id. A shard's engine still picks which rows tied at its cutoff score it returns, so the API comment and user guide promise row id order only among rows the shards return. A null search or a non-positive limit is now rejected. OffsetGlobalIndexReader shifted include row ids by the shard offset without clipping them, so row ids before a shard became negative local ids and row ids past it were kept. It now takes the end of its row range and clips include row ids to it. GlobalIndexScan now ranks a row covered by overlapping index ranges only in the first of them in (from, to) order, so the top k rows of each shard include all of its rows that score above the global k-th score. UnionGlobalIndexReader receives the rows each shard ranks, limits the shard's include row ids to them and skips a shard with none left. UnionGlobalIndexReader trims a copy of the include row ids, which costs time in proportion to the containers of the include set rather than to the rows of the range. FullTextSearch::OffsetRange shifts an include set of consecutive row ids as one range instead of one row id at a time.
1 parent 46cc9d6 commit 5f9fe12

12 files changed

Lines changed: 653 additions & 59 deletions

‎docs/source/user_guide/global_index.rst‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -168,9 +168,12 @@ index shard, the engine ranks matching rows by BM25 score and returns at most ``
168168
the highest scores. ``include_row_ids`` holds global row ids and restricts the rows that are
169169
ranked; an empty set matches no rows.
170170

171-
For both ``full-text`` and ``lucene-fts``, searches across multiple shards currently return the
172-
union of the shard candidates, which can exceed ``limit``. Applying a final global top-k is tracked
173-
in `issue #402 <https://github.com/apache/paimon-cpp/issues/402>`_.
171+
For both ``full-text`` and ``lucene-fts``, a search across multiple index shards keeps the
172+
``limit`` highest-scoring rows returned by the shards, breaking ties by ascending row id. Which rows
173+
tied at a shard's cutoff score the shard returns is up to its engine. A row in overlapping index
174+
ranges is ranked only by the range that starts first, or ends first if both start at the same row.
175+
A shard ignores the ``include_row_ids`` outside the rows it ranks and is not searched if none
176+
remain. The result iterates rows by ascending row id, not by score.
174177

175178
**Configuration:**
176179

‎include/paimon/global_index/global_index_reader.h‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,12 @@ class PAIMON_EXPORT GlobalIndexReader : public FunctionVisitor<std::shared_ptr<G
4545
virtual Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitVectorSearch(
4646
const std::shared_ptr<VectorSearch>& vector_search) = 0;
4747

48-
/// VisitFullTextSearch performs full text search and returns row ids with relevance scores.
49-
/// Each index shard returns at most `full_text_search->limit` rows. Union readers currently
50-
/// retain all shard candidates and can return more than that limit.
48+
/// VisitFullTextSearch performs full text search and returns at most `full_text_search->limit`
49+
/// row ids with the highest relevance scores, iterated by ascending row id rather than score.
50+
/// A search over multiple index shards keeps the `limit` highest-scoring rows returned by the
51+
/// shards, breaking ties by ascending row id; which rows tied at a shard's cutoff score the
52+
/// shard returns is up to its index engine. A row in overlapping index ranges is ranked only by
53+
/// the range that starts first, or ends first if both start at the same row.
5154
/// @warning `VisitFullTextSearch` may return error status when it is incorrectly invoked (e.g.,
5255
/// BTreeGlobalIndexReader call `VisitFullTextSearch`) or the query is invalid.
5356
virtual Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitFullTextSearch(

‎include/paimon/predicate/full_text_search.h‎

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
*/
1818

1919
#pragma once
20+
#include <algorithm>
2021
#include <cstdint>
2122
#include <functional>
2223
#include <map>
@@ -49,9 +50,23 @@ struct PAIMON_EXPORT FullTextSearch {
4950
}
5051
const RoaringBitmap64& global_row_ids = include_row_ids.value();
5152
RoaringBitmap64 local_row_ids;
52-
for (auto iter = global_row_ids.EqualOrLarger(from), end = global_row_ids.End();
53-
iter != end && *iter <= to; ++iter) {
54-
local_row_ids.Add(*iter - from);
53+
auto iter = global_row_ids.EqualOrLarger(from);
54+
auto end = global_row_ids.End();
55+
if (iter != end && *iter <= to) {
56+
// Shift one run of consecutive row ids as a range, so that converting a range of rows
57+
// to rank does not visit each row.
58+
int64_t first = *global_row_ids.Begin();
59+
int64_t last = first + (global_row_ids.Cardinality() - 1);
60+
if (last == RoaringBitmap64::MAX_VALUE ||
61+
global_row_ids.EqualOrLarger(last + 1) == end) {
62+
int64_t local_to = std::min(last, to) - from;
63+
local_row_ids.AddRange(*iter - from, local_to);
64+
local_row_ids.Add(local_to);
65+
} else {
66+
for (; iter != end && *iter <= to; ++iter) {
67+
local_row_ids.Add(*iter - from);
68+
}
69+
}
5570
}
5671
return std::make_shared<FullTextSearch>(field_name, query, limit, std::move(local_row_ids));
5772
}
@@ -83,7 +98,8 @@ struct PAIMON_EXPORT FullTextSearch {
8398
/// and rejects `boost` queries and a `fuzziness` other than 0. An invalid query is reported as
8499
/// an error `Status`.
85100
std::string query;
86-
/// Maximum number of highest-scoring rows requested from each index shard; must be positive.
101+
/// Maximum number of highest-scoring rows to return; must be positive. Each index shard is
102+
/// asked for this many rows before the shard results are merged.
87103
int32_t limit;
88104
/// The row ids to search within, aligned with Java `includeRowIds`. The row id domain is
89105
/// determined by the reader: global row ids for GlobalIndexReader and physical file-local row

‎src/paimon/common/global_index/offset_global_index_reader.cpp‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,8 @@
2424
namespace paimon {
2525

2626
OffsetGlobalIndexReader::OffsetGlobalIndexReader(std::shared_ptr<GlobalIndexReader>&& wrapped,
27-
int64_t offset)
28-
: wrapped_(std::move(wrapped)), offset_(offset) {}
27+
int64_t offset, int64_t to)
28+
: wrapped_(std::move(wrapped)), offset_(offset), to_(to) {}
2929

3030
Result<std::shared_ptr<GlobalIndexResult>> OffsetGlobalIndexReader::VisitIsNotNull() {
3131
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<GlobalIndexResult> result, wrapped_->VisitIsNotNull());
@@ -137,11 +137,10 @@ Result<std::shared_ptr<ScoredGlobalIndexResult>> OffsetGlobalIndexReader::VisitV
137137

138138
Result<std::shared_ptr<ScoredGlobalIndexResult>> OffsetGlobalIndexReader::VisitFullTextSearch(
139139
const std::shared_ptr<FullTextSearch>& full_text_search) {
140-
// Convert global include row ids to local row ids. The shard end is unknown here, so row ids
141-
// past the shard are kept; they match no local row.
140+
// Convert global include row ids to local row ids, dropping those outside [offset_, to_].
142141
std::shared_ptr<FullTextSearch> local_search = full_text_search;
143142
if (full_text_search && full_text_search->include_row_ids) {
144-
local_search = full_text_search->OffsetRange(offset_, RoaringBitmap64::MAX_VALUE);
143+
local_search = full_text_search->OffsetRange(offset_, to_);
145144
}
146145
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<ScoredGlobalIndexResult> result,
147146
wrapped_->VisitFullTextSearch(local_search));

‎src/paimon/common/global_index/offset_global_index_reader.h‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,11 @@ class OffsetGlobalIndexReader : public GlobalIndexReader {
3434
/// Constructs an OffsetGlobalIndexReader.
3535
/// @param wrapped The inner reader to delegate queries to. Its results are expected to
3636
/// contain local row ids.
37-
/// @param offset The offset to add to each row id in the results.
38-
OffsetGlobalIndexReader(std::shared_ptr<GlobalIndexReader>&& wrapped, int64_t offset);
37+
/// @param offset The first global row id covered by the wrapped reader, added to each row id
38+
/// in the results.
39+
/// @param to The last global row id (inclusive) covered by the wrapped reader.
40+
OffsetGlobalIndexReader(std::shared_ptr<GlobalIndexReader>&& wrapped, int64_t offset,
41+
int64_t to);
3942

4043
Result<std::shared_ptr<GlobalIndexResult>> VisitIsNotNull() override;
4144
Result<std::shared_ptr<GlobalIndexResult>> VisitIsNull() override;
@@ -78,6 +81,7 @@ class OffsetGlobalIndexReader : public GlobalIndexReader {
7881
private:
7982
std::shared_ptr<GlobalIndexReader> wrapped_;
8083
int64_t offset_;
84+
int64_t to_;
8185
};
8286

8387
} // namespace paimon

0 commit comments

Comments
 (0)