Files
Literature-Management/src/main/db/cjk-segment.ts
2026-10-21 14:42:59 +08:00

197 lines
8.4 KiB
TypeScript

// 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<object>();
/**
* 注册迁移期需要的确定性 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);
}