?

Day 0344 minIntro+60 XP

Query Processing & Relevance.

System Design Mastery Series — Building Blocks Week

6 stages13 code

watch it first · newThe whole lesson in a 17-minute animated lecture5 quiz checkpoints · ask the AI about any moment · captions, chapters and speed

Intro · 1 min

Introduction#

Yesterday we built a pipeline to keep our search index fresh. Documents flow from PostgreSQL through Kafka into Elasticsearch within seconds. But freshness is only half the battle.

The relevance problem

User searches: "apple"

Your index contains
  • Apple iPhone 14 Pro Max (electronics)
  • Apple MacBook Air M2 (electronics)
  • Fresh Organic Apples (grocery)
  • Apple Pie Recipe Book (books)
  • Apple Watch Series 8 (electronics)
  • Green Apple Shampoo (beauty)

Which results come first? How do we decide? What if the user wanted fruit?

The user sees 10 results. If the right one isn't there, they leave.

Today's Theme: "Finding the needle AND ranking the haystack"

We'll cover:

  • How search scoring actually works (BM25)
  • Query vs Filter: when each matters
  • Boosting strategies for business rules
  • Function score queries for custom ranking
  • Multi-match strategies
  • Handling "no results" gracefully
ExplainerThe whole lesson: query processing and relevance

"running shoes" matches 50,000 documents, so BM25 scores each one: rare terms weigh more (IDF), repeats saturate (k1 = 1.2) and long documents are normalized (b = 0.75), so the short review scores 8.07 against the 150-word guide's 2.91. What the user typed is scored in query context, while selected facets go in filter context: yes/no, never changing the score, and cached as bitsets (about 50 ms first, 1 ms after). Boosting starts with fields (name 3x, brand 2x), adds a phrase bonus, function_score signals (popularity, recency, featured) and an out-of-stock demotion to 20%; when a strict query returns 2 of a minimum 5 results, progressive relaxation drops filters, then tries fuzzy and any-term matching, and quality is measured by zero-result rate, CTR and MRR.

Part I · Stage 1 of 6 · 8 min

Part I: Understanding Search Scoring#

Chapter 1: How BM25 Works#

1.1 The Scoring Problem#

Why scoring matters

Without scoring

  • Query: "running shoes"
  • 50,000 documents match
  • Return in... what order?
  • Random? Insertion order? Alphabetical?

With scoring

  • Query: "running shoes"
  • 50,000 documents match
  • Each document gets a relevance score
  • Sort by score descending
  • Best matches first

Scoring transforms "find matches" into "find BEST matches"

Your turn · checkpoint 1not answered

50,000 matches: what should push a score up?

"running shoes" matches 50,000 products and the user sees 10. Design a score from scratch: what three things about the query words and the document should raise or lower it? And why shouldn't a word that appears 10 times count 10 times as much as one that appears once?

Think it through, or jot it down. Then compare.

One strong answer

  • How often the word appears in this document: more is better, but with diminishing returns, or anyone can stuff a description with a keyword.
  • How rare the word is across all documents: a word in 2% of documents says more than one in 90% ("the" says almost nothing).
  • How long the document is: a long document shouldn't win just because it contains more words, so scores are normalized by length against the average.
  • That's BM25: term frequency with saturation (k1), inverse document frequency, and length normalization (b).

1.2 BM25: The Industry Standard#

BM25 (Best Matching 25)

The algorithm behind Elasticsearch, Lucene, and most modern search engines.

Intuition
  • Words that appear more often in a document = more relevant
  • But diminishing returns (10 occurrences isn't 10x better than 1)
  • Rare words matter more than common words
  • Long documents shouldn't win just by containing more words (length normalization)
Formula (simplified)
text1 line
score(D, Q) = Σ IDF(qi) × (f(qi, D) × (k1 + 1)) / (f(qi, D) + k1 × (1 - b + b × |D|/avgdl))
Where
  • D — Document
  • Q — Query (set of terms qi)
  • f(qi, D) — Term frequency (how many times qi appears in D)
  • |D| — Document length
  • avgdl — Average document length
  • k1 — Term frequency saturation parameter (default 1.2)
  • b — Length normalization parameter (default 0.75)
  • IDF — Inverse Document Frequency
Your turn · checkpoint 2not answered

Estimate the IDFs before the code does

The corpus has 1,000,000 documents. "running" appears in 50,000 of them, "shoes" in 100,000 and "the" in 900,000. Using IDF = ln(1 + (N − n + 0.5) / (n + 0.5)), roughly what is each IDF, and what does that mean for a match on each word?

Think it through, or jot it down. Then compare.

One strong answer

  • "running": ln(1 + 950,000 / 50,000) = ln(20) ≈ 3.0.
  • "shoes": ln(1 + 900,000 / 100,000) = ln(10) ≈ 2.3.
  • "the": ln(1 + 100,000 / 900,000) ≈ ln(1.11) ≈ 0.1.
  • So a match on "running" is worth about 30% more than one on "shoes", and "the" adds almost nothing: rare words carry the ranking.

1.3 Breaking Down BM25 Components#

pythonBM25Params · 258 lines
# scoring/bm25_explained.py

"""
BM25 scoring explained with examples.

Understanding these components helps you tune relevance.
"""

import math
from dataclasses import dataclass
from typing import List, Dict


@dataclass
class BM25Params:
    """BM25 tuning parameters."""
    k1: float = 1.2   # Term frequency saturation
    b: float = 0.75   # Length normalization


class BM25Explainer:
    """
    Explains BM25 scoring step by step.
    """
    
    def __init__(self, params: BM25Params = None):
        self.params = params or BM25Params()
    
    def explain_idf(
        self,
        term: str,
        doc_freq: int,
        total_docs: int
    ) -> dict:
        """
        Explain Inverse Document Frequency.
        
        IDF measures how rare/common a term is across all documents.
        Rare terms get higher scores (more discriminating).
        
        Formula: log(1 + (N - n + 0.5) / (n + 0.5))
        Where N = total docs, n = docs containing term
        """
        
        idf = math.log(1 + (total_docs - doc_freq + 0.5) / (doc_freq + 0.5))
        
        return {
            "term": term,
            "docs_containing_term": doc_freq,
            "total_docs": total_docs,
            "idf_score": round(idf, 4),
            "interpretation": self._interpret_idf(idf, doc_freq, total_docs)
        }
    
    def _interpret_idf(self, idf: float, doc_freq: int, total_docs: int) -> str:
        pct = (doc_freq / total_docs) * 100
        
        if pct > 50:
            return f"Very common term ({pct:.1f}% of docs). Low discriminating power."
        elif pct >= 10:
            return f"Common term ({pct:.1f}% of docs). Moderate discriminating power."
        elif pct > 1:
            return f"Uncommon term ({pct:.1f}% of docs). Good discriminating power."
        else:
            return f"Rare term ({pct:.2f}% of docs). High discriminating power."
    
    def explain_tf_component(
        self,
        term_freq: int,
        doc_length: int,
        avg_doc_length: float
    ) -> dict:
        """
        Explain term frequency component.
        
        More occurrences = higher score, but with saturation.
        Longer documents are normalized.
        """
        
        k1 = self.params.k1
        b = self.params.b
        
        # Length normalization factor
        length_norm = 1 - b + b * (doc_length / avg_doc_length)
        
        # TF saturation
        tf_component = (term_freq * (k1 + 1)) / (term_freq + k1 * length_norm)
        
        return {
            "term_frequency": term_freq,
            "doc_length": doc_length,
            "avg_doc_length": avg_doc_length,
            "length_normalization": round(length_norm, 4),
            "tf_component": round(tf_component, 4),
            "interpretation": self._interpret_tf(term_freq, tf_component)
        }
    
    def _interpret_tf(self, term_freq: int, tf_component: float) -> str:
        if term_freq == 0:
            return "Term not in document. No contribution."
        elif term_freq == 1:
            return f"Single occurrence. Base contribution: {tf_component:.2f}"
        elif term_freq < 5:
            return f"{term_freq} occurrences. Moderate boost: {tf_component:.2f}"
        else:
            return f"{term_freq} occurrences. Saturating (diminishing returns): {tf_component:.2f}"
    
    def score_document(
        self,
        query_terms: List[str],
        document: Dict[str, int],  # term -> frequency
        doc_length: int,
        corpus_stats: dict
    ) -> dict:
        """
        Calculate full BM25 score with explanation.
        """
        
        total_docs = corpus_stats["total_docs"]
        avg_length = corpus_stats["avg_length"]
        doc_freqs = corpus_stats["doc_freqs"]  # term -> docs containing it
        
        term_scores = []
        total_score = 0.0
        
        for term in query_terms:
            term_freq = document.get(term, 0)
            doc_freq = doc_freqs.get(term, 0)
            
            # Calculate IDF
            if doc_freq == 0:
                idf = 0
            else:
                idf = math.log(1 + (total_docs - doc_freq + 0.5) / (doc_freq + 0.5))
            
            # Calculate TF component
            if term_freq == 0:
                tf_component = 0
            else:
                length_norm = 1 - self.params.b + self.params.b * (doc_length / avg_length)
                tf_component = (term_freq * (self.params.k1 + 1)) / (term_freq + self.params.k1 * length_norm)
            
            # Term score
            term_score = idf * tf_component
            total_score += term_score
            
            term_scores.append({
                "term": term,
                "idf": round(idf, 4),
                "tf_component": round(tf_component, 4),
                "term_score": round(term_score, 4)
            })
        
        return {
            "total_score": round(total_score, 4),
            "term_breakdown": term_scores
        }


# =============================================================================
# Example: Understanding Scoring
# =============================================================================

def demonstrate_scoring():
    """Show how BM25 scoring works in practice."""
    
    explainer = BM25Explainer()
    
    # Corpus statistics
    corpus = {
        "total_docs": 1_000_000,
        "avg_length": 50,  # words
        "doc_freqs": {
            "running": 50_000,      # 5% of docs
            "shoes": 100_000,       # 10% of docs
            "nike": 20_000,         # 2% of docs
            "professional": 30_000, # 3% of docs
            "the": 900_000,         # 90% of docs (stop word)
        }
    }
    
    print("=== IDF COMPARISON ===\n")
    
    for term, doc_freq in corpus["doc_freqs"].items():
        result = explainer.explain_idf(term, doc_freq, corpus["total_docs"])
        print(f"Term: '{term}'")
        print(f"  IDF: {result['idf_score']}")
        print(f"  {result['interpretation']}\n")
    
    print("\n=== DOCUMENT SCORING ===\n")
    
    # Score two documents for query "running shoes"
    query = ["running", "shoes"]
    
    doc1 = {
        "content": {"running": 3, "shoes": 2, "nike": 1},
        "length": 45,
        "title": "Nike Running Shoes Review"
    }
    
    doc2 = {
        "content": {"running": 1, "shoes": 1, "professional": 2, "the": 5},
        "length": 150,
        "title": "Professional Running Shoes Guide: The Complete Resource"
    }
    
    for i, doc in enumerate([doc1, doc2], 1):
        result = explainer.score_document(
            query,
            doc["content"],
            doc["length"],
            corpus
        )
        
        print(f"Document {i}: '{doc['title']}'")
        print(f"  Total Score: {result['total_score']}")
        print(f"  Breakdown:")
        for term_score in result["term_breakdown"]:
            print(f"    '{term_score['term']}': IDF={term_score['idf']} × TF={term_score['tf_component']} = {term_score['term_score']}")
        print()


# Output:
# === IDF COMPARISON ===
# 
# Term: 'running'
#   IDF: 2.9957
#   Uncommon term (5.0% of docs). Good discriminating power.
# 
# Term: 'shoes'
#   IDF: 2.3026
#   Common term (10.0% of docs). Moderate discriminating power.
# 
# Term: 'nike'
#   IDF: 3.912
#   Uncommon term (2.0% of docs). Good discriminating power.
# 
# Term: 'professional'
#   IDF: 3.5065
#   Uncommon term (3.0% of docs). Good discriminating power.
# 
# Term: 'the'
#   IDF: 0.1054
#   Very common term (90.0% of docs). Low discriminating power.
#
# === DOCUMENT SCORING ===
#
# Document 1: 'Nike Running Shoes Review'
#   Total Score: 8.0683
#   Breakdown:
#     'running': IDF=2.9957 × TF=1.6058 = 4.8107
#     'shoes': IDF=2.3026 × TF=1.4148 = 3.2577
#
# Document 2: 'Professional Running Shoes Guide: The Complete Resource'
#   Total Score: 2.9141
#   Breakdown:
#     'running': IDF=2.9957 × TF=0.55 = 1.6476
#     'shoes': IDF=2.3026 × TF=0.55 = 1.2664
ExplainerBM25: IDF, TF saturation, length normalization

For "running shoes" over 1M documents averaging 50 words: IDF is 2.9957 for running (5% of docs) and 2.3026 for shoes (10%). Doc 1 (45 words, running ×3, shoes ×2) gets saturated TFs of 1.6058 and 1.4148 and scores 8.0683; Doc 2 (150 words, once each) is length-normalized to TF 0.55 and scores 2.9141.

1.4 Key Insights for Tuning#

BM25 tuning insights
Parameter: k1 (default 1.2)
  • Controls term frequency saturation
  • Higher k1 → More weight to term frequency
  • Lower k1 → Faster saturation (1 occurrence ≈ 5 occurrences)
  • For short fields (product names): Try k1 = 0.5-1.0
  • For long fields (descriptions): Keep k1 = 1.2-2.0
Parameter: b (default 0.75)
  • Controls length normalization
  • b = 0 → No length normalization (long docs not penalized)
  • b = 1 → Full length normalization
  • For variable length content: Keep b = 0.75
  • For fixed length content: Try b = 0.3-0.5
Field-level tuning in Elasticsearch
json19 lines
{
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "similarity": "custom_bm25"
      }
    }
  },
  "settings": {
    "similarity": {
      "custom_bm25": {
        "type": "BM25",
        "k1": 0.8,
        "b": 0.4
      }
    }
  }
}
Quick check3 questions · +5 XP each

BM25 scoring

  1. Q1/3 The query is "nike running shoes". "shoes" appears in 10% of documents, "nike" in 2%. Under BM25, which term counts for more when it matches?

    Answer & explanation

    Answer: A

    IDF rewards terms that pick out fewer documents. A term in 2% of the corpus says more about relevance than one in 10%, so it gets more weight.

  2. Q2/3 A product description repeats "waterproof" 20 times to game ranking. Why doesn't BM25 give it 20x the score of a single mention?

    Answer & explanation

    Answer: C

    BM25's TF component has diminishing returns: 10 occurrences aren't 10x better than 1, and k1 controls how fast it levels off. IDF is a corpus-wide statistic and doesn't change per document.

  3. Q3/3 Your product names are short and about the same length. Which tuning does the lesson suggest for that field?

    Answer & explanation

    Answer: B

    In short fields, repeating a word means little, so faster saturation (lower k1) fits; when lengths barely vary, there's little to normalize, so lower b fits too. Higher values suit long, variable text like descriptions.

Part II · Stage 2 of 6 · 5 min

Part II: Query vs Filter#

Chapter 2: Performance and Scoring Differences#

Your turn · checkpoint 3not answered

Scored or not scored?

A search for "running shoes" comes with three facet selections: category footwear, brand Nike, price up to $100. You could put all four conditions in the scored part of the query, or only some of them. What do you gain and lose each way?

Think it through, or jot it down. Then compare.

One strong answer

  • All four scored: results still come back, but "footwear" and "Nike" now push scores around without saying anything about relevance, every condition is recomputed on every request, and nothing is cached.
  • Text scored, selections as filters: only what the user typed shapes the ranking; the selections are yes/no checks that just shrink the set.
  • The bonus: filters skip scoring and, once repeated, their matching documents are cached and reused by later queries, even ones with different search text.

2.1 The Fundamental Difference#

Query vs filter

Query context

  • "How well does this document match?"
  • Calculates relevance score
  • Results sorted by score
  • More expensive (scoring overhead)
  • Use for: search terms, relevance

Filter context

  • "Does this document match? Yes/No"
  • No score calculation (binary)
  • Results in arbitrary order
  • Cheaper and cacheable
  • Use for: exact values, ranges, booleans

Combined (best practice)

  • QUERY: What the user typed (full-text)
  • FILTER: What the user selected (facets, toggles)
  • Score only calculated for query, filter just reduces set

2.2 Implementation#

pythonSearchQueryBuilder · 119 lines
# queries/query_vs_filter.py

"""
Proper use of query vs filter context.
"""


class SearchQueryBuilder:
    """
    Builds Elasticsearch queries with proper query/filter separation.
    """
    
    def build_search_query(
        self,
        search_text: str,
        filters: dict
    ) -> dict:
        """
        Build query with proper context separation.
        
        Query context: Full-text search (scored)
        Filter context: Exact matches, ranges (not scored, cached)
        """
        
        must_queries = []
        filter_clauses = []
        
        # Full-text search goes in QUERY context (scored)
        if search_text:
            must_queries.append({
                "multi_match": {
                    "query": search_text,
                    "fields": ["name^3", "brand^2", "description"],
                    "type": "best_fields"
                }
            })
        
        # Filters go in FILTER context (not scored, cacheable)
        if filters.get("category"):
            filter_clauses.append({
                "term": {"category": filters["category"]}
            })
        
        if filters.get("brand"):
            brands = filters["brand"]
            if isinstance(brands, list):
                filter_clauses.append({
                    "terms": {"brand.keyword": brands}
                })
            else:
                filter_clauses.append({
                    "term": {"brand.keyword": brands}
                })
        
        if filters.get("price_min") or filters.get("price_max"):
            price_range = {}
            if filters.get("price_min"):
                price_range["gte"] = filters["price_min"]
            if filters.get("price_max"):
                price_range["lte"] = filters["price_max"]
            filter_clauses.append({
                "range": {"price": price_range}
            })
        
        if filters.get("in_stock") is not None:
            filter_clauses.append({
                "term": {"in_stock": filters["in_stock"]}
            })
        
        if filters.get("rating_min"):
            filter_clauses.append({
                "range": {"rating": {"gte": filters["rating_min"]}}
            })
        
        # Build the bool query
        bool_query = {}
        
        if must_queries:
            bool_query["must"] = must_queries
        else:
            # No search text - match all, rely on filters
            bool_query["must"] = [{"match_all": {}}]
        
        if filter_clauses:
            bool_query["filter"] = filter_clauses
        
        return {"bool": bool_query}
    
    def explain_query(self, query: dict) -> str:
        """Explain what the query does."""
        
        explanation = []
        bool_query = query.get("bool", {})
        
        # Query context
        must = bool_query.get("must", [])
        for clause in must:
            if "multi_match" in clause:
                explanation.append(
                    f"SCORED: Search for '{clause['multi_match']['query']}' "
                    f"in fields {clause['multi_match']['fields']}"
                )
            elif "match_all" in clause:
                explanation.append("SCORED: Match all documents (no search text)")
        
        # Filter context
        filters = bool_query.get("filter", [])
        for clause in filters:
            if "term" in clause:
                field, value = list(clause["term"].items())[0]
                explanation.append(f"FILTER: {field} = {value}")
            elif "terms" in clause:
                field, values = list(clause["terms"].items())[0]
                explanation.append(f"FILTER: {field} in {values}")
            elif "range" in clause:
                field, bounds = list(clause["range"].items())[0]
                explanation.append(f"FILTER: {field} range {bounds}")
        
        return "\n".join(explanation)

Scenario: 1 million products, query "running shoes" with filters.

Query vs filter performance

Approach 1: everything in query context (BAD)

jsonc12 lines
{
  "query": {
    "bool": {
      "must": [
        {"match": {"name": "running shoes"}},
        {"match": {"category": "footwear"}},     // Should be filter!
        {"match": {"brand": "Nike"}},             // Should be filter!
        {"range": {"price": {"lte": 100}}}        // Should be filter!
      ]
    }
  }
}

Problems:

  • Scores calculated for category, brand, price matches
  • These scores don't help relevance
  • Filters not cacheable (recalculated each time)
  • ~3x slower than proper approach

Approach 2: proper query/filter separation (GOOD)

jsonc14 lines
{
  "query": {
    "bool": {
      "must": [
        {"match": {"name": "running shoes"}}      // Scored - user intent
      ],
      "filter": [
        {"term": {"category": "footwear"}},       // Not scored
        {"term": {"brand.keyword": "Nike"}},      // Not scored
        {"range": {"price": {"lte": 100}}}        // Not scored
      ]
    }
  }
}

Benefits:

  • Only search text affects scoring
  • Frequently repeated filters get cached by Elasticsearch
  • Later queries with the same filters reuse the bitset
  • ~3x faster, better relevance

2.3 Filter Caching#

Filter cache in Elasticsearch
How it works
  • Lucene tracks how often each filter is used; once it recurs (2+ uses for range/terms, ~5 for others) it is cached
  • Cache stores: a bitset of matching docs, per segment (large segments only)
  • Later queries with the same filter: use the cached bitset
  • Dramatically faster for repeated filters
Cached automatically (once frequent)
  • terms queries
  • range queries
  • bool combinations of filters
  • (a single term or exists query is never cached: it's already fast from the inverted index)
NOT cached
  • Queries in query context
  • Script queries
  • Geo queries (usually)
  • Nested queries
Cache efficiency example

Filter: {"range": {"price": {"lte": 100}}}

Before caching Once cached
Evaluate price ≤ 100 across the index Load cached bitset
Find 100,000 matching document IDs Intersect with other filters
Store in bitset: [1,0,1,1,0,0,1,1,...]
Time: 50ms Time: 1ms

This is why filters are so important for faceted search!

WorksheetWhat one cached filter is worth (load is an assumption)
Value
Products (the lesson's scenario) 1 million
price ≤ 100, evaluated without the cache (the lesson's example) 50 ms per query
Same filter from the cached bitset (the lesson's example) 1 ms per query
Searches using that filter (assumed) 200 a second
Work without the cache (assumed all CPU) 200 × 50 ms = 10 s of work a second, ~10 busy cores
Work with the cache 200 × 1 ms = 0.2 of a core
Bitset size at 1 bit per document 1M bits ≈ 125 KB

Conclusion: about 125 KB of memory saves roughly 10 cores for one popular filter. Put the same condition in query context and it's recomputed and scored on every request, so facet selections belong in filter.

Quick check2 questions · +5 XP each

Query vs filter

  1. Q1/2 A teammate puts category, brand and the price range inside must alongside the text query. Results come back, but what goes wrong?

    Answer & explanation

    Answer: D

    In query context every clause adds to the score, so "footwear" shifts the ranking without helping relevance, and none of it is cached. As filters they're yes/no checks whose bitsets get reused, which the lesson puts at about 3x faster.

  2. Q2/2 Why does a frequently repeated filter like {"range": {"price": {"lte": 100}}} become almost free?

    Answer & explanation

    Answer: B

    Once the filter recurs, Elasticsearch computes which documents match and stores them as a bitset (about 50ms); later requests load and intersect it (about 1ms). It caches per filter, not the full response, so different search text still benefits.

Part III · Stage 3 of 6 · 8 min

Part III: Boosting Strategies#

Chapter 3: Controlling Relevance#

3.1 Field Boosting#

pythonBoostingQueryBuilder · 155 lines
# queries/boosting.py

"""
Boosting strategies to control relevance.
"""


class BoostingQueryBuilder:
    """
    Builds queries with various boosting strategies.
    """
    
    def field_boosting(self, search_text: str) -> dict:
        """
        Boost certain fields over others.
        
        Match in product name is more relevant than description.
        """
        
        return {
            "multi_match": {
                "query": search_text,
                "fields": [
                    "name^3",         # 3x boost for name matches
                    "brand^2",        # 2x boost for brand matches
                    "category^1.5",   # 1.5x boost for category
                    "description",    # Default 1x
                    "tags^1.2"        # Slight boost for tags
                ],
                "type": "best_fields",
                "tie_breaker": 0.3    # Some credit for matches in multiple fields
            }
        }
    
    def phrase_boosting(self, search_text: str) -> dict:
        """
        Boost exact phrase matches.
        
        "running shoes" as a phrase scores higher than
        documents with "running" and "shoes" separately.
        """
        
        return {
            "bool": {
                "must": [
                    {
                        "multi_match": {
                            "query": search_text,
                            "fields": ["name^3", "description"],
                            "type": "best_fields"
                        }
                    }
                ],
                "should": [
                    {
                        "match_phrase": {
                            "name": {
                                "query": search_text,
                                "boost": 2.0,  # 2x boost for exact phrase
                                "slop": 1      # Allow 1 word between
                            }
                        }
                    },
                    {
                        "match_phrase": {
                            "description": {
                                "query": search_text,
                                "boost": 1.5,
                                "slop": 2
                            }
                        }
                    }
                ]
            }
        }
    
    def negative_boosting(self, search_text: str) -> dict:
        """
        Demote certain documents.
        
        Out of stock items pushed down, not removed.
        """
        
        return {
            "boosting": {
                "positive": {
                    "multi_match": {
                        "query": search_text,
                        "fields": ["name^3", "description"]
                    }
                },
                "negative": {
                    "term": {"in_stock": False}
                },
                "negative_boost": 0.2  # Reduce score to 20%
            }
        }
    
    def conditional_boosting(self, search_text: str) -> dict:
        """
        Boost based on business rules.
        
        Sponsored products, featured items, etc.
        """
        
        return {
            "bool": {
                "must": [
                    {
                        "multi_match": {
                            "query": search_text,
                            "fields": ["name^3", "description"]
                        }
                    }
                ],
                "should": [
                    # Boost featured products
                    {
                        "term": {
                            "is_featured": {
                                "value": True,
                                "boost": 5.0
                            }
                        }
                    },
                    # Boost new products
                    {
                        "range": {
                            "created_at": {
                                "gte": "now-30d",
                                "boost": 2.0
                            }
                        }
                    },
                    # Boost highly rated products
                    {
                        "range": {
                            "rating": {
                                "gte": 4.5,
                                "boost": 1.5
                            }
                        }
                    },
                    # Boost products with many reviews
                    {
                        "range": {
                            "review_count": {
                                "gte": 100,
                                "boost": 1.3
                            }
                        }
                    }
                ]
            }
        }
Your turn · checkpoint 4not answered

Popularity, recency, rating: where do they go?

The business wants popular, recent, well-rated and featured products ranked higher. But the text must still decide which products match at all, and one runaway signal mustn't bury relevance. How would you structure that query?

Think it through, or jot it down. Then compare.

One strong answer

  • Keep matching and ranking apart: the text query (with its filters) decides what matches and gives a text score; business signals only adjust that score. Added as must clauses, they'd also restrict what matches.
  • Wrap it in function_score: field_value_factor on popularity (damped with sqrt or log1p), a gauss decay on created_at so recency fades smoothly, a rating-times-confidence script, and filter weights for featured items.
  • Keep it bounded: sum the functions, multiply into the text score, cap with max_boost. Start simple and A/B test every change.

3.2 Function Score Queries#

pythonFunctionScoreBuilder · 244 lines
# queries/function_score.py

"""
Function score queries for complex ranking.

When simple boosting isn't enough, function_score lets you
combine text relevance with arbitrary scoring functions.
"""


class FunctionScoreBuilder:
    """
    Builds function_score queries for custom ranking.
    """
    
    def popularity_boosting(self, search_text: str) -> dict:
        """
        Combine text relevance with popularity score.
        
        Final score = text_score * popularity_factor
        """
        
        return {
            "function_score": {
                "query": {
                    "multi_match": {
                        "query": search_text,
                        "fields": ["name^3", "description"]
                    }
                },
                "functions": [
                    {
                        # Boost by popularity score (stored field)
                        "field_value_factor": {
                            "field": "popularity_score",
                            "factor": 1.2,
                            "modifier": "sqrt",  # Dampen extreme values
                            "missing": 1         # Default if field missing
                        }
                    }
                ],
                "score_mode": "multiply",  # Multiply all function results
                "boost_mode": "multiply"   # Multiply with query score
            }
        }
    
    def recency_decay(self, search_text: str) -> dict:
        """
        Newer products score higher, with decay.
        
        Uses Gaussian decay: full score for recent,
        gradually decreasing for older products.
        """
        
        return {
            "function_score": {
                "query": {
                    "multi_match": {
                        "query": search_text,
                        "fields": ["name^3", "description"]
                    }
                },
                "functions": [
                    {
                        "gauss": {
                            "created_at": {
                                "origin": "now",     # Center point
                                "scale": "30d",      # 50% score at offset + scale (37d)
                                "offset": "7d",      # Full score for first 7 days
                                "decay": 0.5         # Score at scale distance
                            }
                        }
                    }
                ],
                "boost_mode": "multiply"
            }
        }
    
    def multi_factor_ranking(self, search_text: str) -> dict:
        """
        Combine multiple ranking factors.
        
        Real-world ranking often combines:
        - Text relevance
        - Popularity (sales, views)
        - Recency
        - Rating quality
        - Inventory status
        """
        
        return {
            "function_score": {
                "query": {
                    "multi_match": {
                        "query": search_text,
                        "fields": ["name^3", "brand^2", "description"]
                    }
                },
                "functions": [
                    # Factor 1: Popularity score
                    {
                        "field_value_factor": {
                            "field": "popularity_score",
                            "modifier": "log1p",  # log10(1 + x) to dampen
                            "missing": 0
                        },
                        "weight": 2  # This factor counts 2x
                    },
                    
                    # Factor 2: Rating quality
                    {
                        "script_score": {
                            "script": {
                                "source": """
                                    // Combine rating with review count
                                    // High rating with few reviews < Medium rating with many reviews
                                    double rating = doc['rating'].value;
                                    double reviews = doc['review_count'].value;
                                    double confidence = Math.min(1.0, reviews / 50.0);
                                    return rating * confidence;
                                """
                            }
                        },
                        "weight": 1.5
                    },
                    
                    # Factor 3: Recency decay
                    {
                        "gauss": {
                            "created_at": {
                                "origin": "now",
                                "scale": "90d",
                                "decay": 0.5
                            }
                        },
                        "weight": 1
                    },
                    
                    # Factor 4: In-stock boost
                    {
                        "filter": {"term": {"in_stock": True}},
                        "weight": 1.5
                    },
                    
                    # Factor 5: Featured boost
                    {
                        "filter": {"term": {"is_featured": True}},
                        "weight": 3
                    },
                    
                    # Factor 6: Price competitiveness (lower = better)
                    {
                        "script_score": {
                            "script": {
                                "source": """
                                    // Inverse of price, normalized
                                    double price = doc['price'].value;
                                    if (price <= 0) return 1.0;
                                    return 100.0 / (price + 100.0);
                                """
                            }
                        },
                        "weight": 0.5
                    }
                ],
                "score_mode": "sum",       # Sum all function scores
                "boost_mode": "multiply",  # Multiply sum with query score
                "max_boost": 10            # Cap the boost
            }
        }
    
    def personalized_ranking(
        self,
        search_text: str,
        user_preferences: dict
    ) -> dict:
        """
        Personalized ranking based on user behavior.
        
        Boost brands/categories user has purchased from before.
        """
        
        functions = [
            # Base popularity
            {
                "field_value_factor": {
                    "field": "popularity_score",
                    "modifier": "sqrt",
                    "missing": 1
                },
                "weight": 1
            }
        ]
        
        # Boost user's preferred brands
        if user_preferences.get("preferred_brands"):
            functions.append({
                "filter": {
                    "terms": {
                        "brand.keyword": user_preferences["preferred_brands"]
                    }
                },
                "weight": 2.0
            })
        
        # Boost user's preferred categories
        if user_preferences.get("preferred_categories"):
            functions.append({
                "filter": {
                    "terms": {
                        "category": user_preferences["preferred_categories"]
                    }
                },
                "weight": 1.5
            })
        
        # Boost user's price range
        if user_preferences.get("price_range"):
            price_range = user_preferences["price_range"]
            functions.append({
                "filter": {
                    "range": {
                        "price": {
                            "gte": price_range.get("min", 0),
                            "lte": price_range.get("max", 99999)
                        }
                    }
                },
                "weight": 1.3
            })
        
        return {
            "function_score": {
                "query": {
                    "multi_match": {
                        "query": search_text,
                        "fields": ["name^3", "brand^2", "description"]
                    }
                },
                "functions": functions,
                "score_mode": "sum",
                "boost_mode": "multiply"
            }
        }

3.3 Boosting Decision Framework#

Boosting decision framework

Question: What are you trying to achieve?

# Goal Example Approach
1 Field importance "Matches in title matter more than description" Use field-level boosts: name^3, description^1
2 Exact phrase preference "Exact phrase 'running shoes' > 'running' and 'shoes' apart" Add match_phrase in should clause with boost
3 Business rules "Featured products should rank higher" Use filter boost in should clause, or function_score with filter weight
4 Numeric factor "Popular products should rank higher" Use field_value_factor on popularity field
5 Time sensitivity "Recent products should rank higher" Use decay function (gauss/exp/linear)
6 Complex combination "Consider popularity, recency, rating, AND business rules" Use function_score with multiple functions
7 Personalization "Results should reflect user preferences" Use function_score with filter boosts based on user data
Default approach
  1. Start with field boosting (name^3, brand^2, description^1)
  2. Add business boosts in should clauses
  3. Add function_score only when needed

Warning: Over-boosting makes relevance unpredictable. Start simple, add complexity only when needed. Always A/B test ranking changes!

Quick check2 questions · +5 XP each

Boosting strategies

  1. Q1/2 Product wants out-of-stock items lower in the results but still visible. Which approach fits?

    Answer & explanation

    Answer: C

    A filter would remove those items entirely. Negative boosting keeps them but multiplies their score down (to 20% here), so they drop in the ranking without disappearing.

  2. Q2/2 You want newer products to rank higher, with the effect fading smoothly over time. What does the decision framework point to?

    Answer & explanation

    Answer: A

    Decay functions give full credit near the origin and fade with distance, so recency drops off smoothly. A should range clause is a cliff: in the window or not. b has nothing to do with time.

Part IV · Stage 4 of 6 · 3 min

Part IV: Multi-Match Strategies#

Chapter 4: Searching Across Fields#

4.1 Multi-Match Types#

pythonMultiMatchExplainer · 159 lines
# queries/multi_match.py

"""
Multi-match query types and when to use each.
"""


class MultiMatchExplainer:
    """
    Explains different multi_match types.
    """
    
    def best_fields(self, search_text: str) -> dict:
        """
        best_fields (default)
        
        Finds documents where ANY field matches.
        Final score = highest scoring field.
        
        Best for: Searching where match in one field is enough.
        Example: Product search (name OR description)
        """
        
        return {
            "multi_match": {
                "query": search_text,
                "fields": ["name^3", "description"],
                "type": "best_fields",
                "tie_breaker": 0.3  # Give some credit to other fields
            }
        }
        
        # Example:
        # Query: "laptop"
        # Doc A: name="Laptop Stand", description="Holds your computer"
        # Doc B: name="Computer Desk", description="Perfect for your laptop"
        # 
        # best_fields:
        # Doc A score = max(name_score, desc_score) = name_score (higher)
        # Doc B score = max(name_score, desc_score) = desc_score
        # 
        # With tie_breaker=0.3:
        # Doc A score = name_score + 0.3 * desc_score
    
    def most_fields(self, search_text: str) -> dict:
        """
        most_fields
        
        Finds documents matching in MULTIPLE fields.
        Final score = sum of all field scores.
        
        Best for: When matching across fields indicates better match.
        Example: Documents with same text in multiple languages.
        """
        
        return {
            "multi_match": {
                "query": search_text,
                "fields": [
                    "name", 
                    "name.stemmed",    # Stemmed version
                    "name.shingles"    # Word combinations
                ],
                "type": "most_fields"
            }
        }
        
        # Example:
        # Query: "running shoes"
        # Fields: name, name.stemmed, name.shingles
        # 
        # most_fields:
        # Score = name_score + stemmed_score + shingles_score
        # Doc matching in all 3 analyses scores highest
    
    def cross_fields(self, search_text: str) -> dict:
        """
        cross_fields
        
        Treats multiple fields as ONE big field.
        Query terms can match across different fields.
        
        Best for: Person names, addresses split across fields.
        Example: first_name="John", last_name="Smith", query="John Smith"
        """
        
        return {
            "multi_match": {
                "query": search_text,
                "fields": ["first_name", "last_name"],
                "type": "cross_fields",
                "operator": "and"  # All terms must match (across fields)
            }
        }
        
        # Example:
        # Query: "John Smith"
        # Doc: first_name="John", last_name="Smith"
        # 
        # best_fields: Would need "John" AND "Smith" in same field - NO MATCH
        # cross_fields: "John" in first_name, "Smith" in last_name - MATCH!
    
    def phrase(self, search_text: str) -> dict:
        """
        phrase
        
        Searches for exact phrase in each field.
        
        Best for: When word order matters.
        Example: with slop 0 (default), "New York" does not match "York New";
        a transposition needs slop 2, so the slop: 2 below would match it
        """
        
        return {
            "multi_match": {
                "query": search_text,
                "fields": ["name^3", "description"],
                "type": "phrase",
                "slop": 2  # Allow up to 2 words between
            }
        }
    
    def phrase_prefix(self, search_text: str) -> dict:
        """
        phrase_prefix
        
        Like phrase, but last term is treated as prefix.
        
        Best for: Autocomplete / search-as-you-type.
        Example: "quick bro" matches "quick brown fox"
        """
        
        return {
            "multi_match": {
                "query": search_text,
                "fields": ["name^3", "description"],
                "type": "phrase_prefix",
                "max_expansions": 50  # Limit prefix expansions
            }
        }
    
    def bool_prefix(self, search_text: str) -> dict:
        """
        bool_prefix
        
        Each term is a term query except the last, which is a prefix;
        combined with OR by default (set operator "and" to require all).
        
        Best for: Autocomplete where order doesn't matter.
        Example: "quick bro" matches "brown quick", "quick brown"
        """
        
        return {
            "multi_match": {
                "query": search_text,
                "fields": ["name^3", "description"],
                "type": "bool_prefix"
            }
        }
Multi-match type selection guide
Scenario Type to use
Product search (name, description) best_fields + tie_breaker
Search with multiple analyzers most_fields
Person name (first + last) cross_fields
Address search cross_fields
Exact phrase required phrase
Autocomplete (order matters) phrase_prefix
Autocomplete (order doesn't matter) bool_prefix

Default: Start with best_fields, tie_breaker=0.3. Only change if you have a specific reason.

Quick check2 questions · +5 XP each

Multi-match types

  1. Q1/2 Users search "John Smith" over first_name and last_name with operator: and. best_fields returns nothing. Why, and what's the fix?

    Answer & explanation

    Answer: C

    With best_fields and AND, "John" and "Smith" both have to be in the same field. cross_fields treats the fields as one, so one term can match first_name and the other last_name.

  2. Q2/2 You index name, name.stemmed and name.shingles and want docs matching under all three to score highest. Which type do you use?

    Answer & explanation

    Answer: D

    most_fields adds up the score from each field, so matching under several analyses earns more. best_fields takes only the single best field, plus a little from others via tie_breaker.

Part V · Stage 5 of 6 · 9 min

Part V: Handling Edge Cases#

Chapter 5: No Results and Poor Results#

Your turn · checkpoint 5not answered

Zero results for a product that exists

A user searches "nikee runing shoes" with brand Nike, price up to $50 and rating 4.5+. Every word is required, and the search returns nothing, although such shoes exist. What went wrong, and what would you try next, in what order? What would you keep until last?

Think it through, or jot it down. Then compare.

One strong answer

  • What broke: two typos never match the indexed terms, and the strict filters shrink the set further. "No results" when the product exists is the worst outcome.
  • Relax step by step, stopping once there are enough results: drop the low-intent filters (rating and price), then allow typos (fuzzy matching), then require only some of the words, and only at the very end drop every filter.
  • Keep category (a strong intent signal) and stock as long as you can.
  • Offer "did you mean" suggestions when results stay thin.

5.1 The "No Results" Problem#

pythonSearchAttempt · 289 lines
# queries/no_results.py

"""
Strategies for handling no results or poor results.

Nothing frustrates users more than "No results found"
when they know the product exists.
"""

from typing import List, Optional, Tuple
from dataclasses import dataclass


@dataclass
class SearchAttempt:
    """Record of a search attempt."""
    query: dict
    strategy: str
    result_count: int
    top_score: Optional[float]


class ResilientSearcher:
    """
    Implements fallback strategies for better results.
    """
    
    def __init__(self, es_client, index: str):
        self.es = es_client
        self.index = index
    
    async def search_with_fallback(
        self,
        search_text: str,
        filters: dict,
        min_results: int = 5
    ) -> Tuple[dict, List[SearchAttempt]]:
        """
        Search with progressive relaxation.
        
        Strategy:
        1. Try exact query with all filters
        2. Relax filters progressively
        3. Try fuzzy matching
        4. Try partial terms
        5. Suggest alternatives
        """
        
        attempts = []
        
        # Strategy 1: Exact query
        query = self._build_exact_query(search_text, filters)
        result = await self._execute_search(query)
        attempts.append(SearchAttempt(
            query=query,
            strategy="exact",
            result_count=result["hits"]["total"]["value"],
            top_score=self._get_top_score(result)
        ))
        
        if result["hits"]["total"]["value"] >= min_results:
            return result, attempts
        
        # Strategy 2: Relax non-essential filters
        if filters:
            relaxed_filters = self._relax_filters(filters)
            query = self._build_exact_query(search_text, relaxed_filters)
            result = await self._execute_search(query)
            attempts.append(SearchAttempt(
                query=query,
                strategy="relaxed_filters",
                result_count=result["hits"]["total"]["value"],
                top_score=self._get_top_score(result)
            ))
            
            if result["hits"]["total"]["value"] >= min_results:
                return result, attempts
        
        # Strategy 3: Fuzzy matching (typo tolerance)
        query = self._build_fuzzy_query(search_text, filters)
        result = await self._execute_search(query)
        attempts.append(SearchAttempt(
            query=query,
            strategy="fuzzy",
            result_count=result["hits"]["total"]["value"],
            top_score=self._get_top_score(result)
        ))
        
        if result["hits"]["total"]["value"] >= min_results:
            return result, attempts
        
        # Strategy 4: OR instead of AND
        query = self._build_any_term_query(search_text, filters)
        result = await self._execute_search(query)
        attempts.append(SearchAttempt(
            query=query,
            strategy="any_term",
            result_count=result["hits"]["total"]["value"],
            top_score=self._get_top_score(result)
        ))
        
        if result["hits"]["total"]["value"] >= min_results:
            return result, attempts
        
        # Strategy 5: No filters at all
        query = self._build_fuzzy_query(search_text, {})
        result = await self._execute_search(query)
        attempts.append(SearchAttempt(
            query=query,
            strategy="no_filters_fuzzy",
            result_count=result["hits"]["total"]["value"],
            top_score=self._get_top_score(result)
        ))
        
        return result, attempts
    
    def _build_exact_query(self, search_text: str, filters: dict) -> dict:
        """Build exact match query."""
        
        return {
            "query": {
                "bool": {
                    "must": [
                        {
                            "multi_match": {
                                "query": search_text,
                                "fields": ["name^3", "brand^2", "description"],
                                "type": "best_fields",
                                "operator": "and"  # All terms required
                            }
                        }
                    ],
                    "filter": self._build_filters(filters)
                }
            }
        }
    
    def _build_fuzzy_query(self, search_text: str, filters: dict) -> dict:
        """Build query with typo tolerance."""
        
        return {
            "query": {
                "bool": {
                    "must": [
                        {
                            "multi_match": {
                                "query": search_text,
                                "fields": ["name^3", "brand^2", "description"],
                                "type": "best_fields",
                                "fuzziness": "AUTO",  # 1 edit for 3-5 chars, 2 for 6+
                                "prefix_length": 2,    # Don't fuzzy first 2 chars
                                "operator": "and"
                            }
                        }
                    ],
                    "filter": self._build_filters(filters)
                }
            }
        }
    
    def _build_any_term_query(self, search_text: str, filters: dict) -> dict:
        """Build query where ANY term can match."""
        
        return {
            "query": {
                "bool": {
                    "must": [
                        {
                            "multi_match": {
                                "query": search_text,
                                "fields": ["name^3", "brand^2", "description"],
                                "type": "best_fields",
                                "operator": "or",      # Any term matches
                                "minimum_should_match": "50%"  # At least half
                            }
                        }
                    ],
                    "filter": self._build_filters(filters)
                }
            }
        }
    
    def _relax_filters(self, filters: dict) -> dict:
        """
        Relax filters by removing least important ones.
        
        Priority (keep first):
        1. category (high intent signal)
        2. in_stock (critical for purchase)
        3. brand (moderate intent)
        4. price_range (remove first)
        5. rating (remove first)
        """
        
        relaxed = dict(filters)
        
        # Remove in order of least importance
        for key in ["rating_min", "price_max", "price_min"]:
            if key in relaxed:
                del relaxed[key]
        
        return relaxed
    
    def _build_filters(self, filters: dict) -> list:
        """Build filter clauses."""
        
        clauses = []
        
        if filters.get("category"):
            clauses.append({"term": {"category": filters["category"]}})
        
        if filters.get("brand"):
            clauses.append({"term": {"brand.keyword": filters["brand"]}})
        
        if filters.get("in_stock") is not None:
            clauses.append({"term": {"in_stock": filters["in_stock"]}})
        
        if filters.get("price_min") or filters.get("price_max"):
            price_range = {}
            if filters.get("price_min"):
                price_range["gte"] = filters["price_min"]
            if filters.get("price_max"):
                price_range["lte"] = filters["price_max"]
            clauses.append({"range": {"price": price_range}})
        
        return clauses
    
    async def _execute_search(self, query: dict) -> dict:
        """Execute search and return results."""
        
        return await self.es.search(index=self.index, body=query, size=20)
    
    def _get_top_score(self, result: dict) -> Optional[float]:
        """Get score of top result."""
        
        hits = result.get("hits", {}).get("hits", [])
        if hits:
            return hits[0].get("_score")
        return None


# =============================================================================
# "Did You Mean" Suggestions
# =============================================================================

class SpellingSuggester:
    """
    Provides spelling suggestions for queries.
    """
    
    def __init__(self, es_client, index: str):
        self.es = es_client
        self.index = index
    
    async def get_suggestions(self, search_text: str) -> List[str]:
        """
        Get spelling suggestions using Elasticsearch suggester.
        """
        
        response = await self.es.search(
            index=self.index,
            body={
                "suggest": {
                    "text": search_text,
                    "simple_phrase": {
                        "phrase": {
                            "field": "name.trigram",
                            "size": 3,
                            "gram_size": 3,
                            "direct_generator": [{
                                "field": "name.trigram",
                                "suggest_mode": "popular"
                            }],
                            "highlight": {
                                "pre_tag": "<em>",
                                "post_tag": "</em>"
                            }
                        }
                    }
                }
            }
        )
        
        suggestions = []
        for suggestion in response.get("suggest", {}).get("simple_phrase", []):
            for option in suggestion.get("options", []):
                suggestions.append(option["text"])
        
        return suggestions

5.2 Search Quality Metrics#

pythonSearchEvent · 133 lines
# monitoring/search_quality.py

"""
Metrics for measuring search quality.

You can't improve what you don't measure.
"""

from dataclasses import dataclass
from datetime import datetime
from typing import List, Optional


@dataclass
class SearchEvent:
    """A single search interaction."""
    
    query: str
    result_count: int
    clicked_position: Optional[int]  # Position of clicked result (1-indexed)
    clicked_product_id: Optional[str]
    time_to_click_ms: Optional[int]
    converted: bool  # Did user add to cart / purchase
    timestamp: datetime


class SearchQualityMetrics:
    """
    Calculates search quality metrics.
    """
    
    def calculate_metrics(self, events: List[SearchEvent]) -> dict:
        """
        Calculate key search quality metrics.
        """
        
        total_searches = len(events)
        
        if total_searches == 0:
            return {"error": "No events to analyze"}
        
        # Zero Result Rate
        zero_results = sum(1 for e in events if e.result_count == 0)
        zero_result_rate = zero_results / total_searches
        
        # Click-Through Rate (CTR)
        clicks = sum(1 for e in events if e.clicked_position is not None)
        ctr = clicks / total_searches
        
        # Mean Reciprocal Rank (MRR)
        # Measures how high the clicked result ranked
        reciprocal_ranks = []
        for e in events:
            if e.clicked_position:
                reciprocal_ranks.append(1.0 / e.clicked_position)
        mrr = sum(reciprocal_ranks) / len(reciprocal_ranks) if reciprocal_ranks else 0
        
        # Clicks in Top 3
        top3_clicks = sum(
            1 for e in events 
            if e.clicked_position and e.clicked_position <= 3
        )
        top3_rate = top3_clicks / clicks if clicks > 0 else 0
        
        # Conversion Rate (searches that led to purchase)
        conversions = sum(1 for e in events if e.converted)
        conversion_rate = conversions / total_searches
        
        # Average Time to Click
        click_times = [e.time_to_click_ms for e in events if e.time_to_click_ms]
        avg_time_to_click = sum(click_times) / len(click_times) if click_times else 0
        
        return {
            "total_searches": total_searches,
            "zero_result_rate": round(zero_result_rate, 4),
            "click_through_rate": round(ctr, 4),
            "mean_reciprocal_rank": round(mrr, 4),
            "top3_click_rate": round(top3_rate, 4),
            "conversion_rate": round(conversion_rate, 4),
            "avg_time_to_click_ms": round(avg_time_to_click, 0),
            "health_assessment": self._assess_health(
                zero_result_rate, ctr, mrr, top3_rate
            )
        }
    
    def _assess_health(
        self,
        zero_result_rate: float,
        ctr: float,
        mrr: float,
        top3_rate: float
    ) -> dict:
        """Assess overall search health."""
        
        # Flag any metric worse than "Good" in the quality targets table
        issues = []
        
        if zero_result_rate > 0.10:
            issues.append({
                "severity": "high",
                "metric": "zero_result_rate",
                "message": f"High zero-result rate ({zero_result_rate:.1%}). "
                           "Consider fuzzy matching, synonyms, or spell check."
            })
        
        if ctr < 0.35:
            issues.append({
                "severity": "medium",
                "metric": "click_through_rate",
                "message": f"Low CTR ({ctr:.1%}). "
                           "Results may not be relevant or snippets may be poor."
            })
        
        if mrr < 0.50:
            issues.append({
                "severity": "medium",
                "metric": "mean_reciprocal_rank",
                "message": f"Low MRR ({mrr:.2f}). "
                           "Best results not ranking high enough."
            })
        
        if top3_rate < 0.70:
            issues.append({
                "severity": "low",
                "metric": "top3_click_rate",
                "message": f"Low top-3 click rate ({top3_rate:.1%}). "
                           "Users scrolling past top results."
            })
        
        return {
            "status": "healthy" if not issues else "needs_attention",
            "issues": issues
        }
Search quality targets
Metric Poor Average Good Excellent
Zero Result Rate > 15% 10-15% 5-10% < 5%
Click-Through Rate < 20% 20-35% 35-50% > 50%
Mean Reciprocal Rank < 0.3 0.3-0.5 0.5-0.7 > 0.7
Top-3 Click Rate < 50% 50-70% 70-85% > 85%
Search → Conversion < 2% 2-5% 5-10% > 10%

These bands are rule-of-thumb starting points, not published industry benchmarks; calibrate them against your own baseline.

Interpretation
  • Zero Result Rate — the % of searches that return nothing. High = missing products, poor synonyms, no typo tolerance.
  • Click-Through Rate — the % of searches where user clicks a result. Low = poor relevance or bad snippets.
  • Mean Reciprocal Rank — average of 1/position for clicked results. MRR=1.0 means always click first result; MRR=0.5 means a harmonic-mean position of 2. Low = best results not ranking well.
  • Top-3 Click Rate — of searches with clicks, % clicking in top 3. Low = ranking needs improvement.
  • Search → Conversion — % of searches leading to purchase. Ultimate measure of search effectiveness.
Quick check2 questions · +5 XP each

No-results handling and quality metrics

  1. Q1/2 A strict AND query with all filters returns 2 results (minimum is 5). What does progressive relaxation try first?

    Answer & explanation

    Answer: A

    Relaxation goes step by step: drop low-intent filters, then fuzzy matching, then any-term matching, and only at the end drop all filters. Category stays because it's a strong intent signal.

  2. Q2/2 Zero-result rate is 3% and CTR is healthy, but MRR is 0.25. What does that suggest?

    Answer & explanation

    Answer: B

    MRR averages 1/position of the clicked result, so a low MRR means clicks are landing further down. With few zero-result searches and good CTR, matching works; ranking is the problem.

Part VI · Stage 6 of 6 · 10 min

Part VI: Complete Search Service#

Chapter 6: Putting It All Together#

pythonSearchConfig · 475 lines
# services/search_service.py

"""
Complete search service with all features.
"""

import logging
from dataclasses import dataclass
from typing import List, Dict, Any, Optional
from datetime import datetime, timezone

logger = logging.getLogger(__name__)


@dataclass
class SearchConfig:
    """Search service configuration."""
    
    # Field boosts
    name_boost: float = 3.0
    brand_boost: float = 2.0
    description_boost: float = 1.0
    
    # Function score weights
    popularity_weight: float = 2.0
    recency_weight: float = 1.0
    rating_weight: float = 1.5
    
    # Fallback settings
    enable_fuzzy_fallback: bool = True
    min_results_for_fallback: int = 5
    
    # Pagination
    default_page_size: int = 20
    max_page_size: int = 100


@dataclass
class SearchRequest:
    """Search request from client."""
    
    query: str
    filters: Dict[str, Any] = None
    sort: str = "relevance"
    page: int = 1
    page_size: int = 20
    user_id: Optional[str] = None  # For personalization


@dataclass
class SearchResponse:
    """Search response to client."""
    
    products: List[dict]
    total: int
    page: int
    page_size: int
    facets: Dict[str, List[dict]]
    suggestions: List[str]
    query_info: dict  # Debug info about query execution
    took_ms: int


class ProductSearchService:
    """
    Complete product search service.
    
    Features:
    - Multi-field search with boosting
    - Filter/facet support
    - Function score ranking
    - Fallback strategies
    - Spell suggestions
    - Quality tracking
    """
    
    def __init__(
        self,
        es_client,
        cache,
        user_service,
        config: SearchConfig = None
    ):
        self.es = es_client
        self.cache = cache
        self.users = user_service
        self.config = config or SearchConfig()
        
        self.index = "products"
    
    async def search(self, request: SearchRequest) -> SearchResponse:
        """
        Execute product search.
        """
        
        start_time = datetime.now(timezone.utc)
        query_info = {"strategies_tried": []}
        
        # Get user preferences for personalization
        user_prefs = None
        if request.user_id:
            user_prefs = await self.users.get_preferences(request.user_id)
        
        # Build and execute query
        query = self._build_query(request, user_prefs)
        
        result = await self.es.search(
            index=self.index,
            body=query
        )
        query_info["strategies_tried"].append("primary")
        
        # Check if we need fallback
        hit_count = result["hits"]["total"]["value"]
        
        if (hit_count < self.config.min_results_for_fallback 
            and self.config.enable_fuzzy_fallback):
            
            # Try fuzzy search
            fuzzy_query = self._build_fuzzy_query(request)
            fuzzy_result = await self.es.search(
                index=self.index,
                body=fuzzy_query
            )
            query_info["strategies_tried"].append("fuzzy")
            
            if fuzzy_result["hits"]["total"]["value"] > hit_count:
                result = fuzzy_result
                query_info["used_fallback"] = True
        
        # Get suggestions if few/no results
        suggestions = []
        if hit_count < 10:
            suggestions = await self._get_suggestions(request.query)
            query_info["suggestions_fetched"] = True
        
        # Process response
        products = self._process_hits(result["hits"]["hits"])
        facets = self._process_aggregations(result.get("aggregations", {}))
        
        took_ms = int((datetime.now(timezone.utc) - start_time).total_seconds() * 1000)
        
        return SearchResponse(
            products=products,
            total=result["hits"]["total"]["value"],
            page=request.page,
            page_size=request.page_size,
            facets=facets,
            suggestions=suggestions,
            query_info=query_info,
            took_ms=took_ms
        )
    
    def _build_query(
        self,
        request: SearchRequest,
        user_prefs: Optional[dict]
    ) -> dict:
        """Build the main search query."""
        
        # Core search query
        must_query = {
            "multi_match": {
                "query": request.query,
                "fields": [
                    f"name^{self.config.name_boost}",
                    f"brand^{self.config.brand_boost}",
                    f"description^{self.config.description_boost}",
                    "tags"
                ],
                "type": "best_fields",
                "tie_breaker": 0.3,
                "operator": "and"
            }
        }
        
        # Build filters
        filters = self._build_filters(request.filters or {})
        
        # Always filter to in-stock
        filters.append({"term": {"in_stock": True}})
        
        # Build function score
        functions = self._build_score_functions(user_prefs)
        
        # Build should clauses (phrase boost)
        should = [
            {
                "match_phrase": {
                    "name": {
                        "query": request.query,
                        "boost": 2.0,
                        "slop": 1
                    }
                }
            }
        ]
        
        # Complete query
        query = {
            "query": {
                "function_score": {
                    "query": {
                        "bool": {
                            "must": [must_query],
                            "should": should,
                            "filter": filters
                        }
                    },
                    "functions": functions,
                    "score_mode": "sum",
                    "boost_mode": "multiply",
                    "max_boost": 10
                }
            },
            "from": (request.page - 1) * request.page_size,
            "size": min(request.page_size, self.config.max_page_size),
            "aggs": self._build_aggregations(),
            "highlight": {
                "fields": {
                    "name": {},
                    "description": {"fragment_size": 150}
                }
            }
        }
        
        # Add sorting if not relevance
        if request.sort != "relevance":
            query["sort"] = self._build_sort(request.sort)
        
        return query
    
    def _build_filters(self, filters: dict) -> list:
        """Build filter clauses."""
        
        clauses = []
        
        if filters.get("category"):
            clauses.append({"term": {"category": filters["category"]}})
        
        if filters.get("brand"):
            brands = filters["brand"]
            if isinstance(brands, list):
                clauses.append({"terms": {"brand.keyword": brands}})
            else:
                clauses.append({"term": {"brand.keyword": brands}})
        
        if filters.get("price_min") or filters.get("price_max"):
            price_range = {}
            if filters.get("price_min"):
                price_range["gte"] = filters["price_min"]
            if filters.get("price_max"):
                price_range["lte"] = filters["price_max"]
            clauses.append({"range": {"price": price_range}})
        
        if filters.get("rating_min"):
            clauses.append({
                "range": {"rating": {"gte": filters["rating_min"]}}
            })
        
        if filters.get("color"):
            clauses.append({"term": {"colors": filters["color"]}})
        
        return clauses
    
    def _build_score_functions(self, user_prefs: Optional[dict]) -> list:
        """Build function score functions."""
        
        functions = [
            # Popularity boost
            {
                "field_value_factor": {
                    "field": "popularity_score",
                    "modifier": "log1p",
                    "missing": 0
                },
                "weight": self.config.popularity_weight
            },
            
            # Rating quality
            {
                "script_score": {
                    "script": {
                        "source": """
                            double rating = doc['rating'].value;
                            double reviews = doc['review_count'].value;
                            double confidence = Math.min(1.0, reviews / 50.0);
                            return rating * confidence * 0.2;
                        """
                    }
                },
                "weight": self.config.rating_weight
            },
            
            # Recency
            {
                "gauss": {
                    "created_at": {
                        "origin": "now",
                        "scale": "90d",
                        "decay": 0.5
                    }
                },
                "weight": self.config.recency_weight
            },
            
            # Featured products
            {
                "filter": {"term": {"is_featured": True}},
                "weight": 3.0
            }
        ]
        
        # Add personalization if available
        if user_prefs:
            if user_prefs.get("preferred_brands"):
                functions.append({
                    "filter": {
                        "terms": {"brand.keyword": user_prefs["preferred_brands"]}
                    },
                    "weight": 1.5
                })
            
            if user_prefs.get("preferred_categories"):
                functions.append({
                    "filter": {
                        "terms": {"category": user_prefs["preferred_categories"]}
                    },
                    "weight": 1.3
                })
        
        return functions
    
    def _build_fuzzy_query(self, request: SearchRequest) -> dict:
        """Build fuzzy fallback query."""
        
        return {
            "query": {
                "bool": {
                    "must": [
                        {
                            "multi_match": {
                                "query": request.query,
                                "fields": [
                                    f"name^{self.config.name_boost}",
                                    f"brand^{self.config.brand_boost}",
                                    "description"
                                ],
                                "type": "best_fields",
                                "fuzziness": "AUTO",
                                "prefix_length": 2
                            }
                        }
                    ],
                    "filter": [
                        {"term": {"in_stock": True}}
                    ]
                }
            },
            "size": request.page_size
        }
    
    def _build_aggregations(self) -> dict:
        """Build facet aggregations."""
        
        return {
            "categories": {
                "terms": {"field": "category", "size": 20}
            },
            "brands": {
                "terms": {"field": "brand.keyword", "size": 20}
            },
            "price_ranges": {
                "range": {
                    "field": "price",
                    "ranges": [
                        {"key": "under_25", "to": 25},
                        {"key": "25_50", "from": 25, "to": 50},
                        {"key": "50_100", "from": 50, "to": 100},
                        {"key": "100_200", "from": 100, "to": 200},
                        {"key": "over_200", "from": 200}
                    ]
                }
            },
            "ratings": {
                "range": {
                    "field": "rating",
                    "ranges": [
                        {"key": "4_and_up", "from": 4},
                        {"key": "3_and_up", "from": 3},
                        {"key": "2_and_up", "from": 2}
                    ]
                }
            },
            "colors": {
                "terms": {"field": "colors", "size": 15}
            }
        }
    
    def _build_sort(self, sort: str) -> list:
        """Build sort clause."""
        
        sort_map = {
            "price_asc": [{"price": "asc"}],
            "price_desc": [{"price": "desc"}],
            "rating": [{"rating": "desc"}, {"review_count": "desc"}],
            "newest": [{"created_at": "desc"}],
            "popular": [{"popularity_score": "desc"}],
        }
        
        return sort_map.get(sort, [{"_score": "desc"}])
    
    def _process_hits(self, hits: list) -> list:
        """Process search hits into response format."""
        
        products = []
        for hit in hits:
            product = {
                "product_id": hit["_id"],
                "score": hit.get("_score"),
                **hit["_source"]
            }
            
            # Add highlighting if available
            if hit.get("highlight"):
                product["highlights"] = hit["highlight"]
            
            products.append(product)
        
        return products
    
    def _process_aggregations(self, aggs: dict) -> dict:
        """Process aggregations into facets."""
        
        facets = {}
        
        for agg_name, agg_data in aggs.items():
            if "buckets" in agg_data:
                facets[agg_name] = [
                    {"value": b["key"], "count": b["doc_count"]}
                    for b in agg_data["buckets"]
                ]
        
        return facets
    
    async def _get_suggestions(self, query: str) -> list:
        """Get spelling suggestions."""
        
        response = await self.es.search(
            index=self.index,
            body={
                "suggest": {
                    "text": query,
                    "phrase_suggest": {
                        "phrase": {
                            "field": "name.trigram",
                            "size": 3,
                            "gram_size": 3,
                            "direct_generator": [{
                                "field": "name.trigram",
                                "suggest_mode": "popular"
                            }]
                        }
                    }
                }
            }
        )
        
        suggestions = []
        for suggest in response.get("suggest", {}).get("phrase_suggest", []):
            for option in suggest.get("options", []):
                if option["text"] != query:
                    suggestions.append(option["text"])
        
        return suggestions[:3]
Quick check2 questions · +5 XP each

Putting the search service together

  1. Q1/2 Why does function_score wrap the bool query instead of the business signals being added as more must clauses?

    Answer & explanation

    Answer: C

    The bool query sets which documents match and their text score; functions (summed, multiplied in, capped by max_boost) then apply popularity, rating, recency and featured status. As must clauses they would also restrict matching.

  2. Q2/2 The primary query returns 3 hits and the fuzzy fallback returns 40. What does the service return?

    Answer & explanation

    Answer: D

    Below min_results_for_fallback (5), it runs the fuzzy query and keeps those results if there are more of them, recording it in query_info. With fewer than 10 hits it also fetches spelling suggestions.

Summary#

What We Learned Today#

Day 3 summary: query processing & relevance
  • BM25 scoring
    • Term frequency — More occurrences = higher score (with saturation)
    • IDF — Rare terms matter more than common terms
    • Length normalization — Long documents don't win just by having more words
    • Tunable via k1 (TF saturation) and b (length norm)
  • Query vs filter
    • Query context — Calculates score, used for relevance
    • Filter context — Yes/no only, cacheable, fast
    • Best practice — Text search in query, facets in filter
    • Frequently used filters are cached = huge performance win
  • Boosting strategies
    • Field boosts — name^3, brand^2, description^1
    • Phrase boosting — Exact phrase gets bonus
    • Negative boosting — Demote out-of-stock
    • Function scores — Complex ranking formulas
    • Personalization — Boost user's preferred brands
  • Multi-match types
    • best_fields — Default, max of field scores
    • most_fields — Sum of field scores (multiple analyses)
    • cross_fields — Terms can match across fields (names)
    • phrase_prefix — Autocomplete with order
    • bool_prefix — Autocomplete without order
  • Handling no results
    • Progressive relaxation — Strict → fuzzy → any term
    • Spelling suggestions — "Did you mean..."
    • Quality metrics — Zero result rate, CTR, MRR
    • Always provide something useful
◆ Key takeaways

Key Takeaways#

Relevance key takeaways
  1. Start simple — Field boosting first (name^3, description^1). Add complexity only when needed.
  2. Query vs filter — Text search → Query context (scored). Facets/toggles → Filter context (cached).
  3. Measure quality — Track zero result rate, CTR, MRR. Can't improve what you don't measure.
  4. Fallback gracefully — Fuzzy matching, relaxed filters. "No results" is never acceptable.
  5. Personalize carefully — User preferences boost relevance. But don't overdo it (filter bubbles).
Default query pattern
text8 lines
function_score(
  bool(
    must: multi_match(query, fields with boosts)
    should: match_phrase(query) for phrase boost
    filter: [exact filters, cached]
  )
  functions: [popularity, recency, featured]
)
▲ Interview tip

Interview Tip#

When asked "How would you improve search relevance?"

"I'd start by measuring current quality with metrics like zero result rate, CTR, and MRR. You can't improve what you don't measure.

For the query itself, I'd use:

  1. Field boosting - product names weighted higher than descriptions
  2. Proper query/filter separation - text search is scored, facet filters use filter context for caching
  3. Function scores to blend text relevance with business signals like popularity, recency, and ratings

For fallback handling:

  1. Fuzzy matching for typo tolerance
  2. Progressive filter relaxation
  3. Spelling suggestions

And critically, A/B test every change. Relevance tuning is empirical - you need real user behavior data."

This shows you understand both the techniques AND the process.

Tomorrow's Preview#

Day 4: Advanced Search Features — "Autocomplete, facets, and personalization"

We'll cover:

  • Autocomplete with edge n-grams
  • "Did you mean" spelling correction
  • Faceted search and aggregations
  • Synonyms and multi-language support
  • Search personalization strategies
Preview: the autocomplete challenge
  1. User types: "n" — Suggestions appear: ["nike", "new balance", "north face"]
  2. User types: "ni" — Suggestions update: ["nike", "nike air max", "nintendo"]
  3. User types: "nik" — Suggestions narrow: ["nike", "nike air max", "nike running shoes"]

How fast can you make this? How do you handle millions of products? How do you rank suggestions?

Tomorrow: Day 4 — Advanced Search Features: Autocomplete, facets, and personalization

this lesson in shorts · 3

play them all →

One idea from this lesson in about a minute each, made for your phone.

End of Day 03 · Query Processing & Relevance
Finish Day 3Stages clear as you read them (0/6 so far). Skipping ahead doesn't count: marking complete unlocks after ~8 more min of reading.

Go deeper · reading trail

all reading →

Real write-ups from the teams that built these systems. Each opens on the publisher's site in a new tab.

ElasticBlog post · ~11 min

Practical BM25 - Part 2: The BM25 Algorithm and its Variables

Shane Connelly · 2018
What
A term-by-term walk through the BM25 scoring formula.
Key idea
IDF rewards rare terms, k1 makes term frequency saturate and b normalises for field length; tuning them changes ranking.
Why here
The BM25 chapter of this lesson in more depth, with what each parameter does to real results.
taught here: BM25
AirbnbPaper · ~40 min

Applying Deep Learning To Airbnb Search

Malay Haldar, Mustafa Abdool et al. · 2018
What
Airbnb's account of moving search ranking from gradient boosted trees to neural networks.
Key idea
Gains from the tree model plateaued; the paper describes the journey to deep models, including what failed and the features that mattered.
Why here
Where relevance goes after this lesson's BM25 and boosts: a second-stage ranker trained on bookings.
taught here: Learning to rank
ZomatoBlog post · ~6 min

All new Search and Discovery on Zomato

2019
What
How Zomato unified search across restaurants, dishes, cuisines and collections.
Key idea
Search guesses both the entity and the intent behind a query, mixing explicit signals (what is typed) with implicit ones such as location and time.
Why here
Relevance when one box searches restaurants, dishes and cuisines: this lesson's multi-field search plus intent signals.
taught here: Relevance across entities

New lessons by email

New lessons, side quests and case files as they ship, at most once a week. No spam, unsubscribe anytime.

Discussion · …

Public: everyone reading this lesson sees comments. Be kind and keep it on the material.

Sign in to ask a question or answer one. Reading stays open to everyone.
loading the discussion…