背景
最近在学习lucene,看到了tim、tip、tmd文件的倒排索引的构建过程,发现需要梳理一下Burst Tries和FST相关部分的内容作为基础知识,网上的资料搜到的比较少,这里总结一下。
在lucene构建tim文件或者说倒排字典的过程中采用了一种类似于Burst Tries的数据结构,主要目的在于寻找到查询效率和空间占用之间的平衡。本文尝试学习一下burst tries的论文,梳理论文主要内容,并且讨论下在lucene的构建过程做出一些调整和原因。
概念
在倒排索引构构建的过程中我们需要组织构建一个term dictionary,这个过程可以称之为vocabulary accumulation。term dictionary的结构基本上可以视为一个Map,Key是term,Value是当前term的一些原信息,比如出现的位置,频率等等。
term dictionary的性能指标主要是搜索效率和内存占用。在burst tries之前大概有4大类方式去构建字典:BST、Splay trees、Trie、Hash。burst tries综合了上述几种结构的优点,成为在vocabulary accumulation场景下比较合适的数据结构,并且被lucene从4.0版本开始采用沿用至今。下面我们简单对比下几种方式的优缺点
典型的数据结构
BST
Basic
使用BST构建字典的常见思路就是在在每个节点放置一个string,然后通过字符串比较来判断等于当前节点或者进入下一层节点继续比较。通常来说根节点通常比较1个字符串,而随着层级的增加,比较字符串的长度会变得越来越长。一种改进的方式是可以考虑在下层节点比较的过程中忽略前缀,但是如何识别前缀可能会花费更大的代价。
在vocabulary accumulation的场景下词汇的分布往往不是正态的,而是有一定倾斜性,存在一些常见的词汇,在BST的场景下越靠近根节点其访问的效率也就越高。
虽然在常规情况下基本符合这种情况,但是为避免一些极端场景,再平衡技术显得比较关键,但是典型的再平衡树比如AVL或者红黑树通常情况下不考虑数据倾斜的情况,所以试验下来其效果可能还不如基本BST版本。
Splay trees
Splay trees实际上是BST的一个变种,其特点是每次访问某个节点后会通过一系列变化将刚刚访问的节点变化到根节点附近,这种方式非常适合于存在vocabulary accumulation的场景,但是其代价是更高的内存空间占用和比较复杂的变化操作。
Randomised Search Tree
类似于Splay Tree
Hash
Hash是非常高效的数据访问策略,VA的场景下,还有一定的改进空间就是采用一种move-to-front的策略,在hash的链表访问过程中将最常访问的数据移动到链表的前端,以加速下次访问。
Trie
Trie的优点在于保存了数据的顺序,且搜索效率比较高(正比于待匹配字符串的长度。
Trie的问题在整体的空间占用率较低,一种略微增加复杂度的策略是省略叶子节点,并且将其使用一些特殊的结构比较空字符串替代,以减小空间占用。这种树我们称之为compact trie。但是即便如此在靠近叶子节点的trie上依然存在大量的空间浪费。
Trie的另一种变种称之为TST(ternary search trees)。
Burst Tries
概念
从上面的文章中我们可以看到基于树构造的数据结构在VA的场景下有几个比较明显的特征;
- 相当于Hash,树结构普遍存在多次比较的和比较高的平均搜索代价
- 在BST的场景下,在非根节点存在不必要的前缀比较
- 在Tries的场景下,在靠近叶子节点的地方往往存在极大的空间浪费。
Burst Trie的可以看成是一种Trie的变形,总体采用了Trie的结构但是在叶子节点处采用了容器(container)的概念以提高空间的占用率。这里有几个基本概念:
- Records:record包含term及其元信息。
- Access Trie:整个Trie的结构,叶子节点由容器构成
- Container:一系列少量Records的集合,可以采用任意的数据结构,比如列表或者BST等。这里的Records都去除了相同的k个前缀,k等于当前叶子节点所处的深度。
比如图中的Burst Trie是由“came”, “car”, “cat”, “cave”, “cy”, “cyan”, “we”, “went”, “were”, “west”构成的。
这样的数据结构在效率和空间占用上达到了一种平衡的状态,下面我们介绍几种需要支持的操作。
操作
-
search:类似于Trie,有三种情况
- 终止于空字符串,存在且直接通过Access Trie就可以完成定位
- 终止于容器,则尝试查询容器内的后缀
- 终止于空指针,则说明当前元素不能存在于当前字典内。
- insert:插入仅仅指向容器中添加Record的情况。
- burst:是将当前的container使用一个Node和更深一层的container节点替换的过程。需要注意的是burst之后有可能整体的空间占用更小了。
调参
论文论述了一些参数用于调整Burst Tries的参数
- ratio:容器内根节点元素的命中比率/容器的访问次数,通过这个比率来控制burst的触发条件,同时需要设置一下容器访问次数的阈值,避免一些低频的term所在的容器频繁burst
- limit:record的容器的总元素数量
- trend:给每个容器设置一个奖惩机制,如果容器内首个元素命中搜索则奖励一定分数B,如果不是首个元素命中则惩罚一定分数M,并且设置一个初始化值C。在构建VA的过程中如果分数减到0,则进行容器burst。
容器内的数据结构
- 链表:优点是存储效率比较高,并且可以采用此前论述的move-to-front的策略,将最近访问的节点放到首位
- BST:占用更多空间但是平均搜索长度更短。
- Splay trees:效率更高,空间更多,复杂度更高。
Lucene的应用
更细节的Lucene的应用需要在讲到codec的部分再详细讨论,但是基本上采用了类似burst tries的数据结构。
采用了FST的的数据结构来构造Access Trie,而container采用了链表结构也就是对应于block,应该是处于内存占用的考虑。
提供了一些特殊逻辑避免一些过大的blockd生成。具体在tim、tip、tmd的codec生成部分在详细讨论