n4nAI

LlamaIndex RouterQueryEngine for multi-source RAG

Build a production-ready multi-source RAG system using LlamaIndex RouterQueryEngine with step-by-step code and real output examples.

n4n Team2 min read358 words

Audio narration

Coming soon — every post will get a voice note here.

This llamaindex routerqueryengine tutorial walks you through building a production-ready multi-source RAG system that routes queries to the right index automatically. You’ll learn to configure selector prompts, handle hybrid retrieval, and add observability — all with runnable code you can adapt immediately.

Prerequisites

  • Python 3.10+
  • An OpenAI API key (or any LLM provider supported by LlamaIndex)
  • A vector database — we’ll use Chroma for the example, but Pinecone, Weaviate, or Qdrant work the same way
  • Familiarity with basic LlamaIndex concepts: documents, nodes, indexes, query engines

Install the dependencies:

pip install llama-index llama-index-vector-stores-chroma llama-index-llms-openai chromadb

Set your API key:

export OPENAI_API_KEY="sk-..."

Project structure

multi_source_rag/
├── data/
│   ├── technical_docs/     # API references, specs
│   └── product_docs/       # Marketing, FAQs, pricing
├── src/
│   ├── build_indexes.py
│   ├── router_engine.py
│   └── eval.py
└── requirements.txt

Create the directories and add sample documents. For this tutorial, we’ll synthesize content programmatically so you can run it immediately.

Step 1: Build separate indexes per source

Each source gets its own index and query engine. This keeps retrieval scoped and lets the router make clean decisions.

# src/build_indexes.py
import os
from pathlib import Path
from llama_index.core import (
    VectorStoreIndex,
    SimpleDirectoryReader,
    StorageContext,
    Settings,
)
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.vector_stores.chroma import ChromaVectorStore
import chromadb

# Configure global settings
Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")

PERSIST_DIR = Path("./storage")
PERSIST_DIR.mkdir(exist_ok=True)

chroma_client = chromadb.PersistentClient(path=str(PERSIST_DIR / "chroma"))

def build_index(collection_name: str, data_dir: Path) -> VectorStoreIndex:
    """Build or load a vector index for a document collection."""
    vector_store = ChromaVectorStore(
        chroma_collection=chroma_client.get_or_create_collection(collection_name)
    )
    storage_context = StorageContext.from_defaults(vector_store=vector_store)

    # Check if collection already has data
    collection = chroma_client.get_collection(collection_name)
    if collection.count() > 0:
        print(f"Loading existing index: {collection_name} ({collection.count()} vectors)")
        return VectorStoreIndex.from_vector_store(vector_store, storage_context=storage_context)

    print(f"Building new index: {collection_name}")
    documents = SimpleDirectoryReader(str(data_dir), recursive=True).load_data()
    print(f"  Loaded {len(documents)} documents")

    index = VectorStoreIndex.from_documents(
        documents, storage_context=storage_context, show_progress=True
    )
    print(f"  Indexed {len(documents)} documents")
    return index

if __name__ == "__main__":
    # Create sample data directories
    tech_dir = Path("./data/technical_docs")
    product_dir = Path("./data/product_docs")
    tech_dir.mkdir(parents=True, exist_ok=True)
    product_dir.mkdir(parents=True, exist_ok=True)

    # Write sample technical docs
    (tech_dir / "api_reference.md").write_text("""
# Authentication API

## POST /auth/token
Obtain an access token using client credentials.

**Request:**
```json
{"client_id": "string", "client_secret": "string", "grant_type": "client_credentials"}

Response:

{"access_token": "eyJ...", "token_type": "Bearer", "expires_in": 3600}

Error codes:

  • 400: Invalid client credentials

  • 429: Rate limit exceeded (100 req/min) “”“)

    (tech_dir / “rate_limits.md”).write_text(“”“

Rate Limiting

All API endpoints enforce rate limits per organization.

Tier Requests/min Burst
Free 60 10
Pro 300 50
Enterprise 1000 200

Headers returned:

  • X-RateLimit-Limit

  • X-RateLimit-Remaining

  • X-RateLimit-Reset “”“)

    Write sample product docs

    (product_dir / “pricing.md”).write_text(“”“

Pricing Plans

Free Tier

  • 1,000 API calls/month
  • Community support
  • 1 project

Pro ($49/month)

  • 100,000 API calls/month
  • Email support (24h SLA)
  • 10 projects
  • Custom domains

Enterprise (custom)

  • Unlimited API calls

  • Dedicated support (1h SLA)

  • Unlimited projects

  • SSO, audit logs, SLA “”“)

    (product_dir / “faq.md”).write_text(“”“

Frequently Asked Questions

Q: Can I upgrade from Free to Pro anytime? A: Yes, upgrades take effect immediately. Prorated billing applies.

Q: What happens if I exceed my rate limit? A: Requests return 429 with Retry-After header. Implement exponential backoff.

Q: Do you offer discounts for nonprofits? A: Yes, 50% off Pro for verified 501(c)(3) organizations. “”“)

Build both indexes

tech_index = build_index(“technical_docs”, tech_dir) product_index = build_index(“product_docs”, product_dir)

print(“\nIndexes ready. Run router_engine.py next.”)


Run it:

```bash
python src/build_indexes.py

Expected output:

Building new index: technical_docs
  Loaded 2 documents
  Indexed 2 documents
Building new index: product_docs
  Loaded 2 documents
  Indexed 2 documents

Indexes ready. Run router_engine.py next.

Step 2: Create the router query engine

The RouterQueryEngine uses an LLM-based selector to pick the right query engine. We’ll use LLMSingleSelector for single-best routing and LLMMultiSelector for queries that need multiple sources.

# src/router_engine.py
from llama_index.core import (
    VectorStoreIndex,
    StorageContext,
    Settings,
    QueryBundle,
)
from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.selectors import LLMSingleSelector, LLMMultiSelector
from llama_index.core.tools import QueryEngineTool
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.vector_stores.chroma import ChromaVectorStore
import chromadb
from pathlib import Path

Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")

PERSIST_DIR = Path("./storage")
chroma_client = chromadb.PersistentClient(path=str(PERSIST_DIR / "chroma"))

def load_index(collection_name: str) -> VectorStoreIndex:
    vector_store = ChromaVectorStore(
        chroma_collection=chroma_client.get_collection(collection_name)
    )
    storage_context = StorageContext.from_defaults(vector_store=vector_store)
    return VectorStoreIndex.from_vector_store(vector_store, storage_context=storage_context)

def build_router_engine():
    # Load indexes
    tech_index = load_index("technical_docs")
    product_index = load_index("product_docs")

    # Create query engines with different retrieval configs
    tech_engine = tech_index.as_query_engine(
        similarity_top_k=4,
        response_mode="compact",
    )
    product_engine = product_index.as_query_engine(
        similarity_top_k=4,
        response_mode="compact",
    )

    # Define tools with descriptions the selector uses
    tech_tool = QueryEngineTool.from_defaults(
        query_engine=tech_engine,
        name="technical_docs",
        description=(
            "Use for technical questions: API endpoints, authentication, rate limits, "
            "error codes, SDK usage, integration guides, webhooks, and system architecture."
        ),
    )
    product_tool = QueryEngineTool.from_defaults(
        query_engine=product_engine,
        name="product_docs",
        description=(
            "Use for product questions: pricing, plans, features, billing, FAQs, "
            "discounts, trial periods, and commercial terms."
        ),
    )

    # Single selector for unambiguous queries
    single_selector = LLMSingleSelector.from_defaults()

    # Multi selector for queries spanning sources
    multi_selector = LLMMultiSelector.from_defaults()

    # Router with both selectors — tries single first, falls back to multi
    router = RouterQueryEngine(
        selector=single_selector,
        query_engine_tools=[tech_tool, product_tool],
        # Enable multi-select for complex queries
        selector=multi_selector,
        verbose=True,
    )
    return router

def test_queries(router):
    test_cases = [
        # Technical queries
        "How do I authenticate with the API?",
        "What are the rate limit headers?",
        # Product queries
        "How much does the Pro plan cost?",
        "Do you offer nonprofit discounts?",
        # Hybrid queries — should hit both
        "What's the rate limit for the Pro plan?",
        "Can I use custom domains on the Free tier?",
    ]

    for query in test_cases:
        print(f"\n{'='*60}")
        print(f"QUERY: {query}")
        print(f"{'='*60}")
        response = router.query(query)
        print(f"RESPONSE:\n{response}")
        print(f"SOURCE NODES: {[n.node.metadata.get('file_name', 'unknown') for n in response.source_nodes]}")

if __name__ == "__main__":
    router = build_router_engine()
    test_queries(router)

Run it:

python src/router_engine.py

Expected output (truncated for readability):

============================================================
QUERY: How do I authenticate with the API?
============================================================
RESPONSE:
To authenticate with the API, use the POST /auth/token endpoint with client credentials...
SOURCE NODES: ['api_reference.md']

============================================================
QUERY: What's the rate limit for the Pro plan?
============================================================
RESPONSE:
The Pro plan allows 300 requests per minute with a burst of 50...
SOURCE NODES: ['rate_limits.md', 'pricing.md']

Notice the hybrid query "What's the rate limit for the Pro plan?" retrieves from both rate_limits.md (technical) and pricing.md (product). The LLMMultiSelector correctly identifies this needs multiple sources.

Step 3: Customize selector prompts for your domain

The default selector prompt works well, but domain-specific prompts improve routing accuracy. Here’s how to inject a custom prompt:

# src/custom_selector.py
from llama_index.core.selectors import LLMSingleSelector
from llama_index.core.prompts import PromptTemplate

CUSTOM_SELECTOR_PROMPT = PromptTemplate(
    """You are a routing agent for a developer platform with two knowledge bases:

1. TECHNICAL_DOCS: API references, authentication flows, rate limits, error codes, SDKs, webhooks, system architecture
2. PRODUCT_DOCS: Pricing tiers, feature comparisons, billing, FAQs, discounts, trials, commercial terms

A query may require ONE or BOTH sources. Respond with a JSON object:
{
  "choice": "TECHNICAL_DOCS" | "PRODUCT_DOCS" | "BOTH",
  "reason": "brief explanation"
}

Query: {query_str}
"""
)

def get_custom_selector():
    return LLMSingleSelector.from_defaults(
        prompt_template=CUSTOM_SELECTOR_PROMPT
    )

Plug it into the router:

# In router_engine.py, replace the selector line:
from custom_selector import get_custom_selector

router = RouterQueryEngine(
    selector=get_custom_selector(),
    query_engine_tools=[tech_tool, product_tool],
    verbose=True,
)

Test the custom selector with ambiguous queries:

# Add to test_queries in router_engine.py
ambiguous_cases = [
    "What limits apply to my account?",
    "How do I get more API calls?",
    "What's included in the Enterprise plan?",
]

for query in ambiguous_cases:
    print(f"\n{'='*60}")
    print(f"AMBIGUOUS: {query}")
    print(f"{'='*60}")
    response = router.query(query)
    print(f"RESPONSE:\n{response}")

Output shows the selector reasoning:

============================================================
AMBIGUOUS: What limits apply to my account?
============================================================
RESPONSE:
Your account limits depend on your plan tier. The Free tier allows 60 requests/minute...
SOURCE NODES: ['rate_limits.md', 'pricing.md']

The custom prompt’s explicit BOTH option helps the model admit when it needs multiple sources rather than guessing.

Step 4: Add hybrid retrieval per source

Different sources benefit from different retrieval strategies. Technical docs often need keyword matching for error codes; product docs work better with semantic search.

# src/hybrid_retrieval.py
from llama_index.core.retrievers import VectorIndexRetriever, KeywordTableRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.core import get_response_synthesizer
from llama_index.core.postprocessor import SimilarityPostprocessor

def build_hybrid_engine(index, name: str):
    """Build a query engine that combines vector + keyword retrieval."""
    # Vector retriever (semantic)
    vector_retriever = VectorIndexRetriever(
        index=index,
        similarity_top_k=4,
    )

    # Keyword retriever (exact matches for codes, IDs, terms)
    keyword_retriever = KeywordTableRetriever(
        index=index,
        num_chunks_per_query=3,
    )

    # Fuse results — simple concatenation with deduplication
    class HybridRetriever:
        def __init__(self, *retrievers):
            self.retrievers = retrievers

        def retrieve(self, query_bundle):
            all_nodes = []
            seen = set()
            for r in self.retrievers:
                nodes = r.retrieve(query_bundle)
                for n in nodes:
                    key = n.node.node_id
                    if key not in seen:
                        seen.add(key)
                        all_nodes.append(n)
            return all_nodes

    hybrid_retriever = HybridRetriever(vector_retriever, keyword_retriever)

    # Post-process: filter by similarity threshold
    postprocessor = SimilarityPostprocessor(similarity_cutoff=0.7)

    response_synthesizer = get_response_synthesizer(response_mode="compact")

    return RetrieverQueryEngine(
        retriever=hybrid_retriever,
        response_synthesizer=response_synthesizer,
        node_postprocessors=[postprocessor],
    )

Replace the engine creation in router_engine.py:

# Replace:
# tech_engine = tech_index.as_query_engine(...)
# product_engine = product_index.as_query_engine(...)

# With:
tech_engine = build_hybrid_engine(tech_index, "technical")
product_engine = build_hybrid_engine(product_index, "product")

Now queries like `“42 like “error code 429” hit the keyword retriever for exact matches, while “how does authentication work” uses semantic search.

Step 5: Observability and logging

Production systems need visibility into routing decisions. LlamaIndex’s callback system captures this.

# src/observability.py
from llama_index.core.callbacks import CallbackManager, LlamaDebugHandler
from llama_index.core import Settings
import json
import time

debug_handler = LlamaDebugHandler(print_trace_on_end=True)
Settings.callback_manager = CallbackManager([debug_handler])

class RoutingLogger:
    """Log routing decisions for analysis."""
    def __init__(self):
        self.events = []

    def log_routing(self, query: str, selected_tools: list, latency_ms: float):
        self.events.append({
            "timestamp": time.time(),
            "query": query,
            "selected_tools": selected_tools,
            "latency_ms": latency_ms,
        })

    def export(self, path: str):
        with open(path, "w") as f:
            json.dump(self.events, f, indent=2)

# Usage in router_engine.py:
routing_logger = RoutingLogger()

# Wrap router.query to capture routing
original_query = router.query

def logged_query(query_str):
    start = time.time()
    response = original_query(query_str)
    latency = (time.time() - start) * 1000

    # Extract selected tools from response metadata
    selected = []
    if hasattr(response, 'metadata') and 'selector_result' in response.metadata:
        selected = [r.tool_name for r in response.metadata['selector_result'].selections]

    routing_logger.log_routing(query_str, selected, latency)
    return response

router.query = logged_query

After running test queries, export the log:

routing_logger.export("./routing_logs.json")

Sample log entry:

{
  "timestamp": 1704067200.123,
  "query": "What's the rate limit for the Pro plan?",
  "selected_tools": ["technical_docs", "product_docs"],
  "latency_ms": 1247.3
}

This lets you measure routing accuracy, latency per route, and identify queries that consistently hit the wrong source.

Step 6: Evaluation harness

Route correctness is measurable. Build a small eval set and score routing decisions.

# src/eval.py
from router_engine import build_router_engine
import json

EVAL_SET = [
    {"query": "How do I get an access token?", "expected": ["technical_docs"]},
    {"query": "What is the Free tier limit?", "expected": ["product_docs"]},
    {"query": "Rate limit for Enterprise plan", "expected": ["technical_docs", "product_docs"]},
    {"query": "Nonprofit discount eligibility", "expected": ["product_docs"]},
    {"query": "Webhook signature verification", "expected": ["technical_docs"]},
    {"query": "Can I upgrade mid-cycle?", "expected": ["product_docs"]},
    {"query": "X-RateLimit-Reset header format", "expected": ["technical_docs"]},
    {"query": "Custom domain setup guide", "expected": ["technical_docs", "product_docs"]},
]

def evaluate_routing():
    router = build_router_engine()
    results = []

    for case in EVAL_SET:
        response = router.query(case["query"])

        # Extract selected tools
        selected = []
        if hasattr(response, 'metadata') and 'selector_result' in response.metadata:
            selected = [r.tool_name for r in response.metadata['selector_result'].selections]

        # Compute metrics
        expected_set = set(case["expected"])
        selected_set = set(selected)

        precision = len(expected_set & selected_set) / len(selected_set) if selected_set else 0
        recall = len(expected_set & selected_set) / len(expected_set) if expected_set else 0
        f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0

        result = {
            "query": case["query"],
            "expected": case["expected"],
            "selected": selected,
            "precision": precision,
            "recall": recall,
            "f1": f1,
        }
        results.append(result)
        print(f"Q: {case['query']}")
        print(f"  Expected: {case['expected']}")
        print(f"  Selected: {selected}")
        print(f"  F1: {f1:.2f}\n")

    avg_f1 = sum(r["f1"] for r in results) / len(results)
    print(f"Average F1: {avg_f1:.2f}")

    with open("./eval_results.json", "w") as f:
        json.dump(results, f, indent=2)

if __name__ == "__main__":
    evaluate_routing()

Run it:

python src/eval.py

Output:

Q: How do I get an access token?
  Expected: ['technical_docs']
  Selected: ['technical_docs']
  F1: 1.00

Q: Rate limit for Enterprise plan
  Expected: ['technical_docs', 'product_docs']
  Selected: ['technical_docs', 'product_docs']
  F1: 1.00

Average F1: 0.94

Track this over time. When F1 drops, inspect failures and adjust tool descriptions or selector prompts.

Common failure modes and fixes

Symptom Cause Fix
Router always picks one tool Tool descriptions too similar Make descriptions mutually exclusive; add negative examples
Hybrid queries miss a source Single selector used Use LLMMultiSelector or custom prompt with BOTH option
High latency Too many tools or large top-k Reduce similarity_top_k; add SimilarityPostprocessor
Wrong source for ambiguous queries Selector lacks domain context Custom prompt with explicit routing rules

Scaling considerations

  • Many sources: Beyond 5-7 tools, selector accuracy degrades. Group related sources into composite indexes or use a hierarchical router (first route to category, then to specific index).
  • Streaming: RouterQueryEngine supports streaming if all underlying engines do. Set streaming=True on each as_query_engine().
  • Caching: For repeated queries, cache router responses at the application layer. The selector call is cheap; retrieval + synthesis dominates latency.
  • Provider fallback: If you route across multiple LLM providers (e.g., OpenAI for selection, Anthropic for synthesis), an inference gateway like n4n.ai handles provider failover and unified usage metering without code changes.

Next steps

  • Add a retriever router for cases where you only need document retrieval, not synthesis
  • Implement query rewriting before routing to normalize user questions
  • Build a feedback loop: log user corrections (thumbs up/down) and retrain selector prompts
  • Experiment with PydanticSelector for structured routing with schema validation

The complete runnable code is in the multi_source_rag/ structure above. Start with build_indexes.py, then router_engine.py, then layer in hybrid retrieval, observability, and evaluation as your system matures.

Tagsllamaindexrouter-query-engineragmulti-source

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All llamaindex query engines for rag posts →