solr(lucene)的reRank的核心实现源码解读
换公司了,公司的solr使用的是4.10,使用了ReRankQuery,我自己看了下源码。
先介绍一下solr的reRank,他的意思是进行两轮查找,第一轮对所有的doc进行查找,指定要查找多少个doc,第二轮是在第一轮中查找到的所有的doc中在进行一遍查找,使用一个不同的查询逻辑(也就是另一个query),重新打分,可以指定两次得分的最终处理的策略,最后返回需要查找的结果。说白了,他就是一个query,只不过他的查询是分两次的,这样做有一个好处,如果我们的查询的方式很麻烦,也就是需要的计算量很大,那么在文档很多的时候,进行大量的计算是很耗时的,但是如果我们对于大量的文档只进行一个很小的运算,然后再获得的topN中再进行一个很复杂的运算以实现我们的需求,那么就快多了。
我们以一个查询作为例子来讲解源码,比如我们的查询条件是
q=name:xiaoming&sort=age desc&rq={!rerank reRankQuery=$rrq reRankDocs=100 reRankWeight=3}&rrq=desc:很好
先说一下这个请求的最终被解析的意思吧,第一轮查询是在所有的doc中查询name是小明,按照age排序,查找钱100个,第二轮使用desc:很好这个请求再次查找,最后每个doc的得分是第一轮的得分+第二轮的得分*3,最后的排序就是按照得分排序的。 有一点比较重要,这里的sort是在第一轮查找中使用的,第二轮不使用,第二轮仅仅使用得分排序。这里的{!rerank指定使用的reRank的queryParser是ReRankQParserPlugin。
下面是摘自于org.apache.solr.handler.component.QueryComponent.prepare(ResponseBuilder)的方法
String defType = params.get(QueryParsing.DEFTYPE, QParserPlugin.DEFAULT_QTYPE);//查找defType,也就是指定的queryParser QParser parser = QParser.getParser(rb.getQueryString(), defType, req);//根据defType获得QParser,这个parser解析的是rb.getQueryString,也就是q的属性 Query q = parser.getQuery();//解析q的属性形成一个query if (q == null) { q = new BooleanQuery(); } rb.setQuery(q); //解析rq属性, String rankQueryString = rb.req.getParams().get(CommonParams.RQ); if (rankQueryString != null) { QParser rqparser = QParser.getParser(rankQueryString, defType, req);//解析rq的queryParser,如果rq没有指定qparser,则使用上面的defType。这里是生成一个TermQuery,name:xiaoming Query rq = rqparser.getQuery();//解析的query,使用我们自己的那个请求参数的话,是生辰一个ReRankQuery,里面有个属性是reRankQuery,就是解析的rrq属性生成的query,这里是termQuery,desc:很好。当然也可以在rrq中继续使用localparam,比如{!parserName }xyz, // 指定使用的queryParser,如果不指定的话使用默认的queryParser。 if (rq instanceof RankQuery) {//生产的一定是一个RankQuery,不然就没有意义了。 RankQuery rankQuery = (RankQuery) rq; rb.setRankQuery(rankQuery);//设置解析rqq生产的query,也就是这里的termQuery,desc:很好 MergeStrategy mergeStrategy = rankQuery.getMergeStrategy(); if (mergeStrategy != null) { rb.addMergeStrategy(mergeStrategy); if (mergeStrategy.handlesMergeFields()) { rb.mergeFieldHandler = mergeStrategy; } } } else { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, "rq parameter must be a RankQuery"); } } rb.setSortSpec(parser.getSort(true)); rb.setQparser(parser);
从上面的代码中我们可以得出一个几轮,在4.10的solr中,rq这个参数和q已经同等重要了,或者说solr又添加了一个新的参数rq,可以说rq已经成了solr的一个标配了。这在4.7.2的solr中还是不存在,我搜索了一下,4.7.2的solr中根本没有ReRank这个类。
上面的代码的意思很简单,就是先根据q生成一个query,然后对rq也生成一个query,rq的{!reran 指定了使用的qParser是ReRankQParserPlugin,然后这个qparser解析reRankQuery所表示的rrq的字符串,生成一个TermQuery,当然也可以在rrq中继续使用localparam,比如{!parserName }xyz。
然后我们继续向下看,看一下org.apache.solr.handler.component.ResponseBuilder.getQueryCommand()这个方法,
public SolrIndexSearcher.QueryCommand getQueryCommand() { SolrIndexSearcher.QueryCommand cmd = new SolrIndexSearcher.QueryCommand(); cmd.setQuery(wrap(getQuery()))//就看这一点,wrap的方法,传入的参数是q解析的query .setFilterList(getFilters()) .setSort(getSortSpec().getSort()) .setOffset(getSortSpec().getOffset()) .setLen(getSortSpec().getCount()) .setFlags(getFieldFlags()) .setNeedDocSet(isNeedDocSet()) .setCursorMark(getCursorMark()); return cmd; }
Query wrap(Query q) { if(this.rankQuery != null) {//如果有了rankQuery,则将q解析的query作为参数传递到rankQuery的wrap方法中,我们再看一下RankQuery的wrap方法吧 return this.rankQuery.wrap(q); } else {//如果没有rankQuery,则直接返回q,和原来的版本一样。 return q; } }
这里是ReRankQuery的代码截图片段,
private class ReRankQuery extends RankQuery { private Query mainQuery = defaultQuery;//这就是被包装的第一层查询时使用的query,也就是这里的name:xiaoming private Query reRankQuery;//这里就是解析的rrq形成的queyr,也就是第二轮查询使用的query private int reRankDocs;//第一轮查询要保留多少个doc private int length;//start + rows 这个是最后的返回结果,也就是第二轮需要返回多少个doc,是分页查找的缘故 private double reRankWeight;//这个是第二轮的结果的分数的权重,也就是第二轮的结果的查找的得分要乘以这个权重作为第二轮的得分 private Map<BytesRef, Integer> boostedPriority;//没看 public RankQuery wrap(Query _mainQuery) {//包装方法就是包装第一轮查询使用的query, if (_mainQuery != null) { this.mainQuery = _mainQuery; } return this; }
上面就是最终生成的ReRankQuery的参数,我们继续看一下他的weight和collector的生成方法:
public Weight createWeight(IndexSearcher searcher) throws IOException { return new ReRankWeight(mainQuery, reRankQuery, reRankWeight, searcher); }
他会生成一个ReRankWeight,在这个类中,所有的操作都是使用了mainQuery的方法,也就是将所有的方法都委托给mainQuery,很显然他是为了第一轮查询做准备,即使用mainQuery的条件进行第一轮的查询。最重要的方法为:
public Scorer scorer(AtomicReaderContext context, Bits bits) throws IOException { return mainWeight.scorer(context, bits); }
在创建scorer的时候,也是用mainQuery的weight生成的scorer,即收集的时候也是使用mainquery的逻辑进行收集。
那么他是如何做第二轮查询的呢?答案是在ReRankQuery的收集器上:org.apache.solr.search.ReRankQParserPlugin.ReRankQuery.getTopDocsCollector(int, QueryCommand, IndexSearcher)
public TopDocsCollector getTopDocsCollector(int len, SolrIndexSearcher.QueryCommand cmd, IndexSearcher searcher) throws IOException { if (this.boostedPriority == null) {//经过debug发现,不进入这个 SolrRequestInfo info = SolrRequestInfo.getRequestInfo(); if (info != null) { Map context = info.getReq().getContext(); this.boostedPriority = (Map<BytesRef, Integer>) context .get(QueryElevationComponent.BOOSTED_PRIORITY); } } return new ReRankCollector(reRankDocs, length, reRankQuery, reRankWeight, cmd, searcher, boostedPriority); }
他是返回了一个ReRankCollector,代码如下:
public ReRankCollector(int reRankDocs, int length, Query reRankQuery, double reRankWeight, SolrIndexSearcher.QueryCommand cmd, IndexSearcher searcher, Map<BytesRef, Integer> boostedPriority) throws IOException { super(null); this.reRankQuery = reRankQuery;//第二轮的query this.reRankDocs = reRankDocs;//第一轮收集多少个 this.length = length;//第二轮收集多少个 this.boostedPriority = boostedPriority; Sort sort = cmd.getSort();//url中指定的sort if (sort == null) {//如果没有sort, this.mainCollector = TopScoreDocCollector.create(Math.max(this.reRankDocs, length), true);// } else {//如果有sort sort = sort.rewrite(searcher); this.mainCollector = TopFieldCollector.create(sort, Math.max(this.reRankDocs, length), false, true, true, true); } this.searcher = searcher; this.reRankWeight = reRankWeight; }
可以发现,sort也是全部用在了mainQuery上面了,即sort也是在第一轮查询的时候有用的。我们看一下这个collector的其他方法:
public void collect(int doc) throws IOException { mainCollector.collect(doc); } public void setScorer(Scorer scorer) throws IOException { mainCollector.setScorer(scorer); } public void setNextReader(AtomicReaderContext context) throws IOException { mainCollector.setNextReader(context); } public int getTotalHits() { return mainCollector.getTotalHits(); }
所有的方法全部委托给mainQuery的collector,即当ReRankQuery在进行收集的时候,和使用mainQuery进行收集的逻辑是一样的,第二轮收集的逻辑体现在下面的方法上:当在获得最后的doc的时候,将第一轮收集获取的那些doc进行第二次排序:
第一个参数表示分页查找时的偏移量,也就是从第几个开始返回,第一个参数表示返回多少个。 public TopDocs topDocs(int starts, int howMany) { try { TopDocs mainDocs = mainCollector.topDocs(0, Math.max(reRankDocs, length));//从第一轮的手机中获得reRankDocs和lenght较大个的doc, if (mainDocs.totalHits == 0 || mainDocs.scoreDocs.length == 0) { return mainDocs; } if (boostedPriority != null) {//这个不进入,忽略 忽略这个。 } else { ScoreDoc[] mainScoreDocs = mainDocs.scoreDocs; /* * Create the array for the reRankScoreDocs. </br> * 收集到的所有的doc中要重新排序的部分,因为在第一轮排序的时候,可能收集不够length数量的doc,所以要去最小值 */ ScoreDoc[] reRankScoreDocs = new ScoreDoc[Math.min(mainScoreDocs.length, reRankDocs)];//这个表示要进行reRank(也就是第二轮重排)的docs System.arraycopy(mainScoreDocs, 0, reRankScoreDocs, 0, reRankScoreDocs.length); mainDocs.scoreDocs = reRankScoreDocs; // 进行重排,重排后的都在返回的rescoredDocs里面,重排的逻辑在QueryRescorer中,下面有这个的讲解 TopDocs rescoredDocs = new QueryRescorer(reRankQuery) { @Override protected float combine(float firstPassScore, boolean secondPassMatches,float secondPassScore) {//第一轮的得分和第二轮的分的处理 float score = firstPassScore; if (secondPassMatches) {//如果第一轮的某个doc也被第二轮命中了, score += reRankWeight * secondPassScore;//结果是加上第二轮的得分乘以第二轮的权重。 } return score; } }.rescore(searcher, mainDocs, mainDocs.scoreDocs.length); // Lower howMany to return if we've collected fewer documents. howMany = Math.min(howMany, mainScoreDocs.length);//这里的howMany就是start + rows,可能第一轮就没有搜到足够多 //往下走是比较费解的,为啥没有用到start呢?下面有答案。可以先把howMany理解为start + rows,事实上就是这样的 if (howMany == rescoredDocs.scoreDocs.length) {//如果第二轮收集的doc的数量正好是howmay,则直接返回 return rescoredDocs; // Just return the rescoredDocs } else if (howMany > rescoredDocs.scoreDocs.length) {//如果第一轮收集的doc的数量不够howmay,我现在猜测下面的额处理是sorl的一个bug(当然不会造成什么错误结果,只是下面的处理没有任何影响)。 // We need to return more then we've reRanked, so create the combined page. ScoreDoc[] scoreDocs = new ScoreDoc[howMany]; // lay down the initial docs System.arraycopy(mainScoreDocs, 0, scoreDocs, 0, scoreDocs.length); // overlay the rescoreds docs System.arraycopy(rescoredDocs.scoreDocs, 0, scoreDocs, 0, rescoredDocs.scoreDocs.length); rescoredDocs.scoreDocs = scoreDocs; return rescoredDocs; } else {// // We've rescored more then we need to return. ScoreDoc[] scoreDocs = new ScoreDoc[howMany]; System.arraycopy(rescoredDocs.scoreDocs, 0, scoreDocs, 0, howMany); rescoredDocs.scoreDocs = scoreDocs; return rescoredDocs; } } } catch (Exception e) { throw new SolrException(SolrException.ErrorCode.BAD_REQUEST, e); } } }
最后就是那个重排的方法了,也就是org.apache.lucene.search.QueryRescorer.rescore(IndexSearcher, TopDocs, int)方法:
@Override public TopDocs rescore(IndexSearcher searcher, TopDocs firstPassTopDocs, int topN) throws IOException { ScoreDoc[] hits = firstPassTopDocs.scoreDocs.clone(); //对第一轮的结果先按照docid排序, Arrays.sort(hits, new Comparator<ScoreDoc>() { @Override public int compare(ScoreDoc a, ScoreDoc b) { return a.doc - b.doc; } }); List<AtomicReaderContext> leaves = searcher.getIndexReader().leaves(); // 使用第二轮排序的query进行重排 Weight weight = searcher.createNormalizedWeight(query); // Now merge sort docIDs from hits, with reader's leaves: int hitUpto = 0; int readerUpto = -1; int endDoc = 0; int docBase = 0; Scorer scorer = null; while (hitUpto < hits.length) {//循环所有的第一轮收集到的doc ScoreDoc hit = hits[hitUpto]; int docID = hit.doc; AtomicReaderContext readerContext = null; //这个判断的目的是现从一个段中查找,只有当这个段查找完了才查找下一个。 while (docID >= endDoc) { readerUpto++; readerContext = leaves.get(readerUpto); endDoc = readerContext.docBase + readerContext.reader().maxDoc();//endDoc表示当前正在查找的段的最大的docid。 } if (readerContext != null) { // We advanced to another segment: docBase = readerContext.docBase; scorer = weight.scorer(readerContext, null);//在当前的段中查找倒排表 } if(scorer != null) {//如果可以找到结果(此时是有可能找不到的,因为现在使用的第二轮的query,他和第一轮的query是不同的) int targetDoc = docID - docBase;//当前的doc在当前段的id(也就是减去docBase) int actualDoc = scorer.docID();//当前的scorer所在的docid if (actualDoc < targetDoc) { actualDoc = scorer.advance(targetDoc);//移动到不小于指定的id的位置 } if (actualDoc == targetDoc) {//如果相等,表示第二次查询也命中了这个doc // Query did match this doc: hit.score = combine(hit.score, true, scorer.score());//两次得分的处理,可以实现自己的处理方式,模式是加上去,不过要乘以权重! } else {//第二轮查找没有命中这个doc // Query did not match this doc: assert actualDoc > targetDoc; hit.score = combine(hit.score, false, 0.0f); } } else {//如果在这个段中第二轮的qurry没有命中任何的结果,则一定不会命中这个doc了 // Query did not match this doc: hit.score = combine(hit.score, false, 0.0f); } hitUpto++; } //使用第二轮之后的得分重新排序,得分优先 Arrays.sort(hits, new Comparator<ScoreDoc>() { @Override public int compare(ScoreDoc a, ScoreDoc b) { // Sort by score descending, then docID ascending: if (a.score > b.score) { return -1; } else if (a.score < b.score) { return 1; } else { // This subtraction can't overflow int // because docIDs are >= 0: return a.doc - b.doc; } } }); if (topN < hits.length) {//这个的意思是如果最后返回的额topN个(也就是start+rows)小于收集到的doc,则只取topN个。比如第一阶段收集了100个,但是分页显示每一页10个,只要第二页的10个,则topN是20,只要前面的20个即可。 ScoreDoc[] subset = new ScoreDoc[topN]; System.arraycopy(hits, 0, subset, 0, topN); hits = subset; } //将结果用topDocs包装。 return new TopDocs(firstPassTopDocs.totalHits, hits, hits[0].score); }
从这里可以看出,即使在第一轮查找中指定了sort也是没有用的,因为在第二轮排序的时候,会先使用id重排,最后的排序结果和第一轮的sort没有任何关系,全部是靠得分。
最后还有一个问题,为什么上面没有使用start,而是使用了0呢?因为在SolrIndexSearcher的getDocListNC(或者是getDocListAndSetNC)方法中,查找的时候的代码是这样的:
final TopDocsCollector topCollector = buildTopDocsCollector(len, cmd);//创建收集器 DocSetCollector setCollector = new DocSetDelegateCollector(maxDoc >> 6, maxDoc, topCollector);//创建代理收集器,实现更多的功能,比如限制时间,比如返回docSet,默认的收集器只是返回排好序的list Collector collector = setCollector; buildAndRunCollectorChain(qr, query, luceneFilter, collector, cmd, pf.postFilter);//从lucene的索引中查找。 set = setCollector.getDocSet();//获得docSet,这个就是代理收集器的作用。 totalHits = topCollector.getTotalHits();//获得所有的命中数量 TopDocs topDocs = topCollector.topDocs(0, len); //这里就是我得问题的答案:他在使用topDocs方法的时候,没有使用start,而是仅仅使用了0,因为solr在分页的处理中是自己处理的,没有交给lucene!所以之前的那个howmany就是len,也就是start + rows 忽略下面的
完了,ReRank就是这么简单。。。。。。。
一个fruitfull的周末的时间
已有 0 人发表留言,猛击->> 这里<<-参与讨论
ITeye推荐