有了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的打分公式(實際使用):
還記得我們提到的idf(t)的計算公式嗎?
對應到代碼中,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;
}
代碼很清晰,只有條件是MUST或SHOULD的時候,該方法纔會返回true,所以FILTER和MUST_NOT是不會影響它的,這和我們前面介紹BooleanQuery時說的一致:MUST_NOT和FILTER不參與評分。在我們的例子中,一個有三個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)是如何計算的,還記得嗎?(戳這裏回顧一下)
我們看到,這裏代碼的邏輯,就是對上述公式的實現。我們再來回顧一下代碼的實現。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是如何進行檢索和打分的。
注:本文是博主的個人理解,如果有錯誤的地方,希望大家不吝指出,謝謝