FTS5与DIY

勿忘初心2018-10-17 12:22

此文已由作者王荣涛授权网易云社区发布。

欢迎访问网易云社区,了解更多网易技术产品运营经验。

FTS5简介

前文已经介绍了FTS3/FTS4,本文着重介绍它们的继任者FTS5。

FTS5是在SQLite 3.9.0中被引入的,很可惜的是目前很多OS或应用软件都尚未开始使用这个版本或者更新的3.10.x。

注:SQLite 3.9.0中一个非常令人兴奋的版本,除了引入FTS5,还引入了Json1扩展,从此我们可以用它提供的特定函数集直接SQL级操纵列中的JSON而无需“反序列化->修改->序列化”了。

与FTS3/FTS4的不同

建表

  • 创建表时列不能加类型修饰

  • matchinfo=fts3被去掉,使用columnsize=0代替

  • notindexed=被去掉,代之以使用UNINDEXED关键字

  • ICU分词器被去掉。不知道未来是否会支持...

  • compress=、uncompress=和languageid=选项被去掉,而且没有可用的替代功能

SELECT语句

  • MATCH操作符右侧的查询语法更加明确,消除了歧义

  • docid别名支持被取消,现在只能用rowid

  • 全文检索时MATCH操作符左侧必须是表名而不再支持列名

  • FTS5支持ORDER BY rank。rank为一个特殊列,全文检索查询时其值为 bm25() 值

内建辅助函数发生变化

  • matchinfo()和offsets()函数被去掉,snippet()函数功能也被削弱

  • 支持自定义辅助函数,利用API完全可以构建出被去掉的几个函数的功能,甚至构建更加强大的

  • 内建辅助函数未来将会被进一步改进

其他

  • 当前没有提供与fts4aux表等价的功能

  • FTS3/4 "merge=X,Y"被FTS5 merge command代替

  • FTS3/4 "automerge=X"被FTS5 automerge选项代替

底层实现的不同

  • 倒排表的存储方式发生改变,引用单个词的文档(实例)列表可以被分开存储,这样在查询时可以支持渐进式加载并在某些情况下节省不少内存

  • 索引树合并方式的优化

下面着重针对查询语法和自定义函数、分词器进行详述。

查询

FTS5下MATCH查询语法相对于FTS3/FTS4作了改进。MATCH右侧查询条件的BNF范式可以描述如下:

< phrase>    := string [*]
< phrase>    := < phrase> + < phrase>
< neargroup> := NEAR ( < phrase> < phrase> ... [, N] )
< query>     := [< colspec> :] < phrase>
< query>     := [< colspec> :] < neargroup>
< query>     := ( < query> )
< query>     := < query> AND < query>
< query>     := < query> OR < query>
< query>     := < query> NOT < query>
< colspec>   := colname
< colspec>   := { colname1 colname2 ... }

其中string可以是双引号套起来的字符串或者裸词。裸词由连续的以下字符构成:

非ASCII字符
大小写英文字母
数字
下划线
替换符(ASCII/Unicode码点为26)

FTS5下MATCH查询语法更加严谨,对标点符号也更加敏感,其改进带来的一个好处便是减少歧义。同时,在多个列范围内的查询语法也变得相对简单。本文的重点不是讲解查询语法,所以在此不再展开,有兴趣的同学可以从文末给出的链接查看这部分详情。

“简配”的内建函数

当前版本的FTS5模块提供了bm23()、highlight()、snippet()三个内建函数。bm25()函数降低了Okapi BM25函数使用的门槛,算是一种“增配”,而FTS3/FTS4中的snippet()函数现在改名叫highlight()且新函数功能还不如原先强大则算是实打实的“简配”。FTS5下的snippet()函数则提供了命中目标周围单词序列片段的提取,这也算是“简配”,因为这个基本上可以由原版通过参数组合得到。

对于我们项目来说,matchinfo()和offsets()函数的“减配”则是影响最大的!这也坚定了我们使用自定义分词器和自定义辅助函数的决心。

FTS5扩展

SQLite团队在拿掉matchinfo()和offsets()的时候肯定是有考虑的,也确实拿出了切实可行的方案来解决这一问题,那就是更加开发的C API。首先,SQLite提供了三个API分别用于创建自定义分词器、查找当前注册的分词器和创建自定义SQL函数。

typedef struct fts5_api fts5_api;struct fts5_api {    int iVersion;  /* 当前取值为2 */

    /* 创建自定义分词器 */
    int (*xCreateTokenizer)(
        fts5_api *pApi,        const char *zName,        void *pContext,
        fts5_tokenizer *pTokenizer,        void (*xDestroy)(void*)
    );    /* 查找当前注册的分词器 */
    int (*xFindTokenizer)(
        fts5_api *pApi,        const char *zName,        void **ppContext,
        fts5_tokenizer *pTokenizer
    );    /* 创建自定义SQL函数 */
    int (*xCreateFunction)(
        fts5_api *pApi,        const char *zName, /* zName参数指定自定义SQL函数名 */
        void *pContext,
        fts5_extension_function xFunction,        void (*xDestroy)(void*)
    );
};

自定义分词器

要实现自定义分词器,需要实现三个函数xCreate、xDelete和xTokenize。

typedef struct Fts5Tokenizer Fts5Tokenizer;typedef struct fts5_tokenizer fts5_tokenizer;struct fts5_tokenizer {int (*xCreate)(void*, const char **azArg, int nArg, Fts5Tokenizer **ppOut);void (*xDelete)(Fts5Tokenizer*);int (*xTokenize)(Fts5Tokenizer*, 
    void *pCtx,    int flags,              /* 一些以FTS5_TOKENIZE_为前缀的常量标志位,用于指明调用来源 */
    const char *pText, int nText, 
    int (*xToken)(        void *pCtx,         /* xTokenize()函数第二个参数的指针副本 */
        int tflags,         /* 一些以FTS5_TOKEN_为前缀的常量标志位,用于鉴别是否开启同义识别 */
        const char *pToken, /* 指向包含token的buffer */
        int nToken,         /* token大小,单位为字节 */
        int iStart,         /* token在输入文本中的字节偏移量 */
        int iEnd            /* token最后一个字符在输入文本中的偏移量+1 */
    )
);
};

实践中,我们往往会在xCreate中生成上下文,在xDelete中销毁上下文,而xTokenize则是真正实现分词的核心逻辑。当xTokenize被调用时,SQLite给了我们几个参数:

flags用于指定调用来源,是来自创建文档还是全文检索
pText用于制定输入文本
nText用于指明输入文本大小
xToken则是一个回掉函数。当分词器确定一个单词之后,需要调用这个回掉告诉FTS5驱动框架这个单词的信息。

看似简单的过程,实则暗含一些坑,你需要区分是字节还是单词编号,而这些FTS5官方文档中是没有显著说明的。笔者在编写mmfts5时就被坑过,后来是靠阅读SQLite源码结合一定的推理才确定了细节。

下面代码给出了一个非常简单(甚至有些简陋)的分词示例,用以展示xTokenize的实现:

int MyTokenize(Fts5Tokenizer*, 
    void *pCtx,    int flags,    const char *pText, int nText, 
    int (*xToken)(void *, int, const char *, int, int, int)) {    int rc;    int start = -1;    int end;    for (end = 0; end < nText; end++) {        if (isspace(pText[end])) {            if (start != -1) {
                rc = xToken(pCtx, 0, pText, nText, start, end);
                start = -1;                if (rc != SQLITE_OK) {                    return rc;
                }
            }
        } else {            if (start == -1) {
                start = end;
            }
        }
    }    if (start != -1) {        return xToken(pCtx, 0, pText, nText, start, end);
    }    return SQLITE_OK;
}

特别需要注意的是如果xToken返回非SQLITE_OK,则分词过程需要立即终止,我们的mmfts5在查询模式下就利用这一点在找到目标后就停止分词以避免不必要的性能损耗。

自定义SQL函数

根据前文我们可以通过fts5_api->xCreateFunction可以创建并注册自定义SQL函数。自定义SQL函数定义如下:

typedef struct Fts5ExtensionApi Fts5ExtensionApi;typedef struct Fts5Context Fts5Context;typedef struct Fts5PhraseIter Fts5PhraseIter;typedef void (*fts5_extension_function)(    const Fts5ExtensionApi *pApi,   /* API对象本身 */
    Fts5Context *pFts,              /* fts上下文 */
    sqlite3_context *pCtx,          /* 返回值上下文 */
    int nVal,                       /* apVal参数个数 */
    sqlite3_value **apVal           /* 参数列表指针 */);

作为示例,我们编写一个最简单的自定义函数,如下:

void MySQLFunc(const Fts5ExtensionApi *pApi,
                Fts5Context *pFts,
                sqlite3_context *pCtx,                int nVal,
                sqlite3_value **apVal) {
    sqlite3_result_int(pCtx, 0);
}

这个函数永远返回整数0,如果这个函数被注册为名叫“MyFunc”,这意味着你使用类似以下的SQL语句查询message表将得到空集或者一系列只包含0的行。

SELECT MyFunc(message) FROM message WHERE message MATCH '中'

为了让自定义SQL函数有所作为,Fts5ExtensionApi对象提供了丰富的API,这些API足以组合出比matchinfo()、offsets()更加强大的功能。下面利用注释对它们作简要说明:

struct Fts5ExtensionApi {    int iVersion;                   /* 当前固定取值为1 */

    /* 获取自定义函数的上下文,这个在xCreateFunction的第三个参数中给出 */
    void *(*xUserData)(Fts5Context*);    /* 获取表的列的总数 */
    int (*xColumnCount)(Fts5Context*);    /* 获取表的行的总数 */
    int (*xRowCount)(Fts5Context*, sqlite3_int64 *pnRow);    /* 获取第iCol列的单词数,如果iCol为负则返回权标的单词数 */
    int (*xColumnTotalSize)(Fts5Context*, int iCol, sqlite3_int64 *pnToken);    /* 对指定文本进行分词 */
    int (*xTokenize)(Fts5Context*, 
        const char *pText, int nText,        void *pCtx,        int (*xToken)(void*, int, const char*, int, int, int)
    );    /* 返回当前查询表达式中的短语(phrase,见前文)数 */
    int (*xPhraseCount)(Fts5Context*);    /* 返回第iPhrase个短语中的单词数,iPhrase基于0 */
    int (*xPhraseSize)(Fts5Context*, int iPhrase);    /* 返回当前行中命中短语的次数 */
    int (*xInstCount)(Fts5Context*, int *pnInst);    /* 查询当前行第iIdx次命中的详情。piPhrase、piCol、piOff分别返回命中短语编号、命中列、列偏移量 */
    int (*xInst)(Fts5Context*, int iIdx, int *piPhrase, int *piCol, int *piOff);    /* 当前行的rowid */
    sqlite3_int64 (*xRowid)(Fts5Context*);    /* 获取当前行第iCol列的数据 */
    int (*xColumnText)(Fts5Context*, int iCol, const char **pz, int *pn);    /* 获取行某个列单词数,如果iCol为负则返回整行的单词数 */
    int (*xColumnSize)(Fts5Context*, int iCol, int *pnToken);    /* 按rowid升序遍历所有命中第iPhrase个短语的行 */
    int (*xQueryPhrase)(Fts5Context*, int iPhrase, void *pUserData,        int(*)(const Fts5ExtensionApi*,Fts5Context*,void*)
    );    /* 以下两个用于自定义辅助数据操作,用于单次或者多次API间传递数据,xDelete负责销毁数据*/
    int (*xSetAuxdata)(Fts5Context*, void *pAux, void(*xDelete)(void*));    void *(*xGetAuxdata)(Fts5Context*, int bClear);    /* 以下两个用于单词的迭代器操作,对于第iPhrase个短语的访问比较高效、方便但对于全部短语的遍历不太方便 */
    void (*xPhraseFirst)(Fts5Context*, int iPhrase, Fts5PhraseIter*, int*, int*);    void (*xPhraseNext)(Fts5Context*, Fts5PhraseIter*, int *piCol, int *piOff);
};

作为示例,我们再将之前的MySQLFunc修改成如下的自定义函数并注册为“MyRowId”:

void MyRowIdFunc(const Fts5ExtensionApi *pApi,
                Fts5Context *pFts,
                sqlite3_context *pCtx,                int nVal,
                sqlite3_value **apVal) {
    sqlite3_result_int64(pCtx, pApi->xRowid(pCtx));
}

那么执行如下SQL语句将返回空集或者一系列包含“中”的数据行的rowid:

SELECT MyFunc(message) FROM message WHERE message MATCH '中'

总之,FTS5的可定制性非常高,是时候彻底和FTS3/FTS4说再见了~

参考

http://sqlite.org/fts5.html


网易云免费体验馆,0成本体验20+款云产品! 

更多网易技术、产品、运营经验分享请点击


相关文章:
【推荐】 MySQL慢日志线上问题分析及功能优化
【推荐】 基于Redis+Kafka的首页曝光过滤方案
【推荐】 探一探快应用的虚实