?

Day 0135 minIntro+60 XP

Search Fundamentals & Architecture.

System Design Mastery Series — Building Blocks Week

6 stages8 code

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

Intro · 1 min

Introduction#

Today we answer a fundamental question: "Why is SQL LIKE so slow, and what do search engines do differently?"

The answer lies in a simple but powerful data structure: the inverted index. Understanding this structure is the key to understanding all of search.

Today's Theme: "How search engines think"

We'll cover:

  • Why databases are bad at text search
  • Inverted indexes from first principles
  • Text analysis: turning words into searchable tokens
  • Document modeling for search
  • Elasticsearch architecture
  • Designing our product search system
ExplainerThe whole lesson: search fundamentals

A B-tree on products.name only helps exact and prefix matches, so LIKE '%Running%' scans all 50 million rows in 30+ seconds and matches characters, not words ('%red%' finds Bored Games); the inverted index flips it to word → documents, so "running shoes" is two lookups and an intersection in under 1 ms. Text analysis runs character filters, a tokenizer and token filters (lowercase, stop words, stemming, synonyms), and the same analyzer must run at index and query time. Documents are denormalized with text fields for full-text search, keyword fields for exact match and facets, and multi-fields like brand.keyword; the index is split into shards queried by scatter-gather, about 20 shards of 20-40 GB for 500 GB, with the primary count fixed at creation.

Part I · Stage 1 of 6 · 7 min

Part I: The Problem with Database Search#

Chapter 1: Why SQL LIKE Fails#

Your turn · checkpoint 1not answered

What breaks when search runs on the shop's database?

Product search is built as WHERE name LIKE '%red%' AND name LIKE '%running%' AND name LIKE '%shoes%' on the 50-million-row products table, the same PostgreSQL that checkout uses. Traffic picks up. What happens to that query, and to everything else on the database?

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

One strong answer

  • The query: a leading % gives the index no starting point, so every one of the 50M rows is read and string-matched, several times over. That's 30+ seconds.
  • Everyone else: CPU sits at 100%, the connection pool runs out, and other queries (checkout included) wait behind it. Users leave.
  • So text search needs its own structure, built ahead of time, not a scan of the live table.

1.1 The Query That Kills Your Database#

sql5 lines
-- User searches for "red running shoes"
SELECT * FROM products 
WHERE name LIKE '%red%' 
  AND name LIKE '%running%'
  AND name LIKE '%shoes%';

Let's trace what happens:

Query execution

Table: products (50 million rows)

  1. Step 1: Full Table Scan
    • Database must examine EVERY row
    • No index can help with leading wildcard (%)
    • 50M string comparisons
    • Time: 30+ seconds
  2. Step 2: String Matching
    • For each row, check if 'red' appears anywhere
    • Then check 'running'
    • Then check 'shoes'
    • Multiple passes through same data
    • CPU intensive
  3. Result
    • 30+ seconds query time
    • Database CPU at 100%
    • Connection pool exhausted
    • Other queries blocked
    • Users leave

1.2 Why Indexes Don't Help#

B-tree index limitation

Index on products.name — B-Tree Index:

  • "Apple iPhone 14"
  • "Blue Running Shoes"
  • "Green Tea Kettle"
  • "Nike Air Max"
  • "Red Nike Running Shoes"
  • "Running Shorts Blue"

Index is sorted alphabetically by FULL string.

Can efficiently find
  • WHERE name = 'Nike Air Max' — exact match
  • WHERE name LIKE 'Nike%' — prefix match
Cannot efficiently find
  • WHERE name LIKE '%Running%' — substring
  • WHERE name LIKE '%Shoes%' — substring

Why? B-tree is sorted by string start, not contents

1.3 Additional Problems#

Beyond performance: functionality gaps
1. No relevance ranking
  • Query: "nike shoes"
  • SQL returns results in arbitrary order
  • User wants: Most relevant first
2. No typo tolerance
  • Query: "nikee shoes" (typo)
  • SQL returns: 0 results
  • User expected: Nike products
3. No word boundaries

Query: "red" — SQL LIKE '%red%' matches:

  • "Red Shoes" — ok (correct)
  • "Bored Games" — no (incorrect - "bored" contains "red")
  • "Hundred Items" — no (incorrect)
4. No stemming
  • Query: "running"
  • SQL won't match: "run", "runs"
  • Both should match! (A stemmer reduces "running" and "runs" to "run"; "runner" stays a separate word)
5. No synonyms
  • Query: "couch"
  • SQL won't match: "sofa"
  • Both mean the same thing!
Your turn · checkpoint 2not answered

What would you compute ahead of time?

No B-tree helps with a word in the middle of a product name, and scanning 50M rows per search is too slow. You're allowed to do extra work when a product is written. What would you build then, so that "running shoes" needs no scan at all?

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

One strong answer

  • Flip the question: instead of asking each document "do you contain this word?" at query time, record once, at write time, "for each word, which documents contain it?"
  • At query time that's one lookup per word ("running" → its documents, "shoes" → its documents) and an intersection of the lists, starting with the shortest.
  • The cost moves to indexing, which happens once per change instead of once per search.

Chapter 2: The Inverted Index#

2.1 The Key Insight#

Instead of asking "which documents contain this word?" for every query, we pre-compute and store "for each word, which documents contain it?"

Forward index vs inverted index

Forward index (Traditional Database)

Document → Words

Document Words
Doc1: "Red Nike Running Shoes" [red, nike, running, shoes]
Doc2: "Blue Adidas Running Shoes" [blue, adidas, running, shoes]
Doc3: "Nike Basketball Shoes" [nike, basketball, shoes]

To find "running": Must scan ALL documents

Inverted index (Search Engine)

Word → Documents

Word Documents
red [Doc1]
nike [Doc1, Doc3]
running [Doc1, Doc2]
shoes [Doc1, Doc2, Doc3]
blue [Doc2]
adidas [Doc2]
basketball [Doc3]

To find "running": Instant lookup → [Doc1, Doc2]

2.2 Building an Inverted Index#

pythonPosting · 217 lines
# core/inverted_index.py

from collections import defaultdict
from dataclasses import dataclass, field
from typing import Dict, List, Set, Optional
import re


@dataclass
class Posting:
    """A single occurrence of a term in a document."""
    doc_id: str
    term_frequency: int  # How many times term appears
    positions: List[int] = field(default_factory=list)  # Word positions


@dataclass
class PostingList:
    """All occurrences of a term across documents."""
    term: str
    document_frequency: int = 0  # How many docs contain term
    postings: List[Posting] = field(default_factory=list)


class InvertedIndex:
    """
    A simple inverted index implementation.
    
    This is the core data structure behind Elasticsearch,
    Lucene, and all modern search engines.
    """
    
    def __init__(self):
        # term -> PostingList
        self.index: Dict[str, PostingList] = {}
        
        # doc_id -> document metadata
        self.documents: Dict[str, dict] = {}
        
        # Statistics for scoring
        self.total_docs = 0
        self.avg_doc_length = 0.0
        self._total_terms = 0
    
    def index_document(self, doc_id: str, content: str, metadata: dict = None):
        """
        Index a document.
        
        Steps:
        1. Tokenize content into terms
        2. For each term, add to posting list
        3. Store document metadata
        """
        
        # Tokenize (we'll improve this later)
        terms = self._tokenize(content)
        
        # Count term frequencies
        term_counts = defaultdict(int)
        term_positions = defaultdict(list)
        
        for position, term in enumerate(terms):
            term_counts[term] += 1
            term_positions[term].append(position)
        
        # Update index
        for term, count in term_counts.items():
            if term not in self.index:
                self.index[term] = PostingList(term=term)
            
            posting_list = self.index[term]
            posting_list.document_frequency += 1
            posting_list.postings.append(Posting(
                doc_id=doc_id,
                term_frequency=count,
                positions=term_positions[term]
            ))
        
        # Store document
        self.documents[doc_id] = {
            "content": content,
            "length": len(terms),
            "metadata": metadata or {}
        }
        
        # Update statistics
        self.total_docs += 1
        self._total_terms += len(terms)
        self.avg_doc_length = self._total_terms / self.total_docs
    
    def search(self, query: str) -> List[str]:
        """
        Simple search: find documents containing ALL query terms.
        
        Returns matching doc_ids, unranked (we'll add scoring later).
        """
        
        terms = self._tokenize(query)
        
        if not terms:
            return []
        
        # Get posting lists for all terms
        posting_lists = []
        for term in terms:
            if term not in self.index:
                return []  # Term not found, no results
            posting_lists.append(self.index[term])
        
        # Find intersection (documents containing ALL terms)
        result_docs = self._intersect_posting_lists(posting_lists)
        
        return result_docs
    
    def _intersect_posting_lists(
        self,
        posting_lists: List[PostingList]
    ) -> List[str]:
        """
        Find documents that appear in ALL posting lists.
        
        Optimization: Start with smallest list, intersect with others.
        """
        
        if not posting_lists:
            return []
        
        # Sort by document frequency (smallest first)
        sorted_lists = sorted(
            posting_lists,
            key=lambda pl: pl.document_frequency
        )
        
        # Start with smallest list
        result = {p.doc_id for p in sorted_lists[0].postings}
        
        # Intersect with remaining lists
        for posting_list in sorted_lists[1:]:
            list_docs = {p.doc_id for p in posting_list.postings}
            result = result.intersection(list_docs)
            
            if not result:
                return []  # Early termination
        
        return sorted(result)
    
    def _tokenize(self, text: str) -> List[str]:
        """
        Simple tokenization.
        
        Real search engines use much more sophisticated analysis.
        """
        
        # Lowercase
        text = text.lower()
        
        # Split on non-alphanumeric
        tokens = re.findall(r'\w+', text)
        
        return tokens


# =============================================================================
# Example Usage
# =============================================================================

def demonstrate_inverted_index():
    """Show how inverted index works."""
    
    index = InvertedIndex()
    
    # Index some products
    products = [
        ("p1", "Red Nike Running Shoes", {"price": 120, "brand": "Nike"}),
        ("p2", "Blue Adidas Running Shoes", {"price": 110, "brand": "Adidas"}),
        ("p3", "Nike Basketball Shoes White", {"price": 150, "brand": "Nike"}),
        ("p4", "Red Running Shorts", {"price": 45, "brand": "Nike"}),
        ("p5", "Professional Running Watch", {"price": 299, "brand": "Garmin"}),
    ]
    
    for doc_id, content, metadata in products:
        index.index_document(doc_id, content, metadata)
    
    # Show the index structure
    print("=== INVERTED INDEX ===")
    for term, posting_list in sorted(index.index.items()):
        docs = [p.doc_id for p in posting_list.postings]
        print(f"  {term:15} → {docs}")
    
    # Search examples
    print("\n=== SEARCHES ===")
    
    queries = ["running shoes", "nike", "red running", "basketball"]
    for query in queries:
        results = index.search(query)
        print(f"  '{query}' → {results}")


# Output:
# === INVERTED INDEX ===
#   adidas          → ['p2']
#   basketball      → ['p3']
#   blue            → ['p2']
#   nike            → ['p1', 'p3']
#   professional    → ['p5']
#   red             → ['p1', 'p4']
#   running         → ['p1', 'p2', 'p4', 'p5']
#   shoes           → ['p1', 'p2', 'p3']
#   shorts          → ['p4']
#   watch           → ['p5']
#   white           → ['p3']
#
# === SEARCHES ===
#   'running shoes' → ['p1', 'p2']
#   'nike' → ['p1', 'p3']   (p4's text has no "nike")
#   'red running' → ['p1', 'p4']
#   'basketball' → ['p3']
ExplainerInverted index: build once, query by lookup

Five products are tokenized into term → document lists: running → p1, p2, p4, p5; shoes → p1, p2, p3. The query "running shoes" is two lookups and one intersection, p1 and p2, in under a millisecond, where SQL LIKE makes 50 million string comparisons.

2.3 Why This Is Fast#

Query: "running shoes"
  1. Tokenize query
    • "running shoes" → ["running", "shoes"]
    • Time: microseconds
  2. Look up "running" in index
    • Direct hash lookup
    • Returns: [Doc1, Doc2, Doc4, Doc5]
    • Time: microseconds
  3. Look up "shoes" in index
    • Direct hash lookup
    • Returns: [Doc1, Doc2, Doc3]
    • Time: microseconds
  4. Intersect results
    • [Doc1, Doc2, Doc4, Doc5] ∩ [Doc1, Doc2, Doc3]
    • Result: [Doc1, Doc2]
    • Time: microseconds

Total time: < 1 millisecond

Compare to SQL LIKE: 50 million string comparisons · multiple passes · time: 30+ seconds

Quick check3 questions · +5 XP each

Why databases struggle with text search

  1. Q1/3 You add a B-tree index on products.name, but WHERE name LIKE '%running%' still scans the whole table. Why?

    Answer & explanation

    Answer: B

    The B-tree finds exact matches and prefixes like 'Nike%' because it's ordered by how strings begin. A substring could be anywhere in the value, so every row has to be checked.

  2. Q2/3 A user searches "red" and LIKE '%red%' returns "Bored Games". Which gap does this show?

    Answer & explanation

    Answer: A

    LIKE matches characters, not words, so "bored" contains "red". Stemming would be "run" vs "running"; synonyms would be "couch" vs "sofa".

  3. Q3/3 Why does an inverted index answer "running shoes" in well under a millisecond?

    Answer & explanation

    Answer: D

    The work of finding which documents contain each word happens once, at index time. At query time it's two lookups and an intersection (smallest list first) instead of scanning 50M rows.

Part II · Stage 2 of 6 · 6 min

Part II: Text Analysis#

Chapter 3: From Text to Tokens#

The inverted index is only as good as the tokens we create. The text analysis pipeline transforms raw text into searchable tokens.

3.1 The Analysis Pipeline#

DiagramText analysis pipeline
%% title: Text analysis pipeline
flowchart TD
  IN(["Input: #quot;The Quick Brown Fox Jumps Over the Lazy Dog#quot;"]):::client
  IN --> CF["Step 1 · Character filters<br/>remove HTML: #lt;b#gt;text#lt;/b#gt; → text<br/>convert special chars: café → cafe"]:::core
  CF -->|"#quot;The Quick Brown Fox Jumps Over the Lazy Dog#quot;"| TK["Step 2 · Tokenizer<br/>split on whitespace/punctuation"]:::core
  TK -->|"[The, Quick, Brown, Fox, Jumps,<br/>Over, the, Lazy, Dog]"| TF["Step 3 · Token filters<br/>lowercase: THE → the<br/>stop words removal: the, over → removed<br/>stemming: jumps → jump"]:::core
  TF -->|"[quick, brown, fox, jump, lazi, dog]"| OUT[("Final tokens indexed:<br/>#quot;quick#quot;, #quot;brown#quot;, #quot;fox#quot;,<br/>#quot;jump#quot;, #quot;lazi#quot;, #quot;dog#quot;")]:::store

3.2 Text Analyzer Implementation#

pythonCharacterFilter · 284 lines
# core/analyzer.py

from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List, Optional
import re


class CharacterFilter(ABC):
    """Transforms characters before tokenization."""
    
    @abstractmethod
    def filter(self, text: str) -> str:
        pass


class HTMLStripFilter(CharacterFilter):
    """Remove HTML tags."""
    
    def filter(self, text: str) -> str:
        return re.sub(r'<[^>]+>', '', text)


class MappingCharFilter(CharacterFilter):
    """Map characters to replacements."""
    
    def __init__(self, mappings: dict):
        self.mappings = mappings
    
    def filter(self, text: str) -> str:
        for old, new in self.mappings.items():
            text = text.replace(old, new)
        return text


class Tokenizer(ABC):
    """Splits text into tokens."""
    
    @abstractmethod
    def tokenize(self, text: str) -> List[str]:
        pass


class StandardTokenizer(Tokenizer):
    """
    Standard tokenization on word boundaries.
    
    Handles:
    - Whitespace
    - Punctuation
    - Numbers
    """
    
    def tokenize(self, text: str) -> List[str]:
        # Split on non-alphanumeric, keeping numbers
        tokens = re.findall(r'\b\w+\b', text)
        return tokens


class WhitespaceTokenizer(Tokenizer):
    """Simple whitespace tokenization."""
    
    def tokenize(self, text: str) -> List[str]:
        return text.split()


class TokenFilter(ABC):
    """Transforms individual tokens."""
    
    @abstractmethod
    def filter(self, tokens: List[str]) -> List[str]:
        pass


class LowercaseFilter(TokenFilter):
    """Convert to lowercase."""
    
    def filter(self, tokens: List[str]) -> List[str]:
        return [t.lower() for t in tokens]


class StopWordsFilter(TokenFilter):
    """Remove common stop words."""
    
    DEFAULT_STOP_WORDS = {
        'a', 'an', 'the', 'and', 'or', 'but', 'in', 'on', 'at',
        'to', 'for', 'of', 'with', 'by', 'from', 'is', 'are',
        'was', 'were', 'be', 'been', 'being', 'have', 'has',
        'had', 'do', 'does', 'did', 'will', 'would', 'could',
        'should', 'may', 'might', 'must', 'shall', 'can',
        'this', 'that', 'these', 'those', 'it', 'its',
    }
    
    def __init__(self, stop_words: set = None):
        self.stop_words = stop_words or self.DEFAULT_STOP_WORDS
    
    def filter(self, tokens: List[str]) -> List[str]:
        return [t for t in tokens if t.lower() not in self.stop_words]


class PorterStemmer(TokenFilter):
    """
    Apply Porter Stemming algorithm.
    
    Reduces words to their root form:
    - running → run
    - shoes → shoe
    - jumps → jump
    """
    
    def filter(self, tokens: List[str]) -> List[str]:
        return [self._stem(t) for t in tokens]
    
    def _stem(self, word: str) -> str:
        """Simplified Porter Stemmer rules."""
        
        # Common suffix removal (simplified)
        suffixes = [
            ('ational', 'ate'),
            ('tional', 'tion'),
            ('enci', 'ence'),
            ('anci', 'ance'),
            ('izer', 'ize'),
            ('isation', 'ize'),
            ('ization', 'ize'),
            ('ation', 'ate'),
            ('ator', 'ate'),
            ('alism', 'al'),
            ('iveness', 'ive'),
            ('fulness', 'ful'),
            ('ousness', 'ous'),
            ('aliti', 'al'),
            ('iviti', 'ive'),
            ('biliti', 'ble'),
            ('ling', 'l'),
            ('ing', ''),
            ('ies', 'y'),
            ('sses', 'ss'),
            ('ss', 'ss'),
            ('ed', ''),
            ('s', ''),
        ]
        
        for suffix, replacement in suffixes:
            if word.endswith(suffix) and len(word) > len(suffix) + 2:
                stem = word[:-len(suffix)] + replacement
                # Undouble after -ing/-ed: "runn" → "run"
                if suffix in ('ing', 'ed') and stem[-1] == stem[-2] and stem[-1] not in 'lsz':
                    stem = stem[:-1]
                return stem
        
        return word


class SynonymFilter(TokenFilter):
    """
    Expand tokens with synonyms.
    
    "couch" → ["couch", "sofa"]
    """
    
    def __init__(self, synonyms: dict):
        self.synonyms = synonyms
    
    def filter(self, tokens: List[str]) -> List[str]:
        result = []
        for token in tokens:
            result.append(token)
            if token in self.synonyms:
                result.extend(self.synonyms[token])
        return result


@dataclass
class Analyzer:
    """
    Complete text analysis pipeline.
    
    Combines character filters, tokenizer, and token filters.
    """
    
    char_filters: List[CharacterFilter]
    tokenizer: Tokenizer
    token_filters: List[TokenFilter]
    
    def analyze(self, text: str) -> List[str]:
        """Run text through the analysis pipeline."""
        
        # Apply character filters
        for char_filter in self.char_filters:
            text = char_filter.filter(text)
        
        # Tokenize
        tokens = self.tokenizer.tokenize(text)
        
        # Apply token filters
        for token_filter in self.token_filters:
            tokens = token_filter.filter(tokens)
        
        return tokens


# =============================================================================
# Pre-configured Analyzers
# =============================================================================

def create_standard_analyzer() -> Analyzer:
    """Standard analyzer for most use cases."""
    return Analyzer(
        char_filters=[
            HTMLStripFilter(),
        ],
        tokenizer=StandardTokenizer(),
        token_filters=[
            LowercaseFilter(),
            StopWordsFilter(),
        ]
    )


def create_product_analyzer() -> Analyzer:
    """Analyzer optimized for product search."""
    return Analyzer(
        char_filters=[
            HTMLStripFilter(),
            MappingCharFilter({
                '&': ' and ',
                '+': ' plus ',
            }),
        ],
        tokenizer=StandardTokenizer(),
        token_filters=[
            LowercaseFilter(),
            StopWordsFilter(),
            PorterStemmer(),
            SynonymFilter({
                'tv': ['television'],
                'phone': ['mobile', 'smartphone'],
                'laptop': ['notebook'],
                'couch': ['sofa'],
                'sneakers': ['shoes', 'trainers'],
            }),
        ]
    )


# =============================================================================
# Example Usage
# =============================================================================

def demonstrate_analysis():
    """Show how text analysis works."""
    
    analyzer = create_product_analyzer()
    
    texts = [
        "Red Nike Running Shoes",
        "The <b>Quick</b> Brown Fox",
        "Professional TV & Entertainment Center",
        "Comfortable Leather Couch",
    ]
    
    print("=== TEXT ANALYSIS ===")
    for text in texts:
        tokens = analyzer.analyze(text)
        print(f"  '{text}'")
        print(f"    → {tokens}")
        print()


# Output:
# === TEXT ANALYSIS ===
#   'Red Nike Running Shoes'
#     → ['red', 'nike', 'run', 'shoe']
#
#   'The <b>Quick</b> Brown Fox'
#     → ['quick', 'brown', 'fox']
#
#   'Professional TV & Entertainment Center'
#     → ['professional', 'tv', 'television', 'entertainment', 'center']
#     ('&' became 'and', which the stop-word filter then removed)
#
#   'Comfortable Leather Couch'
#     → ['comfortable', 'leather', 'couch', 'sofa']

3.3 Why Analysis Matters#

Same query, different results — query: "running shoes"

Without proper analysis

  • Tokens: ["running", "shoes"]
  • Index contains: ["Running", "SHOES", "shoe", "runs"]
  • Match: None (case mismatch, no stemming)
  • User sees: "No results found"

With proper analysis

  • Query tokens: ["run", "shoe"] (stemmed)
  • Index contains: ["run", "shoe"] (everything stemmed at index time)
  • Match: All running/shoes products
  • User sees: Relevant results

Key insight: the same analyzer MUST be used at:

  1. Index time (when storing documents)
  2. Query time (when searching)

Otherwise tokens won't match!

Quick check2 questions · +5 XP each

Text analysis

  1. Q1/2 Documents are indexed with lowercasing and stemming, but queries are only split on whitespace. A user searches "Running Shoes". What happens?

    Answer & explanation

    Answer: C

    The index holds run and shoe; the query asks for Running and Shoes. That's why the same analyzer must run at index time and at query time.

  2. Q2/2 In the analysis pipeline, which order do the stages run in?

    Answer & explanation

    Answer: D

    Character filters clean the raw string (e.g. strip HTML), the tokenizer splits it, and token filters change each token (lowercase, stop words, stemming, synonyms). Token filters need tokens, so they can't run first.

Part III · Stage 3 of 6 · 5 min

Part III: Document Modeling#

4.1 The Product Document Schema#

python177 lines
# schemas/product_document.py

"""
Product document schema for Elasticsearch.

Design principles:
1. Denormalize for search performance
2. Use appropriate field types
3. Configure analysis per field
4. Store what you need to display
"""

PRODUCT_MAPPING = {
    "mappings": {
        "properties": {
            # Identifiers
            "product_id": {
                "type": "keyword"  # Exact match only, no analysis
            },
            "sku": {
                "type": "keyword"
            },
            
            # Searchable text fields
            "name": {
                "type": "text",  # Full-text search
                "analyzer": "product_analyzer",
                "fields": {
                    "keyword": {
                        "type": "keyword"  # For sorting/aggregations
                    },
                    "autocomplete": {
                        "type": "text",
                        "analyzer": "autocomplete_analyzer",
                        "search_analyzer": "standard"
                    }
                }
            },
            "description": {
                "type": "text",
                "analyzer": "product_analyzer"
            },
            "brand": {
                "type": "text",
                "analyzer": "standard",
                "fields": {
                    "keyword": {
                        "type": "keyword"  # For faceting
                    }
                }
            },
            
            # Categorical fields (exact match, faceting)
            "category": {
                "type": "keyword"
            },
            "subcategory": {
                "type": "keyword"
            },
            "tags": {
                "type": "keyword"  # Array of tags
            },
            "color": {
                "type": "keyword"
            },
            "size": {
                "type": "keyword"
            },
            
            # Numeric fields
            "price": {
                "type": "float"
            },
            "sale_price": {
                "type": "float"
            },
            "discount_percent": {
                "type": "integer"
            },
            "rating": {
                "type": "float"
            },
            "review_count": {
                "type": "integer"
            },
            "stock_quantity": {
                "type": "integer"
            },
            
            # Boolean fields
            "in_stock": {
                "type": "boolean"
            },
            "is_featured": {
                "type": "boolean"
            },
            "is_new": {
                "type": "boolean"
            },
            
            # Date fields
            "created_at": {
                "type": "date"
            },
            "updated_at": {
                "type": "date"
            },
            
            # Geo field (for location-based search)
            "warehouse_location": {
                "type": "geo_point"
            },
            
            # Nested for complex structures
            "attributes": {
                "type": "nested",
                "properties": {
                    "name": {"type": "keyword"},
                    "value": {"type": "keyword"}
                }
            },
            
            # For boosting/scoring
            "popularity_score": {
                "type": "float"
            },
            "sales_rank": {
                "type": "integer"
            }
        }
    },
    
    "settings": {
        "index": {
            "number_of_shards": 5,
            "number_of_replicas": 1
        },
        "analysis": {
            "analyzer": {
                "product_analyzer": {
                    "type": "custom",
                    "tokenizer": "standard",
                    "filter": [
                        "lowercase",
                        "product_synonyms",
                        "porter_stem"
                    ]
                },
                "autocomplete_analyzer": {
                    "type": "custom",
                    "tokenizer": "standard",
                    "filter": [
                        "lowercase",
                        "edge_ngram_filter"
                    ]
                }
            },
            "filter": {
                "product_synonyms": {
                    "type": "synonym",
                    "synonyms": [
                        "tv, television",
                        "phone, mobile, smartphone",
                        "laptop, notebook",
                        "couch, sofa",
                        "sneakers, trainers, shoes"
                    ]
                },
                "edge_ngram_filter": {
                    "type": "edge_ngram",
                    "min_gram": 2,
                    "max_gram": 15
                }
            }
        }
    }
}
Your turn · checkpoint 3not answered

Pick a type for four fields

For the product index, choose a field type for product_id, name, color and price. Which one should be analyzed into tokens, and what goes wrong if you analyze the others?

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

One strong answer

  • name → text: it's searched by words, so it's tokenized, lowercased and stemmed.
  • product_id → keyword: IDs are matched exactly, never by range or by word, even when they look numeric.
  • color → keyword: it's filtered and counted in facets as a whole value; analyzed text is the wrong tool for exact facets and sorting.
  • price → a numeric type: it needs range queries, sorting and price-band aggregations.
  • Analyze the wrong field and a facet counts tokens instead of values, or an exact ID lookup stops being exact.

4.2 Field Type Selection Guide#

Elasticsearch field types
  • KEYWORD — exact match
    • Use for: IDs, categories, tags, status
    • Not analyzed (stored as-is)
    • Supports: exact match, aggregations, sorting
    • Example: product_id, brand.keyword, color
    • DON'T use for: full-text search
  • TEXT — full-text search
    • Use for: names, descriptions, content
    • Analyzed (tokenized, normalized)
    • Supports: full-text queries, relevance
    • Example: name, description
    • DON'T use for: exact match, sorting
  • NUMERIC — integer, float, long
    • Use for: prices, quantities, scores
    • Supports: range queries, sorting, aggregations
    • Example: price, rating, stock_quantity
    • DON'T use for: IDs (use keyword)
  • DATE
    • Use for: timestamps
    • Supports: range queries, date math
    • Example: created_at, updated_at
    • Format: ISO 8601 recommended
  • BOOLEAN
    • Use for: flags
    • Example: in_stock, is_featured
    • Efficient for filtering
  • GEO_POINT
    • Use for: location data
    • Supports: distance queries, geo aggregations
    • Example: warehouse_location
    • Format: {lat, lon} or "lat,lon"
  • NESTED
    • Use for: arrays of objects needing independent queries
    • Example: product variants, attributes
    • More expensive (each nested doc is separate Lucene doc)
    • Use sparingly

4.3 Multi-Field Pattern#

Multi-field pattern

One source field, multiple index representations:

javascript12 lines
"brand": {
    "type": "text",           // Full-text search
    "fields": {
        "keyword": {
            "type": "keyword" // Exact match, aggregations
        },
        "autocomplete": {
            "type": "text",
            "analyzer": "autocomplete_analyzer"
        }
    }
}
Usage
  • Search: query on "brand" (analyzed text)
  • Facet: aggregate on "brand.keyword" (exact)
  • Sort: sort by "brand.keyword" (exact)
  • Autocomplete: query on "brand.autocomplete"

This is a VERY common pattern in production.

Quick check2 questions · +5 XP each

Document modeling

  1. Q1/2 brand needs full-text search ("nike running"), facet counts and sorting. What's the standard mapping?

    Answer & explanation

    Answer: B

    The multi-field pattern indexes one source field several ways. keyword alone can't do full-text matching, and analyzed text is the wrong tool for exact facets and sorting.

  2. Q2/2 A teammate maps product_id as integer because the IDs look numeric. What does the lesson recommend?

    Answer & explanation

    Answer: C

    Numeric types are for prices, quantities and scores that you range-query, sort or aggregate. IDs are only matched exactly, which is what keyword is for.

Part IV · Stage 4 of 6 · 3 min

Part IV: Elasticsearch Architecture#

Chapter 5: How Elasticsearch Works#

5.1 Core Concepts#

DiagramElasticsearch architecture
%% title: Elasticsearch architecture
flowchart TD
  subgraph CL["Elasticsearch cluster"]
    direction TB
    subgraph IDX["Index (like a database table)"]
      direction TB
      P0[("Shard 0 (Primary)")]:::store --> R0[("Shard 0 (Replica)")]:::cache
      P1[("Shard 1 (Primary)")]:::store --> R1[("Shard 1 (Replica)")]:::cache
      P2[("Shard 2 (Primary)")]:::store --> R2[("Shard 2 (Replica)")]:::cache
      P3[("Shard 3 (Primary)")]:::store --> R3[("Shard 3 (Replica)")]:::cache
    end
    subgraph NODES["Nodes"]
      direction TB
      N1@{ icon: "sd:server", label: "Node 1 (Data)" }
      N2@{ icon: "sd:server", label: "Node 2 (Data)" }
      N3@{ icon: "sd:server", label: "Node 3 (Data)" }
      N4["Node 4 (Master)"]:::core
      N5@{ icon: "sd:router", label: "Node 5 (Coordin)" }
    end
  end
  IDX ~~~ NODES
  class N1,N2,N3 service
  class N5 edge

Terminology:

  • Cluster: Group of nodes working together
  • Node: Single Elasticsearch server
  • Index: Collection of documents (like a table)
  • Shard: Horizontal partition of an index
  • Replica: Copy of a shard for redundancy
  • Document: Single record (like a row)

5.2 Node Roles#

Node roles

Master node

  • Cluster management (creating/deleting indexes)
  • Shard allocation decisions
  • Cluster state management
  • Should be lightweight (no data)
  • Recommend: 3 dedicated master nodes

Data node

  • Stores shards
  • Executes searches and indexing
  • Memory and disk intensive
  • Scale horizontally for more capacity

Coordinating node

  • Routes requests to appropriate shards
  • Gathers and merges results
  • Useful for heavy aggregations
  • Also called "client node"

Ingest node

  • Pre-processes documents before indexing
  • Runs ingest pipelines
  • Useful for data transformation

5.3 Query Execution Flow#

DiagramSearch query flow
%% title: Search query flow
%% steps:
%% C --> CN: The query reaches a coordinating node, which works out which shards to ask
%% CN --> S0, CN --> S1, CN --> S2, CN --> S3: Scatter: each shard (primary or replica) searches its local inverted index
%% S0 --> G, S1 --> G, S2 --> G, S3 --> G: Each shard sends back only its own top N, with scores
%% G --> F: Gather: the coordinating node merges, sorts by score and returns the overall top N
flowchart TD
  C@{ icon: "sd:browser", label: "Client<br/>GET /products/_search?q=running+shoes" }
  C -->|"1 · request"| CN["Coordinating node<br/>parses query, determines<br/>which shards to query"]:::core
  subgraph SCATTER["2 · Scatter phase (primary or replica)"]
    S0@{ icon: "sd:index", label: "Shard 0<br/>local inverted index" }
    S1@{ icon: "sd:index", label: "Shard 1<br/>local inverted index" }
    S2@{ icon: "sd:index", label: "Shard 2<br/>local inverted index" }
    S3@{ icon: "sd:index", label: "Shard 3<br/>local inverted index" }
  end
  CN --> S0 & S1 & S2 & S3
  S0 -->|"3 · top N: doc1 0.95, doc5 0.87, doc9 0.82"| G
  S1 -->|"doc2 0.91, doc7 0.84"| G
  S2 -->|"doc3 0.89, doc8 0.81"| G
  S3 -->|"doc4 0.88, doc6 0.85"| G
  G["4 · Gather phase<br/>coordinating node collects,<br/>merges and sorts by score"]:::hot
  G -->|"returns top N"| F(["Final: doc1 0.95, doc2 0.91,<br/>doc3 0.89, doc4 0.88, ..."]):::ok
  class C client
  class S0,S1,S2,S3 store
  1. The query reaches a coordinating node, which works out which shards to ask
  2. Scatter: each shard (primary or replica) searches its local inverted index
  3. Each shard sends back only its own top N, with scores
  4. Gather: the coordinating node merges, sorts by score and returns the overall top N
Your turn · checkpoint 4not answered

Two huge shards or five hundred tiny ones?

The products index will hold about 500 GB. One teammate wants 2 primary shards of 250 GB ("fewer moving parts"); another wants 500 shards of 1 GB ("maximum parallelism"). What does each choice cost, and which decision is hard to undo later?

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

One strong answer

  • 2 huge shards: each one is too big to recover quickly after a node failure, the work can't spread across more than a couple of nodes, and load ends up unbalanced.
  • 500 tiny shards: every shard costs memory and file handles, and every query has to be coordinated across all of them, so queries get slower. Oversharding is a common mistake.
  • The middle: 20-40 GB per shard, so about 20 primaries here.
  • Hard to undo: the primary count is fixed when the index is created. Changing it means _split/_shrink into a new index or a full reindex, so plan for growth and use aliases.

5.4 Shard Sizing Guidelines#

Shard sizing
Rule of thumb
  • 20-40 GB per shard (sweet spot)
  • Max ~50 GB per shard
  • Min 1 GB per shard (avoid too small)
  • Keep each shard under ~200M documents
Example for 500 GB index
  • 500 GB / 30 GB per shard = ~17 shards
  • Round to nice number: 20 shards
  • With 1 replica: 40 total shard copies
  • Distributed across data nodes
Too few shards
  • Individual shards too large
  • Can't scale horizontally
  • Recovery takes too long
  • Unbalanced load
Too many shards
  • Overhead per shard (memory, file handles)
  • More coordination needed
  • Query performance degrades
  • "Oversharding" is common mistake
Primary shard count is fixed at creation
  • Plan for growth (changing it needs _split/_shrink into a new index, or a reindex)
  • Use index aliases for flexibility
  • Consider time-based indices for logs
Quick check3 questions · +5 XP each

Elasticsearch architecture

  1. Q1/3 A query for "running shoes" hits an index with 4 primary shards. What does the coordinating node do?

    Answer & explanation

    Answer: A

    This is scatter-gather. Each shard has its own inverted index over its own documents, so all of them (primary or replica copies) are asked, and the coordinator merges the partial top-N lists.

  2. Q2/3 You estimate a 500 GB index. Which primary shard count fits the lesson's sizing guidance?

    Answer & explanation

    Answer: D

    The sweet spot is 20-40 GB per shard: 500 / 30 is about 17, rounded up to 20. Huge shards recover slowly and can't spread load; hundreds of tiny ones add per-shard overhead (oversharding).

  3. Q3/3 Why does the lesson say to plan primary shard count for growth up front?

    Answer & explanation

    Answer: B

    Primary shard count is set when the index is created; changing it means _split/_shrink (which build a new index) or a full reindex, which is why the lesson recommends aliases and planning for growth. Replica count can be changed at any time.

Part V · Stage 5 of 6 · 7 min

Part V: System Design#

Chapter 6: Product Search Architecture#

6.1 High-Level Architecture#

pythonSearchRequest · 288 lines
# architecture/search_system.py

"""
Product Search System Architecture

Components:
1. Search API - handles user queries
2. Indexing Pipeline - keeps search fresh
3. Elasticsearch Cluster - stores and searches
4. Query Service - builds and optimizes queries
5. Suggestion Service - autocomplete
"""

from dataclasses import dataclass
from typing import List, Optional, Dict, Any


@dataclass
class SearchRequest:
    """User search request."""
    query: str
    filters: Dict[str, Any] = None  # category, brand, price_range
    sort: str = "relevance"  # relevance, price_asc, price_desc, rating
    page: int = 1
    page_size: int = 20


@dataclass
class SearchResult:
    """Search response."""
    products: List[dict]
    total: int
    facets: Dict[str, List[dict]]  # category counts, brand counts, etc.
    suggestions: List[str]  # "did you mean" suggestions
    took_ms: int


class ProductSearchService:
    """
    Main search service coordinating all components.
    """
    
    def __init__(
        self,
        es_client,
        query_builder,
        suggestion_service,
        cache
    ):
        self.es = es_client
        self.query_builder = query_builder
        self.suggestions = suggestion_service
        self.cache = cache
        
        self.index_name = "products"
    
    async def search(self, request: SearchRequest) -> SearchResult:
        """
        Execute product search.
        
        Flow:
        1. Check cache for identical query
        2. Build Elasticsearch query
        3. Execute search
        4. Process and return results
        """
        
        # Check cache
        cache_key = self._build_cache_key(request)
        cached = await self.cache.get(cache_key)
        if cached:
            return cached
        
        # Build query
        es_query = self.query_builder.build(request)
        
        # Execute search
        response = await self.es.search(
            index=self.index_name,
            body=es_query
        )
        
        # Process results
        result = self._process_response(response, request)
        
        # Cache results (short TTL for freshness)
        await self.cache.set(cache_key, result, ttl=60)
        
        return result
    
    def _process_response(
        self,
        response: dict,
        request: SearchRequest
    ) -> SearchResult:
        """Process Elasticsearch response."""
        
        hits = response["hits"]
        
        # Extract products
        products = [
            {
                "product_id": hit["_id"],
                "score": hit["_score"],
                **hit["_source"]
            }
            for hit in hits["hits"]
        ]
        
        # Extract facets
        facets = {}
        if "aggregations" in response:
            for agg_name, agg_data in response["aggregations"].items():
                facets[agg_name] = [
                    {"value": bucket["key"], "count": bucket["doc_count"]}
                    for bucket in agg_data.get("buckets", [])
                ]
        
        return SearchResult(
            products=products,
            total=hits["total"]["value"],
            facets=facets,
            suggestions=[],
            took_ms=response["took"]
        )
    
    def _build_cache_key(self, request: SearchRequest) -> str:
        """Build cache key from request."""
        import hashlib
        import json
        
        key_data = {
            "q": request.query,
            "f": request.filters,
            "s": request.sort,
            "p": request.page,
            "ps": request.page_size
        }
        
        key_str = json.dumps(key_data, sort_keys=True)
        return f"search:{hashlib.md5(key_str.encode()).hexdigest()}"


class QueryBuilder:
    """
    Builds Elasticsearch queries from search requests.
    """
    
    def build(self, request: SearchRequest) -> dict:
        """Build ES query DSL."""
        
        query = {
            "query": self._build_query(request),
            "from": (request.page - 1) * request.page_size,
            "size": request.page_size,
            "aggs": self._build_aggregations(),
            "highlight": self._build_highlight(),
        }
        
        # Add sorting
        if request.sort != "relevance":
            query["sort"] = self._build_sort(request.sort)
        
        return query
    
    def _build_query(self, request: SearchRequest) -> dict:
        """Build the query clause."""
        
        must = []
        filter_clauses = []
        
        # Full-text search on query
        if request.query:
            must.append({
                "multi_match": {
                    "query": request.query,
                    "fields": [
                        "name^3",        # Boost name matches
                        "brand^2",       # Boost brand matches
                        "description",
                        "category",
                        "tags"
                    ],
                    "type": "best_fields",
                    "fuzziness": "AUTO"  # Typo tolerance
                }
            })
        
        # Apply filters
        if request.filters:
            for field, value in request.filters.items():
                if field == "price_range":
                    filter_clauses.append({
                        "range": {
                            "price": {
                                "gte": value.get("min", 0),
                                "lte": value.get("max", 999999)
                            }
                        }
                    })
                elif field == "in_stock":
                    filter_clauses.append({
                        "term": {"in_stock": value}
                    })
                else:
                    # Keyword filter: category is already keyword;
                    # brand is text, so use its .keyword sub-field
                    es_field = f"{field}.keyword" if field == "brand" else field
                    if isinstance(value, list):
                        filter_clauses.append({
                            "terms": {es_field: value}
                        })
                    else:
                        filter_clauses.append({
                            "term": {es_field: value}
                        })
        
        # Filter to in-stock by default (unless the caller chose)
        if not (request.filters and "in_stock" in request.filters):
            filter_clauses.append({"term": {"in_stock": True}})
        
        return {
            "bool": {
                "must": must if must else [{"match_all": {}}],
                "filter": filter_clauses
            }
        }
    
    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_to_50", "from": 25, "to": 50},
                        {"key": "50_to_100", "from": 50, "to": 100},
                        {"key": "100_to_200", "from": 100, "to": 200},
                        {"key": "over_200", "from": 200}
                    ]
                }
            },
            "ratings": {
                "terms": {
                    "field": "rating",
                    "size": 5
                }
            }
        }
    
    def _build_highlight(self) -> dict:
        """Build highlight configuration."""
        
        return {
            "fields": {
                "name": {},
                "description": {"fragment_size": 150}
            },
            "pre_tags": ["<em>"],
            "post_tags": ["</em>"]
        }
    
    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": [{"sales_rank": "asc"}],
        }
        
        return sort_map.get(sort, [{"_score": "desc"}])

6.2 API Layer#

pythonsearch · 90 lines
# api/search_api.py

from fastapi import FastAPI, Query, HTTPException
from typing import Optional, List
import logging

logger = logging.getLogger(__name__)

app = FastAPI()


@app.get("/search")
async def search(
    q: str = Query(..., min_length=1, max_length=200),
    category: Optional[str] = None,
    brand: Optional[List[str]] = Query(None),
    min_price: Optional[float] = None,
    max_price: Optional[float] = None,
    in_stock: Optional[bool] = True,
    sort: str = Query("relevance", pattern="^(relevance|price_asc|price_desc|rating|newest)$"),
    page: int = Query(1, ge=1, le=100),
    page_size: int = Query(20, ge=1, le=100),
):
    """
    Search products.
    
    Examples:
    - /search?q=running+shoes
    - /search?q=nike&category=shoes&min_price=50&max_price=150
    - /search?q=laptop&brand=Apple&brand=Dell&sort=price_asc
    """
    
    # Build filters
    filters = {}
    if category:
        filters["category"] = category
    if brand:
        filters["brand"] = brand
    if min_price is not None or max_price is not None:
        filters["price_range"] = {
            "min": min_price or 0,
            "max": max_price or 999999
        }
    if in_stock is not None:
        filters["in_stock"] = in_stock
    
    # Create request
    request = SearchRequest(
        query=q,
        filters=filters,
        sort=sort,
        page=page,
        page_size=page_size
    )
    
    try:
        result = await search_service.search(request)
        
        return {
            "query": q,
            "total": result.total,
            "page": page,
            "page_size": page_size,
            "took_ms": result.took_ms,
            "products": result.products,
            "facets": result.facets,
        }
        
    except Exception as e:
        logger.error(f"Search failed: {e}", exc_info=True)
        raise HTTPException(status_code=500, detail="Search temporarily unavailable")


@app.get("/autocomplete")
async def autocomplete(
    q: str = Query(..., min_length=1, max_length=100),
    limit: int = Query(10, ge=1, le=20),
):
    """
    Autocomplete suggestions as user types.
    
    Example: /autocomplete?q=run → ["running shoes", "running watch", ...]
    """
    
    suggestions = await suggestion_service.get_suggestions(q, limit)
    
    return {
        "query": q,
        "suggestions": suggestions
    }
Quick check2 questions · +5 XP each

Search service design

  1. Q1/2 The query builder puts the user's text in must and category, price and stock in filter. Why split them?

    Answer & explanation

    Answer: C

    The full-text match produces the relevance score that ranks results. Category and price are yes/no constraints, so they belong in filter, where they don't affect scoring.

  2. Q2/2 Search results are cached in Redis with a 60-second TTL. What trade-off does the short TTL make?

    Answer & explanation

    Answer: A

    A short TTL lets popular queries skip the cluster while capping how stale a price or stock change can look. It doesn't remove staleness or replace keeping the index fresh.

Part VI · Stage 6 of 6 · 6 min

Part VI: Estimation & Sizing#

Chapter 7: Cluster Sizing#

Your turn · checkpoint 5not answered

Before the planner: how big is the cluster?

The catalog has 50 million products at about 10 KB each. Assume index structures add 30%, you keep one replica, shards target ~30 GB, and each data node holds about 7 shard copies. How much raw data and disk is that, how many primary shards, and how many data nodes?

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

One strong answer

  • Raw data: 50M × 10 KB ≈ 500 GB.
  • Disk: × 1.3 for index overhead ≈ 650 GB, × 2 for the replica ≈ 1.3 TB.
  • Shards: 500 ÷ 30 ≈ 17, rounded up to 20 primaries, so 40 shard copies with the replica.
  • Nodes: 40 ÷ 7 ≈ 5.7, so 6 data nodes, plus 3 dedicated masters.

7.1 Capacity Planning#

pythonestimate_cluster_size · 129 lines
# planning/capacity.py

"""
Capacity planning for product search cluster.
"""

import math

def estimate_cluster_size(
    total_documents: int,
    avg_doc_size_kb: float,
    queries_per_second: int,
    indexing_per_second: int,
    retention_days: int = 0,  # 0 for no time-based rotation
    replica_count: int = 1,
) -> dict:
    """
    Estimate Elasticsearch cluster requirements.
    """
    
    # Storage calculation
    raw_data_gb = (total_documents * avg_doc_size_kb) / (1024 * 1024)
    
    # ES overhead (index structures, etc.) ~20-30%
    es_overhead = 1.3
    index_size_gb = raw_data_gb * es_overhead
    
    # With replicas
    total_storage_gb = index_size_gb * (1 + replica_count)
    
    # Shard calculation (§5.4): raw data / 30 GB, rounded up to a
    # nice multiple of 5. ~500 GB -> ~17 -> 20 shards, ~31 GB each on
    # disk once the index overhead is added (inside the 20-40 GB sweet spot)
    target_shard_size_gb = 30  # Sweet spot
    primary_shards = max(1, math.ceil(raw_data_gb / target_shard_size_gb / 5) * 5)
    total_shards = primary_shards * (1 + replica_count)
    
    # Node calculation
    # Elastic's older guidance: at most ~20 shards per GB of heap;
    # we stay far below that
    shards_per_node = 7  # Conservative: small nodes recover quickly
    data_nodes_for_shards = max(3, (total_shards + shards_per_node - 1) // shards_per_node)
    
    # Memory: Want index to mostly fit in OS cache
    # ES heap: at most 50% of RAM, ~26 GB max (compressed-oops limit)
    # OS cache: the rest of a 64 GB node
    heap_per_node_gb = 26
    os_cache_per_node_gb = 38
    
    storage_per_node_gb = total_storage_gb / data_nodes_for_shards
    # Aim for RAM of at least ~25% of the data on each node (about half
    # of it as OS cache for the hot part of the index)
    ram_per_node_gb = max(64, storage_per_node_gb / 4)
    
    # CPU: Depends on query complexity
    # Rule of thumb: 1 core per 100-200 QPS for simple queries;
    # this quick estimate takes the conservative end (100), Day 5's
    # planner uses the midpoint (150)
    total_cores_needed = max(16, queries_per_second // 100)
    cores_per_node = max(8, total_cores_needed // data_nodes_for_shards)
    
    return {
        "storage": {
            "raw_data_gb": round(raw_data_gb, 1),
            "index_size_gb": round(index_size_gb, 1),
            "total_with_replicas_gb": round(total_storage_gb, 1),
        },
        "shards": {
            "primary_shards": primary_shards,
            "replica_shards": primary_shards * replica_count,
            "total_shards": total_shards,
        },
        "nodes": {
            "data_nodes": data_nodes_for_shards,
            "master_nodes": 3,  # Always 3 for HA
            "coordinating_nodes": max(2, queries_per_second // 5000),
        },
        "per_data_node": {
            "ram_gb": int(ram_per_node_gb),
            "heap_gb": min(26, int(ram_per_node_gb / 2)),
            "storage_gb": int(storage_per_node_gb * 1.5),  # 50% headroom
            "cpu_cores": cores_per_node,
        },
        "total_resources": {
            "total_ram_gb": int(ram_per_node_gb * data_nodes_for_shards),
            "total_storage_gb": int(storage_per_node_gb * data_nodes_for_shards * 1.5),
            "total_cores": cores_per_node * data_nodes_for_shards,
        }
    }


# Example: Our product search system
estimate = estimate_cluster_size(
    total_documents=50_000_000,
    avg_doc_size_kb=10,
    queries_per_second=10_000,
    indexing_per_second=100,
    replica_count=1,
)

print("""
CLUSTER SIZING ESTIMATE

Storage:
├── Raw data: {storage[raw_data_gb]} GB
├── Index size: {storage[index_size_gb]} GB
└── With replicas: {storage[total_with_replicas_gb]} GB

Shards:
├── Primary: {shards[primary_shards]}
├── Replicas: {shards[replica_shards]}
└── Total: {shards[total_shards]}

Nodes:
├── Data nodes: {nodes[data_nodes]}
├── Master nodes: {nodes[master_nodes]}
└── Coordinating nodes: {nodes[coordinating_nodes]}

Per Data Node:
├── RAM: {per_data_node[ram_gb]} GB
├── Heap: {per_data_node[heap_gb]} GB
├── Storage: {per_data_node[storage_gb]} GB
└── CPU: {per_data_node[cpu_cores]} cores

Total Resources:
├── RAM: {total_resources[total_ram_gb]} GB
├── Storage: {total_resources[total_storage_gb]} GB
└── CPU: {total_resources[total_cores]} cores
""".format(**estimate))
WorksheetThe product search cluster (inputs are the planner's assumptions)
Value
Products (the lesson's catalog) 50 million
Average document size (assumed) 10 KB → ~500 GB raw (477 GB in the planner's binary units)
Index overhead (assumed 30%) ~620 GB
With one replica ~1.24 TB on disk
Primary shards at ~30 GB each (assumed target) 477 ÷ 30 ≈ 16, rounded up to 20, ~31 GB each with overhead
Shard copies at ~7 per node (assumed) 40 → 6 data nodes
Disk per data node ~207 GB, ~310 GB with 50% headroom
RAM per data node (assumed 64 GB) ~30% of the node's data; 26 GB heap, the rest OS cache
Queries (assumed 10,000/s) at ~100 per core (assumed) ~100 cores → 6 nodes × 16 vCPU = 96

Conclusion: storage isn't what sizes this cluster: each node uses ~310 GB of a 500 GB SSD. Six nodes come from placing 40 shard copies, and those six nodes' cores only just cover 10,000 queries a second, so query growth will add nodes before catalog growth does.

7.2 Final Architecture#

Product search cluster · production architecture
  • Load balancer
    • Routes to Search API instances
  • Search API — 6 instances, auto-scaling
    • Handles /search and /autocomplete
    • Query building and caching
    • 2 cores, 4GB RAM each
  • Elasticsearch cluster
    • Master nodes: 3 (m5.large)
      • Dedicated for cluster management
    • Data nodes: 6 (m5.4xlarge, 16 vCPU)
      • 64 GB RAM (26 GB heap, the rest left to the OS cache)
      • 500 GB SSD each (the estimate needs ~310 GB with headroom)
      • Holds ~7 shards each
    • Coordinating nodes: 2 (c5.xlarge)
      • Query routing and aggregation
  • Index configuration
    • Primary shards: 20
    • Replica shards: 20 (1 replica)
    • Total shards: 40
  • Supporting services
    • Redis: Query result cache (32 GB cluster)
    • Kafka: CDC events from PostgreSQL
    • Indexing Pipeline: 3 workers
Quick check2 questions · +5 XP each

Cluster sizing

  1. Q1/2 The catalog has 50M products averaging 10 KB each. Roughly how much raw data is that?

    Answer & explanation

    Answer: C

    50,000,000 x 10 KB = 500,000,000 KB, about 500 GB. The 30% index overhead and one replica then push total storage to about 1.3 TB.

  2. Q2/2 Why do data nodes get 64 GB of RAM but a heap capped at 26 GB?

    Answer & explanation

    Answer: B

    Search speed depends on index files sitting in the OS cache, so the plan keeps the heap to at most half of RAM and leaves the rest to the cache. Giving nearly everything to the heap would starve that cache.

Summary#

What We Learned Today#

Day 1 summary: search fundamentals
Problem
  • SQL LIKE is O(n) - scans every row
  • No relevance ranking
  • No typo tolerance
  • No stemming or synonyms
  • Database dies under load
Inverted index
  • Pre-compute: word → documents
  • Query: instant lookup + intersection
  • Fast term lookup vs O(n) scan
  • Foundation of all search engines
Text analysis
  • Character filters → Tokenizer → Token filters
  • Lowercase, stemming, synonyms
  • MUST use same analyzer at index and query time
  • Determines what matches what
Document modeling
  • Choose field types carefully
  • keyword: exact match, aggregations
  • text: full-text search
  • Multi-field pattern: one source, multiple representations
  • Denormalize for search performance
Elasticsearch architecture
  • Cluster → Nodes → Indexes → Shards
  • Shards are Lucene indexes
  • Primary + Replicas for HA
  • Scatter-gather query execution
  • Size shards 20-40 GB
Cluster sizing
  • 50M docs × 10KB = 500 GB
  • 20 primary shards + 20 replicas
  • 6 data nodes × 64 GB RAM
  • 3 master nodes (dedicated)
  • 2 coordinating nodes
◆ Key takeaways

Key Takeaways#

Search fundamentals key takeaways
  1. Inverted index is the key — Everything else builds on this data structure
  2. Analysis determines matching — "running" matches "run" only if analyzer stems both
  3. Field type matters — keyword ≠ text — different purposes
  4. Plan shards carefully — Primary count is fixed at creation (split/shrink or reindex to change), plan for growth
  5. Elasticsearch is not a database — It's a search engine — different trade-offs

Default choice: When in doubt, use Elasticsearch with proper analysis and multi-field patterns

▲ Interview tip

Interview Tip#

Tomorrow's Preview#

Day 2: Indexing Pipeline — "Keeping search fresh"

We'll solve:

  • How to sync PostgreSQL → Elasticsearch
  • Change Data Capture (CDC) with Debezium
  • Handling bulk imports without overwhelming the cluster
  • Zero-downtime reindexing
  • What happens when indexing falls behind
Preview: the indexing problem
  • 10:00:00 —
    Product updated in PostgreSQL
  • 10:00:01 —
    User searches → Search returns OLD product data

How do we minimize this lag? How do we handle millions of updates? How do we reindex without downtime?

Tomorrow: Day 2 — Indexing Pipeline: Keeping search fresh

this lesson in shorts · 2

play them all →

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

End of Day 01 · Search Fundamentals & Architecture
Finish Day 1Stages clear as you read them (0/6 so far). Skipping ahead doesn't count: marking complete unlocks after ~7 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.

StanfordDocs · ~5 min

A first take at building an inverted index

Christopher D. Manning, Prabhakar Raghavan, Hinrich Schütze · 2008
What
A section of the standard information retrieval textbook, free online.
Key idea
Tokenise and normalise documents, sort term and document ID pairs, then group them into a dictionary of terms pointing to sorted postings lists.
Why here
The inverted index of this lesson built step by step, and why it beats SQL LIKE scans.
taught here: Inverted index
ElasticBlog post · ~13 min

Elasticsearch from the Bottom Up, Part 1

Alex Brasetvik · 2013
What
A tour of Elasticsearch internals starting from the inverted index and working upwards.
Key idea
An index is made of Lucene shards, each a set of immutable segments that are searched together and merged in the background.
Why here
Explains the 'how Elasticsearch works' chapter: segments, refresh and merges are why indexing is near-real-time, not instant.
taught here: How Elasticsearch works

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…