lucene 搜尋過程
IndexSearcher是搜尋的入口,主要提供的api都是關於search的。關於搜尋,比較有意思的話題有這麼幾個:如何計算打分,這個問題已經在空間向量模型一文中討論過?如何從一個搜尋詞得到一個Query物件?如何從Query物件到評分器從而計算打分的?幾個重要的引數是如何在被組織起來計算的,比如n, filter, sort, collector等。另外,分頁是如何進行的?
本文以展示搜尋的組織和整個搜尋過程為主,其他未討論的問題將會在接下來陸續討論。
大致上,前兩個search屬於簡單搜尋一類的,接下來兩個api是帶Collector的,最後三個api是帶排序的
[java]- public TopDocs search(Query query, int n) throws IOException;
- public TopDocs search(Query query, Filter filter, int n) throws IOException;
- publicvoid search(Query query, Filter filter, Collector results) throws IOException;
-
publicvoid search(Query query, Collector results)
- public TopFieldDocs search(Query query, Filter filter, int n,
- Sort sort) throws IOException;
- public TopFieldDocs search(Query query, Filter filter, int n,
-
Sort sort, boolean doDocScores, boolean doMaxScore) throws IOException;
- public TopFieldDocs search(Query query, int n,
- Sort sort) throws IOException;
[java] view plain copy print?
- public TopDocs searchAfter(ScoreDoc after, Query query, int n) throws IOException;
- public TopDocs searchAfter(ScoreDoc after, Query query, Filter filter, int n) throws IOException;
- public TopDocs searchAfter(ScoreDoc after, Query query, Filter filter, int n, Sort sort) throws IOException;
- public TopDocs searchAfter(ScoreDoc after, Query query, int n, Sort sort) throws IOException;
- public TopDocs searchAfter(ScoreDoc after, Query query, Filter filter, int n, Sort sort,
- boolean doDocScores, boolean doMaxScore) throws IOException;
關鍵字的組織
講到搜尋,首先要從理解輸入的關鍵字講起,也就是QueryParser如何理解輸入關鍵字(當然,也可以自己手動的構造不同的Query),然後如何組織它們。關於關鍵字的組織,應該要想到如何表達與或非這樣的謂詞邏輯以便搜尋的完整。其次,不同的關鍵字有不同的種類用以應對不同的場合,比如,模糊匹配,順序匹配,正則表示式匹配,數字範圍匹配等等,熟悉面向物件的同學應當想到將各個種類的匹配方式封裝到類裡,然後再用謂詞邏輯連線起來,從而構成一個完整的查詢。沒錯,lucene也是這樣做的。由於構建謂詞邏輯的類與構建其他關鍵字的類繼承了相同的介面,操作謂詞類就等於操作整個查詢鏈,所以這裡是典型的裝飾器模式,它的好處是用一個類表示了一整個結構,並且遵循統一的介面形成一個規範。如圖,多個關鍵字搜尋的情況下,一個BooleanQuery便可以表示整棵樹。事實上,後面的Weight和Scorer也是這樣的結構。
下表簡單的介紹了一些常見的Query。關於各種不同的Query如何融入進這樣一個統一的框架中來,有許多值得講的地方,這裡主要介紹從Query產生後真正查詢的過程,暫且略講Query的產生部分以及各個Query的特點。
整個搜尋的橫向上主要是以3種類來組織的,即Query,Weight, Scorer。Query負責組織查詢物件,Weight負責計算查詢物件的權重,Scorer負責計算打分;縱向上依靠BooleanQuery組織成一整顆樹結構,其非葉子節點就是BooleanQuery,葉子節點是其他Query,形成Query後,Weight物件的組織就依靠Query樹遞迴一步一步構建起來的,Scorer也是類似的。
搜尋流程
明白了上面這個綱之後再來看search這個過程,就比較容易理解了,大致步驟如下:
1. 通過createNormalizedWeight從Query建立Weight,Weight是一個非常重要的物件,通過它來計算查詢評分的因子---權重。
2. 通過TopFieldCollector.create生成Collector,Collector的主要作用是用來蒐集原始的評分結果,在結果的基礎上可以進行排序,過濾等操作。
3. 從weight中生成Scorer,Scorer的目的是用於計算評分並生成結果
4. 呼叫Scorer的score方法計算評分結果並用collector蒐集文件結果集
5. 從collector的結果中得到topDocs
以下是這個過程一個大致的順序圖。
接下來我們一步一步來看每個步驟。
在實際建立Weight之前,可以指定Filter來過濾不想搜尋的內容,我們可以瞭解下lucene是如何實現這個filter的。
lucene中通過一個FilteredQuery包裝原來的Query來完成這件事情,這裡的Filte好像一道門禁,使得搜尋能夠從索引中獲得能夠通過門禁的文件ID,下面的MultiTermScorer重寫ConstantScorerQuery時也是使用了Filter, Filter中有個重要的方法getDocIdSet,這個方法過濾對應的文件,然後將結果集返回。在這裡可以根據需要選擇不同的filter,或者自己定義filter來滿足各種過濾或者安全需求。
[java] view plain copy print?- publicabstract DocIdSet getDocIdSet(AtomicReaderContext context, Bits acceptDocs) throws IOException;
在所有步驟中,首先是建立Weight並計算部分評分,原始碼如下:
- public Weight createNormalizedWeight(Query query) throws IOException {
- query = rewrite(query);
- Weight weight = query.createWeight(this);
- float v = weight.getValueForNormalization();
- float norm = getSimilarity().queryNorm(v);
- if (Float.isInfinite(norm) || Float.isNaN(norm)) {
- norm = 1.0f;
- }
- weight.normalize(norm, 1.0f);
- return weight;
- }
建立weight的過程分為這樣幾步
1. 重寫Query樹。重寫的主要目的是將整棵樹上一些需要改變搜尋關鍵詞的地方重新改變。比如,整個索引建立時有這樣幾個term,"演算法","算術",在搜尋"算*"時QueryParser將其解釋為PrefixQuery,在重寫這步便會搜尋所有字首為"算"的term,並用ConstantScoreQuery替換掉原來的PrefixQuery,在ConstantScorer中會將"算*"替換為"演算法", "算術"兩個實際的term,進而轉化成求解一般term評分,這是典型的將複雜問題轉換成已知問題求解的思想。
2. 根據Query樹建立Weight樹,這個建立過程是一個遞迴的過程。呼叫頂層query.createWeight,就會將整棵Weight樹構建起來。
3. 計算ValueForNormalization
4. 根據ValueForNormalization計算queryNorm
5. 計算公共部分打分公式(3,4,5參見打分公式一文),之所以這裡會計算一部分打分公式,因為這部分是每個文件計算時共用的。
其次通過TopScoreDocCollector.create建立Collector,查詢文件數n會被傳入到collector,並且在每次新加入一個文件時驗證是否已經達到上限n。
接下來從Weight中生成Scorer,這部分其實類似從Query建立Weight。值得一提的地方有3個,BooleanScorer2, MultiTermScorer, TermScorer。
TermWeight中是如何得到TermScorer的呢?getTermsEnum會得到TermsEnum,再由termsEnum得到DocsEnum,這兩個都是比較重要的物件,DocsEnum中的nextDoc可以遍歷命中的文件
- public Scorer scorer(AtomicReaderContext context, boolean scoreDocsInOrder,
- boolean topScorer, Bits acceptDocs) 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) {
- returnnull;
- }
- DocsEnum docs = termsEnum.docs(acceptDocs, null);
- assert docs != null;
- returnnew TermScorer(this, docs, similarity.simScorer(stats, context));
- }
在BooleanScorer2中,將Scorer的組織分成了3類,即requiredScorers,optionalScorers, prohibitedScorers,其實就分別代表了與或非。
在MultiTermScorer及其子類中,之前的rewrite方法會將query重新封裝,可以看到,比較重要的是,用了一個ConstantScoreQuery,對應的Weight是ConstantWeight,對應的Scorer是ConstantScorer
[java] view plain copy print?- Query result = new ConstantScoreQuery(new MultiTermQueryWrapperFilter<MultiTermQuery>(query));
隨後呼叫scorer中的score方法, 該方法遍歷所有結果文件,並將目標文件儲存在一個優先佇列中,該優先佇列負責排序。這個過程見下面的程式碼
[java] view plain copy print?- publicvoid score(Collector collector) throws IOException {
- assert docID() == -1; // not started
- collector.setScorer(this);
- int doc;
- while ((doc = nextDoc()) != NO_MORE_DOCS) {
- collector.collect(doc);
- }
- }
該優先佇列是一個比較重要的結構,之所以說它重要,因為一方面,查詢文件數會作為優先佇列的大小;另一方面,排序的也是通過優先佇列完成的。
關於比較的流程,可以看到,呼叫collect方法之後,緊接著就是呼叫優先佇列的updateTop及downHeap,downHeap就是真正調整佇列位置的地方,但是,它的判斷依據是lessThan來的,而lessThan正是利用類似Comparator的比較器來靈活的實現優先度的排序。
我們來看一個MultiComparatorsFieldValueHitQueue中的lessThan方法,如果有多個field需要比較,它會按照field的順序迴圈,分別比較這堆field,一旦判斷兩者分數不一樣就返回比較結果,否則,就要按照順序找下一個field比較。
[java] view plain copy print?- protectedboolean lessThan(final Entry hitA, final Entry hitB) {
- assert hitA != hitB;
- assert hitA.slot != hitB.slot;
- int numComparators = comparators.length;
- for (int i = 0; i < numComparators; ++i) {
- finalint c = reverseMul[i] * comparators[i].compare(hitA.slot, hitB.slot);
- if (c != 0) {
- // Short circuit
- return c > 0;
- }
- }
- // avoid random sort order that could lead to duplicates (bug #31241):
- return hitA.doc > hitB.doc;
優先佇列實現並不難,但是它靈活的實現了許多不同field的比較,因而很值得我們借鑑。
比較特殊的地方是searchAfter,他用到了PagingFieldCollector,因此它在插入優先佇列之前還會先過濾掉afterDoc文件之前的所有文件,從而達到分頁的效果。
scorer最後會呼叫到similarity中的設定來進行實際的打分,similarity實現了一個簡單的策略模式,通過不同的策略選取,可以實現不同的評分演算法。
最後從collector中得到TopDocs,這一步僅僅是將之前的搜尋結果整理成TopDocs的形式。
評分,排序,分頁,過濾的順序
好了,我們來整理一下一個評分,排序,分頁和過濾的過程: 1. 首先會從QueryParser得到一顆Query物件樹。 2. 接下來計算打分公式中的公共部分,同時得到了weight物件樹 3. 過濾可用的文件,得到scorer 4. 呼叫scorer的score方法開始真正的評分 5. 在需要分頁的地方進行過濾,最後做排序