ai_ml / rag embeddings / 03_rag_architecture_patterns.md

RAG Architecture Patterns

8 interview angles 9 min read source

RAG Architecture Patterns

RAG (Retrieval-Augmented Generation) has evolved past the textbook “retrieve top-K chunks, stuff in prompt” picture. Production RAG is a family of architectures with different trade-offs. Knowing the patterns is what differentiates “I’ve used a RAG tutorial” from “I’ve shipped RAG in production.”

For RAG basics see 01_what_is_rag.md. For embeddings see 02_what_are_embeddings.md. For vector DBs see 04_vector_databases.md. For chunking/retrieval see 05_chunking_and_retrieval.md.

The four evolutionary stages

Stage What
Naive RAG retrieve → stuff → generate. The tutorial pattern.
Advanced RAG adds pre-retrieval and post-retrieval optimization (query rewriting, reranking, filtering).
Modular RAG reconfigurable pipeline of components (retrievers, fusion modules, scorers).
Agentic RAG LLM decides what to retrieve and when; multi-step / iterative retrieval.

Most production systems are at “advanced” with elements of modular. Agentic is the frontier.

Naive RAG (the baseline)

Question → Embed → Vector search → Top-K chunks → Stuff in prompt → LLM → Answer
def naive_rag(question: str, top_k: int = 5) -> str:
    query_embedding = embed_model.encode(question)
    chunks = vector_db.search(query_embedding, top_k=top_k)
    context = "\n\n".join(c.text for c in chunks)
    prompt = f"Context:\n{context}\n\nQuestion: {question}\n\nAnswer:"
    return llm.generate(prompt)

What’s wrong:

  • Query-document mismatch: questions and documents have different distributions. “How do I reset my password?” doesn’t embed close to “Click Settings → Account → Security → Change Password.”
  • Top-K is blunt: low K misses; high K dilutes the prompt with noise.
  • No filtering: retrieved irrelevant chunks confuse the LLM.
  • No reranking: vector similarity is approximate; the most relevant chunk isn’t always the highest-scored.
  • Context window limits: stuffing K=20 chunks may overflow or waste tokens.
  • No quality signal: you don’t know if retrieval failed; LLM may hallucinate.

Naive RAG is fine for demos. It’s not production.

Advanced RAG — pre and post retrieval optimization

                  ┌─ pre-retrieval ─┐                      ┌─ post-retrieval ─┐
Question → [query rewrite, decomposition] → Search → [rerank, filter, compress] → Stuff → LLM → Answer

Pre-retrieval techniques

Query expansion / HyDE (Hypothetical Document Embeddings): LLM generates a hypothetical answer; embed the hypothetical answer (not the question); search.

def hyde(question):
    hypo = llm.generate(f"Write a concise paragraph answering: {question}")
    hypo_embedding = embed_model.encode(hypo)
    return vector_db.search(hypo_embedding, top_k=5)

Question and document distributions get closer. Works well for FAQ-like queries.

Query decomposition: break complex questions into sub-questions.

"Compare Python and Go performance for ML workloads."
→ ["Python performance for ML",
   "Go performance for ML",
   "Python vs Go performance comparison"]

Retrieve for each; merge.

Query routing: classify the question, route to the appropriate index / strategy.

def route(question):
    category = classifier.predict(question)
    if category == "technical":
        return docs_index.search(question)
    elif category == "billing":
        return billing_kb.search(question)
    # ...

Post-retrieval techniques

Reranking: vector search gives 50 candidates; a cross-encoder (more expensive but more accurate) reranks down to top 5.

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def rag_with_rerank(question, top_k=5):
    candidates = vector_db.search(embed(question), top_k=50)
    pairs = [(question, c.text) for c in candidates]
    scores = reranker.predict(pairs)
    ranked = [c for c, s in sorted(zip(candidates, scores), key=lambda x: -x[1])]
    return ranked[:top_k]

Vector search is fast but uses bi-encoder embeddings (independent query/doc encoding). Cross-encoders look at the pair together; much more accurate, much slower. Hence the pattern: fast retrieval → slow rerank.

Filtering: remove obviously irrelevant chunks before sending to LLM. Score thresholds, metadata filters, dedup.

Context compression: summarize or extract only relevant parts of long chunks.

def compress_context(question, chunks):
    compressed = []
    for chunk in chunks:
        extracted = llm.generate(
            f"Extract only the parts of this text relevant to: {question}\n\n{chunk.text}",
            max_tokens=200,
        )
        if extracted.strip():
            compressed.append(extracted)
    return compressed

Trades latency (one LLM call per chunk) for cleaner context. Use sparingly.

Modular RAG

Treat RAG as a directed graph of swappable modules:

┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│ Query Aug   │ →  │ Retriever   │ →  │ Reranker    │
└─────────────┘    └─────────────┘    └─────────────┘
                          │                  │
                          ↓                  ↓
                   ┌─────────────┐    ┌─────────────┐
                   │ BM25 Index  │    │ Generator   │
                   └─────────────┘    └─────────────┘

Each module is swappable. Hybrid retrieval combines dense (vector) and sparse (BM25); the fusion module merges scores. Re-rankers slot in or out. Pre-retrieval augmentation routes through different paths.

Frameworks: LlamaIndex, Haystack, DSPy. They expose this as a pipeline configuration.

# LlamaIndex example
from llama_index.core import VectorStoreIndex
from llama_index.core.retrievers import VectorIndexRetriever, BM25Retriever
from llama_index.core.query_engine import RetrieverQueryEngine

vector_retriever = VectorIndexRetriever(index=vector_index, similarity_top_k=10)
bm25_retriever = BM25Retriever.from_defaults(docstore=docstore, similarity_top_k=10)

fused_retriever = HybridRetriever(vector_retriever, bm25_retriever)
query_engine = RetrieverQueryEngine.from_args(fused_retriever, reranker=rerank_module)

Agentic RAG

LLM decides what to retrieve and when, often iteratively. The model is the planner.

LLM: "I need to look up X first."
→ Retrieves X
→ LLM: "Based on X, I also need Y."
→ Retrieves Y
→ LLM: "Now I have enough — final answer."

Built on tool-calling: the LLM emits a “search” tool call; the agent executes; result fed back; LLM continues.

def agentic_rag(question):
    messages = [
        {"role": "system", "content": "You can search the knowledge base. Use it when needed."},
        {"role": "user", "content": question},
    ]
    while True:
        response = llm.chat(messages, tools=[search_tool])
        if response.tool_calls:
            for call in response.tool_calls:
                result = vector_db.search(call.args["query"])
                messages.append({"role": "tool", "content": result, "tool_call_id": call.id})
        else:
            return response.content

Pros:

  • Multi-hop reasoning (“first find X, then use X to find Y”).
  • Adaptive: simple questions skip retrieval entirely; complex ones iterate.
  • Self-correcting: LLM can recognize bad retrieval and search again.

Cons:

  • Latency: each iteration is a round trip.
  • Cost: many LLM calls + many retrievals.
  • Failure modes: infinite loops, getting confused, deciding not to search when it should.

For complex reasoning use cases (research, analysis, coding agents): agentic RAG is the right tool. For simple Q&A: advanced RAG suffices.

See ../10_agents_orchestration/02_the_agent_loop.md.

Specialized patterns

Self-RAG

LLM evaluates its own retrievals: “Is this relevant? Is my answer grounded?” Built-in self-correction.

Retrieve → LLM: "Are these chunks relevant?" → if no, retrieve again
LLM generates → LLM: "Is my answer supported by the chunks?" → if no, regenerate

Reduces hallucination but adds 2-3× LLM calls.

Corrective RAG (CRAG)

A retrieval evaluator scores chunks. If low confidence, fall back to web search or a different index.

def crag(question):
    chunks = vector_db.search(embed(question))
    scores = evaluator.predict(question, chunks)
    if max(scores) < THRESHOLD:
        chunks = web_search(question)
    return generate(question, chunks)

Multi-vector retrieval

Store multiple embeddings per document:

  • Summary embedding (for high-level matching).
  • Hypothetical question embeddings (for FAQ-like queries).
  • Chunk embeddings (for fine-grained retrieval).

Different retrieval strategies hit different embedding indices.

Parent-document retrieval

Embed small chunks (precise matching) but return their larger parent documents (full context):

Embed: each paragraph
Retrieve: top-K paragraphs by similarity
Return: the parent documents (or sections) of those paragraphs

Best of both worlds: precise matching + complete context.

Graph-augmented RAG

For knowledge graph data, retrieve related entities and their relationships, not just text chunks.

Question: "Who manages projects that use Python?"
→ Identify entities (people, projects, technologies)
→ Traverse graph (people MANAGES project USES python)
→ Synthesize result

Used for compliance, recommendation systems, structured Q&A.

Production architecture (typical stack)

User question

┌─────────────────┐
│ Query rewriting │ → optional HyDE / decomposition
└─────────────────┘

┌─────────────────┐
│ Hybrid retrieval│ → dense (vector) + sparse (BM25), fuse with RRF
│  - vector store │
│  - BM25 index   │
└─────────────────┘

┌─────────────────┐
│ Reranker        │ → cross-encoder, top 50 → top 5
└─────────────────┘

┌─────────────────┐
│ Filter / dedup  │ → score threshold, metadata filters
└─────────────────┘

┌─────────────────┐
│ Compress        │ → only if context too long
└─────────────────┘

┌─────────────────┐
│ LLM             │ → prompt with retrieved context + instructions
└─────────────────┘

┌─────────────────┐
│ Citation check  │ → optional: verify claims trace to chunks
└─────────────────┘

Answer + sources

Most production RAG systems are some subset of this. Start naive, add components as needed.

Failure modes

Failure Cause Mitigation
Hallucinated answers Retrieved chunks don’t contain the answer Better retrieval; LLM grounding instructions; cite sources
Lost-in-the-middle LLM ignores middle chunks in long context Reranking; reorder so most relevant chunks are first/last
Outdated information Stale index Periodic re-indexing; freshness scoring
Wrong domain Index doesn’t have the topic Routing / fallback to web search (CRAG)
Empty / sparse results Niche query Query expansion; sparse retrieval (BM25) backup
Off-topic chunks High vector similarity for irrelevant text Reranking; threshold; metadata filters
Repeated info Many chunks say the same thing Deduplication; diversification

Cost vs quality knobs

You’re always trading these:

Knob More quality More cost
Number of retrieved chunks higher bigger prompt, more $$
Reranking better top-K extra latency + compute
HyDE better recall one extra LLM call per query
Iterative agentic multi-hop reasoning N× latency + cost
Self-RAG grounded answers 2-3× LLM calls
Cross-encoder reranker better quality slower than rerank-light models
Larger embedding model better semantic match slower indexing, more storage

Real systems benchmark each knob against held-out queries; tune for the cost/quality target.

Common interview confusions

  • “RAG and fine-tuning are alternatives.” — not quite. RAG injects facts; fine-tuning teaches style or behavior. Often combined: fine-tune for tone, RAG for knowledge.
  • “More chunks = better.” — past ~10 chunks, LLMs start ignoring middle content. Reranking + small top-K beats stuffing 50 chunks.
  • “Vector search is all you need.” — pure dense retrieval misses keyword-heavy queries. Hybrid (dense + BM25) catches both.

Interview angle

  • “What are the RAG architecture patterns?” — four evolutionary stages: naive (retrieve-then-generate), advanced (pre/post retrieval optimization), modular (swappable components in a pipeline), agentic (LLM decides when/what to retrieve, iteratively).
  • “What’s wrong with naive RAG and how do you fix it?” — query-document mismatch (HyDE), bad ranking (rerankers), noise in context (filtering + compression), missing recall (hybrid retrieval), no quality signal (eval + grounding checks).
  • “What’s HyDE?” — Hypothetical Document Embeddings. LLM generates a hypothetical answer to the question; you embed and search using the hypothetical answer. Question and document distributions get closer; better retrieval.
  • “What’s reranking and why use it?” — fast vector search returns 50+ candidates; an expensive cross-encoder rescores them; top 5 go to the LLM. Cross-encoders see the (query, document) pair together — much more accurate than independent embeddings.
  • “What’s agentic RAG?” — LLM uses retrieval as a tool, calling it iteratively when needed. Supports multi-hop reasoning, adaptive retrieval (skip if not needed), self-correction. Slower and more expensive but handles complex queries.
  • “What’s the ‘lost in the middle’ problem?” — LLMs disproportionately ignore information in the middle of long contexts. Mitigate by reranking and placing the most important chunks at the start or end of the prompt.
  • “Hybrid retrieval — what and why?” — combine dense (vector) and sparse (BM25/keyword) retrieval. Dense catches semantic similarity; sparse catches exact terms. Fuse with Reciprocal Rank Fusion (RRF). Better recall than either alone.
  • “How do you reduce hallucinations in RAG?” — instruct LLM to cite sources, require citations to be from provided context, add a verification step (LLM checks each claim against chunks), use self-RAG to evaluate own answer, fall back to “I don’t know” if confidence is low.