背景

都知道lucene使用倒排索引来搜索文档,哪倒排索引究竟是个什么呢?

倒排索引是区分于正排索引的概念

正排索引:以文档的唯一id作为索引,以文档的内容作为记录的结构

倒排索引:以文档中内容的单词作为的索引,以文档的id作为内容的结构

lucene在内存里创建索引 lucene索引原理_lucene在内存里创建索引

相比关系数据库使用的“like %XX%”查询,倒排索引有什么优点

  • 搜索效率更高,like“%xx%”,无法使用索引,会走全表扫描,效率差
  • 可以实现更复杂的搜索场景,like“%xx%”只能实现首尾的模糊查询

lucene的倒排索引实现

lucene索引文件的构成

由上图可知,lucene索引文件的主要组成部分有.tip文件,.tim文件,.doc文件

文件

介绍

.tip

用于存储Term的索引文件Term Index

.tim

存放分词的文件以及对应的posting文件指针

.doc

存放每个分词对应的文档id和词频

.pay

记录的是playload信息,包含记录文档权重值,用于计算文档相关性

.pox

记录位置信息

Lucene把用于存储Term的索引文件叫Terms Index,它的后缀是.tip;把Postings信息分别存储在.doc、.pay、.pox,分别记录Postings的DocId信息和Term的词频、Payload信息、pox是记录位置信息。Terms Dictionary的文件后缀称为.tim,它是Term与Postings的关系纽带,存储了Term和其对应的Postings文件指针。

总体来说,通过Terms Index(.tip)能够快速地在Terms Dictionary(.tim)中找到你的想要的Term,以及它对应的Postings文件指针与Term在Segment作用域上的统计信息。

lucene倒排查询逻辑

在介绍了索引表和记录表的结构后,就可以得到 Lucene 倒排索引的查询步骤:

  • 通过 Term Index 数据(.tip 文件)中的 StartFP 获取指定字段的 FST
  • 通过 FST 找到指定 Term 在 Term Dictionary(.tim 文件)可能存在的 Block
  • 将对应 Block 加载内存,遍历 Block 中的 Entry,通过后缀(Suffix)判断是否存在指定 Term
  • 存在则通过 Entry 的 TermStat 数据中各个文件的 FP 获取 Posting 数据
  • 如果需要获取 Term 对应的所有 DocId 则直接遍历 TermFreqs,如果获取指定 DocId 数据则通过 SkipData 快速跳转

lucene倒排索引实现的关键技术

背景:termIndex是放在内存中加锁term查找的,放在内存当中的话就一定要考虑,所有的分词都放到内存中存储,内存是不是够用。

Term Index的索引结构FST

什么是FST?
FST, 全称 Finite State Transducer(有限状态转换器)。
它具备以下特点:

  • 给定一个 Input 可以得到一个 output,相当于 HashMap
  • 共享前缀、后缀节省空间,FST 的内存消耗要比 HashMap 少很多
  • 词查找复杂度为 O(len(str))
    如下图:FST结构的内存消耗不到Map的四分之一


    https://zhuanlan.zhihu.com/p/366849553 用一个例子来帮助理解fst是如何构建的?
    动态演示工具:http://examples.mikemccandless.com/fst.py

lucene如何用FST来构建TermIndex?
实际上 FST 是通过 Dictionary 的每个 NodeBlock 的前缀构成,所以通过 FST 只可以直接找到这个 NodeBlock 在 .tim 文件上具体的 File Pointer, 然后还需要在 NodeBlock 中遍历 Entry 匹配后缀进行查找。
lucene 4.0版本后开始引入

Term Discionary的介绍

term Dictionary的简单介绍

  • Terms Dictionary是索引表,它能够让你知道你的查询的这个Term的统计信息,如词频和文档频
  • 它来存放了寻找posting数据的指针
  • term Dictionary存放在.tim文件当中

.tim的结构介绍

lucene在内存里创建索引 lucene索引原理_搜索引擎_02


lucene在内存里创建索引 lucene索引原理_lucene_03

由上面两个图,可以总结到一些下面的结论:

  • tim文件中存放term数据是图中的data部分,data部分首先是按不同的fieid进行分层
  • 每个field又会被分成多个block
  • 每个block是由包含共同前缀的term组成,block上有这些term的后缀,以及每个term的出现文档频和词频

Posting的结构

前面已经提过,在lucene中PostingList被拆到了下面3个文件当中:.doc, .pay, .pos文件

三个文件整体实现差不太多,这里以.doc 文件为例分析其实现。

.doc 文件存储的是每个 Term 对应的文档 Id 和词频。每个 Term 都包含一对 TermFreqs 和 SkipData 结构。

其中 TermFreqs 存放 docId 和词频信息,SkipData 为跳表信息,用于实现 TermFreqs 内部的快速跳转。

lucene在内存里创建索引 lucene索引原理_lucene_04

TermFreqs

TermFreqs 存储文档号和对应的词频,它们两是一一对应的两个 int 值。Lucene 为了尽可能的压缩数据,采用的是混合存储 ,由 PackedBlock 和 VIntBlocks 两种结构组成。TermFreqs采用的是混合存储,由Packed Blocks和VInt Blocks两种结构组成。由于PackedBlock是定长的,当前Lucene默认是128个Integers。所以在不满128个值的时候,Lucene采用VIntBlocks结构存储。需要注意的是当用Packed Blocks结构时,DocID和TermFreq是分开存储的,各自将128个值写入到一个Block。
当用VIntBlocks结构时,还是沿用旧版本的存储方式,即上面描述的二元组的方式存储。
在Lucene4.0之前的版本,还没有引入PackedBlock时,DocID和TermFreq确定完全是成对出现,当时只有VIntBlock一种结构。

例如,在同一个Segment里,某一个Term A在259个文档同一个字段出现,那么Term A就需要把这259个文档的文档编号和Term A在每个文档出现的频率一同记下来存储在.doc。此时,Lucene需要用到2个PackedBlocks和3个VIntBlocks来存储它们

PackedBlock

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

VIntBlock

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

根据上述两种 Block 的特点,Lucene 会每处理包含 Term 的 128 篇文档,将其对应的 DocId 数组和 TermFreq 数组分别处理为 PackedDocDeltaBlock 和 PackedFreqBlock 的 PackedInt 结构,两者组成一个 PackedBlock,最后不足 128 的文档则采用 VIntBlock 的方式来存储。如下图:

lucene在内存里创建索引 lucene索引原理_lucene在内存里创建索引_05

SkipData

跳表的原理非常简单,跳表其实就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。首先在最高级索引上查找最后一个小于当前查找元素的位置,然后再跳到次高级索引继续查找,直到跳到最底层为止,这时候以及十分接近要查找的元素的位置了(如果查找元素存在的话)。由于根据索引可以一次跳过多个元素,所以跳查找的查找速度也就变快了。

在搜索中存在将每个 Term 对应的 DocId 集合进行取交集的操作,即判断某个 Term 的 DocId 在另一个 Term 的 TermFreqs 中是否存在。TermFreqs 中每个 Block 中的 DocId 是有序的,可以采用顺序扫描的方式来查询,但是如果 Term 对应的 doc 特别多时搜索效率就会很低。因此 Lucene 为了减少扫描和比较的次数,采用了 SkipData 这个跳表结构来实现快速跳转。

跳表是在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。实质就是一种可以进行二分查找的有序链表。

lucene在内存里创建索引 lucene索引原理_lucene_06

倒排索引由两部分组成,一个是Term Dictionary(字典表),一个是Postings List(记录表)。它们两的关系类似Map中的key-value, 字典表中是key,记录表中是value。
Term Dictionary(字典表):记录的是分词后的term
Postings List(记录表):

  • 文档 id(DocId, Document Id),包含单词的所有文档唯一 id,用于去正排索引中查询原始数据。
  • 词频(TF,Term Frequency),记录 Term 在每篇文档中出现的次数,用于后续相关性算分。
  • 位置(Position),记录 Term 在每篇文档中的分词位置(多个),用于做词语搜索(Phrase Query)。
  • 偏移(Offset),记录 Term 在每篇文档的开始和结束位置,用于高亮显示等。
    是否可以使用Map结构实现?
    全文搜索引擎在海量数据的情况下是需要存储大量的文本,所以面临以下问题:
    Dictionary 是比较大的(比如我们搜索中的一个字段可能有上千万个 Term)
    Postings 可能会占据大量的存储空间(一个 Term 多的有几百万个 doc)
    因此上面说的基于 Map 的实现方式几乎是不可行的。

不足128的部分使用vint而不是packed算法呢?
猜想:如果多出的数据就几个,需要提前加载编译器,这个消耗是否合理。

SkipList主要是搜索时的优化,主要是减少集合间取交集时需要比较的次数,比如在Query被分词器分成多个关键词时,搜索结果需要同时满足这些关键词的,此时需要将每个Term对应的DocId集合进行析取操作,通过跳表能够有效有减少比较的次数。
为什么通过skipList,可以减少比较的次数呢???链表结构中一种类似二分查找的算法?那为什么不用二分查找的算法呢?