网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
在这里我们以一个英文文档为例子,之所以选择用英文文档是因为英文分词比较简单,直接以空格进行分词即可,而中文分词相对比较复杂。
我们以 Elasticsearch
官网中下面两句话作为两位文档来分析:
Elasticsearch is the distributed search and analytics engine at the heart of the Elastic Stack.
Elasticsearch provides near real-time search and analytics for all types of data.
根据上面两句话,假设我们可以得到下面这样的一个索引结构:
term index | term dictionary | Posting list TF |
---|---|---|
term 索引 | elasticsearch | [1,2] |
term 索引 | search | [1,2] |
term 索引 | elastic | [1] |
term 索引 | provides | [2] |
其中:
- term index:顾名思议,这个是为
term
(经过分词后的每个词) 建立的索引,也就是通过这个索引可以快速找到当前term
的位置,从而找到对应的Posting list
。因为在es
中,会为每个字段都建立索引(默认存储在内存中),所以当我们的数据量非常大的时候,就需要能快速定位到这个词对应的索引所在的内存位置,所以就单独为每个term
建立了索引,这个索引一般可以选择哈希表或者B+
树进行索引存储。 - term dictionary:记录了文档中去重后的所有词(经过分词器处理)。
- Posting list TF:记录了含有当前词的文档以及当前词出现在文档的位置(偏移量),该项信息是一个数组,上面表格中为了简单只列举了文档
id
,实际上这里会存储很多信息。
这时候假如我们搜索 Elasticsearch Elastic
这样的关键字,那么会经过以下步骤:
- 对输入的关键字进行分词处理,得到两个词:
elasticsearch
和elastic
(经过分词器之后大写字母都会转化成小写字母)。
- 然后分别用这两个词进行搜索,搜索之后,发现
elasticsearch
在两个文档中都有出现,而elastic
只在文档一中出现。 - 最终的搜索结果就是文档一和文档二都返回,但是因为文档一两个词都命中了,所以相关度(分数)更高,于是文档一会排在文档二前面,这就是算分的过程。不过需要注意的是,实际的这种相关度分数算法不会这么简单,而是有专门的算法来计算,命中词多的并不一定会出现在前面。
倒排索引如何存储数据
知道了倒排索引的搜索过程,那么倒排索引的数据又是如何存储的呢?
回答这个问题之前我们先来看另一个问题,那就是建立索引的目的是什么?最直接的目的肯定是为了加快检索速度,而为了达到这个目的,那么在不考虑其他因素的情况下,必然是需要占用的空间越少越好,而为了减少占用空间,可能就需要压缩之后再进行存储,而压缩之后又涉及到解压缩,所以采用的压缩算法也需要能达到快速压缩和解压的目的。
FOR 压缩
FOR
压缩算法即 Frame Of Reference
。这种算法比较简单,也有一定的局限性,因为其对存储的文档 id
有一定要求。
假设现在有一亿个文档,对应的文档 id
就是从 1
开始自增。假设现在关键字 elasticsearch
存在于 1000W
个文档中,而这 1000W
个文档恰好就是从 1
到 1000W
,那么假如不采用任何压缩算法,直接进行存储需要占用多少空间?
int
类型占用了 4
个字节,而 1000W
这个数量级需要 2
的 24
次方,也就是说如果用二进制来存储,在不考虑符号位的情况下也需要 24
个 bit
才能存储,而因为 Posting list TF
是一个数组,所以为了能解析出数据,文档 id=1
的数据也需要用 24
个 bit
来进行存储,这样就会极大的浪费了空间。
为了解决这个问题,我们就需要使用 FOR
算法,FOR
算法并不直接存储文档 id
,而是存储差值,像这种这么规律的文档 id
,差值都是 1
,而 1
转成二进制就可以只使用 1
个 bit
进行存储,这样就只需要 1000W
个 bit
的空间来进行存储就够了,相比较直接存储原始文档 id
的情况下,这种场景采用 FOR
算法大大减少了空间。
上面举的这个例子是比较理想的情况,然而实际上这种概率是比较小的,那我们再来看下面这一组文档 id
:
1,9,15,45,68,323,457
这个数组计算差值后得到下面这个数组:
8,6,30,23,255,134
这个时候如果还是直接用普通差值的算法,虽然也能节省空间,但是却并不是最优的一种解决方案,那么这个时候有没有一种更高效的方法来进行存储呢?
我们观察下这个差值数组,发现这个数组可以进一步拆分成两组:
- [8,6,30,23]:这一组最大值为
30
,只需要5
个比特就能进行存储。 - [255,134]:这一组最大值为
255
,需要8
个比特就能存储。
这么拆分之后,原始数据需要用 32*7=224
个比特(原始数据直接用 int
存储),普通差值需要 8*6=48
个比特,而经过分组差值拆分之后只需要 5*4+8*2=36
个比特,进一步压缩了空间,这种优势随着数据量的增加会更加明显。
但是不管采用哪种方案都有一个问题,那就是进行差值或者拆分之后,怎么还原数据,解压的时候怎么知道差值数组内的元素占用空间大小?
所以对每一个数据,还需要一块一个字节的空间大小来存储当前数组内元素占用的比特数,所以分组并不是越细越好,假如对每一个差值元素都单独存储,那么反而会比不分组更浪费空间,反之,如果每个分组内的元素足够多,那么存储占用空间的这一个字节反带来的影响就会更小或者忽略不计。
RBM 压缩
上面例子中介绍的差值都不会大相径庭,那么假如我们差值计算之后得到的数组,其每个元素差别都很大呢?比如说下面这个文档 id
数组:
1000,62101,131385,132052,191173,196658
这个数组大家可以去计算一下差值,计算之后会发现一个大一个小,两个差值之间差距很大,所以这种方式就不适合于用 FOR
压缩,所以我们就需要有另外的压缩算法来提升效率,这就是 RBM
压缩。
RBM
压缩算法即 Roaring Bitmap
,是在 2016
年由 S. Chambi、D. Lemire、O. Kaser 等人在论文《Better bitmap performance with Roaring bitmaps》与《Consistently faster and smaller compressed bitmaps with Roaring》中提出来的。
RBM
压缩算法的核心思想是:将 32
位无符号整数按照高 16
位进行划分容器,即最多可能有 65536
个 container
。因为 65536
实际上就是 2
的 16
次方,而一个无符号 int
类型正好是需要 32
位进行存储,划分为高低位正好两边都是 16
位,也就是最多 65536
个。
划分之后根据高 16
位去找 container
(比如高 16
位计算的结果是 1
就去找 container_1
,2
就去找 container_2
,依次类推),找到之后如果发现容器不存在,那么就会新建一个容器,并且把低 16
位存入容器内,如果容器存在,就直接将低 16
位存入容器。
这样就会出现一个现象:那就是容器最多有 65536
个,而每个容器内的元素也恰好最多是 65536
个元素。
也就是上面的数组经过计算就会得到以下容器(container_1
没有元素):
如果说大家觉得上面的高低 16
位不好理解,那么可以这么理解,我们把数组中的元素全部除以 65536
,对其取模,每得到一个模就创建一个容器,而其余数就放入对应的模所对应的容器中。因为一个 int
类型就是 2
的 32
次方,正好是 65536
的平方。
经过运算之后得到容器,那么容器中的元素又该如何进行存储呢?可以选择直接存储,也可以选择其他更高效的存储方式。在 RBM
算法中,总共有三种容器类型,分别采用不同的方法来存储容器中的元素:
- ArrayContainer
ArrayContainer
采用 short
数组来进行存储,因为每个容器中的元素最大值就是 65535
,采用 2
个字节进行存储。这种存储方式的特点是随着元素个数的增多,所需空间会一直增大。
- BitmapContainer
BitmapContainer
采用位图的方式进行存储,也就是固定创建一个 65536
长度的容器,容器中每个元素只用一个比特进行存储,某一个位置有元素则存储 1
,没有元素则存储为 0
。这种存储方式的特点是空间固定就是占用 65536
个比特,也就是大小固定为 8kb
。
- RunContainer
RunContainer
比较特殊,在特定场景下会使用,比如文档 id
从 1-100
是连续的,那么采用这种容器就可以直接存 1,99
,表示 1
后面有 99
个连续的数字,再比如 1,2,3,4,5,6,10,11,12,13
可以被压缩为 1,5,10,3
,表示 1
后面有 5
个连续数字,10
后面有 3
个连续数字。
至于每次存储采用什么容器,需要进行一下判定,比如 ArrayContainer
,当存储的元素少于 4096
个时,他会比 BitmapContainer
占用更少空间,而当大于 4096
个元素时,采用 ArrayContainer
所需要的空间就会大于 8kb
,那么采用 BitmapContainer
就会占用更少空间。
倒排索引如何存储
前面我们讲了 es
中的倒排索引采用的是什么压缩算法进行压缩,那么压缩之后的数据是如何落地到磁盘的呢?采用的是什么数据结构呢?
字典树(Tria Tree)
字典树又称之为前缀树(Prefix Tree),是一种哈希树的变种,可以用于搜索时的自动补全、拼写检查、最长前缀匹配等。
字典树有以下三个特点:
- 根节点不包含字符,除根节点外的其余每个节点都只包含一个字符。
- 从根节点到某一节点,将路径上经过的所有字符连接起来,即为该节点对应的字符串。
- 每个节点的所有子节点包含的字符都不相同。
下图所示就是在数据结构网站上依次输入以下单词(AFGCC、AFG、ABP、TAGCC)后生成的一颗字典树:
上图中可以发现根节点没有字母,除了根节点之外其余节点有白色和绿色两种颜色之分,这两种颜色的节点有什么区别呢?
绿色的节点表示当前节点是一个 Final
节点,也就是说当前节点是某一个单词的结束节点,搜索的时候当发现末尾节点是一个 Final
节点则表示当前字母存在,否则表示不存在。
比如我现在搜索 ABP
,从根节点往下找的时候,最后发现 P
是一个 Final
节点,那就表示当前树中存在字符串 ABP
,如果搜索 AFGC
,虽然也能找到这些字母,但是 C
并不是一个 Final
节点,所以字符串 AFGC
并不存在。
不过字典树存在一个问题,上图中就可以体现出来,比如第二列中的后缀 FGCC
和 第三列中的 GCC
其实最后三个字符是重复的,但是这些重复的字符串都单独存储了,并没有被复用,也就是说字典树没有解决后缀共用问题,只解决了前缀共用(这也是字典树又被称之为前缀树的原因)。当数据量达到一定级别的时候,只共享前缀不共享后缀也会带来很多空间的浪费,那么如何来解决这个问题呢?
FST
要解决上面字典树的缺陷其实思路也很简单,就是除了利用字符串的前缀,同时也将相同的后缀进行利用,这就是 FST
,在了解 FST
之前,我们先了解另一个概念,那就是 FSM
,即:Finite State Transducer。
FSM
FSM
,即 Finite State Machine
,翻译为:有限状态机。如果大家有了解过设计模式中的状态模式的话,那么应该会对状态机有一定了解。有限状态机顾明思议就是状态可以全部被列举出来,然后随着不同的操作在不同的状态之间流程。
如下图所示就是一个简易的有限状态机(假设一个人一天做的事就是下面的所有状态,那么状态之间可以切换流转,下图中的数字表示状态的转换条件):
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!