Elasticsearch – Relevance Tuning That Is Not Guesswork

March 10, 202112 min readUpdated 8/23/2026

"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 field

Every 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 hit

A 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 Mission

A 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.