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

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


简介

对于今天的移动、桌面客户端应用而言,离线全文检索的需求已经十分强烈,我们日常使用的邮件客户端、云音乐、云笔记、易信等就是离线全文检索的潜在用户。

作为目前使用最为广泛的嵌入式数据库,SQLite3其实内置了全文检索的扩展模块——FTS。FTS分为FTS1、FTS2、FTS3、FTS4和FTS5几个版本,其中FTS1和FTS2已经被废弃,而FTS3在2007年9月4日发布的SQLite 3.5.0中被引入,其增强版FTS4则第一次出现在2010年12月8日的SQLite 3.7.4中。由于FTS3与FTS4有着千丝万缕的联系,所以本文将两种FTS引擎放在一起来介绍。FTS5则它们不兼容,所以笔者将以另外一个文章来单独作介绍。

相比于普通表,FTS3/FTS4其实是两种虚表。当你创建一个名为t的FTS虚表的时候,你会发现数据库中其实创建了若干个普通表用于存储物理数据,它们被称为影子表(shadow tables),分别命名为t_content、t_messageize、t_segdir、t_segments、t_stat等。

编译

想让SQLite支持FTS3/FTS4,在编译SQLite的时候需要打开以下编译开关

-DSQLITE_ENABLE_FTS3

注:Chromium、CEF和iOS7及以后的版本内建的SQLite都默认打开了此选项。

如果想要让FTS3/FTS4支持带括号优先级的高级查询(见下文),那么需要同时打开以下开关:

-DSQLITE_ENABLE_FTS3_PARENTHESIS

注:Chromium、CEF内建SQLite没有打开该开关。

如果想要让FTS3/FTS4支持ICU分词器,则需要再打开以下开关:

-DSQLITE_ENABLE_\ICU

注:Chromium、CEF内建SQLite打开并实现了该开关;iOS自带的没有。

表操作

最简单地创建表的形式:

-- 创建一个fts3表message,包含title和body两列CREATE VIRTUAL TABLE message USING fts3(title, body);-- 创建一个fts4表message,包含title和body两列CREATE VIRTUAL TABLE message USING fts4(title, body);

需要注意的是如果在创建表的时候给某个列指定了类型,那么这些类型将被完全忽略。 我们还可以在建表的时候给表指定分词器。例如:

CREATE VIRTUAL TABLE message USING fts3(title, body, tokenize=porter);

以上创建了一个使用porter分词器的表。此外FTS3/FTS4还支持simple、unicode61和外置的ICU等分词器。对于中文,我们建议使用ICU分词器。此外,FTS3/FTS4还支持自定义的分词器,笔者将在之后介绍FTS5的文章中以FTS5为例介绍自定义分词器。

创建FTS4表的时候我们还可以使用一些特殊选项:

compress=、uncompress= 用于支持压缩和解压缩

content= 用于创建无正文表(只有索引)和外部正文表(正文来自其他表而非虚表本身)等

matchinfo= 用于以FTS3方式存储FTS4,忽略FTS4额外所需的信息,但是功能也会因此受限

notindexed= 指定某个列为非索引列

prefix= 额外为指定自己的前缀创建索引,这可以加快前缀查询(见后文)

删除FTS表非常简单,实用DROP语句即可。

增删改

要向FTS表中插入数据类似普通表:

INSERT INTO message(title, body) VALUES('警告', '10086提醒您:您移动卡上余额不足10元');  
INSERT INTO message(docid, title, body) VALUES(2, '警告', '10086提醒您:您移动卡上余额不足5元');

注意到第二句中我们指定了一个叫docid的列,这是 隐藏列 rowid的一个别名,类似于普通表。

更新和删除和普通表无异:

UPDATE message SET title = '提示' WHERE rowid = 1;DELETE FROM message WHERE rowid = 1;

查询

查询操作是FTS表存在的最大意义。两类查询在FTS表上是比较高效的,它们是:

  • 仅包含rowid的普通查询

  • 全文检索

SELECT * FROM message WHERE rowid = 1  SELECT * FROM message WHERE body MATCH '10086'

下面以ICU为分词器针对全文检索进行进一步介绍。

词查询

查询可以针对整个文档或者文档的某些列来进行精确的词查询:

-- 查询包含“移动”关键字的文档SELECT * FROM message WHERE message MATCH '移动'-- 查询消息体包含“移动”关键字的文档SELECT * FROM message WHERE body MATCH '移动'SELECT * FROM message WHERE message MATCH 'body:移动'-- 查询消息体包含“移动”且文档中包含“您”关键字的文档SELECT * FROM message WHERE message MATCH 'body:移动 您'

注意到,用“列名:词”的方式可以指定在某个列上查询,而用空格隔开可以以“且”的方式连接多个条件。

在FTS4下,在词前面加入^,表示该词必须是某个列的第一个词:

SELECT * FROM message WHERE message MATCH 'body:^移动'

特别需要注意的是:英文词必须使用小写。因为后文中很多关键字需要用它们的大写身份来被识别。

前缀查询

我们在词后面加入一个星号(*)即构成以该词为前缀的查询:

-- 下面的查询包含“移动”的文档会被命中SELECT * FROM message WHERE message MATCH '移*'

在FTS4下,^同样适用于前缀查询。

短语查询

如果我们给定一个由词和前缀组成的有序序列,去数据库中匹配一个连续的有序词序列,使得两个序列中词/前缀逐个依序匹配,就构成了短语查询。

-- 下面的查询将匹配以上两条记录SELECT * FROM message WHERE message MATCH '"移 动"';-- 下面的查询将无法匹配,因为原文中“移”出现在“动”之前而查询中则相反SELECT * FROM message WHERE message MATCH '"动 移"';-- 下面的查询将无法匹配,因为“移”、“卡”之间隔了一个“动”SELECT * FROM message WHERE message MATCH '"移 卡"';

注意短语查询必须将有序词/前缀集用双引号引起来,并且将有序集内每个词用空格隔开。

NEAR查询

短语查询要求词之间必须连续重现,但是有时候我们允许他们就近出现,这个时候就需要使用NEAR查询。

SELECT * FROM message WHERE message MATCH '"移 NEAR 动"';

默认情况下两个词允许最大间隔10个词,但是你也可以自定义:

SELECT * FROM message WHERE message MATCH '"移 NEAR/6 动"';

上例最多允许“移”、“动”之间出现6个词。

逻辑操作

FTS3、FTS4支持逻辑条件关键字(必须大写):

AND:逻辑与,取交集;默认不加条件关键字的情况下,就是这种关系。

OR:逻辑或,取并集

NOT:逻辑非,取补集。可以使用 - 代替

-- 以下两个查询是一致的SELECT * FROM message WHERE message MATCH '移 AND 动';SELECT * FROM message WHERE message MATCH '移 动';

优先级方面,NOT高于AND,高于OR。FTS3/FTS4支持使用括号来改变的优先级:

SELECT * FROM message WHERE message MATCH '(移 OR 动) AND 卡';

再次提醒:使用带括号优先级的查询支持,需要打开 -DSQLITE_ENABLE_FTS3_PARENTHESIS 开关编译SQLite

内建函数

FTS3/FTS4支持三个非常有用的内建函数:offsets、snippet、matchinfo。

offsets

offsets函数返回所有匹配项的偏移信息。总体上来说,offsets针对每个匹配项将返回一个四元组,一句话概括就是:词号为term的词在表中第column列的offset字节处命中了连续的size字节的目标。offsets返回所有这样的四元组的文本形式,例如若:

SELECT offsets(tb1) FROM tb1 WHERE tb1 MATCH 'term1 term2';

返回

"0 1 3 4 1 0 0 6"

那么就表示有两处被命中:

  • 第1列的3字节处被2号词命中,命中长度为4

  • 第2列的0字节处被1号词命中,命中长度为6

注意:column、term、offset编号都从0开始的。

snippet

此函数用于返回最佳命中目标及其周围的切片。例如,SQLite官网的 搜索功能的高亮显示 就是用此函数实现的。

这个函数支持可变参数,我们可以给它传1至6个参数。6个参数按照从0开始编号说明如下: 0:必须使用隐藏列,也就是要查询的虚表名,比如上面的message。
1:返回值中被命中目标开始处的标记文本,默认为“”
2:返回值中被命中目标结束处的标记文本,默认为“”
3:被省略文本的标识,比如“...”
4:强制指定从哪个列提取切片文本,默认为-1,表示可从任意列提取
5:此值的绝对值表示返回值中大致包含多少个单词,最大可取64,默认-15

SELECT snippet(message, '[ ']', '...') FROM message WHERE message MATCH '"移* 余*"'

matchinfo

这是一个更加高效的函数,因为它本身的返回值不需要将整个行全部从磁盘调入内存而只需要查询索引数据。此外,这个函数也提供了足够的用于运行常用结果评价算法的信息。

限于篇幅,本函数不作展开详述,大家可以参考最后给出的链接查阅。

常用特殊命令

FTS3/FTS4支持一些特殊命令来维护索引等。下面是我们会常用的两条:

-- 优化表,本质是将所有独立的小索引树合并成一整棵B树

INSERT INTO xyz(xyz) VALUES('optimize');

-- 重建索引

INSERT INTO xyz(xyz) VALUES('rebuild');

FTS3与FTS4的区别

FTS3和FTS4是比较相似的,它们共享了很多底层技术,也共享了相同的接口。它们的不同点在于:

  1. FTS4包含了查询优化,可以显著提升高频词的检索性能

  2. FTS4下matchinfo()内建函数得到更多的可选信息

  3. FTS4为了实现1中提到的优点,需要额外的存储空间,不过一般情况下这部分空间开销比较小

  4. FTS4支持hooks来实现压缩存储以减小磁盘开销

优化建议

控制范围:我们真的必要返回所有结果么?是否可以考虑按区间分批返回呢?

考虑matchinfo:有些时候我们只需要返回部分查询结果的偏移量或者片段,这个时候我们可以考虑先用带matchinfo的子查询确定我们需要返回偏移量或片段的rowid集,然后再对这个集合内的记录进行深度的offsets或者snippet。因为offsets和snippet需要从磁盘调取整行数据,并作一定的字符串加工,效率较低。这方面大家可以读下SQLite源码。

考虑外部正文:如果你需要索引的内容完全可以从一个必要的外部表中获取,不妨考虑下外部正文。这样就可以有效减小存储正文所需要的磁盘和时间开销。遗憾的是,通过提取iOS版QQ邮箱某个版本的数据文件,我们发现QQ邮箱这方面似乎做得不太好。

我们的困扰

FTS3/FTS4是好东西,但在实际项目中我们发现它们无法完全满足我们的需求:

查询语法过于模糊,容易产生歧义,搜索结果不可控
内建函数可定制性不够
offsets返回值为字符串,多次realloc和字符串转换,效率太低
一定情况下会更费内存
过时,采用FTS5后未来需要升级数据库

于是我们找到了替代它们的神器——FTS5!结合我们自定义的分词器(代号mmfts5),需求终于被完全满足了。

参考

http://www.sqlite.org/fts3.html


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

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


相关文章:
【推荐】 Android事件分发机制浅析(2)
【推荐】 “网易有钱”sketch使用分享
【推荐】 内容社交产品中的关键数据——获得良好反馈的用户比例