倒排索引要存哪些信息

  提到倒排索引,第一感觉是词到文档列表的映射,实际上,倒排索引需要存储的信息不止词和文档列表。为了使用余弦相似度计算搜索词和文档的相似度,需要计算文档中每个词的TF-IDF值,这样就需要记录词在每个文档中出现的频率以及包含这个词的文档数量,前者需要对应每个文档记录一个值,后者就是倒排表长度。除此以外,为了能够高亮搜索结果,需要记录每个词在文档中的偏移信息(起始位置和长度),为了支持短语查询,需要记录每个词的position信息,注意position和offset不是一个概念,position是文档分词之后得到的term序列中词的位置,offset是分词之前的偏移,如果文档中一个词被分成多个Term,那么这些Term将共享同一个position,典型场景是同义词,这在自然语言处理中很有用。如果用户希望在Term级别干预查询打分结果,那么就需要对文档中的每个词存储额外的信息(payload)。

  综上,倒排索引需要存储的信息主要有以下几方面:

  • 词(Term)
  • 倒排文档列表(DocIDList)
  • 词频(TermFreq)
  • Position
  • Offset
  • Payload

  有几点需要特别说明,lucene中Term是对每个Field而言的,也就是说在Document不同Field中出现的相同字面的词也算不同的Term。搞清楚了这一点,就很容易理解TermFreq、Position、Offset、Payload都是在一个Document中Field下的统计量。另外,同一个Term在同一个Document的同一个Field中,Position、Offset、Payload可能会出现多次,次数由TermFreq决定。

lucene中倒排索引的逻辑结构如下:

|+ field1(name,type)
|+ term1
|+ doc1
|+ termFreq = 2
|+ [position1,offset1,payload1]
|+ [position2,offset2,payload2]
|+ doc2
|+ termFreq = 1
|+ [position3,offset3,payload3]
|+...
|+ term2
|+...
|+ field2(name,type)
|+ ...

lucene建索引的过程

  上面倒排索引的逻辑结构贯穿lucene的始终,lucene建索引的不同阶段对应逻辑结构的某种具体实现。

  分词阶段所有倒排信息被缓存在内存中,随着缓存的数据越来越多,刷新逻辑会被触发(FlushPolicy),内存中缓存的数据会被写入外存,这部分被写入外存的数据称为段(Segment),段是倒排索引逻辑结构的另外一种表示方式,lucene使用有限状态转移自动机(FST)来表示,这一块后面会有文章深入讨论,本篇文章主要讨论内存中缓存对倒排索引逻辑结构的实现方式。再多说几句为什么lucene会使用段这一概念。首先,lucene建索引是线程安全的,线程安全的实现方式很高效,一般的实现线程安全的方式是对临界变量加锁,lucene没有采用这种方式。用一个比喻来形容lucene的方式,假如对临界变量加锁的方式是多个人在一个工作间里工作,共享这个工作间里的工具,lucene就是给每个人一个工作间,大家工作时互不干扰。这样每个人的工作成果就可以同时输出,效率自然就高了很多,这里的工作成果便是段。其次,机器的内存资源是有限的,不可能把所有数据都缓存在内存中。最后,从查询的角度看,将查询条件分发给多个段同时查询最后再merge各个段的查询结果的方式比单一段查询效率要高。当然,事物总是矛盾的,有利必有弊,使用段的方式给索引管理带来了很大的难度,首当其冲便是索引的删除,用户下发删除Query,这些Query要应用到所有已经存在的段上,如果同时应用删除操作,磁盘IO必将受不了,lucene选择的删除时机是在段合并的时候,在这之前,删除Query会被缓存起来,这又带来另一个问题,如果每个段要维护自己的删除Query内存必然受不了,怎么让所有段共享删除Query。使用段带来的另一个复杂度便是段的合并。在多少个段上同时查询效率最高是一个需要权衡的问题,段太多太少都会导致查询很慢,因此段合并策略很重要。上面提到的这些都会在接下来的文章中深入讨论。

  回到本篇文章的主题,内存缓存是怎么实现上面的倒排索引的逻辑结构的。

缓存实现倒排索引逻辑结构的方式

  首先来看下Term、DocIdList、TermFreq、Position、Offset、Payload产生的时间点。Term刚开始分词的时候就有,根据上面的讨论,这个时候Position、Offset、Payload也都产生了,也就是说分词结束,Term、Position、Offset、Payload就都可以写到缓存里面,TermFreq这个时候还没有得到最终的值,主要有两点原因,第一,这里讨论的缓存都是针对Field而言的,如果一个Document里面包含多个相同的Field,这些相同的Field显然要被写到同一个缓存里面,同一个Term在这些Field里可能会出现多次,每次出现TermFreq就要加1。第二,一个Field中Term也可能会出现多次。基于以上两点,只有当遇到下一个Document的时候前一个Document的各个TermFreq的值才能够固定,这个时候就可以将TermFreq和DocId一起写到缓存。

  lucene里面实现缓存的最基础的组件是org.apache.lucene.util.ByteBlockPool,lucene的缓存块都是基于这个类构建的,这个类的官方解释如下:

  The idea is to allocate slices of increasing lengths For example, the first slice is 5 bytes, the next slice is 14, etc. We start by writing our bytes into the first 5 bytes. When we hit the end of the slice, we allocate the next slice and then write the address of the new slice into the last 4 bytes of the previous slice (the "forwarding address").

  Each slice is filled with 0's initially, and we mark the end with a non-zero byte. This way the methods that are writing into the slice don't need to record its length and instead allocate a new slice once they hit a non-zero byte.

  具体实现请直接参考源码,实现很简单,类的功能是可以将字节数据写入,但是不要求写入的逻辑上是一个整体的数据在物理上也是连续的,将逻辑上的数据块读出来只需要提供两个指针就好了,一个是逻辑块的物理起始位置,一个是逻辑块的物理结束位置,注意逻辑块的长度可能是小于两个结束位置之差的。ByteBlockPool要解决的问题可以联系实际的场景来体会下,不同Term的倒排信息是缓存在一个ByteBlockPool中的,不同Term的倒排信息在时序上是交叉写入的,Term到达的顺序可能是term1,term2,term1,并且每个Term倒排信息的多少是无法事先知道的。

  ByteBlockPool解决的另一类问题是时序上顺序的数据,比如Term,虽然整体上看Term到达的顺序可能是term1,term2,term1这样交叉的情况,但是Term数据有的一个特点是只会被写到缓存块中一次。

  上面提到的ByteBlockPool解决的两类问题对应两类缓存块,[DocIDList,TermFreq,Position,Offset,Payload]缓存块和Term缓存块,前一个缓存块存放的是除了Term字面量外余下的数据。完整的倒排信息不止这两个缓存块,怎么将两个缓存块联接起来构建成倒排索引,还需要有其他的数据。

  下面是别人画的一张图,lucene3.0的实现,很老了,但是整体上思路没有变。

  左侧是PostingList,每个Term都有一个入口,其中byteStart字段存放的是之前提到的这个Term对应的倒排信息[DocIDList,TermFreq,Position,Offset,Payload]在缓存块中的物理偏移,textStart用于记录该Term在Term缓存块中的偏移,lucene5.2.1中,Term缓存块没有用CharBlockPool,而是用ByteBlockPool。上文提到DocID、TermFreq的写入时机和Position、Offset、Payload是不一样的,因此lucene在实际实现中并没有将[DocIDList,TermFreq,Position,Offset,Payload]当成一个数据块,而是分成了两个数据块[DocIDList,TermFreq]和[Position,Offset,Payload],这样为了获得这两块数据就需要记录四个偏移地址,但lucene并没有这样做。byteStart记录[DocIDList,TermFreq]的起始偏移地址,[Position,Offset,Payload]的偏移地址可以通过byteStart计算出来,要理解这一点需要理解ByteBlockPool是怎么实现逻辑上连续的数据物理上离散存储的,这一块不打算展开,感兴趣的读者请直接看ByteBloackPool的源码,类似于链表的结构,链表的Node对应这里叫做Slice,Slice的末尾会存放Next Slice的地址。其实之前提到的两个物理偏移就对应头Slice的起始位置和尾Slice的结束位置。数据块[DocIDList,TermFreq]和[Position,Offset,Payload]的头Slice是相邻的,所有头Slice的大小都是相同的,因此[Position,Offset,Payload]的开始位置很容易从byteStart推算出。这样只需要记录三个偏移地址就够了,byteStart、[DocIDList,TermFreq]块的结束位置、[Position,Offset,Payload]块的结束位置。后两个信息lucene将其为维护在一个IntBlockPool中,IntBlockPool和ByteBlockPool的区别仅仅是存储的数据类型不同,PostingList中记录下这两个偏移在IntBlockPool中的偏移。

  上图中左侧除了上面讨论的外,可以看到还有lastDocID、lastPosition等,这些都是为了做差值编码,节约存储。这里还有个问题需要说明下,新到达的Term,怎么判断其是新的还是已经存在了。针对这个问题lucene对Term做了一层Hash,对应的类是org.apache.lucene.util.BytesRefHash,功能解释如下:

  BytesRefHash is a special purpose hash-map like data-structure optimized for BytesRef instances. BytesRefHash maintains mappings of byte arrays to ids. storing the hashed bytes efficiently in continuous storage. The mapping to the id is encapsulated inside BytesRefHash and is guaranteed to be increased for each added BytesRef.

  综上所述,lucene对缓存的实现可谓煞费苦心,之所以做的这么复杂我觉得有以下几点考虑:

  • 节约内存。比如用那个三个指针记录两个缓存块的偏移、Slice长度的分配策略、差值编码等。
  • 方便对内存资源进行控制。几乎所有数据都是通过BlockPool来管理的,这样的好处是内存使用情况非常容易统计,FlushPolicy很容易获取当前的内存使用情况,从而触发刷新逻辑。
  • 缓存不必针对每个Field,也就是说同一个Segment所有Field的数据可以放在一块缓存中,每个Field有自己的PostingList,所有Field的Term字面量共享一个缓存以及上层的Hash,这样便能很大程度上节约存储空间。对应一个具体的Field,判断Term是否存在首先判断在Term缓存块中是否存在,接着判断PostingList中是否有入口。

下一篇将介绍上面的逻辑倒排结果在段中是怎么表示的,也就是缓存怎么刷到外存上的。

微信公众号:

lucene倒排索引缓冲池的细节的更多相关文章

  1. Elasticsearch压缩索引——lucene倒排索引本质是列存储+使用嵌套文档可以大幅度提高压缩率

    注意:由于是重复数据,词法不具有通用性!文章价值不大! 摘自:https://segmentfault.com/a/1190000002695169 Doc Values 会压缩存储重复的内容. 给定 ...

  2. lucene倒排索引瘦身的一些实验——merge的本质是减少cfx文件 变为pos和doc;存储term vector多了tvx和tvd文件有337M

    store NO 压缩后的原始数据 原始数据大小 索引大小 索引时间 单词搜索时间 266 791 594 176 0.2 文件组成见后 运行forceMerge(3)后 merge的本质是减少cfx ...

  3. Lucene倒排索引结构及关系

  4. Lucene 查询原理 传统二级索引方案 倒排链合并 倒排索引 跳表 位图

    提问: 1.倒排索引与传统数据库的索引相比优势? 2.在lucene中如果想做范围查找,根据上面的FST模型可以看出来,需要遍历FST找到包含这个range的一个点然后进入对应的倒排链,然后进行求并集 ...

  5. Lucene 工作原理 之倒排索引

      1.简介 倒排索引源于实际应用中需要根据属性的值来查找记录.这种索引表中的每一项都包括一个属性值和具有该属性值的各记录的地址.由于不是由记录来确定属性值,而是由属性值来确定记录的位置,因而称为倒排 ...

  6. 【转】Lucene工作原理——反向索引

    原文链接:  http://my.oschina.net/wangfree/blog/77045 倒排索引 倒排索引(反向索引) 倒排索引源于实际应用中需要根据属性的值来查找记录.这种索引表中的每一项 ...

  7. Lucene4.6 把时间信息写入倒排索引的Offset偏移量中,并实现按时间位置查询

    有个新的技术需求,需要对Lucene4.x的源码进行扩展,把如下的有时间位置的文本写入倒排索引,为此,我扩展了一个TimeTokenizer分词器,在这个分词器里将时间信息写入 偏移量Offset中. ...

  8. Solr基础理论【倒排索引,模糊查询】

    一.简介 现有的许多不同类型 的技术系统,如关系型数据库.键值存储.操作磁盘文件的map-reduce[映射-规约]引擎.图数据库等,都是为了帮助用户解决颇具挑战性的数据存储与检索问题而设计的.而搜索 ...

  9. 【原创】Thinking in BigData (1)大数据简介

    提到大数据,就不得不提到Hadoop,提到Hadoop,就不得不提到Google公布的3篇研究论文:GFS.MapReduce.BigTable,Google确实是一家伟大的公司,开启了全球的大数据时 ...

随机推荐

  1. Java线程:线程中断

    interrupt方法可以用来请求终止线程. 当对一个线程调用interrupt方法时,线程的中断状态被置位.这时每个线程都有boolean标志.每个线程都应该不时的检查这个标志,以判断线程是否被中断 ...

  2. 在DataTable数据类型最后增加一列,列名为“Column”,内容都为“AAA”

    DataTable dt = new DataTable(); dt.Columns.Add("Column", typeof(string)); foreach (DataRow ...

  3. JSP 初始化参数

    JSP 初始化参数: tomcat启动的时候就会执行那个函数: xml: <?xml version="1.0" encoding="UTF-8"?> ...

  4. Windows下Python读取GRIB数据

    之前写了一篇<基于Python的GRIB数据可视化>的文章,好多博友在评论里问我Windows系统下如何读取GRIB数据,在这里我做一下说明. 一.在Windows下Python为什么无法 ...

  5. 通过Spring Data Neo4J操作您的图形数据库

    在前面的一篇文章<图形数据库Neo4J简介>中,我们已经对其内部所使用的各种机制进行了简单地介绍.而在我们尝试对Neo4J进行大版本升级时,我发现网络上并没有任何成型的样例代码以及简介,而 ...

  6. KB奇遇记(9):艰难的上线

    经历了非常多的磨难,系统也“如约“在2017年01月01日勉强上线了.尽管我认为它还不到上线的程度,条件不具备,但上头的指令下来和计划便是在这一天.整个上线过程从2016年3月8号开始到上线日,扣除中 ...

  7. IIS 之 在IIS7、IIS7.5中应用程序池最优配置方案

    找到Web站点对应的应用程序池,"应用程序池" → 找到对应的"应用程序池" → 右键"高级设置..." 一.一般优化方案 1.基本设置 [ ...

  8. 开发mis系统的技术

    一.b/s架构 b/s架构:就broser/server,浏览器/服务器的说法.服务器端要运行tomcat,提供链接数据库服务供java代码读写数据,这个可以在eclipse中配置运行.浏览器则解释j ...

  9. 覆写hashCode equal方法

    1.为什么要重写hashCode方法? 当自己要新建一个class,并要把这个类放到HashMap的时候,需要覆写这两个办法.如果不覆写,放入两个新的对象,可能会是不相等的. 在java的集合中,判断 ...

  10. Ext Js详解指南

    什么是Ext JS 走进Ext的世界 Ext JS是一款富客户端开发框架它基于javascript.HTML和CSS开发而成,无需安装任何插件即可在常用浏览器中创建出绚丽的页面效果. 个人总结Ext ...