Skip to content

Commit ec15e0d

Browse files
committed
feat(global-index): align FullTextSearch with Java JSON DSL query model
Align FullTextSearch with org.apache.paimon.predicate.FullTextSearch: - Make FullTextSearch take a field name, a JSON DSL query, a positive limit and optional include_row_ids, which replaces pre_filter. Remove SearchType, with_score, min_score and ReplacePreFilter. - Add FullTextSearch::OffsetRange(from, to), which clips include_row_ids to a shard range and shifts them to local row ids, and use it in OffsetGlobalIndexReader. - Make GlobalIndexReader::VisitFullTextSearch return ScoredGlobalIndexResult and drop the unscored and min_score paths of the full-text reader. - Translate match, multi_match, match_phrase and boolean queries into Lucene queries in the lucene-fts reader, validating them as the paimon-full-text engine does. Reject boost queries and fuzzy matching, and remove prefix, wildcard and unlimited Lucene searches. - Update tests and global index docs, and document that limit applies to each index shard until #402 adds a global top-k. Closes #401.
1 parent 7470e4c commit ec15e0d

30 files changed

Lines changed: 1033 additions & 775 deletions

‎docs/source/user_guide/global_index.rst‎

Lines changed: 46 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Paimon C++ supports the following global index types:
2424

2525
- **BTree Index**: An efficient index based on multi-level SST files for scalar column lookups.
2626
- **Range Bitmap Index**: A range bitmap index optimized for range predicates on ordered scalar columns. Extends the bitmap approach by encoding value ordering, enabling efficient less-than, greater-than, and range conditions.
27-
- **Lucene Index**: A full-text search index powered by Lucene++. Supports tokenized text search with multiple modes including match-all, match-any, phrase, prefix, and wildcard queries.
27+
- **Lucene Index**: A full-text search index powered by Lucene++. Supports tokenized text search with the JSON queries of the full-text index: match, multi-match, phrase and boolean queries.
2828
- **Full-Text Index (experimental)**: A full-text search index powered by the native
2929
``paimon-full-text-index`` engine. It uses the same index file format as Java Paimon's
3030
``full-text`` index.
@@ -66,23 +66,27 @@ less-than, greater-than, and range conditions without scanning all bitmaps.
6666
Lucene Index
6767
------------
6868

69-
A full-text search index powered by Lucene++. It supports tokenized text search with multiple
70-
search modes including match-all, match-any, phrase, prefix, and wildcard queries.
69+
A full-text search index powered by Lucene++. It uses ``FullTextSearch`` and the JSON DSL
70+
described in `Full-Text Index (Experimental)`_, translating queries into Lucene queries on the
71+
indexed field:
7172

72-
**Supported search types:**
73+
- ``match``: one term query per analyzed term, combined with OR, or with AND for
74+
``"operator": "And"``. ``boost`` sets the boost of the query.
75+
- ``multi_match``: one ``match`` query per column, combined with OR. Every column must be the
76+
indexed field.
77+
- ``match_phrase`` (alias ``phrase``): a phrase query with an optional ``slop``.
78+
- ``boolean``: a boolean query of the translated ``must``, ``should``, ``must_not`` and
79+
``queries`` clauses.
7380

74-
- ``MATCH_ALL``: All terms in the query must be present (AND semantics).
75-
- ``MATCH_ANY``: Any term in the query can match (OR semantics).
76-
- ``PHRASE``: Matches the exact sequence of words (with proximity).
77-
- ``PREFIX``: Matches terms starting with the given string (e.g., "run*" → running, runner). The
78-
query is not tokenized. The original prefix is retained, and a pure ASCII alphanumeric prefix
79-
is also matched using the lowercase case-normalization applied to pure ASCII terms at indexing
80-
time. This preserves matches for mixed terms such as ``B超`` while allowing ``THIS`` to match
81-
terms indexed as ``this...``.
82-
- ``WILDCARD``: Supports wildcards ``*`` and ``?`` (e.g., "ap*e", "app?e" → "apple"). The query
83-
is not tokenized, and wildcard operators are preserved. Both the original pattern and an
84-
alternative with each ASCII alphanumeric fragment lowercased are matched, covering pure ASCII
85-
and mixed ASCII/non-ASCII terms.
81+
``boost`` queries and fuzzy matching (a ``fuzziness`` other than 0) are rejected with a
82+
``NotImplemented`` status, and a ``column`` other than the indexed field is rejected. The query
83+
text is analyzed with the Jieba tokenizer of the index. Each index shard returns at most ``limit``
84+
rows with the highest Lucene relevance scores.
85+
86+
Duplicate known fields, raw NUL bytes in the JSON text and boosts that do not round to a finite
87+
positive float are rejected, while unknown fields are ignored. Leading and trailing Unicode
88+
whitespace in operators, boolean occurrences and column names is trimmed as in the native DSL, and
89+
a column name containing only such whitespace selects the indexed field.
8690

8791
**Special Configuration:**
8892

@@ -117,20 +121,37 @@ by it. Rebuild such indexes with the ``full-text`` index type.
117121

118122
**Queries:**
119123

120-
``FullTextSearch::query`` is passed to the engine unchanged and must be a JSON query, for example:
124+
``FullTextSearch`` is aligned with Java Paimon: it takes the field name, a JSON query, a positive
125+
``limit`` and optional ``include_row_ids``. ``FullTextSearch::query`` is passed to the engine
126+
unchanged and must be a JSON query, for example:
121127

122128
- ``{"match": {"query": "paimon lake"}}``: rows that contain any of the analyzed terms. Add
123-
``"operator": "And"`` to require all terms.
129+
``"operator": "And"`` to require all terms. ``boost``, ``fuzziness``, ``max_expansions`` and
130+
``prefix_length`` are optional.
131+
- ``{"multi_match": {"query": "paimon lake", "columns": ["text"]}}``: a ``match`` query on the
132+
default native index field, with optional per-column ``boosts``.
124133
- ``{"match_phrase": {"query": "data lake"}}``: rows that contain the terms as a phrase. An
125134
optional ``"slop"`` allows other terms between them.
126135
- ``{"boolean": {"must": [...], "should": [...], "must_not": [...]}}``: combines other queries.
127-
128-
The query text is analyzed with the analyzer stored in the index file. ``search_type`` is ignored.
129-
A positive ``limit`` is required. The engine always ranks the matching rows by BM25 score and
130-
returns the ``limit`` rows with the highest scores; ``pre_filter`` restricts the rows that are
131-
ranked. ``min_score`` is applied to those rows afterwards and drops rows whose score is not greater
132-
than it, so a search can return fewer than ``limit`` rows. ``with_score`` only selects whether the
133-
scores are returned; scores are computed either way.
136+
- ``{"boost": {"positive": {...}, "negative": {...}, "negative_boost": 0.5}}``: lowers the score
137+
of rows that also match the negative query.
138+
139+
The C++ writer indexes one table column. ``FullTextSearch::field_name`` selects that table column,
140+
while ``column`` and ``columns`` in the JSON query name fields inside the native index. The native
141+
field defaults to ``text`` and can be renamed with ``full-text.text-field``. These names do not
142+
enable searches across multiple table columns. For ``lucene-fts``, DSL column names must instead
143+
match the indexed table column.
144+
145+
The query text is analyzed with the analyzer stored in the index file, and an invalid query is
146+
reported as an error status. A search always returns a ``ScoredGlobalIndexResult``. Within each
147+
index shard, the engine ranks matching rows by BM25 score and returns at most ``limit`` rows with
148+
the highest scores.
149+
``include_row_ids`` holds global row ids and restricts the rows that are ranked; an empty set
150+
matches no rows.
151+
152+
For both ``full-text`` and ``lucene-fts``, searches across multiple shards currently return the
153+
union of the shard candidates, which can exceed ``limit``. Applying a final global top-k is tracked
154+
in `issue #402 <https://github.com/apache/paimon-cpp/issues/402>`_.
134155

135156
**Configuration:**
136157

‎include/paimon/global_index/global_index_reader.h‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +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.
49-
virtual Result<std::shared_ptr<GlobalIndexResult>> VisitFullTextSearch(
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.
51+
/// @warning `VisitFullTextSearch` may return error status when it is incorrectly invoked (e.g.,
52+
/// BTreeGlobalIndexReader call `VisitFullTextSearch`) or the query is invalid.
53+
virtual Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitFullTextSearch(
5054
const std::shared_ptr<FullTextSearch>& full_text_search) = 0;
5155

5256
/// @return true if the reader is thread-safe; false otherwise.

‎include/paimon/predicate/full_text_search.h‎

Lines changed: 47 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -17,121 +17,76 @@
1717
*/
1818

1919
#pragma once
20+
#include <cstdint>
2021
#include <functional>
2122
#include <map>
2223
#include <memory>
2324
#include <optional>
2425
#include <string>
26+
#include <utility>
2527
#include <vector>
2628

2729
#include "paimon/predicate/predicate.h"
2830
#include "paimon/utils/roaring_bitmap64.h"
2931
#include "paimon/visibility.h"
3032
namespace paimon {
31-
/// A configuration structure for full-text search operations.
33+
/// A full-text search on a full-text indexed field, aligned with Java
34+
/// `org.apache.paimon.predicate.FullTextSearch`.
3235
struct PAIMON_EXPORT FullTextSearch {
33-
/// Enumeration of supported full-text search types.
34-
enum class SearchType {
35-
/// All terms in the query must be present (AND semantics).
36-
MATCH_ALL = 1,
37-
/// Any term in the query can match (OR semantics).
38-
MATCH_ANY = 2,
39-
/// Matches the exact sequence of words (with proximity).
40-
PHRASE = 3,
41-
/// Matches terms starting with the given string (e.g., "run*" → running, runner).
42-
PREFIX = 4,
43-
/// Supports wildcards * and ? (e.g., "ap*e", "app?e" -> "apple").
44-
WILDCARD = 5,
45-
/// Default/fallback type for unrecognized or invalid queries.
46-
UNKNOWN = 128
47-
};
48-
49-
FullTextSearch(const std::string& _field_name, std::optional<int32_t> _limit,
50-
const std::string& _query, const SearchType& _search_type,
51-
const std::optional<RoaringBitmap64>& _pre_filter, bool _with_score = false,
52-
std::optional<float> _min_score = std::nullopt)
36+
FullTextSearch(const std::string& _field_name, const std::string& _query, int32_t _limit,
37+
std::optional<RoaringBitmap64> _include_row_ids = std::nullopt)
5338
: field_name(_field_name),
54-
limit(_limit),
5539
query(_query),
56-
search_type(_search_type),
57-
pre_filter(_pre_filter),
58-
with_score(_with_score),
59-
min_score(_min_score) {}
40+
limit(_limit),
41+
include_row_ids(std::move(_include_row_ids)) {}
6042

61-
std::shared_ptr<FullTextSearch> ReplacePreFilter(
62-
const std::optional<RoaringBitmap64>& _pre_filter) const {
63-
return std::make_shared<FullTextSearch>(field_name, limit, query, search_type, _pre_filter,
64-
with_score, min_score);
43+
/// Returns a copy of this search for an index shard covering the global row ids [`from`, `to`]
44+
/// (both inclusive), as Java `FullTextSearch#offsetRange` does: `include_row_ids` is clipped to
45+
/// the range and shifted to shard-local row ids by subtracting `from`.
46+
std::shared_ptr<FullTextSearch> OffsetRange(int64_t from, int64_t to) const {
47+
if (!include_row_ids) {
48+
return std::make_shared<FullTextSearch>(*this);
49+
}
50+
const RoaringBitmap64& global_row_ids = include_row_ids.value();
51+
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);
55+
}
56+
return std::make_shared<FullTextSearch>(field_name, query, limit, std::move(local_row_ids));
6557
}
6658

6759
/// Name of the field to search within (must be a full-text indexed field).
6860
std::string field_name;
69-
/// Maximum number of documents to return. Purely a truncation switch,
70-
/// orthogonal to `with_score`: set `with_score = true` to get relevance
71-
/// scores; a non-empty `limit` does not by itself imply scoring.
72-
///
73-
/// The `full-text` index requires a positive `limit` and always returns at most `limit` rows
74-
/// with the highest scores.
75-
std::optional<int32_t> limit;
76-
/// The query string to search for. The interpretation depends on search_type:
77-
///
78-
/// - For MATCH_ALL/MATCH_ANY: keywords are split into terms using the **same analyzer as
79-
/// indexing**.
80-
/// Example: "Hello World" → terms ["hello", "world"] (after lowercasing and tokenization).
61+
/// The JSON DSL query defined by the `paimon-full-text` engine (`core/src/query.rs` in
62+
/// apache/paimon-full-text). The root object holds exactly one query:
8163
///
82-
/// - For PHRASE: matches the exact word sequence (with optional slop). Also be analyzed.
64+
/// - `match`: `query` (alias `terms`), optional `column`, `operator` (`Or` by default, or
65+
/// `And`), `boost` (1.0), `fuzziness` (0, a number, or `"auto"` or `null` for automatic),
66+
/// `max_expansions` (50) and `prefix_length` (0). The text is analyzed with the analyzer
67+
/// used at indexing time.
68+
/// - `multi_match`: `query`, `columns`, `boosts`, `operator`, `fuzziness`, `max_expansions`
69+
/// and `prefix_length`.
70+
/// - `match_phrase` (alias `phrase`): `query`, optional `column` and `slop` (0).
71+
/// - `boolean`: `must`, `should` and `must_not` lists of queries, and `queries` as
72+
/// `[occur, query]` pairs.
73+
/// - `boost`: `positive`, `negative` and `negative_boost` (0.5).
8374
///
84-
/// - For PREFIX: matches terms starting with the given string (e.g., "run" → running, runner).
85-
/// The query is not tokenized or filtered for stop words. The original prefix is retained,
86-
/// and a prefix consisting entirely of ASCII letters and digits is also matched using the
87-
/// lowercase case-normalization applied to pure ASCII terms at indexing time.
75+
/// Examples: `{"match":{"query":"paimon lake","operator":"And"}}` and
76+
/// `{"match_phrase":{"query":"paimon lake","slop":1}}`.
8877
///
89-
/// - For WILDCARD: supports wildcards * and ? (e.g., "ap*e", "app?e").
90-
/// The query is not tokenized or filtered for stop words. The wildcard operators are
91-
/// preserved. Both the original pattern and an alternative with each ASCII alphanumeric
92-
/// fragment lowercased are matched, covering pure ASCII and mixed ASCII/non-ASCII terms.
78+
/// `field_name` selects the table column. DSL column names refer to fields inside the index:
79+
/// the `full-text` backend defaults to `text`, while `lucene-fts` uses `field_name`.
9380
///
94-
/// - For the `full-text` index, `search_type` is ignored and the query is passed to the
95-
/// native engine unchanged, so it must be a JSON DSL query such as
96-
/// `{"match":{"query":"paimon lake"}}`.
97-
///
98-
/// @note Analyzer consistency between indexing and querying is critical for correctness.
81+
/// The `full-text` index passes the query to the native engine unchanged. The `lucene-fts`
82+
/// index translates `match`, `multi_match`, `match_phrase` and `boolean` into Lucene queries
83+
/// and rejects `boost` queries and a `fuzziness` other than 0. An invalid query is reported as
84+
/// an error `Status`.
9985
std::string query;
100-
/// Type of search to perform.
101-
SearchType search_type;
102-
/// A pre-filter based on **global row IDs**, implemented by leveraging another global index.
103-
/// Only rows whose global row ID is present in `pre_filter` will be included during search.
104-
/// If not set, all rows will be included.
105-
std::optional<RoaringBitmap64> pre_filter;
106-
/// Whether to compute and return relevance scores (e.g. BM25). The 4-path matrix:
107-
/// - `with_score=false, limit=nullopt` → BitmapGlobalIndexResult (all rows, no score)
108-
/// - `with_score=false, limit=N` → BitmapGlobalIndexResult (any N matches, unscored)
109-
/// - `with_score=true, limit=nullopt` → BitmapScoredGlobalIndexResult (all rows + all scores)
110-
/// - `with_score=true, limit=N` → BitmapScoredGlobalIndexResult (top-N by score +
111-
/// scores)
112-
///
113-
/// For plain `LIMIT N` without ORDER BY (the common case when an online
114-
/// engine, e.g. StarRocks, pushes down a predicate) set `with_score=false,
115-
/// limit=N` — the unscored fast path, where the backend provides one. For top-N by relevance
116-
/// use `with_score=true, limit=N` and drop the scores in the caller if unneeded.
117-
///
118-
/// The built-in indexes deviate from this matrix:
119-
/// - `lucene` ignores `with_score`: a search with `limit` returns the top `limit` rows with
120-
/// their scores, and a search without `limit` returns all matching rows without scores.
121-
/// - `full-text` requires `limit` and always ranks the matching rows by BM25 score, keeping the
122-
/// top `limit` rows. `with_score` only selects whether their scores are returned.
123-
///
124-
/// Default is `false`, which avoids score computation on backends with an unscored path.
125-
bool with_score = false;
126-
/// Minimum relevance-score threshold (exclusive); results with score ≤ this value are
127-
/// excluded. The score is whatever the backend's similarity produces (e.g. BM25 for
128-
/// full-text, classic TF-IDF for lucene), so a threshold is not directly comparable across
129-
/// backends.
130-
///
131-
/// Only the `full-text` index supports it; the `lucene` index rejects a search that sets it.
132-
/// The `full-text` index applies it to the top `limit` rows returned by the engine, so a
133-
/// search can return fewer than `limit` rows.
134-
/// Default is nullopt (no threshold filtering).
135-
std::optional<float> min_score;
86+
/// Maximum number of highest-scoring rows requested from each index shard; must be positive.
87+
int32_t limit;
88+
/// The **global row ids** to search within, aligned with Java `includeRowIds`. Only these rows
89+
/// are ranked, so an empty set matches no rows. If not set, all rows are searched.
90+
std::optional<RoaringBitmap64> include_row_ids;
13691
};
13792
} // namespace paimon

‎src/paimon/CMakeLists.txt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,7 @@ if(PAIMON_BUILD_TESTS)
642642
common/metrics/histogram_windowing_test.cpp
643643
common/options/memory_size_test.cpp
644644
common/options/time_duration_test.cpp
645+
common/predicate/full_text_search_test.cpp
645646
common/predicate/literal_converter_test.cpp
646647
common/predicate/literal_test.cpp
647648
common/predicate/multi_literals_leaf_function_test.cpp

‎src/paimon/common/global_index/bitmap/bitmap_index_reader.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ Result<std::shared_ptr<ScoredGlobalIndexResult>> BitmapIndexReader::VisitVectorS
147147
return Status::Invalid("Vector search is not supported in BitmapIndexReader.");
148148
}
149149

150-
Result<std::shared_ptr<GlobalIndexResult>> BitmapIndexReader::VisitFullTextSearch(
150+
Result<std::shared_ptr<ScoredGlobalIndexResult>> BitmapIndexReader::VisitFullTextSearch(
151151
const std::shared_ptr<FullTextSearch>& full_text_search) {
152152
return Status::Invalid("Full text search is not supported in BitmapIndexReader.");
153153
}

‎src/paimon/common/global_index/bitmap/bitmap_index_reader.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ class BitmapIndexReader : public GlobalIndexReader,
6969

7070
Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitVectorSearch(
7171
const std::shared_ptr<VectorSearch>& vector_search) override;
72-
Result<std::shared_ptr<GlobalIndexResult>> VisitFullTextSearch(
72+
Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitFullTextSearch(
7373
const std::shared_ptr<FullTextSearch>& full_text_search) override;
7474

7575
bool IsThreadSafe() const override {

‎src/paimon/common/global_index/btree/btree_global_index_reader.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,7 +272,7 @@ Result<std::shared_ptr<ScoredGlobalIndexResult>> BTreeGlobalIndexReader::VisitVe
272272
return Status::Invalid("Vector search not supported in BTree index");
273273
}
274274

275-
Result<std::shared_ptr<GlobalIndexResult>> BTreeGlobalIndexReader::VisitFullTextSearch(
275+
Result<std::shared_ptr<ScoredGlobalIndexResult>> BTreeGlobalIndexReader::VisitFullTextSearch(
276276
const std::shared_ptr<FullTextSearch>& full_text_search) {
277277
return Status::Invalid("Full text search not supported in BTree index");
278278
}

‎src/paimon/common/global_index/btree/btree_global_index_reader.h‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ class BTreeGlobalIndexReader : public GlobalIndexReader,
7979
Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitVectorSearch(
8080
const std::shared_ptr<VectorSearch>& vector_search) override;
8181

82-
Result<std::shared_ptr<GlobalIndexResult>> VisitFullTextSearch(
82+
Result<std::shared_ptr<ScoredGlobalIndexResult>> VisitFullTextSearch(
8383
const std::shared_ptr<FullTextSearch>& full_text_search) override;
8484

8585
bool IsThreadSafe() const override {

‎src/paimon/common/global_index/btree/lazy_filtered_btree_reader.cpp‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,7 @@ Result<std::shared_ptr<ScoredGlobalIndexResult>> LazyFilteredBTreeReader::VisitV
180180
return Status::Invalid("LazyFilteredBTreeReader does not support vector search");
181181
}
182182

183-
Result<std::shared_ptr<GlobalIndexResult>> LazyFilteredBTreeReader::VisitFullTextSearch(
183+
Result<std::shared_ptr<ScoredGlobalIndexResult>> LazyFilteredBTreeReader::VisitFullTextSearch(
184184
const std::shared_ptr<FullTextSearch>& full_text_search) {
185185
return Status::Invalid("LazyFilteredBTreeReader does not support full text search");
186186
}

0 commit comments

Comments
 (0)