The Big Picture: Search Architecture
Modern search engines process queries in milliseconds across billions of documents. This speed is not magic—it comes from careful engineering of the retrieval pipeline: indexing, query processing, scoring, and ranking.
The Search Pipeline
Every search engine follows a similar architecture:
Sparse vs Dense Retrieval
Sparse Retrieval (BM25) matches exact keywords. Query "running shoes" matches documents containing those terms. Fast, interpretable, requires no training.
Dense Retrieval uses embedding vectors. "running shoes" maps to a vector that's semantically close to "jogging footwear". Slower but handles synonyms better.
Why Inverted Index Matters
Without an inverted index, finding documents containing "search" would require scanning every document—O(n) time. With an inverted index, it's O(1) lookup plus decompression.
| Approach | Lookup Time | Space | Feasibility |
|---|---|---|---|
| Linear scan (no index) | O(n) | O(n) | Billions? No. |
| Inverted index (compressed) | O(1) + decompress | ~10% of original | Billions? Yes! |
| Dense vectors (HNSW) | O(log n) | Large, fixed | Millions, not billions |
Text Preprocessing: From Raw to Queryable
Raw text cannot be searched efficiently. We must normalize it into a canonical form. This is the first step of both indexing and query processing.
The Preprocessing Pipeline
Step-by-Step Breakdown
1. Tokenization
Split text on whitespace and punctuation. "Hello, world!" → ["Hello", "world"].
2. Lowercasing
Convert all to lowercase. "Hello" → "hello". Now "HELLO", "Hello", "hello" are the same term.
3. Stopword Removal
Remove common words that appear in almost all documents: the, is, at, an, of, and. They add little discriminative value and bloat the index.
STOPWORDS = {"the", "is", "at", "an", "of", "and", "or", "but", "in", "on", "to", "for", ...}4. Stemming vs Lemmatization
Stemming: Remove affixes using heuristics (Porter stemmer). "running" → "run", "better" → "better" (may fail).
Lemmatization: Use linguistic knowledge to map to base form. "running" → "run", "better" → "good".
| Word | Stemming (Porter) | Lemmatization |
|---|---|---|
| running, runs, ran | run, run, ran | run, run, run |
| better, good | better, good | good, good |
| studies, studies | studi, studi | study, study |
Inverted Index: The Data Structure
An inverted index maps terms → documents. It's the inverse of a forward index (docID → terms).
Forward Index vs Inverted Index
Posting List Structure
Each term maps to a posting list: a sorted array of (docID, positions, frequency) tuples.
# Example inverted index
{
"information": {
"docIDs": [1, 3],
"frequencies": [1, 2],
"positions": [[5], [2, 15]] # positions in each doc
},
"retrieval": {
"docIDs": [1],
"frequencies": [1],
"positions": [[7]]
},
"system": {
"docIDs": [1, 3],
"frequencies": [1, 1],
"positions": [[8], [3]]
}
}
Building an Inverted Index (Example)
Input corpus:
Doc 1: "cat sat mat" Doc 2: "dog sat log" Doc 3: "cat dog"
Step 1: Tokenize & record (docID, term, position)
(1, "cat", 0), (1, "sat", 1), (1, "mat", 2), (2, "dog", 0), (2, "sat", 1), (2, "log", 2), (3, "cat", 0), (3, "dog", 1)
Step 2: Sort by term, group by docID
cat: [1→[0], 3→[0]] dog: [2→[0], 3→[1]] log: [2→[2]] mat: [1→[2]] sat: [1→[1], 2→[1]]
Step 3: Compress (see Section 4)
Boolean Retrieval
Combine posting lists with AND, OR, NOT:
Query: "cat AND dog"
Posting["cat"] = [1, 3] Posting["dog"] = [2, 3] Result = Intersection([1, 3], [2, 3]) = [3] → Only Doc 3 contains both "cat" and "dog"
Query: "cat OR dog"
Result = Union([1, 3], [2, 3]) = [1, 2, 3]
Posting List Compression: Making Indexes Tiny
Raw docIDs can be large (e.g., 2,000,000). Storing millions of large integers for each term is expensive. Compression reduces index size by 90%, making it fit in RAM and dramatically speeding up I/O.
Gap Encoding (Delta Encoding)
Instead of storing absolute docIDs, store the gap (difference) between consecutive IDs.
# Calculation: Original: [5, 28, 47, 102, 201] Gaps: [5, 23(28-5), 19(47-28), 55(102-47), 99(201-102)] Savings: Gaps are smaller → fewer bytes needed per value
Variable-Byte Encoding (VByte)
Use fewer bytes for small numbers, more for large ones.
| Value | Fixed 4 bytes | VByte | Savings |
|---|---|---|---|
| 5 | 00 00 00 05 | 05 | 75% |
| 255 | 00 00 00 FF | FF 01 | 50% |
| 100000 | 00 01 86 A0 | A0 CD 06 | 25% |
Binary Compression (γ-code, δ-code)
Use Elias γ-code or δ-code for bit-level compression. Each number uses log(n) bits instead of 8.
TF-IDF: The Predecessor to BM25
Before BM25, TF-IDF (Term Frequency - Inverse Document Frequency) was the standard ranking function. Understanding TF-IDF motivates why BM25 exists.
TF-IDF Formula
Term Frequency (TF): How often a term appears in a document.
Inverse Document Frequency (IDF): How rare the term is across the collection.
TF(t, d) = count of term t in document d
IDF(t) = log(N / df_t)
where N = total documents, df_t = documents containing t
Score(q, d) = Σ TF(t, d) × IDF(t)
Example: Ranking Three Documents
Corpus: 1000 documents. Query: "information retrieval"
| Term | Document Frequency | IDF = log(1000/df) |
|---|---|---|
| information | 100 docs | log(10) ≈ 1.0 |
| retrieval | 50 docs | log(20) ≈ 1.3 |
# Document scores: Doc 1: information appears 3x, retrieval appears 1x Score = 3×1.0 + 1×1.3 = 4.3 Doc 2: information appears 1x, retrieval appears 5x Score = 1×1.0 + 5×1.3 = 7.5 Doc 3: information appears 10x, retrieval appears 0x Score = 10×1.0 + 0×1.3 = 10.0 Ranking: Doc 3 > Doc 2 > Doc 1
Problem: Raw TF Saturation
Seeing "information" 10 times is not 10× better than seeing it once. Diminishing returns!
TF-IDF Limitations
Problem 1: Length bias Long documents accumulate higher TF scores unfairly.
Problem 2: TF saturation Raw frequency doesn't model relevance well.
Problem 3: No parameters Cannot tune per-collection behavior.
BM25: The Formula & Components
BM25 (Best Matching 25) is the state-of-the-art baseline. Published by Robertson et al., it combines empirically validated techniques into a robust ranking function.
The Complete BM25 Formula
Score(q, d) = Σ_{i=1}^{n} IDF(q_i) × [TF(q_i, d) × (k1 + 1)] / [TF(q_i, d) + k1 × (1 - b + b × |d| / avgdl)]
where:
q_i = query term i
d = document
TF(q_i, d) = count of q_i in d
|d| = length of d in tokens
avgdl = average document length
k1 = saturation parameter (typically 1.2 - 2.0)
b = length normalization parameter (typically 0.75)
IDF(q_i) = log((N - df_q_i + 0.5) / (df_q_i + 0.5) + 1)
where N = total documents, df = documents containing term
Breaking Down Each Component
1. IDF Component (Term Rarity)
IDF(term) = log((N - df + 0.5) / (df + 0.5) + 1) Example: N=1000 documents - "the" appears in 800 docs: IDF = log((1000-800+0.5)/(800+0.5)) ≈ 0.65 (common) - "information" in 100 docs: IDF = log((1000-100+0.5)/(100+0.5)) ≈ 2.29 (rare) - "xyzzy" in 1 doc: IDF = log((1000-1+0.5)/(1+0.5)) ≈ 6.2 (very rare)
2. TF Component with Saturation (k1)
The numerator models frequency saturation: seeing a term 10 times doesn't help 10× more than once.
Saturation curve: TF × (k1 + 1) / (TF + k1) k1 = 0 (no saturation): score independent of TF (bad) k1 = 1.2 (default): smooth saturation k1 = 2.0 (high saturation): rewards frequency more k1 = ∞ (linear): raw frequency
3. Length Normalization (b)
Long documents naturally have more term occurrences. Normalize so length doesn't inflate scores unfairly.
Normalization denominator: TF + k1 × (1 - b + b × |d| / avgdl) b = 0: no length normalization (pure TF saturation) b = 0.75 (default): moderate normalization b = 1.0: full length normalization - Short docs: (1 - b + b × |d|/avgdl) < 1 → divisor smaller → score boosts - Long docs: divisor larger → score penalizes Example with avgdl = 100: Short doc (50 tokens): divisor = 1.2 × (1 - 0.75 + 0.75 × 0.5) = 0.69 Long doc (200 tokens): divisor = 1.2 × (1 - 0.75 + 0.75 × 2.0) = 2.1
Worked Example: Computing BM25
Setup: 1000 documents, average length 150 tokens. Query: "machine learning"
Query term statistics:
"machine": appears in 50 docs
"learning": appears in 60 docs
IDF("machine") = log((1000-50+0.5)/(50+0.5)+1) ≈ 2.95
IDF("learning") = log((1000-60+0.5)/(60+0.5)+1) ≈ 2.82
Document: 180 tokens
TF("machine") = 2
TF("learning") = 3
BM25 calculation (k1=1.2, b=0.75):
length_norm = 1 - 0.75 + 0.75 × (180/150) = 0.25 + 0.9 = 1.15
score_machine = 2.95 × [2 × 2.2] / [2 + 1.2 × 1.15]
= 2.95 × 4.4 / 3.38
≈ 3.83
score_learning = 2.82 × [3 × 2.2] / [3 + 1.2 × 1.15]
= 2.82 × 6.6 / 4.38
≈ 4.25
Total BM25 = 3.83 + 4.25 ≈ 8.08
Query Processing: Finding Relevant Documents
Given a query, we must efficiently retrieve posting lists, compute scores for relevant documents, and return top-K results.
The Query Processing Pipeline
DAAT vs TAAT Traversal
DAAT (Document-At-A-Time): Iterate through documents, compute full score.
for each document in union of posting lists:
score = 0
for each query term:
if term in doc: score += BM25(term, doc)
accumulate(doc, score)
sort and return top K
TAAT (Term-At-A-Time): Accumulate scores per term.
score_accumulator = {}
for each query term:
for each doc in posting_list[term]:
score_accumulator[doc] += BM25(term, doc)
sort and return top K
Trade-off: TAAT uses less memory per document but requires more random access. DAAT is simpler but uses more memory.
Query Optimization: MaxScore & WAND
For top-K retrieval, we don't need to score all documents. Early termination algorithms skip low-scoring docs:
MaxScore: Compute upper bounds on remaining scores. If current doc + max possible < K-th best, skip it.
WAND (Weak AND): Process posting lists in sorted order by IDF. Skip entire blocks if impossible to reach top-K.
Index Construction at Scale
Building an inverted index for billions of documents requires careful engineering. Single-pass indexing won't fit in memory. Distributed indexing is essential.
The SPIMI Algorithm (Single-Pass In-Memory Indexing)
while documents remain:
block = read_documents_until_memory_full()
# Build in-memory index for this block
for each document in block:
for each term in document:
add(term, docID, position)
# Sort and write to disk
sort_terms_by_id()
write_to_disk("block_" + block_num)
# Merge all blocks
merge_sorted_blocks()
Distributed Indexing (MapReduce)
Index Sharding
Divide the inverted index by term hash or range. Each shard handles a subset of terms, enabling parallel query processing.
Shard 0: terms a-d Shard 1: terms e-h Shard 2: terms i-l ... Query "machine learning": "machine" → hash to Shard X, fetch posting list "learning" → hash to Shard Y, fetch posting list Parallel merge on different machines
Incremental Indexing (Near Real-Time)
Add new documents without full re-indexing:
Soft Delete: Mark documents as deleted, but keep them in index. On query, filter them out.
Periodic Merge: Every N new documents, merge with main index in background.
BM25 Variants & Extensions
The base BM25 formula is solid, but variants address specific weaknesses or domains.
Common Variants
| Variant | Key Difference | Use Case |
|---|---|---|
| BM25 | Standard formula | General-purpose baseline |
| BM25+ | Handles very frequent terms better (adds delta term) | When IDF becomes saturated for common terms |
| BM25L | Alternative length normalization: log(|d| / avgdl) | Documents with highly varied lengths |
| BM25F | Field-weighted: title, body, anchor text weighted separately | Web search (title = higher weight) |
BM25F (Field-Weighted)
Different fields have different importance:
Score = Σ IDF(term) × [TF_weighted × (k1+1)] / [TF_weighted + k1×(1-b+b×|d|/avgdl)]
where TF_weighted = Σ (boost_field × TF_field)
Example:
Title: boost = 3.0
Body: boost = 1.0
Anchor text: boost = 2.0
If "machine" appears 1x in title, 2x in body:
TF_weighted = 3.0×1 + 1.0×2 = 5.0
Parameter Tuning
Default k1=1.2, b=0.75 work for most collections. To optimize:
# Grid search on development set
for k1 in [0.5, 1.0, 1.2, 1.5, 2.0]:
for b in [0.0, 0.25, 0.5, 0.75, 1.0]:
evaluate_on_dev_set(k1, b)
# Measure: NDCG, MAP, MRR
BM25 vs Modern Approaches
Dense embeddings and neural networks promise better semantic understanding, but BM25 remains the baseline. When should you use what?
Comparison Table
| Aspect | BM25 (Sparse) | Dense Retrieval | Hybrid |
|---|---|---|---|
| Exact match | ✓ Excellent | ✗ Poor | ✓ Good |
| Semantic match | ✗ Poor | ✓ Excellent | ✓ Excellent |
| Speed (search) | ✓ <1ms | ✗ 10-100ms | ~ 5-50ms |
| Index size | ✓ 1-2% of corpus | ✗ 20-30% (vectors) | ~ 25-35% |
| Training data | ✓ None | ✗ Labeled pairs | ~ Some helpful |
| Maintainability | ✓ Simple | ✗ Complex | ~ Moderate |
BM25 Strengths
No training required. Works out-of-the-box on any language, domain, corpus.
Interpretable. You understand why a document ranked high (term matching).
Fast. Millisecond latency even on billions of documents.
Efficient. Index size is 1-2% of original corpus.
BM25 Weaknesses
Vocabulary mismatch. "Car" and "automobile" are different terms, no semantic connection.
No context. Cannot understand word sense disambiguation ("bank" financial vs river).
Boolean logic only. "Relevant documents about car crashes" needs phrase/boolean understanding.
When to Use Each
Use BM25 if:
- Exact keyword match is critical (e-commerce, law)
- You have limited labeling budget
- Latency < 10ms is required
- Index size is constrained
Use Dense Retrieval if:
- Semantic match matters more than keywords (search, recommendation)
- You have labeled query-document pairs
- Synonymy and paraphrasing are common
Use Hybrid if:
- You want the best of both (exact + semantic)
- You can afford moderate complexity
- You have the index space for both
Cross-references: For deeper dives, see Dense Retrieval Guide and Hybrid Search & Reranking Guide.
Python Implementation: Build a Search Engine
Let's build a complete BM25 search engine from scratch. This implementation includes tokenization, inverted indexing, and scoring.
Complete Working Code
import math
import re
from collections import defaultdict
from typing import List, Dict, Tuple
class SimpleInvertedIndex:
"""Minimal inverted index with gap encoding support."""
def __init__(self):
self.posting_lists = defaultdict(list) # term -> [(docID, freq)]
self.doc_lengths = {} # docID -> length
self.num_docs = 0
def add_document(self, doc_id: int, terms: List[str]):
"""Index a document's terms."""
self.num_docs = max(self.num_docs, doc_id + 1)
term_freqs = defaultdict(int)
for term in terms:
term_freqs[term] += 1
self.doc_lengths[doc_id] = len(terms)
for term, freq in term_freqs.items():
self.posting_lists[term].append((doc_id, freq))
def get_postings(self, term: str) -> List[Tuple[int, int]]:
"""Get posting list for a term."""
return sorted(self.posting_lists.get(term, []))
def document_frequency(self, term: str) -> int:
"""Count documents containing term."""
return len(self.posting_lists.get(term, []))
class BM25Scorer:
"""Compute BM25 scores for documents."""
def __init__(self, index: SimpleInvertedIndex, k1: float = 1.2, b: float = 0.75):
self.index = index
self.k1 = k1
self.b = b
self.avg_doc_length = sum(index.doc_lengths.values()) / max(1, len(index.doc_lengths))
def idf(self, term: str) -> float:
"""Compute IDF for a term."""
df = self.index.document_frequency(term)
if df == 0:
return 0
N = self.index.num_docs
return math.log((N - df + 0.5) / (df + 0.5) + 1)
def score_document(self, doc_id: int, query_terms: List[str]) -> float:
"""Compute BM25 score for document and query."""
score = 0
doc_length = self.index.doc_lengths.get(doc_id, 0)
for term in query_terms:
postings = self.index.get_postings(term)
tf = 0
for did, freq in postings:
if did == doc_id:
tf = freq
break
if tf > 0:
idf = self.idf(term)
numerator = tf * (self.k1 + 1)
denominator = tf + self.k1 * (1 - self.b + self.b * doc_length / self.avg_doc_length)
score += idf * numerator / denominator
return score
def search(self, query_terms: List[str], top_k: int = 10) -> List[Tuple[int, float]]:
"""Search for documents, return top-K."""
candidate_docs = set()
for term in query_terms:
postings = self.index.get_postings(term)
for doc_id, _ in postings:
candidate_docs.add(doc_id)
scores = []
for doc_id in candidate_docs:
score = self.score_document(doc_id, query_terms)
if score > 0:
scores.append((doc_id, score))
scores.sort(key=lambda x: -x[1])
return scores[:top_k]
class SimpleTokenizer:
"""Basic tokenization and preprocessing."""
def __init__(self):
self.stopwords = {'the', 'is', 'at', 'an', 'of', 'and', 'or', 'but', 'in', 'on', 'to', 'for', 'a', 'be', 'by'}
def tokenize(self, text: str) -> List[str]:
"""Tokenize and normalize text."""
text = text.lower()
tokens = re.findall(r'\b\w+\b', text)
return [t for t in tokens if t not in self.stopwords]
# Example usage with a small corpus
if __name__ == "__main__":
corpus = [
"Machine learning algorithms discover patterns in data",
"Information retrieval systems rank relevant documents",
"Neural networks process information through layers",
"Search engines use ranking algorithms to find documents",
"Data science applies machine learning to real-world problems"
]
# Build index
tokenizer = SimpleTokenizer()
index = SimpleInvertedIndex()
for doc_id, text in enumerate(corpus):
terms = tokenizer.tokenize(text)
index.add_document(doc_id, terms)
# Search
scorer = BM25Scorer(index)
query = "machine learning algorithms"
query_terms = tokenizer.tokenize(query)
results = scorer.search(query_terms, top_k=3)
print(f"Query: {query}")
print(f"Results:")
for doc_id, score in results:
print(f" Doc {doc_id} (score: {score:.3f}): {corpus[doc_id]}")
# Output:
# Query: machine learning algorithms
# Results:
# Doc 0 (score: 4.523): Machine learning algorithms discover patterns in data
# Doc 4 (score: 2.341): Data science applies machine learning to real-world problems
# Doc 3 (score: 1.789): Search engines use ranking algorithms to find documents
Extending the Implementation
Add phrase search: Store positions, check consecutive docIDs.
Add compression: Implement gap encoding and VByte for smaller indexes.
Add stemming: Use NLTK's Porter stemmer for better term matching.
Add field boost: Implement BM25F for title/body/anchor text.
Testing & Benchmarking
# Evaluate on development set
def evaluate_bm25(corpus, relevance_judgments, k=10):
"""
relevance_judgments: dict[query] = set[relevant_docIDs]
"""
tokenizer = SimpleTokenizer()
index = SimpleInvertedIndex()
for doc_id, text in enumerate(corpus):
index.add_document(doc_id, tokenizer.tokenize(text))
scorer = BM25Scorer(index)
ndcg_scores = []
for query, relevant_docs in relevance_judgments.items():
query_terms = tokenizer.tokenize(query)
results = scorer.search(query_terms, top_k=k)
result_docs = [doc_id for doc_id, _ in results]
# Compute nDCG
dcg = sum(1/math.log2(i+2) for i, doc_id in enumerate(result_docs) if doc_id in relevant_docs)
idcg = sum(1/math.log2(i+2) for i in range(min(len(relevant_docs), k)))
ndcg = dcg / idcg if idcg > 0 else 0
ndcg_scores.append(ndcg)
return sum(ndcg_scores) / len(ndcg_scores)