← back to lessons
A systems-architecture guide · no code required

How do you architect
a production RAG system?

From a blank whiteboard to a system you'd actually trust in production. No frameworks, no syntax — just the judgment calls, in the order they need to be made, and why each wrong-looking shortcut fails.

① The napkin lie
② Four unbreakable promises
③ Search & fusion
④ Judging "enough"
⑤ The coupling trap
⑥ Multi-hop & state
⑦ Shipping it
scroll
CH · 01

The napkin lie

Retrieval-Augmented Generation fits on a napkin: embed the documents, embed the query, find the nearest neighbors, stuff them into a prompt. That description is exactly why so many RAG systems fail in production — the napkin convinces people it's a five-line problem, so they write five lines, ship them, and spend the following months firefighting.

A RAG system is not one function. It is a chain of independent judgment calls: should I even search? Did I search well? Did I find enough? Should I try again, and how? Am I confident enough to answer at all? Collapse all of those into one "retrieve top-k and generate" function and you get a system that fails silently — every wrong answer looks exactly as plausible as a right one, because nothing in the code ever had to decide anything.

Think of a factory assembly line with no inspection stations — raw material goes in one end, a finished product comes out the other, and nobody ever checks it mid-line. It "works" until the day a bad batch of material comes in, and by then the defect has been baked into every product downstream. Architecture is the act of placing inspection stations at every point where a decision actually gets made — not adding them later, but designing the line around them from the start.

— The Inspection Line Metaphor
The one move that matters before any code
Name every implicit judgment call as its own explicit decision point. If you can't point to the exact place in the design where a decision is made, it isn't being made deliberately — it's happening by accident, inside a function that was never asked to decide anything.
CH · 02

Four promises you can't break

"Domain-agnostic" is not a feature bolted on at the end — it's a constraint that eliminates entire categories of design before you've drawn a single box. Write these down before sketching any graph, because every later decision in this guide traces back to one of them.

// The permanent constraints
No hardcoded vocabulary Bans query classifiers trained on domain-specific keywords or ontologies.
No structure assumption Bans retrieval logic that quietly expects clean paragraphs — it must survive tables, code, structured records.
No query-style assumption Bans tuning for exact-match or paraphrase queries alone. Both have to work, always.
No fixed "sufficient" threshold Bans a similarity-score cutoff tuned by guesswork on one dataset.

Whenever you catch yourself about to write "if domain == X" anywhere in the core pipeline, that's one of these four promises telling you the logic belongs in a swappable configuration object — not in the graph itself.

CH · 03

Search, and knowing when to bother

Try a query like "thanks, that's all I needed" against any retrieval system. Cosine similarity never returns "nothing" — it returns a ranking, always. The top-k documents will look at least a little relevant, and the system will dutifully generate an answer grounded in context that was never relevant to begin with. That failure looks exactly like a correct answer until a human checks the sources.

Wrong answer
"Every query goes through retrieval — that's what a RAG system does."
Right answer
The very first node is a judgment call, not a retrieval call: does this query need retrieval at all, versus can it be answered directly, versus should the system decline? One small LLM call up front prevents an entire class of confidently-wrong answers downstream.

Once retrieval is genuinely needed, a second blind spot appears — this time between two retrieval methods, not before them.

Imagine two witnesses to the same event. One remembers exact words — every name, every number, verbatim. The other remembers the gist — the feeling of what was said, even when the exact phrasing is gone. You wouldn't dismiss either witness; you'd take both statements and reconcile them. That's hybrid search: a lexical witness (BM25) and a semantic witness (a bi-encoder), reconciled by rank rather than by picking a favorite.

— The Two Witnesses Metaphor
// Interactive — hybrid retrieval & RRF fusion

Same four candidate documents, two different query styles. Watch how relying on only one retriever would pick the wrong winner — and how Reciprocal Rank Fusion (RRF) recovers the right one every time.

Bar length = fused RRF score. Label shows each document's rank from BM25 and from the bi-encoder separately — notice the winner changes depending on which single method you'd trust.

One more blind spot remains: hybrid retrieval is built for recall — casting a wide enough net that the right document is somewhere in the top-k. It was never built for precision — knowing which of those candidates matters most for this exact query. A bi-encoder scores query and document independently and compares vectors afterward, which is fast but structurally blind to fine-grained interaction between the two texts.

The precision pass
A cross-encoder reranker scores the query and each shortlisted candidate together, in one pass — slower, but far more precise, and only run on the small set retrieval already narrowed down. It's a separate node from retrieval, never folded into it: different job, different latency budget, independently swappable.
CH · 04

Judging "enough"

You now have reranked candidates. The next judgment call is the one most systems get wrong quietly: is this enough evidence to answer, or not?

Wrong answer
"If the top result's similarity score is above 0.75, we have enough context to answer."

That threshold was tuned by staring at one domain's score distribution. Move to a different domain — different embedding density, different document length, different query style — and 0.75 means something else entirely, or nothing at all.

// Interactive — one threshold, two domains

Drag the threshold. Watch it succeed for one domain and misfire for the other — there is no position where a single fixed number works for both.

Threshold
0.60
Right answer
Sufficiency has to be judged against the query itself, not a fixed number — an LLM-as-judge node reads the actual query and the actual retrieved documents and decides, in context, whether this specific question is answerable from this specific evidence. Crucially, it must return why the evidence falls short — missing entity, wrong granularity, query too broad — not just a boolean. That rationale is what makes the next stage possible.
CH · 05

The coupling trap

Here is the subtlest trap in the whole design — worth feeling fully rather than skipping to the fix.

Wrong answer
"I'll build one 'query understanding' node that handles rewriting a query when retrieval falls short, and breaking a complex multi-part question into pieces. It's all 'fixing the query' — why would that need two nodes?"

Follow that instinct to its conclusion and you hit a wall: reformulating a query after a failed retrieval takes one query in, produces one improved query out. Decomposing "compare the refund policy in the US and the EU" takes one query in, produces two independent sub-questions, each needing its own retrieval and its own evidence, recombined afterward. One node, two incompatible output shapes — and the downstream node now has to silently branch on which shape it received. That branch is hidden coupling: invisible on any diagram, discoverable only by reading the code.

A detective refining a lead and a translator splitting one big question into two separate interviews are not the same job, even though both involve "working with a question." The detective takes one lead, one piece of new evidence, and sharpens it into a better lead. The translator takes one complicated question and produces two clean, independent ones. Ask one person to be both and you'll eventually get a lead that's secretly two questions stapled together, and nobody downstream will know to unstaple it.

— The Detective & the Translator
// One node vs. two
Coupled node

"Query understanding" both decomposes and reformulates. Its output is sometimes a list, sometimes a string. Every downstream node needs a branch to guess which one it got.

↓ hidden coupling, invisible on the diagram
Split nodes

Decomposition fans out into independent sub-questions. Reformulation loops one query back with a reason attached. Each has one job, one output shape.

↑ the branch is visible in the graph itself

The other half of this stage is the loop itself. "If insufficient, reformulate and try again" — with no limit — is an infinite loop waiting to happen: an LLM judge that's even slightly miscalibrated on an edge case can decide "insufficient" forever, for a query your corpus was never going to answer.

The brake
A single integer, retry_count, lives in shared state and increments on every reformulation. Insufficient and under the limit → reformulate and retry. Insufficient and at the limit → a dedicated give_up exit. What that exit actually does — answer with a caveat, or decline outright — is a configuration value the node reads, never a branch it contains. Different deployments of the same domain-agnostic core get to choose differently, without touching the graph.
CH · 06

Multi-hop, and the state that survives it

Here's the shape of the full pipeline so far — one reusable loop, run once for a plain query.

// The core loop
Retrieve Rerank Grade Reformulate retry < max sufficient retries up — one reusable subgraph —
This whole loop is one unit. Multi-hop doesn't add a new mechanism — it runs this exact unit more than once.

A naive multi-hop implementation concatenates sub-questions into one bigger retrieval query. That silently merges two independent evidence needs into one pass, and whichever sub-question dominates the combined query's semantics crowds out the other's results.

// Multi-hop as fan-out, not concatenation
Query understanding retrieve_grade_loop retrieve_grade_loop retrieve_grade_loop Synthesize sub-answers
Each branch is fully isolated — its own retries, its own evidence — and merges back through one named reducer, never hand-written merge logic.

This is the one place where per-branch state is correct, not a violation of "no nested sub-state" — genuinely independent parallel work deserves genuine isolation, as long as the merge back into shared state happens through one explicit, named step.

// Shared state — what survives a retry
original queryFrozen at entry, never rewritten. Every other field can change; this one is the ground truth of what was actually asked.
active queryWhat actually gets embedded and searched each pass — this is the field reformulate rewrites.
retrieved docsAccumulate across passes, never overwrite. A good hit from pass one shouldn't vanish because pass two's query missed it.
grade rationaleFree text, not a boolean. This is what makes reformulation a domain-agnostic decision instead of guesswork.
retry countIncrements each loop; the single field the whole brake mechanism depends on.
hop resultsOnly exists on the multi-hop path. Merged via a reducer field so parallel branches never collide.
SUMMARY

Shipping it

Two more disciplines keep everything above from rotting once the graph gets big. Every LLM-calling node owns exactly one prompt template and reads only the state fields it needs — the grader never needs the raw original query if all it needs is the active query and the candidates; the reformulator never needs the final answer field, because it doesn't exist yet at that point in the run. A shared "master prompt" with conditionals is the same coupling mistake as the merged query-understanding node, one layer down.

Latency and evaluation numbers are not core logic and must never look like it. A thin wrapper around every node records timing before returning control to the graph — no node ever branches on a timer. Recall@k, MRR, and nDCG@k live entirely outside the graph, in an offline harness run against a fixed query set with known-correct answers. If a metric is ever read inside a node to decide what happens next, evaluation logic has leaked into the architecture.

How edge cases actually get found
Every "wrong answer" in this guide was discovered by testing the design against a domain it didn't fit — not by imagining it in the abstract. Validate against at least two genuinely different domains as you build: something exact-match-heavy (error codes, part numbers) and something paraphrase-heavy (support FAQ, natural-language policy questions). Each will break a different assumption you didn't know you'd made. The README this system deserves is a catalog of exactly those failures and their justified fixes — that catalog is the evidence the system is domain-agnostic, not just labeled that way.
1. Constraints firstWrite the permanent bans down before the first design decision — they eliminate shortcuts you'd otherwise reach for.
2. Name every decisionIf you can't point to where a judgment is made, it's happening by accident.
3. Feel it before you fix itFind the naive version, let it fail on a real awkward case, then fix it. The fix only makes sense once the failure is felt.
4. Split the "and"A node whose job description joins two structurally different outputs is two nodes wearing one coat.
5. Every loop needs a brakeA counter with a ceiling, and a configurable — not silent — exit behavior.
6. Isolate real parallelismGenuine parallel work gets genuinely isolated state, merged back through one named mechanism.
7. Decide each field's lifecycleFrozen-once, overwritten-each-pass, or accumulated — decide before the first line that touches it.
8. Config, not branchesAnything that varies by domain or deployment is a config object the core reads, never a branch it contains.
9. Instrumentation observesThe moment a metric changes control flow, it has quietly become logic.
10. Validate on disagreementTwo domains chosen to disagree with each other find more bugs than ten similar ones.
The one sentence to remember
Build the linear, single-pass spine first — gate, retrieve, rerank, grade, answer — get it correct and tested on its own, and let every other capability in this guide attach to that spine as an addition, never a rewrite. That's what makes it production-ready instead of a demo that happened to work the day you wrote it.