pdf-quiz-generator/backend/app/services/rerank_service.py
Daniel 031de53034 feat: rerank what a learner is shown, with Cohere through the proxy
Retrieval fused a bi-encoder and BM25 by reciprocal rank. A bi-encoder embeds a
document long before the question exists, so the two never meet: it is good at
"same topic" and mediocre at "answers this". A cross-encoder reads the pair.

The proxy already serves three — `cohere-rerank-v4.0-pro` is the default and
measurably better than the fast variant. Query text goes exactly where the
embeddings already go, and nothing new was signed up for.

It found a defect nobody was looking for. In AI Mode each finder scored
`1/(1+rank)` *within its own corpus*, so the best article, section, question and
card all scored 1.0 and the shortlist was a meaningless round-robin. A
cross-encoder is the first thing in this system that can compare a question
with a section. Candidates per kind widened so it can select rather than merely
reorder.

Measured against labels neither ranker produced. Questions, 60 disease tags:
precision@3 0.394 → 0.483. Sections, 60 article titles: 0.772 → 0.833.
"Management of bronchiolitis" led with influenza transmission and a pregnancy
question; "when do you image a first febrile seizure" returned the definition
rather than the sentence saying imaging is unnecessary.

And the honest negative, in docs/reranking.md: board vignettes are written
*not* to name their diagnosis, so on "what causes croup" it prefers a question
that says the word in passing over the barking-cough vignette that never says
it. Some of the bi-encoder's strength is traded away.

Not on the typeahead. A page of results is a choice being made and worth a
third of a second; a typeahead is a word being finished, runs on every
keystroke, and has nothing to judge yet.

The three-state thresholds stay on cosine, argued at the constant: a reranker
only ever sees a shortlist and structurally cannot answer the corpus-wide
question those numbers ask, and whether an answer claims to come from the
library is a promise that must not depend on a network hop.

Every failure returns None and leaves the order alone — unconfigured, no proxy,
connect error, bare 502, timeout, non-JSON, a duplicate or out-of-range index,
a non-numeric score, a list the wrong length. Verified against the running site
with a bogus model name: same results, fused order, no error to the reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TqXevQJhxFrM7jJg82cgZN
2026-09-12 18:23:06 +02:00

225 lines
9.5 KiB
Python

"""Cross-encoder reranking: the model reads the query and the document together.
Everything upstream of here ranks without ever putting the two side by side. A
bi-encoder embedded each row long before anybody typed anything, so cosine
distance measures "same topic", which is not the same as "answers this". BM25
measures which words happen to coincide. Reciprocal rank fusion combines those
two opinions but cannot add a third.
A cross-encoder is that third opinion: one forward pass over the pair, so it can
tell that a section titled "Croup" which spends its whole body on epiglottitis
is not the thing to put first. The price is that it cannot be precomputed — the
work is per pair, per query — which is why nothing here ever sees the corpus.
It sees a shortlist the cheap rankers already produced, and reorders it.
**Reranking is an opinion about order and nothing else.** Every caller keeps the
rows retrieval found; a reranker that is unconfigured, down, slow or talking
nonsense returns `None` from `rerank()` and the caller ships today's ordering.
A search page with no results because a scoring service is unwell is a much
worse failure than a search page in a slightly worse order, and the second
failure is invisible to a user while the first ends their session.
Served through the same proxy as everything else (`LITELLM_API_BASE`), so a
learner's query goes where the embeddings already go and nowhere new. Nothing
here knows which reranker is behind that name: `POST /v1/rerank` with
`{model, query, documents, top_n}`, back `{results: [{index, relevance_score}]}`,
is the shape Cohere defined and Jina, BGE and LiteLLM's own rerank route all
speak. This deployment's proxy serves `cohere-rerank-v4.0-pro` and
`-fast` alongside `jina-reranker-v2-base-multilingual`, which is open-weight —
moving to a self-hosted one is a change to `LITELLM_RERANK_MODEL` and no change
to any code.
"""
import hashlib
import json
import logging
from app.config import settings
logger = logging.getLogger(__name__)
#: Per-document budget. Rerankers truncate at roughly 1k tokens per document
#: anyway, and the part of a clinical vignette that decides relevance is its
#: opening, not the option list; sending more buys nothing and costs latency
#: linear in total tokens.
DOC_CHARS = 1200
#: A cross-encoder is trained on query-length queries. Handing it a whole
#: uploaded document (which `search_service._query_terms` exists to cope with)
#: is out of distribution as well as slow, so the query is clamped too.
QUERY_CHARS = 2000
#: Measured against this proxy: 10 candidates ≈ 250 ms, 30 ≈ 380 ms, 50 ≈ 430 ms,
#: 100 ≈ 580 ms — mostly the round trip, then roughly 2 ms a pair. The fused pool
#: is 60 wide and a page shows ten, so past about fifty the reranker is being
#: paid to confirm that rows neither cheap ranker wanted are still not wanted.
#: The numbers and the method are in docs/reranking.md.
MAX_CANDIDATES = 50
#: Deterministic in (model, query, documents), and the document text is part of
#: the key, so an edited row invalidates its own entry. That leaves nothing a
#: day can make wrong — the same reasoning as the query-embedding cache.
CACHE_TTL = 24 * 3600
#: Long enough for a cold model to answer, short enough that a wedged proxy
#: costs one page load rather than the request. On timeout the caller keeps the
#: order it already had.
TIMEOUT_SECONDS = 8
def rerank_model() -> str:
"""The active reranker, Redis override first — same shape as the embedder.
An administrator can move between served rerankers without a redeploy;
unset everywhere means the feature is simply off.
"""
try:
import redis as redis_lib
client = redis_lib.from_url(settings.REDIS_URL, decode_responses=True,
socket_connect_timeout=1)
chosen = client.get("settings:rerank_model")
if chosen is not None:
return chosen.strip()
except Exception:
pass
return (settings.LITELLM_RERANK_MODEL or "").strip()
def is_configured() -> bool:
"""Whether reranking can be attempted at all."""
return bool(rerank_model() and settings.LITELLM_API_KEY and settings.LITELLM_API_BASE)
def _cache_key(model: str, query: str, documents: list[str]) -> str:
digest = hashlib.sha256()
digest.update(query.encode("utf-8", "ignore"))
for document in documents:
digest.update(b"\x00")
digest.update(document.encode("utf-8", "ignore"))
return f"rerank:{model}:{digest.hexdigest()[:40]}"
def _redis():
try:
import redis as redis_lib
return redis_lib.from_url(settings.REDIS_URL, decode_responses=True,
socket_connect_timeout=1)
except Exception:
return None
def _parse(payload, count: int) -> list[float] | None:
"""Scores aligned to the documents that went in, or None if they are not.
Positional trust is the bug waiting to happen here: the response is sorted
by score, so reading it in order attaches the best document's score to the
first document. Every score is placed by the `index` the server echoes, and
a response that does not cover every input is refused outright rather than
half-applied — a partial reordering is indistinguishable from a bad one.
"""
if not isinstance(payload, dict):
return None
results = payload.get("results")
if not isinstance(results, list) or len(results) != count:
return None
scores: list[float | None] = [None] * count
for item in results:
if not isinstance(item, dict):
return None
index, score = item.get("index"), item.get("relevance_score")
if not isinstance(index, int) or not isinstance(score, (int, float)):
return None
if not 0 <= index < count or scores[index] is not None:
return None
scores[index] = float(score)
if any(score is None for score in scores):
return None
return scores # type: ignore[return-value]
def rerank(query: str, documents: list[str]) -> list[float] | None:
"""Relevance of each document to the query, in the order they were given.
`None` means "no opinion" and is the answer to every kind of trouble: not
configured, unreachable, slow, or a reply that does not line up with the
request. Callers must read it as "keep what you had".
"""
query = " ".join((query or "").split())[:QUERY_CHARS]
if not query or not documents:
return None
model = rerank_model()
if not is_configured():
return None
clipped = [" ".join((document or "").split())[:DOC_CHARS] or " " for document in documents]
key = _cache_key(model, query, clipped)
cache = _redis()
if cache is not None:
try:
hit = cache.get(key)
if hit:
cached = json.loads(hit)
if isinstance(cached, list) and len(cached) == len(clipped):
return [float(score) for score in cached]
except Exception:
logger.debug("Rerank cache unreadable", exc_info=True)
api_base = (settings.LITELLM_API_BASE or "").rstrip("/").removesuffix("/v1")
try:
import httpx
response = httpx.post(
f"{api_base}/v1/rerank",
headers={"Authorization": f"Bearer {settings.LITELLM_API_KEY}",
"Content-Type": "application/json"},
# `top_n` is the whole list on purpose. Asking for fewer would save
# nothing — the pairs are scored either way — and would leave the
# tail unscored, so it could only be appended in its old order.
json={"model": model, "query": query, "documents": clipped,
"top_n": len(clipped)},
timeout=TIMEOUT_SECONDS,
)
response.raise_for_status()
scores = _parse(response.json(), len(clipped))
except Exception as error:
logger.warning("Rerank unavailable (%s); keeping fused order", error)
return None
if scores is None:
logger.warning("Rerank returned a reply that did not match the request; "
"keeping fused order")
return None
if cache is not None:
try:
cache.setex(key, CACHE_TTL, json.dumps(scores))
except Exception:
logger.debug("Could not cache rerank scores", exc_info=True)
return scores
def reorder(query: str, items: list, text_of) -> list:
"""`items` best-first by cross-encoder score, unchanged if there is no score.
Only the head is reordered. Beyond `MAX_CANDIDATES` the list is passed
through untouched, which keeps the cost of a query independent of how many
rows matched it and keeps pagination past the first page stable.
The return value is always a permutation of the input: nothing is dropped
and nothing is added, whatever the reranker does or fails to do. That is the
property that makes this safe to put in front of a search page.
"""
if len(items) < 2:
return list(items)
head = list(items[:MAX_CANDIDATES])
tail = list(items[len(head):])
scores = rerank(query, [text_of(item) for item in head])
# The length is `rerank`'s contract, and a caller that stubs or replaces it
# is exactly when a reordering half the list long would go unnoticed.
if scores is None or len(scores) != len(head):
return list(items)
# Ties keep the fused order, so agreement between the cheap rankers still
# settles anything the cross-encoder is indifferent about.
order = sorted(range(len(head)), key=lambda index: (-scores[index], index))
return [head[index] for index in order] + tail