From 79e2925430988c90ce308218823b868a3488c770 Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Wed, 22 Jul 2026 21:29:39 -0400 Subject: [PATCH 01/13] SOLR-18314: update both DisMax and eDisMax parsers to skip phrase analysis entirely for single-term queries, since a one-word phrase is semantically meaningless --- solr/core/src/java/org/apache/solr/search/DisMaxQParser.java | 3 +++ .../src/java/org/apache/solr/search/ExtendedDismaxQParser.java | 2 ++ 2 files changed, 5 insertions(+) diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index 25e93592d35d..e231e9edce5e 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -227,6 +227,9 @@ protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQ * matched those phrases but do match looser phrases. */ String userPhraseQuery = userQuery.replace("\"", ""); + if (userPhraseQuery.isBlank() || userPhraseQuery.trim().indexOf(' ') < 0) { + return null; + } return pp.parse("\"" + userPhraseQuery + "\""); } diff --git a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java index 4dd2745bf1eb..34738bc541be 100644 --- a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java @@ -630,6 +630,8 @@ protected void addShingledPhraseQueries( if (0 == shingleSize) shingleSize = clauses.size(); + if (shingleSize < 2) return; + final int lastClauseIndex = shingleSize - 1; StringBuilder userPhraseQuery = new StringBuilder(); From c20e036e8b6e2cedf95632826033511b90aed4f4 Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Wed, 22 Jul 2026 21:41:17 -0400 Subject: [PATCH 02/13] add changelog entry --- .../SOLR-18314-skip-phrase-analysis-single-term.yml | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml diff --git a/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml b/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml new file mode 100644 index 000000000000..9ec790b93404 --- /dev/null +++ b/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml @@ -0,0 +1,8 @@ +title: Skip phrase field (pf) boost analysis for single-term queries in DisMax and eDisMax parsers +type: changed +authors: + - name: Ian Springer + nick: ianspringer +links: + - name: SOLR-18314 + url: https://issues.apache.org/jira/browse/SOLR-18314 From 3d97ad7ae22b9f3df86cf94197d88f85adcb0b5a Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Thu, 30 Jul 2026 13:49:55 -0400 Subject: [PATCH 03/13] remove redundant isBlank call to optimize --- solr/core/src/java/org/apache/solr/search/DisMaxQParser.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index e231e9edce5e..b868910470ed 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -227,7 +227,7 @@ protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQ * matched those phrases but do match looser phrases. */ String userPhraseQuery = userQuery.replace("\"", ""); - if (userPhraseQuery.isBlank() || userPhraseQuery.trim().indexOf(' ') < 0) { + if (userPhraseQuery.trim().indexOf(' ') < 0) { return null; } return pp.parse("\"" + userPhraseQuery + "\""); From 760763465ac823ba3a49663de4c9842669050a9f Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Thu, 30 Jul 2026 13:55:42 -0400 Subject: [PATCH 04/13] add note to changelog entry that relevancy scores may be affected by the removal of the single-term phrase boost --- .../SOLR-18314-skip-phrase-analysis-single-term.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml b/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml index 9ec790b93404..169ed7070752 100644 --- a/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml +++ b/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml @@ -1,4 +1,7 @@ -title: Skip phrase field (pf) boost analysis for single-term queries in DisMax and eDisMax parsers +title: Skip phrase field (pf) boost analysis for single-term queries in DisMax and eDisMax parsers. + This may affect relevancy scores for single-term queries if pf fields differ from qf fields or use + different boost weights, since the single-term phrase boost (effectively a redundant term boost) is + no longer applied. type: changed authors: - name: Ian Springer From 51b6c813536fe1f5d8799024b81925b420578d31 Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Mon, 3 Aug 2026 15:39:37 -0400 Subject: [PATCH 05/13] optimization: avoid quote removal until we're sure we're not returning early due to single term; add brief comments explaining reason for early returns --- .../src/java/org/apache/solr/search/DisMaxQParser.java | 9 ++++----- .../org/apache/solr/search/ExtendedDismaxQParser.java | 5 ++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index b868910470ed..9281d5f5c77c 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -218,18 +218,17 @@ protected Query getAlternateUserQuery(SolrParams solrParams) throws SyntaxError protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQueryParser pp) throws SyntaxError { /* * * Add on Phrases for the Query * * */ - + if (userQuery.trim().indexOf(' ') < 0) { + // single term - skip phrase boost + return null; + } /* build up phrase boosting queries */ - /* if the userQuery already has some quotes, strip them out. * we've already done the phrases they asked for in the main * part of the query, this is to boost docs that may not have * matched those phrases but do match looser phrases. */ String userPhraseQuery = userQuery.replace("\"", ""); - if (userPhraseQuery.trim().indexOf(' ') < 0) { - return null; - } return pp.parse("\"" + userPhraseQuery + "\""); } diff --git a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java index 34738bc541be..365db11a3c99 100644 --- a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java @@ -630,7 +630,10 @@ protected void addShingledPhraseQueries( if (0 == shingleSize) shingleSize = clauses.size(); - if (shingleSize < 2) return; + if (shingleSize < 2) { + // single term - skip phrase boost + return; + } final int lastClauseIndex = shingleSize - 1; From 91cfde58ac8cd5bd9bd8809de14d308145df2d5c Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Mon, 3 Aug 2026 15:46:45 -0400 Subject: [PATCH 06/13] revert back to stripping quotes pre-trim for now --- .../src/java/org/apache/solr/search/DisMaxQParser.java | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index 9281d5f5c77c..5716f4ff9373 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -218,17 +218,19 @@ protected Query getAlternateUserQuery(SolrParams solrParams) throws SyntaxError protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQueryParser pp) throws SyntaxError { /* * * Add on Phrases for the Query * * */ - if (userQuery.trim().indexOf(' ') < 0) { - // single term - skip phrase boost - return null; - } + /* build up phrase boosting queries */ + /* if the userQuery already has some quotes, strip them out. * we've already done the phrases they asked for in the main * part of the query, this is to boost docs that may not have * matched those phrases but do match looser phrases. */ String userPhraseQuery = userQuery.replace("\"", ""); + if (userPhraseQuery.trim().indexOf(' ') < 0) { + // single term - skip phrase boost + return null; + } return pp.parse("\"" + userPhraseQuery + "\""); } From 301fc60584825822e7454e0a391f1a48791fb89f Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Tue, 4 Aug 2026 11:39:02 -0400 Subject: [PATCH 07/13] SOLR-18314: skip single-term pf boost via analyzed query; fix empty-phrase MatchAllDocs defeat Detect single-term phrase (pf) boosts from the analyzed query rather than raw-string whitespace, so DisMax no longer drops meaningful phrase boosts for whitespace-free queries that analyze into multiple tokens (CJK, ngram, WordDelimiterGraph, multi-word synonyms). eDisMax already skipped single-term boosts via minClauseSize; remove its redundant/flawed shingleSize guard. Also fix eDisMax adding an empty pf SHOULD clause for clause-less queries such as q=*:*, which defeated the single-clause MatchAllDocsQuery optimization. Co-Authored-By: Claude --- ...-18314-empty-phrase-boost-matchalldocs.yml | 11 ++++ ...18314-skip-phrase-analysis-single-term.yml | 10 ++-- .../org/apache/solr/search/DisMaxQParser.java | 45 ++++++++++++++- .../solr/search/ExtendedDismaxQParser.java | 16 +++--- .../solr/collection1/conf/synonyms.txt | 1 + .../apache/solr/DisMaxRequestHandlerTest.java | 49 ++++++++++++++++ .../solr/search/TestExtendedDismaxParser.java | 56 +++++++++++++++++++ 7 files changed, 174 insertions(+), 14 deletions(-) create mode 100644 changelog/unreleased/SOLR-18314-empty-phrase-boost-matchalldocs.yml diff --git a/changelog/unreleased/SOLR-18314-empty-phrase-boost-matchalldocs.yml b/changelog/unreleased/SOLR-18314-empty-phrase-boost-matchalldocs.yml new file mode 100644 index 000000000000..076c48218b70 --- /dev/null +++ b/changelog/unreleased/SOLR-18314-empty-phrase-boost-matchalldocs.yml @@ -0,0 +1,11 @@ +title: Fix the eDisMax parser adding an empty phrase (pf) boost clause for clause-less queries such + as q=*:* (distinct from single-term queries). The empty SHOULD clause defeated the single-clause + MatchAllDocsQuery optimization; it is now omitted, so these queries again reduce to a + MatchAllDocsQuery. +type: fixed +authors: + - name: Ian Springer + nick: ianspringer +links: + - name: SOLR-18314 + url: https://issues.apache.org/jira/browse/SOLR-18314 diff --git a/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml b/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml index 169ed7070752..40f215e365e7 100644 --- a/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml +++ b/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml @@ -1,7 +1,9 @@ -title: Skip phrase field (pf) boost analysis for single-term queries in DisMax and eDisMax parsers. - This may affect relevancy scores for single-term queries if pf fields differ from qf fields or use - different boost weights, since the single-term phrase boost (effectively a redundant term boost) is - no longer applied. +title: The DisMax parser no longer applies the phrase field (pf) boost to single-term queries, since + a one-word phrase boost is a redundant term boost. (The eDisMax parser already skipped the pf boost + for single-term queries.) This applies only to genuinely single-token queries, so a single input + term whose analysis yields multiple tokens (e.g. via CJK, ngram, WordDelimiterGraph, or multi-word + synonyms) is unaffected and keeps its phrase boost. This may change relevancy scores for DisMax + queries that previously received a single-term phrase boost. type: changed authors: - name: Ian Springer diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index 5716f4ff9373..bced0b153288 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -22,7 +22,11 @@ import org.apache.lucene.search.BooleanClause; import org.apache.lucene.search.BooleanQuery; import org.apache.lucene.search.BoostQuery; +import org.apache.lucene.search.DisjunctionMaxQuery; +import org.apache.lucene.search.MultiPhraseQuery; +import org.apache.lucene.search.PhraseQuery; import org.apache.lucene.search.Query; +import org.apache.lucene.search.TermQuery; import org.apache.solr.common.params.CommonParams; import org.apache.solr.common.params.DisMaxParams; import org.apache.solr.common.params.SolrParams; @@ -227,11 +231,46 @@ protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQ * matched those phrases but do match looser phrases. */ String userPhraseQuery = userQuery.replace("\"", ""); - if (userPhraseQuery.trim().indexOf(' ') < 0) { - // single term - skip phrase boost + Query phrase = pp.parse("\"" + userPhraseQuery + "\""); + // A single-term phrase boost is a no-op adjacency constraint (effectively a redundant term + // boost), so skip it. This is decided from the analyzed query, because analyzers such as CJK, + // ngram, WordDelimiterGraph, and multi-word synonyms can split a single input term into several + // tokens that DO warrant a phrase boost. This is the DisMax-native analog of eDisMax's + // minClauseSize (which this parser lacks); note that DisMax counts terms after stopword + // removal, whereas eDisMax retains stopwords in its pf parse, so the two can differ for + // queries padded with stopwords. + if (isEffectivelySingleTerm(phrase)) { return null; } - return pp.parse("\"" + userPhraseQuery + "\""); + return phrase; + } + + /** + * Returns true if the parsed phrase query boils down to a single analyzed term, in which case the + * phrase boost adds nothing over the term boost already applied by the main query. The parser + * produces a {@link DisjunctionMaxQuery} over per-field {@link BoostQuery}-wrapped term/phrase + * queries; we inspect the underlying query shape so that analyzers which split a single input + * term into multiple tokens keep their phrase boost. Unrecognized shapes conservatively return + * false (keep the boost). + */ + private static boolean isEffectivelySingleTerm(Query query) { + if (query instanceof BoostQuery boostQuery) { + return isEffectivelySingleTerm(boostQuery.getQuery()); + } + if (query instanceof DisjunctionMaxQuery dmq) { + // Every field's clause is a single term, so the phrase across fields is too. + return dmq.getDisjuncts().stream().allMatch(DisMaxQParser::isEffectivelySingleTerm); + } + if (query instanceof TermQuery) { + return true; + } + if (query instanceof PhraseQuery phraseQuery) { + return phraseQuery.getTerms().length < 2; + } + if (query instanceof MultiPhraseQuery multiPhraseQuery) { + return multiPhraseQuery.getTermArrays().length < 2; + } + return false; } protected Query getUserQuery( diff --git a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java index 365db11a3c99..6088007f60aa 100644 --- a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java @@ -629,12 +629,7 @@ protected void addShingledPhraseQueries( return; if (0 == shingleSize) shingleSize = clauses.size(); - - if (shingleSize < 2) { - // single term - skip phrase boost - return; - } - + final int lastClauseIndex = shingleSize - 1; StringBuilder userPhraseQuery = new StringBuilder(); @@ -681,11 +676,18 @@ protected void addShingledPhraseQueries( // TODO: perhaps we shouldn't use synonyms either... Query phrase = pp.parse(userPhraseQuery.toString()); - if (phrase != null) { + // Skip null and *empty* boosts. A clause-less query (e.g. q=*:*) analyzes to an empty + // BooleanQuery rather than null; adding it as a SHOULD clause would defeat the single-clause + // MatchAllDocsQuery optimization applied to the main query. + if (phrase != null && !isEmptyBooleanQuery(phrase)) { mainQuery.add(phrase, BooleanClause.Occur.SHOULD); } } + private static boolean isEmptyBooleanQuery(Query query) { + return query instanceof BooleanQuery && ((BooleanQuery) query).clauses().isEmpty(); + } + /** * @return a {fieldName, fieldBoost} map for the given fields. */ diff --git a/solr/core/src/test-files/solr/collection1/conf/synonyms.txt b/solr/core/src/test-files/solr/collection1/conf/synonyms.txt index d7feb34ee647..d017cc127679 100644 --- a/solr/core/src/test-files/solr/collection1/conf/synonyms.txt +++ b/solr/core/src/test-files/solr/collection1/conf/synonyms.txt @@ -32,6 +32,7 @@ pixima => pixma # multiword synonyms wi fi => wifi crow blackbird, grackle +usa => united states of america # Synonyms used in semantic expansion tabby => tabby, cat, feline, animal diff --git a/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java b/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java index e82df1e4bfbc..bad5a0eb297f 100644 --- a/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java @@ -256,4 +256,53 @@ public void testExtraBlankBQ() throws Exception { assertTrue(p.matcher(resp).find()); assertTrue(p_bool.matcher(resp).find()); } + + // SOLR-18314: the pf phrase boost is dropped for single-term queries, decided from the analyzed + // query. A single input term whose analysis yields multiple tokens (here, WordDelimiter splitting + // "wi-fi" into "wi fi") must keep its phrase boost. Unlike eDisMax, the classic DisMax parser has + // no minClauseSize gate, so this is enforced by inspecting the parsed phrase query. + // + // CJK is an important example of a query with no spaces that can still parse to multiple terms. + // However, we don't add a dedicated CJK test: CJK analysis of a single input term produces the + // same multi-term PhraseQuery that the "wi-fi" case already exercises, so it would cover no new + // code path while requiring a CJK analyzer to be configured in the test schema. + @Test + public void testSingleTermPhraseBoost() throws Exception { + // Single analyzed token: the pf phrase boost is redundant with the qf term boost, so no phrase + // query should appear in the parsed query. + Pattern phrase = Pattern.compile("subword:\""); + String resp = + h.query( + req( + "q", + "wireless", + "qt", + "/dismax", + "qf", + "subword", + "pf", + "subword^10", + CommonParams.DEBUG_QUERY, + "true")); + assertFalse("single-term query should not produce a phrase boost", phrase.matcher(resp).find()); + + // Whitespace-free query that analyzes into multiple tokens: the phrase boost is meaningful and + // must be retained. + resp = + h.query( + req( + "q", + "wi-fi", + "qt", + "/dismax", + "qf", + "subword", + "pf", + "subword^10", + CommonParams.DEBUG_QUERY, + "true")); + assertTrue( + "multi-token query should retain its phrase boost", + Pattern.compile("subword:\"wi fi\"").matcher(resp).find()); + } } diff --git a/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java b/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java index 19b5117488fd..619efe59e54d 100644 --- a/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java +++ b/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java @@ -26,6 +26,7 @@ import static org.hamcrest.Matchers.anyOf; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.isA; +import static org.hamcrest.Matchers.not; import java.util.Arrays; import java.util.HashSet; @@ -1197,6 +1198,61 @@ public void testOperatorsWithLiteralColons() { "//str[@name='id'][.='146']"); } + // SOLR-18314: the single-term pf boost is dropped, but only for genuinely single-token queries. + // A single input term whose analysis yields multiple tokens (WordDelimiter splitting "wi-fi", or + // a multi-word synonym expanding "usa") must keep its phrase boost. + // + // CJK is an important example of a query with no spaces that can still parse to multiple terms. + // However, we don't add a dedicated CJK test: CJK analysis of a single input term produces the + // same multi-term PhraseQuery that the "wi-fi" and "usa" cases already exercise, so it covers no + // new code path while requiring a CJK analyzer to be configured in the test schema. + public void testSingleTermPhraseBoost() throws Exception { + // Plain single-token query: the pf phrase boost adds nothing over the qf term boost, so no + // phrase clause should be present. minClauseSize already discards it regardless of this change. + String singleTokenParsed = + getParsedQuery( + req( + "q", "wireless", + "qf", "subject", + "pf", "subject^10", + "defType", "edismax", + "debugQuery", "true")); + assertThat(singleTokenParsed, not(containsString("subject:\""))); + + // Whitespace-free query that analyzes into multiple tokens: the phrase boost is meaningful + // (imposes adjacency that the qf clause does not) and must survive. + String multiTokenParsed = + getParsedQuery( + req( + "q", "wi-fi", + "qf", "subject", + "pf", "subject^10", + "defType", "edismax", + "debugQuery", "true")); + assertThat(multiTokenParsed, containsString("(subject:\"wi fi\")^10.0")); + + // A single raw token that a multi-word synonym expands into several analyzed tokens (here + // "usa" => "united states of america") also warrants a real phrase boost and must be retained. + // This covers the same "no whitespace in, multiple tokens out" case as CJK and ngram analyzers, + // without needing to configure those analyzers in the test schema. + String synonymParsed = + getParsedQuery( + req( + "q", "usa", + "qf", "subject", + "pf", "subject^10", + "defType", "edismax", + "debugQuery", "true")); + // "of" is a stopword (the "?" gap) and Porter stemming yields unit/state/america. + assertThat(synonymParsed, containsString("(subject:\"unit state ? america\")^10.0")); + + // A clause-less query (q=*:*) must not add an empty "" phrase clause that would defeat the + // MatchAllDocsQuery optimization. + Query matchAll = + QParser.getParser("*:*", "edismax", req("qf", "subject", "pf", "subject^10")).getQuery(); + assertThat(matchAll, isA(MatchAllDocsQuery.class)); + } + // test phrase fields including pf2 pf3 and phrase slop public void testPfPs() throws Exception { assertU(adoc("id", "s0", "phrase_sw", "foo bar a b c", "boost_d", "1.0")); From c618f11689f387fd052f2bd857e183583490ea65 Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Tue, 4 Aug 2026 11:45:56 -0400 Subject: [PATCH 08/13] SOLR-18314: unit-test isEffectivelySingleTerm directly Make DisMaxQParser.isEffectivelySingleTerm package-private (@VisibleForTesting) and add TestDisMaxQParser covering the single-term, multi-term, mixed-DisMax, unrecognized-shape, and null cases that are awkward to reach end-to-end. Co-Authored-By: Claude --- .../org/apache/solr/search/DisMaxQParser.java | 8 +- .../solr/search/ExtendedDismaxQParser.java | 2 +- .../apache/solr/search/TestDisMaxQParser.java | 107 ++++++++++++++++++ 3 files changed, 113 insertions(+), 4 deletions(-) create mode 100644 solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index bced0b153288..b9a7531c18ec 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -16,6 +16,7 @@ */ package org.apache.solr.search; +import com.google.common.annotations.VisibleForTesting; import java.util.ArrayList; import java.util.List; import java.util.Map; @@ -250,10 +251,11 @@ protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQ * phrase boost adds nothing over the term boost already applied by the main query. The parser * produces a {@link DisjunctionMaxQuery} over per-field {@link BoostQuery}-wrapped term/phrase * queries; we inspect the underlying query shape so that analyzers which split a single input - * term into multiple tokens keep their phrase boost. Unrecognized shapes conservatively return - * false (keep the boost). + * term into multiple tokens keep their phrase boost. Unrecognized shapes (and {@code null}) + * conservatively return false (keep the boost). */ - private static boolean isEffectivelySingleTerm(Query query) { + @VisibleForTesting + static boolean isEffectivelySingleTerm(Query query) { if (query instanceof BoostQuery boostQuery) { return isEffectivelySingleTerm(boostQuery.getQuery()); } diff --git a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java index 6088007f60aa..a51fc1937648 100644 --- a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java @@ -629,7 +629,7 @@ protected void addShingledPhraseQueries( return; if (0 == shingleSize) shingleSize = clauses.size(); - + final int lastClauseIndex = shingleSize - 1; StringBuilder userPhraseQuery = new StringBuilder(); diff --git a/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java b/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java new file mode 100644 index 000000000000..b56e5f804992 --- /dev/null +++ b/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.solr.search; + +import java.util.List; +import org.apache.lucene.index.Term; +import org.apache.lucene.search.BooleanClause; +import org.apache.lucene.search.BooleanQuery; +import org.apache.lucene.search.BoostQuery; +import org.apache.lucene.search.DisjunctionMaxQuery; +import org.apache.lucene.search.MatchAllDocsQuery; +import org.apache.lucene.search.MultiPhraseQuery; +import org.apache.lucene.search.PhraseQuery; +import org.apache.lucene.search.TermQuery; +import org.apache.solr.SolrTestCaseJ4; +import org.junit.Test; + +/** Unit tests for {@link DisMaxQParser} internals that do not need a running core. */ +public class TestDisMaxQParser extends SolrTestCaseJ4 { + + private static TermQuery term(String field, String text) { + return new TermQuery(new Term(field, text)); + } + + // SOLR-18314: isEffectivelySingleTerm decides whether a parsed pf phrase query collapses to a + // single analyzed term (so the phrase boost is redundant and should be dropped). It must return + // true only when every field's clause is a single term; any clause with two or more terms means + // the phrase boost adds a real adjacency constraint and must be kept. + @Test + public void testIsEffectivelySingleTermIsTrueForSingleTermShapes() { + // A bare single term. + assertTrue(DisMaxQParser.isEffectivelySingleTerm(term("subject", "wireless"))); + + // The parser wraps each field's clause in a BoostQuery; it must be unwrapped. + assertTrue( + DisMaxQParser.isEffectivelySingleTerm(new BoostQuery(term("subject", "wireless"), 10f))); + + // A one-term phrase is just a term boost in disguise. + assertTrue(DisMaxQParser.isEffectivelySingleTerm(new PhraseQuery("subject", "wireless"))); + + // A one-position MultiPhraseQuery (e.g. a single term with synonyms) is likewise single-term. + MultiPhraseQuery singlePosition = + new MultiPhraseQuery.Builder().add(new Term("subject", "wireless")).build(); + assertTrue(DisMaxQParser.isEffectivelySingleTerm(singlePosition)); + + // A DisjunctionMaxQuery whose every disjunct is single-term is single-term across fields. + DisjunctionMaxQuery allSingle = + new DisjunctionMaxQuery( + List.of( + new BoostQuery(term("subject", "wireless"), 10f), + new BoostQuery(term("features", "wireless"), 5f)), + 0.1f); + assertTrue(DisMaxQParser.isEffectivelySingleTerm(allSingle)); + } + + @Test + public void testIsEffectivelySingleTermIsFalseForMultiTermShapes() { + // A genuine two-term phrase (e.g. WordDelimiter splitting "wi-fi" into "wi fi"). + assertFalse(DisMaxQParser.isEffectivelySingleTerm(new PhraseQuery("subject", "wi", "fi"))); + + // A two-position MultiPhraseQuery (e.g. a multi-word synonym expansion). + MultiPhraseQuery twoPositions = + new MultiPhraseQuery.Builder() + .add(new Term("subject", "wi")) + .add(new Term("subject", "fi")) + .build(); + assertFalse(DisMaxQParser.isEffectivelySingleTerm(twoPositions)); + + // Any field with a multi-term clause keeps the boost, even if other fields are single-term. + DisjunctionMaxQuery mixed = + new DisjunctionMaxQuery( + List.of( + new BoostQuery(term("subject", "wireless"), 10f), + new BoostQuery(new PhraseQuery("features", "wi", "fi"), 5f)), + 0.1f); + assertFalse(DisMaxQParser.isEffectivelySingleTerm(mixed)); + } + + @Test + public void testIsEffectivelySingleTermIsFalseForUnrecognizedShapes() { + // Unrecognized shapes conservatively keep the boost rather than silently dropping it. + assertFalse(DisMaxQParser.isEffectivelySingleTerm(new MatchAllDocsQuery())); + + BooleanQuery booleanQuery = + new BooleanQuery.Builder() + .add(term("subject", "wireless"), BooleanClause.Occur.SHOULD) + .build(); + assertFalse(DisMaxQParser.isEffectivelySingleTerm(booleanQuery)); + + // null is a valid input (parse() can return it) and must not throw. + assertFalse(DisMaxQParser.isEffectivelySingleTerm(null)); + } +} From ae278d35b8f8dec4bb3029fd6ebacfb0904f0181 Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Tue, 4 Aug 2026 11:58:30 -0400 Subject: [PATCH 09/13] SOLR-18314: cover multi-word synonym pf boost in DisMaxRequestHandlerTest Add a "usa" => "united states of america" case exercising the DisMax parser's MultiPhraseQuery branch end-to-end: a single input term expanded by a multi-word synonym analyzes into multiple tokens and must retain its pf phrase boost. Adds a text_syn field (existing synonym-enabled lowerpunctfilt type) to the shared collection1 schema. Co-Authored-By: Claude --- .../solr/collection1/conf/schema.xml | 1 + .../apache/solr/DisMaxRequestHandlerTest.java | 26 ++++++++++++++++--- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/solr/core/src/test-files/solr/collection1/conf/schema.xml b/solr/core/src/test-files/solr/collection1/conf/schema.xml index e20f31d4bf3b..376393279b9f 100644 --- a/solr/core/src/test-files/solr/collection1/conf/schema.xml +++ b/solr/core/src/test-files/solr/collection1/conf/schema.xml @@ -632,6 +632,7 @@ + diff --git a/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java b/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java index bad5a0eb297f..b0ef1561ac85 100644 --- a/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java @@ -258,9 +258,10 @@ public void testExtraBlankBQ() throws Exception { } // SOLR-18314: the pf phrase boost is dropped for single-term queries, decided from the analyzed - // query. A single input term whose analysis yields multiple tokens (here, WordDelimiter splitting - // "wi-fi" into "wi fi") must keep its phrase boost. Unlike eDisMax, the classic DisMax parser has - // no minClauseSize gate, so this is enforced by inspecting the parsed phrase query. + // query. A single input term whose analysis yields multiple tokens must keep its phrase boost, + // whether from WordDelimiter splitting "wi-fi" into "wi fi" or a multi-word synonym expanding + // "usa" into "united states of america". Unlike eDisMax, the classic DisMax parser has no + // minClauseSize gate, so this is enforced by inspecting the parsed phrase query. // // CJK is an important example of a query with no spaces that can still parse to multiple terms. // However, we don't add a dedicated CJK test: CJK analysis of a single input term produces the @@ -304,5 +305,24 @@ public void testSingleTermPhraseBoost() throws Exception { assertTrue( "multi-token query should retain its phrase boost", Pattern.compile("subword:\"wi fi\"").matcher(resp).find()); + + // A single input term expanded by a multi-word synonym ("usa" => "united states of america") + // likewise analyzes into multiple tokens, so its phrase boost must be retained. + resp = + h.query( + req( + "q", + "usa", + "qt", + "/dismax", + "qf", + "text_syn", + "pf", + "text_syn^10", + CommonParams.DEBUG_QUERY, + "true")); + assertTrue( + "multi-word synonym query should retain its phrase boost", + Pattern.compile("text_syn:\"united states of america\"").matcher(resp).find()); } } From 38103f32eb77bd531fac4a0b4f3f30815c36c625 Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Wed, 5 Aug 2026 10:55:39 -0400 Subject: [PATCH 10/13] minor tweaks --- .../java/org/apache/solr/search/DisMaxQParser.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index b9a7531c18ec..c5663ce333ee 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -203,7 +203,7 @@ protected boolean addMainQuery(BooleanQuery.Builder query, SolrParams solrParams query.add(parsedUserQuery, BooleanClause.Occur.MUST); Query phrase = getPhraseQuery(userQuery, pp); - if (null != phrase) { + if (phrase != null) { query.add(phrase, BooleanClause.Occur.SHOULD); } } @@ -220,6 +220,12 @@ protected Query getAlternateUserQuery(SolrParams solrParams) throws SyntaxError } } + /** + * Builds the phrase-boost ("pf") query: the user's terms as a contiguous phrase over the pf + * fields, at the pf boosts. Returns {@code null} when the phrase boils down to a single analyzed + * term, because a one-term phrase boost is a no-op adjacency constraint that adds nothing over + * the term boost already applied by the main query (see {@link #isEffectivelySingleTerm(Query)}) + */ protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQueryParser pp) throws SyntaxError { /* * * Add on Phrases for the Query * * */ @@ -236,10 +242,7 @@ protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQ // A single-term phrase boost is a no-op adjacency constraint (effectively a redundant term // boost), so skip it. This is decided from the analyzed query, because analyzers such as CJK, // ngram, WordDelimiterGraph, and multi-word synonyms can split a single input term into several - // tokens that DO warrant a phrase boost. This is the DisMax-native analog of eDisMax's - // minClauseSize (which this parser lacks); note that DisMax counts terms after stopword - // removal, whereas eDisMax retains stopwords in its pf parse, so the two can differ for - // queries padded with stopwords. + // tokens that DO warrant a phrase boost if (isEffectivelySingleTerm(phrase)) { return null; } From 6590729e2b6232b9ee59f2e7a6a5cb9fc637b0c1 Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Tue, 13 Oct 2026 10:26:57 -0400 Subject: [PATCH 11/13] SOLR-18314: gate DisMax pf boost in addMainQuery; test autoGeneratePhraseQueries=false Move the single-term pf check from getPhraseQuery into addMainQuery, per review, so getPhraseQuery is unchanged from main and subclass overrides still get the gate. Add a subword_noautophrase field type (autoGeneratePhraseQueries="false", the default since schema 1.4) and a DisMax case showing q=wi-fi parses its qf clause as a plain disjunction, so the pf phrase boost is the only adjacency signal and must be kept. Tighten the existing wi-fi and synonym assertions to match the pf clause specifically (its ps slop and boost); they previously also matched the qf clause. Co-authored with Claude Code (Claude Opus 5.5). Co-Authored-By: Claude --- .../org/apache/solr/search/DisMaxQParser.java | 22 ++++-------- .../solr/collection1/conf/schema.xml | 12 +++++++ .../apache/solr/DisMaxRequestHandlerTest.java | 34 +++++++++++++++++-- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index c5663ce333ee..90523edb740f 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -203,7 +203,11 @@ protected boolean addMainQuery(BooleanQuery.Builder query, SolrParams solrParams query.add(parsedUserQuery, BooleanClause.Occur.MUST); Query phrase = getPhraseQuery(userQuery, pp); - if (phrase != null) { + // A single-term phrase boost is a no-op adjacency constraint (effectively a redundant term + // boost), so skip it. This is decided from the analyzed phrase rather than the user query's + // clause count, because analyzers such as CJK, ngram, WordDelimiterGraph, and multi-word + // synonyms can split a single input term into several tokens that DO warrant a phrase boost + if (null != phrase && !isEffectivelySingleTerm(phrase)) { query.add(phrase, BooleanClause.Occur.SHOULD); } } @@ -220,12 +224,6 @@ protected Query getAlternateUserQuery(SolrParams solrParams) throws SyntaxError } } - /** - * Builds the phrase-boost ("pf") query: the user's terms as a contiguous phrase over the pf - * fields, at the pf boosts. Returns {@code null} when the phrase boils down to a single analyzed - * term, because a one-term phrase boost is a no-op adjacency constraint that adds nothing over - * the term boost already applied by the main query (see {@link #isEffectivelySingleTerm(Query)}) - */ protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQueryParser pp) throws SyntaxError { /* * * Add on Phrases for the Query * * */ @@ -238,15 +236,7 @@ protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQ * matched those phrases but do match looser phrases. */ String userPhraseQuery = userQuery.replace("\"", ""); - Query phrase = pp.parse("\"" + userPhraseQuery + "\""); - // A single-term phrase boost is a no-op adjacency constraint (effectively a redundant term - // boost), so skip it. This is decided from the analyzed query, because analyzers such as CJK, - // ngram, WordDelimiterGraph, and multi-word synonyms can split a single input term into several - // tokens that DO warrant a phrase boost - if (isEffectivelySingleTerm(phrase)) { - return null; - } - return phrase; + return pp.parse("\"" + userPhraseQuery + "\""); } /** diff --git a/solr/core/src/test-files/solr/collection1/conf/schema.xml b/solr/core/src/test-files/solr/collection1/conf/schema.xml index 376393279b9f..a2bd7e425da0 100644 --- a/solr/core/src/test-files/solr/collection1/conf/schema.xml +++ b/solr/core/src/test-files/solr/collection1/conf/schema.xml @@ -312,6 +312,17 @@ + + + + + + + + + + @@ -634,6 +645,7 @@ + diff --git a/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java b/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java index b0ef1561ac85..80a1485ebea8 100644 --- a/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java @@ -288,7 +288,9 @@ public void testSingleTermPhraseBoost() throws Exception { assertFalse("single-term query should not produce a phrase boost", phrase.matcher(resp).find()); // Whitespace-free query that analyzes into multiple tokens: the phrase boost is meaningful and - // must be retained. + // must be retained. The qf clause is also the phrase "wi fi" here (this schema's version + // defaults autoGeneratePhraseQueries to true), so match the pf clause by the handler's ps=100 + // slop and the pf boost. resp = h.query( req( @@ -304,7 +306,31 @@ public void testSingleTermPhraseBoost() throws Exception { "true")); assertTrue( "multi-token query should retain its phrase boost", - Pattern.compile("subword:\"wi fi\"").matcher(resp).find()); + Pattern.compile("subword:\"wi fi\"~100\\)\\^10\\.0").matcher(resp).find()); + + // With autoGeneratePhraseQueries="false" (the default since schema 1.4), the qf clause for + // "wi-fi" is a plain disjunction of its tokens, so the pf boost is the only adjacency signal. + // Gating on the user query's clause count (one, since there's no whitespace) would wrongly drop + // it; this is also the shape of CJK bigram queries. + resp = + h.query( + req( + "q", + "wi-fi", + "qt", + "/dismax", + "qf", + "subword_noautophrase", + "pf", + "subword_noautophrase^10", + CommonParams.DEBUG_QUERY, + "true")); + assertTrue( + "qf clause should not be a phrase without autoGeneratePhraseQueries", + Pattern.compile("subword_noautophrase:wi subword_noautophrase:fi").matcher(resp).find()); + assertTrue( + "multi-token query should retain its phrase boost without autoGeneratePhraseQueries", + Pattern.compile("subword_noautophrase:\"wi fi\"~100\\)\\^10\\.0").matcher(resp).find()); // A single input term expanded by a multi-word synonym ("usa" => "united states of america") // likewise analyzes into multiple tokens, so its phrase boost must be retained. @@ -323,6 +349,8 @@ public void testSingleTermPhraseBoost() throws Exception { "true")); assertTrue( "multi-word synonym query should retain its phrase boost", - Pattern.compile("text_syn:\"united states of america\"").matcher(resp).find()); + Pattern.compile("text_syn:\"united states of america\"~100\\)\\^10\\.0") + .matcher(resp) + .find()); } } From f2961239de9fd20406862afc0466aca9c4862811 Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Wed, 21 Oct 2026 19:34:30 -0400 Subject: [PATCH 12/13] SOLR-18314: require single raw term for DisMax pf skip; handle synonym shapes; tighten tests Address code review of the DisMax single-term pf gate: - Only skip the pf boost when the user query is a single whitespace-free term AND it analyzes to a single position. A multi-word query on a keyword or string pf field analyzes to one TermQuery but is an exact-match boost that must be kept. - Treat single-position synonyms as single-term regardless of synonymQueryStyle (SynonymQuery, pick_best DisjunctionMaxQuery, or as_distinct_terms all-SHOULD BooleanQuery), matching eDisMax's minClauseSize behaviour. - Drop the "redundant term boost" rationale from comments and changelog: pf fields not in qf lose their only boost, so say so. eDisMax: return early from addShingledPhraseQueries when there are no clauses instead of building and parsing an empty phrase, and reuse the existing isEmpty(Query) helper. Note the empty-clause fix also applies to single-term queries in the changelog. Tests: make the DisMax single-term assertion check for the pf clause itself (it previously could not fail); add a string-field exact-match case; cover the synonym shapes in TestDisMaxQParser and move it to SolrTestCase; close the inline request in TestExtendedDismaxParser. Ref guide: note that the DisMax pf boost is not applied to single-term queries. Co-authored with Claude Code (Fable 5.1). Co-Authored-By: Claude Co-Authored-By: Claude Fable 5.1 --- ...-18314-empty-phrase-boost-matchalldocs.yml | 8 +-- ...18314-skip-phrase-analysis-single-term.yml | 12 ++-- .../org/apache/solr/search/DisMaxQParser.java | 41 +++++++++---- .../solr/search/ExtendedDismaxQParser.java | 19 +++--- .../apache/solr/DisMaxRequestHandlerTest.java | 43 +++++++++++--- .../apache/solr/search/TestDisMaxQParser.java | 59 +++++++++++++++---- .../solr/search/TestExtendedDismaxParser.java | 17 +++--- .../pages/dismax-query-parser.adoc | 2 + 8 files changed, 139 insertions(+), 62 deletions(-) diff --git a/changelog/unreleased/SOLR-18314-empty-phrase-boost-matchalldocs.yml b/changelog/unreleased/SOLR-18314-empty-phrase-boost-matchalldocs.yml index 076c48218b70..e673bfe2d591 100644 --- a/changelog/unreleased/SOLR-18314-empty-phrase-boost-matchalldocs.yml +++ b/changelog/unreleased/SOLR-18314-empty-phrase-boost-matchalldocs.yml @@ -1,7 +1,7 @@ -title: Fix the eDisMax parser adding an empty phrase (pf) boost clause for clause-less queries such - as q=*:* (distinct from single-term queries). The empty SHOULD clause defeated the single-clause - MatchAllDocsQuery optimization; it is now omitted, so these queries again reduce to a - MatchAllDocsQuery. +title: Fix the eDisMax parser adding an empty phrase field (pf) boost clause when no phrase qualifies + for the boost, such as for single-term queries or q=*:*. The empty SHOULD clause did not affect + scoring but defeated the single-clause MatchAllDocsQuery optimization; it is now omitted, so q=*:* + reduces to a MatchAllDocsQuery even when pf is set. type: fixed authors: - name: Ian Springer diff --git a/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml b/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml index 40f215e365e7..9bc522a52ed1 100644 --- a/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml +++ b/changelog/unreleased/SOLR-18314-skip-phrase-analysis-single-term.yml @@ -1,9 +1,9 @@ -title: The DisMax parser no longer applies the phrase field (pf) boost to single-term queries, since - a one-word phrase boost is a redundant term boost. (The eDisMax parser already skipped the pf boost - for single-term queries.) This applies only to genuinely single-token queries, so a single input - term whose analysis yields multiple tokens (e.g. via CJK, ngram, WordDelimiterGraph, or multi-word - synonyms) is unaffected and keeps its phrase boost. This may change relevancy scores for DisMax - queries that previously received a single-term phrase boost. +title: The DisMax parser no longer applies the phrase field (pf) boost to single-term queries, matching + the eDisMax parser. A query is single-term when it is one whitespace-free term that analyzes to a + single token in every pf field. A term whose analysis yields multiple tokens (e.g. via CJK, ngram, + WordDelimiterGraph, or multi-word synonyms) keeps its phrase boost, as does a multi-word query on + a keyword or string pf field. This may change relevancy scores for single-term DisMax queries, + including those whose pf fields are not also in qf. type: changed authors: - name: Ian Springer diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index 59da2bc18455..e303ec728688 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -27,6 +27,7 @@ import org.apache.lucene.search.MultiPhraseQuery; import org.apache.lucene.search.PhraseQuery; import org.apache.lucene.search.Query; +import org.apache.lucene.search.SynonymQuery; import org.apache.lucene.search.TermQuery; import org.apache.solr.common.params.CommonParams; import org.apache.solr.common.params.DisMaxParams; @@ -203,11 +204,12 @@ protected boolean addMainQuery(BooleanQuery.Builder query, SolrParams solrParams query.add(parsedUserQuery, BooleanClause.Occur.MUST); Query phrase = getPhraseQuery(userQuery, pp); - // A single-term phrase boost is a no-op adjacency constraint (effectively a redundant term - // boost), so skip it. This is decided from the analyzed phrase rather than the user query's - // clause count, because analyzers such as CJK, ngram, WordDelimiterGraph, and multi-word - // synonyms can split a single input term into several tokens that DO warrant a phrase boost - if (null != phrase && !isEffectivelySingleTerm(phrase)) { + // A single-term phrase boost imposes no adjacency constraint, so skip it, as eDisMax does. + // Require both a single user term and a single analyzed term: analyzers such as CJK, ngram, + // WordDelimiterGraph, and multi-word synonyms can split one input term into several tokens, + // and a multi-word query can analyze to one token (e.g. an exact-match keyword or string + // field). Either way the phrase boost is meaningful. + if (null != phrase && !(isSingleUserTerm(userQuery) && isEffectivelySingleTerm(phrase))) { query.add(phrase, BooleanClause.Occur.SHOULD); } } @@ -244,13 +246,20 @@ protected Query getPhraseQuery(String userQuery, SolrPluginUtils.DisjunctionMaxQ return phrase; } + /** Returns true if the user query, ignoring quotes, is a single whitespace-free term. */ + private static boolean isSingleUserTerm(String userQuery) { + String term = userQuery.replace("\"", "").strip(); + return !term.isEmpty() && term.codePoints().noneMatch(Character::isWhitespace); + } + /** - * Returns true if the parsed phrase query boils down to a single analyzed term, in which case the - * phrase boost adds nothing over the term boost already applied by the main query. The parser - * produces a {@link DisjunctionMaxQuery} over per-field {@link BoostQuery}-wrapped term/phrase - * queries; we inspect the underlying query shape so that analyzers which split a single input - * term into multiple tokens keep their phrase boost. Unrecognized shapes (and {@code null}) - * conservatively return false (keep the boost). + * Returns true if the parsed phrase query boils down to a single analyzed position in every + * field, in which case the phrase boost imposes no adjacency constraint. The parser produces a + * {@link DisjunctionMaxQuery} over per-field {@link BoostQuery}-wrapped queries; we inspect the + * underlying query shape so that analyzers which split a single input term into multiple tokens + * keep their phrase boost. Synonyms at a single position count as a single term, whichever {@code + * synonymQueryStyle} built them. Unrecognized shapes (and {@code null}) conservatively return + * false (keep the boost). */ @VisibleForTesting static boolean isEffectivelySingleTerm(Query query) { @@ -258,10 +267,16 @@ static boolean isEffectivelySingleTerm(Query query) { return isEffectivelySingleTerm(boostQuery.getQuery()); } if (query instanceof DisjunctionMaxQuery dmq) { - // Every field's clause is a single term, so the phrase across fields is too. + // Every field's clause is a single term (or synonymQueryStyle=pick_best synonyms). return dmq.getDisjuncts().stream().allMatch(DisMaxQParser::isEffectivelySingleTerm); } - if (query instanceof TermQuery) { + if (query instanceof BooleanQuery booleanQuery) { + // synonymQueryStyle=as_distinct_terms synonyms at a single position + return booleanQuery.clauses().stream() + .allMatch( + c -> c.occur() == BooleanClause.Occur.SHOULD && isEffectivelySingleTerm(c.query())); + } + if (query instanceof TermQuery || query instanceof SynonymQuery) { return true; } if (query instanceof PhraseQuery phraseQuery) { diff --git a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java index 8677d45b265f..955a953570e7 100644 --- a/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/ExtendedDismaxQParser.java @@ -625,8 +625,11 @@ protected void addShingledPhraseQueries( final int slop) throws SyntaxError { - if (null == fields || fields.isEmpty() || null == clauses || clauses.size() < shingleSize) - return; + if (null == fields + || fields.isEmpty() + || null == clauses + || clauses.isEmpty() + || clauses.size() < shingleSize) return; if (0 == shingleSize) shingleSize = clauses.size(); @@ -676,18 +679,14 @@ protected void addShingledPhraseQueries( // TODO: perhaps we shouldn't use synonyms either... Query phrase = pp.parse(userPhraseQuery.toString()); - // Skip null and *empty* boosts. A clause-less query (e.g. q=*:*) analyzes to an empty - // BooleanQuery rather than null; adding it as a SHOULD clause would defeat the single-clause - // MatchAllDocsQuery optimization applied to the main query. - if (phrase != null && !isEmptyBooleanQuery(phrase)) { + // When minClauseSize discards every phrase (e.g. single-term queries or q=*:*), parse returns + // an empty BooleanQuery rather than null; adding it as a SHOULD clause would defeat the + // single-clause MatchAllDocsQuery optimization applied to the main query. + if (!isEmpty(phrase)) { mainQuery.add(phrase, BooleanClause.Occur.SHOULD); } } - private static boolean isEmptyBooleanQuery(Query query) { - return query instanceof BooleanQuery && ((BooleanQuery) query).clauses().isEmpty(); - } - /** * @return a {fieldName, fieldBoost} map for the given fields. */ diff --git a/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java b/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java index 1f1a95dd377f..a6735a73d7f4 100644 --- a/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java +++ b/solr/core/src/test/org/apache/solr/DisMaxRequestHandlerTest.java @@ -246,11 +246,13 @@ public void testExtraBlankBQ() throws Exception { assertTrue(p_bool.matcher(resp).find()); } - // SOLR-18314: the pf phrase boost is dropped for single-term queries, decided from the analyzed - // query. A single input term whose analysis yields multiple tokens must keep its phrase boost, - // whether from WordDelimiter splitting "wi-fi" into "wi fi" or a multi-word synonym expanding - // "usa" into "united states of america". Unlike eDisMax, the classic DisMax parser has no - // minClauseSize gate, so this is enforced by inspecting the parsed phrase query. + // SOLR-18314: the pf phrase boost is dropped for single-term queries, i.e. a single user term + // that also analyzes to a single token. A single input term whose analysis yields multiple tokens + // must keep its phrase boost, whether from WordDelimiter splitting "wi-fi" into "wi fi" or a + // multi-word synonym expanding "usa" into "united states of america". So must a multi-word query + // that analyzes to a single token on a string pf field (an exact-match boost). Unlike eDisMax, + // the classic DisMax parser has no minClauseSize gate, so this is enforced by inspecting the + // user query and the parsed phrase query. // // CJK is an important example of a query with no spaces that can still parse to multiple terms. // However, we don't add a dedicated CJK test: CJK analysis of a single input term produces the @@ -258,9 +260,8 @@ public void testExtraBlankBQ() throws Exception { // code path while requiring a CJK analyzer to be configured in the test schema. @Test public void testSingleTermPhraseBoost() throws Exception { - // Single analyzed token: the pf phrase boost is redundant with the qf term boost, so no phrase - // query should appear in the parsed query. - Pattern phrase = Pattern.compile("subword:\""); + // Single analyzed token: the pf phrase boost imposes no adjacency, so only the unboosted qf + // clause should appear in the parsed query. String resp = h.query( req( @@ -274,7 +275,12 @@ public void testSingleTermPhraseBoost() throws Exception { "subword^10", CommonParams.DEBUG_QUERY, "true")); - assertFalse("single-term query should not produce a phrase boost", phrase.matcher(resp).find()); + assertTrue( + "single-term query should keep its qf clause", + Pattern.compile("\\(subword:wireless\\)(?!\\^)").matcher(resp).find()); + assertFalse( + "single-term query should not produce a phrase boost", + Pattern.compile("subword:wireless\\)\\^10\\.0").matcher(resp).find()); // Whitespace-free query that analyzes into multiple tokens: the phrase boost is meaningful and // must be retained. The qf clause is also the phrase "wi fi" here (this schema's version @@ -341,5 +347,24 @@ public void testSingleTermPhraseBoost() throws Exception { Pattern.compile("text_syn:\"united states of america\"~100\\)\\^10\\.0") .matcher(resp) .find()); + + // A multi-word query on a string pf field analyzes to a single exact-match term, which is the + // only signal ranking an exact match first, so its phrase boost must be retained. + resp = + h.query( + req( + "q", + "hello world", + "qt", + "/dismax", + "qf", + "subword", + "pf", + "foo_s^10", + CommonParams.DEBUG_QUERY, + "true")); + assertTrue( + "multi-word query should retain its exact-match phrase boost", + Pattern.compile("foo_s:hello world\\)\\^10\\.0").matcher(resp).find()); } } diff --git a/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java b/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java index b56e5f804992..8905f0a004e1 100644 --- a/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java +++ b/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java @@ -25,21 +25,31 @@ import org.apache.lucene.search.MatchAllDocsQuery; import org.apache.lucene.search.MultiPhraseQuery; import org.apache.lucene.search.PhraseQuery; +import org.apache.lucene.search.Query; +import org.apache.lucene.search.SynonymQuery; import org.apache.lucene.search.TermQuery; -import org.apache.solr.SolrTestCaseJ4; +import org.apache.solr.SolrTestCase; import org.junit.Test; /** Unit tests for {@link DisMaxQParser} internals that do not need a running core. */ -public class TestDisMaxQParser extends SolrTestCaseJ4 { +public class TestDisMaxQParser extends SolrTestCase { private static TermQuery term(String field, String text) { return new TermQuery(new Term(field, text)); } + private static BooleanQuery should(Query... queries) { + BooleanQuery.Builder builder = new BooleanQuery.Builder(); + for (Query query : queries) { + builder.add(query, BooleanClause.Occur.SHOULD); + } + return builder.build(); + } + // SOLR-18314: isEffectivelySingleTerm decides whether a parsed pf phrase query collapses to a - // single analyzed term (so the phrase boost is redundant and should be dropped). It must return - // true only when every field's clause is a single term; any clause with two or more terms means - // the phrase boost adds a real adjacency constraint and must be kept. + // single analyzed position (so the phrase boost imposes no adjacency and should be dropped). It + // must return true only when every field's clause is a single position; any clause spanning two + // or more positions means the phrase boost adds a real adjacency constraint and must be kept. @Test public void testIsEffectivelySingleTermIsTrueForSingleTermShapes() { // A bare single term. @@ -49,10 +59,28 @@ public void testIsEffectivelySingleTermIsTrueForSingleTermShapes() { assertTrue( DisMaxQParser.isEffectivelySingleTerm(new BoostQuery(term("subject", "wireless"), 10f))); - // A one-term phrase is just a term boost in disguise. - assertTrue(DisMaxQParser.isEffectivelySingleTerm(new PhraseQuery("subject", "wireless"))); + // Synonyms at a single position, as built by each synonymQueryStyle: as_same_term (the + // default) builds a SynonymQuery, pick_best a DisjunctionMaxQuery, and as_distinct_terms a + // BooleanQuery of SHOULD clauses. + assertTrue( + DisMaxQParser.isEffectivelySingleTerm( + new SynonymQuery.Builder("subject") + .addTerm(new Term("subject", "tv")) + .addTerm(new Term("subject", "television")) + .build())); + assertTrue( + DisMaxQParser.isEffectivelySingleTerm( + new BoostQuery( + new DisjunctionMaxQuery( + List.of(term("subject", "tv"), term("subject", "television")), 0f), + 10f))); + assertTrue( + DisMaxQParser.isEffectivelySingleTerm( + should(term("subject", "tv"), term("subject", "television")))); - // A one-position MultiPhraseQuery (e.g. a single term with synonyms) is likewise single-term. + // Degenerate one-position phrases (QueryBuilder emits a TermQuery or SynonymQuery instead, but + // these are equally single-term). + assertTrue(DisMaxQParser.isEffectivelySingleTerm(new PhraseQuery("subject", "wireless"))); MultiPhraseQuery singlePosition = new MultiPhraseQuery.Builder().add(new Term("subject", "wireless")).build(); assertTrue(DisMaxQParser.isEffectivelySingleTerm(singlePosition)); @@ -72,7 +100,7 @@ public void testIsEffectivelySingleTermIsFalseForMultiTermShapes() { // A genuine two-term phrase (e.g. WordDelimiter splitting "wi-fi" into "wi fi"). assertFalse(DisMaxQParser.isEffectivelySingleTerm(new PhraseQuery("subject", "wi", "fi"))); - // A two-position MultiPhraseQuery (e.g. a multi-word synonym expansion). + // A two-position MultiPhraseQuery (e.g. synonyms within a multi-token phrase). MultiPhraseQuery twoPositions = new MultiPhraseQuery.Builder() .add(new Term("subject", "wi")) @@ -80,6 +108,12 @@ public void testIsEffectivelySingleTermIsFalseForMultiTermShapes() { .build(); assertFalse(DisMaxQParser.isEffectivelySingleTerm(twoPositions)); + // A sloppy graph phrase (e.g. a multi-word synonym) is a disjunction of per-path phrases; any + // multi-term path keeps the boost. + assertFalse( + DisMaxQParser.isEffectivelySingleTerm( + should(term("subject", "wifi"), new PhraseQuery(100, "subject", "wi", "fi")))); + // Any field with a multi-term clause keeps the boost, even if other fields are single-term. DisjunctionMaxQuery mixed = new DisjunctionMaxQuery( @@ -95,11 +129,12 @@ public void testIsEffectivelySingleTermIsFalseForUnrecognizedShapes() { // Unrecognized shapes conservatively keep the boost rather than silently dropping it. assertFalse(DisMaxQParser.isEffectivelySingleTerm(new MatchAllDocsQuery())); - BooleanQuery booleanQuery = + // Only all-SHOULD BooleanQueries are synonym disjunctions; required clauses are not. + BooleanQuery required = new BooleanQuery.Builder() - .add(term("subject", "wireless"), BooleanClause.Occur.SHOULD) + .add(term("subject", "wireless"), BooleanClause.Occur.MUST) .build(); - assertFalse(DisMaxQParser.isEffectivelySingleTerm(booleanQuery)); + assertFalse(DisMaxQParser.isEffectivelySingleTerm(required)); // null is a valid input (parse() can return it) and must not throw. assertFalse(DisMaxQParser.isEffectivelySingleTerm(null)); diff --git a/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java b/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java index af7eb81fb419..be7bd11ad72f 100644 --- a/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java +++ b/solr/core/src/test/org/apache/solr/search/TestExtendedDismaxParser.java @@ -1203,8 +1203,8 @@ public void testOperatorsWithLiteralColons() { // same multi-term PhraseQuery that the "wi-fi" and "usa" cases already exercise, so it covers no // new code path while requiring a CJK analyzer to be configured in the test schema. public void testSingleTermPhraseBoost() throws Exception { - // Plain single-token query: the pf phrase boost adds nothing over the qf term boost, so no - // phrase clause should be present. minClauseSize already discards it regardless of this change. + // Plain single-token query: the pf phrase boost imposes no adjacency, so minClauseSize discards + // it and no boosted (or empty) phrase clause should be present. String singleTokenParsed = getParsedQuery( req( @@ -1213,7 +1213,7 @@ public void testSingleTermPhraseBoost() throws Exception { "pf", "subject^10", "defType", "edismax", "debugQuery", "true")); - assertThat(singleTokenParsed, not(containsString("subject:\""))); + assertThat(singleTokenParsed, allOf(not(containsString("^10.0")), not(containsString("()")))); // Whitespace-free query that analyzes into multiple tokens: the phrase boost is meaningful // (imposes adjacency that the qf clause does not) and must survive. @@ -1242,11 +1242,12 @@ public void testSingleTermPhraseBoost() throws Exception { // "of" is a stopword (the "?" gap) and Porter stemming yields unit/state/america. assertThat(synonymParsed, containsString("(subject:\"unit state ? america\")^10.0")); - // A clause-less query (q=*:*) must not add an empty "" phrase clause that would defeat the - // MatchAllDocsQuery optimization. - Query matchAll = - QParser.getParser("*:*", "edismax", req("qf", "subject", "pf", "subject^10")).getQuery(); - assertThat(matchAll, isA(MatchAllDocsQuery.class)); + // q=*:* must not add an empty phrase clause that would defeat the MatchAllDocsQuery + // optimization. + try (SolrQueryRequest req = req("qf", "subject", "pf", "subject^10")) { + Query matchAll = QParser.getParser("*:*", "edismax", req).getQuery(); + assertThat(matchAll, isA(MatchAllDocsQuery.class)); + } } // test phrase fields including pf2 pf3 and phrase slop diff --git a/solr/solr-ref-guide/modules/query-guide/pages/dismax-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/dismax-query-parser.adoc index 08bb9d08ae70..a515ede00026 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/dismax-query-parser.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/dismax-query-parser.adoc @@ -117,6 +117,8 @@ Once the list of matching documents has been identified using the `fq` and `qf` The format is the same as that used by the `qf` parameter: a list of fields and "boosts" to associate with each of them when making phrase queries out of the entire q parameter. +The `pf` boost is not applied to single-term queries, meaning a single term that analyzes to a single token in every `pf` field, since a one-term phrase imposes no proximity constraint. + === ps (Phrase Slop) Parameter From c122384ba230eecd0a6f2ba13a15c02908c9f0fc Mon Sep 17 00:00:00 2001 From: Ian Springer Date: Wed, 21 Oct 2026 19:42:18 -0400 Subject: [PATCH 13/13] SOLR-18314: keep as_distinct_terms synonym pf boost for eDisMax parity; document ranking impact Drop the BooleanQuery branch from DisMaxQParser.isEffectivelySingleTerm: the all-SHOULD BooleanQuery that synonymQueryStyle=as_distinct_terms builds for single-position synonyms is not subject to eDisMax's minClauseSize gate, so treating it as single-term made DisMax drop a boost eDisMax keeps. Also guard against an empty DisjunctionMaxQuery being vacuously single-term. Ref guide: note that a pf-only field no longer affects single-term DisMax scores and point to qf/bq; add the change to major-changes-in-solr-11. Co-authored with Claude Code (Fable 5.1). Co-Authored-By: Claude Co-Authored-By: Claude Fable 5.1 --- .../org/apache/solr/search/DisMaxQParser.java | 17 +++++++---------- .../apache/solr/search/TestDisMaxQParser.java | 17 ++++++++++------- .../query-guide/pages/dismax-query-parser.adoc | 1 + .../pages/major-changes-in-solr-11.adoc | 11 +++++++++++ 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java index e303ec728688..f8ca7382c0f9 100644 --- a/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java +++ b/solr/core/src/java/org/apache/solr/search/DisMaxQParser.java @@ -257,9 +257,11 @@ private static boolean isSingleUserTerm(String userQuery) { * field, in which case the phrase boost imposes no adjacency constraint. The parser produces a * {@link DisjunctionMaxQuery} over per-field {@link BoostQuery}-wrapped queries; we inspect the * underlying query shape so that analyzers which split a single input term into multiple tokens - * keep their phrase boost. Synonyms at a single position count as a single term, whichever {@code - * synonymQueryStyle} built them. Unrecognized shapes (and {@code null}) conservatively return - * false (keep the boost). + * keep their phrase boost. Synonyms at a single position count as a single term when built as a + * {@link SynonymQuery} ({@code synonymQueryStyle=as_same_term}) or {@link DisjunctionMaxQuery} + * ({@code pick_best}); the {@link BooleanQuery} that {@code as_distinct_terms} builds is kept, as + * eDisMax's {@code minClauseSize} gate also keeps it. Unrecognized shapes (and {@code null}) + * conservatively return false (keep the boost). */ @VisibleForTesting static boolean isEffectivelySingleTerm(Query query) { @@ -268,13 +270,8 @@ static boolean isEffectivelySingleTerm(Query query) { } if (query instanceof DisjunctionMaxQuery dmq) { // Every field's clause is a single term (or synonymQueryStyle=pick_best synonyms). - return dmq.getDisjuncts().stream().allMatch(DisMaxQParser::isEffectivelySingleTerm); - } - if (query instanceof BooleanQuery booleanQuery) { - // synonymQueryStyle=as_distinct_terms synonyms at a single position - return booleanQuery.clauses().stream() - .allMatch( - c -> c.occur() == BooleanClause.Occur.SHOULD && isEffectivelySingleTerm(c.query())); + return !dmq.getDisjuncts().isEmpty() + && dmq.getDisjuncts().stream().allMatch(DisMaxQParser::isEffectivelySingleTerm); } if (query instanceof TermQuery || query instanceof SynonymQuery) { return true; diff --git a/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java b/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java index 8905f0a004e1..a5e03654dd03 100644 --- a/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java +++ b/solr/core/src/test/org/apache/solr/search/TestDisMaxQParser.java @@ -59,9 +59,8 @@ public void testIsEffectivelySingleTermIsTrueForSingleTermShapes() { assertTrue( DisMaxQParser.isEffectivelySingleTerm(new BoostQuery(term("subject", "wireless"), 10f))); - // Synonyms at a single position, as built by each synonymQueryStyle: as_same_term (the - // default) builds a SynonymQuery, pick_best a DisjunctionMaxQuery, and as_distinct_terms a - // BooleanQuery of SHOULD clauses. + // Synonyms at a single position: synonymQueryStyle=as_same_term (the default) builds a + // SynonymQuery and pick_best a DisjunctionMaxQuery. assertTrue( DisMaxQParser.isEffectivelySingleTerm( new SynonymQuery.Builder("subject") @@ -74,9 +73,6 @@ public void testIsEffectivelySingleTermIsTrueForSingleTermShapes() { new DisjunctionMaxQuery( List.of(term("subject", "tv"), term("subject", "television")), 0f), 10f))); - assertTrue( - DisMaxQParser.isEffectivelySingleTerm( - should(term("subject", "tv"), term("subject", "television")))); // Degenerate one-position phrases (QueryBuilder emits a TermQuery or SynonymQuery instead, but // these are equally single-term). @@ -129,13 +125,20 @@ public void testIsEffectivelySingleTermIsFalseForUnrecognizedShapes() { // Unrecognized shapes conservatively keep the boost rather than silently dropping it. assertFalse(DisMaxQParser.isEffectivelySingleTerm(new MatchAllDocsQuery())); - // Only all-SHOULD BooleanQueries are synonym disjunctions; required clauses are not. + // synonymQueryStyle=as_distinct_terms builds a BooleanQuery for single-position synonyms; + // eDisMax's minClauseSize gate keeps it too, so the parsers agree. + assertFalse( + DisMaxQParser.isEffectivelySingleTerm( + should(term("subject", "tv"), term("subject", "television")))); BooleanQuery required = new BooleanQuery.Builder() .add(term("subject", "wireless"), BooleanClause.Occur.MUST) .build(); assertFalse(DisMaxQParser.isEffectivelySingleTerm(required)); + // An empty disjunction is not vacuously single-term. + assertFalse(DisMaxQParser.isEffectivelySingleTerm(new DisjunctionMaxQuery(List.of(), 0f))); + // null is a valid input (parse() can return it) and must not throw. assertFalse(DisMaxQParser.isEffectivelySingleTerm(null)); } diff --git a/solr/solr-ref-guide/modules/query-guide/pages/dismax-query-parser.adoc b/solr/solr-ref-guide/modules/query-guide/pages/dismax-query-parser.adoc index a515ede00026..379d19957b45 100644 --- a/solr/solr-ref-guide/modules/query-guide/pages/dismax-query-parser.adoc +++ b/solr/solr-ref-guide/modules/query-guide/pages/dismax-query-parser.adoc @@ -118,6 +118,7 @@ Once the list of matching documents has been identified using the `fq` and `qf` The format is the same as that used by the `qf` parameter: a list of fields and "boosts" to associate with each of them when making phrase queries out of the entire q parameter. The `pf` boost is not applied to single-term queries, meaning a single term that analyzes to a single token in every `pf` field, since a one-term phrase imposes no proximity constraint. +Consequently a `pf` field that is not also in `qf` contributes nothing to the score of a single-term query; to boost single-term matches in such a field, add it to `qf` or use the `bq` parameter. === ps (Phrase Slop) Parameter diff --git a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc index ac2de0f81376..f400854d670f 100644 --- a/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc +++ b/solr/solr-ref-guide/modules/upgrade-notes/pages/major-changes-in-solr-11.adoc @@ -25,6 +25,17 @@ This page highlights the most important changes including new features and chang Before starting an upgrade to this version of Solr, please be sure to review all information about changes from the version you are currently on up to this one, to include the minor version number changes as well. For example, if you are currently using Solr 10.1, you should review changes made in all subsequent 10.x releases in addition to the 11.0-specific changes on this page. +== Changes in Default Behavior + +=== DisMax Phrase Field Boost for Single-Term Queries + +The DisMax query parser no longer applies the phrase field (`pf`) boost to single-term queries, bringing it in line with the eDisMax parser. +A query is single-term when it is one whitespace-free term that analyzes to a single token in every `pf` field. +A term that analyzes to several tokens (for example via CJK, ngram, WordDelimiterGraph, or multi-word synonym analysis) keeps its phrase boost, as does a multi-word query on a keyword or string `pf` field. + +This can change the ranking of single-term DisMax queries, most noticeably where a `pf` field is not also in `qf`, since that field no longer contributes to the score at all. +To keep boosting single-term matches in such a field, add it to `qf` or use a `bq` boost query. + == Removed Features === Overseer Roles API