Relevance Scoring & Full-Text Search Done Right

Elasticsearch / OpenSearch

Chapter 6 · Relevance Scoring & Full-Text Search Done Right

This is the chapter that formally closes the loop postgres1-6 deliberately left open — a real, in-depth comparison against a purpose-built relevance-ranking algorithm, not just a named category.

The Question Every Search Engine Has to Answer

Given multiple documents that all match a query, how does the engine decide which are the best matches, and in what order to return them? This is exactly the question search1-4's own postings-list frequency and position data was collected for, and exactly what search1-5's own must-vs-filter scoring distinction was building toward.

TF-IDF — The Classic Foundation

Term Frequency (TF): how many times does the search term appear in this document — a document mentioning "wireless" five times is, all else equal, probably more relevant to a "wireless" search than one mentioning it once.

Inverse Document Frequency (IDF): how rare is this term across the whole index — a term appearing in nearly every document tells you less about relevance than a term appearing in only a few. A document matching a rare term is weighted more heavily than one matching a common term.

Combining the two: a TF-IDF score weights a document's own term frequency down for common terms and up for rare ones — the classic, foundational formula underlying modern information retrieval.

BM25 — The Modern Default

BM25 (Best Match 25) has been Elasticsearch/OpenSearch's own actual default scoring algorithm since version 5 — built on the same underlying TF/IDF ideas, with two real, meaningful refinements:

  • Term frequency saturation — repeating a term more and more within a document gives diminishing returns to the score, rather than scaling linearly forever. A document that says "wireless" 50 times isn't 50 times as relevant as one that says it once — BM25 accounts for this.
  • Field length normalization — a term match in a genuinely short document or field is weighted more heavily than the identical match in a very long one, since a short document mentioning a term is proportionally more "about" that term.

Formally Delivering on postgres1-6's Own Deferred Comparison

postgres1-6 covered ts_rank() as Postgres's own relevance-ranking function, and explicitly deferred a full comparison to this exact course. Here it is, honestly: ts_rank() does incorporate real signals — term-frequency-adjusted weighting via setweight(), some proximity awareness — but it's a genuinely simpler, less sophisticated ranking function than BM25. It doesn't implement the same level of length normalization or frequency saturation refinement, and Postgres's own full-text search generally wasn't designed to be tuned to the same degree. This isn't "Postgres's own version is broken" — it's a genuine, honest "smaller-scale approximation vs. purpose-built, heavily-refined algorithm" difference, matching this course's own consistent pattern of fair, not dismissive, comparisons.

Explaining a Score — The explain API

A genuinely useful, real practical tool: an _explain endpoint shows exactly how a document's own relevance score was computed, term by term — useful for debugging "why did this result rank above that one" in a real, concrete, inspectable way, rather than treating scoring as an opaque black box.

Boosting — Deliberately Tuning Relevance

Fields or query clauses can be given an explicit "boost" multiplier, deliberately increasing their own contribution to the final score — weighting a product's name field higher than its description field, for instance. This is directly comparable in spirit to postgres1-6's own setweight() material (title vs. body weighting).

Scores aren't comparable across different queries
Relevance scores are not stable, portable numbers — a score of 5.2 from one query means nothing compared to a score of 3.8 from a genuinely different query. Scores are only meaningful for ranking results within the same single query's own result set, not as an absolute, general "how good" a match is. This is a genuinely common, real source of confusion worth flagging explicitly.
postgres1-6's own deferred promise, closed
This formally closes the loop postgres1-6 left open. search1-7 covers aggregations next, resolving that same chapter's own "faceted search as a first-class feature" callout.

Hands-On Exercises

Exercise 1

Explain TF-IDF's own two components (term frequency and inverse document frequency) and explain why combining them produces a better relevance signal than term frequency alone.

📄 View solution
Exercise 2

Explain BM25's two real refinements over plain TF-IDF (term frequency saturation and field length normalization), each with a concrete example of the problem it solves.

📄 View solution
Exercise 3

Using this chapter's own honest comparison, explain specifically what postgres1-6's own ts_rank() function does provide, and what it doesn't provide compared to BM25 — why is this framed as "smaller-scale approximation" rather than "broken"?

📄 View solution

Chapter 6 Quick Reference

  • TF — how often a term appears in THIS document · IDF — how rare the term is across the whole index
  • BM25 — the modern default; adds term-frequency saturation (diminishing returns) and field-length normalization over plain TF-IDF
  • Honest comparison: postgres1-6's own ts_rank() provides real signals but is a smaller-scale approximation, not a broken version, of BM25
  • The _explain API shows exactly how a score was computed, term by term
  • Boosting echoes postgres1-6's own setweight() — deliberately weighting one field's contribution over another
  • Scores are only meaningful for ranking WITHIN one query's own results — never comparable across different queries
  • Next chapter: Aggregations — Analytics Built In