This is an automated email from the ASF dual-hosted git repository.

dsmiley pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/solr.git


The following commit(s) were added to refs/heads/main by this push:
     new c7c23508840 SOLR-3479: New [hl] document transformer for inline 
highlighting (#5126)
c7c23508840 is described below

commit c7c23508840a4c828283c76716902e5b1b6f9d2e
Author: David Smiley <[email protected]>
AuthorDate: Tue Oct 27 12:27:16 2026 -0400

    SOLR-3479: New [hl] document transformer for inline highlighting (#5126)
    
    Also works in conjunction with /export.  Not with RTG.
    
    Co-authored-by: Claude Opus 5.5 (1M context) <[email protected]>
    Co-authored-by: Nick Shanin <[email protected]>
---
 .../SOLR-3479-highlight-doc-transformer.yml        |   7 +
 .../solr/highlight/UnifiedSolrHighlighter.java     |  47 +++-
 .../transform/HighlightAugmenterFactory.java       | 268 +++++++++++++++++++++
 .../response/transform/TransformerFactory.java     |   1 +
 .../apache/solr/cloud/TestRandomFlRTGCloud.java    |   3 +-
 .../transform/TestHighlightAugmenterFactory.java   | 262 ++++++++++++++++++++
 .../query-guide/pages/document-transformers.adoc   |  35 +++
 .../modules/query-guide/pages/highlighting.adoc    |  14 +-
 8 files changed, 621 insertions(+), 16 deletions(-)

diff --git a/changelog/unreleased/SOLR-3479-highlight-doc-transformer.yml 
b/changelog/unreleased/SOLR-3479-highlight-doc-transformer.yml
new file mode 100644
index 00000000000..b7b188a0d31
--- /dev/null
+++ b/changelog/unreleased/SOLR-3479-highlight-doc-transformer.yml
@@ -0,0 +1,7 @@
+title: "New [hl] document transformer: highlight a field inline in each 
document, e.g. fl=id,body:[hl]"
+type: added
+authors:
+  - name: David Smiley
+links:
+  - name: SOLR-3479
+    url: https://issues.apache.org/jira/browse/SOLR-3479
diff --git 
a/solr/core/src/java/org/apache/solr/highlight/UnifiedSolrHighlighter.java 
b/solr/core/src/java/org/apache/solr/highlight/UnifiedSolrHighlighter.java
index e1314790197..0d37c7fd005 100644
--- a/solr/core/src/java/org/apache/solr/highlight/UnifiedSolrHighlighter.java
+++ b/solr/core/src/java/org/apache/solr/highlight/UnifiedSolrHighlighter.java
@@ -136,6 +136,9 @@ public class UnifiedSolrHighlighter extends SolrHighlighter 
implements PluginInf
 
   protected static final String SNIPPET_SEPARATOR = "\u0000";
 
+  /** Default for {@link HighlightParams#SNIPPETS}. */
+  public static final int DEFAULT_SNIPPETS = 1;
+
   @Override
   public void init(PluginInfo info) {}
 
@@ -157,7 +160,8 @@ public class UnifiedSolrHighlighter extends SolrHighlighter 
implements PluginInf
 
     int maxPassages[] = new int[fieldNames.length];
     for (int i = 0; i < fieldNames.length; i++) {
-      maxPassages[i] = params.getFieldInt(fieldNames[i], 
HighlightParams.SNIPPETS, 1);
+      maxPassages[i] =
+          params.getFieldInt(fieldNames[i], HighlightParams.SNIPPETS, 
DEFAULT_SNIPPETS);
     }
 
     UnifiedHighlighter highlighter = getHighlighter(req);
@@ -194,8 +198,7 @@ public class UnifiedSolrHighlighter extends SolrHighlighter 
implements PluginInf
         if (snippet == null) {
           // TODO reuse logic of DefaultSolrHighlighter.alternateField
         } else {
-          // we used a special snippet separator char and we can now split on 
it.
-          summary.add(field, snippet.split(SNIPPET_SEPARATOR));
+          summary.add(field, splitSnippets(snippet));
         }
       }
       list.add(keys[i], summary);
@@ -203,6 +206,15 @@ public class UnifiedSolrHighlighter extends 
SolrHighlighter implements PluginInf
     return list;
   }
 
+  /**
+   * Splits a highlighted value formatted by {@link 
SolrExtendedUnifiedHighlighter} into its
+   * snippets.
+   */
+  public static String[] splitSnippets(String highlighted) {
+    // a special snippet separator char is used unless hl.tag.ellipsis is 
specified
+    return highlighted.split(SNIPPET_SEPARATOR);
+  }
+
   /** Converts solr's DocList to the int[] docIDs */
   protected int[] toDocIDs(DocList docs) {
     int[] docIDs = new int[docs.size()];
@@ -238,8 +250,12 @@ public class UnifiedSolrHighlighter extends 
SolrHighlighter implements PluginInf
     }
   }
 
-  /** From {@link #getHighlighter(org.apache.solr.request.SolrQueryRequest)}. 
*/
-  protected static class SolrExtendedUnifiedHighlighter extends 
UnifiedHighlighter {
+  /**
+   * A {@link UnifiedHighlighter} configured by Solr's highlighting params.
+   *
+   * @lucene.experimental
+   */
+  public static class SolrExtendedUnifiedHighlighter extends 
UnifiedHighlighter {
     protected static final Predicate<String> 
NOT_REQUIRED_FIELD_MATCH_PREDICATE = s -> true;
     private final SolrIndexSearcher solrIndexSearcher;
     protected final SolrParams params;
@@ -248,19 +264,24 @@ public class UnifiedSolrHighlighter extends 
SolrHighlighter implements PluginInf
     protected final RTimerTree loadFieldValuesTimer;
 
     public SolrExtendedUnifiedHighlighter(SolrQueryRequest req) {
-      super(req.getSearcher(), req.getSchema().getIndexAnalyzer());
-      this.solrIndexSearcher = req.getSearcher();
-      this.params = req.getParams();
-      this.schema = req.getSchema();
+      this(req.getSearcher(), req.getSchema(), req.getParams(), 
req.getRequestTimer());
+    }
+
+    /**
+     * @param timerTree request timer to record field-loading time in; may be 
null
+     */
+    public SolrExtendedUnifiedHighlighter(
+        SolrIndexSearcher searcher, IndexSchema schema, SolrParams params, 
RTimerTree timerTree) {
+      super(searcher, schema.getIndexAnalyzer());
+      this.solrIndexSearcher = searcher;
+      this.params = params;
+      this.schema = schema;
       this.setMaxLength(params.getInt(HighlightParams.MAX_CHARS, 
DEFAULT_MAX_CHARS));
       this.setCacheFieldValCharsThreshold(
           params.getInt(
               HighlightParams.CACHE_FIELD_VAL_CHARS_THRESHOLD, 
DEFAULT_CACHE_CHARS_THRESHOLD));
 
-      final RTimerTree timerTree;
-      if (req.getRequestTimer() != null) { // It may be null if not used in a 
search context.
-        timerTree = req.getRequestTimer();
-      } else {
+      if (timerTree == null) { // It may be null if not used in a search 
context.
         timerTree = new RTimerTree(); // since null checks are annoying
       }
       loadFieldValuesTimer =
diff --git 
a/solr/core/src/java/org/apache/solr/response/transform/HighlightAugmenterFactory.java
 
b/solr/core/src/java/org/apache/solr/response/transform/HighlightAugmenterFactory.java
new file mode 100644
index 00000000000..cb8836f1ecd
--- /dev/null
+++ 
b/solr/core/src/java/org/apache/solr/response/transform/HighlightAugmenterFactory.java
@@ -0,0 +1,268 @@
+/*
+ * 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.solr.response.transform;
+
+import java.io.IOException;
+import java.lang.reflect.Modifier;
+import java.util.Arrays;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+import java.util.stream.Collectors;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.ReaderUtil;
+import org.apache.lucene.search.DocIdSetIterator;
+import org.apache.lucene.search.Query;
+import org.apache.lucene.search.uhighlight.FieldHighlighter;
+import org.apache.lucene.search.uhighlight.UnifiedHighlighter;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.SolrException.ErrorCode;
+import org.apache.solr.common.params.HighlightParams;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.handler.component.ResponseBuilder;
+import org.apache.solr.highlight.UnifiedSolrHighlighter;
+import 
org.apache.solr.highlight.UnifiedSolrHighlighter.SolrExtendedUnifiedHighlighter;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.request.SolrRequestInfo;
+import org.apache.solr.response.ResultContext;
+import org.apache.solr.schema.SchemaField;
+import org.apache.solr.search.DocIterationInfo;
+import org.apache.solr.search.QParser;
+import org.apache.solr.search.QParserPlugin;
+import org.apache.solr.search.QueryParsing;
+import org.apache.solr.search.SolrIndexSearcher;
+import org.apache.solr.search.SolrReturnFields;
+import org.apache.solr.search.SyntaxError;
+
+/**
+ * Highlights one field of each document with the {@link UnifiedHighlighter}, 
adding the snippets as
+ * a list under the transformer's name, e.g. {@code body_hl:[hl f=body]}. 
Without {@code f}, the
+ * field is the transformer's name, e.g. {@code body:[hl]}. When the name is 
the field and nothing
+ * is highlighted, the field's original value is returned instead.
+ *
+ * <p>Other local params are {@code hl.*} params without the {@code hl.} 
prefix (e.g. {@code
+ * snippets}, {@code tag.pre}, {@code q}); they override the request's {@code 
hl.*} params, which
+ * otherwise apply.
+ */
+public class HighlightAugmenterFactory extends TransformerFactory {
+
+  /** Local param naming the field to highlight. */
+  public static final String FIELD_PARAM = "f";
+
+  private static final String HL_PREFIX = HighlightParams.HIGHLIGHT + ".";
+
+  /** Names of the {@code hl.*} params in {@link HighlightParams}, without the 
prefix. */
+  private static final Set<String> HL_PARAM_NAMES =
+      Arrays.stream(HighlightParams.class.getFields())
+          .filter(f -> f.getType() == String.class && 
Modifier.isStatic(f.getModifiers()))
+          .map(
+              f -> {
+                try {
+                  return (String) f.get(null);
+                } catch (IllegalAccessException e) {
+                  throw new IllegalStateException(e);
+                }
+              })
+          .filter(name -> name.startsWith(HL_PREFIX))
+          .map(name -> name.substring(HL_PREFIX.length()))
+          .collect(Collectors.toUnmodifiableSet());
+
+  @Override
+  public DocTransformer create(String display, SolrParams localParams, 
SolrQueryRequest req) {
+    String field = localParams.get(FIELD_PARAM, display);
+    SchemaField schemaField = req.getSchema().getFieldOrNull(field);
+    if (schemaField == null) {
+      throw new SolrException(
+          ErrorCode.BAD_REQUEST,
+          "[hl] needs a field to highlight, named by the '"
+              + FIELD_PARAM
+              + "' param or by its alias; not a field: "
+              + field);
+    }
+
+    ModifiableSolrParams overrides = new ModifiableSolrParams();
+    for (Iterator<String> it = localParams.getParameterNamesIterator(); 
it.hasNext(); ) {
+      String name = it.next();
+      if (name.equals(FIELD_PARAM)) {
+        continue;
+      }
+      if (!HL_PARAM_NAMES.contains(name)) {
+        throw new SolrException(ErrorCode.BAD_REQUEST, "Unknown [hl] param: " 
+ name);
+      }
+      String[] values = localParams.getParams(name);
+      overrides.set(HL_PREFIX + name, values);
+      // per-field form too, so that it overrides a request's per-field param
+      overrides.set("f." + field + "." + HL_PREFIX + name, values);
+    }
+    SolrParams params = SolrParams.wrapDefaults(overrides, req.getParams());
+
+    boolean passThrough = display.equals(field);
+
+    boolean singleValue =
+        !schemaField.multiValued()
+            && params.getFieldInt(
+                    field, HighlightParams.SNIPPETS, 
UnifiedSolrHighlighter.DEFAULT_SNIPPETS)
+                == 1;
+
+    return new HighlightAugmenter(
+        display, field, params, parseHighlightQuery(params, req), passThrough, 
singleValue);
+  }
+
+  /** Parses {@code hl.q}, as the highlight component does; null if absent. */
+  private static Query parseHighlightQuery(SolrParams params, SolrQueryRequest 
req) {
+    String hlq = params.get(HighlightParams.Q);
+    if (hlq == null) {
+      return null;
+    }
+    String parserName =
+        params.get(
+            HighlightParams.QPARSER, params.get(QueryParsing.DEFTYPE, 
QParserPlugin.DEFAULT_QTYPE));
+    try {
+      return QParser.getParser(hlq, parserName, req).getHighlightQuery();
+    } catch (SyntaxError e) {
+      throw new SolrException(ErrorCode.BAD_REQUEST, e);
+    }
+  }
+
+  static class HighlightAugmenter extends DocTransformer {
+    private final String name;
+    private final String field;
+    private final SolrParams params;
+    private final Query hlQuery;
+    private final boolean passThrough;
+    private final boolean singleValue; // output a value instead of a list
+    private final SolrReturnFields originalValueFields;
+    private SingleFieldHighlighter highlighter; // null if there's no query
+
+    HighlightAugmenter(
+        String name,
+        String field,
+        SolrParams params,
+        Query hlQuery,
+        boolean passThrough,
+        boolean singleValue) {
+      this.name = name;
+      this.field = field;
+      this.params = params;
+      this.hlQuery = hlQuery;
+      this.passThrough = passThrough;
+      this.singleValue = singleValue;
+      this.originalValueFields = passThrough ? new 
SolrReturnFields(List.of(field), null) : null;
+    }
+
+    @Override
+    public String getName() {
+      return name;
+    }
+
+    @Override
+    public boolean needsSolrIndexSearcher() {
+      return true;
+    }
+
+    @Override
+    public void setContext(ResultContext context) {
+      super.setContext(context);
+      Query query = hlQuery != null ? hlQuery : getMainHighlightQuery(context);
+      SolrIndexSearcher searcher = context.getSearcher();
+      highlighter =
+          (query == null || searcher == null)
+              ? null
+              : new SingleFieldHighlighter(searcher, params, field, query);
+    }
+
+    // Identity is intended: the context must come from this exact request and 
query.
+    @SuppressWarnings("ReferenceEquality")
+    private static Query getMainHighlightQuery(ResultContext context) {
+      SolrRequestInfo info = SolrRequestInfo.getRequestInfo();
+      ResponseBuilder rb = info == null ? null : info.getResponseBuilder();
+      // A custom result context may have a different query from the current 
request.
+      if (rb == null
+          || rb.req != context.getRequest()
+          || rb.getQuery() != context.getQuery()
+          || rb.getQparser() == null) {
+        return context.getQuery();
+      }
+      try {
+        return rb.getQparser().getHighlightQuery();
+      } catch (SyntaxError e) {
+        throw new SolrException(ErrorCode.BAD_REQUEST, e);
+      }
+    }
+
+    @Override
+    public void transform(SolrDocument doc, int docid, DocIterationInfo 
docInfo)
+        throws IOException {
+      if (docid < 0) {
+        return; // not from the index
+      }
+      Object highlighted = highlighter == null ? null : 
highlighter.highlight(docid);
+      List<String> snippets =
+          highlighted == null
+              ? List.of()
+              // a multi-valued field's value separator can yield empty 
snippets
+              : 
Arrays.stream(UnifiedSolrHighlighter.splitSnippets(highlighted.toString()))
+                  .filter(s -> !s.isEmpty())
+                  .toList();
+      if (!snippets.isEmpty()) {
+        doc.setField(name, singleValue ? snippets.getFirst() : snippets);
+      } else if (passThrough) {
+        Object original =
+            context.getDocFetcher().solrDoc(docid, 
originalValueFields).getFieldValue(field);
+        if (original != null) {
+          doc.setField(name, original);
+        }
+      } else if (!singleValue) {
+        doc.setField(name, List.of());
+      }
+    }
+  }
+
+  /**
+   * Highlights one field of one document at a time. Query analysis and 
per-field setup happen once,
+   * in the constructor.
+   */
+  private static final class SingleFieldHighlighter extends 
SolrExtendedUnifiedHighlighter {
+    private final String[] fields;
+    private final FieldHighlighter fieldHighlighter;
+
+    SingleFieldHighlighter(
+        SolrIndexSearcher searcher, SolrParams params, String field, Query 
query) {
+      super(searcher, searcher.getSchema(), params, null);
+      this.fields = new String[] {field};
+      int maxPassages =
+          params.getFieldInt(
+              field, HighlightParams.SNIPPETS, 
UnifiedSolrHighlighter.DEFAULT_SNIPPETS);
+      this.fieldHighlighter = getFieldHighlighter(field, query, 
extractTerms(query), maxPassages);
+    }
+
+    /** Returns the formatted highlight of the doc's field, or null if none. */
+    Object highlight(int docId) throws IOException {
+      CharSequence content =
+          loadFieldValues(fields, DocIdSetIterator.range(docId, docId + 1), 
0).getFirst()[0];
+      if (content == null) {
+        return null;
+      }
+      List<LeafReaderContext> leaves = searcher.getIndexReader().leaves();
+      LeafReaderContext leaf = leaves.get(ReaderUtil.subIndex(docId, leaves));
+      return fieldHighlighter.highlightFieldForDoc(
+          leaf.reader(), docId - leaf.docBase, content.toString());
+    }
+  }
+}
diff --git 
a/solr/core/src/java/org/apache/solr/response/transform/TransformerFactory.java 
b/solr/core/src/java/org/apache/solr/response/transform/TransformerFactory.java
index 249a3a9e1d7..74c658de37a 100644
--- 
a/solr/core/src/java/org/apache/solr/response/transform/TransformerFactory.java
+++ 
b/solr/core/src/java/org/apache/solr/response/transform/TransformerFactory.java
@@ -118,5 +118,6 @@ public abstract class TransformerFactory implements 
NamedListInitializedPlugin {
     defaultFactories.put("xml", new RawValueTransformerFactory("xml"));
     defaultFactories.put("geo", new GeoTransformerFactory());
     defaultFactories.put("core", new CoreAugmenterFactory());
+    defaultFactories.put("hl", new HighlightAugmenterFactory());
   }
 }
diff --git a/solr/core/src/test/org/apache/solr/cloud/TestRandomFlRTGCloud.java 
b/solr/core/src/test/org/apache/solr/cloud/TestRandomFlRTGCloud.java
index cdbab82b654..3e5d0d7a0f7 100644
--- a/solr/core/src/test/org/apache/solr/cloud/TestRandomFlRTGCloud.java
+++ b/solr/core/src/test/org/apache/solr/cloud/TestRandomFlRTGCloud.java
@@ -203,7 +203,8 @@ public class TestRandomFlRTGCloud extends SolrCloudTestCase 
{
     // and a specific Jira for fixing this is listed as a comment
     final List<String> knownBugs =
         Arrays.asList(
-            "child" // way too complicated to check with this test, see 
SOLR-9379 instead
+            "child", // way too complicated to check with this test, see 
SOLR-9379 instead
+            "hl" // presently can only highlight via reading data from the 
index.
             );
 
     for (String buggy : knownBugs) {
diff --git 
a/solr/core/src/test/org/apache/solr/response/transform/TestHighlightAugmenterFactory.java
 
b/solr/core/src/test/org/apache/solr/response/transform/TestHighlightAugmenterFactory.java
new file mode 100644
index 00000000000..fc49fa06f0d
--- /dev/null
+++ 
b/solr/core/src/test/org/apache/solr/response/transform/TestHighlightAugmenterFactory.java
@@ -0,0 +1,262 @@
+/*
+ * 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.solr.response.transform;
+
+import java.util.List;
+import org.apache.solr.SolrTestCase;
+import org.apache.solr.client.solrj.SolrClient;
+import org.apache.solr.client.solrj.request.SolrQuery;
+import org.apache.solr.client.solrj.response.QueryResponse;
+import org.apache.solr.common.SolrDocument;
+import org.apache.solr.common.SolrException;
+import org.apache.solr.common.SolrInputDocument;
+import org.apache.solr.common.params.ModifiableSolrParams;
+import org.apache.solr.util.EmbeddedSolrServerTestRule;
+import org.apache.solr.util.ExternalPaths;
+import org.junit.BeforeClass;
+import org.junit.ClassRule;
+import org.junit.Test;
+
+public class TestHighlightAugmenterFactory extends SolrTestCase {
+
+  private static final String BODY_1 =
+      "The quick brown fox jumps over the lazy dog. Nothing else here. The fox 
ran away.";
+  private static final String BODY_2 = "A story about cats. Cats sleep a lot.";
+
+  @ClassRule
+  public static final EmbeddedSolrServerTestRule solrTestRule = new 
EmbeddedSolrServerTestRule();
+
+  @BeforeClass
+  public static void beforeClass() throws Exception {
+    solrTestRule.startSolr();
+    
solrTestRule.newCollection().withConfigSet(ExternalPaths.DEFAULT_CONFIGSET).create();
+
+    SolrClient client = solrTestRule.getSolrClient();
+    SolrInputDocument doc1 = new SolrInputDocument();
+    doc1.addField("id", "1");
+    doc1.addField("body_t", BODY_1);
+    doc1.addField("title_t", "Fox story");
+    doc1.addField("tags_txt", List.of("animals", "forest fox"));
+    client.add(doc1);
+    SolrInputDocument doc2 = new SolrInputDocument();
+    doc2.addField("id", "2");
+    doc2.addField("body_t", BODY_2);
+    doc2.addField("title_t", "Fox cameo");
+    doc2.addField("tags_txt", List.of("pets", "cats"));
+    client.add(doc2);
+    client.commit();
+  }
+
+  /** Queries for "fox" in body_t or title_t, sorted by id; doc 2 has no "fox" 
in its body. */
+  private static List<SolrDocument> query(String fl, String... extraParams) 
throws Exception {
+    SolrQuery query = new SolrQuery("body_t:fox title_t:fox");
+    query.setFields(fl);
+    query.set("sort", "id asc");
+    for (int i = 0; i < extraParams.length; i += 2) {
+      query.set(extraParams[i], extraParams[i + 1]);
+    }
+    List<SolrDocument> docs = 
solrTestRule.getSolrClient().query(query).getResults();
+    assertEquals(2, docs.size());
+    return docs;
+  }
+
+  @SuppressWarnings("unchecked")
+  private static List<String> snippets(SolrDocument doc, String name) {
+    return (List<String>) doc.getFieldValue(name);
+  }
+
+  private static void assertContains(String expected, Object actual) {
+    assertTrue(String.valueOf(actual), actual instanceof String s && 
s.contains(expected));
+  }
+
+  @Test
+  public void testExplicitField() throws Exception {
+    // single-valued field and 1 snippet: a string, not a list
+    List<SolrDocument> docs = query("id,body_hl:[hl f=body_t]");
+    assertContains("<em>fox</em>", docs.getFirst().getFieldValue("body_hl"));
+    assertNull("the highlighted field isn't returned", 
docs.getFirst().getFieldValue("body_t"));
+    // no highlight, and the name isn't the field, so no pass-through
+    assertFalse(docs.get(1).containsKey("body_hl"));
+
+    // a list when multiple snippets may be returned
+    docs = query("id,body_hl:[hl f=body_t snippets=2]");
+    assertEquals(List.of(), docs.get(1).getFieldValue("body_hl"));
+  }
+
+  @Test
+  public void testFieldFromAlias() throws Exception {
+    List<SolrDocument> docs = query("id,body_t:[hl]");
+    assertContains("<em>fox</em>", docs.getFirst().getFieldValue("body_t"));
+    // no highlight: the original value passes through
+    assertEquals(BODY_2, docs.get(1).getFieldValue("body_t"));
+
+    // also when the name equals an explicitly given field
+    docs = query("id,body_t:[hl f=body_t]");
+    assertEquals(BODY_2, docs.get(1).getFieldValue("body_t"));
+  }
+
+  @Test
+  public void testMultiValuedPassThrough() throws Exception {
+    List<SolrDocument> docs = query("id,tags_txt:[hl]");
+    List<String> snippets = snippets(docs.getFirst(), "tags_txt");
+    assertEquals(List.of("forest <em>fox</em>"), snippets);
+    assertEquals(List.of("pets", "cats"), 
docs.get(1).getFieldValue("tags_txt"));
+  }
+
+  @Test
+  public void testLocalParams() throws Exception {
+    List<SolrDocument> docs =
+        query("id,body_hl:[hl f=body_t snippets=2 fragsize=1 tag.pre='[' 
tag.post=']']");
+    List<String> snippets = snippets(docs.getFirst(), "body_hl");
+    assertEquals(snippets.toString(), 2, snippets.size());
+    for (String snippet : snippets) {
+      assertTrue(snippet, snippet.contains("[fox]"));
+    }
+
+    // q overrides the main query
+    docs = query("id,body_hl:[hl f=body_t q=body_t:cats]");
+    assertFalse(docs.getFirst().containsKey("body_hl"));
+    assertContains("<em>cats</em>", docs.get(1).getFieldValue("body_hl"));
+
+    // defaultSummary returns leading text when nothing matches
+    docs = query("id,body_hl:[hl f=body_t defaultSummary=true fragsize=0]");
+    assertEquals(BODY_2, docs.get(1).getFieldValue("body_hl"));
+  }
+
+  @Test
+  public void testRequestParamFallback() throws Exception {
+    List<SolrDocument> docs =
+        query("id,body_hl:[hl f=body_t]", "hl.tag.pre", "[", "hl.tag.post", 
"]");
+    assertContains("[fox]", docs.getFirst().getFieldValue("body_hl"));
+
+    // local params override the request's, even per-field ones
+    docs = query("id,body_hl:[hl f=body_t tag.pre=< tag.post=>]", 
"hl.tag.pre", "[");
+    assertContains("<fox>", docs.getFirst().getFieldValue("body_hl"));
+    docs = query("id,body_hl:[hl f=body_t tag.pre=< tag.post=>]", 
"f.body_t.hl.tag.pre", "[");
+    assertContains("<fox>", docs.getFirst().getFieldValue("body_hl"));
+
+    // the request's snippets param determines if the output is a list
+    docs = query("id,body_hl:[hl f=body_t]", "hl.snippets", "2");
+    assertEquals(List.of(), docs.get(1).getFieldValue("body_hl"));
+  }
+
+  @Test
+  public void testMultipleInstances() throws Exception {
+    List<SolrDocument> docs = query("id,body_t:[hl],title_t:[hl tag.pre=* 
tag.post=*]");
+    assertContains("<em>fox</em>", docs.getFirst().getFieldValue("body_t"));
+    assertEquals("*Fox* story", docs.getFirst().getFieldValue("title_t"));
+  }
+
+  private static ModifiableSolrParams boostedQuery(boolean weightMatches) {
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    params.set("q", "fox");
+    params.set("defType", "edismax");
+    params.set("qf", "body_t title_t");
+    params.set("bq", "body_t:cats");
+    params.set("fl", "id,body_hl:[hl f=body_t]");
+    params.set("sort", "id asc");
+    params.set("hl.weightMatches", weightMatches);
+    params.set("hl.defaultSummary", false);
+    params.set("hl.method", "unified");
+    params.set("hl.fl", "body_t");
+    return params;
+  }
+
+  private static void assertOnlyUserQueryHighlighted(QueryResponse response) {
+    List<SolrDocument> docs = response.getResults();
+    assertEquals(2, docs.size());
+    assertEquals("1", docs.getFirst().getFieldValue("id"));
+    assertEquals("2", docs.get(1).getFieldValue("id"));
+    assertContains("<em>fox</em>", docs.getFirst().getFieldValue("body_hl"));
+    assertFalse(
+        "Boost-only text must not be highlighted: " + docs.get(1),
+        docs.get(1).containsKey("body_hl"));
+    if (response.getHighlighting() != null) {
+      var highlights = response.getHighlighting().get("2");
+      assertTrue(String.valueOf(highlights), highlights == null || 
highlights.isEmpty());
+    }
+  }
+
+  @Test
+  public void testImplicitQueryUsesHighlightQuery() throws Exception {
+    for (boolean weightMatches : List.of(true, false)) {
+      for (boolean component : List.of(false, true)) {
+        ModifiableSolrParams params = boostedQuery(weightMatches);
+        params.set("hl", component);
+        
assertOnlyUserQueryHighlighted(solrTestRule.getSolrClient().query(params));
+
+        params.set("hl.q", "fox");
+        
assertOnlyUserQueryHighlighted(solrTestRule.getSolrClient().query(params));
+
+        params.remove("hl.q");
+        params.set("fl", "id,body_hl:[hl f=body_t q=fox]");
+        
assertOnlyUserQueryHighlighted(solrTestRule.getSolrClient().query(params));
+      }
+    }
+  }
+
+  @Test
+  public void testImplicitQueryUsesMainParser() throws Exception {
+    ModifiableSolrParams params = boostedQuery(false);
+    params.remove("q");
+    params.set("q.alt", "{!lucene}body_t:fox OR title_t:fox");
+    assertOnlyUserQueryHighlighted(solrTestRule.getSolrClient().query(params));
+
+    params = boostedQuery(false);
+    params.remove("defType");
+    params.remove("qf");
+    params.remove("bq");
+    params.set("q", "{!edismax qf='body_t title_t' bq='body_t:cats'}fox");
+    assertOnlyUserQueryHighlighted(solrTestRule.getSolrClient().query(params));
+  }
+
+  @Test
+  public void testExplicitHighlightQueryOverridesMainQuery() throws Exception {
+    for (boolean weightMatches : List.of(true, false)) {
+      ModifiableSolrParams params = boostedQuery(weightMatches);
+      params.set("hl.q", "body_t:cats");
+      List<SolrDocument> docs = 
solrTestRule.getSolrClient().query(params).getResults();
+      assertEquals(2, docs.size());
+      assertFalse(docs.getFirst().containsKey("body_hl"));
+      assertContains("<em>cats</em>", docs.get(1).getFieldValue("body_hl"));
+
+      params.set("hl.q", "fox");
+      params.set("fl", "id,body_hl:[hl f=body_t q=body_t:cats]");
+      docs = solrTestRule.getSolrClient().query(params).getResults();
+      assertEquals(2, docs.size());
+      assertFalse(docs.getFirst().containsKey("body_hl"));
+      assertContains("<em>cats</em>", docs.get(1).getFieldValue("body_hl"));
+    }
+  }
+
+  @Test
+  public void testRealTimeGet() throws Exception {
+    // no query, so the original value passes through
+    ModifiableSolrParams params = new ModifiableSolrParams();
+    params.set("fl", "id,body_t:[hl]");
+    SolrDocument doc = solrTestRule.getSolrClient().getById("1", params);
+    assertEquals(BODY_1, doc.getFieldValue("body_t"));
+  }
+
+  @Test
+  public void testErrors() {
+    SolrException e = expectThrows(SolrException.class, () -> 
query("id,[hl]"));
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code());
+    e = expectThrows(SolrException.class, () -> query("id,body_t:[hl 
bogus=1]"));
+    assertEquals(SolrException.ErrorCode.BAD_REQUEST.code, e.code());
+  }
+}
diff --git 
a/solr/solr-ref-guide/modules/query-guide/pages/document-transformers.adoc 
b/solr/solr-ref-guide/modules/query-guide/pages/document-transformers.adoc
index e454ebafd97..292ec43f0c4 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/document-transformers.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/document-transformers.adoc
@@ -411,6 +411,41 @@ As such, you needn't mark the field as stored, which would 
be redundant.
 In a sense this double-storage between docValues and stored-value storage 
isn't unique to spatial but with polygonal geometry it can be a lot of data, 
and furthermore you'd like to avoid storing it in a verbose format (like 
GeoJSON or WKT).
 
 
+=== [hl] - HighlightAugmenterFactory
+
+Highlights one field of each document using the 
xref:highlighting.adoc#unified-highlighter[Unified Highlighter], and adds the 
snippets to the document as a list.
+If the field is single-valued and one snippet is requested (the default), the 
snippet is added as a single value instead of a list.
+Unlike the highlight component, the snippets are inline in each document 
instead of in a separate `highlighting` section of the response.
+
+The field to highlight is given by the `f` parameter; if omitted, it's the 
transformer's name (its alias).
+For example, `fl=id,body_hl:[hl f=body]` adds `body_hl` with snippets of 
`body`, without returning the full `body`.
+And `fl=id,body:[hl]` returns snippets of `body` as `body`.
+List multiple instances to highlight multiple fields: 
`fl=id,body:[hl],title:[hl snippets=3]`.
+
+[source,plain]
+----
+q=body:fox&fl=id,body:[hl]
+----
+
+[source,json]
+----
+{ "response":{"numFound":1,"start":0,"docs":[
+      {
+        "id":"1",
+        "body":"The quick brown <em>fox</em> jumps over the lazy dog."}]
+}}
+----
+
+If nothing in the field is highlighted, the result depends on the name:
+
+* If the name is the field (e.g., `body:[hl]`), the field's original value is 
returned, so the field is returned either highlighted or as-is.
+* Otherwise (e.g., `body_hl:[hl f=body]`), the result is an empty list, or 
absent when a single value would have been returned.
+* With `defaultSummary=true`, the leading text of the field is returned as a 
snippet instead.
+
+The highlighting is configured by the request's 
xref:highlighting.adoc[highlighting parameters], such as `hl.tag.pre`; 
`hl=true` is not needed.
+They can be overridden for one transformer, including any per-field form like 
`f.body.hl.snippets`, by giving them without the `hl.` prefix, e.g. `[hl f=body 
snippets=3 tag.pre='<b>' tag.post='</b>']`, or `q` to highlight a query other 
than the main one.
+
+
 === [features] - LTRFeatureLoggerTransformerFactory
 
 The "LTR" prefix stands for xref:learning-to-rank.adoc[].
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc 
b/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc
index 5b51f940050..c98fa6daeeb 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/highlighting.adoc
@@ -17,9 +17,19 @@
 // under the License.
 
 Highlighting in Solr allows fragments of documents that match the user's query 
to be included with the query response.
-
-The fragments are included in a special section of the query response (the 
`highlighting` section), and the client uses the formatting clues also included 
to determine how to present the snippets to users.
 Fragments are a portion of a document field that contains matches from the 
query and are sometimes also referred to as "snippets" or "passages".
+The client uses the formatting clues also included to determine how to present 
the snippets to users.
+
+There are two ways to request highlighting:
+
+* The highlight component, enabled with `hl=true`, includes the fragments in a 
special section of the query response (the `highlighting` section), separate 
from the documents.
+This is what most of this page describes.
+* The xref:document-transformers.adoc#hl-highlightaugmenterfactory[`[hl\]` 
document transformer], requested in `fl`, includes the fragments in each 
document, e.g., `fl=id,body:[hl]`.
+It always uses the <<Unified Highlighter>>, configured with the same 
parameters.
+
+Choosing between them is mostly a matter of preference, though there are 
implications at scale.
+The transformer is preferred when highlighting hundreds of documents or more, 
since each document is highlighted as the results are streamed.
+On the other hand, the transformer has more overhead per field, which could be 
a concern when highlighting many fields.
 
 Highlighting is extremely configurable, perhaps more than any other part of 
Solr.
 There are many parameters each for fragment sizing, formatting, ordering, 
backup/alternate behavior, and more options that are hard to categorize.

Reply via email to