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
181 lines
10 KiB
Markdown
181 lines
10 KiB
Markdown
# Reranking: the second opinion on order
|
||
|
||
Retrieval fuses two rankers that never see the query and the document together.
|
||
A bi-encoder embedded every row long before anybody typed anything, so cosine
|
||
distance answers "is this the same topic"; `ts_rank_cd` answers "do these words
|
||
coincide". Neither answers "does this document answer this question", and that
|
||
is the question a learner is actually asking.
|
||
|
||
A cross-encoder answers it, at the cost of one forward pass per pair, per query,
|
||
which is why it can only ever be given a shortlist the cheap rankers produced.
|
||
It reorders. It never decides what exists.
|
||
|
||
## What is served, and where the query goes
|
||
|
||
The proxy at `LITELLM_API_BASE` already serves three rerankers — `/model/info`
|
||
reports `"mode": "rerank"` for each:
|
||
|
||
| model | notes |
|
||
|---|---|
|
||
| `cohere-rerank-v4.0-pro` | **in use** — the best of the three here, see the numbers below |
|
||
| `cohere-rerank-v4.0-fast` | roughly 150 ms quicker, and measurably worse on this corpus |
|
||
| `jina-reranker-v2-base-multilingual` | open-weight, but rate-limited to the point of unusability on this proxy: a 60-query sweep got 429s on nearly every call |
|
||
|
||
Both `POST /rerank` and `POST /v1/rerank` answer, with the body Cohere defined —
|
||
`{model, query, documents, top_n}` in, `{results: [{index, relevance_score}]}`
|
||
out. Jina, BGE and LiteLLM's own rerank route all speak it, so replacing the
|
||
model behind `LITELLM_RERANK_MODEL` with a self-hosted open-weight one is a
|
||
configuration change and nothing else.
|
||
|
||
## Where it is applied
|
||
|
||
| place | reranked | why |
|
||
|---|---|---|
|
||
| AI Mode shortlist (`ai_mode_service.retrieve`) | yes | The shortlist *is* the evidence the answer may use, and the turn is already waiting on a chat model. It is also the only place where four corpora have to be compared with each other — see below. |
|
||
| Question search (`/questions?q=`, `/search`) | yes | A page of results is a choice being made. |
|
||
| Article search (`/articles?q=`, `/search`) | yes, through its sections | An article row is a title, a summary and a topical vector; the prose is in its sections. Handing a cross-encoder the article row is handing it a stub and asking about a document it cannot see. Its best section already decides its place in the fusion, so reranking the sections reranks the articles. |
|
||
| Test builder from a description | yes | A sentence about what somebody wants to study is exactly the query shape a cross-encoder is trained on, and the top of that list becomes the test rather than a page to scroll past. |
|
||
| Test builder from an uploaded file | **no** | The "query" is a whole handout. Clipped to 2000 characters it judges every candidate against whichever part that happened to be — confidently, and about the wrong thing. |
|
||
| Flashcard and media search | **no** | Short fields (front/back, title/caption) where the lexical ranker is already doing the whole job, and both are small personal collections. Latency for nothing. |
|
||
| Typeahead (`/search/suggest`) | **no** | It runs on every keystroke and has half a word to judge. It is prefix matching on titles, which is what finishing a word wants. Adding a 400 ms round trip per character would make the fastest thing on the site the slowest. |
|
||
|
||
## Latency
|
||
|
||
Measured from the backend container against `cohere-rerank-v4.0-pro`, median of
|
||
three, real question stems as documents:
|
||
|
||
| candidates | round trip |
|
||
|---|---|
|
||
| 10 | 250 ms |
|
||
| 30 | 380 ms |
|
||
| 50 | 430 ms |
|
||
| 100 | 580 ms |
|
||
|
||
Mostly fixed cost, then roughly 2 ms a pair. `MAX_CANDIDATES = 50`: the fused
|
||
pool is 60 wide and a page shows ten, so past fifty the model is being paid to
|
||
confirm that rows neither cheap ranker wanted are still not wanted.
|
||
|
||
In place, including reading the candidate text out of Postgres, over 120 live
|
||
queries never asked before: **median 480 ms, p90 550 ms**. AI Mode's whole
|
||
retrieval step goes from about 85 ms to about 500 ms — one call over roughly
|
||
forty candidates across all four corpora.
|
||
|
||
Two caches sit in front of that, and both matter more than the raw figure. Ours
|
||
is Redis, keyed on model, query and the document text, for a day: **3–5 ms** on
|
||
a hit, which is most repeat queries and every reload of a results page. The
|
||
proxy keeps its own cache of identical rerank requests and answers those in
|
||
about 30 ms, so a query one learner has already asked is cheap for the next even
|
||
after our key expires. A full four-corpus search page measured 94 ms warm,
|
||
236 ms with our cache cleared but the proxy's still warm, and about a second on
|
||
a genuinely new query, which is two corpora reranked one after the other.
|
||
Running the per-corpus finders concurrently would recover half of that and has
|
||
not been done.
|
||
|
||
## Does it help
|
||
|
||
Two measurements, both against labels neither ranker produced.
|
||
|
||
**Questions**, query = a disease tag's name, relevant = the questions carrying
|
||
that tag, 60 tags sampled at random from those with 3–40 questions:
|
||
|
||
| | precision@3 | MRR@10 |
|
||
|---|---|---|
|
||
| fused | 0.394 | 0.595 |
|
||
| + `cohere-rerank-v4.0-pro` | **0.483** | **0.641** |
|
||
| + `cohere-rerank-v4.0-fast` | 0.439 | 0.642 |
|
||
|
||
Top result changed on 38 of 60; precision@3 improved on 21 and fell on 9.
|
||
|
||
**Article sections**, query = an article's title, relevant = that article's
|
||
sections, 60 articles with three or more sections:
|
||
|
||
| | precision@3 | MRR@10 |
|
||
|---|---|---|
|
||
| fused | 0.772 | 0.847 |
|
||
| + `cohere-rerank-v4.0-pro` | **0.833** | **0.908** |
|
||
| + `cohere-rerank-v4.0-fast` | 0.811 | 0.917 |
|
||
|
||
Top result changed on 41 of 60; improved on 12, fell on 4.
|
||
|
||
### Queries where the old top result was wrong
|
||
|
||
* *"management of bronchiolitis in an infant"* — fused first three sections were
|
||
influenza transmission, foreign body ingestion, and the management of a
|
||
pregnancy. Reranked: three supportive-management sections, the third of them
|
||
bronchiolitis's own and the first two transient tachypnoea of the newborn.
|
||
Better, and not right: on this corpus the reranker will take a *Management*
|
||
section from a neighbouring topic over a differently-titled one from the right
|
||
topic.
|
||
* *"when do you image a first febrile seizure"* — fused returned the definition
|
||
of a febrile seizure. Reranked returned "routine laboratory studies and acute
|
||
neuroimaging are unnecessary…", which is the answer.
|
||
* *"teenager with knee pain worse after sport"* — fused led with a 6-year-old
|
||
girl's case and, in the articles, with bone pain that wakes a child at night.
|
||
Reranked led with a 15-year-old's sports knee pain, and with patellofemoral
|
||
pain syndrome.
|
||
* *"delayed passage of meconium"* — fused returned functional constipation.
|
||
Reranked returned "obstruction presents with failure to pass meconium within
|
||
24–48 hours of birth".
|
||
* *"child limping with a fever and refusing to bear weight"* — fused led with a
|
||
urinary tract infection's presentation and acute gastroenteritis. Reranked led
|
||
with osteomyelitis, septic arthritis, and the differential for a limping
|
||
toddler.
|
||
* *"why does my patient with milk in the bottle have anemia"* (AI Mode) — fused
|
||
led with the general anemia article; reranked led with iron deficiency anemia.
|
||
|
||
### Where it does not help, and one place it is worse
|
||
|
||
The gain is uneven across corpora, and the reason is worth keeping in mind: a
|
||
bank question is a vignette written so as *not* to name its diagnosis. Asked
|
||
"what causes croup", the reranker prefers a question that says the word croup
|
||
in passing (a post-influenza bacterial tracheitis case) over the vignette with
|
||
the barking cough that never says it. That is defensible — someone searching
|
||
"croup" is not badly served by either — but it is the bi-encoder's strength being
|
||
partly traded away, and it is why the question-corpus gain (+0.09 precision@3)
|
||
is smaller than the prose-corpus one relative to how wrong the old order was.
|
||
|
||
The failure to watch for is a query the library does not cover. "How is croup
|
||
treated at home", against a corpus with no croup treatment section, is answered
|
||
with *Heat-Related Illness › Treatment*, *Tetanus › Treatment*, *Patellofemoral
|
||
Pain Syndrome › Treatment* — the cross-encoder matching the shape of the
|
||
question when it cannot match the subject. Fusion's answer was no better, but it
|
||
was wrong in a way that looked wrong. Two things contain this: AI Mode's
|
||
closeness gate is measured on cosine over the whole corpus and still says
|
||
"nothing here covers this" regardless of what the reranker thought (see
|
||
retrieval-thresholds.md), and search results carry their own snippet. A minimum
|
||
relevance score would be the third, and it is deliberately not implemented: it
|
||
would need the same measured calibration the cosine thresholds have, on a scale
|
||
that changes with the model, and getting it wrong empties a page.
|
||
|
||
## When it is not there
|
||
|
||
Unset `LITELLM_RERANK_MODEL` (or the `settings:rerank_model` override in Redis,
|
||
which wins) and every list is in fusion order, with no error anywhere. The same
|
||
is true of a proxy that is down, slow, rate-limiting, or answering with
|
||
something that does not line up with the request: `rerank()` returns `None` and
|
||
every caller reads that as "keep what you had". The 429 storm from the Jina
|
||
model above is what that looks like in practice — a warning line per call and
|
||
results in their previous order.
|
||
|
||
`POST /admin/rerank/test` checks the configured model end to end, including
|
||
whether it puts an obviously relevant document above an obviously irrelevant
|
||
one. A reranker that answers 200 and ranks the decoy first is worse than one
|
||
that is switched off, and nothing else on the site would ever tell you.
|
||
|
||
## Re-measuring
|
||
|
||
```
|
||
docker compose exec -T backend python - <<'EOF'
|
||
from app.database import SessionLocal
|
||
from app.services import search_service as ss
|
||
db = SessionLocal()
|
||
for q in ["management of bronchiolitis in an infant", "delayed passage of meconium"]:
|
||
ranked, _ = ss.hybrid_ids(db, q, "article_section", limit=200)
|
||
print(q, "\n before", ranked[:3], "\n after ", ss.rerank_ids(db, q, "article_section", ranked)[:3])
|
||
EOF
|
||
```
|
||
|
||
The precision figures above come from labels that already exist in the database
|
||
— disease tags on questions, and an article owning its sections. Re-run that
|
||
comparison rather than judging by eye after a corpus change; ten queries read by
|
||
hand will agree with whichever ordering was looked at second.
|