Fts #
fts是sqlite的一个支持全文检索的扩展模块,是个五脏俱全的全文搜索引擎
拼音数据的来源是 https://github.com/mozillazg/pinyin-data
项目地址:https://github.com/wangfenjin/simple
以GO为例,其他语言的操作方式可能不一样,但理论是通用的。sqlite的fts5模块默认是不开启的,在编译时要加上fts5
编译标签。
假如原表名是 student_info
其中有 id
name
age
addr
四个字段
需要作为搜索目标的字段是 name
addr
需要搜索出的字段有 name
age
addr
下载 #
从发布页面下载预编译的链接库或自己编译,用预编译的版本方便快速测试,实际发布时要测一下目标平台是否表现正常。 比如我们遇到的预编译版在win7上缺少依赖库,在debian10上glibc版本过低,需要自己编译。
发布页地址:https://github.com/wangfenjin/simple/releases
注册驱动 #
sql.Register(
"sqlite3_simple",
&sqlite3.SQLiteDriver{
Extensions: []string{
"simple.dll",
},
},
)
在打开数据库时选择上面注册的驱动
db, err := gorm.Open(sqlite.Dialector{
DriverName: "sqlite3_simple",
DSN: "test_fts5.db",
})
创建fts5虚拟表 #
虚拟表是fts5需要的 具体如何操作自行查阅fts5文档,关键点是通过tokenize="simple"
指定分词器。
文档地址:https://www.sqlite.org/fts5.html
CREATE
VIRTUAL TABLE IF NOT EXISTS student_info_fts USING fts5(
name, -- 需要分词搜索的字段
addr, -- 需要分词搜索的字段
age UNINDEXED, -- 无需分词的字段 避免回表
content=student_info, -- 原表名
content_rowid=id, -- 原表id
tokenize="simple" -- 使用simple分词器
)
创建触发器 #
通过触发器来同步修改索引表是推荐的方案,如果表的更新频次比较高,应该使用定时同步刷新的机制,而不是触发器。
CREATE TRIGGER IF NOT EXISTS student_info_fts_i AFTER INSERT ON student_info
BEGIN
INSERT INTO student_info_fts(rowid, name, addr, age)
VALUES (new.id, new.name, new.addr, new.age);
END;
CREATE TRIGGER IF NOT EXISTS student_info_info_fts_d AFTER DELETE ON student_info
BEGIN
INSERT INTO student_info_fts(student_info_fts, rowid, name, addr, age)
VALUES ('delete', old.id, old.name, old.addr, new.age);
END;
CREATE TRIGGER IF NOT EXISTS student_info_fts_u AFTER UPDATE ON student_info
BEGIN
INSERT INTO student_info_fts(student_info_fts, rowid, name, addr, age)
VALUES ('delete', old.id, old.name, old.addr, new.age);
INSERT INTO student_info_fts(rowid, name, addr, age)
VALUES (new.id, new.name, new.addr, new.age);
END;
搜索 #
向原表写入数据后即可尝试搜索(触发器会负责构建索引),如果是旧表想使用该功能,需先完成上述步骤,创建对应的 新表、虚拟表、触发器然后再将旧表的数据重新写入到新表即可,写入完成后索引也就构建完成了。
使用fts5进行全文检索是有专门的查询语法的,比如
-- 查找所有包含小明的记录
SELECT *
FROM student_info_fts
WHERE student_info_fts MATCH '小明';
-- AND、OR 和 NOT
-- 同时包含 '小' 和 '明'
SELECT *
FROM student_info_fts
WHERE student_info_fts MATCH '小 AND 明';
-- 包含 '小' 或 '明'
SELECT *
FROM student_info_fts
WHERE student_info_fts MATCH '小 OR 明';
-- 包含 '小' 不包含 '明'
SELECT *
FROM student_info_fts
WHERE student_info_fts MATCH '小 NOT 明';
具体的查询语法也是需要花时间学一下的,如果只是简单的完全匹配搜索,可以直接使用simple提供的simple_query
函数,
该函数会把传入的关键词拆分拼接为fts5的搜索表达式,省去了一些步骤。
如下两种写法是等价的
SELECT simple_query("上海市公安局")
SELECT "上" AND "海" AND "市" AND "公" AND "安" AND "局"
如下搜索示例,就会得到 name
、addr
字段中含有 上海市
三个字的按相关度排序的结果
SELECT rowid as id, name, age, addr
FROM student_info_fts
WHERE student_info_fts MATCH simple_query("上海市")
ORDER BY rank Limit 10
拼音搜索也是支持的,因为simple对拼音也构建了索引
SELECT rowid as id, name, age, addr
FROM student_info_fts
WHERE student_info_fts MATCH simple_query("shanghaishi")
原理解析 #
作者给出的分词规则是:
- 空白符跳过。
- 连续的数字作为整体是一个索引。
- 连续的英文字母作为整体并转换成小写索引。
- 中文字单独建索引,并且把中文字转成拼音后也建搜索,这样就能同时支持中文和拼音检索。另外把拼音首字母也建索引,这样搜索 zjl 就能命中 “周杰伦”。
- 其他字符统一单独建索引。
作者给出的simple_query()转换规则是:
- 如果查数字,我们要把搜索词当作前缀来用,比如用户搜索 123,query 就需要换成 123*,这样如果索引里面有 12345 也能被搜索出来。
- 对于英文,除了要当作前缀,还需要把搜索词转成小写,比如用护搜索 Hello,query 就需要换成 hello*, 这样如果索引里面有 HelloWorld 也能被命中。
- 对于中文和其他字符,都要拆成单个的才能命中索引。
- 拼音和字母统一当作拼音处理就行,需要把拼音按照规则拆分,因为我们的拼音索引是单字建立的。
simple分词器实现 #
要想自定义分词器,要实现三个函数,分别对应到分词器的三个阶段 创建
、销毁
、分词
在 sqlite3.h 中可以找到定义
struct fts5_tokenizer {
int (*xCreate)(void*, const char **azArg, int nArg, Fts5Tokenizer **ppOut);
void (*xDelete)(Fts5Tokenizer*);
int (*xTokenize)(Fts5Tokenizer*,
void *pCtx,
int flags, /* Mask of FTS5_TOKENIZE_* flags */
const char *pText, int nText,
int (*xToken)(
void *pCtx, /* Copy of 2nd argument to xTokenize() */
int tflags, /* Mask of FTS5_TOKEN_* flags */
const char *pToken, /* Pointer to buffer containing token */
int nToken, /* Size of token in bytes */
int iStart, /* Byte offset of token within input text */
int iEnd /* Byte offset of end of token within input text */
)
);
};
创建和销毁比较好理解,单看一下分词的实现
int fts5_simple_xTokenize(Fts5Tokenizer *tokenizer_ptr, void *pCtx, int flags, const char *pText, int nText, xTokenFn xToken) {
auto *p = (simple_tokenizer::SimpleTokenizer *)tokenizer_ptr;
return p->tokenize(pCtx, flags, pText, nText, xToken);
}
// 实际的分词逻辑
// text: 要分词的输入文本。
// textLen: 输入文本的长度。
// xToken: 分词回调函数,用于传递分词结果给外部调用者。
int SimpleTokenizer::tokenize(void *pCtx, int flags, const char *text, int textLen, xTokenFn xToken) const {
int rc = SQLITE_OK;
int start = 0;
int index = 0;
// 存放分词结果
std::string result;
while (index < textLen) {
// 判断字符是 空格/字母/中文 等等
TokenCategory category = from_char(text[index]);
switch (category) {
// 如果是 中文
case TokenCategory::OTHER:
index += PinYin::get_str_len(text[index]);
break;
default:
while (++index < textLen && from_char(text[index]) == category) {
}
break;
}
// 忽略空格
if (category != TokenCategory::SPACE) {
result.clear();
std::copy(text + start, text + index, std::back_inserter(result));
if (category == TokenCategory::ASCII_ALPHABETIC) {
std::transform(result.begin(), result.end(), result.begin(), [](unsigned char c) { return std::tolower(c); });
}
// 返回普通的分词结果
rc = xToken(pCtx, 0, result.c_str(), (int)result.length(), start, index);
// 如果启用了拼音(默认是启用的),并且是汉字就从拼音表拿到对应的拼音
if (enable_pinyin && category == TokenCategory::OTHER && (flags & FTS5_TOKENIZE_DOCUMENT)) {
const std::vector<std::string> &pys = SimpleTokenizer::get_pinyin()->get_pinyin(result);
// 对于每个拼音,调用 xToken 将拼音作为一个新的分词结果传递出去
// 标记为 FTS5_TOKEN_COLOCATED,表示拼音与原文分词是协同的
// 可以理解为标记了拼音和原文是“同义”或者“相关”的
for (const std::string &s : pys) {
rc = xToken(pCtx, FTS5_TOKEN_COLOCATED, s.c_str(), (int)s.length(), start, index);
}
}
}
start = index;
}
return rc;
}
实现需要的函数后再注册分词器,如simple中的操作
fts5_tokenizer tokenizer = {fts5_simple_xCreate, fts5_simple_xDelete, fts5_simple_xTokenize};
fts5api->xCreateTokenizer(fts5api, "simple", reinterpret_cast<void *>(fts5api), &tokenizer, NULL);
这样我们在fts5虚拟表中指定分词器是simple
就会用到该分词器。
simple_query函数 #
注册函数比分词器更简单
sqlite3_create_function(db, "simple_query", -1, SQLITE_UTF8 | SQLITE_DETERMINISTIC, NULL, &simple_query, NULL, NULL);