Design a RAG System
Build a question-answering service over company documents: ingestion, hybrid retrieval and reranking, permission filtering, caching, cost and evaluation.
On this page
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
Design a system that answers employees' questions using the company's documents.
Which documents, and do they all have the same audience? A wiki, tickets, shared drives? Can every employee read everything?
Wiki, ticket system and shared drives, about 10 million documents. No, permissions vary a lot: HR, legal and some engineering spaces are restricted.
Then permission checks are a hard requirement, not a feature. How many people, and how fresh must answers be after a page changes?
20,000 employees. A changed page should show up within minutes.
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.
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?
- 10 million documents, about 5 chunks each
- 1,024 numbers per vector
- 4 bytes per number
Show the working
- Chunks: 10 million × 5 = 50 million
- One vector: 1,024 × 4 B ≈ 4 KB
- 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.
About how many tokens a day does the answer step consume?
- 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
- Per question: 8 × 500 + 500 = 4,500 tokens
- 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.
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
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 AcceptedThe 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.
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 -- 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 itThe 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.
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
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.
- 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:
engandall-staff.- 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.
- elapsed ms
- 150
- candidates
- 50
3One search runs keyword and vector matching, each limited to chunks whose groups include
engorall-staff, and fuses the two lists into 50 candidates. Budget: about 100 ms.- 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.
- 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.
- 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.
- 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.
- chunks embedded
- 0
8The page is moved into a space only the
sregroup can read. An ACL event updatesallowed_groupson its 12 chunks. No re-embedding: the text did not change.- candidates
- 50
9Priya asks again. The filter now excludes those chunks, so the model never sees them and cannot leak them.
- 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".
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:
- Fetch and extract the text, including from PDFs and slides. Extraction errors are the most common silent failure, so count them per source.
- 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.
- Hash each chunk and embed only the ones whose hash changed, in batches. Most edits touch a few chunks.
- 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.
| Chunk | Keyword rank | Vector rank | Score |
|---|---|---|---|
| Staging runbook, step list | 1 | 4 | 1/61 + 1/64 = 0.0320 |
| Staging environment setup | 3 | 6 | 1/63 + 1/66 = 0.0310 |
| Password policy overview | not found | 1 | 1/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.
| Option | Good at | Bad at | Pick it when |
|---|---|---|---|
| Vector search only | Finds 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 search | Catches 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 default | The 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_groupsfield, 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 dies | What happens | What you do |
|---|---|---|
| The language model | No generated answers. | Return reranked documents as search results, with a clear notice. |
| The reranker | Candidates arrive in fused order. | Skip reranking and send the top 8 fused chunks; quality dips, nothing breaks. |
| An index shard | Its chunks are missing from results. | Run replicas per shard; if one is lost, answer from the rest and note lower confidence. |
| The ingestion pipeline | The 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 events | Some edits never arrive. | The scheduled crawl compares hashes and catches them. |
What a strong answer sounds like
| Level | What the answer does |
|---|---|
| Junior | Chunks documents, stores embeddings in a vector database, retrieves the top results and sends them to a model. |
| Mid | Adds incremental ingestion, hybrid search and a reranker with a latency budget, and streams the answer with citations. |
| Senior | Treats 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. |
Some documents are HR-only. How do you handle that?
I'd tell the model in the system prompt not to share HR content with people outside HR.
Some documents are HR-only. How do you handle that?
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.
And in those 5 minutes?
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.
After your attempt, tick what you really did. Be strict: "I thought about it" is not "I said it out loud".