Design problems
AI-era systemsMediumSDE-2 30 min

Design a semantic cache for LLM calls

Asked at
FlipkartFreshworksMicrosoftOpenAISwiggyZomato

The interviewer asks

“A food-delivery app's support assistant answers 3 million questions a day, and many are the same question asked in different words ('where is my refund', 'refund not received yet'). Design a semantic cache that returns a stored answer for similar questions, cutting LLM cost and latency without giving users wrong answers.”

What a strong answer looks like

A strong answer starts by saying what is safe to cache, because correctness is the whole problem: only answers that do not depend on the user's private state, the time, or tenant-specific data unless the cache key carries it. The lookup path is: normalise the question, try an exact hash cache, embed the question (~10-20 ms), search a tenant-scoped vector index for the nearest cached question, and return the answer only if similarity is above a threshold chosen from a labelled false-hit rate. A borderline band can be checked by a cheap verifier model. Entries are scoped by tenant, model, prompt version, locale and the source-document versions used, so a policy change invalidates them. Senior candidates run the cache in shadow mode first, measure precision before turning it on, handle multi-turn chats by caching a rewritten standalone question, and defend against cache poisoning.

Concepts to mention

exact cache before semantic cachecacheable vs personal or time-dependent questionsscope key: tenant, feature, model, prompt version, localethreshold chosen from labelled pairs and precisionshadow mode before servingverifier band for borderline similaritystandalone question rewrite for multi-turn chatsinvalidation by source document versioncache poisoning defences and feedback quarantinecache failure falls back to the LLM

1 Requirements

Functional

  • Before calling the LLM, look up a cached answer for a semantically similar question and return it on a confident match
  • Exact-match cache in front of the semantic one for identical normalised prompts
  • Scope every entry by tenant, feature, model, prompt version and locale
  • Invalidate entries when the policy or document they were built from changes
  • Per-feature switch, threshold and TTL; shadow mode that logs would-be hits without serving them
  • Report hit rate, false-hit rate (from sampling and user feedback) and money saved

Non-functional

  • Lookup adds under 30 ms p99 on a miss (embedding + vector search)
  • False-hit rate under 1% of served cache hits for support answers
  • 3M questions/day, peak 150 lookups/s; up to 20M cached entries
  • Cache failure must never fail the request; on any error, call the LLM
  • New entries searchable within 5 seconds

Out of scope (say it out loud)

  • The LLM gateway's routing and budgets (separate design)
  • Provider-side prompt caching of prefixes (complementary, handled by the provider)
  • Caching tool-calling or action-taking agent steps

2 Estimates

Lookup QPS~35/s avg, 150/s peak3M questions/day ÷ 86,400 ≈ 35/s; dinner-time and sale peaks ~4×
Realistic hit rate20-35% of cacheable trafficSupport FAQs repeat heavily, but only ~60% of questions are cacheable (the rest need order data); ~40% of those hit → ~25% overall
Savings per hit~₹0.8 and ~2.5 sTypical call ~2,500 input + 250 output tokens ≈ ₹0.8; an embedding of 20 tokens costs ~₹0.00004; hit latency ~40 ms vs 2-3 s generation
Daily saving~₹6 lakh/day3M × 25% hits × ₹0.8 ≈ ₹6 lakh/day, minus a few hundred rupees of embeddings and ~₹20k/day of verifier calls
Index memory~31 GB (float32) or ~10 GB (int8)20M entries × 768 dims × 4 B = 61 GB raw is a lot of RAM for a cache; 384-dim vectors are enough for short questions: 20M × 384 × 4 B ≈ 31 GB, or int8 ≈ 7.7 GB plus HNSW graph links (~2.5 GB at M=16)
Answer storage~40 GB20M entries × ~2 KB (answer + metadata) in Redis or a KV store

3 API

EndpointWhat it does
gRPCSemanticCache.Lookup(scope, messages, params)Called by the LLM gateway. Returns hit/miss, answer, entry_id, similarity, and why it missed (below threshold, not cacheable, scope empty).
gRPCSemanticCache.Store(scope, question, answer, sources[], ttl)Called asynchronously after a successful LLM answer that passed moderation and is marked cacheable.
POST/v1/cache/feedbackUser thumbs-down or agent correction on a served cached answer; quarantines the entry and logs a false-hit label.
POST/admin/v1/cache/invalidateInvalidate by tenant, feature, prompt version or source document id (e.g. refund policy v12).
PUT/admin/v1/cache/features/{feature}Enable/disable, mode (shadow|serve), threshold, verifier band, TTL.

4 Data model

cache_entriesPostgres (source of truth) or DynamoDB
entry_id (PK)scope_keyquestion_normstandalone_questionanswersource_doc_versions[]modelprompt_versioncreated_atexpires_athitsstatus (active|quarantined)

scope_key = hash(tenant, feature, model, prompt_version, locale). Status lets you pull a bad entry instantly.

vector_indexRedis vector search / Qdrant / pgvector HNSW
entry_idembedding (384-dim int8)scope_key (filter field)expires_at

Filter by scope_key on every query. Large scopes get their own collection or partition so the filter never starves the ANN search.

exact_cacheRedis
key = sha256(scope_key, normalised question) → entry_id; TTL

Checked first; a few ms and no embedding cost. Catches button-click questions like 'Where is my order?'

doc_dependentsPostgres
doc_iddoc_versionentry_id

Reverse index so a policy document update invalidates exactly the answers built from it.

cache_decisionsKafka → ClickHouse
tsfeaturescopedecisionsimilarityentry_idverifier_resultshadowfeedback

Drives the threshold tuning curves and the false-hit dashboard.

5 Architecture

CLIENTSEDGESERVICESASYNCDATAEXTERNALquestionLookupexact hashembed questiontop-1 in scopeborderline onlyon missStore answerupsertdoc changed → purgedecision logSupport chat appLLM gatewaySemantic cache serviceEmbedding modelVerifier (small model)Kafka: decisions, docsCache writerRedis exact cacheVector index (HNSW)Entries + doc depsLLM provider
ClientEdge / gatewayService / workerQueue / streamCacheDatabase / storageAI / modelThird partyrequestasync (event / queue)

Hover a box to highlight its connections.

6 Request flows

Cache hit

  1. 1A user types 'refund not came yet, its been 5 days'. The support bot sends the turn to the gateway with feature=support-faq.
  2. 2The bot's intent classifier has tagged this turn as a generic policy question (no order lookup needed), so the gateway treats it as cacheable.
  3. 3The cache normalises the text (lowercase, trim, collapse spaces) and checks the exact cache. Miss.
  4. 4It embeds the standalone question with a small embedding model (~10 ms) and searches the HNSW index filtered by scope_key (tenant, feature, model, prompt v7, locale en-IN).
  5. 5Top match: 'refund not received after 5 days' with cosine similarity 0.96, above the 0.94 serve threshold for this feature.
  6. 6It loads the entry, checks status=active and that its source policy version is current, and returns the answer in ~40 ms.
  7. 7The decision is logged; the bot shows the answer with the usual 'Did this help?' buttons.

Borderline match and miss

  1. 1A user asks 'refund for cancelled order not received'. The best match is 'refund for returned order not received' at 0.91, inside the verifier band 0.88-0.94.
  2. 2The verifier, a small cheap model, gets both questions and the cached answer and answers yes/no: does this answer correctly answer the new question?
  3. 3It says no (cancellation and return refunds follow different timelines), so the cache returns a miss.
  4. 4The gateway calls the main LLM; after the answer passes moderation, the gateway sends Store asynchronously.
  5. 5The writer saves the entry with its source document versions and upserts the vector; it is searchable within a few seconds.

Policy change

  1. 1The refund policy document changes from v12 to v13 (refunds now in 3 days, not 5).
  2. 2The document service publishes doc.updated to Kafka.
  3. 3The writer looks up doc_dependents for v12 and marks those entries quarantined, deleting them from the vector index.
  4. 4The next similar question misses and is answered fresh from the new policy.

7 Deep dives

What is safe to cache: scoping the key

The biggest risk is not a low hit rate, it is serving an answer that was right for someone else. Three rules. One: never cache answers that depend on private or live state ('where is my order 123', 'what is my wallet balance'). The bot already knows which intents need tools or order data; only intents marked cacheable go to the cache. Two: everything that changes the answer must be in the scope, not in the similarity. The scope key is hash(tenant, feature, model, prompt_version, locale, user_segment if answers differ for Gold members). Similarity search runs only inside one scope, so a Hindi answer is never served for a Tamil question and tenant A never sees tenant B's answer. Three: answers built from documents carry the document versions they used, so a policy change can invalidate them precisely; TTL alone (say 24 h) is a backstop, not the main mechanism. Also never cache with temperature-driven variety if the product wants different phrasings, and do not cache refusals or error messages, or one transient failure gets served to thousands of users.

Choosing the threshold from data, not vibes

Cosine similarity is not a probability of 'same answer'. 'Can I cancel my order?' and 'Can I cancel my subscription?' can score 0.92 with general embedding models while needing different answers. So you measure. Build a labelled set of 2,000-5,000 question pairs from real logs, labelled 'same answer OK' or 'not OK' by support agents, including hard negatives that differ by one word (cancel/return, before/after delivery). For each threshold compute precision (of the matches served, how many were OK) and hit rate. Pick the threshold that keeps precision ≥ 99% for this feature; often that lands around 0.93-0.96, but it depends on the embedding model and domain, so never copy a number. Then run in shadow mode for a week: compute the would-be hit, still call the LLM, and have a judge compare the two answers on a sample. Only switch to serve mode when shadow precision matches the offline number. Keep a verifier band just below the threshold where a cheap model checks the pair; it raises hit rate safely at a small cost. Re-tune whenever you change the embedding model, because similarity scores are not comparable across models.

Multi-turn chats and normalisation

In a chat, the latest message alone is often meaningless: 'and for COD orders?' only makes sense with the previous turn. Embedding the raw last message gives false hits; embedding the whole conversation gives almost no hits. The usual fix is to cache on a standalone question: a small model rewrites the turn using recent history into 'how long does a refund take for cash-on-delivery orders?'. Many RAG bots already do this rewrite for retrieval, so the cache reuses it at no extra cost. Cache only turns where the rewrite is confident and self-contained. Normalise before embedding: trim, lowercase, remove greetings and filler ('hi', 'pls', 'bhai'), and transliterate common Hinglish spellings where your embedding model handles them poorly, since 'refund kab aayega' and 'when will refund come' should land close together. Use a multilingual embedding model if you serve Indian languages; English-only models scatter Hindi and Hinglish text. Keep the original user text out of the stored answer key, and store the rewritten question for debugging.

Cache poisoning and feedback

Whatever gets stored will be served to many users, so writes need more care than reads. An attacker can ask a crafted question that makes the model produce a misleading answer (for example via prompt injection asking it to include a phone number for 'refund help'), and if cached, that answer reaches thousands. Defences: only store answers that passed the output moderation and policy checks; never store answers from conversations flagged as suspicious; require an entry to be generated twice (two independent misses producing consistent answers) before it is served to others, or have it pass the verifier once; and cap how many users can be served a new entry in its first hour. Feedback closes the loop: a thumbs-down or a human agent correcting a cached answer quarantines the entry immediately and becomes a labelled negative for threshold tuning. Track false-hit rate per feature on a dashboard, and alert if quarantines spike, which often means a bad prompt version or a policy change that was not wired to invalidation.

8 Trade-offs

Vector store: Redis vs pgvector vs dedicated DB

Redis with vector search keeps everything in memory and gives single-digit ms lookups, which fits a latency-sensitive cache of tens of millions of short vectors. pgvector is simpler if Postgres already holds the entries and QPS is modest. A dedicated vector DB (Qdrant, Milvus) helps when the cache grows to hundreds of millions of entries or needs many tenants with heavy filtering. Pick Redis or pgvector here; the scale does not need more.

Small vs large embedding model

A large embedding model separates close-but-different questions better but adds latency and cost on every lookup, including misses. A small model (384 dims) is fast and cheap, and the verifier band covers its weak spots. Measure precision on your labelled set for both before deciding.

Serve cached answer directly vs use it as a hint

Serving directly saves the full LLM call. An alternative is to pass the cached answer to a small model to adapt it to the exact question, which cuts cost by maybe 70% instead of 100% but handles near misses better. Good for long answers; overkill for FAQs.

9 Bottlenecks

Hit rate falls as the scope gets narrower

Adding prompt version, model and segment to the scope makes every deploy start with an empty cache. Warm the new scope by replaying the top 5,000 questions through the new prompt in the background before switching traffic, and avoid putting fields in the scope that do not actually change the answer.

Index growth and memory

Unbounded caching fills RAM with long-tail questions that never hit again. Evict by LFU or 'hits in last 7 days', keep TTLs, and keep only entries with at least one hit after a warm-up period. Most savings come from the top few thousand questions.

10 Failure handling

Vector index or embedding model down

Lookups time out at 30 ms and the gateway calls the LLM as if it were a miss. Cost rises, users notice nothing. The cache is an optimisation and must never be on the failure path.

A wrong answer gets cached

Feedback or the sampling judge flags it; the entry is quarantined and removed from the index within seconds. Because entries carry entry_id in logs, you can find every user who received it and, for serious cases, send a correction.

Invalidation event lost

If the doc.updated event is missed, stale answers live until TTL. A nightly job compares source_doc_versions of active entries with current document versions and purges mismatches.

11 What impresses

  • Leads with correctness (false hits) rather than hit rate
  • Gives a concrete way to pick the threshold and says it changes with the embedding model
  • Uses document-version dependencies for precise invalidation
  • Handles Hinglish and Indian languages with a multilingual embedding model
  • Thinks about cache poisoning and limits the blast radius of a new entry
  • Quantifies savings net of embedding and verifier costs

12 Mistakes

  • Caching personalised answers ('where is my order') by similarity
  • One global cache across tenants or across prompt versions
  • Hard-coding a threshold like 0.8 without measurement
  • Embedding only the last chat message in a multi-turn conversation
  • Caching error messages and refusals
  • Making the request fail when the cache is down

13 Follow-ups

Q1How would you measure the false-hit rate in production?
Q2How does this interact with provider prompt caching? Do you still need both?
Q3What changes if answers are generated with RAG over tenant documents?
Q4How would you warm the cache after a prompt change?
Q5How do you decide TTLs for different features?
Q6Could you cache at the retrieval step instead of the final answer?

Saved on this device. Create a free account to keep it on every device.

Practise this with Sam

A real mock interview, out loud, with an honest scorecard. Free to start.

Start free