Lesson 0003 · Relevance & Internals
How text becomes terms — and the silent bug where a query matches nothing.
BM25 sums a score over each query term. So answer from memory first: what actually decides what counts as a “term”? — Not the query. It's analysis: the pipeline that turns raw text into indexed tokens, run once at index time and again at search time. If those two runs disagree, the match silently fails. That failure is the payoff of this lesson.
An analyzer transforms a text value into a stream of terms (tokens) through exactly three stages, always in this order: — Analyzer reference
&→and. Zero or more.standard tokenizer breaks on word boundaries.The default standard analyzer is standard tokenizer + lowercase
filter. So the field value "The Quick-Brown Fox" becomes three terms:
thequickbrownfox
— lowercased, and the hyphen split into a boundary. Those are what land in the index. The
original string is gone (kept only in _source, which is never searched).
Analysis happens twice, and this is the crux:
text field is
analyzed and the resulting terms are written into the inverted index.match) hits that field,
the query string is analyzed the same way, and the resulting terms are looked up.Matching is term-against-term. By default the same analyzer runs on both sides, so they line up.
You can set a different search_analyzer (a real technique — e.g. index with an
edge-ngram analyzer for autocomplete, search with a plain one). But if the two pipelines ever produce
different tokens for the same word, lookups miss.
— Analyzer reference
text vs keyword: analyzed or notWhether a field is analyzed at all depends on its mapping type — the single most common source of “why didn't my query match” confusion: — Mapping reference
| Type | Analyzed? | Stored term(s) | Built for |
|---|---|---|---|
text | Yes — full pipeline | many tokens, e.g. thequick | full-text search (match) |
keyword | No — verbatim | one exact term The Quick-Brown Fox | exact match, sorting, aggregations |
A keyword field stores the whole value as one untokenized term,
byte-for-byte. No lowercasing, no splitting. That's why you sort and aggregate on keyword,
never on text. A dynamically-mapped string becomes both: a text field
plus a .keyword sub-field.
Now the interview-grade payoff. Two query types treat analysis oppositely:
match analyzes its input; term does not.
A term query looks up your string exactly as written against the stored terms.
So on a text field holding the document "Elasticsearch"
(indexed, after lowercasing, as the term elasticsearch):
GET /docs/_search
{ "query": { "term": { "title": "Elasticsearch" } } } // zero hits!
Zero hits — because term searches for the literal Elasticsearch
(capital E), but the index only contains elasticsearch. The query never got
lowercased, because term skips analysis. Swap to match (which analyzes
Elasticsearch → elasticsearch) and it matches.
The mirror-image mistake: running match against a keyword field. There the
stored term is the whole verbatim string, so a full-text match on a single word
inside it also misses. The fix is always the same diagnostic: ask what terms actually exist.
term on text → your query wasn't analyzed but the field was. ·
match on keyword → the field wasn't analyzed but your query was. Both come from a
pipeline mismatch between the two sides. See Lesson 0001:
term also runs in filter context (unscored), which is why exact-match filters use it.
From memory — effortful recall is the point. Feedback is immediate.
Which stage splits the text stream into individual tokens?
Compared to match, a term query:
term looks up the input verbatim against stored terms. match analyzes the query string first — the reason term on a text field so often misses.A keyword field stores its value as:
keyword is never analyzed — the whole value becomes one exact term, which is why it powers sorting, aggregations, and exact match.The term-mismatch trap fires when index and search analysis:
To see exactly which tokens a field will produce, call:
_analyze shows the token stream. _explain shows BM25 arithmetic (lesson 0002); _profile shows query timing.You have a live local cluster. Run these and watch analysis with your own eyes.
POST /_analyze
{ "analyzer": "standard", "text": "The Quick-Brown Fox" }
The response lists each token with its start_offset/end_offset. Confirm you
get thequickbrownfox
— lowercased and split on the hyphen. This is the ground truth of what's in your index.
PUT /lab_analysis/_doc/1
{ "title": "Elasticsearch" } // text field, dynamically mapped
GET /lab_analysis/_search
{ "query": { "term": { "title": "Elasticsearch" } } } // predict: hits?
GET /lab_analysis/_search
{ "query": { "match": { "title": "Elasticsearch" } } } // predict: hits?
Predict each before you run it. The term query returns nothing; match
returns doc 1. Explain the difference out loud in one sentence — that sentence is the interview answer.
.keyword sub-fieldGET /lab_analysis/_search
{ "query": { "term": { "title.keyword": "Elasticsearch" } } } // now it hits
Here term works: the .keyword sub-field stored the verbatim
Elasticsearch, so the exact string matches. Then
DELETE /lab_analysis to clean up. Bring any surprise to your teacher.
Text analysis — Analyzer reference (Elastic Docs).
The authoritative walkthrough of char filters → tokenizer → token filters, the built-in analyzers,
and how to configure a custom one. Pair it with the _analyze API doc for hands-on probing.