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;
3738use croaring:: { Portable , Treemap } ;
3839use tantivy:: collector:: { Collector , SegmentCollector } ;
3940use 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+ } ;
4144use tantivy:: schema:: { Field , IndexRecordOption } ;
4245use tantivy:: { DocAddress , DocId , Index , IndexReader , ReloadPolicy , Score , SegmentOrdinal ,
4346 SegmentReader , Term } ;
@@ -46,7 +49,7 @@ use crate::buffer::PaimonTantivyBuffer;
4649use crate :: callback_directory:: { PaimonCallbackDirectory , PaimonStreamCallbacks } ;
4750use crate :: error:: { set_last_error, PaimonTantivyStatus } ;
4851use crate :: handle:: { borrow_handle_mut, free_handle, into_handle} ;
49- use crate :: tokenizer:: { PaimonJiebaTokenizer , TokenizeMode } ;
52+ use crate :: tokenizer:: { normalize_case , PaimonJiebaTokenizer , TokenizeMode } ;
5053use 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.
476518fn 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]
0 commit comments