You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@solr.apache.org by ds...@apache.org on 2026/04/24 04:28:19 UTC

(solr) branch branch_10x updated (acd8d041ec4 -> 0cb2aebf043)

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

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


    from acd8d041ec4 Fix up `main` for lint and osgi (#3970)
     new 6cafbde47ee SOLR-18195: CombinedQueryComponent supports Collapse now (#4277)
     new 285e05d7041 SOLR-14687: {!parent} and {!child} qparsers now use NestPathField (#4206)
     new 0cb2aebf043 SOLR-18093: Add 'queries' support to JsonQueryRequest (#4276)

The 3 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails.  The revisions
listed as "add" were already present in the repository and have only
been added to this reference.


Summary of changes:
 .../unreleased/SOLR-14687-parentPath-param.yml     |   9 +
 ...R-18093-add-top-level-queries-in-json-solrj.yml |   9 +
 .../SOLR-18195-collapse-results-combined-query.yml |   9 +
 .../handler/component/CombinedQueryComponent.java  |  49 +++-
 .../solr/handler/component/SearchHandler.java      |   4 +
 .../combine/QueryAndResponseCombiner.java          | 153 +++++++++++-
 .../solr/search/join/BlockJoinChildQParser.java    |  64 ++++-
 .../solr/search/join/BlockJoinParentQParser.java   | 271 +++++++++++++++++++--
 .../apache/solr/search/join/FiltersQParser.java    |  15 +-
 .../collection1/conf/schema-vector-catchall.xml    |   8 +-
 .../solr/collection1/conf/solrconfig.xml           |   5 +-
 .../component/CombinedQuerySolrCloudTest.java      |  93 ++++++-
 .../DistributedCombinedQueryComponentTest.java     | 139 +++++++++++
 .../combine/QueryAndResponseCombinerTest.java      | 106 +++++++-
 .../combine/ReciprocalRankFusionTest.java          |   2 +-
 .../org/apache/solr/search/QueryEqualityTest.java  |  25 ++
 .../solr/update/TestNestedUpdateProcessor.java     | 147 ++++++++++-
 .../conf/solrconfig.xml                            |   9 +-
 .../query-guide/examples/JsonRequestApiTest.java   |  70 ++++++
 .../query-guide/pages/block-join-query-parser.adoc |  85 ++++++-
 .../query-guide/pages/json-combined-query-dsl.adoc |  41 +++-
 .../modules/query-guide/pages/json-query-dsl.adoc  |  15 ++
 .../pages/searching-nested-documents.adoc          |  51 ++--
 .../solrj/request/json/JsonQueryRequest.java       |  20 ++
 .../json/JsonQueryRequestIntegrationTest.java      |  25 ++
 .../request/json/JsonQueryRequestUnitTest.java     |  23 ++
 26 files changed, 1343 insertions(+), 104 deletions(-)
 create mode 100644 changelog/unreleased/SOLR-14687-parentPath-param.yml
 create mode 100644 changelog/unreleased/SOLR-18093-add-top-level-queries-in-json-solrj.yml
 create mode 100644 changelog/unreleased/SOLR-18195-collapse-results-combined-query.yml


(solr) 01/03: SOLR-18195: CombinedQueryComponent supports Collapse now (#4277)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 6cafbde47ee5222cdf8de0295433170038e98bb3
Author: Sonu Sharma <so...@gmail.com>
AuthorDate: Sat Apr 18 09:37:36 2026 +0530

    SOLR-18195: CombinedQueryComponent supports Collapse now (#4277)
    
    Support for using {!collapse} with CombinedQueryComponent (RRF)
---
 .../SOLR-18195-collapse-results-combined-query.yml |   9 ++
 .../handler/component/CombinedQueryComponent.java  |  49 ++++++-
 .../combine/QueryAndResponseCombiner.java          | 153 ++++++++++++++++++++-
 .../collection1/conf/schema-vector-catchall.xml    |   8 +-
 .../component/CombinedQuerySolrCloudTest.java      |  93 ++++++++++++-
 .../DistributedCombinedQueryComponentTest.java     | 139 +++++++++++++++++++
 .../combine/QueryAndResponseCombinerTest.java      | 106 +++++++++++++-
 .../combine/ReciprocalRankFusionTest.java          |   2 +-
 8 files changed, 542 insertions(+), 17 deletions(-)

diff --git a/changelog/unreleased/SOLR-18195-collapse-results-combined-query.yml b/changelog/unreleased/SOLR-18195-collapse-results-combined-query.yml
new file mode 100644
index 00000000000..5ddc729b3dc
--- /dev/null
+++ b/changelog/unreleased/SOLR-18195-collapse-results-combined-query.yml
@@ -0,0 +1,9 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+title: Support for using {!collapse} with CombinedQueryComponent (RRF)
+type: added
+authors:
+  - name: Sonu Sharma
+    nick: ercsonusharma
+links:
+  - name: SOLR-18195
+    url: https://issues.apache.org/jira/browse/SOLR-18195
diff --git a/solr/core/src/java/org/apache/solr/handler/component/CombinedQueryComponent.java b/solr/core/src/java/org/apache/solr/handler/component/CombinedQueryComponent.java
index 9831281d2b2..8b097bc304d 100644
--- a/solr/core/src/java/org/apache/solr/handler/component/CombinedQueryComponent.java
+++ b/solr/core/src/java/org/apache/solr/handler/component/CombinedQueryComponent.java
@@ -31,6 +31,7 @@ import java.util.Objects;
 import java.util.Set;
 import java.util.stream.Collectors;
 import org.apache.lucene.search.Explanation;
+import org.apache.lucene.search.Query;
 import org.apache.lucene.search.Sort;
 import org.apache.lucene.search.SortField;
 import org.apache.solr.client.solrj.SolrServerException;
@@ -42,6 +43,7 @@ import org.apache.solr.common.params.CursorMarkParams;
 import org.apache.solr.common.params.GroupParams;
 import org.apache.solr.common.params.ShardParams;
 import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.CollectionUtil;
 import org.apache.solr.common.util.NamedList;
 import org.apache.solr.common.util.SimpleOrderedMap;
 import org.apache.solr.common.util.StrUtils;
@@ -53,8 +55,10 @@ import org.apache.solr.response.ResultContext;
 import org.apache.solr.response.SolrQueryResponse;
 import org.apache.solr.schema.IndexSchema;
 import org.apache.solr.schema.SchemaField;
+import org.apache.solr.search.CollapsingQParserPlugin;
 import org.apache.solr.search.DocListAndSet;
 import org.apache.solr.search.QueryResult;
+import org.apache.solr.search.SolrIndexSearcher;
 import org.apache.solr.search.SolrReturnFields;
 import org.apache.solr.search.SortSpec;
 import org.apache.solr.util.SolrResponseUtil;
@@ -236,7 +240,10 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
       boolean partialResults,
       boolean segmentTerminatedEarly,
       Boolean setMaxHitsTerminatedEarly) {
-    QueryResult combinedQueryResult = QueryAndResponseCombiner.simpleCombine(queryResults);
+    SolrIndexSearcher searcher = crb.req.getSearcher();
+    List<Query> collapseFilters = getCollapseFilters(crb.getFilters());
+    QueryResult combinedQueryResult =
+        QueryAndResponseCombiner.simpleCombine(queryResults, collapseFilters, searcher);
     combinedQueryResult.setPartialResults(partialResults);
     combinedQueryResult.setSegmentTerminatedEarly(segmentTerminatedEarly);
     combinedQueryResult.setMaxHitsTerminatedEarly(setMaxHitsTerminatedEarly);
@@ -258,6 +265,21 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
     }
   }
 
+  /** Extracts the list of CollapsingPostFilter query from the filter list, if present. */
+  private static List<Query> getCollapseFilters(List<Query> filters) {
+    if (CollectionUtil.isNotEmpty(filters)) {
+      return filters.stream()
+          .filter(q -> q instanceof CollapsingQParserPlugin.CollapsingPostFilter)
+          .toList();
+    }
+    return List.of();
+  }
+
+  /**
+   * Each shard response contains both "response" and "response_per_query". Only "response" is
+   * deduplicated across sub-queries, so any processing of "response_per_query" must exclude docs
+   * not present in "response" to avoid reintroducing docs that were eliminated while deduplication.
+   */
   @Override
   protected void mergeIds(ResponseBuilder rb, ShardRequest sreq) {
     SortSpec ss = rb.getSortSpec();
@@ -296,6 +318,10 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
     long approximateTotalHits = 0;
     Map<String, List<ShardDoc>> shardDocMap = new HashMap<>();
     String[] queriesToCombineKeys = rb.req.getParams().getParams(CombinerParams.COMBINER_QUERY);
+    // Build per-shard set of doc IDs from the shard's combined (deduplicated) response.
+    // Used to filter per-query docs so that RRF doesn't reintroduce docs
+    // excluded by collapse at the shard level.
+    Map<String, Set<Object>> combinedDocIdsPerShard = HashMap.newHashMap(sreq.responses.size());
     // TODO: to be parallelized outer loop
     for (int queryIndex = 0; queryIndex < queriesToCombineKeys.length; queryIndex++) {
       int failedShardCount = 0;
@@ -377,9 +403,15 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
                 : new NamedList<>();
         // go through every doc in this response, construct a ShardDoc, and
         // put it in the uniqueDoc to dedup
+        Set<Object> thisShardCombinedIds =
+            combinedDocIdsPerShard.computeIfAbsent(
+                srsp.getShard(), shard -> extractIdsFromCombinedResponse(rb, srsp, uniqueKeyField));
         for (int i = 0; i < docs.size(); i++) {
           SolrDocument doc = docs.get(i);
           Object id = doc.getFieldValue(uniqueKeyField.getName());
+          if (!thisShardCombinedIds.contains(id)) {
+            continue;
+          }
           ShardDoc shardDoc = new ShardDoc();
           shardDoc.id = id;
           shardDoc.orderInShard = i;
@@ -610,4 +642,19 @@ public class CombinedQueryComponent extends QueryComponent implements SolrCoreAw
     for (int i = 0; i < resultSize; i++) responseDocs.add(null);
     return resultIds;
   }
+
+  /**
+   * Extracts the set of doc IDs from the shard's combined response (produced by simpleCombine).
+   * Returns an empty set if the combined response is not available.
+   */
+  private static Set<Object> extractIdsFromCombinedResponse(
+      ResponseBuilder rb, ShardResponse srsp, SchemaField uniqueKeyField) {
+    Object response = SolrResponseUtil.getSubsectionFromShardResponse(rb, srsp, "response", false);
+    if (response instanceof SolrDocumentList docList) {
+      return docList.stream()
+          .map(doc -> doc.getFieldValue(uniqueKeyField.getName()))
+          .collect(Collectors.toSet());
+    }
+    return Set.of();
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/handler/component/combine/QueryAndResponseCombiner.java b/solr/core/src/java/org/apache/solr/handler/component/combine/QueryAndResponseCombiner.java
index c7d357fb12b..0c023231939 100644
--- a/solr/core/src/java/org/apache/solr/handler/component/combine/QueryAndResponseCombiner.java
+++ b/solr/core/src/java/org/apache/solr/handler/component/combine/QueryAndResponseCombiner.java
@@ -16,19 +16,33 @@
  */
 package org.apache.solr.handler.component.combine;
 
+import java.io.IOException;
 import java.util.HashMap;
+import java.util.HashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.internal.hppc.IntDoubleHashMap;
+import org.apache.lucene.queries.function.FunctionScoreQuery;
+import org.apache.lucene.search.DoubleValues;
+import org.apache.lucene.search.DoubleValuesSource;
 import org.apache.lucene.search.Explanation;
+import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.Query;
 import org.apache.lucene.search.TotalHits;
 import org.apache.solr.common.SolrException;
 import org.apache.solr.common.params.SolrParams;
+import org.apache.solr.common.util.CollectionUtil;
 import org.apache.solr.common.util.SimpleOrderedMap;
 import org.apache.solr.handler.component.ShardDoc;
 import org.apache.solr.search.DocIterator;
 import org.apache.solr.search.DocSet;
 import org.apache.solr.search.DocSlice;
+import org.apache.solr.search.QueryCommand;
 import org.apache.solr.search.QueryResult;
+import org.apache.solr.search.SolrIndexSearcher;
+import org.apache.solr.search.SortedIntDocSet;
 import org.apache.solr.util.plugin.NamedListInitializedPlugin;
 
 /**
@@ -49,12 +63,19 @@ public abstract class QueryAndResponseCombiner implements NamedListInitializedPl
       Map<String, List<ShardDoc>> queriesDocMap, SolrParams solrParams);
 
   /**
-   * Simple combine query result list as a union.
+   * Combine query result list as a union, optionally deduplicating by a collapse field. When a
+   * collapse filter is provided, only one document per unique field value is kept (based on the
+   * collapse sort/score selection). This ensures that collapse semantics are preserved across
+   * combined queries.
    *
    * @param queryResults the query results to be combined
+   * @param collapseFilters the collapse post filters, or empty if no collapse dedup is needed
+   * @param searcher the searcher to read field values from, required when collapseFilters is
+   *     non-empty
    * @return the combined query result
    */
-  public static QueryResult simpleCombine(List<QueryResult> queryResults) {
+  public static QueryResult simpleCombine(
+      List<QueryResult> queryResults, List<Query> collapseFilters, SolrIndexSearcher searcher) {
     QueryResult combinedQueryResults = new QueryResult();
     DocSet combinedDocSet = null;
     Map<Integer, Float> uniqueDocIds = new HashMap<>();
@@ -71,6 +92,19 @@ public abstract class QueryAndResponseCombiner implements NamedListInitializedPl
         combinedDocSet = combinedDocSet.union(queryResult.getDocSet());
       }
     }
+
+    // If collapse fields are specified, deduplicate by field value across combined queries.
+    // Each sub-query already collapsed individually, but different sub-queries may have
+    // selected different group heads for the same field value.
+    int removedByCollapse = 0;
+    if (CollectionUtil.isNotEmpty(collapseFilters) && searcher != null && queryResults.size() > 1) {
+      int preCollapseSize = uniqueDocIds.size();
+      combinedDocSet =
+          removeCollapsedDuplicatesViaSearcher(
+              collapseFilters, searcher, uniqueDocIds, combinedDocSet);
+      removedByCollapse = preCollapseSize - uniqueDocIds.size();
+    }
+
     int combinedResultsLength = uniqueDocIds.size();
     int[] combinedResultsDocIds = new int[combinedResultsLength];
     float[] combinedResultScores = new float[combinedResultsLength];
@@ -87,7 +121,7 @@ public abstract class QueryAndResponseCombiner implements NamedListInitializedPl
             combinedResultsLength,
             combinedResultsDocIds,
             combinedResultScores,
-            Math.max(combinedResultsLength, totalMatches),
+            Math.max(combinedResultsLength, totalMatches - removedByCollapse),
             combinedResultScores.length > 0 ? combinedResultScores[0] : 0,
             TotalHits.Relation.GREATER_THAN_OR_EQUAL_TO);
     combinedQueryResults.setDocList(combinedResultSlice);
@@ -95,6 +129,56 @@ public abstract class QueryAndResponseCombiner implements NamedListInitializedPl
     return combinedQueryResults;
   }
 
+  /**
+   * Removes collapsed duplicates across combined sub-queries. Ensures that only one document per
+   * collapse field value retained across the merged results. Entries removed by collapsing are also
+   * removed from {@code uniqueDocIds} (mutated in place).
+   *
+   * @return the collapsed combined DocSet, or null if combinedDocSet was null
+   */
+  private static DocSet removeCollapsedDuplicatesViaSearcher(
+      List<Query> collapseFilters,
+      SolrIndexSearcher searcher,
+      Map<Integer, Float> uniqueDocIds,
+      DocSet combinedDocSet) {
+    IntDoubleHashMap scoreMap = new IntDoubleHashMap(uniqueDocIds.size());
+    uniqueDocIds.forEach((doc, score) -> scoreMap.put(doc, score.doubleValue()));
+    Query baseQuery;
+    boolean needDocSet;
+    if (combinedDocSet != null) {
+      baseQuery = combinedDocSet.makeQuery();
+      needDocSet = true;
+    } else {
+      int[] queryDocIds =
+          uniqueDocIds.keySet().stream().mapToInt(Integer::intValue).sorted().toArray();
+      baseQuery = new SortedIntDocSet(queryDocIds).makeQuery();
+      needDocSet = false;
+    }
+    Query scoredQuery =
+        FunctionScoreQuery.boostByValue(baseQuery, new PrecomputedScoreValuesSource(scoreMap));
+
+    try {
+      QueryCommand cmd =
+          new QueryCommand()
+              .setQuery(scoredQuery)
+              .setFilterList(collapseFilters)
+              .setLen(uniqueDocIds.size())
+              .setNeedDocSet(needDocSet);
+      QueryResult result = searcher.search(cmd);
+
+      Set<Integer> retainedDocIds = HashSet.newHashSet(result.getDocList().size());
+      DocIterator iter = result.getDocList().iterator();
+      while (iter.hasNext()) {
+        retainedDocIds.add(iter.nextDoc());
+      }
+
+      uniqueDocIds.keySet().retainAll(retainedDocIds);
+      return needDocSet ? result.getDocSet() : null;
+    } catch (IOException e) {
+      throw new SolrException(SolrException.ErrorCode.SERVER_ERROR, e);
+    }
+  }
+
   /**
    * Retrieves a list of explanations for the given queries and results.
    *
@@ -127,4 +211,67 @@ public abstract class QueryAndResponseCombiner implements NamedListInitializedPl
     throw new SolrException(
         SolrException.ErrorCode.BAD_REQUEST, "Unknown Combining algorithm: " + algorithm);
   }
+
+  /**
+   * A {@link DoubleValuesSource} backed by a global doc ID to score map. Returns pre-computed
+   * scores for specific document IDs.
+   */
+  private static class PrecomputedScoreValuesSource extends DoubleValuesSource {
+
+    private final IntDoubleHashMap scoreByDoc;
+
+    PrecomputedScoreValuesSource(IntDoubleHashMap scoreByDoc) {
+      this.scoreByDoc = scoreByDoc;
+    }
+
+    @Override
+    public DoubleValues getValues(LeafReaderContext ctx, DoubleValues existing) {
+      int base = ctx.docBase;
+      return new DoubleValues() {
+        private double currentScore;
+
+        @Override
+        public double doubleValue() {
+          return currentScore;
+        }
+
+        @Override
+        public boolean advanceExact(int doc) {
+          int globalDoc = base + doc;
+          currentScore = scoreByDoc.get(globalDoc);
+          return true;
+        }
+      };
+    }
+
+    @Override
+    public boolean needsScores() {
+      return false;
+    }
+
+    @Override
+    public DoubleValuesSource rewrite(IndexSearcher searcher) {
+      return this;
+    }
+
+    @Override
+    public boolean isCacheable(LeafReaderContext ctx) {
+      return false;
+    }
+
+    @Override
+    public boolean equals(Object o) {
+      return o instanceof PrecomputedScoreValuesSource other && scoreByDoc.equals(other.scoreByDoc);
+    }
+
+    @Override
+    public int hashCode() {
+      return scoreByDoc.hashCode();
+    }
+
+    @Override
+    public String toString() {
+      return "PrecomputedScoreValuesSource(docs=" + scoreByDoc.size() + ")";
+    }
+  }
 }
diff --git a/solr/core/src/test-files/solr/collection1/conf/schema-vector-catchall.xml b/solr/core/src/test-files/solr/collection1/conf/schema-vector-catchall.xml
index 37bb2b98557..7716a8ee0f5 100644
--- a/solr/core/src/test-files/solr/collection1/conf/schema-vector-catchall.xml
+++ b/solr/core/src/test-files/solr/collection1/conf/schema-vector-catchall.xml
@@ -504,7 +504,7 @@
         </analyzer>
     </fieldType>
     <copyField source="*_commongrams" dest="*_commongrams_stop" />
-    
+
     <fieldType name="binary" class="solr.BinaryField" />
     <fieldType name="collation" class="solr.CollationField" language="en" />
     <fieldType name="icuCollation" class="solr.ICUCollationField" locale="en" />
@@ -691,6 +691,7 @@
 
 
     <dynamicField name="*_s" type="string" indexed="true" stored="true"/>
+    <dynamicField name="*_sdv" type="string" indexed="true" stored="true" docValues="true" multiValued="false"/>
     <dynamicField name="*_s1" type="string" indexed="true" stored="true" multiValued="false"/>
     <dynamicField name="*_s1_ns" type="string" indexed="true" stored="false" multiValued="false"/>
     <dynamicField name="*_l" type="long" indexed="true" stored="true"/>
@@ -857,14 +858,9 @@
     <copyField source="lowerfilt1" dest="lowerfilt1and2"/>
     <copyField source="lowerfilt" dest="lowerfilt1and2"/>
 
-    <copyField source="*" dest="text"/>
-    <copyField source="id" dest="range_facet_l"/>
-    <copyField source="id" dest="id_i1"/>
     <copyField source="range_facet_f" dest="range_facet_d"/>
     <copyField source="range_facet_f1" dest="range_facet_f1_dv"/>
 
-    <copyField source="id" dest="range_facet_l_dv"/>
-    <copyField source="id" dest="range_facet_i_dv"/>
     <copyField source="range_facet_f" dest="range_facet_f_dv"/>
     <copyField source="range_facet_f" dest="range_facet_d_dv"/>
     <copyField source="bday" dest="range_facet_dt_dv"/>
diff --git a/solr/core/src/test/org/apache/solr/handler/component/CombinedQuerySolrCloudTest.java b/solr/core/src/test/org/apache/solr/handler/component/CombinedQuerySolrCloudTest.java
index 4fbbb7f17e5..448ced51254 100644
--- a/solr/core/src/test/org/apache/solr/handler/component/CombinedQuerySolrCloudTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/component/CombinedQuerySolrCloudTest.java
@@ -45,6 +45,8 @@ public class CombinedQuerySolrCloudTest extends AbstractFullDistribZkTestBase {
     super();
     sliceCount = 2;
     fixShardCount(2);
+    schemaString = "schema-vector-catchall.xml";
+    configString = "solrconfig-combined-query.xml";
   }
 
   @Override
@@ -58,6 +60,28 @@ public class CombinedQuerySolrCloudTest extends AbstractFullDistribZkTestBase {
   }
 
   private synchronized void prepareIndexDocs() throws Exception {
+    List<SolrInputDocument> docs = getSolrDocuments();
+    del("*:*");
+    for (SolrInputDocument doc : docs) {
+      indexDoc(doc);
+    }
+    commit();
+  }
+
+  /** Index all docs to the same shard for co-location (limitation for collapse). */
+  private synchronized void prepareIndexDocsColocated() throws Exception {
+    del("*:*");
+    List<SolrInputDocument> docs = getSolrDocuments();
+    for (SolrInputDocument doc : docs) {
+      doc.setField("id", doc.getFieldValue("mod3_idv") + "!" + doc.getField("id").getValue());
+    }
+    for (SolrInputDocument doc : docs) {
+      indexDoc(doc);
+    }
+    commit();
+  }
+
+  private static synchronized List<SolrInputDocument> getSolrDocuments() {
     List<SolrInputDocument> docs = new ArrayList<>();
     for (int i = 1; i <= NUM_DOCS; i++) {
       SolrInputDocument doc = new SolrInputDocument();
@@ -87,11 +111,7 @@ public class CombinedQuerySolrCloudTest extends AbstractFullDistribZkTestBase {
     docs.get(8).addField(vectorField, Arrays.asList(200f, 50f, 100f, 25f));
     // cosine distance vector1= 0.997
     docs.get(9).addField(vectorField, Arrays.asList(1.8f, 2.5f, 3.7f, 4.9f));
-    del("*:*");
-    for (SolrInputDocument doc : docs) {
-      indexDoc(doc);
-    }
-    commit();
+    return docs;
   }
 
   @Test
@@ -249,6 +269,69 @@ public class CombinedQuerySolrCloudTest extends AbstractFullDistribZkTestBase {
         rsp.getHighlighting().get("5").get("title").getFirst());
   }
 
+  /**
+   * Tests the combined query feature with faceting, highlighting and collapse.
+   *
+   * @throws Exception if any unexpected error occurs during the test execution.
+   */
+  @Test
+  public void testQueriesWithFacetAndHighlightsCollapse() throws Exception {
+    // Re-index all docs to the first shard for co-location (required for collapse).
+    prepareIndexDocsColocated();
+    String jsonQuery =
+        """
+          {
+            "queries": {
+                "lexical1": {
+                    "lucene": {
+                        "query": "id:(2!2^2 OR 0!3^1 OR 0!6^2 OR 2!5^1)"
+                    }
+                },
+                "lexical2": {
+                    "lucene": {
+                        "query": "id:(2!8^1 OR 2!5^2 OR 1!7^3 OR 1!10^2)"
+                    }
+                }
+            },
+            "limit": 1,
+            "fields": [
+                "id",
+                "score",
+                "title"
+            ],
+            "params": {
+                "combiner": true,
+                "facet": true,
+                "facet.field": "id",
+                "fq": [
+                    "{!collapse field=mod3_idv sort='id asc'}"
+                ],
+                "expand": true,
+                "expand.q": "*:*",
+                "combiner.query": [
+                    "lexical1",
+                    "lexical2"
+                ],
+                "hl": true,
+                "hl.fl": "title",
+                "hl.q": "test doc"
+            }
+        }""";
+    handle.put("expanded", UNORDERED);
+    QueryResponse rsp = query(CommonParams.JSON, jsonQuery, CommonParams.QT, "/search");
+    assertEquals(1, rsp.getResults().size());
+    assertFieldValues(rsp.getResults(), id, "2!2");
+    assertEquals("id", rsp.getFacetFields().getFirst().getName());
+    assertEquals(
+        "[0!3 (1), 1!10 (1), 2!2 (1), 0!6 (0), 0!9 (0), 1!1 (0), 1!4 (0), 1!7 (0), 2!5 (0), 2!8 (0)]",
+        rsp.getFacetFields().getFirst().getValues().toString());
+    assertEquals(1, rsp.getHighlighting().size());
+    assertEquals(
+        "title <em>test</em> for <em>doc</em> 2",
+        rsp.getHighlighting().get("2!2").get("title").getFirst());
+    assertEquals(1, rsp.getExpandedResults().size());
+  }
+
   /** To test that we can force distrib */
   public static class ForcedDistribSearchHandler extends SearchHandler {
     @Override
diff --git a/solr/core/src/test/org/apache/solr/handler/component/DistributedCombinedQueryComponentTest.java b/solr/core/src/test/org/apache/solr/handler/component/DistributedCombinedQueryComponentTest.java
index 46441382c30..bb9105cad30 100644
--- a/solr/core/src/test/org/apache/solr/handler/component/DistributedCombinedQueryComponentTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/component/DistributedCombinedQueryComponentTest.java
@@ -290,4 +290,143 @@ public class DistributedCombinedQueryComponentTest extends BaseDistributedSearch
     QueryResponse rsp = query("qt", "/forcedDistribTest", "q", "*:*", "rows", "0");
     // ForcedDistribSearchHandler would trigger a failure if this didn't work
   }
+
+  /**
+   * Reproduces duplicate documents in combined query results when using collapse. Two different
+   * sub-queries may select different group heads for the same collapse field value, and
+   * simpleCombine() merges by Lucene doc ID — not by collapse field value — so both survive.
+   *
+   * <p>Data setup (mod3_idv is the collapse field, values cycle 1,2,0,1,2,0,...):
+   *
+   * <pre>
+   *   id=1  mod3_idv=1  text="alpha bravo"     title="alpha bravo"
+   *   id=2  mod3_idv=2  text="alpha charlie"    title="alpha charlie"
+   *   id=3  mod3_idv=0  text="bravo delta"      title="bravo delta"
+   *   id=4  mod3_idv=1  text="charlie delta"    title="charlie delta"
+   *   id=5  mod3_idv=2  text="alpha delta"      title="alpha delta"
+   *   id=6  mod3_idv=0  text="bravo charlie"    title="bravo charlie"
+   * </pre>
+   *
+   * <p>Query 1: "alpha bravo" → matches docs 1,2,3,5,6 with varying scores. After collapse on
+   * mod3_idv, picks one group head per value (0,1,2).
+   *
+   * <p>Query 2: "charlie delta" → matches docs 2,3,4,5,6 with varying scores. After collapse on
+   * mod3_idv, picks one group head per value (0,1,2).
+   *
+   * <p>Because the queries score differently, they may pick DIFFERENT group heads for the same
+   * mod3_idv value. After simpleCombine() merges by doc ID, both heads appear → duplicates on the
+   * collapse field.
+   */
+  @Test
+  public void testCollapseWithCombinedQueryProducesDuplicates() throws Exception {
+    del("*:*");
+
+    // Index 6 docs where mod3_idv groups docs: {3,6}→0, {1,4}→1, {2,5}→2
+    // All docs indexed to the same shard (first client) to ensure co-location,
+    // which is a requirement for collapse in SolrCloud.
+    List<SolrInputDocument> docs = new ArrayList<>();
+    String[][] data = {
+      {"1", "alpha bravo", "alpha bravo"},
+      {"2", "alpha charlie", "alpha charlie"},
+      {"3", "bravo delta", "bravo delta"},
+      {"4", "charlie delta", "charlie delta"},
+      {"5", "alpha delta", "alpha delta"},
+      {"6", "bravo charlie", "bravo charlie"},
+    };
+    for (String[] row : data) {
+      SolrInputDocument doc = new SolrInputDocument();
+      doc.addField("id", row[0]);
+      doc.addField("text", row[1]);
+      doc.addField("title", row[2]);
+      int idVal = Integer.parseInt(row[0]);
+      doc.addField("mod3_idv", idVal % 3);
+      doc.addField("mod3_sdv", String.valueOf(idVal % 3));
+      docs.add(doc);
+    }
+    // Index all docs to the first shard (co-location for collapse).
+    // indexDoc(client, doc) also indexes to controlClient automatically.
+    for (SolrInputDocument doc : docs) {
+      indexDoc(clients.getFirst(), doc);
+    }
+    commit();
+
+    // Two queries that score docs differently, both collapsed on mod3_idv.
+    // Query 1 "alpha bravo" favours doc 1 (mod3_idv=1) and doc 3 (mod3_idv=0).
+    // Query 2 "charlie delta" favours doc 4 (mod3_idv=1) and doc 3 or 6 (mod3_idv=0).
+    // After collapse, each query returns 3 docs (one per mod3_idv value 0,1,2).
+    // But the group heads for mod3_idv=1 differ: query1 picks id=1, query2 picks id=4.
+    // simpleCombine() merges by doc ID, so both id=1 and id=4 survive → duplicate on mod3_idv=1.
+    // q1 -> 1,2,5,3,6    q2 -> 2,4,6,3,5
+    // q1 -> 1,2,2,0,0    q2 -> 2,1,0,0,2
+    // q1 -> 1 for sure, 2,3  => 4 for sure, 3 and 2
+    String jsonQuery =
+        """
+        {
+         "queries": {
+             "q1": {
+                 "lucene": {
+                     "query": "text:alpha OR text:bravo"
+                 }
+             },
+             "q2": {
+                 "lucene": {
+                     "query": "text:charlie OR text:delta"
+                 }
+             }
+         },
+         "limit": 10,
+         "fields": [
+             "id",
+             "score",
+             "mod3_idv"
+         ],
+         "params": {
+             "combiner": true,
+             "combiner.query": [
+                 "q1",
+                 "q2"
+             ],
+             "fq": [
+                 "{!collapse field=mod3_idv sort='mod3_sdv asc, id desc'}"
+             ],
+             "facet": true,
+             "facet.field": "id",
+             "hl": true,
+             "hl.fl": "title",
+             "hl.q": "alpha delta",
+             "expand": true,
+             "expand.q": "text:alpha OR text:bravo OR text:charlie OR text:delta"
+         }
+     }""";
+
+    handle.put("expanded", UNORDERED);
+    QueryResponse rsp = query(CommonParams.JSON, jsonQuery, CommonParams.QT, "/search");
+
+    // Collect mod3_idv values from the result to check for duplicates
+    List<Integer> collapseValues = new ArrayList<>();
+    for (int i = 0; i < rsp.getResults().size(); i++) {
+      Object val = rsp.getResults().get(i).getFieldValue("mod3_idv");
+      collapseValues.add(((Number) val).intValue());
+    }
+
+    // Without the fix: more than 3 results because different group heads survive for the same
+    // mod3_idv value. With the fix: exactly 3 results (one per mod3_idv value 0, 1, 2).
+    long uniqueCollapseValues = collapseValues.stream().distinct().count();
+    assertEquals(
+        "Expected no duplicate collapse field values in combined results, "
+            + "but got collapseValues="
+            + collapseValues,
+        uniqueCollapseValues,
+        collapseValues.size());
+    assertEquals("Expected exactly 3 groups (mod3_idv values 0, 1, 2)", 3, collapseValues.size());
+    assertEquals("id", rsp.getFacetFields().getFirst().getName());
+    assertEquals(
+        "[4 (1), 5 (1), 6 (1), 1 (0), 2 (0), 3 (0)]",
+        rsp.getFacetFields().getFirst().getValues().toString());
+    assertEquals(3, rsp.getHighlighting().size());
+    assertEquals("charlie <em>delta</em>", rsp.getHighlighting().get("4").get("title").getFirst());
+    assertEquals(
+        "<em>alpha</em> <em>delta</em>", rsp.getHighlighting().get("5").get("title").getFirst());
+    assertEquals(3, rsp.getExpandedResults().size());
+  }
 }
diff --git a/solr/core/src/test/org/apache/solr/handler/component/combine/QueryAndResponseCombinerTest.java b/solr/core/src/test/org/apache/solr/handler/component/combine/QueryAndResponseCombinerTest.java
index aece94c5ea3..a03aa7219e5 100644
--- a/solr/core/src/test/org/apache/solr/handler/component/combine/QueryAndResponseCombinerTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/component/combine/QueryAndResponseCombinerTest.java
@@ -17,15 +17,29 @@
 package org.apache.solr.handler.component.combine;
 
 import java.util.List;
+import org.apache.lucene.search.Query;
 import org.apache.lucene.search.TotalHits;
 import org.apache.solr.SolrTestCaseJ4;
+import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.request.SolrRequestInfo;
+import org.apache.solr.response.SolrQueryResponse;
+import org.apache.solr.search.CollapsingQParserPlugin.CollapsingPostFilter;
 import org.apache.solr.search.DocSlice;
+import org.apache.solr.search.QParser;
 import org.apache.solr.search.QueryResult;
+import org.apache.solr.search.SolrIndexSearcher;
 import org.apache.solr.search.SortedIntDocSet;
+import org.apache.solr.util.RefCounted;
+import org.junit.BeforeClass;
 import org.junit.Test;
 
 public class QueryAndResponseCombinerTest extends SolrTestCaseJ4 {
 
+  @BeforeClass
+  public static void beforeClass() throws Exception {
+    initCore("solrconfig.xml", "schema11.xml");
+  }
+
   public static List<QueryResult> getQueryResults() {
     QueryResult r1 = new QueryResult();
     r1.setDocList(
@@ -52,10 +66,100 @@ public class QueryAndResponseCombinerTest extends SolrTestCaseJ4 {
     return List.of(r1, r2);
   }
 
+  /** Parses a collapse fq string and returns the CollapsingPostFilter. */
+  private static CollapsingPostFilter parseCollapseFilter(String collapseFq) throws Exception {
+    SolrQueryRequest solrReq = req();
+    SolrQueryResponse rsp = new SolrQueryResponse();
+    SolrRequestInfo.setRequestInfo(new SolrRequestInfo(solrReq, rsp));
+    try {
+      Query q = QParser.getParser(collapseFq, solrReq).getQuery();
+      assertTrue(
+          "Expected CollapsingPostFilter but got " + q.getClass(),
+          q instanceof CollapsingPostFilter);
+      return (CollapsingPostFilter) q;
+    } finally {
+      SolrRequestInfo.clearRequestInfo();
+      solrReq.close();
+    }
+  }
+
+  /** Tests that simpleCombine without using collapse. */
   @Test
   public void simpleCombine() {
-    QueryResult queryResult = QueryAndResponseCombiner.simpleCombine(getQueryResults());
+    QueryResult queryResult = QueryAndResponseCombiner.simpleCombine(getQueryResults(), null, null);
     assertEquals(3, queryResult.getDocList().size());
     assertEquals(4, queryResult.getDocSet().size());
   }
+
+  /**
+   * Tests that simpleCombine deduplicates by collapse field value when a collapse field is
+   * provided. Uses the same query results from getQueryResults() but indexes docs so that Lucene
+   * doc IDs 0 and 1 share the same collapse field value — only the higher-scoring doc should
+   * survive.
+   */
+  @Test
+  public void simpleCombineWithCollapseField() throws Exception {
+    // getQueryResults() returns:
+    //   r1: docs [1, 2] scores [0.67, 0.62]
+    //   r2: docs [0]    scores [0.87]
+    assertU(delQ("*:*"));
+    assertU(adoc("id", "0", "group_ti_dv", "1"));
+    assertU(adoc("id", "1", "group_ti_dv", "1"));
+    assertU(adoc("id", "2", "group_ti_dv", "2"));
+    assertU(commit());
+
+    CollapsingPostFilter collapseFilter = parseCollapseFilter("{!collapse field=group_ti_dv}");
+
+    RefCounted<SolrIndexSearcher> searcherRef = h.getCore().getSearcher();
+    try {
+      SolrIndexSearcher searcher = searcherRef.get();
+
+      QueryResult combined =
+          QueryAndResponseCombiner.simpleCombine(
+              getQueryResults(), List.of(collapseFilter), searcher);
+
+      // group=1: doc 0 (0.87) beats doc 1 (0.67) → keep doc 0
+      // group=2: doc 2 (0.62) → kept
+      assertEquals(
+          "Expected 2 docs after collapse dedup (one per group value)",
+          2,
+          combined.getDocList().size());
+      assertEquals(0, combined.getDocList().iterator().nextDoc());
+    } finally {
+      searcherRef.decref();
+    }
+  }
+
+  /**
+   * Tests that docs with missing collapse field values are kept (null policy pass-through) and not
+   * considered duplicates.
+   */
+  @Test
+  public void simpleCombineWithCollapseFieldNullPolicy() throws Exception {
+    assertU(delQ("*:*"));
+    assertU(adoc("id", "0", "group_ti_dv", "1"));
+    assertU(adoc("id", "1", "group_ti_dv", "1"));
+    assertU(adoc("id", "2")); // no collapse field
+    assertU(commit());
+
+    CollapsingPostFilter collapseFilter =
+        parseCollapseFilter("{!collapse field=group_ti_dv nullPolicy=collapse sort='score asc'}");
+
+    RefCounted<SolrIndexSearcher> searcherRef = h.getCore().getSearcher();
+    try {
+      SolrIndexSearcher searcher = searcherRef.get();
+
+      QueryResult combined =
+          QueryAndResponseCombiner.simpleCombine(
+              getQueryResults(), List.of(collapseFilter), searcher);
+
+      // group=1: doc 0 (0.87) > doc 1 (0.67) → keep doc 1
+      // doc 2 has no group value → kept (null policy)
+      assertEquals(
+          "Expected 2 docs: one group head + one null-field doc", 2, combined.getDocList().size());
+      assertEquals(1, combined.getDocList().iterator().nextDoc());
+    } finally {
+      searcherRef.decref();
+    }
+  }
 }
diff --git a/solr/core/src/test/org/apache/solr/handler/component/combine/ReciprocalRankFusionTest.java b/solr/core/src/test/org/apache/solr/handler/component/combine/ReciprocalRankFusionTest.java
index 27ab8c5a312..ac416c49aab 100644
--- a/solr/core/src/test/org/apache/solr/handler/component/combine/ReciprocalRankFusionTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/component/combine/ReciprocalRankFusionTest.java
@@ -55,7 +55,7 @@ public class ReciprocalRankFusionTest extends SolrTestCaseJ4 {
   @Test
   public void testSimpleCombine() {
     List<QueryResult> rankedList = QueryAndResponseCombinerTest.getQueryResults();
-    QueryResult result = QueryAndResponseCombiner.simpleCombine(rankedList);
+    QueryResult result = QueryAndResponseCombiner.simpleCombine(rankedList, null, null);
     assertEquals(3, result.getDocList().size());
     assertEquals(4, result.getDocSet().size());
   }


(solr) 02/03: SOLR-14687: {!parent} and {!child} qparsers now use NestPathField (#4206)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 285e05d7041d7e146002eddeb9837ea57b3a13ae
Author: David Smiley <ds...@apache.org>
AuthorDate: Sat Apr 18 00:30:29 2026 -0400

    SOLR-14687: {!parent} and {!child} qparsers now use NestPathField (#4206)
    
    The {!parent} and {!child} query parsers now support a parentPath local param that automatically derives the correct parent filter using the _nest_path_ field, making nested document queries easier to write correctly.  childPath is also added as an optional param to limit the child-side of both queries.
    
    Co-authored-by: copilot-swe-agent[bot] <19...@users.noreply.github.com>
    Co-authored-by: dsmiley <37...@users.noreply.github.com>
---
 .../unreleased/SOLR-14687-parentPath-param.yml     |   9 +
 .../solr/search/join/BlockJoinChildQParser.java    |  64 ++++-
 .../solr/search/join/BlockJoinParentQParser.java   | 271 +++++++++++++++++++--
 .../apache/solr/search/join/FiltersQParser.java    |  15 +-
 .../org/apache/solr/search/QueryEqualityTest.java  |  25 ++
 .../solr/update/TestNestedUpdateProcessor.java     | 147 ++++++++++-
 .../query-guide/pages/block-join-query-parser.adoc |  85 ++++++-
 .../pages/searching-nested-documents.adoc          |  51 ++--
 8 files changed, 588 insertions(+), 79 deletions(-)

diff --git a/changelog/unreleased/SOLR-14687-parentPath-param.yml b/changelog/unreleased/SOLR-14687-parentPath-param.yml
new file mode 100644
index 00000000000..424deee0c42
--- /dev/null
+++ b/changelog/unreleased/SOLR-14687-parentPath-param.yml
@@ -0,0 +1,9 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+title: The {!parent} and {!child} query parsers now support a parentPath local param that automatically derives the correct parent filter using the _nest_path_ field, making nested document queries easier to write correctly.  childPath is also added.
+type: added # added, changed, fixed, deprecated, removed, dependency_update, security, other
+authors:
+  - name: David Smiley
+  - name: hossman
+links:
+  - name: SOLR-14687
+    url: https://issues.apache.org/jira/browse/SOLR-14687
diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java
index bb6c80db07a..0d7383dfc4f 100644
--- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java
+++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinChildQParser.java
@@ -25,6 +25,7 @@ import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.request.SolrQueryRequest;
 import org.apache.solr.search.SyntaxError;
 
+/** Matches child documents based on parent doc criteria. */
 public class BlockJoinChildQParser extends BlockJoinParentQParser {
 
   public BlockJoinChildQParser(
@@ -33,18 +34,18 @@ public class BlockJoinChildQParser extends BlockJoinParentQParser {
   }
 
   @Override
-  protected Query createQuery(Query parentListQuery, Query query, String scoreMode) {
-    return new ToChildBlockJoinQuery(query, getBitSetProducer(parentListQuery));
+  protected Query createQuery(Query parentListQuery, Query fromQuery, String scoreMode) {
+    return new ToChildBlockJoinQuery(fromQuery, getBitSetProducer(parentListQuery));
   }
 
   @Override
-  protected String getParentFilterLocalParamName() {
+  protected String getLegacyParentFilterParamName() {
     return "of";
   }
 
   @Override
-  protected Query noClausesQuery() throws SyntaxError {
-    final Query parents = parseParentFilter();
+  protected Query noClausesQueryLegacy() throws SyntaxError {
+    final Query parents = parseLegacyParentFilter();
     final BooleanQuery notParents =
         new BooleanQuery.Builder()
             .add(new MatchAllDocsQuery(), Occur.MUST)
@@ -52,4 +53,57 @@ public class BlockJoinChildQParser extends BlockJoinParentQParser {
             .build();
     return new BitSetProducerQuery(getBitSetProducer(notParents));
   }
+
+  /**
+   * Parses the query using the {@code parentPath} local-param for the child parser.
+   *
+   * <p>For the {@code child} parser with {@code parentPath="/a/b/c"}:
+   *
+   * <pre>NEW: q={!child parentPath="/a/b/c"}p_title:dad
+   *
+   * OLD: q={!child of=$ff v=$vv}
+   *      ff=(*:* -{!prefix f="_nest_path_" v="/a/b/c/"})
+   *      vv=(+p_title:dad +{!field f="_nest_path_" v="/a/b/c"})</pre>
+   *
+   * <p>For {@code parentPath="/"}:
+   *
+   * <pre>NEW: q={!child parentPath="/"}p_title:dad
+   *
+   * OLD: q={!child of=$ff v=$vv}
+   *      ff=(*:* -_nest_path_:*)
+   *      vv=(+p_title:dad -_nest_path_:*)</pre>
+   *
+   * <p>The optional {@code childPath} local-param narrows the returned children to docs at exactly
+   * {@code parentPath/childPath}.
+   *
+   * @param parentPath the normalized parent path (starts with "/", no trailing slash except for
+   *     root "/")
+   * @param childPath optional path constraining the children relative to parentPath
+   */
+  @Override
+  protected Query parseUsingParentPath(String parentPath, String childPath) throws SyntaxError {
+    final BooleanQuery parsedParentQuery = parseImpl();
+
+    if (parsedParentQuery.clauses().isEmpty()) { // i.e. match all parents
+      // no block-join needed; just filter to certain children
+      return wrapWithChildPathConstraint(parentPath, childPath, new MatchAllDocsQuery());
+    }
+
+    // allParents filter: (*:* -{!prefix f="_nest_path_" v="<parentPath>/"})
+    // For root: (*:* -_nest_path_:*)
+    final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath);
+
+    // constrain the parent query to only match docs at exactly parentPath
+    // (+<original_parent> +{!field f="_nest_path_" v="<parentPath>"})
+    // For root: (+<original_parent> -_nest_path_:*)
+    Query constrainedParentQuery = wrapWithParentPathConstraint(parentPath, parsedParentQuery);
+
+    Query joinQuery = createQuery(allParentsFilter, constrainedParentQuery, null);
+    // matches all children of matching parents
+    if (childPath == null) {
+      return joinQuery;
+    }
+    // need to constrain to certain children
+    return wrapWithChildPathConstraint(parentPath, childPath, joinQuery);
+  }
 }
diff --git a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java
index 1d73bbd78aa..6e7dbaf4c63 100644
--- a/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java
+++ b/solr/core/src/java/org/apache/solr/search/join/BlockJoinParentQParser.java
@@ -18,15 +18,23 @@ package org.apache.solr.search.join;
 
 import java.io.IOException;
 import java.io.UncheckedIOException;
+import java.util.Locale;
 import java.util.Objects;
 import org.apache.lucene.index.LeafReaderContext;
+import org.apache.lucene.index.Term;
+import org.apache.lucene.search.BooleanClause.Occur;
+import org.apache.lucene.search.BooleanQuery;
+import org.apache.lucene.search.ConstantScoreQuery;
 import org.apache.lucene.search.ConstantScoreScorer;
 import org.apache.lucene.search.ConstantScoreWeight;
 import org.apache.lucene.search.DocIdSetIterator;
 import org.apache.lucene.search.IndexSearcher;
+import org.apache.lucene.search.MatchAllDocsQuery;
+import org.apache.lucene.search.PrefixQuery;
 import org.apache.lucene.search.Query;
 import org.apache.lucene.search.QueryVisitor;
 import org.apache.lucene.search.ScorerSupplier;
+import org.apache.lucene.search.TermQuery;
 import org.apache.lucene.search.Weight;
 import org.apache.lucene.search.join.BitSetProducer;
 import org.apache.lucene.search.join.QueryBitSetProducer;
@@ -34,21 +42,41 @@ import org.apache.lucene.search.join.ScoreMode;
 import org.apache.lucene.search.join.ToParentBlockJoinQuery;
 import org.apache.lucene.util.BitSet;
 import org.apache.lucene.util.BitSetIterator;
+import org.apache.solr.common.SolrException;
 import org.apache.solr.common.params.SolrParams;
 import org.apache.solr.request.SolrQueryRequest;
+import org.apache.solr.schema.IndexSchema;
+import org.apache.solr.schema.SchemaField;
 import org.apache.solr.search.ExtendedQueryBase;
-import org.apache.solr.search.QParser;
 import org.apache.solr.search.SolrCache;
 import org.apache.solr.search.SyntaxError;
 import org.apache.solr.util.SolrDefaultScorerSupplier;
 
+/** Matches parent documents based on child doc criteria. */
 public class BlockJoinParentQParser extends FiltersQParser {
   /** implementation detail subject to change */
   public static final String CACHE_NAME = "perSegFilter";
 
-  protected String getParentFilterLocalParamName() {
-    return "which";
-  }
+  /**
+   * Optional local-param that, when specified, makes this parser natively aware of the {@link
+   * IndexSchema#NEST_PATH_FIELD_NAME} field to automatically derive the parent filter (the {@code
+   * which} param). The value must be an absolute path starting with {@code /} using {@code /} as
+   * separator, e.g. {@code /} for root-level parents or {@code /skus} for parents nested at that
+   * path. When specified, the {@code which} param must not also be specified.
+   *
+   * @see <a href="https://issues.apache.org/jira/browse/SOLR-14687">SOLR-14687</a>
+   */
+  public static final String PARENT_PATH_PARAM = "parentPath";
+
+  /**
+   * Optional local-param, only valid together with {@link #PARENT_PATH_PARAM} on the {@code parent}
+   * parser. When specified, the subordinate (child) query is constrained to docs at exactly the
+   * path formed by concatenating {@code parentPath + "/" + childPath}, instead of the default
+   * behavior of matching all descendants. For example, {@code parentPath="/skus"
+   * childPath="manuals"} constrains children to docs whose {@code _nest_path_} is exactly {@code
+   * /skus/manuals}.
+   */
+  public static final String CHILD_PATH_PARAM = "childPath";
 
   @Override
   protected String getFiltersParamName() {
@@ -60,35 +88,241 @@ public class BlockJoinParentQParser extends FiltersQParser {
     super(qstr, localParams, params, req);
   }
 
-  protected Query parseParentFilter() throws SyntaxError {
-    String filter = localParams.get(getParentFilterLocalParamName());
-    QParser parentParser = subQuery(filter, null);
-    Query parentQ = parentParser.getQuery();
-    return parentQ;
+  @Override
+  public Query parse() throws SyntaxError {
+    // Dispatch based on parentPath or none (DIY/legacy)
+
+    String parentPath = localParams.get(PARENT_PATH_PARAM);
+    if (parentPath != null) {
+      if (localParams.get(getLegacyParentFilterParamName()) != null) {
+        throw new SolrException(
+            SolrException.ErrorCode.BAD_REQUEST,
+            PARENT_PATH_PARAM
+                + " and "
+                + getLegacyParentFilterParamName()
+                + " local params are mutually exclusive");
+      }
+      if (!parentPath.startsWith("/")) {
+        throw new SolrException(
+            SolrException.ErrorCode.BAD_REQUEST, PARENT_PATH_PARAM + " must start with '/'");
+      }
+      // strip trailing slash (except for root "/")
+      if (parentPath.length() > 1 && parentPath.endsWith("/")) {
+        parentPath = parentPath.substring(0, parentPath.length() - 1);
+      }
+
+      String childPath = localParams.get(CHILD_PATH_PARAM);
+      if (childPath != null) {
+        if (childPath.startsWith("/")) {
+          throw new SolrException(
+              SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " must not start with '/'");
+        }
+        if (childPath.isEmpty()) {
+          childPath = null; // treat empty as not specified
+        }
+      }
+      return parseUsingParentPath(parentPath, childPath);
+    }
+
+    // NO parentPath; use legacy/advanced/DIY code path:
+
+    if (localParams.get(CHILD_PATH_PARAM) != null) {
+      throw new SolrException(
+          SolrException.ErrorCode.BAD_REQUEST, CHILD_PATH_PARAM + " requires " + PARENT_PATH_PARAM);
+    }
+    if (localParams.get(getLegacyParentFilterParamName()) == null) {
+      throw new SyntaxError(
+          String.format(
+              Locale.ROOT,
+              "'%s' or '%s' is required for the '%s' query parser",
+              getLegacyParentFilterParamName(),
+              PARENT_PATH_PARAM,
+              localParams.get("type", "parent")));
+    }
+    return parseWithLegacyParam();
+  }
+
+  /**
+   * Parses the query using the {@code parentPath} localparam to automatically derive the parent
+   * filter and child query constraints from {@link IndexSchema#NEST_PATH_FIELD_NAME}.
+   *
+   * <p>For the {@code parent} parser with {@code parentPath="/a/b/c"}:
+   *
+   * <pre>NEW: q={!parent parentPath="/a/b/c"}c_title:son
+   *
+   * OLD: q=(+{!field f="_nest_path_" v="/a/b/c"} +{!parent which=$ff v=$vv})
+   *      ff=(*:* -{!prefix f="_nest_path_" v="/a/b/c/"})
+   *      vv=(+c_title:son +{!prefix f="_nest_path_" v="/a/b/c/"})</pre>
+   *
+   * <p>For {@code parentPath="/"}:
+   *
+   * <pre>NEW: q={!parent parentPath="/"}c_title:son
+   *
+   * OLD: q=(+(*:* -_nest_path_:*) +{!parent which=$ff v=$vv})
+   *      ff=(*:* -_nest_path_:*)
+   *      vv=(+c_title:son +_nest_path_:*)</pre>
+   *
+   * @param parentPath the normalized parent path (starts with "/", no trailing slash except for
+   *     root "/")
+   * @param childPath optional path constraining the children relative to parentPath
+   */
+  protected Query parseUsingParentPath(String parentPath, String childPath) throws SyntaxError {
+    final BooleanQuery parsedChildQuery = parseImpl();
+
+    if (parsedChildQuery.clauses().isEmpty()) { // i.e. all children
+      // no block-join needed; just return all "parent" docs at this level
+      return wrapWithParentPathConstraint(parentPath, new MatchAllDocsQuery());
+    }
+
+    // allParents filter: (*:* -{!prefix f="_nest_path_" v="<parentPath>/"})
+    // For root: (*:* -_nest_path_:*)
+    final Query allParentsFilter = buildAllParentsFilterFromPath(parentPath);
+
+    // constrain child query: (+<original_child> +{!prefix f="_nest_path_" v="<parentPath>/"})
+    // For root: (+<original_child> +_nest_path_:*)
+    // If childPath specified: (+<original_child> +{!term f="_nest_path_"
+    // v="<parentPath>/<childPath>"})
+    final Query constrainedChildQuery =
+        wrapWithChildPathConstraint(parentPath, childPath, parsedChildQuery);
+
+    final String scoreMode = localParams.get("score", ScoreMode.None.name());
+    final Query parentJoinQuery = createQuery(allParentsFilter, constrainedChildQuery, scoreMode);
+
+    // wrap result: (+<parent_join> +{!field f="_nest_path_" v="<parentPath>"})
+    // For root: (+<parent_join> -_nest_path_:*)
+    return wrapWithParentPathConstraint(parentPath, parentJoinQuery);
   }
 
-  @Override
-  protected Query wrapSubordinateClause(Query subordinate) throws SyntaxError {
+  /**
+   * Builds the "all parents" filter query from the given {@code parentPath}. This query matches all
+   * documents that are NOT strictly below (nested inside) the given path. This includes:
+   *
+   * <ul>
+   *   <li>documents without any {@code _nest_path_} (root-level, non-nested docs)
+   *   <li>documents at the same level as {@code parentPath} (i.e. with exactly that path)
+   *   <li>documents at levels above {@code parentPath}
+   *   <li>documents at completely orthogonal paths (e.g. {@code /x/y/z} when parentPath is {@code
+   *       /a/b/c})
+   * </ul>
+   *
+   * <p>Equivalent to: {@code (*:* -{!prefix f="_nest_path_" v="<parentPath>/"})} For root ({@code
+   * /}): {@code (*:* -_nest_path_:*)}
+   */
+  protected Query buildAllParentsFilterFromPath(String parentPath) {
+    final Query excludeQuery;
+    if (parentPath.equals("/")) {
+      excludeQuery = newNestPathExistsQuery();
+    } else {
+      excludeQuery = new PrefixQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath + "/"));
+    }
+    return new BooleanQuery.Builder()
+        .add(new MatchAllDocsQuery(), Occur.MUST)
+        .add(excludeQuery, Occur.MUST_NOT)
+        .build();
+  }
+
+  /**
+   * Wraps the given query with a constraint ensuring only docs at exactly {@code parentPath} are
+   * matched.
+   */
+  protected Query wrapWithParentPathConstraint(String parentPath, Query query) {
+    final BooleanQuery.Builder builder = new BooleanQuery.Builder().add(query, Occur.MUST);
+    if (parentPath.equals("/")) {
+      builder.add(newNestPathExistsQuery(), Occur.MUST_NOT);
+    } else {
+      final Query constraint =
+          new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath));
+      if (query instanceof MatchAllDocsQuery) {
+        return isFilter() ? constraint : new ConstantScoreQuery(constraint);
+      }
+      builder.add(constraint, Occur.FILTER);
+    }
+    return builder.build();
+  }
+
+  protected Query newNestPathExistsQuery() {
+    final SchemaField nestPathField = req.getSchema().getField(IndexSchema.NEST_PATH_FIELD_NAME);
+    return nestPathField.getType().getExistenceQuery(this, nestPathField);
+  }
+
+  /**
+   * Wraps the sub-query with a constraint ensuring only docs that are descendants of {@code
+   * parentPath} are matched. If {@code childPath} is non-null, further narrows to docs at exactly
+   * {@code parentPath/childPath}.
+   */
+  protected Query wrapWithChildPathConstraint(String parentPath, String childPath, Query subQuery) {
+    final Query nestPathConstraint;
+    if (childPath != null) {
+      String effectiveChildPath =
+          parentPath.equals("/") ? "/" + childPath : parentPath + "/" + childPath;
+      nestPathConstraint =
+          new TermQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, effectiveChildPath));
+    } else if (parentPath.equals("/")) {
+      nestPathConstraint = newNestPathExistsQuery();
+    } else {
+      nestPathConstraint =
+          new PrefixQuery(new Term(IndexSchema.NEST_PATH_FIELD_NAME, parentPath + "/"));
+    }
+    if (subQuery instanceof MatchAllDocsQuery) {
+      return new ConstantScoreQuery(nestPathConstraint);
+    }
+    return new BooleanQuery.Builder()
+        .add(subQuery, Occur.MUST)
+        .add(nestPathConstraint, Occur.FILTER)
+        .build();
+  }
+
+  //
+  // Advanced/DIY parsing follows
+  //
+
+  protected Query parseWithLegacyParam() throws SyntaxError {
+    BooleanQuery subordinateQuery = parseImpl();
+
+    if (subordinateQuery.clauses().isEmpty()) { // i.e. all children
+      return noClausesQueryLegacy();
+    }
+
     String scoreMode = localParams.get("score", ScoreMode.None.name());
-    Query parentQ = parseParentFilter();
-    return createQuery(parentQ, subordinate, scoreMode);
+    Query parentQ = parseLegacyParentFilter();
+    return createQuery(parentQ, subordinateQuery, scoreMode);
   }
 
-  @Override
-  protected Query noClausesQuery() throws SyntaxError {
-    return new BitSetProducerQuery(getBitSetProducer(parseParentFilter()));
+  protected Query parseLegacyParentFilter() throws SyntaxError {
+    return subQuery(localParams.get(getLegacyParentFilterParamName()), null).getQuery();
+  }
+
+  protected Query noClausesQueryLegacy() throws SyntaxError {
+    return new BitSetProducerQuery(getBitSetProducer(parseLegacyParentFilter()));
+  }
+
+  protected String getLegacyParentFilterParamName() {
+    return "which";
   }
 
-  protected Query createQuery(final Query parentList, Query query, String scoreMode)
+  /**
+   * Create the block-join query, the core Query of the QParser.
+   *
+   * @param parentList the "parent" query. The result will internally be cached.
+   * @param fromQuery source/from query. For {!parent}, this is a child, otherwise it's a parent
+   * @param scoreMode see {@link ScoreMode}
+   * @return non-null
+   * @throws SyntaxError Only if scoreMode doesn't parse
+   */
+  protected Query createQuery(final Query parentList, Query fromQuery, String scoreMode)
       throws SyntaxError {
     return new AllParentsAware(
-        query, getBitSetProducer(parentList), ScoreModeParser.parse(scoreMode), parentList);
+        fromQuery, getBitSetProducer(parentList), ScoreModeParser.parse(scoreMode), parentList);
   }
 
   BitSetProducer getBitSetProducer(Query query) {
     return getCachedBitSetProducer(req, query);
   }
 
+  /**
+   * Returns a Lucene {@link BitSetProducer}, typically cached by query. Note that BSP itself
+   * internally caches a per-segment {@link BitSet}.
+   */
   public static BitSetProducer getCachedBitSetProducer(
       final SolrQueryRequest request, Query query) {
     @SuppressWarnings("unchecked")
@@ -105,6 +339,7 @@ public class BlockJoinParentQParser extends FiltersQParser {
     }
   }
 
+  /** A {@link ToParentBlockJoinQuery} exposing the query underlying the {@link BitSetProducer}. */
   static final class AllParentsAware extends ToParentBlockJoinQuery {
     private final Query parentQuery;
 
diff --git a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java
index b031f136566..451de63337a 100644
--- a/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java
+++ b/solr/core/src/java/org/apache/solr/search/join/FiltersQParser.java
@@ -48,9 +48,10 @@ public class FiltersQParser extends QParser {
   @Override
   public Query parse() throws SyntaxError {
     BooleanQuery query = parseImpl();
-    return !query.clauses().isEmpty() ? wrapSubordinateClause(query) : noClausesQuery();
+    return !query.clauses().isEmpty() ? query : new MatchAllDocsQuery();
   }
 
+  /** Parses the subQuery, applying filters and exclusions. Caller must check if empty. */
   protected BooleanQuery parseImpl() throws SyntaxError {
     Map<QParser, Occur> clauses = clauses();
 
@@ -72,14 +73,6 @@ public class FiltersQParser extends QParser {
     return query;
   }
 
-  protected Query wrapSubordinateClause(Query subordinate) throws SyntaxError {
-    return subordinate;
-  }
-
-  protected Query noClausesQuery() throws SyntaxError {
-    return new MatchAllDocsQuery();
-  }
-
   protected void exclude(Collection<QParser> clauses) {
     Set<String> tagsToExclude = new HashSet<>();
     String excludeTags = localParams.get("excludeTags");
@@ -126,14 +119,14 @@ public class FiltersQParser extends QParser {
 
   private Collection<QParser> excludeSet(Map<?, ?> tagMap, Set<String> tagsToExclude) {
 
-    IdentityHashMap<QParser, Boolean> excludeSet = new IdentityHashMap<>();
+    IdentityHashMap<QParser, Object> excludeSet = new IdentityHashMap<>();
     for (String excludeTag : tagsToExclude) {
       Object olst = tagMap.get(excludeTag);
       // tagMap has entries of List<String,List<QParser>>, but subject to change in the future
       if (!(olst instanceof Collection)) continue;
       for (Object o : (Collection<?>) olst) {
         if (!(o instanceof QParser qp)) continue;
-        excludeSet.put(qp, Boolean.TRUE);
+        excludeSet.put(qp, null); // dummy value
       }
     }
     return excludeSet.keySet();
diff --git a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java
index 817458569bf..b687d826077 100644
--- a/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java
+++ b/solr/core/src/test/org/apache/solr/search/QueryEqualityTest.java
@@ -733,6 +733,31 @@ public class QueryEqualityTest extends SolrTestCaseJ4 {
               + parent_path.replace("/", "\\/")
               + ")");
     }
+
+    // Test parentPath with no subordinate query: {!parent parentPath=/ v=''} returns all root-level
+    // docs. This is a useful trick to query docs at a specific nest path without using _nest_path_
+    // directly.
+    try (SolrQueryRequest req = req()) {
+      Query q =
+          assertQueryEqualsAndReturn(
+              "parent",
+              req,
+              "{!parent parentPath=/ v=''}",
+              "{!parent parentPath=/}"); // omitting v is equivalent to empty v
+      assertEquals("+*:* -FieldExistsQuery [field=_nest_path_]", q.toString());
+
+      q =
+          assertQueryEqualsAndReturn(
+              "parent",
+              req,
+              "{!parent parentPath=/aa/bb v=}",
+              "{!parent parentPath=/aa/bb}"); // omitting v is equivalent to empty v
+      assertEquals("ConstantScore(_nest_path_:/aa/bb)", q.toString());
+    }
+
+    // Test that {!parent} and {!child} without required 'which'/'of' or 'parentPath' throw error
+    expectThrows(SyntaxError.class, () -> QParser.getParser("{!parent}", req()).getQuery());
+    expectThrows(SyntaxError.class, () -> QParser.getParser("{!child}", req()).getQuery());
   }
 
   public void testFilters() throws Exception {
diff --git a/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java b/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java
index 98c847ec12e..fca48d5765a 100644
--- a/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java
+++ b/solr/core/src/test/org/apache/solr/update/TestNestedUpdateProcessor.java
@@ -277,6 +277,44 @@ public class TestNestedUpdateProcessor extends SolrTestCaseJ4 {
     assertU(commit());
   }
 
+  /** Test the {@code filters} local-param works with {@code parentPath}. */
+  @Test
+  public void testFiltersWithParentPath() {
+    // integer IDs required: schema copies id→id_i (int) so auto-generated "p1/items#0" would fail
+    SolrInputDocument p1 = sdoc("id", "1", "name_s", "p1");
+    p1.addField("items", sdoc("id", "11", "status_s", "active"));
+    p1.addField("items", sdoc("id", "12", "status_s", "inactive"));
+    assertU(adoc(p1));
+
+    SolrInputDocument p2 = sdoc("id", "2", "name_s", "p2");
+    p2.addField("items", sdoc("id", "21", "status_s", "inactive"));
+    assertU(adoc(p2));
+
+    SolrInputDocument p3 = sdoc("id", "3", "name_s", "p3");
+    p3.addField("items", sdoc("id", "31", "status_s", "active"));
+    assertU(adoc(p3));
+
+    assertU(commit());
+
+    // {!parent parentPath=/}: parents with children matching v AND filters
+    assertQ(
+        req(
+            "q", "{!parent parentPath=/ filters=$cf}*:*",
+            "cf", "status_s:active",
+            "fl", "id",
+            "sort", "id asc"),
+        "//*[@numFound='2']",
+        "//doc[1]/str[@name='id']='1'",
+        "//doc[2]/str[@name='id']='3'");
+
+    // {!child parentPath=/}: children of parents matching v AND filters
+    assertQ(
+        req(
+            "q", "{!child parentPath=/ filters=$pf}*:*",
+            "pf", "name_s:p1"),
+        "//*[@numFound='2']");
+  }
+
   /**
    * Randomized test to look for flaws in the documented approach for building "safe" values of the
    * <code>of</code> / <code>which</code> params in the <code>child</code> / <code>parent</code>
@@ -438,6 +476,97 @@ public class TestNestedUpdateProcessor extends SolrTestCaseJ4 {
               "//result/@numFound=1",
               "//doc/str[@name='id'][.='" + ancestorId + "']");
 
+          // additionally test childPath: find the immediate parent of descendentId and use
+          // childPath to constrain to that exact child path level
+          final String directParentPath =
+              doc_path.contains("/")
+                  ? (doc_path.lastIndexOf("/") == 0
+                      ? "/"
+                      : doc_path.substring(0, doc_path.lastIndexOf("/")))
+                  : "/";
+          final String childSegment = doc_path.substring(doc_path.lastIndexOf("/") + 1);
+          // find the ancestor ID whose path is directParentPath
+          for (Object candAncestorId : allAncestorIds) {
+            final String candPath =
+                allDocs.get(candAncestorId.toString()).getFieldValue("test_path_s").toString();
+            if (candPath.equals(directParentPath)) {
+              // childPath constrains the child query to exactly doc_path, so we should find
+              // the direct parent
+              assertQ(
+                  req(
+                      params(
+                          "q",
+                          "{!parent parentPath='"
+                              + directParentPath
+                              + "' childPath='"
+                              + childSegment
+                              + "'}id:"
+                              + descendentId),
+                      "_trace_childPath_tested",
+                      directParentPath + "/" + childSegment,
+                      "fl",
+                      "id",
+                      "indent",
+                      "true"),
+                  "//result/@numFound=1",
+                  "//doc/str[@name='id'][.='" + candAncestorId + "']");
+              // a childPath that doesn't match descendentId's path should return 0 results
+              assertQ(
+                  req(
+                      params(
+                          "q",
+                          "{!parent parentPath='"
+                              + directParentPath
+                              + "' childPath='xxx_yyy'}id:"
+                              + descendentId),
+                      "_trace_childPath_tested",
+                      directParentPath + "/xxx_yyy",
+                      "fl",
+                      "id",
+                      "indent",
+                      "true"),
+                  "//result/@numFound=0");
+              // childPath for {!child}: constrain returned children to exactly doc_path
+              assertQ(
+                  req(
+                      params(
+                          "q",
+                          "{!child parentPath='"
+                              + directParentPath
+                              + "' childPath='"
+                              + childSegment
+                              + "'}id:"
+                              + candAncestorId),
+                      "_trace_child_childPath_tested",
+                      directParentPath + "/" + childSegment,
+                      "rows",
+                      "9999",
+                      "fl",
+                      "id",
+                      "indent",
+                      "true"),
+                  "count(//doc)>=1",
+                  "//doc/str[@name='id'][.='" + descendentId + "']");
+              // a childPath that doesn't match should return 0 results
+              assertQ(
+                  req(
+                      params(
+                          "q",
+                          "{!child parentPath='"
+                              + directParentPath
+                              + "' childPath='xxx_yyy'}id:"
+                              + candAncestorId),
+                      "_trace_child_childPath_tested",
+                      directParentPath + "/xxx_yyy",
+                      "fl",
+                      "id",
+                      "indent",
+                      "true"),
+                  "//result/@numFound=0");
+              break;
+            }
+          }
+
           // meanwhile, a 'child' query wrapped around a query for the ancestorId, using the
           // ancestor_path, should match all of its descendents (for simplicity we'll check just
           // the numFound and the 'descendentId' we started with)
@@ -544,7 +673,14 @@ public class TestNestedUpdateProcessor extends SolrTestCaseJ4 {
      */
     private SolrParams parentQueryMaker(String parent_path, String inner_child_query) {
       assertValidPathSyntax(parent_path);
-      final boolean verbose = random().nextBoolean();
+      final int variant = random().nextInt(3);
+
+      if (variant == 2) {
+        // new parentPath sugar
+        return params("q", "{!parent parentPath='" + parent_path + "'}" + inner_child_query);
+      } // else old-style with explicit which/of...
+
+      final boolean verbose = variant == 1;
 
       if (parent_path.equals("/")) {
         if (verbose) {
@@ -632,7 +768,14 @@ public class TestNestedUpdateProcessor extends SolrTestCaseJ4 {
      */
     private SolrParams childQueryMaker(String parent_path, String inner_parent_query) {
       assertValidPathSyntax(parent_path);
-      final boolean verbose = random().nextBoolean();
+      final int variant = random().nextInt(3);
+
+      if (variant == 2) {
+        // new parentPath sugar
+        return params("q", "{!child parentPath='" + parent_path + "'}" + inner_parent_query);
+      } // else old-style with explicit which/of...
+
+      final boolean verbose = variant == 1;
 
       if (parent_path.equals("/")) {
         if (verbose) {
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc
index 2eed9721639..205fce03025 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/block-join-query-parser.adoc
@@ -51,10 +51,40 @@ The example usage of the query parsers below assumes the following documents hav
 
 This parser wraps a query that matches some parent documents and returns the children of those documents.
 
-The syntax for this parser is: `q={!child of=<blockMask>}<someParents>`.
+=== Using `parentPath`
 
-* The inner subordinate query string (`someParents`) must be a query that will match some parent documents
-* The `of` parameter must be a query string to use as a <<block-mask,Block Mask>> -- typically a query that matches the set of all possible parent documents
+If your schema supports xref:indexing-guide:indexing-nested-documents.adoc[nested documents], you _should_ specify `parentPath`.
+Specify the path at which the parent documents live:
+
+[source,text]
+q={!child parentPath=<path>}<someParents>
+
+Key points about `parentPath`:
+
+* Must start with `/`.
+* Use `parentPath="/"` to treat root-level documents as the parents.
+* A trailing `/` is stripped automatically (e.g., `"/skus/"` is treated as `"/skus"`).
+* `parentPath` and `of` are mutually exclusive; specifying both returns a `400 Bad Request` error.
+* Optionally, use `childPath` to narrow the returned children to docs at exactly `parentPath/childPath`. Without `childPath`, all descendants of parents at `parentPath` are returned.
+
+For example, using the deeply nested documents described in xref:searching-nested-documents.adoc[], the following query returns all children of root-level product documents that match a description query:
+
+[source,text]
+q={!child parentPath="/"}description_t:staplers
+
+To return only `skus` children of root documents matching a description query (excluding other child types):
+
+[source,text]
+q={!child parentPath="/" childPath="skus"}description_t:staplers
+
+=== Using the `of` Parameter
+
+This approach is used with anonymous child documents (schemas without `_nest_path_`).
+It is more verbose and has some <<block-mask,gotchas>>.
+The syntax is: `q={!child of=<blockMask>}<someParents>`.
+
+* The inner subordinate query string (`someParents`) must be a query that will match some parent documents.
+* The `of` parameter must be a query string to use as a <<block-mask,Block Mask>> -- typically a query that matches the set of all possible parent documents.
 
 The resulting query will match all documents which do _not_ match the `<blockMask>` query and are children (or descendents) of the documents matched by `<someParents>`.
 
@@ -111,10 +141,53 @@ More precisely, `q={!child of=<blockMask>}` is equivalent to `q=\*:* -<blockMask
 
 This parser takes a query that matches child documents and returns their parents.
 
-The syntax for this parser is similar to the `child` parser: `q={!parent which=<blockMask>}<someChildren>`.
+=== Using `parentPath`
+
+If your schema supports xref:indexing-guide:indexing-nested-documents.adoc[nested documents], you _should_ specify `parentPath`.
+Specify the path at which the parent documents live:
+
+[source,text]
+q={!parent parentPath=<path>}<someChildren>
+
+Key points about `parentPath`:
+
+* Must start with `/`.
+* Use `parentPath="/"` to treat root-level documents as the parents.
+* A trailing `/` is stripped automatically (e.g., `"/skus/"` is treated as `"/skus"`).
+* `parentPath` and `which` are mutually exclusive; specifying both returns a `400 Bad Request` error.
+* Optionally, use `childPath` to constrain the child query to docs at exactly `parentPath/childPath`. Without `childPath`, all descendants of `parentPath` are eligible as children.
+
+For example, using the deeply nested documents described in xref:searching-nested-documents.adoc[], the following query returns the root-level product documents that are ancestors of manuals with exactly one page:
+
+[source,text]
+q={!parent parentPath="/"}pages_i:1
+
+To instead return the `skus` that are ancestors of one-page _manuals_ (only manuals, not other sku children):
+
+[source,text]
+q={!parent parentPath="/skus" childPath="manuals"}pages_i:1
+
+==== Filtering to a Specific Nest Path
+
+When the subordinate query is omitted (empty), `{!parent parentPath=<path>}` is a convenient way to filter documents to exactly a specific nest path without needing to reference `_nest_path_` directly:
+
+[source,text]
+----
+# Return all root-level documents (no _nest_path_):
+q={!parent parentPath=/}
+
+# Return all documents at exactly /skus (not deeper descendants like /skus/manuals):
+q={!parent parentPath=/skus}
+----
+
+=== Using the `which` Parameter
+
+This approach is used with anonymous child documents (schemas without `_nest_path_`).
+It is more verbose and has some <<block-mask,gotchas>>.
+The syntax is: `q={!parent which=<blockMask>}<someChildren>`.
 
-* The inner subordinate query string (`someChildren`) must be a query that will match some child documents
-* The `which` parameter must be a query string to use as a <<block-mask,Block Mask>> -- typically a query that matches the set of all possible parent documents
+* The inner subordinate query string (`someChildren`) must be a query that will match some child documents.
+* The `which` parameter must be a query string to use as a <<block-mask,Block Mask>> -- typically a query that matches the set of all possible parent documents.
 
 The resulting query will match all documents which _do_ match the `<blockMask>` query and are parents (or ancestors) of the documents matched by `<someChildren>`.
 
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc b/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc
index 83b2e35f54c..15979f8ac32 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/searching-nested-documents.adoc
@@ -108,11 +108,11 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select?omitHeader=true&q=descr
 The `{!child}` query parser can be used to search for the _descendent_ documents of parent documents matching a wrapped query.
 For a detailed explanation of this parser, see the section xref:block-join-query-parser.adoc#block-join-children-query-parser[Block Join Children Query Parser].
 
-Let's consider again the `description_t:staplers` query used above -- if we wrap that query in a `{!child}` query parser then instead of "matching" & returning the product level documents, we instead match all of the _descendent_ child documents of the original query:
+Let's consider again the `description_t:staplers` query used above -- if we wrap that query in a `{!child}` query parser with `parentPath="/"` then instead of "matching" & returning the product level documents, we instead match all of the _descendent_ child documents of the original query:
 
 [source,text]
 ----
-$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'q={!child of="*:* -_nest_path_:*"}description_t:staplers'
+$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!child parentPath="/"}description_t:staplers'
 {
   "response":{"numFound":5,"start":0,"maxScore":0.30136836,"numFoundExact":true,"docs":[
       {
@@ -146,14 +146,14 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -
   }}
 ----
 
-In this example we've used `\*:* -\_nest_path_:*` as our xref:block-join-query-parser.adoc#block-mask[`of` parameter] to indicate we want to consider all documents which don't have a nest path -- i.e., all "root" level document -- as the set of possible parents.
+In this example `parentPath="/"` indicates we want to consider all root-level documents as the set of possible parents.
 
-By changing the `of` parameter to match ancestors at specific `\_nest_path_` levels, we can narrow down the list of children we return.
-In the query below, we search for all descendants of `skus` (using an `of` parameter that identifies all documents that do _not_ have a `\_nest_path_` with the prefix `/skus/*`) with a `price_i` less than `50`:
+By changing the `parentPath` to a specific `_nest_path_` level, we can narrow down the list of children we return.
+In the query below, we search for all children of `skus` with a `price_i` less than `50`:
 
 [source,text]
 ----
-$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!child of="*:* -_nest_path_:\\/skus\\/*"}(+price_i:[* TO 50] +_nest_path_:\/skus)'
+$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!child parentPath="/skus"}price_i:[* TO 50]'
 {
   "response":{"numFound":1,"start":0,"maxScore":1.0,"numFoundExact":true,"docs":[
       {
@@ -165,25 +165,6 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -
   }}
 ----
 
-[#double-escaping-nest-path-slashes]
-[CAUTION]
-.Double Escaping `\_nest_path_` slashes in `of`
-====
-Note that in the above example, the `/` characters in the `\_nest_path_` were "double escaped" in the `of` parameter:
-
-* One level of `\` escaping is necessary to prevent the `/` from being interpreted as a {lucene-javadocs}/queryparser/org/apache/lucene/queryparser/classic/package-summary.html#Regexp_Searches[Regex Query]
-* An additional level of "escaping the escape character" is necessary because the `of` local parameter is a quoted string; so we need a second `\` to ensure the first `\` is preserved and passed as is to the query parser.
-
-(You can see that only a single level of `\` escaping is needed in the body of the query string -- to prevent the Regex syntax --  because it's not a quoted string local param).
-
-You may find it more convenient to use xref:local-params.adoc#parameter-dereferencing[parameter references] in conjunction with xref:other-parsers.adoc[other parsers] that do not treat `/` as a special character to express the same query in a more verbose form:
-
-[source,text]
-----
-$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!child of=$block_mask}(+price_i:[* TO 50] +{!field f="_nest_path_" v="/skus"})' --data-urlencode 'block_mask=(*:* -{!prefix f="_nest_path_" v="/skus/"})'
-----
-====
-
 === Parent Query Parser
 
 The inverse of the `{!child}` query parser is the `{!parent}` query parser, which lets you search for the _ancestor_ documents of some child documents matching a wrapped query.
@@ -217,11 +198,11 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select?omitHeader=true&q=pages
   }}
 ----
 
-We can wrap that query in a `{!parent}` query to return the details of all products that are ancestors of these manuals:
+We can wrap that query in a `{!parent}` query with `parentPath="/"` to return the details of all root-level products that are ancestors of these manuals:
 
 [source,text]
 ----
-$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent which="*:* -_nest_path_:*"}(+_nest_path_:\/skus\/manuals +pages_i:1)'
+$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent parentPath="/"}pages_i:1'
 {
   "response":{"numFound":2,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[
       {
@@ -237,14 +218,15 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -
   }}
 ----
 
-In this example we've used `\*:* -\_nest_path_:*` as our xref:block-join-query-parser.adoc#block-mask[`which` parameter] to indicate we want to consider all documents which don't have a nest path -- i.e., all "root" level document -- as the set of possible parents.
+In this example `parentPath="/"` indicates we want root-level documents to be the parents.
 
-By changing the `which` parameter to match ancestors at specific `\_nest_path_` levels, we can change the type of ancestors we return.
-In the query below, we search for `skus` (using an `which` parameter that identifies all documents that do _not_ have a `\_nest_path_` with the prefix `/skus/*`) that are the ancestors of `manuals` with exactly `1` page:
+By changing `parentPath` to a specific path, we can change the type of ancestors we return.
+In the query below, we search for the `skus` that are the ancestors of `manuals` with exactly `1` page.
+Adding `childPath="manuals"` constrains the child query to only docs nested at `/skus/manuals`, preventing pages from other child types from matching:
 
 [source,text]
 ----
-$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent which="*:* -_nest_path_:\\/skus\\/*"}(+_nest_path_:\/skus\/manuals +pages_i:1)'
+$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' --data-urlencode 'q={!parent parentPath="/skus" childPath="manuals"}pages_i:1'
 {
   "response":{"numFound":2,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[
       {
@@ -260,11 +242,6 @@ $ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -
   }}
 ----
 
-[CAUTION]
-====
-Note that in the above example, the `/` characters in the `\_nest_path_` were "double escaped" in the `which` parameter, for the <<double-escaping-nest-path-slashes,same reasons discussed above>> regarding the `{!child} pasers `of` parameter.
-====
-
 === Combining Block Join Query Parsers with Child Doc Transformer
 
 The combination of these two parsers with the `[child]` transformer enables seamless creation of very powerful queries.
@@ -279,7 +256,7 @@ Here for example is a query where:
 
 [source,text]
 ----
-$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'fq=color_s:RED' --data-urlencode 'q={!child of="*:* -_nest_path_:*" filters=$parent_fq}' --data-urlencode 'parent_fq={!parent which="*:* -_nest_path_:*"}(+_nest_path_:"/manuals" +content_t:"lifetime guarantee")' -d 'fl=*,[child]'
+$ curl 'http://localhost:8983/solr/gettingstarted/select' -d 'omitHeader=true' -d 'fq=color_s:RED' --data-urlencode 'q={!child parentPath="/" filters=$parent_fq}' --data-urlencode 'parent_fq={!parent parentPath="/"}content_t:"lifetime guarantee"' -d 'fl=*,[child]'
 {
   "response":{"numFound":1,"start":0,"maxScore":1.4E-45,"numFoundExact":true,"docs":[
       {


(solr) 03/03: SOLR-18093: Add 'queries' support to JsonQueryRequest (#4276)

Posted by ds...@apache.org.
This is an automated email from the ASF dual-hosted git repository.

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

commit 0cb2aebf043ba90f34fe3e4db338f62b7636e397
Author: Sonu Sharma <so...@gmail.com>
AuthorDate: Fri Apr 24 12:21:21 2026 +0800

    SOLR-18093: Add 'queries' support to JsonQueryRequest (#4276)
    
    Add top-level "queries" support to JsonQueryRequest in SolrJ.
    
    And hardened a related flaky test.
    SearchHandler: give a clear error if referring to a non-existent SearchComponent
    
    Co-authored-by: David Smiley <ds...@apache.org>
---
 ...R-18093-add-top-level-queries-in-json-solrj.yml |  9 +++
 .../solr/handler/component/SearchHandler.java      |  4 ++
 .../solr/collection1/conf/solrconfig.xml           |  5 +-
 .../component/CombinedQuerySolrCloudTest.java      | 22 +++----
 .../conf/solrconfig.xml                            |  9 ++-
 .../query-guide/examples/JsonRequestApiTest.java   | 70 ++++++++++++++++++++++
 .../query-guide/pages/json-combined-query-dsl.adoc | 41 +++++++++++--
 .../modules/query-guide/pages/json-query-dsl.adoc  | 15 +++++
 .../solrj/request/json/JsonQueryRequest.java       | 20 +++++++
 .../json/JsonQueryRequestIntegrationTest.java      | 25 ++++++++
 .../request/json/JsonQueryRequestUnitTest.java     | 23 +++++++
 11 files changed, 224 insertions(+), 19 deletions(-)

diff --git a/changelog/unreleased/SOLR-18093-add-top-level-queries-in-json-solrj.yml b/changelog/unreleased/SOLR-18093-add-top-level-queries-in-json-solrj.yml
new file mode 100644
index 00000000000..0c91f5aa11b
--- /dev/null
+++ b/changelog/unreleased/SOLR-18093-add-top-level-queries-in-json-solrj.yml
@@ -0,0 +1,9 @@
+# See https://github.com/apache/solr/blob/main/dev-docs/changelog.adoc
+title: Add top-level "queries" support to JsonQueryRequest in SolrJ
+type: added
+authors:
+  - name: Sonu Sharma
+    nick: ercsonusharma
+links:
+  - name: SOLR-18093
+    url: https://issues.apache.org/jira/browse/SOLR-18093
diff --git a/solr/core/src/java/org/apache/solr/handler/component/SearchHandler.java b/solr/core/src/java/org/apache/solr/handler/component/SearchHandler.java
index 58b4596e706..3897d6c737e 100644
--- a/solr/core/src/java/org/apache/solr/handler/component/SearchHandler.java
+++ b/solr/core/src/java/org/apache/solr/handler/component/SearchHandler.java
@@ -274,6 +274,10 @@ public class SearchHandler extends RequestHandlerBase
     DebugComponent dbgCmp = null;
     for (String c : list) {
       SearchComponent comp = core.getSearchComponent(c);
+      if (comp == null) {
+        throw new SolrException(
+            SolrException.ErrorCode.SERVER_ERROR, "Unknown search component: " + c);
+      }
       if (comp instanceof DebugComponent && makeDebugLast == true) {
         dbgCmp = (DebugComponent) comp;
       } else {
diff --git a/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml b/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml
index 9efa5d4a29c..6a960666848 100644
--- a/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml
+++ b/solr/core/src/test-files/solr/collection1/conf/solrconfig.xml
@@ -42,7 +42,7 @@
     <double name="maxWriteMBPerSecDefault">1000000</double>
     <double name="maxWriteMBPerSecFlush">2000000</double>
     <double name="maxWriteMBPerSecMerge">3000000</double>
-    <double name="maxWriteMBPerSecRead">4000000</double>   
+    <double name="maxWriteMBPerSecRead">4000000</double>
   </directoryFactory>
 
   <schemaFactory class="ClassicIndexSchemaFactory"/>
@@ -380,6 +380,9 @@
       </arr>
  </requestHandler>
 
+  <searchComponent class="solr.CombinedQueryComponent" name="combined_query">
+  </searchComponent>
+
   <requestHandler name="/mltrh" class="org.apache.solr.handler.component.SearchHandler">
 
   </requestHandler>
diff --git a/solr/core/src/test/org/apache/solr/handler/component/CombinedQuerySolrCloudTest.java b/solr/core/src/test/org/apache/solr/handler/component/CombinedQuerySolrCloudTest.java
index 448ced51254..4f5f31d8006 100644
--- a/solr/core/src/test/org/apache/solr/handler/component/CombinedQuerySolrCloudTest.java
+++ b/solr/core/src/test/org/apache/solr/handler/component/CombinedQuerySolrCloudTest.java
@@ -73,7 +73,7 @@ public class CombinedQuerySolrCloudTest extends AbstractFullDistribZkTestBase {
     del("*:*");
     List<SolrInputDocument> docs = getSolrDocuments();
     for (SolrInputDocument doc : docs) {
-      doc.setField("id", doc.getFieldValue("mod3_idv") + "!" + doc.getField("id").getValue());
+      doc.setField("id", "CO!" + doc.getField("id").getValue());
     }
     for (SolrInputDocument doc : docs) {
       indexDoc(doc);
@@ -284,16 +284,16 @@ public class CombinedQuerySolrCloudTest extends AbstractFullDistribZkTestBase {
             "queries": {
                 "lexical1": {
                     "lucene": {
-                        "query": "id:(2!2^2 OR 0!3^1 OR 0!6^2 OR 2!5^1)"
+                        "query": "id:(CO!2^3 OR CO!3^1 OR CO!6^2 OR CO!5^1)"
                     }
                 },
                 "lexical2": {
                     "lucene": {
-                        "query": "id:(2!8^1 OR 2!5^2 OR 1!7^3 OR 1!10^2)"
+                        "query": "id:(CO!8^1 OR CO!5^2 OR CO!7^3 OR CO!10^2)"
                     }
                 }
             },
-            "limit": 1,
+            "limit": 3,
             "fields": [
                 "id",
                 "score",
@@ -304,7 +304,7 @@ public class CombinedQuerySolrCloudTest extends AbstractFullDistribZkTestBase {
                 "facet": true,
                 "facet.field": "id",
                 "fq": [
-                    "{!collapse field=mod3_idv sort='id asc'}"
+                    "{!collapse field=mod3_idv sort='id asc, score desc'}"
                 ],
                 "expand": true,
                 "expand.q": "*:*",
@@ -319,17 +319,17 @@ public class CombinedQuerySolrCloudTest extends AbstractFullDistribZkTestBase {
         }""";
     handle.put("expanded", UNORDERED);
     QueryResponse rsp = query(CommonParams.JSON, jsonQuery, CommonParams.QT, "/search");
-    assertEquals(1, rsp.getResults().size());
-    assertFieldValues(rsp.getResults(), id, "2!2");
+    assertEquals(3, rsp.getResults().size());
+    assertFieldValues(rsp.getResults(), id, "CO!2", "CO!10", "CO!3");
     assertEquals("id", rsp.getFacetFields().getFirst().getName());
     assertEquals(
-        "[0!3 (1), 1!10 (1), 2!2 (1), 0!6 (0), 0!9 (0), 1!1 (0), 1!4 (0), 1!7 (0), 2!5 (0), 2!8 (0)]",
+        "[CO!10 (1), CO!2 (1), CO!3 (1), CO!1 (0), CO!4 (0), CO!5 (0), CO!6 (0), CO!7 (0), CO!8 (0), CO!9 (0)]",
         rsp.getFacetFields().getFirst().getValues().toString());
-    assertEquals(1, rsp.getHighlighting().size());
+    assertEquals(3, rsp.getHighlighting().size());
     assertEquals(
         "title <em>test</em> for <em>doc</em> 2",
-        rsp.getHighlighting().get("2!2").get("title").getFirst());
-    assertEquals(1, rsp.getExpandedResults().size());
+        rsp.getHighlighting().get("CO!2").get("title").getFirst());
+    assertEquals(3, rsp.getExpandedResults().size());
   }
 
   /** To test that we can force distrib */
diff --git a/solr/server/solr/configsets/sample_techproducts_configs/conf/solrconfig.xml b/solr/server/solr/configsets/sample_techproducts_configs/conf/solrconfig.xml
index a6214acf2ee..e70e689b1a5 100644
--- a/solr/server/solr/configsets/sample_techproducts_configs/conf/solrconfig.xml
+++ b/solr/server/solr/configsets/sample_techproducts_configs/conf/solrconfig.xml
@@ -640,7 +640,14 @@
      </lst>
   </requestHandler>
 
-  <initParams path="/update/**,/query,/select,/tvrh,/elevate,/spell,update">
+  <requestHandler name="/rrf" class="solr.CombinedQuerySearchHandler">
+  </requestHandler>
+
+  <searchComponent class="solr.CombinedQueryComponent" name="combined_query">
+    <int name="maxCombinerQueries">2</int>
+  </searchComponent>
+
+  <initParams path="/update/**,/query,/search,/select,/tvrh,/elevate,/spell,update">
     <lst name="defaults">
       <str name="df">text</str>
     </lst>
diff --git a/solr/solr-ref-guide/modules/query-guide/examples/JsonRequestApiTest.java b/solr/solr-ref-guide/modules/query-guide/examples/JsonRequestApiTest.java
index 14aac24c4e7..16f60ea2dad 100644
--- a/solr/solr-ref-guide/modules/query-guide/examples/JsonRequestApiTest.java
+++ b/solr/solr-ref-guide/modules/query-guide/examples/JsonRequestApiTest.java
@@ -83,6 +83,76 @@ public class JsonRequestApiTest extends SolrCloudTestCase {
     assertResponseFoundNumDocs(queryResponse, expectedResults);
   }
 
+  /**
+   * Test json query behaviour in case of multiple query to be executed using Combined Query.
+   *
+   * @throws Exception the exception
+   */
+  @Test
+  public void testSimpleJsonQueryWithQueriesParams() throws Exception {
+    SolrClient solrClient = cluster.getSolrClient();
+    final int expectedResults = 2;
+    final Map<String, Object> queriesMap = new HashMap<>();
+    queriesMap.put(
+        "query1",
+        Map.of(
+            "lucene",
+            Map.of(
+                "query", "apache",
+                "df", "manu")));
+    queriesMap.put(
+        "query2",
+        Map.of(
+            "edismax",
+            Map.of(
+                "query", "solr",
+                "df", "name")));
+    final JsonQueryRequest query =
+        new JsonQueryRequest()
+            .setQueries(queriesMap)
+            .withFilter("inStock:true")
+            .withParam("fl", "name")
+            .withParam("combiner", "true")
+            .withParam("combiner.query", List.of("query1", "query2"));
+    query.setPath("/rrf");
+    QueryResponse queryResponse = query.process(solrClient, COLLECTION_NAME);
+    assertResponseFoundNumDocs(queryResponse, expectedResults);
+  }
+
+  /**
+   * Test json query behaviour in case of multiple query to be executed using Additional Queries.
+   *
+   * @throws Exception the exception
+   */
+  @Test
+  public void testAdditionalJsonQueries() throws Exception {
+    SolrClient solrClient = cluster.getSolrClient();
+    final int expectedResults = 12;
+    // tag::solrj-json-query-with-queries[]
+    final Map<String, Object> queriesMap = new HashMap<>();
+    queriesMap.put(
+        "electronic",
+        Map.of(
+            "field",
+            Map.of(
+                "query", "electronics",
+                "f", "cat")));
+    queriesMap.put(
+        "manufacturers",
+        List.of(
+            "manu: apple",
+            Map.of(
+                "field",
+                Map.of(
+                    "query", "belkin",
+                    "f", "manu"))));
+    final JsonQueryRequest query =
+        new JsonQueryRequest().setQueries(queriesMap).setQuery(Map.of("param", "electronic"));
+    QueryResponse queryResponse = query.process(solrClient, COLLECTION_NAME);
+    // end::solrj-json-query-with-queries[]
+    assertEquals(expectedResults, queryResponse.getResults().getNumFound());
+  }
+
   @Test
   public void testJsonQueryWithJsonQueryParamOverrides() throws Exception {
     SolrClient solrClient = cluster.getSolrClient();
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/json-combined-query-dsl.adoc b/solr/solr-ref-guide/modules/query-guide/pages/json-combined-query-dsl.adoc
index 0af349c4e80..de62c7a6d1b 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/json-combined-query-dsl.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/json-combined-query-dsl.adoc
@@ -78,14 +78,21 @@ The query structure is similar to JSON Query DSL except for how multiple queries
 
 === Example
 
-Below is a sample JSON query payload:
+Below is an example with sample JSON query payload:
 
-```
+[tabs#json-query-with-queries]
+======
+curl::
++
+====
+[source,bash]
+----
+curl -X POST http://localhost:8983/solr/techproducts/query -d '
 {
     "queries": {
         "lexical1": {
             "lucene": {
-                "query": "title:sales"
+                "query": "name:apache"
             }
         },
         "vector": {
@@ -97,15 +104,37 @@ Below is a sample JSON query payload:
         }
     },
     "limit": 5,
-    "fields": ["id", "score", "title"],
+    "fields": ["id", "score", "name"],
     "params": {
         "combiner": true,
         "combiner.query": ["lexical1", "vector"],
         "combiner.algorithm": "rrf",
         "combiner.rrf.k": "15"
     }
-}
-```
+}'
+----
+====
+SolrJ::
++
+====
+[source,java,indent=0]
+----
+final Map<String, Object> queriesMap = new HashMap<>();
+queriesMap.put("lexical1", Map.of("lucene", Map.of("query", "apache", "df", "name")));
+queriesMap.put("vector", Map.of("knn", Map.of("query", [0.1,-0.34,0.89,0.02], "f", "vector", "topK", 5)));
+final JsonQueryRequest query =
+    new JsonQueryRequest()
+        .setQueries(queriesMap)
+        .withParam("id", "score", "name")
+        .setLimit(5)
+        .withParam("combiner", "true")
+        .withParam("combiner.query", List.of("lexical1", "vector"))
+        .withParam("combiner.algorithm", "rrf")
+        .withParam("combiner.rrf.k", "15");
+QueryResponse queryResponse = query.process(solrClient, COLLECTION_NAME);
+----
+====
+======
 
 == Combiner Algorithm Plugin
 
diff --git a/solr/solr-ref-guide/modules/query-guide/pages/json-query-dsl.adoc b/solr/solr-ref-guide/modules/query-guide/pages/json-query-dsl.adoc
index 104fba52eec..a52300aac34 100644
--- a/solr/solr-ref-guide/modules/query-guide/pages/json-query-dsl.adoc
+++ b/solr/solr-ref-guide/modules/query-guide/pages/json-query-dsl.adoc
@@ -388,6 +388,11 @@ Beware of arity for these references.
 Depending on the context, a reference might be resolved into the first element in the array ignoring the later elements, e.g., if one changes the reference below from `{"param":"electronic"}` to `{"param":"manufacturers"}`, it's equivalent to querying for `manu:apple`, ignoring the later query.
 These queries don't impact the query result until explicit referencing.
 
+[tabs#json-additional-queries]
+======
+curl::
++
+====
 [source,bash]
 ----
 curl -X POST http://localhost:8983/solr/techproducts/query -d '
@@ -402,6 +407,16 @@ curl -X POST http://localhost:8983/solr/techproducts/query -d '
     "query":{"param":"electronic"}
 }'
 ----
+====
+SolrJ::
++
+====
+[source,java,indent=0]
+----
+include::example$JsonRequestApiTest.java[tag=solrj-json-query-with-queries]
+----
+====
+======
 
 Overall this example doesn't make much sense, but just demonstrates the syntax.
 This feature is useful in xref:json-faceting-domain-changes.adoc#adding-domain-filters[filtering domain] in JSON Facet API xref:json-facet-api.adoc#changing-the-domain[domain changes].
diff --git a/solr/solrj/src/java/org/apache/solr/client/solrj/request/json/JsonQueryRequest.java b/solr/solrj/src/java/org/apache/solr/client/solrj/request/json/JsonQueryRequest.java
index 421edc72bc8..29ef7d84539 100644
--- a/solr/solrj/src/java/org/apache/solr/client/solrj/request/json/JsonQueryRequest.java
+++ b/solr/solrj/src/java/org/apache/solr/client/solrj/request/json/JsonQueryRequest.java
@@ -101,6 +101,26 @@ public class JsonQueryRequest extends QueryRequest {
     return this;
   }
 
+  /**
+   * Specify the queries parameter sent as a part of JSON request.
+   *
+   * <p>This method would be helpful in setting the queries parameter specially for Combined Query
+   * Component {@code CombinedQueryComponent} use case.
+   *
+   * <p><b>Example:</b> You wish to send the JSON request:
+   *
+   * <pre>{@code {'limit': 5, 'queries': {'query1': {'lucene':
+   * {'df':'genre_s', 'query': 'scifi'}}}, 'query2': {'knn': {'f': 'vector', 'query': [0.1, 0.43]}}}}
+   * </pre>
+   *
+   * @param queriesJson a Map of values representing the query subtree of the JSON request you wish
+   *     to send.
+   */
+  public JsonQueryRequest setQueries(Map<String, Object> queriesJson) {
+    jsonRequestMap.put("queries", queriesJson);
+    return this;
+  }
+
   /**
    * Specify the query sent as a part of this JSON request.
    *
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/request/json/JsonQueryRequestIntegrationTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/request/json/JsonQueryRequestIntegrationTest.java
index dc516e00f3a..b43310e8fe1 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/request/json/JsonQueryRequestIntegrationTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/request/json/JsonQueryRequestIntegrationTest.java
@@ -20,6 +20,7 @@ package org.apache.solr.client.solrj.request.json;
 import java.io.IOException;
 import java.util.Collection;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import org.apache.solr.client.solrj.request.AbstractUpdateRequest;
 import org.apache.solr.client.solrj.request.CollectionAdminRequest;
@@ -45,6 +46,7 @@ public class JsonQueryRequestIntegrationTest extends SolrCloudTestCase {
   private static final int NUM_SCIFI_BOOKS = 2;
   private static final int NUM_IN_STOCK = 8;
   private static final int NUM_IN_STOCK_AND_FIRST_IN_SERIES = 5;
+  private static final int NUM_MARTIN_BOOKS = 3;
 
   @BeforeClass
   public static void setupCluster() throws Exception {
@@ -88,6 +90,29 @@ public class JsonQueryRequestIntegrationTest extends SolrCloudTestCase {
     assertEquals(NUM_SCIFI_BOOKS, queryResponse.getResults().getNumFound());
   }
 
+  /**
+   * Test multiple queries can use local params syntax with Combined Query Component.
+   *
+   * @throws Exception the exception
+   */
+  @Test
+  public void testMultipleQueriesCanUseLocalParamsSyntax() throws Exception {
+    final Map<String, Object> queriesMap = new HashMap<>();
+    queriesMap.put("query1", "{!lucene df=genre_s v='scifi'}");
+    queriesMap.put("query2", "{!edismax df=author_t v='martin'}");
+    final JsonQueryRequest query =
+        new JsonQueryRequest()
+            .setQueries(queriesMap)
+            .withFilter("inStock:true")
+            .withParam("fl", "name")
+            .withParam("combiner", "true")
+            .withParam("combiner.query", List.of("query1", "query2"));
+    query.setPath("/rrf");
+    QueryResponse queryResponse = query.process(cluster.getSolrClient(), COLLECTION_NAME);
+    assertEquals(0, queryResponse.getStatus());
+    assertEquals(NUM_SCIFI_BOOKS + NUM_MARTIN_BOOKS, queryResponse.getResults().size());
+  }
+
   @Test
   public void testQueriesCanUseExpandedSyntax() throws Exception {
     // Construct a tree representing the JSON: {lucene: {df:'genre_s', 'query': 'scifi'}}
diff --git a/solr/solrj/src/test/org/apache/solr/client/solrj/request/json/JsonQueryRequestUnitTest.java b/solr/solrj/src/test/org/apache/solr/client/solrj/request/json/JsonQueryRequestUnitTest.java
index 6297c56cd6c..51a407ce526 100644
--- a/solr/solrj/src/test/org/apache/solr/client/solrj/request/json/JsonQueryRequestUnitTest.java
+++ b/solr/solrj/src/test/org/apache/solr/client/solrj/request/json/JsonQueryRequestUnitTest.java
@@ -88,6 +88,29 @@ public class JsonQueryRequestUnitTest extends SolrTestCase {
         requestBody, containsString("\"query\":{\"lucene\":{\"q\":\"*:*\",\"df\":\"text\"}}"));
   }
 
+  @Test
+  public void testWritesProvidedQueriesMapToJsonCorrectly() {
+    final Map<String, Object> queriesMap = new HashMap<>();
+    queriesMap.put(
+        "query1",
+        Map.of(
+            "lucene",
+            Map.of(
+                "query", "*:*",
+                "df", "text")));
+    queriesMap.put(
+        "query2",
+        Map.of(
+            "edismax",
+            Map.of(
+                "query", "solr",
+                "df", "text")));
+    final JsonQueryRequest request = new JsonQueryRequest().setQueries(queriesMap);
+    final String requestBody = writeRequestToJson(request);
+    assertThat(requestBody, containsString("\"query1\":{\"lucene\":"));
+    assertThat(requestBody, containsString("\"query2\":{\"edismax\":"));
+  }
+
   @Test
   public void testWritesProvidedQueryMapWriterToJsonCorrectly() {
     final MapWriter queryWriter =