lucene查询原理

lucene是一个基于java的全文信息检索工具包,目前主流的搜索系统Elasticsearch和solr都是基于lucene检索功能实现的。想要理解搜索系统的实现原理,就需要深入lucene这一层,看看lucene是如何存储需要检索的数据,以及如何完成高效的数据检索。

1. lucene 数据模型

lucene中包含四种基本的数据类型:

  • index:索引,由很多的Document组成。相当于mysql的数据库
  • Document:由很多的Field组成。相当于mysql的一行数据
  • Field:由Field Name和Field Value组成。相当于mysql的字段和字段值
  • Term:由很多的字节组成。一般将Text类型的Field Value分词之后的每个最小单元叫做Term。

2. lucene 查询过程

  在lucene中查询是基于segment。。每个segment可以看做是一个独立的subindex,在建立索引的过程中,lucene会不断的flush内存中的数据持久化形成新的segment。多个segment也会不断的被merge成一个大的segment,在老的segment还有查询在读取的时候,不会被删除,没有被读取且被merge的segement会被删除。这个过程类似于LSM数据库的merge过程。下面我们主要看在一个segment内部如何实现高效的查询。
  为了方便大家理解,我们以人名字,年龄,学号为例,如何实现查某个名字(有重名)的列表。

docid id name age
1 101 Alice 18
2 102 Alice 20
3 103 Alice 21
4 104 Alan 21
5 105 Alan 18
  1. 每个document的name value经过分词形成多个term,多个term组成term dictionary,这里默认不分词,基于name创建倒排链如下:

在这里插入图片描述

  在这里,Alice、Alan都是term。所以倒排本质上就是基于term的反向列表,方便进行属性查找。到这里我们有个很自然的问题,如何快速拿到这个倒排表呢?在lucene里面就引入了term dictonary的概念,也就是term的字典。term字典里我们可以按照term进行排序,那么用一个二分查找就可以定为这个term所在的地址。这样的复杂度是logN,在term很多,内存放不下的时候,效率还是需要进一步提升。 为解决这个问题,lucene引用trem index来快速找到匹配的term,那么term index是怎么实现的呢?term index其实就是FST构成的一棵树

  如果所有的 term 都是英文字符的话,可能这个 term index 就真的是 26 个英文字符表构成的了。但是实际的情况是,term 未必都是英文字符,term 可以是任意的 byte 数组。

图示:用了字符便于观看。

在这里插入图片描述

  在 Term Index 中需要保存的是 Term 的前面部分字段,以及与 Term Dictionary 之间的映射关系,这使得存储的信息量减少。再结合 FST(Finite State Transducer)压缩技术,Term Iindex 可以被压缩到足够小,以至于可以被缓存进服务器内存中。这样,在用户查找的时候,先在内存里从 Term Index 找到 Term Dictionary 中的位置映射关系,然后再去磁盘上找对应的 Term,进而查找对应的倒排表,这就大大减少了磁盘的读取次数,也就提高了效率和速度。

  找到了倒排表,怎么通过docId找到我们需要的文档呢?

3. SkipList

  为了能够快速查找docid对应的document,lucene采用了SkipList这一数据结构。SkipList有以下几个特征:

  • skipInterval:该值描述了在level=0层,每处理skipInterval篇文档,就生成一个skipDatum,该值默认为128。
  • skipMultiplier:该值描述了在所有层,每处理skipMultiplier个skipDatum,就在上一层生成一个新的skipDatum,该值默认为8
  • numberOfSkipLevels:skipList中的层数

  直接给出一个生成后的跳表:

在这里插入图片描述

  文档号0~3455的3456篇文档,我们另skipInterval为128,即每处理128篇文档生成一个PackedBlock,对应一个datum;另skipMultiplier为3(源码中默认值为8),即每生成3个datum就在上一层生成一个新的索引,新的索引也是一个datum,它是3个datum中的最后一个,并且增加了一个索引值SkipChildLevelPointer来实现映射关系,每一层的数值为PackedBlock中的最后一篇文档的文档号,例如level=2的三个数值1151、2303、3455。

哨兵数组skipDoc

哨兵数组skipDoc的定义如下所示:

int[] skipDoc;

  该数组用来描述每一层中正在处理的datum,datum对应的PackedBlock中的最后一篇文档的文档号作为哨兵值添加到哨兵数组中,在初始化阶段,skipDoc数组中的数组元素如下所示(见图2红框标注的数值):

int[] skipDoc = {
    
    127, 383, 1151, 3455}

  初始化阶段将每一层的第一个datum对应的PackedBlock中的最后一篇文档的文档号作为哨兵值。

docDeltaBuffer

  docDeltaBuffer是一个int类型数组,总是根据docDeltaBuffer中的文档集合来判断SkipList中是否存在待处理的文档号。

  在初始化阶段,docDeltaBuffer数组中的数组元素是level=0的第一个datum对应的PackedBlock中文档集合。

Lucene中使用读取跳表SkipList的过程

读取过程分为下面三步:

  • 步骤一:获得需要更新哨兵值的层数N
    • 从skipDoc数组的第一个哨兵值开始,依次与待处理的文档号比较,找出所有比待处理的文档号小的层
  • 步骤二:从N层开始依次更新每一层在skipDoc数组中的哨兵值
    • 如果待处理的文档号大于当前层的哨兵值,那么令当前层的下一个datum对应的PackedBlock中的最后一篇文档的文档号作为新的哨兵值,直到待处理的文档号小于当前层的哨兵值
    • 在处理level=0时,更新后的datum对应的PackedBlock中的文档集合更新到docDeltaBuffer中
  • 步骤三:遍历docDeltaBuffer数组
    • 取出PackedBlock中的所有文档号到docDeltaBuffer数组中,依次与待处理的文档号作比较,判断SkipList中是否存在该文档号

读取跳表SKipList

  我们依次处理下面的文档号,判断是否在跳表SKipList中来了解读取过程:

文档号:{
    
    23, 700}

文档号:23

  更新前的skipDoc数组如下所示:

int[] skipDoc = {
    
    127, 383, 1151, 3455}

  当前skipDoc数组对应的SkipList如下所示:

在这里插入图片描述

  由于文档号23小于skipDoc数组中的所有哨兵值,故不需要更新skipDoc数组中的哨兵值,那么直接遍历docDeltaBuffer,判断文档号23是否在该数组中即可。

文档号:700

  更新前的skipDoc数组如下所示:

int[] skipDoc = {
    
    127, 383, 1151, 3455}

  文档号700小于1151、3455,执行了步骤一之后,判断出需要更新哨兵值的层数N为2(两层),即只要从level=1开始更新level=1以及level=0对应的skipDoc数组中的哨兵值。

  对于level=1层,在执行了步骤二后,更新后的skipDoc数组如下所示:

int[] skipDoc = {
    
    127, 767, 1151, 3455}

在这里插入图片描述

  随后更新level=0层,在执行了步骤二后,更新后的skipDoc数组如下所示:

int[] skipDoc = {
    
    767, 767, 1151, 3455}

在这里插入图片描述

  这里要注意的是,level=0层的datum更新过程如下所示:

在这里插入图片描述

  在更新的过程中,跳过了两个datum,其原因是在图3中,当更新完level=1的datum之后,该datum通过它包含的SkipChildLevelPointer字段,重新设置在level=0层的哨兵值,随后在处理level=0时,根据待处理的文档号继续更新哨兵值。

4. 倒排合并

  到这里还有另一个问题,那就是当我们查询中出现了 name=‘Alan’ and name=‘Alice’ limit 0,20 该如何处理?

  它会拆分为 term = ‘Alan’ 和 term = 'Alice’的查询。分别得到每个term的倒排链(docId列表),再对多个倒排表做交集。那么倒排表怎么做交集呢?

水平分桶,多线程并行

有序集合1{1,3,5,7,8,9, 10,30,50,70,80,90}和有序集合2{2,3,4,5,6,7, 20,30,40,50,60,70},先进行分桶拆分【桶1的范围为[1, 9]、桶2的范围为[10, 100]、桶3的范围为[101, max_int]】,于是拆分成集合1【a={1,3,5,7,8,9}、b={10,30,50,70,80,90}、c={}】和集合2【d={2,3,4,5,6,7}、e={20,30,40,50,60,70}、e={}】,利用多线程对桶1(a和d)、桶2(b和e)、桶3(c和e)分别求交集,最后求并集。

bitmap,大大提高运算并行度,时间复杂度O(n)

假设set1{1,3,5,7,8,9}和set2{2,3,4,5,6,7}的所有元素都在桶值[1, 16]的范围之内,可以用16个bit来描述这两个集合,原集合中的元素x,在这个16bitmap中的第x个bit为1,此时两个bitmap求交集,只需要将两个bitmap进行“与”操作,结果集bitmap的3,5,7位是1,表明原集合的交集为{3,5,7}

水平分桶(每个桶内的数据一定处于一个范围之内),使用bitmap来表示集合,能极大提高求交集的效率,但时间复杂度仍旧是O(n),但bitmap需要大量连续空间,占用内存较大。

猜你喜欢

转载自blog.csdn.net/weixin_44981707/article/details/114437027