RFC-092: ML Query Router¶
- Status: Draft
- v2 cross-reference (RFC-097, 2026-06-20): orthogonal to RFC-097 v2; rules-router design unchanged. Intent taxonomy may extend post-v2 if new node types (Person/Org/Podcast) enable new intent classes (e.g., position-tracking vs. semantic search). See RFC-097.
- Authors: Marko
- Stakeholders: Core team
- Related PRDs:
docs/prd/PRD-032-hybrid-corpus-search.md— hybrid corpus searchdocs/prd/PRD-031-search.md— Search product- Related RFCs:
docs/rfc/RFC-090-hybrid-retrieval.md— ships the rules-based router this replacesdocs/rfc/RFC-057-autoresearch-optimization-loop.md— eval loop that produces labeled queries- Related Documents:
config/search.yaml— router mode switch
Stabilization note (2026-05-30): Split out of an earlier combined draft (RFC-079). This is the ML-router concern only. Of the three RFC-079 splits, this one has its prerequisites met — it needs labeled query data (from the eval loop), not new architecture — so it is the most readily shippable once data exists. It supersedes the rules-based router in RFC-090 §3.6 via a config switch; the rules router remains the default until the ML model is trained.
Abstract¶
Upgrade the RFC-090 rules-based query router to an ML classifier. Input is the query text; output
is one of {entity_lookup, raw_evidence, temporal_tracking, cross_show_synthesis, semantic}, which
selects signal and tier weights for retrieval. The model is a small fine-tuned sentence-transformer
classification head exported to ONNX for local inference. Activation is config-driven; the rules
router stays the default until the model is trained on ≥500 labeled queries.
As built (§4): superseded — shipped as a scikit-learn
LogisticRegressionover MiniLM embeddings persisted with joblib, not ONNX (noonnxruntime/skl2onnxdependency). The ONNX references throughout this RFC are the original design; §4 records the deviation and rationale.
Architecture Alignment: Implements the existing QueryRouter interface
(src/podcast_scraper/search/router.py) so RulesQueryRouter and MLQueryRouter are
interchangeable behind a config flag. No retrieval-layer change. ONNX local inference keeps the
no-cloud-dependency constraint (runs on Apple M-series via ONNX Runtime).
Problem Statement¶
The RFC-090 rules router covers obvious cases (capitalised names → entity lookup, "compare" → synthesis) but has systematic blind spots: ambiguous queries, novel phrasings, and domain-specific language misclassify. Misclassification degrades to sub-optimal signal/tier weights (RRF is robust, so not wrong results — but worse ranking). An ML classifier trained on real query data is more robust and improves continuously as query patterns emerge from the eval loop.
Use Cases:
- Ambiguous phrasing: "what did the field think about scaling laws over the last year" should
route to
temporal_trackingeven without an explicit date token. - Domain language: corpus-specific terminology routes correctly without hand-written rules.
- Continuous improvement: misrouted queries corrected in the eval loop become training data.
Goals¶
- Drop-in ML classifier behind the existing
QueryRouterinterface. - Local inference via ONNX Runtime — no cloud dependency, cheap redeploy (replace file).
- Config-switchable (
router.mode: rules | ml) with the rules router as default. - Trained on real data — silver labels from the rules router + human corrections.
Constraints & Assumptions¶
Constraints:
- Local inference only (ONNX Runtime on M-series MPS/CPU); no inference-time cloud calls.
- Label space is fixed at 5 classes; input is short text.
Assumptions:
- The eval loop (RFC-057) and viewer Search usage can produce ≥500 labeled queries before training is worthwhile.
- A ~100M-parameter encoder with a classification head is sufficient for 5-class short-text routing.
Design & Implementation¶
1. Classifier Design¶
- Input: query text string.
- Output: one of
{entity_lookup, raw_evidence, temporal_tracking, cross_show_synthesis, semantic}. - Model: fine-tuned sentence-transformer classification head; ONNX export for local inference.
- Training data: query logs from the eval loop + viewer Search usage, labeled by (1) automatic silver labels from the rules router, (2) human correction of misclassified queries.
2. Interface¶
# src/podcast_scraper/search/router.py
class QueryRouter:
def classify(self, text: str) -> str: ...
def signal_weights(self, query_type: str) -> dict: ...
def tier_weights(self, query_type: str) -> dict: ...
class RulesQueryRouter(QueryRouter):
"""RFC-090 rules-based implementation. Default until the ML router is trained."""
def classify(self, text: str) -> str:
... # existing keyword/regex logic from RFC-090 §3.6
class MLQueryRouter(QueryRouter):
"""ONNX-exported classifier. Activated via config once trained."""
def __init__(self, model_path: str):
import onnxruntime as ort
self.session = ort.InferenceSession(model_path)
self.tokenizer = ... # matching tokenizer
def classify(self, text: str) -> str:
inputs = self.tokenizer(text, return_tensors="np")
logits = self.session.run(None, dict(inputs))[0]
return QUERY_TYPES[logits.argmax()]
3. Config-Driven Activation¶
# config/search.yaml
router:
mode: rules # "rules" | "ml"
model_path: ./data/query_router.joblib # only used if mode: ml
4. Implementation status (#860)¶
Built in search/query_router.py (QueryRouter protocol, RulesQueryRouter, MLQueryRouter),
wired into RetrievalLayer via an optional router= arg, switched in config/search.yaml.
scripts/train_query_router.py bootstraps the labeled set and trains the model. Two deliberate
deviations from this RFC's sketch:
- sklearn/joblib, not ONNX. A
LogisticRegressionover MiniLM embeddings, persisted with joblib.onnxruntime/skl2onnxare not in the stack and adding them needs approval; sklearn + joblib already are, and still satisfy the local-first / no-cloud constraint. ONNX export can be revisited if a redeploy-as-file-swap workflow demands it. - Template-defined labels, not rules-silver labels. Goal §4's "silver labels from the rules
router" is circular — it can only teach the model to copy the rules. Instead each query is
generated from an intent template (the label is ground truth by construction) with slots filled
from real corpus entities/topics (FAISS
kg_entity/kg_topicsurface text), so the lexical distribution is realistic while the labels are genuine.
Bootstrap result (750 queries, 1083 people × 1106 topics, 80/20 split): holdout accuracy 1.000;
on the same held-out queries the rules router scores 0.867 ground-truth — so ML adds +13.3 points,
recovering intents the bigram-name / keyword rules miss. The 1.0 is inflated by template
separability, so this is a bootstrap, not the final story: default stays mode: rules until
the model is validated on real (messy, human-judged) queries — the same query set that re-gates FAISS
removal in RFC-090 #858. The two unblock together.
Key Decisions¶
- ML router as ONNX, local inference.
- Decision: Export to ONNX; run locally.
- Rationale: No cloud dependency; M-series runs ONNX Runtime efficiently; redeploy is a file swap + restart.
- Rules router stays the default.
- Decision: Ship
mode: rulesuntil the model is trained and A/B-validated. - Rationale: No regression risk before data exists; switch is one config line.
Alternatives Considered¶
- LLM-based intent classification (API call per query).
- Pros: No training; flexible.
- Cons: Per-query latency/cost; cloud dependency; overkill for 5 classes.
- Why rejected: Violates the local-first constraint; a small local classifier suffices.
- Expand the rules router indefinitely.
- Pros: No model.
- Cons: Rule sprawl; brittle on novel phrasings; no continuous improvement.
- Why rejected: ML generalizes from real data the rules can't anticipate.
Testing Strategy¶
Test Coverage:
- Unit:
MLQueryRouter.classify()returns valid labels; config switch selects the right implementation; tokenizer/ONNX I/O shape. - Eval: ML router vs rules router on a held-out query set with human labels (not rules-router silver labels).
Test Organization: tests/unit/podcast_scraper/search/test_router.py;
src/podcast_scraper/eval/router_eval.py for the A/B harness.
Test Execution: Unit in ci-fast; router eval is an operator/CI job, not per-PR.
Rollout & Monitoring¶
Rollout Plan:
- Phase 1 — Data collection: instrument query logging in the eval loop and viewer Search; accumulate to ≥500 labeled queries.
- Phase 2 — Train + export: silver labels + human corrections; ONNX export; A/B eval.
- Phase 3 — Switch: flip
router.mode: mlonce the ML router beats rules on held-out human labels.
Monitoring: Track classification accuracy on the held-out set; track query-refinement rate (PRD-031 success metric) as a proxy for routing quality in production.
Success Criteria: ML router ≥ rules router on held-out human-labeled accuracy; query-refinement rate trends down after the switch.
Relationship to Other RFCs¶
Key Distinction:
- RFC-090: ships the rules router (baseline) and the weight tables the router selects.
- RFC-092 (this): replaces the router's
classify()with an ML model behind the same interface.
| RFC | Relationship |
|---|---|
| RFC-090 | Defines QueryRouter, SIGNAL_WEIGHTS, TIER_WEIGHTS_BY_QUERY; this RFC swaps classify() |
| RFC-057 | Autoresearch eval loop is the training-data source |
Benefits¶
- Robust routing on ambiguous/novel phrasings the rules miss.
- Continuous improvement as eval-loop corrections feed retraining.
- No cloud dependency — local ONNX inference; cheap redeploy.
Migration Path¶
- Phase 1: Add query logging; keep
mode: rules. - Phase 2: Train, export ONNX, validate A/B.
- Phase 3: Flip
mode: ml; retainrulesas instant rollback.
Open Questions¶
- OQ-1 Training-data quality. Silver labels from the rules router are noisy precisely on the queries the ML router is meant to fix. Do a human annotation pass on a stratified sample before training; eval on human labels, not rules-router labels.
- OQ-2 Class boundary drift. As query patterns evolve, the 5-class taxonomy may need a sixth
class (e.g. explicit
corpus_coverage). Version the label space.
References¶
- Related PRD:
docs/prd/PRD-032-hybrid-corpus-search.md - Related RFC:
docs/rfc/RFC-090-hybrid-retrieval.md,docs/rfc/RFC-057-autoresearch-optimization-loop.md - Source Code:
src/podcast_scraper/search/router.py(rules router from RFC-090) - Config:
config/search.yaml