Skip to content
adaptive retrieval router

Route each query to the cheapest backend that can answer it.

A retrieval router that classifies every query and dispatches it to sparse BM25, dense FAISS, an RRF-fused hybrid, or live web search — instead of paying always-dense or always-hybrid cost on every request. Every decision is logged, benchmarked against public IR datasets, and tunable to your corpus.

4.3× faster than always-hybrid on NFCorpus
80% fewer embedding calls, balanced profile
0.743 SciFact nDCG@10 — beats every fixed backend
GET /router/explain
# classify a query without spending a token
$ curl -s 'https://arr.dev/router/explain?query=BM25+rank+fusion'

{
  "query": "BM25 rank fusion",
  "route": "sparse",
  "confidence": 0.6,
  "profile": "balanced",
  "matched_rule": {
    "id": 3, "name": "short_keyword",
    "condition": "<= 3 tokens, no stopwords removed"
  },
  "tokens": ["bm25", "rank", "fusion"],
  "latency_ms": 0.4,
  "cost_usd": 0.0
} 

This query would have paid a dense embedding call under always-hybrid. Here it costs $0.00 and 0.4 ms.

One retrieval strategy can't win every query.

Each backend is strong somewhere and wasteful somewhere else. Pick one for the whole corpus and you either overpay or under-answer. The evidence, backend by backend:

dense Expensive, and surprisingly weak on named-entity queries. Semantic embeddings blur exact terms. 50–200 ms · 1 embed/query
sparse Free and instant, but misses paraphrases. No lexical overlap, no match. 1–5 ms · $0/query
hybrid Works — but always pays the dense cost. RRF fusion runs the embedding on every single query, even the trivially lexical ones. 50–200 ms · dense cost
web Real-time queries are unanswerable from any static corpus. "Latest", "today", this year — none of it is in your index. 200–600 ms · $0.008 flat

So route per query. Classify first, then dispatch to the cheapest backend likely to answer well — and log every decision so the routing itself is auditable.

Five rules, first match wins, every fired rule logged.

The default router is a heuristic classifier — deliberately simple and fully inspectable. Watch a query flow through it, or read the rule table it runs.

router · heuristic mode ▶ auto
query BM25Okapi default… 3 tokens router rule 3 web Tavily · 200–600ms · $0.008 dense FAISS · 50–200ms · 1 embed hybrid dense+sparse RRF · dense cost sparse BM25 · 1–5ms · $0
routesparse
rule#3 short
conf0.60
est. cost$0.00
router.rules — evaluated top to bottom
#conditionrouteconf
1Freshness keyword (latest / current / today / this week / right now) or year ≥ currentweb0.9
2keyword_overlap_ratio > θ and short token countsparse0.7
3≤ 3 tokens, no stopwords removedsparse0.6
4> 12 tokens, or contains how / why / what / explaindense0.7
5otherwisehybrid0.5

Tokenization is deliberately simple: lowercase → split on non-alphanumeric, minus a hand-curated 30-word stopword set. Nothing learned, nothing opaque — every decision is reproducible from the query string alone, at GET /router/explain, without spending a token.

The backends it routes between.

Same interface, four cost/latency profiles. The router's whole job is to spend the expensive ones only when they'll earn it.

retrieval backends
backendenginelatencycost / querybest for
sparse rank_bm25 · BM25Okapi 1–5 ms $0 keyword, named-entity, exact term
dense FAISS IndexFlatIP + text-embedding-3-small 50–200 ms ~$0.0000001 semantic, paraphrase
hybrid dense + sparse, RRF (k=60), run in parallel 50–200 ms dense cost only balanced default
web Tavily basic search 200–600 ms $0.008 flat real-time / out-of-corpus

Measured on real graded relevance — no LLM judge in the loop.

Two public BEIR datasets with human qrels. The question isn't "is adaptive good" — it's exactly how much quality a per-query decision buys back the latency and embedding cost it saves.

Cost vs. quality — NFCorpus

323 queries · 3,633 docs · lower-left is cheaper & faster, upper is more relevant.

0.30 0.32 0.34 0.36 0.38 0 50 100 150 200 Mean latency (ms) → nDCG@10 ↑ 4.3× faster · −0.047 nDCG vs hybrid always-sparse always-dense always-hybrid adaptive speed adaptive balanced adaptive quality
fixed backend adaptive router
BEIR NFCorpus — 3,633 docs · 323 queries · real graded qrels
strategy nDCG@10 Recall@10 latency embed/q
always-sparse0.3060.1528.0 ms0.00
always-dense0.3840.184202.0 ms1.00
always-hybrid0.3750.180190.0 ms1.00
adaptive · speed0.3130.15520.8 ms0.07
adaptive · balanced0.3280.16044.5 ms0.20
adaptive · quality0.3770.181189.9 ms0.96

Reading: balanced sends ~80% of short keyword queries to free 8 ms BM25 — 4.3× faster than always-hybrid with 80% fewer embedding calls — at a cost of 0.056 nDCG versus always-dense. quality closes that gap to 0.007 nDCG of always-dense while beating always-hybrid outright.

The headline result: SciFact.

On 300 longer claim-verification queries, the per-query choice doesn't just trade quality for speed — it wins outright. Adaptive balanced scores a higher nDCG@10 than every fixed backend, including always-dense and always-hybrid. When the workload is mixed, choosing per query beats committing to any single strategy.

This is the case for adaptive routing in one number: 0.743 > 0.742 > 0.731 > 0.652.

BEIR SciFact — 5,183 docs · 300 claim-verification queries
strategynDCG@10Δ vs balanced
adaptive · balanced0.743
always-hybrid0.742−0.001
always-dense0.731−0.012
always-sparse0.652−0.091

The presets are starting points. Calibrate to your corpus in one pass.

Thresholds are a selectable RouterParameters bundle, not magic constants — validated by a 208-config sweep across NFCorpus + SciFact, and stamped on every decision.

speed

Max free BM25 traffic, lowest latency. Sends the most queries to $0 sparse.

20.8msNFCorpus latency
0.07embed / query
balanced default

The original shipped behavior. Cheap where it's safe, dense where it counts.

44.5msNFCorpus latency
0.20embed / query
quality

Only near-certain keyword hits bypass embeddings; everything else goes hybrid or dense.

189.9msNFCorpus latency
0.96embed / query

Sweep once, re-route hundreds of times.

calibrate_router.py runs retrieval once per backend, then scores hundreds of candidate configs by pure re-routing over the cached results — so the whole sweep costs no more than a single benchmark pass. It emits the winning config plus ready-to-paste env lines.

Ground-truth sources: BEIR qrels · an existing judge parquet (zero API cost) · or a public BEIR dataset.

calibrate against your qrels
$ python scripts/calibrate_router.py \
    --qrels data/mycorpus/qrels.tsv \
    --profile-out .env.router

  sweeping 208 configs over cached retrieval…
  best nDCG@10  0.741  (Δ +0.018 vs balanced)
  wrote .env.router — paste to deploy:

  ROUTER_THETA=0.42
  ROUTER_SHORT_TOKENS=4
  ROUTER_PROFILE=custom

Under the hood.

The heuristic router is the front door. Behind it, each of these is a real module built for production retrieval.

backends/cascade.py

Result-aware CASCADE backend

Runs free ~10 ms sparse first, inspects the score profile — the top-1 floor and the relative margin between the top and k-th hit — and only pays for dense embedding + RRF when lexical evidence is weak. It decides on measured results, not query shape, so it doesn't degrade as corpus vocabulary grows.

sparse.top1 < floor or margin(top, k) < δ → escalate to dense + RRF
index/cache.py

Disk-cached dense index

FAISS index + doc map keyed by SHA-256(doc_id, content). Corpus edits auto-invalidate. First boot embeds (~$0.0003); later boots load from disk in milliseconds.

cache/ttl_lru.py

Thread-safe TTL + LRU cache

On the hot path: long TTL for embeddings (saves an OpenAI round trip), short TTL for web results (freshness). ttl=0 disables cleanly via env.

bench/pipeline.py

2-phase benchmark pipeline

Sync retrieve across all four backends, then async LLM-judge on a 1/2/3 rubric — checkpointed every 50 queries, so a mid-run crash loses nothing.

POST /answer

Streaming RAG with citations

Returns inline [n] citations. stream=true emits SSE — meta → delta → done — with token usage and cost in the final event.

obs/logging.py

Structured per-retrieve logging

One JSON line per /retrieve: query_hash (sha1, first 8 hex — correlate without leaking query text), backend, latency_ms, cost_usd.

enterprise/ · optional

Optional enterprise layer

Everything above ships open. When you need more, it's already wired:

RBAC · reader/indexer/benchmark/admin per-tenant quotas + daily cost caps hashed API keys JSONL audit log OpenTelemetry tracing SLO targets FAISS flat | HNSW ML router (refuses to train on eval) cross-encoder / overlap reranker S3 connectors shared index root · horizontal scale

The API surface.

A small, honest HTTP surface. FastAPI with frozen Pydantic v2 models — the same contract the product UI and the ops dashboard call.

POST
/retrieveRoute + fetch. Returns ranked hits, the chosen backend, and cost.
POST
/answer SSEGrounded RAG with inline [n] citations. stream=true emits meta → delta → done.
GET
/router/explainDry-run the classifier for a query. No retrieval, no token spend.
GET
/healthLiveness + the active router profile.
POST
/corpus/documentsCorpus CRUD — upload / list / delete. Edits auto-invalidate the dense index.
DEL
/corpus/documents/{id}Remove a document; its cached embeddings are dropped by content hash.
POST /retrieve — example payload
$ curl -s https://arr.dev/retrieve \
    -H 'content-type: application/json' \
    -d '{"query": "cochrane review vitamin d", "k": 5}'

{
  "backend": "sparse",       # router chose free BM25
  "profile": "balanced",
  "latency_ms": 3.1,
  "cost_usd": 0.0,
  "hits": [
    { "doc_id": "MED-2419", "score": 18.7 },
    { "doc_id": "MED-0088", "score": 15.2 }
  ]
}

Stack & deploy.

Boring, legible infrastructure. Every layer is something you'd choose yourself.

FastAPIPydantic v2, frozen models
React + Vite + TSSSE chat, citations, doc upload, evals
Streamlitops / analytics dashboard
Dockermulti-stage: uv builder → slim runtime
Caddyautomatic HTTPS
Dokploy / Traefikdeploy path
pytestrouter covered rule-by-rule
CI quality gategreen before merge

cost model — honest, itemized

~$0.0003corpus embed (one-time, 80 docs, cache miss)
~$0.0000001per-query dense embed
$0sparse / hybrid's sparse part
$0.008Tavily web, flat per call
~$0.48full 40q × 4-backend benchmark (~$0.12 with --skip-web)
copied