"The wrong listing is first" is the hardest kind of bug report, because it has no stack trace and everyone in the room has an opinion. It is also, in Elasticsearch, one of the few bugs where you can get the actual arithmetic and settle it.
What a score is
Elasticsearch ranks with BM25, and for a single term in a single field it is three numbers multiplied together.
Term frequency (tf) — how often the term appears in this field of this document. More is better, with strongly diminishing returns.
Inverse document frequency (idf) — how rare the term is across the index. A term in 1 of 12 documents is a much stronger signal than one in 11 of 12. This is what makes "the" nearly worthless without configuring a stop-word list.
Field length — a term in a five-word title is worth more than the same term buried in a 500-word description.
Two things follow from that shape and are worth holding on to.
Scores are not comparable between queries. A score of 8 for one search and 3 for
another says nothing — they were computed over different terms with different rarities. There
is no scale, no maximum, and no "70% relevant". A percentage bar built from _score is
meaningless, and normalising by max_score only tells you how each hit compares to the
best one in that result set.
Scores are computed per shard. Term frequencies are local to a shard, so on a
multi-shard index the same document can score differently depending on where it landed. That is why
this track's index uses a single shard, and why
"search_type": "dfs_query_then_fetch" exists for when it matters.
Reading _explain
_explain takes one document and one query and returns the tree of arithmetic:
GET /stayhub-properties/_explain/8f1e-...
{ "query": { "match": { "title": "cabin" } } }4.3190 max of:
4.3190 weight(title:cabin in 1) [PerFieldSimilarity], result of:
4.3190 score(freq=1.0), computed as boost * idf * tf from:
4.4000 boost
2.1595 idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:
1.0000 n, number of documents containing term
12.0000 N, total number of documents with field
0.4545 tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:
1.0000 freq, occurrences of term within document
1.2000 k1, term saturation parameter
0.7500 b, length normalization parameter
5.0000 dl, length of field
5.0000 avgdl, average length of fieldEvery number in a relevance argument is in there. idf is 2.1595 because the term appears in 1 of 12 documents. tf is 0.4545 because the term appears once in a five-word title, and the title is exactly average length. Multiply through and the score is 4.3190.
Two parameters are visible and worth knowing by name. k1 controls saturation: how
quickly extra occurrences stop helping. b controls how much field length matters
— 0 ignores length entirely, 1 normalises fully. Both are tunable per field in the mapping,
and both are best left alone until you have exhausted the levers below, because they change every
score in the index at once.
The boost that is not the boost you wrote
The query was title^2, and _explain says the boost is
4.4.
Not a bug and not a typo. Lucene folds BM25's (k1 + 1) factor — 2.2 with the
default k1 of 1.2 — into the boost when it builds the query. So
2 × 2.2 = 4.4.
The practical consequence is that the absolute value of a boost means nothing. Only the
ratio between fields matters. city^3, title^2, description is the same
ranking as city^30, title^20, description^10. Nobody should be tuning a boost from 2.0
to 2.1.
What a field boost actually buys
Less than people assume, and the reason is the multi_match type.
TEXT_FIELDS = ["city^3", "title^2", "description", "state", "country"]The default type is best_fields, which scores each field independently and keeps
the best one — note the max of: at the top of the explain output
above. It does not add the fields together.
So a boost decides which field wins, not how much total score a document accumulates. A document matching in three fields does not beat one matching strongly in one.
tie_breaker softens that — it adds a fraction of the other fields' scores to
the winner. Values around 0.3 are usual. It only changes anything when more than one field matches,
which is worth knowing before you conclude it does nothing.
The bug best_fields caused
StayHub's search used one multi_match with operator: and and the default
type. On the seeded index:
query "san francisco loft"
best_fields -> 0 hits
cross_fields -> 1 hitA perfectly reasonable search returned nothing. Work it through:
best_fields scores each field separately, so operator: and means "every
term in one field". "san francisco" is in city; "loft" is in
title. No single field held all three terms.
Nobody had noticed because every test query was single-field.
cross_fields is the fix. It treats the listed fields as one big field for
term-matching, which is exactly the mental model a user has. And it cannot do
fuzziness:
400 parsing_exception
Fuzziness not allowed for type [cross_fields]A hard refusal, not a degradation — a fuzzy expansion has no stable per-term document frequency to blend across fields. So neither type alone is right, and the answer is both:
return {
"bool": {
"should": [
{
"multi_match": {
"query": req.q,
"fields": TEXT_FIELDS,
"type": "cross_fields",
"operator": "and",
}
},
{
"multi_match": {
"query": req.q,
"fields": TEXT_FIELDS,
"fuzziness": "AUTO",
"operator": "and",
}
},
],
"minimum_should_match": 1,
}
}cross_fields catches terms spread across fields, exactly. best_fields
with fuzziness catches typos within one field. minimum_should_match: 1 makes it an OR,
and a document matching both scores higher — which is the correct preference, since an exact
cross-field hit should beat a fuzzy one.
Verified after the change: "san francisco loft" finds the loft, and "cabbin" still finds cabins.
The other types, briefly
most_fields adds the per-field scores instead of taking the best. It is for the same
text analysed several ways — a field with a stemmer and a field without — where matching
in both genuinely is a stronger signal.
phrase and phrase_prefix are best_fields with the terms
required adjacent, the latter treating the last term as a prefix.
bool_prefix is the one to use with search_as_you_type fields.
Fuzziness
{ "match": { "title": { "query": "cabbin", "fuzziness": "AUTO" } } }Fuzziness is Levenshtein edit distance — how many single-character changes turn one term
into another. AUTO scales with term length: 0 edits for 1–2 characters, 1 edit up
to 5, 2 edits beyond. That scaling is the point. A fixed "fuzziness": 2 on a
three-letter word matches almost every three-letter word in the index.
Two things to know. Fuzzy matches score lower than exact ones, which is correct and means you rarely need to separate them. And fuzziness is expensive — it expands each term against the term dictionary — so it is not something to enable on every field of a large index without looking at the latency.
prefix_length is the usual mitigation: requiring the first one or two characters to
match exactly cuts the expansion enormously and costs almost nothing in recall, since people rarely
mistype the first letter.
Before you tune anything: check it is a relevance problem
Most "bad relevance" reports are not about ranking at all, and the tuning levers below will not touch them. Four checks, in order, and they take five minutes.
Is the document matching at all? Run _explain on it. If the answer
is "does not match" rather than a score, no amount of boosting will help — this is an
analysis or a query-type problem, and lesson 5's two-list comparison finds it.
Is it matching in the field you think? Name your clauses with
_name and read matched_queries on the hit. A document matching only on
description when you assumed title explains a low score immediately.
Is a filter excluding it? Remove the filters and re-run. A listing missing because its price is outside the range is not a relevance issue, however much it looks like one.
Is the field unusually short or long? BM25 divides by field length relative to the average. A one-word title scores strikingly high for its single term, which is why stub content sometimes outranks good content. That is a data problem with a data fix.
Only when all four come back clean is the ranking itself the thing to change.
function_score
Text relevance alone will rank a barely-reviewed listing above an excellent one, because BM25
knows nothing about ratings. function_score multiplies the text score by something
about the document:
fs = {
"function_score": {
"query": query,
"field_value_factor": {
"field": "rating_average",
"modifier": "log1p",
"factor": 2.0,
"missing": 1.0,
},
"boost_mode": "multiply",
}
}Three details, each of which is a mistake if you get it wrong.
"modifier": "log1p" rather than the raw value. Linear on a 0–5 rating makes
rating the dominant term and text merely a tie-break, which is the usual overcorrection. A log
compresses the top end so a 4.9 and a 4.7 are close, which is honest — they are close.
"missing": 1.0. Without it, a listing with no reviews scores zero and disappears
entirely. A new host would never get a first booking.
boost_mode decides how the function combines with the text score:
multiply (default), sum, replace, max,
min, avg. multiply preserves the relevance ordering within
similar ratings; replace throws relevance away entirely.
Decay functions
For "closer to this value is better" — a price near what the user was browsing, a date near today, a location near a map centre:
{ "gauss": { "price_per_night": { "origin": 150, "scale": 80, "decay": 0.5 } } }Score 1.0 at the origin, falling to decay (0.5) at scale away from it.
On the seeded index with boost_mode: replace:
0.9489 $128 Quiet Studio near Zilker Park
0.8481 $189 Cedar Cabin with Mountain Views
0.7792 $198 Modern Condo, Downtown Skyline
0.6328 $215 Sunlit Loft in the MissionA smooth preference rather than a cliff. gauss, linear and
exp differ in shape; gauss is the usual choice because it is flat near the
origin, so small differences close to the target barely matter.
This is much better than the instinct, which is to sort by distance from the value. Sorting discards relevance completely; a decay function keeps it and biases it.
Rescoring: expensive scoring on few documents
Some signals are too costly to compute for every match. rescore runs a second query
over only the top N per shard:
{
"query": { "match": { "title": "cabin" } },
"rescore": {
"window_size": 50,
"query": {
"rescore_query": { "match_phrase": { "title": { "query": "cedar cabin", "slop": 1 } } },
"query_weight": 1,
"rescore_query_weight": 3
}
}
}The cheap query finds and ranks everything; the expensive one reorders the top 50. Phrase matching is the classic use — "the exact phrase should win, but I cannot afford to check phrases across the whole index".
The trap is that window_size applies per shard and interacts with
pagination: rescoring the top 50 while serving page 10 does nothing, because page 10 is past the
window. Rescoring is for the first page or two, which is where it matters anyway.
Several functions at once
Real ranking usually blends more than one signal, and function_score takes a list
with a score_mode saying how they combine:
{ "function_score": {
"query": { "match": { "description": "mountain cabin" } },
"functions": [
{ "field_value_factor": { "field": "rating_average", "modifier": "log1p", "missing": 1 } },
{ "gauss": { "created_at": { "origin": "now", "scale": "180d", "decay": 0.5 } } },
{ "filter": { "term": { "amenities": "hot-tub" } }, "weight": 1.2 }
],
"score_mode": "multiply",
"boost_mode": "multiply",
"max_boost": 3
} }Rating, recency, and a flat boost for one amenity. Note the third form: a
filter plus a weight is how you say "these documents are worth a bit
more", and it is cheaper and clearer than a should clause doing the same job.
Two parameters keep it under control. score_mode combines the functions with each
other; boost_mode combines that result with the text score. And
max_boost caps the total, which matters because multiplying three unbounded functions
together produces occasional documents whose score has nothing to do with the query.
The discipline worth applying: add one function, check the judgment list, keep it or drop it,
then add the next. A function_score with five functions added at once cannot be
reasoned about, and removing one later is a guess.
Knowing whether you improved anything
Everything above changes the ranking. None of it tells you whether the ranking got better, and "it looks better to me" is how relevance work turns into an argument.
The cheap version: keep a list of queries with the documents that should be in their top few results — a judgment list — and check it after every change. Twenty queries collected from real logs is enough to catch the changes that make things dramatically worse, which is most of them.
Elasticsearch has an API for exactly this, _rank_eval, which takes the judgments and
returns standard metrics like precision at k and mean reciprocal rank. Its real value is not the
metric — it is that it makes the judgments a file in your repository rather than an opinion.
The honest version is user behaviour: click-through on the top result, how often people go to page two, how often they search again immediately. Slower and less comfortable, and the only thing that actually measures relevance.
A worked order of operations
When relevance genuinely needs work, this is the sequence that wastes the least time, roughly cheapest and most effective first.
One: fix the analysis. Accent folding, a stemmer on the right fields, a normalizer on the keyword ones. These change what can match, and no amount of ranking work compensates for a document that never matches. Lesson 5.
Two: fix the query type. cross_fields versus
best_fields, operator, minimum_should_match. This is where
the largest single improvements usually come from, and the StayHub bug above is the example —
that was not a tuning problem, it was zero results.
Three: set field boosts. Coarse ratios, not decimals. Title and name fields above body text, and an exact-match field above an analysed one if you have both.
Four: add fuzziness with a prefix_length, if typos are a real
source of empty results. Check the latency after.
Five: add business signals with function_score — rating,
recency, popularity. One at a time.
Six: rescore the top window for anything expensive, such as phrase proximity.
Most projects should stop after three. Steps five and six are where relevance work starts consuming time without obviously repaying it, and they are much easier to justify once you have the measurement in place.
What not to do
Do not tune boosts by feel, one query at a time. Every change helps the query in front of you and may hurt ten you are not looking at. That is what the judgment list is for.
Do not sort by a field to fix relevance. A sort discards
_score entirely — every carefully tuned signal, gone. If newer or better-rated
should count for more, that is function_score, not sort.
Do not reach for k1 and b early. They change every
score in the index and their effects are hard to predict. Field boosts, query types and
function_score first.
Do not add synonyms to fix one bad query. Each one broadens matching everywhere, and the cost is diffuse and shows up later.
Do not skip _explain. Almost every relevance surprise has a
mundane explanation — a field not being matched at all, an analyzer disagreement, a document
that is short so its terms are weighted heavily — and the tree tells you which in under a
minute.