Lucene检索源码解析(上)

有了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信息。如果获取的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的打分公式(实际使用):

                                 score(q,d)=coord(q,d)\times \sum_{t\ in\ q}{(tf(t \ in\ d)\times idf(t)^2\times t.getBoost()\times norm(t,d))}

还记得我们提到的idf(t)的计算公式吗?

                                       idf(t)=1+log(\frac{docCount}{docFreq+1})

对应到代码中,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;
  }

代码很清晰,只有条件是MUSTSHOULD的时候,该方法才会返回true,所以FILTERMUST_NOT是不会影响它的,这和我们前面介绍BooleanQuery时说的一致:MUST_NOTFILTER不参与评分。在我们的例子中,一个有三个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)是如何计算的,还记得吗?(戳这里回顾一下

                                                             coord(q,d)=\frac{overlap}{maxOverlap}

我们看到,这里代码的逻辑,就是对上述公式的实现。我们再来回顾一下代码的实现。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是如何进行检索和打分的。

注:本文是博主的个人理解,如果有错误的地方,希望大家不吝指出,谢谢

猜你喜欢

转载自blog.csdn.net/huangzhilin2015/article/details/89329854
今日推荐