关系型数据库除了普通表,还支持 FTS(Full-Text Search)虚拟表,专门做全文检索。普通表用 LIKE '%关键词%' 查询,数据量一大就慢得没法用;FTS 建了倒排索引,检索速度是数量级的提升。下面用一个"聊天记录搜索"的完整案例,讲清楚 FTS 虚拟表创建、中文分词器配置、关键词检索的全流程。
一、案例背景:聊天记录搜索
一个聊天应用,用户要搜索历史消息。消息表存了发送者、内容、时间。用户输入"会议"两个字,要快速找出所有内容包含"会议"的消息。
如果用普通表的 LIKE:
sql
SELECT * FROM MESSAGE WHERE content LIKE '%会议%'
这个查询是全表扫描,几万条消息就开始卡。FTS 就是来解决这个问题的。
二、FTS 是什么:倒排索引
FTS(Full-Text Search)是 SQLite 提供的全文检索能力。它的核心是倒排索引------不是"每条记录去匹配关键词",而是"关键词去定位记录"。
建立 FTS 虚拟表后,系统会对文本内容分词,建立"词 → 记录"的索引。查询时直接查索引,不用扫描全表,速度极快。
鸿蒙的关系型数据库支持 FTS,并且针对中文提供了 ICU 分词器,能正确处理中文分词(默认只支持英文分词)。
三、第一步:判断分词器支持
ICU 分词器不是所有平台都支持,用之前先判断:
typescript
import { relationalStore } from '@kit.ArkData';
import { hilog } from '@kit.PerformanceAnalysisKit';
const DOMAIN = 0x0000;
let tokenType = relationalStore.Tokenizer.ICU_TOKENIZER;
let tokenTypeSupported = relationalStore.isTokenizerSupported(tokenType);
if (!tokenTypeSupported) {
// 当前平台不支持 ICU 分词器,降级为无分词器
tokenType = relationalStore.Tokenizer.NONE_TOKENIZER;
hilog.error(DOMAIN, 'fts', '当前平台不支持 ICU_TOKENIZER,降级处理');
}
这个判断很重要------不支持 ICU 的平台上,中文分词会退化为按字符切分,检索效果会差。开发时要做降级处理。
四、第二步:创建 FTS 虚拟表
FTS 表是"虚拟表",用 USING fts4 语法创建。关键是指定分词器:
typescript
async function createFtsTable(store: relationalStore.RdbStore) {
if (tokenTypeSupported) {
// 支持中文分词:用 ICU 分词器,指定中文语言环境
const SQL_CREATE_FTS =
'CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts4(name, content, tokenize=icu zh_CN)';
try {
await store.execute(SQL_CREATE_FTS);
hilog.info(DOMAIN, 'fts', 'FTS 表创建成功(ICU 中文分词)');
} catch (error) {
hilog.error(DOMAIN, 'fts', `创建 FTS 表失败: ${(error as BusinessError).message}`);
}
} else {
// 不支持中文分词:用默认分词器(仅英文)
const SQL_CREATE_FTS =
'CREATE VIRTUAL TABLE IF NOT EXISTS message_fts USING fts4(name, content)';
await store.execute(SQL_CREATE_FTS);
}
}
几个要点:
CREATE VIRTUAL TABLE:FTS 表是虚拟表,语法和普通表不同fts4:FTS 版本,鸿蒙支持 fts4tokenize=icu zh_CN:指定 ICU 分词器 + 中文环境,这是中文检索的关键- 字段 :
name和content是要建索引的文本字段
五、第三步:写入数据
往 FTS 表插入数据和普通表一样:
typescript
async function insertMessage(store: relationalStore.RdbStore, name: string, content: string) {
const valueBucket: relationalStore.ValuesBucket = {
name: name,
content: content
};
await store.insert('message_fts', valueBucket);
}
// 批量插入一些聊天记录
await insertMessage(store, '张三', '明天下午三点开项目会议');
await insertMessage(store, '李四', '会议纪要已经发到群里了');
await insertMessage(store, '王五', '这个方案需要再讨论一下');
await insertMessage(store, '赵六', '下午的会议改到四点');
插入时,系统会自动对 name 和 content 字段做分词,建立倒排索引。
六、第四步:关键词检索
用 MATCH 语法检索,这是 FTS 的核心查询方式:
typescript
async function searchByKeyword(store: relationalStore.RdbStore, keyword: string) {
try {
// 用 MATCH 进行全文检索,参数化绑定防注入
const resultSet = await store.querySql(
'SELECT name, content FROM message_fts WHERE message_fts MATCH ?',
[keyword]
);
hilog.info(DOMAIN, 'fts', `搜索 "${keyword}",结果数: ${resultSet.rowCount}`);
while (resultSet.goToNextRow()) {
const name = resultSet.getString(resultSet.getColumnIndex('name'));
const content = resultSet.getString(resultSet.getColumnIndex('content'));
hilog.info(DOMAIN, 'fts', `${name}: ${content}`);
}
resultSet.close();
} catch (error) {
hilog.error(DOMAIN, 'fts', `搜索失败: ${(error as BusinessError).message}`);
}
}
// 搜索"会议"
await searchByKeyword(store, '会议');
// 输出:
// 张三: 明天下午三点开项目会议
// 李四: 会议纪要已经发到群里了
// 赵六: 下午的会议改到四点
MATCH 的匹配规则:
- 中文用 ICU 分词器时,"会议"会被当作一个词匹配
- 英文按单词匹配,"test" 能匹配 "this is a test"
- 支持前缀匹配:
"app*"匹配 "apple"、"application"
七、第五步:FTS 表和普通表的联动
实际项目里,FTS 表通常和普通表配合用------普通表存完整数据,FTS 表只存需要检索的文本字段,通过 ID 关联。
typescript
// 普通表:存完整消息
await store.execute(
`CREATE TABLE IF NOT EXISTS MESSAGE (
ID INTEGER PRIMARY KEY AUTOINCREMENT,
SENDER TEXT,
CONTENT TEXT,
TIME INTEGER,
CHAT_ID INTEGER
)`
);
// FTS 表:只存检索字段,用 content 对应普通表的 CONTENT
// 插入时两边一起写
async function insertMessageFull(
store: relationalStore.RdbStore,
sender: string, content: string, chatId: number
) {
// 先插普通表
const rowId = await store.insert('MESSAGE', {
SENDER: sender,
CONTENT: content,
TIME: Date.now(),
CHAT_ID: chatId
});
// 再插 FTS 表(用 rowid 关联)
await store.insert('message_fts', {
name: sender,
content: content
});
return rowId;
}
// 搜索时,先 FTS 查到匹配的,再 JOIN 普通表拿完整数据
async function searchWithDetail(store: relationalStore.RdbStore, keyword: string) {
const resultSet = await store.querySql(
`SELECT m.ID, m.SENDER, m.CONTENT, m.TIME
FROM message_fts f
JOIN MESSAGE m ON m.CONTENT = f.content
WHERE message_fts MATCH ?
ORDER BY m.TIME DESC`,
[keyword]
);
while (resultSet.goToNextRow()) {
// 处理完整消息
}
resultSet.close();
}
这种"普通表 + FTS 表"的双表结构是生产环境的常见做法,兼顾完整存储和快速检索。
八、FTS 检索的进阶语法
MATCH 支持丰富的查询语法:
typescript
// 1. 多词查询(空格分隔,AND 关系)
await store.querySql('SELECT * FROM message_fts WHERE message_fts MATCH ?', ['会议 下午']);
// 2. OR 查询
await store.querySql('SELECT * FROM message_fts WHERE message_fts MATCH ?', ['会议 OR 讨论']);
// 3. 前缀匹配(* 通配)
await store.querySql('SELECT * FROM message_fts WHERE message_fts MATCH ?', ['会*']);
// 4. 指定字段匹配
await store.querySql('SELECT * FROM message_fts WHERE name MATCH ?', ['张三']);
// 5. 排序按相关度
await store.querySql(
'SELECT name, content FROM message_fts WHERE message_fts MATCH ? ORDER BY rank',
['会议']
);
九、FTS 的维护
9.1 删除数据
FTS 表删除数据和普通表一样:
typescript
let predicates = new relationalStore.RdbPredicates('message_fts');
predicates.equalTo('name', '张三');
await store.delete(predicates);
9.2 更新数据
更新 FTS 表会重建对应的索引项:
typescript
let predicates = new relationalStore.RdbPredicates('message_fts');
predicates.equalTo('name', '张三');
await store.update({ content: '更新后的内容' }, predicates);
9.3 索引优化
数据量很大时,可以手动优化索引:
typescript
await store.execute("INSERT INTO message_fts(message_fts) VALUES('optimize')");
十、FTS vs LIKE:性能对比
| 维度 | LIKE '%关键词%' | FTS MATCH |
|---|---|---|
| 查询方式 | 全表扫描 | 倒排索引 |
| 时间复杂度 | O(n) | O(1) ~ O(log n) |
| 中文支持 | 字符串包含匹配 | 分词后匹配 |
| 大数据量表现 | 卡顿 | 快 |
| 写入开销 | 无 | 需建索引,写入略慢 |
结论哦:数据量小(几百条)用 LIKE 就行;数据量大(几千条以上)或需要频繁搜索,必须上 FTS。
十一、几条小经验分享
- 先判断分词器支持 :
isTokenizerSupported(ICU_TOKENIZER),不支持要降级 - 中文用 ICU 分词器 :
tokenize=icu zh_CN是中文检索的关键 - FTS 表是虚拟表 :用
CREATE VIRTUAL TABLE ... USING fts4创建 - 用 MATCH 查询:比 LIKE 快几个数量级
- 双表结构:普通表存完整数据,FTS 表存检索字段,JOIN 关联
- 参数化绑定 :MATCH 查询也要用
?占位防注入