Elasticsearch 倒排索引学习

346 阅读14分钟

本文由官方文档、他人博客以及个人理解汇总而来;并未与源码进行详细对照,不保证正确性

引言

倒排索引是ES中最关键的概念之一,ES之所以可以实现海量文本的全文检索,倒排索引功不可没。

正排索引与倒排索引

所谓全文检索,本质上是判断一个文本中是否包含我们所需的单词,比如我们需要检索稀土掘金是一个技术分享网站中是否包含技术这一单词。在传统的数据库中,数据检索只能通过 like 来实现,比如使用如下SQL

select id from demo_table where msg like '%技术%'

在数据量小的情况下这并没有什么问题;但如果数据量很大,会严重拖累系统速度且无法使用索引优化;

正排索引

所谓的正排索引就是id -> doc的索引,通过id值去快速查找信息,例如

iddoc
1The quick brown fox jumped over the lazy dog
2Quick brown foxes leap over lazy dogs in summer

正排索引的特点主要包括:

  • 文档为中心:每个文档作为一个独立的单元被索引,索引条目通常是文档ID和文档内容的组合。
  • 适合浏览:由于索引是按照文档来组织的,因此它非常适合浏览文档集合,但对于搜索特定关键词效率不高。
  • 简单直接:构建和维护相对简单,易于理解和实现。

正排索引结构非常适合浏览和管理文档集合,但它不适合快速的关键词搜索,因为要找到包含某个特定关键词的所有文档,需要遍历整个索引。

倒排索引

Elasticsearch 使用一种称为倒排索引的结构,旨在实现非常快速的全文检索。 倒排索引包括整个索引下,文档中出现的单词的列表,以及每个单词所对应的文档列表。

倒排索引(Inverted Index)是针对每个关键词建立索引,索引项指向包含该关键词的所有文档,这种方式更适合快速查询包含特定关键词的文档集。大多数现代搜索引擎和文档管理系统都会使用倒排索引来提高搜索效率和性能。

以上面的例子为例

term(doc_id, freq)
Quick(2,1)
The(1,1)
brown(1,1) (2,1)
dog(1,1)
dogs(2,1)
fox(1,1)
foxes(2,1)
in(2,1)
jumped(1,1)
lazy(1,1) (2,1)
leap(2,1)
over(1,1) (2,1)
quick(1,1)
summer(1,1)
the(1,1)

倒排索引的优势:

  • 高效查询:当用户输入查询词后,系统可以直接从索引中找到含有该查询词的所有文档,从而极大地提高了查询速度。
  • 节省空间:相对于存储每个文档及其包含的所有单词的传统索引,倒排索引可以减少存储空间,尤其是在文档数量庞大时。
  • 支持高级检索:倒排索引不仅支持基本的全文检索,还可以支持更复杂的查询,如短语查询、布尔查询等。 倒排索引的结构通常包含两部分:

一个典型的倒排索引包含两个主要部分:

词汇表(Vocabulary): 词汇表是一个有序的列表,包含索引中所有的唯一词条。词条通常是按字母顺序排列的,这样可以方便地进行查找。每个词条都对应一个或多个文档,这些文档包含了该词条。

倒排列表(Inverted List): 每个词条都有一个对应的倒排列表,列表中包含了一系列文档标识符(如文档ID),表示哪些文档包含了该词条。倒排列表还可能包含其他信息,如词条在文档中的频率(TF)、词条的位置、词条的重要性等,这些信息有助于后续的查询处理和评分。

ES倒排索引生成

正排索引使用唯一id作为key,其中id可以自己指定也可以自动生成;id的生成可以与doc无关;而倒排索引的term取自doc,需要先将文档中的单词解析出来(可能还需要转化),然后才能作为索引。

分词与转化

1. 基本过程: 将文档分为一个一个词或词组(term)<br>    
2. 将词进行转化:以上面为例
    2.1 Quick 和 quick 是作为单独的词出现的,而用户可能认为它们是同一个词
    2.2 fox 和 foxes 是单复数,搜索时可以认定为同一个词
    2.3 dog 和 dogs 也很相似;它们共享同一个词根。 它们是同义词

所以,通常在分词时,分词器还会做一些处理来将词进行转化,来解决近义词,大小写等问题

常用中文分词器:ik分词器
倒排索引在建立的过程中需要进行分词和转化,分词和转化的质量直接影响索引的质量。一些特定领域的词汇,可能需要自己自定义分词器

生成索引结构

在分完词后,ES就会根据分词结果,建立索引的数据结构;从抽象的角度讲,所有的索引的数据结构都是K-V类型;其中K就是在ES中的词汇表,V就是ES中的倒排列表

倒排列表(Posting List)

ES中的倒排列表称为PostingList,其包含文档 id、词频、位置等多个信息,这些数据之间本身是相对独立的,因此 Postings List 被拆成三个文件存储:

  • .doc后缀文件:记录 Postings 的 doc_Id 信息和 Term 的词频
  • .pay后缀文件:记录 Payload 信息和偏移量信息
  • .pos后缀文件:记录位置信息

基本所有的查询都会用 .doc 文件获取文档 id,且一般的查询仅需要用到 .doc 文件就足够了,只有对于近似查询等位置相关的查询则需要用位置相关数据。

id与词频文件doc

.doc 文件存储的是每个 Term 对应的文档 Id 和词频。每个 Term 都包含一对 TermFreqs 和 SkipData 结构。 其中 TermFreqs 存放 doc_Id 和词频信息,SkipData 为跳表信息,用于实现 TermFreqs 内部的快速跳转。

.doc文件结构
+-------+----------+-----+----------+
|header | TermInfo | ... | TermInfo |
+-------+----------+-----+----------+
            ⬇
+-----------+-----------+
| TermFreqs | SkipData  |
+-----------+-----------+
      ⬇
+-------------+-------------+-------------+-----------+
| PackedBlock | PackedBlock | PackedBlock | VIntBlock |
+-------------+-------------+-------------+-----------+
      ⬇
+--------------------+-----------------+
|PacketDocDetlaBlock | PacketFreqBlock |
+--------------------+-----------------+
TermFreqs

TermFreqs 是一个列表,存储文档号和对应的词频,它们两是一一对应的两个 int 值。Lucene 为了尽可能的压缩数据,采用的是混合存储 ,由 PackedBlock 和 VIntBlocks 两种数据结构组成。

PackedBlock

其采用 PackedInts 结构将一个 int[] 压缩打包成一个紧凑的 Block。它的压缩方式是取数组中最大值所占用的 bit 长度作为一个预算的长度,然后将数组每个元素按这个长度进行截取,以达到压缩的目的。

例如,一个数组包含3个元素,int[3]{3, 2, 1}
原始:
00000000 00000000 00000000 00000011; 
00000000 00000000 00000000 00000010; 
00000000 00000000 00000000 00000001;
长度一共 32 * 3 = 96 bit
压缩后:
11; 10; 01
长度一共 2 * 3 = 6 bit

在这里插入图片描述

VIntBlock

VIntBlock 是采用 VInt 来压缩 int 值,对于绝大多数语言,int 型都占4个字节,不论这个数据是1、100、1000、还是1000,000。VInt 采用可变长的字节来表示一个整数。数值较大的数,使用较多的字节来表示,数值较少的数,使用较少的字节来表示。每个字节仅使用第1至第7位(共7 bits)存储数据,第8位作为标识,表示是否需要继续读取下一个字节。

小端机(主机端):从低到高,先读低位
大端机(网络端):从低到高,先读高位

举个例子:

整数130为 int 类型时需要4个字节,转换成 VInt 后仅用2个字节,其中第一个字节的第8位为1,标识需要继续读取第二个字节。

在这里插入图片描述

根据上述两种 Block 的特点,如果一个term所对应的len(doc) >= 128,就其对应的 DocId 数组和 TermFreq 数组分别处理为 PackedDocDeltaBlock 和 PackedFreqBlock 的 PackedInt 结构,两者组成一个 PackedBlock,不足128的则采用 VIntBlock 的方式来存储。

在这里插入图片描述

SkipData

每一个TermFreqs都绑定了一个SkipData

在搜索中存在需要判断某个 term 的 doc_id 在另一个 term 的 TermFreqs 中是否存在的情况,也就是取一个TermFreqs中的某一doc_id去其他里面查询的情况。TermFreqs 中每个 Block 中的 doc_id 是有序的,可以采用顺序扫描的方式来查询,但是如果 term 对应的 doc 特别多时搜索效率就会很低,同时由于 Block 的大小是不固定的,我们无法使用二分的方式来进行查询。因此 Lucene 为了减少扫描和比较的次数,采用了 SkipData 这个跳表结构来实现快速跳转。

这里的跳表原理和Redis的zset一样; 最终得到的是对应doc_id在各文件下具体的Block偏移量

词汇表(term index、term dict)

就像JavaTreeMap使用红黑树来组织KMySQL使用B+树组织K一样,ES对自己的K也有自己的组织方式。

主要涉及到的文件

  • .tim: 词汇表(Terms Dictionary)通过 .tim 文件来存储
  • .tip: 当词汇表越来越大时,为了保证词汇表的检索速度,又拥有了对词汇表检索Terms index
term数据文件tim

在tim文件中,ES会将具有相同前缀的数据压缩为一个NodeBlock, Entry中保存了

+-------+-----------+-----------+-----+-----------+
|header | NodeBlock | NodeBlock | ... | NodeBlock |
+-------+-----------+-----------+-----+-----------+
              ⬇
+-----------+-----------+--------+-----+
|EntryCount | NodeBlock | EntryN | ... |
+-----------+-----------+--------+-----+
                            ⬇
                    +-------+----------+
                    |Suffix | TermMeta |
                    +-------+----------+
                                 ⬇
        +-----------+------------+------------+------------+
        |DocStartFP | PosStartFP | PayStartFP | SkipOffset |
        +-----------+------------+------------+------------+

为什么NodeBlock下级节点还有NodeBlock?主要是为了处理相同前缀的 Term 集合内部部分 Term 又包含了相同前缀。 具体例子如下图所示

在一组具有相同前缀a的terms内,部分term又具有ab前缀

image.png

TermMeta内具有Posing List的元信息。

  • SkipOffset:用来描述当前 term 信息在 .doc 文件中跳表信息的起始位置。
  • DocStartFP:是当前 term 信息在 .doc 文件中的文档 ID 与词频信息的起始位置。
  • PosStartFP:是当前 term 信息在 .pos 文件中的起始位置。
  • PayStartFP:是当前 term 信息在 .pay 文件中的起始位置。
term索引文件tip

.tip文件中使用的是一种类似于前缀树的结构,但又有所不同,这里的数据结构名叫Finite State Transducers (FST),

image.png

对应web

image.png

对应web

前缀树是一种树形结构,且只利用了前缀信息;而FST 是一种特殊的有限状态机(FSM),它可以看作是一种带权重的有向图,同时利用了前缀和后缀信息;当存储数据缺少相同前缀,但存在大量后缀时,FST的优势更加明显

image.png image.png

根据 FST 就可以将需要搜索 Term 作为 Input,对其途径的边上的值进行累加就可以得到 output:

image.png

例如,按图去搜索thrus,

output = 0;
input t -> output += 2
input h -> output += 2
input r -> output += 0
input u -> output += 0
input s -> output += 0
output = 4;

总结

检索过程 Term index -> Term Dict -> Posting List

image.png

附录

常用字典数据结构

很多数据结构均能完成字典功能,总结如下。

数据结构优缺点
排序列表Array/List使用二分法查找,不平衡
HashMap/TreeMap性能高,内存消耗大,几乎是原始数据的三倍
Skip List跳跃表,可快速查找词语,在lucene、redis、Hbase等均有实现。相对于TreeMap等结构,特别适合高并发场景(Skip List介绍
Trie适合英文词典,如果系统中存在大量字符串且这些字符串基本没有公共前缀,则相应的trie树将非常消耗内存(数据结构之trie树
Double Array Trie适合做中文词典,内存占用小,很多分词工具均采用此种算法(深入双数组Trie
Ternary Search Tree三叉树,每一个node有3个节点,兼具省空间和查询快的优点(Ternary Search Tree
Finite State Transducers (FST)一种有限状态转移机,Lucene 4有开源实现,并大量使用

FST与B+树

在讨论 FST(Finite State Transducer)与 B+ Tree 的优缺点时,我们需要考虑它们各自的设计目的和应用场景。B+ Tree 是一种广泛使用的数据结构,特别是在数据库管理系统中用于索引,而 FST 更多地用于文本索引和压缩场景,尤其是在搜索引擎中。

B+ Tree 的特点 B+ Tree 是一种自平衡的树数据结构,它的特点是所有实际的数据都存储在叶子节点上,而内部节点只用于索引。

B+ Tree 的优点包括:

  • 查询效率高:B+ Tree 支持快速的范围查询,因为叶子节点之间通过指针连接,形成了一个链表。
  • 磁盘 I/O 效率高:B+ Tree 的每个节点可以存储多个键值对,这减少了磁盘 I/O 的次数。
  • 易于维护:B+ Tree 的插入和删除操作相对简单,且易于保持平衡。
  • 支持范围查询:由于叶子节点之间连接,B+ Tree 支持高效的范围查询。

FST 的特点 FST 是一种用于文本索引和压缩的数据结构,它的设计目的是为了高效地存储和检索字符串。FST 的优点包括:

  • 空间效率高:FST 可以通过共享公共前缀来减少存储空间,特别适用于存储大量字符串。
  • 查询速度快:对于已知的字符串,FST 的查询时间复杂度与字符串长度成线性关系。
  • 支持模式匹配:FST 可以支持更复杂的模式匹配查询,如正则表达式匹配。
  • 易于压缩:FST 可以通过多种编码技术进行压缩,从而减少存储空间。

FST 相较于 B+ Tree 的优缺点

优点

存储效率:FST 可以通过共享公共前缀来减少存储空间,这对于存储大量文本数据特别有效。 模式匹配:FST 支持复杂的模式匹配查询,如正则表达式,这是 B+ Tree 难以实现的。 压缩:FST 可以通过多种编码技术进行压缩,从而进一步减少存储空间。

缺点

查询灵活性:相较于 B+ Tree 支持的范围查询,FST 在处理非字符串类型的键值对时可能不如 B+ Tree 灵活。 插入和删除成本:FST 在插入或删除数据时可能需要重新构建部分结构,这在某些情况下可能会导致较高的维护成本。 实现复杂度:FST 的实现相对复杂,需要更多的编程技巧来构建和维护。

应用场景

B+ Tree:适用于需要支持范围查询和高效磁盘 I/O 的场景,如数据库索引。 FST:适用于需要高效存储和检索大量文本数据的场景,如搜索引擎中的术语字典。

总结

总的来说,B+ Tree 和 FST 都是非常有效的数据结构,它们各有侧重。B+ Tree 更适合需要支持范围查询和高效磁盘访问的应用场景,而 FST 更适合需要高效存储和检索大量文本数据的应用场景。选择哪种数据结构取决于具体的应用需求和场景。

参考

juejin.cn/post/694798…

www.cnblogs.com/bonelee/p/6…

elasticsearch.cn/article/617…

www.6aiq.com/article/158…