Lab QE1: QueryBuilder to Lucene Query
Background
The intensive drew the pipeline: JSON DSL → QueryBuilder tree
(via NamedXContentRegistry) → rewrite(QueryRewriteContext) → toQuery(QueryShardContext)
→ a Lucene Query. This lab makes that pipeline visible on a running cluster.
You will issue a bool/match query and use two APIs to see exactly what it
becomes: _validate/query?rewrite=true shows the rewritten Lucene query
string, and _search?profile=true shows the per-component timing of the
Weight/Scorer machinery. Then you will code-trace the OpenSearch classes that
do the work — MatchQueryBuilder.doToQuery calling the analyzer, and
BoolQueryBuilder mapping its clause lists onto BooleanClause.Occur — so the
API output stops being magic.
The single most important thing you will internalize: match analyzes at query
time using the field's search analyzer, term does not. That one fact explains
a large fraction of "why doesn't my query match anything" tickets.
Why This Matters for Contributors
When a user files "my query returns the wrong docs," the first question is which
representation is wrong — the parsed builder, the rewritten query, or the scoring.
_validate/query?rewrite=true collapses the first two into one inspectable string,
and profile=true opens up the third. A contributor who can read those two
outputs triages a query bug in minutes instead of guessing. And when you add a new
query type via SearchPlugin.getQueries(), the only way to verify your
doToQuery produced the Lucene query you intended is to read it back out of
_validate/query. This lab builds that reflex.
Prerequisites
-
A running OpenSearch (3.x). Quickest:
docker run -p 9200:9200 -e "discovery.type=single-node" -e "DISABLE_SECURITY_PLUGIN=true" opensearchproject/opensearch:latest— or./gradlew runfrom a source checkout (attaches a debugger on 5005 with--debug-jvm). -
curlandjq. -
An OpenSearch source checkout for the code-trace steps:
git clone https://github.com/opensearch-project/OpenSearch. - You've read the intensive and the deep-dive Query DSL and QueryBuilders.
Note:
DISABLE_SECURITY_PLUGIN=trueis for the lab only. On a real cluster the requests are identical; you just add credentials/TLS. "Cluster manager" (formerly master) is the elected coordinating-config node; none of this lab needs it beyond a healthy single-node cluster.
Step-by-Step Tasks
Step 1 — Create an index with a known analyzer
We want a text field (analyzed) and a keyword field (not), so the match vs
term contrast is unambiguous.
H='-H content-type:application/json'
curl -s -XDELETE localhost:9200/qe1 >/dev/null
curl -s -XPUT localhost:9200/qe1 $H -d '{
"settings": { "number_of_shards": 1, "number_of_replicas": 0 },
"mappings": { "properties": {
"title": { "type": "text", "analyzer": "standard" },
"status": { "type": "keyword" }
}}
}' | jq .
curl -s -XPOST 'localhost:9200/qe1/_bulk?refresh=true' $H --data-binary '
{"index":{"_id":1}}
{"title":"Open Source Search Engine","status":"published"}
{"index":{"_id":2}}
{"title":"open data and open formats","status":"draft"}
{"index":{"_id":3}}
{"title":"closed proprietary system","status":"published"}
' | jq '.errors'
number_of_shards: 1 keeps per-shard idf stable and makes the profile output a
single shard — easier to read.
Step 2 — See the parsed-and-rewritten Lucene query
_validate/query?rewrite=true&explain=true does parse + rewrite + toQuery and
hands you the Lucene string form without running the search:
curl -s 'localhost:9200/qe1/_validate/query?rewrite=true&explain=true' $H -d '{
"query": { "bool": {
"must": [ { "match": { "title": "Open Source" } } ],
"filter": [ { "term": { "status": "published" } } ]
}}
}' | jq -r '.explanations[0].explanation'
You should see something close to:
+(title:open title:source) #status:published
Read it carefully:
title:open title:source— thematchanalyzed "Open Source" with the standard analyzer: lowercased and split into two tokens, producing aBooleanQueryof twoSHOULDTermQuerys.- The leading
+(...)marks the whole match clause asMUST(required, scored). #status:published— the#prefix is Lucene's notation for aFILTERclause (required, not scored).publishedwas not analyzed becausestatusis akeyword.
Step 3 — Prove term on a text field is the classic bug
curl -s 'localhost:9200/qe1/_validate/query?rewrite=true&explain=true' $H -d '{
"query": { "term": { "title": "Open Source" } }
}' | jq -r '.explanations[0].explanation'
You get title:Open Source — a single term with a capital O and a space,
because term does no analysis. No indexed token equals "Open Source" (the
indexed tokens are open, source, lowercased and split), so this matches
nothing. Confirm:
curl -s 'localhost:9200/qe1/_search' $H -d '{"query":{"term":{"title":"Open Source"}}}' \
| jq '.hits.total.value' # -> 0
curl -s 'localhost:9200/qe1/_search' $H -d '{"query":{"match":{"title":"Open Source"}}}' \
| jq '.hits.total.value' # -> 2 (docs 1 and 2)
This is the index-time vs query-time analysis lesson: the indexed tokens were
produced by the analyzer at index time; match runs the same analyzer at query
time so the tokens line up; term skips analysis entirely and compares your raw
string to the stored tokens.
Step 4 — Profile the execution
Now run it for real with profile=true and inspect the Weight/Scorer
breakdown:
curl -s 'localhost:9200/qe1/_search?pretty' $H -d '{
"profile": true,
"query": { "bool": {
"must": [ { "match": { "title": "open source" } } ],
"filter": [ { "term": { "status": "published" } } ]
}}
}' > /tmp/qe1-profile.json
# The Lucene query type and the per-method timings:
jq '.profile.shards[0].searches[0].query[0]
| {type, description, time_ns: .time_in_nanos, breakdown}' /tmp/qe1-profile.json
You'll see a BooleanQuery at the top with children, and a breakdown object
with the exact phases from the intensive: create_weight, build_scorer,
next_doc, advance, score, match, set_min_competitive_score. Map them:
| Profile key | Intensive concept |
|---|---|
create_weight | Query.createWeight building the Weight |
build_scorer | Weight.scorerSupplier(...).get(...) per segment |
next_doc / advance | the DocIdSetIterator walking / leap-frogging |
score | Scorer.score() → BM25 |
match | TwoPhaseIterator.matches() confirmation |
set_min_competitive_score | the top-k threshold θ feeding skipping |
# The collector tree (TopScoreDocCollector etc.) is reported separately:
jq '.profile.shards[0].searches[0].collector' /tmp/qe1-profile.json
Step 5 — Code-trace MatchQueryBuilder.doToQuery
Now connect the API output to the source. In your OpenSearch checkout:
cd ~/src/OpenSearch
# Where match becomes a Lucene query, and where it grabs the analyzer:
grep -n "doToQuery\|MatchQuery\|getSearchAnalyzer\|MappedFieldType\|analyzer" \
server/src/main/java/org/opensearch/index/query/MatchQueryBuilder.java | head
# The heavy lifting is delegated to a MatchQuery / analyzer helper:
grep -rn "class MatchQuery\b\|createBooleanQuery\|createPhraseQuery\|Analyzer" \
server/src/main/java/org/opensearch/index/search/MatchQuery.java | head
Answer in your notes, from the code:
- Which
MappedFieldType/analyzer method supplies the analyzer? (Look for the field type's search analyzer — the same one the mapping declared.) - For a two-token input, which Lucene factory is called, and does it return a
BooleanQuery, aPhraseQuery, or aSynonymQuery? (It depends on positions and synonyms — trace the branch.)
Step 6 — Code-trace BoolQueryBuilder.doToQuery
grep -n "doToQuery\|Occur\|MUST\|SHOULD\|FILTER\|MUST_NOT\|minimumShouldMatch\|addBooleanClauses" \
server/src/main/java/org/opensearch/index/query/BoolQueryBuilder.java
Confirm the mapping you saw in _validate/query: the four DSL clause lists
(must, should, filter, must_not) each become BooleanClauses with the
matching BooleanClause.Occur, and minimum_should_match sets the
BooleanQuery.Builder.setMinimumNumberShouldMatch. The + and # in the Lucene
string are exactly MUST and FILTER.
Step 7 — (Optional) Attach a debugger to watch it live
If you ran from source with ./gradlew run --debug-jvm, attach your IDE to port
5005, set a breakpoint in MatchQueryBuilder.doToQuery and BoolQueryBuilder.doToQuery,
and re-issue the Step 4 query. Step through and watch the analyzer produce
[open, source] and the BooleanQuery.Builder accumulate clauses. There is no
substitute for seeing the tree assemble.
Deliverables
-
The
_validate/query?rewrite=trueoutput for theboolquery, with the+(title:open title:source) #status:publishedform annotated (which token came from analysis, which clause is the filter). -
The
term-on-textoutput (title:Open Source) plus thehits.totalof0vs thematchhits.totalof2, with a one-sentence explanation. -
The
profile=truebreakdownfor theBooleanQuery, with each phase mapped to aQuery/Weight/Scorerconcept. -
A reading-log artifact (a short markdown file) answering Step 5 and
Step 6: which analyzer method
MatchQueryBuilderuses, which Lucene factory a two-token match calls, and howBoolQueryBuildermaps clause lists toOccur.
Expected Output
# Step 2 _validate
+(title:open title:source) #status:published
# Step 3 term-on-text
title:Open Source
term total = 0
match total = 2
# Step 4 profile (abridged)
{
"type": "BooleanQuery",
"description": "+(title:open title:source) #status:published",
"time_ns": 412345,
"breakdown": {
"create_weight": 51000, "build_scorer": 90000,
"next_doc": 41000, "advance": 12000, "score": 33000,
"match": 0, "set_min_competitive_score": 1200, ...
}
}
Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
_validate returns valid:false | bad JSON or unknown query name | check the error; verify the query type is registered |
explanation is empty | forgot explain=true (and rewrite=true) | add both query params |
profile block missing | forgot "profile": true in the body | it's a body flag, not a URL param |
term match returns >0 unexpectedly | you queried a keyword, not the text field | re-check the field in the term clause |
| Lucene string has capital letters | keyword field or non-lowercasing analyzer | that's a mapping fact, not a bug — confirm intended |
| Two shards, scores differ | per-shard idf | use number_of_shards:1 for the lab, or dfs_query_then_fetch |
Stretch Goals
- Phrase vs boolean. Run
_validate/query?rewrite=trueon{"match_phrase":{"title":"open source"}}and compare tomatch. You'll see aPhraseQuery(title:"open source") — it reads positions, wherematchis a position-freeBooleanQuery. Tie back toIndexOptionsin The Inverted Index and Postings. - Watch a rewrite collapse a query. Validate
{"bool":{"must":[{"match_all":{}}]}}and a single-clausebool; observe Lucene simplifying it. - Custom analyzer surprise. Add an
analyzerwith astopfilter, re-index, and watchmatchon"the open source"dropthe— visible in the validate string. - Register-and-inspect. Skim
SearchModule.registerQueryand find one query type registered by a plugin (e.g., grep thek-NNplugin forgetQueries), then validate that query and read its Lucene form.
Coding Exercises
_validate/query and the profiler showed you what a builder becomes; these
exercises make you assert it in code. The canonical pattern is a JUnit test
extending AbstractQueryTestCase<YourBuilder> (or a hand-built
QueryShardContext) that calls doToQuery and inspects the returned Lucene
Query. Find the base with rg -l "class AbstractQueryTestCase" test/; find a
sibling test with rg -l "class MatchQueryBuilderTests|class BoolQueryBuilderTests" server/.
-
(warm-up) Assert
matchanalyzes andtermdoes not. Write a JUnit test that buildsnew MatchQueryBuilder("title", "Open Source"), callsdoToQuery(context)against atextfield with the standard analyzer, and asserts the result is aBooleanQueryof two lowercasedTermQuerys (title:open,title:source). Add a sibling assertion thatnew TermQueryBuilder("title", "Open Source")yields a single un-analyzedTermQuery. This is the Step 3 lesson as an executable contract. -
(core) Map every
boolclause to itsOccur. Write a test that builds aBoolQueryBuilderwith one clause in each ofmust/should/filter/must_not, callsdoToQuery, and asserts each resultingBooleanClausecarries the rightBooleanClause.Occur(MUST/SHOULD/FILTER/MUST_NOT) — the code form of the+/#markers you read in Step 2. Then setminimum_should_match(1)and assertgetMinimumNumberShouldMatch()on the builtBooleanQuery. -
(core) A rewrite test. Write a test that rewrites
bool { must: [ match_all ] }(callRewriteable.rewrite(...)or the builder'srewrite(QueryRewriteContext); find it withrg -n "rewrite\(QueryRewriteContext" server/.../index/query/) and asserts the tree collapses (e.g. a single-clauseboolsimplifies). Capture the before/after in the test as comments so the simplification is documented. -
(core)
match_phraseproduces aPhraseQuery. Write a test assertingnew MatchPhraseQueryBuilder("title", "open source").doToQuery(ctx)returns aPhraseQuery(positions), contrasting with the position-freeBooleanQueryfrom exercise 1. Tie it toIndexOptionsfrom The Inverted Index and Postings. -
(advanced) Advanced challenge — ship a tiny query type via
SearchPlugin.getQueries()and prove its Lucene form. Build a minimalSearchPluginregistering aQueryBuilder(the smallest real one: a thin wrapper whosedoToQueryreturns, say, aTermQueryor delegates to an existing builder — confirm theQuerySpecAPI withrg -n "getQueries|class QuerySpec" server/.../plugins/SearchPlugin.java). Register it, write anAbstractQueryTestCase-style serialization +doToQuerytest, build the plugin, then verify end to end by issuing your query through_validate/query?rewrite=trueand asserting the Lucene string matches what your test predicted. Deliverable: a registered query type whose unit test and live_validate/queryoutput agree.
Issues to Practice On
The query-translation skill you just built triages "wrong docs returned" tickets.
Practice on opensearch-project/OpenSearch.
| What to look for | How to list it |
|---|---|
| Search/query area | gh issue list --repo opensearch-project/OpenSearch --label "Search" --state open |
| Good first issues | gh issue list --repo opensearch-project/OpenSearch --label "good first issue" --state open |
| Query-DSL bugs | gh issue list --repo opensearch-project/OpenSearch --label "bug" --search "match OR term OR bool query in:title" --state open |
Labels drift; confirm with gh label list --repo opensearch-project/OpenSearch
(look for Search, Search:Query Capabilities, Search:Relevance).
Representative patterns. (1) "term/match on field X returns nothing / wrong
docs." — almost always an analysis mismatch (index-time vs query-time), exactly the
Step 3 bug; reproduce with _validate/query?rewrite=true, then locate the analyzer
fetch in MatchQuery/MappedFieldType. (2) "bool with minimum_should_match
behaves unexpectedly." — reproduce, trace BoolQueryBuilder.doToQuery to where it
sets setMinimumNumberShouldMatch, and add a test. Approach: reproduce → locate via
rg → fix → test → PR with CHANGELOG + DCO.
Planted-bug drill. In BoolQueryBuilder.doToQuery, swap the Occur for the
filter list to MUST (so a filter clause starts scoring). Rebuild and run the
bool query tests (rg -l "class BoolQueryBuilderTests" server/); watch the
Occur-mapping assertion go red — and note the subtler consequence that a previously
non-scored, cacheable clause now scores. Revert, then add an explicit assertion that
the filter list maps to Occur.FILTER (not MUST). That assertion is the
regression test for a class of "my filter suddenly affects scores" bugs.
Etiquette: claim the issue with a comment first, reproduce before fixing, and every PR needs a test + CHANGELOG entry + DCO
Signed-off-by(git commit -s). See community interaction and the prepare-a-PR lab.
Validation / Self-check
-
You can predict the
_validate/query?rewrite=truestring for anyboolofmatch/term/rangeclauses, including the+/#markers and which tokens analysis produced. -
You can explain index-time vs query-time analysis and why
matchmatches wheretermdoes not on atextfield. -
You can read a
profile=truebreakdownand name what each phase is doing inQuery/Weight/Scorerterms. -
You can point at the line in
MatchQueryBuilder/MatchQuerywhere the analyzer is fetched, and the line inBoolQueryBuilderwhere a clause list becomesBooleanClause.Occur.