LlamaIndex RouterQueryEngine — selector picks wrong engine or none
<code>RouterQueryEngine</code> uses an LLM or embeddings to route each query to the best sub-engine. When routing is wrong, users get the "no info" answer even though the right sub-engine holds the answer.
Quick fix (TL;DR)
RouterQueryEngine takes QueryEngineTools and a selector. The selector reads tool descriptions to decide where to route. Failure modes: (a) tool descriptions too similar; (b) LLM selector wrong on ambiguous queries; (c) embedding selector missing on out-of-distribution queries. Fix by (a) writing sharply differentiated tool descriptions, (b) picking LLMSingleSelector/PydanticSingleSelector based on model, and (c) allowing multi-selection when queries legitimately span engines.Real error messages you'll see
These are the exact strings returned by the LlamaIndex framework and its integrations when this error occurs. Copy-paste-searching any of them should land on this page.
# Tools: [policy_engine, product_engine] # Query: "What is the return window on ACME-5000?" # Router picks product_engine (finds product info; misses return window) # Should have picked policy_engine or multi-select both
ValueError: Could not parse selector output. Expected JSON with "choice" and "reason" keys. Got: "I think you should use the first tool because..."
# Query: "Refund policy?" # Router picks BOTH engines; results duplicated and confusing
Reference
Selector types
| Selector | How it decides | Cost |
|---|---|---|
LLMSingleSelector | LLM reads tool descs, returns one choice | One LLM call per query |
LLMMultiSelector | LLM returns list of choices | One LLM call |
PydanticSingleSelector | LLM function-call output | One LLM call (structured) |
PydanticMultiSelector | Function-call, multiple | One LLM call |
EmbeddingSingleSelector | Similarity between query and tool descs | One embedding call |
Root causes, ranked by frequency
Based on developer reports across LlamaIndex forums, GitHub issues, and Discord community during 2025–2026.
- 26%Tool descriptions too similar. Two engines described in nearly identical language.
- 18%LLM selector wrong on ambiguous queries. Query touches multiple domains; single-select forced to pick one.
- 14%Descriptions do not list examples of what to route. Selector has to guess based on abstract description.
- 10%Wrong selector class for provider. LLMSingleSelector parser fails on non-JSON output.
- 8%Not using multi-select when queries span engines. "Compare policy and product info" needs both.
- 7%Embedding selector on out-of-distribution query. Query semantics do not match any tool description.
- 7%Tool descriptions written for humans, not LLMs. Verbose prose instead of "when to use / when not to".
- 10%No fallback when router fails. If no tool selected, the whole query fails instead of degrading.
Fixes — copy-paste solutions
Write task-specific tool descriptions with "when to use / when not to"
Structure every tool description as: what domain it covers, examples of queries it should handle, and explicit anti-triggers.
from llama_index.core.query_engine import RouterQueryEngine from llama_index.core.selectors import PydanticSingleSelector, PydanticMultiSelector from llama_index.core.tools import QueryEngineTool, ToolMetadata from llama_index.llms.openai import OpenAI llm = OpenAI(model="gpt-4o-mini") # Rich tool descriptions with explicit anti-triggers policy_tool = QueryEngineTool( query_engine=policy_index.as_query_engine(similarity_top_k=6), metadata=ToolMetadata( name="company_policies", description=( "Company policies including refund policy, return policy, warranty, " "shipping terms, and terms of service.\n\n" "USE THIS FOR queries about:\n" "- Refund windows, return conditions, warranty periods\n" "- Shipping options, delivery timelines\n" "- Terms of service, privacy policy details\n\n" "DO NOT USE FOR:\n" "- Product specifications or features\n" "- Pricing of specific products\n" "- Availability inventory" ), ), ) product_tool = QueryEngineTool( query_engine=product_index.as_query_engine(similarity_top_k=6), metadata=ToolMetadata( name="product_catalog", description=( "Product catalog including features, specifications, pricing, " "and inventory status.\n\n" "USE THIS FOR queries about:\n" "- Specific product features or technical specs\n" "- Product prices and availability\n" "- Product comparisons or recommendations\n\n" "DO NOT USE FOR:\n" "- Refund/return policies (use company_policies)\n" "- Order status (use orders_api)" ), ), ) # --- Router with a single-select LLM selector --- router_query_engine = RouterQueryEngine( selector=PydanticSingleSelector.from_defaults(llm=llm), query_engine_tools=[policy_tool, product_tool], verbose=True, # prints selection reasoning ) # --- Test various query types --- for query in [ "What is the refund window?", "What are the specs of the ACME-5000?", "What is the return window for the ACME-5000 specifically?", # ambiguous ]: response = router_query_engine.query(query) print(f"\nQ: {query}") print(f"A: {response.response[:200]}")
Use PydanticMultiSelector for queries that span domains
Multi-select allows the router to pick multiple tools. Combine with a response synthesizer that consolidates outputs across engines.
from llama_index.core.query_engine import RouterQueryEngine from llama_index.core.selectors import PydanticMultiSelector router = RouterQueryEngine( selector=PydanticMultiSelector.from_defaults(llm=llm, max_outputs=2), query_engine_tools=[policy_tool, product_tool, orders_tool], verbose=True, ) # Query that spans multiple engines response = router.query( "What is the return window for the ACME-5000, and is it currently in stock?" ) print(response) # --- Router with LLM selector for models that do not do function calling --- from llama_index.core.selectors import LLMSingleSelector, LLMMultiSelector # LLMSingleSelector: outputs "the answer is: X" free-text; parser extracts the choice # Use when your LLM does NOT support structured output well router_llm = RouterQueryEngine( selector=LLMSingleSelector.from_defaults(llm=llm), query_engine_tools=[policy_tool, product_tool], ) # --- Fallback strategy --- # When all tools score low, fall back to a "generic" engine generic_engine = general_index.as_query_engine() generic_tool = QueryEngineTool( query_engine=generic_engine, metadata=ToolMetadata( name="general_knowledge", description="General knowledge fallback for queries that do not fit other tools.", ), ) router_with_fallback = RouterQueryEngine( selector=PydanticSingleSelector.from_defaults(llm=llm), query_engine_tools=[policy_tool, product_tool, generic_tool], verbose=True, )
Log routing decisions and A/B test selector types
Track which tool the router picks per query, sample manually or with an eval, and swap selectors if one is consistently better on your query mix.
from llama_index.core.query_engine import RouterQueryEngine from llama_index.core.selectors import ( PydanticSingleSelector, LLMSingleSelector, EmbeddingSingleSelector, ) from llama_index.embeddings.openai import OpenAIEmbedding # --- Build multiple routers with different selectors --- tools = [policy_tool, product_tool, orders_tool] routers = { "pydantic": RouterQueryEngine( selector=PydanticSingleSelector.from_defaults(llm=llm), query_engine_tools=tools, ), "llm": RouterQueryEngine( selector=LLMSingleSelector.from_defaults(llm=llm), query_engine_tools=tools, ), "embedding": RouterQueryEngine( selector=EmbeddingSingleSelector.from_defaults( embed_model=OpenAIEmbedding(model="text-embedding-3-small"), ), query_engine_tools=tools, ), } # --- Test set with expected routing --- test_set = [ ("What is the refund window?", "company_policies"), ("Show me the ACME-5000 features", "product_catalog"), ("Where is my order?", "orders_api"), # ... ] for name, router in routers.items(): correct = 0 for query, expected_tool in test_set: response = router.query(query) # metadata.selector_result shows which was selected selected = getattr(response, "metadata", {}).get("selector_result", {}).get("selection", "?") # (the exact attribute name may vary; verify in your version) if selected == expected_tool: correct += 1 print(f"{name} selector: {correct}/{len(test_set)} correct") # Also log embedding selector cost: it does not call the LLM per query, # which makes it cheaper — worth it if quality matches
EmbeddingSingleSelector is much cheaper than LLM-based selectors. Test carefully — quality is sometimes worse than LLM-based but often acceptable.Prevention checklist
Ship these seven safeguards once and this error stops appearing in your logs.
- Write tool descriptions with explicit "USE FOR" and "DO NOT USE FOR" sections.
- Include example queries in tool descriptions when queries are ambiguous.
- Choose PydanticSingleSelector for OpenAI/Anthropic; LLMSingleSelector for others.
- Use MultiSelector when queries legitimately need multiple tools.
- Add a generic fallback tool so router "unable to route" does not fail the user.
- Log routing decisions in production for evaluation.
- A/B test selector types on your query distribution; embedding selector is cheaper.
Frequently asked questions
prompt_template_str to the selector. Customize when you need domain-specific routing hints or a different output format.Related errors & hubs
Get the weekly AI-error digest
New fixes, provider status recaps, and one deep tutorial — every Tuesday. 8,400+ engineers.