Design a RAG System

Build a question-answering service over company documents: ingestion, hybrid retrieval and reranking, permission filtering, caching, cost and evaluation.

Case study32 minLesson 8 of 8
On this page
  1. Clarify the problem
  2. Estimate the scale
  3. Define the API
  4. Model the data
  5. High-level design
  6. Deep dives
  7. Bottlenecks and failures
  8. What a strong answer sounds like
  9. Follow-up questions

An engineer asks the company's internal assistant how to rotate the staging database password. Three seconds later they get four clear steps, each with a link to the runbook page it came from. Behind that answer, a system found 8 relevant passages among 50 million, checked the engineer was allowed to read every one, and gave them to a language model with instructions to answer only from them.

This is retrieval-augmented generation, covered as an idea in RAG basics and chunking and retrieval. Interviewers ask it as a system design question because the model call is the easy part. The hard parts are keeping millions of documents indexed and fresh, finding the right passages fast, never leaking a document to someone who cannot open it, and knowing whether the answers are any good. The steps follow A Framework for Any Design.

Clarify the problem

The first five minutes
Interviewer

Design a system that answers employees' questions using the company's documents.

You

Which documents, and do they all have the same audience? A wiki, tickets, shared drives? Can every employee read everything?

Interviewer

Wiki, ticket system and shared drives, about 10 million documents. No, permissions vary a lot: HR, legal and some engineering spaces are restricted.

You

Then permission checks are a hard requirement, not a feature. How many people, and how fresh must answers be after a page changes?

Interviewer

20,000 employees. A changed page should show up within minutes.

You

My priorities: never show anyone content they can't open, cite sources for every answer, say "I don't know" rather than guess, stream the first words within about 2 seconds, and keep cost per question under control. I'll leave out actions such as filing tickets, and training our own models.

  • Functional: ask a question in plain language and get an answer with citations; answer only from documents the asker may read; say when the documents do not contain the answer; ingest from the wiki, tickets and drives as they change; collect feedback on answers.
  • Non-functional: zero permission leaks; first words within 2 seconds, full answer within about 10; edits searchable within 10 minutes, and removed access effective within 5; cost per question measured and bounded; available during working hours in every region.
  • Out of scope: taking actions, training or fine-tuning models, public customers.

Estimate the scale

  • Chunks: 10 million documents cut into about 5 passages each is 50 million chunks.
  • Questions: 20,000 people × 10 questions a day = 200,000 a day. Spread over 8 working hours, 200,000 ÷ 28,800 s ≈ 7 a second; with a 3x peak, about 20 a second. A small rate, but each request is expensive.
  • Model input: 8 chunks of about 500 tokens is 4,000 tokens of context; with instructions and a 300-token answer, about 4,500 tokens per question. 200,000 × 4,500 = 900 million tokens a day. This number, not the request rate, drives cost.
  • Changes: if 1% of documents change each day, that is 100,000 documents and about 500,000 chunks to re-embed, about 6 a second on average.
Estimate: Size of the vector index

Suppose the embedding model turns each chunk into 1,024 numbers stored as 4-byte floats. How much memory do the vectors for all chunks take?

You know
  • 10 million documents, about 5 chunks each
  • 1,024 numbers per vector
  • 4 bytes per number

Show the working
  1. Chunks: 10 million × 5 = 50 million
  2. One vector: 1,024 × 4 B ≈ 4 KB
  3. All vectors: 50 × 106 × 4 KB = 200 GB

Estimate 200 GB (anything from 60 GB to 600 GB is a good answer)

Too much for one machine's memory with room to spare. Storing each number in 1 byte instead of 4 cuts it to about 50 GB at a small cost in accuracy; sharding the index is the other lever.

Estimate: Tokens sent to the model per day

About how many tokens a day does the answer step consume?

You know
  • 200,000 questions a day
  • 8 retrieved chunks of about 500 tokens each
  • About 500 more tokens for instructions, question and answer

Show the working
  1. Per question: 8 × 500 + 500 = 4,500 tokens
  2. Per day: 200,000 × 4,500 = 900 million tokens

Estimate 900 million (anything from 300 million to 3 billion is a good answer)

Halving the chunks sent per question roughly halves this. That is why retrieval quality is also a cost lever.

Your turn: Define the API

Write the endpoint an employee's chat window calls, and the one connectors call when a document changes. How does the answer reach the user: one response, or a stream?

Compare with a model answer

An ask endpoint that streams the answer and ends with the citations, a feedback endpoint, and an internal change-event endpoint for connectors. Streaming matters because the model takes seconds to write a full answer.

Define the API

HTTP API
POST /ask                       Authorization: the employee's identity
  body: { "question": "How do I rotate the staging DB password?", "conversation_id": "c_19"? }
  200  text/event-stream
       event: token     data: "1. Open the "
       ...
       event: done      data: { "answer_id": "a_77", "citations": [
                          { "doc_id": "wiki-4410", "title": "Staging runbook", "url": "..." } ] }
  429  too many questions from this user
  503  model unavailable; body holds the top documents as plain search results
 
POST /answers/{id}/feedback     body: { "helpful": false, "comment": "outdated" }
 
POST /ingest/events             internal, from connectors
  body: { "source": "wiki", "doc_id": "wiki-4410", "change": "updated" | "deleted" | "acl" }
  202 Accepted

The decision to argue is streaming. A full answer takes several seconds to generate, but the first words can arrive in about one. Streaming over server-sent events makes the 2-second target reachable. Citations come at the end because the model decides which sources it used as it writes. The 503 shape matters too: when the model is down, ranked documents are still a useful answer.

Your turn: Model the data

What does one indexed chunk hold so that you can search it, cite it, filter it by permission and know when to re-embed it?

Compare with a model answer

The chunk text, its vector, its document id, title and link for citations, the groups allowed to read it, and a hash of its text so unchanged chunks are not embedded again. Search needs vectors and words in one index, with filters.

Model the data

Documents and chunks
documents                         -- source of truth for what is indexed
  doc_id          VARCHAR(64) PRIMARY KEY     -- "wiki-4410"
  source          VARCHAR(16)
  title, url      TEXT
  allowed_groups  TEXT[]          -- copied from the source system
  content_hash    CHAR(64)
  updated_at      TIMESTAMP
 
chunks                            -- lives in the search index
  chunk_id        VARCHAR(80)     -- "wiki-4410#3"
  doc_id          VARCHAR(64)
  text            TEXT            -- about 500 tokens, about 2 KB
  embedding       VECTOR(1024)
  allowed_groups  TEXT[]          -- filter field
  text_hash       CHAR(64)        -- skip re-embedding unchanged chunks
  index_version   INT             -- which chunker and embedding model made it

The hot query is "nearest chunks to this vector, and best keyword matches for these words, among chunks this person may read". That needs a search index that supports vector search, keyword search and filters together: a search engine with vector support, or a vector database with keyword support. At smaller scale, a relational database with a vector extension is a fine start. The document table is ordinary relational data that tells ingestion what is indexed. Vector search here is approximate: it trades a little recall for speed, so measure recall rather than assume it.

Your turn: High-level design

Sketch both paths: how a changed document becomes searchable chunks, and how a question becomes a cited answer. Where does the permission check happen?

Compare with a model answer

Ingestion: connectors send change events, workers fetch, chunk and embed, then upsert into the search index along with each document's allowed groups. Query: the answer service embeds the question, runs a filtered hybrid search, reranks, and sends the top chunks to the model. Permissions are applied inside the search, as a filter.

High-level design

Ingestion (top) keeps the index fresh in the background. A question (bottom) is embedded with the same model, searched with a permission filter, reranked and answered.Doc sources to Ingestion workers (changes, ACLs); Ingestion workers to Embedding model (embed chunks); Ingestion workers to Hybrid search index (upsert); Employee to Answer service (POST /ask); Answer service to Embedding model (embed); Answer service to Hybrid search index (filtered search); Answer service to Reranker (top 50); Answer service to Language model (prompt + 8 chunks).Doc sourcesIngestionworkersEmbedding modelEmployeeAnswer serviceHybrid searchindexLanguage modelRerankerchanges, ACLsembed chunksupsertPOST /askembedfiltered searchtop 50prompt + 8 chunks
Ingestion (top) keeps the index fresh in the background. A question (bottom) is embedded with the same model, searched with a permission filter, reranked and answered.

Both paths use the same embedding model, because vectors from different models cannot be compared. Step through one question, then an edit and a permission change.

A question, an edit, a permission change, an outageUse the arrows or ← → keys
  1. A question, an edit, a permission change, an outage, step 1Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkersEmbedding modelEmployeeAnswer serviceHybrid searchindexLanguage modelReranker
    elapsed ms
    0
    candidates
    0

    1Priya asks how to rotate the staging database password. The answer service looks up her groups in the company directory, cached for a minute: eng and all-staff.

  2. A question, an edit, a permission change, an outage, step 2Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkersEmbedding modelquery vectorEmployeeAnswer serviceHybrid searchindexLanguage modelReranker
    elapsed ms
    50
    candidates
    0

    2It embeds the question with the same model used for the chunks, so both live in the same vector space. Budget: about 50 ms.

  3. A question, an edit, a permission change, an outage, step 3Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkersEmbedding modelEmployeeAnswer serviceHybrid searchindexeng, all-staffLanguage modelReranker
    elapsed ms
    150
    candidates
    50

    3One search runs keyword and vector matching, each limited to chunks whose groups include eng or all-staff, and fuses the two lists into 50 candidates. Budget: about 100 ms.

  4. A question, an edit, a permission change, an outage, step 4Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkersEmbedding modelEmployeeAnswer serviceHybrid searchindexLanguage modelReranker50 to 8
    elapsed ms
    350
    candidates
    8

    4The reranker reads the question and each candidate together and scores how well it answers. The 8 best are kept. Budget: about 200 ms.

  5. A question, an edit, a permission change, an outage, step 5Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkersEmbedding modelEmployeeAnswer serviceHybrid searchindexLanguage modelReranker
    elapsed ms
    1350
    candidates
    8

    5The prompt holds the instructions, the 8 numbered chunks and the question: about 4,500 tokens. Assume the model's first token takes about 1 second.

  6. A question, an edit, a permission change, an outage, step 6Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkersEmbedding modelEmployeeAnswer serviceHybrid searchindexLanguage modelReranker
    elapsed ms
    1350
    citations
    2

    6Tokens stream to Priya, ending with citations to the runbook. First words at 1.35 seconds, under the 2-second target.

  7. A question, an edit, a permission change, an outage, step 7Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkers2 of 12 changedEmbedding modelEmployeeAnswer serviceHybrid searchindexLanguage modelReranker
    chunks embedded
    2

    7Someone edits the runbook. The wiki sends a change event; a worker re-chunks the page into 12 chunks and compares text hashes: only 2 changed, so only 2 are embedded and upserted.

  8. A question, an edit, a permission change, an outage, step 8Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkersEmbedding modelEmployeeAnswer serviceHybrid searchindexgroups: sreLanguage modelReranker
    chunks embedded
    0

    8The page is moved into a space only the sre group can read. An ACL event updates allowed_groups on its 12 chunks. No re-embedding: the text did not change.

  9. A question, an edit, a permission change, an outage, step 9Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkersEmbedding modelEmployeeAnswer serviceHybrid searchindex0 runbook chunksLanguage modelReranker
    candidates
    50

    9Priya asks again. The filter now excludes those chunks, so the model never sees them and cannot leak them.

  10. A question, an edit, a permission change, an outage, step 10Doc sources to Ingestion workers; Ingestion workers to Embedding model; Ingestion workers to Hybrid search index; Employee to Answer service; Answer service to Embedding model; Answer service to Hybrid search index; Answer service to Reranker; Answer service to Language model.Doc sourcesIngestionworkersEmbedding modelEmployeeAnswer serviceHybrid searchindexLanguage modeldownReranker
    status
    503

    10Later the model endpoint is down. The service still searches and reranks, then returns the top documents as links with "answers are unavailable right now".

1 / 10

Deep dives

The ingestion pipeline

Connectors subscribe to each source's change events and also crawl on a schedule, so a missed event is caught by the next crawl. Each change becomes a message on a queue, and workers process documents independently:

  1. Fetch and extract the text, including from PDFs and slides. Extraction errors are the most common silent failure, so count them per source.
  2. Chunk along the document's own structure, headings first, at about 500 tokens with a little overlap, and put the title and section heading at the top of each chunk so it makes sense alone.
  3. Hash each chunk and embed only the ones whose hash changed, in batches. Most edits touch a few chunks.
  4. Upsert and delete in the index: write new and changed chunks, and remove chunks that no longer exist, by doc_id.

Changing the embedding model or the chunker is different: old and new vectors cannot be compared. Build a new index alongside the old one, with a new index_version, and switch queries when it is complete. If the embedding service handles 1,000 chunks a second (an assumption; measure yours), backfilling 50 million chunks takes 50,000 seconds, about 14 hours. Meanwhile, keep writing live changes to both indexes.

Retrieval quality versus latency

Vector search finds passages with the same meaning in different words. Keyword search finds exact terms: error codes, product names, "staging". Questions inside a company are full of exact terms, so use both. A common way to combine the lists is reciprocal rank fusion: each list gives a chunk 1 ÷ (60 + rank) points, and the totals are sorted.

ChunkKeyword rankVector rankScore
Staging runbook, step list141/61 + 1/64 = 0.0320
Staging environment setup361/63 + 1/66 = 0.0310
Password policy overviewnot found11/61 = 0.0164

Chunks found by both methods rise to the top, even when neither ranked them first. Then a reranker, a model that reads the question and one chunk together, scores the top candidates more accurately than either search can, at the cost of one model pass per candidate.

Retrieval pipelines
OptionGood atBad atPick it when
Vector search onlyFinds paraphrases. One index, one query, tens of milliseconds.Misses exact identifiers such as an error code or a ticket number.Prose-heavy content with few special terms.
Hybrid searchCatches both meaning and exact terms. Barely slower than either alone.Two scoring systems to tune; fusion is a heuristic.The latency budget is tight, for example search-as-you-type.
Hybrid plus reranker Usual defaultThe best top 8 for the money: the model gets fewer, better chunks.Adds about 200 ms here and a model to run and scale.Answers take seconds anyway. This design.

I would pick hybrid plus reranker: 200 ms is small next to a second of generation, and better chunks let you send 8 instead of 20, which cuts tokens. What would change my mind: a strict latency target with no generation step, where hybrid alone is enough.

Permissions

The model uses whatever text it is given, and a prompt instruction is not a security boundary. So restricted text must never reach the prompt. Filter in the retrieval step, using permissions copied from the source onto every chunk.

  • Filter inside the search, not after it. Taking the top 50 and then dropping what the user cannot read can leave nothing. Someone in a small team may find 48 of the top 50 hidden. Search engines with filtered vector search apply the filter while searching.
  • Sync permission changes fast and separately. An ACL change only updates the allowed_groups field, so it is cheap. Run it more often than content sync: removed access within 5 minutes.
  • Read the asker's groups fresh. Access is also removed when a person leaves a group. Look groups up in the directory, cached for a minute, not in a login token that lives for hours.
  • Check again at answer time for sensitive sources. For HR or legal spaces, ask the source system whether the user can still open each cited document before returning it. It costs a call per document and closes the window between a permission change and the sync.

Retrieved documents are also untrusted input: a page can contain text written to steer the model. Treat chunk text as data in the prompt, and see prompt injection for defences.

Caching and cost

Cost is the 900 million tokens a day. The levers, in order:

  • Send fewer, better chunks. A reranker that lets you send 6 chunks instead of 8 cuts the context from 4,000 to 3,000 tokens per question.
  • Cache answers to repeated questions, keyed by the normalized question and the user's group set. Leaving groups out of the key leaks answers between users with different access. Store which documents each answer cites, and delete those entries when any of them gets a content or ACL event; otherwise a cached answer keeps quoting a page after it was edited or restricted. Free-form questions repeat less than you hope, so measure the hit rate before counting on it.
  • Keep the prompt's fixed part at the start. Some model servers can reuse work for a repeated prompt prefix; stable instructions first make that possible.
  • Route easy questions to a smaller, cheaper model, checked with the evaluation set below.

Be careful with a "semantic" cache that returns an answer for a similar question. "Parental leave in France" and "parental leave in Germany" are similar and have different answers.

Evaluation

Without measurement, every change to chunking, models or prompts is a guess. Build a golden set of a few hundred real questions, each labelled with the documents that answer it, and score every change against it before shipping (see evals):

  • Retrieval recall at 8: how often is a right chunk among the 8 sent? If it is not there, the model cannot answer well, whatever the prompt says.
  • Faithfulness: is every claim in the answer supported by a cited chunk? A model can grade this at scale; check its grades against people on a sample.
  • Correct "I don't know": include questions the documents cannot answer, and score the refusals.

In production, track thumbs down, citation clicks, the "I don't know" rate and answers per source, and add failed questions to the golden set.

Bottlenecks and failures

At 10x the questions, about 200 a second at peak, the model and the reranker run out first; the index barely notices. Add model capacity, cache more, and cut tokens. At 10x the documents, 2 TB of vectors at 4 bytes per number, shard the index by chunk and query all shards in parallel, and store numbers in 1 byte each.

When this diesWhat happensWhat you do
The language modelNo generated answers.Return reranked documents as search results, with a clear notice.
The rerankerCandidates arrive in fused order.Skip reranking and send the top 8 fused chunks; quality dips, nothing breaks.
An index shardIts chunks are missing from results.Run replicas per shard; if one is lost, answer from the rest and note lower confidence.
The ingestion pipelineThe index stops updating; answers grow stale.Queue changes durably and catch up. Keep ACL sync on its own path so removed access still applies.
A connector misses eventsSome edits never arrive.The scheduled crawl compares hashes and catches them.

What a strong answer sounds like

LevelWhat the answer does
JuniorChunks documents, stores embeddings in a vector database, retrieves the top results and sends them to a model.
MidAdds incremental ingestion, hybrid search and a reranker with a latency budget, and streams the answer with citations.
SeniorTreats permissions as a filter inside retrieval with its own fast sync, sizes cost in tokens and pulls the right levers, plans re-indexing for model changes, and defines how quality is measured before shipping.
Restricted documentsWeak answer
Interviewer

Some documents are HR-only. How do you handle that?

You

I'd tell the model in the system prompt not to share HR content with people outside HR.

Restricted documentsStrong answer
Interviewer

Some documents are HR-only. How do you handle that?

You

Every chunk carries its document's allowed groups, copied from the source. Retrieval filters on the asker's groups inside the search, so restricted text never reaches the prompt. Permission changes sync on their own fast path, within 5 minutes.

Interviewer

And in those 5 minutes?

You

For HR and legal I'd also ask the source system, at answer time, whether the person can still open each cited document. It costs a call per document, which is worth it for those spaces.

Follow-up questions

How do you handle follow-up questions in a conversation?

Rewrite the follow-up into a standalone question using the recent turns ("And for production?" becomes "How do I rotate the production database password?"), then retrieve with that. Retrieving on the raw follow-up finds nothing useful.

The answer is wrong but the right document exists. How do you debug it?

Check retrieval first: was the right chunk in the 50 candidates, and in the final 8? If not, fix chunking, search or ranking. If it was, look at the prompt and the model's use of it.

Why not put all the documents in the context window?

Ten million documents fit in no context window, and large windows cost tokens on every question. Models also use long contexts unevenly. Retrieval sends a few thousand relevant tokens instead.

How do you handle tables and spreadsheets?

Chunk them by rows with the header repeated in each chunk. For numeric questions such as "sum this column", give the model a tool that queries the data instead.

How would you deploy this in several regions?

Replicate the index to each region and run answer services and models close to users. Ingestion can stay central. If documents must stay in a region by law, keep separate indexes and route by the user's region.

Self-review: RAG system0 of 12

After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".

Before drawing

Numbers and interfaces

Design and depth

Failure