Skip to content
Venki Notes
Go back

Fixing Unstable RAG Retrieval (Part 2)

Updated:

In Part 1 I went through about ten reasons an identical query can come back with a different top 10. This part is about finding out which one is yours, and what to do about it.

First, though, the thing I end up saying on almost every one of these calls: you probably don’t want determinism.

You can get byte stable retrieval, but you pay for it in throughput, freshness and how independently your replicas can run. Most teams who say they want it actually want something else. They want results that don’t move enough to matter, and a way to find out when that changes. Once you look at it that way the problem gets a lot more manageable. You stop trying to get rid of variance and start putting a bound on it.

Start with the check that costs nothing

Before changing any config, run the same request twice and compare the traces.

A diagnostic ladder with eight rungs, cheapest at the top. Rung one, trace-diff two identical requests. Rung two, log scores not just IDs. Rung three, bypass caches. Rung four, pin the query vector bytes. Rung five, turn the reranker off. Rung six, turn fusion off and run each leg alone. Rung seven, pin to one shard and one replica with partial results disabled. Rung eight, compare against brute force exact search. Each rung is annotated with what it rules out when the variance disappears

Most investigations I’ve seen end on the first rung, and it costs nothing but some logging. Give each request a trace that carries:

Run the request twice and diff the traces field by field. The first field that differs tells you the layer. That’s the whole technique, and it beats guessing every time.

The second rung is nearly as cheap and catches a surprising number of cases: log scores, not just document IDs. If the scores are identical across runs and only the IDs swap places, it’s a tie breaking problem. You can stop there and jump to the total order fix below.

After that, pin one stage at a time and work down the ladder. If the variance disappears when you pin a stage, that stage, or something before it, is your culprit.

The last rung deserves its own paragraph. Brute force the full filtered set to get the true top K, then measure recall@10 on each run. That’s what separates “my ordering moves around” from “my index isn’t accurate”, and they’re completely different problems. A stable index with 70% recall needs tuning. An unstable index with 95% recall has a reproducibility problem. I’ve watched teams spend weeks on the second when what they actually had was the first.

One more trick: replay the pinned request under load. Variance that only shows up when the system is busy points at dynamic batching, timeouts, rejected requests or partial results. Variance that shows up when it’s idle is structural.

Fixes, layer by layer

Text preparation

This is where the real correctness bugs live, so fix it first, even if it isn’t what’s making your results flap.

If you use a semantic chunker that splits on an embedding similarity threshold, either cache its decisions or accept that your chunks won’t reproduce. A tiny float wobble that moves a boundary changes chunk text by whole sentences, which is a far bigger effect than anything else in this post.

Embedding

Pipeline

The cheap fix most people skip

If I could only make one change, it would be this one. Per line of code, nothing else in either post comes close.

Make your ranking a total order.

sort by (-round(score, 6), stable_doc_id)

Two ranking tables side by side. On the left, raw scores with tiny differences in the seventh decimal place produce two different orders across two runs. On the right, the same scores rounded to six decimal places become equal, and the stable document ID breaks the tie, so both runs produce an identical order

Round the score to just above your measured noise floor, then break ties on an ID that never changes. Noise smaller than the rounding step now almost never changes the order. The one exception is a score sitting right on a rounding edge, which can still tip either way, so you’ll still see the odd flip. But you’ve gone from variance nobody can explain to a small, known amount that you chose and can write down.

Two things go well with it:

Make it a metric, not a bug report

This is the piece that keeps it fixed.

Track Jaccard@10 and a rank correlation, Kendall’s tau or rank biased overlap, between repeated identical runs. Track it continuously, like any other service level indicator, and gate releases on it.

Asking “is retrieval deterministic?” doesn’t help, because the answer is always no and everyone stops paying attention. A number catches the day someone changes the fusion depth, adds a shard, swaps the embedding model or turns on quantization. Pair it with recall@10 against a brute force ground truth you recompute now and then, so you can tell the two failure modes apart. From the outside they look identical.

When you report these numbers, break them down by how selective the filters were and by tenant. Averages hide exactly the cases that break, which is the same lesson as the filtering cliff.

What good looks like

You won’t get identical results every time, and you shouldn’t want to. Here’s what I’d want to see instead:

That gets you variance that’s small, bounded, explained and watched. In my experience, that’s what people actually mean when they ask for reproducible retrieval.


Share this post on:

Previous Post
Why Your RAG Search Returns Different Results Every Time (Part 1)
Next Post
The Pre-Filter and Post-Filter Cliff in Vector Search