fts

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 "局"

如下搜索示例,就会得到 nameaddr 字段中含有 上海市 三个字的按相关度排序的结果

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")

原理解析 #

作者给出的分词规则是:

  1. 空白符跳过。
  2. 连续的数字作为整体是一个索引。
  3. 连续的英文字母作为整体并转换成小写索引。
  4. 中文字单独建索引,并且把中文字转成拼音后也建搜索,这样就能同时支持中文和拼音检索。另外把拼音首字母也建索引,这样搜索 zjl 就能命中 “周杰伦”。
  5. 其他字符统一单独建索引。

作者给出的simple_query()转换规则是:

  1. 如果查数字,我们要把搜索词当作前缀来用,比如用户搜索 123,query 就需要换成 123*,这样如果索引里面有 12345 也能被搜索出来。
  2. 对于英文,除了要当作前缀,还需要把搜索词转成小写,比如用护搜索 Hello,query 就需要换成 hello*, 这样如果索引里面有 HelloWorld 也能被命中。
  3. 对于中文和其他字符,都要拆成单个的才能命中索引。
  4. 拼音和字母统一当作拼音处理就行,需要把拼音按照规则拆分,因为我们的拼音索引是单字建立的。

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);