The corpus router answers which corpora a query should touch, then drives
retrieval across them. It sits one level above the Step 2.10
RetrievalRouter (which picks backends for one corpus).
A multi-tenant tenant can own many corpora (Legal, Support KB, Engineering
docs…). CorpusRouter selects a relevant subset per query — via an explicit
caller pin, config-driven static rules, a dependency-free learned classifier, or
a fall-through to all visible corpora — and then either fans out (one retrieval
per corpus, RRF-fused) or runs a single corpus-scoped retrieval. The selection is
returned as a frozen CorpusRoutingDecision, surfaced on the gateway
query/retrieve responses, an OTel span, and an audit event.
Public API lives in rag_retrieval:
from rag_retrieval import (
CorpusRouter,
CorpusRouterConfig,
CorpusRule,
StaticRuleClassifier,
LearnedCorpusClassifier,
)
from rag_retrieval.corpus_router import CorpusClassifier # ProtocolThe wire types are in rag_core:
from rag_core.types import (
CorpusRoutingStrategy, # EXPLICIT / STATIC_RULES / LEARNED / ALL / UNCONSTRAINED
CorpusRoutingDecision, # frozen selection output
CorpusScore, # per-corpus score + reason
)router = CorpusRouter(
corpus_store=corpus_store, # CorpusStore SPI — the visibility source
retrieval_router=retrieval_router, # Step 2.10 RetrievalRouter
static_classifier=StaticRuleClassifier([
CorpusRule(corpus_ids=("legal",), match_any=("contract", "gdpr"), name="legal-kw"),
]),
learned_classifier=LearnedCorpusClassifier(temperature=1.0),
config=CorpusRouterConfig(fan_out=True, max_corpora=3, score_threshold=0.0),
audit_writer=audit_writer, # optional — enables corpus.route audit events
)In production you rarely build this by hand — rag_gateway.wiring
(build_corpus_router_from_config) constructs it from rag.yaml. See
guides/corpus-routing.md.
decision = await router.decide(ctx, text="how do I reset my password")
decision.strategy # CorpusRoutingStrategy.STATIC_RULES
decision.selected_corpora # (CorpusId('support'),)
decision.fan_out # False (single corpus selected)
decision.candidate_n # number of visible corpora considered
for s in decision.scores: # CorpusScore(corpus_id, score, selected, reason)
...decide is pure selection — no retrieval backends run. An explicit
corpus_ids= that names only invisible corpora raises CorpusRouterError.
corpus_decision, routing_decision, refs = await router.route(
ctx,
text="review this contract",
top_k=10,
corpus_ids=None, # or pin: ["legal"] → EXPLICIT
)Returns the CorpusRoutingDecision (corpus selection), the backend-level
RoutingDecision from the underlying RetrievalRouter, and the fused
list[ChunkRef]. top_k <= 0 raises ValueError.
| Field | Type | Meaning |
|---|---|---|
strategy |
CorpusRoutingStrategy |
The effective strategy that drove this decision |
selected_corpora |
tuple[CorpusId, ...] |
Corpora that will be queried |
scores |
tuple[CorpusScore, ...] |
Per-corpus score + selected + advisory reason |
fan_out |
bool |
Federated (one retrieval per corpus, RRF-fused) vs single-pass |
candidate_n |
int |
Visible corpora considered before selection |
reason |
str |
Advisory summary ("explicit", "static_rules", "no_visible_corpora", …) |
CorpusScore.reason is advisory only (rule name, "learned", "explicit",
"below_threshold", "excluded:<rule>") — never parse it in production.
| Strategy | When it fires |
|---|---|
EXPLICIT |
Caller passed corpus_ids; intersected with visibility, no scoring |
STATIC_RULES |
A static rule boosted or excluded a corpus |
LEARNED |
No rule fired but the learned classifier differentiated ≥2 corpora |
ALL |
Nothing differentiated the visible corpora — query every one |
UNCONSTRAINED |
Tenant has no visible corpora — single unconstrained retrieval (runtime-only; not a config choice) |
CorpusRouterConfig (the runtime knobs):
| Field | Default | Meaning |
|---|---|---|
min_corpora |
1 |
Floor on selected set; always wins over score_threshold |
max_corpora |
0 |
Cap on selected set (0 = unbounded) |
score_threshold |
0.0 |
Drop corpora scoring below this (subject to min_corpora) |
fan_out |
True |
Federated execution (per-corpus retrieval, RRF-fused) vs single-pass |
rrf_k |
60 |
RRF constant for fan-out fusion |
Validated at construction: min_corpora >= 1, max_corpora >= 0,
max_corpora >= min_corpora (when set), rrf_k > 0.
Operators configure routing under retrieval.routing (CorpusRoutingConfig in
rag-config) and register corpora under corpora (CorpusDefn):
backends:
corpus_store:
provider: in_memory # none | in_memory | postgres
retrieval:
routing:
strategy: static_rules # explicit | static_rules | learned | all
fan_out: true
max_corpora: 3
score_threshold: 0.0
learned_temperature: 1.0
rules:
- name: legal-keywords
corpus_ids: [legal]
match_any: [contract, liability, gdpr]
corpora:
- id: legal
tenant_id: acme
display_name: Legal Documents
term_weights: {contract: 2.0, gdpr: 1.8} # learned-classifier profileThe config strategy decides which classifiers are wired; the router still
auto-detects the effective strategy per query (configured vs effective).
unconstrained is intentionally absent from the config enum — it is a runtime
fallback only.
Deterministic, config-driven. Each CorpusRule fires when the query matches
match_any (case-insensitive token / substring) or match_regex; an empty match
condition fires unconditionally (always-on default boost). A fired rule either
boosts (exclude=False, additive) or removes (exclude=True) its corpus_ids.
Exclude wins over boost for the same corpus.
Dependency-free per-corpus term-weight scorer. Each corpus carries a profile
(metadata["term_weights"], or an explicit profiles= map); the query bag-of-words
is scored as a dot product against each profile, normalised with a
temperature-scaled softmax. A uniform softmax (no profiles) reads as "no signal"
and the router falls through. temperature must be > 0.
Any object implementing the runtime-checkable Protocol can be plugged in as the learned classifier:
class CorpusClassifier(Protocol):
def score(self, *, text: str, corpora: Sequence[Corpus]) -> Mapping[CorpusId, float]: ...Higher = more relevant; scores combine additively with rule boosts. To exclude a corpus, omit it or return a non-positive score — hard exclusion is the static-rule layer's job.
Config-driven smoke commands (no DB needed with in_memory):
ragctl corpus list -f rag.yaml # show provider, tenant, seeded corpora
ragctl corpus seed -f rag.yaml # upsert corpora (no-op for in_memory)
ragctl corpus route "reset my password" -f rag.yaml # print the routing decision
ragctl corpus route "anything" -f rag.yaml -c legal # pin → EXPLICITcorpus route prints configured vs effective strategy, fan_out, candidate
count, selected corpora, per-corpus scores + reasons. See the full
ragctl reference.
- Selection precedence (highest first): explicit pin → static rules → learned
classifier → all visible. Effective strategy is auto-detected: a fired rule
beats a learned signal, which beats
all. - Fan-out fusion reuses
rrf_fuse(Step 2.5), weighting each corpus's ranking by its relevance score (floored at a small positive value so a zero score does not erase a selected corpus). - Observability:
routeopens acorpus.routeOTel span (rag.corpus_route.{strategy,candidate_n,selected_n,fan_out,selected,results_n,elapsed_ms}), emits acorpus.route_decisionstructured-log event, and appends acorpus.routeaudit event when anAuditWriteris wired.
- Custom scorer — implement
CorpusClassifierand pass aslearned_classifier. - Custom store — implement the
CorpusStoreSPI;provider: postgresships aPgCorpusStore(see backends reference). - Single-pass vs fan-out —
fan_out: falsefor homogeneous corpora with directly comparable scores.
- architecture/corpus-router.md — design rationale + reviewer checklist
- guides/corpus-routing.md —
rag.yaml+ragctlwalkthrough - ADR-0014 — the decision record
- reference/router.md — the Step 2.10 backend router below this layer