TL;DR

1. The problem: why can't the database just search?

When you tap the search bar on Instagram and type sun, you expect to instantly see @sunsetchaser, #sunset and Sunset Boulevard, Los Angeles — ranked so that the account your friend follows is on top. Then you type suns and the list updates again, in under 100 ms. Every keystroke is a new query.

Try that with the User DB (sharded PostgreSQL in our architecture):

SELECT * FROM users
WHERE username ILIKE '%sun%' OR full_name ILIKE '%sun%'
ORDER BY follower_count DESC LIMIT 10;

The solution is a dedicated Search Service backed by a search index (Elasticsearch in our diagram) that is built from the source-of-truth databases and optimised for one thing: finding the best few documents that match some text, fast.

🧠 Analogy To find every page of a 1,000-page textbook that mentions "photosynthesis", you could read every page (a table scan) — or flip to the index at the back, which lists the word alphabetically followed by page numbers: photosynthesis … 12, 47, 311. That back-of-book index is literally an inverted index. A search engine builds one for billions of "pages" (users, hashtags, places), keeps it updated every second, and also decides which page numbers are the best answer for you.

2. Requirements and numbers

2.1 Functional requirements

2.2 Non-functional requirements

2.3 Back-of-the-envelope (assumptions)

Assume 500M DAU, 5 searches/day each           → 2.5B searches/day
Each search ≈ 6 typeahead keystroke requests    → 15B typeahead requests/day
15B / 86,400 s ≈ 175,000 requests/s average     → ~400,000/s at peak
(client-side debouncing and caching typically cut this by 30-50%)

Corpus (assumptions):
  users:    ~2B accounts × ~1 KB of searchable fields   ≈ 2 TB raw
  hashtags: ~1B distinct tags × ~100 B                   ≈ 100 GB raw
  places:   ~200M places × ~500 B                        ≈ 100 GB raw
Index overhead (postings, n-grams, doc values) ≈ 1-3× raw → ~3-6 TB primary data
With 2 replicas → ~10-18 TB across the search cluster

Note the shape: the corpus is moderate (a few TB) but the query rate is enormous and latency-critical. That pushes us toward many replicas, aggressive caching, and keeping hot data in RAM.

3. The inverted index, step by step

A normal (forward) index maps document → words: "user 17's bio contains these words". An inverted index flips it: word → documents. Let's build one by hand.

3.1 A worked example

Take three tiny documents (think of them as post captions or bios):

Doc IDText
1Sunset at the beach 🌅 #Travel
2Best tacos on the beach
3Chasing sunsets daily #travel
  1. Analyze each document into terms (details in section 4): lowercase, drop stop words (at, the, on), stem (sunsets → sunset, chasing → chase, tacos → taco), keep hashtags and emoji as tokens.
    Doc 1 → sunset, beach, 🌅, #travel · Doc 2 → best, taco, beach · Doc 3 → chase, sunset, daily, #travel
  2. Invert: for each term, record the IDs of documents containing it, in sorted order. Optionally store term frequency and positions (for phrase queries like "sunset beach").
  3. Sort the terms into a dictionary (Lucene stores it as a compact FST, a finite-state transducer — think of it as a compressed trie) so any term can be located in microseconds.
Documents (forward) 1: Sunset at the beach 🌅 #Travel 2: Best tacos on the beach 3: Chasing sunsets daily #travel analyze + invert Inverted index (term → postings) #travel→ [1, 3] beach→ [1, 2] best→ [2] chase→ [3] daily→ [3] sunset→ [1, 3] taco→ [2] 🌅→ [1] Query "sunsets beach" → terms: sunset, beach AND: [1,3] ∩ [1,2] = [1]
Building an inverted index: each analysed term points to a sorted list of document IDs (its posting list).

3.2 Answering queries

Now the query sunsets beach is analysed with the same analyzer → terms sunset, beach. Look up both posting lists:

Because posting lists are sorted, intersection is a linear two-pointer walk — the same merge idea used in merge sort:

def intersect(a, b):                 # a, b sorted doc-ID lists
    i = j = 0; out = []
    while i < len(a) and j < len(b):
        if a[i] == b[j]: out.append(a[i]); i += 1; j += 1
        elif a[i] < b[j]: i += 1
        else: j += 1
    return out
# Real engines start with the SHORTEST list and use skip pointers to
# leap ahead in the long one: "rare AND common" costs ~ size of the rare list.

3.3 Why it's fast and small

4. Text analysis: turning text into terms

Whether a search "finds" something depends entirely on whether the query and document produce the same terms. The component that does this is the analyzer, a pipeline of three stages:

Raw text"Café Déjà-Vu ☕ #SunsetVibes"
→
Char filtersUnicode NFKC, strip HTML
→
Tokenizersplit into tokens
→
Token filterslowercase, fold, stem…
→
Termscafe, deja, vu, ☕, #sunsetvibes, sunset, vibes

4.1 The standard steps

StepWhat it doesExampleWhy
Unicode normalization (char filter)Converts equivalent code-point sequences to one form (NFC/NFKC)"é" as one code point vs "e" + combining accent → same; full-width "sun" → "sun"Visually identical strings must match
TokenizationSplits text into tokens at word boundaries (Unicode text segmentation rules)"Best tacos, ever!" → [Best, tacos, ever]Terms are the unit of the index
LowercasingCase-folds every token"Sunset" → "sunset"Users rarely type case correctly
ASCII / accent foldingRemoves diacritics"café" → "cafe", "São" → "sao"Users often type without accents
Stop wordsDrops very common words"the", "at", "on" removedSaves space; but can break queries like "The Who" — often kept for names
StemmingChops words to a root by rules (e.g. Porter / Snowball stemmers)"running", "runs" → "run"; "sunsets" → "sunset"Matches different forms of a word
Lemmatization (alternative)Dictionary-based root form"better" → "good", "mice" → "mouse"More accurate than stemming, slower
SynonymsAdds equivalent terms"nyc" ↔ "new york"Matches user intent

4.2 Instagram-specific text: usernames, hashtags, emoji, scripts

⚠️ The golden rule of analysis The query must be analysed the same way as the indexed text (or deliberately compatibly — e.g. edge n-grams at index time but not at query time). If you stem at index time but not at query time, "sunsets" is indexed as sunset but searched as sunsets → zero results. And changing an analyzer means reindexing everything, because terms already in the index were produced by the old rules.

5. Lucene & Elasticsearch architecture

Lucene is a Java library that implements an inverted index on a single machine. Elasticsearch (and its fork OpenSearch, and Solr) wraps Lucene into a distributed system: it splits data across machines, replicates it, routes queries and exposes a JSON/HTTP API. Learn these terms precisely — interviewers love them.

🗂️ Index

A logical collection of documents with the same kind of schema (a mapping), e.g. users, hashtags, places. Like a table.

📄 Document

A JSON object, e.g. one user: {username, full_name, follower_count, verified, ...}. Like a row.

🧩 Shard

A slice of an index; each shard is one complete Lucene index holding a subset of the documents. A primary shard accepts writes.

🪞 Replica

A copy of a primary shard on a different node. Used for failover and to serve more read queries in parallel.

🧱 Segment

An immutable mini-index inside a shard. A shard is a set of segments searched together.

🖥️ Node / cluster

A node is one Elasticsearch server; the cluster is all nodes plus elected master nodes that manage shard placement.

Search Service Coordinating nodescatter query, gather top-k Node 1 Node 2 Node 3 P0primary R1replica P1primary R2replica P2primary R0replica
An index with 3 primary shards and 1 replica each, spread over 3 nodes so that no node holds both copies of the same shard. A query hits one copy of every shard.

5.1 Writing: routing, translog, refresh and near-real-time search

A document is routed to a primary shard by shard = hash(_routing) % number_of_primary_shards (the routing key defaults to the document ID). This is why the number of primary shards is fixed when the index is created — changing it changes every document's home (it requires a split, shrink or reindex; compare with consistent hashing). The primary indexes the document and forwards it to its replicas.

Inside a shard, Lucene never modifies existing files. New documents go to an in-memory buffer and an append-only translog (a write-ahead log, for durability):

 index request ──► in-memory buffer  +  translog append (fsync'd per request or every few s)
                         │
                         │  REFRESH  (default every 1 s)
                         ▼
               new small segment, opened for search   ──►  document is now SEARCHABLE
               (written to the OS page cache, not yet fsync'd)
                         │
                         │  FLUSH  (periodically, or when the translog grows large)
                         ▼
               segments fsync'd to disk + new commit point; translog trimmed
                         │
                         │  MERGE  (background, continuous)
                         ▼
     many small segments ──► fewer, larger segments; deleted docs physically removed

5.2 Reading: query-then-fetch

  1. The coordinating node (any node that receives the request) sends the query to one copy (primary or replica) of every shard of the index — a scatter-gather.
  2. Query phase: each shard searches all its segments and returns only its top-k (doc ID, score) pairs. With 20 shards and k = 10, the coordinator receives 200 pairs.
  3. The coordinator merges them into the global top 10 (a k-way merge again).
  4. Fetch phase: it asks only the shards holding those 10 winners for the actual documents (or just the fields needed), then returns the results.
⚠️ Deep pagination is expensive To return results 10,000–10,010, every shard must produce its top 10,010 and the coordinator must sort 20 × 10,010 entries. That's why Elasticsearch caps from + size (10,000 by default) and recommends search_after (a cursor of the last sort values) for deep scrolling — the same cursor idea as in feed pagination.

5.3 Sizing shards and replicas

6. Document-partitioned vs term-partitioned indexes

There are two fundamentally different ways to split an inverted index across machines (this is sharding applied to search):

 DOCUMENT-partitioned (local index)              TERM-partitioned (global index)
 ┌──────────── Shard A ───────────┐              ┌──────────── Shard A ───────────┐
 │ docs 1-1M: ALL terms           │              │ terms a..m: postings for ALL   │
 │ beach → [3, 77, 902 ...]       │              │ docs: beach → [3,77,...,9.9M]  │
 │ sunset → [5, 77 ...]           │              └────────────────────────────────┘
 └────────────────────────────────┘              ┌──────────── Shard B ───────────┐
 ┌──────────── Shard B ───────────┐              │ terms n..z: postings for ALL   │
 │ docs 1M-2M: ALL terms          │              │ docs: sunset → [5,77,...,9.8M] │
 │ beach → [1000012 ...]          │              └────────────────────────────────┘
 └────────────────────────────────┘
 query "sunset beach" → ask EVERY shard         query "sunset beach" → ask shard A (beach)
 write doc → ONE shard                          and shard B (sunset), ship lists, intersect
                                                write doc → one shard PER TERM it contains
Document-partitioned ⭐Term-partitioned
Each shard holdsA complete index of a subset of documentsComplete posting lists for a subset of terms
Query costEvery shard does a little work (scatter-gather to all)Only shards owning the query's terms are touched
Indexing a documentTouches one shardTouches one shard per distinct term — a distributed transaction-like write
Multi-term queriesIntersection is local to each shard (cheap)Long posting lists must be shipped across the network to intersect
Load balanceEven (documents hash evenly)Skewed: popular terms (#love) create hot shards
Tail latencyGrows with shard count (slowest shard wins)Fewer shards per query
Used byElasticsearch, Solr, OpenSearch, most production enginesSome specialised and research systems; mostly a theoretical alternative today

Document partitioning wins in practice because indexing stays simple and load stays balanced; its main cost — every query touches every shard — is managed by keeping the shard count moderate, using replicas, and caching. A useful hybrid is custom routing: if a query is always scoped to one key (e.g. "search within one user's own posts"), route documents by that key so the query touches one shard.

7. The indexing pipeline: Kafka, CDC and eventual consistency

The search index is a derived view of the source-of-truth databases. It must be kept up to date without slowing down or coupling the write path. In our architecture this is the Search Indexer consumer of Kafka.

User DB / Post storesource of truth
→CDC / events
Kafkauser.changed, post.created
→
Search Indexertransform, batch
→_bulk API
Elasticsearchusers, hashtags, places

7.1 Getting changes out of the databases

7.2 The indexer

 User Svc     PostgreSQL     CDC connector     Kafka (user.changes)     Search Indexer        Elasticsearch
    | UPDATE username |            |                    |                       |                     |
    |---------------> | commit     |                    |                       |                     |
    |                 |-- WAL ---->|-- {id:17, username:"maya.t", lsn:9001} -->|                     |
    |                 |            |                    |-- poll batch (500) -->|                     |
    |                 |            |                    |                       | build search docs   |
    |                 |            |                    |                       | (denormalize: follower
    |                 |            |                    |                       |  count, verified...) |
    |                 |            |                    |                       |-- POST _bulk -----> |
    |                 |            |                    |                       |   version=9001      |
    |                 |            |                    |                       |   version_type=     |
    |                 |            |                    |                       |   external          |
    |                 |            |                    |                       |<-- 200 (per-item) --|
    |                 |            |                    |<-- commit offsets ----|                     |
    |                 |            |                    |                       |   ~1 s later: refresh
    |                 |            |                    |                       |   → "maya.t" searchable

7.3 Eventual consistency and its user-visible effects

End-to-end lag = CDC delay + Kafka lag + batching + refresh interval — typically 1–10 seconds, longer during incidents. Consequences and fixes:

7.4 Reindexing without downtime

Changing a mapping or analyzer requires rebuilding the index. The standard pattern uses an alias (a pointer name that queries use):

  1. Create users_v2 with the new mapping. Queries still go to alias users → users_v1.
  2. Backfill users_v2 from a database snapshot (a Spark/batch job), recording the Kafka offset at snapshot time.
  3. Start a second indexer consumer group writing live changes to users_v2 from that offset (external versioning resolves overlaps).
  4. Validate (document counts, sample queries, relevance metrics), then atomically switch the alias users → users_v2. Keep v1 for quick rollback.

8. Relevance: deciding which match is best

Finding matching documents is half the job; the other half is ranking them. For "maya" there may be 2 million matching accounts — the user will look at the top 5.

8.1 TF-IDF: the classic intuition

Two common-sense ideas:

tf-idf(t, d) = tf(t, d) × idf(t)          idf(t) = log( N / df(t) )
   N = number of documents, df(t) = number of documents containing t

Worked example: N = 1,000,000 captions
  "sunset" appears in 1,000 docs    → idf = log10(1,000,000 / 1,000)   = 3.0
  "photo"  appears in 900,000 docs  → idf = log10(1,000,000 / 900,000) ≈ 0.046

Query "sunset photo": a caption that contains "sunset" once scores ~3.0 from that term,
while containing "photo" adds only ~0.05. The rare word dominates — as it should.

Plain TF-IDF has two weaknesses: TF grows without limit (a caption that spams "sunset sunset sunset ×50" wins), and long documents match more terms just by being long.

8.2 BM25: the modern default

BM25 ("Best Matching 25") is the default similarity in Lucene/Elasticsearch. It fixes both problems:

                         f(t,d) · (k1 + 1)
score(d, q) = Σ  IDF(t) · ─────────────────────────────────────────
             t∈q          f(t,d) + k1 · (1 − b + b · |d| / avgdl)

  f(t,d)  = how many times term t occurs in document d
  |d|     = length of d in terms;  avgdl = average document length
  k1 ≈ 1.2  controls TF saturation;  b ≈ 0.75 controls length normalization
  IDF(t)  = ln( 1 + (N − n + 0.5) / (n + 0.5) ),  n = docs containing t

Intuition, using an average-length document (|d| = avgdl, so the length term is 1):

Occurrences fBM25 TF part = f·2.2 / (f + 1.2)Plain TF
11.001
21.382
51.775
101.9610
∞→ 2.2 (k1 + 1), a hard ceiling∞

Field boosts refine this further: a match in username is worth more than in full_name, which is worth more than in bio; an exact match ("maya" = @maya) beats a prefix match, which beats a fuzzy match.

8.3 Beyond text: popularity, social and personal signals

For people search, text relevance alone is nearly useless: thousands of accounts are called "maya". What users want is the Maya they mean. Signals:

Signal familyExamplesWhere it comes from
Text match qualityBM25 score, exact vs prefix vs fuzzy, which field matchedThe search engine
Popularity / qualityFollower count (log-scaled), verified badge, engagement rate, account age, spam scoreDenormalised fields in the search document
Social graphYou follow them; they follow you; mutual followers; your friends follow themGraph service at query time
Personal historyYou searched/visited this profile before; you DM them; you liked their postsPer-user activity store / feature store
ContextYour language, country, location (for places), trending now (for hashtags)Request context, stream aggregates

A practical design is two-stage ranking, the same funnel as the feed:

  1. Retrieval: the search engine finds candidates using text match plus cheap static boosts, returning the top ~200–1,000 per index.
  2. Re-ranking: the Search Service fetches social and personal features for those candidates (batched) and scores them with a learning-to-rank model (e.g. gradient-boosted trees) trained on clicks: which result did people tap for this query?
  3. Blending: results from the users, hashtags and places indices are merged into one list ("Top" tab), using per-type scores calibrated to be comparable, plus rules (e.g. an exact username match always first).
# Elasticsearch-style first stage for user search (simplified)
{
  "query": { "function_score": {
      "query": { "multi_match": {
          "query": "maya",
          "fields": ["username^3", "username.edge^2", "full_name"],
          "type": "best_fields" } },
      "functions": [
        { "field_value_factor": { "field": "follower_count", "modifier": "log1p", "factor": 0.5 } },
        { "filter": { "term": { "verified": true } }, "weight": 1.5 }
      ],
      "boost_mode": "multiply" } },
  "post_filter": { "term": { "is_searchable": true } },
  "size": 200
}
💡 Personal results first, then global A very effective trick for people search: the most likely targets are people in your own network. A small per-user index (the accounts you follow, plus recent searches and frequent contacts — a few hundred to a few thousand entries) can be searched first, even on the device, giving instant results for the first keystroke while the global query is still in flight. Facebook has publicly described this kind of "first-degree connections first" approach for its typeahead.

9. Typeahead (autocomplete)

Typeahead is the hardest latency target: a query per keystroke, very short and ambiguous prefixes ("s"), a budget of tens of milliseconds, and results that must feel personal. There are three main tools.

9.1 Tries with top-k at every node

A trie (prefix tree) stores strings character by character; every path from the root spells a prefix. Finding all completions of "su" means walking to the "su" node and exploring its subtree — but the subtree for "s" might contain 50 million terms. The fix: precompute and store the top-k completions at every node. Lookup becomes "walk |prefix| steps, read the list" — O(prefix length), independent of corpus size.

root "s"top: sunset, summer, sushi "su"top: sunset, summer, sushi "sun"top: sunset, sunny "sum" … "summer" ★top: summer "sus" … "sushi" ★top: sushi "sunset" ★score 9.1M "sunny" ★score 2.3M each node caches its best k completions ★ = complete term
A (compressed) trie for hashtags. Typing "su" walks two edges and returns the cached list instantly — no subtree search.

9.2 Edge n-gram indexes

The alternative that works inside a normal search engine: at index time, emit every prefix of each word as a term. For maya_travels:

edge n-grams (min 1, max 15) of "maya":    m, ma, may, maya
edge n-grams of "travels":                  t, tr, tra, trav, trave, travel, travels

Index time: analyzer = lowercase → split on _ . → edge_ngram
Query time: analyzer = lowercase only   (do NOT n-gram the query!)
Query "trav" → a plain term lookup of "trav" → matches maya_travels

Edge n-grams turn a prefix query into an ordinary, fast term lookup, and the result benefits from all the normal machinery (BM25, filters, function scores, personalization). The cost is a bigger index (each word produces up to ~10 terms) and very long posting lists for 1–2 letter prefixes. (Full n-grams — all substrings, not just prefixes — allow "contains" matching and some typo tolerance but are much larger.)

9.3 Top-k caching for short prefixes

Short prefixes are both the most frequent (every search starts with one letter) and the most expensive (posting lists of millions). But there are only 26 one-letter and ~1,000 common two-letter prefixes per language. So precompute: a batch job computes the global top-k for every prefix up to length 2–3 per country/language and stores it in Redis or in-process memory. Typeahead then becomes:

def typeahead(viewer, prefix):
    prefix = normalize(prefix)                       # lowercase, NFKC, trim
    personal = local_index[viewer].prefix_search(prefix)      # follows, recents (instant)
    if len(prefix) <= 2:
        global_ = topk_cache.get(f"ta:{viewer.country}:{prefix}")   # precomputed
    else:
        global_ = es.search(edge_ngram_query(prefix), size=50, timeout_ms=60)
    return rerank(viewer, dedupe(personal + global_))[:10]

9.4 Client-side tricks (they matter as much as the server)

10. Caching popular queries

Query popularity follows a Zipf-like distribution: a small set of queries (celebrity names, trending hashtags, big cities) accounts for a large share of traffic. That makes caching extremely effective (see Caching):

11. Geo search for places

For places, "relevant" mostly means near me and popular. Typing "starb" in Mumbai should show Starbucks branches in Mumbai, not Seattle. We need to answer "places matching X within ~5 km of (lat, lon)" quickly.

11.1 Geohash

A geohash encodes a latitude/longitude into a short string by repeatedly halving the world: the first bit says east or west half (longitude), the next says north or south half (latitude), and so on, alternating. Every 5 bits become one base-32 character. Each extra character makes the cell ~32× smaller in area, and points that share a prefix are in the same cell, so proximity becomes a prefix match — which inverted indexes and B-trees are great at.

Geohash lengthApprox. cell sizeGood for
4~39 km × 19.5 kmCity / metro area
5~4.9 km × 4.9 km"Near me" neighbourhood
6~1.2 km × 0.6 kmWalking distance
7~153 m × 153 mA block
8~38 m × 19 mA building
 Worked example (Mumbai)
   Gateway of India      (18.9220, 72.8347) → te7g9kss
   CST railway station   (18.9400, 72.8350) → te7g9ruv   shares "te7g9" → same ~5 km cell
   Colaba, west side     (18.9067, 72.8147) → te7g8cy5   only ~3 km away, but shares just "te7g"
                                                          (it sits in the cell to the WEST)

   Real neighbours of cell te7g9 (precision 5):    ┌───────┬───────┬───────┐
   a point near a cell EDGE can be 50 m from a      │ te7gb │ te7gc │ te7gf │   N
   point in the next cell with a different prefix → ├───────┼───────┼───────┤
   so always search the cell AND its 8 neighbours   │ te7g8 │*te7g9*│ te7gd │
                                                    ├───────┼───────┼───────┤
                                                    │ te7g2 │ te7g3 │ te7g6 │   S
                                                    └───────┴───────┴───────┘
                                                       W                 E

Notice that the neighbours' last characters (b, c, f, 8, d, 2, 3, 6) don't follow alphabetical order: geohash cells are laid out along a zig-zag Z-order curve, so neighbours are computed with a small lookup algorithm (every geo library has one), never by incrementing characters.

11.2 Running a place query

  1. Compute the user's geohash at a precision matching the search radius (5 chars ≈ 5 km), plus its 8 neighbours.
  2. Filter place documents whose geohash starts with any of those 9 prefixes (cheap term/prefix filters), and whose name matches the text (edge n-grams for typeahead).
  3. Compute exact distances for the survivors and score: text match × popularity (check-ins, tagged posts) × a distance decay function (e.g. Gaussian: full score within 1 km, half at 5 km).
  4. If too few results, widen to precision 4 (city) and then drop the geo filter entirely ("Starbucks Reserve Roastery, Seattle" is still a valid answer if nothing is nearby).
# Elasticsearch has this built in: geo_point fields are indexed with BKD trees,
# and a distance decay can be applied inside function_score.
{ "query": { "function_score": {
    "query": { "bool": {
        "must":   { "match": { "name.edge": "starb" } },
        "filter": { "geo_distance": { "distance": "10km", "location": { "lat": 18.92, "lon": 72.83 } } } } },
    "functions": [
      { "gauss": { "location": { "origin": "18.92,72.83", "scale": "2km" } } },
      { "field_value_factor": { "field": "checkins", "modifier": "log1p" } } ] } } }

Alternatives to geohash: quadtrees (adaptive cells — small where places are dense, big where sparse), Google's S2 cells (on a sphere, fewer edge distortions) and Uber's hexagonal H3 (equal-distance neighbours). All share the idea: map 2-D space onto 1-D cell IDs so standard indexes can search it.

12. Search vs Explore: retrieval vs recommendation

Instagram has a search bar and an Explore grid on the same tab, but they are different systems:

SearchExplore (recommendations)
InputAn explicit query typed by the user ("maya", "#sunset")No query — the user's history and interests
Core question"Which documents match this text, and which match best?""What might this user enjoy that they haven't seen?"
Candidate retrievalInverted index lookups (terms, prefixes, geohashes)Embedding similarity (approximate nearest neighbour search), co-engagement ("people who liked X also liked Y"), graph walks
Main data storeSearch index (Elasticsearch)Embedding indexes, feature stores, model servers
Success metricDid the user find what they wanted? (click on a top result, time to success)Engagement (likes, saves, watch time) and long-term satisfaction
FreshnessNew accounts must be findable in secondsNew posts need engagement signals before being recommended widely
LessonThis oneAnalytics & ML Ranking

They do converge: search ranking uses ML (learning to rank), and "keyword search for posts" (searching captions/topics) blends retrieval with recommendation — the hashtag page's "Top posts" are ranked with engagement models. But in an interview, keep them separate: search is pull with intent; Explore is push without a query.

13. Scaling search: putting numbers on it

Let's size the cluster for the assumptions in section 2.3 (all numbers are illustrative):

Data: ~4 TB of primary index (users ~3 TB with n-grams, hashtags ~0.5 TB, places ~0.5 TB)
Shard size target ~30 GB → users: ~100 primary shards; hashtags/places: ~16 each

Query load: ~400k typeahead req/s at peak
  − client debouncing & caches (~40%)           → ~240k req/s reach the Search Service
  − precomputed short-prefix & result caches (~50%) → ~120k req/s reach Elasticsearch
  Each request fans out to every shard of the index queried (scatter-gather)

Assume one shard copy handles ~500 simple queries/s with data in RAM
  users index: 120k q/s needs 120k / 500 = 240 copies of EVERY shard?  ✗ far too many
  → this is why typeahead is NOT a 100-shard scatter-gather:
     • short prefixes are served from precomputed top-k caches
     • a dedicated small "typeahead" index holds only the ~50M most searchable
       accounts (2-4 shards, fully in RAM), with ~20-40 replicas across regions
     • the full 100-shard users index serves only "see all results" queries

This calculation reveals the key scaling insights:

14. How Instagram uses search

📱 Search tabtypes "sun"
→debounced
API Gateway
→
Search Servicepersonal + global
→top-k / query
Search Indexusers, tags, places
→re-rank, filter
📱 Results
  1. When you open the search tab, the app shows recent searches and preloads a small personal index (accounts you interact with).
  2. As you type, debounced requests go through the API Gateway to the Search Service, which queries the users, hashtags and places indices in parallel (short prefixes hit precomputed caches).
  3. Candidates are re-ranked with social signals from the Follow/Graph Service and personal history, filtered for blocks, privacy and policy, and blended into the "Top" list; tabs (Accounts, Tags, Places) show per-type lists.
  4. Every change that affects search — new account, rename, new post with hashtags, new place tag — flows through Kafka to the Search Indexer and into Elasticsearch within seconds.
  5. Taps and query logs flow to Analytics & ML to train the ranking model and rebuild typeahead top-k lists.
📸 In Instagram Our architecture diagram uses Elasticsearch as the search index, which is the standard open-source choice for this design. Inside Meta, social search has historically run on in-house systems: Facebook publicly described Unicorn (a 2013 VLDB paper), an in-memory inverted-index system built for searching the social graph, which powers typeahead and graph-aware search by combining text terms with graph edges (e.g. "people named Maya who are friends of my friends"). Public descriptions of Instagram search emphasise exactly the signals discussed here — the text you type, your activity (accounts you follow, posts you've interacted with) and the popularity of results. Exact infrastructure, thresholds and numbers are internal; treat all figures in this lesson as assumptions.
📸 Instagram-specific design choices

15. Trade-offs & common pitfalls

✅ Dedicated search index: benefits

  • Millisecond full-text, prefix and geo queries over billions of docs
  • Rich relevance (BM25, boosts, function scores, LTR)
  • Scales reads horizontally with replicas
  • Decoupled from the write path — search outages don't block posting
  • Language-aware analysis per field

⚠️ Costs

  • Another large system to run (merges, shard balancing, upgrades)
  • Eventual consistency — seconds of indexing lag
  • Data duplication and drift vs the source of truth
  • Analyzer/mapping changes require full reindexing
  • Scatter-gather tail latency grows with shard count
  • Memory-hungry, especially n-gram indexes

Common pitfalls

16. Real-world technologies

TechnologyWhat it isWhen to use it
Apache LuceneThe Java inverted-index library underneath most enginesEmbedding search inside your own JVM service
Elasticsearch / OpenSearchDistributed Lucene with JSON API, aggregations, geo, completion suggesterThe default for full-text + typeahead + geo at scale (our diagram's choice)
Apache SolrMature distributed Lucene server (SolrCloud)Similar use cases; common in enterprise and e-commerce search
VespaSearch + ML ranking engine (from Yahoo) with built-in tensor rankingWhen large-scale ML re-ranking must run inside the engine
Algolia, Typesense, MeilisearchTypeahead-optimised hosted / lightweight engines with typo toleranceFast product search for small-to-medium catalogues
Redis (sorted sets, RediSearch)In-memory prefix → top-k lists; lightweight secondary indexesPrecomputed typeahead caches, query result caches
PostgreSQL full-text (tsvector, GIN, pg_trgm)Inverted index inside the relational DBSmall/medium apps where a separate cluster isn't worth it
Debezium + Kafka ConnectCDC from database logs into Kafka; sink connectors into ElasticsearchKeeping the index in sync without dual writes
FAISS / ScaNN / HNSW librariesApproximate nearest-neighbour vector searchSemantic search and Explore-style recommendation retrieval

17. Interview corner

Design search for Instagram users, hashtags and places.

Separate indices per entity in Elasticsearch, fed by Kafka (CDC from the User DB, post events for hashtags, places DB). Users indexed on username (exact keyword, split parts, edge n-grams) and full name (folded, n-grams), with denormalised follower_count, verified, is_private. The Search Service runs typeahead: personal index first (follows, recents), precomputed top-k for 1–2 character prefixes, edge-n-gram queries for longer ones, then re-ranks ~200 candidates with social and personal features using a learning-to-rank model and filters blocks/policy. Places use geo_point/geohash filters plus distance decay. Index is eventually consistent (~seconds); reindexing via aliases.

Explain an inverted index and how a two-word query is executed.

It maps each analysed term to a sorted, compressed posting list of document IDs (plus frequencies/positions). For "sunset beach", analyse the query into terms, fetch both posting lists, intersect them with a two-pointer/skip-list walk starting from the shorter list (AND) or union them (OR), score the matching docs with BM25 plus boosts, keep the top-k in a heap. In a sharded cluster each shard does this locally and the coordinator merges per-shard top-k, then fetches the winning documents.

Why is Elasticsearch "near-real-time" and not real-time?

Indexed documents go to an in-memory buffer and the translog; they become visible to search only when a refresh writes the buffer as a new immutable segment and opens it — by default every 1 second. The translog provides durability before segments are fsync'd (flush). Refreshing more often creates more tiny segments and more merge work, so it's a throughput/freshness trade-off.

Document-partitioned or term-partitioned index — which and why?

Document-partitioned: each shard is a complete index of a subset of documents. Writes touch one shard, load is balanced, and multi-term intersections are local. The cost is scatter-gather to every shard per query, handled with a moderate shard count, replicas and caching. Term partitioning reduces shards touched per query but makes every write touch many shards, creates hot shards for popular terms, and needs posting lists shipped across the network to intersect.

How do you implement typeahead with < 100 ms latency at 400k QPS?

Client debouncing and cancellation; instant personal results from a small per-user index; precomputed top-k for very short prefixes (tries/Redis) per locale; edge-n-gram indexes (or an FST completion suggester) for longer prefixes in a small, hot, in-RAM "typeahead" index with many replicas; a result cache for popular prefixes; a lightweight re-rank with social signals; strict timeouts with partial results. Tries with top-k per node make lookup O(prefix length).

Explain BM25 and why it beats raw TF-IDF.

BM25 scores a document as the sum over query terms of IDF × a saturating TF component normalised by document length: f·(k1+1) / (f + k1·(1−b+b·|d|/avgdl)). Saturation (k1 ≈ 1.2) means repeated occurrences give diminishing returns, so keyword stuffing doesn't win; length normalisation (b ≈ 0.75) means matches in short fields count more. IDF rewards rare terms. Raw TF-IDF grows linearly with TF and favours long documents.

How do you keep the index consistent with the database?

Never dual-write. Capture changes with CDC (Debezium on the PostgreSQL WAL) or a transactional outbox into Kafka, partitioned by entity ID for per-entity ordering. The indexer bulk-writes with external versioning (source LSN/updated_at) so retries and reordering can't overwrite newer data. Deletes are propagated as deletes. A periodic reconciliation job compares DB and index. Privacy-sensitive checks (blocks, private, banned) are re-applied at query time.

How would you find "coffee shops near me"?

Index places with a geo_point (geohash/BKD). At query time compute the user's cell at a radius-appropriate precision plus its 8 neighbours (to handle edge effects), filter by those cells or a geo_distance filter, match the text with edge n-grams, and score with text relevance × popularity × distance decay (Gaussian). Widen the radius if too few results. Quadtrees, S2 or H3 are alternatives to geohash.

18. Check your understanding

Build the posting list for the term "cat" given: D1 "Cats are great", D2 "A dog and a cat", D3 "Dogs only". (Assume lowercase + stemming.)

cat → [1, 2]. "Cats" stems to "cat" in D1; D2 contains "cat"; D3 doesn't. Similarly dog → [2, 3]; the query "cat dog" (AND) returns [1,2] ∩ [2,3] = [2].

Your index stems words at index time, but a developer configured the query to use a "keyword" analyzer. A user searches "Sunsets". What happens?

The index contains the term sunset, but the query looks up the literal term Sunsets (not even lowercased) → zero results. Index-time and query-time analysis must be compatible.

With N = 10M documents, term A appears in 100 docs and term B in 5M docs. Using idf = log10(N/df), which term contributes more, and by how much?

idf(A) = log10(10M/100) = 5; idf(B) = log10(10M/5M) ≈ 0.30. A contributes ~17× more per occurrence — rare terms carry the meaning.

Under BM25 with k1 = 1.2 on an average-length document, how much more does a term occurring 4 times score compared with once?

TF part = f·2.2/(f+1.2): f=1 → 1.0; f=4 → 8.8/5.2 ≈ 1.69. So only ~1.7× more, not 4× — that's TF saturation.

An index has 50 shards with 1 replica. A query asks for the top 20 results. How many (doc, score) pairs does the coordinator merge, and why might you reduce the shard count?

50 × 20 = 1,000 pairs (one copy of each shard answers). Every query touches 50 shards, so latency is governed by the slowest of 50, and per-shard overhead is multiplied. If the data fits in fewer shards (~30 GB each), fewer shards plus more replicas gives lower latency and more throughput.

A user changes their username, and for 3 seconds the old name still appears in search. Is this a bug?

No — it's the expected eventual consistency of a derived index (CDC lag + Kafka + bulk batching + 1 s refresh). It would be a bug if it persisted for hours (pipeline lag or a dropped event — monitor consumer lag and run reconciliation), or if a privacy change (block, deactivate) were not enforced at serve time.

Why do you query a geohash cell and its 8 neighbours instead of just the user's own cell?

Because two points can be metres apart yet fall in different cells (and share a much shorter prefix) when they straddle a cell boundary. Searching only the user's cell would miss nearby places just across the edge — like Colaba being in te7g8 while the Gateway of India is in te7g9.

The typeahead query for prefix "a" is the most expensive and most frequent. What do you do?

Don't run it against the index at all: precompute top-k results for all 1–2 character prefixes per locale (from popularity and query logs), store them in memory/Redis, refresh periodically, and add personal results from the user's own network on top. Client debouncing also means a single "a" is often never sent.

🎯 Key takeaways

Related lessons