Lucene檢索源碼解析(上)

有了Lucene得分公式(戳這裏看詳情)的基礎,我們現在先跳過寫索引的步驟,直接解析查詢這塊兒的代碼(還是基於5.5.0)。另外由於內容實在太多,所以文章分爲三部分介紹,第一部分主要介紹實際檢索前的一些處理,第二部分介紹檢索和評分,第三部分進行總結。

一、場景

假設現在已經有多個文檔被索引成功,索引目錄爲:D:\index。我們要對name域(Field)進行查詢,代碼如下:

Path path = Paths.get("D:\\index");
Directory directory = FSDirectory.open(path);
IndexReader indexReader = DirectoryReader.open(directory);
IndexSearcher indexSearcher = new IndexSearcher(indexReader);

Analyzer nameanalyzer = new StandardAnalyzer();
QueryParser nameParser = new QueryParser("name", nameanalyzer);
Query nameQuery = nameParser.parse("詹姆斯");

TopDocs topDocs = indexSearcher.search(nameQuery, 2);
......

二、查詢語句解析

第一步需要先做關鍵字識別、詞條處理等等,最終生成語法樹。

我們創建QueryParser的時候,使用的是StandardAnalyzer分詞器,analyzer主要完成分詞、去除停用詞、轉換大小寫等操作。QueryParser在parse生成Query的時候,會根據不同的查詢類型生成不同的Query,比如WildcardQuery、RegexpQuery、PrefixQuery等等。在本例中,最終生成的是BooleanQuery,“詹姆斯”被分爲三個詞:詹  姆  斯。(當然也根據實際情況也可以不用分詞,比如使用TermQuery)

BooleanQuery爲布爾查詢,支持四種條件字句:

MUST("+"):表示必須匹配該子句,類似於sql中的AND。

FILTER("#"):和MUST類似,但是它不參與評分。

SHOULD(""):表示可能匹配該字句。類似於sql中的OR,對於沒有MUST字句的布爾查詢,匹配文檔至少需要匹配一個SHOULD字句。

MUST_NOT("-"):表示不匹配該字句。類似於sql中的!=。但是要注意的是,布爾查詢不能僅僅只包含一個MUST_NOT字句。並且這些字句不會參與文檔的評分。

使用這些條件,可以組成很複雜的複合查詢。在我們的例子中,會根據分詞結果生成三個查詢子句,它們之間使用SHOULD關聯:

                                      name:詹 SHOULD name:姆 SHOULD name:斯

將上述查詢語句按照語法樹分析:

它表示的是:查詢“name”中包含“詹”或“姆”或“斯”的文檔。當然,我們可以有更加複雜的語法樹,比如我們加入“hobby”字段的查詢項:在上述基礎上還需要愛好籃球或電影:

它表示的是:查詢“name”中包含“詹”或“姆”或“斯” 並且 “hobby”中包含“籃球”或“電影”的文檔。這裏我們發現,整個語法樹的所有連接點,也就是非葉子節點,其實就是通過BooleanQuery實現的。像這樣就可以生成複雜的複合查詢,類似於SQL。

三、計算IDF

本例中,我們調用的:searcher.search(query,n)方法,它表示按照query查詢,最多返回包含n條結果的TopDocs,接下來我們詳細探索該方法的實現。

該方法默認調用的:searchAfter(after,query,n)方法,after參數可用於分頁查詢,在這裏after爲空:

我們來看看searchAfter是如何實現的:

public TopDocs searchAfter(final ScoreDoc after, Query query, int numHits) throws IOException {
    //reader是在我們讀取索引目錄的時候就生成的,reader.maxDoc會返回索引庫總共的文檔數量
    final int limit = Math.max(1, reader.maxDoc());
    if (after != null && after.doc >= limit) {
      throw new IllegalArgumentException("after.doc exceeds the number of documents in the reader: after.doc="
          + after.doc + " limit=" + limit);
    }
    //常規操作,numHits爲我們指定的查詢數量,如果大於文檔數量,則直接替換爲文檔數量
    //但是這裏沒必要比較兩次,個人認爲是這個版本此處的“Bug”
    numHits = Math.min(numHits, limit);
    final int cappedNumHits = Math.min(numHits, limit);
	
    final CollectorManager<TopScoreDocCollector, TopDocs> manager = new CollectorManager<TopScoreDocCollector, TopDocs>() {

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

      @Override
      public TopDocs reduce(Collection<TopScoreDocCollector> collectors) throws IOException {
        final TopDocs[] topDocs = new TopDocs[collectors.size()];
        int i = 0;
        for (TopScoreDocCollector collector : collectors) {
          topDocs[i++] = collector.topDocs();
        }
        return TopDocs.merge(cappedNumHits, topDocs);
      }

    };

    return search(query, manager);
  }

我們發現該方法中創建了一個CollectorManager,這個CollectorManager是一個接口,它用於並行化的處理查詢請求。怎麼叫並行化的處理呢?我們知道Lucene的索引目錄結構中有個很重要的內容:segment(段)。索引由多個段組成,Lucene需要針對所有的segment(段)進行文檔收集,然後根據需要將結果進行彙總。IndexSearcher給我們提供了入口設置線程池,通過線程池,我們可以並行的對多個段進行索引,以提高檢索效率。該接口中只有兩個方法,

public interface CollectorManager<C extends Collector, T> {

  C newCollector() throws IOException;

  T reduce(Collection<C> collectors) throws IOException;
  
}

1:CollectorManager#newCollector()。創建一個Collector,Collector主要用於收集search的原始結果,並且實現排序、自定義結果過濾等等。但是需要保證每次查詢都返回一個新的Collector,也就是不能被複用。Collector也是一個接口,包含兩個方法:

public interface Collector {

  LeafCollector getLeafCollector(LeafReaderContext context) throws IOException;
  
  boolean needsScores();
}

注:關於Collector在檢索階段再詳細介紹,我們現在知道有這麼個東西就可以了。

1.1:Collector

1.1.1:Collector#getLeafCollector方法接受LeafReaderContext參數,返回一個LeafCollector。

注:關於LeafReaderContext,我們現在可以簡單的將其當做是索引中每個segment(段)的“代表”,它包含segment(段)的一些基礎數據和父級Context,後面會介紹。

LeafCollector還是爲一個接口,Lucene使用它將收集文檔和對文檔打分解耦,其包含兩個方法:

public interface LeafCollector {

  
  void setScorer(Scorer scorer) throws IOException;

  void collect(int doc) throws IOException;

}

1.1.2:Collector#needsScores,就和方法名描述的一樣,此方法返回的布爾值結果表示該collector是否需要對匹配文檔進行打分。

1.2 LeafCollector

LeafCollector#collect方法用於收集文檔,setScorer則用於配置打分器(Scorer),如果我們不需要對文檔進行打分,那麼就不用設置它;如果要打分的話,就需要在collect方法的實現中調用scorer來計算文檔的得分。

2:CollectorManager#reduce()。用於將每個collector收集的結果轉化爲“有意義”的結果。具體要看對應的Collector如何實現它。比如,TopDocsCollector會計算每個collector的topDocs,然後將他們進行合併。該方法必須在所有的collector完成收集之後再被調用。

我們現在回到search方法中,生成CollectorManager後,會調用search(query,collectorManager)方法,我們來看看該方法的實現:

public <C extends Collector, T> T search(Query query, CollectorManager<C, T> collectorManager) throws IOException {
  //executor爲線程池,可以在創建IndexSearcher的時候指定
  if (executor == null) {
    final C collector = collectorManager.newCollector();
    search(query, collector);
    return collectorManager.reduce(Collections.singletonList(collector));
  } else {
    final List<C> collectors = new ArrayList<>(leafSlices.length);
    boolean needsScores = false;
    for (int i = 0; i < leafSlices.length; ++i) {
      final C collector = collectorManager.newCollector();
      collectors.add(collector);
      needsScores |= collector.needsScores();
    }
    final Weight weight = createNormalizedWeight(query, needsScores);
    final List<Future<C>> topDocsFutures = new ArrayList<>(leafSlices.length);
    for (int i = 0; i < leafSlices.length; ++i) {
      final LeafReaderContext[] leaves = leafSlices[i].leaves;
      final C collector = collectors.get(i);
      topDocsFutures.add(executor.submit(new Callable<C>() {
        @Override
        public C call() throws Exception {
          search(Arrays.asList(leaves), weight, collector);
          return collector;
        }
      }));
    }
    final List<C> collectedCollectors = new ArrayList<>();
    for (Future<C> future : topDocsFutures) {
      try {
        collectedCollectors.add(future.get());
      } catch (InterruptedException e) {
        throw new ThreadInterruptedException(e);
      } catch (ExecutionException e) {
        throw new RuntimeException(e);
      }
    }
    return collectorManager.reduce(collectors);
  }
}

我們看到第一步就是看executor是否爲空,這個executor是一個線程池,可以在IndexSearcher創建的時候設定,我們上面提到了searcher的並行搜索,就是使用這個線程池來實現的。當然,在我們的例子中,並沒有使用線程池。

然後調用 search(query, collector)方法,該方法主要完成匹配文檔的收集和打分(如果需要的話),我們來看一下該方法的實現:

public void search(Query query, Collector results)
  throws IOException {
  search(leafContexts, createNormalizedWeight(query, results.needsScores()), results);
}

第一步調用了一個方法:createNormalizedWeight。該方法接受query和needsScores參數,返回一個Weight。這裏就涉及到我們的評分公式了。還記得我們在Lucene評分公式解析中說的,某一些和具體文檔(Document)無關的部分可以在查詢開始的時候就可以計算嗎?就是這裏了,我們看一下該方法的概況:

public Weight createNormalizedWeight(Query query, boolean needsScores) throws IOException {
  query = rewrite(query);
  Weight weight = createWeight(query, needsScores);
  float v = weight.getValueForNormalization();
  float norm = getSimilarity(needsScores).queryNorm(v);
  if (Float.isInfinite(norm) || Float.isNaN(norm)) {
    norm = 1.0f;
  }
  weight.normalize(norm, 1.0f);
  return weight;
}

該方法首先會將query進行重寫,重寫的意思是將查詢轉化爲基本查詢,一個典型的例子就是:一個前綴查詢會被重寫爲一個由TermQuerys組成的BooleanQuery;當boost!=1的時候(包括每個子查詢),會生成BoostQuery。當然,我們的例子中就是簡單的BooleanQuery,也沒有設置boost,所以不會發生實質性的重寫,BooleanQuery下的每個clause是TermQuery。

接下來是創建Weight。它的目的是保證接下來的搜索操作不會改變經歷過重寫的query,以便可以複用query實例。那麼它到底是幹什麼的呢?

Weight由頂級查詢(top-query)構建,它會提供最終加權算法中的和query相關的部分(和具體Document無關)。我們前面講了,一個頂級查詢可以包含很多的子查詢。同樣,Weight的創建,會“遞歸”創建,對所有Query都會創建Weight,上層Weight通過一個List存有下層Weight列表的引用,就像一棵樹一樣:

在介紹如何創建Weight之前,必須要介紹一下IndexReader和IndexReaderContext這兩個東西。

3. IndexReader

我們可將它理解爲讀取索引的媒介,但是它讀取索引並不是“實時”的,如果索引發生了更新,對於已經創建的IndexReader是不可見的,除非重新打開一個Reader,或者使用Lucene爲我們提供的DirectoryReader#openIfChanged,關於IndexReader的管理可以參考這篇博文。IndexReader主要分爲兩個大類型:LeafReader 和 CompositeReader。

3.1 LeafReader

它是一個抽象類我們的索引檢索工作,最終都是依賴這個類型的Reader實現,它有一些子類,比如CodecReader。CodeReader基於Lucene的編碼實現索引檢索,SegmentReader就是CodeReader的子類,通過SegmentReader我們可以讀取對應segment(段)的數據。LeafReader 從名字上我們就能看出來,它是“原子的”,不再包含其它子Reader。同時出於效率考慮,LeafReader通過docId返回文檔,docId是唯一的正整數,但是它隨着文檔的更新或刪除,可能會發生改變。

3.2 CompositeReader

顧名思義,它作爲一種複合Reader,持有多個LeafReader,只能通過它持有的LeafReader獲取字段,它不能直接對倒排表進行檢索。我們通過DirectoryReader#open(Directory)創建的Reader其實就是一種CompositeReader。它通過List類型的變量持有多個SegmentReader,而我們上面提到了,SegmentReader就是一種LeafReader。

3.3 IndexReaderContext

可以理解爲IndexReader的“上下文”,而它同樣表徵了IndexReader之間的層次關係,每個IndexReader都有一個IndexReaderContext。它通過IndexReader創建。就像一個CompositeReader下可能包含有多個LeafReader 一樣,一個CompositeReaderContext可能有多個LeafReaderContext,一個IndexReaderContext則有它的父級CompositeReaderContext。我們前面提到了,它包含一些segment的基礎信息,通過它我們可以方便的獲取該Leaf(可以簡單理解爲segment)的docBase(docId在該segment中的起始值)、ord(sengment序號)等等。

注:我們知道每個segment裏的docId是按照增量計算的。也就是每個segment有一個起始docId(docBase),segment中具體Document的docId=docBase+indexInSegment

介紹完這些核心的類,我們接着創建Weight的流程往下說。創建weight其實是通過調用對應的query#createWeight方法實現的:

public Weight createWeight(Query query, boolean needsScores) throws IOException {
    final QueryCache queryCache = this.queryCache;
    Weight weight = query.createWeight(this, needsScores);
    if (needsScores == false && queryCache != null) {
      weight = queryCache.doCache(weight, queryCachingPolicy);
    }
    return weight;
  }

我們前面提到了,在我們的例子中,有一個頂層Query,它是一個BooleanQuery,該Query下面的子Query是多個TermQuery:

所以創建Weigh其實是調用的BooleanQuery的createWight方法,我們直接看對應的實現的上半部分(下半部分後面再解釋):

BooleanWeight(BooleanQuery query, IndexSearcher searcher, boolean needsScores, boolean disableCoord) throws IOException {
    super(query);
    this.query = query;
    this.needsScores = needsScores;
    this.similarity = searcher.getSimilarity(needsScores);
    weights = new ArrayList<>();
    int i = 0;
    int maxCoord = 0;
    for (BooleanClause c : query) {
      Weight w = searcher.createWeight(c.getQuery(), needsScores && c.isScoring());
      weights.add(w);
      if (c.isScoring()) {
        maxCoord++;
      }
      i += 1;
    }
    //...
  }

我們先關注for循環這塊兒的內容。可以看到,循環做的事很簡單:把該BooleanQuery下的所有子Query(BooleanClause)都拿出來分別創建對應的Weight,然後將其放到List類型的weights變量中。我們上面說了,這裏的每個子Query是TermQuery,所以我們接下來着重看一下實際調用的TermQuery#createWeight方法:

public Weight createWeight(IndexSearcher searcher, boolean needsScores) throws IOException {
    final IndexReaderContext context = searcher.getTopReaderContext();
    final TermContext termState;
    if (perReaderTermState == null
        || perReaderTermState.topReaderContext != context) {
      termState = TermContext.build(context, term);
    } else {
      termState = this.perReaderTermState;
    }

    return new TermWeight(searcher, needsScores, termState);
  }

我們看到,第一步先從IndexSearcher獲取了IndexReaderContext,這裏的IndexReaderContext就是我們前面提到的CompositeReaderContext,它持有了一個或多個LeafReaderContext。

然後是創建TermContext。perReaderTermState可以理解爲一個“緩存”。在創建一次之後,我們可以將其保留起來,只要Context沒發生變更就可以重用該TermContext。那麼TermContex是幹嘛的呢?

我們看到,TermContext是通過TermContext#build方法創建的,該方法是一個靜態方法,參數包含IndexSearcher和Term。它會在所有的LeafReaderContext中尋找指定的Term,如果某一個LeafReaderContext包含指定的Term,那麼就會將對應的Context通過其序號(oridinal)註冊到TermContext中,最後將TermContext返回。核心代碼如下:

public static TermContext build(IndexReaderContext context, Term term){
    final String field = term.field();
    final BytesRef bytes = term.bytes();
    final TermContext perReaderTermState = new TermContext(context);
    for (final LeafReaderContext ctx : context.leaves()) {
    //從context中查詢term
    final Terms terms = ctx.reader().terms(field);
    if (terms != null) {
      final TermsEnum termsEnum = terms.iterator();
      if (termsEnum.seekExact(bytes)) { 
        //如果找到到了該term,則返回其TermState
        final TermState termState = termsEnum.termState();
        //註冊到TermContext
        perReaderTermState.register(termState, ctx.ord, termsEnum.docFreq(), termsEnum.totalTermFreq());
        }
      }
    }
}

我們可以看到,通過ctx.reader().terms(field)可以獲取此context下對應field的terms信息,可以暫時簡單理解爲:獲取此context下包含哪些term(Terms)。如果獲取的terms不爲空,則會通過調用其iterator()方法,返回一個TermsNum。

這個TermsEnum是一個抽象類,可以把它理解該context下指定field下的terms的迭代器描述。而裏面的Term都是通過BytesRef表示的,涉及到前綴存儲等。我們可以通過TermsEnum的seek*方法或next遍歷對指定的Term(BytesRef)進行定位,如果找到了(定位成功),那麼就可以直接獲取它的TermState。這個TermState,就相當於該Term在當前Context中一些狀態信息的描述,它我們可以查找該term的頻率信息、倒排信息、處於哪一個block等等。比如:

docFreq:此context中,包含該term的文檔數量(注:從這裏往後說到的contex可以簡單對應segment)

totalTermFreq:此context中,該term在所有文檔中出現的總次數

比如,若索引庫中有3個文檔,文檔只有一個field:name,分詞爲一元分詞:

doc1:name:詹姆斯詹

doc2:name:詹娃娃

doc3:name:姆斯

那麼,對於field:name,term:“詹” 來說,docFreq=2(doc1和doc2包含該term);totalTermFreq=3(doc1中出現2次,doc2中出現1次),docFreq和totalTermFreq是後面計算的重要指標,我們一會兒就會用到。

我們有了TermState之後,就可以創建TermWeight了:

new TermWeight(searcher, needsScores, termState);

我們看到,TermWeight的構造方法需要三個參數:

searcher:就是我們的IndexSearcher;

needsScores:布爾型變量,表明是否需要進行評分(還記得前面提到的Collector#needsScores()嗎?);

termState:就是上面我們獲取的TermState。

我們來看一下其構造函數具體是怎麼實現的:

public TermWeight(IndexSearcher searcher, boolean needsScores, TermContext termStates)
        throws IOException {
      super(TermQuery.this);
      this.needsScores = needsScores;
      assert termStates != null : "TermContext must not be null";

      assert termStates.hasOnlyRealTerms();
      this.termStates = termStates;
      this.similarity = searcher.getSimilarity(needsScores);

      final CollectionStatistics collectionStats;
      final TermStatistics termStats;
      if (needsScores) {
        collectionStats = searcher.collectionStatistics(term.field());
        termStats = searcher.termStatistics(term, termStates);
      } else {

        final int maxDoc = searcher.getIndexReader().maxDoc();
        final int docFreq = termStates.docFreq();
        final long totalTermFreq = termStates.totalTermFreq();
        collectionStats = new CollectionStatistics(term.field(), maxDoc, -1, -1, -1);
        termStats = new TermStatistics(term.bytes(), docFreq, totalTermFreq);
      }
     
      this.stats = similarity.computeWeight(collectionStats, termStats);
    }

我們現在關注代碼中的關鍵點。首先從searcher中獲取了一個Similarity。這個Similarity是一個抽象類,相當於我們的評分組件,可以在初始化IndexSearcher的時候指定。如果我們要自定義該組件,就繼承該抽象類實現對應的方法,然後設置到IndexSearcher中即可。由於我們這裏沒有指定,獲取的就是默認的Similarity:DefaultSimilarity。這個DefaultSimilarity繼承自ClassicSimilarity,而ClassicSimilarity繼承自TFIDFSimilarity,也就是我們上篇博文提到的TFIDF算法實現。

DefaultSimilarity->ClassicSimilarity->TFIDFSimilarity

注:目前DefaultSimilarity 已經被棄用了!默認的評分組件已經在6.0的版本更換成了BM25Similarity:一種在TF-IDF的基礎上改進後的加權算法。如果我們還要繼續使用DefaultSimilarity ,直接使用ClassicSimilarity即可。當然我們當前版本是5.5,重點也並不是BM25Similarity,感興趣的同學可以去研究一下。

接下來,如果我們需要評分的話,就需要通過searcher獲取該Field的一些基礎信息(CollectionStatistics),比如:

maxDoc:該context中的文檔數量(無論文檔是否包含該field)。

docCount:該context中,包含該field,且field下至少有一個term(不需要和指定term相同)的文檔數量。

sumDocFreq:該context的所有文檔中,該field下,所有詞的docFreq的總和。

sumTotalTermFreq:該context中的所有文檔中,該field下,所有詞的totalTermFreq的總和。

可能某些參數理解起來有點晦澀,我們還是舉例子進行說明:

假設索引庫中有4個文檔,文檔只有一個field:name,分詞爲一元分詞:

doc1:name:詹姆斯詹

doc2:name:詹娃娃

doc3:name:姆斯

doc4:name:""

那麼在這個索引庫中,對於field:name,term:“詹”,有如下結論:

maxDoc = 4:總共有4個文檔;

docCount = 3:4個文檔都包含name域,但是doc4的name域不包含任何詞條,所以它不能算在docCount裏面(如果沒有name域,則更不能算在docCount了噻);

sumDocFreq = 7:docFreq(詹)=2;docFreq(姆)=2;docFreq(斯)=2;docFreq(娃)=1;全部加起來就是7(docFreq的計算上面已經介紹過了);

sumTotalTermFreq = 9:totalTermFreq(詹)=3;totalTermFreq(姆)=2;totalTermFreq(斯)=2;totalTermFreq(娃)=2;全部加起來就是9(totalTermFreq的計算上面已經介紹過了);

有了這些指標之後,就可以使用Similarity進行權重計算了,具體的方法就是Similarity#computeWeight:

public final SimWeight computeWeight(CollectionStatistics collectionStats, TermStatistics... termStats) {
    final Explanation idf = termStats.length == 1
    ? idfExplain(collectionStats, termStats[0])
    : idfExplain(collectionStats, termStats);
    return new IDFStats(collectionStats.field(), idf);
  }

我們看到,該方法主要就是計算idf,然後將結果封裝到IDFStates返回。而根據termStats的數量不同,調用的是不同的方法,雖然方法不同,但是計算idf的原理都一樣,我們看單個termStats的idfExplain方法是如何實現的:

public Explanation idfExplain(CollectionStatistics collectionStats, TermStatistics termStats) {
    final long df = termStats.docFreq();
    final long max = collectionStats.maxDoc();
    final float idf = idf(df, max);
    return Explanation.match(idf, "idf(docFreq=" + df + ", maxDocs=" + max + ")");
  }

public float idf(long docFreq, long numDocs) {
    return (float)(Math.log(numDocs/(double)(docFreq+1)) + 1.0);
  }

看到計算公式是不是很熟悉了?我們來回顧一下Lucene的打分公式(實際使用):

                                 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))}

還記得我們提到的idf(t)的計算公式嗎?

                                       idf(t)=1+log(\frac{docCount}{docFreq+1})

對應到代碼中,numDocs即公式中的docCount,也就是collectionStats.maxDoc():context中的文檔總數量;docFreq即公式中的docFreq,也就是termStats.docFreq():此context中,包含該term的文檔數量。這裏的代碼邏輯,就是對該公式的實現。

創建好的IDFStat最終會保存在TermWeight中。就這樣我們就創建好一個Weight了,當然創建好之後還涉及到一些緩存的操作,我們這裏不關注它。就像這樣,頂層BooleanQuery下的每個clause(TermQuery)都會創建好對應的Weight(TermWeight),然後以List的形式保存在頂層BooleanWeight(BooleanQuery的Weight)的weights成員變量中。

四、計算coord

到現在我們創建BooleanWeight方法的前半部分說完了,接下來看後半部分:

    //前半部分
    super(query);
    this.query = query;
    this.needsScores = needsScores;
    this.similarity = searcher.getSimilarity(needsScores);
    weights = new ArrayList<>();
    int i = 0;
    int maxCoord = 0;
    for (BooleanClause c : query) {
      Weight w = searcher.createWeight(c.getQuery(), needsScores && c.isScoring());
      weights.add(w);
      if (c.isScoring()) {
        maxCoord++;
      }
      i += 1;
    }
    this.maxCoord = maxCoord;
    //後半部分
    this.maxCoord = maxCoord;
    coords = new float[maxCoord+1];
    Arrays.fill(coords, 1F);
    coords[0] = 0f;
    if (maxCoord > 0 && needsScores && disableCoord == false) {
      boolean seenActualCoord = false;
      for (i = 1; i < coords.length; i++) {
        coords[i] = coord(i, maxCoord);
        seenActualCoord |= (coords[i] != 1F);
      }
      this.disableCoord = seenActualCoord == false;
    } else {
      this.disableCoord = true;
    }

我們在這裏發現了一個新的成員變量:coords,這個coords又是幹嘛的呢?還記得我們在解析打分公式的時候提到過,Lucene爲我們提供了一個“協調因子”:coord-factor(q,d),這個東西再代碼層面的體現就是這個float數組類型的coords。我們來看一下Lucene是如何處理這個coords的。

從上面代碼就可以看出來,coords的數量是根據maxCoord+1計算的,而maxCoord是根據當前子Query的isScoring()方法決定是否增加的,我們看一看這個isScoring方法:

public boolean isScoring() {
    return occur == Occur.MUST || occur == Occur.SHOULD;
  }

代碼很清晰,只有條件是MUSTSHOULD的時候,該方法纔會返回true,所以FILTERMUST_NOT是不會影響它的,這和我們前面介紹BooleanQuery時說的一致:MUST_NOTFILTER不參與評分。在我們的例子中,一個有三個clause,條件均爲SHOULD,所以這裏的maxCoord==3。自然,coords數組的長度爲3+1==4。並且通過Arrays.fill(coords, 1F); 將coords[0]設爲0f,其它每個元素的值都設爲默認值:1f。至於爲什麼要這樣操作,我們介紹完該方法再解釋。

注:關於disableCoord,我們可以設置BooleanQuery的disableCoord參數爲true,通過這個手段來禁用該“協調因子”。但是現在我們不考慮禁用的情況。

我們看代碼中對coords的循環:

      boolean seenActualCoord = false;
      for (i = 1; i < coords.length; i++) {
        coords[i] = coord(i, maxCoord);
        seenActualCoord |= (coords[i] != 1F);
      }
      this.disableCoord = seenActualCoord == false;

循環直接跳過了coords[0],從coords[1]開始循環,然後將方法coord(i,maxCoord)計算的結果放到對應的位置,我們來看一下coord(i,maxCoord)方法是何方神聖:

  public float coord(int overlap, int maxOverlap) {
    if (overlap == 0) {
      return 0F;
    } else if (maxOverlap == 1) {
      return 1F;
    } else {
      return similarity.coord(overlap, maxOverlap);
    }
  }

方法中做了簡單的判斷,如果不爲極端值的話,將兩個參數傳入了similarity的coord方法,交由similarity計算,我們來看看我們使用的ClassicSimilarity是如何實現的(前面已經提到了幾個Similarity之間的關係,這裏就不贅述了哈):

public float coord(int overlap, int maxOverlap) {
    return overlap / (float)maxOverlap;
  }

看到這裏是不是又覺得熟悉了?看看上面貼出的打分公式中,我們的coord(q,d)是如何計算的,還記得嗎?(戳這裏回顧一下

                                                             coord(q,d)=\frac{overlap}{maxOverlap}

我們看到,這裏代碼的邏輯,就是對上述公式的實現。我們再來回顧一下代碼的實現。BooleanWeight的coord方法中,做了兩個極端值的判斷,overLap==0和maxOverlap==1。overLap==0只有在沒有isScoring()從句的查詢的時候纔會出現;而關於maxOverlap==1,則是緣起Lucene之前的一個Bug,見LUCENE-4300。這個bug已經修復了,jira裏面是說的很清楚,感興趣的可以看看。而關於公式中overlap和maxOverlap的定義,解析公式的博文中已經介紹了:

overlap表示:文檔中匹配的“詞條”的數量;maxOverlap表示:查詢中所有“詞條”的數量

明顯,我們的例子中,每個詞條都被添加到了一個子查詢(BooleanClause)中,他們通過SHOULD組合。maxOverlap就應該等於子查詢的數量;而overlap如何理解呢?我們假設有n個子查詢,那麼overlap的情況只有0,1,2...n 這些情況,表示匹配0個,1個,2個,...,n個。到現在我們就能理解上面代碼的邏輯了:maxCoord在這裏就是n,再加上匹配0個的情況,所以coords的長度爲maxCoord+1。而通過遍歷的操作,將各個情況的coord都計算好,放到對應的數組位置上,以供後面使用。比如最終的結果就是:0,1/n,2/n,3/n,...,n/n。這些值就是不同overlap取不同值時的coord的值。最後在使用的時候根據實際的匹配情況獲取對應位置的coord即可。

五、計算queryNorm

到這裏BooleanWeight就已經創建完畢,我們的createWeight也告一段落了。我們回到最開始的createNormalizedWeight方法:

public Weight createNormalizedWeight(Query query, boolean needsScores) throws IOException {
    query = rewrite(query);
    //這個createWeight我們已經解析完畢
    Weight weight = createWeight(query, needsScores);
    float v = weight.getValueForNormalization();
    float norm = getSimilarity(needsScores).queryNorm(v);
    if (Float.isInfinite(norm) || Float.isNaN(norm)) {
      norm = 1.0f;
    }
    weight.normalize(norm, 1.0f);
    return weight;
  }

我們剛剛把createWeight(query,needsScores)分析完,接下來調用了weight.getValueForNormalization()方法,明顯我們這裏回調用BooleanWeight的該方法,瞅瞅:

public float getValueForNormalization() throws IOException {
    float sum = 0.0f;
    int i = 0;
    for (BooleanClause clause : query) {

      float s = weights.get(i).getValueForNormalization(); 
      if (clause.isScoring()) {
        sum += s;
      }
      i += 1;
    }

    return sum ;
  }

此方法的實現看起來也比較簡單,就是把所有子項的weight調用getValueForNormalization()方法,將isScoring()的子項的返回值相加,最後返回,我們這裏的clause爲TermWeight,瞅瞅:

public float getValueForNormalization() {
      return queryWeight * queryWeight;  // sum of squared weights
    }

我們看到,這裏就是把queryWeight去平方,但是這個queryWeight是哪裏來的呢?想想我們前面提到的生成IDFStats,其實在創建IDFStats的時候,就會進行歸一化處理:

public IDFStats(String field, Explanation idf) {
      this.field = field;
      this.idf = idf;
      normalize(1f, 1f);
    }

public void normalize(float queryNorm, float boost) {
      this.boost = boost;
      this.queryNorm = queryNorm;
      queryWeight = queryNorm * boost * idf.getValue();
      value = queryWeight * idf.getValue();
    }

normalize方法主要根據傳入的查詢歸一化因子和boost來更新權重,從其實現中可以看出來,我們這一步這裏的查詢權重(queryWeight) = 1 * 1 * idf,其實也就是idf的值。那麼getValueForNormalization方法返回的值其實也就是idf的平方。

然後將BooleanWeight#getValueForNormalization方法的歸一化結果傳入Similarity#queryNorm()中用以計算最終的歸一化值,我們看看ClassicSimilarity的實現:

public float queryNorm(float sumOfSquaredWeights) {
    return (float)(1.0 / Math.sqrt(sumOfSquaredWeights));
  }

操作很簡單,就是開平方,再取倒數。我們整理一下這裏的公式:將BooleanWeight下的所有weight的queryWeight平方的結果加起來,最後開平方,然後取倒數。熟悉嗎?我們發現,這就是我們前面博文提到的queryNorm的計算公式:

六、歸一化

然後回到我們的IndexSearcher#createNormalizedWeight方法。計算好queryNorm之後,將norm作爲參數傳入weight,再次進行歸一化處理:

weight.normalize(norm, 1.0f);

這裏處理和我們前面做歸一化處理一樣了,前面我們只處理is-scoring的字句(也就是SHOULD或MUST),這裏會對所有的字句都進行處理:

public void normalize(float norm, float boost) {
    for (Weight w : weights) {
      w.normalize(norm, boost);
    }
  }

normalize(norm,boost),前面已經介紹了,這裏就不再贅述。差別就是之前的queryNorm是1,這裏的queryNorm是根據公式計算得出的。最終得到的值其實就是:queryNorm * idf * idf。

到現在,我們的頂級查詢(BooleanQuery)的整個歸一化之後的Weight纔算真正創建完畢。這樣我們最終打分公式中的一些和具體Document無關的部分已經計算完畢了,到現在都還沒有在索引庫中進行實質性的檢索,接下來的纔是真正的檢索操作。

七、總結

本文詳細介紹了Query本身的評分因子的計算流程,這些評分因子是和具體的Document無關的,所以我們可以在查詢開始的時候就計算好,最終都包裝在Weight對象中。就像Query的樹狀關係一樣,Weight的創建也是根據Query樹的結構進行遞歸創建,最終生成一顆Weight樹,當然也涉及了一些歸一化操作。在這個過程中我們引出了Lucene打分公式中的一些部分的計算,比如coord、queryNorm等。下文開始介紹Lucene是如何進行檢索和打分的。

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

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