Skip to main content
Lesson 17 of 18 · Embeddings, RAG, and Semantic Search

Adaptive RAG

Lesson 17 of 18 Updated November 2, 2026 Developer
On this page
  1. Route questions intelligently
  2. Implement the router
  3. The complete pipeline
  4. Key takeaways

Route questions intelligently

Self-RAG applies the same treatment to every question, and that is precisely its flaw. Ask it what two plus two equals: it will encode the query, scour the index, grade four irrelevant documents, switch to the web, generate, and verify. That is a dozen LLM calls for a question a model solves instantly. Not all questions have the same complexity, and a simple factual question does not require the same treatment as one demanding synthesis across multiple documents. Adaptive RAG therefore adds an intelligent router upstream of the pipeline: it analyzes the question and selects the optimal strategy before engaging in any costly processing.

The router has three destinations. Questions about topics actually covered by your documents go to the vector store; this is the full RAG path. Questions about current events or outside the indexed scope go to web search, since querying the index would only return noise. Finally, simple or general knowledge questions receive a direct answer from the model, with no retrieval at all.

Implement the router

The tricky part is that the router must know the scope of your index to make a decision. The chosen solution is as simple as it is effective: pass it the list of covered topics, indexed_topics, which it compares against the question. The three rules are stated explicitly in the prompt, and the constrained JSON format guarantees a usable value. The result.get("route", "vectorstore") deserves attention: in case of unexpected output, the default falls back to the vector store. This is the safest behavior, relying on your data rather than the web or the model’s memory.

import json
from mistralai.client import Mistral

client = Mistral(api_key=api_key)

def route_question(question, indexed_topics):
    """Route a question to the best strategy."""
    topics_str = ", ".join(indexed_topics)

    prompt = f"""You are a question router. Given a question,
determine the best source of information.

Your indexed documents cover the following topics: {topics_str}

Rules:
- If the question concerns indexed topics -> "vectorstore"
- If the question concerns current events or an uncovered topic -> "websearch"
- If the question is very simple or general knowledge -> "direct"

Respond ONLY with a JSON object: {{"route": "vectorstore"}} or {{"route": "websearch"}} or {{"route": "direct"}}

Question: {question}

Route JSON:"""

    response = client.chat.complete(
        model="mistral-large-latest",
        messages=[{"role": "user", "content": prompt}],
        temperature=0,
        response_format={"type": "json_object"},
    )

    result = json.loads(response.choices[0].message.content)
    return result.get("route", "vectorstore")

# Test the router
topics = ["embeddings", "RAG", "Mistral AI", "vector search", "FAISS"]

tests = [
    "How does mistral-embed work?",          # -> vectorstore
    "What is Apple's stock price today?",  # -> websearch
    "How much is 2 + 2?",                        # -> direct
    "What are the steps of RAG?",             # -> vectorstore
    "Who won the 2026 World Cup?",         # -> websearch
]

for q in tests:
    route = route_question(q, topics)
    print(f"  [{route:12s}] {q}")

The test set illustrates each scenario and will serve as your safeguard. A question about mistral-embed falls squarely within the declared topics, so it goes to the vector store. Stock prices or competition results change daily: no static index can contain them, so they go to the web. Two plus two requires neither a corpus nor search. Reuse this block with your own topics and about fifteen questions representative of your actual traffic: this is the most economical way to verify that the topics list is accurate enough. A router that sends everything to the vector store adds no value, and a router that answers directly on your business topics is downright dangerous.

The complete pipeline

AdaptiveRAG brings together everything you have built over the last three lessons. Each route has its own execution method, and the ask method reads like a switch statement. The direct route returns immediately, with a temperature of 0.3 since there is no context to adhere to, and an empty sources list that honestly signals to the user that nothing underpins this answer. The websearch route generates from web results alone. The vectorstore route, however, deploys the full artillery: document grading in the style of Corrective RAG, web fallback if none survive, then a generation loop with grounding and usefulness checks inherited from Self-RAG, grouped here in grade_and_correct.

One threshold difference is worth noting compared to the Corrective RAG lesson. The web fallback no longer triggers as soon as a document is discarded, but only if relevant_docs is empty. This choice is more tolerant: when two out of four chunks remain relevant, it is reasonable to generate with them rather than go to the web, which saves a call and reduces latency. You must arbitrate based on your domain’s requirements.

import numpy as np

class AdaptiveRAG:
    """Adaptive RAG pipeline with intelligent routing."""

    def __init__(self, chunks, embeddings, index, topics):
        self.chunks = chunks
        self.embeddings = embeddings
        self.index = index
        self.topics = topics

    def route(self, question):
        """Determine the optimal strategy."""
        return route_question(question, self.topics)

    def retrieve_from_vectorstore(self, question, k=4):
        """Retrieval from the local vector store."""
        response = client.embeddings.create(
            model="mistral-embed", inputs=[question]
        )
        query_vec = np.array([response.data[0].embedding], dtype=np.float32)
        distances, indices = self.index.search(query_vec, k)

        return [
            {"text": self.chunks[idx]["text"], "source": "vectorstore"}
            for dist, idx in zip(distances[0], indices[0]) if idx >= 0
        ]

    def search_web(self, question):
        """Web search for out-of-scope questions."""
        return web_search_fallback(question)

    def direct_answer(self, question):
        """Direct answer without retrieval."""
        response = client.chat.complete(
            model="mistral-large-latest",
            messages=[{"role": "user", "content": question}],
            temperature=0.3,
        )
        return response.choices[0].message.content

    def generate_with_context(self, question, documents):
        """Generation with context (classic RAG)."""
        context = "\n\n---\n\n".join([d["text"] for d in documents])
        prompt = f"Context:\n{context}\n\nQuestion: {question}"

        response = client.chat.complete(
            model="mistral-large-latest",
            messages=[
                {"role": "system", "content": "Answer based on the provided context."},
                {"role": "user", "content": prompt},
            ],
            temperature=0.1,
        )
        return response.choices[0].message.content

    def grade_and_correct(self, question, documents, generation):
        """Self-RAG checks (hallucinations + usefulness)."""
        is_grounded = check_hallucination(documents, generation)
        if not is_grounded:
            return None, "hallucination"

        is_useful = check_answer_usefulness(question, generation)
        if not is_useful:
            return None, "not_useful"

        return generation, "ok"

    def ask(self, question, max_retries=2):
        """Complete Adaptive RAG pipeline."""
        print(f"\n{'='*60}")
        print(f"Question: {question}")

        # 1. Routing
        route = self.route(question)
        print(f"Chosen route: {route}")

        # 2. Execute based on route
        if route == "direct":
            answer = self.direct_answer(question)
            return {"answer": answer, "route": "direct", "sources": []}

        elif route == "websearch":
            docs = self.search_web(question)
            answer = self.generate_with_context(question, docs)
            return {"answer": answer, "route": "websearch", "sources": ["web"]}

        else:  # vectorstore
            docs = self.retrieve_from_vectorstore(question)

            # Document grading
            relevant_docs = []
            for doc in docs:
                if grade_document(question, doc["text"]):
                    relevant_docs.append(doc)

            # Web fallback if not enough relevant documents
            if not relevant_docs:
                print("  No relevant documents, web fallback...")
                relevant_docs = self.search_web(question)

            # Generation loop with verification
            for attempt in range(max_retries + 1):
                generation = self.generate_with_context(question, relevant_docs)
                answer, status = self.grade_and_correct(
                    question, relevant_docs, generation
                )

                if status == "ok":
                    return {
                        "answer": answer,
                        "route": "vectorstore",
                        "attempts": attempt + 1,
                        "sources": [d.get("source", "") for d in relevant_docs],
                    }
                elif status == "not_useful":
                    web_docs = self.search_web(question)
                    relevant_docs.extend(web_docs)

            # Return the last generation even if imperfect
            return {
                "answer": generation,
                "route": "vectorstore",
                "attempts": max_retries + 1,
                "sources": [d.get("source", "") for d in relevant_docs],
            }

# Usage
adaptive_rag = AdaptiveRAG(chunks, embeddings, index, topics=topics)

questions = [
    "How do I create a FAISS index with mistral-embed?",
    "What is the weather in Paris tomorrow?",
    "How many dimensions does codestral-embed have?",
]

for q in questions:
    result = adaptive_rag.ask(q)
    print(f"\nRoute: {result['route']}")
    print(f"Answer: {result['answer'][:200]}...")

The three example questions walk through all paths in a single run, and the route field in the result tells you which one was taken. This is the value you should log in production: the distribution of routes across your real traffic is the best health indicator for your system, revealing both an index that is too narrow and a poorly calibrated router.

Key takeaways

  • Adaptive RAG routes each question to the optimal strategy (vectorstore, web, direct)
  • The router uses an LLM with structured output to classify questions
  • Questions about indexed topics go to the vector store; the rest go to the web
  • Routing reduces latency and costs by avoiding full RAG for simple questions
  • Adaptive RAG combines ideas from Corrective RAG and Self-RAG with routing