Lucene的数字范围搜索 (Numeric Range Query)原理

0. 全文索引的核心就是倒排索引.

 

 

1. 若数字不支持范围查询直接变成字符串查找即可

 

 

2. 如果要支持范围查询直接的字符串存储支持么?

 

   目前lucene要求term按照字典序(lexicographic sortable)排列,然后它的范围查询根据tii找到范围的起始Term,然后把这中间的所有的Term展开成一个BooleanQuery

   因此若按照现有的方式如果直接保存16,24,3,46, 当搜索[24,46]的时候会同时将3也搜索出来这是有问题的.

 

   为了解决这个问题,初步想到的方案有:

   (1) 数字能够补齐成固定的位数例如上面这个例子固定是两位补齐后的结果是:

03,16,24,46, 当搜索[24,46]的时候就正确了.

   (2) 建索引的时候, term的排序按照数字来排序上面的例子顺序是3,16,24,46, 搜索的时候也会正确.

 

   上面的方案有的问题是:

   (1) 方案1固定多少位是个问题固定补齐的位数太多浪费空间太少则存储的值的范围有限

   (2) 方案1和方案2都存在的问题展开成所有的TermBooleanOrquery有一个问题,那就是如果范围太大,那么可能包含非常多的Boolean Clause,较早的版本可能会抛出Too Many Boolean ClauseException。后来的版本做了改进,不展开所以的term,而是直接合并这些term的倒排表。这样的缺点是合并后的term的算分成了问题,比如tf,你是把所有的termtf加起来算一个termidf呢,coord呢?(luceneScoring也会在后面讲到,可以参考http://lucene.apache.org/core/old_versioned_docs/versions/3_5_0/api/core/org/apache/lucene/search/Similarity.htmlhttp://lucene.apache.org/core/old_versioned_docs/versions/3_5_0/scoring.html

   即使我们可以合并成一个term,合并这些termdocIds也是很费时间的,因为这些信息都在磁盘上。

 

 

3. lucene数字范围搜索的解决方案

 

   首先可以把数值转换成一个字符串,并且保持顺序。也就是说如果 number1 < number2 ,那么transform(number) < transform(number)transform就是把数值转成字符串的函数,如果拿数学术语来说,transform就是单调的。

注意, 数字做索引时只能是同一类型例如不可能是同一个field, 里面有int, 又有float.

 

 

3.1 首先float可以转成intdouble可以转成long,并且保持顺序。

 

     这个是不难实现的,因为floatint都是4个字节,doublelong都是8个字节,从概念上讲,如果是用科学计数法,把指数放在前面就行了,因为指数大的肯定大,指数相同的尾数大的排前面。 比如 0.5e3, 0.4e3, 0.2e4,那么逻辑上保存的就是<4, 0.2> <3, 0.5> <3, 0.4>,那么显然是保持顺序的。Java的浮点数采用了ieee 754的表示方法(参考http://docs.oracle.com/javase/6/docs/api/java/lang/Float.html#floatToIntBits(float)),它的指数在前,尾数在后,第 31 位(掩码 0x80000000 选定的位)表示浮点数的符号。第 30-23 位(掩码 0x7f800000 选定的位)表示指数。第 22-0 位(掩码 0x007fffff 选定的位)表示浮点数的有效位数(有时也称为尾数)。这很好,不过有一点,它的最高位是符号位,正数0,负数1。这样就有点问题了。



 

那么我们怎么解决这个问题呢?如果这个float是正数,那么把它看成int也是正数,而且根据前面的说明,指数在前,所以顺序也是保持好的。如果它是个负数,把它看出int也是负数,但是顺序就反了,举个例子 <4,-0.2> <3, -0.5>,如果不看符号,显然是前者大,但是加上符号,那么正好反过来。也就是说,负数的顺序需要反过来,怎么反过来呢? 就是符号位不变,其它位0变成11变成0?具体怎么实现呢?还记得异或吗?^ 0 = 1; 1 ^ 1 = 0,注意左边那个加粗的1,然后看第二个操作数,也就是想把一个位取反,那么与1异或运算就行了。类似的,如果想保持某一位不变,那么就让它与0异或。

因此我们可以发现NumericUtils有这样一个方法,就是上面说的实现。

  

Java代码   收藏代码
  1. /** 
  2.    * Converts a <code>float</code> value to a sortable signed <code>int</code>. 
  3.    * The value is converted by getting their IEEE 754 floating-point "float format" 
  4.    * bit layout and then some bits are swapped, to be able to compare the result as int. 
  5.    * By this the precision is not reduced, but the value can easily used as an int. 
  6.    * @see #sortableIntToFloat 
  7.    */  
  8.   public static int floatToSortableInt(float val) {  
  9.     int f = Float.floatToRawIntBits(val);  
  10.     if (f<0) f ^= 0x7fffffff;  
  11.     return f;  
  12.   }  
 

 

 3.2  一个int可以转换成一个字符串,并且保持顺序

我们这里考虑的是javaint,也就是有符号的32位正数,补码表示。如果只考虑正数,从0x0-0x7fffffff,那么它的二进制位是升序的(也就是把它看成无符号整数的时候);如果只考虑负数,从0x10000000-0xffffffff,那么它的二进制位也是升序的。唯一美中不足的就是负数排在正数后面。

因此如果我们把正数的最高符号位变成1,把负数的最高符号位变成0,那么就可以把一个int变成有序的二进制位。

我们可以在intToPrefixCoded看到这样的代码:int sortableBits = val ^ 0x80000000;

因为lucene只能索引字符串,那么现在剩下的问题就是怎么把一个4byte变成字符串了。Java在内存使用Unicode字符集,并且一个Javachar占用两个字节(16位),我们可能很自然的想到把4byte变成两个char。但是Lucene保存Unicode时使用的是UTF-8编码,这种编码的特点是,0-127使用一个字节编码,大于127的字符一般两个字节,汉字则需要3个字节。这样4byte最多需要6个字节。其实我们可以把32位的int看出57位的整数,这样的utf8编码就只有5个字节了。这段代码就是上面算法的实现:

 

Java代码   收藏代码
  1. int sortableBits = val ^ 0x80000000;  
  2.   sortableBits >>>= shift;  
  3.   while (nChars>=1) {  
  4.     // Store 7 bits per character for good efficiency when UTF-8 encoding.  
  5.     // The whole number is right-justified so that lucene can prefix-encode  
  6.     // the terms more efficiently.  
  7.     buffer[nChars--] = (char)(sortableBits & 0x7f);  
  8.     sortableBits >>>= 7;  
  9.   }  
 

 

 

    首先把val用前面说的方法变成有序的二进制位。然后把一个32位二进制数变成57位的正数(0-127)

总结一下,我们可以通过上面的方法把Java里常见的数值类型(intfloatlongdouble)转成字符串,并且保持顺序。【大家可以思考一下其它的类型比如short】。这样很好的解决了用原来的方法需要给整数补0的问题。

现在我们来看看第二个问题:范围查询时需要展开的term太多的问题。参考下图:



 

引自Schindler, U, Diepenbroek, M, 2008. Generic XML-based Framework for Metadata Portals. Computers & Geosciences 34 (12)

我们可以建立trie结构的索引。比如我们需要查找423--642直接的文档。我们只需要构建一个boolean or query,包含6term42344563641642)就行了。而不用构建一个包含11termquery。当然要做到这点,那么需要在建索引的时候把445446以及448docId都合并到44。怎么做到这一点呢?我们可以简单的构建一个分词器。比如423我们同时把它分成3个词,442423。当然这是把数字直接转成字符串,我们可以用上面的方法把一个整数变成一个UTF8的字符串。但现在的问题是怎么索引它的前缀。比如在上图中,我们把423“分词”成423424;类似的,我们可以把一个二进制位也进行“前缀”分词,比如6的二进制位表示是110,那么我们可以同时索引它的前缀111。当然对于上图,对于423,我们可以只分词成4234,也就是只索引百位,这样trie索引本身要小一些,对某些query,比如搜索300-500,和原来一样,只需要搜索term 4”,但是某些query,比如搜索420-450,那么需要搜索更多的term

因此NumericRangeQuery有一个precisionStep,默认是4,也就是隔4位索引一个前缀,比如0100,0011,0001,1010会被分成下列的二进制位“0100,0011,0001,1010“,”0100,0011,0001“,”0100,0011“,”0100“。这个值越大,那么索引就越小,那么范围查询的性能(尤其是细粒度的范围查询)也越差;这个值越小,索引就越大,那么性能越差。这个值的最优选择和数据分布有关,最优值的选择只能通过实验来选择。

另外还有一个问题,比如423会被分词成423424,那么4也会被分词成4,那么4表示哪个呢?

所以intToPrefixCoded方法会额外用一个char来保存shiftbuffer[0] = (char)(SHIFT_START_INT + shift);

比如423分词的4shift2(这里是10进制的例子,二进制也是同样的),423分成423shift04shift0,因此前缀肯定比后缀大。

注意: 这里概念上是一棵trie实际存储的方式不像一般的树结构一般的树结构是一个节点会有指向孩子节点的指针同时会有指向父节点的指针. Lucene由于是按照索引方式存储的只是通过计算可以知道一个term对应的父节点(前缀term).  shift=0的前缀可以逻辑上看做是trie树的叶节点, shift=1的前缀是上一层的父节点依次类推之所以说是逻辑上是由于这些shift=0, shift=1之类的前缀lucene里都是对应一个分词至于两个分词之间的关系是通过计算确定的.

上面说了怎么索引,那么Query呢?比如我给你一个Range Query423-642,怎么找到那6term呢?

我们首先可以用shift==0找到范围的起点后终点(有可能没有相等的,比如搜索422,也会找到423)。然后一直往上找,直到找到一个共同的祖先(肯定能找到,因为树根是所有叶子节点的祖先),对应起点,每次往上走的时候, 左边范围节点都要把它右边的兄弟节点都加进去, 右边范围节点都要把它左边的兄弟节点加进去若已经到达顶点则是将左边范围节点和右边范围节点之间的节点加进行去.

查找423642之间的具体的区间:

423-429,640-642

43-49,60-63

5-5

 

最后,看看实际的代码:

(1). 创建索引时的代码数字的分词实现NumericTokenStream:

 

Java代码   收藏代码
  1. @Override  
  2.  public boolean incrementToken() {  
  3.    if (valSize == 0)  
  4.      throw new IllegalStateException("call set???Value() before usage");  
  5.    if (shift >= valSize)  
  6.      return false;  
  7.    clearAttributes();  
  8.    final char[] buffer;  
  9.    switch (valSize) {  
  10.      case 64:  
  11.        buffer = termAtt.resizeTermBuffer(NumericUtils.BUF_SIZE_LONG);  
  12.        termAtt.setTermLength(NumericUtils.longToPrefixCoded(value, shift, buffer));  
  13.        break;  
  14.        
  15.      case 32:  
  16.        buffer = termAtt.resizeTermBuffer(NumericUtils.BUF_SIZE_INT);  
  17.        termAtt.setTermLength(NumericUtils.intToPrefixCoded((int) value, shift, buffer));  
  18.        break;  
  19.        
  20.      default:  
  21.        // should not happen  
  22.        throw new IllegalArgumentException("valSize must be 32 or 64");  
  23.    }  
  24.      
  25.    typeAtt.setType((shift == 0) ? TOKEN_TYPE_FULL_PREC : TOKEN_TYPE_LOWER_PREC);  
  26.    posIncrAtt.setPositionIncrement((shift == 0) ? 1 : 0);  
  27.    shift += precisionStep;  
  28.    return true;  
  29.  }  
 

 

  (2) 范围搜索时的处理代码:

 

Java代码   收藏代码
  1. /** This helper does the splitting for both 32 and 64 bit. */  
  2.  private static void splitRange(  
  3.    final Object builder, final int valSize,  
  4.    final int precisionStep, long minBound, long maxBound  
  5.  ) {  
  6.    if (precisionStep < 1)  
  7.      throw new IllegalArgumentException("precisionStep must be >=1");  
  8.    if (minBound > maxBound) return;  
  9.    for (int shift=0; ; shift += precisionStep) {  
  10.      // calculate new bounds for inner precision  
  11.      // 本次处理的范围值  
  12.      final long diff = 1L << (shift+precisionStep),  
  13.      // mask, 只取本次精度的范围值  
  14.      mask = ((1L<<precisionStep) - 1L) << shift;  
  15.      System.out.println("diff=" + diff);  
  16.      System.out.println("mask=" + Integer.toBinaryString((int) mask));  
  17.      final boolean  
  18.        // 当最小界限不是范围的最小值时,则本层次最小界限有值,否则已经是本层级的最小值了,那么可以直接移到上一层,因为上一层包含本层的所有值  
  19.        hasLower = (minBound & mask) != 0L,  
  20.        // 当最大界限不是范围的最大值时,则本层次最大界限有值,否则已经是本层级的最大值了,那么可以直接移到上一层,因为上一层包含本层的所有值  
  21.        hasUpper = (maxBound & mask) != mask;  
  22.      final long  
  23.         // 移到上一层  
  24.        nextMinBound = (hasLower ? (minBound + diff) : minBound) & ~mask,  
  25.        nextMaxBound = (hasUpper ? (maxBound - diff) : maxBound) & ~mask;  
  26.      final boolean  
  27.        lowerWrapped = nextMinBound < minBound,  
  28.        upperWrapped = nextMaxBound > maxBound;  
  29.          
  30.      if (shift+precisionStep>=valSize || nextMinBound>nextMaxBound || lowerWrapped || upperWrapped) {  
  31.        // We are in the lowest precision or the next precision is not available.  
  32.        addRange(builder, valSize, minBound, maxBound, shift);  
  33.        // exit the split recursion loop  
  34.        break;  
  35.      }  
  36.        
  37.      if (hasLower)  
  38.       // min->minMax  
  39.        addRange(builder, valSize, minBound, minBound | mask, shift);  
  40.      if (hasUpper)  
  41.       // maxMin->max  
  42.        addRange(builder, valSize, maxBound & ~mask, maxBound, shift);  
  43.        
  44.      // recurse to next precision  
  45.      minBound = nextMinBound;  
  46.      maxBound = nextMaxBound;  
  47.    }  
  48.  }  

 


 对文本搜索引擎的倒排索引(数据结构和算法)、评分系统、分词系统都清楚掌握之后,本人对数值索引和搜索一直有很大的兴趣,最近对Lucene对数值索引和范围搜索做了些学习,并将主要内容整理如下:

1. Lucene不直接支持数值(以及范围)的搜索,数值必须转换为字符(串);

2. Lucene搜索数值的初步方案;

3. Lucene如何索引数值,并支持范围查询。

 

1. Lucene不直接支持数值搜索

Lucene不直接支持数值(以及范围)的搜索,数值必须转换为字符(串)——这是由倒排索引这个核心所决定,lucene要求term按照字典序(lexicographic sortable)排列。如果只是简单的将数值转为字符串,会带来很多的问题:

 

2. Lucene搜索数值的初步方案

2.1 如直接保存11,24,3,50,按照字典序查询范围[24,50],会将3一起带出来。这个问题有个简单的解决方案,就是将字符串补全成定长的串,如000011,000024,000003,000050。这样就能解决[000024,000050]这样的字符范围查询。

2.2 建立索引的时候,term按照数字顺序排序,上面的例子以3,11,24,50,搜索也能正确。

显而易见,上述方案有“硬伤”:

 2.1方案的问题是,固定多少位难以控制,补的位数多则浪费空间,少则存储的数值范围有限;

 2.2方案的问题是,对范围[24,50]查询,必须要展开成25,26...50,这样Boolean query查询效率会低到无法接受。

 

3. Lucene如何索引数值,并支持范围查询

  首先可以把数值转换成字符串,且保持顺序。也就是说如果 number1 < number2 ,那么transform(number) < transform(number)。transform就是把数值转成字符串的函数,如果拿数学术语来说,transform就是单调的。

  *注意, 数字做索引时, 只能是同一类型, 例如不可能是同一个field, 里面有int, 又有float的.

 

 3.1 Lucene 对NumericField建索引的时候,首先把Numeric Value转成 Lexicographic Sortable Binary然后根据某个步长(Precision Step 后面详说)不断右移然后转换成 Lexicographic Sortable String建索引,本质上相当于建了一个Trie。

  怎么把numeric value转成  Lexicographic Sortable Binary 所有的Byte的词典顺序就是Numeric顺序。

  对于Long 二进制表示方式 http://en.wikipedia.org/wiki/Two‘s_complement

  最高位是符号位0表示正数 1表示负数。对于正数来说低63位越大这个数越大,对于负数来说也是低63位越大(0xFFFFFFFFFFFFFFFF是-1,最大的负整数)这个数越大。所以只要把符号位取反Long就可以按字节映射成一个 Lexicographic Sortable Binary了。

 对于Double 二进制表示方式 http://en.wikipedia.org/wiki/Binary64

技术分享

The real value assumed by a given 64-bit double-precision datum with a given biased exponent  and a 52-bit fraction is

技术分享

对于正Double来说低63位越大这个数越大,对于负Double来说低63位越大这个数越小。负数情况和Long是相反的,因此对于小于0的Double把低63位取反,然后和Long相同再把符号位取反,Double就可以按字节映射成一个 Lexicographic Sortable Binary了。

对于Int和Float 32位的类型一样道理,就不赘述了。

 

 3.2 利用Trie的性质把RangeQuery分解成尽量少TermQuery,然后用这些TermQuery做搜索就可以了

原理就是Shift从0开始以precisionStep为步长递增,对每一个Shift试图找到最多两个子Range:Lower和Upper,然后中间的Range继续递归直到break发生,这时的Range成为Center Range。当Shift=n时,对于split出来的Range满足把minBound的低Shift位全部置0和把maxBound的低Shift位全部置1后之间的所有数值都在要查询的Range中。基本思想和树状数组类似。

 

看例子更容易明白比如[1, 10000]这个Range,通过splitRange出来的Range:

Shift: 0  

Lower: [0x1,0xF],  表示从1到15

Upper: [0x2710,0x2710] 表示10000到10000

Shift: 4

Lower:[0x10, 0xF0]   表示从16(0x10)到255(0xFF) 

Upper:[0x2700, 0x2700]  表示从9984(0x2700)到 9999(0x270F)

Shift: 8

Lower: [0x100,0xF00]  表示从256(0x100)到 4095(0xFFF)

Upper: [0x2000,0x2600] 表示从8192(0x2000)到9983(0x26FF)

Shift: 12

Center: [0x1000, 0x1000] 表示从4096(0x1000)到8191(0x1FFF)

一共7个Range最后一个Range是Center Range, 这7个Range也正好覆盖了[1,10000]

 

addRange中会对每个split出来的Long Range的minBound和maxBoud右移Shift位然后转成Lexicographic Sortable String,最后和建索引时一样在前面加一个Byte表示Shift。因为Shift是以precisionStep为步长递增的,所以splitRange出来的多个Lexicographic Sortable String Range是递增的(Pair顺序比较)。这样查找所有属于这些Range中的Term,只需要对这个field一直seek forward,不需要seek backward。

 

对于上面的例子,这7个Range转换成Lexicographic Sortable String, 然后用这些Range去查找所有属于这些Range范围内的Term。

比如shift: 8

Lower: [0x100,0xF00]  表示从256(0x100)到 4095(0xFFF)

0x100,最高位变成1  成为 0x80,00,00,00,00,00,01,00  然后右移8位变成 0x80,00,00,00,00,00,01 然后每7个bit变成一个Byte成为

0x40, 00, 00, 00, 00, 00, 00,01

0xF00 同理变成0x40, 00, 00, 00, 00, 00, 00,0F。

在最前面加一个Byte表示Shift那么最终的Lexicographic Sortable String

0x100  -> 0x28,40, 00, 00, 00, 00, 00, 00,01

0xF00  -> 0x28,40, 00, 00, 00, 00, 00, 00,0F

第一个Byte 0x28表示Shift为8,0x20是偏移量,区分不同数值类型。

这样如果要查找[256, 4095]的数值共有3840个,那么只需要查找15个Term  

 0x28,40, 00, 00, 00, 00, 00, 00,01 ~  0x28,40, 00, 00, 00, 00, 00, 00,0F

整体来看[0, 10000]之间共1000个数值,最多需要查找的Term数量是55个。

[0x1,0xF]               15 

[0x2710,0x2710]         1 

[0x10, 0xF0]             15

[0x2700, 0x2700]         1

[0x100,0xF00]           15 

[0x2000,0x2600]         7

[0x1000, 0x1000]         1

如果不做Trie树,那么需要最多遍历查找10000个Term。

 

理论上对于precisionStep=4时一个Range最多需要查找多少个Term?

根据splitRange可以看出除了最后一次Shift,前面的每次Shift最多产生两个Range(Lower 和 Upper),最后一个Shift产生的是Center Range。

64位的数字Value最多Shift  64/4=16次。 所以最多有Lower和Upper最多各15个Range, Center 1个Range,每个Range最多覆盖15个Term。

为什么不是16个Term?16个Term的话,这个Range的存在是没有意义可以进位到下一个Shift。

只有一种情况是特殊的就是无法进位的时候,比如Range是[Long.MIN_VALUE, Long.MAX_VALUE]  只得到一个Center Range在Shift=60时,覆盖了16个Term的。

所以理论上对precisionStep=4,最多需要查找的Term   31个Range * 15个Term/Range = 465

更一般的结论

  n = [ (bitsPerValue/precisionStep - 1) * (2^precisionStep - 1 ) * 2 ] + (2^precisionStep - 1 )

precisionStep=8, n=3825

precisionStep=2, n=189

显然precisionStep越小n越小,但是precisionStep越小意味着对每个Field需要index的Term越多,对64位的数值需要index的Term是64/precisionStep。

 

以上主要讨论了LongField的搜索,对于DoubleField只是需要做一步处理就是对于小于0的Double,低63位取反,接下来和LongField完全相同流程。对于Int和Float只是数值类型从64位变成32位了,其余的都一样。


  • 5
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值