11 September 2026 · 7 min read
A Pre-Filter Was Capping My Recall at 65%
No amount of tuning top-k or thresholds moved it, because a third of the right answers were never candidates in the first place. How to tell a candidate-generation failure from a ranking failure.
- Retrieval
- LLM
- Evaluation
Recall sat at 65.4% and would not move.
I tuned the obvious things. Raised top-k. Lowered the similarity threshold. Widened the limits at every stage. Recall went up by a point or two and then flattened again, at roughly two-thirds, every time.
Eventually the shape of that became the interesting part. A number that refuses to move under tuning is not a tuning problem.
The system
This was the retrieval layer of a long-term memory system — the part that decides which facts about a user get put in front of the model before it answers.
Three branches, fused:
- vector similarity over embeddings
- keyword matching (BM25)
- entity matching
No model call anywhere in the path. Retrieval that has to run on every turn of a live conversation cannot afford one, so everything here is cheap and deterministic.
The vector branch had an optimisation in it, and the optimisation was mine. Rather than search the whole store, it searched within a pre-selected subset — the episodes that had already been matched as relevant to the current conversation. This seemed obviously correct when I designed it. It's cheaper, and it's more focused: why would you search memories from unrelated conversations?
Why tuning couldn't fix it
Here is the thing about a filter that runs before scoring: nothing downstream can undo it.
Top-k picks from the candidates. The threshold filters the candidates. The fusion weights re-rank the candidates. Every knob I was turning operated on a set that had already been decided, and the decision that mattered had happened upstream of all of them.
If the right memory never became a candidate, there is no value of k that retrieves it. Not a large one, not infinity. It isn't in the pool.
This is worth stating as a general rule, because the mistake is easy to repeat:
Any stage that shrinks the candidate set before scoring sets a hard ceiling on recall, and no downstream parameter can raise it.
The reason it's easy to repeat is that pre-filters don't look like bugs. They look like performance work. A narrowing step is a completely reasonable thing to put in a retrieval pipeline, it makes everything faster, and it is usually invisible in the code review because it is doing exactly what it says.
How to tell which failure you have
The diagnostic that broke this open is simple, and it requires having a labelled set — a list of which facts should have come back for a given query. You cannot do this by eyeballing results, because eyeballing only ever shows you what did come back.
For every fact that was missed, ask one question:
Was this item ever a candidate, at any setting?
That splits your failures into two piles that need completely different fixes:
- Ranking failures. The item was a candidate and got scored too low. This is what fusion weights, thresholds and better embeddings are for.
- Candidate-generation failures. The item never entered the pool. Nothing in your scoring layer can touch this.
When I ran that split, about a third of the essential facts were in the second pile. They were never candidates, at any setting I tried. That is almost exactly the gap between the recall I had and the recall I wanted, which is the kind of arithmetic that tells you you've found the whole problem rather than a piece of it.
Removing the pre-filter took recall to 98.9%.
It's worth being clear about what that means: the optimisation I had designed into the system was the entire bottleneck. Not a contributing factor. The measurement overturned my own design, and it did so only because there was a labelled set to measure against. Without one I would have kept turning knobs, because from the inside "recall is 65% and tuning helps a little" feels exactly like "this needs more tuning."
The corollary: check what your indexes are actually buying
There's a coda to this that comes from the same instinct — measure the thing you assumed.
Once retrieval no longer depended on that scoped path, I went looking at what the vector indexes were costing. Indexes get built because you need them, and then they persist because nobody re-asks the question. They're assumed infrastructure.
Measuring memory per component put three vector indexes at the top of the list — the largest single contributor. And after the retrieval change, they weren't earning it. All three came out.
The same review turned up an unbounded in-process cache of embeddings, which had been quietly growing until it caused a memory incident. Unbounded caches are a specific kind of nasty: they pass every test, they behave perfectly in development, and they fail slowly in production at a time unrelated to any deploy.
The fix was a bounded LRU, and the sizing is the interesting part. I sized it from measured reuse rather than guessing — 256 entries, 99.9% hit rate. Reuse was far more concentrated than I would have estimated. A cache a fraction of the size of the unbounded one did essentially the same job.
That number is the argument for measuring rather than guessing, in miniature. If I had picked a cache size by intuition I would have chosen something much larger, felt reasonable about it, and carried the extra memory forever without ever learning it was unnecessary.
What generalises
A metric that won't move under tuning is telling you the constraint is somewhere else. Flat under tuning is a signal, not a plateau to push harder against.
Split candidate generation from ranking before you tune either. They fail differently and they're fixed differently, and you cannot tell them apart from the output alone.
Pre-filters are recall ceilings wearing performance-optimisation clothing. If a stage narrows the set before scoring, measure what it excludes, not just what it costs.
Size caches from measured reuse. And put a bound on every cache, because the unbounded ones don't fail until it's expensive.
Review indexes like any other cost. They're built for a reason that may no longer hold, and nobody gets around to asking.