Skip to content

Reranking

Fused hybrid results are plausible. Plausible is not the same as good enough to cite: the passage that actually answers the question sits fourth, and an answer built from the top three is confidently about something adjacent.

A reranker fixes the order by reading query and passage together. It is also a second model call per retrieval, over a candidate set someone has to bound, and the usual way it arrives — a provider's rerank endpoint pasted into application code — is none of substitutable, budgeted or traced.

The shape

from tesserix_adk.rag import RerankingRetriever

stage = RerankingRetriever(
    retriever,
    ModelReranker(provider, model="rerank-1"),
    candidates=50,
    top_n=8,
    timeout_seconds=5.0,
    budget=budget,
    tracer=tracer,
)

found = await stage.retrieve("how do I get my money back", scope=HANDBOOK, k=8)

found.hits[0].rerank_score   # what the reranker said
found.hits[0].score          # what fusion said, still there
found.reranked               # whether this order was computed or inherited

RerankingRetriever is a Retriever wrapping a Retriever, so it goes wherever retrieval already goes. NoReranking is the default reranker for measuring the stage's own overhead before paying a vendor for the ranking.

The candidate set is bounded at construction

candidates is what the stage asks the inner retriever for; top_n is what it returns. Both must be at least one and candidates may not be below top_n — a stage that cannot produce the best N of anything is a configuration mistake, not a runtime surprise. The call's own k narrows the result further but never widens the fan-out.

Passages are data

ModelReranker sends the passages as a JSON payload under a system instruction that says they are text to be scored and never instructions to follow. A candidate set containing injection-shaped text is scored exactly like any other candidate set.

Scores come back as JSON. A reply that is prose, or JSON of the wrong shape, yields no scores rather than an invented order — the stage then keeps the fused order and says so. A score for a chunk id nobody retrieved is ignored: the stage reorders the hits retrieval found, and a reranker cannot add to them.

The order is reproducible

Ties are broken by chunk id, so replaying a retrieval gives the same ranking. A candidate the reranker did not score keeps its fused position, behind everything that was scored — being unranked is not evidence of being good.

Every hit keeps both numbers. score is fusion's, rerank_score is the reranker's, and contributions still names the branches that found it, so a bad answer can be traced to the step that produced it.

What it costs, and when it is skipped

The call is recorded against the BudgetPolicy as one model call with its usage, like any other. If the budget is already exhausted the reranker is not called at all: the fused order comes back with reranked=False. A timeout or a failing reranker does the same. Retrieval degrading is better than retrieval failing, but only if the caller can tell — reranked is the flag, and the tracer records adk.rerank.degraded with the reason (budget, timeout, failed or empty).

A reranker declared unavailable is different. RerankingRetriever raises CapabilityError at construction rather than degrading on every call for the lifetime of the process.

Rerankers

Reranker What it is
NoReranking Keeps the fused order. Costs nothing, still sets reranked.
CrossEncoderReranker A local cross-encoder, run off the event loop.
ModelReranker A provider call, budgeted and traced like any other.

CrossEncoder is one method, score(pairs) -> Sequence[float], so a sentence-transformers model or an in-house one drops in. Returning a different number of scores than pairs raises ConfigurationError: scores that do not line up with passages cannot be attributed to any of them.

Known limitations

Training or fine-tuning a reranking model, and learning-to-rank from production click signals, are out of scope. This is the stage they would be served through.