|
8 | 8 | package org.elasticsearch.compute.operator; |
9 | 9 |
|
10 | 10 | import org.apache.lucene.analysis.Analyzer; |
11 | | -import org.apache.lucene.index.LeafReaderContext; |
| 11 | +import org.apache.lucene.analysis.CharArraySet; |
| 12 | +import org.apache.lucene.analysis.FilteringTokenFilter; |
| 13 | +import org.apache.lucene.analysis.TokenStream; |
| 14 | +import org.apache.lucene.analysis.tokenattributes.CharTermAttribute; |
| 15 | +import org.apache.lucene.index.LeafReader; |
| 16 | +import org.apache.lucene.index.Term; |
12 | 17 | import org.apache.lucene.index.memory.MemoryIndex; |
| 18 | +import org.apache.lucene.search.BooleanClause; |
13 | 19 | import org.apache.lucene.search.IndexSearcher; |
14 | 20 | import org.apache.lucene.search.Query; |
| 21 | +import org.apache.lucene.search.QueryVisitor; |
15 | 22 | import org.apache.lucene.search.highlight.DefaultEncoder; |
16 | 23 | import org.apache.lucene.search.highlight.Encoder; |
17 | 24 | import org.apache.lucene.search.highlight.SimpleHTMLEncoder; |
|
20 | 27 | import org.apache.lucene.search.uhighlight.SplittingBreakIterator; |
21 | 28 | import org.apache.lucene.search.uhighlight.UnifiedHighlighter; |
22 | 29 | import org.apache.lucene.util.BytesRef; |
| 30 | +import org.apache.lucene.util.automaton.ByteRunAutomaton; |
23 | 31 | import org.elasticsearch.common.settings.Settings; |
24 | 32 | import org.elasticsearch.compute.data.Block; |
25 | 33 | import org.elasticsearch.compute.data.BlockFactory; |
@@ -88,6 +96,10 @@ public String describe() { |
88 | 96 | private final int highlighterNumberOfFragments; |
89 | 97 | private final Supplier<BreakIterator> breakIteratorSupplier; |
90 | 98 | private final ExpressionEvaluator[] fieldEvaluators; |
| 99 | + private final MemoryIndex memoryIndex; |
| 100 | + private LeafReader memoryIndexReader; |
| 101 | + private final CustomUnifiedHighlighter[] highlighters; |
| 102 | + private final CharArraySet termsToKeep; |
91 | 103 |
|
92 | 104 | public HighlightOperator(BlockFactory blockFactory, HighlightConfig config, ExpressionEvaluator[] fieldEvaluators) { |
93 | 105 | this.blockFactory = blockFactory; |
@@ -116,6 +128,76 @@ public HighlightOperator(BlockFactory blockFactory, HighlightConfig config, Expr |
116 | 128 | config.wordBoundary(), |
117 | 129 | config.locale() |
118 | 130 | ); |
| 131 | + this.memoryIndex = new MemoryIndex(true); // true == store offsets, required by OffsetSource.POSTINGS |
| 132 | + // Term extraction for the highlighters only. |
| 133 | + IndexSearcher searcher = memoryIndex.createSearcher(); |
| 134 | + this.highlighters = new CustomUnifiedHighlighter[fieldNames.size()]; |
| 135 | + for (int i = 0; i < fieldNames.size(); i++) { |
| 136 | + UnifiedHighlighter.Builder builder = UnifiedHighlighter.builder(searcher, analyzer); |
| 137 | + builder.withFormatter(formatter); |
| 138 | + builder.withBreakIterator(breakIteratorSupplier); |
| 139 | + highlighters[i] = new CustomUnifiedHighlighter( |
| 140 | + builder, |
| 141 | + UnifiedHighlighter.OffsetSource.POSTINGS, |
| 142 | + null, |
| 143 | + "", |
| 144 | + fieldNames.get(i), |
| 145 | + query, |
| 146 | + config.noMatchSize(), |
| 147 | + highlighterNumberOfFragments, |
| 148 | + indexMaxAnalyzedOffset, |
| 149 | + queryMaxAnalyzedOffset, |
| 150 | + true, |
| 151 | + true |
| 152 | + ); |
| 153 | + } |
| 154 | + this.termsToKeep = termsToKeep(query); |
| 155 | + } |
| 156 | + |
| 157 | + /** |
| 158 | + * Collects the terms the query needs, either to highlight or to decide whether a row matches. Returns {@code null} |
| 159 | + * when a clause's terms cannot be enumerated (wildcards, regexps, or any leaf that reports no terms), which turns |
| 160 | + * filtering off. Keeping too many tokens only costs time. Keeping too few would drop real highlights. |
| 161 | + * <p> |
| 162 | + * One set covers every ON field, so a field can keep tokens that only another field's query mentions. Those never |
| 163 | + * become a highlight, because the highlighter looks up postings per {@code field:term}. |
| 164 | + * <p> |
| 165 | + * {@code MUST_NOT} terms are kept as well. The query still runs against the memory index to decide whether the row |
| 166 | + * matches, so dropping them would turn an excluded row into a match. |
| 167 | + */ |
| 168 | + private static CharArraySet termsToKeep(Query query) { |
| 169 | + TermCollector collector = new TermCollector(); |
| 170 | + query.visit(collector); |
| 171 | + return collector.unfilterable || collector.terms.isEmpty() ? null : collector.terms; |
| 172 | + } |
| 173 | + |
| 174 | + private static final class TermCollector extends QueryVisitor { |
| 175 | + // Create terms set with an initial capacity. |
| 176 | + private final CharArraySet terms = new CharArraySet(8, false); |
| 177 | + private boolean unfilterable; |
| 178 | + |
| 179 | + @Override |
| 180 | + public void consumeTerms(Query query, Term... queryTerms) { |
| 181 | + for (Term term : queryTerms) { |
| 182 | + terms.add(term.text()); |
| 183 | + } |
| 184 | + } |
| 185 | + |
| 186 | + @Override |
| 187 | + public void consumeTermsMatching(Query query, String field, Supplier<ByteRunAutomaton> automaton) { |
| 188 | + unfilterable = true; // wildcard/prefix/regexp, whose terms cannot be enumerated |
| 189 | + } |
| 190 | + |
| 191 | + @Override |
| 192 | + public void visitLeaf(Query query) { |
| 193 | + unfilterable = true; // leaf that reported no terms, we don't know what it matches |
| 194 | + } |
| 195 | + |
| 196 | + @Override |
| 197 | + public QueryVisitor getSubVisitor(BooleanClause.Occur occur, Query parent) { |
| 198 | + // QueryVisitor's default returns EMPTY_VISITOR for MUST_NOT, which would skip its terms. |
| 199 | + return this; |
| 200 | + } |
119 | 201 | } |
120 | 202 |
|
121 | 203 | // Mirrors DefaultHighlighter#getBreakIterator: the word scanner ignores fragment_size, the sentence scanner honours it. |
@@ -200,28 +282,78 @@ private void highlightRow(int row, HighlightField[] fields, BytesRef scratch) { |
200 | 282 | appendNulls(fields); |
201 | 283 | return; |
202 | 284 | } |
203 | | - IndexSearcher searcher = createRowSearcher(fields); |
204 | | - for (HighlightField field : fields) { |
| 285 | + if (indexRow(fields) == false) { |
| 286 | + appendNulls(fields); |
| 287 | + return; |
| 288 | + } |
| 289 | + for (int fieldIndex = 0; fieldIndex < fields.length; fieldIndex++) { |
| 290 | + HighlightField field = fields[fieldIndex]; |
205 | 291 | if (field.rowText == null) { |
206 | 292 | field.builder.appendNull(); |
207 | 293 | continue; |
208 | 294 | } |
209 | 295 | try { |
210 | | - appendSnippets(field.builder, highlight(searcher, field.name, field.rowText)); |
| 296 | + appendSnippets(field.builder, highlight(fieldIndex, field.rowText)); |
211 | 297 | } catch (IOException e) { |
212 | 298 | throw new IllegalStateException("HIGHLIGHT failed for ON field [" + field.name + "]", e); |
213 | 299 | } |
214 | 300 | } |
215 | 301 | } |
216 | 302 |
|
217 | | - private IndexSearcher createRowSearcher(HighlightField[] fields) { |
218 | | - MemoryIndex memoryIndex = new MemoryIndex(true); |
| 303 | + /** |
| 304 | + * Analyzes this row's values into the shared memory index. Returns {@code false} when filtering kept nothing the |
| 305 | + * query could match and {@code no_match_size} is 0, so every field of the row is {@code null} and the caller can |
| 306 | + * skip the highlighters. |
| 307 | + */ |
| 308 | + private boolean indexRow(HighlightField[] fields) { |
| 309 | + memoryIndex.reset(); |
| 310 | + boolean keptToken = false; |
219 | 311 | for (HighlightField field : fields) { |
220 | | - if (field.rowText != null) { |
| 312 | + if (field.rowText == null) { |
| 313 | + continue; |
| 314 | + } |
| 315 | + if (termsToKeep == null) { |
221 | 316 | memoryIndex.addField(field.name, field.rowText, memoryIndexAnalyzer); |
| 317 | + } else { |
| 318 | + TokenStream tokenStream = memoryIndexAnalyzer.tokenStream(field.name, field.rowText); |
| 319 | + KeepQueryTermsFilter filtered = new KeepQueryTermsFilter(tokenStream, termsToKeep); |
| 320 | + memoryIndex.addField(field.name, filtered); // addField resets and closes the stream |
| 321 | + keptToken |= filtered.keptToken; |
222 | 322 | } |
223 | 323 | } |
224 | | - return memoryIndex.createSearcher(); |
| 324 | + // With filtering off keptToken stays false, so it says nothing about the row. |
| 325 | + if (termsToKeep != null && keptToken == false && config.noMatchSize() == 0) { |
| 326 | + return false; |
| 327 | + } |
| 328 | + // MemoryIndex snapshots FieldInfos at reader construction, so create it after addField. |
| 329 | + memoryIndexReader = (LeafReader) memoryIndex.createSearcher().getIndexReader(); |
| 330 | + return true; |
| 331 | + } |
| 332 | + |
| 333 | + /** |
| 334 | + * Drops tokens the query cannot match, so the memory index only hashes and sorts the terms the query asks for. |
| 335 | + * {@link FilteringTokenFilter} accumulates the position increments of dropped tokens, so kept tokens keep their |
| 336 | + * original positions and phrase queries match as they would against an unfiltered index. |
| 337 | + * <p> |
| 338 | + * Query DSL highlighting gets the same filtering for free from Lucene's {@code MemoryIndexOffsetStrategy}, but that |
| 339 | + * strategy indexes one field at a time, and cross-field queries here need every ON field in one index. |
| 340 | + */ |
| 341 | + private static final class KeepQueryTermsFilter extends FilteringTokenFilter { |
| 342 | + private final CharArraySet terms; |
| 343 | + private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class); |
| 344 | + private boolean keptToken; |
| 345 | + |
| 346 | + KeepQueryTermsFilter(TokenStream in, CharArraySet terms) { |
| 347 | + super(in); |
| 348 | + this.terms = terms; |
| 349 | + } |
| 350 | + |
| 351 | + @Override |
| 352 | + protected boolean accept() { |
| 353 | + boolean keep = terms.contains(termAtt.buffer(), 0, termAtt.length()); |
| 354 | + keptToken |= keep; |
| 355 | + return keep; |
| 356 | + } |
225 | 357 | } |
226 | 358 |
|
227 | 359 | private static void appendNulls(HighlightField[] fields) { |
@@ -275,28 +407,10 @@ private static String joinValues(BytesRefBlock fieldValues, int row, int valueCo |
275 | 407 | return sb.toString(); |
276 | 408 | } |
277 | 409 |
|
278 | | - // TODO(perf): reuse a per-field CustomUnifiedHighlighter across rows; the Query is constant and the searcher |
279 | | - // argument is unused under POSTINGS + WEIGHT_MATCHES today (Lucene internal — guard with a multi-row test). |
280 | | - private Snippet[] highlight(IndexSearcher searcher, String field, String text) throws IOException { |
281 | | - UnifiedHighlighter.Builder builder = UnifiedHighlighter.builder(searcher, analyzer); |
282 | | - builder.withFormatter(formatter); |
283 | | - builder.withBreakIterator(breakIteratorSupplier); |
284 | | - CustomUnifiedHighlighter highlighter = new CustomUnifiedHighlighter( |
285 | | - builder, |
286 | | - UnifiedHighlighter.OffsetSource.POSTINGS, |
287 | | - null, |
288 | | - "", |
289 | | - field, |
290 | | - query, |
291 | | - config.noMatchSize(), |
292 | | - highlighterNumberOfFragments, |
293 | | - indexMaxAnalyzedOffset, |
294 | | - queryMaxAnalyzedOffset, |
295 | | - true, |
296 | | - true |
297 | | - ); |
298 | | - LeafReaderContext leaf = searcher.getIndexReader().leaves().getFirst(); |
299 | | - return highlighter.highlightField(leaf.reader(), 0, () -> text); |
| 410 | + // CustomUnifiedHighlighter derives its FieldHighlighter from the query at build time and caches nothing from the |
| 411 | + // reader, so the constructor's per-field instances can be reused for every row and page. |
| 412 | + private Snippet[] highlight(int fieldIndex, String text) throws IOException { |
| 413 | + return highlighters[fieldIndex].highlightField(memoryIndexReader, 0, () -> text); |
300 | 414 | } |
301 | 415 |
|
302 | 416 | /** |
|
0 commit comments