上文已经介绍了检索前的准备工作,本文接着上文的内容,继续剖析检索和打分操作
一、获取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信息存取的,虽然这种技巧不是一般人能想到的,但通过学习这些细节,也会给我们自身带来很大的提升。
注:本文是博主的个人理解,如果有错误的地方,希望大家不吝指出,谢谢