// src/main/db/cjk-segment.ts // // r196:CJK 分词 —— 让中文全文搜索真正能用。 // // ── 问题(2026-10-20 内存库实测)──────────────────────────────────────── // FTS5 的 unicode61 分词器把**连续的中文串**当成**一个** token。 // unicode61 不区分文字系统 —— 汉字是 Unicode 字母(Lo),于是 // 「本文介绍注意力机制」整串 = 1 个 token // 「Attention注意力机制」整串 = 1 个 token(中英混排同样中招) // 后果:搜「学习」「机制」「注意力」这类**词内子串**一律 0 结果, // 只有恰好在整串开头的前缀能命中。实测(unicode61 + remove_diacritics 2): // 学习 → 0 行 / 注意力 → 1 行(恰好在句首) / 机制 → 0 行 // 即中文笔记的全文搜索基本等于不可用,这是 r196 要修的根因。 // // ── 解法:索引侧插 ZWSP 分隔 + 查询侧改用短语 ──────────────────────── // 索引侧:「深度学习」→「深␣度␣学␣习」(␣ = U+200B 零宽空格) // ⇒ unicode61 视 ZWSP 为分隔符(它属 Cf 类,不是字母/数字), // 于是每个汉字成为独立 token。 // 查询侧:「学习」→ FTS5 短语 "学 习" // ⇒ 要求两个 token **相邻** ⇒ 命中所有含「学习」的文本且保序。 // CJK↔拉丁边界同样用 ZWSP 断开(否则「Attention注意力」仍是**一个** // token,两边都搜不到)。 // // ── 为什么用 ZWSP(U+200B)而不是别的分隔符 ───────────────────────── // * unicode61 视其为分隔符(实测); // * 视觉不可见 ⇒ 万一漏了清洗也不会被用户看见(我们仍然清洗); // * 不占用 ASCII 可见字符 ⇒ 不污染搜索词 / 文件名 / 片段文本。 // // ── 为什么不用 trigram 分词器 ─────────────────────────────────────── // trigram 需要**至少 3 个字符**才能表达一次查询,中文双字词 // (「学习」「机制」「注意力」)根本没法用 trigram 查 —— 恰恰是中文 // 最常见的查询形态。 // // ── 为什么不用自定义 tokenizer ─────────────────────────────────────── // FTS5 的自定义分词器必须是 C 扩展,better-sqlite3 不允许在 JS 层注册。 // // ── 模块边界 ───────────────────────────────────────────────────────── // * 本模块是**纯函数**(无 electron / 无 DB 依赖)⇒ 可直接单测; // * 索引侧(segmentCjk)只在写 FTS 前调用,主表 resources.title 保持原样; // * 查询侧(segmentQueryToken / splitScriptRuns)在 sanitizeFtsQuery 里用; // * 展示侧(stripZwsp)在 searchResources 返回前把 ZWSP 洗掉。 // 类型导入(编译期擦除)⇒ 本模块运行时**不依赖** better-sqlite3 原生模块, // 纯函数部分可直接单测。 import type Database from 'better-sqlite3'; /** * 分隔符:零宽空格(U+200B)。unicode61 当它是分隔符。 * * ⚠ 必须写成转义序列而不是字面量 —— U+200B 是**不可见字符**,写进源码后 * 在编辑器 / diff / 复制粘贴里都看不出来,极易退化成空串(实测踩过: * 一旦变成 '' 分词就完全不生效,而代码看着完全正常,只能靠断言抓住)。 */ export const ZWSP = String.fromCharCode(0x200b); const ZWSP_RE = new RegExp(ZWSP, 'g'); /** * CJK 字符判定。 * * 覆盖:汉字(CJK 统一表意文字 + 扩展 A + 兼容表意文字 + 扩展 B)、 * 日文假名(平假名 / 片假名)、谚文音节。 * * 为何包含假名与谚文:这些文字同样「字之间无空格」,unicode61 会把整串 * 当一个 token,与汉字同病。谚文音节虽然以词为单位,但按音节切开仍能 * 保持短语相邻性 ⇒ 词内子串查询照样成立(见 segmentQueryToken)。 * * 为何不含 CJK 标点:标点在 unicode61 下本就是分隔符,不需要额外处理。 */ const CJK_RE = /[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3040-\u309F\u30A0-\u30FF\uAC00-\uD7AF]|[\u{20000}-\u{2A6DF}]/u; /** 单字符是否是 CJK。 */ export function isCjkChar(ch: string): boolean { return CJK_RE.test(ch); } /** 文本里是否含 CJK(查询侧据此决定用短语还是前缀)。 */ export function hasCjk(text: string): boolean { return CJK_RE.test(text); } /** 去掉 ZWSP(展示侧 / 幂等重分词前用)。 */ export function stripZwsp(text: string): string { return text.replace(ZWSP_RE, ''); } /** * 索引侧分词:在 CJK 字符之间、以及 CJK 与非 CJK 的边界插入 ZWSP。 * * 幂等:先剥掉已有的 ZWSP 再切 ⇒ 对同一文本反复调用结果相同 * (存量迁移 0009 依赖这一点:万一某行已被分词,重跑不会累积 ZWSP)。 */ export function segmentCjk(text: string): string { if (!text) return ''; const clean = stripZwsp(text); let out = ''; let prevCjk = false; // for...of 按码点迭代 ⇒ 代理对(扩展 B 汉字)不会被拆成两半 for (const ch of clean) { const c = isCjkChar(ch); if (out.length > 0 && (c || prevCjk)) out += ZWSP; out += ch; prevCjk = c; } return out; } /** * 查询侧分词:把一个查询 token 拆成 FTS5 短语的组成部分。 * * '学习' → ['学', '习'] ⇒ 短语 "学 习" * '机器学习' → ['机', '器', '学', '习'] * 'Attention机制' → ['Attention', '机', '制'] ⇒ 短语 "Attention 机 制" * * 非 CJK 连续片段整体保留为一个部分(它会被 FTS5 自己按 unicode61 切开, * 与索引侧的处理一致)。 */ export function segmentQueryToken(token: string): string[] { const parts: string[] = []; let buf = ''; for (const ch of token) { if (isCjkChar(ch)) { if (buf) { parts.push(buf); buf = ''; } parts.push(ch); } else { buf += ch; } } if (buf) parts.push(buf); return parts; } /** * 按「文字系统」把 token 切成若干 run —— **连续的同族字符合成一个 run**。 * * 'CNN模型' → [{cjk:false, text:'CNN'}, {cjk:true, text:'模型'}] * '模型' → [{cjk:true, text:'模型'}] * 'Attention机制' → [{cjk:false, text:'Attention'}, {cjk:true, text:'机制'}] * * 与 segmentQueryToken 的分工: * - segmentQueryToken 把**每个汉字**单独成一份 ⇒ 用来拼 FTS5 短语 * (短语要求逐字 token 相邻); * - splitScriptRuns 把**连续汉字合成一个 run** ⇒ 用来判断一个 token * 是不是「混排」,以及混排时该按几个子句去查(r208)。 */ export interface ScriptRun { /** run 是否由 CJK 字符组成(决定用短语还是前缀通配)。 */ cjk: boolean; /** run 的原文(不含任何分隔符)。 */ text: string; } export function splitScriptRuns(token: string): ScriptRun[] { const runs: ScriptRun[] = []; let text = ''; let cjk = false; for (const ch of token) { const c = isCjkChar(ch); if (text && c !== cjk) { runs.push({ cjk, text }); text = ''; } cjk = c; text += ch; } if (text) runs.push({ cjk, text }); return runs; } // ── 迁移层:把分词暴露成 SQL 函数 ─────────────────────────────────────── // 存量数据重分词需要「在 SQL 里调用 JS 分词」的能力(迁移 0009 的 // cjk_seg(title) / cjk_seg(body))。SQLite 的自定义函数必须由宿主注册, // better-sqlite3 提供 db.function()。 // // 注册时机:migrator.applyMigrations 开头(所有迁移跑之前)—— 0009 依赖它。 // 幂等:同一个 db 重复注册同名函数会抛错,故用 WeakSet 记住已注册的实例。 const registeredDbs = new WeakSet(); /** * 注册迁移期需要的确定性 SQL 函数(cjk_seg)。 * * - deterministic:true ⇒ SQLite 可缓存结果 / 允许在索引表达式里用; * - 参数可能是 NULL(列可空)⇒ 一律按空串处理,返回空串。 */ export function registerMigrationFunctions(db: Database.Database): void { if (registeredDbs.has(db)) return; db.function('cjk_seg', { deterministic: true }, (v: unknown) => segmentCjk(typeof v === 'string' ? v : ''), ); registeredDbs.add(db); }