Lucene源码系列(七):你真的理解Lucene中的PhraseQuery吗?

2,571 阅读20分钟

预告

从本文中你可以知道Lucene中如何查找短语匹配的文档(公式化表达+源码图文解释),以及Lucene现有的查找算法存在哪些问题。

必看说明

这里只需要知道这个格式即可,方便后文描述,具体每个参数什么意思,后文会解释。

  • PhraseQuery(a:0, b:2, slop:1)表示短语查询包含a和b两个term,他们的position分别是0和2,短语查询的slop是1。

  • MultiPhraseQuery([a, b]:0, [c, d, e]:2, slop:3)表示位于position 0的term可以是a或者b,位于position 2的term可以是c,d或者e,短语查询的slop是3。

  • PhraseQuery和MultiPhraseQuery,统称为短语查询

  • 本文中说的单term短语查询指的是PhraseQuery,多term短语查询指的是MultiPhraseQuery。(多term短语查询还有一种是PhraseWildcardQuery,本文不做讨论)

  • 本文源码解析基于lucene-core-9.1.0。

短语查询

PhraseQuery

PhraseQuery翻译过来也就是短语查询。既然是短语查询,通常情况就是包含多个词语,在Lucene中多个词语就是多个term。也就是说短语查询是要查找文档中包含多个指定的term的文档列表。但是文档仅仅是包含这些指定的term只是满足了第一个条件,PhraseQuery中重要的参数slop是判断文档是否满足短语查询的另一个依据。

那什么是slop呢?网上很多资料说slop判断的是最大编辑距离,这个其实是不准确的。Lucene官方文档的描述只有一句话“PhraseQuery uses a slop factor to determine how many positions may occur between any two terms in the phrase and still be considered a match. The slop is 0 by default, meaning the phrase must match exactly.”,简单来说,slop就是控制短语中任意两个term之间的位置关系。本文的目的就是要从源码分析中把这个slop从根上说明白。

从官方的描述中可以知道,slop是和position相关的,所以理解slop必须先理解PhraseQuery中的term的position的概念。在PhraseQuery中定义的每个term都指定了一个position,Lucene在执行查询的时候会进行query改写,如果最小的position不是0,会把所有term的position都减去最小的position,从而第一个position肯定是0。因此,这个position可以理解成term在短语中的相对位置

有了position的概念之后,slop就是对于短语查询定义中的所有term,通过移动term使得所有term刚好满足position定义的相对位置的最大步数限制。通过移动term移动满足position定义的最小步数后文描述中称之为slop距离

我们用下面的文本来算一下几个短语查询的slop距离,加深理解:

 pos: 0 1 2 3 4
text: a b c d e
  • PhraseQuery(a:0, c:1)的slop距离为1,a往后移1个位置,或者c往前移1个位置。
  • PhraseQuery(e:0, c:1)的slop距离为3,c往后移3个位置,或者e往前移3个位置。
  • PhraseQuery(a:0, c:1, d:4)的slop距离为2,c往前移1个位置并且d往后移1个位置。
  • 特殊情况,PhraseQuery(a:0, e:0)的slop距离是4,a往后移4个位置或者e往前移4个位置,也就是a和e在同一个位置上。这个例子是不是有点反直觉,怎么可能同一个position出现不同的term。所以不要从字面意思理解短语查询。

最后看下Lucene中PhraseQuery的定义:

// 短语中所有term两两之间slop距离的最大值
private final int slop;
// 查询的短语匹配的字段
private final String field;
// 短语的term列表
private final Term[] terms;
// 要匹配短语的term之间的相对位置,和term列表一一对应
private final int[] positions;

MultiPhraseQuery

如果已经理解上了PhraseQuery,理解MultiPhraseQuery就简单多了。

MultiPhraseQuery也是短语查询,MultiPhraseQuery和PhraseQuery最大的不同是MultiPhraseQuery中对同一个position可以设置一个term集合。

看下Lucene中MultiPhraseQuery的定义(可以对比PhraseQuery的定义,只有term列表有区别):

// 查询的短语匹配的字段
private final String field;
// termArrays[i][] 表示positions[i]对应的term集合,也就是这个position上面可以出现的term
private final Term[][] termArrays;
private final int[] positions;
private final int slop;

MultiPhraseQuery查询的结果其实是多个PhraseQuery结果的并集。计算MultiPhraseQuery中不同position中的term集合的笛卡尔积,笛卡尔积中的每个结果就是一个PhraseQuery,MultiPhraseQuery的结果就是每个PhraseQuery结果的并集。

我们看个MultiPhraseQuery的例子:

MultiPhraseQuery([a,d]:0, [c,e]:1, slop:3)的查询结果是:

PhraseQuery(a:0, c:1, slop:3) OR PhraseQuery(a:0, e:1, slop:3) OR PhraseQuery(d:0, c:1, slop:3) OR PhraseQuery(d:0, e:1, slop:3)

从PhraseQuery来理解MultiPhraseQuery比较简单,但是Lucene中的短语查询匹配查找算法是把PhraseQuery当做一种特殊的MultiPhraseQuery来处理的。这也符合一般工程实现的直觉,代码实现往往是更通用的。

接下来我们来看看Lucene中是怎么查找短语匹配的文档。

查找策略

满足短语查询的文档必须满足两个条件。

条件一

文档必须包含短语查询中定义的所有的term。

在Lucene中,这个条件使用的是term倒排链(包含term的文档号列表)的交并集实现的。

看几个例子:

  • PhraseQuery(a:0, c:1, d:4):a,c,d倒排链的交集肯定是包含所有term的文档号列表。
  • MultiPhraseQuery([a,d]:0, [c,e]:1):先求a和d倒排链的并集为union_a_d,再求c和e倒排链的并集union_c_e,最后再求union_a_d和union_c_e倒排链的交集就是包含所有term的文档号列表。

条件二

仅仅满足条件一,不一定就是满足短语查询,因为还要所有的term的slop距离不超过slop,虽然前面我们介绍了slop距离怎么算,那有没有更直观的判断方式?继续往下看,这是本文的亮点之一。

对于PhraseQuery(term1:pos1, term2:pos2, slop),我们标记term1在文本中的position记为term1_pos,term2在文本中的position记为term2_pos。对于term1_pos和term2_pos相对位置的不同,我们需要分情况进行讨论:

当term2_pos > term1_pos时

如果短语查询需要满足slop的要求则必须有:

term2_pos - term1_pos <= pos2 - pos1 + slop,

转化为: (term2_pos - pos2) - (term1_pos - pos1) <= slop,我们记为 (1)

当term2_pos <= term1_pos时

如果短语查询需要满足slop的要求则必须有:

term2_pos + slop - term1_pos >= pos2 -pos1,

两边取负号:term1_pos - term2_pos - slop <= pos1 - pos2

再转化为:(term1_pos - pos1) - (term2_pos - pos2) <= slop,我们记为 (2)

PhraseQuery满足slop要求的条件

结合 (1)(2) 可得:

|(term1_pos - pos1) - (term2_pos - pos2)| <= slop,

即:Max(term1_pos - pos1, term2_pos - pos2) - Min(term1_pos - pos1, term2_pos - pos2) <= slop

这就是短语查询是否满足slop要求的判断条件,也是Lucene中对于slop不为0查找算法(SloppyPhraseMatcher)的核心思想:。

slop为0的特殊情况

如果slop为0,则满足:|(term1_pos - pos1) - (term2_pos - pos2)| <= 0,

即:|(term1_pos - pos1) - (term2_pos - pos2)| = 0

最终得:term1_pos - pos1 = term2_pos - pos2

这里有个印象,Lucene中针对slop为0的情况实现了一个单独的查找算法:ExactPhraseMatcher

N个position

以上是两个position的情况,N个position的大家可以自行推导,这边给出结论:

Max(term1_pos - pos1, term2_pos - pos2, ..., termN_pos - posN) - Min(term1_pos - pos1, term2_pos - pos2, ..., termN_pos - posN) <= slop

后文描述中,我们把termN_pos - posN叫做PhrasePos请记住这个说明!!!

小结

有了上面推导的结论,在查找之前,我们先计算每个position的PhrasePos列表,然后执行查找过程就是判断所有position的PhrasePos中最大值和最小值的差是否小于等于slop,如果是,那就是满足PhraseQuery的一个匹配位置。

这句话描述比较简单,但是查找短语匹配的算法真正执行需要区分不同的情况,接下来我们用例子来说明算法的执行流程。

查找的算法流程

slop为0

假设我们要从“a b d a b c”中查找满足PhraseQuery(a:0, b:1, c:2, slop:0)的短语查询。

首先,我们需要计算每个position的PhrasePos列表,如下图中的PhrasePos_a,PhrasePos_b和PhrasePos_c所示。

根据我们在上节中的结论有如果slop为0,则各个position的PhrasePos必须是相等的。所以问题也就转化成了从多个PhrasePos中找交集。多个有序列表找交集我们之前有文章介绍过了,这里就不重复介绍了,不懂的欢迎前去考古。

slop不为0

slop不为0的情况下,我们分为四种场景来描述算法执行过程,但是场景三和场景四都是可以转化成场景一和场景二,所以场景一和场景二一定要理解。

场景一:每个position只有一个term,并且每个position的term都不相同

假设我们要从“a a b c a b c”中查找满足PhraseQuery(a:1, b:2, slop:2)的短语查询。

首先,我们需要计算每个position的PhrasePos列表,如下图中的PhrasePos_a和PhrasePos_b所示。当前查找的位置由绿红两个箭头所示,分别指向了两个position的PhrasePos列表的第一个位置。

当前的PhrasePos_a < PhrasePos_b,所以第一轮查找以a开头的短语,停止条件是PhrasePos_a > PhrasePos_b。

计算初始的匹配距离为 matchLength = 0 - (-1)= 1 < 2,所以我们已经找到一个匹配位置,但是这个不一定是以a开始的最短匹配,我们可以继续查找a的下一个位置,直到PhrasePos_a > PhrasePos_b

PhrasePos_a的下一个位置是0, 0 - 0 = 0 < slop,并且更新matchLength = 0,同时记录更优的匹配位置,然后继续查找PhrasePos_a。

PhrasePos_a的下一个位置是3,大于PhrasePos_b,所以我们找到了一个以a开始的短语匹配,对应文本中的position是[1, 2]。接下来需要开始查找以b开头的短语匹配。初始matchLength=3-0=3>slop,不满足短语查询条件,所以需要找b的下一个位置。

PhrasePos_b的下一个位置是3,3-3=0<slop,并且PhrasePos_a和PhrasePos_b都已经查找结束,所以我们又找到了一个以b开头的匹配位置,对应文本中的position是[4, 5]

所以,对于例子中我们一共找到了两个匹配位置。后续就不单独说明记录找到的位置,只讲查找流程。

场景二:每个position只有一个term,并且不同的position的term可能相同

假设我们要从“a a b c a b a c”中查找满足PhraseQuery(a:1, b:2, a:3, slop:2)的短语查询。

同样地,我们需要先计算每个position的PhrasePos列表,如下图所示。因为短语中包含了两个a,因此我们用PhrasePos_a_0和PhrasePos_a_1分别表示position=1和position=3的a的PhrasePos列表。当前查找的位置由绿红蓝三个箭头所示,分别指向了三个position的PhrasePos列表的第一个位置。

因为初始位置PhrasePos_a_0和PhrasePos_a_1冲突了(对应的PhrasePos是文本中的同一个位置),所以我们需要先处理冲突的情况。碰到冲突的情况,我们把PhrasePos小的继续往后查找一个位置(为什么是PhrasePos小的往后找一个位置?最后一节我们单独讨论),如下图所示,PhrasePos_a_1指向了-2。

当前最小的是PhrasePos_a_1,第2小的是PhrasePos_a_0,所以第一轮查找以PhrasePos_a_1开始,停止条件是PhrasePos_a_1 > PhrasePos_a_0,这个是以PhrasePos_a_1为短语起点开始查找的结束条件,自行理解下。

计算初始的匹配距离为 matchLength = PhrasePos_b - PhrasePos_a_1 = 0 - (-2)= 2 <= 2,所以初始位置满足匹配要求,但是它不一定是以PhrasePos_a_1开始的最短匹配,我们可以继续查找PhrasePos_a_1的下一个位置,直到PhrasePos_a_1 > PhrasePos_a_0

如下图所示,PhrasePos_a_1的下一个位置指向1,大于PhrasePos_a_0,所以我们找到了一个以PhrasePos_a_1开始的匹配。

当前最小的是PhrasePos_a_0,第2小的是PhrasePos_b,所以第二轮查找以PhrasePos_a_0开始,停止条件是PhrasePos_a_0 > PhrasePos_b。

第二轮初始匹配距离为 matchLength = PhrasePos_a_1 - PhrasePos_a_0 = 1 - (-1)= 2 <= 2,所以初始位置满足匹配要求,但是它不一定是以PhrasePos_a_0开始的最短匹配,我们可以继续查找PhrasePos_a_0的下一个位置,直到PhrasePos_a_0> PhrasePos_b

如下图所示,PhrasePos_a_0的下一个位置是0,当前匹配距离是matchLength=PhrasePos_a_1 - PhrasePos_a_0 = 1 - 0 = 1是个更短的匹配。但是它不一定是以PhrasePos_a_0开始的最短匹配,我们可以继续查找PhrasePos_a_0的下一个位置,直到PhrasePos_a_0> PhrasePos_b

如下图所示,PhrasePos_a_0的下一个位置是3,大于PhrasePos_b,所以我们已经找到一个以PhrasePos_a_0开始的匹配。

如上图所示,当前的PhrasePos_a_0和PhrasePos_a_1冲突了。我们需要先处理下冲突。碰到冲突的情况,我们把PhrasePos小的继续往后查找一个位置,如下图所示,PhrasePos_a_0指向了3。

当前最小的是PhrasePos_b,第2小的是PhrasePos_a_0,所以第三轮查找以PhrasePos_b开始,停止条件是PhrasePos_b > PhrasePos_a_0。

第三轮初始匹配距离为 matchLength = PhrasePos_a_1 - PhrasePos_b = 3 - 0= 3 > 2,所以初始位置不满足匹配要求,我们可以继续查找PhrasePos_b的下一个位置,直到PhrasePos_b > PhrasePos_a_0

如下图所示,PhrasePos_b的下一个位置指向3,当前的匹配距离matchLength=PhrasePos_a_1 - PhrasePos_a_0=3-3=0<slop,满足匹配要求。因为PhrasePos_b没有下一个位置了并且PhrasePos_b没有大于PhrasePos_a_0,所以查找结束。

场景三:每个position可能有多个term,并且每个position的term集合都不相同

这种情况其实就是MultiPhraseQuery的查找。对于MultiPhraseQuery的查找,只比场景一中多做一步,就是对每个position的term集合中的每个term计算PhrasePos的并集。

对于MultiPhraseQuery([a, b]:0, [c, d]:1, slop:2)查找的起始状态如下图所示,剩下流程和场景一相同。

场景四:每个position可能有多个term,并且各个position的term集合可能有重叠

这种情况其实也是MultiPhraseQuery的查找。情况四只比场景二中多做一步,就是对每个position的term集合中的每个term计算PhrasePos的并集。

对于MultiPhraseQuery([a, b]:0, [a, c]:1, slop:2)查找的起始状态如下图所示,剩下流程和场景二相同。

知道了短语匹配四种不同情况如何查找匹配的算法流程之后,我们就可以进入Lucene的源码实现了。

Lucene中的实现

在Lucene中PhraseQuery和MultiPhraseQuery的匹配查找算法的实现是同一套(其实还有PhraseWildcardQuery,这里我们就不介绍了)。查找短语匹配的顶层接口是PhraseMatcher,Lucene中区分了slop为0和非0两种情况,这两种情况的查找实现分别是ExactPhraseMatcherSloppyPhraseMatcher

PhraseMatcher

首先我们看下,短语查询匹配查找的顶层接口,我们只看跟短语查找有关的方法:

  // 包含所有term的文档id迭代器
  abstract DocIdSetIterator approximation();

  // 可能匹配的最大次数
  abstract float maxFreq() throws IOException;

  // 当定位到一个新的文档之后,需要先执行先reset
  public abstract void reset() throws IOException;

  // 如果找到一个匹配的位置,则返回true。找不到返回false。
  public abstract boolean nextMatch() throws IOException;

  // 当前匹配的得分,用来计算文档的短语查询得分
  abstract float sloppyWeight();

  // 匹配位置的起始position,在nextMatch方法中找到匹配位置会记录。
  abstract int startPosition();

  // 匹配位置的结束position,在nextMatch方法中找到匹配位置会记录。
  abstract int endPosition();

  // 匹配位置的起始offset,在nextMatch方法中找到匹配位置会记录。
  abstract int startOffset() throws IOException;

  // 匹配位置的结束offset,在nextMatch方法中找到匹配位置会记录。
  abstract int endOffset() throws IOException;

ExactPhraseMatcher

ExactPhraseMatcher是处理slop为0的情况。ExactPhraseMatcher中的大部分方法都比较简单,这里就直接都列出来:

public final class ExactPhraseMatcher extends PhraseMatcher {

  private static class PostingsAndPosition {
    // 倒排链
    private final PostingsEnum postings;
    // 短语匹配中定义的term的position 
    private final int offset;
    // freq:总的匹配位置个数
    // upTo:当前已经找到了几个匹配位置
    // pos:当前匹配位置在文档中的position
    private int freq, upTo, pos;

    public PostingsAndPosition(PostingsEnum postings, int offset) {
      this.postings = postings;
      this.offset = offset;
    }
  }
  // 短语查询中的每个position对应一个
  private final PostingsAndPosition[] postings;
  // 所有position的倒排链的交集  
  private final DocIdSetIterator approximation;
  private final ImpactsDISI impactsApproximation;

  // 这里需要注意的是: postings已经按短语匹配中的position排序了
  public ExactPhraseMatcher(
      PhraseQuery.PostingsAndFreq[] postings,
      ScoreMode scoreMode,
      SimScorer scorer,
      float matchCost) {
    super(matchCost);

    final DocIdSetIterator approximation =
        // 倒排链交集我们之前的文章介绍过了
        ConjunctionUtils.intersectIterators(
            Arrays.stream(postings).map(p -> p.postings).collect(Collectors.toList()));
    。。。省略和匹配无关的

    List<PostingsAndPosition> postingsAndPositions = new ArrayList<>();
    for (PhraseQuery.PostingsAndFreq posting : postings) {
      postingsAndPositions.add(new PostingsAndPosition(posting.postings, posting.position));
    }
    this.postings =
        postingsAndPositions.toArray(new PostingsAndPosition[postingsAndPositions.size()]);
  }

  @Override
  // 短语中各个position倒排链的交集  
  DocIdSetIterator approximation() {
    return approximation;
  }

  // 所有position上term在当前文档中频率最低的那个就是当前文档可能匹配的最大次数
  float maxFreq() {
    int minFreq = postings[0].freq;
    for (int i = 1; i < postings.length; i++) {
      minFreq = Math.min(minFreq, postings[i].freq);
    }
    return minFreq;
  }

  // 查找position 大于等于 target的匹配位置
  private static boolean advancePosition(PostingsAndPosition posting, int target)
      throws IOException {
    // 往后查找,直到找到 position 大于等于 target的匹配位置
    while (posting.pos < target) {
      // 如果已经找到了所有的匹配位置了,则返回false  
      if (posting.upTo == posting.freq) {
        return false;
      } else {
        // 定位下一个匹配位置  
        posting.pos = posting.postings.nextPosition();
        posting.upTo += 1;
      }
    }
    return true;
  }

  @Override
  // 初始化所有的 PostingsAndPosition
  public void reset() throws IOException {
    for (PostingsAndPosition posting : postings) {
      posting.freq = posting.postings.freq();
      posting.pos = -1;
      posting.upTo = 0;
    }
  }

  @Override
  // 每个匹配位置对相关性得分贡献一样  
  float sloppyWeight() {
    return 1;
  }

  @Override
  // 短语匹配定义中最小的position对应的term的位置  
  public int startPosition() {
    return postings[0].pos;
  }

  @Override
  // 短语匹配定义中最大的position对应的term的位置  
  public int endPosition() {
    return postings[postings.length - 1].pos;
  }

  @Override
  // 短语匹配定义中最小的position对应的term的offset  
  public int startOffset() throws IOException {
    return postings[0].postings.startOffset();
  }

  @Override
  // 短语匹配定义中最大的position对应的term的offset 
  public int endOffset() throws IOException {
    return postings[postings.length - 1].postings.endOffset();
  }
}

查找匹配的核心方法,我们单独拿出来分析。在分析具体实现之前,先回顾下查找策略中的特殊情况

如果slop为0,则满足:term1_pos - pos1 = term2_pos - pos2

所以,如果已经定位到了term1的位置,则term2的位置必须是term2_pos = term1_pos - pos1 + pos2

有了这结论,我们以position最小的PostingsAndPosition为查找起点,每次定位到一个位置之后,根据上述结论查找其他的PostingsAndPosition。

代码的逻辑实现和之前我们介绍倒排链交集的实现非常类似:

  @Override
  public boolean nextMatch() throws IOException {
    final PostingsAndPosition lead = postings[0];
    // 短语中第一个term的倒排定位到下一个匹配位置,
    // 如果没有下一个匹配位置,则直接返回false。因为肯定没有满足匹配要求的短语。
    if (lead.upTo < lead.freq) {
      lead.pos = lead.postings.nextPosition();
      lead.upTo += 1;
    } else {
      return false;
    }
    // 复习下java语法:
    // continue advanceHead; 表示继续执行下面的while循环。
    // break advanceHead; 表示执行while循环的下一个语句
    advanceHead:
    while (true) {
      // 知道为什么我们提出PhrasePos了吧  
      final int phrasePos = lead.pos - lead.offset;
      for (int j = 1; j < postings.length; ++j) {
        final PostingsAndPosition posting = postings[j];
        // 需要查找在文本中的position  
        final int expectedPos = phrasePos + posting.offset;

        // 如果没有找到大于等于expectedPos的位置
        if (advancePosition(posting, expectedPos) == false) {
          // 停止查找  
          break advanceHead;
        }

        // 如果找到的比expectedPos大
        if (posting.pos != expectedPos) {
          // 则我们从lead中找大于等于 posting.pos - posting.offset + lead.offset 的位置,
          // 如果能够找到,则以这个位置为准重新开始查找,
          // 找不到则停止查找。  
          if (advancePosition(lead, posting.pos - posting.offset + lead.offset)) {
            continue advanceHead;
          } else {
            break advanceHead;
          }
        }
      }
      return true;
    }
    return false;
  }

SloppyPhraseMatcher

短语查询中slop不为0的情况使用SloppyPhraseMatcher来查找匹配。

在SloppyPhraseMatcher的实现中,最复杂的就是处理不同position的term集合有重叠的情况。

从前面的算法描述我们知道,在查找匹配之前需要先算每个position的PhrasePos列表。Lucene的实现并没有一次性把PhrasePos列表计算好,而是用一个工具类可以获取position的下一个PhrasePos,我们先看下这个工具类:PhrasePositions

PhrasePositions

短语查询中一个position对应了一个PhrasePositions,PhrasePositions最重要的是它可以返回position的下一个PhrasePos。

final class PhrasePositions {
  // 注意,这个position是我们前面说的PhrasePos,也就是文档中的pos - offset  
  int position; 
  // term剩余的匹配位置个数
  int count;
  // term在PhraseQuery中position  
  int offset;
  // PhraseQuery中的第几个term组 
  final int ord; 
  // term的倒排  
  final PostingsEnum postings;
  // 指向下一个  PhrasePositions
  PhrasePositions next;
  // rptGroup >= 0表示当前position的term集合和其他position的term集合有重叠,
  // 有重叠的PhrasePositions属于同一组,rptGroup标识当前PhrasePositions的组号
  int rptGroup = -1; 
  // 一个组的PhrasePositions是一个数组,rptInd表示PhrasePositions在组中的下标
  int rptInd;
  // 当前position的term集合  
  final Term[] terms; 

  PhrasePositions(PostingsEnum postings, int o, int ord, Term[] terms) {
    this.postings = postings;
    offset = o;
    this.ord = ord;
    this.terms = terms;
  }

  final void firstPosition() throws IOException {
    count = postings.freq(); 
    nextPosition();
  }

  // 返回下一个PhrasePos
  final boolean nextPosition() throws IOException {
    // 如果还存在下一个匹配的位置  
    if (count-- > 0) {
      // 计算下一个PhrasePos
      position = postings.nextPosition() - offset;
      return true;
    } else {
      return false;
    }
  }
}

SloppyPhraseMatcher的成员变量

private final PhrasePositions[] phrasePositions;

private final int slop;
private final int numPostings;
// 用来获取最小PhrasePos的PhrasePositions。
// 每一轮的查找都是从具有最小的PhrasePos的PhrasePositions开始的。
// 所以用一个堆来存储所有的PhrasePositions处理起来比较方便。
private final PhraseQueue pq;
private final boolean captureLeadMatch;

private final DocIdSetIterator approximation;
private final ImpactsDISI impactsApproximation;
// 当前短语匹配位置中最后一个term的在文本中的position
private int end; 

// 当前短语匹配位置的第一个term的位置信息
private int leadPosition;
private int leadOffset;
private int leadEndOffset;
private int leadOrd;

// 不同的position的term集合是否有重叠(交集)
private boolean hasRpts; 
// 是否检查过重叠情况,只会处理一次
private boolean checkedRpts; 
// 标记是否是场景四
private booleanPhrasePositions hasMultiTermRpts;

// 有重叠的PhrasePositions是同一个组,会按PhrasePositions.offset排序
// PhrasePositions[i][]表示第i组的PhrasePositions
private PhrasePositions[][] rptGroups; 
// 处理冲突的时候使用
private PhrasePositions[] rptStack; 

// 用来快速判断后面是否还有匹配的词组
// 如果PhraseQueue不满的话后面肯定就不存在匹配的位置
private boolean positioned;
private int matchLength;

SloppyPhraseMatcher的初始化

SloppyPhraseMatcher的初始化在reset方法中,整体流程如下所示:

SloppyPhraseMatcher初始化.png

public void reset() throws IOException {
  // 初始化的过程中会尝试寻找第一个包含所有term的短语,
  // 需要注意找到的不一定是满足slop的,是否满足slop会在nextMatch方法中判断。
  // 如果没有找到则直接标记positioned 为false,表示不存在任何的匹配位置。nextMatch方法会直接返回false。
  this.positioned = initPhrasePositions();
  this.matchLength = Integer.MAX_VALUE;
  this.leadPosition = Integer.MAX_VALUE;
}

private boolean initPhrasePositions() throws IOException {
  end = Integer.MIN_VALUE;
  // 还没检查过是否有重叠的term,则需要进行一次检查,只会执行一次  
  if (!checkedRpts) {
    return initFirstTime();
  }
  // 如果没有重叠的term  
  if (!hasRpts) {
    // 执行简单的初始化逻辑  
    initSimple();
    return true;
  }
  // 如果有重叠的term,则执行复杂的初始化逻辑  
  return initComplex();
}

第一次初始化需要执行每个position的term集合是否有重叠的处理,这部分逻辑比较复杂:

  1. 获取重叠的term集合,并为每个重叠term编号
  2. 对PhrasePositions进行分组,包含相同term的PhrasePositions分为一组
  3. 对同组的PhrasePositions按offset排序
  4. 处理因为重叠term导致PhrasePos冲突的情况
private boolean initFirstTime() throws IOException {
  // 检查过了
  checkedRpts = true;
  // 每个  PhrasePositions 都定位到term第一次出现的位置。
  // 注意:如果  PhrasePositions之间有重叠的term,则当前的PhrasePositions会定位到相同的位置
  placeFirstPositions();

  // 查找重复的term,value是重复term出现的编号,从0开始  
  LinkedHashMap<Term, Integer> rptTerms = repeatingTerms();
  hasRpts = !rptTerms.isEmpty();

  // 如果存在重复的term  
  if (hasRpts) {
    rptStack = new PhrasePositions[numPostings]; 
    // 根据重复的term进行分组  
    ArrayList<ArrayList<PhrasePositions>> rgs = gatherRptGroups(rptTerms);
    // 重复的分组进行排序  
    sortRptGroups(rgs);
    // 处理因为重叠term导致PhrasePos冲突的情况,无法解决冲突就说明不存在任何匹配的位置  
    if (!advanceRepeatGroups()) {
      // 没有一处匹配
      return false; 
    }
  }

  fillQueue();
  return true; 
}
获取重叠的term集合,并为每个重叠的term编号

term的编号是按照重叠term出现的次序,只保证唯一,没有其他要求:

private LinkedHashMap<Term, Integer> repeatingTerms() {
  LinkedHashMap<Term, Integer> tord = new LinkedHashMap<>();
  HashMap<Term, Integer> tcnt = new HashMap<>();
  for (PhrasePositions pp : phrasePositions) {
    for (Term t : pp.terms) {
      Integer cnt = tcnt.compute(t, (key, old) -> old == null ? 1 : 1 + old);
      // 第二次出现的相同的term  
      if (cnt == 2) {
        tord.put(t, tord.size());
      }
    }
  }
  return tord;
}
对PhrasePositions进行分组,包含相同term的PhrasePositions分为一组

对包含相同term的PhrasePositions分组是为了处理PhrasePos的冲突的情况。

首先获取全部包含重叠term的PhrasePositions集合:

private PhrasePositions[] repeatingPPs(HashMap<Term, Integer> rptTerms) {
  ArrayList<PhrasePositions> rp = new ArrayList<>();
  for (PhrasePositions pp : phrasePositions) {
    for (Term t : pp.terms) {
      if (rptTerms.containsKey(t)) {
        // 收集包含重叠的term的PhrasePositions  
        rp.add(pp);
        // 如果PhrasePositions中有多个term,则设置hasMultiTermRpts
        hasMultiTermRpts |= (pp.terms.length > 1);
        break;
      }
    }
  }
  return rp.toArray(new PhrasePositions[0]);
}

接着分为两种情况处理分组:

  • 如果存在重叠term的PhrasePositions都是单term

    这种情况处理比较简单,暴力查找拥有相同term的PhrasePositions为同一组。

  • 如果存在重叠term的PhrasePositions存在多个term

    这种情况需要更巧妙的处理方式。

    1. 每个PhrasePositions都转成一个位图,位图的长度是重叠term集合的长度,PhrasePositions中存在的重叠term在位图中做标记
    // 重复term的长度作为位图的长度
    // PhrasePositions中的重复term在位图中设置
    private ArrayList<FixedBitSet> ppTermsBitSets(
        PhrasePositions[] rpp, HashMap<Term, Integer> tord) {
      ArrayList<FixedBitSet> bb = new ArrayList<>(rpp.length);
      for (PhrasePositions pp : rpp) {
        FixedBitSet b = new FixedBitSet(tord.size());
        Integer ord;
        for (Term t : pp.terms) {
          if ((ord = tord.get(t)) != null) {
            b.set(ord);
          }
        }
        bb.add(b);
      }
      return bb;
    }
    
    1. 合并有重叠的位图,包含位图中标记的term就是同一个PhrasePositions组
    // 有重叠的位图合并为一个位图
    private void unionTermGroups(ArrayList<FixedBitSet> bb) {
      int incr;
      // 双重循环寻找有重叠的位图  
      for (int i = 0; i < bb.size() - 1; i += incr) {
        incr = 1;
        int j = i + 1;
        while (j < bb.size()) {
          // 如果有重叠  
          if (bb.get(i).intersects(bb.get(j))) {
            // 将j合并到i中  
            bb.get(i).or(bb.get(j));
            // 去掉j  
            bb.remove(j);
            // 当前i对应的  FixedBitSet 被更新了,需要重新在外层循环中查找重叠的位图
            incr = 0;
          } else {
            ++j;
          }
        }
      }
    }
    
    1. 为每个重叠的term指定组号
    private HashMap<Term, Integer> termGroups(
        LinkedHashMap<Term, Integer> tord, ArrayList<FixedBitSet> bb) throws IOException {
      HashMap<Term, Integer> tg = new HashMap<>();
      // 重复的term列表  
      Term[] t = tord.keySet().toArray(new Term[0]);
      // 一个位图就是一个组  
      for (int i = 0; i < bb.size(); i++) {
        FixedBitSet bits = bb.get(i);
        for (int ord = bits.nextSetBit(0);
            ord != DocIdSetIterator.NO_MORE_DOCS;
            ord =
                ord + 1 >= bits.length() ? DocIdSetIterator.NO_MORE_DOCS : bits.nextSetBit(ord + 1)) {
          // 位图设置的位置的term都属于i组  
          tg.put(t[ord], i);
        }
      }
      return tg;
    }
    
    1. 把 PhrasePositions 加到对应的组中

整体的分组逻辑:

// 对term有重叠的PhrasePositions进行分组。
private ArrayList<ArrayList<PhrasePositions>> gatherRptGroups(
    LinkedHashMap<Term, Integer> rptTerms) throws IOException {
  // 收集包含重复term的  PhrasePositions
  PhrasePositions[] rpp = repeatingPPs(rptTerms);
  // res.get(i)表示第i组的所有的 PhrasePositions之间有重叠的term 
  ArrayList<ArrayList<PhrasePositions>> res = new ArrayList<>();
  // 如果只有单term的重叠情况  
  if (!hasMultiTermRpts) {
    for (int i = 0; i < rpp.length; i++) {
      PhrasePositions pp = rpp[i];
      // 单term的情况下,如果有重复,最多需要处理一次。所以已经标记为重复的了,不需要再处理。
      if (pp.rptGroup >= 0) continue;
      // 在文档中的真实位置  
      int tpPos = tpPos(pp);
      for (int j = i + 1; j < rpp.length; j++) {
        PhrasePositions pp2 = rpp[j];
        if (pp2.rptGroup >= 0 // pp2已经标记为重复的了,那肯定是处理过了
            || pp2.offset == pp.offset // 是同一个position的
            || tpPos(pp2) != tpPos) { // 第一个的真实位置不同,就肯定不是重叠的(看initFirstTime方法中的注释)
          continue;
        }

        int g = pp.rptGroup;
        // 如果找到pp的第一个重复的重叠的PhrasePositions  
        if (g < 0) {
          // 为pp设置组号  
          g = res.size();
          pp.rptGroup = g;
          ArrayList<PhrasePositions> rl = new ArrayList<>(2);
          rl.add(pp);
          res.add(rl);
        }
        pp2.rptGroup = g;
        res.get(g).add(pp2);
      }
    }
  } else { // 处理MultiPhraseQuery的情况
    ArrayList<HashSet<PhrasePositions>> tmp = new ArrayList<>();
    // 每个PhrasePositions中包含重复term的位图,利用这个位图来做分组
    ArrayList<FixedBitSet> bb = ppTermsBitSets(rpp, rptTerms);
    // 合并有重叠的位图,包含位图中标记的term就是同一个PhrasePositions组
    unionTermGroups(bb);
    // 为每个重叠的term指定组号  
    HashMap<Term, Integer> tg = termGroups(rptTerms, bb);
    HashSet<Integer> distinctGroupIDs = new HashSet<>(tg.values());
    for (int i = 0; i < distinctGroupIDs.size(); i++) {
      tmp.add(new HashSet<PhrasePositions>());
    }
    // 把  PhrasePositions 加到对应的组中
    for (PhrasePositions pp : rpp) {
      for (Term t : pp.terms) {
        if (rptTerms.containsKey(t)) {
          int g = tg.get(t);
          tmp.get(g).add(pp);
          assert pp.rptGroup == -1 || pp.rptGroup == g;
          pp.rptGroup = g;
        }
      }
    }
    for (HashSet<PhrasePositions> hs : tmp) {
      res.add(new ArrayList<>(hs));
    }
  }
  return res;
}
对同组的PhrasePositions按offset排序

按offset排序的目的是对每个新文档执行reset的时候,如果存在重叠的term则更高效。

因为碰到冲突总是PhrasePos小的往后找一个位置,而对于同term的重叠冲突,offset大的那个PhrasePos肯定比较小,因此初始状态如果冲突的话,冲突同组的PhrasePositions从offset小的开始处理会保证后面解决offset大冲突不会影响已经处理过的。

如果上面的描述觉得不是很清楚,我这边给个例子:对于文本"a d c a b c",MultiPhraseQuery([a, b]:0, [a, c, d]:1, [c, d]:2, [a, d]:3, slop:3)。各个position的PhrasePos列表如下图所示,你可以分别按offset从大到小和从小到大分别走一遍advanceRepeatGroups方法的逻辑就能深切感受到为什么需要排序。

private void sortRptGroups(ArrayList<ArrayList<PhrasePositions>> rgs) {
  rptGroups = new PhrasePositions[rgs.size()][];
  Comparator<PhrasePositions> cmprtr =
      new Comparator<PhrasePositions>() {
        @Override
        // 按PhrasePositions的offset排序
        public int compare(PhrasePositions pp1, PhrasePositions pp2) {
          return pp1.offset - pp2.offset;
        }
      };
  for (int i = 0; i < rptGroups.length; i++) {
    PhrasePositions[] rg = rgs.get(i).toArray(new PhrasePositions[0]);
    // 每一组中的PhrasePositions按offset排序
    Arrays.sort(rg, cmprtr);
    rptGroups[i] = rg;
    for (int j = 0; j < rg.length; j++) {
      // 设置  PhrasePositions 在组中的下标
      rg[j].rptInd = j; 
    }
  }
}
处理因为重叠term导致PhrasePos冲突的情况

接着分为两种情况处理冲突:

  • 如果存在重叠term的PhrasePositions都是单term

    第一个PhrasePositions不需要动,后面跟第一个冲突的第i个PhrasePositions往后找第i个PhrasePos(i从1开始)。

  • 如果存在重叠term的PhrasePositions存在多个term

    分别处理每个组,如果存在冲突的PhrasePositions,则对PhrasePos小的那个往后找一个位置。

// 对于有重叠term的PhrasePositions当前的倒排列表定位到的position是同一个,
// 需要对有重复的单独处理下,使得每个倒排都定位到唯一的位置。
private boolean advanceRepeatGroups() throws IOException {
  for (PhrasePositions[] rg : rptGroups) {
    if (hasMultiTermRpts) {
      int incr;
      // 分别处理每个组的冲突情况  
      for (int i = 0; i < rg.length; i += incr) {
        incr = 1;
        PhrasePositions pp = rg[i];
        // 和pp冲突的PhrasePositions 在组中的下标
        int k;
        while ((k = collide(pp)) >= 0) {
          // 先比position再比offset  
          PhrasePositions pp2 = lesser(pp, rg[k]);
          // 没有下一个匹配的位置了  
          if (!advancePP(pp2)) { 
            return false; 
          }
          // 当发现如果当前和i冲突的PhrasePositions在组中排在i前面,则重新处理位于i的PhrasePositions。
          // 注意:这种情况一般不会出现,因为在上面while循环的条件中,会从前到后判断同组中的每一个。这边是个防御式编程
          if (pp2.rptInd < i) {
            incr = 0;
            break;
          }
        }
      }
    } else {
      // 对于单term的重复PhrasePositions来说,这些PhrasePositions其实倒排是同一个。
      // 所以同组中的PhrasePositions,第0个已经定位到第1个匹配的位置,不需要再动,
      // 第1个需要往后找再找1个位置,第2个需要往后再找2个位置,以此类推。
      // j是从1开始的。  
      for (int j = 1; j < rg.length; j++) {
        // 第j个需要往后找第j个位置 
        for (int k = 0; k < j; k++) {
          // 如果没有找到,则说明当前没有匹配的情况
          if (!rg[j].nextPosition()) {
            return false; 
          }
        }
      }
    }
  }
  return true; 
}

冲突的判断方式,如果PhrasePositions的指向文本中相同的position就是冲突了:

private int collide(PhrasePositions pp) {
  // 在文档中的position  
  int tpPos = tpPos(pp);
  PhrasePositions[] rg = rptGroups[pp.rptGroup];
  // 寻找同组中的是否有在相同position的 PhrasePositions
  for (int i = 0; i < rg.length; i++) {
    PhrasePositions pp2 = rg[i];
    if (pp2 != pp && tpPos(pp2) == tpPos) {
      return pp2.rptInd;
    }
  }
  return -1;
}

PhrasePositions首先比较的是PhrasePos,然后是offset:

private PhrasePositions lesser(PhrasePositions pp, PhrasePositions pp2) {
  if (pp.position < pp2.position || (pp.position == pp2.position && pp.offset < pp2.offset)) {
    return pp;
  }
  return pp2;
}

查找下一个匹配

在看下面代码之前,一定要理解算法中的场景二

// 在初始化的时候已经找到一个满足第一步的情况。
// nextMatch
// nextMatch是找下一个匹配位置。
// 
public boolean nextMatch() throws IOException {
  // 如果已经没有下一个匹配位置,直接返回false  
  if (!positioned) {
    return false;
  }
  // PhrasePos最小的出来  
  PhrasePositions pp = pq.pop();
  // 存储短语匹配最前面的PhrasePositions的位置信息  
  captureLead(pp);
  // 当前匹配位置的匹配的长度
  matchLength = end - pp.position;
  // 第二小的PhrasePos,用来判断当前查找轮次的结束时机
  int next = pq.top().position;
  // 当前找到的不一定是满足slop距离的或者不是最优匹配,需要继续查找
  while (advancePP(pp)) {
    // 处理pp有冲突的情况 
    if (hasRpts && !advanceRpts(pp)) {
      break;
    }
    // 当前查找轮次可以结束了  
    if (pp.position > next) {
      // pp的PhrasePos已经更新了,把它加回队列  
      pq.add(pp);
      if (matchLength <= slop) {
        return true;
      }
      // 如果不满足slop距离要求,则从当前具有最小PhrasePos的PhrasePositions继续查找  
      pp = pq.pop();
      next = pq.top().position;
      matchLength = end - pp.position;
    } else {// 第一个位置经过一次查找之后还在第二个位置之前,说明匹配的距离减少了,找到了一个更优的匹配
      int matchLength2 = end - pp.position;
      if (matchLength2 < matchLength) {
        matchLength = matchLength2;
      }
    }
    // pp往后找了一个位置,需要更新存储短语匹配最前面的PhrasePositions的位置信息   
    captureLead(pp);
  }
  positioned = false;
  return matchLength <= slop;
}

处理冲突的情况:

private boolean advanceRpts(PhrasePositions pp) throws IOException {
  // 如果pp没有跟任何的PhrasePositions有重叠
  if (pp.rptGroup < 0) {
    return true;
  }
  // 和pp有重叠的组  
  PhrasePositions[] rg = rptGroups[pp.rptGroup];
  // 用来记录同一个组中哪个PhrasePositions的PhrasePos更新过了,更新过了需要重新入队
  FixedBitSet bits = new FixedBitSet(rg.length);
  // k0对应的PhrasePositions在nextMatch中已经不在队列中了,不需要再记录到位图中  
  int k0 = pp.rptInd;
  int k;
  while ((k = collide(pp)) >= 0) {
    pp = lesser(pp, rg[k]); 
    // 如果找不到下一个位置,说明不能处理冲突,返回false  
    if (!advancePP(pp)) {
      return false;
    }
    // 如果PhrasePositions的PhrasePos更新过了,并且不是k0,则标记位图
    if (k != k0) { 
      bits = FixedBitSet.ensureCapacity(bits, k);
      bits.set(k);
    }
  }

  int n = 0;
  int numBits = bits.length();
  // 如果有更新过PhrasePos的PhrasePositions还在队列中,则持续出队处理  
  while (bits.cardinality() > 0) {
    PhrasePositions pp2 = pq.pop();
    rptStack[n++] = pp2;
    // 如果pp2是更新过PhrasePos的,则清空位图中的标记  
    if (pp2.rptGroup >= 0
        && pp2.rptInd < numBits 
        && bits.get(pp2.rptInd)) {
      bits.clear(pp2.rptInd);
    }
  }
  // 下标从大到小,对于未修改的PhrasePos的PhrasePositions的相对位置也是从大到小,则入队效率高
  for (int i = n - 1; i >= 0; i--) {
    pq.add(rptStack[i]);
  }
  return true;
}

每个匹配位置对相关性的贡献

float sloppyWeight() {
  // 匹配距离越短,贡献越高  
  return 1f / (1f + matchLength);
}

重点讨论:为什么冲突是PhrasePos小的继续往后查找一个位置呢?

官方的说明

先来看SloppyPhraseMatcher的官方文档注释,我们拆开来看:

SloppyPhraseMatcher的功能

Find all slop-valid position-combinations (matches) encountered while traversing/hopping the PhrasePositions.

通过遍历所有position的PhrasePosition(就是我们前面说的PhrasePos)查找所有满足slop距离要求的匹配位置。

每个匹配贡献的得分

The sloppy frequency contribution of a match depends on the distance:

  • highest freq for distance=0 (exact match).

  • freq gets lower as distance gets higher.

Example:

for query "a b"~2, a document "x a b a y" can be matched twice: once for "a b" (distance=0), and once for "b a" (distance=2).

越短的slop距离贡献的得分越高。

冲突的解决方式

Possibly not all valid combinations are encountered, because for efficiency we always propagate the least PhrasePosition. This allows to base on PriorityQueue and move forward faster.

纯属是为了性能考虑,所以冲突的时候都是PhrasePos小的需要往后找一个位置,这样可以快向前推进。

算法的缺点

As result, for example, document "a b c b a" would score differently for queries "a b c"~4 and "c b a"~4, although they really are equivalent.

因为对于 "a b c"~4,SloppyPhraseMatcher从"a b c b a"找到的是["a b c", "b c b a", "c b a"]。而"c b a"~4找到的是["a b c", "b c b a"]。

Similarly, for doc "a b c b a f g", query "c b"~2 would get same score as "g f"~2, although "c b"~2 could be matched twice.

因为对于"c b"~2 ,SloppyPhraseMatcher从"a b c b a f g"找到的是"c b",前面的"b c"会被忽略。

展望

We may want to fix this in the future (currently not, for performance reasons).

一句话,目前并没有修改的打算。

个人理解

单term的短语查询

end为冲突时,短语最后一个term所在的位置。

冲突term的下一个位置小于等于end

对于从"a a b c a b a c"中查找PhraseQuery(a:0, b:1, a:2, slop:2)。

如上图所示的冲突状态:

  • 如果是PhrasePos大的PhrasePos_a_0往后找一个位置,则匹配长度是0 - (-3) = 3 > 2不满足slop距离要求,但是其实"a a b"和"a b a"的slop距离是2,是满足查询条件的。
  • 如果是PhrasePos小的PhrasePos_a_1往后找一个位置,则匹配长度是0 - (-2) = 2 <= 2,则满足查询条件符合预期。

归纳: 如果冲突的term在文本的下一个位置在当前短语最后一个term位置之前,则把冲突中PhrasePos小的往后找一个位置,肯定是减少匹配长度的,更可能满足查询要求。

证明:

因为在冲突的时候,如果

current(PhrasePos_a_0) > current(PhrasePos_a_1) 且 next(PhrasePos_a_0) <= end 且 next(PhrasePos_a_1) <= end,

则必有next(PhrasePos_a_0) > next(PhrasePos_a_1),

所以end - current(PhrasePos_a_0) > end - current(PhrasePos_a_1),

所以需要把PhrasePos_a_1往后找一个位置。

冲突term的下一个位置大于end

对于从"a a b c a b a c"中查找PhraseQuery(a:0, b:1, a:2, slop:2)。

如上图所示的冲突状态:

  • 如果是PhrasePos大的PhrasePos_a_0往后找一个位置,则匹配长度是2 - (-3) = 5 > 2不满足slop距离要求,但是其实"a b c a"和"a b a"的slop距离是1,是满足查询条件的。
  • 如果是PhrasePos小的PhrasePos_a_1往后找一个位置,则匹配长度是0 - (-1) = 1 <= 2,则满足查询条件符合预期。

归纳: 如果冲突的term在文本的下一个位置在当前短语最后一个term位置之后,则把冲突中PhrasePos小的往后找一个位置,肯定是减少匹配长度的,更可能满足查询要求。

证明:

因为在冲突的时候,

如果current(PhrasePos_a_0) > current(PhrasePos_a_1) 且 next(PhrasePos_a_0) > end 且 next(PhrasePos_a_1) > end,

则必有next(PhrasePos_a_0) > next(PhrasePos_a_1),

所以next(PhrasePos_a_1) - current(PhrasePos_a_0) < next(PhrasePos_a_0) - current(PhrasePos_a_1),

所以需要把PhrasePos_a_1往后找一个位置。

多term的短语查询

如果是多term则可能找不到。看下面的例子:

               a  b  d  e
          pos: 0  1  2  3
PhrasePos_a_b: 0  1
PhrasePos_a_b:-1

MultiPhraseQuery([a, b]:0, [a, c]:1, slop:10)其实有匹配的位置"a b",但是找不到。

这种情况在不修改源码的情况下只能把MultiPhraseQuery转成多个PhraseQuery,再通过BooleanQuery的should字句处理,当然这样会有性能损耗,这就看业务的取舍了。

写在最后

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