FTS5与DIY
此文已由作者王荣涛授权网易云社区发布。
欢迎访问网易云社区,了解更多网易技术产品运营经验。
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说再见了~
参考
网易云免费体验馆,0成本体验20+款云产品!
更多网易技术、产品、运营经验分享请点击。
相关文章:
【推荐】 Vue框架核心之数据劫持
【推荐】 从疑似华住集团4.93亿开房信息泄露看个人如何预防信息泄露
FTS5与DIY的更多相关文章
- 从零开始,DIY一个jQuery(1)
从本篇开始会陪大家一起从零开始走一遍 jQuery 的奇妙旅途,在整个系列的实践中,我们会把 jQuery 的主要功能模块都了解和实现一遍. 这会是一段很长的历程,但也会很有意思 —— 作为前端领域的 ...
- [展示]手把手教你如何diy门户幻灯片
第一步后台新建页面:这个就不用说了大家都会 新建后FTP里面会出现如下一个模板页面 第二步从ftp里面下载 template的index.htm文件 给首页模板页面添加JS代码 如下 将这段jS ...
- 用python DIY一个图片转pdf工具并打包成exe
最近因为想要看漫画,无奈下载的漫画是jpg的格式,网上的转换器还没一个好用的,于是乎就打算用python自己DIY一下: 这里主要用了reportlab.开始打算随便写几行,结果为若干坑纠结了挺久,于 ...
- 联想A880 DIY 换触摸屏屏幕
今年初入手的Lenovo A880手机,由于摔坏了屏幕不过能正常显示,咨询了联想的售后,说触摸屏和显示屏是分离的,换触摸屏需要280左右 为发挥DIY的精神,准备自己来处理这个屏幕 第一步:购买屏幕, ...
- 电子爱好者DIY篇
2016/7/15 电子爱好者DIY篇 一年和之前就想到了一些感悟,现在有些模糊的清晰起来了,但还是不够清晰,故写下来做个日志. 结论 首先把结论放在前面.目前随着电子集成电路的发展,电子DIY越来越 ...
- 【DIY】【外壳】木板 & 亚克力 加工
—————————————————————————————————————————————————————————————————————— 一.途径 淘宝 https://item.taobao.c ...
- 世界超强完美DIY 电子奇才五年全手工制作CPU
世界超强完美DIY 电子奇才五年全手工制作CPU 2015-07-08 极客范 (点击上方公众号,可快速关注我们) 在如今越来越靠程序化.流水线作业来完成生产的制造业中,想找一件手工打造的产品,真是越 ...
- DIY FSK RFID Reader
This page describes the construction of an RFID reader using only an Arduino (Nano 3.0 was tested, b ...
- python 简单爬虫diy
简单爬虫直接diy, 复杂的用scrapy import urllib2 import re from bs4 import BeautifulSoap req = urllib2.Request(u ...
随机推荐
- OGG How to Resync Tables / Schemas on Different SCN s in a Single Replicat
To resync one or more tables/schemas on different SCN's using a single or minimum number of replicat ...
- HDU 5808 Price List Strike Back bitset优化的背包。。水过去了
http://acm.hdu.edu.cn/showproblem.php?pid=5808 用bitset<120>dp,表示dp[0] = true,表示0出现过,dp[100] = ...
- 通用mapper的generator
<plugin> <groupId>org.mybatis.generator</groupId> <artifactId>mybatis-genera ...
- ETH Dapp 体验报告
Dapp 体验报告 Dapp是分散式的应用程序.DApp运行在去中心化的网络上,也就是区块链网络中.网络中不存在中心化的节点可以完整的控制DApp. 必须依赖合约部署,没有一个中心化的服务器托管. 对 ...
- EF为什么向我的数据库再次插入已有对象?(ZT)
最近做了个多对多对实体对象,结果发现每次只要增加一个子实体,就会自动添加一个父实体进去,而不管该父实体是否已经存在. 找了好久,终于找到这篇文章,照文章内容来看,应该是断开连接导致的. 原文地址:ht ...
- C#飞行棋总结
以下是掷色子的一个代码,比较有代表性,里面的逻辑和内容都已注释,可通过注释了解这一方法的运作模式. public static void RowTouZi(int playerPos) //掷色子 { ...
- linux centos 中目录结构的含义
文件夹的含义 文件夹路径 含义 / 所有内容的开始 /root 系统管理员目录 /bin 缺省的liunx工具,就是存储命令的目录 环境变量等等 /etc 系统的配置 配置文件的存 ...
- js Math 对象
Math 对象方法 方法 描述 abs(x) 返回数的绝对值. acos(x) 返回数的反余弦值. asin(x) 返回数的反正弦值. atan(x) 以介于 -PI/2 与 PI/2 弧度之间的数值 ...
- TebsorFlow低阶API(五)—— 保存和恢复
简介 tf.train.Saver 类提供了保存和恢复模型的方法.通过 tf.saved_model.simple_save 函数可以轻松地保存适合投入使用的模型.Estimator会自动保存和恢复 ...
- powerDesigner的name和comment转化
name2comment.vbs '****************************************************************************** '* ...