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信息存取的,雖然這種技巧不是一般人能想到的,但通過學習這些細節,也會給我們自身帶來很大的提升。

注:本文是博主的個人理解,如果有錯誤的地方,希望大家不吝指出,謝謝

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章