Lucene源码系列(五):UnifiedHighlighter高亮算法实现

1,610 阅读33分钟

背景

上一篇我们讨论了Lucene中的第二套高亮实现方案FastVectorHighlighter,虽然FastVectorHighlighter解决了第一套方案Highlighter处理大文档效率问题,但是它自身又引入了其他的问题使得其使用场景受限。

Lucene推出的第三套高亮解决方案是UnifiedHighlighter,从名字可以看出,这是Lucene高亮的“大一统”解决方案。UnifiedHighlighter凭什么是高亮的大一统解决方案?我觉得是两方面:

  1. 首先在接口使用上,涵盖了Highlighter(字段名称+文本)和FastVectorHighlighter(字段名称+文档号)的使用方式,下一节的例子我们可以看到。
  2. 其次在寻找高亮匹配term的位置方式也涵盖了Highlighter(Analyzer)和FastVectorHighlighter(TermVector),另外UnifiedHighlighter还增加了基于倒排信息寻找匹配term的位置方式。

注意:本文源码解析基于Lucene 9.1.0版本。

UnifiedHighlighter使用示例

我们先了解下UnifiedHighlighter怎么用。UnifiedHighlighter有两种接口,一种是直接传文本信息进行高亮处理(类似Highlighter的使用方式),另一种是传文档号(类似FastVectorHighlighter的使用方式),从接口来看,确实是大一统(包含了Highlighter和FastVectorHighlighter的使用方式):

public class UnifiedHighlighterDemo {
    private static final String FIELD_NAME = "field0";
    private static final String CONTENT = "Lucene is a search engine library.";
    
    public static void main(String[] args) throws IOException {
        Directory directory = new ByteBuffersDirectory();
        StandardAnalyzer analyzer = new StandardAnalyzer();
        IndexWriterConfig indexWriterConfig = new IndexWriterConfig(analyzer);
        IndexWriter indexWriter = new IndexWriter(directory, indexWriterConfig);

        FieldType fieldType = new FieldType();
        fieldType.setIndexOptions(IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS);
        fieldType.setStored(true);

        Document document = new Document();
        document.add(new Field(FIELD_NAME, CONTENT, fieldType));
        indexWriter.addDocument(document);

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

        TermQuery termQuery = new TermQuery(new Term(FIELD_NAME, "lucene"));
        BoostQuery boostQuery = new BoostQuery(termQuery, 2);
        PhraseQuery phraseQuery = new PhraseQuery(2, FIELD_NAME, "search", "library");
        BooleanQuery booleanQuery = new BooleanQuery.Builder()
            .add(boostQuery, BooleanClause.Occur.SHOULD)
            .add(phraseQuery, BooleanClause.Occur.SHOULD)
            .build();

        // searcher为null,直接传文本处理
        UnifiedHighlighter highlighterWithoutSearcher = UnifiedHighlighter
            .builder(null, analyzer)
            .build();
        // 接口一(可以对比Highlighter的接口)
        String snippet =
            (String) highlighterWithoutSearcher.highlightWithoutSearcher(FIELD_NAME, booleanQuery, CONTENT, 5);
        System.out.println("highlighter without searcher: ");
        System.out.println(snippet);
        System.out.println();
        
        IndexReader reader = DirectoryReader.open(indexWriter);
        IndexSearcher searcher = new IndexSearcher(reader);
        // searcher不为null,传文档号,searcher可以通过文档号和字段名获取文本信息
        UnifiedHighlighter highlighterWithSearcher = UnifiedHighlighter
            .builder(searcher, analyzer)
            .build();
        TopDocs topDocs = searcher.search(booleanQuery, 5);
        // 接口二(可以对比FastVectorHighlighter的接口)
        Map<String, String[]> fieldSnippets =
            highlighterWithSearcher.highlightFields(new String[]{FIELD_NAME}, booleanQuery, topDocs, new int[]{5});
        System.out.println("highlighter with searcher: ");
        for (String s : fieldSnippets.get(FIELD_NAME)) {
            System.out.println(s);
        }
    }
}

高亮结果是:

highlighter without searcher: 
<b>Lucene</b> is a <b>search engine library</b>.

highlighter with searcher: 
<b>Lucene</b> is a <b>search engine library</b>.

注意: 从上面的结果我们可以看到,对于PhraseQuery,UnifiedHighlighter是把匹配的整个片段高亮。这跟一些配置有关,后面介绍源码我们会具体分析。

我们把上面中的TermQuery改成:

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

高亮结果是:

highlighter without searcher: 
Lucene is a <b>search engine</b> library.

highlighter with searcher: 
Lucene is a <b>search engine</b> library.

这个结果让我们惊讶了,PhraseQuery("search", "library")只高亮了“search”,而“library”并没有被高亮。

正经的PhraseQuery被拆开了,那我们不禁要问,官方是不是吹牛了,名字取“大一统高亮”,怎么还有这样的bug?那这到底是不是个bug?能修吗?我们是不是有参与源码提交的机会?接下来,我们就去扒扒看UnifiedHighlighter的实现。

必看说明

  • 后文说的跟term位置敏感的query指的是PhraseQuery,MultiPhraseQuery,SpanQuery,term位置不敏感的query就是其他的文本查询query,最简单的就是TermQuery。

  • 后文说的基于Matches的寻找匹配位置,指的是使用org.apache.lucene.search.Weight#matches获取Matches,再通过Matches#getMatches获取MatchesIterator来获取匹配的位置,这是Lucene检索使用的查找匹配的方式,是最精确的匹配方式。

  • UnifiedHighlighter中默认是把所有的高亮片段连接成一个摘要,因此接口返回的不是一个个高亮片段,而是一个摘要。所以在UnifiedHighlighter中如果没有找到有匹配关键字的高亮片段,默认会取前几个句子作为摘要。

预备知识

老规矩,为了后面分析源码不卡壳,我们需要先了解一些预备知识。如果真要磕源码就不要烦,下面也都是一些接口级描述,相信很容易理解。

TermVector和Posting(倒排)

TermVector和Posting中都可以保存term的position和offset信息,那它们有什么区别呢?

Posting结构的查找步骤是:field -> term -> doc -> freq/pos/offset

也就是说Posting是从字段定位到term,再定位到文档,获取位置信息。

TermVector结构的查找步骤是:doc -> field -> term -> freq/pos/offset

TermVector是从文档定位到字段,再定位term,获取位置信息。

从上面的介绍中,我们可以看出一些基本的规律:

  • query查询是通过Posting来查找匹配的文档的,因为query就是从field中查找匹配的term,顺着Posting的结构,下一步就能得到所有匹配的文档了。

  • 从指定文档中获取指定字段单个term的匹配位置,则TermVector(doc查找1次,field查找1次,term查找1次,)和Posting(field查找1次,term查找1次,doc查找1次)效率差不多。

  • 从指定文档中获取指定字段多个term的匹配位置,则TermVector(doc查找1次,field查找1次,term查找n次)性能比Posting(field查找1次,term查找n次,doc查找n次)好。

  • 查询多个文档位置信息一般都是Posting性能好。

大家可以自行分析下多字段多term查询位置信息哪种结构好。

MatchesIterator

MatchesIterator一个获取指定文档中满足query查询的位置的迭代器,注意对于term位置敏感的query,一次匹配可能是跨多个position的:

public interface MatchesIterator {

  // 定位到下一个匹配的位置,如果存在下一个匹配的位置返回true,否则返回false。
  boolean next() throws IOException;

  // 当前匹配位置的起始position
  int startPosition();

  // 当前匹配位置的结束position
  int endPosition();

  // 当前匹配位置在文本中的起始偏移量
  int startOffset() throws IOException;

  // 当前匹配位置在文本中的结束偏移量
  int endOffset() throws IOException;

  // 如PhraseQuery,SpanNearQuery等是包含多个term的,上面介绍的接口都是对整体来说的,比如:
  //         a b c d e
  // pos:    0 1 2 3 4
  // offset: 012345678  
  // 查找PhraseQuery(b, c, d), 则(startPosition=1, endPosition=4,startOffset=2,endOffset=7),
  // 如果需要获取b,c,d的posision和offset,则通过getSubMatches方法获取迭代器。  
  MatchesIterator getSubMatches() throws IOException;

  // 当前匹配位置的Query
  Query getQuery();
}

PostingsEnum

是获取指定term的倒排信息迭代器,获取term的position,offset以及频率信息:

  // 返回当前term在文档中的频率
  public abstract int freq() throws IOException;

  // 返回下一个position。最多调用freq()次。
  public abstract int nextPosition() throws IOException;

  // 当前position的term的在文档中的起始位置
  public abstract int startOffset() throws IOException;

  // 当前position的term在文档中的结束位置
  public abstract int endOffset() throws IOException;

  // 当前position的payload信息
  public abstract BytesRef getPayload() throws IOException;

TermsEnum

TermsEnum可以迭代所有的term或者定位到指定的term,获取term的频率,倒排等信息。

寻找term结果的状态表示:

  public enum SeekStatus {
    // 没找到,并且迭代器已经遍历结束
    END,
    // 找到指定的term
    FOUND,
    // 没有找到指定的term,找到了一个别的term
    NOT_FOUND
  };

关键接口:

  // 寻找指定的term,如果找到,则返回true,没找到返回false 
  public abstract boolean seekExact(BytesRef text) throws IOException;

  // 寻找指定的term或者比指定term大的下一个term。
  public abstract SeekStatus seekCeil(BytesRef text) throws IOException;

  // 寻找迭代器中第ord个term
  public abstract void seekExact(long ord) throws IOException;  

  // 寻找指定状态的term(迭代器中有多个相同的term,但是他们位置信息不一样)
  public abstract void seekExact(BytesRef term, TermState state) throws IOException;

  // 返回当前的term
  public abstract BytesRef term() throws IOException;

  // 返回当前term在迭代器中的序数
  public abstract long ord() throws IOException;

  // 返回多少个文档包含当前的term
  public abstract int docFreq() throws IOException;

  // 返回当前term所有文档中的频率。(不包含已经删除的文档)
  public abstract long totalTermFreq() throws IOException;

  // 如果只需要文档和频率信息,则可以转成PostingsEnum
  public final PostingsEnum postings(PostingsEnum reuse) throws IOException {
    return postings(reuse, PostingsEnum.FREQS);
  }

  // 可以指定需要文档的那些信息(flags的每一位表示一种信息,position,offset等)
  public abstract PostingsEnum postings(PostingsEnum reuse, int flags) throws IOException;

  // 相当于是一个term状态信息的快照,可以重复获取,不需要再调用seek方法
  public abstract TermState termState() throws IOException;

DocIdSetIterator

DocIdSetIterator用来迭代一个非递减的docId集合。NO_MORE_DOCS表示迭代的结束哨兵,它是整型的最大值。

public abstract class DocIdSetIterator {
  // 迭代器结束的哨兵
  public static final int NO_MORE_DOCS = Integer.MAX_VALUE;

  // 如果nextDoc和advance方法没有被调用过,返回-1
  // 如果迭代结束,返回NO_MORE_DOCS
  // 其他情况下返回当前迭代到的文档id
  public abstract int docID();

  // 获取下一个文档id,如果没有则返回NO_MORE_DOCS。
  // 注意如果已经迭代结束,则调用此方法的结果未定义。
  public abstract int nextDoc() throws IOException;

  // 获取大于等于target的文档id。
  // 如果target > 整个迭代器中最大的docid(最后一个),则返回NO_MORE_DOCS。
  // 如果target比当前迭代定位到的docId小或者迭代器已经迭代结束,则方法行为未定义。
  public abstract int advance(int target) throws IOException;

  // advance方法的一个简单遍历版本。通过一次次迭代直到找到目标。
  protected final int slowAdvance(int target) throws IOException {
    assert docID() < target;
    int doc;
    do {
      doc = nextDoc();
    } while (doc < target);
    return doc;
  }

  // 迭代器"代价"的估计值。一般是这个迭代器文档总数的上限的估计值。
  public abstract long cost();
}

TwoPhaseIterator

查询文档是否满足query要求有时候需要分两步来判断。比如对于Term位置敏感的query来说,寻找匹配分为两个阶段:

  1. 第一个阶段是获取满足第一个条件的文档迭代器,也就是必须包含query中所有的term的文档(term倒排的交集)。
  2. 第二个阶段判断是否满足第二个匹配条件,也就是term之间的位置关系是否满足slop距离。

我们稍微了解下TwoPhaseIterator的接口:

// 省略了一些无关的代码
public abstract class TwoPhaseIterator {
  // 用来获取满足第一个条件的文档
  protected final DocIdSetIterator approximation;

  protected TwoPhaseIterator(DocIdSetIterator approximation) {
    this.approximation = Objects.requireNonNull(approximation);
  }

  public DocIdSetIterator approximation() {
    return approximation;
  }

  // 判断approximation中当前的doc是否有满足第二个条件的匹配位置
  public abstract boolean matches() throws IOException;

  // 估计匹配的代价
  public abstract float matchCost();
}

WeightedSpanTermExtractor

这个工具类是在Highlighter中首先使用的,当时在介绍Highlighter的时候没有详细分析这个类,只是简单说明了这个类中主要的操作是把PhraseQuery和MultiPhraseQuery都转成了SpanQuery来统一处理,今天我们就来看看具体实现。

WeightedSpanTermExtractor依赖的对象

首先WeightedSpanTermExtractor的结果是WeightedSpanTerm,WeightedSpanTerm继承了WeightedTerm:

public class WeightedTerm {
  // term的权重,算高亮得分用的  
  float weight;  
  String term;

   。。。省略其他简单的方法
}

WeightedSpanTerm是存储SpanQuery中每个term和SpanQuery匹配位置的对象:

public class WeightedSpanTerm extends WeightedTerm {
  // 标记是否是term位置敏感的,因为它继承了 WeightedTerm, 所以加这个标记,可以用来存单独的term和weight。 
  boolean positionSensitive;
  // 如果是SpanQuery,则保存所有匹配的位置
  private List<PositionSpan> positionSpans = new ArrayList<>();

  。。。省略构造方法
  
  // 判断某个term的position是否在positionSpans中,也就是判断term是否满足SpanQuery
  public boolean checkPosition(int position) {
    Iterator<PositionSpan> positionSpanIt = positionSpans.iterator();

    while (positionSpanIt.hasNext()) {
      PositionSpan posSpan = positionSpanIt.next();

      if (((position >= posSpan.start) && (position <= posSpan.end))) {
        return true;
      }
    }

    return false;
  }

  。。。 省略简单方法
}

PositionSpan就是记录SpanQuery一个匹配前后位置:

public class PositionSpan {
  int start;
  int end;

  public PositionSpan(int start, int end) {
    this.start = start;
    this.end = end;
  }
}

WeightedSpanTermExtractor核心方法解析

判断SpanQuery是否需要改写,涉及query解析一般都是递归实现,我们重点注意递归出口就好:

  protected boolean mustRewriteQuery(SpanQuery spanQuery) {
    if (!expandMultiTermQuery) {
      // 如果设置了不需要扩展  MultiTermQuery,则全部不需要改写
      return false; // Will throw UnsupportedOperationException in case of a SpanRegexQuery.
    } else if (spanQuery instanceof FieldMaskingSpanQuery) {
      return mustRewriteQuery(((FieldMaskingSpanQuery) spanQuery).getMaskedQuery());
    } else if (spanQuery instanceof SpanFirstQuery) {
      return mustRewriteQuery(((SpanFirstQuery) spanQuery).getMatch());
    } else if (spanQuery instanceof SpanNearQuery) {
      for (final SpanQuery clause : ((SpanNearQuery) spanQuery).getClauses()) {
        // SpanNearQuery中只要有一个子query需要改写,就需要改写  
        if (mustRewriteQuery(clause)) {
          return true;
        }
      }
      //  SpanNearQuery中所有子query都不需要改写,才不需要改写   
      return false;
    } else if (spanQuery instanceof SpanNotQuery) {
      SpanNotQuery spanNotQuery = (SpanNotQuery) spanQuery;
      return mustRewriteQuery(spanNotQuery.getInclude())
          || mustRewriteQuery(spanNotQuery.getExclude());
    } else if (spanQuery instanceof SpanOrQuery) {
      for (final SpanQuery clause : ((SpanOrQuery) spanQuery).getClauses()) {
        // SpanOrQuery中只要有一个子query需要改写,就需要改写    
        if (mustRewriteQuery(clause)) {
          return true;
        }
      }
      //  SpanOrQuery中所有子query都不需要改写,才不需要改写   
      return false;
    } else if (spanQuery instanceof SpanTermQuery) {
      // SpanTermQuery不需要改写  
      return false;
    } else {
      // 其他SpanQuery的子类都需要改写  
      return true;
    }
  }

默认实现跟空间相关的query都是不支持的:

protected boolean isQueryUnsupported(Class<? extends Query> clazz) {
  if (clazz.getName().startsWith("org.apache.lucene.spatial.")) {
    return true;
  }

  if (clazz.getName().startsWith("org.apache.lucene.spatial3d.")) {
    return true;
  }
  return false;
}

获取跟位置信息无关的term信息:

protected void extractWeightedTerms(Map<String, WeightedSpanTerm> terms, Query query, float boost)
    throws IOException {
  Set<Term> nonWeightedTerms = new HashSet<>();
  final IndexSearcher searcher = new IndexSearcher(getLeafContext());
  // 借助query的访问器模式,获取query树中的所有的term  
  searcher.rewrite(query).visit(QueryVisitor.termCollector(nonWeightedTerms));

  // 对所有的term做下过滤,只保留我们需要处理的字段的term  
  for (final Term queryTerm : nonWeightedTerms) {
    if (fieldNameComparator(queryTerm.field())) {
      WeightedSpanTerm weightedSpanTerm = new WeightedSpanTerm(boost, queryTerm.text());
      terms.put(queryTerm.text(), weightedSpanTerm);
    }
  }
}

获取跟位置信息有关的term信息,重点是会获取匹配的位置保存到WeightedSpanTerm:

protected void extractWeightedSpanTerms(
    Map<String, WeightedSpanTerm> terms, SpanQuery spanQuery, float boost) throws IOException {
  Set<String> fieldNames;

  // 如果没有指定字段,则去query树获取  
  if (fieldName == null) {
    fieldNames = new HashSet<>();
    collectSpanQueryFields(spanQuery, fieldNames);
  } else {
    fieldNames = new HashSet<>(1);
    fieldNames.add(fieldName);
  }
  // 如果设置了默认字段,把默认字段也加进来需要处理
  if (defaultField != null) {
    fieldNames.add(defaultField);
  }

  Map<String, SpanQuery> queries = new HashMap<>();
  Set<Term> nonWeightedTerms = new HashSet<>();
  // 前面介绍过了,判断SpanQuery是否需要改写  
  final boolean mustRewriteQuery = mustRewriteQuery(spanQuery);
  final IndexSearcher searcher = new IndexSearcher(getLeafContext());
  searcher.setQueryCache(null);
  // 获取query中出现的term,如果需要改写,先改写  
  if (mustRewriteQuery) {
    for (final String field : fieldNames) {
      final SpanQuery rewrittenQuery = (SpanQuery) spanQuery.rewrite(getLeafContext().reader());
      queries.put(field, rewrittenQuery);
      rewrittenQuery.visit(QueryVisitor.termCollector(nonWeightedTerms));
    }
  } else {
    spanQuery.visit(QueryVisitor.termCollector(nonWeightedTerms));
  }

  List<PositionSpan> spanPositions = new ArrayList<>();

  for (final String field : fieldNames) {
    final SpanQuery q;
    if (mustRewriteQuery) {
      q = queries.get(field);
    } else {
      q = spanQuery;
    }
    LeafReaderContext context = getLeafContext();
    SpanWeight w =
        (SpanWeight) searcher.createWeight(searcher.rewrite(q), ScoreMode.COMPLETE_NO_SCORES, 1);
    Bits acceptDocs = context.reader().getLiveDocs();
    // SpanWeight的getSpans方法会获Spans,Spans中可以获取匹配SpanQuery的文档编号,和具体在每个文档中匹配的位置  
    final Spans spans = w.getSpans(context, SpanWeight.Postings.POSITIONS);
    if (spans == null) {
      return;
    }

    while (spans.nextDoc() != Spans.NO_MORE_DOCS) {
      // 寻找我们要处理的文档的spans  
      if (acceptDocs != null && acceptDocs.get(spans.docID()) == false) {
        continue;
      }
      // 遍历所有的匹配的位置信息,保存下来
      while (spans.nextStartPosition() != Spans.NO_MORE_POSITIONS) {
        spanPositions.add(new PositionSpan(spans.startPosition(), spans.endPosition() - 1));
      }
    }
  }

  if (spanPositions.size() == 0) {
    return;
  }

  // 把term对应的匹配位置保存到结果集中。
  // 后面判断term是否是合法位置的时候,就可以判断term的position是否满足任意一个PositionSpan就可以。  
  for (final Term queryTerm : nonWeightedTerms) {
    if (fieldNameComparator(queryTerm.field())) {
      WeightedSpanTerm weightedSpanTerm = terms.get(queryTerm.text());

      if (weightedSpanTerm == null) {
        weightedSpanTerm = new WeightedSpanTerm(boost, queryTerm.text());
        weightedSpanTerm.addPositionSpans(spanPositions);
        weightedSpanTerm.positionSensitive = true;
        terms.put(queryTerm.text(), weightedSpanTerm);
      } else {
        if (spanPositions.size() > 0) {
          weightedSpanTerm.addPositionSpans(spanPositions);
        }
      }
    }
  }
}

最核心的方法,超级长,但是好消息也是递归实现,所以逻辑简单,PhraseQuery和MultiPhraseQuery转SpanQuery的实现都在这个方法里面,这边可以不用深究为什么是这么转,后面会出一个系列专门介绍常用的Query以及寻找匹配的算法:

protected void extract(Query query, float boost, Map<String, WeightedSpanTerm> terms)
    throws IOException {
  if (query instanceof BoostQuery) {
    BoostQuery boostQuery = (BoostQuery) query;
    extract(boostQuery.getQuery(), boost * boostQuery.getBoost(), terms);
  } else if (query instanceof BooleanQuery) {
    for (BooleanClause clause : (BooleanQuery) query) {
      if (!clause.isProhibited()) {
        extract(clause.getQuery(), boost, terms);
      }
    }
  } else if (query instanceof PhraseQuery) { // PhraseQuery需要转SpanQuery
    PhraseQuery phraseQuery = ((PhraseQuery) query);
    Term[] phraseQueryTerms = phraseQuery.getTerms();
    if (phraseQueryTerms.length == 1) {
      // 单term的PhraseQuery转SpanTermQuery,当做位置无关的term处理
      extractWeightedSpanTerms(terms, new SpanTermQuery(phraseQueryTerms[0]), boost);
    } else {
      SpanQuery[] clauses = new SpanQuery[phraseQueryTerms.length];
      // 每个term都转成SpanTermQuery  
      for (int i = 0; i < phraseQueryTerms.length; i++) {
        clauses[i] = new SpanTermQuery(phraseQueryTerms[i]);
      }

      // sum position increments beyond 1
      int positionGaps = 0;
      int[] positions = phraseQuery.getPositions();
      if (positions.length >= 2) {
        positionGaps =
            Math.max(0, positions[positions.length - 1] - positions[0] - positions.length + 1);
      }

      boolean inorder = (phraseQuery.getSlop() == 0);

      SpanNearQuery sp =
          new SpanNearQuery(clauses, phraseQuery.getSlop() + positionGaps, inorder);
      extractWeightedSpanTerms(terms, sp, boost);
    }
  } else if (query instanceof TermQuery || query instanceof SynonymQuery) {
    extractWeightedTerms(terms, query, boost);
  } else if (query instanceof SpanQuery) {
    extractWeightedSpanTerms(terms, (SpanQuery) query, boost);
  } else if (query instanceof ConstantScoreQuery) {
    final Query q = ((ConstantScoreQuery) query).getQuery();
    if (q != null) {
      extract(q, boost, terms);
    }
  } else if (query instanceof CommonTermsQuery) {
    extractWeightedTerms(terms, query, boost);
  } else if (query instanceof DisjunctionMaxQuery) {
    for (Query clause : ((DisjunctionMaxQuery) query)) {
      extract(clause, boost, terms);
    }
  } else if (query instanceof MultiPhraseQuery) {
    final MultiPhraseQuery mpq = (MultiPhraseQuery) query;
    final Term[][] termArrays = mpq.getTermArrays();
    final int[] positions = mpq.getPositions();
    if (positions.length > 0) {
      // 获取最大的position
      int maxPosition = positions[positions.length - 1];
      for (int i = 0; i < positions.length - 1; ++i) {
        if (positions[i] > maxPosition) {
          maxPosition = positions[i];
        }
      }

      final List<SpanQuery>[] disjunctLists = new List[maxPosition + 1];
      int distinctPositions = 0;

      // 把所有的Term都转成SpanTermQuery  
      for (int i = 0; i < termArrays.length; ++i) {
        final Term[] termArray = termArrays[i];
        List<SpanQuery> disjuncts = disjunctLists[positions[i]];
        if (disjuncts == null) {
          disjuncts = (disjunctLists[positions[i]] = new ArrayList<>(termArray.length));
          ++distinctPositions;
        }
        for (Term aTermArray : termArray) {
          disjuncts.add(new SpanTermQuery(aTermArray));
        }
      }

      int positionGaps = 0;
      int position = 0;
      final SpanQuery[] clauses = new SpanQuery[distinctPositions];
      // 同一个position的SpanTermQuery列表转成SpanOrQuery
      for (List<SpanQuery> disjuncts : disjunctLists) {
        if (disjuncts != null) {
          clauses[position++] =
              new SpanOrQuery(disjuncts.toArray(new SpanQuery[disjuncts.size()]));
        } else {
          ++positionGaps;
        }
      }

      if (clauses.length == 1) {
        // 如果只有一个position,则直接获取WeightedSpanTerm保存在terms中
        extractWeightedSpanTerms(terms, clauses[0], boost);
      } else {
        final int slop = mpq.getSlop();
        final boolean inorder = (slop == 0);
        // 多个position转成SpanNearQuery,再获取WeightedSpanTerm保存在terms中
        SpanNearQuery sp = new SpanNearQuery(clauses, slop + positionGaps, inorder);
        extractWeightedSpanTerms(terms, sp, boost);
      }
    }
  } else if (query instanceof MatchAllDocsQuery) {
    // nothing
  } else if (query instanceof FunctionScoreQuery) {
    extract(((FunctionScoreQuery) query).getWrappedQuery(), boost, terms);
  } else if (isQueryUnsupported(query.getClass())) {
    // nothing
  } else {
    // 如果是  MultiTermQuery,但是设置了不需要展开,并且不是我们关注的字段,则直接返回
    if (query instanceof MultiTermQuery
        && (!expandMultiTermQuery || !fieldNameComparator(((MultiTermQuery) query).getField()))) {
      return;
    }
    Query origQuery = query;
    final IndexReader reader = getLeafContext().reader();
    Query rewritten;
    if (query instanceof MultiTermQuery) {
      rewritten = MultiTermQuery.SCORING_BOOLEAN_REWRITE.rewrite(reader, (MultiTermQuery) query);
    } else {
      rewritten = origQuery.rewrite(reader);
    }
    if (rewritten != origQuery) {
      // 如果改写成功,则递归获取WeightedSpanTerm
      extract(rewritten, boost, terms);
    } else {
      // 这个方法默认实现是空实现,也就是不处理未知的query  
      extractUnknownQuery(query, terms);
    }
  }
}

PhraseHelper

处理PhraseQuery和MultiPhraseQuery的辅助类。内部是通过WeightedSpanTermExtractor把PhraseQuery和MultiPhraseQuery转成SpanQuery来处理。

PhraseHelper的成员变量:

  // 指定要处理的字段
  private final String fieldName;
  // 跟位置无关的term,其实可以当做是TermQuery的term
  private final Set<BytesRef> positionInsensitiveTerms; 
  // 所有跟位置有关的query都会转成SpanQuery
  private final Set<SpanQuery> spanQueries;
  // spanQueries的SpanQuery是否需要重写的
  private final boolean willRewrite;
  // 判断要处理的字段,一般就是判断 是否是fieldName
  private final Predicate<String> fieldMatcher;

PhraseHelper中最重要的就是创建匹配位置的迭代器:

public void createOffsetsEnumsForSpans(
    LeafReader leafReader, int docId, List<OffsetsEnum> results) throws IOException {
  leafReader = new SingleFieldWithOffsetsFilterLeafReader(leafReader, fieldName);
  IndexSearcher searcher = new IndexSearcher(leafReader);
  searcher.setQueryCache(null);
  // 按startPosition排序的最小堆
  PriorityQueue<Spans> spansPriorityQueue =
      new PriorityQueue<Spans>(spanQueries.size()) {
        @Override
        protected boolean lessThan(Spans a, Spans b) {
          return a.startPosition() <= b.startPosition();
        }
      };
  for (Query query : spanQueries) {
    Weight weight =
        searcher.createWeight(searcher.rewrite(query), ScoreMode.COMPLETE_NO_SCORES, 1);
    Scorer scorer = weight.scorer(leafReader.getContext());
    if (scorer == null) {
      continue;
    }
 
    // 如果docId中没有匹配的  
    TwoPhaseIterator twoPhaseIterator = scorer.twoPhaseIterator();
    if (twoPhaseIterator != null) {
      if (twoPhaseIterator.approximation().advance(docId) != docId
          || !twoPhaseIterator.matches()) {
        continue;
      }
    } else if (scorer.iterator().advance(docId)!= docId) { 
      continue;
    }

    // 如果docId中有匹配的,则保存所有匹配的位置信息  
    Spans spans = ((SpanScorer) scorer).getSpans();
    if (spans.nextStartPosition() != Spans.NO_MORE_POSITIONS) {
      spansPriorityQueue.add(spans);
    }
  }

  // 把匹配位置封装成OffsetEnum迭代器(OffsetEnum后面详细说明)
  OffsetSpanCollector spanCollector = new OffsetSpanCollector();
  while (spansPriorityQueue.size() > 0) {
    Spans spans = spansPriorityQueue.top();
    spans.collect(spanCollector);

    if (spans.nextStartPosition() == Spans.NO_MORE_POSITIONS) {
      spansPriorityQueue.pop();
    } else {
      spansPriorityQueue.updateTop();
    }
  }
  results.addAll(spanCollector.termToOffsetsEnums.values());
}

MultiTermHighlighting

处理MultiTermQuery的辅助类,MultiPhraseQuery都是需要使用自动机来寻找匹配:

final class MultiTermHighlighting {
  private MultiTermHighlighting() {}

  // 根据query获取匹配query的自动机
  static LabelledCharArrayMatcher[] extractAutomata(
      Query query, Predicate<String> fieldMatcher, boolean lookInSpan) {
    AutomataCollector collector = new AutomataCollector(lookInSpan, fieldMatcher);
    query.visit(collector);
    return collector.runAutomata.toArray(new LabelledCharArrayMatcher[0]);
  }

  // 在当前版本中,MultiTermQuery的子类就是这两个
  public static boolean canExtractAutomataFromLeafQuery(Query query) {
    return query instanceof AutomatonQuery || query instanceof FuzzyQuery;
  }

  private static class AutomataCollector extends QueryVisitor {

    List<LabelledCharArrayMatcher> runAutomata = new ArrayList<>();
    // 是否需要处理SpanQuery  
    final boolean lookInSpan;
    final Predicate<String> fieldMatcher;

    private AutomataCollector(boolean lookInSpan, Predicate<String> fieldMatcher) {
      this.lookInSpan = lookInSpan;
      this.fieldMatcher = fieldMatcher;
    }

    @Override
    public boolean acceptField(String field) {
      return fieldMatcher.test(field);
    }

    @Override
    public QueryVisitor getSubVisitor(BooleanClause.Occur occur, Query parent) {
      if (lookInSpan == false && parent instanceof SpanQuery) {
        return QueryVisitor.EMPTY_VISITOR;
      }
      return super.getSubVisitor(occur, parent);
    }

    @Override
    public void consumeTermsMatching(
        Query query, String field, Supplier<ByteRunAutomaton> automaton) {
      runAutomata.add(LabelledCharArrayMatcher.wrap(query.toString(), automaton.get()));
    }
  }
}

Passage

Passage表示了一个高亮片段,其中有需要高亮的term的位置,先看下成员变量:

  // 整个片段在文档某个字段的起始位置
  private int startOffset = -1;
  // 整个片段在文档某个字段的结束位置
  private int endOffset = -1;
  // 整个片段的相关性得分(一个字段有多个片段,最后的片段按这个得分排序,结果会返回用户指定的片段个数)
  private float score = 0.0f;

  // 匹配term的起始位置
  private int[] matchStarts = new int[8];
  // 匹配term的结束位置
  private int[] matchEnds = new int[8];
  // 匹配的term
  private BytesRef[] matchTerms = new BytesRef[8];
  // 匹配的term在文档中出现的频率
  private int[] matchTermFreqInDoc = new int[8];
  // 所有的term总共匹配了几次
  private int numMatches = 0;

重点方法:

  // 新增一个关键字匹配
  public void addMatch(int startOffset, int endOffset, BytesRef term, int termFreqInDoc) {
    // 信息存储的数组做个扩容  
    if (numMatches == matchStarts.length) {
      int newLength = ArrayUtil.oversize(numMatches + 1, RamUsageEstimator.NUM_BYTES_OBJECT_REF);
      int[] newMatchStarts = new int[newLength];
      int[] newMatchEnds = new int[newLength];
      int[] newMatchTermFreqInDoc = new int[newLength];
      BytesRef[] newMatchTerms = new BytesRef[newLength];
      System.arraycopy(matchStarts, 0, newMatchStarts, 0, numMatches);
      System.arraycopy(matchEnds, 0, newMatchEnds, 0, numMatches);
      System.arraycopy(matchTerms, 0, newMatchTerms, 0, numMatches);
      System.arraycopy(matchTermFreqInDoc, 0, newMatchTermFreqInDoc, 0, numMatches);
      matchStarts = newMatchStarts;
      matchEnds = newMatchEnds;
      matchTerms = newMatchTerms;
      matchTermFreqInDoc = newMatchTermFreqInDoc;
    }
    // 新增一个匹配项
    matchStarts[numMatches] = startOffset;
    matchEnds[numMatches] = endOffset;
    matchTerms[numMatches] = term;
    matchTermFreqInDoc[numMatches] = termFreqInDoc;
    numMatches++;
  }

其他方法都是获取成员变量,比较简单。

HighlightFlag

用来控制高亮的行为:

public enum HighlightFlag {
  PHRASES, // 是否严格匹配处理PhraseQuery高亮,也就是是否必须满足slop要求
  MULTI_TERM_QUERY, // 是否要处理MultiTermQuery
  PASSAGE_RELEVANCY_OVER_SPEED, // 基于Analyzer有两种offset的获取方式,这个标记用来决定用哪一种
  // 如果没开启PASSAGE_RELEVANCY_OVER_SPEED和WEIGHT_MATCHES,并且没有位置相关的query或者是没有开启PHRASES,则使用   // 分词器直接匹配term,其他情况则使用内存索引,会精确判断匹配。
  WEIGHT_MATCHES // 统一使用org.apache.lucene.search.Weight#matches获取Matches,
                 // 再通过Matches#getMatches获取MatchesIterator获取匹配的位置进行高亮
}

OffsetSource

匹配位置迭代器OffsetEnum中获取offset的来源:

public enum OffsetSource {
  POSTINGS,    // 倒排中的offset
  TERM_VECTORS,// 词向量中的offset
  ANALYSIS,    // 分词器分词过程中的offset
  POSTINGS_WITH_TERM_VECTORS, // 倒排+词向量中的offset
  NONE_NEEDED  // 不需要offset
}

核心逻辑

如果看了之前Highlighter的实现分析,现在分析UnifiedHighlighter的整体思路实现是比较简单的。因为UnifiedHighlighter的实现在整体思路上和Highlighter非常相似:

  1. 寻找匹配需要高亮的term:匹配term的位置迭代器
  2. 获取高亮片段的边界:高亮片段分割器
  3. 为高亮片段打分,最后按分数排序:高亮片段打分器
  4. 格式化高亮片段:高亮片段格式化器

最最重要的区别是UnifiedHighlighter提供了多种寻找高亮term的实现,这也是UnifiedHighlighter中最复杂的地方之一。

匹配term的位置迭代器

从之前分析Highlighter和FastVectorHighlighter的文章中,我们知道,Highlighter寻找匹配term位置是通过分词器,这种寻找方式对于大文档效率比较低,而FastVectorHighlighter通过TermVector提高了查找匹配位置的效率,但是它必须在构建索引的时候打开构建TermVector的配置开关。

今天要介绍的UnifiedHighlighter不仅支持Highlighter和FastVectorHighlighter的匹配查找方式,又新增了从倒排数据查找匹配term位置的方式,还有一种是直接构建完整的内存索引。接下来我们看看UnifiedHighlighter中查找term匹配位置的实现。

OffsetsEnum

OffsetsEnum是一个匹配位置offset的迭代器,通过这个迭代器就可以获取需要高亮的位置进行高亮处理。

它实现了Comparable接口,定义的比较策略是:

  public int compareTo(OffsetsEnum other) {
    try {
      // 1.比较的是第一个term的起始位置  
      int cmp = Integer.compare(startOffset(), other.startOffset());
      if (cmp != 0) {
        return cmp; // vast majority of the time we return here.
      }
      // 2.比较第一个term的结束位置  
      cmp = Integer.compare(endOffset(), other.endOffset());
      if (cmp != 0) {
        return cmp;
      }
      // 3.比较term内容本身  
      final BytesRef thisTerm = this.getTerm();
      final BytesRef otherTerm = other.getTerm();
      if (thisTerm == null || otherTerm == null) {
        if (thisTerm == null && otherTerm == null) {
          return 0;
        } else if (thisTerm == null) {
          return 1; // put "this" (wildcard mtq enum) last
        } else {
          return -1;
        }
      }
      return thisTerm.compareTo(otherTerm);
    } catch (IOException e) {
      throw new RuntimeException(e);
    }
  }

我们再看下关键接口:

  // 如果存在下一个匹配的position,则返回true
  public abstract boolean nextPosition() throws IOException;

  // term在字段中出现的频率
  public abstract int freq() throws IOException;

  public abstract BytesRef getTerm() throws IOException;

  // 当前position的term的起始offset
  public abstract int startOffset() throws IOException;

  // 当前position的term的结束offset
  public abstract int endOffset() throws IOException;

OffsetsEnum有好几种的实现类,接下来我们分别来看下:

OfPostings

通过倒排获取term的offset信息,这种一般都是term位置无关的query使用:

public static class OfPostings extends OffsetsEnum {
  // term  
  private final BytesRef term;
  // term的倒排信息,真正获取term位置信息都是通过它
  private final PostingsEnum postingsEnum; 
  // term出现的频率  
  private final int freq;
  // 剩余的term出现的位置次数
  private int posCounter = -1;

  public OfPostings(BytesRef term, int freq, PostingsEnum postingsEnum) throws IOException {
    this.term = Objects.requireNonNull(term);
    this.postingsEnum = Objects.requireNonNull(postingsEnum);
    this.freq = freq;
    this.posCounter = this.postingsEnum.freq();
  }

  public OfPostings(BytesRef term, PostingsEnum postingsEnum) throws IOException {
    this(term, postingsEnum.freq(), postingsEnum);
  }

  public PostingsEnum getPostingsEnum() {
    return postingsEnum;
  }

  @Override
  public boolean nextPosition() throws IOException {
    // 如果还有剩余匹配的位置,则定位到下一个匹配的位置  
    if (posCounter > 0) {
      posCounter--;
      postingsEnum.nextPosition();
      return true;
    } else {
      return false;
    }
  }

  @Override
  public BytesRef getTerm() throws IOException {
    return term;
  }

  @Override
  public int startOffset() throws IOException {
    return postingsEnum.startOffset();
  }

  @Override
  public int endOffset() throws IOException {
    return postingsEnum.endOffset();
  }

  @Override
  public int freq() throws IOException {
    return freq;
  }
}
OfMatchesIterator

基于MatchesIterator,适用所有的query,因为Lcuene引擎查询文档是否匹配用的就是它:

public static class OfMatchesIterator extends OffsetsEnum {
  // 获取匹配位置都是基于它  
  private final MatchesIterator matchesIterator;
  private final Supplier<BytesRef> termSupplier;

  public OfMatchesIterator(MatchesIterator matchesIterator, Supplier<BytesRef> termSupplier) {
    this.matchesIterator = matchesIterator;
    this.termSupplier = termSupplier;
  }

  @Override
  public boolean nextPosition() throws IOException {
    return matchesIterator.next();
  }

  @Override
  public int freq() throws IOException {
    return 1; // 基于 MatchesIterator 无法获取term频率,全部返回1
  }

  @Override
  public BytesRef getTerm() throws IOException {
    return termSupplier.get();
  }

  @Override
  public int startOffset() throws IOException {
    return matchesIterator.startOffset();
  }

  @Override
  public int endOffset() throws IOException {
    return matchesIterator.endOffset();
  }
}
OfMatchesIteratorWithSubs

类似OfMatchesIterator,如果存在子匹配器的话,类似深度优先地获取匹配的位置,比如:

pos: 0 1 2 3 4 5 6 7 
     a b c d a b c d

PhraseQuery(a:0, c:2)
OfMatchesIterator的pos迭代是:0,4
OfMatchesIterator的pos迭代是:0,2,4,6

具体实现:

public static class OfMatchesIteratorWithSubs extends OffsetsEnum {
  // 堆顶就是下一匹配的位置
  private final PriorityQueue<OffsetsEnum> pendingQueue = new PriorityQueue<>();
  private final HashMap<Query, BytesRef> queryToTermMap = new HashMap<>();

  public OfMatchesIteratorWithSubs(MatchesIterator matchesIterator) {
    pendingQueue.add(
        new OfMatchesIterator(matchesIterator, () -> queryToTerm(matchesIterator.getQuery())));
  }

  @Override
  public boolean nextPosition() throws IOException {
    OffsetsEnum formerHeadOE = pendingQueue.poll();
    // 如果前堆顶是子匹配器,那需要把当前堆顶的子匹配入堆
    if (formerHeadOE instanceof CachedOE) {
      OffsetsEnum newHeadOE = pendingQueue.peek();
      if (newHeadOE instanceof OfMatchesIterator) {
        nextWhenMatchesIterator((OfMatchesIterator) newHeadOE);
      } 
    } else { // formerHeadOE is OfMatchesIterator; advance it
      OfMatchesIterator miOE = (OfMatchesIterator) formerHeadOE;
      if (miOE.nextPosition()) {
        nextWhenMatchesIterator(miOE); // requires processing.  May or may not re-enqueue itself
      }
    }
    return pendingQueue.isEmpty() == false;
  }

  private void nextWhenMatchesIterator(OfMatchesIterator miOE) throws IOException {
    boolean isHead = miOE == pendingQueue.peek();
    MatchesIterator subMatches = miOE.matchesIterator.getSubMatches();
    if (subMatches != null) {
      // remove this miOE from the queue, add it's submatches, next() it, then re-enqueue it
      if (isHead) {
        pendingQueue.poll(); // remove
      }

      enqueueCachedMatches(subMatches);

      if (miOE.nextPosition()) {
        pendingQueue.add(miOE);
        assert pendingQueue.peek() != miOE; // miOE should follow cached entries
      }

    } else { // else has no subMatches.  It will stay enqueued.
      if (!isHead) {
        pendingQueue.add(miOE);
      } // else it's *already* in pendingQueue
    }
  }

  private boolean enqueueCachedMatches(MatchesIterator thisMI) throws IOException {
    if (thisMI == null) {
      return false;
    } else {
      while (thisMI.next()) {
        if (false == enqueueCachedMatches(thisMI.getSubMatches())) { // recursion
          // if no sub-matches then add ourselves
          pendingQueue.add(
              new CachedOE(
                  queryToTerm(thisMI.getQuery()), thisMI.startOffset(), thisMI.endOffset()));
        }
      }
      return true;
    }
  }

  /**
   * Maps a Query from {@link MatchesIterator#getQuery()} to {@link OffsetsEnum#getTerm()}. See
   * {@link Passage#getMatchTerms()}.
   */
  private BytesRef queryToTerm(Query query) {
    // compute an approximate BytesRef term of a Query.  We cache this since we're likely to see
    // the same query again.
    // Our approach is to visit each matching term in order, concatenating them with an adjoining
    // space.
    //  If we don't have any (perhaps due to an MTQ like a wildcard) then we fall back on the
    // toString() of the query.
    return queryToTermMap.computeIfAbsent(
        query,
        (Query q) -> {
          BytesRefBuilder bytesRefBuilder = new BytesRefBuilder();
          q.visit(
              new QueryVisitor() {
                @Override
                public void consumeTerms(Query query, Term... terms) {
                  for (Term term : terms) {
                    if (bytesRefBuilder.length() > 0) {
                      bytesRefBuilder.append((byte) ' ');
                    }
                    bytesRefBuilder.append(term.bytes());
                  }
                }
              });
          if (bytesRefBuilder.length() > 0) {
            return bytesRefBuilder.get();
          }
          // fallback:  (likely a MultiTermQuery)
          return new BytesRef(q.toString());
        });
  }

  @Override
  public int freq() throws IOException {
    return pendingQueue.peek().freq();
  }

  @Override
  public BytesRef getTerm() throws IOException {
    return pendingQueue.peek().getTerm();
  }

  @Override
  public int startOffset() throws IOException {
    return pendingQueue.peek().startOffset();
  }

  @Override
  public int endOffset() throws IOException {
    return pendingQueue.peek().endOffset();
  }

  // MatchesIterator的子MatchesIterator,也就是子query的匹配器
  private static class CachedOE extends OffsetsEnum {
    // 多个term是空格拼起来的  
    final BytesRef term;
    final int startOffset;
    final int endOffset;

    private CachedOE(BytesRef term, int startOffset, int endOffset) {
      this.term = term;
      this.startOffset = startOffset;
      this.endOffset = endOffset;
    }

    @Override
    public boolean nextPosition() throws IOException {
      return false;
    }

    @Override
    public int freq() throws IOException {
      return 1; 
    }

    @Override
    public BytesRef getTerm() throws IOException {
      return term;
    }

    @Override
    public int startOffset() throws IOException {
      return startOffset;
    }

    @Override
    public int endOffset() throws IOException {
      return endOffset;
    }
  }
}
MultiOffsetsEnum

合并多个OffsetsEnum,每次迭代获取的是多个OffsetsEnum中position最小的那个位置:

public static class MultiOffsetsEnum extends OffsetsEnum {

  private final PriorityQueue<OffsetsEnum> queue;
  private boolean started = false;

  public MultiOffsetsEnum(List<OffsetsEnum> inner) throws IOException {
    this.queue = new PriorityQueue<>();
    // 通过对所有的  OffsetsEnum 定位到第一个匹配的位置,就可以按堆排序
    for (OffsetsEnum oe : inner) {
      if (oe.nextPosition()) this.queue.add(oe);
    }
  }

  @Override
  public boolean nextPosition() throws IOException {
    if (started == false) {
      started = true;
      return this.queue.size() > 0;
    }
    // 更新下堆顶OffsetsEnum的匹配位置,从而可以更新整个堆,匹配位置最靠前的放在堆顶
    if (this.queue.size() > 0) {
      OffsetsEnum top = this.queue.poll();
      if (top.nextPosition()) {
        this.queue.add(top);
        return true;
      } else {
        top.close();
      }
      return this.queue.size() > 0;
    }
    return false;
  }

  @Override
  public BytesRef getTerm() throws IOException {
    return this.queue.peek().getTerm();
  }

  @Override
  public int startOffset() throws IOException {
    return this.queue.peek().startOffset();
  }

  @Override
  public int endOffset() throws IOException {
    return this.queue.peek().endOffset();
  }

  @Override
  public int freq() throws IOException {
    return this.queue.peek().freq();
  }

  @Override
  public void close() throws IOException {
    IOUtils.close(queue);
  }
}
TokenStreamOffsetsEnum

基于分词器的匹配位置查找,需要注意的是TokenStreamOffsetsEnum只能处理term位置无关的query,因此每得到一个token,简单用自动机判断是否匹配query中的term:

private static class TokenStreamOffsetsEnum extends OffsetsEnum {
  TokenStream stream; 
  // term匹配器
  final CharArrayMatcher[] matchers;
  // 从分词器中获取term  
  final CharTermAttribute charTermAtt;
  // 从分词器中获取offset  
  final OffsetAttribute offsetAtt;
  // 匹配matchers中的哪一个
  int currentMatch = -1;

  // 和  matchers 一一对应,表示是哪个term的自动机
  final BytesRef[] matchDescriptions;

  TokenStreamOffsetsEnum(TokenStream ts, CharArrayMatcher[] matchers) throws IOException {
    this.stream = ts;
    this.matchers = matchers;
    matchDescriptions = new BytesRef[matchers.length];
    charTermAtt = ts.addAttribute(CharTermAttribute.class);
    offsetAtt = ts.addAttribute(OffsetAttribute.class);
    ts.reset();
  }

  @Override
  public boolean nextPosition() throws IOException {
    if (stream != null) {
      while (stream.incrementToken()) {
        for (int i = 0; i < matchers.length; i++) {
          // 找到匹配的term  
          if (matchers[i].match(charTermAtt.buffer(), 0, charTermAtt.length())) {
            currentMatch = i;
            return true;
          }
        }
      }
      stream.end();
      close();
    }

    return false;
  }

  @Override
  public int freq() throws IOException {
    return Integer.MAX_VALUE; 
  }

  @Override
  public int startOffset() throws IOException {
    return offsetAtt.startOffset();
  }

  @Override
  public int endOffset() throws IOException {
    return offsetAtt.endOffset();
  }

  @Override
  public BytesRef getTerm() throws IOException {
    if (matchDescriptions[currentMatch] == null) {
      matchDescriptions[currentMatch] = new BytesRef(matchers[currentMatch].toString());
    }
    return matchDescriptions[currentMatch];
  }

  @Override
  public void close() throws IOException {
    if (stream != null) {
      stream.close();
      stream = null;
    }
  }
}
SpanCollectedOffsetsEnum

关于SpanQuery的专用OffsetsEnum,会通过Spans来构建匹配位置的迭代器:

private static class SpanCollectedOffsetsEnum extends OffsetsEnum {
  private final BytesRef term;
  private final int[] startOffsets;
  private final int[] endOffsets;
  // 总共有几个匹配位置  
  private int numPairs = 0;
  // 当前迭代到哪一个匹配位置  
  private int enumIdx = -1;

  private SpanCollectedOffsetsEnum(BytesRef term, int postingsFreq) {
    this.term = term;
    this.startOffsets = new int[postingsFreq];
    this.endOffsets = new int[postingsFreq];
  }

  // 内部的offset数组要求必须是按startOffset,endOffset顺序的
  void add(int startOffset, int endOffset) {
    assert enumIdx == -1 : "bad state";

    // 从后往前找,当前的offset信息应该插入的位置
    int pairIdx = numPairs - 1;
    for (; pairIdx >= 0; pairIdx--) {
      int iStartOffset = startOffsets[pairIdx];
      int iEndOffset = endOffsets[pairIdx];
      int cmp = Integer.compare(iStartOffset, startOffset);
      if (cmp == 0) {
        cmp = Integer.compare(iEndOffset, endOffset);
      }
      if (cmp == 0) {
        return; 
      } else if (cmp < 0) {
        break; 
      }
    }
    // 把大于当前offset往后移一个位置
    final int shiftLen = numPairs - (pairIdx + 1);
    if (shiftLen > 0) {
      System.arraycopy(startOffsets, pairIdx + 1, startOffsets, pairIdx + 2, shiftLen);
      System.arraycopy(endOffsets, pairIdx + 1, endOffsets, pairIdx + 2, shiftLen);
    }
    // 插入当前的offset
    startOffsets[pairIdx + 1] = startOffset;
    endOffsets[pairIdx + 1] = endOffset;
    numPairs++;
  }

  @Override
  public boolean nextPosition() throws IOException {
    return ++enumIdx < numPairs;
  }

  @Override
  public int freq() throws IOException {
    return numPairs;
  }

  @Override
  public BytesRef getTerm() throws IOException {
    return term;
  }

  @Override
  public int startOffset() throws IOException {
    return startOffsets[enumIdx];
  }

  @Override
  public int endOffset() throws IOException {
    return endOffsets[enumIdx];
  }
}

OffsetEnum的创建策略

以上介绍的OffsetEnum都有创建他们的策略:

FieldOffsetStrategy

FieldOffsetStrategy是所有策略的顶层抽象实现,里面有基本的OffsetEnum创建逻辑,创建逻辑依赖的信息都在UHComponents中:

public class UHComponents {
  // 字段名  
  private final String field;
  // 用来判断是否是需要处理的字段  
  private final Predicate<String> fieldMatcher;
  private final Query query;
  // query中的所有的term
  private final BytesRef[] terms;
  // 前面介绍过了
  private final PhraseHelper phraseHelper;
  // 匹配MultiTermQuery的自动机
  private final LabelledCharArrayMatcher[] automata;
  // 是否有未识别的子query(后面详细解释)
  private final boolean hasUnrecognizedQueryPart;
  // 控制高亮行为的标记,前面已介绍
  private final Set<UnifiedHighlighter.HighlightFlag> highlightFlags;
    
  。。。省略get方法  
}
public abstract class FieldOffsetStrategy {

  protected final UHComponents components;

  public FieldOffsetStrategy(UHComponents components) {
    this.components = components;
  }

  public String getField() {
    return components.getField();
  }

  public abstract UnifiedHighlighter.OffsetSource getOffsetSource();

  // 不同策略具体实现
  public abstract OffsetsEnum getOffsetsEnum(LeafReader reader, int docId, String content)
      throws IOException;

  protected OffsetsEnum createOffsetsEnumFromReader(LeafReader leafReader, int doc)
      throws IOException {
    final Terms termsIndex = leafReader.terms(getField());
    if (termsIndex == null) {
      return OffsetsEnum.EMPTY;
    }

    final List<OffsetsEnum> offsetsEnums = new ArrayList<>();

    // 如果配置了使用最精确的查找方式,则通过MatchesIterator来迭代匹配位置
    if (components.getHighlightFlags().contains(UnifiedHighlighter.HighlightFlag.WEIGHT_MATCHES)) {
      createOffsetsEnumsWeightMatcher(leafReader, doc, offsetsEnums);
    } else { 

      // Handle position insensitive terms (a subset of this.terms field):
      final BytesRef[] insensitiveTerms;
      final PhraseHelper phraseHelper = components.getPhraseHelper();
      final BytesRef[] terms = components.getTerms();
      // 如果存在位置敏感的query,则需要区分位置敏感(PhraseQuery,SpanQuery)和位置不敏感的term(TermQuery)
      if (phraseHelper.hasPositionSensitivity()) {
        insensitiveTerms = phraseHelper.getAllPositionInsensitiveTerms();
      } else {
        insensitiveTerms = terms;
      }

      // 处理term位置不敏感的query  
      if (insensitiveTerms.length > 0) {  
        createOffsetsEnumsForTerms(insensitiveTerms, termsIndex, doc, offsetsEnums);
      }

      // 处理term位置敏感的qeury
      if (phraseHelper.hasPositionSensitivity()) {
        phraseHelper.createOffsetsEnumsForSpans(leafReader, doc, offsetsEnums);
      }

      // 处理MultiTermQuery
      if (components.getAutomata().length > 0) {
        createOffsetsEnumsForAutomata(termsIndex, doc, offsetsEnums);
      }
    }

    // 如果是多个OffsetsEnum,则封装成MultiOffsetsEnum
    switch (offsetsEnums.size()) {
      case 0:
        return OffsetsEnum.EMPTY;
      case 1:
        return offsetsEnums.get(0);
      default:
        return new OffsetsEnum.MultiOffsetsEnum(offsetsEnums);
    }
  }

  // Lucene引擎查找匹配query相关文档的方式,是最精确的匹配查找  
  protected void createOffsetsEnumsWeightMatcher(
      LeafReader _leafReader, int docId, List<OffsetsEnum> results) throws IOException {
    LeafReader leafReader =
        new FilterLeafReader(_leafReader) {
          @Override
          public Terms terms(String field) throws IOException {
            if (components.getFieldMatcher().test(field)) {
              return super.terms(components.getField());
            } else {
              return super.terms(field);
            }
          }

          @Override
          public CacheHelper getCoreCacheHelper() {
            return null;
          }

          @Override
          public CacheHelper getReaderCacheHelper() {
            return null;
          }
        };
    IndexSearcher indexSearcher = new IndexSearcher(leafReader);
    indexSearcher.setQueryCache(null);
    // 获取Matches  
    Matches matches =
        indexSearcher
            .rewrite(components.getQuery())
            .createWeight(indexSearcher, ScoreMode.COMPLETE_NO_SCORES, 1.0f)
            .matches(leafReader.getContext(), docId);
    if (matches == null) {
      return; 
    }
    for (String field : matches) {
      if (components.getFieldMatcher().test(field)) {
        // 通过Matches获取字段的MatchesIterator
        MatchesIterator iterator = matches.getMatches(field);
        if (iterator == null) {
          continue;
        }
        // 创建OfMatchesIteratorWithSubs类型的OffsetsEnum
        results.add(new OffsetsEnum.OfMatchesIteratorWithSubs(iterator));
      }
    }
  }

  // 对于term位置不敏感的query,使用倒排查找匹配位置 
  protected void createOffsetsEnumsForTerms(
      BytesRef[] sourceTerms, Terms termsIndex, int doc, List<OffsetsEnum> results)
      throws IOException {
    TermsEnum termsEnum = termsIndex.iterator(); // does not return null
    for (BytesRef term : sourceTerms) {
      if (termsEnum.seekExact(term)) {
        PostingsEnum postingsEnum = termsEnum.postings(null, PostingsEnum.OFFSETS);
        if (postingsEnum == null) {
          // no offsets or positions available
          throw new IllegalArgumentException(
              "field '" + getField() + "' was indexed without offsets, cannot highlight");
        }
        if (doc == postingsEnum.advance(doc)) { // now it's positioned, although may be exhausted
          results.add(new OffsetsEnum.OfPostings(term, postingsEnum));
        }
      }
    }
  }

  // 对于MultiTermQuery,使用自动机查找匹配的term,对于每个匹配的term获取倒排构建OfPostings类型的OffsetsEnum
  protected void createOffsetsEnumsForAutomata(Terms termsIndex, int doc, List<OffsetsEnum> results)
      throws IOException {
    final LabelledCharArrayMatcher[] automata = components.getAutomata();
    List<List<PostingsEnum>> automataPostings = new ArrayList<>(automata.length);
    for (int i = 0; i < automata.length; i++) {
      automataPostings.add(new ArrayList<>());
    }

    TermsEnum termsEnum = termsIndex.iterator();
    BytesRef term;

    CharsRefBuilder refBuilder = new CharsRefBuilder();
    while ((term = termsEnum.next()) != null) {
      for (int i = 0; i < automata.length; i++) {
        CharArrayMatcher automaton = automata[i];
        refBuilder.copyUTF8Bytes(term);
        // 使用自动机判断匹配的term  
        if (automaton.match(refBuilder.get())) {
          // 获取匹配term的倒排  
          PostingsEnum postings = termsEnum.postings(null, PostingsEnum.OFFSETS);
          if (doc == postings.advance(doc)) {
            automataPostings.get(i).add(postings);
          }
        }
      }
    }

    // 为每个term创建OfPostings类型的OffsetsEnum
    for (int i = 0; i < automata.length; i++) {
      LabelledCharArrayMatcher automaton = automata[i];
      List<PostingsEnum> postingsEnums = automataPostings.get(i);
      if (postingsEnums.isEmpty()) {
        continue;
      }
      // Build one OffsetsEnum exposing the automaton label as the term, and the sum of freq
      BytesRef wildcardTerm = new BytesRef(automaton.getLabel());
      int sumFreq = 0;
      for (PostingsEnum postingsEnum : postingsEnums) {
        sumFreq += postingsEnum.freq();
      }
      for (PostingsEnum postingsEnum : postingsEnums) {
        results.add(new OffsetsEnum.OfPostings(wildcardTerm, sumFreq, postingsEnum));
      }
    }
  }
}

具体的实现类:

  • NoOpOffsetStrategy

    创建没有任何匹配结果的OffsetsEnum。

  • TermVectorOffsetStrategy

  • PostingsOffsetStrategy

  • PostingsWithTermVectorsOffsetStrategy

  • AnalysisOffsetStrategy

    基于分词器获取offset,有两种实现:

    • MemoryIndexOffsetStrategy

      基于分词器构建内存级索引,可以查找任何类型的query的匹配位置。是需要比较精确查找匹配的情况使用。

    • TokenStreamOffsetStrategy

      对于term位置不敏感的,可以使用这种策略。这种策略不考虑term的位置,所有的query类型只考虑term的匹配位置。

这几种策略的基本优先级是(UnifiedHighlighter会根据特殊情况进行调整,逻辑难懂):

  1. 如果有倒排,并且有词向量,则使用PostingsWithTermVectorsOffsetStrategy
  2. 如果有倒排,如果没有词向量,则使用PostingsOffsetStrategy
  3. 如果没有倒排,有词向量,则使用TermVectorOffsetStrategy
  4. 其他情况都使用AnalysisOffsetStrategy
// UnifiedHighlighter中的判断逻辑
protected OffsetSource getOffsetSource(String field) {
  FieldInfo fieldInfo = getFieldInfo(field);
  if (fieldInfo != null) {
    if (fieldInfo.getIndexOptions() == IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS) {
      return fieldInfo.hasVectors()
          ? OffsetSource.POSTINGS_WITH_TERM_VECTORS
          : OffsetSource.POSTINGS;
    }
    if (fieldInfo.hasVectors()) {
      return OffsetSource.TERM_VECTORS;
    }
  }
  return OffsetSource.ANALYSIS;
}

// 对上面方法的判断做了一些调整,逻辑非常难理解:
protected OffsetSource getOptimizedOffsetSource(UHComponents components) {
    OffsetSource offsetSource = getOffsetSource(components.getField());

    // 是否存在MultiTermQuery或者是有需要改写的子query
    boolean mtqOrRewrite =
        components.getAutomata() == null
        || components.getAutomata().length > 0
        || components.getPhraseHelper().willRewrite()
        || components.hasUnrecognizedQueryPart();

    // 没有term用来高亮
    if (mtqOrRewrite == false
        && components.getTerms() != null
        && components.getTerms().length == 0) {
        return OffsetSource.NONE_NEEDED; 
    }

    switch (offsetSource) {
        case POSTINGS:
            if (mtqOrRewrite) {
                return OffsetSource.ANALYSIS;
            }
            break;
        case POSTINGS_WITH_TERM_VECTORS:
            if (mtqOrRewrite == false) {
                // 不存在MultiTermQuery就不需要词向量
                return OffsetSource.POSTINGS; 
            }
            break;
        case ANALYSIS:
        case TERM_VECTORS:
        case NONE_NEEDED:
        default:
            break;
    }

    return offsetSource;
}

这边策略的选用中,关于Posting和TermVector并没有按照我们总结的适用场景来选取,这边是否可以做个优化??

高亮片段分割器

在FastVectorHighlighter中我们介绍了BreakIterator,UnifiedHighlighter的高亮片段分割器就是BreakIterator。

在UnifiedHighlighter中为了支持Document中的多个同名字段的文本,扩展了BreakIterator的实现,叫SplittingBreakIterator。SplittingBreakIterator的实现思路很简单,内部封装了一个BreakIterator,真正寻找高亮分片的分界还是基于BreakIterator,但是BreakIterator处理的是根据指定的分割符分割的子文本片段。

我们看下SplittingBreakIterator的成员变量:

  // 真正寻找分界的BreakIterator
  private final BreakIterator baseIter;
  // 子文本的分割字符
  private final char sliceChar;

  // 待处理的文本
  private String text;
  // 根据sliceChar分割的子文本的起始位置和终点位置
  private int sliceStartIdx;
  private int sliceEndIdx;
  // 当前分界点的位置
  private int current;

SplittingBreakIterator中方法就不详细讨论了,逻辑比较简单,可以自行翻阅源码。这里举个例子:

Document document = new Document();
document.add(new Field("field0", "Lucene is a high-performance search engine library.", fieldType));
document.add(new Field("field0", "Lucene is a search engine library.", fieldType));

在寻找field0的分割点就会把 "Lucene is a high-performance search engine library." + sliceChar + "Lucene is a search engine library."传给SplittingBreakIterator处理,SplittingBreakIterator内部的baseIter按sliceChar分割,依次处理"Lucene is a high-performance search engine library."和"Lucene is a search engine library."。

高亮片段打分器

UnifiedHighlighter中只有一个高亮片段的打分器PassageScorer,它把文本按照pivot长度分割成子文档来计算BM25得分。大家可以自行查看BM25算法,对照公式看源码即可,需要注意的是PassageScorer有额外的归一化操作。

高亮片段格式化器

格式化器主要用来为匹配的term增加高亮标签。UnifiedHighlighter中的格式化器底层接口是个抽象类:

public abstract class PassageFormatter {

  // passages: top-maxPassages
  // content:文本  
  public abstract Object format(Passage[] passages, String content);
}

在UnifiedHighlighter唯一实现类是DefaultPassageFormatter,DefaultPassageFormatter实现的结果是一个String,相当于Lucene直接帮我们使用高亮的片段生成了一个摘要。还记得我们示例中的问题吗,就是DefaultPassageFormatter中的逻辑造成的,我们看下DefaultPassageFormatter:

public class DefaultPassageFormatter extends PassageFormatter {
  // 高亮term的前置标签
  protected final String preTag;
  // 高亮term的后置标签
  protected final String postTag;
  // 不同的高亮片段之间用ellipsis隔开
  protected final String ellipsis;
  // 是否对html转义
  protected final boolean escape;

  // 默认是使用加组高亮,用"... "分隔不同的高亮片段
  public DefaultPassageFormatter() {
    this("<b>", "</b>", "... ", false);
  }

  public DefaultPassageFormatter(String preTag, String postTag, String ellipsis, boolean escape) {
    if (preTag == null || postTag == null || ellipsis == null) {
      throw new NullPointerException();
    }
    this.preTag = preTag;
    this.postTag = postTag;
    this.ellipsis = ellipsis;
    this.escape = escape;
  }

  @Override
  public String format(Passage[] passages, String content) {
    StringBuilder sb = new StringBuilder();
    int pos = 0;
    for (Passage passage : passages) {
      // 除了第一个高亮片段之外,其他高亮片段之前都拼接ellipsis
      if (passage.getStartOffset() > pos && pos > 0) {
        sb.append(ellipsis);
      }
      pos = passage.getStartOffset();
      for (int i = 0; i < passage.getNumMatches(); i++) {
        int start = passage.getMatchStarts()[i];
        // 拼接高亮之前的部分
        append(sb, content, pos, start);

        int end = passage.getMatchEnds()[i];

        // 处理重叠的部分
        while (i + 1 < passage.getNumMatches() && passage.getMatchStarts()[i + 1] < end) {
          // 示例问题的原因
          // 我觉得应该改成: end = Math.max(end, passage.getMatchEnds()[++i]);
          end = passage.getMatchEnds()[++i];
        }
        // 不能超过高亮片段的结束位置  
        end = Math.min(end, passage.getEndOffset()); 

        sb.append(preTag);
        // 对start和end范围全部高亮。
        // 如果是PhraseQuery等位置敏感的query则会高亮中间不匹配的term。
        append(sb, content, start, end);
        sb.append(postTag);

        pos = end;
      }
      // 处理剩下不用高亮的部分
      append(sb, content, pos, Math.max(pos, passage.getEndOffset()));
      pos = passage.getEndOffset();
    }
    return sb.toString();
  }

  // 转义处理
  protected void append(StringBuilder dest, String content, int start, int end) {
    if (escape) {
      for (int i = start; i < end; i++) {
        char ch = content.charAt(i);
        switch (ch) {
          case '&':
            dest.append("&amp;");
            break;
          case '<':
            dest.append("&lt;");
            break;
          case '>':
            dest.append("&gt;");
            break;
          case '"':
            dest.append("&quot;");
            break;
          case '\'':
            dest.append("&#x27;");
            break;
          case '/':
            dest.append("&#x2F;");
            break;
          default:
            dest.append(ch);
        }
      }
    } else {
      dest.append(content, start, end);
    }
  }
}

从上面看到DefaultPassageFormatter的实现逻辑比较简单,需要注意的是我们示例中看到的bug就是这个格式化器产生。之所以我们没看到有什么issue提这个是bug,我觉得应该Lucene官方认为这只是他们提供的一种简单的格式化策略,如果需要自己完全可以自定义格式化器处理这个问题。不过你有兴趣也可以提个issue,看看有没有机会参与开源MR提交。

另外,对于该问题还有一种解决方式:

UnifiedHighlighter highlighterWithoutSearcher = UnifiedHighlighter
    .builder(null, analyzer)
    // 关闭使用Weight.matches(LeafReaderContext, int)接口来匹配PhraseQuery,
    // 这个接口就是引擎检索查询匹配的接口,是精确匹配的。它返回的匹配信息是一个片段范围。
    // 关闭之后,查找到的匹配的PhraseQuery会当成单独的term处理,
    // 输出为:Lucene is a <b>search</b> <b>engine</b> <b>library</b>.
    .withWeightMatches(false)  
    .build();

UnifiedHighlighter类

内部类

LimitedStoredFieldVisitor

UnifiedHighlighter中提供了按照docId获取摘要的接口,就需要根据docId获取字段的文本内容:

protected static class LimitedStoredFieldVisitor extends StoredFieldVisitor {
  // 需要处理的字段  
  protected final String[] fields;
  // 相同字段名的不同文本的分隔符  
  protected final char valueSeparator;
  // 每个字段最大的文本长度  
  protected final int maxLength;
  // 每个字段一个CharSequence
  protected CharSequence[] values; 
  // 当前处理的字段在fields中的下标
  protected int currentField;

  public LimitedStoredFieldVisitor(String[] fields, char valueSeparator, int maxLength) {
    this.fields = fields;
    this.valueSeparator = valueSeparator;
    this.maxLength = maxLength;
  }

  void init() {
    values = new CharSequence[fields.length];
    // currentField的更新在needsField方法中
    currentField = -1;
  }

  @Override
  public void stringField(FieldInfo fieldInfo, String value) throws IOException {
    CharSequence curValue = values[currentField];
    // 第一次遇到该字段,创建一个有大小限制的buffer,复制字段的文本
    if (curValue == null) {
      values[currentField] =
          value.substring(0, Math.min(maxLength, value.length())); 
      return;
    }
    // 还能添加的文本大小  
    final int lengthBudget = maxLength - curValue.length();
    if (lengthBudget <= 0) {
      return;
    }
    StringBuilder curValueBuilder;
    if (curValue instanceof StringBuilder) {
      curValueBuilder = (StringBuilder) curValue;
    } else {
      // 扩容当前的buffer  
      curValueBuilder =
          new StringBuilder(curValue.length() + Math.min(lengthBudget, value.length() + 256));
      // 复制已有的文本  
      curValueBuilder.append(curValue);
    }
    // 相同字段不同文本的分隔符  
    curValueBuilder.append(valueSeparator);
    // 复制当前字段的文本  
    curValueBuilder.append(value.substring(0, Math.min(lengthBudget - 1, value.length())));
    values[currentField] = curValueBuilder;
  }

  @Override
  public Status needsField(FieldInfo fieldInfo) throws IOException {
    currentField = Arrays.binarySearch(fields, fieldInfo.name);
    // 不是我们需要的字段  
    if (currentField < 0) {
      return Status.NO;
    }
    CharSequence curVal = values[currentField];
    // 如果只有一个字段需要处理,并且该字段的文本已经超过的最大长度则停止处理  
    if (curVal != null && curVal.length() >= maxLength) {
      return fields.length == 1 ? Status.STOP : Status.NO;
    }
    return Status.YES;
  }

  CharSequence[] getValuesByField() {
    return this.values;
  }
}
UnifiedHighlighter构建器

Lucene 9.1.0刚改成构建器模式创建UnifiedHighlighter,并且使得UnifiedHighlighter是个不可变类。我们通过构建器成员变量就能知道UnifiedHighlighter中有哪些关键的变量:

  // searcher为null,只能使用highlightWithoutSearcher接口获取高亮片段。
  // 不为null,只能使用 highlightFields 和 highlight 接口,这两个接口都有几个重载方法,都能用。
  private final IndexSearcher searcher;
  
  // 使用OffsetSource.ANALYSIS的时候会用来分词获取offset
  private final Analyzer indexAnalyzer;
  // 限定只用处理的字段  
  private Predicate<String> fieldMatcher;
  // 控制高亮的具体表现行为  
  private Set<HighlightFlag> flags;
  // 是否需要处理MultiTermQuery  
  private boolean handleMultiTermQuery = DEFAULT_ENABLE_MULTI_TERM_QUERY;
  // 是否要严格按照位置要求处理term位置敏感的query
  private boolean highlightPhrasesStrictly = DEFAULT_ENABLE_HIGHLIGHT_PHRASES_STRICTLY;
  // 决定是用内存索引还是分词器获取term的offset  
  private boolean passageRelevancyOverSpeed = DEFAULT_ENABLE_RELEVANCY_OVER_SPEED;
  //是否使用Matches方式获取匹配的位置   
  private boolean weightMatches = DEFAULT_ENABLE_WEIGHT_MATCHES;
  // 每个字段最多处理的文本长度  
  private int maxLength = DEFAULT_MAX_LENGTH;

  // 高亮分片器
  private Supplier<BreakIterator> breakIterator = DEFAULT_BREAK_ITERATOR;
  // 高亮打分器
  private PassageScorer scorer = DEFAULT_PASSAGE_SCORER;
  // 高亮格式化器  
  private PassageFormatter formatter = DEFAULT_PASSAGE_FORMATTER;
  // 如果没有匹配的高亮片段,最多获取几个非高亮片段作为摘要  
  private int maxNoHighlightPassages = DEFAULT_MAX_HIGHLIGHT_PASSAGES;
  // 一次性处理的文本长度,用来分批处理,避免内存oom  
  private int cacheFieldValCharsThreshold = DEFAULT_CACHE_CHARS_THRESHOLD;

高亮接口

UnifiedHighlighter中对提供的获取高亮的接口可以分成3条调用链:

UnfieldHighlighter调用链.png

我们可以看到最底层都是使用FieldHighlighter(最后单独介绍)来获取高亮。UnifiedHighlighter#highlightFieldsAsObjects和UnifiedHighlighter#highlightWithoutSearcher中主要就是创建FieldHighlighter。接下来我们分别来看下这些调用链。

第一条调用链

UnifiedHighlighter#highlightWithoutSearcher

使用这个接口,必须是searcher不存在。这是因为searcher存在,可以根据docId获取字段文本内容,而通过searcher来获取文本内容就可以直接使用构建索引的时候的TermVector或者是Posting来获取高亮term的位置信息,没有searcher,只能通过直接传文本,而客户端传的文本只能通过分词器处理获取高亮term的位置信息,大文档效率比较低。

  // field:要处理的字段名称
  // query:查询query
  // content: 要高亮的文本
  // maxPassages:最多获取几个高亮片段,这边需要注意下,Lucene默认实现是把所有的高亮片段连起来作为摘要(看格式化器)
  public Object highlightWithoutSearcher(String field, Query query, String content, int maxPassages)
      throws IOException {
    // 使用这个接口,必须是searcher不存在。 
    if (this.searcher != null) {
      throw new IllegalStateException(
          "highlightWithoutSearcher should only be called on a "
              + getClass().getSimpleName()
              + " without an IndexSearcher.");
    }
    // 获取query中的所有的term
    Set<Term> queryTerms = extractTerms(query);
    // 获取 FieldHighlighter,通过FieldHighlighter来获取高亮
    return getFieldHighlighter(field, query, queryTerms, maxPassages)
        .highlightFieldForDoc(null, -1, content);
  }
创建FieldHighlighter
protected FieldHighlighter getFieldHighlighter(
    String field, Query query, Set<Term> allTerms, int maxPassages) {  
  UHComponents components = getHighlightComponents(field, query, allTerms);
  // 重要逻辑,获取匹配Offset的来源,决定了使用哪种OffsetEnum的创建策略
  OffsetSource offsetSource = getOptimizedOffsetSource(components);
  return new FieldHighlighter(
      field,
      // 重要逻辑,获取OffsetEnum的创建策略
      getOffsetStrategy(offsetSource, components),
      new SplittingBreakIterator(getBreakIterator(field), UnifiedHighlighter.MULTIVAL_SEP_CHAR),
      // 默认所有字段的打分器都是PassageScorer
      getScorer(field),
      maxPassages,
      getMaxNoHighlightPassages(field),
      // 默认所有字段的高亮格式化器都是DefaultPassageFormatter
      getFormatter(field));
}
创建UHCompnnents

UHComponents只是一个封装了创建FieldHighlighter所必要的信息的对象:

protected UHComponents getHighlightComponents(String field, Query query, Set<Term> allTerms) {
  // 用来判断是否使我们要进行高亮处理的字段  
  Predicate<String> fieldMatcher = getFieldMatcher(field);
  // 获取创建UnifiedHighlighter时候设置的HighlighterFlag集合  
  Set<HighlightFlag> highlightFlags = getFlags(field);
  PhraseHelper phraseHelper = getPhraseHelper(field, query, highlightFlags);
  // 一般都是false  
  boolean queryHasUnrecognizedPart = hasUnrecognizedQuery(fieldMatcher, query);
  BytesRef[] terms = null;
  LabelledCharArrayMatcher[] automata = null;
  // 如果没有使用matches寻找匹配,则需要获取terms集合
  // 如果不存在无法识别的query,则需要获取terms集合
  if (!highlightFlags.contains(HighlightFlag.WEIGHT_MATCHES) || !queryHasUnrecognizedPart) {
    // 过滤满足fieldMatcher字段的term
    terms = filterExtractedTerms(fieldMatcher, allTerms);
    automata = getAutomata(field, query, highlightFlags);
  } 
  return new UHComponents(
      field,
      fieldMatcher,
      query,
      terms,
      phraseHelper,
      automata,
      queryHasUnrecognizedPart,
      highlightFlags);
}
创建PhraseHelper
protected PhraseHelper getPhraseHelper(
    String field, Query query, Set<HighlightFlag> highlightFlags) {
  boolean useWeightMatchesIter = highlightFlags.contains(HighlightFlag.WEIGHT_MATCHES);
  // 如果配置了使用Matchers的方式寻找匹配位置,
  // 就不需要单独在创建PhraseHelper来处理term位置敏感的query
  if (useWeightMatchesIter) {
    return PhraseHelper.NONE; 
  }
  // 是否需要按照位置要求严格处理term位置敏感的query
  boolean highlightPhrasesStrictly = highlightFlags.contains(HighlightFlag.PHRASES);
  // 是否需要处理MultiTermQuery
  boolean handleMultiTermQuery = highlightFlags.contains(HighlightFlag.MULTI_TERM_QUERY);
  return highlightPhrasesStrictly
      ? new PhraseHelper(
          query,
          field,
          getFieldMatcher(field),
          this::requiresRewrite,
          this::preSpanQueryRewrite,
          !handleMultiTermQuery)
      : PhraseHelper.NONE;
}
判断是否存在未识别的子query
protected boolean hasUnrecognizedQuery(Predicate<String> fieldMatcher, Query query) {
  boolean[] hasUnknownLeaf = new boolean[1];
  query.visit(
      new QueryVisitor() {
        @Override
        public boolean acceptField(String field) {
          return hasUnknownLeaf[0] == false && fieldMatcher.test(field);
        }

        @Override
        // query参数一般就是this,也就是调用此方法的query  
        public void visitLeaf(Query query) {
          if (MultiTermHighlighting.canExtractAutomataFromLeafQuery(query) == false) {
            if (!(query instanceof MatchAllDocsQuery || query instanceof MatchNoDocsQuery)) {
              // visitLeaf方法不是所有的query都会调用,可以在ide里面查看这个方法被调用的地方。
              // 所以,如果query不是MultiTermQuery,MatchAllDocsQuery,MatchNoDocsQuery,
              // 并且会调用这个方法的query都是无法识别的query
              hasUnknownLeaf[0] = true;
            }
          }
        }
      });
  return hasUnknownLeaf[0];
}
第二条调用链和第三条调用链

这两条调用链中间的方法都是UnifiedHighlighter对外提供的接口,逻辑比较简单,做一些参数转化最终处理逻辑都在highlightFieldsAsObjects方法中,这里我们详细分析下highlightFieldsAsObjects方法。

  protected Map<String, Object[]> highlightFieldsAsObjects(
      String[] fieldsIn, Query query, int[] docIdsIn, int[] maxPassagesIn) throws IOException {
    。。。(省略一些校验逻辑)

    
    int[] docIds = new int[docIdsIn.length];
    // 因为返回的结果需要和docIdsIn中的文档编号一一对应,所以要记录排序之前的位置
    int[] docInIndexes = new int[docIds.length]; 
    // 对文档编号排序,可以使用顺序IO
    copyAndSortDocIdsWithIndex(docIdsIn, docIds, docInIndexes); 

    final String[] fields = new String[fieldsIn.length];
    final int[] maxPassages = new int[maxPassagesIn.length];
    copyAndSortFieldsWithMaxPassages(
        fieldsIn, maxPassagesIn, fields, maxPassages); // latter 2 are "out" params

    // 获取query的所有term
    Set<Term> queryTerms = extractTerms(query);
    FieldHighlighter[] fieldHighlighters = new FieldHighlighter[fields.length];
    int numTermVectors = 0;
    int numPostings = 0;
    for (int f = 0; f < fields.length; f++) {
      // 在第一条调用链中详细解析过了  
      FieldHighlighter fieldHighlighter =
          getFieldHighlighter(fields[f], query, queryTerms, maxPassages[f]);
      fieldHighlighters[f] = fieldHighlighter;

      switch (fieldHighlighter.getOffsetSource()) {
        case TERM_VECTORS:
          numTermVectors++;
          break;
        case POSTINGS:
          numPostings++;
          break;
        case POSTINGS_WITH_TERM_VECTORS:
          numTermVectors++;
          numPostings++;
          break;
        case ANALYSIS:
        case NONE_NEEDED:
        default:
          // do nothing
          break;
      }
    }

    // 限制每次批量处理的数据量,如果要处理的文档很多很长,可以避免oom  
    int cacheCharsThreshold = calculateOptimalCacheCharsThreshold(numTermVectors, numPostings);
    
    // TermVectorReusingLeafReader 
    IndexReader indexReaderWithTermVecCache =
        (numTermVectors >= 2) ? TermVectorReusingLeafReader.wrap(searcher.getIndexReader()) : null;

    // 每个字段在每个文档中的高亮结果
    Object[][] highlightDocsInByField = new Object[fields.length][docIds.length];
    // 把需要处理的文档id封装成迭代器
    DocIdSetIterator docIdIter = asDocIdSetIterator(docIds);
    for (int batchDocIdx = 0; batchDocIdx < docIds.length; ) {
      // 按docIdIter中的文档顺序,最多加载cacheCharsThreshold长度的字段数据
      List<CharSequence[]> fieldValsByDoc = loadFieldValues(fields, docIdIter, cacheCharsThreshold);
      // 
      for (int fieldIdx = 0; fieldIdx < fields.length; fieldIdx++) {
        Object[] resultByDocIn = highlightDocsInByField[fieldIdx]; // parallel to docIdsIn
        FieldHighlighter fieldHighlighter = fieldHighlighters[fieldIdx];
        for (int docIdx = batchDocIdx; docIdx - batchDocIdx < fieldValsByDoc.size(); docIdx++) {
          int docId = docIds[docIdx]; // sorted order
          CharSequence content = fieldValsByDoc.get(docIdx - batchDocIdx)[fieldIdx];
          if (content == null) {
            continue;
          }
          IndexReader indexReader =
              (fieldHighlighter.getOffsetSource() == OffsetSource.TERM_VECTORS
                      && indexReaderWithTermVecCache != null)
                  ? indexReaderWithTermVecCache
                  : searcher.getIndexReader();
          final LeafReader leafReader;
          if (indexReader instanceof LeafReader) {
            leafReader = (LeafReader) indexReader;
          } else {
            // docId是全局的,在每个segment中需要重新算法偏移量所谓segment中的docId  
            List<LeafReaderContext> leaves = indexReader.leaves();
            LeafReaderContext leafReaderContext = leaves.get(ReaderUtil.subIndex(docId, leaves));
            leafReader = leafReaderContext.reader();
            docId -= leafReaderContext.docBase; 
          }
          // 输入的docId的位置,高亮的结果需要正确对应起来
          int docInIndex = docInIndexes[docIdx];
          resultByDocIn[docInIndex] =
              fieldHighlighter.highlightFieldForDoc(leafReader, docId, content.toString());
        }
      }
      // 更新迭代的文档号
      batchDocIdx += fieldValsByDoc.size();
    }
    IOUtils.close(indexReaderWithTermVecCache); 
    // 以字段名为key,字段在每个文档中的高亮结果数组为值作为返回值
    Map<String, Object[]> resultMap = new HashMap<>(fields.length);
    for (int f = 0; f < fields.length; f++) {
      resultMap.put(fields[f], highlightDocsInByField[f]);
    }
    return resultMap;
  }

FieldHighlighter

真正寻找高亮片段组成摘要的逻辑都在FieldHighlighter中。

先看下FieldHighlighter的成员变量:

// 当前高亮处理的字段
protected final String field;
// 匹配迭代器的创建策略
protected final FieldOffsetStrategy fieldOffsetStrategy;
// 高亮片段分割器
protected final BreakIterator breakIterator; 
// 高亮片段打分器
protected final PassageScorer passageScorer;
// 最多获取几个高亮片段
protected final int maxPassages;
// 如果没有term匹配的片段,最多获取几个没有term匹配片段
protected final int maxNoHighlightPassages;
// 高亮片段格式化器
protected final PassageFormatter passageFormatter;

摘要获取逻辑入口,先通过OffsetsEnum的创建策略创建OffsetsEnum,通过OffsetsEnum获取高亮片段,如果没有获取到高亮片段(不存在匹配关键字),则获取文本的句子作为高亮片段,最后格式化成摘要:

  public Object highlightFieldForDoc(LeafReader reader, int docId, String content)
      throws IOException {
    if (content.length() == 0) {
      return null; 
    }

    breakIterator.setText(content);

    // 通过指定的匹配位置迭代器的策略获取匹配位置的迭代器  
    try (OffsetsEnum offsetsEnums = fieldOffsetStrategy.getOffsetsEnum(reader, docId, content)) {

      // 通过匹配位置的迭代器获取每个高亮片段
      Passage[] passages = highlightOffsetsEnums(offsetsEnums); 

      if (passages.length == 0) {
        // 如果没有匹配term的高亮片段,则获取前maxNoHighlightPassages句子作为摘要
        passages =
            getSummaryPassagesNoHighlight(
                maxNoHighlightPassages == -1 ? maxPassages : maxNoHighlightPassages);
      }

      if (passages.length > 0) {
        // 格式化高亮片段  
        return passageFormatter.format(passages, content);
      } else {
        return null;
      }
    }
  }

获取高亮片段,通过OffsetsEnum迭代获取匹配的位置,组装成Passage(一个高亮片段),整体流程如下图所示:

FieldHighliter.png

 // 获取高亮片段核心逻辑
  protected Passage[] highlightOffsetsEnums(OffsetsEnum off) throws IOException {
    final int contentLength = this.breakIterator.getText().getEndIndex();

    // 定位到第一匹配的位置,如果没有找到匹配的位置返回空数组
    if (off.nextPosition() == false) {
      return new Passage[0];
    }

    // 高亮片段的排序规则:先分数,再startOffset  
    PriorityQueue<Passage> passageQueue =
        new PriorityQueue<>(
            Math.min(64, maxPassages + 1),
            (left, right) -> {
              if (left.getScore() < right.getScore()) {
                return -1;
              } else if (left.getScore() > right.getScore()) {
                return 1;
              } else {
                return left.getStartOffset() - right.getStartOffset();
              }
            });
    // 当前处理中的高亮片段  
    Passage passage = new Passage(); 
    // 上一个高亮片段结束的位置  
    int lastPassageEnd = 0;

    do {
      // 当前匹配位置的startOffset  
      int start = off.startOffset();
      if (start == -1) {
        throw new IllegalArgumentException(
            "field '" + field + "' was indexed without offsets, cannot highlight");
      }
      // 当前匹配位置的endOffset   
      int end = off.endOffset();
      // 如果匹配位置有部分超出了处理文本的长度,则忽略这个匹配位置
      if (start < contentLength && end > contentLength) {
        continue;
      }
      // 如果当前的匹配位置属于新的高亮片段
      if (start >= passage.getEndOffset()) {
        // 把当前的高亮片段保存起来,返回一个可复用的passage
        passage = maybeAddPassage(passageQueue, passageScorer, passage, contentLength);
        // 如果超出了处理文本的大小,停止处理
        if (start >= contentLength) {
          break;
        }
        // 获取匹配位置的中心
        final int center = start + (end - start) / 2;
        // 根据第一个匹配的位置寻找高亮分片的边界
        passage.setStartOffset(
            Math.min(
                start,
                Math.max(
                    this.breakIterator.preceding(Math.max(start + 1, center)), lastPassageEnd)));
        lastPassageEnd =
            Math.max(
                end,
                Math.min(this.breakIterator.following(Math.min(end - 1, center)), contentLength));
        passage.setEndOffset(lastPassageEnd);
      }
      // 当前高亮片段新增匹配的term信息
      BytesRef term = off.getTerm(); 
      passage.addMatch(start, end, term, off.freq());
    } while (off.nextPosition());
    // 处理最后一个高亮片段  
    maybeAddPassage(passageQueue, passageScorer, passage, contentLength);

    Passage[] passages = passageQueue.toArray(new Passage[passageQueue.size()]);
    // 最终的高亮片段是按startOffset排序
    Arrays.sort(passages, Comparator.comparingInt(Passage::getStartOffset));
    return passages;
  }

  private Passage maybeAddPassage(
      PriorityQueue<Passage> passageQueue,
      PassageScorer scorer,
      Passage passage,
      int contentLength) {
    if (passage.getStartOffset() == -1) {
      // 空的高亮片段,直接返回
      return passage;
    }
    // 计算高亮的得分  
    passage.setScore(scorer.score(passage, contentLength));
    // 如果已经获取到了指定的高亮片段,并且当前处理的高亮片段的得分比所有的已有的高亮片段得分低,
    // 则重置passage返回复用
    if (passageQueue.size() == maxPassages && passage.getScore() < passageQueue.peek().getScore()) {
      passage.reset(); 
    } else {
      passageQueue.offer(passage);
      // 如果高亮片段超出个数限制,则去掉分数最低的,重置返回复用  
      if (passageQueue.size() > maxPassages) {
        passage = passageQueue.poll();
        passage.reset();
      } else {
        // 当前的passage加入队列,则新建一个返回使用  
        passage = new Passage();
      }
    }
    return passage;
  }

如果没有匹配关键字的高亮片段,则寻找前maxPassages个句子作为兜底的高亮片段:

    protected Passage[] getSummaryPassagesNoHighlight(int maxPassages) {
    assert breakIterator.current() == breakIterator.first();

    List<Passage> passages = new ArrayList<>(Math.min(maxPassages, 10));
    int pos = breakIterator.current();
    assert pos == 0;
    while (passages.size() < maxPassages) {
      int next = breakIterator.next();
      if (next == BreakIterator.DONE) {
        break;
      }
      Passage passage = new Passage();
      passage.setStartOffset(pos);
      passage.setEndOffset(next);
      passages.add(passage);
      pos = next;
    }

    return passages.toArray(new Passage[passages.size()]);
  }

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

总结

经过上面的分析,我们也比较清楚UnifiedHighlighter的高亮方案实现。UnifiedHighlighter基本上确实是个大一统的高亮解决方案,几乎适用全场景。但是,从本文的长度就可以知道,它的实现相对另外两种高亮实现来说比较复杂。

如果看Lucene highliter模块中,它还有一个org.apache.lucene.search.matchhighlight的包,这里面其实不是一个新的高亮实现方案,一定程度上可以理解成是对UnifiedHighlighter的重构,matchhighlight的实现版本代码高度解耦,实现更优雅,建议如果业务有比较多的定制化需求,可以基于matchhighlight做二次开发。

需要注意的是Highlighter,FastVectorHighlighter,UnifiedHighlighter这三个高亮方案已经工业界(Elasticsearch,Solr)经历过长时间的考验了。而matchhighlight包目前还未有产品落地使用,所以可能有bug。

写在最后

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