Skip to content

Commit 19685f3

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 as Java ScoredGlobalIndexResult#merge and #topK do: a duplicate row id keeps its first score, and only the k highest-scoring rows are kept. Scores follow Java Double#compare ordering, and ties among merged rows are broken by ascending row id. Which rows tied at a shard's cutoff the shard returns is still up to its engine, so the API comment and user guide promise row id order only among rows the shards return. 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 that range before shifting them. As in Java DataEvolutionFullTextScan#chooseIndexRanges, GlobalIndexScan ranks a row covered by overlapping index ranges only in the first of them, so the top k rows of each shard include all of its rows that score above the global k-th score. Each reader is given the last row that earlier ranges rank, which does not overflow when a range ends at INT64_MAX. A shard with no row left to rank is not searched, but it still rejects a non-positive limit.
1 parent 8182974 commit 19685f3

11 files changed

Lines changed: 504 additions & 52 deletions

File tree

‎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, as Java
173+
Paimon does. Which rows tied at a shard's cutoff score the shard returns is up to its engine. A row
174+
in overlapping index ranges is ranked only by the range that starts first, or ends first if both
175+
start at the same row. A shard ignores the ``include_row_ids`` outside the rows it ranks and is not
176+
searched if none 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: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,8 @@ struct PAIMON_EXPORT FullTextSearch {
8383
/// and rejects `boost` queries and a `fuzziness` other than 0. An invalid query is reported as
8484
/// an error `Status`.
8585
std::string query;
86-
/// Maximum number of highest-scoring rows requested from each index shard; must be positive.
86+
/// Maximum number of highest-scoring rows to return; must be positive. Each index shard is
87+
/// asked for this many rows before the shard results are merged.
8788
int32_t limit;
8889
/// The row ids to search within, aligned with Java `includeRowIds`. The row id domain is
8990
/// 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: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,28 @@
1919

2020
#include "paimon/common/global_index/offset_global_index_reader.h"
2121

22+
#include <limits>
2223
#include <utility>
24+
#include <vector>
25+
26+
#include "paimon/global_index/bitmap_scored_global_index_result.h"
27+
#include "paimon/utils/roaring_bitmap64.h"
2328

2429
namespace paimon {
30+
namespace {
31+
std::shared_ptr<ScoredGlobalIndexResult> EmptyScoredResult() {
32+
return std::make_shared<BitmapScoredGlobalIndexResult>(RoaringBitmap64(), std::vector<float>());
33+
}
34+
} // namespace
35+
36+
OffsetGlobalIndexReader::OffsetGlobalIndexReader(std::shared_ptr<GlobalIndexReader>&& wrapped,
37+
int64_t offset, int64_t to)
38+
: OffsetGlobalIndexReader(std::move(wrapped), offset, to,
39+
/*skip_to=*/std::numeric_limits<int64_t>::min()) {}
2540

2641
OffsetGlobalIndexReader::OffsetGlobalIndexReader(std::shared_ptr<GlobalIndexReader>&& wrapped,
27-
int64_t offset)
28-
: wrapped_(std::move(wrapped)), offset_(offset) {}
42+
int64_t offset, int64_t to, int64_t skip_to)
43+
: wrapped_(std::move(wrapped)), offset_(offset), to_(to), skip_to_(skip_to) {}
2944

3045
Result<std::shared_ptr<GlobalIndexResult>> OffsetGlobalIndexReader::VisitIsNotNull() {
3146
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<GlobalIndexResult> result, wrapped_->VisitIsNotNull());
@@ -137,11 +152,34 @@ Result<std::shared_ptr<ScoredGlobalIndexResult>> OffsetGlobalIndexReader::VisitV
137152

138153
Result<std::shared_ptr<ScoredGlobalIndexResult>> OffsetGlobalIndexReader::VisitFullTextSearch(
139154
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.
142155
std::shared_ptr<FullTextSearch> local_search = full_text_search;
143-
if (full_text_search && full_text_search->include_row_ids) {
144-
local_search = full_text_search->OffsetRange(offset_, RoaringBitmap64::MAX_VALUE);
156+
if (full_text_search) {
157+
if (full_text_search->limit <= 0) {
158+
return Status::Invalid("full-text search requires a positive limit");
159+
}
160+
// Rows up to `skip_to_` are ranked by a reader over an overlapping row range.
161+
if (skip_to_ >= to_) {
162+
return EmptyScoredResult();
163+
}
164+
bool skips_rows = skip_to_ >= offset_;
165+
if (full_text_search->include_row_ids) {
166+
local_search = full_text_search->OffsetRange(offset_, to_);
167+
if (skips_rows) {
168+
local_search->include_row_ids->RemoveRange(0, skip_to_ - offset_ + 1);
169+
}
170+
if (local_search->include_row_ids->IsEmpty()) {
171+
return EmptyScoredResult();
172+
}
173+
} else if (skips_rows) {
174+
// `local_to` may be the largest int64 value, so add it apart from the half-open range.
175+
int64_t local_to = to_ - offset_;
176+
RoaringBitmap64 local_row_ids;
177+
local_row_ids.AddRange(skip_to_ - offset_ + 1, local_to);
178+
local_row_ids.Add(local_to);
179+
local_search = std::make_shared<FullTextSearch>(
180+
full_text_search->field_name, full_text_search->query, full_text_search->limit,
181+
std::move(local_row_ids));
182+
}
145183
}
146184
PAIMON_ASSIGN_OR_RAISE(std::shared_ptr<ScoredGlobalIndexResult> result,
147185
wrapped_->VisitFullTextSearch(local_search));

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,22 @@ namespace paimon {
3131
/// results. This is used to convert local row IDs into global row IDs.
3232
class OffsetGlobalIndexReader : public GlobalIndexReader {
3333
public:
34-
/// Constructs an OffsetGlobalIndexReader.
34+
/// Constructs an OffsetGlobalIndexReader whose full-text searches rank all rows it covers.
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);
42+
43+
/// Constructs an OffsetGlobalIndexReader whose full-text searches rank rows in [`offset`,
44+
/// `to`], excluding rows up to `skip_to` (inclusive) assigned to earlier readers.
45+
/// No row is ranked if `skip_to` >= `to`. Storing the inclusive end avoids computing
46+
/// INT64_MAX + 1 when an earlier range covers the largest row id.
47+
/// @param skip_to The last global row id already assigned to an earlier reader.
48+
OffsetGlobalIndexReader(std::shared_ptr<GlobalIndexReader>&& wrapped, int64_t offset,
49+
int64_t to, int64_t skip_to);
3950

4051
Result<std::shared_ptr<GlobalIndexResult>> VisitIsNotNull() override;
4152
Result<std::shared_ptr<GlobalIndexResult>> VisitIsNull() override;
@@ -78,6 +89,8 @@ class OffsetGlobalIndexReader : public GlobalIndexReader {
7889
private:
7990
std::shared_ptr<GlobalIndexReader> wrapped_;
8091
int64_t offset_;
92+
int64_t to_;
93+
int64_t skip_to_;
8194
};
8295

8396
} // namespace paimon

0 commit comments

Comments
 (0)