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
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: The Problem with Database Search#
Chapter 1: Why SQL LIKE Fails#
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?
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#
-- 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:
Table: products (50 million rows)
- Step 1: Full Table Scan
- Database must examine EVERY row
- No index can help with leading wildcard (
%) - 50M string comparisons
- Time: 30+ seconds
- 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
- Result
- 30+ seconds query time
- Database CPU at 100%
- Connection pool exhausted
- Other queries blocked
- Users leave
1.2 Why Indexes Don't Help#
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 matchWHERE name LIKE 'Nike%'— prefix match
Cannot efficiently find
WHERE name LIKE '%Running%'— substringWHERE name LIKE '%Shoes%'— substring
Why? B-tree is sorted by string start, not contents
1.3 Additional Problems#
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!
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?
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 (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#
# 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']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#
- Tokenize query
- "running shoes" → ["running", "shoes"]
- Time: microseconds
- Look up "running" in index
- Direct hash lookup
- Returns: [Doc1, Doc2, Doc4, Doc5]
- Time: microseconds
- Look up "shoes" in index
- Direct hash lookup
- Returns: [Doc1, Doc2, Doc3]
- Time: microseconds
- 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
Why databases struggle with text search
Q1/3 You add a B-tree index on
products.name, butWHERE 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.Q2/3 A user searches "red" and
LIKE '%red%'returns "Bored Games". Which gap does this show?Answer & explanation
Answer: A
LIKEmatches characters, not words, so "bored" contains "red". Stemming would be "run" vs "running"; synonyms would be "couch" vs "sofa".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: 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#
%% 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;")]:::store3.2 Text Analyzer Implementation#
# 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#
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:
- Index time (when storing documents)
- Query time (when searching)
Otherwise tokens won't match!
Text analysis
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
runandshoe; the query asks forRunningandShoes. That's why the same analyzer must run at index time and at query time.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: Document Modeling#
Chapter 4: Designing Documents for Search#
4.1 The Product Document Schema#
# 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
}
}
}
}
}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?
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#
- 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#
One source field, multiple index representations:
"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.
Document modeling
Q1/2
brandneeds 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.
keywordalone can't do full-text matching, and analyzedtextis the wrong tool for exact facets and sorting.Q2/2 A teammate maps
product_idasintegerbecause 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
keywordis for.
Part IV: Elasticsearch Architecture#
Chapter 5: How Elasticsearch Works#
5.1 Core Concepts#
%% 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 edgeTerminology:
- 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#
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#
%% 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- The query reaches a coordinating node, which works out which shards to ask
- Scatter: each shard (primary or replica) searches its local inverted index
- Each shard sends back only its own top N, with scores
- Gather: the coordinating node merges, sorts by score and returns the overall top N
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?
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/_shrinkinto a new index or a full reindex, so plan for growth and use aliases.
5.4 Shard Sizing Guidelines#
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/_shrinkinto a new index, or a reindex) - Use index aliases for flexibility
- Consider time-based indices for logs
Elasticsearch architecture
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.
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).
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: System Design#
Chapter 6: Product Search Architecture#
6.1 High-Level Architecture#
# 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#
# 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
}Search service design
Q1/2 The query builder puts the user's text in
mustand category, price and stock infilter. 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.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: Estimation & Sizing#
Chapter 7: Cluster Sizing#
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?
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#
# 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))| 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#
Load balancer
- Routes to Search API instances
Search API — 6 instances, auto-scaling
- Handles
/searchand/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
Cluster sizing
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.
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#
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, aggregationstext: 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#
- Inverted index is the key — Everything else builds on this data structure
- Analysis determines matching — "running" matches "run" only if analyzer stems both
- Field type matters — keyword ≠ text — different purposes
- Plan shards carefully — Primary count is fixed at creation (split/shrink or reindex to change), plan for growth
- 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#
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
- 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.
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.
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.
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.
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.

