Lucene检索源码解析(下)

上文已经介绍了检索前的准备工作,本文接着上文的内容,继续剖析检索和打分操作

一、获取LeafCollector

我们先来看一下IndexSearcher的search方法:

protected void search(List<LeafReaderContext> leaves, Weight weight, Collector collector)
      throws IOException {
    for (LeafReaderContext ctx : leaves) { // search each subreader
      final LeafCollector leafCollector;
      try {
        leafCollector = collector.getLeafCollector(ctx);
      } catch (CollectionTerminatedException e) {
        continue;
      }
      BulkScorer scorer = weight.bulkScorer(ctx);
      if (scorer != null) {
        try {
          scorer.score(leafCollector, ctx.reader().getLiveDocs());
        } catch (CollectionTerminatedException e) {
       
        }
      }
    }
  }

这个search方法主要就是做检索文档和对文档进行打分的工作,它接受LeafReaderContext列表、之前创建好的Weight、第一步创建好的collector三个参数。

我们前面已经讲了这个LeafReaderContext,为了方便理解,在我们这里,就简单认为它就是一个segment代表,它包含一些基础信息。Lucene的检索工作,需要检索所有的segment,然后将结果汇总,所以我们看到,方法中就是一个循环,遍历所有的LeafReaderContex进行检索和打分。

首先需要通过collector来获取对应LeafReaderContext的LeafCollector,这个LeafCollector用于收集对应的LeafReadercontext。我们回到最开始创建

前文已经提到过了,在当前场景下,我们使用的collector是通过TopScoreDocCollector#create创建的:

 public TopScoreDocCollector newCollector() throws IOException {
        return TopScoreDocCollector.create(cappedNumHits, after);
      }

我们看一下这个TopScoreDocCollector#create的实现:

public static TopScoreDocCollector create(int numHits, ScoreDoc after) {

    if (numHits <= 0) {
      throw new IllegalArgumentException("numHits must be > 0; please use TotalHitCountCollector if you just need the total hit count");
    }

    if (after == null) {
      return new SimpleTopScoreDocCollector(numHits);
    } else {
      return new PagingTopScoreDocCollector(numHits, after);
    }
  }

我们可以看到,它涉及到numHits的参数校验,然后根据after是否为空创建不同的Collector。如果after不为空,则认为是一个分页查询,会返回一个PagingTopScoreDocCollector,否则返回简单的SimpleTopScoreDocCollector。在我们的例子中不涉及到分页,所以返回的也就是这个SimpleTopScoreDocCollector了。它是TopScoreDocCollector的一个子类,会默认根据分数、docId对文档进行收集,最终以TopDocs的形式返回。

同时,它在创建的时候会根据numHits的值创建对应长度的HitQueue,用于最后来存储该collector命中的document。

我们来看一下这个SimpleTopScoreDocCollector的getLeafCollector的实现(由于该方法在后面进行文档收集的时候才会调用,所以后面用到的时候再详细解释哈):

public LeafCollector getLeafCollector(LeafReaderContext context)
        throws IOException {
      final int docBase = context.docBase;
      return new ScorerLeafCollector() {

        @Override
        public void collect(int doc) throws IOException {
          float score = scorer.score();

          totalHits++;
          if (score <= pqTop.score) {
            return;
          }
          //真实的docId需要当前reader的docBase加上doc(相当于偏移量)
          pqTop.doc = doc + docBase;
          pqTop.score = score;
          //pg是一个PriorityQueue,它是通过堆结构实现的一个优先队列
          pqTop = pq.updateTop();
        }

      };
    }

上文已经提到过,LeafCollector对文档的收集和评分进行了解耦。而collect方法接受的doc是基于当前reader的,也就是当前segment的,如果当前segment不是第一个segment,则doc是不能等同于docId的,它只能算是文档在当前segment中的index,docId应为:docBase+index。

注:代码注释中提到的PriorityQueue是Lucene实现的一个小(大)顶堆,具体的子类通过实现其抽象方法lessThan()来进行排序,后面会提到。

二、创建BulkScorer

在获取了我们的LeafCollector之后,还需要创建一个BulkScorer,这个BulkScorer是何方神圣?

BulkScorer可以每次对一系列的文档进行匹配和打分,并且将匹配到的结果交给我们上面获取的collector。它是通过Weight#bulkScorer方法返回的,所以Weight的子类都可以重写该方法以返回不同的BulkScorer。

其核心方法为:

public abstract int score(LeafCollector collector, Bits acceptDocs, int min, int max) throws IOException;

其中,collector就是我们上面获取的SimpleTopScoreDocCollector;acceptDocs表示只对那些文档进行匹配,若为空表示全部进行匹配;min和max表示匹配的边界:[min,max)。下面我们会结合具体的BulkScorer进行说明。

回到search方法中,BulkScorer是通过调用weight#bulkScorer方法返回的,明显我们此处的Weight是BooleanWeight,我们看一下对应的实现,BooleanWeight#bulkScorer:

@Override
  public BulkScorer bulkScorer(LeafReaderContext context) throws IOException {
    final BulkScorer bulkScorer = booleanScorer(context);
    if (bulkScorer != null) {
      return bulkScorer;
    } else {
      //使用默认的BulkScorer
      return super.bulkScorer(context);
    }
  }

它重写了父类Weight的bulkScorer方法:先根据查询条件通过booleanScorer(context)方法尝试获取一个BulkScorer,如果没有获取到则还是使用默认的BulkScorer,该方法层层调用,代码量比较大,这里就不贴代码了,直接给出对应的流程:

首先它根据我们的查询字句判断我们的查询类别,将我们的查询在逻辑上分为了几个类别,比如只包含一个MUST,只包含多个SHOULD,只包含一个SHOULDE,包含MUST_NOT等等。对于不同的条件组合情况和coord的情况,会尝试创建不同的BulkScorer,比如ReqExclBulkScorer、BooleanScorer、BoostedBulkScorer等等。这些BulkScorer会完成对应情况的文档匹配和评分操作。由于我们的例子中,单纯的只包含三个SHOULD字句,所以最终返回的是BooleanScorer,我们现在着重看这个BulkScorer。

我们刚刚提到了,BulkScorer是通过Weight#bulkScorer()方法返回的,而我们的BooleanWeight其实是根据BooleanQuery树结构递归生成的Weight树,同样的,在创建BooleanWeight的BulkScorer的时候,也要根据Weight树的结构递归生成BulkScorer:

只是Query和Weight树的结构都是通过一个List类型的变量来实现的,而BulkScorer就没有那么简单。我们先看在我们整个情况下的BulkScorer树是如何生成的。

我们的BooleanWeight中包含有3个TermWeight(见上一篇博文),所以会创建这3个TermWeight的BulkScorer,TermWeight并没有重写Weight#bulkScorer,其默认实现如下:

public BulkScorer bulkScorer(LeafReaderContext context) throws IOException {
    Scorer scorer = scorer(context);
    if (scorer == null) {
      return null;
    }
    return new DefaultBulkScorer(scorer);
  }

先通过抽象的scorer方法创建一个Scorer,这个Scorer是一个抽象类,主要是为了给不同类型的查询提供通用的评分功能。通过它可以遍历所有匹配的文档,并且为他们进行打分,当然打分的具体操作还是Similarity实施的。Scorer最重要的方法为:

public abstract DocIdSetIterator iterator();

可以把DocIdSetIterator理解为一个迭代器,就是通过它来实现迭代评分的,我们马上就要提到。当然,我们说了scorer方法是一个抽象方法,需要子类去实现,我们来看一下TermQeury是如何实现scorer方法的:

 public Scorer scorer(LeafReaderContext context) throws IOException {
      assert termStates.topReaderContext == ReaderUtil.getTopLevelContext(context) : "The top-reader used to create Weight (" + termStates.topReaderContext + ") is not the same as the current reader's top-reader (" + ReaderUtil.getTopLevelContext(context);
      final TermsEnum termsEnum = getTermsEnum(context);
      if (termsEnum == null) {
        return null;
      }
      PostingsEnum docs = termsEnum.postings(null, needsScores ? PostingsEnum.FREQS : PostingsEnum.NONE);
      assert docs != null;
      return new TermScorer(this, docs, similarity.simScorer(stats, context));
    }

这里涉及到两个关键类,TermsEnumPostingsEnum

我们看一下getTermsEnum(context)的实现:

private TermsEnum getTermsEnum(LeafReaderContext context) throws IOException {
      //首先从termStates中获取当前context的TermState信息
      final TermState state = termStates.get(context.ord);
      if (state == null) {
        //如果term没有在该context中出现,则返回null
        return null;
      }
      final TermsEnum termsEnum = context.reader().terms(term.field())
          .iterator();
      termsEnum.seekExact(term.bytes(), state);
      return termsEnum;
    }

我们看到,首先要从termStates中获取对应context的TermState(关于termStates,在上文中已经讲过如何创建的了,它保存了每个包含该term的context下的TermState信息)。如果没有获取到,则说明该context下并不存在该term,返回null即可。

而关于TermsEnum,在上一篇博文也已经介绍了,它包含了该Term在当前Context中的基础信息,像docFreq、totalTermFreq、termBlockOrd等等,我们通过seekExact方法对该term进行定位,如果没有找到(定位失败)则会返回null。我们这里的TermsEnum其实是SegmentTermsEnum。

PostingsEnum是通过termsEnum#postings方法返回的。方法参数提供给我选择想要返回哪些数据,像频率、位置、偏移量、payloads等等。如果我们需要进行评分,则需要返回频率信息(frequencies)(后面会用到)。我们实际使用的是BlockDocsEnum,它是Lucene50PostingsReader的一个内部类,包含位置信息、文件指针等等(当然是针对当前context的),很多信息在我们创建IndexSearcher的时候,读取索引文件就创建好了。这个时候我们看代码就能发现,这里的PostingsEnum就是我们刚刚提到的DocIdSetIterator,它能读取对应的docId,在后面会有用。

BlockDocsEnum->PostingsEnum->DocIdSetIterator

在获取了PostingsEnum之后,还需要通过Similarity创建一个SimScorer,它主要用于对从倒排索引中匹配的文档进行评分。最终创建TermScorer:

return new TermScorer(this, docs, similarity.simScorer(stats, context));

然后将TermScorer包装为DefaultBulkScorer:

return new DefaultBulkScorer(scorer);

现在我们回到BooleanWeight创建BulkScorer的流程中。按照上述流程,对每个TermWeight都创建对应的BulkScorer。然后将其存入List<BulkScorer> 类型的变量 optional中,最终生成BooleanScorer,我们看一下BooleanScorer的构造方法:

  BooleanScorer(BooleanWeight weight, boolean disableCoord, int maxCoord, Collection<BulkScorer> scorers, int minShouldMatch, boolean needsScores) {
    if (minShouldMatch < 1 || minShouldMatch > scorers.size()) {
      throw new IllegalArgumentException("minShouldMatch should be within 1..num_scorers. Got " + minShouldMatch);
    }
    if (scorers.size() <= 1) {
      throw new IllegalArgumentException("This scorer can only be used with two scorers or more, got " + scorers.size());
    }
    //默认生成2048个长度的bucket,用于后面的批处理
    for (int i = 0; i < buckets.length; i++) {
      buckets[i] = new Bucket();
    }
    this.leads = new BulkScorerAndDoc[scorers.size()];
    this.head = new HeadPriorityQueue(scorers.size() - minShouldMatch + 1);
    this.tail = new TailPriorityQueue(minShouldMatch - 1);
    this.minShouldMatch = minShouldMatch;
    for (BulkScorer scorer : scorers) {
      if (needsScores == false) {
        scorer = BooleanWeight.disableScoring(scorer);
      }
      //将BulkScorer转换为BulkScorerAndDoc,存入堆
      final BulkScorerAndDoc evicted = tail.insertWithOverflow(new BulkScorerAndDoc(scorer));
      if (evicted != null) {
        head.add(evicted);
      }
    }
    //通过每个子BulkScore的cost计算本BulkScorer的cost
    //cost在这里可以简单理解为:docFreq
    this.cost = cost(scorers, minShouldMatch);

    coordFactors = new float[scorers.size() + 1];
    for (int i = 0; i < coordFactors.length; i++) {
      coordFactors[i] = disableCoord ? 1.0f : weight.coord(i, maxCoord);
    }
  }

通过方法的实现我们看到,在BooleanScorer的内部,其子BulkScorer并不是List结构了,而是通过前面我们提到的堆存储的。并且将BulkScorer转换成了BulkScorerAndDoc。

然后计算BooleanScorer的cost。关于cost的理解,它相当于一个PostingsEnum理论上能匹配到的最大文档数量,由于我们之前已经计算过该term的docFreq,所以此时默认cost就为docFreq。在计算总的cost的时候会把每个子cost进行相加,这里有个细节,若minShouldMatch大于1,则最终参与计算cost的BulkScorer不为所有的BulkScorer,这里会取最高的前bulkScorerSize-minShouldMatch+1的cost进行相加。我们的例子中就是相当于直接相加了。

最后就是计算coordFactors协调因子,计算方式和上文介绍的BooleanWeight的coords计算方式一样,这里就不赘述了。

创建BooleanScorer后续的流程还涉及到一些MUST_NOT的筛选操作等,以改变最终的BulkScorer,但和我们现在没有什么关系,最终会直接返回BooleanScorer,到这里我们顶层Weight(BooleanWeight)的BulkScorer(BooleanScorer)就创建完毕了。

三、收集doc

接下来就是调用该BulkScorer的scorer方法进行匹配和打分操作。我们只关注核心方法,该方法接受四个参数:

collector:为我们前面创建的LeafCollector

acceptDocs:标识对那些文档进行匹配,如果为空,则全部匹配,我们这里为空

min和max:表示匹配区间:[min,max),默认为[0,Integer.MAX_VALUE)

 public int score(LeafCollector collector, Bits acceptDocs, int min, int max) throws IOException {
    fakeScorer.doc = -1;
    collector.setScorer(fakeScorer);

    final LeafCollector singleClauseCollector;
    if (coordFactors[1] == 1f) {
      //按照我们coordFactors的计算方式
      //如果coordFactors[1] == 1f,则表明scorers.size==1,比如查询关键字分了多个term,
      //但是在当前context中只有一个term匹配到了
      //这个时候使用原本的collector即可
      singleClauseCollector = collector;
    } else {
      //否则需要使用一个代理类,重设了它的Scorer
      singleClauseCollector = new FilterLeafCollector(collector) {
        @Override
        public void setScorer(Scorer scorer) throws IOException {
          super.setScorer(new BooleanTopLevelScorers.BoostedScorer(scorer, coordFactors[1]));
        }
      };
    }
    
    //寻找大于等于min的docId
    BulkScorerAndDoc top = advance(min);
    while (top.next < max) {
      //迭代匹配
      top = scoreWindow(top, collector, singleClauseCollector, acceptDocs, min, max);
    }

    return top.next;
  }

我们看到,给collector设置的Scorer是一个叫做FakeScorer,它比较简单,等用到的时候我们在介绍。之后根据coordFactors的实际情况来决定是否需要使用Collector代理,紧接着会调用advance(min)方法,min==0。

我们前面讲了,每个子BulkScorer都被封装成了BulkScorerAndDoc,并且存储在BooleaScorer的堆结构中,且他们的next为-1,标识下一个匹配的docId。这个advance(min)方法的作用就是:将每个子BulkScorerAndDoc都找到匹配到的大于等于min的doc,使其next指向该docId,然后返回堆顶元素。

举个例子:如果我们的查询有两个Term:term1和term2。它们对应了两个BulkScorerAndDoc,存储在父级BulkScorer的堆结构中,它们的next默认值都为-1。能匹配term1的第一个docId=2,能匹配term2的第一个docId=5.。

那么调用advance(0)之后,他们的next值则分别指向2和5。

接下来就是核心的操作,通过scoreWindow循环匹配文档:

while (top.next < max) {
      top = scoreWindow(top, collector, singleClauseCollector, acceptDocs, min, max);
    }

循环的跳出条件为top.next<max,这个比较好理解,我们来看一下scoreWindow是如何实现的:

private BulkScorerAndDoc scoreWindow(BulkScorerAndDoc top, LeafCollector collector,
      LeafCollector singleClauseCollector, Bits acceptDocs, int min, int max) throws IOException {
    final int windowBase = top.next & ~MASK; // find the window that the next match belongs to
    final int windowMin = Math.max(min, windowBase);
    final int windowMax = Math.min(max, windowBase + SIZE);

    // Fill 'leads' with all scorers from 'head' that are in the right window
    leads[0] = head.pop();
    int maxFreq = 1;
    while (head.size() > 0 && head.top().next < windowMax) {
      leads[maxFreq++] = head.pop();
    }

    if (minShouldMatch == 1 && maxFreq == 1) {
      // special case: only one scorer can match in the current window,
      // we can collect directly
      final BulkScorerAndDoc bulkScorer = leads[0];
      scoreWindowSingleScorer(bulkScorer, singleClauseCollector, acceptDocs, windowMin, windowMax, max);
      return head.add(bulkScorer);
    } else {
      // general case, collect through a bit set first and then replay
      scoreWindowMultipleScorers(collector, acceptDocs, windowBase, windowMin, windowMax, maxFreq);
      return head.top();
    }
  }

我们先简单介绍一下这个流程:

前面我们已经对每个子BulkScorer通过advice(0)进行过定位,现在他们各自都指向自己匹配成功的第一个docId,作为自己接下来迭代匹配的起始docId。scoreWindow方法会对每个子BulkScorer都从各自的起始docId进行迭代匹配,匹配的时候,会将docId进行“拆分”,会以一个起始docId(nextDocId)开始进行一次迭代,每次最多匹配2048个doc,这个算法下面会详细解释。就这样依次迭代,直到匹配到的结果为空。而匹配的时候,当然并不会一个一个doc的匹配,这样索引就没有意义了。实际上通过DocIdSetIterator的nextDoc()方法,每次都能返回匹配成功的下一个docId,下面我们会结合代码进行解析。等所有子BulkScorer都迭代匹配完成之后,会将匹配到的每个docId提交给collector.collect()方法进行收集,最后再整合结果。同时在匹配的过程中,如果匹配成功,会记录每个docId匹配到的query数量,最终会根据我们的最少匹配值:minShouldMatch进行过滤,整合评分。

我们理出来几个关键的点,来介绍代码是如何实现的。

1、循环对每个BulkScorer进行匹配

2、通过DocIdSetIterator#nexDoc()返回下一个匹配成功的docId

3、如果doc匹配成功,需要记录该doc命中的query数量

4、对结果进行整合、评分

这些点是该检索过程中比较关键的地方,我们来逐一结合代码介绍实现。

对BulkScorer的循环匹配如下:

private void scoreWindowIntoBitSetAndReplay(LeafCollector collector, Bits acceptDocs,
      int base, int min, int max, BulkScorerAndDoc[] scorers, int numScorers) throws IOException {
    for (int i = 0; i < numScorers; ++i) {
      //获取每个BUlkScorerAndDoc进行检索
      final BulkScorerAndDoc scorer = scorers[i];
      assert scorer.next < max;
      scorer.score(orCollector, acceptDocs, min, max);
    }
       //整理每个BUlkScorerAndDoc检索的结果
    scoreMatches(collector, base);
    Arrays.fill(matching, 0L);
  }

我们看到,循环中其实是调用BulkSorerAndDoc#score方法进行匹配的,我们这里的score方法最终会调用Weight#scoreRange方法:

static int scoreRange(LeafCollector collector, DocIdSetIterator iterator, TwoPhaseIterator twoPhase,
        Bits acceptDocs, int currentDoc, int end) throws IOException 

该方法主要参数有:currentDoc:此次匹配会从它开始进行匹配,第一次它的值就是该BulkScorer匹配到的第一个docId,也就是advice(0)的结果;end:表示档次匹配最多匹配到多大的docId,第一次它的值就是我们前面介绍的2048;iterator:代表我们使用的用于查询的“迭代器”;towPhase:它也是一个“迭代器”,我们没有使用,不讨论他;然后就是我们熟悉的LeafCollector了,但是要注意,我们这里的collector是外部传入的OrCollector。下面我们解释。该方法的核心实现如下:

        while (currentDoc < end) {
          if (acceptDocs == null || acceptDocs.get(currentDoc)) {
            collector.collect(currentDoc);
          }
          currentDoc = iterator.nextDoc();
        }
        return currentDoc;

我们可以看到,while循环的跳出条件为currentDoc>=end。然后如果acceptDocs不为空,则需要看当前匹配的docId是否在acceptDocs中,如果不在其中,那么就会跳过该docId,否则调用collector.collect进行doc收集,我们上面也提到了这里使用的collector其实是OrCollector,它是BooleanScorer的内部类。我们看一下OrCollector#collect是如何实现的(为了方便解释,将windowBase的创建代码也一并贴在下面):

//BooleanScorer#scoreWindow:

//...
int windowBase = top.next & ~MASK; 
int windowMin = Math.max(min, windowBase);
int windowMax = Math.min(max, windowBase + SIZE);
//...


//OrCollector#collect:

public void collect(int doc) throws IOException {
      //MAST==2047,获取docId在Bucket数组中的索引位置
      final int i = doc & MASK;
      //获取在matching数组中的下标
      final int idx = i >>> 6;
      matching[idx] |= 1L << i;
      final Bucket bucket = buckets[i];
      //匹配到的docId.freq加1
      bucket.freq++;
      //将docId对于每个子query的得分加起来,用于最终得分的计算
      bucket.score += scorer.score();
    }

我们这里解释一下上面提到的将docId进行“拆分”和这个"window"是什么意思。

首先BooleanScorer中有如下定义:SIZE == 2048(2的11次幂),MASK==SIZE - 1==2047。

生成windowBase的算法是:top.next&~MASK。第一次查询的时候,我们的top.next的初始值其实就是advice(0)的结果,它相当于当前scorer匹配的起始docId。docId经过该计算之后,windowBase其实就是docId"抹除"低11位的结果。然后会基于它进行一个范围匹配:匹配的最小docId为我们传入的min和windowBase的最大值,匹配的最大值为传入的max和当前windowBase+SIZE的最小值。到现在这里我们其实已经明了了,这里做的操作其实很简单:就是我们传入一个起始匹配的docId,在匹配的时候,会以该docId为基础,进行批量匹配,而这批的数量最多就是2048,这样依次循环直到匹配结束。而这批次匹配到的docId的信息(其实也就是Bucekt,它包含匹配到的query数量和score总和)都会存在这个长度为2048的Bucket数组中。

这会儿我们回到collect方法,看一下是如何存储的。一个docId对应的Bucekt在数组中的下标计算怎么算呢?我们很容易能想到的方法是取余:docId % 2048。当然这样是没有问题的,但是有更高效的实现:doc&(2048-1)。还记得前面我们提到的windowBase吗?当前批次的docId都是"属于"一个windosBase下的,也就是他们的高21位是相同的,所以每个不同的docId,他们的低11位都不同,这样通过位操作在定位下标也会是唯一的,不会有什么问题(注:HashMap中计算一个hash在数组中的下标也是采取的这种方式)。其实Bucekt数组在一开始就初始化好了,其中freq和score默认值都为0,这样每个scorer匹配到一个docId,并将其Bucket信息存入Bucket数组的时候,都将对应位置的Bucket进行freq++操作,同样该query的分数也累加起来,用于后面过滤和最终得分计算。

我们说了,Bucekt只是docId对于查询的一些额外信息,那么docId本身该存储在哪里呢?这时候matching数组就派上用场了。matching是BooleanScorer的成员变量,是一个长度为32的long型数组,它就主要用于存储匹配到的docId,但是它的存储方式有点儿“特别”,我们这里简单介绍一下:

保存数据

首先,matching为一个默认长度为32的long型数组,docId是int类型的数值,而我们前面提到了Bucket为长度2048长度的数组。我们接受到一个docId,第一步需要通过doc & (2048-1),确定它在Bucket数组中的下标(i),该值的范围为:[0,2047]。然后将该下标值进一步处理生成在matching数组中的下标值,处理的方式是:i >>> 6。它将在Bucekt数组中的下标值无符号右移6位的结果作为matching数组的下标。由于i最高也就11位,无符号右移6位之后,就剩下5位。5位能表示的范围为:[0,31],正好能对应matchings数组的32个位置。

docId在matching数组中的下标我们是找到了,那么每个数组元素存什么值呢?这样定位下标的意义又是什么呢?我们该想想怎么样存储才能节约空间。大家应该已经注意到,流程到了这里,我们已经将原始的docId,拆成了两部分,低11位和高21位。高21位就是我们前面的windowBase。现在我们只需要处理这低11位就可以了,而我们同一批次的docId,其windowBase是相同的,这低11位又都对应到了Bucket数组的一个下标。但是我们上面也提到了,matching数组长度为32,它的下标是由i的高五位决定的,这样的话,就可能会出现不同的i会匹配到同一个matching下标的情况,比如:11111111110、11111111101、11111111100等等在matching的下标都为31。也就是类似于哈希冲突。有了上面定义的基础,解决这个问题就变得简单了。现在我们能保证不同的i,只要高5位不同,那么它们在matching数组的下标也就不同,我们只需要想办法怎么样把低6位存下来即可,并且要找到合适的方式解决冲突,这样我们通过matching数组的下标和低6位的值就能还原11位的i了,然后再根据windowBase就能还原整个docId了。可是怎么存低6位呢?一个6位的二进制数,能表示的最大范围就是[0,2^6 - 1],也就是[0,63]。我们知道,long型正好占64位。那我们直接根据这个6位数算2次幂,然后将结果通过按位或(|)的方式存入指定的下标位置不就行了?这样只要这个long型的数据中,只要一个位的值为1,那么就表示该位表示了一个数,这个数就是i的值的低6位表示。这里举个简单的例子进行说明,如果我们要存储多个不超过6位的int类型数据,当然可以用一个int数组进行存储,但是我们看用long来如何存,比如我们要存入1,2,3,23,62,63这些int类型的数值,在一个long中的表示,就是这样的:

             11000000 00000000 00000000 00000000 00000000 10000000 00000000 00001110

我们可以很清楚的看到,一个long可以表示0到63这一共64个int类型变量。这样只需要64位空间即可,但是如果使用int数组进行存储的话,则需要32*64=2048位,是不是差别很大?

所以我们看到,源码中对应的代码中是这样写的:matching[idx] |= 1L << i;  但是我们发现这里有个“问题”,我们刚刚说了,通过这种方式存储数据的前提条件是,数据不能超过6 位能表示的数,我们存入的应该是i的低6位才对,可是代码中却直接的对i算二次幂,而i的数据范围是[0,2047]啊。其实在JVM中做左移运算的时候,移动位数如果超过参与运算的数据类型的最大位数(int:32,long:64),实际移位的位数会是对最大位数取余的结果,这里就不详细说了,具体可以参考这篇博文。其实这样实际执行的操作是:1L << (i % 64)。基于这样的理论,这里通过按位或操作存入matching数组中的值也就是咱们的低6位了。

注:如果将1后面的L去掉的话:matching[idx] |= 1 << i;  实际计算就不是取低6位存储了,而是取的低5位了哦,这就会是一个惊天BUG了,哈哈

到这里,不论是windowBase、Bucket数组还是matching数组是如何使用的,我们已经完全清楚了。现在数据是存进去了,接下来我们看如何将docId从取出来。

读取数据

首先我们需要遍历matching数组,这个没得说。根据存入的逻辑我们知道,matching的下标表示的是高5位(i无符号右移6位的结果),我们只需要把下标再左移(<<)6位,即可还原高五位。现在我们只需要取出剩下的低6位就可以了。根据存数据的流程解析,我们知道matching数组中的每个元素可能都包含了多个数值(其实就是Bucket数组的下标)的“一部分”(低6位)。所以当我们遍历到一个元素,要把该元素中存的数据都取出来,比如若某一个元素(long类型)的值为(这就是上面我们的例子,我直接放在这里哈):

           11000000 00000000 00000000 00000000 00000000 10000000 00000000 00001110

我们需要把:1,2,3,23,62,63这些数取出来,这些数代表了各自数值的低6位。

可是怎么取呢?我们可以从低往高取:先把最低位的1代表的数取出来,而它代表的数就是它右边0的个数,比如10代表1,100代表2等等,这个是二进制的基础,忘记的朋友可以看这里(二进制:基础、正负数表示、存储与运算)。当我们获取到最右边的这个数之后就将对应的位设置为0,方便获取下一个数。具体到上面的64位例子,我们取低8位做一个演示:

                                                                        00001110

step1:获取10(1),00001110转换为:00001100

step2:获取100(2),00001100转换为:00001000

step3:获取1000(3),00001000转换为:00000000

step4:全是0,没有数据了,结束

这些10,100,1000的获取,其实我们只需要知道最右边有多少个0就可以了,如果有n个0,那么数就是2^n。正好java为我们提供了这样的方法:Long.numberOfTrailingZeros(long param)方法,该方法返回参数param最右边0的个数。获取到值之后,还需要进行转换操作,也很简单,假设我们取出来的最右边的0的个数是m,那我们则需要把低m+1为置为0:只需要把原值和1L<<m的值进行“异或”操作即可。比如:00001100 ^ (1<<2) = 00001100 ^ 00000100 = 00001000。

这样我们获取到了Bucket数组下标的低6位:ntz,算上高5位(也就是matching数组的下标):indx<<6,就能算出完成的Bucket数组下标了:idx << 6 | ntz。这时候我们已经可以根据windowBase还原docId了:windosBase | (idx << 6 | ntz)。当然这个docId也只是在当前context(或者说segment)中的docId,在索引库中的docId还需要加上对应segment的docBase。具体的代码如下:

for (int idx = 0; idx < matching.length; idx++) {
      long bits = matching[idx];
      while (bits != 0L) {
        //获取最右边0的个数
        int ntz = Long.numberOfTrailingZeros(bits);
        //这里的doc其实是Bucket数组的下标
        int doc = idx << 6 | ntz;
        //打分操作
        scoreDocument(collector, base, doc);
        //将取出来的数对应位置为0
        bits ^= 1L << ntz;
      }
    }

Lucene用这种特别的方式,配合windowBase将docId进行拆分使用long型数组存储,实现可谓非常的巧妙。

注:这里的scoreDocument方法就是实际的打分方法了,我们下面单独讲哈,这会儿先主要讲清楚doc文档的存储和读取就行了。

我们回到collect方法,其中还有个关键的内容:

bucket.score += scorer.score();

该方法会调用docScorer.scorer方法进行一次打分,我们这里的docScorer是ClassicSimilarity(TFIDFSimilarity)。我们来看一下该方法的实现:

//TFIDFSimilarity#score
public float score(int doc, float freq) {
      final float raw = tf(freq) * weightValue; // compute tf(f)*weight
      
      return norms == null ? raw : raw * decodeNormValue(norms.get(doc));  // normalize for field
    }

......

//ClassicSimilarity#tf
  public float tf(float freq) {
    return (float)Math.sqrt(freq);
  }

doc就是docId,freq为term在该文档中出现的频率。这就对应了我们评分公式中的:tf(t in d),我们已经讲过,它的默认计算公式为:

                                                    tf(t \ in \ q) = \sqrt{frequency}

这里的代码就是对该公式的实现。和tf(freq)相乘的weightValue就是我们前面计算QueryWeight归一化的结果,这里就派上用场了。最后还需要乘上归一化因子,该归一化因子是存储在索引中的所以需要调用decodeNormValue方法进行解码。

所以就实现了我们一下公式:

                                                         tf(t \ in \ q) \times norm(t,d)

这样把得分加到Bucket上,以供最后的打分使用。

我们前面讲了,通过iterator#nextDoc(),能返回匹配到的下一个docId。来看看它的方法是如何实现的:

public int nextDoc() throws IOException {
      //docUpto表示已经匹配了多少个doc,如果已经匹配了docFreq个了
      //说明不会再有更多了,就直接返回NO_MORE_DOCS(它的值是Integer.MAX_VALUE,代表无更多匹配)
      if (docUpto == docFreq) {
        return doc = NO_MORE_DOCS;
      }
      if (docBufferUpto == BLOCK_SIZE) {
        //重新读取缓存
        refillDocs();
      }
      //由于是差值存储,所以需要累加以获取实际值
      accum += docDeltaBuffer[docBufferUpto];
      docUpto++;

      doc = accum;
      freq = freqBuffer[docBufferUpto];
      docBufferUpto++;
      return doc;
    }

在前面介绍BulkScorer创建的时候我们已经提到过,这个terator就是一个DocIdSetIterator,它是一个抽象类,在我们的BulkScorer中使用的是BlockDocsEnum。它在是通过TermsEnum.postings方法创建的,已经返回了我们需要的信息,保存在了BlukScorer中留待我们score方法调用的时候使用。(可以回忆一下索引文件中都存了写什么数据)

在使用的时候,Lucene通过两个int数组来缓存docId和freq的信息,两个数组的长度是BLOCK_SIZE,而我们知道Lucene在存储整数的时候引入了压缩存储的技术来节约空间,这里使用的BLOCK_SIZE其实是根据对应的译码器来计算的。我们暂时不考虑这部分计算的内容,只需要知道这两个数组大小并不是写死的就行了。

代码写的很清楚,如果docBufferUpto == BLOCK_SIZE的时候,就会重新读取数据,缓存到数组里,接下来看看具体是怎么实现的:

private void refillDocs() throws IOException {
      //根据docFreq和docUpto(我们已经读取的数量)来计算还需要读取多少数据
      final int left = docFreq - docUpto;
      assert left > 0;

      if (left >= BLOCK_SIZE) {
        //如果我们要读取的数据超过了BLOCK_SIZE的大小,就可能不使用VInt格式读取数据了
        //这里会涉及到按字节读取,还有解码的操作
        forUtil.readBlock(docIn, encoded, docDeltaBuffer);

        if (indexHasFreq) {
          if (needsFreq) {
            forUtil.readBlock(docIn, encoded, freqBuffer);
          } else {
            forUtil.skipBlock(docIn); // skip over freqs
          }
        }
      } else if (docFreq == 1) {
        //这个是docFerq==1时的特殊处理,直接返回singletonDocID即可
        docDeltaBuffer[0] = singletonDocID;
        freqBuffer[0] = (int) totalTermFreq;
      } else {
        // Read vInts:
        readVIntBlock(docIn, docDeltaBuffer, freqBuffer, left, indexHasFreq);
      }
      docBufferUpto = 0;
    }

我们简单介绍一下VInt差值存储。它是一种“长度可变”的int,是一种数据压缩策略。每个字节的最高位用做标志位,后7位才是有效数据位,如果标志位为1,则说明后一个字节和当前字节是前一个数字的一部分,为0说明后一个字节是一个新的数字。像我们一个值为20的Int类型数据,正常需要4个字节存储,但是用VInt的话,只需要一个字节就可以了。当然,如果单纯站在VInt的角度看的话,要存一个至少需要29位才能表示的了的整形的话,就需要5个字节存储了。

注:Lucene还有其它的压缩算法,比如位压缩(bit-packing),这里就不详细介绍了。

关于差值存储,理解起来还是比较容易,就是当前值存储的是当前值和前一个值的差值。比如我们存储:1,3,8。这三个数字实际的存储为:1,2(3-1),5(8-3)。这样结合VInt能节约很多存储空间。

readVIntBlock方法就是以VInt的存储格式读取数据,最终就是调用IndexInput#readVInt();而我们这里的IndexInput其实是ByteBufferIndexInput$SingleBufferImpl。而我们的索引中是存储了freq信息的,所以在读取数据的时候需要把doc和freq都读取出来,放到对应的缓存数组中(IndexOptions中对应的了多种索引选项,可以翻代码瞅瞅)。

我们之前为了对每个scorer找到对应的第一个匹配到的docId(advice方法),其实已经调用过refillDocs()方法了,在我们这里调用scoreRange的时候,缓存数组里是有数据的,所以能直接:

通过 accum += docDeltaBuffer[docBufferUpto];获取doc

通过 freqBuffer[docBufferUpto];获取freq

但是我们要注意,这里的docId并不一定是正确的docId,需要加上当前context的docBase。

四、打分

当对我们每个BulkScorerAndDoc都执行了上述介绍的score方法之后,我们本轮需要的数据都被收集到matchings数组里了,关于matchings数组的数据如何存储和如何读取,上面已经介绍过了,这里不再赘述,我们着重介绍这里的scoreDocument方法:

private void scoreDocument(LeafCollector collector, int base, int i) throws IOException {
    final FakeScorer fakeScorer = this.fakeScorer;
    final Bucket bucket = buckets[i];
    //如果匹配到的query数量不符合我们的最低要求,则放弃该文档
    if (bucket.freq >= minShouldMatch) {
      //具体的打分操作,然后交由我们LeafCollector进行收集
      fakeScorer.freq = bucket.freq;
      fakeScorer.score = (float) bucket.score * coordFactors[bucket.freq];
      //计算docId,这个doc只是当前segment的相对值,全局的docId还需要加上docBase
      //为什么这样计算,前面讲数据的存储和读取的时候也讲过了
      final int doc = base | i;
      fakeScorer.doc = doc;
      collector.collect(doc);
    }
    bucket.freq = 0;
    bucket.score = 0;
  }

根据前面的解释,我们知道,方法签名中的base参数,其实传入的是windowBase,i其实是Bucket数组的下标。而Bucket里的元素对应的doc匹配的query数量:freq(当然还有scorer),在score的过程中已经计算好了,这里可以直接和我们的最低要求:minShouldMatch,进行比对,丢弃不满足要求的doc。

计算得分的时候,我们的coordFactors协调因子派上用场了。前面我们已经解释过,协调因子在检索的开始阶段就把匹配query数量的值已经计算好存入了数组中,我们要使用的时候,直接根据对应的匹配query数量到对应的下标位置获取即可,我们发现就是在这里操作这步的。

      fakeScorer.score = (float) bucket.score * coordFactors[bucket.freq];

而bucket.score我们在上文也解释过是如何计算的了,这时候直接将其与协调因子相乘。

而关于fakeScorer,我们前面只是简单提了一下,将它设置给了我们的collector。所以我们的这里collector.collect(doc)方法的里使用的就是fakeScorer,具体实现如下(这段代码在介绍getLeafCollector方法的时候已经贴过,这里为了直观感受逻辑,还是再贴一次哈):

public void collect(int doc) throws IOException {
          //这里的scorer就是我们的fakeScorer
          float score = scorer.score();
          //总共命中文档数量++
          totalHits++;
          if (score <= pqTop.score) {
            //我们前面提到了,创建collector的时候会创建numHits大小的pq
            //这里的元素都是默认值,而我们的docId是按顺序返回的,
            //如果score<=pqTop.score,根据我们的排序规则,它是不可能会进入到我们
            //的队列中的,所以直接忽略它
            return;
          }
          //实际的docId需要加上当前context的docBase,前面已经提到过了
          pqTop.doc = doc + docBase;
          pqTop.score = score;
          pqTop = pq.updateTop();
        }

我们前面也提到过,pq是以小(大)顶堆结构存储的,实际这里的pq是:HitQueue。它用于存储我们的命中文档。我们简单看一下它的一些定义:

//通过类定义,我们可以看出它存储的元素是ScoreDoc
final class HitQueue extends PriorityQueue<ScoreDoc> {
...
@Override
  protected final boolean lessThan(ScoreDoc hitA, ScoreDoc hitB) {
    //这里设定的排序规则
    if (hitA.score == hitB.score)
      return hitA.doc > hitB.doc; 
    else
      return hitA.score < hitB.score;
  }
...

}

可以看到,它排序的规则是:按照score和docId排序。这里是不会有排序操作的,最终在将数据放入TopDocs的过程中在按序获取,以实现排序。当本轮文档处理完毕之后,清空matching数组,以用于下一轮的匹配:

 Arrays.fill(matching, 0L);

就像这样一直迭代处理所有文档,而到现在我们整个评分公式的计算各个部分已经全部实现了,到这里计算的得分就是我们最终的得分了:

score(q,d)=coord(q,d)\times \sum_{t\ in\ q}{(tf(t \ in\ d)\times idf(t)^2\times t.getBoost()\times norm(t,d))}

按照前面讲的所有流程,会把所有的LeafReaderContext都收集一遍,当然,而每个context的结果都在各自的collector中,所以还剩最后一步:整合所有collector收集的数据。

五、整合结果

现在我们到了检索的最后一步:collectorManager.reduce方法。二话不多说,我们直接看其核心代码:

@Override
      public TopDocs reduce(Collection<TopScoreDocCollector> collectors) throws IOException {
        final TopDocs[] topDocs = new TopDocs[collectors.size()];
        int i = 0;
        for (TopScoreDocCollector collector : collectors) {
          //将每个collector收集的结果都存入TopDocs中
          //这里的TopDocs里的数据都是已经按照既定的排序规则排好序的
          topDocs[i++] = collector.topDocs();
        }
        //cappedNumHits就是我们设置的最终返回的记录条数
        return TopDocs.merge(cappedNumHits, topDocs);
      }

由于我们每个LeafReaderContext都对应一个collector,所以这里对所有collector收集的结果进行整合,最终存入TopDocs中返回。每个collector收集的结果都会对应一个TopDocs,最终交由TopDocs.merge方法进行合并,由于代码很多这里还是不贴了,主要就是根据排序、命中数等等条件对结果进行合并,代码不复杂,感兴趣的小伙伴可以自行查看。

六、总结

本文接着上文的内容,介绍了Lucene检索数据的整个流程:首先为每个LeafReaderContext获取对应的collector,然后根据我们的顶层Weight创建BulkScorer结构,接着通过BulkScorer#score方法进行文档收集和评分。当然我们是以BooleanScorer为例子介绍的:BooleanScorer包含多个子BulkScorer,我们需要对每个BulkScorer对当前context进行文档收集和评分,收集的过程中也会计算一些会参与最终评分的一些数值,像tf(t in d)等,收集完成后,就对所有收集到的数据进行过滤(比如最少匹配条件数)和评分,同时将评完分的数据放入当前的collector之中,同时我们也介绍了其它一些实现细节。最后把每个conetxt收集的数据通过reduce方法整合到TopDocs返回。

本文只是以一个具体的Query查询例子做的源码解析,不同的查询在具体的流程上会有些许的差异,比如前缀、模糊查询等还涉及到FST的读取,但是在大体上原理都是一样的。

我们还着重探究了Lucene在收集文档的过程中是如何进行docId和对应的Bucket信息存取的,虽然这种技巧不是一般人能想到的,但通过学习这些细节,也会给我们自身带来很大的提升。

注:本文是博主的个人理解,如果有错误的地方,希望大家不吝指出,谢谢

猜你喜欢

转载自blog.csdn.net/huangzhilin2015/article/details/89372127