Lucene倒排索引简述 番外篇

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/zteny/article/details/83547164

一、前言

Lucene构建索引是一个非常复杂的过程,需要经过多道工序才能完成。那你知道Lucene在索引构建过程有哪些工序吗?又是整体流程是怎么样的呢?这里尽量从宏观的角度来介绍索引全过程,给大家一个全景的印象,且不失关键步骤细节的介绍。

在Lucene接用户提交的第一个文档开始,Lucene为用户创建一个Segment,从此开始索引之旅。

文档到Lucene之后,大概会如下6大步骤,当然并不是所有字段都会经历这些步骤。如步骤2与3、3跟4、4跟5都是互斥,同时每个步骤都通过配置被跳过。

  1. 先将文档中需要存储的字段编码然后存储。
  2. 将需要正向索引的字段,构建正向索引。
  3. 需要分词的字段进行分词,倒排索引和TermVectors。
  4. Lucene6.0之后的版本,如果是Points字段的话,则需要存储PointValues。
  5. 保存分词字段的Norms信息,如果没有配置省略的话。
  6. 最终一次冲刷之后,完成所以数据的落地。

在Lucene的官方档中,实际上是对Segment、Document、Field和Term等术语都给出了完整的定义了。这里也重新认识一下:

  1. Segment是最小的独立的索引单位。
  2. Document是Field的集合。
  3. Field是Term的集合。

Document进到Segment的管辖内会被分配一个唯一的ID叫DocID,同时根据每个Field的名字定一个唯一的称呼FieldNaming,对于索引字段进到Postings之前也会被分配一个唯一的TermID。Field除了FieldNaming之外也有一个FieldNumber的,跟DocID和TermID都是一个自增长的数值。

二、索引生产流程

在程序中,我们是这样组织文档的。然后将文档逐一提交给Lucene,在接到文档之后,先是给每个文档打上一个标记,叫DocID。从此开始文档的索引之旅。

这里用不同的背景色来表示不同的DocID和FieldNaming。

1. 字段存储

文档是由于一系列的字段组成的,只要将所有需要的字段都存储了,便是完成了文档的存储。这过程是比较容易理解的,无非是将文档按自己的组织方式存储。Lucene的文档存储格式跟MySQL的frm文件的格式基本类似,将整个文档编码之后写到文件上。即就是说Lucene接收到来自用户提交的文档之后,就能够编码并且存储的。

这个过程中,主要是由StoredFieldsConsumer完成。

Lucene为了保证写入的性能,文档一旦提交实际上不可变的,这点跟MySQL的frm文件是不一样的。字段存储是唯一一个按文档存储的文件,此外都是打破文档的边界按字段存储的。

另外,因为Lucene作为搜索引擎,它读取文档的操作往往是以随机访问的方式。为此Lucene将文档数据写到.fdt文件的同时,还需要为DocID对应的文档所在.tdt文件上位置构建索引,并将索引存储到.fdx文件。以提供快速随机访问的能力。

DocID是Lucene内部ID,用户并不能直接获取。实际上是.fdx通过倒排索引得到了DocID之后,能够快速取回文档的作用。需要区分DocID与用户定义的有业务意义的文档ID,它们不是一回事。

2. 索引构建及存储

索引是搜索引擎的根础,关乎搜索引擎的性能。在Lucene中,索引可以分成两类,正向索引和反向索引的倒排索引。在倒排索引表过程中,通常需要指定在哪个Field进行检索的,也就是TermID在所有具有相同FieldName的Field中唯一的。

方便理解,将文档进行加工变形。实现上,Lucene通常为所有相同FieldName的字段分配一个PerField对象,由PerField实现所有字段级别所有操作。

相同FieldName的所有Field在处理逻辑是可以认为是连续,DocID被在这里仅是Term的属性。Field是Terms的集合,因此可以以为索引阶段Field就是所有同名Field的所有Terms的集合。

2.1. 正向索引

正向索引记录了DocId到FieldValue的映射关系,提供了通过DocID就能直接获取字段值的能力。DocValuesWriter将DocIdSet与FieldValue分别存储在类似数组的结构中,他们的存储顺序的是一致的。然后,DocValuesConsumer将FieldValues和DocIdSet一并写到.dvd文件中。每个需要存储DocValues的Field都有一对这样的结构,且DocValues是按字段连续存储在.dvd文件中。每个Field的DocIdSet和FieldValues在dvd文件中的索引信息(起始位置),被存储在.dvm文件中。

这种存储结构实现上是列式存储的结构,当然Lucene也是一种列存储数据库了。这种列式存储结构,给Lucene带来很多二次计算的可能,比如Hive On Solr/ElasticSearch,Solr的高级特性Streaming Expression等。Streaming Expression是Solr提供基于Lucene索引实现的计算框架,以及在Streaming Expression上实现SQL的能力。

存储DocID的内存是用DocsWithFieldSet(底层实际上是BitSet),在磁盘上的则是要复杂一些。在Lucene7.0之后,Lucene针对BitSet的稠稀性,用不存储的方式。当BitSet比较稀疏时,直接存储DocID;当BitSet稠密时,则将BitSet的Bits数据存储。根据数据的分布情况不同,采用适当的结构不仅可以提高空间的利用率,还能提高遍历的效率。唯一的缺点估计就是实现起来比较复杂。

当前版本DocValues还不支持分词。

Lucene定义多种DocValues类型,每种类型的存储方式还不太一样,但有一点是一样的。DocValues存储DocIDSet和DocValues是分开存储的,总之DocValues是一个大话题,这里先不展开讨论。

2.2. 倒排索引

字段的存储,通常都会比较简单,是因为他不需要知道全局的状态是怎么样的。但是,反向的倒排索引则要复杂一些,因为他都是需要整个Segment的信息的。比如Term在哪些文档出现了,在每个文档分别出现几次,每次出现在什么位置等等。这些信息都是需要站到Segment这个视角上才能够收集的。

在Lucene中倒排索引实现又能成两类,一种是传统的,按Segment构建的Postings,这是我们所说的倒排索引;另一种方式是在每个上文档构建的TermVectors,又叫词向量或者文档向量。

a. Postings

倒排索引的数据结构大家所熟悉的,左边是Terms列表,记录Field中出现的所有的Terms,也是叫TermsDictionary;右边是Postings,记录Term所对应的所出现哪些文档的文档号,出现次数,位置信息等。画出来的示意图如下:

上图看似简单的结构,在实现过程却是非常不简单。在构建Posting过程中需要考虑如何收集Terms的位置信息和统计信息,还要考虑在大规模的数据量级下如何去重和排序。这些都是实现倒排索引需要考虑的关键问题,一些不合理的细节所导致的额外性能开销,就会直接影响全局索引性能。

那么Lucene又是怎么做的呢? 在构建索引时,Postings是内存上临时构建的,在整个过程Postings完全是在内存上的。回到之前工序继续工作,此时Field会被分词,变成一系列Terms的集合。遍历这个Terms的集合,为每个Term分配一个ID,叫TermID。当然,相同的Terms的ID必须得是一样,所以Lucene用一个类HashMap的数据结构来存储Term与TermID的映射关系,同时实现去重的目的。分配完TermID之后,基本上就都用TermID来表示Term的身份了。

在Postings构建过程中,会在PostingsArrays存储上个文档的DocID和TermFreq,还有Term上次出现的位置和位移的情况。即是PostingsArrays由几个int[]组成,其下标都是TermID(TermID是连续分配的整型数,所以PostingsArrays是能被紧凑的存储的),对应的值便是记录TermID上一次出现的各种信息了。就是说Lucene用多个int[]存储Term的各种信息,一个int[]仅存TermID的一种信息中的一个数据。

Lucene为了能够直接使用基本类型数据(因为基本类型有两大好处,减少内存开销和更高性能),所以才有了PostingsArrays结构。方便理解你可以理解成是Postings[],每个Postings对象含有docFreq,intStart,lastPos等属性。

PostingsArrays这个结构只保留每个TermID最后出现的情况,对于TermID每次出现的具体信息则是需要存在其它的结构之中。它们就是IntBlockPool&ByteBlockPool它能有效的避免Java堆中由于分配小对象而引发内存碎片化从而导致Full GC的问题,同时还解决数组长度增长所需要数据拷贝问题,最后是不再需要申请超大且连续的内存。

这两结构有点高级,关于它们的故事非常有趣,但由于篇幅的关系留到下一篇分享。这里我们只需要把他看成是两个连续的int[]和byte[]即可,跟一般的数组有一样的功能。Postings的数据实际只存储在BytesBlockPool(byte[])一个地方,IntBlockPool(int[]),它存储的是索引。

需要注意的是,Postings是在byte[]存储的结构是一个表尾增加的链表结构,在构建索引的时候用IntBlockPool来记录Term下一次要写的位置。也就是说,PostingsArrays的intStarts[]是Term的byte[]的表尾,而表头是记录在PostingsArrays的byteStart上,这也是一个int数组,记录每个Term的在BytesBlockPool的起始位置。有了表头和表尾之后,我们就可以ByteBlockPool里拿到整条链表了。

为什么不能直接写磁盘? 之所以不能直接写盘,是因为在构建过程中不能知道Postings有多长,不能确定要预留多少空间;另外构建过程中Term出现并非有序,所以还需要随机写;最后是Term难以再排序,只能按TermID的顺序处理。

为什么需要postingsArrays呢? 因为写到byte[]的只是增量,那么就需要找到上次的Term出现情况才能计算。如果总是在byte[]上找显得过得重,因为Postings存储在byte[]时,它的结构是单向链表结构。所以就有了PostingsArrays记录上次的信息,方便计算增量。

这里有多提一句,Lucene在Segment提交之前,实现上不是在写Buffer,而是先在内存上构建了。当Segment提交之后,将内存上的索引重新编码之后再刷磁盘。
也就是说,索引在构建时写在内存的数据结构和编码与最终写磁盘的完全不一样的。
基于以上,且不仅限以上原因,需要先收集posting的信息。知道这点之后,至于它叫什么,是缓冲,预构建,还是收集都是一样的。

收集完,也就是已经把整个Segment的文档全部遍历了,此时触发冲刷的操作。然后,将Term排序之后编排成TermEnum格式,此处进入索引写磁盘的步骤了。

关于倒排索引更多详细,可以读一下《Lucene倒排索引简述 之索引表》《Lucene倒排索引简述 之倒排表》这两篇文章,文章介绍了倒排索引的存储结构和Lucene的实现的诸多细节。

b. TermVectors

上面已经用了比较长的篇幅来介绍第一种,就是大家很熟悉的倒排索引结构了。接下来简单介绍一下第二种,存储的数据跟第一种实际上一样的,都是Term和Term的统计信息、位置信息。只不过,TermVectors在Postings的基础又将Terms按文档的重新排序。按文档的结构,两个文档的同名Field的出现两个同相的Term,会被分开记录两次。

回顾Postings记录的几种信息的术语含义,这些信息也TermVectors也会记录的。

  1. TermFreq:在一个文档中出现的次数,通过与DocID成对出现。
  2. Position:Term出现的位置,相当于DocID。
  3. Offset:在文档内的位移,与Position一起才能确定一个位置。
  4. Payload:附加信息,如词性等,可用于自定义评分等。

显然这些信息实际上与是否在文档内并无影响,所以TermVectors记录信息实际上Postings并无太大差别。只不过对于TermVectors是已经知DocID的,所以并不会在所有Term上记录DocID。当然,Freq、Position和Offset也不会记录在其它Documnet出现的情况了。
此外还有一点需要注意的,TermVectors把Term所有信息都记录在同一个文件上(.tvd),这与Postings的记录方式是一样的。Postings将它们拆分成三个文件分别存储DocID和TermFreq、Position和Offset、Payload。

Lucene在存储TermVectors的时候,默认将4096个文档打包成一个chunk来存储。在一个chuck的结构如上图,这里想强调的是,TermFreq/Position/Offset/Payload的存储格式基本一样,这里以TermFreq为例。

假设有个Term="Solr"出现这三个文档的FieldA、FieldB和FieldC三个字段中,它们TermID是不一定相同,只要这三文档不是一样,它们极可能是不相同的。因为每个文档的每个字段都有自己的Term和TermID的映射表,这就是跟Postings最大差异。

为什么没有DocFreq、TotalTermFreq呢
如果已经读过《Lucene倒排索引简述 之索引表》的应该知道这些字段级别的统计信息,它们会在TermDictionary的FieldMetaData上的。也就是说,Postings和TermVectors都不会记录部分信息的。

TermVectors在Solr有挺多应该场景的,比如Highlight,tvch(TermVectorsComponentHandler),MoreLikeThis,等。TermVectors更多的应用可能还是在像MoreLikeThis,分类聚类等NLP任务上。

默认TermVectors是开启的,虽然在搜索时,只要不用它就不会去读这部分信息。但是在索引时,还是会一样性能开销的。不仅如此Segment冲刷之后还可能会出现多次Merge,也都会一定的开销。如果在搜索时,不需要用TermVectors的情况下是可以省略不写TermVectors的。

4. PointValues

PointValues原本是用于地理信息的索引和查询,它在地理信息、多维数值、或者多维数值区间索引和搜索上表现都非常出色。因此,PointValues成为数值字段的默认实现。原先的数值字段(IntField/FloatField/LongField/DoubleField)全被标记为@Deprecated,且在加Legacy前缀。

Solr在Solr7.0开始支持PointValues,并成为数值字段的推荐使用类型。同时BackPort到Solr6.5版本。

PointValues采用新存储结构,BKD-Tree(KD-Tree的变种)。KD-Tree主要应用于多维空间,范围搜索和最近邻搜索。BKD-Tree是基于KD-Tree实现的数据结构,它有高效的IO性能、更高磁盘利用率。基于BKD-Tree实现的IntPoint(含LongPoint,FloatPoint,DoublePoint)不管是索引的性能,还是在搜索的性能都对原先的TrieField的性能更加高,索引文件也更小,尤其是搜索时占用Heap内存要小很多。

PointValues是优秀特性,它并不只是适用于多维的空间搜索,在一维的各个场景的性能指标都非常不错。强烈推荐大家关注并且使用的新特性。

5. Norms

Norms,Normalization Factors,存储的是每个文档中每个字段的归一化因子和Boost(索引时的Boost已经被弃用了,交由Payload接管)。这两个数值都会直接影响搜索时最终文档评分。

在TFIDFSimailary模型下,归一化因子的计算可以简单理解为 l o g 2 1 n u m T e r m s log_2\frac{1}{numTerms}

Norms是所有索引文件中最简单的。它在我脑海中的存在感极弱,可能你在印象中也是如此。Norms可以通过配置FieldType.setOmitNorms(true),表示不存储norms,但默认是会存储的。这个配置是字段级别的,也就是在一个多字段的文档中,可以选择部分字段存储,部分不存储。由上公式就可以知道,Norms的计算需要知道字段中去重后有多少个Terms,所以它跟Postings也算是有一点关系的,也是放在Postings之后处理的。

三、总结

这里着重介绍了Postings是如何构建的,在内存中的存储结构,顺带介绍了Norms等。

文章篇幅尽管是在多次删减之后依然很长,可见Lucene倒排索引的多么的宏大与精彩。所以对我来说研读Lucene是一种兴趣爱好,也是一件非常美好的事情。

猜你喜欢

转载自blog.csdn.net/zteny/article/details/83547164
今日推荐