├── BooleanQuery倒排表合并策略.md ├── README.md ├── integer_range_query_term_dict.md ├── lucene_6_bkd_tree.md ├── lucene倒排索引缓冲池的细节.md ├── lucene数值区间查询原理.md └── pic ├── ByteBlockPool.jpg └── ieee754.jpg /BooleanQuery倒排表合并策略.md: -------------------------------------------------------------------------------- 1 | Lucene中基本的Query类型有以下几种: 2 | ``` 3 | TermQuery,WildcardQuery,PhraseQuery,PrefixQuery,MultiPhraseQuery,FuzzyQuery,RegexpQuery,TermRangeQuery,NumericRangeQuery,ConstantScoreQuery,DisjunctionMaxQuery,MatchAllDocsQuery 4 | ``` 5 | 除此以外还有一种特殊的BooleanQuery,用户可以通过该Query将查询组装成树状结构。举例如下:
6 | +title:lucene +(+title:lucene title:luc* +title:lucene~2)
7 | 上面的查询语句表示成树的形式:
8 | ``` 9 | |__+ title:lucene(TermQuery) 10 | |__+|__+ title:lucene(TermQuery) 11 | |__ title:luc*( PrefixQuery) 12 | |__+ title:lucene~2(FuzzyQuery) 13 | ``` 14 | +表示MUST,没有前缀表示SHOULD,-表示MUST_NOT,对应到BooleanQuery中,MUST表示该Term在Document中必须出现,SHOULD表示可以出现也可以不(影响打分),MUST_NOT表示必须不出现。 15 | 16 | 对于PrefixQuery,查询之前,需要将其重写成TermQuery的组合,但是当满足Prefix的Term数量大于阀值或者满足条件的倒排文档数量大于阀值时,就不会将其重写成TermQuery的组合, 17 | 此时会使用PrefixQuery构造一个QueryFilter,使用此过滤器对Term进行过滤(term.StartWith(prefix)),同理FuzzyQuery也将被重写。
18 | 在我测试的例子中,ReWrite结束之后,查询树变成: 19 | ``` 20 | |__+ title:lucene(TermQuery) 21 | |__+|__+ title:lucene(TermQuery) 22 | |__ title:luc*( PrefixQueryFilter) 23 | |__+ | title:lucek(TermQuery) 24 | | title:lucene(TermQuery) 25 | | title:lucewe(TermQuery) 26 | ``` 27 | ReWrite从顶向下重写,可以看成是对Query树的遍历过程。
28 | 忽略打分过程,接下来就是合并倒排表了。
29 | Lucene首先自底向上将同一父亲下的子节点归类,所有MUST的放在一起,SHOULD的放在一起,MUST_NOT的放在一起,这样做的目的是为了方便合并倒排表。
30 | 对于MUST,倒排表取交集,SHOULD取并集,MUST_NOT内部取交集,之后再和其它的取差集。
31 | 这里需要注意的是取出来的doc需要保序,对于单独的Term,其倒排表本身是有序的,因此lucene实现并集用了小顶堆,交集就是一次遍历。
32 | MUST、SHOULD、MUST_NOT不同情况的组合会影响查询结果,(MUST|MUST_NOT)&SHOULD时SHOULD只会影响打分,其它的组合没什么好说的。
33 | 34 | Lucene实现倒排表没有使用bitmap,为了效率,lucene使用了一些策略,具体如下:
35 | 1. 使用FST保存词典,FST可以实现快速的Seek,这种结构在当查询可以表达成自动机时(PrefixQuery、FuzzyQuery、RegexpQuery等)效率很高。(可以理解成自动机取交集)
36 | 此种场景主要用在对Query进行rewrite的时候。
37 | 2. FST可以表达出Term倒排表所在的文件偏移。
38 | 3. 倒排表使用SkipList结构。从上面的讨论可知,求倒排表的交集、并集、差集需要各种SeekTo(docId),SkipList能对Seek进行加速。
39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # lucene 2 | - [lucene倒排索引缓冲池的细节](./lucene倒排索引缓冲池的细节.md) 3 | - [lucene数值区间查询原理](./lucene数值区间查询原理.md) 4 | - [数值区间查询的一种实现方案](./integer_range_query_term_dict.md) 5 | - [lucene 6 数值索引以及空间索引方案](./lucene_6_bkd_tree.md) 6 | - lucene词典的构造原理 7 | - lucene模糊查询以及正则查询的原理 8 | - [BooleanQuery倒排表合并策略](./BooleanQuery倒排表合并策略.md) 9 | - lucene删除索引的实现 10 | - lucene段merge的过程 11 | - lucene怎么实现Field 12 | - lucene的分词过程 13 | -------------------------------------------------------------------------------- /integer_range_query_term_dict.md: -------------------------------------------------------------------------------- 1 | # 说明 2 | 3 | 本文提出一种复用全文检索技术的数值类区间查询方案,即基于词典的方案。 4 | 5 | # 词典 6 | 7 | 词典是全文检索里面的概念,可以类比成Trie树,词典有一个功能就是将字符串映射成整型的ID,词典中字符串的大小关系和映射成的ID之间的大小关系是一致的。 8 | 9 | # 数值类型->字符串 10 | 数值类型数据可以转换成字符串类型,并且任意数值之间的大小关系和转换而得的字符串之间的大小关系是一致的,具体转换方法可以参考:[lucene数值区间查询原理](https://github.com/zzboy/lucene/blob/master/lucene%E6%95%B0%E5%80%BC%E5%8C%BA%E9%97%B4%E6%9F%A5%E8%AF%A2%E5%8E%9F%E7%90%86.md) 11 | 12 | # 数值类型->ID 13 | 将数值类型转换成字符串,使用该字符串构建词典,这样对于每个数值都能得到一个ID,并且数值之间的大小关系和ID之间的大小关系是一致的,另外ID是连续的。 14 | 15 | # 倒排表 16 | 文本索引技术中,针对分词而得的每个term和该文档的序号关联,这样当文档集处理完后,每个term都会关联一个文档集合,这个文档集合就称为倒排表。 17 | 18 | 我们将词典中的每个词的倒排表拼接起来,并记录每个ID其倒排表在拼接后的list中的偏移,这样在查询一个数值的时候,首先将数值处理成字符串,再通过词典获得ID,接着根据ID拿到偏移,再用后一个ID的偏移就能够拿到长度,这样就很容易读出来该数值关联的倒排表。 19 | # 优化 20 | 1. ID其实是一个帮助理解的概念,实际上可以在词典中直接存储词在doclist中的偏移。 21 | 2. 将doc list分块存储,比如4096个doc一个block,这样在数值区间查询的时候,就可以只读关联的doc block,而不需要整个读出doc list,同样是一次读盘,数据量可能会小很多。 22 | # 总结 23 | 1.优点 24 | 25 | 复用全文索引的词典技术,只是改动了倒排表的存储方式,如果已经有词典功能,开发代价很小,上线很快。 26 | 27 | 2.缺点 28 | * 数值要转换成文本,构建词典时要字符串比较,这样对写入性能会有影响。 29 | * 词典必须实现很复杂,否则需要将词典整个加载到内存才能查询,这样load了大量无关数据,没有必要。 30 | 31 | 目前调研下来Block K-D tree是更好的方案。 32 | -------------------------------------------------------------------------------- /lucene_6_bkd_tree.md: -------------------------------------------------------------------------------- 1 | ## 要解决的问题 2 | * 范围查询 3 | 4 | 在一个二维平面上,有很多点,给定一个矩形,怎么快速的将落在矩形中的点找出来? 5 | 这个问题还可以推广到任意维度,一维就是区间查询,三维就是在长方体内部。 6 | * 近邻查询 7 | 8 | 离我最近的餐馆有哪些? 9 | 这个问题可以抽象成二维空间中,要找出距离某个点最近的点的集合。 10 | 11 | ## 一维的场景 12 | 13 | 这个场景非常简单,只需要将数值类型数据做个全排序,不管是范围查询还是近邻查询只需要进行二分搜索就好了。 14 | 15 | 如果数据集特别大,内存中放不下,上面的方案就会有问题,因为所有数据没有办法全部load到内存中。 16 | 17 | 这个时候可以在内存中维护一份索引,索引的构建过程如下: 18 | ``` 19 | step 1: 对数据进行外排序。 20 | step 2: 构建二叉搜索树,选取step 1中数据中位数作为根节点的值,并在在根节点中记录下中位数的位置。 21 | step 3:递归构造根节点左子树和右子树,左子树的根节点依据step 2中中位数左侧的数据集构造, 22 | 右子树依据step 2中中位数右侧的数据集构造。 23 | step 4: 持续上述递归构造过程直到节点关联的数据数集数量小于某个阈值。 24 | ``` 25 | 上述索引构造完成之后,查询过程中首先将索引load到内存中,先在索引中查询,最后再将命中的数据块load到内存中进行查询。 26 | ### 范围查询 27 | ``` 28 | step 1: 如果根节点和query range相交,则依据根节点将query range划分成左右两个query range, 29 | 使用left query range在左子树中查询,right query range在右子树中查询。如果不相交,则根据范围在左或者右子树中查询。 30 | step 2: 命中的数据block中,需要对起点和终点block进行精确的过滤,中间的block结果肯定都满足查询范围。 31 | ``` 32 | 33 | ### k 近邻查询 34 | ``` 35 | step 1: 比较查询值和根节点的大小,如果大于,则递归查找右子树,否则左子树。 36 | step 2: 对递归路径维护一个k个数据的小根堆,使用路径命中的数据block中距离查询值最近的k个值初始化小根堆,比较函数是距离。 37 | step 3: 在当前的路径节点中,如果索引节点表示的超平面距离查询点比堆中最大值近,则将超平面另一侧的数据加入到小根堆中。 38 | step 4: 沿着递归路径向上回溯,直到将根节点处理完。 39 | ``` 40 | 此时小根堆中的k个数据就是查询数值的k近邻。 41 | 说明: 42 | * 如果查询值和索引值相等,则左右子树中任选一边进行递归查找就可以了,不需要在两边都进行递归。 43 | 44 | ## 高维场景 45 | 有了一维场景,非常容易推广到高维的场景。 46 | ### 构建过程 47 | ``` 48 | step 1: 选择一个维度,将数据按照该维度排序,数据量大的话使用外排序。 49 | step 2: 选取中位数作为根节点的split value,除此以外,根节点需要记录下是哪一个维度的split value。 50 | step 3: 前面两步将数据集划分成两部分,分别对两部分重复step 1、step 2,构造根节点的左右子树。 51 | step 4: 持续上述递归构造过程直到节点关联的数据数集数量小于某个阈值。 52 | ``` 53 | 这里的关键就是怎么选择在哪一个维度上将数据进行划分,这个和数据在该维度上的分布关系很大,遵循的原则是在这一步骤中,该维度的数据分布最散。 54 | ### 范围查询 55 | 和一维的区别在于每一次划分query range要在相应的维度划分,其他没有任何变化。 56 | ### k近邻查询 57 | 和一维的区别在于回溯的过程中,查询点到超平面距离计算要相应的变化,其他没有区别。 58 | 59 | ## merge过程中的优化 60 | 61 | 两份索引merge,对于一维的数据,可以使用归并排序,高维场景则需要当成一份数据集来处理,目前没有什么好的方法来优化高维数据merge的速度。 62 | 63 | ## 总结 64 | 上面的方案就是lucene 6对数值类数据(高维)范围查询和近邻查询的优化方案(bkd tree),相比之前的前缀编码的方案,存储空间大约能节省50%,查询速度提高2倍。 65 | 具体实验结果可以参考下面elastic的blog。 66 | 67 | ## 参考资料 68 | * [kd tree](https://en.wikipedia.org/wiki/K-d_tree) 69 | * [KDB tree](https://en.wikipedia.org/wiki/K-D-B-tree) 70 | * [bkd tree](https://users.cs.duke.edu/~pankaj/publications/papers/bkd-sstd.pdf) 71 | * [es blog](https://www.elastic.co/blog/lucene-points-6.0) 72 | * [lucene issue tracking](https://issues.apache.org/jira/browse/LUCENE-6477) 73 | -------------------------------------------------------------------------------- /lucene倒排索引缓冲池的细节.md: -------------------------------------------------------------------------------- 1 | ## 倒排索引要存哪些信息 2 |   提到倒排索引,第一感觉是词到文档列表的映射,实际上,倒排索引需要存储的信息不止词和文档列表。为了使用余弦相似度计算搜索词和文档的相似度,需要计算文档中每个词的[TF-IDF](https://en.wikipedia.org/wiki/Tf%E2%80%93idf)值,这样就需要记录词在每个文档中出现的频率以及包含这个词的文档数量,前者需要对应每个文档记录一个值,后者就是倒排表长度。除此以外,为了能够高亮搜索结果,需要记录每个词在文档中的偏移信息(起始位置和长度),为了支持短语查询,需要记录每个词的position信息,注意position和offset不是一个概念,position是文档分词之后得到的term序列中词的位置,offset是分词之前的偏移,如果文档中一个词被分成多个Term,那么这些Term将共享同一个position,典型场景是同义词,这在自然语言处理中很有用。如果用户希望在Term级别干预查询打分结果,那么就需要对文档中的每个词存储额外的信息(payload)。 3 | 4 |   综上,倒排索引需要存储的信息主要有以下几方面: 5 | - 词(Term) 6 | - 倒排文档列表(DocIDList) 7 | - 词频(TermFreq) 8 | - Position 9 | - Offset 10 | - Payload 11 | 12 |   有几点需要特别说明,lucene中Term是对每个Field而言的,也就是说在Document不同Field中出现的相同字面的词也算不同的Term。搞清楚了这一点,就很容易理解TermFreq、Position、Offset、Payload都是在一个Document中Field下的统计量。另外,同一个Term在同一个Document的同一个Field中,Position、Offset、Payload可能会出现多次,次数由TermFreq决定。 13 | 14 | **lucene中倒排索引的逻辑结构如下:** 15 | ``` 16 | |+ field1(name,type) 17 | |+ term1 18 | |+ doc1 19 | |+ termFreq = 2 20 | |+ [position1,offset1,payload1] 21 | |+ [position2,offset2,payload2] 22 | |+ doc2 23 | |+ termFreq = 1 24 | |+ [position3,offset3,payload3] 25 | |+... 26 | |+ term2 27 | |+... 28 | |+ field2(name,type) 29 | |+ ... 30 | ``` 31 | ## lucene建索引的过程 32 |   上面倒排索引的逻辑结构贯穿lucene的始终,lucene建索引的不同阶段对应逻辑结构的某种具体实现。 33 | 34 |   分词阶段所有倒排信息被缓存在内存中,随着缓存的数据越来越多,刷新逻辑会被触发(FlushPolicy),内存中缓存的数据会被写入外存,这部分被写入外存的数据称为段(Segment),段是倒排索引逻辑结构的另外一种表示方式,lucene使用有限状态转移自动机(FST)来表示,这一块后面会有文章深入讨论,本篇文章主要讨论内存中缓存对倒排索引逻辑结构的实现方式。再多说几句为什么lucene会使用段这一概念。首先,lucene建索引是线程安全的,线程安全的实现方式很高效,一般的实现线程安全的方式是对临界变量加锁,lucene没有采用这种方式。用一个比喻来形容lucene的方式,假如对临界变量加锁的方式是多个人在一个工作间里工作,共享这个工作间里的工具,lucene就是给每个人一个工作间,大家工作时互不干扰。这样每个人的工作成果就可以同时输出,效率自然就高了很多,这里的工作成果便是段。其次,机器的内存资源是有限的,不可能把所有数据都缓存在内存中。最后,从查询的角度看,将查询条件分发给多个段同时查询最后再merge各个段的查询结果的方式比单一段查询效率要高。当然,事物总是矛盾的,有利必有弊,使用段的方式给索引管理带来了很大的难度,首当其冲便是索引的删除,用户下发删除Query,这些Query要应用到所有已经存在的段上,如果同时应用删除操作,磁盘IO必将受不了,lucene选择的删除时机是在段合并的时候,在这之前,删除Query会被缓存起来,这又带来另一个问题,如果每个段要维护自己的删除Query内存必然受不了,怎么让所有段共享删除Query。使用段带来的另一个复杂度便是段的合并。在多少个段上同时查询效率最高是一个需要权衡的问题,段太多太少都会导致查询很慢,因此段合并策略很重要。上面提到的这些都会在接下来的文章中深入讨论。 35 | 36 |   回到本篇文章的主题,内存缓存是怎么实现上面的倒排索引的逻辑结构的。 37 | 38 | ## 缓存实现倒排索引逻辑结构的方式 39 |   首先来看下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一起写到缓存。 40 | 41 |   lucene里面实现缓存的最基础的组件是``org.apache.lucene.util.ByteBlockPool``,lucene的缓存块都是基于这个类构建的,这个类的官方解释如下: 42 | 43 | >   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"). 44 | 45 | >   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. 46 | 47 |   具体实现请直接参考源码,实现很简单,类的功能是可以将字节数据写入,但是不要求写入的逻辑上是一个整体的数据在物理上也是连续的,将逻辑上的数据块读出来只需要提供两个指针就好了,一个是逻辑块的物理起始位置,一个是逻辑块的物理结束位置,注意逻辑块的长度可能是小于两个结束位置之差的。ByteBlockPool要解决的问题可以联系实际的场景来体会下,不同Term的倒排信息是缓存在一个ByteBlockPool中的,不同Term的倒排信息在时序上是交叉写入的,Term到达的顺序可能是``term1,term2,term1``,并且每个Term倒排信息的多少是无法事先知道的。 48 | 49 |   ByteBlockPool解决的另一类问题是时序上顺序的数据,比如Term,虽然整体上看Term到达的顺序可能是``term1,term2,term1``这样交叉的情况,但是Term数据有的一个特点是只会被写到缓存块中一次。 50 | 51 |   上面提到的ByteBlockPool解决的两类问题对应两类缓存块,[DocIDList,TermFreq,Position,Offset,Payload]缓存块和Term缓存块,前一个缓存块存放的是除了Term字面量外余下的数据。完整的倒排信息不止这两个缓存块,怎么将两个缓存块联接起来构建成倒排索引,还需要有其他的数据。 52 | 53 |   下面是别人画的一张图,lucene3.0的实现,很老了,但是整体上思路没有变。 54 | 55 | ![ByteBlockPool](./pic/ByteBlockPool.jpg) 56 | 57 |   左侧是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中的偏移。 58 | 59 |   上图中左侧除了上面讨论的外,可以看到还有lastDocID、lastPosition等,这些都是为了做差值编码,节约存储。这里还有个问题需要说明下,新到达的Term,怎么判断其是新的还是已经存在了。针对这个问题lucene对Term做了一层Hash,对应的类是``org.apache.lucene.util.BytesRefHash``,功能解释如下: 60 | >   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. 61 | 62 |   综上所述,lucene对缓存的实现可谓煞费苦心,之所以做的这么复杂我觉得有以下几点考虑: 63 | - 节约内存。比如用那个三个指针记录两个缓存块的偏移、Slice长度的分配策略、差值编码等。 64 | - 方便对内存资源进行控制。几乎所有数据都是通过BlockPool来管理的,这样的好处是内存使用情况非常容易统计,FlushPolicy很容易获取当前的内存使用情况,从而触发刷新逻辑。 65 | - 缓存不必针对每个Field,也就是说同一个Segment所有Field的数据可以放在一块缓存中,每个Field有自己的PostingList,所有Field的Term字面量共享一个缓存以及上层的Hash,这样便能很大程度上节约存储空间。对应一个具体的Field,判断Term是否存在首先判断在Term缓存块中是否存在,接着判断PostingList中是否有入口。 66 | 67 | 下一篇将介绍上面的逻辑倒排结果在段中是怎么表示的,也就是缓存怎么刷到外存上的。 68 | -------------------------------------------------------------------------------- /lucene数值区间查询原理.md: -------------------------------------------------------------------------------- 1 | 本文讨论lucene数值类查询的原理,将分三部分阐述,分别是数值转换成可比较的字符串、数值分词、数值区间查询。为了便于大家理解,有些地方没有完全按照lucene的方式说明,但是和lucene是等价的。 2 | ## 数值转换成可比较的字符串 3 | Lucene主要用于全文检索,其设计都是围绕文本进行的,Term的编码方式是UTF8字符串(byte数组),对于数值数据,lucene索引的思路是将数值型转换成可比较的字符串。 4 | 假设有任意两个浮点数x和y,分别转换成字符串a和b,lucene要求String.Compare(a,b) == Double.Compare(x,y)。 5 | Lucene的做法如下:
6 | **Step 1:** 将浮点数转换成IEEE754格式。
7 | ![IEEE754](./pic/ieee754.jpg) 8 | **Step 2:** 如果S==0,将S翻转。如果S==1,将整体翻转。
9 | **Step 3:** 从左向右每7bit看作一个ASCII码,尾部不够低位补0,构造字符串。
10 | UTF8编码在当字符只需要一个byte时,最高位是0,其余是数据,此时等价于ASCII码。 11 | 经过上述步骤构造的字符串满足String.Compare(a,b) == Double.Compare(x,y)。 12 | ## 数值分词 13 | 之前一次分享中提到lucene加快数值类区间查询速度的方法是构建后缀树。这里需要纠正下,lucene并不会真正的构建后缀树索引,而是利用后缀树索引的思想进行数值分词以及切分查询区间。
14 | 以32bit数据data = 0x12334AEF为例子讲述分词过程(此数由32位浮点数转换而成)。
15 | 分词之后的Term是UTF8编码的,使用多个byte表示,第一个byte是标志位,用以表示数据类型以及数据偏移,假设此数是Float类型,类型标志0x20。分词就是将数据不断向右移位(多位)构造Term的过程,这样数据偏移的概念就很好理解了,每次移位都是数据偏移的整数倍。由于转成ASCII前后并不会影响数据的可比较性,接下来的讨论将省去构造ASCII码的过程。假设数据偏移为8。分词过程如下:
16 | **Step 1:** 数据偏移shift =8,迭代步骤i=0;
17 | **Step 2:** 数据右移i\*shift位,构造第一个byte[0]=0x20|(i\*shift),将移位之后剩下的数据顺序填充到byte数组中,一个Term就构造好了。
18 | **Step 3:** ++i,如果i==sizeof(data)/shift停止,否则重复Step 2.
19 | 数据data = 0x12334AEF经过上述分词之后会得到4个Term如下:
20 | 0x2012334AEF
21 | 0x2812334A
22 | 0x301233
23 | 0x3812
24 | 数据偏移越小,Term越多,lucene索引越大。不同数据类型其标志位是不一样的,标志位的选择要考虑到最多可以右移的位数。
25 | Lucene中每一个Term后面都是一个文档列表,这就是倒排索引,分词之后当前文档会被加到上述四个Term的文档列表中。
26 | 通过一个例子来看下lucene为什么要这样做:
27 | 假设要查询Term范围是[0x2012334A00, 0x2012334AFF]的所有文档,一般的做法是遍历范围内的所有Term,合并每一个Term的倒排表,效率很低,lucene的做法是将其看作一个Term 0x2812334A,直接返回其倒排表。当然如果上述范围内缺少任意一个或多个Term就不可以合并。
28 | 再看一个例子,查询范围是[0x2012330000, 0x281233FF],lucene将其合并成一个Term 0x301233。过程是首先将区间分裂成[0x2012330000, 0x28123300FF],[0x28123301, 0x281233FF],合并前一个区间为0x20123300,加到后面区间上得到[0x28123300, 0x281233FF],再合并得到0x301233。
29 | ## 数值区间查询 30 | 从上面最后的讨论可以了解到,lucene在做数值范围查询时会将查询区间进行切分和合并。切分和合并的过程比较朴素的步骤如下:
31 | **Step 1:** 迭代查询区间内的所有数,对每一个数进行分词,构造链结构。
32 | 0x12334AEF构造的链结构如下:
33 | 0x3812->0x301233->0x2812334A->0x2012334AEF
34 | **Step 2:** 对所有的链构造前缀树。
35 | **Step 3:** 假如某个节点其孩子包含了最小值到最大值之间的所有数,那么就说该节点是完全的。从底向上,删掉后缀树中完全节点的孩子节点。
36 | **Step 4:** 对树中余下的叶子节点构造Query。属于同一个父亲的叶子节点构造RangeQuery,单独的叶子构造TermQuery,将所有Query插入链表,按照Term对链表中Query排序。
37 | Lucene的实现比上面的方法高效的多,其并不会构建后缀树。代码如下:
38 | ```java 39 | private static void splitRange( 40 | final Object builder, final int valSize, 41 | final int precisionStep, long minBound, long maxBound 42 | ) { 43 | if (precisionStep < 1) 44 | throw new IllegalArgumentException("precisionStep must be >=1"); 45 | if (minBound > maxBound) return; 46 | for (int shift=0; ; shift += precisionStep) { 47 | // calculate new bounds for inner precision 48 | final long diff = 1L << (shift+precisionStep), 49 | mask = ((1L< maxBound; 59 | 60 | if (shift+precisionStep>=valSize || nextMinBound>nextMaxBound || lowerWrapped || upperWrapped) { 61 | // We are in the lowest precision or the next precision is not available. 62 | addRange(builder, valSize, minBound, maxBound, shift); 63 | // exit the split recursion loop 64 | break; 65 | } 66 | 67 | if (hasLower) 68 | addRange(builder, valSize, minBound, minBound | mask, shift); 69 | if (hasUpper) 70 | addRange(builder, valSize, maxBound & ~mask, maxBound, shift); 71 | 72 | // recurse to next precision 73 | minBound = nextMinBound; 74 | maxBound = nextMaxBound; 75 | } 76 | } 77 | ``` 78 | Lucene索引中Term是有序的,切分好的Query也是有序的,除此之外,lucene4.x为了加快Term的查询速度,采用FST(有限状态自动机)对Term做前缀标记,FST可以指出以abc为前缀的Term块起始位置,在块内lucene4.x又使用跳跃表进行查询加速,所以lucene seek到指定Term的速度是很快的。需要seek的Term数量仅仅是Query中Distinct的Term数量。最坏情况下查询复杂度就是Query中Distinct Term的数量。
79 | 迭代N bit数的取值范围,假设偏移是k,对每一个数进行分词,构建后缀树,这样的后缀树称为完全后缀树,最坏情况下的查询区间由最底层的第二个叶子和倒数第二个叶子构成。此时需要Seek的Term数量为:
80 | ``` 81 | (2^k-1)×2×(N÷k-1)+2^k-2 82 | ``` 83 | ## 附录 84 | - [作者主页](http://www.thetaphi.de/) 85 | - [lucene邮件组中TrieRangeQuery的commit申请](http://www.gossamer-threads.com/lists/lucene/java-dev/67807) 86 | - [作者介绍这个算法的ppt](https://www.google.com.hk/url?sa=t&rct=j&q=&esrc=s&source=web&cd=1&cad=rja&uact=8&ved=0CB8QFjAAahUKEwi07fH7r4THAhXILpQKHc_PCPQ&url=%68%74%74%70%3a%2f%2f%77%77%77%2e%74%68%65%74%61%70%68%69%2e%64%65%2f%73%68%61%72%65%2f%53%63%68%69%6e%64%6c%65%72%2d%54%72%69%65%52%61%6e%67%65%2e%70%70%74&ei=ZOS6VbSrFsjd0ATPn6OgDw&usg=AFQjCNHDWZaW472jl9Pn4epskF52ccuf3w) 87 | -------------------------------------------------------------------------------- /pic/ByteBlockPool.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzboy/lucene/05f673781969b1b248476048575acb93cd9d4895/pic/ByteBlockPool.jpg -------------------------------------------------------------------------------- /pic/ieee754.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zzboy/lucene/05f673781969b1b248476048575acb93cd9d4895/pic/ieee754.jpg --------------------------------------------------------------------------------