Semantic Routing

The SemanticRouter component in EMOS allows you to route text queries to specific components based on the user’s intent or the output of a preceding component.

The router operates in two distinct modes:

  1. Vector Mode (Default): This mode uses a Vector DB to calculate the mathematical similarity (distance) between the incoming query and the samples defined in your routes. It is extremely fast and lightweight.

  2. LLM Mode (Agentic): This mode uses an LLM to intelligently analyze the intent of the query and triggers routes accordingly. This is more computationally expensive but can handle complex nuances, context, and negation (e.g., “Don’t go to the kitchen” might be routed differently by an agent than a simple vector similarity search).

In this recipe, we will route queries between two components: a General Purpose LLM (for chatting) and a Go-to-X Component (for navigation commands) that we built in the previous recipe. The Go-to-X component resolves a place name by calling the Memory component’s locate tool, so we set up Vision and Memory here too. Lets start by setting up our components.

Setting up the components

In the following code snippet we will set up the perception side (Vision + Memory, which builds the spatial map) and our two query components.

import re
from typing import Optional
import numpy as np

from agents.components import LLM, Memory, Vision
from agents.models import OllamaModel, VisionModel
from agents.vectordbs import ChromaDB
from agents.config import LLMConfig, MemoryConfig, VisionConfig
from agents.clients import ChromaClient, OllamaClient, RoboMLRESPClient
from agents.ros import Launcher, Topic, Route, MemLayer

# Reuse one (tool-capable) model for the generic LLM and the Go-to-X LLM
qwen = OllamaModel(name="qwen", checkpoint="qwen3.5:latest")
qwen_client = OllamaClient(qwen)

# Embeddings for Memory (the spatial map)
embedding_client = OllamaClient(
    OllamaModel(name="embeddings", checkpoint="nomic-embed-text-v2-moe:latest")
)

# Vector DB for the SemanticRouter — it stores the route samples
chroma = ChromaDB()
chroma_client = ChromaClient(db=chroma)


# -- Perception: vision + memory build the map --
image0 = Topic(name="image_raw", msg_type="Image")
detections_topic = Topic(name="detections", msg_type="Detections")
position = Topic(name="odom", msg_type="Odometry")

vision = Vision(
    inputs=[image0],
    outputs=[detections_topic],
    trigger=image0,
    config=VisionConfig(threshold=0.5),
    model_client=RoboMLRESPClient(
        VisionModel(name="rtdetr", checkpoint="PekingU/rtdetr_r50vd_coco_o365")
    ),
    component_name="vision",
)

memory = Memory(
    layers=[MemLayer(subscribes_to=detections_topic)],
    position=position,
    embedding_client=embedding_client,
    config=MemoryConfig(db_path="/tmp/go_to_x.db"),
    trigger=10.0,
    component_name="memory",
)


# Make a generic LLM component for general questions
llm_in = Topic(name="text_in_llm", msg_type="String")
llm_out = Topic(name="text_out_llm", msg_type="String")

llm = LLM(
    inputs=[llm_in],
    outputs=[llm_out],
    model_client=qwen_client,
    trigger=llm_in,
    component_name="generic_llm",
)

# Make a Go-to-X component — it looks places up via Memory's `locate` tool
goto_in = Topic(name="goto_in", msg_type="String")
goal_point = Topic(name="goal_point", msg_type="PoseStamped")

goto = LLM(
    inputs=[goto_in],
    outputs=[goal_point],
    model_client=qwen_client,
    trigger=goto_in,
    config=LLMConfig(),
    component_name="go_to_x",
)

goto.set_component_prompt(
    template=(
        "The user asks you to go to a place. Use the available tools to "
        "look up the place's location in memory. Pass the place name to "
        "the locate tool as the ``concept`` argument. User said: {{goto_in}}"
    )
)

# Register Memory's `locate` tool on the Go-to-X LLM so it can be called
memory.register_tools_on(goto, tools=["locate"], send_tool_response_to_model=False)


# pre-process the output before publishing to a topic of msg_type PoseStamped
_LOCATION_RE = re.compile(r"Location:\s*\(([^)]+)\)")


def locate_text_to_goal_point(output: str) -> Optional[np.ndarray]:
    """Pull the centroid coordinates out of Memory.locate's text output."""
    match = _LOCATION_RE.search(output)
    if not match:
        return
    try:
        coords = np.fromstring(match.group(1), sep=",", dtype=np.float64)
    except ValueError:
        return
    if coords.shape[0] == 2:
        coords = np.append(coords, 0.0)
    if coords.shape[0] != 3:
        return
    return coords


# add the pre-processing function to the goal_point output topic
goto.add_publisher_preprocessor(goal_point, locate_text_to_goal_point)

Note

We reused the same model and its client for both query components. The model must support tool calling, since the Go-to-X component calls Memory’s locate tool.

Note

For a detailed explanation of the Go-to-X component — how Memory builds the map and exposes the locate tool — check the previous recipe.

Important

The Go-to-X LLM calls Memory in-process, so the router, the LLMs, and Memory must launch in the same process (no multiprocessing=True on add_pkg).

Creating the SemanticRouter

The SemanticRouter takes an input String topic and sends whatever is published on that topic to a Route. A Route is a thin wrapper around Topic and takes in the name of a topic to publish on and example queries, that would match a potential query that should be published to a particular topic. For example, if we ask our robot a general question, like “Whats the capital of France?”, we do not want that question to be routed to a Go-to-X component, but to a generic LLM. Thus in its route, we would provide examples of general questions. Lets start by creating our routes for the input topics of the two components above.

from agents.ros import Route

# Create the input topic for the router
query_topic = Topic(name="question", msg_type="String")

# Define a route to a topic that processes go-to-x commands
goto_route = Route(routes_to=goto_in,
    samples=["Go to the door", "Go to the kitchen",
        "Get me a glass", "Fetch a ball", "Go to hallway"])

# Define a route to a topic that is input to an LLM component
llm_route = Route(routes_to=llm_in,
    samples=["What is the capital of France?", "Is there life on Mars?",
        "How many tablespoons in a cup?", "How are you today?", "Whats up?"])

Note

The routes_to parameter of a Route can be a Topic or an Action. Actions can be system level functions (e.g. to restart a component), functions exposed by components (e.g. to start the VLA component for manipulation, or the ‘say’ method in TextToSpeech component) or arbitrary functions written in the recipe. Actions are a powerful concept in EMOS, because their arguments can come from any topic in the system. To learn more, check out Events & Actions.

Option 1: Vector Mode (Similarity)

This is the standard approach. In Vector mode, the SemanticRouter component stores the route samples in a vector DB. Distance is calculated between an incoming query’s embedding and the embeddings of the example queries to determine which Route(Topic) the query should be sent on. We pass the chroma_client set up above as the db_client, and specify a router name in the config, which acts as a collection_name in the database.

from agents.components import SemanticRouter
from agents.config import SemanticRouterConfig

router_config = SemanticRouterConfig(router_name="go-to-router", distance_func="l2")
# Initialize the router component
router = SemanticRouter(
    inputs=[query_topic],
    routes=[llm_route, goto_route],
    default_route=llm_route,  # If none of the routes fall within a distance threshold
    config=router_config,
    db_client=chroma_client,  # Providing db_client enables Vector Mode
    component_name="router"
)

Option 2: LLM Mode (Agentic)

Alternatively, we can use an LLM to make routing decisions. This is useful if your routes require “understanding” rather than just similarity. We simply provide a model_client instead of a db_client (no ChromaDB needed in this mode).

Note

We can even use the same LLM (model_client) as we are using for our other Q&A components.

# No SemanticRouterConfig needed, we can use LLMConfig or let it be default
router = SemanticRouter(
    inputs=[query_topic],
    routes=[llm_route, goto_route],
    model_client=qwen_client, # Providing model_client enables LLM Mode
    component_name="smart_router"
)

And that is it. Whenever something is published on the input topic question, it will be routed, either to a Go-to-X component or an LLM component. We can now expose this topic to our command interface. The complete code for setting up the router is given below:

Semantic Routing
  1import re
  2from typing import Optional
  3import numpy as np
  4
  5from agents.components import LLM, Memory, SemanticRouter, Vision
  6from agents.models import OllamaModel, VisionModel
  7from agents.vectordbs import ChromaDB
  8from agents.config import LLMConfig, MemoryConfig, SemanticRouterConfig, VisionConfig
  9from agents.clients import ChromaClient, OllamaClient, RoboMLRESPClient
 10from agents.ros import Launcher, Topic, Route, MemLayer
 11
 12# Reuse one (tool-capable) model for the generic LLM and the Go-to-X LLM
 13qwen = OllamaModel(name="qwen", checkpoint="qwen3.5:latest")
 14qwen_client = OllamaClient(qwen)
 15
 16# Embeddings for Memory (the spatial map)
 17embedding_client = OllamaClient(
 18    OllamaModel(name="embeddings", checkpoint="nomic-embed-text-v2-moe:latest")
 19)
 20
 21# Vector DB for the SemanticRouter — it stores the route samples
 22chroma = ChromaDB()
 23chroma_client = ChromaClient(db=chroma)
 24
 25
 26# -- Perception: vision + memory build the map --
 27image0 = Topic(name="image_raw", msg_type="Image")
 28detections_topic = Topic(name="detections", msg_type="Detections")
 29position = Topic(name="odom", msg_type="Odometry")
 30
 31vision = Vision(
 32    inputs=[image0],
 33    outputs=[detections_topic],
 34    trigger=image0,
 35    config=VisionConfig(threshold=0.5),
 36    model_client=RoboMLRESPClient(
 37        VisionModel(name="rtdetr", checkpoint="PekingU/rtdetr_r50vd_coco_o365")
 38    ),
 39    component_name="vision",
 40)
 41
 42memory = Memory(
 43    layers=[MemLayer(subscribes_to=detections_topic)],
 44    position=position,
 45    embedding_client=embedding_client,
 46    config=MemoryConfig(db_path="/tmp/go_to_x.db"),
 47    trigger=10.0,
 48    component_name="memory",
 49)
 50
 51
 52# Make a generic LLM component for general questions
 53llm_in = Topic(name="text_in_llm", msg_type="String")
 54llm_out = Topic(name="text_out_llm", msg_type="String")
 55
 56llm = LLM(
 57    inputs=[llm_in],
 58    outputs=[llm_out],
 59    model_client=qwen_client,
 60    trigger=llm_in,
 61    component_name="generic_llm",
 62)
 63
 64
 65# Make a Go-to-X component — it looks places up via Memory's `locate` tool
 66goto_in = Topic(name="goto_in", msg_type="String")
 67goal_point = Topic(name="goal_point", msg_type="PoseStamped")
 68
 69goto = LLM(
 70    inputs=[goto_in],
 71    outputs=[goal_point],
 72    model_client=qwen_client,
 73    trigger=goto_in,
 74    config=LLMConfig(),
 75    component_name="go_to_x",
 76)
 77
 78goto.set_component_prompt(
 79    template=(
 80        "The user asks you to go to a place. Use the available tools to "
 81        "look up the place's location in memory. Pass the place name to "
 82        "the locate tool as the ``concept`` argument. User said: {{goto_in}}"
 83    )
 84)
 85
 86# Register Memory's `locate` tool on the Go-to-X LLM so it can be called
 87memory.register_tools_on(goto, tools=["locate"], send_tool_response_to_model=False)
 88
 89
 90# pre-process the output before publishing to a topic of msg_type PoseStamped
 91_LOCATION_RE = re.compile(r"Location:\s*\(([^)]+)\)")
 92
 93
 94def locate_text_to_goal_point(output: str) -> Optional[np.ndarray]:
 95    """Pull the centroid coordinates out of Memory.locate's text output."""
 96    match = _LOCATION_RE.search(output)
 97    if not match:
 98        return
 99    try:
100        coords = np.fromstring(match.group(1), sep=",", dtype=np.float64)
101    except ValueError:
102        return
103    if coords.shape[0] == 2:
104        coords = np.append(coords, 0.0)
105    if coords.shape[0] != 3:
106        return
107    return coords
108
109
110# add the pre-processing function to the goal_point output topic
111goto.add_publisher_preprocessor(goal_point, locate_text_to_goal_point)
112
113# Create the input topic for the router
114query_topic = Topic(name="question", msg_type="String")
115
116# Define a route to a topic that processes go-to-x commands
117goto_route = Route(
118    routes_to=goto_in,
119    samples=[
120        "Go to the door",
121        "Go to the kitchen",
122        "Get me a glass",
123        "Fetch a ball",
124        "Go to hallway",
125    ],
126)
127
128# Define a route to a topic that is input to an LLM component
129llm_route = Route(
130    routes_to=llm_in,
131    samples=[
132        "What is the capital of France?",
133        "Is there life on Mars?",
134        "How many tablespoons in a cup?",
135        "How are you today?",
136        "Whats up?",
137    ],
138)
139
140# --- MODE 1: VECTOR ROUTING (Active) ---
141router_config = SemanticRouterConfig(router_name="go-to-router", distance_func="l2")
142
143router = SemanticRouter(
144    inputs=[query_topic],
145    routes=[llm_route, goto_route],
146    default_route=llm_route,
147    config=router_config,
148    db_client=chroma_client, # Vector mode requires db_client
149    component_name="router",
150)
151
152# --- MODE 2: LLM ROUTING (Commented Out) ---
153# To use LLM routing (Agentic), comment out the block above and uncomment this:
154#
155# router = SemanticRouter(
156#     inputs=[query_topic],
157#     routes=[llm_route, goto_route],
158#     default_route=llm_route,
159#     model_client=qwen_client, # LLM mode requires model_client
160#     component_name="router",
161# )
162
163# Launch the components — single process so the Go-to-X LLM can call Memory in-process
164launcher = Launcher()
165launcher.add_pkg(components=[vision, memory, llm, goto, router])
166launcher.bringup()

Tip

Promote this recipe to production. While you’re shaping it, the script runs straight with python recipe.py. Once it’s solid, drop it at ~/emos/recipes/<your_name>/recipe.py and run emos run <your_name> – you’ll get sensor pre-flight checks, persistent logs, and a card on the dashboard so an operator can launch it from a browser. See Running Recipes for the full development-vs-production comparison and install-mode pitfalls (especially in Container mode).