上文已經介紹了檢索前的準備工作,本文接着上文的內容,繼續剖析檢索和打分操作
一、獲取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));
}
這裏涉及到兩個關鍵類,TermsEnum和PostingsEnum。
我們看一下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(freq)相乘的weightValue就是我們前面計算QueryWeight歸一化的結果,這裏就派上用場了。最後還需要乘上歸一化因子,該歸一化因子是存儲在索引中的所以需要調用decodeNormValue方法進行解碼。
所以就實現了我們一下公式:
這樣把得分加到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);
就像這樣一直迭代處理所有文檔,而到現在我們整個評分公式的計算各個部分已經全部實現了,到這裏計算的得分就是我們最終的得分了:
按照前面講的所有流程,會把所有的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信息存取的,雖然這種技巧不是一般人能想到的,但通過學習這些細節,也會給我們自身帶來很大的提升。
注:本文是博主的個人理解,如果有錯誤的地方,希望大家不吝指出,謝謝