背景
在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;
}
}
写在最后
感谢看到此处的看官,如有疏漏,欢迎指正讨论。