n4nAI

Multi-document RAG with LlamaIndex RouterQueryEngine

Build a multi-document RAG system using LlamaIndex RouterQueryEngine with step-by-step code examples and expected outputs.

n4n Team3 min read553 words

Audio narration

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

You need to query across multiple document collections — maybe a codebase, a set of PDFs, and a Notion workspace — and route each question to the right index automatically. LlamaIndex’s RouterQueryEngine does exactly this. This tutorial builds a working multi-document RAG pipeline from scratch, showing how to configure retrievers, define routing logic, and handle the edge cases that appear in production.

Prerequisites

  • Python 3.10+
  • An OpenAI API key (or any LlamaIndex-compatible LLM/embedding provider)
  • Basic familiarity with LlamaIndex concepts: documents, nodes, indexes, query engines

Install the required packages:

pip install llama-index llama-index-llms-openai llama-index-embeddings-openai \
    llama-index-readers-file pypdf python-dotenv

Create a .env file with your API key:

OPENAI_API_KEY=sk-...

Project structure

multi-doc-rag/
├── data/
│   ├── engineering/
│   │   ├── api-spec.md
│   │   └── database-schema.sql
│   ├── product/
│   │   ├── roadmap-q3.md
│   │   └── user-feedback.md
│   └── legal/
│       ├── privacy-policy.md
│       └── terms-of-service.md
├── main.py
└── .env

Populate the data/ directories with real files. The examples below assume markdown and SQL files, but any format LlamaIndex readers support will work.

Load and index each collection separately

The key insight: each domain gets its own index with its own retriever configuration. This lets you tune chunk size, similarity cutoff, and metadata filters per collection.

# main.py
import os
from pathlib import Path
from dotenv import load_dotenv

from llama_index.core import (
    SimpleDirectoryReader,
    VectorStoreIndex,
    StorageContext,
    Settings,
)
from llama_index.core.node_parser import SentenceSplitter
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding

load_dotenv()

Settings.llm = OpenAI(model="gpt-4o-mini", temperature=0)
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
Settings.node_parser = SentenceSplitter(chunk_size=512, chunk_overlap=64)

DATA_ROOT = Path("data")
COLLECTIONS = ["engineering", "product", "legal"]

def build_index(collection_name: str) -> VectorStoreIndex:
    """Load documents from data/<collection> and build a vector index."""
    docs = SimpleDirectoryReader(
        input_dir=DATA_ROOT / collection_name,
        recursive=True,
        required_exts=[".md", ".sql", ".txt", ".pdf"],
    ).load_data()
    
    print(f"Loaded {len(docs)} documents from {collection_name}")
    for d in docs:
        print(f"  - {d.metadata.get('file_name', 'unknown')}")
    
    index = VectorStoreIndex.from_documents(docs, show_progress=True)
    return index

indices = {name: build_index(name) for name in COLLECTIONS}

Run this and verify output:

Loaded 2 documents from engineering
  - api-spec.md
  - database-schema.sql
Loaded 2 documents from product
  - roadmap-q3.md
  - user-feedback.md
Loaded 2 documents from legal
  - privacy-policy.md
  - terms-of-service.md

Create retrievers with domain-specific configuration

Different collections need different retrieval strategies. Engineering docs benefit from tighter similarity thresholds; legal docs may need broader recall.

from llama_index.core.retrievers import VectorIndexRetriever
from llama_index.core.postprocessor import SimilarityPostprocessor

retrievers = {}
for name, index in indices.items():
    if name == "engineering":
        # Precise retrieval for technical specs
        retriever = VectorIndexRetriever(
            index=index,
            similarity_top_k=4,
        )
        retriever = SimilarityPostprocessor(retriever, similarity_cutoff=0.75)
    elif name == "legal":
        # Broader recall for policy questions
        retriever = VectorIndexRetriever(
            index=index,
            similarity_top_k=6,
        )
        retriever = SimilarityPostprocessor(retriever, similarity_cutoff=0.65)
    else:
        # Default balanced config
        retriever = VectorIndexRetriever(
            index=index,
            similarity_top_k=5,
        )
        retriever = SimilarityPostprocessor(retriever, similarity_cutoff=0.70)
    
    retrievers[name] = retriever

Define the routing logic

RouterQueryEngine needs a selector that maps queries to retrievers. LlamaIndex provides LLMSingleSelector and LLMMultiSelector — the former picks one index, the latter can combine multiple. For most multi-document RAG, start with single selection.

from llama_index.core.query_engine import RouterQueryEngine
from llama_index.core.selectors import LLMSingleSelector
from llama_index.core.tools import QueryEngineTool
from llama_index.core import PromptTemplate

# Build a query engine per collection
query_engines = {}
for name, retriever in retrievers.items():
    query_engines[name] = indices[name].as_query_engine(
        retriever=retriever,
        response_mode="compact",
    )

# Wrap each as a tool with a clear description for the selector LLM
tools = [
    QueryEngineTool.from_defaults(
        query_engine=query_engines["engineering"],
        name="engineering_docs",
        description=(
            "Technical documentation: API specifications, database schemas, "
            "infrastructure configs, and codebase architecture. "
            "Use for questions about endpoints, data models, deployment, "
            "or system internals."
        ),
    ),
    QueryEngineTool.from_defaults(
        query_engine=query_engines["product"],
        name="product_docs",
        description=(
            "Product planning and user feedback: roadmaps, feature specs, "
            "user research, and prioritization decisions. "
            "Use for questions about upcoming features, product strategy, "
            "or customer requests."
        ),
    ),
    QueryEngineTool.from_defaults(
        query_engine=query_engines["legal"],
        name="legal_docs",
        description=(
            "Legal and compliance documents: privacy policy, terms of service, "
            "data processing agreements. "
            "Use for questions about data handling, user rights, "
            "or regulatory obligations."
        ),
    ),
]

# The selector uses these descriptions to route
selector = LLMSingleSelector.from_defaults(
    llm=Settings.llm,
    verbose=True,
)

router_engine = RouterQueryEngine(
    selector=selector,
    query_engine_tools=tools,
    verbose=True,
)

The verbose=True flags print the selector’s reasoning — essential for debugging routing decisions.

Test the router with representative queries

test_queries = [
    "What are the required fields for the /v1/users endpoint?",
    "When is the dark mode feature scheduled for release?",
    "What personal data do we collect under the privacy policy?",
    "How do we handle database migrations in production?",
    "What are the top user complaints about onboarding?",
]

for q in test_queries:
    print(f"\n{'='*60}")
    print(f"QUERY: {q}")
    print(f"{'='*60}")
    response = router_engine.query(q)
    print(f"RESPONSE: {response}")
    print(f"SOURCE NODES: {len(response.source_nodes)}")
    for node in response.source_nodes:
        print(f"  - {node.metadata.get('file_name', 'unknown')} (score: {node.score:.3f})")

Expected output pattern:

============================================================
QUERY: What are the required fields for the /v1/users endpoint?
============================================================
Selecting query engine: engineering_docs
Reasoning: The query asks about API endpoint specifications, which falls under technical documentation...
RESPONSE: The /v1/users endpoint requires the following fields: email (string, format: email), 
  name (string, min 2 chars), and role (enum: admin, member, viewer). Optional fields include 
  avatar_url and timezone.
SOURCE NODES: 3
  - api-spec.md (score: 0.872)
  - api-spec.md (score: 0.841)
  - database-schema.sql (score: 0.793)
============================================================
QUERY: When is the dark mode feature scheduled for release?
============================================================
Selecting query engine: product_docs
Reasoning: The query asks about a feature release timeline, which is product planning...
RESPONSE: Dark mode is scheduled for Q3 2024, targeting the week of August 19th. 
  It's currently in design review with engineering implementation starting July 15th.
SOURCE NODES: 2
  - roadmap-q3.md (score: 0.912)
  - user-feedback.md (score: 0.687)

Handle multi-collection queries with LLMMultiSelector

Some questions genuinely need multiple sources. Switch the selector:

from llama_index.core.selectors import LLMMultiSelector

multi_selector = LLMMultiSelector.from_defaults(
    llm=Settings.llm,
    verbose=True,
    max_outputs=3,  # cap at 3 collections
)

multi_router = RouterQueryEngine(
    selector=multi_selector,
    query_engine_tools=tools,
    verbose=True,
    # Combine responses from multiple engines
    response_mode="compact",
)

Test a cross-cutting query:

cross_query = "What data protection measures apply to user data in the new analytics feature?"
response = multi_router.query(cross_query)

Output shows multiple engines selected:

Selecting query engines: ['product_docs', 'legal_docs', 'engineering_docs']
Reasoning: This question spans product feature details, legal compliance requirements, 
  and technical implementation of data protection...
RESPONSE: The analytics feature (product_docs) implements pseudonymization at ingestion 
  (engineering_docs) and retains data for 13 months per GDPR Article 5 (legal_docs). 
  Access is restricted to the analytics service account with audit logging enabled.
SOURCE NODES: 5
  - roadmap-q3.md (score: 0.834)
  - privacy-policy.md (score: 0.791)
  - api-spec.md (score: 0.756)
  - terms-of-service.md (score: 0.712)
  - database-schema.sql (score: 0.689)

Persist indices to avoid re-indexing

Re-building indices on every startup wastes time and API calls. Persist to disk:

PERSIST_DIR = Path("storage")

def persist_indices(indices: dict, base_dir: Path):
    base_dir.mkdir(exist_ok=True)
    for name, index in indices.items():
        index.storage_context.persist(persist_dir=base_dir / name)
    print(f"Persisted {len(indices)} indices to {base_dir}")

def load_indices(base_dir: Path) -> dict:
    from llama_index.core import load_index_from_storage
    loaded = {}
    for name in COLLECTIONS:
        storage_context = StorageContext.from_defaults(persist_dir=base_dir / name)
        loaded[name] = load_index_from_storage(storage_context)
    return loaded

# After building:
persist_indices(indices, PERSIST_DIR)

# On subsequent runs:
# indices = load_indices(PERSIST_DIR)

Add metadata filtering for tighter control

When documents share a collection but differ in version, team, or sensitivity, attach metadata at ingestion and filter at query time.

from llama_index.core.schema import Document
from llama_index.core.vector_stores import MetadataFilters, ExactMatchFilter

# Example: tag engineering docs with version during ingestion
def load_with_metadata(collection: str) -> list[Document]:
    docs = SimpleDirectoryReader(
        input_dir=DATA_ROOT / collection,
        recursive=True,
    ).load_data()
    
    for doc in docs:
        doc.metadata["collection"] = collection
        # Extract version from filename or content
        if "v2" in doc.metadata.get("file_name", ""):
            doc.metadata["version"] = "v2"
        else:
            doc.metadata["version"] = "v1"
    return docs

# At query time, filter by metadata
from llama_index.core.retrievers import VectorIndexRetriever

versioned_retriever = VectorIndexRetriever(
    index=indices["engineering"],
    similarity_top_k=4,
    filters=MetadataFilters(
        filters=[ExactMatchFilter(key="version", value="v2")]
    ),
)

Common failure modes and fixes

Symptom Cause Fix
Router always picks the same tool Tool descriptions too similar Rewrite descriptions with distinct keywords; add negative examples
Selector hallucinates a tool name LLM doesn’t respect tool list Use PydanticSingleSelector for structured output; lower temperature
Empty results from selected engine Similarity cutoff too high Lower similarity_cutoff or increase similarity_top_k
Multi-selector returns all tools max_outputs too high or query too vague Set max_outputs=2; add a “general” catch-all tool with low priority
Slow latency Multiple sequential LLM calls Use n4n.ai or similar gateway for parallel provider routing and cached embeddings

Production considerations

Observability: Wrap the router with a callback handler to log selection reasoning, latency per engine, and token usage.

from llama_index.core.callbacks import CallbackManager, LlamaDebugHandler

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

Evaluation: Build a small eval set (20-30 queries with expected tool selections) and measure routing accuracy before deploying changes to descriptions or thresholds.

Fallback: Add a default vector index over all documents as a catch-all tool with the lowest priority description. This handles queries that don’t cleanly map to any domain.

# Catch-all index over everything
all_docs = []
for name in COLLECTIONS:
    all_docs.extend(SimpleDirectoryReader(input_dir=DATA_ROOT / name).load_data())

catchall_index = VectorStoreIndex.from_documents(all_docs)
catchall_tool = QueryEngineTool.from_defaults(
    query_engine=catchall_index.as_query_engine(similarity_top_k=5),
    name="general_docs",
    description="General company knowledge. Use only when no other tool matches.",
)
tools.append(catchall_tool)

Summary

You now have a multi-document RAG system that:

  • Maintains separate indices per domain with tuned retrieval
  • Routes queries automatically via LLM-based selection
  • Supports both single and multi-collection queries
  • Persists indices for fast startup
  • Includes metadata filtering for versioned content
  • Has observability hooks for production debugging

The router pattern scales well to 10-20 collections. Beyond that, consider hierarchical routing (category → sub-category) or a dedicated classifier model instead of LLM selection.

Tagsllamaindexmulti-documentragrouter-query-engine

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 →