Lucene檢索全流程學習筆記

2023-06-14 18:01:02

一 簡介

寫作目的

1 為什麼學習Lucene

lucene是基於倒排索引的檢索工具庫,倒排索引是典型的文字匹配,它能夠精確匹配使用者搜尋的query,它的缺點是不擅長語意理解,而深度學習檢索模型擅長的正是理解使用者query背後的語意。深度學習的一個優點是可以把使用者的各種特徵很容易地輸入模型以豐富使用者搜尋的上下文資訊。深度學習的一個流派是表示學習,把使用者的query、及其他特徵通過學習得到向量表示,在向量空間裡面比較使用者和檔案的相似度,返回相似度最高的\(TopK\)檔案。

在語意召回的優點之外,embedding召回的結果相關性存在一些問題。例如淘寶 在它的MGDSPR論文中給出了一個例子:使用者搜尋阿迪達斯運動鞋 ,在向量空間裡它和耐克運動鞋 比較相似,因而會返回給使用者,然而這違背了使用者的本意,因為對使用者來說品牌是一個強需求。淘寶通過一些演演算法上的、工程上的手段解決了這個問題,並認為倒排 是基礎,深度學習召回作為補充進一步提升搜尋質量。

因此\(倒排索引\) 技術雖然有它的缺點,但是也有精確匹配使用者query的優點,作為召回的基礎,仍然是值得學習的。

2 學習過程中遇到的問題

初高中語文課上老師教過一種總-分-總的寫作正規化,先從總體上描述一件事情的脈絡,然後針對脈絡中的關鍵點,細細展開來描述,最後做一個總結性的描述來收尾。

筆者在工作上學習模組原始碼以及閱讀開原始碼時,喜歡先從總體上梳理出模組的脈絡:從主流程最開始的點,順著它的呼叫關係,在模組的脈絡上跳轉。這一塊OK後,再針對模組關鍵點深入梳理了解。否則如果沒有第一個階段,而直接進入到第二階段,會有一種不踏實的感覺,總感覺沒有真正掌握。

同樣學習Lucene原始碼時,最開始免不了去網上搜尋一些部落格,然而很多優秀的Lucene模組都是針對某一塊展開,例如Lucene中的跳躍連結串列、索引Merge、寫term詞典的FST演演算法等專題的形式出現。這些部落格寫的固然很好,但是總體上沒法把Lucene的邏輯串聯起來,例如:

  • Lucene中最終要拉出對應query的倒排表,而這個關鍵點隱藏在query分析中,它會返回一個\(TermQuery\) 型別的query,而這個query中隱含了拉取倒排的介面.

    • 初學者很容易忽略那一行簡單的程式碼:Query query = parser.parse(line); 然後迷失在Lucene程式碼的汪洋大海中,最終並不知道由於在哪個地方做了什麼事,才導致我們能成功拉取倒排,儘管他可能知道拉讀倒排的邏輯主要在\(BlockTreeTermsReader\) ,但是整個流程卻無法串聯到一塊
  • 假如初學者找到了呼叫 拉取倒排的函數,很有可能繼續迷失在,倒排是怎麼被拉去出來的。我們根據關鍵字posting很有可能來到下面這個呼叫鏈:

    public EverythingEnum reset(IntBlockTermState termState, int flags) throws IOException {
          docTermStartFP = termState.docStartFP;
          posTermStartFP = termState.posStartFP;
          payTermStartFP = termState.payStartFP;
    }
    

    然而這個呼叫鏈只是把倒排檔案(.doc .pay .pos)的控制程式碼簡單賦值過來,這些控制程式碼其實是隱藏在一個比較深的不太起眼的地方:

    public void decodeTerm(DataInput in, FieldInfo fieldInfo, BlockTermState _termState, boolean absolute) {    
    if (fieldHasPositions) {
          termState.posStartFP += in.readVLong();
          if (fieldHasOffsets || fieldHasPayloads) {
            termState.payStartFP += in.readVLong();
          }
        }
    }
    

    在網上也有一些技術部落格,講解呼叫鏈路方面的,但是都不太完整,或者缺一些東西。因此本文的目的就是系統性的分析一下Lucene檢索全流程的呼叫鏈路,使得我們順著demo中的searcher.search(query, 100); 這行程式碼,一點點深入檢索流程的呼叫鏈,看看最終是如何從索引庫裡將檔案檢索出來的。另外看過的一些東西,過段時間再看很容易就忘記部分,因此有必要通過一篇部落格記錄下來。

寫作方式

本文的目的在於從主流程最開始的地方,通過呼叫關係逐步深入,來呈現出Lucene檢索的完整呼叫鏈。因此會在寫作時會進行一些捨棄

  • 某個區域性區域,挑選一條簡單的路徑,捨棄其他路徑,以求簡潔,突出本文的目的。例如在paraser.parse(line) 中,呼叫路徑非常多且複雜,筆者會挑選其中一個返回TermQuery 的呼叫鏈來描述,其他的呼叫路徑功能類似,不會再一一描述。另外還會描述一些關鍵資料,例如query分析中,在哪個地方體現了針對輸入的query進行分析(注:程式碼中將域、query設定在一個比較容易忽視的變數中)

  • 會捨棄一些關鍵元件的介紹,例如在介紹從詞典找到對應的term 拉取倒排的時候,必然涉及FST,但是筆者不會介紹FST, 會介紹FST的哪個地方的哪個呼叫,去真正拉取了倒排。因為一方面:FST作為Lucene存詞典的重要演演算法,它值得單獨一個篇幅專門介紹,另一方面這麼處理,也可以突出本文梳理脈絡的目的,突出總-分-總 中的第一個總字

  • 本文的目標是梳理總體脈絡,重點著墨的部分,這個是針對Lucene這個龐大的程式碼庫而言。具體到本文內部,會採用總-分的方式,在某一塊區域性的功能中,會先給出的描述,以期快速瞭解整個面貌,然後針對其中的關鍵點,細細展開。

  • Lucene中的索引檔案種類較多,每一種格式也比較複雜,這些東西也值得單獨的篇幅來介紹,因此本文遇到的索引檔案,會簡要說明它的目的,不會做過深入的分析。

Lucene閱讀難點

設計模式

瞭解設計模式的人,一定聽說過里氏代換 依賴倒轉 原則,前者的含義是:所有基礎類別出現的地方,子類一定可以出現;後者的含義是:應該針對抽象程式設計而不是針對實現程式設計。這樣的好處是可以很容易擴充套件系統功能,而不會破壞現有的系統,符合開閉原則。

讀了Lucene原始碼,你就會了解這正是Lucene中的程式設計正規化, 由於Lucene原始碼非常複雜,在呼叫鏈很深的地方,往往看到程式碼在操作一個個基礎類別,而找到它實際的型別才能瞭解深入它的細節,瞭解它的正在功能原理。遺憾的是在Lucene這樣一個複雜的邏輯裡,找到這一點並不容易。當然使用這個程式設計正規化的優點遠遠大於其的缺點。

二 從demo開始

檔案位置:org/apache/lucene/demo/SearchFiles.java

public static void main(String[] args){
    IndexReader reader = DirectoryReader.open(FSDirectory.open(Paths.get(index)));
    IndexSearcher searcher = new IndexSearcher(reader);
    Analyzer analyzer = new StandardAnalyzer();
    QueryParser parser = new QueryParser(field, analyzer);
    Query query = parser.parse(line);
    searcher.search(query, 100);
}

上面這個demo程式碼是Lucene官方提供的一個搜尋例子,為了描述的簡潔高效,程式碼經過刪減,只留下了最關鍵的部分。後文在介紹其他邏輯的時候,也會採用這種方式,不再贅述。

demo中主要包括了6行程式碼,此處先一一簡介其功能,後續章節會詳細深入

1 構造Reader

reader = DirectoryReader.open(FSDirectory.open(Paths.get(index)));

Lucene中有一個名為    segments_n 的索引檔案,它裡面記錄了歷次的commit,通過它可以找到所有名為_n.si的檔案,每一個.si檔案裡面記錄了諸如.doc .pos .pay .fnm 等索引檔案的資訊。 這個函數會讀取索引元資訊,例如域相關的資訊,然後層層封裝到reader裡面,供檢索的時候呼叫。

2 構造searcher

IndexSearcher searcher = new IndexSearcher(reader);

searcher的功能是檢索的總控模組,呼叫1中的reader進行檢索,對檢索的結果進行打分等。後文搜尋的過程會詳細深入。

3 解析query

Query query = parser.parse(line);

對使用者的query進行分析,包裝成Lucene中定義好的query物件,例如TermQuery BooleanQuery 等。其中TermQuery 是最基本的query物件,它提供了拉取倒排的介面,供Search流程呼叫。

4 搜尋query

searcher.search(query, 100)

搜尋的入口函數,這個呼叫的含義是在field 域中搜尋query命中的檔案,並返回打分最高的前100個檔案。

小結

構造reader解析query搜尋query 是三個主要流程,其中解析query相對簡單,後面先介紹之,構造reader 邏輯比較複雜,關係到搜尋流程依賴的很多索引讀取邏輯,次之介紹,搜尋query是最重要,最複雜的流程,放到最後介紹。

三 解析query

Query query = parser.parse(line); 從這行程式碼開始,line是使用者輸入的query

上面的呼叫進入下面這個函數

位置:org/apache/lucene/queryparser/classic/QueryParserBase.java

public Query parse(String query) throws ParseException {
    ReInit(new FastCharStream(new StringReader(query)));
    Query res = TopLevelQuery(field);
}

1 ReInit 這個函數會將query包裝後存入:token_source.ReInit(stream);

2 Query res = TopLevelQuery(field); 進入

位置:org/apache/lucene/queryparser/classic/QueryParser.java

final public Query TopLevelQuery(String field) throws ParseException {
    q = Query(field);
}

note 這個函數的引數是域名,並不是使用者搜尋的query,第一次看程式碼的話,順著這個地方往下看,不容易發現使用者query作用的點,所以 1 中特意點出了query儲存的地方,後面還會再遇到

3 q = Query(field); 進入到

位置:org/apache/lucene/queryparser/classic/QueryParser.java

final public Query Query(String field) throws ParseException {
    firstQuery = MultiTerm(field, clauses);
}

MultiTerm() 中的text = jj_consume_token(TERM); 體現了消費使用者搜尋query的地方。接著呼叫getFieldQuery(field, discardEscapeChar(text.image), false);繼續呼叫

newFieldQuery(Analyzer analyzer, String field, String queryText, boolean quoted) ,進而呼叫createFieldQuery(analyzer, occur, field, queryText...); 接著進入下一輪呼叫,見下文4

4 接著調研另外一個同名不同參的函數

位置:org/apache/lucene/util/QueryBuilder.java

protected Query createFieldQuery(TokenStream source, BooleanClause.Occur operator, String field, boolean quoted, int phraseSlop) {
     return analyzeTerm(field, stream);
}

5 進入analyzeTerm函數,它的某個程式碼分支會返回如下程式碼

return newTermQuery(new Term(field, termAtt.getBytesRef()), boostAtt.getBoost());

注意 newTermQuery 很關鍵,它有一個成員函數getTermsEnum(LeafReaderContext context 用於從索引中拉倒排資料

四 構造reader

0 codec

lucene用codec來描述所有倒排檔案的格式,codec的版本會再索引階段寫入段檔案裡面,讀取的時候根據這個值找到對應的版本,以Lucene87為例

位置:org/apache/lucene/codecs/lucene87/Lucene87Codec.java

擷取主要倒排檔案定義如下

public class Lucene87Codec extends Codec {
    private final FieldInfosFormat fieldInfosFormat = new Lucene60FieldInfosFormat();
    private final SegmentInfoFormat segmentInfosFormat = new Lucene86SegmentInfoFormat();

    public Lucene87Codec(Mode mode) {
        super("Lucene87");
        this.storedFieldsFormat = new Lucene87StoredFieldsFormat(Objects.requireNonNull(mode).storedMode);
        //posting的格式
        this.defaultFormat = new Lucene84PostingsFormat();
        this.defaultDVFormat = new Lucene80DocValuesFormat(mode.dvMode);
    }
}

後文會有很多地方從codec裡面取出對應的倒排工具類,然後執行業務邏輯

1 OverView

1) 從DirectoryReader.open()說起

IndexReader reader = DirectoryReader.open(FSDirectory.open(Paths.get(index)));

再到達核心流程前會經過層層巢狀呼叫:

1)org/apache/lucene/index/DirectoryReader.java

StandardDirectoryReader.open(directory, null, null);

2)org/apache/lucene/index/SegmentInfos.java

public T run(IndexCommit commit)

3)org/apache/lucene/index/SegmentInfos.java

protected DirectoryReader doBody(String segmentFileName)

主要的流程在doBody() 這個函數中

2) doBody() 流程

protected DirectoryReader doBody(String segmentFileName) throws IOException {
        SegmentInfos sis = SegmentInfos.readCommit(directory, segmentFileName);
        final SegmentReader[] readers = new SegmentReader[sis.size()];
        for (int i = sis.size()-1; i >= 0; i--) {
            readers[i] = new SegmentReader(sis.info(i), sis.getIndexCreatedVersionMajor(), IOContext.READ);
        }
        DirectoryReader reader = new StandardDirectoryReader(directory, readers, null, sis, leafSorter, false, false);
        return reader;
}

2 readCommit

2.1 主流程

位置:org/apache/lucene/index/SegmentInfos.java

readCommit(Directory directory, String segmentFileName) segmentFileName是形如segments_N的檔案,讀取的時候會選取目錄中N最大的那個值對應的段檔案。

  1. 從磁碟讀取segments_N檔案

  2. 呼叫同名不同參的readCommit函數

  3. 範例化infos = new SegmentInfos ,infos裡面有一個List容器,前文說過segments_N檔案管理了多個commit,每一個commit被讀取後,存入這個List 容器中。這個List的定義是private List<SegmentCommitInfo> segments = new ArrayList<>();

  4. 關鍵欄位讀取:int numSegments = input.readInt(); 這個欄位描述了段檔案segments_N 管理了多少個commit,根據Lucene的刪除保留策略,可能有多個,也可能只有一個,我們假定是多個

  5. 遍歷numSegments 個commit,每一個commit的邏輯,進入6)開始的流程描述

  6. 讀取關鍵欄位Codec codec = readCodec(input); (其餘還有很多欄位,本文只描述能串聯呼叫鏈的欄位,後文也是如此,不再贅述) 本文的Codec以Lucene87為例

  7. SegmentInfo info = codec.segmentInfoFormat().read(directory, segName, segmentID, IOContext.READ);

    1)去lucene87codec.java中能夠看到定義

    segmentInfosFormat = new Lucene86SegmentInfoFormat();

    2)然後進入到org/apache/lucene/codecs/lucene86/Lucene86SegmentInfoFormat.java

public SegmentInfo read(Directory dir, String segment, byte[] segmentID, IOContext context)

讀單個segmentinfo的邏輯比較複雜,下文專門一個小節來介紹

  1. 7返回的info描述了一個commit的資訊,例如該commit裡面的檔案數量、軟刪除的數量等等

  2. 以8中的info為引數,再包裝一層

SegmentCommitInfo siPerCommit = new SegmentCommitInfo

  1. 將9中的siPerCommit 塞進 3)中提到的List容器中

  2. 將3中的infos返回給前文提到的dobody()函數

2.2單個Segment的讀取

在2.1 readCommit 主流程裡面還遺留了單個segment的讀取邏輯在此介紹。它對應讀取的總控檔案是_N.si檔案,N是一個數位,跟索引的迭代有關。下面介紹它的主要呼叫流程

位置:org/apache/lucene/codecs/lucene86/Lucene86SegmentInfoFormat.java

public SegmentInfo read(Directory dir, String segment, byte[] segmentID, IOContext context)
  1. 首先讀取si檔案

    input = dir.openChecksumInput(fileName, context)
    

    會讀取一些欄位,進行版本check之類。

  2. 讀取排序的域個數

    numSortFields = input.readVInt(),每一個域在索引階段可以指定排序的方式,在讀取的時候要求每一個commit的相同域的排序規則一致,否則返回失敗

  3. 最後將讀取到的欄位為引數,構造si = new SegmentInfo 返回給呼叫者

3 構造SegmentReader

3.1 總控流程

前文有一段程式碼如下, 遍歷讀到的每一個segmentInfo(對應_N.si檔案),構造每一個對應的SegmentReader, 本小節就來介紹下這塊邏輯

for (int i = sis.size()-1; i >= 0; i--) {
    readers[i] = new SegmentReader(sis.info(i), sis.getIndexCreatedVersionMajor(), IOContext.READ);
}

位置:org/apache/lucene/index/SegmentReader.java

  1. 構造CoreReader, CoreReader是這塊的核心,會在第4小節專門介紹

     core = new SegmentCoreReaders(si.info.dir, si, context);
    
  2. 讀取Livedocs:操作的是.liv檔案,從這個檔案能看到哪些docid還「活著」

    liveDocs = codec.liveDocsFormat().readLiveDocs(directory(), si, IOContext.READONCE);
    
  3. 初始化域相關資訊

    fieldInfos = initFieldInfos();
    
    private FieldInfos initFieldInfos() throws IOException {
        if (!si.hasFieldUpdates()) {
          return core.coreFieldInfos;
        } else {
          // updates always outside of CFS
          FieldInfosFormat fisFormat = si.info.getCodec().fieldInfosFormat();
          final String segmentSuffix = Long.toString(si.getFieldInfosGen(), Character.MAX_RADIX);
          return fisFormat.read(si.info.dir, si.info, segmentSuffix, IOContext.READONCE);
        }
      }
    

    可以看出,如果讀取期間域沒有更新,則直接返回core.coreFieldInfos,它是在SegmentCoreReader中讀取的。否則的話需要讀取.fnm檔案

  4. 其他倒排檔案相關初始化

    docValuesProducer = initDocValuesProducer();
    

4 構造SegmentCoreReader

4.1 總控流程

  1. 獲取前文提到的codec(lucene87)

  2. 讀域檔案.fnm

    coreFieldInfos = codec.fieldInfosFormat().read(cfsDir, si.info, "", context);
    

    檢視Lucene87codec,可以看到fieldInfosFormat()返回的是如下工具類

     private final FieldInfosFormat fieldInfosFormat = new Lucene60FieldInfosFormat();
    

    \(read()\)的邏輯比較複雜,在4.2 小節會轉門介紹

  3. 倒排資料(此處指.doc .pay .pos .tim .tip等)讀取工具類的初始化

    final PostingsFormat format = codec.postingsFormat();
    fields = format.fieldsProducer(segmentReadState);
    

    此部分邏輯鏈條也比較長、複雜,在4.3 小節專門介紹

  4. 域檔案.fdt .fdx .fdm的讀取。可以簡單理解為.fdt .fdx .fdm從屬於.fnm

    fieldsReaderOrig = si.info.getCodec().storedFieldsFormat().fieldsReader(cfsDir, si.info, coreFieldInfos, context);
    
  5. 其他索引檔案的讀取,此處略去

4.2 域的讀取

本文介紹前文 4.1) 中第2步提到的域.fnm

coreFieldInfos = codec.fieldInfosFormat().read(cfsDir, si.info, "", context);

read函數的位置:org/apache/lucene/codecs/lucene60/Lucene60FieldInfosFormat.java

public FieldInfos read(Directory directory, SegmentInfo segmentInfo, String segmentSuffix, IOContext context) throws IOException {
}
  1. 從磁碟索引中讀取.fnm檔案

    input = directory.openChecksumInput(fileName, context)
    
  2. 讀取域的個數

    final int size = input.readVInt(); //read in the size
    infos = new FieldInfo[size];
    
  3. 遍歷size個域,讀取每個域的資訊,進入到4中描述之

  4. 讀取域的編號:fieldNumber = input.readVInt();

  5. 讀取標誌位位元組:bits = input.readByte(); 這個位元組的不同bit位描述了這個域是否儲存了term vector資料、norms資料、payload資料及軟刪除資料;

  6. 其他的還讀取了諸如點資料等屬性欄位

  7. 對於每一個域讀取完上述屬性欄位後,作為引數構造FieldInfo

    infos[i] = new FieldInfo(name, fieldNumber, storeTermVector, omitNorms, storePayloads, 
                                         indexOptions, docValuesType, dvGen, attributes,
                                         pointDataDimensionCount, pointIndexDimensionCount, pointNumBytes, isSoftDeletesField);
    
  8. 包裝infos並返回:return new FieldInfos(infos); 這個建構函式裡面,將infos生成2個map 1)byName : key是域名,值是FieldInfo型別資料,也即7中的infos[i]; 2) byNumber 同byName 區別是key是域的編號

  9. 返回到SegmentCoreReaders主流程,FieldInfos賦值給coreFieldInfos

4.3 倒排資料相關初始化

4.1第3步提到倒排資料的讀取

final PostingsFormat format = codec.postingsFormat();
fields = format.fieldsProducer(segmentReadState);

由前文可知,這個format是 Lucene84PostingsFormat ,因此進入對應的fieldsProducer函數

位置:org/apache/lucene/codecs/lucene84/Lucene84PostingsFormat.java

public FieldsProducer fieldsProducer(SegmentReadState state) throws IOException {
    PostingsReaderBase postingsReader = new Lucene84PostingsReader(state);
    boolean success = false;
    try {
      FieldsProducer ret = new BlockTreeTermsReader(postingsReader, state);
      success = true;
      return ret;
    } finally {
      if (!success) {
        IOUtils.closeWhileHandlingException(postingsReader);
      }
    }
  }

這其中有兩個非常重要的倒排工具類:postingsReaderBlockTreeTermsReader 。它們是讀取倒排檔案的核心類,是lucenen檢索流程的基石,下面會分2個單獨的章節來介紹

5 構造postingsReader

docIn = state.directory.openInput(docName, state.context);
posIn = state.directory.openInput(proxName, state.context);
payIn = state.directory.openInput(payName, state.context);

主要邏輯是從磁碟索引檔案讀取.doc .pos .pay檔案,是後續檢索流程查詢倒排資料的基礎。doc檔案裡面儲存的是檔案id和term的詞頻;pos檔案儲存的是term的位置資訊;pay檔案裡面儲存的是term的offset和term的payload資訊。

6 構造BlockTreeTermsReader

6.1 BlockTreeTermsReaderpostingsReader 的關係

所謂的倒排索引值的是根據term找到對應的檔案list,正排索引指的是根據檔案號找到檔案的屬性資訊。此處BlockTreeTermsReader 是讀取term詞典用的,postingsReader 是讀取檔案相關的倒排檔案的用的。因此兩者是包含的關係:

FieldsProducer ret = new BlockTreeTermsReader(postingsReader, state);

6.2 new BlockTreeTermsReader

  1. 從磁碟索引中讀取.tim .tip資料,它們儲存的就是前文提及的term詞典資料,以FST演演算法產出的格式儲存。

  2. 讀取.tmd檔案(PS:這個可能是新版本增加的一種格式,後面再研究,此處略去)

  3. 從.tmd檔案中讀取域的個數 numFields = termsMetaIn.readVInt();

  4. 遍歷numFields中的每一個,進入到5中來描述

  5. 讀取域的編號、rootcode(FST相關資料)

  6. 根據5中域的編號獲取域物件

    final FieldInfo fieldInfo = state.fieldInfos.fieldInfo(field);
    

    state.fieldInfos實際上是前文提到的coreFieldInfos,因此上面程式碼實際上是從其內部的byNumber容器中取出已經構造好的域物件

  7. 讀取minTerm、maxTerm(按字典序最小、最大). 這個用於快速排除一些肯定不在term詞典的情況

    BytesRef minTerm = readBytesRef(termsMetaIn);
    BytesRef maxTerm = readBytesRef(termsMetaIn);
    
  8. 構造FieldReader

    final long indexStartFP = indexMetaIn.readVLong();
    FieldReader previous = fieldMap.put(fieldInfo.name,
                    new FieldReader(this, fieldInfo, numTerms, rootCode, sumTotalTermFreq, sumDocFreq, docCount,
                        indexStartFP, indexMetaIn, indexIn, minTerm, maxTerm));
    

    6.3 new FieldReader

    FieldReader 類成員函數iterator是拉取倒排的第一步:構造TermsEnum,後面呼叫iterator的SeekExact()、postings介面後就會拉取到倒排表。前文提到TermQuery中有一個介面getTermsEnum() 它的內部實際上底層呼叫的就是此處FieldReader的iterator介面。

      public TermsEnum iterator() throws IOException {
        return new SegmentTermsEnum(this);
      }
    

五 搜尋

1 Search函數

searcher.search(query, 100); 出發走完整個搜尋流程。

經過了前文query分析、Reader構造等大量的初始化工作,現在已經可以進入搜尋的流程了。Lucene中同名search函數很多,它們層層呼叫,最終才會進入主題,此處沿著這個呼叫鏈,直達最接近檢索的那個search函數,然後在其他小節具體深入分析。

  1. public TopDocs search(Query query, int n)

  2. 然後進入public TopDocs searchAfter(ScoreDoc after, Query query, int numHits) 函數

  3. 從2中進入public <C extends Collector, T> T search(Query query, CollectorManager<C, T> collectorManager)函數

  4. 從3進入public void search(Query query, Collector results)函數,其中檢索到的檔案存入results中

  5. 進入protected void search(List leaves, Weight weight, Collector collector)中,至此已經比較接近搜尋真正的入口了,從第2節開始詳細介紹

2 最終的Search函數

位置:org/apache/lucene/search/IndexSearcher.java

  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) {
        // there is no doc of interest in this reader context
        // continue with the following leaf
        continue;
      }
      BulkScorer scorer = weight.bulkScorer(ctx);
      if (scorer != null) {
        try {
          scorer.score(leafCollector, ctx.reader().getLiveDocs());
        } catch (CollectionTerminatedException e) {
          // collection was terminated prematurely
          // continue with the following leaf
        }
      }
    }
  }
  • 在for迴圈語句裡,遍歷leaves,然後對每一個leave執行對應的檢索流程。這裡面的context和leaves是什麼關係,它們和前文提到的SegmentReader又是如何聯絡到一塊的,下面會在第3 小節理清這裡面的呼叫關係

  • 接下來的呼叫BulkScorer scorer = weight.bulkScorer(ctx); 比較關鍵,拉取倒排的迭代器,隱藏在打分物件的成員變數裡。這一部分會在第4小節拉取倒排資料部分介紹,由於本文的目的是串聯檢索呼叫路徑,因此對打分部分簡單提下,不會深入梳理,儘管拉倒排的邏輯嵌入在打分物件裡面。

  • scorer.score(leafCollector, ctx.reader().getLiveDocs());上一個步驟拉取了倒排資料,那麼接下來就輪到了從倒排裡面檢索資料,取出檔案了。這一部分會在下文第5 小節介紹。

3 ReaderContext相關

3.1 回顧讀取索引目錄的邏輯

protected DirectoryReader doBody(String segmentFileName) throws IOException {
        SegmentInfos sis = SegmentInfos.readCommit(directory, segmentFileName);
        final SegmentReader[] readers = new SegmentReader[sis.size()];
        for (int i = sis.size()-1; i >= 0; i--) {
            readers[i] = new SegmentReader(sis.info(i), sis.getIndexCreatedVersionMajor(), IOContext.READ);
        }
        DirectoryReader reader = new StandardDirectoryReader(directory, readers, null, sis, leafSorter, false, false);
        return reader;
}

為了避免上下文距離過長影響閱讀,此處再把對應的程式碼貼出來。這段程式碼先從目錄中讀取每一個commit對應的索引檔案,構造出SegmentReader, 然後再把所有的SegmentReader List作為引數構造出StandardDirectoryReader 型別的Reader, 賦值給基礎類別物件,返回撥用方

返回的reader會用來構造IndexSearcher(), 進而生成leaves。每一個leave代表了一個不可分割的原子結點,它裡面實際上是一個SegmentReader。先來看看StandardDirectoryReader 的繼承體系,在後文會用到,以下按照順序依次給出繼承體系中每一個類

  1. StandardDirectoryReader

  2. DirectoryReader

  3. BaseCompositeReader

  4. CompositeReader

  5. IndexReader

3.2 生成Leaves

再回到前問提到的IndexSearcher searcher = new IndexSearcher(reader);

  1. 這段程式碼 實際上呼叫的是下面這個函數
  public IndexSearcher(IndexReader r, Executor executor) {
    this(r.getContext(), executor);
  }
  1. r.getContext()的r即是前文提到的StandardDirectoryReader;根據繼承鏈的關係,它呼叫的是類CompositeReader中的getContext函數

  2. 接2,它裡面有一行關鍵程式碼

    readerContext = CompositeReaderContext.create(this); 這個create函數裡面執行了Builder(reader).build(); 引數裡面的reader即是呼叫語句的引數this,即為StandardDirectoryReader。

  3. 接3,呼叫的build函數的邏輯

     public CompositeReaderContext build() {
          return (CompositeReaderContext) build(null, reader, 0, 0);
     }
    
  4. 接4,此時進入到最底層真正發揮作用的build函數,經過刪減只留下關鍵邏輯的程式碼如下

     private IndexReaderContext build(CompositeReaderContext parent, IndexReader reader, int ord, int docBase) {
          if (reader instanceof LeafReader) {
            final LeafReader ar = (LeafReader) reader;
            final LeafReaderContext atomic = new LeafReaderContext(parent, ar, ord, docBase, leaves.size(), leafDocBase);
            leaves.add(atomic);
            return atomic;
          } else {
            final CompositeReader cr = (CompositeReader) reader;
            final List<? extends IndexReader> sequentialSubReaders = cr.getSequentialSubReaders();
            final List<IndexReaderContext> children = Arrays.asList(new IndexReaderContext[sequentialSubReaders.size()]);
            for (int i = 0, c = sequentialSubReaders.size(); i < c; i++) {
              final IndexReader r = sequentialSubReaders.get(i);
              children.set(i, build(newParent, r, i, newDocBase));
              newDocBase += r.maxDoc();
            }
            assert newDocBase == cr.maxDoc();
            return newParent;
          }
        }
      }
    

    1) 根據StandardDirectoryReader的繼承體系,得知引數裡面的reader是CompositeReader 型別,於是進入到else分支;

    2)else 分支裡面children.set(i, build(newParent, r, i, newDocBase)); 中的r是一個SegmentReader() note : StandardDirectoryReader的建構函式呼叫鏈裡面會呼叫基礎類別的建構函式,將SegmentReader塞入到一個List中,而此getSequentialSubReaders(); 返回到便是這個List.

    3)build(newParent, r, i, newDocBase) 遞迴 呼叫自身。r是單個SegmentReader, 而LeafReader在它的繼承鏈裡面,於是進入到if 分支

    4)leaves.add(atomic); if 分支裡面,對單個SegmentReader簡單包裝後,存入到leaves中

    5)隨著迴圈+遞迴的執行,所有的SegmentReader都會加入到leaves中。注:實際上leaves存入的是LeafReaderContext型別的資料。在SegmentReader的基礎類別LeafReader中有這麼一行程式碼:

     private final LeafReaderContext readerContext = new LeafReaderContext(this);
    

    引數this實際就是SegmentReader物件自身

    小結 再回到第五章,第2小節提到的search流程裡面

    for (LeafReaderContext ctx : leaves) {
    }
    

    此時呼叫鏈的關係已經非常清楚,解答了第五章第2小節提出的問題:這裡面的context和leaves是什麼關係,它們和前文提到的SegmentReader又是如何聯絡到一塊的

    每一個ctx裡面包含了一個SegmentReader,後面會在每一個SegmentReader裡面取檢索資料。

4 拉取倒排資料

4.1 createWeight

//函數呼叫
search(leafContexts, createWeight(query, results.scoreMode(), 1), results);
  1. 前文那一大坨同名不同參的Search各種大亂調,最終呼叫的是上面的這個函數,它的引數裡面有一個createWeight函數。Weight和打分有關,但是本文只關注拉取倒排、檢索相關邏輯,因此下面的敘述會省略掉打分相關的描述。

  2. 進入到下一層呼叫鏈:Weight weight = query.createWeight(this, scoreMode, boost); 在前文第章提到query分析後得到TermQuery物件,因此此處呼叫的實際上是TermQuery類的成員函數createWeight

  3. 接2,進入createWeight看看,函數最終返回的是return new TermWeight(searcher, scoreMode, boost, termState); 記住是TermWeight 型別的物件,下文會用到

4.2 拉取倒排

 BulkScorer scorer = weight.bulkScorer(ctx);

同前文,此處只關注檢索部分,忽略打分相關。由4.1可以知道呼叫的應該是TermWeight物件的成員函數bulkScorer. 不過子類沒有實現這個方法,呼叫的是基礎類別Weight中的方法

  1. bulkScorer邏輯

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

    關鍵的一行呼叫:Scorer scorer = scorer(context);

  2. 接1,繼續介紹scorer函數,由於子類實現了scorer函數,此處呼叫的是TermWeight中的方法.

        public Scorer scorer(LeafReaderContext context) throws IOException {
          assert termStates == null || termStates.wasBuiltFor(ReaderUtil.getTopLevelContext(context)) : "The top-reader used to create Weight is not the same as the current reader's top-reader (" + ReaderUtil.getTopLevelContext(context);;
          final TermsEnum termsEnum = getTermsEnum(context);
          if (termsEnum == null) {
            return null;
          }
          LeafSimScorer scorer = new LeafSimScorer(simScorer, context.reader(), term.field(), scoreMode.needsScores());
          if (scoreMode == ScoreMode.TOP_SCORES) {
            return new TermScorer(this, termsEnum.impacts(PostingsEnum.FREQS), scorer);
          } else {
            return new TermScorer(this, termsEnum.postings(null, scoreMode.needsScores() ? PostingsEnum.FREQS : PostingsEnum.NONE), scorer);
          }
        }
    
  3. 接2繼續介紹scorer函數,其中的final TermsEnum termsEnum = getTermsEnum(context); 會為獲取搜尋query對應的倒排資料做準備,邏輯比較複雜,放到下文的4.3小節講。termsEnum.postings()是把對應使用者搜尋query(分詞後的term)對應的檔案List 倒排資料拉取出來。這部分邏輯同樣放到下文4.4小節講述。

4.3 獲取倒排資料前的準備工作

private TermsEnum getTermsEnum(LeafReaderContext context) throws IOException {
      final TermState state = termStates.get(context);
      if (state == null) { // term is not present in that reader
        assert termNotInReader(context.reader(), term) : "no termstate found but term exists in reader term=" + term;
        return null;
      }
      final TermsEnum termsEnum = context.reader().terms(term.field()).iterator();
      termsEnum.seekExact(term.bytes(), state);
      return termsEnum;
    }
  1. 獲取termsEnum

    final TermsEnum termsEnum = context.reader().terms(term.field()).iterator();
    

    從前文可知,這個context裡面儲存的是SegmentReader,它的基礎類別codecReader實現了terms函數

  2. 接1,繼續介紹terms函數。CodecReader的成員函數terms裡面呼叫了return getPostingsReader().terms(field); getPostingsReader呼叫的是SegmentReader的成員函數,從第章節的第4.3小節知道,它返回的是BlockTreeTermsReader

  3. 接2,進而呼叫BlockTreeTermsReader的成員函數terms(field)

      public Terms terms(String field) throws IOException {
        assert field != null;
        return fieldMap.get(field);
      }
    

    從第章第6.2小節可以知道返回的是FieldReader物件

  4. 繼續往下走,要呼叫FieldReader的迭代器iterator()介面,從第章第6.3小節可以看到返回的實際是SegmentTermsEnum

      public TermsEnum iterator() throws IOException {
        return new SegmentTermsEnum(this);
      }
    
  5. 接4,SegmentTermsEnum 的建構函式裡面,定義、初始化FST相關資料,還記得前文提到的Lucene中Term詞典是用FST演演算法格式儲存的麼?

  6. 再回到getTermsEnum函數的termsEnum.seekExact(term.bytes(), state); 語句呼叫。這個函數裡面將term設定到成員變數中,同時也做了其他成員變數的初始化等操作。接下來進入到4.4 小節繼續描述

4.4 將匹配對應Term的檔案倒排資料拉出來

回到4.2 第2步的score函數裡面,它有如下呼叫

if (scoreMode == ScoreMode.TOP_SCORES) {
        return new TermScorer(this, termsEnum.impacts(PostingsEnum.FREQS), scorer);
} else {
        return new TermScorer(this, termsEnum.postings(null, scoreMode.needsScores() ? PostingsEnum.FREQS : PostingsEnum.NONE), scorer);
}

impacts和postings都是拉取倒排資料,但是impacts裡面使用了跳躍連結串列等高階技術,為了描述的簡潔,此處以termsEnum.postings()為例來介紹,雖然demo中的例子,其實走的是impacts()這條路徑

  1. 進入到SegmentTermsEnum的成員函數postings

    位置:org/apache/lucene/codecs/blocktree/SegmentTermsEnum.java

      public PostingsEnum postings(PostingsEnum reuse, int flags) throws IOException {
        assert !eof;
        currentFrame.decodeMetaData();
        return fr.parent.postingsReader.postings(fr.fieldInfo, currentFrame.state, reuse, flags);
      }
    
  2. 接1,進入decodeMetaData 中,下面這行程式碼的呼叫比較關鍵ste.fr.parent.postingsReader.decodeTerm(bytesReader, ste.fr.fieldInfo, state, absolute); 它實際呼叫的是Lucene84PostingsReader.java 中的decodeTerm函數。

  3. 接2,decodeTerm函數裡面,會讀取term對應的doc、pos、pay檔案的偏移量

    final long l = in.readVLong();
    if ((l & 0x01) == 0) {
    termState.docStartFP += l >>> 1; //.doc檔案
    if (fieldHasPositions) {
          termState.posStartFP += in.readVLong();//.pos檔案
          if (fieldHasOffsets || fieldHasPayloads) {
            termState.payStartFP += in.readVLong();//.pay檔案
          }
    }
    

    至此拉取倒排檔案的呼叫全流程已經串聯起來了

  4. 再回到1中的fr.parent.postingsReader.postings函數,它呼叫的是Lucene84PostingsReader.java 中的postings函數

  5. 接4 以postings函數中的EverythingEnum 為例,它會構造本地資料結構,用以容納從磁碟索引讀到的倒排資料。還會將3.中的docStartFP、posStartFP、payStartFP賦值給自己的成員變數。後面遍歷倒排資料,實際就是從這些檔案控制程式碼裡面按照已知的格式讀取磁碟資料到記憶體

6. new TermScore

  TermScorer(Weight weight, PostingsEnum postingsEnum, LeafSimScorer docScorer) {
    super(weight);
    iterator = this.postingsEnum = postingsEnum;
    impactsEnum = new SlowImpactsEnum(postingsEnum);
    impactsDisi = new ImpactsDISI(impactsEnum, impactsEnum, docScorer.getSimScorer());
    this.docScorer = docScorer;
  }

可以看到5中的EverythingEnum 最後賦值給了TermScore的iterator成員變數

5 遍歷檢索倒排資料

前文4.4中已經將term對應的倒排資料拉出來了,此處會遍歷倒排資料,獲取每一個docid,進行一些操作:

  1. 過濾已經被刪除的檔案,例如lucene的軟刪除機制,會在.liv檔案裡面標誌被刪除的檔案

  2. 打分、排序等

回到本章第2節的scorer.score(leafCollector, ctx.reader().getLiveDocs()); 呼叫,它會將符合條件的檔案,收集到leafCollector中。最終呼叫的是DefaultBulkScorer中的score函數,進而呼叫ScoreAll函數

static void scoreAll(LeafCollector collector, DocIdSetIterator iterator, TwoPhaseIterator twoPhase, Bits acceptDocs) throws IOException {
      if (twoPhase == null) {
        for (int doc = iterator.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = iterator.nextDoc()) {
          if (acceptDocs == null || acceptDocs.get(doc)) {
            collector.collect(doc);
          }
        }
      } else {
        // The scorer has an approximation, so run the approximation first, then check acceptDocs, then confirm
        for (int doc = iterator.nextDoc(); doc != DocIdSetIterator.NO_MORE_DOCS; doc = iterator.nextDoc()) {
          if ((acceptDocs == null || acceptDocs.get(doc)) && twoPhase.matches()) {
            collector.collect(doc);
          }
        }
      }
    }
  }

我們只看if分支,遍歷呼叫EveryhtingEnum的成員變數nextDoc(),取出一個檔案

    public int nextDoc() throws IOException {
      if (docBufferUpto == BLOCK_SIZE) {
        refillDocs();
      }

      doc = (int) docBuffer[docBufferUpto];
      freq = (int) freqBuffer[docBufferUpto];
      posPendingCount += freq;
      docBufferUpto++;

      position = 0;
      lastStartOffset = 0;
      return doc;
    }

可見如果還有資料的話,會返回docBufferUpto索引對應的檔案,然後移到下一個準備提取的位置。如果已經超過128了,則重新從磁碟索引檔案裡面讀下一塊資料到索引中,具體的工作在函數refillDocs中做。