Search Engines & Full-Text Search
Building Your First Search Experience
Youβre launching an e-commerce platform with three million products. A customer types βcomfortable running shoes for flat feetβ and expects relevant results in milliseconds. Your first instinct? Query your PostgreSQL database with:
SELECT * FROM products WHERE description LIKE '%running shoes%' LIMIT 20;This works fine for thousands of rows. For millions? Youβre looking at a sequential scan. Performance tanks. Worse, the query misses synonyms (βsneakers,β βtrainersβ), typos (βrunniingβ), and has no concept of relevanceβa shoe made of rubber that happens to mention βrunning shoesβ in a review ranks the same as an actual running shoe.
This is the gap that search engines fill. Theyβre not traditional databases. Theyβre purpose-built systems for finding needles in massive haystacks, ranking results by relevance, handling typos, and delivering results in milliseconds.
In this chapter, weβll explore how search engines work, why theyβre different from your primary data store, and when (and when not) to use them.
What Full-Text Search Actually Is
Full-text search breaks free from exact matching. Instead of βdoes this row match this pattern?β, it asks βhow relevant is this document to this query?β and ranks results accordingly.
Letβs start with the fundamental data structure: the inverted index. If a normal database indexes by document -> words, an inverted index flips the relationship:
word β [list of documents containing that word]When you search for βrunning shoes,β the engine:
- Finds all documents containing βrunningβ
- Finds all documents containing βshoesβ
- Combines results (intersection, union, or phrase proximity depending on query type)
- Scores and ranks them using relevance algorithms
Before indexing, text goes through analysis:
- Tokenization: Split βcomfortable running shoesβ into tokens: [βcomfortableβ, βrunningβ, βshoesβ]
- Lowercasing: Ensure βRunningβ and βrunningβ are the same token
- Stop word removal: Words like βthe,β βa,β βforβ often donβt add value; skip them
- Stemming/Lemmatization: Reduce words to their root form. βRunning,β βruns,β βranβ all become βrunβ
This preprocessing is where search engines handle the quirks that make searching hard.
The Library Card Catalog Analogy
Imagine a library before computers. To find books on gardening, youβd:
- Walk to the card catalog
- Look up βgardeningβ -> see cards pointing to shelf locations
- Notice the catalog also points you to cards labeled βgarden,β βgardens,β βhorticultureβ (all related)
- Pull books from those shelves
You didnβt read every book to find gardening books. You used an index.
An inverted index in a search engine is exactly this. The βcardsβ are index entries. The βshelvesβ are document IDs or positions. The catalogβs ability to connect βgardeningβ and βgardensβ? Thatβs stemming. The cross-references between related terms? Thatβs synonym expansion.
The key insight: a search engine trades write complexity for read speed. Building that card catalog takes time (indexing). But finding books is instant (querying). In systems with millions of documents, this trade-off is essential.
Inside a Search Engine: Elasticsearch and Friends
Modern search engines like Elasticsearch, OpenSearch, and Apache Solr share common architecture:
The Cluster Model
βββββββββββββββββββββββββββββββββββ
β Search Cluster β
βββββββββββββββββββββββββββββββββββ€
β ββββββββββββββββ β
β β Node 1 β β
β β ββββββββββββ β β
β β β Shard 0 β β β
β β β (Primary)β β β
β β ββββββββββββ β β
β β ββββββββββββ β β
β β β Shard 2 β β β
β β β(Replica) β β β
β β ββββββββββββ β β
β ββββββββββββββββ β
β β
β ββββββββββββββββ β
β β Node 2 β β
β β ββββββββββββ β β
β β β Shard 1 β β β
β β β (Primary)β β β
β β ββββββββββββ β β
β β ββββββββββββ β β
β β β Shard 0 β β β
β β β(Replica) β β β
β β ββββββββββββ β β
β ββββββββββββββββ β
β β
β ββββββββββββββββ β
β β Node 3 β β
β β ββββββββββββ β β
β β β Shard 2 β β β
β β β(Replica) β β β
β β ββββββββββββ β β
β β ββββββββββββ β β
β β β Shard 1 β β β
β β β(Replica) β β β
β β ββββββββββββ β β
β ββββββββββββββββ β
βββββββββββββββββββββββββββββββββββAn index is divided into shards (partitions), each living on different nodes. Replicas provide redundancy. This is the scatter-gather pattern in action: a query hits every shard in parallel, each searches locally, results merge at the coordinator node.
The Indexing Pipeline
When you index a document:
Document (raw JSON)
β
Analyzer (language-specific rules)
β
Tokenizer ("comfortable running shoes" β ["comfortable", "running", "shoes"])
β
Token Filters (lowercase, remove stop words, stem)
ββ "comfortable" (pass through)
ββ "running" β "run" (stemmed)
ββ "shoes" β "shoe" (stemmed)
β
Inverted Index
{
"comfortable": [doc_1, doc_5, ...],
"run": [doc_1, doc_2, doc_3, ...],
"shoe": [doc_1, doc_2, doc_4, ...]
}This happens once at index time. Search is fast because weβre working with pre-processed data.
Relevance Scoring: TF-IDF and BM25
Two algorithms dominate relevance scoring:
TF-IDF (Term Frequency x Inverse Document Frequency):
Score(doc, term) = TF(term in doc) x IDF(term in collection)
Where:
TF = how often the term appears in this document
IDF = log(total documents / documents containing term)If βrunningβ appears 10 times in a product and in 50% of all products, TF-IDF would be:
- TF: 10 (or normalized: 10/total_words_in_doc)
- IDF: log(1,000,000 / 500,000) β 0.7
- Score: ~7 (normalized)
Common words have low IDF (theyβre in half of all docs), so they contribute less. Rare words have high IDF.
BM25 (used by Elasticsearch by default) improves on TF-IDF:
Score(doc, term) = IDF(term) x (
(TF(term) x (k1 + 1)) / (TF(term) + k1 x (1 - b + b x (doc_length / avg_doc_length)))
)
Where:
k1 β 1.2 (saturation parameterβdiminishing returns after a point)
b β 0.75 (length normalization)Why the complexity? Because in real data, term frequency doesnβt scale linearly. If βrunning shoesβ appears 1 time, itβs important. If it appears 100 times, we shouldnβt boost the score 100xβthatβs diminishing returns. BM25 bakes this in.
Pro Tip: Field boosting lets you say βmatches in the product name count 10x more than matches in the description.β Youβd configure:
"name": { "type": "text", "boost": 10 }.
Query Types
Elasticsearch supports multiple query types:
// Match query: "running shoes" (flexible matching)
{
"query": {
"match": {
"description": {
"query": "running shoes",
"operator": "and",
"fuzziness": "AUTO"
}
}
}
}
// Phrase query: exact phrase in order
{
"query": {
"match_phrase": {
"description": "comfortable running shoes"
}
}
}
// Boolean query: combine multiple conditions
{
"query": {
"bool": {
"must": [
{ "match": { "description": "running shoes" } }
],
"filter": [
{ "range": { "price": { "lte": 150 } } },
{ "term": { "in_stock": true } }
],
"should": [
{ "match": { "brand": "Nike" } }
],
"minimum_should_match": 0
}
}
}
// Fuzzy query: handles typos
{
"query": {
"fuzzy": {
"product_name": {
"value": "runniing",
"fuzziness": "1"
}
}
}
}The bool query is powerful: must clauses must match (AND), should clauses boost if matched (OR), filter clauses must match but donβt affect scoring, and must_not excludes results.
Near-Real-Time Search
Elasticsearch doesnβt index documents instantly. Every few seconds (default: 1 second), it refreshes: segments of the inverted index become searchable. This trade-offβeventual consistency for throughputβis fundamental.
You can force immediate visibility with a flush, but thatβs expensive. Most use cases accept the slight lag.
Search Engine Comparison
| Aspect | Elasticsearch | OpenSearch | Apache Solr | Meilisearch | Typesense |
|---|---|---|---|---|---|
| Setup Complexity | Moderate | Moderate | High | Low | Low |
| Scaling Model | Horizontal (shards/replicas) | Horizontal | Horizontal | Limited | Limited |
| Query Language | JSON DSL | JSON DSL | Lucene syntax | Simple JSON | Simple JSON |
| Relevance Tuning | Advanced (BM25, field boost, function score) | Advanced | Advanced | Limited | Good |
| Full-Text Features | Excellent | Excellent | Excellent | Good | Good |
| Typo Tolerance | Yes (fuzzy) | Yes | Yes | Native | Native |
| Autocomplete | Via prefix queries | Via prefix queries | Via edge-gram filter | Native | Native |
| Cost at Scale | High (memory-intensive) | High | Moderate | Low | Low |
| Operational Overhead | High | High | High | Low | Low |
| Best For | Enterprise, complex search | Enterprise (Elasticsearch alternative) | Complex search, Lucene fans | Simple SAAS | Real-time, hosted |
Real-World Example: E-Commerce Product Search
Your index might look like:
{
"settings": {
"number_of_shards": 5,
"number_of_replicas": 1,
"analysis": {
"analyzer": {
"product_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "stop", "snowball"]
}
}
}
},
"mappings": {
"properties": {
"product_name": {
"type": "text",
"analyzer": "product_analyzer",
"boost": 10
},
"description": {
"type": "text",
"analyzer": "product_analyzer"
},
"brand": {
"type": "keyword"
},
"category": {
"type": "keyword"
},
"price": {
"type": "float"
},
"in_stock": {
"type": "boolean"
},
"rating": {
"type": "float"
},
"created_at": {
"type": "date"
}
}
}
}Notice: product_name is boosted 10x (matches there matter more), brand and category are keywords (exact matching, used for filtering, not scoring), numeric fields enable range filters.
A complex query:
{
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "comfortable running shoes",
"fields": ["product_name^10", "description", "brand"],
"type": "best_fields"
}
}
],
"filter": [
{ "range": { "price": { "lte": 150 } } },
{ "term": { "in_stock": true } },
{ "terms": { "category": ["athletic", "footwear"] } }
],
"should": [
{ "range": { "rating": { "gte": 4 } } }
]
}
},
"aggs": {
"brands": {
"terms": { "field": "brand", "size": 10 }
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 50 },
{ "from": 50, "to": 100 },
{ "from": 100, "to": 150 }
]
}
}
}
}This query finds products matching the search terms, filters by price and availability, boosts highly-rated products, and returns facets (aggregations) for filtering.
Handling Typos: Fuzzy Matching and Edit Distance
Users type βrunniingβ instead of βrunning.β How do we still find relevant results?
Edit distance (Levenshtein distance) measures how many single-character edits (insertions, deletions, substitutions) are needed to transform one word into another:
"running" -> "runniing" = 1 insertion = distance 1
"nike" -> "nike" = 0 = distance 0
"shoe" -> "shoe" = 0 = distance 0
"comfort" -> "comfert" = 1 substitution = distance 1Elasticsearch allows fuzziness: 1 or fuzziness: 2 for typo tolerance. Higher values match more, but slower and less relevant.
For autocomplete, consider edge-grams: index substrings.
"running shoes" ->
Tokens: ["r", "ru", "run", "runn", "runni", "runnin", "running", "s", "sh", "sho", "shoe", "shoes"]User types βrunnβ -> instantly matches edge-gram, returns βrunning shoesβ as top result.
Distributed Search: The Scatter-Gather Pattern
When a query arrives:
Query Request
β
Coordinator Node
ββ Send query to Shard 0 (on Node 1)
ββ Send query to Shard 1 (on Node 2)
ββ Send query to Shard 2 (on Node 3)
β
Each shard searches locally, returns top-K results
β
Coordinator merges and re-ranks results
β
Return top results to clientThis is embarrassingly parallelβeach shard is independent. With 5 shards, a query touching all of them runs in parallel, achieving single-digit millisecond latency even across millions of documents.
The Cost of Change: Updates and Deletes
Search engines donβt delete documents instantly. Elasticsearch uses soft deletes and segment merging:
- When you delete a document, itβs marked as deleted (not removed)
- Search queries skip deleted documents (they have a filter applied)
- Periodically, segments merge, physically removing deleted documents
- This process (garbage collection) frees disk space
Did You Know? If you have a high update rate (say, millions of updates per day), the garbage collection overhead can become significant. Some teams run nightly reindex jobs to rebuild the index from a source-of-truth database.
PostgreSQL Full-Text Search: A Simpler Alternative
For smaller datasets or simpler requirements, PostgreSQLβs built-in tsvector and tsquery might suffice:
-- Create a full-text search column
ALTER TABLE products ADD COLUMN search_index tsvector;
-- Populate it with tokenized text
UPDATE products SET search_index =
to_tsvector('english', product_name || ' ' || description);
-- Create a GiST index for faster searches
CREATE INDEX idx_search ON products USING GiST (search_index);
-- Query
SELECT * FROM products
WHERE search_index @@ to_tsquery('english', 'running & shoes')
ORDER BY ts_rank(search_index, query) DESC;Pros: No external dependency, transactional consistency, simple. Cons: Limited to single node (no sharding), slower on millions of documents, weaker relevance tuning.
Key Takeaways
- Inverted indexes flip the lookup: word -> documents, enabling fast full-text search at scale.
- Analysis (tokenization, stemming, lowercasing) handles language complexity before indexing.
- BM25 scoring balances term frequency and rarity, outperforming simpler TF-IDF in real data.
- Sharding and replicas let you scale searches horizontally across a cluster.
- Near-real-time indexing (refresh intervals) trades immediate consistency for throughput.
Practice Scenarios
Scenario 1: Autocomplete at Scale You need to autocomplete product searches while users type. 10 million products, 1,000 QPS, latency under 50ms. Design the indexing strategy (edge-grams vs. prefix queries) and query execution (single-node coordinator vs. distributed).
Scenario 2: Multi-Language Search Your platform operates in 8 languages. Users search for products in any language, expecting relevant results even for synonyms and typos. Design the analyzer configuration and index structure.
Search engines excel at full-text, but struggle with relationships. Next chapter, weβll explore graph databases to model and query interconnected data efficiently.