Skip to content

Commit f7edb6c

Browse files
authored
fix(lucene): normalize prefix and wildcard queries (alibaba#458)
1 parent af80965 commit f7edb6c

12 files changed

Lines changed: 337 additions & 55 deletions

File tree

crates/tantivy_ffi/src/reader.rs

Lines changed: 99 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@
2121
//! 1 MATCH_ALL — tokenize query, BooleanQuery (Must)
2222
//! 2 MATCH_ANY — tokenize query, BooleanQuery (Should)
2323
//! 3 PHRASE — tokenize query, PhraseQuery
24-
//! 4 PREFIX — RegexQuery `<escaped>.*` (no tokenization, mirrors lucene-fts)
25-
//! 5 WILDCARD — RegexQuery from glob pattern (`*` → `.*`, `?` → `.`, others escaped)
24+
//! 4 PREFIX — original + case-normalized RegexQuery `<escaped>.*` (no tokenization)
25+
//! 5 WILDCARD — original + case-normalized RegexQuery from glob pattern
26+
//! (`*` → `.*`, `?` → `.`, others escaped)
2627
//!
2728
//! For paimon-java compatibility, row_id is stored as an explicit u64 field
2829
//! (`fast` for O(1) retrieval). Reader translates tantivy DocAddress → row_id
@@ -37,7 +38,9 @@ use std::path::Path;
3738
use croaring::{Portable, Treemap};
3839
use tantivy::collector::{Collector, SegmentCollector};
3940
use tantivy::columnar::Column;
40-
use tantivy::query::{BooleanQuery, Occur, PhraseQuery, Query, RegexQuery, TermQuery};
41+
use tantivy::query::{
42+
BooleanQuery, DisjunctionMaxQuery, Occur, PhraseQuery, Query, RegexQuery, TermQuery,
43+
};
4144
use tantivy::schema::{Field, IndexRecordOption};
4245
use tantivy::{DocAddress, DocId, Index, IndexReader, ReloadPolicy, Score, SegmentOrdinal,
4346
SegmentReader, Term};
@@ -46,7 +49,7 @@ use crate::buffer::PaimonTantivyBuffer;
4649
use crate::callback_directory::{PaimonCallbackDirectory, PaimonStreamCallbacks};
4750
use crate::error::{set_last_error, PaimonTantivyStatus};
4851
use crate::handle::{borrow_handle_mut, free_handle, into_handle};
49-
use crate::tokenizer::{PaimonJiebaTokenizer, TokenizeMode};
52+
use crate::tokenizer::{normalize_case, PaimonJiebaTokenizer, TokenizeMode};
5053
use crate::writer::{PAIMON_ROW_ID_FIELD_NAME, PAIMON_TEXT_FIELD_NAME, PAIMON_TOKENIZER_NAME};
5154

5255
/// Numeric encoding of `paimon::FullTextSearch::SearchType`. Kept in sync
@@ -229,22 +232,44 @@ impl PaimonTantivyReader {
229232
if query.is_empty() {
230233
return Err("prefix query is empty".into());
231234
}
232-
// Mirror lucene-fts: don't tokenize prefix; match indexed term bytes
233-
// starting with the given prefix verbatim.
234-
let pattern = format!("{}.*", regex_escape(query));
235-
RegexQuery::from_pattern(&pattern, self.text_field)
236-
.map(|q| Box::new(q) as Box<dyn Query>)
237-
.map_err(|e| format!("RegexQuery from prefix {query:?}: {e}"))
235+
// Mirror lucene-fts: don't tokenize the prefix. Retain the original form for mixed
236+
// ASCII/CJK indexed terms, and add the normalized form for pure ASCII terms.
237+
let normalized_query = normalize_case(query);
238+
let create_query = |value: &str| -> Result<Box<dyn Query>, String> {
239+
let pattern = format!("{}.*", regex_escape(value));
240+
RegexQuery::from_pattern(&pattern, self.text_field)
241+
.map(|q| Box::new(q) as Box<dyn Query>)
242+
.map_err(|e| format!("RegexQuery from prefix {value:?}: {e}"))
243+
};
244+
let original = create_query(query)?;
245+
if normalized_query == query {
246+
return Ok(original);
247+
}
248+
let normalized = create_query(&normalized_query)?;
249+
Ok(Box::new(DisjunctionMaxQuery::new(vec![
250+
original, normalized,
251+
])))
238252
}
239253

240254
fn build_wildcard_query(&self, query: &str) -> Result<Box<dyn Query>, String> {
241255
if query.is_empty() {
242256
return Err("wildcard query is empty".into());
243257
}
244-
let pattern = wildcard_to_regex(query);
245-
RegexQuery::from_pattern(&pattern, self.text_field)
246-
.map(|q| Box::new(q) as Box<dyn Query>)
247-
.map_err(|e| format!("RegexQuery from wildcard {query:?} (pattern {pattern}): {e}"))
258+
let normalized_query = normalize_wildcard_query(query);
259+
let create_query = |value: &str| -> Result<Box<dyn Query>, String> {
260+
let pattern = wildcard_to_regex(value);
261+
RegexQuery::from_pattern(&pattern, self.text_field)
262+
.map(|q| Box::new(q) as Box<dyn Query>)
263+
.map_err(|e| format!("RegexQuery from wildcard {value:?} (pattern {pattern}): {e}"))
264+
};
265+
let original = create_query(query)?;
266+
if normalized_query == query {
267+
return Ok(original);
268+
}
269+
let normalized = create_query(&normalized_query)?;
270+
Ok(Box::new(DisjunctionMaxQuery::new(vec![
271+
original, normalized,
272+
])))
248273
}
249274

250275
fn build_query(&self, search_type: SearchType, query: &str) -> Result<Box<dyn Query>, String> {
@@ -471,6 +496,23 @@ fn regex_escape(input: &str) -> String {
471496
out
472497
}
473498

499+
/// Normalize each literal segment independently while preserving wildcard
500+
/// operators. This matches lucene-fts, where `*` and `?` are not analyzed.
501+
fn normalize_wildcard_query(input: &str) -> String {
502+
let mut out = String::with_capacity(input.len());
503+
let mut segment_start = 0;
504+
for (index, ch) in input.char_indices() {
505+
if ch != '*' && ch != '?' {
506+
continue;
507+
}
508+
out.push_str(&normalize_case(&input[segment_start..index]));
509+
out.push(ch);
510+
segment_start = index + ch.len_utf8();
511+
}
512+
out.push_str(&normalize_case(&input[segment_start..]));
513+
out
514+
}
515+
474516
/// Translate a glob-style wildcard ('*' = any, '?' = single char) into a
475517
/// regex pattern, escaping all other regex metacharacters.
476518
fn wildcard_to_regex(input: &str) -> String {
@@ -1024,6 +1066,14 @@ mod tests {
10241066
assert_eq!(ids, vec![0u64]);
10251067
}
10261068

1069+
#[test]
1070+
fn prefix_normalizes_ascii_alphanumeric_case() {
1071+
let bytes = build(&["This is a test document", "another document"]);
1072+
let r = open(&bytes);
1073+
let ids = r.search_all(SearchType::Prefix, "THIS").unwrap();
1074+
assert_eq!(ids, vec![0u64]);
1075+
}
1076+
10271077
#[test]
10281078
fn wildcard_with_star() {
10291079
let bytes = build(&["unordered", "ordered", "border"]);
@@ -1032,6 +1082,34 @@ mod tests {
10321082
assert_eq!(ids, vec![0u64, 1, 2]);
10331083
}
10341084

1085+
#[test]
1086+
fn wildcard_normalizes_ascii_alphanumeric_segments() {
1087+
let bytes = build(&["This is a test document", "another document"]);
1088+
let r = open(&bytes);
1089+
assert_eq!(
1090+
r.search_all(SearchType::Wildcard, "*THIS*").unwrap(),
1091+
vec![0u64]
1092+
);
1093+
assert_eq!(
1094+
r.search_all(SearchType::Wildcard, "*?HIS*").unwrap(),
1095+
vec![0u64]
1096+
);
1097+
}
1098+
1099+
#[test]
1100+
fn prefix_and_wildcard_preserve_mixed_ascii_cjk_terms() {
1101+
let bytes = build(&["B超检查", "T恤"]);
1102+
let r = open(&bytes);
1103+
assert_eq!(
1104+
r.search_all(SearchType::Prefix, "B").unwrap(),
1105+
vec![0u64]
1106+
);
1107+
assert_eq!(
1108+
r.search_all(SearchType::Wildcard, "*T*").unwrap(),
1109+
vec![1u64]
1110+
);
1111+
}
1112+
10351113
#[test]
10361114
fn empty_query_for_match_returns_query_parse_error() {
10371115
let bytes = build(&["hello"]);
@@ -1048,6 +1126,13 @@ mod tests {
10481126
assert_eq!(wildcard_to_regex("*a*"), ".*a.*");
10491127
}
10501128

1129+
#[test]
1130+
fn wildcard_normalization_preserves_non_alphanumeric_segments() {
1131+
assert_eq!(normalize_wildcard_query("*?HIS*"), "*?his*");
1132+
assert_eq!(normalize_wildcard_query("*THIS_IS*"), "*THIS_IS*");
1133+
assert_eq!(normalize_wildcard_query("*机器*"), "*机器*");
1134+
}
1135+
10511136
// ----- limit + pre_filter + scoring (row_id-based) -----
10521137

10531138
#[test]

crates/tantivy_ffi/src/tokenizer.rs

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -120,11 +120,7 @@ impl PaimonJiebaTokenizer {
120120
let start = piece.as_ptr() as usize - text_start;
121121
let end = start + piece.len();
122122
// lowercase only if pure ASCII alphanumeric (match cppjieba Normalize behavior)
123-
let token_text = if is_ascii_alnum(piece) {
124-
piece.to_ascii_lowercase()
125-
} else {
126-
piece.to_string()
127-
};
123+
let token_text = normalize_case(piece);
128124
out.push((start, end, token_text));
129125
}
130126
out
@@ -135,6 +131,14 @@ fn is_ascii_alnum(s: &str) -> bool {
135131
!s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric())
136132
}
137133

134+
pub(crate) fn normalize_case(s: &str) -> String {
135+
if is_ascii_alnum(s) {
136+
s.to_ascii_lowercase()
137+
} else {
138+
s.to_string()
139+
}
140+
}
141+
138142
fn load_jieba(dict_dir: &Path) -> Result<Jieba, String> {
139143
let main_dict = dict_dir.join("jieba.dict.utf8");
140144
let mut jieba = if main_dict.exists() {

docs/source/user_guide/global_index.rst

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,8 +71,15 @@ search modes including match-all, match-any, phrase, prefix, and wildcard querie
7171
- ``MATCH_ALL``: All terms in the query must be present (AND semantics).
7272
- ``MATCH_ANY``: Any term in the query can match (OR semantics).
7373
- ``PHRASE``: Matches the exact sequence of words (with proximity).
74-
- ``PREFIX``: Matches terms starting with the given string (e.g., "run*" → running, runner).
75-
- ``WILDCARD``: Supports wildcards ``*`` and ``?`` (e.g., "ap*e", "app?e" → "apple").
74+
- ``PREFIX``: Matches terms starting with the given string (e.g., "run*" → running, runner). The
75+
query is not tokenized. The original prefix is retained, and a pure ASCII alphanumeric prefix
76+
is also matched using the lowercase case-normalization applied to pure ASCII terms at indexing
77+
time. This preserves matches for mixed terms such as ``B超`` while allowing ``THIS`` to match
78+
terms indexed as ``this...``.
79+
- ``WILDCARD``: Supports wildcards ``*`` and ``?`` (e.g., "ap*e", "app?e" → "apple"). The query
80+
is not tokenized, and wildcard operators are preserved. Both the original pattern and an
81+
alternative with each ASCII alphanumeric fragment lowercased are matched, covering pure ASCII
82+
and mixed ASCII/non-ASCII terms.
7683

7784
**Special Configuration:**
7885

include/paimon/predicate/full_text_search.h

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,14 @@ struct PAIMON_EXPORT FullTextSearch {
7777
/// - For PHRASE: matches the exact word sequence (with optional slop). Also be analyzed.
7878
///
7979
/// - For PREFIX: matches terms starting with the given string (e.g., "run" → running, runner).
80-
/// Only the prefix part is considered; analysis will not be applied.
80+
/// The query is not tokenized or filtered for stop words. The original prefix is retained,
81+
/// and a prefix consisting entirely of ASCII letters and digits is also matched using the
82+
/// lowercase case-normalization applied to pure ASCII terms at indexing time.
8183
///
8284
/// - For WILDCARD: supports wildcards * and ? (e.g., "ap*e", "app?e").
83-
/// Not passed through analyzer — matched directly against indexed terms.
85+
/// The query is not tokenized or filtered for stop words. The wildcard operators are
86+
/// preserved. Both the original pattern and an alternative with each ASCII alphanumeric
87+
/// fragment lowercased are matched, covering pure ASCII and mixed ASCII/non-ASCII terms.
8488
///
8589
/// @note Analyzer consistency between indexing and querying is critical for correctness.
8690
std::string query;

src/paimon/global_index/lucene/jieba_analyzer.cpp

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,21 @@ void JiebaTokenizer::CutWithMode(const std::string& tokenize_mode, const cppjieb
8383
}
8484
}
8585

86+
void JiebaTokenizer::NormalizeCase(std::string* term) {
87+
bool is_alphanumeric = true;
88+
for (const auto& c : *term) {
89+
if (!std::isalnum(static_cast<unsigned char>(c))) {
90+
is_alphanumeric = false;
91+
break;
92+
}
93+
}
94+
if (is_alphanumeric && !term->empty()) {
95+
std::transform(term->begin(), term->end(), term->begin(), [](char ch) {
96+
return static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
97+
});
98+
}
99+
}
100+
86101
void JiebaTokenizer::Normalize(const std::unordered_set<std::string>& stop_words,
87102
std::vector<std::string>* input_ptr,
88103
std::vector<std::string_view>* output_ptr) {
@@ -98,19 +113,7 @@ void JiebaTokenizer::Normalize(const std::unordered_set<std::string>& stop_words
98113
if (stop_words.find(term) != stop_words.end()) {
99114
continue;
100115
}
101-
// to lower case
102-
bool is_alphanumeric = true;
103-
for (const auto& c : term) {
104-
if (!std::isalnum(static_cast<unsigned char>(c))) {
105-
is_alphanumeric = false;
106-
break;
107-
}
108-
}
109-
if (is_alphanumeric && !term.empty()) {
110-
std::transform(term.begin(), term.end(), term.begin(), [](char ch) {
111-
return static_cast<char>(std::tolower(static_cast<unsigned char>(ch)));
112-
});
113-
}
116+
NormalizeCase(&term);
114117
output.emplace_back(term.data(), term.length());
115118
}
116119
}

src/paimon/global_index/lucene/jieba_analyzer.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ class JiebaTokenizer : public Lucene::Tokenizer {
5353
static void CutWithMode(const std::string& tokenize_mode, const cppjieba::Jieba* jieba,
5454
const std::string& str, std::vector<std::string>* terms_ptr);
5555

56+
static void NormalizeCase(std::string* term);
57+
5658
// In-place converts each string in `input` to lowercase to avoid data copying.
5759
static void Normalize(const std::unordered_set<std::string>& stop_words,
5860
std::vector<std::string>* input, std::vector<std::string_view>* output);

src/paimon/global_index/lucene/jieba_analyzer_test.cpp

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,24 @@ TEST_P(JiebaAnalyzerTest, TestNormalize) {
107107
ASSERT_EQ(expected, results);
108108
}
109109

110+
TEST(JiebaTokenizerTest, TestNormalizeCase) {
111+
std::string alphanumeric_term = "THIS123";
112+
JiebaTokenizer::NormalizeCase(&alphanumeric_term);
113+
ASSERT_EQ(alphanumeric_term, "this123");
114+
115+
std::string mixed_ascii_cjk_term = "B超";
116+
JiebaTokenizer::NormalizeCase(&mixed_ascii_cjk_term);
117+
ASSERT_EQ(mixed_ascii_cjk_term, "B超");
118+
119+
std::string term_with_underscore = "THIS_IS";
120+
JiebaTokenizer::NormalizeCase(&term_with_underscore);
121+
ASSERT_EQ(term_with_underscore, "THIS_IS");
122+
123+
std::string chinese_term = "机器";
124+
JiebaTokenizer::NormalizeCase(&chinese_term);
125+
ASSERT_EQ(chinese_term, "机器");
126+
}
127+
110128
INSTANTIATE_TEST_SUITE_P(ReadBufferSize, JiebaAnalyzerTest,
111129
::testing::ValuesIn(std::vector<int32_t>({2, 5, 10, 100})));
112130

src/paimon/global_index/lucene/lucene_global_index_reader.cpp

Lines changed: 52 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
#include "paimon/global_index/lucene/lucene_global_index_reader.h"
1717

1818
#include "arrow/c/bridge.h"
19+
#include "lucene++/DisjunctionMaxQuery.h"
1920
#include "lucene++/FileUtils.h"
2021
#include "paimon/common/utils/options_utils.h"
2122
#include "paimon/common/utils/path_util.h"
@@ -116,6 +117,25 @@ std::vector<std::wstring> LuceneGlobalIndexReader::TokenizeQuery(const std::stri
116117
return wterms;
117118
}
118119

120+
std::string LuceneGlobalIndexReader::NormalizeWildcardQuery(const std::string& query) {
121+
std::string normalized_query;
122+
normalized_query.reserve(query.size());
123+
size_t term_begin = 0;
124+
for (size_t i = 0; i <= query.size(); i++) {
125+
if (i != query.size() && query[i] != '*' && query[i] != '?') {
126+
continue;
127+
}
128+
std::string term = query.substr(term_begin, i - term_begin);
129+
JiebaTokenizer::NormalizeCase(&term);
130+
normalized_query.append(term);
131+
if (i != query.size()) {
132+
normalized_query.push_back(query[i]);
133+
}
134+
term_begin = i + 1;
135+
}
136+
return normalized_query;
137+
}
138+
119139
Lucene::QueryPtr LuceneGlobalIndexReader::ConstructMatchQuery(
120140
const std::shared_ptr<FullTextSearch>& full_text_search) const noexcept(false) {
121141
assert(full_text_search->search_type == FullTextSearch::SearchType::MATCH_ALL ||
@@ -153,15 +173,43 @@ Lucene::QueryPtr LuceneGlobalIndexReader::ConstructPhraseQuery(
153173
Lucene::QueryPtr LuceneGlobalIndexReader::ConstructPrefixQuery(
154174
const std::shared_ptr<FullTextSearch>& full_text_search) const noexcept(false) {
155175
assert(full_text_search->search_type == FullTextSearch::SearchType::PREFIX);
156-
return Lucene::newLucene<Lucene::PrefixQuery>(Lucene::newLucene<Lucene::Term>(
157-
wfield_name_, LuceneUtils::StringToWstring(full_text_search->query)));
176+
auto create_query = [this](const std::string& query) -> Lucene::QueryPtr {
177+
return Lucene::newLucene<Lucene::PrefixQuery>(
178+
Lucene::newLucene<Lucene::Term>(wfield_name_, LuceneUtils::StringToWstring(query)));
179+
};
180+
std::string normalized_query = full_text_search->query;
181+
JiebaTokenizer::NormalizeCase(&normalized_query);
182+
Lucene::QueryPtr query = create_query(full_text_search->query);
183+
if (normalized_query == full_text_search->query) {
184+
return query;
185+
}
186+
187+
// Preserve the original query for mixed ASCII/CJK indexed terms such as "B超", whose
188+
// complete token is not lowercased by the index analyzer. The normalized alternative still
189+
// matches pure ASCII terms. DisjunctionMax avoids double-counting scores if both match.
190+
auto disjunction = Lucene::newLucene<Lucene::DisjunctionMaxQuery>(0.0);
191+
disjunction->add(query);
192+
disjunction->add(create_query(normalized_query));
193+
return disjunction;
158194
}
159195

160196
Lucene::QueryPtr LuceneGlobalIndexReader::ConstructWildCardQuery(
161197
const std::shared_ptr<FullTextSearch>& full_text_search) const noexcept(false) {
162198
assert(full_text_search->search_type == FullTextSearch::SearchType::WILDCARD);
163-
return Lucene::newLucene<Lucene::WildcardQuery>(Lucene::newLucene<Lucene::Term>(
164-
wfield_name_, LuceneUtils::StringToWstring(full_text_search->query)));
199+
auto create_query = [this](const std::string& query) -> Lucene::QueryPtr {
200+
return Lucene::newLucene<Lucene::WildcardQuery>(
201+
Lucene::newLucene<Lucene::Term>(wfield_name_, LuceneUtils::StringToWstring(query)));
202+
};
203+
std::string normalized_query = NormalizeWildcardQuery(full_text_search->query);
204+
Lucene::QueryPtr query = create_query(full_text_search->query);
205+
if (normalized_query == full_text_search->query) {
206+
return query;
207+
}
208+
209+
auto disjunction = Lucene::newLucene<Lucene::DisjunctionMaxQuery>(0.0);
210+
disjunction->add(query);
211+
disjunction->add(create_query(normalized_query));
212+
return disjunction;
165213
}
166214

167215
Result<std::shared_ptr<GlobalIndexResult>> LuceneGlobalIndexReader::SearchWithLimit(

0 commit comments

Comments
 (0)