'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 };