Lucene源码系列(六):多个有序列表如何取交集?

1,677 阅读7分钟

背景

在Lucene中,对于满足某个query或者是包含某个term的文档号列表(迭代器)我们称为倒排链,在Lucene中倒排链通常就是DocIdSetIterator(在Lucene高亮算法系列中已经介绍过了)。而对于倒排链,有个非常常见的问题是,怎么求多个倒排链的交集?

求倒排链的交集在诸如BooleanQuery的多个must子句,或者是PhraseQuery的多个term中等都涉及到。BooleanQuery的多个must子句需要求每个must子句倒排链的交集,PhraseQuery查找满足要求的文档的第一步就是需要获取PhraseQuery中每个term倒排链的交集。

我们以PhraseQuery为例,举个例子,短语匹配查找是分为两步,第一步查找包含短语中所有term的文档列表,这一步就需要对每个term的倒排链表取交集,另一步是判断满足slop距离的短语,这个我们以后介绍。

public class ConjunctionDISIDemo {
    private static final String FIELD_NAME = "field0";

    public static void main(String[] args) throws IOException {
        Directory directory = new ByteBuffersDirectory();
        WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);

        Document document;
        // doc0
        document = new Document();
        document.add(new TextField(FIELD_NAME, "b d", Field.Store.YES));
        indexWriter.addDocument(document);
        // doc1
        document = new Document();
        document.add(new TextField(FIELD_NAME, "d", Field.Store.YES));
        indexWriter.addDocument(document);
        // doc2
        document = new Document();
        document.add(new TextField(FIELD_NAME, "b c", Field.Store.YES));
        indexWriter.addDocument(document);
        // doc3
        document = new Document();
        document.add(new TextField(FIELD_NAME, "a b d", Field.Store.YES));
        indexWriter.addDocument(document);
        // doc4
        document = new Document();
        document.add(new TextField(FIELD_NAME, "a b c", Field.Store.YES));
        indexWriter.addDocument(document);
        // doc5
        document = new Document();
        document.add(new TextField(FIELD_NAME, "a b c", Field.Store.YES));
        indexWriter.addDocument(document);
        // doc6
        document = new Document();
        document.add(new TextField(FIELD_NAME, "a c", Field.Store.YES));
        indexWriter.addDocument(document);
        // doc7
        document = new Document();
        document.add(new TextField(FIELD_NAME, "c", Field.Store.YES));
        indexWriter.addDocument(document);
        // doc8
        document = new Document();
        document.add(new TextField(FIELD_NAME, "a b c d", Field.Store.YES));
        indexWriter.addDocument(document);

        indexWriter.flush();
        indexWriter.commit();

        // 假设我们要查找这个短语匹配
        PhraseQuery phraseQuery = new PhraseQuery.Builder()
                .setSlop(20)
                .add(new Term(FIELD_NAME, "a"), 0)
                .add(new Term(FIELD_NAME, "b"), 1)
                .add(new Term(FIELD_NAME, "c"), 2)
                .add(new Term(FIELD_NAME, "d"), 3)
                .build();

        // 以下就是Lucene中寻找满足PhraseQuery的第一步,寻找同时包含a b c d的文档列表
        IndexReader reader = DirectoryReader.open(indexWriter);
        LeafReaderContext leafReaderContext = reader.getContext().leaves().get(0);
        // 包含a的倒排链
        PostingsEnum postingsEnum1 = leafReaderContext.reader().postings(new Term(FIELD_NAME, "a"));
        // 包含b的倒排链
        PostingsEnum postingsEnum2 = leafReaderContext.reader().postings(new Term(FIELD_NAME, "b"));
        // 包含c的倒排链
        PostingsEnum postingsEnum3 = leafReaderContext.reader().postings(new Term(FIELD_NAME, "c"));
        // 包含d的倒排链
        PostingsEnum postingsEnum4 = leafReaderContext.reader().postings(new Term(FIELD_NAME, "d"));

        // 同时包含a b c d的倒排链,对上面四个倒排表求交集
        DocIdSetIterator docIdSetIterator = ConjunctionUtils.intersectIterators(
                List.of(postingsEnum1, postingsEnum2, postingsEnum3, postingsEnum4));

        while (docIdSetIterator.nextDoc() != DocIdSetIterator.NO_MORE_DOCS) {
            System.out.println(docIdSetIterator.docID());
        }

        indexWriter.close();
    }
}

输出为:

8

对上述问题进行一般化,假设倒排链就是数组。对于上面的例子,就有四个倒排链,如下所示,每个倒排链中就是包含此term的文档编号,按升序排列:

那我们应该怎么查找同时包含a b c d的文档号呢?

算法流程

接下来我们就以上面的例子来介绍​倒排链交集的算法执行流程。

预处理

首先,应该确定一个倒排链作为处理中心,那是以哪个倒排链为中心呢?作为中心的倒排链处理完了就找完了,那当然越短越好了。因此,我们找最短的倒排链作为处理的中心即可。

所以,在算法执行之前,我们要对所有的倒排链按照长度排序,取最短的倒排链作为处理中心(倒排链d),我们称它为lead

接下来我们看看怎么以这个倒排链为中心来寻找交集的文档。

步骤1

如下图所示,取出lead的第一个文档号doc0,在倒排链a中查找大于等于doc0的文档号,在倒排链a中没有找到doc0,而是找到了doc3。

此时,可以知道小于doc3的肯定不是我们要找的结果,因为倒排链a中没有小于doc3的文档编号。所以lead需要从大于等于doc3的文档号开始查找。

步骤2

如下图所示,lead大于等于doc3的文档刚好就是doc3,在这一轮中,因为倒排链a刚好存在doc3,所以接着判断其他倒排链。

如上图所示,在判断其他倒排链时,倒排链b找到了doc3,但是倒排链c没有找到doc3,本轮查找失败。在倒排链c中我们找到的是doc4,所以小于doc4的肯定是不满足交集要求的。新的一轮,我们需要从lead1中查找大于等于4的文档号。

步骤3

如下图所示,从lead中查找大于等于doc4的文档,找到了doc8。所以新的一轮就以doc8为查找目标。这次我们在所有的倒排链中都找到了doc8,所以doc8就是满足交集的第一个文档。也正如我们示例的输出结果所示。

从上面的算法流程描述中,我们了解整个倒排链交集算法的执行过程,接下来我们看看Lucene中是怎么实现的。

ConjunctionDISI

Lucene中多个倒排链的交集实现在ConjunctionDISI中,ConjunctionDISI继承DocIdSetIterator的子类,所以它其实也是一个docId的迭代器,使用的装饰器模式增加了额外的获取交集的逻辑。接下来我们就来看看ConjunctionDISI的具体实现。

特殊说明

  • Lucene中获取交集的逻辑跟上面的算法描述有点区别,Lucene的实现有两个lead,但是第二个lead(第二短的倒排链)其实只是为了每一轮查找少一次DocIdSetIterator.docId()方法的调用(后面源码会看到)。通常DocIdSetIterator.docId()没有什么代价,但是在倒排链很长的情况下,积少成多,也是一部分性能的优化。

  • 因为倒排链DocIdSetIterator其实是个迭代器,它只能往后查找,所以在算法处理之前,必须保证每一次查找开始时,所有的倒排链必须都是定位到相同的文档(DocIdSetIterator初始文档id都是-1)。想想如果对于如下图所示的倒排链,绿色箭头表示交集算法处理的起始位置,第二个倒排链中的文档3就被遗漏了,从而交集的结果少了文档3,这个限制也说明了整个ConjunctionDISI的交集算法执行过程中没有中间状态,只要还有匹配的文档,则当前的状态肯定是每个倒排链都在匹配的位置。

成员变量

// lead1就是长度最短的倒排链
// lead2是长度第二短的倒排链
final DocIdSetIterator lead1, lead2;
// 其他倒排链
final DocIdSetIterator[] others;

构造方法

在ConjunctionDISI的构造函数中主要是按长度对倒排链做排序,初始化lead1和lead2以及其他的倒排链:

private ConjunctionDISI(List<? extends DocIdSetIterator> iterators) {
  // 按倒排链的长度进行排序
  CollectionUtil.timSort(
      iterators,
      new Comparator<DocIdSetIterator>() {
        @Override
        public int compare(DocIdSetIterator o1, DocIdSetIterator o2) {
          // cost通常情况就是 DocIdSetIterator 的长度
          return Long.compare(o1.cost(), o2.cost());
        }
      });
  // 最短的倒排链  
  lead1 = iterators.get(0);
  // 第二短的倒排链  
  lead2 = iterators.get(1);
  // 剩余的倒排链  
  others = iterators.subList(2, iterators.size()).toArray(new DocIdSetIterator[0]);
}

核心接口

@Override
public int advance(int target) throws IOException {
  // 涉及文档查找,必须做当前状态的预检查,所有的倒排链必须处于同一个docId  
  assert assertItersOnSameDoc()
      : "Sub-iterators of ConjunctionDISI are not one the same document!";
  // 查找都是从lead1开始查找的  
  return doNext(lead1.advance(target));
}

@Override
// 当前符合交集要求的文档肯定是
public int docID() {
  return lead1.docID();
}

@Override
public int nextDoc() throws IOException {
  // 涉及文档查找,必须做当前状态的预检查,所有的倒排链必须处于同一个docId  
  assert assertItersOnSameDoc()
      : "Sub-iterators of ConjunctionDISI are not on the same document!";
  // 查找都是从lead1开始查找的   
  return doNext(lead1.nextDoc());
}

@Override
// 最多遍历的次数就是lead1的长度
public long cost() {
  return lead1.cost();
}

// 判断所有的倒排链当前必须处于同一个文档号。
private boolean assertItersOnSameDoc() {
  int curDoc = lead1.docID();
  boolean iteratorsOnTheSameDoc = (lead2.docID() == curDoc);
  for (int i = 0; (i < others.length && iteratorsOnTheSameDoc); i++) {
    iteratorsOnTheSameDoc = iteratorsOnTheSameDoc && (others[i].docID() == curDoc);
  }
  return iteratorsOnTheSameDoc;
}

交集算法实现

我们看下交集查找算法最核心的部分,必须是lead1和lead2满足了才去判断其他的倒排链:

private int doNext(int doc) throws IOException {
  advanceHead:
  for (; ; ) {
    // 查找都是从lead1开始查找的    
    assert doc == lead1.docID();

    // 在lead2中查找大于等于doc的文档号
    // lead2肯定要执行一次定位,因为执行到这里说明是新的一轮查找,lead1要查找的文档肯定变了​。
    // lead2的出现就是为了少一次 other.docID() < doc 的判断​(见下面)
    final int next2 = lead2.advance(doc);
    // lead2中找到的文档号next2大于lead1当前的文档号,
    // 说明小于next2的文档号都不用处理,因为lead2中没有,所以肯定不满足    
    if (next2 != doc) {
      // lead1中直接跳过不在lead2中的文档号  
      doc = lead1.advance(next2);
      // 如果lead1当前的文档号和lead2当前的文档号不一致,则不需要去判断其他的倒排链,直接重新开始查找
      if (next2 != doc) {
        continue;
      }
    }

    // 逻辑走到这里说明当前的lead1和lead2的文档号相同,需要判断其他的倒排链是否满足
    for (DocIdSetIterator other : others) {
      // 这里需要注意docID初始都是-1,因为下面的逻辑(continue中),此后other.docID都是小于等于doc的。
      if (other.docID() < doc) {
        final int next = other.advance(doc);

        if (next > doc) {
          // 如果在other中没有找到doc,则更新lead1的起始位置,重新开始找
          doc = lead1.advance(next);
          continue advanceHead;
        }
      }
    }

    // 返回所有迭代器同时指向的文档号(交集)
    return doc;
  }
}

写在最后

感谢看到此处的看官,如有疏漏,欢迎指正讨论。