diff --git a/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/OffsetProducer.java b/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/OffsetProducer.java
new file mode 100644
index 0000000000..cc883c938d
--- /dev/null
+++ b/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/OffsetProducer.java
@@ -0,0 +1,312 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.search.uhighlight;
+
+import java.io.IOException;
+import java.util.Collection;
+import java.util.EnumSet;
+import java.util.HashSet;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.function.Predicate;
+
+import org.apache.lucene.analysis.Analyzer;
+import org.apache.lucene.index.FieldInfo;
+import org.apache.lucene.index.FieldInfos;
+import org.apache.lucene.index.IndexOptions;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.MultiTermQuery;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreMode;
+import org.apache.lucene.search.Weight;
+import org.apache.lucene.search.spans.SpanQuery;
+import org.apache.lucene.util.BytesRef;
+import org.apache.lucene.util.automaton.CharacterRunAutomaton;
+
+/**
+ * Makes match offsets visible for a given Query
+ */
+public class OffsetProducer {
+
+  private final Query query;
+  private final Set<Term> allTerms;
+  private final Predicate<String> defaultFieldMatcher;
+  private final FieldInfos fieldInfos;
+  private final Analyzer analyzer;
+  private final Set<UnifiedHighlighter.HighlightFlag> defaultFieldFlags;
+
+  /**
+   * Create an OffsetProducer for a highlighter and query
+   */
+  public OffsetProducer(Query query, Analyzer analyzer, FieldInfos fieldInfos,
+                        Predicate<String> defaultFieldMatcher, Set<UnifiedHighlighter.HighlightFlag> defaultFieldFlags) throws IOException {
+    this.query = query;
+    this.allTerms = extractTerms(query);
+    this.defaultFieldMatcher = defaultFieldMatcher;
+    this.analyzer = analyzer;
+    this.defaultFieldFlags = defaultFieldFlags;
+    this.fieldInfos = fieldInfos;
+  }
+
+  /**
+   * Returns a {@link FieldOffsetStrategy} for a specific field
+   */
+  public FieldOffsetStrategy getFieldOffsetStrategy(String field) {
+    BytesRef[] terms = filterExtractedTerms(getFieldMatcher(field), allTerms);
+    Set<UnifiedHighlighter.HighlightFlag> highlightFlags = getFlags(field);
+    PhraseHelper phraseHelper = getPhraseHelper(field, query, highlightFlags);
+    CharacterRunAutomaton[] automata = getAutomata(field, query, highlightFlags);
+    UnifiedHighlighter.OffsetSource offsetSource = getOptimizedOffsetSource(field, terms, phraseHelper, automata);
+    return getOffsetStrategy(offsetSource, field, terms, phraseHelper, automata, highlightFlags);
+  }
+
+  /**
+   * Returns an {@link OffsetsEnum} for a specific document
+   *
+   * The docid here is relative to an {@link org.apache.lucene.index.IndexReader} held by the parent
+   * highlighter
+   *
+   * @param field the field to highlight
+   * @param doc   the document to highlight
+   * @param text  the source text
+   */
+  public OffsetsEnum getFieldOffsets(String field, IndexReader reader, int doc, String text) throws IOException {
+    return getFieldOffsetStrategy(field).getOffsetsEnum(reader, doc, text);
+  }
+
+  public OffsetsEnum getFieldOffsets(String field, String text) throws IOException {
+    BytesRef[] terms = filterExtractedTerms(getFieldMatcher(field), allTerms);
+    Set<UnifiedHighlighter.HighlightFlag> highlightFlags = getFlags(field);
+    PhraseHelper phraseHelper = getPhraseHelper(field, query, highlightFlags);
+    CharacterRunAutomaton[] automata = getAutomata(field, query, highlightFlags);
+    FieldOffsetStrategy fos = getOffsetStrategy(UnifiedHighlighter.OffsetSource.ANALYSIS, field, terms, phraseHelper, automata, highlightFlags);
+    return fos.getOffsetsEnum(null, -1, text);
+  }
+
+  /**
+   * Returns whether {@link MultiTermQuery} derivatives will be highlighted.  By default it's enabled.  MTQ
+   * highlighting can be expensive, particularly when using offsets in postings.
+   */
+  protected boolean shouldHandleMultiTermQuery(String field) {
+    return defaultFieldFlags.contains(UnifiedHighlighter.HighlightFlag.MULTI_TERM_QUERY);
+  }
+
+  /**
+   * Returns whether position sensitive queries (e.g. phrases and {@link SpanQuery}ies)
+   * should be highlighted strictly based on query matches (slower)
+   * versus any/all occurrences of the underlying terms.  By default it's enabled, but there's no overhead if such
+   * queries aren't used.
+   */
+  protected boolean shouldHighlightPhrasesStrictly(String field) {
+    return defaultFieldFlags.contains(UnifiedHighlighter.HighlightFlag.PHRASES);
+  }
+
+
+  protected boolean shouldPreferPassageRelevancyOverSpeed(String field) {
+    return defaultFieldFlags.contains(UnifiedHighlighter.HighlightFlag.PASSAGE_RELEVANCY_OVER_SPEED);
+  }
+
+  /**
+   * Returns the predicate to use for extracting the query part that must be highlighted.
+   * By default only queries that target the current field are kept. (AKA requireFieldMatch)
+   */
+  protected Predicate<String> getFieldMatcher(String field) {
+    if (defaultFieldMatcher != null)
+      return defaultFieldMatcher;
+    return field::equals;
+  }
+
+  protected Set<UnifiedHighlighter.HighlightFlag> getFlags(String field) {
+    Set<UnifiedHighlighter.HighlightFlag> highlightFlags = EnumSet.noneOf(UnifiedHighlighter.HighlightFlag.class);
+    if (shouldHandleMultiTermQuery(field)) {
+      highlightFlags.add(UnifiedHighlighter.HighlightFlag.MULTI_TERM_QUERY);
+    }
+    if (shouldHighlightPhrasesStrictly(field)) {
+      highlightFlags.add(UnifiedHighlighter.HighlightFlag.PHRASES);
+    }
+    if (shouldPreferPassageRelevancyOverSpeed(field)) {
+      highlightFlags.add(UnifiedHighlighter.HighlightFlag.PASSAGE_RELEVANCY_OVER_SPEED);
+    }
+    return highlightFlags;
+  }
+
+  protected PhraseHelper getPhraseHelper(String field, Query query, Set<UnifiedHighlighter.HighlightFlag> highlightFlags) {
+    boolean highlightPhrasesStrictly = highlightFlags.contains(UnifiedHighlighter.HighlightFlag.PHRASES);
+    boolean handleMultiTermQuery = highlightFlags.contains(UnifiedHighlighter.HighlightFlag.MULTI_TERM_QUERY);
+    return highlightPhrasesStrictly ?
+        new PhraseHelper(query, field, getFieldMatcher(field),
+            this::requiresRewrite, this::preSpanQueryRewrite, !handleMultiTermQuery) : PhraseHelper.NONE;
+  }
+
+  protected static final CharacterRunAutomaton[] ZERO_LEN_AUTOMATA_ARRAY = new CharacterRunAutomaton[0];
+
+
+  protected CharacterRunAutomaton[] getAutomata(String field, Query query, Set<UnifiedHighlighter.HighlightFlag> highlightFlags) {
+    return highlightFlags.contains(UnifiedHighlighter.HighlightFlag.MULTI_TERM_QUERY)
+        ? MultiTermHighlighting.extractAutomata(query, getFieldMatcher(field), !highlightFlags.contains(UnifiedHighlighter.HighlightFlag.PHRASES), this::preMultiTermQueryRewrite)
+        : ZERO_LEN_AUTOMATA_ARRAY;
+  }
+
+  protected FieldOffsetStrategy getOffsetStrategy(UnifiedHighlighter.OffsetSource offsetSource, String field, BytesRef[] terms,
+                                                  PhraseHelper phraseHelper, CharacterRunAutomaton[] automata,
+                                                  Set<UnifiedHighlighter.HighlightFlag> highlightFlags) {
+    switch (offsetSource) {
+      case ANALYSIS:
+        if (!phraseHelper.hasPositionSensitivity() &&
+            !highlightFlags.contains(UnifiedHighlighter.HighlightFlag.PASSAGE_RELEVANCY_OVER_SPEED)) {
+          //skip using a memory index since it's pure term filtering
+          return new TokenStreamOffsetStrategy(field, terms, phraseHelper, automata, analyzer);
+        } else {
+          return new MemoryIndexOffsetStrategy(field, getFieldMatcher(field), terms, phraseHelper, automata, analyzer,
+              this::preMultiTermQueryRewrite);
+        }
+      case NONE_NEEDED:
+        return NoOpOffsetStrategy.INSTANCE;
+      case TERM_VECTORS:
+        return new TermVectorOffsetStrategy(field, terms, phraseHelper, automata);
+      case POSTINGS:
+        return new PostingsOffsetStrategy(field, terms, phraseHelper, automata);
+      case POSTINGS_WITH_TERM_VECTORS:
+        return new PostingsWithTermVectorsOffsetStrategy(field, terms, phraseHelper, automata);
+      default:
+        throw new IllegalArgumentException("Unrecognized offset source " + offsetSource);
+    }
+  }
+
+  /**
+   * Determine the offset source for the specified field.  The default algorithm is as follows:
+   * <ol>
+   * <li> If there's the field has
+   * {@link IndexOptions#DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS} then {@link UnifiedHighlighter.OffsetSource#POSTINGS} is
+   * returned.</li>
+   * <li>If there's a field info and {@link FieldInfo#hasVectors()} then {@link UnifiedHighlighter.OffsetSource#TERM_VECTORS} is
+   * returned (note we can't check here if the TV has offsets; if there isn't then an exception will get thrown
+   * down the line).</li>
+   * <li>Fall-back: {@link UnifiedHighlighter.OffsetSource#ANALYSIS} is returned.</li>
+   * </ol>
+   * <p>
+   * Note that the highlighter sometimes switches to something else based on the query, such as if you have
+   * {@link UnifiedHighlighter.OffsetSource#POSTINGS_WITH_TERM_VECTORS} but in fact don't need term vectors.
+   */
+  protected UnifiedHighlighter.OffsetSource getOffsetSource(String field) {
+    FieldInfo fieldInfo = fieldInfos == null ? null : fieldInfos.fieldInfo(field);
+    if (fieldInfo != null) {
+      if (fieldInfo.getIndexOptions() == IndexOptions.DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS) {
+        return fieldInfo.hasVectors() ? UnifiedHighlighter.OffsetSource.POSTINGS_WITH_TERM_VECTORS : UnifiedHighlighter.OffsetSource.POSTINGS;
+      }
+      if (fieldInfo.hasVectors()) { // unfortunately we can't also check if the TV has offsets
+        return UnifiedHighlighter.OffsetSource.TERM_VECTORS;
+      }
+    }
+    return UnifiedHighlighter.OffsetSource.ANALYSIS;
+  }
+
+  protected UnifiedHighlighter.OffsetSource getOptimizedOffsetSource(String field, BytesRef[] terms, PhraseHelper phraseHelper, CharacterRunAutomaton[] automata) {
+    UnifiedHighlighter.OffsetSource offsetSource = getOffsetSource(field);
+
+    if (terms.length == 0 && automata.length == 0 && !phraseHelper.willRewrite()) {
+      return UnifiedHighlighter.OffsetSource.NONE_NEEDED; //nothing to highlight
+    }
+
+    switch (offsetSource) {
+      case POSTINGS:
+        if (phraseHelper.willRewrite()) {
+          // We can't choose the postings offset source when there is "rewriting" in the strict phrase
+          // processing (rare but possible). Postings requires knowing all the terms (except wildcards)
+          // up front.
+          return UnifiedHighlighter.OffsetSource.ANALYSIS;
+        } else if (automata.length > 0) {
+          return UnifiedHighlighter.OffsetSource.ANALYSIS;
+        }
+        break;
+      case POSTINGS_WITH_TERM_VECTORS:
+        if (!phraseHelper.willRewrite() && automata.length == 0) {
+          return UnifiedHighlighter.OffsetSource.POSTINGS; //We don't need term vectors
+        }
+        break;
+      case ANALYSIS:
+      case TERM_VECTORS:
+      case NONE_NEEDED:
+      default:
+        //stick with the original offset source
+        break;
+    }
+
+    return offsetSource;
+  }
+
+  /**
+   * When highlighting phrases accurately, we need to know which {@link SpanQuery}'s need to have
+   * {@link Query#rewrite(IndexReader)} called on them.  It helps performance to avoid it if it's not needed.
+   * This method will be invoked on all SpanQuery instances recursively. If you have custom SpanQuery queries then
+   * override this to check instanceof and provide a definitive answer. If the query isn't your custom one, simply
+   * return null to have the default rules apply, which govern the ones included in Lucene.
+   */
+  protected Boolean requiresRewrite(SpanQuery spanQuery) {
+    return null;
+  }
+
+  /**
+   * When highlighting phrases accurately, we may need to handle custom queries that aren't supported in the
+   * {@link org.apache.lucene.search.highlight.WeightedSpanTermExtractor} as called by the {@code PhraseHelper}.
+   * Should custom query types be needed, this method should be overriden to return a collection of queries if appropriate,
+   * or null if nothing to do. If the query is not custom, simply returning null will allow the default rules to apply.
+   *
+   * @param query Query to be highlighted
+   * @return A Collection of Query object(s) if needs to be rewritten, otherwise null.
+   */
+  protected Collection<Query> preSpanQueryRewrite(Query query) {
+    return null;
+  }
+
+  /**
+   * When dealing with multi term queries / span queries, we may need to handle custom queries that aren't supported
+   * by the default automata extraction in {@code MultiTermHighlighting}. This can be overridden to return a collection
+   * of queries if appropriate, or null if nothing to do. If query is not custom, simply returning null will allow the
+   * default rules to apply.
+   *
+   * @param query Query to be highlighted
+   * @return A Collection of Query object(s) if needst o be rewritten, otherwise null.
+   */
+  protected Collection<Query> preMultiTermQueryRewrite(Query query) {
+    return null;
+  }
+
+  private static BytesRef[] filterExtractedTerms(Predicate<String> fieldMatcher, Set<Term> queryTerms) {
+    // Strip off the redundant field and sort the remaining terms
+    SortedSet<BytesRef> filteredTerms = new TreeSet<>();
+    for (Term term : queryTerms) {
+      if (fieldMatcher.test(term.field())) {
+        filteredTerms.add(term.bytes());
+      }
+    }
+    return filteredTerms.toArray(new BytesRef[filteredTerms.size()]);
+  }
+
+  /**
+   * Calls {@link Weight#extractTerms(Set)} on an empty index for the query.
+   */
+  private static Set<Term> extractTerms(Query query) throws IOException {
+    Set<Term> queryTerms = new HashSet<>();
+    UnifiedHighlighter.EMPTY_INDEXSEARCHER.createNormalizedWeight(query, ScoreMode.COMPLETE_NO_SCORES).extractTerms(queryTerms);
+    return queryTerms;
+  }
+
+}
diff --git a/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java b/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java
index 065ad5ce95..aeb181517d 100644
--- a/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java
+++ b/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java
@@ -21,17 +21,13 @@ import java.nio.charset.StandardCharsets;
 import java.text.BreakIterator;
 import java.util.ArrayList;
 import java.util.Arrays;
-import java.util.Collection;
 import java.util.EnumSet;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
 import java.util.Set;
-import java.util.SortedSet;
-import java.util.TreeSet;
 import java.util.function.Predicate;
 import java.util.function.Supplier;
 
@@ -49,19 +45,12 @@ import org.apache.lucene.index.LeafReaderContext;
 import org.apache.lucene.index.MultiFields;
 import org.apache.lucene.index.MultiReader;
 import org.apache.lucene.index.StoredFieldVisitor;
-import org.apache.lucene.index.Term;
 import org.apache.lucene.search.DocIdSetIterator;
 import org.apache.lucene.search.IndexSearcher;
-import org.apache.lucene.search.MultiTermQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.ScoreDoc;
-import org.apache.lucene.search.ScoreMode;
 import org.apache.lucene.search.TopDocs;
-import org.apache.lucene.search.Weight;
-import org.apache.lucene.search.spans.SpanQuery;
-import org.apache.lucene.util.BytesRef;
 import org.apache.lucene.util.InPlaceMergeSorter;
-import org.apache.lucene.util.automaton.CharacterRunAutomaton;
 
 /**
  * A Highlighter that can get offsets from either
@@ -109,44 +98,30 @@ public class UnifiedHighlighter {
     }
   }
 
-  protected static final CharacterRunAutomaton[] ZERO_LEN_AUTOMATA_ARRAY = new CharacterRunAutomaton[0];
-
   protected final IndexSearcher searcher; // if null, can only use highlightWithoutSearcher
 
   protected final Analyzer indexAnalyzer;
 
-  private boolean defaultHandleMtq = true; // e.g. wildcards
-
-  private boolean defaultHighlightPhrasesStrictly = true; // AKA "accuracy" or "query debugging"
+  protected final EnumSet<HighlightFlag> defaultHighlightFlags
+      = EnumSet.of(HighlightFlag.MULTI_TERM_QUERY, HighlightFlag.PHRASES, HighlightFlag.PASSAGE_RELEVANCY_OVER_SPEED);
 
-  private boolean defaultPassageRelevancyOverSpeed = true; //For analysis, prefer MemoryIndexOffsetStrategy
+  protected Predicate<String> defaultFieldMatcher;
 
   private int maxLength = DEFAULT_MAX_LENGTH;
 
   // BreakIterator is stateful so we use a Supplier factory method
   private Supplier<BreakIterator> defaultBreakIterator = () -> BreakIterator.getSentenceInstance(Locale.ROOT);
 
-  private Predicate<String> defaultFieldMatcher;
-
   private PassageScorer defaultScorer = new PassageScorer();
 
   private PassageFormatter defaultFormatter = new DefaultPassageFormatter();
 
   private int defaultMaxNoHighlightPassages = -1;
 
-  // lazy initialized with double-check locking; protected so subclass can init
-  protected volatile FieldInfos fieldInfos;
-
   private int cacheFieldValCharsThreshold = DEFAULT_CACHE_CHARS_THRESHOLD;
 
-  /**
-   * Calls {@link Weight#extractTerms(Set)} on an empty index for the query.
-   */
-  protected static Set<Term> extractTerms(Query query) throws IOException {
-    Set<Term> queryTerms = new HashSet<>();
-    EMPTY_INDEXSEARCHER.createNormalizedWeight(query, ScoreMode.COMPLETE_NO_SCORES).extractTerms(queryTerms);
-    return queryTerms;
-  }
+  // Lazily constructed
+  private volatile FieldInfos fieldInfos;
 
   /**
    * Constructs the highlighter with the given index searcher and analyzer.
@@ -163,11 +138,17 @@ public class UnifiedHighlighter {
   }
 
   public void setHandleMultiTermQuery(boolean handleMtq) {
-    this.defaultHandleMtq = handleMtq;
+    if (handleMtq)
+      defaultHighlightFlags.add(HighlightFlag.MULTI_TERM_QUERY);
+    else
+      defaultHighlightFlags.remove(HighlightFlag.MULTI_TERM_QUERY);
   }
 
   public void setHighlightPhrasesStrictly(boolean highlightPhrasesStrictly) {
-    this.defaultHighlightPhrasesStrictly = highlightPhrasesStrictly;
+    if (highlightPhrasesStrictly)
+      defaultHighlightFlags.add(HighlightFlag.PHRASES);
+    else
+      defaultHighlightFlags.remove(HighlightFlag.PHRASES);
   }
 
   public void setMaxLength(int maxLength) {
@@ -199,44 +180,8 @@ public class UnifiedHighlighter {
     this.cacheFieldValCharsThreshold = cacheFieldValCharsThreshold;
   }
 
-  public void setFieldMatcher(Predicate<String> predicate) {
-    this.defaultFieldMatcher = predicate;
-  }
-
-  /**
-   * Returns whether {@link MultiTermQuery} derivatives will be highlighted.  By default it's enabled.  MTQ
-   * highlighting can be expensive, particularly when using offsets in postings.
-   */
-  protected boolean shouldHandleMultiTermQuery(String field) {
-    return defaultHandleMtq;
-  }
-
-  /**
-   * Returns whether position sensitive queries (e.g. phrases and {@link SpanQuery}ies)
-   * should be highlighted strictly based on query matches (slower)
-   * versus any/all occurrences of the underlying terms.  By default it's enabled, but there's no overhead if such
-   * queries aren't used.
-   */
-  protected boolean shouldHighlightPhrasesStrictly(String field) {
-    return defaultHighlightPhrasesStrictly;
-  }
-
-
-  protected boolean shouldPreferPassageRelevancyOverSpeed(String field) {
-    return defaultPassageRelevancyOverSpeed;
-  }
-
-  /**
-   * Returns the predicate to use for extracting the query part that must be highlighted.
-   * By default only queries that target the current field are kept. (AKA requireFieldMatch)
-   */
-  protected Predicate<String> getFieldMatcher(String field) {
-    if (defaultFieldMatcher != null) {
-      return defaultFieldMatcher;
-    } else {
-      // requireFieldMatch = true
-      return (qf) -> field.equals(qf);
-    }
+  public void setFieldMatcher(Predicate<String> matcher) {
+    this.defaultFieldMatcher = matcher;
   }
 
   /**
@@ -325,60 +270,6 @@ public class UnifiedHighlighter {
     POSTINGS, TERM_VECTORS, ANALYSIS, POSTINGS_WITH_TERM_VECTORS, NONE_NEEDED
   }
 
-  /**
-   * Determine the offset source for the specified field.  The default algorithm is as follows:
-   * <ol>
-   * <li>This calls {@link #getFieldInfo(String)}. Note this returns null if there is no searcher or if the
-   * field isn't found there.</li>
-   * <li> If there's a field info it has
-   * {@link IndexOptions#DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS} then {@link OffsetSource#POSTINGS} is
-   * returned.</li>
-   * <li>If there's a field info and {@link FieldInfo#hasVectors()} then {@link OffsetSource#TERM_VECTORS} is
-   * returned (note we can't check here if the TV has offsets; if there isn't then an exception will get thrown
-   * down the line).</li>
-   * <li>Fall-back: {@link OffsetSource#ANALYSIS} is returned.</li>
-   * </ol>
-   * <p>
-   * Note that the highlighter sometimes switches to something else based on the query, such as if you have
-   * {@link OffsetSource#POSTINGS_WITH_TERM_VECTORS} but in fact don't need term vectors.
-   */
-  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()) { // unfortunately we can't also check if the TV has offsets
-        return OffsetSource.TERM_VECTORS;
-      }
-    }
-    return OffsetSource.ANALYSIS;
-  }
-
-  /**
-   * Called by the default implementation of {@link #getOffsetSource(String)}.
-   * If there is no searcher then we simply always return null.
-   */
-  protected FieldInfo getFieldInfo(String field) {
-    if (searcher == null) {
-      return null;
-    }
-    // Need thread-safety for lazy-init but lets avoid 'synchronized' by using double-check locking idiom
-    FieldInfos fieldInfos = this.fieldInfos; // note: it's volatile; read once
-    if (fieldInfos == null) {
-      synchronized (this) {
-        fieldInfos = this.fieldInfos;
-        if (fieldInfos == null) {
-          fieldInfos = MultiFields.getMergedFieldInfos(searcher.getIndexReader());
-          this.fieldInfos = fieldInfos;
-        }
-
-      }
-
-    }
-    return fieldInfos.fieldInfo(field);
-  }
-
   /**
    * Highlights the top passages from a single field.
    *
@@ -520,6 +411,38 @@ public class UnifiedHighlighter {
     return snippets;
   }
 
+  public OffsetProducer getOffsetProducer(Query query) throws IOException {
+    return getOffsetProducer(query, indexAnalyzer, getFieldInfos(), defaultFieldMatcher, defaultHighlightFlags);
+  }
+
+  protected OffsetProducer getOffsetProducer(Query query, Analyzer analyzer, FieldInfos fieldInfos,
+                                             Predicate<String> defaultFieldMatcher, Set<HighlightFlag> defaultFieldFlags) throws IOException {
+    return new OffsetProducer(query, analyzer, fieldInfos, defaultFieldMatcher, defaultFieldFlags);
+  }
+
+  /**
+   * If there is no searcher then we simply always return null.
+   */
+  protected FieldInfos getFieldInfos() {
+    if (searcher == null) {
+      return null;
+    }
+    // Need thread-safety for lazy-init but lets avoid 'synchronized' by using double-check locking idiom
+    FieldInfos fieldInfos = this.fieldInfos; // note: it's volatile; read once
+    if (fieldInfos == null) {
+      synchronized (this) {
+        fieldInfos = this.fieldInfos;
+        if (fieldInfos == null) {
+          fieldInfos = MultiFields.getMergedFieldInfos(searcher.getIndexReader());
+          this.fieldInfos = fieldInfos;
+        }
+
+      }
+
+    }
+    return fieldInfos;
+  }
+
   /**
    * Expert: highlights the top-N passages from multiple fields,
    * for the provided int[] docids, to custom Object as
@@ -566,12 +489,12 @@ public class UnifiedHighlighter {
     copyAndSortFieldsWithMaxPassages(fieldsIn, maxPassagesIn, fields, maxPassages); // latter 2 are "out" params
 
     // Init field highlighters (where most of the highlight logic lives, and on a per field basis)
-    Set<Term> queryTerms = extractTerms(query);
+    OffsetProducer offsetProducer = getOffsetProducer(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]);
+      FieldHighlighter fieldHighlighter = getFieldHighlighter(fields[f], offsetProducer, maxPassages[f]);
       fieldHighlighters[f] = fieldHighlighter;
 
       switch (fieldHighlighter.getOffsetSource()) {
@@ -736,19 +659,13 @@ public class UnifiedHighlighter {
           getClass().getSimpleName() + " without an IndexSearcher.");
     }
     Objects.requireNonNull(content, "content is required");
-    Set<Term> queryTerms = extractTerms(query);
-    return getFieldHighlighter(field, query, queryTerms, maxPassages)
+    return getFieldHighlighter(field, getOffsetProducer(query), maxPassages)
         .highlightFieldForDoc(null, -1, content);
   }
 
-  protected FieldHighlighter getFieldHighlighter(String field, Query query, Set<Term> allTerms, int maxPassages) {
-    BytesRef[] terms = filterExtractedTerms(getFieldMatcher(field), allTerms);
-    Set<HighlightFlag> highlightFlags = getFlags(field);
-    PhraseHelper phraseHelper = getPhraseHelper(field, query, highlightFlags);
-    CharacterRunAutomaton[] automata = getAutomata(field, query, highlightFlags);
-    OffsetSource offsetSource = getOptimizedOffsetSource(field, terms, phraseHelper, automata);
+  protected FieldHighlighter getFieldHighlighter(String field, OffsetProducer offsetProducer, int maxPassages) {
     return new FieldHighlighter(field,
-        getOffsetStrategy(offsetSource, field, terms, phraseHelper, automata, highlightFlags),
+        offsetProducer.getFieldOffsetStrategy(field),
         new SplittingBreakIterator(getBreakIterator(field), UnifiedHighlighter.MULTIVAL_SEP_CHAR),
         getScorer(field),
         maxPassages,
@@ -756,142 +673,6 @@ public class UnifiedHighlighter {
         getFormatter(field));
   }
 
-  protected static BytesRef[] filterExtractedTerms(Predicate<String> fieldMatcher, Set<Term> queryTerms) {
-    // Strip off the redundant field and sort the remaining terms
-    SortedSet<BytesRef> filteredTerms = new TreeSet<>();
-    for (Term term : queryTerms) {
-      if (fieldMatcher.test(term.field())) {
-        filteredTerms.add(term.bytes());
-      }
-    }
-    return filteredTerms.toArray(new BytesRef[filteredTerms.size()]);
-  }
-
-  protected Set<HighlightFlag> getFlags(String field) {
-    Set<HighlightFlag> highlightFlags = EnumSet.noneOf(HighlightFlag.class);
-    if (shouldHandleMultiTermQuery(field)) {
-      highlightFlags.add(HighlightFlag.MULTI_TERM_QUERY);
-    }
-    if (shouldHighlightPhrasesStrictly(field)) {
-      highlightFlags.add(HighlightFlag.PHRASES);
-    }
-    if (shouldPreferPassageRelevancyOverSpeed(field)) {
-      highlightFlags.add(HighlightFlag.PASSAGE_RELEVANCY_OVER_SPEED);
-    }
-    return highlightFlags;
-  }
-
-  protected PhraseHelper getPhraseHelper(String field, Query query, Set<HighlightFlag> highlightFlags) {
-    boolean highlightPhrasesStrictly = highlightFlags.contains(HighlightFlag.PHRASES);
-    boolean handleMultiTermQuery = highlightFlags.contains(HighlightFlag.MULTI_TERM_QUERY);
-    return highlightPhrasesStrictly ?
-        new PhraseHelper(query, field, getFieldMatcher(field),
-            this::requiresRewrite, this::preSpanQueryRewrite, !handleMultiTermQuery) : PhraseHelper.NONE;
-  }
-
-  protected CharacterRunAutomaton[] getAutomata(String field, Query query, Set<HighlightFlag> highlightFlags) {
-    return highlightFlags.contains(HighlightFlag.MULTI_TERM_QUERY)
-        ? MultiTermHighlighting.extractAutomata(query, getFieldMatcher(field), !highlightFlags.contains(HighlightFlag.PHRASES), this::preMultiTermQueryRewrite)
-        : ZERO_LEN_AUTOMATA_ARRAY;
-  }
-
-  protected OffsetSource getOptimizedOffsetSource(String field, BytesRef[] terms, PhraseHelper phraseHelper, CharacterRunAutomaton[] automata) {
-    OffsetSource offsetSource = getOffsetSource(field);
-
-    if (terms.length == 0 && automata.length == 0 && !phraseHelper.willRewrite()) {
-      return OffsetSource.NONE_NEEDED; //nothing to highlight
-    }
-
-    switch (offsetSource) {
-      case POSTINGS:
-        if (phraseHelper.willRewrite()) {
-          // We can't choose the postings offset source when there is "rewriting" in the strict phrase
-          // processing (rare but possible). Postings requires knowing all the terms (except wildcards)
-          // up front.
-          return OffsetSource.ANALYSIS;
-        } else if (automata.length > 0) {
-          return OffsetSource.ANALYSIS;
-        }
-        break;
-      case POSTINGS_WITH_TERM_VECTORS:
-        if (!phraseHelper.willRewrite() && automata.length == 0) {
-          return OffsetSource.POSTINGS; //We don't need term vectors
-        }
-        break;
-      case ANALYSIS:
-      case TERM_VECTORS:
-      case NONE_NEEDED:
-      default:
-        //stick with the original offset source
-        break;
-    }
-
-    return offsetSource;
-  }
-
-  protected FieldOffsetStrategy getOffsetStrategy(OffsetSource offsetSource, String field, BytesRef[] terms,
-                                                  PhraseHelper phraseHelper, CharacterRunAutomaton[] automata,
-                                                  Set<HighlightFlag> highlightFlags) {
-    switch (offsetSource) {
-      case ANALYSIS:
-        if (!phraseHelper.hasPositionSensitivity() &&
-            !highlightFlags.contains(HighlightFlag.PASSAGE_RELEVANCY_OVER_SPEED)) {
-          //skip using a memory index since it's pure term filtering
-          return new TokenStreamOffsetStrategy(field, terms, phraseHelper, automata, getIndexAnalyzer());
-        } else {
-          return new MemoryIndexOffsetStrategy(field, getFieldMatcher(field), terms, phraseHelper, automata, getIndexAnalyzer(),
-              this::preMultiTermQueryRewrite);
-        }
-      case NONE_NEEDED:
-        return NoOpOffsetStrategy.INSTANCE;
-      case TERM_VECTORS:
-        return new TermVectorOffsetStrategy(field, terms, phraseHelper, automata);
-      case POSTINGS:
-        return new PostingsOffsetStrategy(field, terms, phraseHelper, automata);
-      case POSTINGS_WITH_TERM_VECTORS:
-        return new PostingsWithTermVectorsOffsetStrategy(field, terms, phraseHelper, automata);
-      default:
-        throw new IllegalArgumentException("Unrecognized offset source " + offsetSource);
-    }
-  }
-
-  /**
-   * When highlighting phrases accurately, we need to know which {@link SpanQuery}'s need to have
-   * {@link Query#rewrite(IndexReader)} called on them.  It helps performance to avoid it if it's not needed.
-   * This method will be invoked on all SpanQuery instances recursively. If you have custom SpanQuery queries then
-   * override this to check instanceof and provide a definitive answer. If the query isn't your custom one, simply
-   * return null to have the default rules apply, which govern the ones included in Lucene.
-   */
-  protected Boolean requiresRewrite(SpanQuery spanQuery) {
-    return null;
-  }
-
-  /**
-   * When highlighting phrases accurately, we may need to handle custom queries that aren't supported in the
-   * {@link org.apache.lucene.search.highlight.WeightedSpanTermExtractor} as called by the {@code PhraseHelper}.
-   * Should custom query types be needed, this method should be overriden to return a collection of queries if appropriate,
-   * or null if nothing to do. If the query is not custom, simply returning null will allow the default rules to apply.
-   *
-   * @param query Query to be highlighted
-   * @return A Collection of Query object(s) if needs to be rewritten, otherwise null.
-   */
-  protected Collection<Query> preSpanQueryRewrite(Query query) {
-    return null;
-  }
-
-  /**
-   * When dealing with multi term queries / span queries, we may need to handle custom queries that aren't supported
-   * by the default automata extraction in {@code MultiTermHighlighting}. This can be overridden to return a collection
-   * of queries if appropriate, or null if nothing to do. If query is not custom, simply returning null will allow the
-   * default rules to apply.
-   *
-   * @param query Query to be highlighted
-   * @return A Collection of Query object(s) if needst o be rewritten, otherwise null.
-   */
-  protected Collection<Query> preMultiTermQueryRewrite(Query query) {
-    return null;
-  }
-
   private DocIdSetIterator asDocIdSetIterator(int[] sortedDocIds) {
     return new DocIdSetIterator() {
       int idx = -1;
diff --git a/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestHighlighterOffsets.java b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestHighlighterOffsets.java
new file mode 100644
index 0000000000..c6a384822d
--- /dev/null
+++ b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestHighlighterOffsets.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.lucene.search.uhighlight;
+
+import java.io.IOException;
+
+import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+import org.apache.lucene.analysis.MockAnalyzer;
+import org.apache.lucene.analysis.MockTokenizer;
+import org.apache.lucene.document.Document;
+import org.apache.lucene.document.Field;
+import org.apache.lucene.document.FieldType;
+import org.apache.lucene.index.IndexReader;
+import org.apache.lucene.index.RandomIndexWriter;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.TermQuery;
+import org.apache.lucene.store.Directory;
+import org.apache.lucene.util.LuceneTestCase;
+import org.junit.After;
+import org.junit.Before;
+
+import static org.apache.lucene.search.uhighlight.TestUnifiedHighlighter.randomUnifiedHighlighter;
+
+public class TestHighlighterOffsets extends LuceneTestCase {
+
+  private final FieldType fieldType; // for "body" generally, but not necessarily others. See constructor
+
+  private MockAnalyzer indexAnalyzer;
+  private Directory dir;
+
+  @ParametersFactory
+  public static Iterable<Object[]> parameters() {
+    return UHTestHelper.parametersFactoryList();
+  }
+
+  public TestHighlighterOffsets(FieldType fieldType) {
+    this.fieldType = fieldType;
+  }
+
+
+  @Before
+  public void doBefore() throws IOException {
+    indexAnalyzer = new MockAnalyzer(random(), MockTokenizer.SIMPLE, true);//whitespace, punctuation, lowercase
+    dir = newDirectory();
+  }
+
+  @After
+  public void doAfter() throws IOException {
+    dir.close();
+  }
+
+  public void testRetrieveOffsets() throws IOException {
+    RandomIndexWriter iw = new RandomIndexWriter(random(), dir, indexAnalyzer);
+
+    Document doc = new Document();
+
+    final String value = "This is a multivalued field. Sentencetwo field.";
+    doc.add(new Field("body", value, fieldType));
+
+    iw.addDocument(doc);
+
+    IndexReader ir = iw.getReader();
+    iw.close();
+
+    IndexSearcher searcher = newSearcher(ir);
+    UnifiedHighlighter highlighter = randomUnifiedHighlighter(searcher, indexAnalyzer);
+    highlighter.setMaxLength(value.length() * 2 + 1);
+    Query query = new TermQuery(new Term("body", "field"));
+
+    OffsetsEnum oe = highlighter.getOffsetProducer(query).getFieldOffsets("body", ir, 0, value);
+    assertTrue(oe.nextPosition());
+    assertEquals(22, oe.startOffset());
+    assertEquals(27, oe.endOffset());
+    assertTrue(oe.nextPosition());
+    assertEquals(41, oe.startOffset());
+    assertEquals(46, oe.endOffset());
+    assertFalse(oe.nextPosition());
+
+    ir.close();
+  }
+}
diff --git a/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java
index 086d7a03cf..30a4a5cbec 100644
--- a/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java
+++ b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java
@@ -36,6 +36,7 @@ import org.apache.lucene.analysis.MockTokenizer;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.FieldType;
+import org.apache.lucene.index.FieldInfos;
 import org.apache.lucene.index.IndexOptions;
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.RandomIndexWriter;
@@ -96,15 +97,16 @@ public class TestUnifiedHighlighter extends LuceneTestCase {
     } else {
       final UnifiedHighlighter uh = new UnifiedHighlighter(searcher, indexAnalyzer) {
         @Override
-        protected Set<HighlightFlag> getFlags(String field) {
-          final EnumSet<HighlightFlag> result = EnumSet.copyOf(mandatoryFlags);
+        protected OffsetProducer getOffsetProducer(Query query, Analyzer analyzer, FieldInfos fieldInfos,
+                                                   Predicate<String> defaultFieldMatcher, Set<HighlightFlag> defaultFieldFlags) throws IOException {
+          final EnumSet<HighlightFlag> newFlags = EnumSet.copyOf(mandatoryFlags);
           int r = random().nextInt();
           for (HighlightFlag highlightFlag : HighlightFlag.values()) {
             if (((1 << highlightFlag.ordinal()) & r) == 0) {
-              result.add(highlightFlag);
+              newFlags.add(highlightFlag);
             }
           }
-          return result;
+          return super.getOffsetProducer(query, analyzer, fieldInfos, defaultFieldMatcher, newFlags);
         }
       };
       uh.setCacheFieldValCharsThreshold(random().nextInt(100));
@@ -1022,13 +1024,8 @@ public class TestUnifiedHighlighter extends LuceneTestCase {
   public void testFieldMatcherTermQuery() throws Exception {
     IndexReader ir = indexSomeFields();
     IndexSearcher searcher = newSearcher(ir);
-    UnifiedHighlighter highlighterNoFieldMatch = new UnifiedHighlighter(searcher, indexAnalyzer) {
-      @Override
-      protected Predicate<String> getFieldMatcher(String field) {
-        // requireFieldMatch=false
-        return (qf) -> true;
-      }
-    };
+    UnifiedHighlighter highlighterNoFieldMatch = new UnifiedHighlighter(searcher, indexAnalyzer);
+    highlighterNoFieldMatch.setFieldMatcher(qf -> true);
     UnifiedHighlighter highlighterFieldMatch = randomUnifiedHighlighter(searcher, indexAnalyzer);
     highlighterFieldMatch.setFieldMatcher(null);//default
     BooleanQuery.Builder queryBuilder =
@@ -1106,13 +1103,8 @@ public class TestUnifiedHighlighter extends LuceneTestCase {
   public void testFieldMatcherMultiTermQuery() throws Exception {
     IndexReader ir = indexSomeFields();
     IndexSearcher searcher = newSearcher(ir);
-    UnifiedHighlighter highlighterNoFieldMatch = new UnifiedHighlighter(searcher, indexAnalyzer) {
-      @Override
-      protected Predicate<String> getFieldMatcher(String field) {
-        // requireFieldMatch=false
-        return (qf) -> true;
-      }
-    };
+    UnifiedHighlighter highlighterNoFieldMatch = new UnifiedHighlighter(searcher, indexAnalyzer);
+    highlighterNoFieldMatch.setFieldMatcher(qf -> true);
     UnifiedHighlighter highlighterFieldMatch = randomUnifiedHighlighter(searcher, indexAnalyzer, EnumSet.of(HighlightFlag.MULTI_TERM_QUERY));
     highlighterFieldMatch.setFieldMatcher(null);//default
     BooleanQuery.Builder queryBuilder =
@@ -1190,13 +1182,8 @@ public class TestUnifiedHighlighter extends LuceneTestCase {
   public void testFieldMatcherPhraseQuery() throws Exception {
     IndexReader ir = indexSomeFields();
     IndexSearcher searcher = newSearcher(ir);
-    UnifiedHighlighter highlighterNoFieldMatch = new UnifiedHighlighter(searcher, indexAnalyzer) {
-      @Override
-      protected Predicate<String> getFieldMatcher(String field) {
-        // requireFieldMatch=false
-        return (qf) -> true;
-      }
-    };
+    UnifiedHighlighter highlighterNoFieldMatch = new UnifiedHighlighter(searcher, indexAnalyzer);
+    highlighterNoFieldMatch.setFieldMatcher(qf -> true);
     UnifiedHighlighter highlighterFieldMatch = randomUnifiedHighlighter(searcher, indexAnalyzer, EnumSet.of(HighlightFlag.PHRASES));
     highlighterFieldMatch.setFieldMatcher(null);//default
     BooleanQuery.Builder queryBuilder =
diff --git a/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterMTQ.java b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterMTQ.java
index a9fadc0175..ccf82ab550 100644
--- a/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterMTQ.java
+++ b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterMTQ.java
@@ -19,10 +19,12 @@ package org.apache.lucene.search.uhighlight;
 
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
-import java.util.List;
 import java.util.Objects;
+import java.util.Set;
+import java.util.function.Predicate;
 
 import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
 import com.carrotsearch.randomizedtesting.generators.RandomStrings;
@@ -36,6 +38,7 @@ import org.apache.lucene.analysis.standard.StandardAnalyzer;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.FieldType;
+import org.apache.lucene.index.FieldInfos;
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.RandomIndexWriter;
 import org.apache.lucene.index.Term;
@@ -1001,11 +1004,16 @@ public class TestUnifiedHighlighterMTQ extends LuceneTestCase {
     IndexSearcher searcher = newSearcher(ir);
     UnifiedHighlighter highlighter = new UnifiedHighlighter(searcher, indexAnalyzer) {
       @Override
-      protected List<Query> preMultiTermQueryRewrite(Query query) {
-        if (query instanceof MyWrapperSpanQuery) {
-          return Collections.singletonList(((MyWrapperSpanQuery) query).originalQuery);
-        }
-        return null;
+      protected OffsetProducer getOffsetProducer(Query query, Analyzer analyzer, FieldInfos fieldInfos, Predicate<String> defaultFieldMatcher, Set<HighlightFlag> defaultFieldFlags) throws IOException {
+        return new OffsetProducer(query, analyzer, fieldInfos, defaultFieldMatcher, defaultFieldFlags){
+          @Override
+          protected Collection<Query> preMultiTermQueryRewrite(Query query) {
+            if (query instanceof MyWrapperSpanQuery) {
+              return Collections.singletonList(((MyWrapperSpanQuery) query).originalQuery);
+            }
+            return null;
+          }
+        };
       }
     };
 
diff --git a/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterStrictPhrases.java b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterStrictPhrases.java
index 08820aa543..3722db68d3 100644
--- a/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterStrictPhrases.java
+++ b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterStrictPhrases.java
@@ -20,13 +20,17 @@ import java.io.IOException;
 import java.util.Arrays;
 import java.util.Collection;
 import java.util.Collections;
+import java.util.Set;
+import java.util.function.Predicate;
 
 import com.carrotsearch.randomizedtesting.annotations.ParametersFactory;
+import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.analysis.MockAnalyzer;
 import org.apache.lucene.analysis.MockTokenizer;
 import org.apache.lucene.document.Document;
 import org.apache.lucene.document.Field;
 import org.apache.lucene.document.FieldType;
+import org.apache.lucene.index.FieldInfos;
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.RandomIndexWriter;
 import org.apache.lucene.index.Term;
@@ -436,11 +440,17 @@ public class TestUnifiedHighlighterStrictPhrases extends LuceneTestCase {
 
     highlighter = new UnifiedHighlighter(searcher, indexAnalyzer) {
       @Override
-      protected Collection<Query> preSpanQueryRewrite(Query query) {
-        if (query instanceof MyQuery) {
-          return Collections.singletonList(((MyQuery)query).wrapped);
-        }
-        return null;
+      protected OffsetProducer getOffsetProducer(Query query, Analyzer analyzer, FieldInfos fieldInfos,
+                                                 Predicate<String> defaultFieldMatcher, Set<HighlightFlag> defaultFieldFlags) throws IOException {
+        return new OffsetProducer(query, analyzer, fieldInfos, defaultFieldMatcher, defaultFieldFlags){
+          @Override
+          protected Collection<Query> preSpanQueryRewrite(Query query) {
+            if (query instanceof MyQuery) {
+              return Collections.singletonList(((MyQuery)query).wrapped);
+            }
+            return null;
+          }
+        };
       }
     };
     highlighter.setHighlightPhrasesStrictly(true);
diff --git a/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/visibility/TestUnifiedHighlighterExtensibility.java b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/visibility/TestUnifiedHighlighterExtensibility.java
index 4eaa821b3c..c2819f6303 100644
--- a/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/visibility/TestUnifiedHighlighterExtensibility.java
+++ b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/visibility/TestUnifiedHighlighterExtensibility.java
@@ -19,6 +19,7 @@ package org.apache.lucene.search.uhighlight.visibility;
 
 import java.io.IOException;
 import java.text.BreakIterator;
+import java.util.EnumSet;
 import java.util.List;
 import java.util.Map;
 import java.util.Set;
@@ -27,13 +28,14 @@ import org.apache.lucene.analysis.Analyzer;
 import org.apache.lucene.analysis.MockAnalyzer;
 import org.apache.lucene.index.IndexReader;
 import org.apache.lucene.index.LeafReader;
-import org.apache.lucene.index.Term;
 import org.apache.lucene.search.DocIdSetIterator;
 import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.MatchNoDocsQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.spans.SpanQuery;
 import org.apache.lucene.search.uhighlight.FieldHighlighter;
 import org.apache.lucene.search.uhighlight.FieldOffsetStrategy;
+import org.apache.lucene.search.uhighlight.OffsetProducer;
 import org.apache.lucene.search.uhighlight.OffsetsEnum;
 import org.apache.lucene.search.uhighlight.Passage;
 import org.apache.lucene.search.uhighlight.PassageFormatter;
@@ -81,6 +83,27 @@ public class TestUnifiedHighlighterExtensibility extends LuceneTestCase {
     assertEquals(offsetSource, strategy.getOffsetSource());
   }
 
+  @Test
+  public void testOffsetProducerExtensibility() throws IOException {
+    new OffsetProducer(new MatchNoDocsQuery(), null, null, null,
+        EnumSet.noneOf(UnifiedHighlighter.HighlightFlag.class)) {
+      @Override
+      protected UnifiedHighlighter.OffsetSource getOffsetSource(String field) {
+        return super.getOffsetSource(field);
+      }
+
+      @Override
+      protected Boolean requiresRewrite(SpanQuery spanQuery) {
+        return super.requiresRewrite(spanQuery);
+      }
+
+      @Override
+      protected FieldOffsetStrategy getOffsetStrategy(UnifiedHighlighter.OffsetSource offsetSource, String field, BytesRef[] terms, PhraseHelper phraseHelper, CharacterRunAutomaton[] automata, Set<UnifiedHighlighter.HighlightFlag> highlightFlags) {
+        return super.getOffsetStrategy(offsetSource, field, terms, phraseHelper, automata, highlightFlags);
+      }
+    };
+  }
+
   /**
    * This test is for maintaining the extensibility of the UnifiedHighlighter
    * for customizations out of package.
@@ -95,11 +118,6 @@ public class TestUnifiedHighlighterExtensibility extends LuceneTestCase {
         return super.highlightFieldsAsObjects(fieldsIn, query, docIdsIn, maxPassagesIn);
       }
 
-      @Override
-      protected OffsetSource getOffsetSource(String field) {
-        return super.getOffsetSource(field);
-      }
-
       @Override
       protected BreakIterator getBreakIterator(String field) {
         return super.getBreakIterator(field);
@@ -130,11 +148,6 @@ public class TestUnifiedHighlighterExtensibility extends LuceneTestCase {
         return super.getMaxNoHighlightPassages(field);
       }
 
-      @Override
-      protected Boolean requiresRewrite(SpanQuery spanQuery) {
-        return super.requiresRewrite(spanQuery);
-      }
-
       @Override
       protected LimitedStoredFieldVisitor newLimitedStoredFieldsVisitor(String[] fields) {
         return super.newLimitedStoredFieldsVisitor(fields);
@@ -146,15 +159,9 @@ public class TestUnifiedHighlighterExtensibility extends LuceneTestCase {
       }
 
       @Override
-      protected FieldHighlighter getFieldHighlighter(String field, Query query, Set<Term> allTerms, int maxPassages) {
-        // THIS IS A COPY of the superclass impl; but use CustomFieldHighlighter
-        BytesRef[] terms = filterExtractedTerms(getFieldMatcher(field), allTerms);
-        Set<HighlightFlag> highlightFlags = getFlags(field);
-        PhraseHelper phraseHelper = getPhraseHelper(field, query, highlightFlags);
-        CharacterRunAutomaton[] automata = getAutomata(field, query, highlightFlags);
-        OffsetSource offsetSource = getOptimizedOffsetSource(field, terms, phraseHelper, automata);
+      protected FieldHighlighter getFieldHighlighter(String field, OffsetProducer offsetProducer, int maxPassages) {
         return new CustomFieldHighlighter(field,
-            getOffsetStrategy(offsetSource, field, terms, phraseHelper, automata, highlightFlags),
+            offsetProducer.getFieldOffsetStrategy(field),
             new SplittingBreakIterator(getBreakIterator(field), UnifiedHighlighter.MULTIVAL_SEP_CHAR),
             getScorer(field),
             maxPassages,
@@ -162,11 +169,6 @@ public class TestUnifiedHighlighterExtensibility extends LuceneTestCase {
             getFormatter(field));
       }
 
-      @Override
-      protected FieldOffsetStrategy getOffsetStrategy(OffsetSource offsetSource, String field, BytesRef[] terms, PhraseHelper phraseHelper, CharacterRunAutomaton[] automata, Set<HighlightFlag> highlightFlags) {
-        return super.getOffsetStrategy(offsetSource, field, terms, phraseHelper, automata, highlightFlags);
-      }
-
       @Override
       public int getMaxLength() {
         return maxLength;
