背景
学过Elasticsearch的jy们肯定都听说过倒排索引,我也不例外,但是我总是只知道大概,详细的原理就说不上来了。因为很好奇为什么倒排索引就这么快,遂开始深入理解一下其核心原理,
倒排索引
什么是倒排索引
从场景入手
假如我们有以下场景:
用户在浏览器输入了一段话要求查找出相关的网页。
以掘金为例,假如我要搜索elasticsearch的原理,
如果使用传统的mysql数据库,我应该如何查询?
select * from article where title like '%elasticsearch的原理%' and description like '%elasticsearch的原理%'
但是出现问题:
- 模糊查询存在问题
- 结果中必须包含
elasticsearch的原理, 而无法检索出elasticsearch相关的文章,因为没有分词。 - 即使文章中带有
elasticsearch原理也无法被匹配到,因为少了一个的。 - 也就是无法满足在搜索“ABCD"这样的关键词时,看到"A","AB","CD",“ABC”的搜索结果。
- 结果中必须包含
- 性能问题
- mysql针对于前缀模糊查询,索引会失效。
于是就有了特别的索引结构--倒排索引
倒排索引
倒排索引: 倒排索引就像一本“词语目录”,每个词后面跟着所有出现过它的文章或页面,这样搜索时能立刻找到包含这个词的所有内容。
既然是'词语目录',那么首先我们应该将我们需要进行模糊查询的数据进行'分词',提取出关键词,比如使用 Elasticsearch 的默认分词器(standard analyzer)对短语 elaticsearch的原理 进行分词,结果会是:
[ "elaticsearch", "的", "原", "理" ]
但是,这并不符合我们的使用习惯,比如"原理"本身应该是一个词,而且"的"并没有实际含义。所以我们可以选择合适的分词器,比如ik_smart分词器,来得到想要的分词效果
[ "elaticsearch", "原理" ]
有了分词以后,我们就可以从词语(term)出发,找到对应的文章,比如
| 关键词(term) | id |
|---|---|
| elaticsearch | A,B,C,D |
| 原理 | C,E,D |
将两个分词的结果合并一下,即可得出我们想要的结果是【C,D】,因为C,D包含我们想要的所有关键词。
索引结构
term dictionary
假设我现在写了一篇文章,里面包含"elasticsearch"这个关键词(term),那么我先要找到这个term,然后找到这个term指向的的id列表,去列表中添加新文章的id。
那么如何先找到"elasticsearch"这个关键词(term)呢?
为了能够快速找到term,于是有了term dictionary。term dictionary将所有的term进行排序,然后就可以采用二分查找,达到logN的查找效率。
TermIndex
但是所有的文章分词以后,term的数量非常的大,很可能会将内存撑爆,所以就有了TermIndex,termIndex相当于是关键词(term)的索引。
前缀树(trie)
我们可以想到可以使用二叉树将前缀进行存储,就可以达到索引的效果,类似于Mysql的like的索引结构,前缀树(trie)
但是仅仅这样也是不够的,因为对于海量的数据而言,前缀树仍有优化的空间。具有相同的后缀,是否可以共用后缀呢?比如有这两个字符串term afg cdfg,假如共用后缀的话,就变成以下结构:
这样的话,有了入口和出口,由此转化为了一个有向图,这就是FSA(Finite State Acceptor)。
但是目前仍有一个问题就是,FSA无法存储key-value的数据类型,为了解决这个问题,Lucene采用了另一种数据结构:FST(Finite State Transducer),即“有限状态转换机”。
FST
完整FST非常复杂,以下是我参考文章以及个人理解而成,应该能够清晰描述其基本原理
FST在基于FSA的基础上,为每一个出度添加了一个output属性。以下面以Term Dictionary:(msb/10、msbtech/5、msn/2、wltech/8、wth/16)为例
我的浅显的理解是:
- 单词进入FSA有向图是增量的,每个字符都是按照顺序进行排列
- 共同的后缀符合"通用最小化"法则。
- 以value为数字类型为例
- 我们将value作为路径的输出
- 当有公共部分时,那么这个公共路径的权重数字是多少呢,怎么将单词的value进行拆分?
- "通用最小化"就是假设有两个单词,有公共部分,先提取value的最小的那个作为公共的路径。
- 然后不断调整路径上公共的部分,让其符合各个具有相同路径的单词输出。
如上图,一开始msb是 10,我们可以将第一位字母m设为最大权重10,但是我们发现加入了msbtech之后,mesbtech路径值和只能是5,引起了路径冲突,这说明m设为10无法满足mesbtech。
而目前最小的以m开头的单词msn的value是2,那么我们可以让m的权重是2,这样即满足了三个单词共同的前缀的权重。
随后我们加入了wltech, 这个单词和msbtech具有相同的后缀tech。由于我们将wltech的第一位w设置为这个单词最大权重8,此时并没有引起冲突,那么wltech后面的路径就都是0,而msbtech由于与msn冲突,拆分后还剩一个3,所以将3作为b的权重,这样也避免了和wltech的冲突,这就是公共后缀。
由于比较复杂,我只能浅显的说出我的理解,详情可以参考:ES开源社区: 倒排索引:ES倒排索引底层原理及FST算法的实现过程
FST 中的 value(值)并不限于数组类型,它可以是各种可编码为字节序列(byte sequence)的类型,例如:
- 整数(如词项编号、频率)
- 字符串(如自动补全候选)
- 字节数组(Lucene 中常用
BytesRef类型) - 任意可序列化的自定义结构(前提是你自行编码)
FST的压缩率非常高,相比HashMap,FST的性能相差的并不多,但是可以大大的节省空间占用。“搜索引擎”级别的词项字典动辄几亿甚至几十亿的数量级,如果使用FST对其进行存储,其高效的数据存储使得数据被压缩的很小,使其完全缓存在内存中成为了可能。FST在Lucene中的应用非常广泛,比如同义词处理、模糊查询、Suggest等都有应用。
postings list
postings list就是term指向的文档id,既然全文检索经常被用在“大数据检索”这一应用领域,搜索引擎级别的数据量级通常通常在亿级甚至十亿级上,那么也就说如果我们对其建立倒排索引,每个字段被拆分成了若干Term,结果就有可能导致倒排索引的数据量甚至超过了source data,即便我们对倒排索引的检索不必全表扫描,但是太多的数据不管是存储成本还是查询性能可能都不是我们想要的,解决办法就是采用高效的压缩算法和快速的编码和解码算法。
FOR(Frame Of Reference)
假设我们现在有一个term指向100w个doc,如果使用int类型去存储100w个int,由于int一般占用4字节,32bit,那么总内存占用就是:
1,000,000×4 字节=4,000,000 字节≈3.81 MB
那么我们如何优化呢?假设我们的数据的id最大是100w,那么我们无需使用32位,只需要使用20位bit即可存储所有的id。
1,000,000 * 2.5字节 = 2,500,000字节 ≈2.5Mb
依然很大。
这时我们就考虑是否可以用差值存储(dealta list),即不存储原本的数值,而是存储每个数值与前一个数字的差值,这时原本的数字组就变成了[1,1,1…1],数组中共包含100W个1,如果存储数字1,那么用1个bit就足够存储差值,也就是我们存储一百万个数字,只需要用100万个bit,虽然看上去还是很多,但是原本存储这些数据需要使用3200W个bit,现在数据压缩了32倍,这也是采用差值存储的理论最该的压缩倍率。
但现在有一个问题,实际存储并不会那么巧合的每个id连续。
比如我就直接用社区里的例子:数组[73,300,302,332,343,372],我们得到差值[73,227,2,30,11,29], 经过计算后,最大值为227,使用8个bit来存储。但是细心思考可以发现,除了227以外,其他数字都很小,如果都是用8个bit来存储,那么显然浪费了不少存储空间。于是我们尝试将数组进行拆分,将原本一个数组拆分成[73,227]和[2,30,11,29]两个数组,这样做的好处就是第二个数组的数值区间进一步减小了,最大值由227变为了30,这时只需要5个bit就可以存储任意一个数字。
那么我为什么不每个差值单独使用最合适的bit数来存储?
这是因为我们需要一个Record Space 来描述每一个差值的bit位数,这样才能编码和解码,如果每一个差值单独记录bit位数,记录数组单位大小的Record Space大小为1Byte,8bit,得不偿失。
所以在计算数组的拆分长度的时候就要权衡得失,尽量把数组保留的足够长,数组越长Record Space所占用的空间越可以忽略不计,但同时数组越扁平越好,取值区间越小越好。比如这个数组:[22, 43, 21, 34, 55, 64, 4322, 345],就可以把4322和345拆分出来,因为4322加大了每个数字的bit占用,造成了空间浪费。
RBM(RoaringBitmap)
但是上述数组有一个问题,就是数组存在一定的特殊性,因为他是一个稠密数组,可以理解为是一个取值区间波动不大的数组。如果倒排表中出现这样的情况:[1000W, 2001W, 3003W, 5248W, 9548W, 10212W, … , 21Y],情况将会特别糟糕,因为我们如果还按照FOR的压缩算法对这个数组进行压缩,我们对其计算dealta list,可以发现其每个项与前一个数字的差值仍然是一个很大的数值,也就意味着dealta list的每个元素仍然是需要很多bit来存储的。于是Lucene对于这种稀疏数组采用了另一种压缩算法:RBM(Roaring Bitmaps)
这个算法其实比较容易理解,对于这个数组里面的数字,比如196658转为二进制就是0000 0000 0000 0011 0000 0000 0011 0010,我们将这个32位进行切分为高16位和低16位,那么高16位就是0000 0000 0000 0011,也就是3,低16位是0000 0000 0011 0010,也就是50。
3和50分别正好是196658除以(2^16)的得数和余数,换句话说,int类型的高16位和低16位分别就是其本身对2^16的商和模。
对数组中每个数字进行相同的操作,会得到以下结果:(0,1000)(0,62101)(2,313)(2,980)(2,60101)(3,50),其含义就是每个数字都由一个很大的数字变为了两个很小的数字,并且这两个数字都不超过65536(2^16),更重要的是,当前结果是非常适合压缩的,因为不难看出,出现了很多重复的数字,比如前两个数字的得数都是0,以及第2、3、4个数字的得数都是2。这样类似于hash的结构,我们可以使用【key-容器】来描述所有的相同的高位。
如上图可以是:
0 -> 1000 62101 // key是0 容器里面有1000 62101
2 -> 313 980 // key是2 容器里面有313 980
3 -> 50 // key是3 容器里面有50
因为id不同,所以在高位相同的情况下,低位肯定不同,所以容器里面的数字是不会冲突的。
这里的【容器】,RBM中包含了三种Container,分别是ArrayContainer、BitmapContainer和RunContainer。
ArrayContainer
顾名思义,Container中实际就是一个short类型的数组。也就是说当容器中达到最大的数量65536时,其占用是 16bit * 65536 / 8 /1024 = 128KB
BitmapContainer 本质上就是一个位图,用0和1来表示是否存在这个数字。当容器中达到最大数量65536时,那么我们就需要一个65536长度的位图,来描述是否存在其中的数字,换算成存储大小就是8字节。
Lucene的RBM中BitmapContainer固定占用8KB大小的空间,通过对比可以发现,当doc的数量小于4096的时候,使用ArrayContainer更加节省空间,当doc数量大于4096的时候,使用BitmapContainer更加节省空间。
RunContainer
Lucene 5之后新增的类型,主要应用在连续数字的存储,比如倒排表中存储的数组为 [1,2,3…100W] 这样的连续数组,如果使用RunContainer,只需存储开头和结尾两个数字:1和100W,即占用8个字节。这种存储方式的优缺点都很明显,它严重收到数字连续性的影响,连续的数字越多,它存储的效率就越高。