Skip to content

Commit 2511c76

Browse files
draixclaude
andcommitted
fix(memory-core): score CJK keyword search instead of textScore=0 (trigram FTS5)
Multi-character CJK query tokens were emitted as a single quoted trigram MATCH phrase that only matches an exact contiguous substring, so MATCH returned zero rows and (because the LIKE fallback only fired on a thrown MATCH) the keyword channel collapsed to textScore=0 for every CJK query. - planKeywordSearch: decompose any CJK-bearing token into per-character LIKE substring terms (keeping contiguous non-CJK runs whole), not just tokens shorter than 3 characters. - searchKeyword: fall back to per-term LIKE when MATCH succeeds but returns zero rows, via a shared runLikeFallback helper reused by the exception path so both use the correctly decomposed term set. Adds CJK FTS regression tests (per-character AND semantics, empty-MATCH fallback, mixed ASCII+CJK token splitting); the failing cases reproduce textScore=0 against main. Fixes #92061 Co-Authored-By: Claude Opus 4.8 <[email protected]>
1 parent 9a6c71a commit 2511c76

2 files changed

Lines changed: 168 additions & 21 deletions

File tree

extensions/memory-core/src/memory/manager-search.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,103 @@ describe("searchKeyword trigram fallback", () => {
222222

223223
expect(repeated[0]?.score).toBe(unique[0]?.score);
224224
});
225+
226+
// ===== CJK FTS textScore regression coverage (issue #92061) =====
227+
//
228+
// A multi-character CJK query used to be emitted as a single quoted trigram
229+
// MATCH phrase (e.g. MATCH "飞书插件配置"), which only matches the exact
230+
// contiguous substring. Real documents rarely contain that verbatim run, so
231+
// MATCH returned zero rows, no LIKE fallback fired, and every hybrid result
232+
// collapsed to textScore=0. The fix decomposes CJK tokens into per-character
233+
// LIKE terms and adds a LIKE fallback when MATCH returns zero rows.
234+
235+
itWithTrigramFts(
236+
"scores a multi-character CJK query with textScore=1 instead of 0 (issue #92061)",
237+
async () => {
238+
// The query characters are all present, but never as the contiguous
239+
// substring "飞书插件配置" — the doc has "...配置飞书插件". Pre-fix this
240+
// produced an empty keyword result (textScore=0); post-fix it matches.
241+
const results = await runSearch({
242+
rows: [{ id: "feishu", path: "memory/zh.md", text: "如何配置飞书插件" }],
243+
query: "飞书插件配置",
244+
});
245+
expect(results.map((row) => row.id)).toEqual(["feishu"]);
246+
expect(results[0]?.textScore).toBe(1);
247+
expect(results[0]?.textScore).toBeGreaterThan(0);
248+
},
249+
);
250+
251+
itWithTrigramFts("requires every CJK character of the query to be present", async () => {
252+
const results = await runSearch({
253+
rows: [
254+
{ id: "full", path: "memory/full.md", text: "如何配置飞书插件" },
255+
// Contains 飞书 but not 插件配置 — must be excluded by the per-character
256+
// AND semantics rather than matched on a partial overlap.
257+
{ id: "partial", path: "memory/partial.md", text: "飞书云文档简介" },
258+
],
259+
query: "飞书插件配置",
260+
});
261+
expect(results.map((row) => row.id)).toEqual(["full"]);
262+
});
263+
264+
itWithTrigramFts(
265+
"covers Japanese kanji queries that are not a contiguous substring",
266+
async () => {
267+
const results = await runSearch({
268+
rows: [{ id: "jp", path: "memory/jp.md", text: "東京の駅の周辺を歩く" }],
269+
query: "東京駅周辺",
270+
});
271+
expect(results.map((row) => row.id)).toEqual(["jp"]);
272+
expect(results[0]?.textScore).toBe(1);
273+
},
274+
);
275+
276+
itWithTrigramFts(
277+
"falls back to LIKE when a trigram MATCH returns zero rows without throwing",
278+
async () => {
279+
// "id" is a sub-trigram (<3 chars) Latin token, so MATCH "id" returns
280+
// zero rows (without throwing) under the trigram tokenizer, while the CJK
281+
// token splits into substring terms. Pre-fix the empty MATCH short-
282+
// circuited to no results because the LIKE fallback only ran on
283+
// exceptions; post-fix the zero-row MATCH triggers the LIKE fallback.
284+
const results = await runSearch({
285+
rows: [{ id: "doc", path: "memory/mixed.md", text: "id配置文档说明" }],
286+
query: "id 配置",
287+
});
288+
expect(results.map((row) => row.id)).toEqual(["doc"]);
289+
expect(results[0]?.textScore).toBe(1);
290+
},
291+
);
292+
293+
itWithTrigramFts("keeps non-CJK runs whole when splitting a mixed CJK token", async () => {
294+
const results = await runSearch({
295+
rows: [
296+
// Contains the substring "API" and the characters 飞书 (not contiguous).
297+
{ id: "hit", path: "memory/api.md", text: "查看API文档和飞书设置" },
298+
// Has 飞书 and scattered letters but no "API" substring — must be
299+
// excluded, proving the ASCII run is matched whole rather than as the
300+
// individual letters A, P, I.
301+
{ id: "miss", path: "memory/apple.md", text: "苹果Apple的飞书通知" },
302+
],
303+
query: "API飞书",
304+
});
305+
expect(results.map((row) => row.id)).toEqual(["hit"]);
306+
});
307+
308+
itWithTrigramFts(
309+
"still BM25-scores a mixed Latin+CJK query through MATCH (no regression)",
310+
async () => {
311+
// The Latin term is trigram-eligible and present, so MATCH succeeds and
312+
// the keyword score stays a real BM25 value rather than the LIKE default.
313+
const results = await runSearch({
314+
rows: [{ id: "doc", path: "memory/mixed.md", text: "feishu 配置文档说明" }],
315+
query: "feishu 配置",
316+
});
317+
expect(results.map((row) => row.id)).toEqual(["doc"]);
318+
expect(results[0]?.textScore).toBeGreaterThan(0);
319+
expect(results[0]?.textScore).toBeLessThan(1);
320+
},
321+
);
225322
});
226323

227324
describe("searchKeyword FTS MATCH fallback", () => {

extensions/memory-core/src/memory/manager-search.ts

Lines changed: 71 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
97121
function 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

Comments
 (0)