iOS微信全文搜索技术优化
一、iOS微信全文搜索技术的现状
Token
建立一个索引,索引中保存了这个Token
在内容中的具体位置。全文搜索技术主要应用在对大量文本内容进行搜索的场景。Like
语句去匹配文本,联系人搜索甚至用的是内存搜索(在内存中遍历所有联系人的所有属性进行匹配)。随着用户在微信上积累的数据越来越多,提升微信底层搜索技术的需求也越来越迫切。在2021年,我们对iOS微信的全文搜索技术进行了一次全面升级,本文主要介绍本次技术升级的工作经验。二、全文搜索引擎的选型与优化
1、搜索引擎选型
2、实现FTS5的Segment自动Merge机制
segment
,segment
中保存了本次写入内容中的每个词在本次内容中行号(rowid
)、列号和字段中的每次出现的位置偏移,所以这个segment
就是该内容的倒排索引。多次写入就会形成多个segment
,查询时就需要分别查询这些segment
再汇总结果,从而segment
数量越多,查询速度越慢。segment
的数量,SQLite FTS5引入了merge
机制。新写入的segment
的level
为0,merge
操作可以把level
为i
的现有segment
合并成一个level
为i+1
的新的segment
。merge
的示例如下:merge
操作有两种:某一个
level
的segment
达到4
时就开始在写入内容时自动执行一部分merge
操作,称为一次automerge
。每次automerge
的写入量跟本次更新的写入量成正比,需要多次automerge
才能完整合并成一个新segment
。Automerge
在完整生成一个新的segment
前,需要多次裁剪旧的segment
的已合并内容,引入多余的写入量。本次写入后某一个
level
的segment
数量达到16时,一次性合并这个level
的segment
,称为crisismerge
。
merge
操作都是在写入时同步执行的,会对业务逻辑造成性能影响,特别是crisismerge
会偶然导致某一次写入操作特别久,这会让业务性能不可控。之前的测试中FTS5的建索引耗时较久,也主要因为FTS5的merge
操作比其他两种引擎更加耗时。segment
自动merge
机制,将这些merge
操作集中到一个单独子线程执行,并且优化执行参数,具体如下:监听有FTS5索引的数据库每个事务变更到的FTS5索引表,抛通知到子线程触发WCDB的自动
merge
操作。Merge线程检查所有FTS5索引表中
segment
数超过1
的level执行一次merge
。Merge
时每写入16
页数据检查一次有没有其他线程的写入操作因为merge
操作阻塞,如果有就立即commit
,尽量减小merge
对业务性能的影响。
merge
逻辑执行的流程图如下:level
的segment
数量为1
,可以让FTS5的查询性能最接近optimize
(所有segment
合并成一个)之后的性能,而且引入的写入量是可接受的。假设业务每次写入量为M
,写入了N
次,那么在merge执行完整之后,数据库实际写入量为MN(log2(N)+1)。业务批量写入,提高M
也可以减小总写入量。optimize
状态下耗时2.9ms
,分别限制每个level
的segment
数量为2
、3
、4
时的查询耗时分别为4.7ms
、8.9ms
、15ms
。100w条内容每次写入100条的情况下,按照WCDB的方案执行merge
的耗时在10s
内。Merge
机制,可以在不影响索引更新性能的情况下,将FTS5索引保持在最接近Optimize
的状态,提高了搜索速度。3、分词器优化
分词器性能优化
Token
并提供这些Token
的位置,搜索引擎再对这些Token
建立索引。SQLite的FTS组件支持自定义分词器,可以按照业务需求实现自己的分词器。Token
数量,也可以减少搜索时匹配的Token
数量,劣势是需要理解语义,而且用户输入的词不完整时也会有搜不到的问题。OneOrBinaryTokenizer
是采用了一种巧妙的按字分词算法,除了对输入内容逐字建索引,还会对内容中每两个连续的字建索引,对于搜索内容则是按照每两个字进行分词。下面是一个用“北京欢迎你”去搜索相同内容的分词例子:Token
数量接近降低一半,提高搜索速度,而且在一定程度上可以提升搜索精度,比如搜索“欢迎你北京”就匹配不到“北京欢迎你”;这种分词方式的劣势就是保存的索引内容很多,基本输入内容的每个字都在索引中保存了三次,是一种用空间换时间的做法。OneOrBinaryTokenizer
用接近三倍的索引内容增长才换取不到两倍的搜索性能提升,不是很划算,所以我们在FTS5上重新开发了一种新的分词器VerbatimTokenizer
,这个分词器只采用基本的按字分词,不保存冗余索引内容。同时在搜索时,每两个字用引号引起来组成一个Phrase
,按照FTS5的搜索语法,搜索时Phrase
中的字要按顺序相邻出现的内容才会命中,实现了跟OneOrBinaryTokenizer
一样的搜索精度。 VerbatimTokenizer
的分词规则示意图如下:分词器能力扩展
支持在分词时将繁体字转换成简体字。这样用户可以用繁体字搜到简体字内容,用简体字也能搜到繁体字内容,避免了因为汉字的简体和繁体字形相近导致用户输错的问题。
支持Unicode归一化。Unicode支持相同字形的字符用不同的编码来表示,比如编码为
\ue9
的é
和编码为\u65\u301
的é
有相同的字形,这会导致用户用看上去一样的内容去搜索结果搜不到的问题。Unicode归一化就是把字形相同的字符用同一个编码表示。支持过滤符号。大部分情况下,我们不需要支持对符号建索引,符号的重复量大而且用户一般也不会用符号去搜索内容,但是联系人搜索这个业务场景需要支持符号搜索,因为用户的昵称里面经常出现颜文字,符号的使用量不低。
支持用
Porter Stemming
算法对英文单词取词干。取词干的好处是允许用户搜索内容的单复数和时态跟命中内容不一致,让用户更容易搜到内容。但是取词干也有弊端,比如用户要搜索的内容是“happyday”,输入“happy”作为前缀去搜索却会搜不到,因为“happyday”取词干变成“happydai”,“happy”取词干变成“happi”,后者就不能成为前者的前缀。这种badcase在内容为多个英文单词拼接一起时容易出现,联系人昵称的拼接英文很常见,所以在联系人的索引中没有取词干,在其他业务场景中都用上了。支持将字母全部转成小写。这样用户可以用小写搜到大写,反之亦然。
VerbatimTokenizer
将这些转变能力都集中到了分词器中实现。4、索引内容支持多级分隔符
Token
在不同的属性,那这条数据也会命中,这个结果显然不是用户想要的,搜索结果的精确度就降低了。Token
中间不存在分隔符,那这样可以确保匹配的Token
都在一个属性内。同时,为了支持业务灵活扩展,还需要支持多级分隔符,而且搜索结果中还要支持获取匹配结果的层级、位置以及该段内容的原文和匹配词。这个能力FTS5还不没有,而FTS5的自定义辅助函数支持在搜索时获取到所有命中结果中每个命中Token
的位置,利用这个信息可以推断出这些Token
中间有没有分隔符,以及这些Token
所在的层级,所以我们开发了SubstringMatchInfo
这个新的FTS5搜索辅助函数来实现这个能力。这个函数的大致执行流程如下:三、全文搜索应用逻辑优化
1、数据库表格式优化
1.1 非文本搜索内容的保存方式
在实际应用中,我们除了要在数据库中保存需要搜索的文本的FTS索引,还需要额外保存这个文本对应的业务数据的id
、用于结果排序的的属性(常见的是业务数据的创建时间)以及其他需要直接跟随搜索结果读出的内容,这些都是不参与文本搜索的内容。根据非文本搜索内容的不同存储位置,我们可以将FTS索引表的表格式分成两种:
第一种方式是将非文本搜索内容存储在额外的普通表中,这个表保存FTS索引的
Rowid
和非文本搜索内容的映射关系,而FTS索引表的每一行只保存可搜索的文本内容,这个表格式类似于这样:
Rowid
读取到普通表的Rowid
,这样才能读取到普通表的其他内容,搜索速度慢一点,而且搜索时需要联表查询,搜索SQL语句稍微复杂一点。第二种方式是将非文本搜索内容直接和可搜索文本内容一起存储在FTS索引表中,表格式类似于这样:
1.2 避免冗余索引内容
UNINDEXED
约束,这样FTS5就不会对这个列建索引了,所以给可搜索文本内容之外的所有列添加这个约束就可以避免冗余索引。1.3 降低索引内容的大小
Token
对应的行号(rowid
)、列号和字段中的每次出现的位置偏移,其中的行号是SQLite自动分配的,位置偏移是根据业务的实际内容,这两个我们都决定不了,但是列号是可以调整的。Token
在一行中的索引内容的格式是这样的:0x01
和列号,这样可以明显降低索引文件大小。1.4 索引文件大小优化数据
下面是iOS微信优化前后的平均每个用户的索引文件大小对比:
2、索引更新逻辑优化
2.1 保证索引和数据的一致
rowid
,收藏是使用收藏跟后台同步的updateSequence
,而联系人找不到这种一直增长的进度数据,我们是通过在联系人数据库中标记有新增或有更新的联系人的微信号来作为索引更新进度。进度数据的使用方法如下:WAL
模式而无法保证不同数据库的事务的原子性)。还有一个操作图中没有画出,具体是微信启动时如果检查到业务进度小于索引进度,这种一般意味着业务数据损坏后被重置了,这种情况下要删掉索引并重置索引进度。2.2 建索引速度优化
减少磁盘的写入次数,提高平均建索引速度。
在一个事务中,建索引SQL语句的解析结果可以反复使用,可以减少SQL语句的解析次数,进而提高平均建索引速度。
减少生成Segment的数量,从而减少
Merge Segment
带来的读写消耗。
Segment
自动Merge
机制,索引的写入速度非常可控,只要控制好量,就不用担心批量建索引带来的高耗时问题。我们综合考虑了低端机器的建索引速度和搜索页面的拉起时间,确定了最大批量建索引数据条数为100条。同时,我们会在内存中cache本次微信运行期间产生的未建索引业务数据,在极端情况下给没有来得及建索引的业务数据提供相对内存搜索,保证搜索结果的完整性。因为cache上一次微信运行期间产生的未建索引数据需要引入额外的磁盘IO,所以微信启动后会触发一次建索引逻辑,对现有的未建索引业务数据建一次索引。总结一下触发建索引的时机有三个:未建索引业务数据达到100条。
进入搜索界面。
微信启动。
2.3 删除索引速度优化
建一个业务数据
id
到FTS索引的rowid
的普通索引。在FTS索引表中去掉业务数据
Id
那一列的UNINDEXED
约束,给业务数据Id
添加倒排索引。
倒排索引相比普通索引还带了很多额外信息,搜索效率低一些。
如果需要多个业务字段才能确定一条倒排索引时,倒排索引是建不了联合索引的,只能匹配其中一个业务字段,其他字段就是遍历匹配,这种情况搜索效率会很低。
2.4 索引更新性能优化数据
3、搜索逻辑优化
3.1 单个搜索任务支持并行执行
对于搜索数据量大的业务(比如聊天记录搜索),可以将索引数据均分存储到多个FTS索引表(注意这里不均分的话还是会存在短板效应),这样搜索时可以并行搜索各个索引表,然后汇总各个表的搜索结果,再进行统一排序。这里拆分的索引表数量既不能太多也不能太少,太多会超出手机实际的并行处理能力,也会影响其他搜索任务的性能,太少又不能充分利用并行处理能力。以前微信用了十个FTS表存储聊天记录索引,现在改为使用四个FTS表。
对于搜索逻辑复杂的业务(比如联系人搜索),可以将可独立执行的搜索逻辑并行执行。比如在联系人搜索任务中,我们将联系人的普通文本搜索、拼音搜索、标签和地区的搜索、多群成员的搜索并行执行,搜完之后再合并结果进行排序。这里为什么不也用拆表的方式呢?因为这种搜索结果数量少的场景,搜索的耗时主要是集中在搜索索引的环节,索引可以看做一颗B树,将一颗B树拆分成多个,搜索耗时并不会成比例下降。
3.2 搜索任务支持中断
CancelFlag
,在搜索逻辑执行时每搜到一个结果就判断一下CancelFlag
是否置位,如果置位了就立即退出任务。外部逻辑可以通过置位CancelFlag
来中断搜索任务。逻辑流程如下图所示:CancelFlag
的时间间隔尽量相等,要实现这个目标就要在搜索时避免使用OrderBy子句对结果进行排序。因为FTS5不支持建立联合索引,所以在使用OrderBy
子句时,SQLite在输出第一个结果前会遍历所有匹配结果进行排序,这就让输出第一个结果的耗时几乎等于输出全部结果的耗时,中断逻辑就失去了意义。不使用OrderBy
子句就对搜索逻辑添加了两个限制:从数据库读取所有结果之后再排序。我们可以在读取结果时将用于排序的字段一并读出,然后在读完所有结果之后再对所有结果执行排序。因为排序的耗时占总搜索耗时的比例很低,加上排序算法的性能大同小异,这种做法对搜索速度的影响可以忽略。
不能使用分段查询。在全文搜索这个场景中,分段查询其实是没有什么作用的。因为分段查询就要对结果排序,对结果排序就要遍历所有结果,所以分段查询并不能降低搜索耗时(除非按照FTS索引的
Rowid
分段查询,但是Rowid
不包含实际的业务信息)。
3.3 搜索读取内容最少化
Rowid
以外的内容时,就需要用Rowid
到保存原文的表的读取内容,索引表输出结果的内部执行过程如下:highlight
函数有这个能力)。因为要获取高亮字段不仅要将文本的原文读取出来,还要对文本原文再次分词,才能定位命中位置的原文内容,搜索结果多的情况下分词带来的消耗非常明显。那展示搜索结果时如何获取高亮匹配内容呢?我们采用的方式是将用户的搜索文本进行分词,然后在展示结果时查找每个Token
在展示文本中的位置,然后将那个位置高亮显示。同样因为用户一屏看到的结果数量是很少的,这里的高亮逻辑带来的性能消耗可以忽略。SubstringMatchInfo
函数来读取高亮内容。这里主要还是因为要读取匹配内容所在的层级和位置用于排序,所以逐个结果重新分词的操作在所难免。