Lucene源码系列(四):FastVectorHighlighter高亮算法实现

1,073 阅读23分钟

背景

在上一篇文章中,我们分析了Highlighter高亮实现方案,遗留了两个问题:

  1. Highlighter不适用于大文档,因为它是通过走一遍分词器,遍历token寻找匹配关键字,效率比较低。
  2. Highlighter最后会对首尾相连的片段进行合并,而拼接的可能是不包含关键字的片段,导致可能整体看起来相关性被稀释。

问题1,本质就是需要解决term匹配的性能问题,幸运的是,在构建索引文件的时候,其实已经有了每个字段中的term的相关信息,不需要再进行分词遍历了。

问题2,我们需要对高亮分片裁剪,对于Highlighter的结果1 “The goal of Apache <b>Lucene</b> is to provide”,可以裁剪成“Apache <b>Lucene</b>”。

Lucene推出的第二个高亮解决方案FastVectorHighlighter就是为了解决这两个问题(准确来说是部分场景解决),既然FastVectorHighlighter已经可以解决这两个问题,为什么Lucene后面还推出了UnifiedHighlighter?这就需要我们深入理解下FastVectorHighlighter的实现,看它是如何解决Highlighter的两个问题,以及它自身又存在什么问题。

FastVectorHighlighter使用示例

我们先通过几个例子来了解FastVectorHighlighter的使用方式以及它存在的一些问题。

下面的测试数据来自官网

示例1

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

        // 字段索引类型(以下是词向量数据生成的设置)
        FieldType fieldType = new FieldType();
        fieldType.setStoreTermVectorPositions(true);
        fieldType.setStoreTermVectorOffsets(true);
        fieldType.setStoreTermVectors(true);
        fieldType.setIndexOptions(IndexOptions.DOCS);
        fieldType.setStored(true);

        // 测试的数据
        Document document = new Document();
        document.add(new Field("field0", "Lucene is a search engine library.", fieldType));
        indexWriter.addDocument(document);

        // 构建查询条件: 字段field0中 包含lucene关键字 或者  同时包含slop距离不超过10的search和library
        TermQuery termQuery = new TermQuery(new Term("field0", "lucene"));
        BoostQuery boostQuery = new BoostQuery(termQuery, 2);
        PhraseQuery phraseQuery = new PhraseQuery(10, "field0", "search", "library");
        BooleanQuery booleanQuery = new BooleanQuery.Builder()
            .add(boostQuery, BooleanClause.Occur.SHOULD)
            .add(phraseQuery, BooleanClause.Occur.SHOULD)
            .build();
        
        // margin=5(后面解释这个参数)
        SimpleFragListBuilder simpleFragListBuilder = new SimpleFragListBuilder(5);
        ScoreOrderFragmentsBuilder scoreOrderFragmentsBuilder = new ScoreOrderFragmentsBuilder(BaseFragmentsBuilder.COLORED_PRE_TAGS, BaseFragmentsBuilder.COLORED_POST_TAGS);
        FastVectorHighlighter fastVectorHighlighter = new FastVectorHighlighter(true, true, simpleFragListBuilder, scoreOrderFragmentsBuilder);
        // 解析query
        FieldQuery fieldQuery = fastVectorHighlighter.getFieldQuery(booleanQuery);
        IndexReader reader = DirectoryReader.open(indexWriter);
        // docId=0
        // fragCharSize=50
        // maxNumFragments=10
        String[] lucenes = fastVectorHighlighter.getBestFragments(fieldQuery, reader, 0, "field0", 50, 10);
        for (String lucene : lucenes) {
            System.out.println(lucene);
        }
    }

输出:

<b style="background:yellow">Lucene</b> is a <b style="background:lawngreen">search</b> engine <b style="background:lawngreen">library</b>. 

注意输出结果中BooleanQuery不同匹配的子query使用不同的颜色高亮。

示例2

把上面的BooleanQuery换成MultiPhraseQuery(注意也替换示例1的34行的变量)

// 查找字段field0中包含search和library间隔不超过10的词组
MultiPhraseQuery multiPhraseQuery = new MultiPhraseQuery.Builder()
	.setSlop(5)
	.add(new Term[]{new Term("field0", "lucene"), new Term("field0", "search")})
	.add(new Term[]{new Term("field0", "search"), new Term("field0", "library")})
    .build();

查询条件满足,但是高亮无结果。

示例3

示例1的BooleanQuery换成SpanTermQuery(注意也替换示例1的34行的变量)

// 查找字段field0中包含search和library的slop距离不超过10的词组
SpanTermQuery start = new SpanTermQuery(new Term("field0", "search"));
SpanTermQuery end = new SpanTermQuery(new Term("field0", "library"));
SpanNearQuery spanNearQuery = new SpanNearQuery(new SpanQuery[] {start,end}, 10, false);

查询条件满足,但是高亮无结果。

示例4

示例1中的PhraseQuery的term列表换个顺序:

PhraseQuery phraseQuery = new PhraseQuery(10, "field0", "library", "search");

输出:

<b style="background:yellow">Lucene</b> is a search engine library. 

可以看到满足PhraseQuery查询的search和library没有高亮。

示例5

示例1中的34行的fragCharSize参数从50改成30:

String[] lucenes = fastVectorHighlighter.getBestFragments(fieldQuery, reader, 0, "field0", 30, 10);

输出:

<b style="background:yellow">Lucene</b> is a search engine library

可以看到满足条件的子查询PhraseQuery(search, library)没有高亮。

示例6

示例1中的TermQuery改成engine,注意term查询的关键字engine在PhraseQuery词组search和library的中间:

TermQuery termQuery = new TermQuery(new Term("field0", "engine"));

输出:

Lucene is a search <b style="background:yellow">engine</b> library. 

可以看到满足PhraseQuery查询的search和library没有高亮。

示例总结

从以上所有的示例中,我们可以看出来FastVectorHighlighter存在的一些问题:

  1. 示例2和示例3说明了FastVectorHighlighter不支持MultiPhraseQuery和SpanQuery。
  2. 示例4说明了FastVectorHighlighter中PhraseQuery是否高亮跟短语词组中的term的定义顺序有关。
  3. 示例5说明高亮结果中包含了满足PhraseQuery的词组,但是因为fragCharSize参数的关系没有高亮。
  4. 示例6说明了匹配PhraseQuery的词组之间插入匹配的TermQuery之后就不高亮了。

那是什么原因造成了以上这些问题,我们只能从源码中找答案了。

注意:

  • 以下如果没有特殊说明,一些数据结构都以示例1中的Query和Document为例进行说明。
  • 本文源码解析基于Lucene 9.1.0版本。

预备知识

词向量

这里我们不详细说明lucene中词向量的数据结构以及如何生成的。只需要知道它们是什么以及怎么用就行了。

词向量其实就是一组term的集合。索引库的词向量就是索引中所有文档的term的集合。文档的词向量就是特定文档中所有term的集合。同样可以类推文档中某个字段的词向量就是这个字段中term的集合。

词向量数据的生成必须在索引的时候打开示例1中的那些设置。

如下图所示(注意此图非lucene中正式的词向量数据文件格式),此图只是示意图,用来展示词向量及其位置信息。

文档doc1中有两个字段,对于字段field1有词向量(c,e,g),对于term c在field1中出现了两次,分别在第0和第2个位置出现,两个位置出现的起始位置和结束位置分别是0,4和1,5。

词向量和倒排示意图 (1).png

基于词向量及其位置信息就可以不用重新进行分词获取token。从而解决了Highlighter中的问题1。

lucene中通过IndexReader#getTermVectors(int docId)获取指定doc的词向量Fields,Fields#terms(String field)可以获取指定字段的词向量Terms。通过迭代Terms,获取term,根据term可以获取term的位置信息(在字段中出现的频率,position以及offset信息等)。

Query树

在Lucene中Query是可以嵌套Query的,以BooleanQuery为例,BooleanQuery是由多个子Query组成的,而子Query又可以由子Query组成,就构成了Query树。Query树的叶子节点的Query就是不可再分的Query,文本匹配中一般就是TermQuery,PhraseQuery。

BreakIterator

BreakIterator是JDK自带的一个文本处理工具类,用它可以找到特定文本的分割点。BreakIterator有四种不同的实现,分别是按字符分割,按词分割,按句子分割,按行分割,默认是使用按句子分割的。但是这四种分割实现跟语言是相关的,所以不要从字面的意思理解,比如英文不要就以为“.”就是句子的分隔符,其实是“ .”(空格+.)或者"."+换行符才是句子的分隔标记。具体的可以参考JDK官方文档,自己写demo上手试试。

不管是哪种分割实现,我们只需要知道它怎么用,具体来看下BreakIterator提供了哪些接口:

    // 设置要分割的文本
    public void setText(String newText)
    {
        setText(new StringCharacterIterator(newText));
    }

    // 交由不同实现类实现,会做一些初始化操作
    public abstract void setText(CharacterIterator newText);

    // 获取被分割的文本
    public abstract CharacterIterator getText();

    // 返回当前的offset
    public abstract int current();

    // 获取第一个分割点,并且把当前位置设置成第一个分割点
    public abstract int first();

    // 获取最后一个分割点,并且当前位置会设置成最后一个分割点
    public abstract int last();

    // 获取第n个分割点,如果存在第n个分割点,当前位置会设置成第n个分割点
    public abstract int next(int n);

    // 获取当前位置的下一个分割点,当前位置会设置成下一个分割点
    public abstract int next();

    // 获取当前位置的前一个分割点,当前位置会设置成前一个分割点
    public abstract int previous();

    // 获取offset之后的一个分割点,当前位置会设置成offset的下一个分割点
    public abstract int following(int offset);

    // 获取offset的前一个分割点,当前位置会设置成offset的前一个分割点
    public int preceding(int offset) {
        int pos = following(offset);
        while (pos >= offset && pos != DONE) {
            pos = previous();
        }
        return pos;
    }

    // offset是否是一个分割点
    public boolean isBoundary(int offset) {
        if (offset == 0) {
            return true;
        }
        int boundary = following(offset - 1);
        if (boundary == DONE) {
            throw new IllegalArgumentException();
        }
        return boundary == offset;
    }


BoundaryScanner

FastVectorHighlighter中寻找高亮片段offset的工具类,BoundaryScanner接口有两个方法,分别寻找高亮片段的startOffset和endOffset。

public interface BoundaryScanner {

  // 以start为起点往前查找,在buffer中寻找高亮分片的起点
  public int findStartOffset(StringBuilder buffer, int start);

  // 以start为起点往后查找,在buffer中寻找高亮分片的终点
  public int findEndOffset(StringBuilder buffer, int start);
}

它有两个实现类:

SimpleBoundaryScanner

SimpleBoundaryScanner中有个固定的字符集合,集合中的每个字符都可以作为边界,SimpleBoundaryScanner有两个成员变量:

  // 最多查找的字符个数
  protected int maxScan;

  // 可以用来做分界的字符集合
  protected Set<Character> boundaryChars;

看高亮片段起点和终点查找方法的实现:

  @Override
  public int findStartOffset(StringBuilder buffer, int start) {
    // 如果start参数不合法,则直接返回start
    if (start > buffer.length() || start < 1) return start;
    int offset, count = maxScan;
    // 从start往前查找,最多查找maxScan次  
    for (offset = start; offset > 0 && count > 0; count--) {
      // 找到边界字符,返回边界字符位置+1,可以知道返回的起始offset是不包含边界字符的
      if (boundaryChars.contains(buffer.charAt(offset - 1))) return offset;
      offset--;
    }
    // 如果已经找到了buffer的第一个字符,则直接返回0作为起点
    if (offset == 0) {
      return 0;
    }
    // 经过maxScan次还没有找到,则返回start
    return start;
  }

  @Override
  public int findEndOffset(StringBuilder buffer, int start) {
    // 如果start参数不合法,则直接返回start
    if (start > buffer.length() || start < 0) return start;
    int offset, count = maxScan;
    // 从start往后找,最多找maxScan次
    for (offset = start; offset < buffer.length() && count > 0; count--) {
      // 如果找到边界字符,则返回边界字符所在的位置做为终点
      if (boundaryChars.contains(buffer.charAt(offset))) return offset;
      offset++;
    }
    // 经过maxScan次没有找到或者找到buffer的末尾,则返回start。
    // 这边为什么没有一个到末尾直接返回末尾作为结束位置的逻辑呢?  
    return start;
  }

BreakIteratorBoundaryScanner

BreakIteratorBoundaryScanner寻找高亮分片的起始位置和结束位置依赖了BreakIterator:

public class BreakIteratorBoundaryScanner implements BoundaryScanner {

  final BreakIterator bi;

  public BreakIteratorBoundaryScanner(BreakIterator bi) {
    this.bi = bi;
  }

  @Override
  public int findStartOffset(StringBuilder buffer, int start) {
    // start合法性校验
    if (start > buffer.length() || start < 1) return start;
    // 只在子串中查找
    bi.setText(buffer.substring(0, start));
    // bi中的当前位置会定位到最后一个分割点  
    bi.last();
    // 之所以返回最后一个分割点的前一个分割点,
    // 是因为最后一个分割点一般就是字符串的末尾,如果只返回最后一个分割点,返回的就是start(子串是以start结束的)
    return bi.previous();
  }

  @Override
  public int findEndOffset(StringBuilder buffer, int start) {
    // start合法性校验
    if (start > buffer.length() || start < 0) return start;
    bi.setText(buffer.substring(start));
    // 因为bi处理的是start开始的子串,所以真正的位置需要加上start  
    return bi.next() + start;
  }

算法流程

接下来会以我们示例1(官网的例子)来介绍整个FastVectorHighlighter高亮实现流程。

我们用的是StandardAnalyzer分词器,会对所有字母进行小写。

Sample TextUser Query
Lucene is a search engine library.lucene^2 OR "search library"~1

在实现的过程中,FastVectorHighlighter依赖了几个重要的数据结构,为了方便对照观察各个数据结构,我们把文本的position和offset都列出来:

 +--------+-----------------------------------+
 |        |          1111111111222222222233333|
 |  offset|01234567890123456789012345678901234|
 +--------+-----------------------------------+
 |document|Lucene is a search engine library. |
 +--------*-----------------------------------+
 |position|0      1  2 3      4      5        |
 +--------*-----------------------------------+

step1 解析Query

解析Query做了以下几个事情:

  1. 获取query树叶子节点的query集合(TermQuery和PhraseQuery)。
  2. 获取query集合中出现的term集合。
  3. 扩展PhraseQuery,什么叫扩展PhraseQuery以及为什么需要扩展PhraseQuery后面解释。
  4. 构建QueryPhraseMap结构。

解析query的所有逻辑都在FieldQuery中,先看下FieldQuery的成员变量:

  // 是否需要区分字段,如果不区分字段,则所有的字段共享query解析的结果。
  final boolean fieldMatch;

  // fieldMatch==true,  Map<fieldName,QueryPhraseMap>
  // fieldMatch==false, Map<null,QueryPhraseMap>
  // 映射了query树的结构
  Map<String, QueryPhraseMap> rootMaps = new HashMap<>();

  // fieldMatch==true,  Map<fieldName,setOfTermsInQueries>
  // fieldMatch==false, Map<null,setOfTermsInQueries>
  // 保存query中出现的所有的term
  Map<String, Set<String>> termSetMap = new HashMap<>();

  // 如果query树不同的子query匹配需要用不同的高亮颜色,则会用到这个编码(算法最后一步会介绍)
  int termOrPhraseNumber; 

解析Query的逻辑都在构造函数中:

  // query: 检索的query
  // reader: 获取词向量
  // phraseHighlight: true的话必须是按短语匹配规格处理高亮;false就对词组中的term单独匹配高亮
  // fieldMatch: 是否需要区分字段,如果不区分字段,则所有的字段共享query解析的结果。
  public FieldQuery(Query query, IndexReader reader, boolean phraseHighlight, boolean fieldMatch)
      throws IOException {
    this.fieldMatch = fieldMatch;
    Set<Query> flatQueries = new LinkedHashSet<>();
    // 获取query树叶子节点的query集合,放在flatQueries中
    flatten(query, reader, flatQueries, 1f);
    // 从所有的flatQueries的query获取term放在termSetMap中
    saveTerms(flatQueries, reader);
    // 如果有首尾重叠的PhraseQuery则需要扩展一个  
    Collection<Query> expandQueries = expand(flatQueries);

    // 构建QueryPhraseMap结构,映射的整个term前缀树
    for (Query flatQuery : expandQueries) {
      QueryPhraseMap rootMap = getRootMap(flatQuery);
      rootMap.add(flatQuery, reader);
      float boost = 1f;
      while (flatQuery instanceof BoostQuery) {
        BoostQuery bq = (BoostQuery) flatQuery;
        flatQuery = bq.getQuery();
        boost *= bq.getBoost();
      }
      // 如果phraseHighlight为false(不进行Phrase级别的匹配),
      // 会把Phrase中的所有term当成termquery来匹配,会更新boost  
      if (!phraseHighlight && flatQuery instanceof PhraseQuery) {
        PhraseQuery pq = (PhraseQuery) flatQuery;
        if (pq.getTerms().length > 1) {
          for (Term term : pq.getTerms()) rootMap.addTerm(term, boost);
        }
      }
    }
  }
获取query树叶子节点的query

flatten方法实现了获取所有叶子节点Query的逻辑,flattern方法很长,但是逻辑非常简单,大部分是根据不同的Query递归获取叶子节点query,我们关注两个地方一个是递归出口(有两处,分别是TermQuery和PhraseQuery),另一个是最后一个注释,不在这个方法中的Query类型都被忽略了,所以这个方法决定了FastVectorHighlighter只支持哪些query,这就是FastVectorHighlighter不支持MultiQuery和SpanQuery的原因,解释了示例问题1

  protected void flatten(
      Query sourceQuery, IndexReader reader, Collection<Query> flatQueries, float boost)
      throws IOException {
    while (sourceQuery instanceof BoostQuery) {
      BoostQuery bq = (BoostQuery) sourceQuery;
      sourceQuery = bq.getQuery();
      boost *= bq.getBoost();
    }
    if (sourceQuery instanceof BooleanQuery) {
      BooleanQuery bq = (BooleanQuery) sourceQuery;
      for (BooleanClause clause : bq) {
        if (!clause.isProhibited()) {
          flatten(clause.getQuery(), reader, flatQueries, boost);
        }
      }
    } else if (sourceQuery instanceof DisjunctionMaxQuery) {
      DisjunctionMaxQuery dmq = (DisjunctionMaxQuery) sourceQuery;
      for (Query query : dmq) {
        flatten(query, reader, flatQueries, boost);
      }
    } 
      else if (sourceQuery instanceof TermQuery) {
      if (boost != 1f) {
        sourceQuery = new BoostQuery(sourceQuery, boost);
      }
      // 递归的出口1    
      if (!flatQueries.contains(sourceQuery)) flatQueries.add(sourceQuery);
    } else if (sourceQuery instanceof SynonymQuery) {
      SynonymQuery synQuery = (SynonymQuery) sourceQuery;
      for (Term term : synQuery.getTerms()) {
        flatten(new TermQuery(term), reader, flatQueries, boost);
      }
    } else if (sourceQuery instanceof PhraseQuery) {
      PhraseQuery pq = (PhraseQuery) sourceQuery;
      if (pq.getTerms().length == 1) sourceQuery = new TermQuery(pq.getTerms()[0]);
      if (boost != 1f) {
        sourceQuery = new BoostQuery(sourceQuery, boost);
      }
      // 递归的出口2
      // 这里我觉得加个判断比较好,如果已经存在相同词组的PhraseQuery,更新slop为大的那个    
      flatQueries.add(sourceQuery);
    } else if (sourceQuery instanceof ConstantScoreQuery) {
      final Query q = ((ConstantScoreQuery) sourceQuery).getQuery();
      if (q != null) {
        flatten(q, reader, flatQueries, boost);
      }
    } else if (sourceQuery instanceof FunctionScoreQuery) {
      final Query q = ((FunctionScoreQuery) sourceQuery).getWrappedQuery();
      if (q != null) {
        flatten(q, reader, flatQueries, boost);
      }
    } else if (reader != null) {
      Query query = sourceQuery;
      Query rewritten;
      if (sourceQuery instanceof MultiTermQuery) {
        rewritten =
            new MultiTermQuery.TopTermsScoringBooleanQueryRewrite(MAX_MTQ_TERMS)
                .rewrite(reader, (MultiTermQuery) query);
      } else {
        rewritten = query.rewrite(reader);
      }
      if (rewritten != query) {
        flatten(rewritten, reader, flatQueries, boost);
      }
    }
    // 其他的query类型都被忽略(示例问题1的原因)  
    // else discard queries
  }
获取query集合中出现的term集合
  // 从flatQueries中获取所有的term
  void saveTerms(Collection<Query> flatQueries, IndexReader reader) throws IOException {
    for (Query query : flatQueries) {
      while (query instanceof BoostQuery) {
        query = ((BoostQuery) query).getQuery();
      }
      Set<String> termSet = getTermSet(query);
      if (query instanceof TermQuery) termSet.add(((TermQuery) query).getTerm().text());
      else if (query instanceof PhraseQuery) {
        for (Term term : ((PhraseQuery) query).getTerms()) termSet.add(term.text());
      } // MultiTermQuery 需要做query改写来获取term
        else if (query instanceof MultiTermQuery && reader != null) {
        BooleanQuery mtqTerms = (BooleanQuery) query.rewrite(reader);
        for (BooleanClause clause : mtqTerms) {
          termSet.add(((TermQuery) clause.getQuery()).getTerm().text());
        }
      } else
        throw new RuntimeException("query \"" + query.toString() + "\" must be flatten first.");
    }
  }

  // 获取query中字段对应的term集合
  private Set<String> getTermSet(Query query) {
    String key = getKey(query);
    Set<String> set = termSetMap.get(key);
    if (set == null) {
      set = new HashSet<>();
      termSetMap.put(key, set);
    }
    return set;
  }

  // 获取Query中的字段名,因为调用此方法之前,已经调用flattern方法,所以Query都是叶子query,都是属于一个字段的。
  private String getKey(Query query) {
    // 如果没有指定字段名,统一会null作为key,也就是所有字段共享最终的term集合
    if (!fieldMatch) return null;
    while (query instanceof BoostQuery) {
      query = ((BoostQuery) query).getQuery();
    }
    // TermQuery直接获取term的字段名
    if (query instanceof TermQuery) return ((TermQuery) query).getTerm().field();
    // PhraseQuery中的多个子Term肯定都是相同字段的,所以从第一个term中取就行
    else if (query instanceof PhraseQuery) {
      PhraseQuery pq = (PhraseQuery) query;
      Term[] terms = pq.getTerms();
      return terms[0].field();
    } else if (query instanceof MultiTermQuery) {
      return ((MultiTermQuery) query).getField();
    } else throw new RuntimeException("query \"" + query.toString() + "\" must be flatten first.");
  }
扩展PhraseQuery

为什么需要扩展PhraseQuery,我们举个例子就清楚了。

对于文本“a b c”,PhraseQuery(a,b)和PhraseQuery(b,c)的短语匹配,则在匹配了a和b之后,c就无法匹配,从而导致c不会被高亮。所以发现PhraseQuery(a,b)和PhraseQuery(b,c)有重叠,则额外扩展一个PhraseQuery(a,b,c)的短语匹配,在匹配的时候选最长短语匹配就能把(a,b,c)都高亮。

 Collection<Query> expand(Collection<Query> flatQueries) {
    Set<Query> expandQueries = new LinkedHashSet<>();
    for (Iterator<Query> i = flatQueries.iterator(); i.hasNext(); ) {
      Query query = i.next();
      i.remove();
      expandQueries.add(query);
      float queryBoost = 1f;
      while (query instanceof BoostQuery) {
        BoostQuery bq = (BoostQuery) query;
        queryBoost *= bq.getBoost();
        query = bq.getQuery();
      }
      // 只处理PhraseQuery  
      if (!(query instanceof PhraseQuery)) continue;
      // 暴力遍历判断是否首尾相重叠 
      for (Iterator<Query> j = flatQueries.iterator(); j.hasNext(); ) {
        Query qj = j.next();
        float qjBoost = 1f;
        while (qj instanceof BoostQuery) {
          BoostQuery bq = (BoostQuery) qj;
          qjBoost *= bq.getBoost();
          qj = bq.getQuery();
        }
        if (!(qj instanceof PhraseQuery)) continue;
        // 判断两个PhraseQuery是否首尾重叠
        checkOverlap(expandQueries, (PhraseQuery) query, queryBoost, (PhraseQuery) qj, qjBoost);
      }
    }
    return expandQueries;
  }

  /*
   * Check if PhraseQuery A and B have overlapped part.
   *
   * ex1) A="a b", B="b c" => overlap; expandQueries={"a b c"}
   * ex2) A="b c", B="a b" => overlap; expandQueries={"a b c"}
   * ex3) A="a b", B="c d" => no overlap; expandQueries={}
   */
  private void checkOverlap(
      Collection<Query> expandQueries, PhraseQuery a, float aBoost, PhraseQuery b, float bBoost) {
    // slop不相同,则直接返回  
    if (a.getSlop() != b.getSlop()) return;
    Term[] ats = a.getTerms();
    Term[] bts = b.getTerms();
    // 如果指定了字段名,则两个PhraseQuery的字段必须相同  
    if (fieldMatch && !ats[0].field().equals(bts[0].field())) return;
    // 判断是否a重叠b  
    checkOverlap(expandQueries, ats, bts, a.getSlop(), aBoost);
    // 判断是否b重叠a  
    checkOverlap(expandQueries, bts, ats, b.getSlop(), bBoost);
  }

  /*
   * 看注释,非常清楚。
   *
   * ex1) src="a b", dest="c d"       => no overlap
   * ex2) src="a b", dest="a b c"     => no overlap
   * ex3) src="a b", dest="b c"       => overlap; expandQueries={"a b c"}
   * ex4) src="a b c", dest="b c d"   => overlap; expandQueries={"a b c d"}
   * ex5) src="a b c", dest="b c"     => no overlap
   * ex6) src="a b c", dest="b"       => no overlap
   * ex7) src="a a a a", dest="a a a" => overlap;
   *                                     expandQueries={"a a a a a","a a a a a a"}
   * ex8) src="a b c d", dest="b c"   => no overlap
   */
  private void checkOverlap(
      Collection<Query> expandQueries, Term[] src, Term[] dest, int slop, float boost) {
    // 从src的第二个term开始遍历  
    for (int i = 1; i < src.length; i++) {
      boolean overlap = true;
      for (int j = i; j < src.length; j++) {
        // 如果j - i >= dest.length,则说明dest被包含在src中了
        // 第二个判断条件就是从src的第i个开始的子term集合是否是dest的前缀  
        if ((j - i) < dest.length && !src[j].text().equals(dest[j - i].text())) {
          overlap = false;
          break;
        }
      }
      // 如果有重叠,则增加一个扩展的PhraseQuery  
      if (overlap && src.length - i < dest.length) {
        PhraseQuery.Builder pqBuilder = new PhraseQuery.Builder();
        for (Term srcTerm : src) pqBuilder.add(srcTerm);
        for (int k = src.length - i; k < dest.length; k++) {
          pqBuilder.add(new Term(src[0].field(), dest[k].text()));
        }
        pqBuilder.setSlop(slop);
        Query pq = pqBuilder.build();
        if (boost != 1f) {
          pq = new BoostQuery(pq, 1f);
        }
        if (!expandQueries.contains(pq)) expandQueries.add(pq);
      }
    }
  }
构建QueryPhraseMap结构

QueryPhraseMap是FieldQuery的内部类,它存储的是term的前缀树。

如下图所示,有四个query,它们构成的term前缀树,PhraseQuery("search", "library")和PhraseQuery("search", "engine")共享“search”这个前缀term,因为存在TermQuery("lucene"),所以“lucene”这个节点是terminal节点,并且它和PhraseQuery("lucene", "engine")共享“lucene”这个前缀term。

QueryPhraseMap.png

QueryPhraseMap的成员变量:

    // 是否是query中的最后一个term
    boolean terminal;
    // (terminal == true &&  phraseHighlight == true)才有效,表示的是phraseQuery中的slop
    int slop; 
    // terminal == true 才有效
    float boost; 
    // terminal == true 才有效,
    // 如果query树不同的子query匹配需要用不同的高亮颜色,则会用到这个编码(算法最后一步会介绍)
    int termOrPhraseNumber; 
    FieldQuery fieldQuery;
    // 下一个term的QueryPhraseMap结构
    Map<String, QueryPhraseMap> subMap = new HashMap<>();

如果是TermQuery,则它的subMap是空,并且terminal=true,表示没有后续的term。如果是PhraseQuery,则PhraseQuery中的第一个term的QueryPhraseMap结构中的subMap就是第二个term的QueryPhraseMap结构,直到最后一个term的subMap是空,并且terminal=true,表示没有后续的term。

QueryPhraseMap中的核心方法:

    void addTerm(Term term, float boost) {
      QueryPhraseMap map = getOrNewMap(subMap, term.text());
      map.markTerminal(boost);
    }

    // 如果subMap中不存在term的QueryPhraseMap结构,则创建
    private QueryPhraseMap getOrNewMap(Map<String, QueryPhraseMap> subMap, String term) {
      QueryPhraseMap map = subMap.get(term);
      if (map == null) {
        map = new QueryPhraseMap(fieldQuery);
        subMap.put(term, map);
      }
      return map;
    }

    void add(Query query, IndexReader reader) {
      float boost = 1f;
      // 找出BoostQuery中最底层的query  
      while (query instanceof BoostQuery) {
        BoostQuery bq = (BoostQuery) query;
        query = bq.getQuery();
        boost = bq.getBoost();  // 这里感觉是个bug,应该是boost *= bq.getBoost();  
      }
      // 如果是个TermQuery就添加term  
      if (query instanceof TermQuery) {
        addTerm(((TermQuery) query).getTerm(), boost);
      } else if (query instanceof PhraseQuery) {
        PhraseQuery pq = (PhraseQuery) query;
        Term[] terms = pq.getTerms();
        Map<String, QueryPhraseMap> map = subMap;
        QueryPhraseMap qpm = null;
        // 把PhraseQuery中的每个term都连起来  
        // 注意注意!!!这里是按照PhraseQuery中term的定义顺序处理的,这是造成示例问题2的原因之一
        for (Term term : terms) {
          qpm = getOrNewMap(map, term.text());
          map = qpm.subMap;
        }
        // 最后一个term添加结束标记  
        qpm.markTerminal(pq.getSlop(), boost);
      } else
        throw new RuntimeException("query \"" + query.toString() + "\" must be flatten first.");
    }

    // 获取子term的QueryPhraseMap结构
    public QueryPhraseMap getTermMap(String term) {
      return subMap.get(term);
    }

    private void markTerminal(float boost) {
      markTerminal(0, boost);
    }

    private void markTerminal(int slop, float boost) {
      this.terminal = true;
      this.slop = slop;
      this.boost = boost;
      this.termOrPhraseNumber = fieldQuery.nextTermOrPhraseNumber();
    }

    public QueryPhraseMap searchPhrase(final List<TermInfo> phraseCandidate) {
      QueryPhraseMap currMap = this;
      for (TermInfo ti : phraseCandidate) {
        currMap = currMap.subMap.get(ti.getText());
        if (currMap == null) return null;
      }
      return currMap.isValidTermOrPhrase(phraseCandidate) ? currMap : null;
    }

    public boolean isValidTermOrPhrase(final List<TermInfo> phraseCandidate) {
      // 如果不是terminal的肯定不是合法的
      if (!terminal) return false;

      // 如果是单term的PraseQuery肯定是合法的
      if (phraseCandidate.size() == 1) return true;

      // phraseCandidate中的term已经是按position排序的。
      // 计算PhraseQuery中两两term之间的距离是否满足slop的要求
      int pos = phraseCandidate.get(0).getPosition();
      for (int i = 1; i < phraseCandidate.size(); i++) {
        int nextPos = phraseCandidate.get(i).getPosition();
        if (Math.abs(nextPos - pos - 1) > slop) return false;
        pos = nextPos;
      }
      return true;
    }
  }

构建QueryPhraseMap结构的实现在FieldQuery的构造函数中,这边我觉得可以抽一个方法出来:

  public FieldQuery(Query query, IndexReader reader, boolean phraseHighlight, boolean fieldMatch)
      throws IOException {
    this.fieldMatch = fieldMatch;
    Set<Query> flatQueries = new LinkedHashSet<>();
    // 获取query树叶子节点的query集合,放在flatQueries中
    flatten(query, reader, flatQueries, 1f);
    // 从所有的flatQueries的query获取term放在termSetMap中
    saveTerms(flatQueries, reader);
    // 如果有首尾重叠的PhraseQuery则需要扩展一个  
    Collection<Query> expandQueries = expand(flatQueries);

    // 构建QueryPhraseMap结构,映射的整个Query树
    for (Query flatQuery : expandQueries) {
      QueryPhraseMap rootMap = getRootMap(flatQuery);
      rootMap.add(flatQuery, reader);
      float boost = 1f;
      while (flatQuery instanceof BoostQuery) {
        BoostQuery bq = (BoostQuery) flatQuery;
        flatQuery = bq.getQuery();
        boost *= bq.getBoost();
      }
      // 如果phraseHighlight为false(不进行Phrase级别的匹配),
      // 会把Phrase中的所有term当成TermQuery来匹配,会更新boost  
      // 这里需要注意,已有的Phrase的term的级联的QueryPhraseMap结构还在,只是第一个term的QueryPhraseMap中terminal会更新为true,让subMap失效
      if (!phraseHighlight && flatQuery instanceof PhraseQuery) {
        PhraseQuery pq = (PhraseQuery) flatQuery;
        if (pq.getTerms().length > 1) {
          for (Term term : pq.getTerms()) rootMap.addTerm(term, boost);
        }
      }
    }
  }

  // 获取query字段对应的QueryPhraseMap结构
  QueryPhraseMap getRootMap(Query query) {
    String key = getKey(query);
    QueryPhraseMap map = rootMaps.get(key);
    if (map == null) {
      map = new QueryPhraseMap(this);
      rootMaps.put(key, map);
    }
    return map;
  }

我们看一下示例1中最后的QueryPhraseMap结构:

如果phraseHighlight=true:

 QueryPhraseMap
 +--------+-+  +-------+-------------+
 |"lucene"|--->|boost=2|terminal=true|  
 +--------+-+  +-------+-------------+

 +--------+-+  +---------+-+  +-------+------+-------------+
 |"search"|--->|"library"|--->|boost=1|slop=1|terminal=true| 
 +--------+-+  +---------+-+  +-------+------+-------------+

如果phraseHighlight=false:

 QueryPhraseMap
 +--------+-+  +-------+--------+
 |"lucene"|--->|boost=2|terminal| 
 +--------+-+  +-------+--------+

 +--------+-+  +-------+--------+
 |"search"|--->|boost=1|terminal|
 +--------+-+  +-------+--------+
 
 +---------+-+  +-------+--------+
 |"library"|--->|boost=1|terminal| 
 +---------+-+  +-------+--------+

step2 获取term的位置信息

在FieldQuery中已经获取query集合中出现的所有term,基于词向量我们可以获取这些term的位置信息。

获取query中所有term的位置信息的逻辑在FieldTermStack中实现。FieldTermStack获取所有term的位置信息之后,会按照term的startOffset排序,栈顶元素是query中的term在文档中特定字段出现最早的term。

示例1中FieldTermStack的结果是:

 FieldTermStack
 +------------------+
 |"lucene"(0,6,0)   |
 +------------------+
 |"search"(12,18,3) |
 +------------------+
 |"library"(26,33,5)|
 +------------------+
 结构为: "term"(startOffset,endOffset,position)

接下来,我们来看下这个结构是怎么生成的。

FieldTermStack的内部类TermInfo是栈中的元素类型,实现了Comparable接口,排序规则是term在文档中的起始位置。需要注意的是为了处理同义词,TermInfo有个next变量,可以将相同position的TermInfo串成一个单向循环链表。我们看下TermInfo的成员变量:

    // term
    private final String text;
    // 起始偏移
    private final int startOffset;

    // 结束偏移
    private final int endOffset;

    // 位置
    private final int position;

    // term的得分
    private final float weight;

    // 对于同义词来说,相同position有多个term,通过next构成一个单向循环链表。
    // 如果没有同义词,就是指向自己。
    private TermInfo next;

TermInfo方法比较简单,可以自己查看。接下我们介绍FieldTermStack的成员变量:

  // 字段名称
  private final String fieldName;

  // query中的term,在指定文档的fieldName字段中出现的term的位置信息
  LinkedList<TermInfo> termList = new LinkedList<>();

最关键的构造方法:

  public FieldTermStack(
      IndexReader reader, int docId, String fieldName, final FieldQuery fieldQuery)
      throws IOException {
    this.fieldName = fieldName;

    // query中出现的所有的term集合 (step 1中获取的结果) 
    Set<String> termSet = fieldQuery.getTermSet(fieldName);
    // 如果query中没有term信息,则直接返回
    if (termSet == null) return;

    // 获取要高亮文档的词向量  
    final Fields vectors = reader.getTermVectors(docId);  
    if (vectors == null) {
      return;
    }

    // 获取docId中特定字段的词向量  
    final Terms vector = vectors.terms(fieldName);
    if (vector == null || vector.hasPositions() == false) {
      return;
    }

    final CharsRefBuilder spare = new CharsRefBuilder();
    // term的迭代器,从词向量中获取
    final TermsEnum termsEnum = vector.iterator();
    // 用来获取term的位置信息  
    PostingsEnum dpEnum = null;
    BytesRef text;

    // 用来计算idf  
    int numDocs = reader.maxDoc();
    // 通过词向量遍历该字段所有的term 
    //(这里可以优化的,不需要遍历所有的term,只需要遍历query中的term就行,具体怎么优化,看下一篇UnifiedHighlighter的解析)
    while ((text = termsEnum.next()) != null) {
      spare.copyUTF8Bytes(text);
      final String term = spare.toString();
      // 只处理query中出现的term 
      if (!termSet.contains(term)) {
        continue;
      }
      dpEnum = termsEnum.postings(dpEnum, PostingsEnum.POSITIONS);
      dpEnum.nextDoc();

      // 计算term的idf作为权重
      final float weight =
          (float)
              (Math.log(numDocs / (double) (reader.docFreq(new Term(fieldName, text)) + 1)) + 1.0);

      final int freq = dpEnum.freq();

      // 获取term的位置信息  
      for (int i = 0; i < freq; i++) {
        int pos = dpEnum.nextPosition();
        if (dpEnum.startOffset() < 0) {
          return;
        }
        termList.add(new TermInfo(term, dpEnum.startOffset(), dpEnum.endOffset(), pos, weight));
      }
    }

    // 按出现的startOffset排序
    Collections.sort(termList);

    // 处理同义词:对于同个position的多个不同term,构建循环链表。
    // 如果没有同义词,自己单独成为一个循环链表。  
    int currentPos = -1;
    TermInfo previous = null;
    TermInfo first = null;
    Iterator<TermInfo> iterator = termList.iterator();
    while (iterator.hasNext()) {
      TermInfo current = iterator.next();
      if (current.position == currentPos) {
        assert previous != null;
        previous.setNext(current);
        previous = current;
        iterator.remove();
      } else {
        if (previous != null) {
          previous.setNext(first);
        }
        previous = first = current;
        currentPos = current.position;
      }
    }
    if (previous != null) {
      previous.setNext(first);
    }
  }

step3 构建最长的短语词组

step2中获取到了按出现位置排序的term列表,接下来,我们就要从中找出各个短语词组。

我们先看下表示一个短语词组的结构WeightedPhraseInfo。Weighted表示了短语词组是带权重的,也是后面计算高亮片段分数用的。WeightedPhraseInfo的成员变量:

    // 对应短语中所有term的startOffset和endOffset,
    // 注意如果term的position是前后相连的,则会共享一个Toffs,startOffset和endOffset涵盖的是相连term的整体位置。
    // 这是为了高亮的时候一起打标签,
    // 比如terma和termb相连,高亮标签是<b>terma termb</b>而不是<b>terma</b> <b>termb</b>
    private List<Toffs> termsOffsets;
    // 短语的boost
    private float boost;
    // 短语的编号,就是FieldQuery中的termOrPhraseNumber
    private int seqnum;
    // 短语中所有的term信息
    private ArrayList<TermInfo> termsInfos;

WeightedPhraseInfo有两个构造函数,第一个是通过元信息构建:

  // terms中的term是按startOffset排好序的  
  public WeightedPhraseInfo(LinkedList<TermInfo> terms, float boost, int seqnum) {
      this.boost = boost;
      this.seqnum = seqnum;
      termsInfos = new ArrayList<>(terms);

      termsOffsets = new ArrayList<>(terms.size());
      TermInfo ti = terms.get(0);
      termsOffsets.add(new Toffs(ti.getStartOffset(), ti.getEndOffset()));
      // 如果只有一个term,不需要处理term相邻的情况  
      if (terms.size() == 1) {
        return;
      }
        
      int pos = ti.getPosition();
      for (int i = 1; i < terms.size(); i++) {
        ti = terms.get(i);
        // 如果term相邻,则更新前一个term的结束位置,把当前term也涵盖进去,共享一个Toffs,最后高亮就用一对标签就可以
        if (ti.getPosition() - pos == 1) {
          Toffs to = termsOffsets.get(termsOffsets.size() - 1);
          to.setEndOffset(ti.getEndOffset());
        } else {
          // 如果不相邻,就新增一个Toffs记录单独的term的位置
          termsOffsets.add(new Toffs(ti.getStartOffset(), ti.getEndOffset()));
        }
        pos = ti.getPosition();
      }
    }

第二个构造函数是通过已有的WeightedPhraseInfo列表合并成一个WeightedPhraseInfo:

  // 多个  WeightedPhraseInfo 合并成一个
  public WeightedPhraseInfo(Collection<WeightedPhraseInfo> toMerge) {
      Iterator<WeightedPhraseInfo> toMergeItr = toMerge.iterator();
      if (!toMergeItr.hasNext()) {
        throw new IllegalArgumentException("toMerge must contain at least one WeightedPhraseInfo.");
      }
      WeightedPhraseInfo first = toMergeItr.next();
      Iterator<Toffs>[] allToffs = new Iterator[toMerge.size()];
      termsInfos = new ArrayList<>();
      // seqnum 是第一个WeightedPhraseInfo的seqnum
      seqnum = first.seqnum;
      boost = first.boost;
      allToffs[0] = first.termsOffsets.iterator();
      int index = 1;
      while (toMergeItr.hasNext()) {
        WeightedPhraseInfo info = toMergeItr.next();
        // boost是所有的WeightedPhraseInfo之和  
        boost += info.boost;
        termsInfos.addAll(info.termsInfos);
        allToffs[index++] = info.termsOffsets.iterator();
      }
      // MergedIterator是个工具类,会返回所有迭代器中元素最小的
      MergedIterator<Toffs> itr = new MergedIterator<>(false, allToffs);
      termsOffsets = new ArrayList<>();
      if (!itr.hasNext()) {
        return;
      }
      Toffs work = itr.next();
      while (itr.hasNext()) {
        Toffs current = itr.next();
        // 有重叠的话就共享一个Toff  
        if (current.startOffset <= work.endOffset) {
          work.endOffset = Math.max(work.endOffset, current.endOffset);
        } else {
          termsOffsets.add(work);
          work = current;
        }
      }
      termsOffsets.add(work);
    }

了解了最终的词组的表示结构,文档中多个词组的结构就是FieldPhraseList,内部是一个WeightedPhraseInfo的列表,代表的是query在文本中可以高亮的词组位置信息。FieldPhraseList在示例1中有两个词组,结构如下:

 FieldPhraseList
 +----------------+-----------------+-------+
 |"lucene"        |[(0,6)]          |boost=2|
 +----------------+-----------------+-------+
 |"search library"|[(12,18),(26,33)]|boost=1|
 +----------------+-----------------+-------+

FieldPhraseList有两种构建方式,一种基于FieldTermStack和FieldQuery提供的信息构建,我们看下这种构建的示意图:

FieldTermStack和QueryPhraseMap.png

如上图所示,遍历FieldTermStack,以startOffset小的term作为QueryPhraseMap起始节点,深度优先搜索最长的PhraseQuery词组。具体实现:

  // fieldTermStack中存储的是按startOffset排序的term
  // 深度优先遍历FieldQuery中的QueryPhraseMap,我们前面说了,QueryPhraseMap是一个term的前缀树
  public FieldPhraseList(FieldTermStack fieldTermStack, FieldQuery fieldQuery, int phraseLimit) {
    final String field = fieldTermStack.getFieldName();

    LinkedList<TermInfo> phraseCandidate = new LinkedList<>();
    QueryPhraseMap currMap = null;
    QueryPhraseMap nextMap = null;
    // 因为是按startOffset遍历term的,然后深度搜索前缀树,而前缀树中的term顺序是按照PhraseQuery定义的顺序,
    // 所以如果顺序刚好定义跟term的startOffset顺序相反,则短语无法匹配,
    // 这就是示例问题2的第2个原因,第一个原因在QueryPhraseMap构建。
    while (!fieldTermStack.isEmpty() && (phraseList.size() < phraseLimit)) {
      phraseCandidate.clear();

      TermInfo ti = null;
      TermInfo first = null;

      first = ti = fieldTermStack.pop();
      currMap = fieldQuery.getFieldTermMap(field, ti.getText());
      // 如果是同义词,则遍历循环链表 
      while (currMap == null && ti.getNext() != first) {
        ti = ti.getNext();
        currMap = fieldQuery.getFieldTermMap(field, ti.getText());
      }

      // 没有找到term对应的QueryPhraseMap,则丢弃
      if (currMap == null) continue;

      // 如果找到了term对应的QueryPhraseMap,则找subMap
      phraseCandidate.add(ti);
      
      while (true) {
      // 从下一个开始查找,是否可以组成更长的短语匹配    
      // 这边逻辑有问题,如果当前的  fieldTermStack 中是  a b c d,而a 和 c是短语匹配,则遍历b的时候就停止迭代了,
      // 从而造成了文本中匹配的短语如果中间插入了一个匹配的termquery,则优先是高亮termquery ,这就是示例问题4的原因
        first = ti = fieldTermStack.pop();
        nextMap = null;
        // 处理同义词  
        if (ti != null) {
          nextMap = currMap.getTermMap(ti.getText());
          while (nextMap == null && ti.getNext() != first) {
            ti = ti.getNext();
            nextMap = currMap.getTermMap(ti.getText());
          }
        }
        if (ti == null || nextMap == null) {
          // 回溯,让ti有机会成为深度遍历的起点
          if (ti != null) fieldTermStack.push(ti);
          if (currMap.isValidTermOrPhrase(phraseCandidate)) {
            addIfNoOverlap(
                new WeightedPhraseInfo(
                    phraseCandidate, currMap.getBoost(), currMap.getTermOrPhraseNumber()));
          } else {
            // 如果词组是(a,b,c,d),而深度遍历到term d的时候发现slop不满足条件,则回溯看(a,b,c)是否满足  
            while (phraseCandidate.size() > 1) {
              fieldTermStack.push(phraseCandidate.removeLast());
              currMap = fieldQuery.searchPhrase(field, phraseCandidate);
              if (currMap != null) {
                addIfNoOverlap(
                    new WeightedPhraseInfo(
                        phraseCandidate, currMap.getBoost(), currMap.getTermOrPhraseNumber()));
                break;
              }
            }
          }
          break;
        } else {
          phraseCandidate.add(ti);
          currMap = nextMap;
        }
      }
    }
  }

  // 判断wpi是否和已有的高亮片段有重叠
  public void addIfNoOverlap(WeightedPhraseInfo wpi) {
    for (WeightedPhraseInfo existWpi : getPhraseList()) {
      if (existWpi.isOffsetOverlap(wpi)) {
        existWpi.getTermsInfos().addAll(wpi.getTermsInfos());
        return;
      }
    }
    getPhraseList().add(wpi);
  }

FieldPhraseList另一种构建方式是通过已有的FieldPhraseList列表合并,在多字段的内容高亮的时候使用,先用上一种方式构建所有字段的FieldPhraseList,再进行merge:

  public FieldPhraseList(FieldPhraseList[] toMerge) {
    Iterator<WeightedPhraseInfo>[] allInfos = new Iterator[toMerge.length];
    int index = 0;
    for (FieldPhraseList fplToMerge : toMerge) {
      allInfos[index++] = fplToMerge.phraseList.iterator();
    }
    // MergedIterator包含了多个有序的子迭代器,整体是逐步迭代子迭代器中最小的元素
    // 这里就是按WeightedPhraseInfo的startOffset排序的
    MergedIterator<WeightedPhraseInfo> itr = new MergedIterator<>(false, allInfos);
    
    phraseList = new LinkedList<>();
    if (!itr.hasNext()) {
      return;
    }
    List<WeightedPhraseInfo> work = new ArrayList<>();
    WeightedPhraseInfo first = itr.next();
    work.add(first);
    int workEndOffset = first.getEndOffset();
    // 把重叠的WeightedPhraseInfo放在一起,后面通过WeightedPhraseInfo的merge方法(前面已介绍)合并成一个
    while (itr.hasNext()) {
      WeightedPhraseInfo current = itr.next();
      if (current.getStartOffset() <= workEndOffset) {
        workEndOffset = Math.max(workEndOffset, current.getEndOffset());
        work.add(current);
      } else {
        // 跟前一个没有重叠,并且当前work只有自己一个,则直接加入结果集 
        if (work.size() == 1) {
          phraseList.add(work.get(0));
          work.set(0, current);
        } else {
          // 跟前一个没有重叠,并且当前work中有多个WeightedPhraseInfo,则先merge成1个再加入结果集
          phraseList.add(new WeightedPhraseInfo(work));
          work.clear();
          work.add(current);
        }
        workEndOffset = current.getEndOffset();
      }
    }
    // 处理work中剩余的  
    if (work.size() == 1) {
      phraseList.add(work.get(0));
    } else {
      phraseList.add(new WeightedPhraseInfo(work));
      work.clear();
    }
  }

step4 计算高亮片段的位置信息

在step3中我们已经得到了需要高亮的词组的位置信息,接下来就需要获取高亮片段在文本的具体位置。

先看下高亮片段的表示对象WeightedFragInfo:

    // 匹配词组的位置信息,因为一个高亮片段可能包含多个匹配的词组
    private List<SubInfo> subInfos;
    // 片段总得分
    private float totalBoost;
    // 高亮片段在文本中的起始offset
    private int startOffset;
    // 高亮片段在文本中的结束offset
    private int endOffset;

SubInfo(高亮片段中的匹配词组的位置信息):

      // 如果是单term,就是term
      // 如果是PhraseQuery,则多个term连在一起
      private final String text; 
      // 所有term出现的位置
      private final List<Toffs> termsOffsets;
      // 唯一的编号,为了高亮的时候取不同的颜色(因为最终是取模,所以还是可能颜色相同)
      private final int seqnum;
      // 得分
      private final float boost; 

FieldFragList

所有高亮片段的结果集用FieldFragList保存,示例1的高亮片段结果集如下所示:

 FieldFragList
 +---------------------------------+
 |"lucene"[(0,6)]                  |
 |"search library"[(12,18),(26,33)]|
 |totalBoost=3                     |
 |startOffset=0                    |
 |endOffset=50                     |
 +---------------------------------+

FieldFragList是个抽象类,提供了一个抽象方法由子类实现通过WeightedPhraseInfo列表构建FieldFragList:

  // startOffset:高亮片段在文本中的起始位置
  // endOffset:高亮片段在文本中的起始位置
  // phraseInfoList:高亮片段中需要高亮的词组
  public abstract void add(int startOffset, int endOffset, List<WeightedPhraseInfo> phraseInfoList);

FieldFragList有两个实现类,计算WeightedFragInfo得分的方式不一样:

SimpleFieldFragList

SimpleFieldFragList生成WeightedFragInfo的得分是累加所有的WeightedPhraseInfo的得分:

  public void add(int startOffset, int endOffset, List<WeightedPhraseInfo> phraseInfoList) {
    float totalBoost = 0;
    List<SubInfo> subInfos = new ArrayList<>();
    for (WeightedPhraseInfo phraseInfo : phraseInfoList) {
      subInfos.add(
          new SubInfo(
              phraseInfo.getText(),
              phraseInfo.getTermsOffsets(),
              phraseInfo.getSeqnum(),
              phraseInfo.getBoost()));
      // 高亮片段的分数是所有的词组分数的总和  
      totalBoost += phraseInfo.getBoost();
    }
    getFragInfos().add(new WeightedFragInfo(startOffset, endOffset, subInfos, totalBoost));
  }
WeightedFieldFragList

WeightedFieldFragList会对WeightedPhraseInfo做归一化:

  public void add(int startOffset, int endOffset, List<WeightedPhraseInfo> phraseInfoList) {
    List<SubInfo> tempSubInfos = new ArrayList<>();
    List<SubInfo> realSubInfos = new ArrayList<>();
    HashSet<String> distinctTerms = new HashSet<>();
    int length = 0;

    for (WeightedPhraseInfo phraseInfo : phraseInfoList) {
      float phraseTotalBoost = 0;
      for (TermInfo ti : phraseInfo.getTermsInfos()) {
        if (distinctTerms.add(ti.getText()))
          phraseTotalBoost += ti.getWeight() * phraseInfo.getBoost();
        length++;
      }
      tempSubInfos.add(
          new SubInfo(
              phraseInfo.getText(),
              phraseInfo.getTermsOffsets(),
              phraseInfo.getSeqnum(),
              phraseTotalBoost));
    }

    float norm = length * (1 / (float) Math.sqrt(length));

    float totalBoost = 0;
    for (SubInfo tempSubInfo : tempSubInfos) {
      // 分数归一化
      float subInfoBoost = tempSubInfo.getBoost() * norm;
      realSubInfos.add(
          new SubInfo(
              tempSubInfo.getText(),
              tempSubInfo.getTermsOffsets(),
              tempSubInfo.getSeqnum(),
              subInfoBoost));
      totalBoost += subInfoBoost;
    }

    getFragInfos().add(new WeightedFragInfo(startOffset, endOffset, realSubInfos, totalBoost));
  }

FragListBuilder

FieldFragList是通过FragListBuilder构建的。

FragListBuilder只有一个接口,通过FieldPhraseList来构建FieldFragList:

  public FieldFragList createFieldFragList(FieldPhraseList fieldPhraseList, int fragCharSize);

FragListBuilder的继承体系如下:

FragListBuilder.png

SingleFragListBuilder
public class SingleFragListBuilder implements FragListBuilder {

  @Override
  public FieldFragList createFieldFragList(FieldPhraseList fieldPhraseList, int fragCharSize) {

    FieldFragList ffl = new SimpleFieldFragList(fragCharSize);

    List<WeightedPhraseInfo> wpil = new ArrayList<>();
    Iterator<WeightedPhraseInfo> ite = fieldPhraseList.phraseList.iterator();
    WeightedPhraseInfo phraseInfo = null;
    while (true) {
      if (!ite.hasNext()) break;
      phraseInfo = ite.next();
      if (phraseInfo == null) break;

      wpil.add(phraseInfo);
    }
    if (wpil.size() > 0) ffl.add(0, Integer.MAX_VALUE, wpil);
    return ffl;
  }
}
SimpleFragListBuilder和WeightedFragListBuilder

SimpleFragListBuilder和WeightedFragListBuilder的区别在于分别使用SimpleFieldFragList和WeightedFieldFragList存储高亮片段的位置信息:

public class SimpleFragListBuilder extends BaseFragListBuilder {

  public SimpleFragListBuilder() {
    super();
  }

  public SimpleFragListBuilder(int margin) {
    super(margin);
  }

  @Override
  public FieldFragList createFieldFragList(FieldPhraseList fieldPhraseList, int fragCharSize) {
    return createFieldFragList(
        fieldPhraseList, new SimpleFieldFragList(fragCharSize), fragCharSize);
  }
}
public class WeightedFragListBuilder extends BaseFragListBuilder {

  public WeightedFragListBuilder() {
    super();
  }

  public WeightedFragListBuilder(int margin) {
    super(margin);
  }

  @Override
  public FieldFragList createFieldFragList(FieldPhraseList fieldPhraseList, int fragCharSize) {
    return createFieldFragList(
        fieldPhraseList, new WeightedFieldFragList(fragCharSize), fragCharSize);
  }
}

他们构建FieldFragList的主逻辑在BaseFragListBuilder中完成,先介绍BaseFragListBuilder的两个变量:

// margin就是在匹配的term之前需要再多取几个字符,后面统称为“留白”
// 比如文本“Lucene is a search engine library”,margin是7,
// 匹配到的是engine,则会从engine的前7个字符,search开始作为高亮片段的起始位置。
// 注意,这只是个初始的margin,获取整个高亮片段之后会进行调整,使得高亮的内容更靠近中心。
final int margin;

// 会根据margin算出一个最小的高亮片段长度
final int minFragCharSize;

具体的构建FieldFragList逻辑:

  protected FieldFragList createFieldFragList(
      FieldPhraseList fieldPhraseList, FieldFragList fieldFragList, int fragCharSize) {
    // 校验  fragCharSize 是否满足最短的高亮片段要求
    if (fragCharSize < minFragCharSize)
      throw new IllegalArgumentException(
          "fragCharSize("
              + fragCharSize
              + ") is too small. It must be "
              + minFragCharSize
              + " or higher.");

    List<WeightedPhraseInfo> wpil = new ArrayList<>();
    IteratorQueue<WeightedPhraseInfo> queue =
        new IteratorQueue<>(fieldPhraseList.getPhraseList().iterator());
    WeightedPhraseInfo phraseInfo = null;
    // 可以理解成前一个高亮片段的结束位置  
    int startOffset = 0;
    while ((phraseInfo = queue.top()) != null) {
      // 和前一个高亮片段有重叠,直接丢弃
      if (phraseInfo.getStartOffset() < startOffset) {
        queue.removeTop();
        continue;
      }

      wpil.clear();
      final int currentPhraseStartOffset = phraseInfo.getStartOffset();
      int currentPhraseEndOffset = phraseInfo.getEndOffset();
      // 根据留白的大小,调整高亮片段的起始位置  
      int spanStart = Math.max(currentPhraseStartOffset - margin, startOffset);
      // 根据新的起始位置和高亮片段的大小限制,更新高亮片段的结束位置
      // 这里逻辑就会造成问题3。因为重新计算spanEnd可能刚好截断了短语中的词组
      int spanEnd = Math.max(currentPhraseEndOffset, spanStart + fragCharSize);
      // acceptPhrase判断如果是单term,则接受,不考虑是否满足fragCharSize
      // 如果是多term,则必须  currentPhraseEndOffset - currentPhraseStartOffset <= fragCharSize
      if (acceptPhrase(
          queue.removeTop(), currentPhraseEndOffset - currentPhraseStartOffset, fragCharSize)) {
        wpil.add(phraseInfo);
      }
      
      // 如果后面还  phraseInfo 是在前一个高亮片段范围内的,则放在一起高亮
      while ((phraseInfo = queue.top()) != null) {
        if (phraseInfo.getEndOffset() <= spanEnd) {
          currentPhraseEndOffset = phraseInfo.getEndOffset();
          if (acceptPhrase(
              queue.removeTop(), currentPhraseEndOffset - currentPhraseStartOffset, fragCharSize)) {
            wpil.add(phraseInfo);
          }
        } else {
          break;
        }
      }
      if (wpil.isEmpty()) {
        continue;
      }

      final int matchLen = currentPhraseEndOffset - currentPhraseStartOffset;
      // 这边就是重新调整前后的留白空间,使高亮term靠近中间。
      // 具体实现是高亮无关的内容长度对半分,作为前后的留白长度。
      final int newMargin = Math.max(0, (fragCharSize - matchLen) / 2);
      // 根据新的留白空间更新高亮片段的起始和结束位置  
      spanStart = currentPhraseStartOffset - newMargin;
      if (spanStart < startOffset) {
        spanStart = startOffset;
      }

      spanEnd = spanStart + Math.max(matchLen, fragCharSize);
      startOffset = spanEnd; 
      fieldFragList.add(spanStart, spanEnd, wpil);
    }
    return fieldFragList;
  }

step5 构建高亮片段

step4中我们已经得到所有高亮片段在文本中的位置,接下来就是生成高亮片段文本。

FragmentsBuilder

最终的高亮片段文本是通过FragmentsBuilder来构建的。FragmentsBuilder是通过FieldFragList来构建最终的高亮片段,FragmentsBuilder中有好几个重载方法,最终实现都是调用一个(参数最全的),我们就只看这个就好:

  public String[] createFragments(
      IndexReader reader,
      int docId,  // 要高亮的文档
      String fieldName, // 字段名称
      FieldFragList fieldFragList,  // 需要高亮的列表
      int maxNumFragments, // 最多高亮片段
      String[] preTags, // 前置标签数组
      String[] postTags, // 后置标签数组
      Encoder encoder) // Highlighter中的那个编码器
      throws IOException;

FragmentsBuilder接口的继承体系如下所示:

FragmentsBuilder.png

抽象类BaseFragmentsBuilder完成了FragmentsBuilder的大部分功能,只提供了抽象方法getWeightedFragInfoList在处理WeightedFragInfoList之前做一些预处理。FragmentsBuilder的两个实现类就是通过实现getWeightedFragInfoList方法实现不同的高亮片段顺序。实现类有两个:

ScoreOrderFragmentsBuilder

最终的高亮片段是按照分数排序的

  public List<WeightedFragInfo> getWeightedFragInfoList(List<WeightedFragInfo> src) {
    Collections.sort(src, new ScoreComparator());
    return src;
  }

  // 先按分数排序,再按offset排序
  public static class ScoreComparator implements Comparator<WeightedFragInfo> {

    @Override
    public int compare(WeightedFragInfo o1, WeightedFragInfo o2) {
      if (o1.getTotalBoost() > o2.getTotalBoost()) return -1;
      else if (o1.getTotalBoost() < o2.getTotalBoost()) return 1;
      else {
        if (o1.getStartOffset() < o2.getStartOffset()) return -1;
        else if (o1.getStartOffset() > o2.getStartOffset()) return 1;
      }
      return 0;
    }
  }
SimpleFragmentsBuilder

最终的高亮片段是按startOffset排序的(因为src中的WeightedFragInfo是按offset排序)

  public List<WeightedFragInfo> getWeightedFragInfoList(List<WeightedFragInfo> src) {
    return src;
  }
BaseFragmentsBuilder

抽象类BaseFragmentsBuilder。

  public String[] createFragments(
      IndexReader reader,
      int docId,
      String fieldName,
      FieldFragList fieldFragList,
      int maxNumFragments,
      String[] preTags,
      String[] postTags,
      Encoder encoder)
      throws IOException {

    if (maxNumFragments < 0) {
      throw new IllegalArgumentException(
          "maxNumFragments(" + maxNumFragments + ") must be positive number.");
    }

    List<WeightedFragInfo> fragInfos = fieldFragList.getFragInfos();
    // Document中可以加多个相同字段  
    Field[] values = getFields(reader, docId, fieldName);
    if (values.length == 0) {
      return null;
    }
    // discreteMultiValueHighlighting参数表示每个字段是否要单独处理
    // 如果相同字段的值要单独处理,则需要调整  fragInfos ,因为索引的时候词向量是把字段值拼起来的
    // 这部分逻辑就不展开了  
    if (discreteMultiValueHighlighting && values.length > 1) {
      fragInfos = discreteMultiValueHighlighting(fragInfos, values);
    }

    // 两个实现类实现了这个方法,结果是高亮分片的顺序不同  
    fragInfos = getWeightedFragInfoList(fragInfos);
    int limitFragments = maxNumFragments < fragInfos.size() ? maxNumFragments : fragInfos.size();
    List<String> fragments = new ArrayList<>(limitFragments);

    StringBuilder buffer = new StringBuilder();
    int[] nextValueIndex = {0};
    for (int n = 0; n < limitFragments; n++) {
      WeightedFragInfo fragInfo = fragInfos.get(n);
      fragments.add(
          // 构建高亮分片
          makeFragment(buffer, nextValueIndex, values, fragInfo, preTags, postTags, encoder));
    }
    return fragments.toArray(new String[fragments.size()]);
  }

Document中可能存在多个相同名称的Field,getFields就是获取所有指定字段的内容,使用访问器,如果字段匹配的话,就保存起来:

  protected Field[] getFields(IndexReader reader, int docId, final String fieldName)
      throws IOException {
    // according to javadoc, doc.getFields(fieldName) cannot be used with lazy loaded field???
    final List<Field> fields = new ArrayList<>();
    reader.document(
        docId,
        new StoredFieldVisitor() {

          @Override
          public void stringField(FieldInfo fieldInfo, String value) {
            Objects.requireNonNull(value, "String value should not be null");
            FieldType ft = new FieldType(TextField.TYPE_STORED);
            ft.setStoreTermVectors(fieldInfo.hasVectors());
            fields.add(new Field(fieldInfo.name, value, ft));
          }

          @Override
          public Status needsField(FieldInfo fieldInfo) {
            return fieldInfo.name.equals(fieldName) ? Status.YES : Status.NO;
          }
        });
    return fields.toArray(new Field[fields.size()]);
  }

构建高亮:

  protected String makeFragment(
      StringBuilder buffer,
      int[] index,
      Field[] values,
      WeightedFragInfo fragInfo,
      String[] preTags,
      String[] postTags,
      Encoder encoder) {
    StringBuilder fragment = new StringBuilder();
    final int s = fragInfo.getStartOffset();
    // 为了能够传引用,但是又不想自动装箱拆箱,所以用数组保存,只有一个值  
    int[] modifiedStartOffset = {s};
    String src =
        getFragmentSourceMSO(
            buffer, index, values, s, fragInfo.getEndOffset(), modifiedStartOffset);
    int srcIndex = 0;
    for (SubInfo subInfo : fragInfo.getSubInfos()) {
      for (Toffs to : subInfo.getTermsOffsets()) {
        fragment
            .append(
                encoder.encodeText(
                    src.substring(srcIndex, to.getStartOffset() - modifiedStartOffset[0])))
            // 通过高亮词组的唯一编号获取高亮前置标签
            .append(getPreTag(preTags, subInfo.getSeqnum()))
            .append(
                encoder.encodeText(
                    src.substring(
                        to.getStartOffset() - modifiedStartOffset[0],
                        to.getEndOffset() - modifiedStartOffset[0])))
            // 通过高亮词组的唯一编号获取高亮后置标签
            .append(getPostTag(postTags, subInfo.getSeqnum()));
        srcIndex = to.getEndOffset() - modifiedStartOffset[0];
      }
    }
    fragment.append(encoder.encodeText(src.substring(srcIndex)));
    return fragment.toString();
  }

  protected String getFragmentSourceMSO(
      StringBuilder buffer,
      int[] index,
      Field[] values,
      int startOffset,
      int endOffset,
      int[] modifiedStartOffset) {
    // 把相同字段的所有值用  MultiValuedSeparator 连接起来
    while (buffer.length() < endOffset && index[0] < values.length) {
      buffer.append(values[index[0]++].stringValue());
      buffer.append(getMultiValuedSeparator());
    }
    int bufferLength = buffer.length();
    // 最后一个连接符去掉
    if (values[index[0] - 1].fieldType().tokenized()) {
      bufferLength--;
    }
    // 使用boundaryScanner为高亮的片段寻找边界  
    int eo =
        bufferLength < endOffset ? bufferLength : boundaryScanner.findEndOffset(buffer, endOffset);
    modifiedStartOffset[0] = boundaryScanner.findStartOffset(buffer, startOffset);
    return buffer.substring(modifiedStartOffset[0], eo);
  }

  // num就是高亮词组的唯一编号,所以同一词组的term标签是一样的,如示例1的结果
  protected String getPreTag(String[] preTags, int num) {
    int n = num % preTags.length;
    return preTags[n];
  }

  // num就是高亮词组的唯一编号,所以同一词组的term标签是一样的,如示例1的结果
  protected String getPostTag(String[] postTags, int num) {
    int n = num % postTags.length;
    return postTags[n];
  }

到此,FastVectorHighlighter的高亮原理就介绍结束了。

总结

经过上面的分析,我们也了解了FastVectorHighlighter的工作原理,从中也可以发现FastVectorHighlighter虽然解决了Highlighter的问题,但是它不是全场景通用,它无法解决下面这些问题:

  1. FastVectorHighlighter依赖词向量,构建索引必须开启词向量构建的相关配置。
  2. FastVectorHighlighter不支持MultiPhraseQuery和SpanQuery。
  3. FastVectorHighlighter中PhraseQuery是否高亮跟短语词组中的term的定义顺序有关。
  4. 高亮结果中包含了满足PhraseQuery的词组,但是因为fragCharSize参数的关系没有高亮。
  5. 匹配PhraseQuery的词组之间插入匹配的TermQuery之后就不高亮了。

那Lucene有没有全场景通用的高亮实现呢?肯定是有的,我们下一篇见。

写在最后

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

参考资料

lucene.apache.org/core/9_1_0/…