Retrieval recall sat at 0.60 and would not move. Hybrid search did nothing. A reranker rescued exactly one question, and cost seconds per query to do it. The number that finally explained everything came from neither technique. It came from reading what the retriever had actually pulled back.
Two numbers that shouldn't coexist
payments-rag is a small RAG over two SEPA payment rulebooks. One eval checks whether the labelled page lands in the top 5 retrieved chunks. Another checks whether the final answer is correct.
The two evals disagreed. Recall@5 read 0.60, six of ten. The answer eval read 84.8 out of 100, a 90% pass rate. If the right page misses the top 5 four times in ten, how do nine answers in ten come out fine? That gap was the whole story.
Hybrid search: no lift
Hybrid retrieval was the obvious first move: vector similarity plus keyword search, combined. (The first run looked broken for a dumb reason: Postgres's plainto_tsquery ANDs every query word by default. Fixed the operator before the real measurement.)
The fair result was 0.60 to 0.60. Zero. Vector stayed the default.
A reranker hits its ceiling
A reranker was the fashionable next move, and it did move the number: 0.60 to 0.70. But 0.70 was not a random landing spot.
It promoted every fetchable page into the top 5. The remaining misses were never retrieved in the first place, so no amount of reordering could reach them. Worth building to understand the mechanism, but it costs seconds per query and stays eval-only.
The misses weren't misses
A recall miss hides two very different failures: the content was fetched but ranked low, or it was never fetched at all. Reranking and hybrid only reorder what was fetched, so neither could touch the second kind. To tell them apart, I re-ran retrieval on each of the four misses and searched the full text of every retrieved chunk for the answer fact itself. Not the labelled page. The fact.
Three of the four had the fact sitting in a retrieved chunk, on a page I had never labelled. My golden set pointed at one correct page among several, and recall@5 punished the retriever for finding a different correct one. I spent a Tuesday convinced it was broken. It wasn't.
Count "fact present in any retrieved chunk" instead of "labelled page in the top-k" and retrieval scores 9/10, exactly the answer eval's pass rate. The two numbers reconcile.
The fourth miss, currency, is the real one. "Euro" appeared in none of the retrieved chunks, and currency is the single question that scored 0 on the answer eval. Every LLM knows SEPA runs in euro, yet the model refused to answer without a grounded chunk. For a citation tool, failing out loud beats papering over a gap with training knowledge.
Coarse chunks: why the page ranks low
Why did the right page rank low in the first place? Each page-sized chunk embeds hundreds of words, averaging the answer together with the legal scaffolding around it. The further your wording drifts from the specification's own, the lower that chunk ranks. One fact on page 26, retrieved under three wordings:
Same fact. The rank tracks how far your phrasing sits from the spec's.
What carries over
- Split "ranked low" from "never fetched" before you tune. Reranking and hybrid reorder the fetched set; they cannot conjure a missing page. Both were aimed at the wrong problem here.
- Literature numbers don't transfer. Big-corpus hybrid gains say nothing about a 2-document, 10-question set. Small corpora behave differently, and 10 questions is a noisy ruler.
- Label your golden set to the fact, not to one page. A single "correct" page turns real hits into phantom misses, which is the whole reason these two numbers ever disagreed.
The honest gap
One caveat: that soft recall, fact-present-in-any-chunk, is still a manual check, not a built eval. For now I ship the strict 6/10.
I started the week thinking the retriever found the right answer 60% of the time. It was finding it 90% of the time. The metric was wrong, not the retriever.
Serhiy Kucherenko builds backend systems and LLM tooling. Try payments-rag live at rag.serhiykucherenko.dev. Full code and evals in the repo: github.com/KucherenkoSerhiy/payments-rag.




Top comments (0)