Why
LIKE '%shoes%'is a design error, how a search engine finds one document among a billion in 10 ms, and why relevance is harder than retrieval.
Prerequisites: Indexing, Databases Overview Time to read: ~24 minutes
Users type “red running shoes” into a box. You have 50 million products. They expect results in under 200 ms, ranked by usefulness, tolerating typos, and understanding that “shoe” and “shoes” are the same thing.
Your first instinct:
SELECT * FROM products WHERE description LIKE '%running shoes%';
This is broken in five ways:
Search is a genuinely different problem from database retrieval, and it needs a different data structure.
🧠 Mental model: a book’s index. A regular index maps page → words on it. An inverted index maps word → pages containing it. You want the second one, because you’re searching by word.
Forward index (what a database has):
doc1 → "red running shoes for men"
doc2 → "blue running shorts"
doc3 → "red leather shoes"
Inverted index (what a search engine has):
"red" → [doc1, doc3]
"running" → [doc1, doc2]
"shoes" → [doc1, doc3]
"shorts" → [doc2]
"leather" → [doc3]
"men" → [doc1]
Now “red shoes” is a set intersection: [doc1, doc3] ∩ [doc1, doc3] = [doc1, doc3]. Fast, and
completely independent of total corpus size — you only touch the posting lists for the terms
queried.
📐 This is why a search over a billion documents can be faster than a LIKE over a million rows.
The database scans everything; the search engine touches only the two or three lists it needs.
Real posting lists store more than document IDs:
"running" → [(doc1, freq:1, positions:[1]), (doc2, freq:1, positions:[1])]
Positions enable phrase queries — “running shoes” as an exact phrase requires “shoes” to appear at position n+1 after “running”. Frequencies feed the ranking.
Text goes through several transformations before it becomes index terms. Getting this pipeline right matters more than any other search tuning, and it’s where most bad search experiences come from.
"Red Running SHOES for Men!"
↓ character filters (strip HTML, normalize unicode)
"Red Running SHOES for Men!"
↓ tokenization (split into terms)
["Red", "Running", "SHOES", "for", "Men"]
↓ lowercasing
["red", "running", "shoes", "for", "men"]
↓ stop word removal (drop "for", "the", "a")
["red", "running", "shoes", "men"]
↓ stemming / lemmatization
["red", "run", "shoe", "men"]
↓ synonyms
["red", "run", "jog", "shoe", "sneaker", "men"]
Stemming chops to a root: “running” → “run”, “shoes” → “shoe”. Crude but fast (Porter stemmer). Lemmatization uses a dictionary to find the true base form: “better” → “good”. Slower, more accurate.
🚨 The critical rule: the same analysis must be applied at index time and at query time. If you index “shoes” as “shoe” but search for the raw “shoes”, you get nothing. This is the most common search bug, and every search engine’s “why does my query return no results” FAQ starts here.
⚖️ Stop words are a real trade-off. Removing “the”, “a”, “for” shrinks the index and speeds queries. But then you can’t search for “The Who”, “to be or not to be”, or “vitamin A”. Modern engines mostly keep stop words and handle them at ranking time instead.
Retrieval finds matches. Ranking decides which matter, and it’s the harder half.
TF-IDF combines two intuitions:
score(term, doc) = TF(term, doc) × log(total_docs / docs_containing_term)
So matching a rare term is worth far more than matching a common one. That’s the whole idea.
BM25 is the modern refinement and what Elasticsearch actually uses by default. It adds two corrections that matter in practice:
🎙️ “I’d use BM25 for lexical relevance — it handles term frequency saturation and document length, which plain TF-IDF gets wrong and which shows up immediately with keyword-stuffed listings.”
Beyond text relevance, real ranking blends in business signals: popularity, recency, click-through rate, conversion rate, stock availability, personalization, and paid placement. Text relevance is often only 30–50% of the final score in a commercial system, and saying so shows product awareness.
Lexical search matches words. It fails when the user’s words differ from the document’s:
Query: "affordable laptop"
Document: "budget notebook computer"
Lexical overlap: zero. Semantic overlap: complete.
Vector (semantic) search solves this. An embedding model maps text to a high-dimensional vector where semantically similar text is nearby. Search becomes “find the nearest vectors to the query vector.”
Approximate Nearest Neighbour (ANN) algorithms make this fast — HNSW (graph-based, the common choice), IVF, product quantization. Exact nearest-neighbour search over millions of vectors is too slow; ANN trades a small amount of recall for orders of magnitude of speed.
⚖️ Vector search isn’t a replacement — it’s a complement:
| Lexical (BM25) | Vector | |
|---|---|---|
| Exact terms, product codes, names | ✅ Excellent | ❌ Often poor |
| Synonyms and paraphrases | ❌ | ✅ |
| Rare/novel terms | ✅ | ❌ (not in training data) |
| Explainability | ✅ You can see why | ❌ Opaque |
| Index cost | Low | High (embeddings + ANN structure) |
🚨 Hybrid search is the current standard answer: run both, and fuse the results (commonly with Reciprocal Rank Fusion). Lexical catches exact matches and identifiers; vector catches intent. If a 2026 interview asks about search, proposing hybrid retrieval — and knowing why pure vector search is worse at product codes — is the strong answer.
flowchart TB
C[Client] --> CO[Coordinator node]
CO --> S1[Shard 1<br/>docs 0–10M]
CO --> S2[Shard 2<br/>docs 10–20M]
CO --> S3[Shard 3<br/>docs 20–30M]
S1 --> CO
S2 --> CO
S3 --> CO
CO --> C
Two ways to partition, and the choice matters:
Document partitioning (used almost universally). Each shard holds a complete index for a subset of documents. A query goes to all shards; each returns its top K; the coordinator merges.
Term partitioning. Each shard holds all documents for a subset of terms. Fewer shards touched per query, but hot terms create hot shards and indexing a document requires writing to many shards. Rarely used in practice.
Replication on top gives read scaling and availability, exactly as for databases.
📐 Latency budget for a distributed search:
Coordinator → 20 shards, in parallel ~5 ms (bounded by slowest shard)
Each shard: index lookup + BM25 scoring ~3 ms
Merge and re-rank top 100 ~2 ms
Fetch document bodies for top 10 ~5 ms
────────
~15 ms
The index is a derived data store. The database is the source of truth. Something must keep them in sync, and that something is where the bugs live.
| Approach | Freshness | Complexity |
|---|---|---|
| Dual write (app writes both) | Immediate | 🚨 The dual-write problem — they will diverge |
| Periodic full reindex | Hours | Simple, but heavy and stale |
| Event-driven (publish on change) | Seconds | Needs the outbox pattern to be reliable |
| CDC (read the DB replication log) | Seconds | ✅ The best approach — no application changes, no missed writes |
🎙️ “I’d keep search updated via CDC from the database’s replication log rather than dual-writing — dual writes silently diverge, and CDC guarantees we see every committed change. Plus a nightly full reindex as a backstop to correct any drift.”
That “plus a periodic full reindex as a backstop” clause is worth including. Derived stores drift; having a reconciliation path is a mark of operational maturity.
Near-real-time indexing. Lucene (and therefore Elasticsearch) writes to an in-memory buffer and
“refreshes” to a searchable segment periodically — the default is 1 second. So there’s a small
window where a written document isn’t searchable. Tune refresh_interval up (30s) for bulk indexing
throughput; keep it low when freshness matters. And note this interacts with
read-your-writes: a user creates a listing and
immediately searches for it, finding nothing.
search_after / cursor pagination, not from/size.
→ Pagination| System | Character |
|---|---|
| Elasticsearch / OpenSearch | The default. Lucene-based, distributed, huge ecosystem, also used for log analytics |
| Apache Solr | Also Lucene. Older, strong for traditional enterprise search |
| Typesense / Meilisearch | Lightweight, developer-friendly, fast to set up. Great for small-to-mid catalogues |
| Algolia | Hosted, extremely fast, excellent relevance tooling. Expensive at scale |
| Postgres full-text search | 🚨 Genuinely good enough for a lot of cases — tsvector + GIN index, no extra system to run |
| Vector DBs (Pinecone, Weaviate, Qdrant, pgvector) | Semantic/hybrid search |
🎙️ A strong, non-obvious answer: “For a catalogue of 100,000 products with modest query volume, I’d start with Postgres full-text search. It handles stemming, ranking, and phrase queries, and it avoids running a second datastore and keeping it in sync. I’d move to Elasticsearch when we need faceting at scale, better relevance tuning, or the write volume justifies it.”
Resisting the reflex to add Elasticsearch scores well — it’s a real operational commitment.
| Decision | Gain | Cost |
|---|---|---|
| Dedicated search engine | Real relevance, faceting, typo tolerance, scale | A second datastore to run and keep in sync |
| Postgres FTS | No new system; transactional consistency with your data | Weaker relevance tuning, scaling ceiling, limited faceting |
| More aggressive analysis (stemming, synonyms) | Higher recall | Lower precision — irrelevant results creep in |
| Vector search | Semantic understanding | Cost, opacity, poor at exact terms |
| Hybrid retrieval | Best of both | Two pipelines, fusion tuning |
| Frequent refresh | Fresh results | Lower indexing throughput, more segment merging |
The precision/recall trade is the fundamental one in search. Aggressive stemming and synonyms find more (recall) but include more junk (precision). There’s no universally right point — it depends on whether users are looking for something specific (favour precision) or browsing options (favour recall).
LIKE '%term%' for search. It cannot use an index and doesn’t rank.1. Build an inverted index by hand. 20 lines of Python:
from collections import defaultdict
index = defaultdict(set)
docs = {1: "red running shoes", 2: "blue running shorts", 3: "red leather shoes"}
for doc_id, text in docs.items():
for token in text.lower().split():
index[token].add(doc_id)
def search(query):
sets = [index[t] for t in query.lower().split() if t in index]
return set.intersection(*sets) if sets else set()
print(search("red shoes")) # {1, 3}
Then add TF-IDF scoring. Doing this once makes every search engine’s behaviour legible.
2. Use Postgres full-text search. You’ll be surprised how capable it is:
ALTER TABLE products ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || description)) STORED;
CREATE INDEX idx_tsv ON products USING GIN (tsv);
SELECT title, ts_rank(tsv, q) AS rank
FROM products, to_tsquery('english', 'running & shoes') q
WHERE tsv @@ q ORDER BY rank DESC LIMIT 10;
3. Break the analyzer. In Elasticsearch, index a field with an English analyzer and query it with
a keyword (unanalyzed) query. Watch nothing match. This is the single most common search bug and
seeing it deliberately is worth ten minutes.
LIKE '%shoes%'?