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:
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.
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:
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).