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..3c8803cda2
--- /dev/null
+++ b/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/OffsetProducer.java
@@ -0,0 +1,101 @@
+/*
+ * 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.HashSet;
+import java.util.Set;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.function.Predicate;
+
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.ScoreMode;
+import org.apache.lucene.search.Weight;
+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 UnifiedHighlighter unifiedHighlighter;
+  private final Query query;
+  private final Set<Term> allTerms;
+
+  /**
+   * Create an OffsetProducer for a highlighter and query
+   */
+  public OffsetProducer(UnifiedHighlighter unifiedHighlighter, Query query) throws IOException {
+    // TODO abstract away the UnifiedHighlighter reference here
+    this.unifiedHighlighter = unifiedHighlighter;
+    this.query = query;
+    this.allTerms = extractTerms(query);
+  }
+
+  /**
+   * Returns a {@link FieldOffsetStrategy} for a specific field
+   */
+  public FieldOffsetStrategy getFieldOffsetStrategy(String field) {
+    BytesRef[] terms = filterExtractedTerms(unifiedHighlighter.getFieldMatcher(field), allTerms);
+    Set<UnifiedHighlighter.HighlightFlag> highlightFlags = unifiedHighlighter.getFlags(field);
+    PhraseHelper phraseHelper = unifiedHighlighter.getPhraseHelper(field, query, highlightFlags);
+    CharacterRunAutomaton[] automata = unifiedHighlighter.getAutomata(field, query, highlightFlags);
+    UnifiedHighlighter.OffsetSource offsetSource = unifiedHighlighter.getOptimizedOffsetSource(field, terms, phraseHelper, automata);
+    return unifiedHighlighter.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, int doc, String text) throws IOException {
+    if (unifiedHighlighter.searcher == null)
+      throw new IllegalStateException("Cannot highlight document without a searcher");
+    return getFieldOffsetStrategy(field).getOffsetsEnum(unifiedHighlighter.searcher.getIndexReader(), doc, text);
+  }
+
+  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..1014024a90 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
@@ -24,14 +24,11 @@ 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,15 +46,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;
@@ -139,15 +133,6 @@ public class UnifiedHighlighter {
 
   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;
-  }
-
   /**
    * Constructs the highlighter with the given index searcher and analyzer.
    *
@@ -520,6 +505,10 @@ public class UnifiedHighlighter {
     return snippets;
   }
 
+  public OffsetProducer getOffsetProducer(Query query) throws IOException {
+    return new OffsetProducer(this, query);
+  }
+
   /**
    * Expert: highlights the top-N passages from multiple fields,
    * for the provided int[] docids, to custom Object as
@@ -566,12 +555,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 = new OffsetProducer(this, 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 +725,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, new OffsetProducer(this, 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,17 +739,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)) {
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..c024ed67ae
--- /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", 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/visibility/TestUnifiedHighlighterExtensibility.java b/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/visibility/TestUnifiedHighlighterExtensibility.java
index 4eaa821b3c..2a44568fb8 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
@@ -27,13 +27,13 @@ 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.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;
@@ -146,15 +146,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,
