San Francisco, CA
Bravim Purohit
engineering10 min read

I measured my semantic cache. Then I turned it off.

A semantic cache saves money by answering a repeat question from memory instead of calling the model again. Mine looked excellent against paraphrases and fell apart against near misses. Here is the measurement, the number that ended it, and what I would build instead.

Someone asks your assistant "What is Redis?" and gets back a confident, well written explanation of Memcached.

Nothing crashed. No error was logged. The latency looked wonderful, because the answer never went near a model. It came out of a cache that decided the two questions were close enough to be the same question.

That failure is the reason I spent a weekend measuring a feature I had already built, and the reason I ended up switching it off.

What a semantic cache is trying to do

Ordinary caching is exact. The key is the text of the prompt. Ask the identical question twice and the second answer is free.

The trouble is that people almost never ask the identical question twice. "What is Docker?" and "Tell me about Docker containerization" want the same answer, but as strings they share very little. An exact cache sees two different keys and pays for the model twice.

A semantic cache tries to close that gap. Instead of matching text, it converts each prompt into an embedding, which is a list of numbers meant to capture what the sentence is about. When a new prompt arrives, it looks for a stored prompt whose embedding is close by. If the closest one is nearer than some threshold, it serves that stored answer.

Everything rests on that threshold. Below it, two questions are treated as the same question.

The request path. Everything interesting happens at the single comparison in the middle.

The appeal is obvious. A cache hit costs nothing and returns in milliseconds. If a meaningful share of traffic is repeat questions in different words, you have removed that share of your bill.

I built this twice inside my LLM inference gateway, once on Redis vector search and once on Postgres with pgvector, so I could compare them. Then, before wiring it into anything real, I tried to answer a question I had written into the project spec at the start: what is the false hit rate, measured rather than assumed?

The test everybody runs

The natural way to test a semantic cache is to collect pairs of questions that mean the same thing and check how many the cache catches.

I wrote 120 of them. Real rephrasings, the kind people actually type:

One way to askAnother way to ask
What is Docker?Tell me about Docker containerization
What is Kubernetes?Explain what K8s is
How does OAuth work?Explain the OAuth authentication flow
Explain recursionWhat is recursion and how does it work?

Run the sweep against only these, and the result is genuinely encouraging. At a threshold of 0.80 the cache catches 76.7 percent of them. Three out of four rephrased questions answered for free.

If I had stopped there, I would have shipped it. The number was good, the feature worked, and the demo was convincing.

The test that changed my mind

The paraphrase set can only tell you how often the cache fires. It cannot tell you how often it fires when it should not, because every pair in it is supposed to be a hit. To learn anything about the failure, I needed pairs that look similar and mean different things.

So I wrote 120 more. Same shape, same vocabulary, same topic, opposite meaning:

QuestionNear miss
What is Redis?What is Memcached?
Explain recursionExplain iteration
What is a microservice?What is a monolith?
What is async programming?What is synchronous programming?
How does a compiler work?How does an interpreter work?
What is Python?What is the python snake?

Every one of these is a wrong answer waiting to happen. If the cache treats "What is a microservice?" and "What is a monolith?" as the same question, someone gets an answer about the opposite of what they asked, with no indication anything went wrong.

Then I swept the threshold from 0.80 to 0.99 and measured both sets at each step. The hit rate is how many paraphrase pairs the cache correctly catches. The false hit rate is how many near miss pairs it wrongly catches.

The numbers

ThresholdCaught the paraphraseWrongly caught the near miss
0.8076.7%60.8%
0.8552.5%48.3%
0.8647.5%45.0%
0.8838.3%39.2%
0.9028.3%33.3%
0.955.0%20.0%
0.990.0%8.3%
Blue is correct hits on paraphrases. Red is wrong hits on near misses. Tightening the threshold kills the correct hits faster than the wrong ones, and the two lines never separate.

Read the top row again. At the loosest setting, the cache catches 76.7 percent of the questions it should, and also 60.8 percent of the questions it must not. Nearly two thirds of the near misses come back as confident wrong answers.

The instinct is to tighten the threshold. Tightening does reduce wrong answers, but it destroys the correct ones faster.

By 0.88 the two rates cross. Past that point the cache is wrong more often than it is right, which is a strange thing for a cache to be.

At 0.95 it catches 5 percent of real paraphrases and 20 percent of near misses. It has stopped being a cache and become a random wrong answer generator with excellent latency.

The last row is the one that ended the feature. At 0.99, the cache catches zero paraphrases. Not one out of 120. And it still produces false hits on 8.3 percent of the near misses. Every possible benefit is gone and some of the harm remains.

There is no threshold on this chart I would put in front of users. That is the finding.

Why this happens

I expected a tradeoff. I expected to find a threshold that gave up some hits to buy safety. What I did not expect was that no such point exists, and the reason is worth sitting with.

An embedding model is trained to place text that appears in similar contexts near other text that appears in similar contexts. It learns topic, register, and shape.

Nothing in that training teaches it that two sentences make opposite claims.

"What is a microservice?" and "What is a monolith?" appear in the same articles, the same documentation, the same conversations. They are maximally related. A model trained on relatedness is doing exactly its job when it places them close together, and that correctness is the problem: relatedness is not the property a cache needs.

A cache needs to know whether two questions have the same answer. That is a much stricter relationship. "Compiler" and "interpreter" are near neighbours in meaning and complete opposites in what they should return.

Worse, the words that flip a meaning tend to be small and common. Async and synchronous. Encryption and compression. Microservice and monolith. These carry the entire semantic load of the sentence while barely moving the embedding, because the rest of the sentence, which is almost all of it, is identical.

So near misses do not sit safely far away with a clean gap before the paraphrases. They sit right on top of them. Adjusting a threshold cannot separate two things that overlap.

What I would build instead

Giving up on similarity does not mean giving up on caching. It means being honest about which parts are safe.

Take the free wins where they are provably safe. Verify the rest. Never let similarity alone decide.

Three ideas in that diagram, in order of how much I trust them.

Normalise, then match exactly. Lowercase the text, trim the whitespace, strip the trailing question mark. This catches a real share of repeat traffic and it can never produce a wrong answer, because the questions genuinely are the same. It is unglamorous and it is free.

Verify before serving. Treat similarity as a shortlist rather than a decision. When a near neighbour turns up, ask a small cheap model whether the two questions have the same answer. You are still avoiding the expensive call, and you have replaced a distance measurement with something that actually reasons about meaning.

Narrow the scope. Every number above comes from general questions on many topics. A cache over one narrow intent, in one product surface, with a known set of things users ask, is a different problem with a different answer. The wide open case is the one that fails.

What I would not do is pick a threshold, watch the hit rate climb on a dashboard, and call it a win. Hit rate is the metric that made this feature look good for as long as I only measured hit rate.

What I actually shipped

The gateway still contains both semantic cache implementations, the Redis one and the pgvector one. They work. The sweep is committed next to them, along with all 240 pairs, and the README says plainly that the feature is off by default and why.

I thought about deleting the whole thing. I kept it because the measurement is more useful than the feature would have been, and because a cache that is off with a written reason is a better artifact than a cache that is on with a nice hit rate.

The tradeoff I went looking for turned out not to exist. Finding that out cost a weekend, which is considerably less than finding it out from a user who was told that a monolith is a microservice.

If you are running a semantic cache in production right now, I would gently suggest writing thirty near miss pairs for your own traffic and measuring your false hit rate before you finish reading anything else today. You may get a better answer than I did. Your users are already living with whatever the answer is.

The code, the pair set, and the full sweep are in llm-inference-gateway. If your pairs disagree with mine, I would genuinely like to see the numbers.

Update #110 min readPermalink

Update: layering a lexical prefilter, and where it earns its place

A reader proposed putting a cheap SimHash or MinHash near-duplicate stage in front of the embedding match. I had been circling the same two-stage shape, so I measured the combination properly. The lexical stage cannot do the job I wanted it to do — and it turns out to be genuinely useful somewhere else in the pipeline.

The best response to the post above came from a reader who did not stop at agreeing with it:

Have you thought of using multi-layered cache processing, first a SimHash or MinHash for near duplicate detection, then a semantic match for approximate guesses? Might reduce the false-hit rate but of course not 100% accurate as a full hash.

What struck me is that we had arrived at the same shape from opposite directions. The last section of that post is a two-stage pipeline: normalise and match exactly first, treat similarity as a shortlist second. His proposal is also a two-stage pipeline. We had different candidates for stage one, and neither of us had measured which one belongs there.

That is a testable disagreement, so I tested it. What follows is his idea and my existing design measured against the same pair set, and the combined answer is better than either of us had on our own.

The shared instinct

Cheap filter first, expensive filter second. Only pay for the expensive one on candidates that survive. It is how most retrieval systems are laid out, and it is why both of us landed there.

Where we differed is what stage one is for. I was reaching for a stage that could not be wrong. He was reaching for a stage that was fast. Those turn out to be very different requirements, and the measurement is what separates them.

The proposal, as I tested it: a cheap lexical gate runs first, and only survivors reach the embedding comparison.

What I measured

The evaluation set from the post above is 121 paraphrase pairs that should be cache hits and 121 adversarial near-miss pairs that must not be. I computed three lexical measures over both: character 3-gram Jaccard, word-set Jaccard, and a 64-bit SimHash.

One deliberate choice. I computed exact Jaccard rather than building a MinHash sketch, because MinHash is an estimator of Jaccard. Measuring Jaccard directly gives the ceiling of what any MinHash configuration could reach, so the result speaks to the whole family rather than to one tuning I happened to pick.

For the prefilter to help, paraphrases need to score high and near misses need to score low.

MeasureParaphrases (want high)Near misses (want low)Separation
Character 3-gram Jaccard0.1900.374−0.184
Word-set Jaccard0.1310.506−0.376
SimHash, 64-bit0.6120.677−0.065

Every separation is negative.

Blue is the paraphrase pairs the filter should catch. Red is the near-miss pairs it must reject. The bars are the wrong way round on all three measures.

On word-set Jaccard the pairs that must never be merged are close to four times more lexically similar than the pairs that should be. The filter does not fail to discriminate. It discriminates confidently, in the wrong direction.

Why it comes out backwards

This is not noise, and it was sitting in the two tables above the whole time.

The near misses are minimal edits:

QuestionNear missWords changed
What is MVCC?What is 2PL?one
What is a microservice?What is a monolith?one
Explain recursionExplain iterationone

The paraphrases are near-total rewrites:

One way to askAnother way to askWords shared
What is Docker?Tell me about Docker containerizationone
What is Kubernetes?Explain what K8s iszero
How do I sort a list in Python?What's the way to sort a Python list?most, reordered

Lexical similarity is a direct measure of edit distance. Flipping the meaning of a question is, by construction, a tiny edit — usually one noun. That is the entire mechanism. The prefilter fires hardest on exactly the pairs that are dangerous and stays quiet on exactly the pairs that are safe.

Following it through as a gate

Means are suggestive; what matters is the behaviour when it is actually wired in as stage one.

In an AND gate a candidate must clear the lexical threshold before the embedding stage runs at all. That makes the paraphrase pass rate a hard ceiling on end-to-end recall, and the near-miss rejection rate the most the gate can possibly buy in safety.

At a word-set Jaccard cutoff of 0.30:

Passes the gate
Paraphrases (want these through)5.8%
Near misses (want these stopped)91.7%

The gate caps recall under 6% while removing 8% of the false hits — the trade running the wrong way, paid for with an extra stage. Loosening the cutoff lets more paraphrases through but proportionally more near misses with them. Tightening it removes false hits, but by then it has already removed every true hit.

The OR version, where either stage firing is enough to serve, can only add false hits.

The principle this gave me

I did not have a clean way to say why my two-stage design should work and this one should not. Now I do, and it came out of testing his version rather than defending mine.

Layered filters only help when the stages fail independently.

SimHash and embeddings do not fail independently. They fail on the same pairs, for a related reason: two strings that are nearly identical in characters are also nearly identical in embedding space, because the embedding is computed from those characters. Asking a cheaper method to check the same property is not a second opinion — it is the first opinion with less information.

That is most of why the embeddings failed in the first place, and it is why a cheaper measure of the same thing cannot patch it.

It also explains why the verifier stage does work. It is not a better distance measurement. It answers a different questiondo these two questions have the same answer? — which is the property a cache actually needs, and the one nothing in the embedding pipeline was ever measuring.

Where the prefilter earns its place

Here is the part I did not expect, and the reason this update improves the design rather than just closing a question.

The lexical stage is a bad safety gate and a genuinely good retrieval stage. Those are different jobs, and once they are separated both ideas fit in the same pipeline:

The revised design. MinHash moves to candidate retrieval where speed is the point, the embedding produces a shortlist, and the verifier is the only thing allowed to decide.

Two places his suggestion lands, both real:

  • Candidate retrieval. MinHash LSH narrows the search space before the exact comparison runs. That is a latency win, and a good one, on a stage where being approximate costs nothing because the verifier still has the final say.
  • The normalise-then-match-exactly tier. This is the shingling and hashing machinery doing what it is actually reliable at. The guarantee there comes from exactness, not from the sketch.

Speed, both times. Never safety. That distinction is the thing I took from this exchange, and it is now the rule the design is built on: similarity is allowed to propose, and only a verifier is allowed to decide.

The other two questions

The same comment asked two more things worth answering.

What is Anthropic doing differently? Less than it looks, and the difference is not the one people expect. Their prompt caching is not semantic at all — a hit requires an exact prefix match, identical tokens up to a marked breakpoint.

But the structural difference is not exact versus fuzzy. It is what gets cached. A prompt cache stores the encoded prefix state so those tokens do not have to be reprocessed. The model still runs. It still generates a fresh answer to the question that was actually asked. Mine skipped the model entirely and replayed a stored answer.

That is the whole risk surface. A prefix cache's worst case is a miss, and you pay full price. An answer cache's worst case is that it is confidently wrong. One is a compute cache and the other is an answer cache, and only one of them can be incorrect.

Isn't the whole point of AI to have some randomness? I would separate two things this merges: variance in how a question is answered, and variance in which question was answered.

Temperature explores the distribution of good responses conditioned on an input. A cache false hit changes the input — it answers a question the user did not ask. No sampling temperature has ever turned a prompt about Redis into an essay about Memcached.

There is an irony worth landing gently, too. The cache is the least random component in the whole stack: frozen, deterministic replay of a response generated once, at some temperature, on some past day. If you genuinely valued output diversity, the semantic cache is the first thing you would switch off.

Rerun it

The test is committed as eval/lexical_sweep.py, with results in eval/results/lexical_sweep.json.

python -m eval.lexical_sweep

That hashing exists because of this exercise. A result you cannot tie to its inputs is a screenshot, not a measurement.

If you write your own pairs and the separation comes out positive, I would like to see them. The interesting version of this update is the one where somebody's traffic has near misses that look nothing alike, and the gate works exactly as proposed. Thanks to the reader who pushed on it — the pipeline is better for it than the one I published.

Reply to this post

Pushback, questions, a different take — I read everything and reply to most.

⌘ + Enter to send
Book a 30-min call