Chapter 16: Search Systems
Modern search systems power everything from e-commerce to log analysis. This chapter covers inverted indexes, scoring algorithms, Elasticsearch architecture, and search at scale.
1. Inverted Index
The fundamental data structure for full-text search. Maps terms to document IDs where they appear.
Structure
| Component |
Purpose |
Example |
| Term Dictionary |
Sorted list of all unique terms |
"elasticsearch", "search", "system" |
| Posting List |
Document IDs containing term |
"search" → [doc1, doc5, doc12] |
| Term Frequency (TF) |
Count of term in each doc |
doc1: 3, doc5: 1, doc12: 7 |
| Positional Index |
Positions within document |
doc1: [0, 15, 42] (word positions) |
| Field Data |
Per-field indexing |
title:search vs body:search |
Inverted Index Structure
graph TB
subgraph Documents
D1["Doc 1: 'quick brown fox'"]
D2["Doc 2: 'quick blue fox'"]
D3["Doc 3: 'brown dog'"]
end
subgraph "Inverted Index"
T1["brown → [1,3]"]
T2["blue → [2]"]
T3["dog → [3]"]
T4["fox → [1,2]"]
T5["quick → [1,2]"]
end
subgraph "Posting Lists with TF & Positions"
P1["brown:
doc1(tf=1, pos=[1])
doc3(tf=1, pos=[0])"]
P2["quick:
doc1(tf=1, pos=[0])
doc2(tf=1, pos=[0])"]
end
D1 --> T1
D1 --> T4
D1 --> T5
D2 --> T2
D2 --> T4
D2 --> T5
D3 --> T1
D3 --> T3
T1 --> P1
T5 --> P2
style T1 fill:#e3f2fd
style T5 fill:#e3f2fd
style P1 fill:#fff3e0
style P2 fill:#fff3e0
Building an Inverted Index
Step 1: Tokenization
"The quick brown fox" → ["the", "quick", "brown", "fox"]
Step 2: Normalization
Lowercase, stemming, stop words
["quick", "brown", "fox"]
Step 3: Create Postings
Term → (DocID, Frequency, Positions)
quick → (1, 1, [0])
brown → (1, 1, [1])
fox → (1, 1, [2])
Step 4: Merge & Sort
Sort by term, compress posting lists
Interview Tip: Explain why inverted indexes are O(1) for term lookup (hash/trie) but O(k) for intersection (k = posting list size). Mention compression techniques like delta encoding and bit packing for posting lists.
2. Scoring Algorithms
Ranking documents by relevance to a query.
TF-IDF (Term Frequency - Inverse Document Frequency)
TF-IDF(t, d) = TF(t, d) × IDF(t)
TF(t, d) = count of term t in document d
IDF(t) = log(N / df(t))
where N = total documents, df(t) = documents containing term t
Example:
Query: "elasticsearch performance"
Doc1: contains "elasticsearch" 5 times, "performance" 2 times
Total docs: 10,000
Docs with "elasticsearch": 500
Docs with "performance": 2000
TF-IDF(elasticsearch, doc1) = 5 × log(10000/500) = 5 × 3 = 15
TF-IDF(performance, doc1) = 2 × log(10000/2000) = 2 × 1.6 = 3.2
Final score = 15 + 3.2 = 18.2
BM25 (Best Match 25)
BM25(d, q) = Σ IDF(qi) × [f(qi,d) × (k1+1)] / [f(qi,d) + k1 × (1 - b + b × |d|/avgdl)]
Where:
qi = query term i
f(qi,d) = frequency of qi in document d
|d| = document length
avgdl = average document length
k1 = term frequency saturation (typically 1.2-2.0)
b = length normalization (typically 0.75)
Key difference: Diminishing returns for term frequency (saturation)
TF-IDF vs BM25
| Aspect |
TF-IDF |
BM25 |
| Term Frequency |
Linear (5x term = 5x score) |
Saturates (diminishing returns) |
| Document Length |
No normalization |
Normalizes by avg length |
| Spam Resistance |
Weak (keyword stuffing works) |
Strong (saturation prevents) |
| Performance |
Faster (simpler math) |
Slightly slower |
| Accuracy |
Good baseline |
Better for varied doc lengths |
| Use Case |
Simple search, logs |
Web search, e-commerce (default) |
Interview Tip: BM25 is the default in Elasticsearch since v5.0. Mention that it handles document length better (short docs not penalized, long docs not favored just for having more words). k1 controls saturation, b controls length normalization.
3. Elasticsearch Architecture
Distributed, RESTful search engine built on Apache Lucene.
Core Components
| Component |
Description |
Key Points |
| Cluster |
One or more nodes with shared cluster name |
Distributed coordination, master election |
| Node |
Single Elasticsearch instance |
Master, data, ingest, coordinating roles |
| Index |
Collection of documents (like database) |
Settings, mappings, aliases |
| Shard |
Lucene index, horizontal partition |
Primary + replicas, ~50GB max per shard |
| Segment |
Immutable file in shard (mini-index) |
Created on refresh, merged periodically |
| Document |
JSON object with _id |
Indexed, stored, routed to shard |
Elasticsearch Cluster Architecture
graph TB
Client[Client Request]
subgraph "Elasticsearch Cluster"
CN[Coordinating Node]
M[Master Node
Cluster State]
subgraph "Index: products (3 shards, 1 replica)"
subgraph "Node 1"
P0[Primary Shard 0]
R1[Replica Shard 1]
end
subgraph "Node 2"
P1[Primary Shard 1]
R2[Replica Shard 2]
end
subgraph "Node 3"
P2[Primary Shard 2]
R0[Replica Shard 0]
end
end
end
Client --> CN
CN --> M
CN --> P0
CN --> P1
CN --> P2
P0 -.replicate.-> R0
P1 -.replicate.-> R1
P2 -.replicate.-> R2
style M fill:#e1f5e1
style CN fill:#e3f2fd
style P0 fill:#fff3e0
style P1 fill:#fff3e0
style P2 fill:#fff3e0
style R0 fill:#f3e5f5
style R1 fill:#f3e5f5
style R2 fill:#f3e5f5
Near-Real-Time Search
| Operation |
What Happens |
Latency |
| Index Document |
Written to in-memory buffer + translog |
~1ms |
| Refresh |
Buffer → new segment (searchable) |
1s (default) |
| Flush |
Segments → disk, clear translog |
30s or 512MB translog |
| Merge |
Small segments → large segments |
Background, throttled |
Write Path:
1. Document indexed → in-memory buffer + translog (disk)
2. Refresh (every 1s) → buffer → new segment (searchable, in memory)
3. Flush (every 30s) → segments → disk, translog cleared
4. Merge (background) → segments consolidated
Read Path:
1. Query → coordinating node
2. Query phase → broadcast to all shards, score top-K per shard
3. Fetch phase → retrieve full docs for global top-K
4. Return results to client
Interview Tip: Explain the 1-second delay (refresh interval) for near-real-time. Mention that translog ensures durability. Describe scatter-gather: query phase is lightweight (IDs + scores), fetch phase retrieves full docs only for final results.
4. Indexing Pipeline
How documents are processed before storage in the inverted index.
Analysis Chain
graph LR
Input["Input Text:
'The QUICK Brown Fox!'"]
subgraph "Analyzer = Tokenizer + Filters"
Char[Character Filter
HTML strip, mapping]
Token[Tokenizer
Split into tokens]
Filter1[Token Filter 1
Lowercase]
Filter2[Token Filter 2
Stop words]
Filter3[Token Filter 3
Stemming]
end
Output["Tokens:
['quick','brown','fox']"]
Input --> Char --> Token --> Filter1 --> Filter2 --> Filter3 --> Output
style Input fill:#e3f2fd
style Output fill:#e1f5e1
style Token fill:#fff3e0
Common Analyzers & Filters
| Component |
Examples |
Purpose |
| Tokenizer |
standard, whitespace, ngram, edge_ngram, pattern |
Split text into tokens |
| Lowercase Filter |
Quick → quick |
Case-insensitive search |
| Stop Words |
Remove "the", "a", "is" |
Reduce index size, noise |
| Stemming |
running → run, foxes → fox |
Match word variations |
| Synonyms |
quick → fast, rapid |
Expand query coverage |
| ASCII Folding |
café → cafe, ñ → n |
Handle accents |
| Character Filter |
HTML strip, pattern replace |
Clean input before tokenization |
Mapping (Schema)
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "english",
"fields": {
"keyword": { "type": "keyword" }, // exact match
"suggest": { "type": "completion" } // autocomplete
}
},
"price": { "type": "float" },
"published": { "type": "date" },
"category": { "type": "keyword" }, // no analysis, exact
"description": {
"type": "text",
"analyzer": "standard",
"search_analyzer": "english" // different for search
}
}
}
}
Field Types
| Type |
Indexed |
Use Case |
| text |
Analyzed (tokenized) |
Full-text search (titles, descriptions) |
| keyword |
Not analyzed (exact) |
Filtering, sorting, aggregations |
| date |
Numeric (milliseconds) |
Time ranges, sorting by date |
| numeric |
long, float, double, etc. |
Range queries, math aggregations |
| boolean |
true/false |
Binary flags |
| geo_point |
lat/lon |
Geospatial search (nearby) |
| dense_vector |
Float array |
Semantic/vector search |
Bulk Indexing
POST /_bulk
{ "index": { "_index": "products", "_id": "1" } }
{ "title": "Laptop", "price": 999 }
{ "index": { "_index": "products", "_id": "2" } }
{ "title": "Mouse", "price": 29 }
Best Practices:
- Batch 1000-5000 docs per request (5-15MB)
- Use multiple threads (parallel bulk requests)
- Disable refresh during bulk load (set to -1, re-enable after)
- Monitor queue size, indexing rate
- Typical: 10k-50k docs/sec per node
Interview Tip: Distinguish text (analyzed, full-text) vs keyword (exact, aggregations). Mention that multi-fields allow indexing the same content multiple ways. For bulk indexing, emphasize batching and disabling refresh for throughput.
5. Query Types
Different query DSL types for various search scenarios.
| Query Type |
Description |
Use Case |
Example |
| term |
Exact match (no analysis) |
Keyword fields, IDs, enums |
{ "term": { "status": "active" } } |
| match |
Analyzed, full-text search |
Text fields, user queries |
{ "match": { "title": "quick fox" } } |
| match_phrase |
Exact phrase, order matters |
"machine learning", quotes |
{ "match_phrase": { "text": "brown fox" } } |
| bool |
Combine queries (AND/OR/NOT) |
Complex filters, multi-criteria |
{ "bool": { "must": [...], "filter": [...] } } |
| fuzzy |
Levenshtein distance (typos) |
Spelling mistakes, autocorrect |
{ "fuzzy": { "name": { "value": "quikc" } } } |
| range |
Numeric/date ranges |
Price filters, date ranges |
{ "range": { "price": { "gte": 10, "lte": 100 } } } |
| wildcard |
Pattern matching (* and ?) |
Partial matches, prefixes |
{ "wildcard": { "email": "*@gmail.com" } } |
| prefix |
Starts with (optimized) |
Autocomplete, typeahead |
{ "prefix": { "name": "john" } } |
| multi_match |
Search across multiple fields |
Search title + description |
{ "multi_match": { "query": "laptop", "fields": ["title", "desc"] } } |
| nested |
Query nested objects |
Arrays of objects (comments) |
{ "nested": { "path": "comments", "query": {...} } } |
Bool Query Structure
{
"query": {
"bool": {
"must": [ // AND, affects score
{ "match": { "title": "laptop" } }
],
"filter": [ // AND, no scoring (faster)
{ "term": { "status": "active" } },
{ "range": { "price": { "lte": 1000 } } }
],
"should": [ // OR, affects score (boost)
{ "match": { "brand": "Dell" } }
],
"must_not": [ // NOT, excludes
{ "term": { "category": "refurbished" } }
],
"minimum_should_match": 1 // at least 1 should clause must match
}
}
}
Query vs Filter Context:
- Query: Affects relevance score (expensive)
- Filter: Yes/no match, cached (fast)
- Use filter for exact matches, ranges, binary flags
Interview Tip: Explain query vs filter context. Filters are cacheable and don't affect scoring, making them much faster for boolean conditions. Use "must" for scoring, "filter" for pure filtering.
6. Relevance Tuning
Techniques to improve search result quality.
Boosting
Query-time Boost:
{
"query": {
"multi_match": {
"query": "laptop",
"fields": ["title^3", "description"] // title 3x more important
}
}
}
Index-time Boost (deprecated, use function_score):
Boost documents at index time (now handled at query time)
Function Score
{
"query": {
"function_score": {
"query": { "match": { "title": "laptop" } },
"functions": [
{
"filter": { "term": { "featured": true } },
"weight": 2 // boost featured products 2x
},
{
"gauss": { // decay function
"published_date": {
"origin": "now",
"scale": "30d",
"decay": 0.5
}
}
},
{
"field_value_factor": { // use field value
"field": "popularity",
"factor": 1.2,
"modifier": "log1p" // log(1 + popularity)
}
}
],
"boost_mode": "multiply", // how to combine scores
"score_mode": "sum" // how to combine functions
}
}
}
Decay Functions
| Function |
Curve |
Use Case |
| linear |
Straight line decay |
Simple distance penalty |
| exp |
Exponential decay |
Rapid drop-off (strict recency) |
| gauss |
Bell curve |
Smooth decay (recommended) |
Relevance Tuning Strategies
| Strategy |
Technique |
When to Use |
| Field Boosting |
title^3, tags^2 |
Prioritize certain fields |
| Recency Boost |
Gauss decay on date |
News, blog posts, time-sensitive |
| Popularity Boost |
field_value_factor on views/sales |
E-commerce, content platforms |
| Geospatial Decay |
Gauss on geo_point |
Local search, nearby results |
| Personalization |
Boost by user prefs/history |
Recommendations, personalized search |
| A/B Testing |
Compare scoring functions |
Optimize for click-through rate |
Interview Tip: Mention that relevance tuning is iterative. Use Elasticsearch's "explain" API to understand scoring. Common pattern: BM25 baseline + function_score for business logic (popularity, recency, personalization).
7. Search at Scale
Handling billions of documents and thousands of queries per second.
Shard Strategies
| Strategy |
Description |
Trade-offs |
| Few Large Shards |
5 shards × 200GB each |
Pro: Less overhead. Con: Harder to rebalance, longer recovery |
| Many Small Shards |
100 shards × 10GB each |
Pro: Better distribution. Con: Overhead, many segments |
| Time-based |
Index per day/month |
Pro: Easy to delete old data. Con: Many indices |
| Tenant-based |
Index per customer |
Pro: Isolation. Con: Uneven sizes, routing needed |
| Rollover |
Auto-create index at size/age |
Pro: Consistent shard sizes. Con: Complexity |
Shard Sizing Guidelines
Rule of thumb:
- Shard size: 20-50GB (max ~50GB)
- Shards per node: < 20 per GB heap (e.g., 32GB heap → 600 shards max)
- Documents per shard: Not a hard limit, depends on doc size
Example calculation:
1TB index, 50GB per shard → 20 primary shards
3 nodes, 1 replica → 40 total shards → 13-14 shards per node ✓
Over-sharding symptoms:
- High cluster state size (master node pressure)
- Slow search (scatter-gather to too many shards)
- Many small segments (merge overhead)
Routing
Default routing:
shard = hash(_id) % number_of_primary_shards
Custom routing:
PUT /products/_doc/1?routing=user123
{ "title": "Laptop", "user_id": "user123" }
Query with same routing:
GET /products/_search?routing=user123
Benefits:
- Co-locate related docs (reduce scatter-gather)
- Single-shard queries (much faster)
Risk:
- Unbalanced shards (hot spots)
Deep Pagination Problem
Problem with from/size:
GET /products/_search
{
"from": 10000, // Skip 10k results
"size": 10
}
What happens:
- Each shard returns top 10,010 results
- Coordinating node sorts 10,010 × num_shards
- For 10 shards: sorts 100,100 results, returns 10 (wasteful!)
Solution: search_after
1. First query:
GET /products/_search { "size": 10, "sort": [{"price": "asc"}] }
Returns docs + last sort value [99.99]
2. Next page:
GET /products/_search {
"size": 10,
"search_after": [99.99],
"sort": [{"price": "asc"}]
}
Benefits:
- Constant memory (no deep pagination penalty)
- Stateless (no scroll context)
- Live results (reflects index changes)
Limit:
- No random page access (can't jump to page 500)
Scatter-Gather Pattern
sequenceDiagram
participant Client
participant Coord as Coordinating Node
participant S1 as Shard 1
participant S2 as Shard 2
participant S3 as Shard 3
Client->>Coord: Search query (top 10)
Note over Coord,S3: Query Phase (scatter)
Coord->>S1: Score & return top 10 doc IDs
Coord->>S2: Score & return top 10 doc IDs
Coord->>S3: Score & return top 10 doc IDs
S1-->>Coord: [id1, id5, id9...] + scores
S2-->>Coord: [id2, id7, id11...] + scores
S3-->>Coord: [id3, id8, id12...] + scores
Note over Coord: Merge & sort
global top 10
Note over Coord,S3: Fetch Phase (gather)
Coord->>S1: Fetch full doc for id1, id5
Coord->>S2: Fetch full doc for id2
Coord->>S3: Fetch full doc for id3, id8
S1-->>Coord: Full documents
S2-->>Coord: Full documents
S3-->>Coord: Full documents
Coord->>Client: Final top 10 results
Interview Tip: Explain the scatter-gather problem: query phase is lightweight (IDs + scores), but fetch phase retrieves full docs. Deep pagination wastes resources in query phase. search_after solves this by using a cursor instead of offset.
8. Autocomplete
Providing instant search suggestions as users type.
Techniques Comparison
| Technique |
How It Works |
Pros |
Cons |
| Edge N-grams |
Index prefixes: q, qu, qui, quic, quick |
Fast, flexible, scores by relevance |
Large index size |
| Completion Suggester |
FST (Finite State Transducer) in memory |
Extremely fast, low latency |
No scoring, prefix-only, limited flexibility |
| Prefix Query |
Match terms starting with prefix |
Simple, no special indexing |
Slower, no mid-word match |
| Search-as-you-type |
Combined field type (edge-ngrams + shingles) |
Handles multi-word, typos |
Complex setup |
Edge N-grams Implementation
Mapping:
{
"settings": {
"analysis": {
"analyzer": {
"autocomplete": {
"tokenizer": "standard",
"filter": ["lowercase", "autocomplete_filter"]
}
},
"filter": {
"autocomplete_filter": {
"type": "edge_ngram",
"min_gram": 2,
"max_gram": 10
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "autocomplete",
"search_analyzer": "standard" // no ngrams on query
}
}
}
}
Indexing "quick":
Tokens: [qu, qui, quic, quick]
Query "qui":
Matches "quick", "quiet", "quite"
Completion Suggester
Mapping:
{
"mappings": {
"properties": {
"suggest": {
"type": "completion",
"contexts": [
{
"name": "category",
"type": "category"
}
]
}
}
}
}
Index document:
{
"suggest": {
"input": ["laptop", "notebook computer"],
"weight": 10, // priority/popularity
"contexts": {
"category": ["electronics"]
}
}
}
Query:
{
"suggest": {
"product-suggest": {
"prefix": "lap",
"completion": {
"field": "suggest",
"size": 5,
"contexts": {
"category": ["electronics"]
}
}
}
}
}
Response time: 1-5ms (in-memory FST)
Autocomplete Best Practices
| Aspect |
Recommendation |
Why |
| Latency Target |
< 50ms |
Feels instant (user typing ~200ms/char) |
| Min Characters |
2-3 characters |
Reduce too many matches, server load |
| Debouncing |
100-200ms |
Don't query every keystroke |
| Caching |
Client + server (CDN) |
Popular prefixes ("a", "the") cached |
| Popularity Weighting |
Boost by click rate |
Show most relevant suggestions first |
| Context Filtering |
Category, location, user |
Personalized, relevant suggestions |
Interview Tip: Compare edge n-grams (flexible, scored) vs completion suggester (faster, simpler). Mention that search-as-you-type field type (ES 7.2+) combines edge n-grams and shingles for multi-word autocomplete. Always debounce on client side.
9. Vector/Semantic Search
Beyond keyword matching: understanding intent and meaning.
Embedding-Based Search
graph TB
D1[Document: 'MacBook Pro']
E1[Embedding Model]
I1[(Vector Index)]
D1 --> E1 --> V1 --> I1
Q[Query: 'Apple laptop']
E2[Embedding Model]
Q --> E2 --> V2
ANN["ANN Algorithm HNSW"]
R["Results: MacBook Pro similarity: 0.95"]
V2 --> ANN
I1 --> ANN
ANN --> R
style D1 fill:#e3f2fd
style Q fill:#e3f2fd
style R fill:#e1f5e1
style ANN fill:#fff3e0
Approximate Nearest Neighbor (ANN) Algorithms
| Algorithm |
Structure |
Search Time |
Recall |
| HNSW |
Hierarchical graph (skip lists) |
O(log N) |
~95-99% (tunable) |
| IVF |
Inverted file (k-means clusters) |
O(k + n/k) |
~90-95% |
| LSH |
Locality-sensitive hashing |
O(1) average |
~80-90% |
| Exact (KNN) |
Brute force |
O(N) |
100% |
Elasticsearch Vector Search
Mapping:
{
"mappings": {
"properties": {
"title": { "type": "text" },
"embedding": {
"type": "dense_vector",
"dims": 384, // embedding dimension
"index": true,
"similarity": "cosine", // or dot_product, l2_norm
"index_options": {
"type": "hnsw",
"m": 16, // connections per layer
"ef_construction": 100 // index-time quality
}
}
}
}
}
Query:
{
"knn": {
"field": "embedding",
"query_vector": [0.2, 0.8, ...], // from embedding model
"k": 10, // top-k neighbors
"num_candidates": 100 // search more, return k (quality)
}
}
Performance:
- HNSW: 10-50ms for millions of vectors
- Exact KNN: 1000ms+ (linear scan)
Hybrid Search (BM25 + Vector)
Approach 1: Reciprocal Rank Fusion (RRF)
1. Run BM25 query → results with ranks
2. Run vector query → results with ranks
3. Combine: score = 1/(k + rank_bm25) + 1/(k + rank_vector)
4. Re-rank by combined score
Approach 2: Weighted Sum
{
"query": {
"script_score": {
"query": { "match": { "title": "laptop" } }, // BM25
"script": {
"source": "_score + cosineSimilarity(params.vector, 'embedding') * 10",
"params": { "vector": [0.2, 0.8, ...] }
}
}
}
}
Approach 3: Two-Stage (Coarse + Rerank)
1. BM25 → top 1000 candidates (fast)
2. Vector rerank → top 10 (accurate)
Why hybrid?
- BM25: Good for exact keywords, rare terms
- Vector: Good for synonyms, paraphrases, intent
- Hybrid: Best of both worlds
Embedding Models
| Model |
Dimensions |
Quality |
Speed |
| all-MiniLM-L6-v2 |
384 |
Good (general) |
Fast |
| all-mpnet-base-v2 |
768 |
Better |
Moderate |
| OpenAI ada-002 |
1536 |
Excellent |
API call |
| Cohere embed-v3 |
1024 |
Excellent |
API call |
| BGE-large |
1024 |
Excellent (MTEB) |
Moderate |
Interview Tip: Explain HNSW (Hierarchical Navigable Small World) as a multi-layer graph where search starts at the top layer and drills down. Mention that vector search is approximate (trade-off: speed vs accuracy). Hybrid search often outperforms either alone.
10. Search Alternatives Comparison
Choosing the right search engine for your use case.
| Engine |
Core Strength |
Architecture |
Scale |
Best For |
| Elasticsearch |
Full-featured, analytics, logs |
Lucene, distributed, near-RT |
Billions of docs, PB scale |
Enterprise search, logs, APM, complex queries |
| Solr |
Mature, stable, XML config |
Lucene, distributed |
Billions of docs |
Enterprise search, libraries, faceting |
| Meilisearch |
Fast, typo-tolerant, simple |
Rust, single-node |
Millions of docs |
Website search, small apps, instant results |
| Typesense |
Typo-tolerance, instant search |
C++, clustered |
Millions-billions of docs |
E-commerce, autocomplete, geo search |
| Algolia |
Hosted, fast, global CDN |
C++, distributed (SaaS) |
SaaS limits |
E-commerce, mobile apps, instant search |
| OpenSearch |
Elasticsearch fork (Apache 2.0) |
Lucene, distributed |
Billions of docs |
Same as ES, open-source purists |
| Vespa |
Real-time ML, recommendations |
Yahoo origin, distributed |
Billions of docs |
ML-powered search, personalization |
Detailed Comparison
| Feature |
Elasticsearch |
Solr |
Meilisearch |
Typesense |
Algolia |
| Setup Complexity |
Medium |
High |
Low |
Low |
Lowest (SaaS) |
| Query DSL |
JSON, powerful |
XML/JSON, complex |
Simple REST |
Simple REST |
Simple REST |
| Typo Tolerance |
Fuzzy queries (manual) |
Manual |
Built-in, automatic |
Built-in, automatic |
Built-in, automatic |
| Vector Search |
Yes (HNSW) |
Yes (plugin) |
No (roadmap) |
Yes (alpha) |
Limited |
| Aggregations |
Extensive |
Facets, extensive |
Basic facets |
Facets, grouping |
Facets |
| Real-time |
Near (1s refresh) |
Near (soft commit) |
Instant |
Instant |
Instant |
| Geo Search |
Yes (geo_point) |
Yes |
Yes |
Yes (strong) |
Yes (strong) |
| Multi-tenancy |
Index per tenant or field |
Collection per tenant |
Index per tenant |
Collection per tenant |
Index per tenant |
| Cost |
Self-hosted (infra) or Elastic Cloud |
Self-hosted |
Self-hosted or cloud |
Self-hosted or cloud |
Pay per operation (expensive) |
| Language |
Java |
Java |
Rust |
C++ |
C++ |
| License |
SSPL (v7.11+) / Elastic |
Apache 2.0 |
MIT |
GPL v3 (self-host free) |
Proprietary (SaaS) |
Selection Criteria
| Use Case |
Recommended |
Why |
| Log Analytics |
Elasticsearch / OpenSearch |
Time-series optimization, Kibana, aggregations |
| E-commerce Search |
Typesense / Algolia |
Fast autocomplete, typo-tolerance, faceting |
| Enterprise Search |
Elasticsearch / Solr |
Complex queries, security, scale |
| Simple Website Search |
Meilisearch |
Easy setup, fast, typo-tolerant, affordable |
| Mobile App Search |
Algolia |
Global CDN, instant results, managed |
| Semantic/Vector Search |
Elasticsearch / Vespa |
HNSW support, hybrid search |
| Recommendations |
Vespa |
ML models, personalization, real-time |
| Open Source (no restrictions) |
OpenSearch / Solr / Meilisearch |
Apache 2.0 / MIT licenses |
Interview Tip: For "design a search system" questions, start with requirements (scale, latency, features). Elasticsearch is the safe default for most cases. Mention simpler alternatives for smaller scale or specific needs (e.g., Meilisearch for < 10M docs, Algolia for managed/global). Discuss trade-offs: operational complexity vs features vs cost.
Key Takeaways
- Inverted Index: Core data structure mapping terms to documents. Enables O(1) term lookup, O(k) intersection.
- Scoring: BM25 superior to TF-IDF for varied document lengths and spam resistance (saturation + length normalization).
- Elasticsearch Architecture: Distributed Lucene with shards/replicas. Near-real-time via refresh (1s). Scatter-gather for queries.
- Indexing: Analysis chain (tokenizer + filters). Text fields analyzed, keyword fields exact. Bulk indexing for throughput.
- Query Types: Term (exact), match (analyzed), bool (combine), phrase (order), fuzzy (typos), range (numeric/date).
- Relevance: Field boosting, function_score for business logic (popularity, recency, personalization). Iterative tuning.
- Scale: Shard sizing (20-50GB), routing for co-location, search_after for deep pagination (no offset penalty).
- Autocomplete: Edge n-grams (flexible) vs completion suggester (faster). Debounce client-side, cache popular prefixes.
- Vector Search: HNSW for ANN, ~log(N) search time. Hybrid (BM25 + vector) often best. Embeddings capture semantic meaning.
- Alternatives: Elasticsearch for enterprise/logs, Typesense/Algolia for e-commerce, Meilisearch for simple/small, Vespa for ML.