Skip to content

Query Routing

Not all questions are the same shape. A query asking for a single precise fact and a query asking how three events are causally connected are retrieval problems that favour different architectures. Query routing lets your system pick — or combine — the right retrieval path per query, rather than applying one strategy universally.

What you'll learn

  • Why query type determines the best retrieval strategy
  • When standard dense/hybrid RAG wins and when GraphRAG wins
  • How to build a lightweight Ollama-powered LLM classifier that routes queries
  • The parallel "run both, merge" integration pattern
  • Practical trade-offs and when routing overhead is not worth it

Why routing matters

Research on hybrid RAG vs graph-augmented retrieval (arXiv 2502.11371) shows that neither architecture dominates across all query types:

  • Standard RAG (dense + BM25 hybrid) wins on single-hop, fact-centric queries where precise chunk retrieval matters most.
  • GraphRAG variants (HippoRAG2, Community-GraphRAG) win on multi-hop reasoning queries that require traversing relationships between entities.

The same paper found that the best systems either select a strategy per query (routing) or integrate both in parallel (ensemble).

Single-strategy ceiling

Picking one retrieval strategy for everything means you're either leaving multi-hop accuracy on the table (pure dense) or paying graph-construction cost for queries that never need it (pure GraphRAG). Routing breaks that ceiling.


Query taxonomy

Before building a router, you need a taxonomy. A practical three-way split for most RAG systems:

Query type Characteristics Best strategy
Fact / single-hop One entity, one attribute. "What is the capital of Germany?" Standard dense/hybrid RAG
Relational / multi-hop Requires connecting ≥2 entities or traversing relationships. "Which papers cited by Author X were later cited by Author Y?" GraphRAG
Structured / SQL Numerical filters, aggregations, joins over tabular data. "Which customers spent > $10 000 last quarter?" RAG-over-SQL or direct SQL
Open / web Requires up-to-date or out-of-corpus information. "What happened in the news today?" Web search + RAG

See graph-rag.md, agentic-rag.md, and rag-over-sql.md for deep dives into the non-standard branches.


Architecture: Selection routing

flowchart TD
    Q[User query] --> R[Router\nLLM classifier]
    R -->|fact / single-hop| A[Hybrid RAG\nChromaDB + BM25]
    R -->|multi-hop / relational| B[GraphRAG\nNeo4j / NetworkX]
    R -->|structured / numeric| C[Text-to-SQL\nSQLite / Postgres]
    R -->|needs fresh data| D[Web search\n+ RAG grounding]
    A --> G[LLM generation\nOllama]
    B --> G
    C --> G
    D --> G
    G --> ANS[Answer]

Building a local LLM router with Ollama

The router is a small LLM call that classifies the query before retrieval. Because classification is cheap (a few hundred tokens), even a small model like llama3.2:3b works well here.

import httpx
import json

OLLAMA_URL = "http://localhost:11434/api/generate"
ROUTER_MODEL = "llama3.2:3b"  # tiny model is fine for classification

ROUTE_LABELS = ["fact", "multi_hop", "sql", "web"]

SYSTEM_PROMPT = """You are a query classifier for a RAG system.
Classify the user query into exactly one of these categories:

- fact        : single-hop factual lookup, one entity/attribute
- multi_hop   : requires connecting multiple entities or reasoning chains
- sql         : needs numeric aggregation, filters, or joins over tabular data
- web         : requires current events or information outside the document corpus

Respond with a JSON object: {"route": "<label>", "reason": "<one sentence>"}
No other text."""

def classify_query(query: str) -> dict:
    payload = {
        "model": ROUTER_MODEL,
        "prompt": f"Query: {query}",
        "system": SYSTEM_PROMPT,
        "stream": False,
        "format": "json",
    }
    resp = httpx.post(OLLAMA_URL, json=payload, timeout=30)
    resp.raise_for_status()
    return json.loads(resp.json()["response"])

# --- Stub retrieval functions (replace with your real implementations) ---

def retrieve_hybrid(query: str) -> list[str]:
    """Dense + BM25 hybrid retrieval from ChromaDB."""
    return [f"[Hybrid result for: {query}]"]

def retrieve_graph(query: str) -> list[str]:
    """Graph traversal retrieval."""
    return [f"[Graph result for: {query}]"]

def retrieve_sql(query: str) -> list[str]:
    """Text-to-SQL retrieval."""
    return [f"[SQL result for: {query}]"]

def retrieve_web(query: str) -> list[str]:
    """Web search retrieval."""
    return [f"[Web result for: {query}]"]

ROUTE_MAP = {
    "fact":      retrieve_hybrid,
    "multi_hop": retrieve_graph,
    "sql":       retrieve_sql,
    "web":       retrieve_web,
}

def routed_retrieve(query: str) -> list[str]:
    classification = classify_query(query)
    route = classification.get("route", "fact")
    print(f"Route: {route} | Reason: {classification.get('reason', '')}")
    retriever = ROUTE_MAP.get(route, retrieve_hybrid)
    return retriever(query)

# --- Example ---
if __name__ == "__main__":
    queries = [
        "What is the boiling point of ethanol?",
        "Which researchers cited both Smith 2020 and Jones 2021?",
        "How many orders exceeded $5000 in Q1 2024?",
        "What AI announcements happened this week?",
    ]
    for q in queries:
        docs = routed_retrieve(q)
        print(f"Q: {q}\nDocs: {docs}\n")

Prompt engineering the router

Few-shot examples in the system prompt dramatically improve classification accuracy. Add 2–3 examples of each route label with their query. A structured JSON response format ("format": "json" in Ollama) prevents the model from wrapping the answer in prose.

Router latency

Every query pays one extra LLM call. With llama3.2:3b on a modern CPU this is typically 200–800 ms. If latency is critical, consider a fine-tuned embedding classifier (e.g. a sentence-transformers model trained to predict route labels) — near-zero latency at the cost of a training step.


Architecture: Integration (parallel run-both)

When misclassification risk is high — or when you cannot tell in advance which strategy will win — run both paths in parallel and merge results.

flowchart LR
    Q[User query] --> A[Hybrid RAG retriever]
    Q --> B[GraphRAG retriever]
    A --> M[Merge / rerank\nRRF or cross-encoder]
    B --> M
    M --> G[LLM generation\nOllama]
    G --> ANS[Answer]
import asyncio
import httpx

async def retrieve_hybrid_async(query: str) -> list[str]:
    # Replace with real async ChromaDB call
    await asyncio.sleep(0)
    return [f"[Hybrid] {query}"]

async def retrieve_graph_async(query: str) -> list[str]:
    await asyncio.sleep(0)
    return [f"[Graph] {query}"]

def reciprocal_rank_fusion(result_lists: list[list[str]], k: int = 60) -> list[str]:
    scores: dict[str, float] = {}
    for results in result_lists:
        for rank, doc in enumerate(results, start=1):
            scores[doc] = scores.get(doc, 0.0) + 1.0 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

async def integrated_retrieve(query: str) -> list[str]:
    hybrid_docs, graph_docs = await asyncio.gather(
        retrieve_hybrid_async(query),
        retrieve_graph_async(query),
    )
    merged = reciprocal_rank_fusion([hybrid_docs, graph_docs])
    return merged[:5]

if __name__ == "__main__":
    results = asyncio.run(integrated_retrieve("How do transformer attention heads relate to BERT fine-tuning?"))
    print(results)

RRF vs cross-encoder merge

RRF (Reciprocal Rank Fusion) is fast and parameter-free. A cross-encoder reranker gives higher quality at the cost of an additional inference step. See reranking.md for a local cross-encoder setup with cross-encoder/ms-marco-MiniLM-L-6-v2.


Choosing between Selection and Integration

Criterion Selection (route to one) Integration (run both)
Latency Lower — one retrieval path Higher — two paths in parallel, then merge
Accuracy when query type is clear High Marginal gain over selection
Accuracy on ambiguous queries Risk of wrong route More robust
Cost Lower (one retriever) Higher (two retrievers + merge)
Recommended when Query types are distinct and classifiable Queries are mixed or stakes are high

Routing to web search and SQL

For web queries, replace the stub above with a search API call (SerpAPI, Tavily, or DuckDuckGo's unofficial API). Ground the LLM generation on those snippets before answering.

For SQL, the route feeds a text-to-SQL chain rather than a vector retriever. See rag-over-sql.md for a full local implementation with SQLite.


Next steps