Wicked Smart Data
LearnInsightsAboutContact
Sign InLet's Build
LearnInsightsAboutContact
Sign InLet's Build
Wicked Smart Data

Intelligence, automation, and expert execution — plus an elite library of free knowledge. We turn complexity into competitive advantage.

Start a conversation

Platform

  • Learning Paths
  • Insights
  • RSS Feed

Company

  • About
  • Contact
  • Work With Us

Legal

  • Privacy Policy
  • Terms of Service

© 2026 Wicked Smart Data. All rights reserved.

Intelligence · Automation · Advantage

All Insights
AI & Machine Learning

Implementing Semantic Routing in LLM Applications: Classifying User Intent to Dispatch Queries Across Specialized Chains, Indexes, and Agents

Learn how to build a production-grade semantic router that classifies user intent and dispatches queries to specialized handlers. Covers embedding-based and LLM-based classification, hybrid routing with confidence thresholds, and wiring everything to real downstream chains, indexes, and agents.

⚡ Practitioner25 min readSep 20, 2026Updated Sep 20, 2026
Implementing Semantic Routing in LLM Applications: Classifying User Intent to Dispatch Queries Across Specialized Chains, Indexes, and Agents
On this page
  • Introduction
  • Prerequisites
  • Why "Semantic" Routing? The Limits of Keyword Matching
  • Designing Your Routing Schema
  • Strategy 1: Embedding-Based Routing
  • Strategy 2: LLM-Based Intent Classification
  • Building the Hybrid Router
  • Wiring Up Downstream Handlers
  • Handling Multi-Intent Queries
  • Hands-On Exercise
  • Common Mistakes & Troubleshooting
  • Mistake 1: Setting the confidence threshold too high or too low
  • Mistake 2: Using identical examples across routes
  • Mistake 3: Routing without conversation context
  • Mistake 4: No monitoring on routing decisions
  • Mistake 5: Letting your routing schema drift from your handlers
  • Performance Considerations
  • Evolving Your Router Over Time
  • Summary & Next Steps
  • Implementing Semantic Routing in LLM Applications: Classifying User Intent to Dispatch Queries Across Specialized Chains, Indexes, and Agents

    Introduction

    Imagine you've built a customer-facing AI assistant for a mid-sized SaaS company. Users ask it everything: "How do I reset my password?", "What's my invoice total for Q3?", "Can you write me a SQL query to pull churn data?", and "I'm furious that my account got suspended without warning." Each of those questions demands a completely different response strategy — one needs a documentation lookup, one needs a live database query, one needs a code-generation chain, and one needs a carefully tuned empathetic tone with escalation logic baked in. If you route all four to the same generic chain, you'll get mediocre results on all of them.

    This is the core problem that semantic routing solves. Rather than running every query through one monolithic pipeline, you build a dispatcher that classifies user intent and sends each query to the handler best equipped to serve it — a specialized RAG index, a tool-calling agent, a fine-tuned prompt chain, a human escalation path, or some combination. The result is a system that's simultaneously faster, cheaper, more accurate, and more maintainable than the all-in-one alternative.

    By the end of this lesson, you'll have built a production-grade semantic router from scratch. We'll cover both embedding-based and LLM-based classification strategies, show you how to compose them into a hybrid system, and wire everything up to specialized downstream handlers. We'll also cover failure modes, fallback behavior, and how to monitor routing decisions in production.

    What you'll learn:

    • How semantic routing differs from keyword routing and why it matters at scale
    • Two complementary classification strategies: embedding similarity and LLM-based intent classification
    • How to design a routing schema that maps intents to downstream handlers (chains, indexes, agents)
    • How to build a hybrid router with confidence thresholds and graceful fallbacks
    • How to instrument, test, and iterate on your routing layer in production

    Prerequisites

    You should be comfortable with:

    • Making API calls to OpenAI or another LLM provider (see Using the OpenAI API with Python if you need a refresher)
    • The basics of embeddings and vector similarity (covered in Embeddings Explained)
    • What RAG is and how retrieval pipelines are structured (Retrieval-Augmented Generation Explained)
    • Basic Python: async functions, dataclasses, type hints

    You don't need to have used LangChain or any specific orchestration framework — we'll build the core logic from first principles and show where frameworks can help.


    Why "Semantic" Routing? The Limits of Keyword Matching

    The naive approach to routing is keyword matching: if the message contains "invoice" or "billing," send it to the billing handler; if it contains "error" or "bug," send it to support. This works until it doesn't — which is approximately day three of production traffic.

    Users don't phrase things the way you expect. "I keep getting charged twice" doesn't contain the word "billing." "My dashboard is totally broken" might be a UI bug or a permission issue. "Can you help me understand my options?" could be pre-sales, support, or a cancellation attempt. Keyword rules become an unmaintainable spider web of regexes and special cases.

    Semantic routing sidesteps this by working at the meaning level rather than the token level. Instead of matching words, you're matching intent — the underlying communicative goal of the message. Two messages that share zero vocabulary ("I need to export my data" vs. "How do I get a copy of everything I've stored?") can have identical intent and should be routed identically.

    There are two primary mechanisms for achieving this:

    Embedding similarity routing encodes the incoming query as a vector and compares it against a set of reference vectors (one or more per route). The route whose reference vectors are most similar wins. This is fast and cheap — it requires no LLM call — but it's a blunt instrument for nuanced or ambiguous queries.

    LLM-based classification sends the query to a language model with a structured classification prompt. This is slower and more expensive but handles ambiguity, context-dependency, and multi-intent queries much more gracefully.

    In production, you almost always want both: use embedding similarity for high-confidence cases and fall back to LLM classification when confidence is low.

    Key insight: Semantic routing is an architectural pattern, not a library. The libraries (LangChain's SemanticRouter, custom dispatchers) implement the pattern, but understanding the pattern lets you build it, debug it, and extend it regardless of which tools you use.


    Designing Your Routing Schema

    Before writing a single line of routing code, you need to design your intent taxonomy. This is the most important step and the one most people rush. A poorly designed schema leads to overlapping routes that confuse the classifier, gaps that cause unmatched queries, and handler mismatches that degrade response quality.

    Let's use a concrete example throughout this lesson: an AI assistant for a data analytics SaaS platform. After analyzing 500 real user queries (which you should always do before designing routes), you identify these intent categories:

    Route Name Description Handler
    sql_generation User wants a SQL query written or explained Code-gen chain
    data_lookup User wants specific data from their warehouse Database agent
    documentation How-to questions about the product Docs RAG index
    billing_support Questions about invoices, plans, payments Billing RAG + CRM tool
    bug_report Something isn't working correctly Support ticketing agent
    account_management Password resets, permissions, user management Account management chain
    data_export Requests to export, download, or share data Export workflow agent
    escalate_human Frustrated users, complex complaints, legal mentions Human handoff

    Two design principles to internalize:

    Routes should be mutually exclusive at the decision point. If a query could legitimately belong to two routes, either merge them or add a secondary routing step. "My SQL query is returning wrong data — is this a bug?" could be sql_generation or bug_report. You need to decide: does the system try to fix the SQL first, or file a bug report? Pick one as the default and be explicit about it.

    Routes should be collectively exhaustive with a fallback. Every query that doesn't match a known route should hit a graceful fallback — usually a general-purpose chain with a disclaimer about its limitations, not a cryptic error.


    Strategy 1: Embedding-Based Routing

    The embedding router works by creating a set of example queries for each route, embedding them all, and then comparing any incoming query against those reference embeddings using cosine similarity.

    Here's the full implementation:

    import numpy as np
    from openai import OpenAI
    from dataclasses import dataclass, field
    from typing import Optional
    import json
    
    client = OpenAI()
    
    @dataclass
    class Route:
        name: str
        description: str
        examples: list[str]
        embeddings: list[list[float]] = field(default_factory=list)
    
        def embed_examples(self, client: OpenAI, model: str = "text-embedding-3-small"):
            """Pre-compute embeddings for all examples at startup."""
            response = client.embeddings.create(
                input=self.examples,
                model=model
            )
            self.embeddings = [item.embedding for item in response.data]
    
        def similarity_score(self, query_embedding: list[float]) -> float:
            """Return the max cosine similarity across all example embeddings."""
            if not self.embeddings:
                raise ValueError(f"Route '{self.name}' has no embeddings. Call embed_examples() first.")
            
            query_vec = np.array(query_embedding)
            similarities = []
            for emb in self.embeddings:
                ref_vec = np.array(emb)
                cosine_sim = np.dot(query_vec, ref_vec) / (
                    np.linalg.norm(query_vec) * np.linalg.norm(ref_vec)
                )
                similarities.append(float(cosine_sim))
            
            # Use max rather than mean — one strong match is enough
            return max(similarities)
    
    
    class EmbeddingRouter:
        def __init__(self, routes: list[Route], confidence_threshold: float = 0.75):
            self.routes = routes
            self.confidence_threshold = confidence_threshold
            self.embed_model = "text-embedding-3-small"
    
        def initialize(self):
            """Pre-compute all route embeddings. Call once at startup."""
            print("Initializing embedding router...")
            for route in self.routes:
                route.embed_examples(client, self.embed_model)
            print(f"Router ready with {len(self.routes)} routes.")
    
        def _embed_query(self, query: str) -> list[float]:
            response = client.embeddings.create(
                input=[query],
                model=self.embed_model
            )
            return response.data[0].embedding
    
        def route(self, query: str) -> tuple[Optional[str], float]:
            """
            Returns (route_name, confidence_score).
            Returns (None, best_score) if confidence is below threshold.
            """
            query_embedding = self._embed_query(query)
            
            scores = {
                route.name: route.similarity_score(query_embedding)
                for route in self.routes
            }
            
            best_route = max(scores, key=scores.get)
            best_score = scores[best_route]
            
            if best_score >= self.confidence_threshold:
                return best_route, best_score
            else:
                return None, best_score
    

    Now let's define the routes with realistic examples:

    routes = [
        Route(
            name="sql_generation",
            description="User wants a SQL query written, explained, or debugged",
            examples=[
                "Write me a SQL query to find all users who signed up last month",
                "How do I join the events table with users on customer_id?",
                "My GROUP BY isn't working, what am I doing wrong?",
                "Can you write a query that calculates 30-day rolling retention?",
                "What's the SQL syntax for a window function in Snowflake?",
                "Show me how to write a CTE for this problem",
                "Why is my query returning duplicates?",
            ]
        ),
        Route(
            name="data_lookup",
            description="User wants to retrieve specific data from their warehouse",
            examples=[
                "How many active users do we have right now?",
                "What was our revenue last Tuesday?",
                "Show me the top 10 customers by spend this quarter",
                "How many events did we track yesterday?",
                "What's the conversion rate for the onboarding funnel?",
                "Give me the MRR breakdown by plan type",
            ]
        ),
        Route(
            name="documentation",
            description="User has how-to questions about using the product",
            examples=[
                "How do I create a new dashboard?",
                "Where do I configure data source connections?",
                "What's the difference between a metric and a dimension?",
                "How do I share a report with my team?",
                "Can I schedule reports to run automatically?",
                "What file formats can I import?",
            ]
        ),
        Route(
            name="billing_support",
            description="Questions about invoices, pricing, plans, or payments",
            examples=[
                "I was charged twice this month",
                "Can I get a copy of my invoice for Q2?",
                "What's included in the enterprise plan?",
                "I need to upgrade my subscription",
                "Is there a discount for annual billing?",
                "My payment method is failing",
            ]
        ),
        Route(
            name="escalate_human",
            description="Frustrated users, complex complaints, or legal/compliance concerns",
            examples=[
                "This is absolutely unacceptable and I want to speak to someone",
                "I'm going to cancel my account if this isn't fixed immediately",
                "I think you may have exposed my customer data — this is a GDPR issue",
                "I've been waiting 3 days and nobody has responded",
                "I want to make a formal complaint",
                "My entire team is blocked and we're losing money every hour",
            ]
        ),
    ]
    
    # Initialize the router at application startup
    router = EmbeddingRouter(routes=routes, confidence_threshold=0.75)
    router.initialize()
    
    # Test it
    test_queries = [
        "Write a query to show me churn by cohort",
        "How many trials converted this month?",
        "How do I set up a Looker-style explore?",
        "I want a refund, this product is garbage",
    ]
    
    for q in test_queries:
        route_name, score = router.route(q)
        print(f"Query: '{q[:50]}...'")
        print(f"  → Route: {route_name or 'FALLBACK'} (confidence: {score:.3f})\n")
    

    Tip: The number and quality of your example queries per route matters more than anything else. Aim for at least 7-10 examples per route, and make sure they cover the lexical diversity you'd expect from real users — not just rephrasing the same sentence. Pull examples from actual user logs whenever possible.


    Strategy 2: LLM-Based Intent Classification

    When embedding similarity doesn't return a confident match, you escalate to an LLM. Rather than asking the model to freeform-describe the intent, you give it your routing schema and ask for structured output — this is much more reliable than parsing natural language responses.

    If you need a refresher on getting reliable structured outputs from LLMs, check out our lesson on Structured Output: Getting JSON, Tables, and Code from LLMs.

    from pydantic import BaseModel, Field
    from typing import Literal
    
    # Define the structured output schema
    class RoutingDecision(BaseModel):
        route: str = Field(
            description="The name of the most appropriate route from the provided list"
        )
        confidence: Literal["high", "medium", "low"] = Field(
            description="How confident you are in this routing decision"
        )
        reasoning: str = Field(
            description="One sentence explaining why you chose this route"
        )
        secondary_route: Optional[str] = Field(
            default=None,
            description="If the query could also belong to another route, name it here"
        )
    
    
    class LLMRouter:
        def __init__(self, routes: list[Route]):
            self.routes = routes
            self._build_schema_description()
    
        def _build_schema_description(self):
            """Build a compact description of all routes for the system prompt."""
            route_descriptions = "\n".join(
                f"- {r.name}: {r.description}" for r in self.routes
            )
            self.schema_description = route_descriptions
    
        def _build_system_prompt(self) -> str:
            return f"""You are an intent classifier for a data analytics platform assistant.
    
    Your job is to analyze user queries and determine which routing category best matches their intent.
    
    Available routes:
    {self.schema_description}
    
    Rules:
    - Choose exactly one primary route — the one that best represents the user's core need
    - If the query could reasonably belong to multiple routes, pick the most actionable one and note the secondary
    - If you're genuinely uncertain, use low confidence — don't guess
    - Base your decision on intent, not surface-level keywords
    - "escalate_human" should be used when the user shows strong frustration or raises compliance/legal concerns"""
    
        def classify(self, query: str, conversation_context: str = "") -> RoutingDecision:
            """
            Classify a query using LLM-based intent detection.
            conversation_context: recent message history for context-dependent routing
            """
            user_content = f"User query: {query}"
            if conversation_context:
                user_content = f"Conversation context:\n{conversation_context}\n\nCurrent query: {query}"
    
            response = client.beta.chat.completions.parse(
                model="gpt-4o-mini",  # Use a cheap, fast model for routing
                messages=[
                    {"role": "system", "content": self._build_system_prompt()},
                    {"role": "user", "content": user_content}
                ],
                response_format=RoutingDecision,
                temperature=0,  # Deterministic routing
            )
            
            return response.choices[0].message.parsed
    
    
    # Usage
    llm_router = LLMRouter(routes=routes)
    
    result = llm_router.classify(
        "The export I scheduled last week still hasn't shown up in my inbox and I've emailed support twice",
        conversation_context="User has been waiting for a data export for 7 days."
    )
    
    print(f"Route: {result.route}")
    print(f"Confidence: {result.confidence}")
    print(f"Reasoning: {result.reasoning}")
    print(f"Secondary: {result.secondary_route}")
    # Output:
    # Route: escalate_human
    # Confidence: high
    # Reasoning: User shows significant frustration and has already attempted support contact without resolution.
    # Secondary: data_export
    

    Notice a few things about this design:

    1. We use gpt-4o-mini not gpt-4o. Routing decisions should be fast and cheap — you're not generating content, you're making a binary decision. For tips on model selection by task complexity, see Implementing LLM Router Architecture.

    2. We use temperature=0. Routing should be deterministic. You don't want the same query to route differently on different calls.

    3. We include conversation context. Without it, "Can you fix that?" is unroutable. With it, the classifier knows whether "that" refers to a SQL query or a billing issue.

    Warning: Don't use a routing prompt that describes routes vaguely. "Handle billing questions" is much weaker than "Questions about invoices, pricing, plans, payment failures, or subscription changes." The LLM needs enough signal to distinguish routes that might be adjacent in meaning.


    Building the Hybrid Router

    Now we combine both strategies. The embedding router runs first (it's fast and free). If it returns a high-confidence result, we dispatch immediately. If it's uncertain, we escalate to the LLM classifier.

    import time
    import logging
    from dataclasses import dataclass
    
    logging.basicConfig(level=logging.INFO)
    logger = logging.getLogger("semantic_router")
    
    @dataclass
    class RoutingResult:
        route: str
        confidence_score: float
        strategy_used: str  # "embedding" | "llm" | "fallback"
        reasoning: Optional[str] = None
        secondary_route: Optional[str] = None
        latency_ms: float = 0.0
    
    
    class HybridSemanticRouter:
        """
        Two-stage router: embedding similarity first, LLM classification as fallback.
        """
        
        def __init__(
            self,
            routes: list[Route],
            embedding_confidence_threshold: float = 0.78,
            fallback_route: str = "documentation",
        ):
            self.embedding_router = EmbeddingRouter(
                routes=routes,
                confidence_threshold=embedding_confidence_threshold
            )
            self.llm_router = LLMRouter(routes=routes)
            self.fallback_route = fallback_route
            self.routes = routes
    
        def initialize(self):
            self.embedding_router.initialize()
    
        def route(
            self,
            query: str,
            conversation_context: str = "",
            force_llm: bool = False
        ) -> RoutingResult:
            start_time = time.time()
            
            # Stage 1: Try embedding router (unless forced to LLM)
            if not force_llm:
                embed_route, embed_score = self.embedding_router.route(query)
                
                if embed_route is not None:
                    latency = (time.time() - start_time) * 1000
                    logger.info(
                        f"Embedding router matched '{embed_route}' "
                        f"(score={embed_score:.3f}, latency={latency:.1f}ms)"
                    )
                    return RoutingResult(
                        route=embed_route,
                        confidence_score=embed_score,
                        strategy_used="embedding",
                        latency_ms=latency
                    )
                
                logger.info(
                    f"Embedding confidence too low ({embed_score:.3f}), "
                    f"escalating to LLM classifier"
                )
            
            # Stage 2: LLM classification
            try:
                llm_decision = self.llm_router.classify(query, conversation_context)
                
                # Map LLM confidence string to numeric score
                confidence_map = {"high": 0.90, "medium": 0.70, "low": 0.50}
                numeric_confidence = confidence_map[llm_decision.confidence]
                
                latency = (time.time() - start_time) * 1000
                logger.info(
                    f"LLM classifier matched '{llm_decision.route}' "
                    f"(confidence={llm_decision.confidence}, latency={latency:.1f}ms)"
                )
                
                # If LLM is also low confidence, route to fallback
                if llm_decision.confidence == "low":
                    return RoutingResult(
                        route=self.fallback_route,
                        confidence_score=numeric_confidence,
                        strategy_used="fallback",
                        reasoning=f"Low confidence routing: {llm_decision.reasoning}",
                        latency_ms=latency
                    )
                
                return RoutingResult(
                    route=llm_decision.route,
                    confidence_score=numeric_confidence,
                    strategy_used="llm",
                    reasoning=llm_decision.reasoning,
                    secondary_route=llm_decision.secondary_route,
                    latency_ms=latency
                )
            
            except Exception as e:
                latency = (time.time() - start_time) * 1000
                logger.error(f"LLM routing failed: {e}. Using fallback route.")
                return RoutingResult(
                    route=self.fallback_route,
                    confidence_score=0.0,
                    strategy_used="fallback",
                    reasoning=f"Router error: {str(e)}",
                    latency_ms=latency
                )
    

    Note: The force_llm parameter is useful for debugging and A/B testing. You can direct a random sample of traffic through the LLM router even when the embedding router would have matched, then compare outcomes to validate that embedding routing isn't making silent mistakes.


    Wiring Up Downstream Handlers

    The router is only useful if it dispatches to real handlers. Let's build a dispatcher that connects routing results to actual execution logic:

    from typing import Callable, Any
    from abc import ABC, abstractmethod
    
    class QueryHandler(ABC):
        """Base class for all route handlers."""
        
        @abstractmethod
        async def handle(self, query: str, context: dict) -> dict:
            """
            Process a routed query.
            Returns a dict with 'response', 'sources', and any handler-specific metadata.
            """
            pass
    
    
    class SQLGenerationHandler(QueryHandler):
        async def handle(self, query: str, context: dict) -> dict:
            # In production, this would call a specialized SQL-generation chain
            # with schema context, dialect awareness, and query validation
            system_prompt = """You are an expert SQL assistant for a Snowflake data warehouse.
    The user's schema includes: users, events, sessions, revenue, subscriptions tables.
    Write clean, well-commented SQL. Validate that all table and column references are real."""
            
            response = client.chat.completions.create(
                model="gpt-4o",  # Use the powerful model for actual generation
                messages=[
                    {"role": "system", "content": system_prompt},
                    {"role": "user", "content": query}
                ],
                temperature=0.1
            )
            
            return {
                "response": response.choices[0].message.content,
                "handler": "sql_generation",
                "model_used": "gpt-4o"
            }
    
    
    class DocumentationHandler(QueryHandler):
        def __init__(self, vector_store):
            self.vector_store = vector_store
        
        async def handle(self, query: str, context: dict) -> dict:
            # Retrieve relevant docs, then generate a grounded response
            # See: Building a Production Document Q&A System with Vector Embeddings
            docs = self.vector_store.similarity_search(query, k=5)
            
            context_text = "\n\n".join([doc.page_content for doc in docs])
            sources = [doc.metadata.get("source", "unknown") for doc in docs]
            
            response = client.chat.completions.create(
                model="gpt-4o-mini",
                messages=[
                    {"role": "system", "content": "Answer based on the provided documentation. If the answer isn't in the docs, say so."},
                    {"role": "user", "content": f"Documentation:\n{context_text}\n\nQuestion: {query}"}
                ],
                temperature=0
            )
            
            return {
                "response": response.choices[0].message.content,
                "handler": "documentation",
                "sources": sources
            }
    
    
    class HumanEscalationHandler(QueryHandler):
        async def handle(self, query: str, context: dict) -> dict:
            # Create a support ticket and return a holding message
            ticket_id = self._create_ticket(query, context)
            
            return {
                "response": f"I can see this is urgent and I want to make sure you get the right help. "
                           f"I've created support ticket #{ticket_id} and flagged it as high priority. "
                           f"A team member will reach out within 2 hours. Is there anything else I can "
                           f"document to help them resolve this faster?",
                "handler": "human_escalation",
                "ticket_id": ticket_id,
                "requires_human": True
            }
        
        def _create_ticket(self, query: str, context: dict) -> str:
            # Integrate with your ticketing system (Zendesk, Linear, etc.)
            # Returns ticket ID
            return "TKT-" + str(hash(query))[:6].upper()
    
    
    class RoutingDispatcher:
        """Connects routing results to handler execution."""
        
        def __init__(self, router: HybridSemanticRouter):
            self.router = router
            self.handlers: dict[str, QueryHandler] = {}
            self.fallback_handler: Optional[QueryHandler] = None
    
        def register_handler(self, route_name: str, handler: QueryHandler):
            self.handlers[route_name] = handler
    
        def register_fallback(self, handler: QueryHandler):
            self.fallback_handler = handler
    
        async def dispatch(self, query: str, context: dict = {}) -> dict:
            """Route and handle a query end-to-end."""
            
            # Get conversation context for context-aware routing
            conversation_history = context.get("conversation_history", "")
            
            # Route the query
            routing_result = self.router.route(query, conversation_context=conversation_history)
            
            # Select handler
            handler = self.handlers.get(routing_result.route)
            if handler is None:
                if self.fallback_handler:
                    handler = self.fallback_handler
                    routing_result.route = "fallback"
                else:
                    raise ValueError(f"No handler registered for route '{routing_result.route}'")
            
            # Execute handler
            handler_result = await handler.handle(query, context)
            
            # Merge routing metadata with handler result
            return {
                **handler_result,
                "routing": {
                    "route": routing_result.route,
                    "confidence": routing_result.confidence_score,
                    "strategy": routing_result.strategy_used,
                    "reasoning": routing_result.reasoning,
                    "latency_ms": routing_result.latency_ms
                }
            }
    

    The routing metadata in every response is not optional in production. You need it for monitoring, debugging, and building your human preference dataset to improve routing quality over time — a topic covered in Building a Feedback Collection and Human Preference Dataset Pipeline.


    Handling Multi-Intent Queries

    Real users often have multi-intent queries: "Can you write me a query to pull last month's revenue and also explain how the billing dashboard works?" This is simultaneously sql_generation and documentation. You have three options:

    Option 1: Primary route only. Pick the dominant intent and serve that. Mention at the end that you can also help with the secondary intent. This is the simplest approach and usually good enough.

    Option 2: Sequential routing. Decompose the query into sub-queries, route each one, execute in sequence, and synthesize the results. This is exactly what query decomposition pipelines do — see Implementing Query Decomposition and Sub-Question Synthesis for Complex RAG Pipelines for the full pattern.

    Option 3: Parallel routing. Route to multiple handlers simultaneously and merge results. Effective when the sub-queries are independent and latency matters.

    Here's a minimal parallel routing implementation:

    import asyncio
    
    async def dispatch_parallel(
        dispatcher: RoutingDispatcher,
        query: str,
        routes: list[str],
        context: dict = {}
    ) -> list[dict]:
        """Dispatch the same query to multiple handlers in parallel."""
        
        async def run_handler(route_name: str) -> dict:
            handler = dispatcher.handlers.get(route_name)
            if handler:
                result = await handler.handle(query, context)
                result["_route"] = route_name
                return result
            return {"_route": route_name, "error": "No handler found"}
        
        results = await asyncio.gather(*[run_handler(r) for r in routes])
        return list(results)
    
    
    # Example: query has secondary route that warrants parallel execution
    async def smart_dispatch(dispatcher: RoutingDispatcher, query: str, context: dict = {}):
        routing_result = dispatcher.router.route(query)
        
        # If there's a secondary route and primary confidence is medium, run both
        if (
            routing_result.secondary_route
            and routing_result.confidence_score < 0.85
            and routing_result.confidence_score > 0.65
        ):
            results = await dispatch_parallel(
                dispatcher,
                query,
                [routing_result.route, routing_result.secondary_route],
                context
            )
            return {"type": "multi_intent", "results": results}
        else:
            return await dispatcher.dispatch(query, context)
    

    Hands-On Exercise

    Build a semantic router for a financial data assistant. The assistant should handle four types of queries:

    1. market_data — Requests for stock prices, market indices, or financial metrics
    2. portfolio_analysis — Questions about portfolio composition, performance, or risk
    3. regulatory_compliance — Questions about reporting requirements, audits, or regulations
    4. trade_execution — Requests to place, modify, or cancel trades

    Your tasks:

    Part 1: Define a Route for each category with at least 8 realistic example queries. Think about edge cases — where do the category boundaries get fuzzy? Write down at least 3 queries per boundary that you'd expect to be ambiguous.

    Part 2: Initialize an EmbeddingRouter with your routes and test it against the ambiguous queries you identified. What threshold value gives you the best balance of coverage versus accuracy? Plot confidence scores for 20 test queries to visualize the distribution.

    Part 3: Implement an LLMRouter that includes a custom system prompt explaining the financial context and the business consequence of misrouting (e.g., routing a trade execution query to the wrong handler could have real money implications). Does adding that context improve classification?

    Part 4: Wire up a HybridSemanticRouter and add logging that writes each routing decision to a local JSONL file, including: timestamp, query (hashed for privacy), route chosen, strategy used, and confidence score. After running 50 test queries, analyze the log to find which routes have the lowest average confidence — those are candidates for adding more examples.


    Common Mistakes & Troubleshooting

    Mistake 1: Setting the confidence threshold too high or too low

    If you set the embedding threshold at 0.90, you'll send almost everything through the LLM classifier and lose the performance benefit. If you set it at 0.60, you'll get false positive route matches for genuinely ambiguous queries.

    Fix: Start with 0.75, run 100+ representative queries, plot the score distribution, and look for a natural gap between the "clearly matched" cluster and the "uncertain" cluster. Your threshold belongs in that gap.

    Mistake 2: Using identical examples across routes

    If two routes share overlapping example queries, the embedding space gets confused. Route A examples like "How do I download my data?" and Route B examples like "Can I export my data?" will produce embeddings that compete.

    Fix: Review your examples for semantic overlap and make each route's examples as distinctive as possible. If two routes genuinely look the same to an embedding model, they probably belong together as one route.

    Mistake 3: Routing without conversation context

    A query like "Can you fix that?" will fail to route correctly without knowing what "that" refers to. The embedding router will return low confidence (correctly), but even the LLM classifier can't route it without context.

    Fix: Always pass recent conversation history to your router. A rolling window of the last 3-5 exchanges is usually sufficient. Longer windows cost more tokens without proportional benefit — see our discussion of token costs in Understanding Tokens.

    Mistake 4: No monitoring on routing decisions

    In production, your routing will silently degrade as user behavior shifts. New product features introduce new query types. Marketing campaigns drive traffic from different personas. Without monitoring, you won't know your router is failing until users complain.

    Fix: Log every routing decision with its confidence score, strategy used, and — when available — user feedback. Set up alerts when the average confidence score drops more than 5 points week-over-week, or when fallback route usage spikes. Check out Implementing LLM Observability for the full monitoring stack.

    Warning: Never trust your routing layer blindly in security-sensitive contexts. A user crafting a query specifically to get routed to a privileged handler (like trade_execution or admin_tools) is a real threat. Combine routing with handler-level authorization checks and input validation — ideally using a dedicated guardrails layer.

    Mistake 5: Letting your routing schema drift from your handlers

    You add a new route definition but forget to register a handler. Or you rename a route in the router but not in the dispatcher. These mismatches fail silently or produce confusing errors.

    Fix: Add a startup validation check:

    def validate_dispatcher(dispatcher: RoutingDispatcher, routes: list[Route]):
        """Ensure every defined route has a registered handler."""
        route_names = {r.name for r in routes}
        handler_names = set(dispatcher.handlers.keys())
        
        unhandled = route_names - handler_names
        unused_handlers = handler_names - route_names
        
        if unhandled:
            raise ValueError(f"Routes without handlers: {unhandled}")
        if unused_handlers:
            logger.warning(f"Handlers without routes (may be intentional): {unused_handlers}")
        
        print("✓ Dispatcher validation passed")
    

    Performance Considerations

    The embedding-based routing path costs you one embedding API call per query. At text-embedding-3-small pricing, that's roughly $0.00002 per query — essentially free. The computation to compare the embedded query against your route reference embeddings is pure numpy math that runs in microseconds.

    The LLM fallback path costs you a full inference call. With gpt-4o-mini and a typical routing prompt (under 500 tokens), that's approximately $0.0003 per query. Still cheap, but 15x more expensive than the embedding path, and with significantly higher latency (500ms-1500ms vs 100-200ms for embeddings).

    This is exactly why the hybrid approach matters: if your embedding router handles 80% of queries confidently, you only pay LLM costs for the remaining 20%. At scale, that's a meaningful difference.

    Key insight: Improving your embedding router's coverage (by adding better examples per route) has a direct cost and latency impact. Every percentage point you shift from the LLM fallback to the embedding match saves money and cuts response time. Treat example quality as infrastructure investment.

    For very high-traffic systems, you can go further by caching routing decisions. If 40% of your traffic is semantically similar recurring queries, you can cache the routing result by semantic fingerprint using a Redis-backed semantic cache — covered thoroughly in Implementing LLM Response Caching with Redis.


    Evolving Your Router Over Time

    A routing schema is not a one-time design decision. It evolves as your product evolves. Here's a process for keeping it sharp:

    Weekly: Review routing decisions where the LLM was used (because embedding confidence was low). Are there recurring query patterns that consistently fall through? Those are candidates for new example phrases in an existing route, or a new route entirely.

    Monthly: Sample routing decisions where human feedback indicates a bad response. Trace back to the routing decision — was the query sent to the wrong handler? If so, was it a routing failure or a handler failure?

    Quarterly: Audit your route definitions against actual traffic distribution. If billing_support is seeing 50% of traffic but data_export sees 2%, either the routes need rebalancing or the product usage patterns have shifted dramatically.

    This feedback loop is essentially building a preference dataset specifically for your routing layer. Over time, it lets you evaluate whether routing changes actually improve outcomes — not just whether they change routing decisions.


    Summary & Next Steps

    Semantic routing transforms your LLM application from a monolithic pipeline into a modular, expert system. Instead of asking one model to be a SQL expert, a billing support agent, a documentation bot, and a customer de-escalation specialist simultaneously, you dispatch each query to the handler built specifically for it.

    The key principles to carry forward:

    • Design your routing schema before your routes. Analyze real user queries, identify natural clusters, and define routes that are mutually exclusive and collectively exhaustive.
    • Use embedding routing for speed, LLM routing for precision. The hybrid approach gives you the best of both.
    • Every routing decision should be logged and monitored. Routing failures are silent — they produce mediocre responses, not errors.
    • Routing schema evolution is a continuous practice. Treat it like maintaining any other critical piece of infrastructure.

    Where to go next from here:

    • If your specialized handlers are doing RAG, you'll want a sophisticated retrieval layer — Building a Production Document Q&A System with Vector Embeddings covers the full stack
    • If your handlers are tool-calling agents, Function Calling and Tool Use with LLMs covers building reliable agents that work at the end of a routing chain
    • For building multi-step handlers that need to maintain state across the conversation, read Implementing Structured Agent Memory
    • If you're handling parallel execution across multiple routes, Orchestrating Parallel LLM Calls covers the async patterns you need
    Work With Us

    From insight to implementation

    Reading is the start. When you're ready to build the data, automation, or AI systems behind it, our team turns strategy into shipped results.

    Let's Build

    Building with LLMs

    Previous

    Vector Databases Explained: Choosing, Configuring, and Querying Pinecone, Weaviate, and pgvector for LLM Applications

    Related Insights

    AI & Machine LearningPractitioner

    Translating Business Requirements into AI Task Specifications: A Practitioner's Framework for Scoping, Decomposing, and Documenting LLM Use Cases Before Building

    26 min
    AI & Machine LearningFoundation

    Knowledge Graphs 101: Understanding Nodes, Edges, and Relationships as a Foundation for RAG and AI Agents

    16 min
    AI & Machine LearningFoundation

    Vector Databases Explained: Choosing, Configuring, and Querying Pinecone, Weaviate, and pgvector for LLM Applications

    17 min

    On this page

    • Introduction
    • Prerequisites
    • Why "Semantic" Routing? The Limits of Keyword Matching
    • Designing Your Routing Schema
    • Strategy 1: Embedding-Based Routing
    • Strategy 2: LLM-Based Intent Classification
    • Building the Hybrid Router
    • Wiring Up Downstream Handlers
    • Handling Multi-Intent Queries
    • Hands-On Exercise
    • Common Mistakes & Troubleshooting
    • Mistake 1: Setting the confidence threshold too high or too low
    • Mistake 2: Using identical examples across routes
    • Mistake 3: Routing without conversation context
    • Mistake 4: No monitoring on routing decisions
    • Mistake 5: Letting your routing schema drift from your handlers
    • Performance Considerations
    • Evolving Your Router Over Time
    • Summary & Next Steps