Semantic Routing of User Intents

AILLMMachine LearningArchitecture
Share on LinkedIn

"What's your refund policy?" and "I want a refund for order #8821" sound similar to a keyword matcher. They need completely different handlers — one reads a FAQ chunk, the other calls an order API with write access. A semantic router sits at the front of your LLM app and decides which pipeline runs before you spend tokens on the wrong one.

Router architecture

User message
    ↓
[Normalize text]
    ↓
[Router: intent + confidence]
    ↓
┌─────────┬──────────┬────────────┐
│ FAQ/RAG │ Actions  │ Human handoff │
└─────────┴──────────┴────────────┘

Each handler is a separate prompt, tool set, and model tier. The router's job is classification, not answering.

Embedding-based routing

Define intents with 5–15 example utterances each. Embed examples at deploy time; embed the user query at runtime; return nearest intent by cosine similarity.

@dataclass
class IntentRoute:
    name: str
    examples: list[str]
    handler: str
    centroid: list[float] | None = None

class EmbeddingRouter:
    def __init__(self, intents: list[IntentRoute], embed_fn):
        self.intents = intents
        for intent in self.intents:
            vectors = [embed_fn(ex) for ex in intent.examples]
            intent.centroid = mean_vector(vectors)

    def route(self, message: str) -> tuple[str, float]:
        q = embed_fn(message)
        scores = {
            i.name: cosine(q, i.centroid)
            for i in self.intents
        }
        best = max(scores, key=scores.get)
        return best, scores[best]

Latency: 50–150ms for embedding + comparison. Cost: one small embedding call.

Works well when intents are semantically distant ("weather" vs "refund" vs "password reset"). Breaks when intents overlap ("change subscription" vs "cancel subscription").

LLM classifier

For ambiguous intents, use a cheap model with structured output:

CLASSIFY_PROMPT = """Classify the user message into exactly one intent.
Intents: {intent_list}
Return JSON: {{"intent": "...", "confidence": 0.0-1.0, "reason": "..."}}
"""

async def llm_route(message: str) -> RouteResult:
    response = await llm.complete(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": CLASSIFY_PROMPT.format(
            intent_list=intents, message=message
        )}],
        response_format=RouteResult,
    )
    return response

Include intent descriptions, not just names. "refund_request: user wants money back for a specific purchase" beats "refund_request."

Cascade pattern

Combine speed and accuracy:

async def route(message: str) -> Handler:
    intent, score = embedding_router.route(message)
    if score >= HIGH_CONFIDENCE:
        return handlers[intent]
    if score >= MEDIUM_CONFIDENCE:
        result = await llm_route(message)
        if result.confidence >= 0.7:
            return handlers[result.intent]
    return handlers["general_fallback"]

Log every routing decision with scores. You'll find intents that need more examples or should merge.

Multi-intent and slot filling

Real messages carry multiple intents: "Cancel my subscription and email me a confirmation."

Options:

  1. Primary intent routing — handle the highest-priority intent, queue the rest
  2. Multi-label classification — route to orchestrator that sequences handlers
  3. Unified agent — skip routing, let a tool-equipped agent handle it (expensive)

For transactional apps, extract slots before routing:

slots = await extract_slots(message, intent="refund_request")
# {"order_id": "8821", "reason": None}
if not slots.complete:
    return clarifying_question(slots.missing)
return handlers["refund_request"](slots)

Calibration and monitoring

Track per intent:

Retrain examples monthly from misfires. The best training data is messages the router got wrong.

Avoiding routing brittleness

Intent taxonomy design

Well-designed intent taxonomies are mutually exclusive and collectively exhaustive:

INTENTS = {
    "refund_request":    "User wants money back for a purchase",
    "order_status":      "User asking where their order is",
    "product_question":  "User asking about product features or specs",
    "account_issue":     "Login, password, or account access problems",
    "billing_dispute":   "Charge amount or billing cycle questions",
    "general_faq":       "General questions answerable from knowledge base",
    "out_of_scope":      "Not related to our product or service",
    "human_escalation":  "User explicitly requesting human agent",
}

Each intent maps to one handler. Overlapping intents ("billing_dispute" vs "refund_request") cause routing inconsistency — merge or clarify boundaries.

Embedding-based router implementation

from semantic_router import Route, RouteLayer

routes = [
    Route(name="refund_request", utterances=[
        "I want my money back", "Can I get a refund?", "Return this item",
    ]),
    Route(name="order_status", utterances=[
        "Where is my order?", "Track my package", "When will it arrive?",
    ]),
    # 20-50 utterances per intent for good coverage
]

router = RouteLayer(encoder=EmbeddingEncoder(), routes=routes)

async def route_message(message: str) -> RouteResult:
    result = router(message)
    if result.confidence < 0.75:
        return RouteResult(intent="out_of_scope", confidence=result.confidence)
    return RouteResult(intent=result.name, confidence=result.confidence)

20–50 example utterances per intent. Below 10 examples, routing accuracy degrades significantly.

Fallback and escalation routing

ROUTING_POLICY = {
    (0.85, 1.0):  "route_directly",      # high confidence
    (0.65, 0.85): "route_with_confirm",  # "Did you mean refund request?"
    (0.0,  0.65): "clarify_or_escalate", # ask clarifying question or human
}

async def handle_message(message: str) -> Response:
    route = await route_message(message)
    policy = get_policy(route.confidence)

    if policy == "route_directly":
        return await handlers[route.intent](message)
    if policy == "route_with_confirm":
        return ConfirmIntent(route.intent, message)
    return ClarifyIntent(message)

Never force-classify low-confidence messages into nearest intent — out_of_scope or clarify is better than wrong handler.

Failure modes

Production checklist

Resources

Frequently asked questions

Embedding router vs LLM classifier — which is better?

Embedding routers are 10–50x cheaper and faster — good for 5–20 well-separated intents with example utterances. LLM classifiers handle ambiguous, overlapping, or long-tail intents better but cost more. Production systems often cascade: embedding router first, LLM fallback when confidence is low.

How many intents can a semantic router handle?

Embedding routers stay accurate to roughly 30–50 intents with good example coverage. Beyond that, hierarchical routing (domain → sub-intent) or LLM classification works better. More than 100 flat intents usually means you need to redesign your handler map, not buy a bigger model.

What confidence threshold should I use?

Calibrate on labeled data — don't guess 0.8. Plot precision-recall per intent at various thresholds. High-stakes intents (billing, account deletion) should require high confidence or explicit confirmation; low-stakes (FAQ) can tolerate lower thresholds with graceful 'I'm not sure' fallbacks.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →