Pipeline
On this page
Orchestrates the full routing decision: embed → select group → score routes → evaluate confidence → return ranked candidates + a trace. Also provides the EmbeddingProvider wrapper and the ConfidenceEvaluator.
ConfidenceEvaluator
class ConfidenceEvaluator:
def __init__(self, cfg: ScoringConfig): ...
Translates a raw numeric combined score into a ConfidenceLevel and decides what action the pipeline should take.
Constructor
Reads high_confidence_threshold, medium_confidence_threshold, and fallback_confidence_threshold from the given ScoringConfig and stores them.
Methods
evaluate(self, score: float) -> ConfidenceLevel
score >= high_threshold → ConfidenceLevel.HIGH
score >= medium_threshold → ConfidenceLevel.MEDIUM
score >= fallback_threshold → ConfidenceLevel.LOW
otherwise → ConfidenceLevel.NONE
Thresholds are checked in descending order — the first satisfied condition wins.
should_use_llm(self, score: float) -> bool
Returns score < self._fallback (i.e. fallback_confidence_threshold). Used by HybridScorer to decide whether the (optional) LLM scorer is worth invoking.
should_route(self, level: ConfidenceLevel) -> bool
Returns True for HIGH, MEDIUM, or LOW; False only for NONE. This is the single gate HierarchicalRouter.route_query/route_async use to decide between executing a route and calling _handle_no_match.
Examples:
from zerda.router.config import ScoringConfig
from zerda.router.routing.pipeline import ConfidenceEvaluator
evaluator = ConfidenceEvaluator(ScoringConfig())
evaluator.evaluate(0.90) # ConfidenceLevel.HIGH
evaluator.evaluate(0.50) # ConfidenceLevel.MEDIUM
evaluator.evaluate(0.35) # ConfidenceLevel.NONE (below default 0.40 fallback threshold)
evaluator.should_route(ConfidenceLevel.NONE) # False
Related APIs: ScoringConfig, RoutingPipeline
EmbeddingProvider
class EmbeddingProvider:
def __init__(self, model_name: str, batch_size: int = 32): ...
Thin wrapper around a sentence_transformers.SentenceTransformer model, adding an in-memory embedding cache keyed by MD5 hash of the input text.
Constructor
Parameters:
Raises: ImportError — if sentence_transformers cannot be imported (message: "sentence-transformers is required. Install with: pip install sentence-transformers"), chained from the original ImportError via raise ... from exc.
Side effects: Loads the sentence-transformer model into memory immediately (constructor-time, not lazy) — this can be a slow, potentially network-dependent operation (model download on first use) and a significant memory allocation.
Methods
encode(self, text: str, use_cache: bool = True) -> List[float]
Encodes a single string into an embedding vector.
Algorithm: Compute hashlib.md5(text.encode()).hexdigest() as the cache key. If use_cache and the key is cached, return the cached vector. Otherwise call self._model.encode(text, convert_to_numpy=True, normalize_embeddings=True).tolist(), cache it (if use_cache), and return it.
Complexity: O(1) on cache hit; O(model forward pass) on cache miss.
Side effects: Mutates self._cache on a cache miss (if use_cache=True).
Thread safety: The internal self._cache dict is a plain Python dict with no locking — concurrent calls from multiple threads with overlapping cache misses on the same key are not guaranteed safe (a classic read-check-write race), though CPython's GIL makes outright corruption unlikely for simple dict assignment; treat as not explicitly thread-safe.
encode_batch(self, texts: List[str], use_cache: bool = True) -> List[List[float]]
Encodes multiple texts efficiently, checking the cache for each and only sending genuinely uncached texts to the model in one batched encode(..., batch_size=self._batch_size) call.
Algorithm: Compute an MD5 key per text. Partition into already-cached (filled in directly) vs. uncached (collected for a single batched model call). Batch-encode only the uncached texts, then scatter results back into the correct output positions and populate the cache.
Complexity: O(1) per cache hit + O(model forward pass over uncached texts) for misses, in one batched call rather than N individual calls — this is the primary reason to prefer encode_batch over looping encode.
encode_route(self, route: Route)
If route.examples is non-empty, sets route.embeddings = self.encode_batch(route.examples). No-op (leaves route.embeddings unchanged) if route.examples is empty.
encode_group(self, group: RouteGroup)
Same pattern for group.intent_examples → group.embeddings.
clear_cache(self)
Empties self._cache entirely (self._cache.clear()). There is no partial/TTL-based eviction — this is a full flush.
cache_size (property)
Returns len(self._cache).
Examples
1. Basic:
from zerda.router.routing.pipeline import EmbeddingProvider
embedder = EmbeddingProvider("sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2")
vec = embedder.encode("hello world")
2. Intermediate — batch encoding for a route's examples:
from zerda.router import Route
route = Route(name="faq", description="...", handler=faq_handler, examples=["q1", "q2", "q3"])
embedder.encode_route(route)
print(len(route.embeddings)) # 3
3. Advanced — cache inspection:
embedder.encode("repeated query")
embedder.encode("repeated query") # served from cache
print(embedder.cache_size) # 1, not 2
4. Error handling — missing dependency:
try:
embedder = EmbeddingProvider("some-model")
except ImportError as e:
print(e) # "sentence-transformers is required. Install with: pip install sentence-transformers"
5. Production — memory management via explicit cache clearing:
# If your app encodes a huge number of unique one-off queries and memory
# growth matters, periodically clear the unbounded embedding cache:
if embedder.cache_size > 50_000:
embedder.clear_cache()
Best practices
- Prefer
encode_batchover callingencodein a loop whenever you have several texts up front (e.g. a route's initial example list) — it batches the actual model call. - Be aware the embedding cache is unbounded and never expires on its own — for long-running processes with high query cardinality, monitor
cache_sizeand callclear_cache()if memory becomes a concern.
Related APIs: Route.embeddings, RouteGroup.embeddings
RoutingPipeline
class RoutingPipeline:
def __init__(self, cfg: RouterConfig, embedder: EmbeddingProvider, scorer: HybridScorer, confidence: ConfidenceEvaluator): ...
Two-level (group → route) routing pipeline. Stateless with respect to routes — each call to run() receives a fresh snapshot of active groups; all persistent state (feedback, cache) lives in components owned by HierarchicalRouter.
Methods
run(self, query: str, groups: List[RouteGroup], request_id: str, embedding_cache: Optional[Dict[str, List[float]]] = None) -> Tuple[List[RouteCandidate], RoutingTrace]
Executes the full pipeline for one query.
Parameters:
Returns: (candidates, trace) — candidates is a list of RouteCandidate, best-first, truncated to top_k; trace is a fully populated RoutingTrace.
Algorithm (internal workflow):
- Create the trace.
- Filter
groupstoactive_groups = [g for g in groups if g.enabled]. If empty, note it and return([], trace)immediately. - Embed the query once via
self._embedder.encode(query), timing it intotrace.embedding_time_ms. - Call
self._select_groups(...)(see below) to narrow down to the best group(s). If none selected, note it and return([], trace). - Collect
candidate_routes= allenabled_only=Trueroutes from every selected group. - If no candidate routes, note it and return
([], trace). - Score all candidates in one call:
self._scorer.score_all(query, candidate_routes, q_emb), timing intotrace.scoring_time_ms. - Take the top
self._cfg.scoring.top_kscored tuples, evaluate confidence per candidate, and buildRouteCandidateobjects. - Populate
trace.candidates, and if there's a best candidate, stamptrace.selected_route/selected_group/confidence; if that candidate doesn't clearshould_route, add an explanatory note citing the score and threshold. - Return
(candidates, trace).
Complexity: One embedding call (O(model inference) for the query) + one batched scoring call over all candidate routes in the selected group(s) — group and route selection are each single-pass linear scans, not iterative/recursive.
Thread safety: The pipeline object itself holds no per-call mutable state (each call constructs its own local trace), but the injected embedder and scorer have their own thread-safety characteristics (see EmbeddingProvider above).
_select_groups(self, query, query_emb, groups, trace) -> List[RouteGroup] (private, documented for clarity)
Algorithm:
- If exactly one group is active, return it directly — no scoring needed (documented in source as avoiding "double-encoding").
- Otherwise, build a synthetic
Routeproxy per group:name=group.name,description=group.description, a no-opCallableHandler,examples=group.intent_examples,keywords=group.intent_keywords,priority=group.priority, and — critically —proxy.embeddings = group.embeddings(pre-computed group intent embeddings are reused directly rather than re-encoded). - Score all proxies via the same
HybridScorer.score_allused for routes. - Take the single top-scoring proxy (
scored[0]) — the implementation currently only ever selects one group, despite the docstring calling this "top group(s)" and leaving a comment noting it can be "extended for multi-group queries." - Map the winning proxy's name back to the real
RouteGroupobject and return it as a single-element list (or[]if the mapping somehow fails). - Add a trace note recording which group was selected, its score, and its confidence level.
[!NOTE] Group selection is currently single-winner only Even though multiple groups may score close to the top group,
_select_groupsreturns at most one group. If you need routes from multiple domains to be compared together, put them in the sameRouteGrouprather than relying on multi-group selection, since only the single best-scoring group's routes reach step 5 ofrun().
Examples
1. Basic — direct pipeline invocation (advanced/testing use):
from zerda.router.routing.pipeline import RoutingPipeline, EmbeddingProvider, ConfidenceEvaluator
from zerda.router.routing.scorers import HybridScorer, SemanticScorer, KeywordScorer
from zerda.router import RouterConfig
cfg = RouterConfig()
embedder = EmbeddingProvider(cfg.embedding.model_name)
scorer = HybridScorer(cfg.scoring, SemanticScorer(cfg.scoring), KeywordScorer(), None)
confidence = ConfidenceEvaluator(cfg.scoring)
pipeline = RoutingPipeline(cfg, embedder, scorer, confidence)
candidates, trace = pipeline.run("What's the weather?", groups=[tools_group], request_id="req-1")
2. Inspecting why a group was chosen:
print(trace.notes) # e.g. ["Group selected: 'tools' (score=0.734, confidence=medium)"]
Related APIs: HybridScorer, RouteCandidate, RoutingTrace, HierarchicalRouter