背景
上一篇我们讨论了Lucene中的第二套高亮实现方案FastVectorHighlighter,虽然FastVectorHighlighter解决了第一套方案Highlighter处理大文档效率问题,但是它自身又引入了其他的问题使得其使用场景受限。
Lucene推出的第三套高亮解决方案是UnifiedHighlighter,从名字可以看出,这是Lucene高亮的“大一统”解决方案。UnifiedHighlighter凭什么是高亮的大一统解决方案?我觉得是两方面:
- 首先在接口使用上,涵盖了Highlighter(字段名称+文本)和FastVectorHighlighter(字段名称+文档号)的使用方式,下一节的例子我们可以看到。
- 其次在寻找高亮匹配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来说,寻找匹配分为两个阶段:
- 第一个阶段是获取满足第一个条件的文档迭代器,也就是必须包含query中所有的term的文档(term倒排的交集)。
- 第二个阶段判断是否满足第二个匹配条件,也就是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非常相似:
- 寻找匹配需要高亮的term:匹配term的位置迭代器
- 获取高亮片段的边界:高亮片段分割器
- 为高亮片段打分,最后按分数排序:高亮片段打分器
- 格式化高亮片段:高亮片段格式化器
最最重要的区别是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会根据特殊情况进行调整,逻辑难懂):
- 如果有倒排,并且有词向量,则使用PostingsWithTermVectorsOffsetStrategy
- 如果有倒排,如果没有词向量,则使用PostingsOffsetStrategy
- 如果没有倒排,有词向量,则使用TermVectorOffsetStrategy
- 其他情况都使用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("&");
break;
case '<':
dest.append("<");
break;
case '>':
dest.append(">");
break;
case '"':
dest.append(""");
break;
case '\'':
dest.append("'");
break;
case '/':
dest.append("/");
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条调用链:
我们可以看到最底层都是使用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(一个高亮片段),整体流程如下图所示:
// 获取高亮片段核心逻辑
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。
写在最后
感谢看到此处的看官,如有疏漏,欢迎指正讨论。