Lucene源码系列(三):Highlighter高亮算法实现

1,807 阅读13分钟

背景介绍

在搜索引擎中,一般对于检索出来的相关文档会对query中的关键字进行高亮,借以直观地表示引擎检索结果的相关性。如下图所示,我们在Google中搜索“lucene engine”,搜索的结果列表中的摘要对query词中关键字的匹配进行了红色高亮表示。这种高亮的功能在引擎背后到底是怎么实现的,这就是我们接下来要介绍的内容。

google检索高亮的示例.png

在继续往下面看之前,先想想如果要自己来设计高亮功能的实现,应该要怎么做?

比较直接的想法是遍历检索返回的文档,寻找文档和query中的匹配的关键字进行高亮,最后把关键字匹配个数比较多的某几个片段作为摘要展示,关键字匹配比较多的片段理论上是更能体现相关性的。

对于我们这个直接的想法具体实现需要哪些步骤呢?

  • 寻找匹配项
  • 对匹配项进行高亮
  • 裁剪高亮的片段
  • 寻找匹配个数比较多的某几个片段

而Lucene中实现高亮要解决的主要问题还真就这四个问题!

摘要

Lucene中有三种对搜索结果进行关键字高亮的实现方式(Highlighter,FastVectorHighlighter,UnifiedHighlighter),我会按照它们在Lucene中出现的顺序分为三篇来一一介绍。今天先介绍出现最早的一种实现:Highlighter实现。

Highlighter是lucene中最早提供的高亮实现方式。Highlighter的工作机制依赖了四大组件Fragmenter(分片器),Encoder(编码器),Scorer(打分器)以及Formatter(格式化器)。使用这四大组件刚好可以解决我们上面提出的要实现高亮功能的问题。

  • Fragmenter是用来决定每个高亮片段的长度。
  • Encoder支持对高亮片段的内容进行转义。
  • Scorer对每个高亮片段打分,最后的结果是按分数排序的。
  • Formatter是怎么高亮(加粗还是标颜色等)匹配的关键字。

至于这四大组件是怎么配合一起工作完成高亮功能我们后面会详细介绍。

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

Highlighter使用示例

在正式分析实现之前,先看个Highlighter使用的例子,知道Highlighter是怎么用的:

public class HighlighterDemo {
    public static void main(String[] args) throws IOException, InvalidTokenOffsetsException {
        // Lucene内置的标准分词器
        StandardAnalyzer analyzer = new StandardAnalyzer();
        
        // 搜索field0中匹配短语(lucene search)且slop不超过1的文档
        PhraseQuery phraseQuery = new PhraseQuery(1, "field0", "lucene", "search");
        
        // Highlighter四大组件:formatter,encoder,scorer,fragmenter
        SimpleHTMLFormatter formatter = new SimpleHTMLFormatter();
        DefaultEncoder encoder = new DefaultEncoder();
        QueryTermScorer scorer = new QueryTermScorer(phraseQuery);
        Highlighter highlighter = new Highlighter(formatter, encoder, scorer);
        
        // 设置每个高亮分片的最大长度为10(字符个数)
        SimpleFragmenter fragmenter = new SimpleFragmenter(10);
        highlighter.setTextFragmenter(fragmenter);
        
        // 寻找最多5个高亮分片
        String[] fragments = highlighter.getBestFragments(analyzer, "field0", "The goal of Apache Lucene is to provide world class search capabilities.", 5);
        for(String fragment: fragments) {
            System.out.println(fragment);
        }
    }
}

输出:

The goal of Apache <B>Lucene</B> is to provide
 class <B>search</B>

例子虽然运行起来了,也产生了结果,但是有几个疑问:

  1. 我们明明设置了高亮分片的长度最大是10个字符,但是第一个结果远远超出了10字符
  2. 我们要高亮的文本明明不满足PhraseQuery的查询条件,那怎么也有高亮结果

带着这两个问题,我们一起进入源码来一探究竟。

预备知识

为了后面一起看源码不卡壳,有几个工具类需要先了解下。

TokenStream

TokenStream是用来列举文本的token(或者叫term)序列的,俗称分词器。

在分词的过程中,我们可以获取每个token内容,在文本中的position信息,offset信息等属性。而token的每一种属性,在Lucene中就是一个Attribute。不同的分词器支持的属性集合不同,具体可以查看对应的分词器介绍,这里我们就不展开了。

在分词执行之前,用户可以使用AttributeSource#addAttribute接口注册感兴趣的属性,TokenStream继承了AttributeSource,用来管理这些用户关注的属性。分词器在运行的过程中(incrementToken方法),每处理一个分词就会记录这些属性,用户就可以获取对应token的属性信息。

使用示例:

public class TokenStreamDemo {
    public static void main(String[] args) throws IOException {
        // 简单的空白字符分词器
        WhitespaceAnalyzer analyzer = new WhitespaceAnalyzer();
        TokenStream tokenStream = analyzer.tokenStream("test", "My name is zjc, what's your name.");
        // 注册offset属性和term属性
        OffsetAttribute offsetAttribute = tokenStream.addAttribute(OffsetAttribute.class);
        CharTermAttribute charTermAttribute = tokenStream.addAttribute(CharTermAttribute.class);
        // 准备启动分词器
        tokenStream.reset();
        // 如果存在下一个分词
        while (tokenStream.incrementToken()) {
            // incrementToken方法中会为我们感兴趣的属性填充值,
            // 所以incrementToken执行之后就可以获取到对应token的属性
            String token = charTermAttribute.toString();
            int startOffset = offsetAttribute.startOffset();
            int endOffset = offsetAttribute.endOffset();
            System.out.println(token + ": " + startOffset + ", " + endOffset);
        }
    }
}

输出:

My: 0, 2
name: 3, 7
is: 8, 10
zjc,: 11, 15
what's: 16, 22
your: 23, 27
name.: 28, 33

对于TokenStream,只要知道在分词器执行之前我们可以注册感兴趣的属性,调用incrementToken方法之后,可以获取当前token的这些属性即可。

TokenGroup

TokenGroup是一个token组,包含了一个token或者是多个重叠的token。

一般分词器不会分词出有重叠的token,目前来看只有一种情况会产生重叠的token,那就是同义词,同义词在同一个position会有多个不同的token。

所以通常情况下TokenGroup只有一个token。

注意:是否高亮是以TokenGroup为单位的,如果TokenGroup中的总得分大于0(TokengGroup中包含query中的关键字得分才会大于0),则整组一起高亮。

成员变量:

  // 一个组最多有多少个token
  private static final int MAX_NUM_TOKENS_PER_GROUP = 50;

  // 数组元素是每个token的得分
  private float[] scores = new float[MAX_NUM_TOKENS_PER_GROUP];

  // 这个组中目前有多少个token
  private int numTokens = 0;

  // 整个组在文本中的起始位置
  private int startOffset = 0;

  // 整个组在文本中的结束位置
  private int endOffset = 0;

  // 整个组的总得分,也是所有token的得分和
  private float tot;

  // 关键字匹配token在文本中的起始位置
  private int matchStartOffset;

  // 关键字匹配token在文本中的结束位置
  private int matchEndOffset;

  // 用来获取当前token的offset信息
  private OffsetAttribute offsetAtt;

几个关键方法:

  // 构造方法主要是注册了两个分词过程中关注的属性:offset和term
  public TokenGroup(TokenStream tokenStream) {
    offsetAtt = tokenStream.addAttribute(OffsetAttribute.class);
    tokenStream.addAttribute(CharTermAttribute.class);
  }

  // score是当前token的得分,怎么来的后面会介绍
  void addToken(float score) {
    if (numTokens < MAX_NUM_TOKENS_PER_GROUP) {
      final int termStartOffset = offsetAtt.startOffset();
      final int termEndOffset = offsetAtt.endOffset();
      // 如果是第一个token
      if (numTokens == 0) {
        startOffset = matchStartOffset = termStartOffset;
        endOffset = matchEndOffset = termEndOffset;
        tot += score;
      } else {
        // 更新整组的起始位置和结束位置  
        startOffset = Math.min(startOffset, termStartOffset);
        endOffset = Math.max(endOffset, termEndOffset);
        // score > 0表示token是匹配的token  
        if (score > 0) {
          // 如果是第一个匹配的token  
          if (tot == 0) {
            matchStartOffset = termStartOffset;
            matchEndOffset = termEndOffset;
          } else {
            // 更新匹配token的范围  
            matchStartOffset = Math.min(matchStartOffset, termStartOffset);
            matchEndOffset = Math.max(matchEndOffset, termEndOffset);
          }
          tot += score;
        }
      }

      scores[numTokens] = score;
      numTokens++;
    }
  }

  // 判断当前token是否和组内的其他token重叠
  boolean isDistinct() {
    return offsetAtt.startOffset() >= endOffset;
  }

QueryTermExtractor

QueryTermExtractor实现的功能是从query中获取所有的term,并为每个term生成一个权重。

有两个核心方法:

  public static WeightedTerm[] getTerms(Query query, boolean prohibited, String fieldName) {
    HashSet<WeightedTerm> terms = new HashSet<>();  
    Predicate<String> fieldSelector = fieldName == null ? f -> true : fieldName::equals;
    // 访问器模式遍历query树,获取query树中所有的term并且为term设置权重
    query.visit(new BoostedTermExtractor(1, terms, prohibited, fieldSelector));
    return terms.toArray(new WeightedTerm[0]);
  }

具体看下BoostedTermExtractor:

  private static class BoostedTermExtractor extends QueryVisitor {
    // 初始的权重
    final float boost;
      
    // 最后的term集合  
    final Set<WeightedTerm> terms;
      
    // 要获取的term集合是否包含BooleanQuery中的MUST_NOT字句query中的term  
    final boolean includeProhibited;
      
    // Query中要遍历的字段
    final Predicate<String> fieldSelector;

    private BoostedTermExtractor(
        float boost,
        Set<WeightedTerm> terms,
        boolean includeProhibited,
        Predicate<String> fieldSelector) {
      this.boost = boost;
      this.terms = terms;
      this.includeProhibited = includeProhibited;
      this.fieldSelector = fieldSelector;
    }

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

    @Override
    public void consumeTerms(Query query, Term... terms) {
      // 把遍历到的term加入集合中,boost作为权重  
      for (Term term : terms) {
        this.terms.add(new WeightedTerm(boost, term.text()));
      }
    }

    @Override
    public QueryVisitor getSubVisitor(BooleanClause.Occur occur, Query parent) {
      if (parent instanceof BoostQuery) {
        // 从这里可以看出,除了BoostQuery会修改term权重,其他term都是1
        float newboost = boost * ((BoostQuery) parent).getBoost();
        return new BoostedTermExtractor(newboost, terms, includeProhibited, fieldSelector);
      }
      if (occur == BooleanClause.Occur.MUST_NOT && includeProhibited == false) {
        return QueryVisitor.EMPTY_VISITOR;
      }
      return this;
    }
  }

如果可以获取到reader的话(reader可以用来获取所有的索引数据),则可以计算idf作为term的权重:

  public static final WeightedTerm[] getIdfWeightedTerms(
      Query query, IndexReader reader, String fieldName) {
    // 获取所有的term  
    WeightedTerm[] terms = getTerms(query, false, fieldName);
    int totalNumDocs = reader.maxDoc();
    for (int i = 0; i < terms.length; i++) {
      try {
        int docFreq = reader.docFreq(new Term(fieldName, terms[i].term));
        // 计算term的IDF得分,更新权重
        float idf = (float) (Math.log(totalNumDocs / (double) (docFreq + 1)) + 1.0);
        terms[i].weight *= idf;
      } catch (
          @SuppressWarnings("unused")
          IOException e) {
        // ignore
      }
    }
    return terms;
  }

TextFragment

TextFragment表示一个高亮片段,它没有直接封装高亮文本片段,而是封装了一个文本序列,用起始位置和结束位置表示文本序列中的一段是高亮片段。

我们看下成员变量:

  // 文本序列,本高亮片段是它的一个子集
  CharSequence markedUpText;

  // 高亮片段编号
  int fragNum;

  // 高亮片段在文本序列中的起始位置
  int textStartPos;

  // 高亮片段在文本序列中的结束位置
  int textEndPos;

  // 高亮文本片片段的得分
  float score;

TextFragment最主要的有三个方法:

  // 合并两个首尾相接的高亮片段,得分取大的那个
  public void merge(TextFragment frag2) {
    textEndPos = frag2.textEndPos;
    score = Math.max(score, frag2.score);
  }
  // 判断当前的高亮片段是否紧接着fragment
  public boolean follows(TextFragment fragment) {
    return textStartPos == fragment.textEndPos;
  }

  // toString方法就是返回真正的高亮片段
  @Override
  public String toString() {
    return markedUpText.subSequence(textStartPos, textEndPos).toString();
  }

四大组件

Highlighter依赖的四大组件各自完成什么功能,以及如何实现的?接下来我们分别看下:

Formatter

格式化器会对找到高亮片段中的匹配的token做格式化,一般是对匹配的token加一些html标签(加粗,加颜色),在html网页中进行突出显示,如我们示例中的在关键字前后分别加上加粗的标签。

Formatter接口中只有一个方法:

public interface Formatter {
  // 如果tokenGroup的得分大于0,才对originalText进行高亮。
  // 具体实现类都是高亮的处理方式不同(加粗,加颜色),是否高亮的判断逻辑都是一样的。  
  String highlightTerm(String originalText, TokenGroup tokenGroup);
}

SimpleHTMLFormatter

SimpleHTMLFormatter定义了匹配token的前置标签和后置标签,默认是加粗处理。

public class SimpleHTMLFormatter implements Formatter {
  // 默认加粗处理
  private static final String DEFAULT_PRE_TAG = "<B>";
  private static final String DEFAULT_POST_TAG = "</B>";

  private String preTag;
  private String postTag;

  public SimpleHTMLFormatter(String preTag, String postTag) {
    this.preTag = preTag;
    this.postTag = postTag;
  }

  // 默认构造器就是使用默认的标签
  public SimpleHTMLFormatter() {
    this(DEFAULT_PRE_TAG, DEFAULT_POST_TAG);
  }

  @Override
  public String highlightTerm(String originalText, TokenGroup tokenGroup) {
    // 没有匹配的token,则直接返回原始文本  
    if (tokenGroup.getTotalScore() <= 0) {
      return originalText;
    }

    // 预置长度,避免StringBuilder的自动扩容
    StringBuilder returnBuffer =
        new StringBuilder(preTag.length() + originalText.length() + postTag.length());
    // 为匹配的token加上前后标签  
    returnBuffer.append(preTag);
    returnBuffer.append(originalText);
    returnBuffer.append(postTag);
    return returnBuffer.toString();
  }
}

GradientFormatter

SimpleHTMLFormatter对所有的高亮片段处理方式一样,并没有考虑每个高亮片段的得分情况。

GradientFormatter根据TokenGroup分数的不同,用不同的颜色进行高亮。

SpanGradientFormatter

SpanGradientFormatter的实现逻辑和GradientFormatter几乎一样,只不过它高亮颜色使用的是span标签,对所有浏览器都适用。

Encoder

在目前的版本中有两个编码器DefaultEncoder和SimpleHTMLEncoder。

DefaultEncoder不做任何处理,原样返回:

public class DefaultEncoder implements Encoder {
  public DefaultEncoder() {}

  @Override
  public String encodeText(String originalText) {
    return originalText;
  }
}

SimpleHTMLEncoder是对HTML的特殊字符做转义:

public class SimpleHTMLEncoder implements Encoder {
  public SimpleHTMLEncoder() {}

  @Override
  public String encodeText(String originalText) {
    return htmlEncode(originalText);
  }

  /** Encode string into HTML */
  public static final String htmlEncode(String plainText) {
    if (plainText == null || plainText.length() == 0) {
      return "";
    }

    StringBuilder result = new StringBuilder(plainText.length());

    for (int index = 0; index < plainText.length(); index++) {
      char ch = plainText.charAt(index);

      switch (ch) {
        case '"':
          result.append("&quot;");
          break;
        case '&':
          result.append("&amp;");
          break;
        case '<':
          result.append("&lt;");
          break;
        case '>':
          result.append("&gt;");
          break;
        case '\'':
          result.append("&#x27;");
          break;
        case '/':
          result.append("&#x2F;");
          break;
        default:
          result.append(ch);
      }
    }

    return result.toString();
  }
}

Scorer

打分器是用来对高亮片段进行打分的。底层实现都是对高亮片段中的每个token打分,所有token的得分和就是高亮片段的总得分。

我们看下主要的接口方法:

public interface Scorer {

  // 在init方法中,最重要的是注册自己关注的token属性
  public TokenStream init(TokenStream tokenStream) throws IOException;

  // 重置一些信息来处理新的高亮片段
  public void startFragment(TextFragment newFragment);

  // 获取token的得分
  public float getTokenScore();

  // 获取当前高亮片段的得分
  public float getFragmentScore();
}

在lucene中,Scorer有两种不同的实现,最大区别是是否关注term之间的位置信息。

QueryTermScorer

QueryTermScorer对于所有的query都只考虑单个term的匹配情况,不考虑term之间位置距离信息。需要注意的是它没有做Query改写,所以前缀匹配,正则匹配等Query是不支持的。

这个打分器就是造成了示例中问题2的原因。示例中要查找(“lucene”,“search”)之间距离不超过1的短语匹配,因为我们例子中使用的是QueryTermScorer,它不关注位置信息,只考虑“lucene”和“search”两个term的匹配情况。

我们来看下QueryTermScorer的具体实现,先看成员变量:

  // 当前处理中的高亮片段
  TextFragment currentTextFragment = null;

  // 当前处理的高亮片段中的term集合
  HashSet<String> uniqueTermsInFragment;

  // 总得分
  float totalScore = 0;

  // term的最大权重
  float maxTermWeight = 0;

  // term->weightedTerm的缓存
  // WeightedTerm只是简单封装了term和weight的工具类。weight也是term的得分。
  // termsToFind中存储的是query中出现的所有的term和得分。
  private HashMap<String, WeightedTerm> termsToFind;

  // term属性
  private CharTermAttribute termAtt;

QueryTermScorer构造方法:

  // 使用QueryTermExtractor从query中获取所有的term。
  public QueryTermScorer(Query query) {
    this(QueryTermExtractor.getTerms(query));
  }

  // 同上
  public QueryTermScorer(Query query, String fieldName) {
    this(QueryTermExtractor.getTerms(query, false, fieldName));
  }

  // 计算term的idf作为权重
  public QueryTermScorer(Query query, IndexReader reader, String fieldName) {
    this(QueryTermExtractor.getIdfWeightedTerms(query, reader, fieldName));
  }

  // 直接传入一个已有的term集合
  public QueryTermScorer(WeightedTerm[] weightedTerms) {
    termsToFind = new HashMap<>();
    for (int i = 0; i < weightedTerms.length; i++) {
      WeightedTerm existingTerm = termsToFind.get(weightedTerms[i].term);
      if ((existingTerm == null) || (existingTerm.weight < weightedTerms[i].weight)) {
        // 如果是新term,或者是一个分数更高的term则更新缓存
        termsToFind.put(weightedTerms[i].term, weightedTerms[i]);
        maxTermWeight = Math.max(maxTermWeight, weightedTerms[i].getWeight());
      }
    }
  }

其他的内容我们只看两个跟打分有关的方法:

  // 单个token的得分
  public float getTokenScore() {
    String termText = termAtt.toString();

    WeightedTerm queryTerm = termsToFind.get(termText);
    // 如果不是query中出现的term,则直接返回得分是0。  
    if (queryTerm == null) {
      return 0;
    }
    // 如果term是第一次出现,则更新总得分。可知重复出现的term对总得分没有加成
    if (!uniqueTermsInFragment.contains(termText)) {
      totalScore += queryTerm.getWeight();
      uniqueTermsInFragment.add(termText);
    }
    // term的权重作为token得分  
    return queryTerm.getWeight();
  }

  // 从上面的方法可知,高亮片段的得分是每个term的权重之和,多次出现的term只取一个
  public float getFragmentScore() {
    return totalScore;
  }

QueryScorer

QueryScorer支持所有的Query,并且对于需要考虑term组位置信息的PhraseQuery和MultiPhraseQuery都转成SpanQuery,这样可以统一处理term间的slop距离问题。只有满足slop距离要求的term组,才会被高亮处理。

因为QueryScorer涉及的内容太多了(SpanQuery,query改写,Weighter,交并集等),这边就不展开了,以QueryTermScorer为例也能清楚地了解Highlighter的工作原理。

Fragmenter

Fragmenter是对文本做分段的。主要提供了一个方法判断是否当前从TokenStream中获取到的token是否属于一个新的文本片段。

public interface Fragmenter {

  // 主要是注册关注的分词属性
  public void start(String originalText, TokenStream tokenStream);

  // 当前TokenStream得到的token是否属于新文本段
  public boolean isNewFragment();
}

三个实现类

NullFragmenter

不分段

SimpleFragmenter

按照固定token个数做文本分段:

  // fragmentSize是文本片段的token个数
  // currentNumFrags当前的文本片段个数
  public boolean isNewFragment() {
    boolean isNewFrag = offsetAtt.endOffset() >= (fragmentSize * currentNumFrags);
    if (isNewFrag) {
      currentNumFrags++;
    }
    return isNewFrag;
  }

SimpleSpanFragmenter

SimpleSpanFragmenter在SimpleFragmenter的基础上做了一个判断,如果是满足位置条件的SpanQuery,则为了在单个高亮片段中包含满足SpanQuery的所有term,不会强制按照要求的片段大小截断(如果使用这个分片器的话,也会造成示例问题1,但是我们示例中没有使用这个分片器,那肯定还有其他原因,请继续往下看)。

高亮主逻辑

高亮的主逻辑就在Highlighter中,Highlighter中有好几个getBestFragment和getBestFragments的重载方法,最终都会走到下面这个方法:

  public final String[] getBestFragments(TokenStream tokenStream, String text, int maxNumFragments)
      throws IOException, InvalidTokenOffsetsException {
    maxNumFragments = Math.max(1, maxNumFragments); 

    // 获取指定的个数的高亮片段,是核心逻辑  
    TextFragment[] frag = getBestTextFragments(tokenStream, text, true, maxNumFragments);

    // 从TextFragment中获取真正的高亮片段,前面工具类中我们说到TextFragment#toString方法就是返回真正的高亮片段
    ArrayList<String> fragTexts = new ArrayList<>();
    for (int i = 0; i < frag.length; i++) {
      if ((frag[i] != null) && (frag[i].getScore() > 0)) {
        fragTexts.add(frag[i].toString());
      }
    }
    return fragTexts.toArray(new String[0]);
  }

Highlighter的核心方法getBestTextFragments中主逻辑可以分为四部分,在这个方法中综合使用四大组件完成高亮功能:

  1. 获取全部的片段
  2. 处理最后一个片段
  3. 对所有片段按分数排序
  4. 对首尾相连的片段merge成一个

接下来我们就详细分析这四部分的源码实现:

获取全部的片段

Highlighter会获取所有的片段做备选,不管是否包含关键字。我们先看下获取高亮片段的一个整体流程:

Highlighter获取高亮片段流程图.png

源码对应上面的流程图看,逻辑还是比较清晰的:

  public final TextFragment[] getBestTextFragments(
      TokenStream tokenStream, String text, boolean mergeContiguousFragments, int maxNumFragments)
      throws IOException, InvalidTokenOffsetsException {
    // 初始化结果集  
    ArrayList<TextFragment> docFrags = new ArrayList<>();
    // 完整的文本,对匹配的term做了高亮处理  
    StringBuilder newText = new StringBuilder();

    CharTermAttribute termAtt = tokenStream.addAttribute(CharTermAttribute.class);
    OffsetAttribute offsetAtt = tokenStream.addAttribute(OffsetAttribute.class);
    // 初始化当前处理的TextFragment
    TextFragment currentFrag = new TextFragment(newText, newText.length(), docFrags.size());

    if (fragmentScorer instanceof QueryScorer) {
      ((QueryScorer) fragmentScorer).setMaxDocCharsToAnalyze(maxDocCharsToAnalyze);
    }

    TokenStream newStream = fragmentScorer.init(tokenStream);
    if (newStream != null) {
      tokenStream = newStream;
    }
    fragmentScorer.startFragment(currentFrag);
    // 把当前的片段加入结果集  
    docFrags.add(currentFrag);

    // 高亮片段的堆,用来按得分排序的,如果分数相等,分片编号小的在堆顶。注意是有限的。
    FragmentQueue fragQueue = new FragmentQueue(maxNumFragments);

    try {
      String tokenText;
      int startOffset;
      int endOffset;
      // 上一次文本片段结束的位置  
      int lastEndOffset = 0;
      textFragmenter.start(text, tokenStream);
      // 初始化token组
      TokenGroup tokenGroup = new TokenGroup(tokenStream);

      tokenStream.reset();
      // 遍历所有的token  
      for (boolean next = tokenStream.incrementToken();
          next && (offsetAtt.startOffset() < maxDocCharsToAnalyze);
          next = tokenStream.incrementToken()) {
        if ((offsetAtt.endOffset() > text.length()) || (offsetAtt.startOffset() > text.length())) {
          throw new InvalidTokenOffsetsException(
              "Token "
                  + termAtt.toString()
                  + " exceeds length of provided text sized "
                  + text.length());
        }
        // 如果当前token组非空并且当前处理的token和token组没有重叠  
        if ((tokenGroup.getNumTokens() > 0) && (tokenGroup.isDistinct())) {
          // 获取当前token组中的内容进行高亮
          startOffset = tokenGroup.getStartOffset();
          endOffset = tokenGroup.getEndOffset();
          tokenText = text.substring(startOffset, endOffset);
          // 这一步就会对匹配的关键字做高亮  
          String markedUpText = formatter.highlightTerm(encoder.encodeText(tokenText), tokenGroup);
          // 如果token组合上一次片段的结尾中间还有内容,则把这部分加入完整的文本中
          if (startOffset > lastEndOffset)
            newText.append(encoder.encodeText(text.substring(lastEndOffset, startOffset)));
          // 加入高亮之后的内容  
          newText.append(markedUpText);
          // 更新上一次文本片段结束的位置  
          lastEndOffset = Math.max(endOffset, lastEndOffset);
          // 清空token组  
          tokenGroup.clear();

          // 如果当前token属于新的片段。
          // 需要为当前片段做一些收尾工作,比如计算总得分,记录在当前片段在文本中的结束位置
          if (textFragmenter.isNewFragment()) {
            // 计算当前片段的得分  
            currentFrag.setScore(fragmentScorer.getFragmentScore());
            // 记录在当前片段在文本中的结束位置
            currentFrag.textEndPos = newText.length();
            // 创建一个新的片段,准备处理后面的token  
            currentFrag = new TextFragment(newText, newText.length(), docFrags.size());
            fragmentScorer.startFragment(currentFrag);
            // 把当前片段加入结果集  
            docFrags.add(currentFrag);
          }
        }
        // 当前的token加入token组
        tokenGroup.addToken(fragmentScorer.getTokenScore());
      }
      
      。。。(处理最后一个片段)
      。。。(对所有片段按分数排序)
      。。。(对首尾相连的片段merge成一个)
    } finally {
      if (tokenStream != null) {
        try {
          tokenStream.end();
          tokenStream.close();
        } catch (
            @SuppressWarnings("unused")
            Exception e) {
        }
      }
    }
  }

处理最后一个片段

最后一个token处理完成,还需要为当前处理中的最后一个片段做收尾工作:

  public final TextFragment[] getBestTextFragments(
      TokenStream tokenStream, String text, boolean mergeContiguousFragments, int maxNumFragments)
      throws IOException, InvalidTokenOffsetsException {
      。。。(获取所有的高亮片段)
      
      // 处理最后一个片段,最后一个token可能不是刚好满足一个片段的大小  
      currentFrag.setScore(fragmentScorer.getFragmentScore());
      // 如果token组非空,则把token组进行高亮,再加入完整的文本序列中,这部分逻辑和获取所有的片段中的一致
      if (tokenGroup.getNumTokens() > 0) {
        startOffset = tokenGroup.getStartOffset();
        endOffset = tokenGroup.getEndOffset();
        tokenText = text.substring(startOffset, endOffset);
        String markedUpText = formatter.highlightTerm(encoder.encodeText(tokenText), tokenGroup);
        if (startOffset > lastEndOffset)
          newText.append(encoder.encodeText(text.substring(lastEndOffset, startOffset)));
        newText.append(markedUpText);
        lastEndOffset = Math.max(lastEndOffset, endOffset);
      }

      // 如果还存在输入文本的内容,则把剩余部分也加入完整的文本序列中
      if (lastEndOffset < text.length() && text.length() <= maxDocCharsToAnalyze) {
        newText.append(encoder.encodeText(text.substring(lastEndOffset)));
      }
      // 标记最后一个片段的结束位置
      currentFrag.textEndPos = newText.length();

      。。。(对所有片段按分数排序)
      。。。(对首尾相连的片段merge成一个)

      return frag;

    } finally {
      if (tokenStream != null) {
        try {
          tokenStream.end();
          tokenStream.close();
        } catch (
            @SuppressWarnings("unused")
            Exception e) {
        }
      }
    }
  }

对所有片段按分数排序

最终的片段是按照分数排序,只保留了maxNumFragments个片段,注意其中不一定都是包含关键字的:

  public final TextFragment[] getBestTextFragments(
      TokenStream tokenStream, String text, boolean mergeContiguousFragments, int maxNumFragments)
      throws IOException, InvalidTokenOffsetsException {
      。。。(获取所有的片段)
      。。。(处理最后一个片段)

      // 把片段插入最大堆中(堆顶的片段分数最高,如果分数相等,分片编号小的在堆顶)
      for (Iterator<TextFragment> i = docFrags.iterator(); i.hasNext(); ) {
        currentFrag = i.next();
        fragQueue.insertWithOverflow(currentFrag);
      }

      // 从堆中依次取出片段,在数组中的顺序就是按照得分从高到低
      TextFragment[] frag = new TextFragment[fragQueue.size()];
      for (int i = frag.length - 1; i >= 0; i--) {
        frag[i] = fragQueue.pop();
      }

      。。。(对首尾相连的片段merge成一个)
    } finally {
      if (tokenStream != null) {
        try {
          tokenStream.end();
          tokenStream.close();
        } catch (
            @SuppressWarnings("unused")
            Exception e) {
        }
      }
    }
  }

对首尾相连的片段merge成一个

如果最后结果中的片段集合有首尾相接的,则merge成一个片段。这个逻辑也是造成示例问题1的原因之一。

  public final TextFragment[] getBestTextFragments(
      TokenStream tokenStream, String text, boolean mergeContiguousFragments, int maxNumFragments)
      throws IOException, InvalidTokenOffsetsException {
      。。。(获取所有的片段)
      。。。(处理最后一个片段)
      。。。(对所有片段按分数排序)

      // 如果最后结果中的片段集合有首尾相接的,则merge成一个片段。
      // 这个逻辑就是造成示例问题1的原因  
      if (mergeContiguousFragments) {
        // 具体实现merge的逻辑可以自己看,虽然比较长,但是比较简单,就是暴力搜索看看是否首尾相接。  
        mergeContiguousFragments(frag);
        ArrayList<TextFragment> fragTexts = new ArrayList<>();
        for (int i = 0; i < frag.length; i++) {
          if ((frag[i] != null) && (frag[i].getScore() > 0)) {
            fragTexts.add(frag[i]);
          }
        }
        frag = fragTexts.toArray(new TextFragment[0]);
      }

      return frag;

    } finally {
      if (tokenStream != null) {
        try {
          tokenStream.end();
          tokenStream.close();
        } catch (
            @SuppressWarnings("unused")
            Exception e) {
        }
      }
    }
  }

merge逻辑处理之后的片段结果集作为最终的高亮片段集合返回。

总结

经过上面的分析,我们也了解了Highlighter工作原理,从中也可以发现Highlighter虽然可以满足高亮的需求,但是仍然存在问题:

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

对于Highlighter存在的这两个问题该怎么优化呢?我们下一篇见。

写在最后

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