云端 Mac 上的 iOS FTS5 搜索验收:分词、查询转义与索引同步

云端 Mac 上的 iOS FTS5 搜索验收:分词、查询转义与索引同步

客户把工单标题改了,列表显示新名称,离线搜索却仍然命中旧词。这类问题在工作室轮换云端 Mac 出包时容易漏检:工程能编译,数据库也能打开,但全文索引已经与业务表脱节。先选择固定样本验收,适合交付前验证搜索契约;直接跑线上数据适合补充观察,不适合作为唯一标准,因为结果难以稳定复现。

先确认实际使用的 SQLite

在云端 Mac 终端执行 sqlite3 :memory: 'SELECT sqlite_version();',记录实验使用的版本。随后尝试创建 FTS5 虚拟表;创建失败就停止,不要把“没有搜索结果”误判为分词问题。

命令行工具只验证实验环境。iOS 工程可能链接系统库,也可能自带 SQLite,因此还要通过应用实际连接执行相同建表语句。把这项检查放进测试目标,并在目标系统执行。纯 SQL 样本可以先在 Mac 快速迭代,但不能替代移动端运行结果。

固定分词与查询语义

先选“字面短语搜索”还是“高级搜索语法”。前者适合普通工单搜索框,后者适合明确支持布尔表达式的工具;不适合把两种行为混在一个没有提示的输入框中。

unicode61 remove_diacritics 2 可以处理常见拉丁字母重音,但不是通用中文分词器。样本至少覆盖大小写、重音、连字符、引号和连续中文。不要根据英文测试通过,就宣称中文支持任意词语检索。

SQL 参数绑定与 MATCH 转义是两层不同的事。参数绑定防止输入成为 SQL 结构,却不会关闭 MATCH 自己的查询语法。字面短语可先把内部双引号加倍,再包上一对双引号:

func literalFTSQuery(_ text: String) -> String? {
    let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
    guard !trimmed.isEmpty else { return nil }
    return "\"" + trimmed.replacingOccurrences(of: "\"", with: "\"\"") + "\""
}

将返回值绑定到 WHERE ticket_fts MATCH ?,不要拼接 SQL。这里的“字面”仍经过分词,并不等于逐字节子串匹配;空输入直接返回空结果或产品约定的默认列表。

搜索命中规则是产品契约。前缀、短语与子串不能互相代替,更不能用吞掉语法错误的方式制造“零结果”。

用触发器同步外部内容索引

选择外部内容表,适合已有业务表且不想重复保存正文的工程;不适合期待 FTS5 自动监听业务表变化的实现。content 只声明关联关系,增删改仍需同步。

下面可直接在终端执行。所有数据只存在于内存中,任一断言失败都会让命令返回非零状态:

sqlite3 -bail :memory: <<'SQL'
CREATE TABLE ticket(id INTEGER PRIMARY KEY, title TEXT NOT NULL);
CREATE VIRTUAL TABLE ticket_fts USING fts5(
  title,
  content='ticket',
  content_rowid='id',
  tokenize='unicode61 remove_diacritics 2'
);

CREATE TRIGGER ticket_ai AFTER INSERT ON ticket BEGIN
  INSERT INTO ticket_fts(rowid, title) VALUES(new.id, new.title);
END;

CREATE TRIGGER ticket_ad AFTER DELETE ON ticket BEGIN
  INSERT INTO ticket_fts(ticket_fts, rowid, title)
    VALUES('delete', old.id, old.title);
END;

CREATE TRIGGER ticket_au AFTER UPDATE ON ticket BEGIN
  INSERT INTO ticket_fts(ticket_fts, rowid, title)
    VALUES('delete', old.id, old.title);
  INSERT INTO ticket_fts(rowid, title) VALUES(new.id, new.title);
END;

CREATE TEMP TABLE assertion(ok INTEGER NOT NULL CHECK(ok = 1));

INSERT INTO ticket VALUES(1, 'Café delivery');
INSERT INTO assertion
  SELECT count(*) = 1 FROM ticket_fts
  WHERE ticket_fts MATCH '"cafe"';

UPDATE ticket SET title = 'Invoice approved' WHERE id = 1;
INSERT INTO assertion
  SELECT count(*) = 0 FROM ticket_fts
  WHERE ticket_fts MATCH '"cafe"';
INSERT INTO assertion
  SELECT count(*) = 1 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';

BEGIN;
DELETE FROM ticket WHERE id = 1;
INSERT INTO assertion
  SELECT count(*) = 0 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';
ROLLBACK;

INSERT INTO assertion
  SELECT count(*) = 1 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';

DELETE FROM ticket WHERE id = 1;
INSERT INTO assertion
  SELECT count(*) = 0 FROM ticket_fts
  WHERE ticket_fts MATCH '"invoice"';
SQL

删除索引时必须提供旧文本,所以更新触发器先删旧项再插新项。业务变更与触发器处于同一事务,回滚后业务表和索引应一起恢复。样例没有错误输出即通过,不需要人为检查终端中的表格。

历史数据与排序也要验收

先建业务表、后加索引的数据库,不会因为装好触发器就自动补齐历史记录。迁移时在受控写入边界内执行 INSERT INTO ticket_fts(ticket_fts) VALUES('rebuild');,随后用迁移前的固定记录验证命中。重建属于迁移动作,不应每次打开搜索页面都运行。

不要用 SELECT count(*) FROM ticket_fts 判断索引是否完整:外部内容表在不使用 MATCH 时可能直接读取内容表。必须用实际查询断言,分别验证旧词消失、新词出现。

排名相同时增加稳定的业务主键排序,例如 ORDER BY rank, rowid。分页样本要包含多个同分结果;否则重复运行时顺序变化,容易被误诊为漏数据。

把失败变成交付检查项

每次交付保存 SQLite 版本、建表参数、样本版本和失败用例名称,不保存客户真实搜索文本。新增空串、纯标点、超长输入和双引号样本;长度上限按产品规则明确拒绝,不靠数据库异常兜底。

验收时逐项确认:新建库与迁移库结果一致,改名后旧词不命中,删除后没有残留,事务回滚恢复结果,中文行为符合说明。最后在 iOS 测试目标运行同一套数据。这样更换云端 Mac 或交接客户项目时,检查的是搜索行为本身,而不是某台机器上偶然留下的正确索引。

常见问题

Mac 上的 sqlite3 能运行 FTS5,就能证明 iOS App 支持吗?

不能。命令行工具与应用可能链接不同的 SQLite 库。必须在应用实际连接上创建临时 FTS5 表,并在目标系统运行同一组样本。

unicode61 会自动把中文句子切成词吗?

不会提供通用中文词语切分。需要用实际中文样本确定产品行为;若要求词级检索,应另行选择分词方案,并在写入与查询两端保持一致。

让租期跟着项目排期走

适合临时出包、交付周加机与多客户项目分机。若团队常年满负荷构建且已有自有设备,先比较自购与租用,再做决定。

VMSSD 提供独享物理 Mac mini,非虚拟机。按天、周、月、季选择周期,到期不续即停;机型与节点可用性以控制台实时返回为准。

把阅读变成下一步安排

先核对项目工期与构建负载,再决定机型和租期。首次使用,可以从下单清单开始。