NumericField&NumericRangeQuery原理分析

本文涉及的产品
云原生网关 MSE Higress,422元/月
注册配置 MSE Nacos/ZooKeeper,118元/月
Serverless 应用引擎免费试用套餐包,4320000 CU,有效期3个月
简介: NumericField和NumericRangeQuery是Lucene 针对数值型区间查询的优化方案。在展开阐述 NumericField 和NumbericRanageQuery 的实现原理之前,对于Lucene范围查询的实现和概念可以参考博文《TermRangeQuery源码解析

NumericField和NumericRangeQuery是Lucene

针对数值型区间查询的优化方案。在展开阐述

NumericField

NumbericRanageQuery

的实现原理之前,对于Lucene范围查询的实现和概念可以参考博文《TermRangeQuery源码解析》一文。

      从Lucene 2.9 开始,提供对数字范围的支持,然而欲使用此查询,必须使用NumericField 添加域,使用Lucene原生API:

Java代码
  1. document.add(new NumericField(name).setIntValue(value));  


或者使用NumericTokenStream添加域:

Java代码
  1. Field field = new Field(name, new NumericTokenStream(precisionStep).setIntValue(value));  
    field.setOmitNorms(true);  
    field.setOmitTermFreqAndPositions(true);  
    document.add(field);  


如果要在Solr框架上使用需要定义schema.xml:

Java代码

  1. <fieldType  name="long" class="solr.TrieLongField" precisionStep="8"   
    mitNorms="true" positionIncrementGap="0"/>  
    <fieldType  name="int" class="solr.TrieIntField" precisionStep="4"   
    mitNorms="true" positionIncrementGap="0"/>  

 
 

所以如果定义为trieField则在构造field的时候调用TrieField类的createField()方法:

Java代码

  1. public Fieldable createField(SchemaField field, String externalVal, float boost) {  
      boolean indexed = field.indexed();  
      boolean stored = field.stored();  
      
      if (!indexed && !stored) {  
        if (log.isTraceEnabled())  
          log.trace("Ignoring unindexed/unstored field: " + field);  
        return null;  
      }  


  2.  
       //构建NumericField  
      final NumericField f = new NumericField(field.getName(), precisionStep, stored ? Field.Store.YES :   
    Field.Store.NO, indexed);  
      switch (type) {//根据具体field类型来set具体类型值  
        case INTEGER:  
          f.setIntValue(Integer.parseInt(externalVal));  
          break;  
        case FLOAT:  
          f.setFloatValue(Float.parseFloat(externalVal));  
          break;  
        case LONG:  
          f.setLongValue(Long.parseLong(externalVal));  
          break;  
        case DOUBLE:  
          f.setDoubleValue(Double.parseDouble(externalVal));  
          break;  
        case DATE:  
          f.setLongValue(dateField.parseMath(null, externalVal).getTime());  
          break;  
        default:  
          throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, "Unknown type for trie field: " + type);  
      }  
      
      f.setOmitNorms(field.omitNorms());  
      f.setIndexOptions(getIndexOptions(field, externalVal));  
      f.setBoost(boost);  
      return f;  
    }  

构造Field后经过docment.add()后再经Wirter.updateDocument(),会经过Lucene构造索引流程来将Doc

转变成索引。在其中DocInverterPerField.processFields()的过程就是将该域所制定的分词规则将其域值

切分为term后通过CharTermAttribute提交给其consumer对象进行下一步的构建索引流程,

而这个其中最重要的部分就是NumericField.tokenStreamValue()方法:

Java代码

  1. public TokenStream tokenStreamValue()   {  
       if (!isIndexed())  
         return null;  
       if (numericTS == null) {  
         // lazy init the TokenStream as it is heavy to instantiate (attributes,...),  
         // if not needed (stored field loading)  
         numericTS = new NumericTokenStream(precisionStep);  
         // initialize value in TokenStream  
         if (fieldsData != null) {  
           assert type != null;  
           final Number val = (Number) fieldsData;  
           switch (type) {  
             case INT:  
               numericTS.setIntValue(val.intValue()); break;  
             case LONG:  
               numericTS.setLongValue(val.longValue()); break;  
             case FLOAT:  
               numericTS.setFloatValue(val.floatValue()); break;  
             case DOUBLE:  
               numericTS.setDoubleValue(val.doubleValue()); break;  
             default:  
               assert false : "Should never get here";  
           }  
         }  
       }  
       return numericTS;  
     }  

NumericTokenStream stream = field.tokenStreamValue(); 执行完毕后返回TokenStream, TokenStream在Lucene中是决定如何进行对域值进行分词的核心类,而其中的incrementToken() 方法就是实现分词关键方法,在回过头来看NumericTokenStream的这个方法:

Java代码

 

 


    1. public boolean incrementToken() {  
         if (valSize == 0)  
           throw new IllegalStateException("call set???Value() before usage");
         if (shift >= valSize)  
           return false;  
         clearAttributes();//置空termAttr  
         /** 
          *NumericTokenStream的value虽然是数字型, 
          *但是Lucene的Token只能保持字符串 
          *所以接下的动作是将数字编码成字符串, 
          *然后才作为某个域的域值存入索引 
          */  
         final char[] buffer;  
         switch (valSize) {  
           case 64:// 首先分配TermBuffer,然后将数字编码为字符串  
            //重置ternAtt's buffer的大小  
             buffer = termAtt.resizeTermBuffer(NumericUtils.BUF_SIZE_LONG);  
              //longToPrefixCoded 方法变是将Long型的value值编码成字符串并放入到termAtt.buffer中  
             termAtt.setTermLength(NumericUtils.longToPrefixCoded(value, shift, buffer));  
             break;  
           case 32:  
             buffer = termAtt.resizeTermBuffer(NumericUtils.BUF_SIZE_INT);  
             termAtt.setTermLength(NumericUtils.intToPrefixCoded((int) value, shift, buffer));  
             break;  
           default:  
             // should not happen  
             throw new IllegalArgumentException("valSize must be 32 or 64");  
        }  
         typeAtt.setType((shift == 0) ? TOKEN_TYPE_FULL_PREC : TOKEN_TYPE_LOWER_PREC);  
         posIncrAtt.setPositionIncrement((shift == 0) ? 1 : 0);  
         shift += precisionStep;  
         return true;  
       }  
       


 

接下来看看NumericUtils是如何将数值型转成字符串的:

Java代码

  1. public static int longToPrefixCoded(final long val, final int shift, final char[] buffer) {  
      if (shift>63 || shift<0)  
        throw new IllegalArgumentException("Illegal shift value, must be 0..63");  
      //shift初始为0; valSize = 64; 根据(63 - shift) / 7 + 1计算出当前Token的buffer的长度  
      int nChars = (63-shift)/7 + 1, len = nChars+1;  
      //buffer[0] = 0x20+0;  
      buffer[0] = (char)(SHIFT_START_LONG + shift);  
      long sortableBits = val ^ 0x8000000000000000L;  
      sortableBits >>>= shift;  
      while (nChars>=1) {  
        // Store 7 bits per character for good efficiency when UTF-8 encoding.  
        // The whole number is right-justified so that lucene can prefix-encode  
        // the terms more efficiently.  
        //每七位组成一个uft-8的编码  
        buffer[nChars--] = (char)(sortableBits & 0x7f);  
        sortableBits >>>= 7;  
      }  
      return len;  
    }  

    将incrementToken和NumericUtils.longToPrefixCoded()结合来看将按照如下流程进行处理: (1) 进入longToPrefixCoded(),如果shift初始为0; valSize = 64; 根据(63 - shift) / 7 + 1计算出当前Token的buffer的长度 (2)buffer[0]存放的是32+shift (3)sortableBits = value ^ 0x8000000000000000L,sortableBits >>>= shift (右移shift位) (4)迭代buffer[buffer.size-1 -> 1]各个值为buffer[n] = (char) (sortableBits & 0x7f), sortableBits >>>= 7 ,再将sortableBits左移7位 (5) 出longToPrefixCoded(),设置TermAttribute的termBuffer为buffer,以及bufferLength等属性 (6) shift += precisionStep 如果shift >= valSize(64,以long为例)了并返回false将会退出切分char过程,否则进入仍然进入步骤(1) 所以我们以value=2048,precisionStep =8为例经过编码后的TOKENSTREAM流为:
Java代码

 

 

    1. 32 1 0 0 0 0 0 0 0 16 0 ]  

 

  • 40 64 0 0 0 0 0 0 8 ]  

 

 

  • 48 32 0 0 0 0 0 0 ]  

 

 

  • 56 16 0 0 0 0 0 ]  

 

 

  • 64 8 0 0 0 0 ]  

 

 

  • 72 4 0 0 0 ]  

 

 

  • 80 2 0 0 ]  

 

 

  • 88 1 0 ]  

 

 

也就是一个2048的Long型数值被 NumericTokenStream“切” 成了8个length不相同的字符串。为了显示precisionStep的作用,我们在使用value=2048,precisionStep=4来看看会出现什么情况:

Java代码

 

 

    1. 32 1 0 0 0 0 0 0 0 16 0 ]  

 

  • 36 8 0 0 0 0 0 0 1 0 ]  

 

 

  • 40 64 0 0 0 0 0 0 8 ]  

 

 

  • 44 4 0 0 0 0 0 0 0 ]  

 

 

  • 48 32 0 0 0 0 0 0 ]  

 

 

  • 52 2 0 0 0 0 0 0 ]  

 

 

  • 56 16 0 0 0 0 0 ]  

 

 

  • 60 1 0 0 0 0 0 ]  

 

 

  • 64 8 0 0 0 0 ]  

 

 

  • 68 64 0 0 0 ]  

 

 

  • 72 4 0 0 0 ]  

 

 

  • 76 32 0 0 ]  

 

 

  • 80 2 0 0 ]  

 

 

  • 84 16 0 ]  

 

 

  • 88 1 0 ]  

 

 

  • 92 8 ]  

 

 

很明显,precisionStep 越小那么被“切”成的的字符串就会多。precisionStep 越小则value被“切”成的term就越多,也就是说索引体积将会增大。被“切”分的term越多则可能在NumericRangeQuery中被细分的小区间中存在的可能性也就越高,那么查询遍历的次数也就越少,性能也就越好。但这不是绝对的,一般而言,搜索速度被降低是因为更多的在index中搜索term的列举,因此,理想的precisionStep只能依据自己的测试而获取。同时请注意如果只是对其进行sort而不需要范围查询可以将precisionStep=Integer.MAX_VALUE。这样只会生成一个term,从而不会增大索引体积。 NumericRangeQuery是MultiTermQuery,所以也是遵循Lucene的搜索流程: Query树->rewrite->weight  ->Scorer 树,这块的详细流程请看笔者的另一篇Blog《TermRangeQuery源码解析》文章将会详细这块内容,所以这里不在复述。 query rewrite过程:

Java代码

Query result = new ConstantScoreQuery(new MultiTermQueryWrapperFilter(query));  
result.setBoost(query.getBoost());  
return result;  


 

构建weight树:

Java代码
  1. public Weight createWeight(Searcher searcher) {  
       return new ConstantScoreQuery.ConstantWeight(searcher);  
     }  


构建scorer树:

Java代码

public Scorer scorer(IndexReader reader, boolean scoreDocsInOrder, boolean topScorer) throws IOException {  
 return new ConstantScorer(similarity, reader, this);  
  }  
 

上述过程后,整个区间过滤DocIdSet将交由MultiTermQueryWrapperFilter的getDocIdSet方法进行区间查询过滤:

Java代码

  1. public DocIdSet getDocIdSet(IndexReader reader) throws IOException {  
      final TermEnum enumerator = query.getEnum(reader);////得到NumericRangeQuery的Term枚举器  
      try {  
        // if current term in enum is null, the enum is empty -> shortcut  
        if (enumerator.term() == null)  
          return DocIdSet.EMPTY_DOCIDSET;  
        // else fill into a OpenBitSet  
        final OpenBitSet bitSet = new OpenBitSet(reader.maxDoc());//创建包含多个Term的文档号集合  
        new TermGenerator() {  
          public void handleDoc(int doc) {  
            bitSet.set(doc);  
          }  
        }.generate(reader, enumerator);  
        return bitSet;  
      } finally {  
        enumerator.close();  
      }  
    }  


 
 
Java代码

  1. protected FilteredTermEnum getEnum(final IndexReader reader) throws IOException {  
      return new NumericRangeTermEnum(reader);  
    }  


继续看NumericRangeTermEnum的构造函数的关键代码部分:

Java代码

 

 


 

这部分关键代码就是将一个查询范围区间分解成若干个小的查询区间,如区间[1,12340]将最终被分解成:

Java代码
  1. (1) low [ 32 1 0 0 0 0 0 0 0 0 1 ]      high  [ 32 1 0 0 0 0 0 0 0 0 15 ]  
  2. (2) low [ 32 1 0 0 0 0 0 0 0 96 48 ]  high  [ 32 1 0 0 0 0 0 0 0 96 52 ]  
  3. (3) low [ 36 8 0 0 0 0 0 0 0 1 ]         high  [ 36 8 0 0 0 0 0 0 0 15 ]  
  4. (4) low [ 36 8 0 0 0 0 0 0 6 0 ]         high  [ 36 8 0 0 0 0 0 0 6 2 ]  
  5. (5) low [ 40 64 0 0 0 0 0 0 1 ]          high  [ 40 64 0 0 0 0 0 0 15 ]  
  6. (6) low [ 44 4 0 0 0 0 0 0 1 ]            high  [ 44 4 0 0 0 0 0 0 2 ]  

六个小区间,NumericRangeTermEnum枚举器根据next()方法来判断term是否在这些区间内,如果在则代表可以继续遍历,直到某个currentTerm和currentUpperBound(当前子high区间的值)字符串比较更大,则认为进入下一个子区间进行比较,主要代码逻辑如下所示:

Java代码

  1. public boolean next() throws IOException {  
          // if a current term exists, the actual enum is initialized:  
          // try change to next term, if no such term exists, fall-through  
          if (currentTerm != null) {  
            assert actualEnum != null;  
            if (actualEnum.next()) {  
              currentTerm = actualEnum.term();  
              if (termCompare(currentTerm))  
                return true;  
            }  
          }  
            
          // if all above fails, we go forward to the next enum,  
          // if one is available  
          currentTerm = null;  
          while (rangeBounds.size() >= 2) {  
            assert rangeBounds.size() % 2 == 0;  
            // close the current enum and read next bounds  
            if (actualEnum != null) {  
              actualEnum.close();  
              actualEnum = null;  
            }  
            final String lowerBound = rangeBounds.removeFirst();  
            this.currentUpperBound = rangeBounds.removeFirst();  
            // create a new enum  
            actualEnum = reader.terms(termTemplate.createTerm(lowerBound));  
            currentTerm = actualEnum.term();  
            if (currentTerm != null && termCompare(currentTerm))  
              return true;  
            // clear the current term for next iteration  
            currentTerm = null;  
          }  
            
          // no more sub-range enums available  
          assert rangeBounds.size() == 0 && currentTerm == null;  
          return false;  
        }  


接下来举例说明区间比较的逻辑顺序,假设目前索引中有1024和12341 2个值,precisionStep=4,查询范围为[1,12340]。 1024在索引中存储中存储结构为:

Java代码

 

 

    1. 1   [ 32 1 0 0 0 0 0 0 0 8 0 ]  

 

  • 2   [ 36 8 0 0 0 0 0 0 0 64 ]  

 

 

  • 3   [ 40 64 0 0 0 0 0 0 4 ]  

 

 

  • 4   [ 44 4 0 0 0 0 0 0 0 ]  

 

 

  • 5   [ 48 32 0 0 0 0 0 0 ]  

 

 

  • 6   [ 52 2 0 0 0 0 0 0 ]  

 

 

  • 7   [ 56 16 0 0 0 0 0 ]  

 

 

  • 8   [ 60 1 0 0 0 0 0 ]  

 

 

  • 9   [ 64 8 0 0 0 0 ]  

 

 

  • 10 [ 68 64 0 0 0 ]  

 

 

  • 11 [ 72 4 0 0 0 ]  

 

 

  • 12 [ 76 32 0 0 ]  

 

 

  • 13 [ 80 2 0 0 ]  

 

 

  • 14 [ 84 16 0 ]  

 

 

  • 15 [ 88 1 0 ]  

 

 

  • 16 [ 92 8 ]  

 

 

备注:low[1] (代表:[ 32 1 0 0 0 0 0 0 0 0 1 ])

high[1](代表:[ 32 1 0 0 0 0 0 0 0 0 15 ])

1024[1] (代表:[ 32 1 0 0 0 0 0 0 0 8 0 ] ),其他依次类推

第一次查询low[1]-high[1],发现1024[1]-1024[16]都不在该区间,则进入low[2]--high[2]比较

第二次查询low[2]--high[2],发现1024[1]-1024[16]都不在该区间,则进入low[3]--high[3]比较

第三次查询low[3]--high[3],发现1024[1]-1024[16]都不在该区间,则进入low[4]--high[4]比较

第四次查询low[4]--high[4],发现1024[1]-1024[16]都不在该区间,则进入low[5]--high[5]比较

第五次查询low[5]--high[5],发现1024[3]在该区间,则代表1024该值在[1,12340]区间内,将该term对应的docId放入OpenBitSet中。

接下来看12341在索引中的存储结构:

Java代码
  1. 1 [ 32 1 0 0 0 0 0 0 0 96 53 ]  
  2. 2 [ 36 8 0 0 0 0 0 0 6 3 ]  
  3. 3 [ 40 64 0 0 0 0 0 0 48 ]  
  4. 4 [ 44 4 0 0 0 0 0 0 3 ]  
  5. 5 [ 48 32 0 0 0 0 0 0 ]  
    switch (valSize) {  
       case 64: {  
         // lower  
         long minBound = Long.MIN_VALUE;  
            if (min instanceof Long) {  
          minBound = min.longValue();  
            } else if (min instanceof Double) {  
          minBound = NumericUtils.doubleToSortableLong(min.doubleValue());  
            }  
            if (!minInclusive && min != null) {  
           if (minBound == Long.MAX_VALUE) break;  
           minBound++;  
            }  
           // upper  
        long maxBound = Long.MAX_VALUE;  
          if (max instanceof Long) {  
            maxBound = max.longValue();  
         } else if (max instanceof Double) {  
             maxBound = NumericUtils.doubleToSortableLong(max.doubleValue());  
           }  
          if (!maxInclusive && max != null) {  
            if (maxBound == Long.MIN_VALUE) break;  
             maxBound--;  
         }  
           //将查询区间分解成若干个小的查询区间  
          NumericUtils.splitLongRange(new NumericUtils.LongRangeBuilder() {  
             //@Override  
             public final void addRange(String minPrefixCoded, String maxPrefixCoded) {  
             rangeBounds.add(minPrefixCoded);  
              rangeBounds.add(maxPrefixCoded);  
           }  
            }, precisionStep, minBound, maxBound);  
            break;  
         }  
     

  6. 6 [ 52 2 0 0 0 0 0 0 ]  
  7. 7 [ 56 16 0 0 0 0 0 ]  
  8. 8 [ 60 1 0 0 0 0 0 ]  
  9. 9 [ 64 8 0 0 0 0 ]  
  10. 10 [ 68 64 0 0 0 ]  
  11. 11 [ 72 4 0 0 0 ]  
  12. 12 [ 76 32 0 0 ]  
  13. 13 [ 80 2 0 0 ]  
  14. 14 [ 84 16 0 ]  
  15. 15 [ 88 1 0 ]  
  16. 16 [ 92 8 ]  

根据上面的分析规则:

第一次查询low[1]--high[1],发现12341[1]-12341[16]都不在该区间,则进入low[2]--high[2]比较

第二次查询low[2]--high[2],发现12341[1]-12341[16]都不在该区间,则进入low[3]--high[3]比较

第三次查询low[3]--high[3],发现12341[1]-12341[16]都不在该区间,则进入low[4]--high[4]比较

第四次查询low[4]--high[4],发现12341[1]-12341[16]都不在该区间,则进入low[5]--high[5]比较

第五次查询low[5]--high[5],发现12341[1]-12341[16]都不在该区间,则进入low[6]--high[6]比较

第六次查询low[6]--high[6],发现12341[1]-12341[16]都不在该区间,next()方法返回false;

另外需要了解的是每次子区间查询会重定位新的term枚举:

Java代码
  1. actualEnum = reader.terms(termTemplate.createTerm(lowerBound));  

那么以1024为例,也就是说不需要每次都是从1024[1]开始重新比较,可能是从1024[2-n]的某个最临近子区间的一个term值来进行比较,这样比较次数将大幅减少。

和TermRangeQuery的优势:

(1)支持数值型的范围查询

(2)使用子区间来减少term枚举的遍历次数,极大提高性能

缺点:

(1)数值切分多个term存储,增大索引体积

(2)并不能完全杜绝范围查询的性能损耗,仍然有范围比较,枚举遍历等性能损耗。

总结:

NumericRangeQuery虽然能提高区间查询的性能,但是并不能完全杜绝区间查询带来的性能损耗,如果需要完全消除区间查询带来的性能消耗,只能使用区间过滤的方式:将docId和 rangeUid作为数组在内存中保存起来,然后保证满足其他查询条件的docId同时也在这个区间数组范围内才认为是最终满足条件的docId,否则过滤该docId。只有这样的设计才能完全消耗区间带来的性能损失。所以NumericRangeQuery在数据量不大和查询区间不大的情况下作为范围查询的首选方案还是没有问题的。

相关文章
|
30天前
|
存储 JavaScript 前端开发
事件队列的实现原理
【10月更文挑战第15天】事件队列的实现原理
42 6
|
测试技术 iOS开发 数据格式
WDA原理分析
1、什么是WDA WebDriverAgent是Facebook 在17年的 SeleniumConf 大会上推出了一款新的iOS移动测试框架。 下面摘录一段官方对于WebDriverAgent的介绍字段:(官方文档:https://github.com/facebook/WebDriverAgent) WebDriverAgent 在 iOS 端实现了一个 WebDriver server ,借助这个 server 我们可以远程控制 iOS 设备。
12055 0
|
18天前
|
存储 人工智能 算法
pfinder实现原理揭秘
`pfinder`算法通过启发式搜索和图搜索方法,提供了一种高效的路径查找和路径优化解决方案。在导航系统、机器人路径规划和游戏AI等领域,`pfinder`算法具有广泛的应用前景。本文详细解析了 `pfinder`算法的实现原理及其在实际中的应用,希望对您理解和实现路径查找算法有所帮助。
24 1
|
3月前
|
存储 负载均衡 API
服务发现原理分析与源码解读
服务发现原理分析与源码解读
|
3月前
|
存储 缓存 前端开发
EaselJS 源码分析系列--第三篇
EaselJS 源码分析系列--第三篇
|
6月前
|
网络协议 小程序 测试技术
ChaoBlade 的实现原理
【4月更文挑战第6天】ChaoBlade 的实现原理
249 3
ChaoBlade 的实现原理
|
SQL Java 数据库连接
源码分析系列教程(06) - 数据库连接池原理
源码分析系列教程(06) - 数据库连接池原理
51 0
|
数据采集 算法 安全
GSI服务的实现原理是什么?
答:通过光算科技自研的GPC爬虫池系统。 GSI服务,全称Google Search Infrastructure服务,是Google用来处理和返回用户搜索查询结果的基础设施。 这个基础设施包括了庞大的硬件和软件系统,通过复杂的算法和技术,它可以在瞬间处理数亿的搜索查询,返回相关且有价值的结果。 下面,我们将深入探讨GSI服务的实现原理。
199 0
GSI服务的实现原理是什么?
|
存储 应用服务中间件 API
Session使用和原理分析图与实现原理
Session使用和原理分析图与实现原理
179 0