Elasticsearch和Lucene的关系
Lucene 是一个开源、免费、高性能、纯 Java 编写的全文检索引擎,可以算作是开源领域最好的全文检索工具包。ElasticSearch 是基于Lucene实现的一个分布式、可扩展、近实时性的高性能搜索与数据分析引擎。
Lucene索引层次结构
Lucene的基础层次结构由索引、段、文档、域、词五个部分组成。正向索引的生成即为基于Lucene的基础层次结构一级一级处理文档并分解域存储词的过程。
索引文件层级关系如图1所示:
-
索引(Index):Lucene索引库包含了搜索文本的所有内容,可以通过文件或文件流的方式存储在不同的数据库或文件目录下。
-
段(Segment):一个索引中包含多个段,段与段之间相互独立。由于Lucene进行关键词检索时需要加载索引段进行下一步搜索,如果索引段较多会增加较大的I/O开销,减慢检索速度,因此写入时会通过段合并策略对不同的段进行合并。
-
文档(Document):Lucene会将文档写入段中,一个段中包含多个文档。
-
域(Field):一篇文档会包含多种不同的字段,不同的字段保存在不同的域中。
-
词(Term):Lucene会通过分词器将域中的字符串通过词法分析和语言处理后拆分成词,Lucene通过这些关键词进行全文检索。
倒排索引
其中主要有如下几个核心术语需要理解:
-
词条(Term): 索引里面最小的存储和查询单元,对于英文来说是一个单词,对于中文来说一般指分词后的一个词。
-
词典(Term Dictionary): 或字典,是词条 Term 的集合。搜索引擎的通常索引单位是单词,单词词典是由文档集合中出现过的所有单词构成的字符串集合,单词词典内每条索引项记载单词本身的一些信息以及指向“倒排列表”的指针。
-
倒排表(Post list): 一个文档通常由多个词组成,倒排表记录的是某个词在哪些文档里出现过以及出现的位置。每条记录称为一个倒排项(Posting)。倒排表记录的不单是文档编号,还存储了词频等信息。
-
倒排文件(Inverted File): 所有单词的倒排列表往往顺序地存储在磁盘的某个文件里,这个文件被称之为倒排文件,倒排文件是存储倒排索引的物理文件。
-
字典树(Term Index): 从数据结构上分类算是一个“Trie 树”,也就是我们常说的字典树。这棵树不会包含所有的 term,它包含的是 term 的一些前缀(这也是字典树的使用场景,公共前缀)。通过 term index 可以快速地定位到 term dictionary 的某个 offset。
索引查询及文档搜索过程
Lucene利用倒排索引定位需要查询的文档号,通过文档号搜索出文件后,再利用词权重等信息对文档排序后返回。
-
内存加载tip文件,根据FST匹配到后缀词块在tim文件中的位置;
-
根据查询到的后缀词块位置查询到后缀及倒排表的相关信息;
-
根据tim中查询到的倒排表信息从doc文件中定位出文档号及词频信息,完成搜索;
-
文件定位完成后Lucene将去.fdx文件目录索引及.fdt中根据正向索引查找出目标文件。
文件格式如下表格所示:
文件后缀 | 存放信息 | 关联 ES 的查询场景 |
---|---|---|
.tip | 词典的fst,以及到.tim的索引 | term查询、全文搜索等场景 |
.tim | 存放term info以及指向.doc、.pos、.pay的指针 | |
.doc | 倒排表及词频 | |
.pos | 记录term的位置信息 | match_pharse等需要位置信息的查询 |
.pay | payload信息 | |
.fdt | 存放原始文档,正向存储 | 获取source等字段值 |
.fdx | fdt的索引 | |
.dvd | DocValues 列式存储 | 聚合、排序、脚本等场景数值类型的range查询 |
.dvm | .dvd的索引 | |
.nvd | Norms data | 全文搜索的tfNorms计算 |
.nvm | .nvd的索引 | |
.dim | 数值类型的索引,BKD树结构 | 数值类型的range查询 |
.dii | .dim的索引 | |
.liv | 记录被删除的文档号 | |
.cfs | 复合文件 | 新写入数据生成的段文件,引入该文件主要用于减少文件句柄数 |
.cfe | 复合文件,.cfs的索引 | |
.si | 记录段的元信息 |
倒排索引存储结构
- 为了能够在数量巨大的 terms 中快速定位到某一个 term,同时节约对内存的使用和减少磁盘 io 的读取。lucene 使用 “term index→term dictionary→postings list” 的倒排索引结构,通过 FST 压缩放入内存,进一步提高搜索效率。
- 为了减少 postings list 的磁盘消耗,lucene 使用了 FOR(Frame of Reference)技术压缩,带来的压缩效果十分明显。
- ES 的 filter 语句采用了 Roaring Bitmap 技术来缓存搜索结果,保证高频 filter 查询速度的同时降低存储空间消耗。
- 在联合查询时,在有 filter cache 的情况下,会直接利用位图的原生特性快速求交并集得到联合查询结果,否则使用 skip list 对多个 postings list 求交并集,跳过遍历成本并且节省部分数据的解压缩 cpu 成本。
FST(Finite State Transducer 有穷状态转换器)
FST(Finite State Transducer 有穷状态转换器),一种类似前缀树的结构。FST不仅仅是一种前缀匹配树,它的实现还包含了后缀匹配操作。FST构建过程
例如,假设我们有一个这样Term Dictionary:mon,tues,thurs。FST是这样的:
FOR(Frame of Reference)
在 lucene 中,要求 postings lists 都要是有序的整形数组。这样就带来了一个很好的好处,可以通过 增量编码(delta-encode)这种方式进行压缩。比如现在有 id 列表 [73, 300, 302, 332, 343, 372],转化成每一个 id 相对于前一个 id 的增量值(第一个 id 的前一个 id 默认是 0,增量就是它自己)列表是 [73, 227, 2, 30, 11, 29]。
它会把所有的文档分成很多个 block,每个 block 正好包含 256 个文档,然后单独对每个文档进行增量编码。计算出存储这个 block 里面所有文档最多需要多少位来保存每个 id,并且把这个位数作为头信息(header)放在每个 block 的前面。这个技术叫 Frame of Reference。
上图也是来自于ES 官方博客中的一个示例(假设每个 block 只有 3 个文件而不是 256)。
FOR 的步骤可以总结为:
RBM(RoaringBitmap)
在 ES 中,可以使用 filters 来优化查询,filter 查询只处理文档是否匹配与否,不涉及文档评分操作,查询的结果可以被缓存。filter cache 会存储那些经常使用的数据,针对 filter 的缓存就是为了加速处理效率,对压缩算法要求更高。业内对于稀疏位图也有很多成熟的压缩方案,lucene 采用的就是 roaring bitmaps。
我这里用简单的方式描述一下这个压缩过程是怎么样:
将 doc id 拆成高 16 位,低 16 位。对高位进行聚合 (以高位做 key,value 为有相同高位的所有低位数组),根据低位的数据量 (不同高位聚合出的低位数组长度不相同),使用不同的 container(数据结构) 存储。
- ArrayContainer:len<4096 ArrayContainer 直接存值
- BitmapContainer:len>=4096 BitmapContainer 使用 bitmap 存储
- RunContainer(Lucene 5之后):比如倒排表中存储的数组为 [1,2,3…100W] 这样的连续数组,如果使用RunContainer,只需存储开头和结尾两个数字:1和100W,即占用8个字节。
分界线的来源:value 的最大总数是为2^16=65536. 假设以 bitmap 方式存储需要 65536bit=8kb,而直接存值的方式,一个值 2 byte,4K 个总共需要2byte*4K=8kb。
所以当 value 总量 <4k 时,使用直接存值的方式更节省空间。
空间压缩主要体现在:
-
高位聚合(假设数据中有 100w 个高位相同的值,原先需要 100w2byte,现在只要 12byte)
-
低位压缩
缺点就在于位操作的速度相对于原生的 bitmap 会有影响。这就是 trade-off 呀。平衡的艺术。
SKIP LIST
讲完了压缩,我们再来讲讲联合查询。先讲简单的,如果查询有 filter cache,那就是直接拿 filter cache 来做计算,也就是说位图来做 AND 或者 OR 的计算。如果查询的 filter 没有缓存,那么就用 skip list 的方式去遍历磁盘上的 postings list。
以上是三个 posting list。我们现在需要把它们用 AND 的关系合并,得出 posting list 的交集。
首先选择最短的 posting list,逐个在另外两个 posting list 中查找看是否存在,最后得到交集的结果。遍历的过程可以跳过一些元素,比如我们遍历到绿色的 13 的时候,就可以跳过蓝色的 3 了,因为 3 比 13 要小。
用 skip list 还会带来一个好处,还记得前面说的吗,postings list 在磁盘里面是采用 FOR 的编码方式存储的。会把所有的文档分成很多个 block,每个 block 正好包含 256 个文档,然后单独对每个文档进行增量编码,计算出存储这个 block 里面所有文档最多需要多少位来保存每个 id,并且把这个位数作为头信息(header)放在每个 block 的前面。
因为这个 FOR 的编码是有解压缩成本的。利用 skip list,除了跳过了遍历的成本,也跳过了解压缩这些压缩过的 block 的过程,从而节省了 cpu。
参考: