← Back to Index

Pattern: Naive → Advanced → Modular → Agentic Migration Path

How to upgrade a RAG system incrementally without a full rewrite, with specific forcing functions that justify each upgrade.


The Core Principle

Don't build the most complex system upfront. Build the simplest system that meets your current requirements, measure where it fails, and upgrade only the component that is actually failing. Each upgrade has a specific forcing function — a metric that justifies the added complexity and cost.

Naive RAG ──► Advanced RAG ──► Modular RAG ──► Agentic RAG
    │               │               │               │
    │           Add when:       Add when:       Add when:
    │           Retrieval       One strategy    Query scope
    │           recall < 0.7    underperforms   is unknown
    │           for some query  on some query   at query time
    │           types           types
    │
  Start here

Stage 1: Naive RAG

What it is: Chunk → embed → store → retrieve top-k → generate. No query transformation, no reranking.

Build it when: Starting a new system, unknown query distribution, <1M documents, latency SLA < 500ms.

def naive_rag(query: str, vector_db, llm) -> str:
    chunks = vector_db.search(query, k=5)
    context = "\n\n".join(c["text"] for c in chunks)
    return llm.generate(f"Context:\n{context}\n\nQuestion: {query}")

Measure: Recall@5 on a 50-query probe set. Deploy evaluation in CI.

Forcing function to advance: Recall@5 < 0.70 on your probe set after tuning chunk size and embedding model.


Stage 2: Advanced RAG

What it adds over Naive: Query rewriting, hybrid search (dense + BM25), reranker.

def advanced_rag(query: str, vector_db, bm25_index, reranker, llm) -> str:
    # Query transformation
    rewritten = rewrite_query(query)  # HyDE or simple rewrite
    
    # Hybrid retrieval
    dense_results = vector_db.search(rewritten, k=20)
    bm25_results  = bm25_index.search(rewritten, k=20)
    merged        = rrf_merge(dense_results, bm25_results, k=20)
    
    # Reranking
    reranked = reranker.rerank(rewritten, merged, top_k=5)
    
    context  = "\n\n".join(c["text"] for c in reranked)
    return llm.generate(f"Context:\n{context}\n\nQuestion: {query}")

Cost added: ~$0.001/query (reranker call + extra retrieval).

Forcing functions:


Stage 3: Modular RAG

What it adds over Advanced: Multiple pluggable retrieval strategies, routed by query type.

STRATEGY_MAP = {
    "semantic":   lambda q: vector_db.search(q, k=5),
    "exact_match": lambda q: bm25_index.search(q, k=5),
    "hybrid":     lambda q: rrf_merge(vector_db.search(q, k=10), bm25_index.search(q, k=10), k=5),
    "graph":      lambda q: graph_db.traverse(q, max_hops=2),
    "sql":        lambda q: text_to_sql(q, db_schema),
}

def modular_rag(query: str, llm) -> str:
    strategy_name = router.classify(query)  # "semantic" / "hybrid" / "graph" / "sql"
    chunks        = STRATEGY_MAP[strategy_name](query)
    context       = "\n\n".join(c["text"] for c in chunks)
    return llm.generate(f"Context:\n{context}\n\nQuestion: {query}")

Cost added: Router call + strategy-specific retrieval costs.

Forcing functions:


Stage 4: Agentic RAG

What it adds over Modular: The LLM decides the retrieval strategy, can retry, and can chain multiple hops.

def agentic_rag(query: str, max_iterations: int = 5) -> str:
    return run_react_agent(
        query=query,
        tools=[semantic_retrieve, hybrid_retrieve, graph_retrieve, web_search],
        max_iterations=max_iterations,
    )

Cost added: 3–10× more LLM calls; $0.05–$0.20/query.

Forcing functions:


Decision Matrix

Signal Stage to Add
Recall@5 < 0.70 overall Naive → Advanced
Exact match queries fail (product codes, names) Add BM25 hybrid
Faithfulness low despite good recall Add reranker
Some query categories always fail Advanced → Modular
Different data sources need different retrieval Advanced → Modular
Multi-hop questions fail Modular → Agentic
Query scope unknown at design time Modular → Agentic

What NOT to Do


Interview Q&A

Q: How would you upgrade a production Naive RAG system to handle complex multi-hop queries without rewriting everything?

Incrementally. First add a query classifier that detects multi-hop queries (signals: "compare", "what are all the ways", "across all documents"). Route those queries to an agentic sub-path while keeping Naive RAG for simple queries. The agentic sub-path can start as just two serial retrievals with a query reformulation step between them — not a full ReAct agent. Measure if that solves the failing cases before adding more complexity. The key discipline: add one component at a time, measure its impact on your probe set, and only keep it if it moves the metric.