@@ -94,28 +94,61 @@ function readCount(row: { count?: number | bigint } | undefined): number {
9494 return 0 ;
9595}
9696
97+ // Decompose a token that contains CJK characters into LIKE substring terms.
98+ // Each CJK character becomes its own term while contiguous non-CJK runs (e.g.
99+ // the "API" in "API配置") are kept whole, so a doc only has to contain each
100+ // character/run somewhere — not the exact concatenation — to match.
101+ function splitCjkToken ( token : string ) : string [ ] {
102+ const terms : string [ ] = [ ] ;
103+ let asciiRun = "" ;
104+ for ( const char of Array . from ( token ) ) {
105+ if ( SHORT_CJK_TRIGRAM_RE . test ( char ) ) {
106+ if ( asciiRun ) {
107+ terms . push ( asciiRun ) ;
108+ asciiRun = "" ;
109+ }
110+ terms . push ( char ) ;
111+ } else {
112+ asciiRun += char ;
113+ }
114+ }
115+ if ( asciiRun ) {
116+ terms . push ( asciiRun ) ;
117+ }
118+ return terms ;
119+ }
120+
97121function planKeywordSearch ( params : {
98122 query : string ;
99123 ftsTokenizer ?: "unicode61" | "trigram" ;
100124 buildFtsQuery : ( raw : string ) => string | null ;
101- } ) : { matchQuery : string | null ; substringTerms : string [ ] } {
125+ } ) : { matchQuery : string | null ; substringTerms : string [ ] ; fallbackLikeTerms : string [ ] } {
102126 if ( params . ftsTokenizer !== "trigram" ) {
127+ const tokens = normalizeStringEntries ( params . query . match ( FTS_QUERY_TOKEN_RE ) ?? [ ] ) ;
103128 return {
104129 matchQuery : params . buildFtsQuery ( params . query ) ,
105130 substringTerms : [ ] ,
131+ fallbackLikeTerms : uniqueStrings ( tokens ) ,
106132 } ;
107133 }
108134
109135 const tokens = normalizeStringEntries ( params . query . match ( FTS_QUERY_TOKEN_RE ) ?? [ ] ) ;
110136 if ( tokens . length === 0 ) {
111- return { matchQuery : null , substringTerms : [ ] } ;
137+ return { matchQuery : null , substringTerms : [ ] , fallbackLikeTerms : [ ] } ;
112138 }
113139
114140 const matchTerms : string [ ] = [ ] ;
115141 const substringTerms : string [ ] = [ ] ;
116142 for ( const token of tokens ) {
117- if ( SHORT_CJK_TRIGRAM_RE . test ( token ) && Array . from ( token ) . length < 3 ) {
118- substringTerms . push ( token ) ;
143+ // The trigram tokenizer turns a multi-character CJK token into a quoted
144+ // MATCH phrase that only matches the exact contiguous substring, which
145+ // rarely exists verbatim in documents — so every CJK query scored
146+ // textScore=0 (issue #92061). Route any CJK-bearing token through per-
147+ // character LIKE terms instead, which reliably match regardless of where
148+ // the characters appear. Single/double CJK characters already needed this
149+ // because trigram requires >=3 characters to form a searchable token.
150+ if ( SHORT_CJK_TRIGRAM_RE . test ( token ) ) {
151+ substringTerms . push ( ...splitCjkToken ( token ) ) ;
119152 continue ;
120153 }
121154 matchTerms . push ( token ) ;
@@ -124,6 +157,7 @@ function planKeywordSearch(params: {
124157 return {
125158 matchQuery : buildMatchQueryFromTerms ( matchTerms ) ,
126159 substringTerms,
160+ fallbackLikeTerms : uniqueStrings ( [ ...matchTerms , ...substringTerms ] ) ,
127161 } ;
128162}
129163
@@ -346,6 +380,29 @@ export async function searchKeyword(params: {
346380 } > ;
347381 let usedMatch = false ;
348382
383+ // Per-term LIKE substring search used whenever MATCH is unusable: either it
384+ // threw, or it succeeded but returned no rows for a query whose CJK tokens
385+ // were decomposed into substring terms (issue #92061).
386+ const runLikeFallback = ( ) : typeof rows => {
387+ const fallbackLikeClause = plan . fallbackLikeTerms
388+ . map ( ( ) => " AND text LIKE ? ESCAPE '\\'" )
389+ . join ( "" ) ;
390+ const fallbackLikeParams = plan . fallbackLikeTerms . map ( ( term ) => `%${ escapeLikePattern ( term ) } %` ) ;
391+ return params . db
392+ . prepare (
393+ `SELECT id, path, source, start_line, end_line, text,\n` +
394+ ` 0 AS rank\n` +
395+ ` FROM ${ params . ftsTable } \n` +
396+ ` WHERE 1=1${ fallbackLikeClause } ${ liveChunkClause } ${ params . sourceFilter . sql } \n` +
397+ ` LIMIT ?` ,
398+ )
399+ . all (
400+ ...fallbackLikeParams ,
401+ ...params . sourceFilter . params ,
402+ params . limit ,
403+ ) as typeof rows ;
404+ } ;
405+
349406 if ( plan . matchQuery ) {
350407 try {
351408 rows = params . db
@@ -364,29 +421,22 @@ export async function searchKeyword(params: {
364421 params . limit ,
365422 ) as typeof rows ;
366423 usedMatch = true ;
424+ // A successful MATCH that returns nothing is the CJK failure mode: the
425+ // quoted trigram phrase had no exact substring hit. Retry with LIKE on
426+ // the decomposed per-character terms so the keyword component still
427+ // contributes instead of collapsing every hybrid result to textScore=0.
428+ if ( rows . length === 0 && plan . substringTerms . length > 0 ) {
429+ rows = runLikeFallback ( ) ;
430+ usedMatch = false ;
431+ }
367432 } catch ( matchErr ) {
368433 // FTS5 MATCH can fail on certain token patterns depending on the
369434 // Node.js sqlite runtime and tokenizer (e.g. unicode61 vs trigram).
370435 // Log the root cause, then fall back to per-token LIKE-based substring
371436 // search so results are still returned instead of being silently dropped.
372437 console . warn ( `memory search: FTS5 MATCH failed, falling back to LIKE: ${ String ( matchErr ) } ` ) ;
373- const queryTokens = normalizeStringEntries ( params . query . match ( FTS_QUERY_TOKEN_RE ) ?? [ ] ) ;
374- const allTerms = uniqueStrings ( [ ...queryTokens , ...plan . substringTerms ] ) ;
375- const fallbackLikeClause = allTerms . map ( ( ) => " AND text LIKE ? ESCAPE '\\'" ) . join ( "" ) ;
376- const fallbackLikeParams = allTerms . map ( ( term ) => `%${ escapeLikePattern ( term ) } %` ) ;
377- rows = params . db
378- . prepare (
379- `SELECT id, path, source, start_line, end_line, text,\n` +
380- ` 0 AS rank\n` +
381- ` FROM ${ params . ftsTable } \n` +
382- ` WHERE 1=1${ fallbackLikeClause } ${ liveChunkClause } ${ params . sourceFilter . sql } \n` +
383- ` LIMIT ?` ,
384- )
385- . all (
386- ...fallbackLikeParams ,
387- ...params . sourceFilter . params ,
388- params . limit ,
389- ) as typeof rows ;
438+ rows = runLikeFallback ( ) ;
439+ usedMatch = false ;
390440 }
391441 } else {
392442 rows = params . db
0 commit comments