This commit is contained in:
2026-09-12 13:59:12 +08:00
commit 941ce4baab
71 changed files with 13037 additions and 0 deletions

58
lib/search-plan.js Normal file
View File

@@ -0,0 +1,58 @@
'use strict';
// trigram 分词器按 3 字符滑窗建索引,因此**任何**短于 3 字符的词都匹配不到。
const TRIGRAM_MIN = 3;
/**
* 转义 LIKE 元字符(% _ \),供 `LIKE ? ESCAPE '\'` 使用。
* 用户输入里的 % / _ 不转义会被当通配符:搜 "100%" 变成匹配一切以 100 开头的串。
*/
function escapeLike(s) {
return String(s).replace(/[\\%_]/g, (c) => '\\' + c);
}
/**
* 把查询拆成「每个 token 一个 LIKE 模式」的数组(已转义)。
* 多 token 是 AND 关系 —— 与 FTS MATCH 多短语的语义对齐,
* 且各 token 独立匹配,不要求原文里连空格一起逐字出现。
*
* 空查询返回空数组(调用方按"列出全部"处理)。
*/
function likePatterns(query) {
const trimmed = String(query == null ? '' : query).trim();
if (!trimmed) return [];
return trimmed.split(/\s+/).filter(Boolean).map((tok) => `%${escapeLike(tok)}%`);
}
/**
* 决定一个搜索词该走 FTS 还是退回 LIKE并给出安全转义后的 FTS 查询串。
*
* 背景clips_fts 用的是 trigram 分词器(见 lib/migrate.js 的 v2->v3
* 选它是因为默认的 unicode61 会把一整串中文当成一个 token导致
* "公园" 搜不到 "今天去公园散步" —— 中文场景下搜索基本全废。
* trigram 解决了子串匹配,但代价是 ≤2 字符的词一条也匹配不到,
* 所以这类查询必须退回 LIKE"公园""会议""复制" 这种两字词非常常见)。
*
* 另外,用户输入直接塞进 MATCH 会因 FTS5 保留字符(- " ( ) * ^ :)报语法错误,
* 所以每个 token 都要包成 FTS5 字符串字面量。
*
* @param {string} query 用户原始输入
* @returns {{mode:'all'} | {mode:'like', patterns:string[]} | {mode:'fts', match:string}}
*/
function planSearch(query) {
const patterns = likePatterns(query);
if (patterns.length === 0) return { mode: 'all' };
// 任何一个 token 太短 → 整个 MATCH 都会返回空(多 token 是 AND 关系),
// 所以只要有一个短 token 就整体退回 LIKE。
// 注意用 [...tok] 按码点算长度,别用 .length代理对会算成 2
const trimmed = String(query == null ? '' : query).trim();
const tokens = trimmed.split(/\s+/).filter(Boolean);
const tooShort = tokens.some((tok) => [...tok].length < TRIGRAM_MIN);
if (tooShort) return { mode: 'like', patterns };
const match = tokens.map((tok) => `"${tok.replace(/"/g, '""')}"`).join(' ');
return { mode: 'fts', match };
}
module.exports = { planSearch, likePatterns, escapeLike, TRIGRAM_MIN };