All findings · Lucene finding · LFV-714 · complexphrase-lone-group-read-as-sequence

ComplexPhraseQueryParser reads a quoted phrase that is only one bracketed group as a sequence of positions

In ComplexPhraseQueryParser a bracketed group inside quotes is one word position, but a group that is the whole phrase is read as a sequence: on five documents "(jo* -john)" returns all 5, including "john smith", instead of 4, and "(john OR jonathan)" returns only "john jonathan". Silent, since 2.9.0.

Summary

Priority
Low, rank 381Needs a quoted phrase that is only one parenthesized group in the opt-in ComplexPhraseQueryParser.
Impact
silent wrong results
Since
2.9.0 (2009, with the parser)introduced 2009-07-06 · over 17 years
Affected
2.9.0 to 10.5.2 and main
Status
documented
Found by
a bounded-exhaustive check of the parser against a one-position-per-group reference

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Diagram of the bug: inside a longer phrase a bracketed group is one word position with alternatives; when the group is the whole phrase its brackets are dropped and its members become consecutive positions, so the OR group returns only the document with the sequence "john jonathan" and the NOT group also returns "john smith".
The same bracketed group inside a longer phrase (one position) and as the whole phrase (read as a sequence), with the hits on a five-document index. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index five one-line names: "john smith", "jonathan smith", "johnny walker", "peter jonathan", "john jonathan". Parse queries with ComplexPhraseQueryParser and its default settings.
  2. Inside quotes, a bracketed group is one word position with alternatives: "(jo* -john) smith" finds "jonathan smith" (a jo* word other than john, then smith), and "(john OR jonathan) smith" finds "john smith" and "jonathan smith". Both are right.
  3. Drop the " smith". You expect "(jo* -john)" to find the 4 documents with a jo* word other than john, and "(john OR jonathan)" the 4 documents with john or jonathan.
  4. Instead "(jo* -john)" returns all 5, including "john smith", and "(john OR jonathan)" returns 1, "john jonathan": brackets around the whole phrase are dropped while parsing, so each member becomes its own position ("a jo* word not followed by john"; "john, then jonathan").
  5. No error is raised. Dropping the quotes does not help the NOT form: the unquoted (jo* -john) returns 3, because it also drops "john jonathan".

Who is affected

Applications that use the opt-in ComplexPhraseQueryParser, with default settings (reproduced with MockAnalyzer and StandardAnalyzer), when the whole text between the quotes is one bracketed group, typed by a user or generated by an application that quotes each word's alternatives. Wrong hits in both directions, no error, no scale needed. Rare: the only upstream use found is from 2017.

Mechanism, terms and numbers665 words

Inside a ComplexPhraseQueryParser phrase, a bracketed group is one word position with alternatives: the class javadoc's example is "(john jon jonathan~) peters*", and the upstream tests check "(jo* -john) smith" and "(john OR johathon) smith" (two spaces before smith in the test). That works when another element follows the group. When the group is the whole text between the quotes, the brackets are lost: the second pass parses the text with the classic grammar, which returns the inner query of a lone ( ... ), so "(john OR jonathan)" gets exactly the contents of "john OR jonathan", and ComplexPhraseQuery.rewrite turns each member into its own consecutive position. On five documents, "(john OR jonathan)" returns 1 document (the one with the sequence "john jonathan") instead of 4, and "(jo* -john)" returns all 5, including "john smith", instead of the 4 with a jo* term other than john. There is no error. It has behaved this way since the parser was added in 2.9.0: executed on 28 releases and main (both lone shapes on 16, the OR shape only on 12 more); the releases in between share the unchanged code.

Terms

  • Complex phrase: ComplexPhraseQueryParser (in lucene/queryparser, opt-in) lets the text between quotes use query syntax: wildcards, fuzzy terms, ranges and bracketed groups. It parses the query twice. Pass 1 keeps the raw text of each phrase; pass 2 parses that text with the same classic grammar and turns the result into span queries.
  • Position: each top-level element of the phrase is one word position, and the positions must follow each other (SpanNearQuery, with the phrase's slop). Top-level OR and AND are ignored inside a phrase: the implicit operator is "next to".
  • Bracketed group: the documented exception. A group stands for one position whose word is any of its alternatives (spanOr), or any of its positive alternatives minus its negative ones (spanNot).

The bug

  1. Pass 2 parses the raw phrase text: contents[0] = qp.parse(phrasedQueryStringContents) (ComplexPhraseQueryParser.java:246, in parsePhraseElements, :234-250).
  2. The classic grammar drops brackets around the whole input. Clause() returns the inner query of <LPAREN> q=Query(field) <RPAREN> (QueryParser.jj:311), and Query() returns a lone, unmodified clause as it is: if (clauses.size() == 1 && firstQuery != null) return firstQuery; (:289-293). So (john OR jonathan) parses to the same BooleanQuery(john, jonathan) as john OR jonathan. For Boolean logic that is harmless.
  3. ComplexPhraseQuery.rewrite (ComplexPhraseQueryParser.java:258-361) reads every top-level clause of that BooleanQuery as one position (:278-330) and returns new SpanNearQuery(allSpanClauses, slopFactor, inOrder) (:333): "(john OR jonathan)" becomes spanNear([john, jonathan], 0, true).
  4. With a MUST_NOT clause it builds SpanNotQuery(include = the positive clauses, exclude = SpanNearQuery over all clauses) (:338-360): "(jo* -john)" becomes "a jo* term not followed by john", which "john smith" satisfies.
  5. Inside a longer phrase the group survives as a nested BooleanQuery clause, and addComplexPhraseClause makes it one spanOr or spanNot position (:294-300, :371-420). That is why the same group followed by "smith" works.

The numbers (main a9779ef3c193, five documents)

queryexpectedmainrewritten on main
"(john OR jonathan) smith" (control)john smith, jonathan smiththe samespanNear([spanOr([name:john, name:jonathan]), name:smith], 0, true)
"(jo* -john) smith" (control)jonathan smiththe samespanNear([spanNot(spanOr([name:john, name:johnny, name:jonathan]), spanOr([name:john]), 0, 0), name:smith], 0, true)
"(john OR jonathan)"4 documents1: john jonathanspanNear([name:john, name:jonathan], 0, true)
"(jo* -john)"4 documents5: also john smithspanNot(spanOr([name:john, name:johnny, name:jonathan]), spanNear([spanOr([name:john, name:johnny, name:jonathan]), name:john], 0, true), 0, 0)

The lone group gives the same rewritten query and hits as the bracket-free "john OR jonathan" and "jo* -john"; so do "((john OR jonathan))", "(john jonathan)", a field prefix and slop ("(john OR jonathan)"~2 still returns only "john jonathan"). A boosted lone group, "(john OR jonathan)^2", throws IllegalArgumentException ("Unknown query type ... BoostQuery") at search time instead: that is the separate sibling single-clause bug in the same rewrite.

Exposure and likelihood293 words
  • Who is hit. Users of ComplexPhraseQueryParser, with default settings. The brackets are lost in the parser's grammar, before analysis, so the analyzer should not matter; it was reproduced with MockAnalyzer (whitespace and simple tokenizers, lower-casing) and StandardAnalyzer. Solr's complexphrase query parser subclasses it without overriding the second pass, so it should inherit the behaviour (read in its source, not run).
  • Trigger. The whole text between the quotes is one bracketed group, such as name:"(jo* -john)" or name:"(color OR colour)": typed by a user, or produced by an application that wraps the alternatives of each word position in quotes and gets a one-word input. Slop, a field prefix and either default operator do not matter.
  • Effect. Wrong hits in both directions with no error. An OR group misses every document that holds only one alternative. A NOT group returns documents its own exclusion should drop, and dropping the quotes does not rescue it: the unquoted (jo* -john) drops every document that also contains john (3 hits instead of 4).
  • Scale. None needed: five one-line documents show it. Nothing is stored or corrupted.
  • How likely. Rare. People seldom quote a lone OR group, since the unquoted group already gives the OR. The only upstream use found is the 2017 query "(emissi* OR investerin*)"~30 in #8746 (LUCENE-7695), which the reporter called "works fine" after a crash fix; which documents it matched was not discussed.
  • Bounds. In a probe of 126 two-member lone-group shapes (OR, implicit and NOT forms over 4 terms and 3 prefixes, whitespace MockAnalyzer, 84 documents), the wrong hits always lay inside what the unquoted positive terms match, and a FILTER clause around the parsed query was honoured exactly (0 violations). Other syntax and analyzers were not probed for this.

Minimal repro

The test shows that ComplexPhraseQueryParser reads a phrase that is only one parenthesized group as a sequence: "(john OR jonathan)" becomes spanNear([john, jonathan], 0, true) instead of one position with alternatives.

TestComplexPhraseLoneGroup.java (JUnit test)49 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.queryparser.complexPhrase;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestComplexPhraseLoneGroup extends LuceneTestCase {

  public void testLoneOrGroup() throws Exception {
    Analyzer analyzer = new StandardAnalyzer();
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
        Document doc = new Document();
        doc.add(new TextField("name", "john smith", Field.Store.NO));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        ComplexPhraseQueryParser qp = new ComplexPhraseQueryParser("name", analyzer);
        // one position whose term is john or jonathan; actual: spanNear([john, jonathan]), 0 hits
        assertEquals(1, new IndexSearcher(reader).count(qp.parse("\"(john OR jonathan)\"")));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-714/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:$LUCENE/lucene/facet/build/libs/lucene-facet-$V.jar
CP=$CP:$LUCENE/lucene/queries/build/libs/lucene-queries-$V.jar
CP=$CP:$LUCENE/lucene/queryparser/build/libs/lucene-queryparser-$V.jar
CP=$CP:$LUCENE/lucene/sandbox/build/libs/lucene-sandbox-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.queryparser.complexPhrase.TestComplexPhraseLoneGroup
# the module(s) facet also need their third-party runtime jars
Expected vs actual
Expected 1 hit on the document john smith; actual 0 (expected:<1> but was:<0>), with -ea and -da; passes with the bug's fix (the group kept as one clause).

All files of this repro: repro/LFV-714/

More files, environment, full output34 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: expected:<1> but was:<0>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.junit.Assert.assertEquals(Assert.java:633)
       org.apache.lucene.queryparser.complexPhrase.TestComplexPhraseLoneGroup.testLoneOrGroup(TestComplexPhraseLoneGroup.java:45)

Why it looks unintended

  • The parser's own javadoc and tests treat a bracketed group as one position: "(john jon jonathan~) peters*", "(jo* -john) smith", "(john OR johathon) smith".
  • One-element phrases are supported ("john"), and nothing exempts a group that is the whole phrase.
  • The same text silently changes meaning when it stands alone, and dropping the quotes changes the NOT form's meaning too.
The full argument226 words
  • Class javadoc (ComplexPhraseQueryParser.java:47, also package-info.java:18): "QueryParser which permits complex phrase query syntax eg "(john jon jonathan~) peters*"".
  • Upstream tests (TestComplexPhraseQuery.java): :70 "(jo* -john) smith" gives doc 2 ("boolean logic works"); :74 "(john OR johathon) smith" gives docs 1 and 2 (comment "boolean logic with" / "brackets works" over :74-75); also :76 and :78.
  • One-element phrases are supported: :73 "john" ("Simple single-term still works") and testSingleTermPhrase (:92-99, from LUCENE-7614).
  • The original author's rule (LUCENE-1486, #2560, 2009): "OR,||,+, AND, && ..... ignored. The implicit operator is AND_NEXT_TO apart from in bracketed sections where all elements at this level are ORed", and "In brackets it's an OR - the brackets are used to suggest that the current phrase element at position X is composed of some choices".
  • Behaviour. The same characters (john OR jonathan) mean one position inside a longer phrase and a two-position sequence alone, with no error. The lone NOT group falls back to the top-level reading "jo* not followed by john" of the bracket-free "jo* -john"; the test file keeps top-level NOT inside a phrase commented out as unsupported (:81-82, "not logic doesn't work currently").
  • History. It came with the class in 2009; no later test or commit covers a phrase that is only a group, and no issue about it was found.
The devil's-advocate check266 words

The strongest case for "intended": outer brackets are transparent in the classic grammar, and the parser reuses that grammar on purpose ("working within the constraints of the existing parser as a base class", :56-59); the author said top-level operators inside a phrase are ignored; no doc or test shows a phrase that is only a group; the behaviour is 17 years old; and for a pure OR group the unquoted query already gives the OR.

Why it fails: that explains how the result arises, not a contract. The same author made bracketed sections the exception to the "next to" rule, the documented example and the tests rely on it, and nothing says it stops applying when the group stands alone. Dropping the quotes changes the NOT form's meaning, so it is no answer. The cross-model reviews and the adjudication rated the intended-behaviour argument weak; the devil's-advocate verifier was "moderately high" confident it is not intended; the final cross-model fact check called it a moderate case on whether this is a defect at all. Maintainers may still label the fix an improvement, as they labelled LUCENE-7614 (one-term phrases).

What still stands: compatibility. The sequence reading is 17 years old, so existing queries such as the #8746 one ("(emissi* OR investerin*)"~30) change meaning with a fix. The cross-model fact check also called the case that the practical impact is small a strong one (opt-in parser, rare shape, no report found, #16820 proposes deprecating the parser). That argues for a clear CHANGES entry, or a fix on 10.x only, rather than for the behaviour being intended.

Suggested fix

Recommended Keep a lone group as the single clause, with the single-clause guard

In parsePhraseElements, when the text between the quotes is one bracketed group (optionally followed directly by a boost), wrap the parsed group in a one-clause BooleanQuery so it stays one position; rewrite then returns a lone span clause instead of a one-clause SpanNearQuery, the sibling single-clause guard. Validated with gradle: the repro, TestComplexPhraseQuery and the whole queryparser module pass.

// parsePhraseElements, after contents[0] = qp.parse(phrasedQueryStringContents);
if (isSingleGroup(phrasedQueryStringContents)) {
  contents[0] =
      new BooleanQuery.Builder().add(contents[0], BooleanClause.Occur.SHOULD).build();
}
// rewrite(): unwrap a top-level boost; return a lone span clause as it is
while (contents instanceof BoostQuery boostQuery) {
  contents = boostQuery.getQuery();
}
if (allSpanClauses.length == 1) {
  return allSpanClauses[0];
}
All options and trade-offs5 options ยท 923 words

One fix was built and validated in a scratch worktree; the other options are untested. The fix contains the guard of the sibling single-clause bug in the same rewrite (a boosted, +-prefixed or stop-word phrase that reduces to one clause throws).

Keep a lone group as the single clause, with the single-clause guard (validated by the checks) (recommended)

ComplexPhraseQueryParser.java only, +40/-1. In parsePhraseElements, after parsing, isSingleGroup(phrasedQueryStringContents) checks that the stripped text starts with ( and that its matching ) (backslash escapes skipped) ends it, optionally followed directly (no space) by a boost ^<number>; then the parsed group is wrapped in a one-clause BooleanQuery, so rewrite sees one top-level clause, the group, and makes it one spanOr or spanNot position. In rewrite, a top-level BoostQuery is unwrapped and a lone span clause is returned as it is (a one-clause SpanNearQuery throws "Less than 2 subSpans" at search time).

// parsePhraseElements, after contents[0] = qp.parse(phrasedQueryStringContents);
if (isSingleGroup(phrasedQueryStringContents)) {
  contents[0] =
      new BooleanQuery.Builder().add(contents[0], BooleanClause.Occur.SHOULD).build();
}
// rewrite(): unwrap a top-level boost; return a lone span clause as it is
while (contents instanceof BoostQuery boostQuery) {
  contents = boostQuery.getQuery();
}
if (allSpanClauses.length == 1) {
  return allSpanClauses[0];
}

Trade-offs

  • Compatibility. "(john OR jonathan)"~2 changes from "john, then jonathan within 2 positions" to "john or jonathan" (slop does not apply to one position), which changes existing queries such as the #8746 one. The bracket-free "jo* -john" and "john OR jonathan" keep their sequence readings (users rely on "foo -bar" meaning "foo not followed by bar"), and so do the groups inside a longer phrase that were run ("(john OR jonathan) smith", "(jo* -john) smith", "(john OR jonathan) (smith OR walker)"), with the regexp exception below.
  • "(john OR jonathan)^2" and "+(jo* -john)" stop throwing and return the 4 documents (the sibling's shapes); the boost is dropped ("(john OR jonathan)^2" scores exactly like the unboosted group).
  • Query structure and scores change for some one-position shapes (BM25 probe): "(jo*)" gives the same hits but becomes a spanOr instead of a scoring BooleanQuery, with different scores and order (stock 0.643, 0.630, 0.398, 0.245, 0.245; fixed 1.750, then 1.273 for the other four). "(john)" keeps its hits and, in the probe, its scores, but is returned as a SpanTermQuery instead of the TermQuery fast path. "(-john)" keeps its hits (none), but rewrites to the parser's dummy SpanTermQuery that matches nothing. Scores were compared only for these probe shapes.
  • isSingleGroup is a character-level check of the raw text, wrong in three ways. A boost after a space is missed: "(john OR jonathan) ^2" is valid (the grammar skips whitespace before ^) but keeps the sequence reading with the fix, 1 hit. A bracket inside a regexp character class can hide a lone group: "(/a[(a]/ OR cc)" keeps today's reading. It can also join two groups: "(/a[(a]/) (/[)b]b/)" works on main (spanNear([aa, bb], 0, true), 1 of 5 test documents) and becomes one position with the fix (spanOr([aa, bb]), all 5), so a contrived phrase that works today breaks. The token-level check below avoids all three; a maintainer may also prefer a marker for the bracketed clause in the parsed result, through the grammar hook, over any check of the text.
  • The sibling's guard alone does not fix this bug: both lone-group tests still fail with only that guard.
  • Add a CHANGES entry.

What the checks validated

  • Clean scratch worktrees at a9779ef3c193, gradle, seed B71EF714. Stock: the repro fails 2 of 3 and the first new TestComplexPhraseQuery line fails (14 tests, 3 failed), with -Ptests.asserts=true and with the seed's default, which is assertions off. With the fix: the repro passes 3 of 3 and TestComplexPhraseQuery 11 of 11 with the two new lines, with -Ptests.asserts=true and with -Ptests.asserts=false. The whole lucene/queryparser module with the fix and the repro, with the seed's default (assertions off): 31 classes, 536 tests, 1 skipped, 0 failures. checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass with the fix, the repro and the two test lines in place.
  • With the fixed class shadowed into the stock jars: the repro passes 3 of 3; earlier, the hunt's repro, the independent reproducer's test and the second reviewer's test pass.
  • Executed edge cases of the character check (stock vs fix): the spaced boost (fix/probe-edge) and the two regexp-bracket phrases (the fact check's edge-stock and edge-fix runs), as listed under the trade-offs.

The same, with the parser's own tokenizer

Detect the lone group with QueryParserTokenManager (LPAREN, the matching RPAREN, an optional CARAT NUMBER, then EOF) instead of characters, so regexp and escaped terms are single tokens and whitespace before ^ does not matter.

Trade-offs

Same behaviour otherwise; not written or run.

A grammar hook for bracketed sub-queries

Give Clause() in QueryParser.jj a protected hook for a bracketed sub-query (the default returns it unchanged) that ComplexPhraseQueryParser overrides in pass 2 to keep the group as one clause.

Trade-offs

Exact, but it changes the shared classic grammar and needs the generated parser regenerated. Untested.

Reject a phrase that is only a group

Throw a ParseException that suggests dropping the quotes.

Trade-offs

No silent wrong hits, but it breaks queries that are only odd today, and the NOT form would have no query-string spelling left on stock main. Untested.

Documentation only

Document that a phrase that is only a group is read as a sequence of positions.

Trade-offs

Keeps the silent wrong hits and contradicts the bracket rule. Not recommended.

Downstream note

Downstream: Solr's complexphrase query parser subclasses this parser without overriding the second-pass parse, so it should inherit the behaviour (read in its source, not run).

How it was found

A bounded-exhaustive check ran every one- and two-clause phrase over small term sets through the real parser and compared the hits with a reference in which each clause or group is one position. The shape was first suspected while reading the grammar; the check then found 1,056 of 1,080 lone-group cases wrong and none inside longer phrases.

The method in full188 words

Method: bounded-exhaustive translation validation of the stock jar (semi-formal tier; no model checker, solver or prover).

  • The harness parses and runs every 1- and 2-clause phrase over 15 atoms (term, prefix, wildcard, fuzzy, range, regexp, empty prefix) and 90 groups ((x OR y), (x y), (x -y)), plus 3-clause phrases over 13 clauses, with slop 0-2, inOrder true and false and splitOnWhitespace false and true: 159,924 checks on an index of all 1,364 documents of 1-5 tokens over 4 terms.
  • The reference, built from the javadoc and the upstream tests, treats each clause as a set of index terms at one position.
  • The lone-group shape was first suspected while reading the grammar's Query() and Clause() for the model, and probed by hand. The harness then showed it as one signature: 1,056 of the 1,080 lone-group checks mismatch (516 with inOrder=false, 540 with inOrder=true; the rest coincide by accident) on main and on 10.5.2, while the same 90 groups as one clause of a two-clause phrase give 0 mismatches.
  • The harness's other signature (3-clause plain runs) is a different, separately registered classic-parser bug.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-27) in GitHub issues and PRs of Lucene, Solr and Lucene.NET, JIRA, the mailing lists, Stack Overflow or the web. Nearest: #2560, the original contribution, and #8746, the only upstream use of the shape. Open #16820 proposes deprecating this parser for Lucene 11.

Searches4 searches

Verdict new. A report that names neither the parser nor its syntax would not be found; JIRA's text search cannot search the word OR; the web tool throttled some queries (retried).

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; downstream repositories; dev@, java-user@, issues@, commits@; JIRA archive; the web2026-10-27the hunt's prior-report tool, four runs (45, 45, 51 and 51 queries, the last with 12 web queries): class and method names, brackets, groups, spanOr, "same position" and symptom terms39, 461, 228 and 258 distinct hits; the high ones (#2560, #6269, #8746) are not this bug
GitHub (apache/lucene; lucene-solr, solr, lucenenet; GitHub-wide)2026-10-2720 manual apache/lucene searches (group, alternatives, position, brackets, parentheses, spanNear, SpanNot, the file and test names, "(john OR) and 12 in other repositories; the file lists of all 409 open apache/lucene PRsonly known complexphrase issues with other mechanisms; no open PR lists a file under complexPhrase/ or QueryParser.jj (#16911 lists 3,000 files, the API cap, so its list may be incomplete)
JIRA (LUCENE, SOLR)2026-10-279 JQL queries (complexphrase, "complex phrase" with brackets, group, SpanNotQuery); 17 Solr issues read with commentsSOLR-1604 and others discuss groups followed by a term, never a lone group
Mailing lists, Stack Overflow, the web2026-10-278 list searches with 34 threads read; Stack Overflow API (264 questions scanned, 7 read); 12 web queriesusage questions only; Stack Overflow 36406573 states the group semantics, 48682884 shows users rely on "foo -bar"

History

Present since the class was added (f7fa5799719, 2009, LUCENE-1486); first release 2.9.0. Executed on 28 releases from 2.9.0 to 10.5.2 and on main: all affected. Not a regression.

Versions tested8 rows

Introduced with the class: f7fa5799719 (2009-07-06, "Initial commit of LUCENE-1486", by Mark Harwood (@markharwood); #2560; Subversion era, no PR). Its parsePhraseElements already parsed the raw phrase text, and its rewrite already built one SpanNearQuery position per top-level clause and the SpanNotQuery for negatives. The classic parser's unwrapping of a lone clause is older (ae45d392f8d, 2002). Later edits on these lines: the inOrder flag (LUCENE-3578 and LUCENE-5530, 2014), a feature for unordered phrases that keeps one position per clause (the harness finds the lone-group mismatch with both settings); and mechanical ones (immutable BooleanQuery 2015, reformatting 2021, pattern variables 2026). Other edits to rewrite added behaviour off this path: MatchNoDocsQuery handling (LUCENE-7337, 2016), the single-term early return (LUCENE-7614, 2017) and synonyms (LUCENE-7695, 2017). Not fixed upstream as of 20ced71c1e6 (2026-10-27).

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedrepro 2 of 3 fail, assertions on and off; also through gradle
10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 affectedthe hunt's JUnit fails 2 of 3 on each; this repro too on 10.5.2
9.12.3 affectedJUnit 2 of 3 fail (the hunt's repro and this one)
8.11.4 affecteda plain main() probe: both lone shapes wrong
5.0.0 to 7.7.3 (12 releases) OR form runthe lone OR group is read as a sequence; the NOT form was not run
2.9.0, 2.9.4, 3.0.0, 3.6.2, 4.0.0, 4.10.4 affectedAPI ports of the test: both lone shapes wrong, structurally the same rewritten queries as main (2.9.0 with assertions on and off)
2.4.1 no such parserthe parser does not exist yet (compile error)
fix, scratch worktree at a9779ef3c193 not affectedrepro 3 of 3, TestComplexPhraseQuery 11 of 11, queryparser module 536 tests, 0 failures

Independent checks

The finder's repro, an independent reproducer's own test, reviews by another model family (OpenAI GPT-5.6) and a devil's-advocate check all found it real, and an adjudication confirmed it. The registration check, an audit and this doc re-ran it on main with assertions on and off.

Checks and verdicts384 words

Every check below ran on main; the cross-model reviews ran on the previous main ea57360912ed, whose queryparser and spans code is identical.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateThe harness signature and a JUnit that fails 2 of 3 on main (on and off), 10.5.2 and 9.12.3; the fix sketch passes it and upstream TestComplexPhraseQuery.
finder's cross-model review (refute lens) real, highAn independent reviewer from another model family (OpenAI GPT-5.6): its own test fails on main and 10.5.2; the intended-behaviour argument is weak.
independent reproducer realOwn test with 7 documents: fails 2 of 5 on main (on and off), 10.5.2 and 9.12.3; a plain main() shows both shapes on 8.11.4; passes with the fix. Corrected the "matches nothing" wording.
intended-behaviour review real, highOpenAI GPT-5.6, intended lens: its own test fails 2 of 4 on main (on and off) and on 10.5.2, and passes with the fix.
devil's advocate realBuilt the strongest "intended" case and showed why it fails; every spelling of the lone group behaves the same; the sibling's guard alone does not fix it.
adjudication confirmed, highRe-ran the independent reproduction's test (2 of 5 fail, on and off) and the finder's repro; the fix passes 5 of 5.
security screen not security-sensitive126 lone-group shapes: wrong hits stay inside the unquoted terms' hits, and filter clauses are honoured.
registry re-run and audit keepRe-run on a clean main (2 of 3 fail, on and off; 14 of 14 pass with the fix); the audit re-read the root cause and the wording.
this doc's runs reproduced; fix validatedThe repro with five documents fails 2 of 3 on main (standalone runner, -ea and -da; gradle, assertions on and off), 10.5.2 and 9.12.3; with the fix, gradle passes the repro, TestComplexPhraseQuery and the whole queryparser module.
fact checks of this doc real; text correctedAn independent fact check re-ran the repro, the probe and gradle (assertions on and off), and an independent reviewer from another model family (OpenAI GPT-5.6) re-read the sources. Both confirmed the bug and the mechanism; their corrections (the assertion mode of the gradle runs, the limits of the fix's character check, scoped wording) are applied here.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,328 words

Finding record

LFV-714, snapshot of 2026-11-06

Registry title
ComplexPhraseQueryParser: a quoted phrase that is only one parenthesized group is read as a sequence of positions, so "(john OR jonathan)" matches only the sequence 'john jonathan' and "(jo* -john)" matches documents whose only jo* term is john
Area
queryparser / complexPhrase: ComplexPhraseQueryParser (ComplexPhraseQuery.parsePhraseElements second-pass parse, read by ComplexPhraseQuery.rewrite)
Priority
Low, rank 381: Needs a quoted phrase that is only one parenthesized group in the opt-in ComplexPhraseQueryParser.
Impact class
silent wrong results
Impact note
Hits applications that use the opt-in ComplexPhraseQueryParser, with default parser settings and any analyzer, when the whole text between the quotes is one parenthesized group, such as name:"(john OR jonathan)" or name:"(jo* -john)", typed by a user or generated by an application that wraps the alternatives for each position in quotes: the search returns wrong hits in both directions with no error (an OR group misses documents that hold only one alternative; a NOT group returns documents its own exclusion should drop, and that NOT form has no unquoted equivalent); no scale is needed, nothing is corrupted, and the same group followed by another phrase element works.
Summary
You parse "(john OR jonathan)" or "(jo* -john)" with ComplexPhraseQueryParser and search; inside a complex phrase a bracketed group is one position with alternatives (class javadoc example "(john jon jonathan~) peters*", upstream tests "(john OR johathon) smith" and "(jo* -john) smith") and one-element phrases are supported, so you expect the documents that contain john or jonathan, or a jo* term other than john. Instead the classic grammar drops parentheses around the whole input (QueryParser.jj:289-293 and :311), so the second pass gets the same BooleanQuery as for "john OR jonathan", and ComplexPhraseQuery.rewrite makes each member a consecutive position: "(john OR jonathan)" becomes spanNear([john, jonathan], 0, true) and matches only documents that contain the sequence 'john jonathan', and "(jo* -john)" becomes spanNot(spanOr(jo*), spanNear([spanOr(jo*), john])) and also matches 'john smith', whose only jo* term is john.
Introduced
f7fa57997191026f3bef51cb3c13edd031b5d781 (LUCENE-1486 = apache/lucene#2560 'Wildcards, ORs etc inside Phrase queries', svn r791579 'Initial commit of LUCENE-1486'; Subversion era, no pull request; author Mark Harwood (@markharwood), CHANGES.txt 2.9 credits 'Mark Harwood via Mark Miller'): the class's first commit already passed the raw phrase text to QueryParser.parse in parsePhraseElements and made every top-level clause of the result one SpanNearQuery position in rewrite (with a MUST_NOT clause, SpanNotQuery(positives, SpanNearQuery(all clauses))); the classic parser's unwrapping of a lone clause predates it (ae45d392f8d, 2002-01-17, 'don't wrap queries with BooleanQuery in query parser unless necessary'). Not a regression [read: git blame -w -M -C on main traces the rewrite loop, the SpanNear/SpanNot construction and the parsePhraseElements TODO to f7fa5799719; later edits on those lines are mechanical (inOrder flag e8c203b5453/bcedf1bba16 2014, immutable BooleanQuery 4aafacfbef4 2015, reformatting 2cbf261032d 2021, pattern variables dfd536186fe 2026); exec: the first release, 2.9.0, gives the same rewritten queries as main] (2009-07-06 (f7fa5799719); moved from core to contrib/misc by 5aaf5b01672 on 2009-08-03, before the 2.9.0 release)
First affected version
2.9.0 [exec] (the first release with ComplexPhraseQueryParser); 2.4.1 has no such API [exec: the probe does not compile and none of its release jars has a complexPhrase entry]
Affected versions
Every release that has ComplexPhraseQueryParser, 2.9.0 through 10.5.2, and main a9779ef3c193 (unreleased 11.0) [exec on 28 releases plus main: release-portable probes fail both lone-group checks on 2.9.0 (-ea and -da), 2.9.4, 3.0.0, 3.6.2, 4.0.0 and 4.10.4; the finder's probe (lone OR group only) fails on 5.0.0, 5.1.0, 5.2.0, 5.2.1, 5.3.0, 5.4.0, 5.5.5, 6.3.0, 6.4.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4 and 9.12.3; the independent reproduction's main() shows both shapes on 8.11.4; the candidate JUnit fails 2 of 3 on 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 and main (-ea and -da)]; the releases between executed ones are affected by reading (blame shows the code path unchanged). Not fixed: upstream main a95c754ee003 (2026-10-26) changes neither ComplexPhraseQueryParser.java nor QueryParser.jj, and no open PR touches complexPhrase/ or QueryParser.jj [read].
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (the prior-report record verdict new, searched 2026-10-27, read-only): four scripted prior-report searches (the finder's 45 queries, then 45, 51 and 51 plus 12 web queries over GitHub issues, PRs and commits, downstream repositories, the dev, java-user, issues and commits lists and the JIRA archive; 39, 461, 228 and 258 distinct hits, the only high hits #2560, #6269 and #8746, none about this bug), 20 manual apache/lucene issue and PR searches, 12 searches in lucene-solr, solr, lucenenet, two other downstream search engines and GitHub-wide, the file lists of all 409 open apache/lucene PRs (none touches complexPhrase/ or QueryParser.jj; #16911's list is capped at 3000 files), 9 JIRA JQL queries over LUCENE and SOLR with 17 Solr issues read with comments, 8 list searches with 34 threads read, Stack Overflow (264 questions scanned, 7 read) and 12 web queries. Nearest items, not reports of this bug: #2560 (LUCENE-1486, the original contribution, where the author states that a bracketed section is one position whose members are ORed), #8746 (LUCENE-7695, 2017, the only upstream use of the lone-group shape found: "(emissi* OR investerin*)"~30, called 'works fine', meaning it no longer threw; its matches were not discussed), #9712 (LUCENE-8666, zero positive clauses, NullPointerException; the sibling's partial overlap), #8665 (LUCENE-7614, a single quoted term), #10417 and #9541 (SynonymQuery inside a group), #13680 (one token analysed into two terms), #9369 (nested phrases), #6269 (LUCENE-5205, a separate span parser), SOLR-1604, and Stack Overflow 36406573 (which states the one-position group semantics) and 48682884 (users rely on the unbracketed "foo -bar" meaning 'foo not followed by bar'). Context: open #16820 (2026-10-12) proposes deprecating the complexPhrase parser and span queries for Lucene 11. Not fixed: upstream main a95c754ee003 (2026-10-26) changes neither file. Gaps: a report that names neither the parser nor its syntax would not be found, JIRA text search cannot search the word OR, and the web tool throttled some queries (retried).
Lucene commit tested
main a9779ef3c193 (2026-10-26) with assertions on and off, through gradle in clean scratch worktrees (-Ptests.asserts=true, and the seed's default, which is off) and through a standalone JUnit runner on the built jars (-ea, -da); the release jars of 28 releases from 2.9.0 to 10.5.2 (version ports of the test before 9.x); the fix in the same scratch worktree. Upstream main up to 20ced71c1e6 (2026-10-27) changes nothing under lucene/queryparser (read).

Lucene and upstream sources

Finding record
  • a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/complexPhrase/ComplexPhraseQueryParser.java:47 (class javadoc: 'complex phrase query syntax eg "(john jon jonathan~) peters*"'), :234-250 (parsePhraseElements: the TODO at :235-239, contents[0] = qp.parse(phrasedQueryStringContents) at :246), :258 (rewrite), :281 (one SpanNearQuery position per top-level clause), :333 (new SpanNearQuery(allSpanClauses, ...)), :349-358 (MUST_NOT: include = the positive clauses, exclude = SpanNearQuery over all clauses); lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.jj:289-293 (Query(): a lone unmodified clause is returned as is) and :311 (Clause(): '<LPAREN> q=Query(field) <RPAREN>' returns the inner query), generated QueryParser.java:336; lucene/queryparser/src/java/org/apache/lucene/queryparser/complexPhrase/package-info.java:18 (same example); lucene/queryparser/src/test/org/apache/lucene/queryparser/complexPhrase/TestComplexPhraseQuery.java:70 ('"(jo* -john) smith"' -> 2, 'boolean logic works'), :73 ('"john"' -> 1,3), :74 ('"(john OR johathon) smith"' -> 1,2), :76, :78
The finding in 30 seconds
  • a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/complexPhrase/ComplexPhraseQueryParser.java:234-250, 258-361, 371-420
  • a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.jj:264-295, 297-314; classic/QueryParser.java:336
Why it looks unintended
  • a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/complexPhrase/ComplexPhraseQueryParser.java:47, 56-59; complexPhrase/package-info.java:18
  • a Lucene main checkout lucene/queryparser/src/test/org/apache/lucene/queryparser/complexPhrase/TestComplexPhraseQuery.java:70-82, 92-99
Suggested fix
  • a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/classic/QueryParser.jj:191-193 (whitespace skipped in the DEFAULT state, so before ^)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made in parallel from an executed probe on main a9779ef3c193 and re-rendered after the fact checks; the PNG is the final summary frame.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.