A team that produces regulatory documents kept getting the same kind of question from other teams: does the current rule allow X? Answering meant someone reading through memos, manuals and regulations to find where the rule was stated, then writing a summary with the source cited. The work was real, and the answer was almost always already written down somewhere.
Retrieval fits this exactly. The user asks, the system summarises, and it cites which document the answer came from. The citation is the whole point — a summary without a source is useless when the question is about a rule.
It also fits the constraint from Part 1: the documents can't leave the perimeter, there's no GPU, and each generated answer costs tens of seconds on CPU.
Which is why a semantic cache looked like the most obvious optimisation in the system. Several teams ask about the same regulation. If two questions mean the same thing, serve the stored answer and skip the model entirely. I implemented it with cosine similarity between question embeddings and a threshold of 0.92, and it went out in the reference implementation this repository publishes — always on, with no flag to turn it off.
Then Giulio D'Erme asked me to prove that the threshold was safe. His example was clinical rather than administrative — pacientes con fiebre against pacientes sin fiebre, one token apart, opposite correct answers — but it transfers to every corpus I care about.
The interesting question turned out not to be whether embedders handle negation badly — they do, and the literature has said so for years. It's what happens when that failure lands in a component that suppresses generation rather than one that reorders results. A bad ranking degrades an answer the user can still see and judge. A cache hit skips the model entirely and returns the opposite rule with full confidence and no signal that anything happened.
Answering him took six days — the commit history runs from 6 to 11 August and every step is in it. The cache turned out to be unsafe, and so did the way I had measured it.
What the first run showed
The design is simple enough to break in one sentence: two questions that differ only in polarity mean opposite things, and a cosine similarity between their embeddings doesn't know that.
So I built twenty pairs of Spanish administrative questions in four categories — negation, temporal, entity, and paraphrase as a control — and measured cosine similarity with nomic-embed-text running on Ollama. The pairs are written by hand for the experiment, not drawn from any query log: leave, sick notes, permits, budgets, the vocabulary any public administration shares. No harness, no vector store, no retrieval. Just an embedder, a similarity function, and a script.
The result was blunt. The highest adversarial similarity was 0.9984, for a pair whose two questions have opposite correct answers. The lowest genuine paraphrase sat at 0.7470. Any threshold high enough to reject the adversarial pairs also rejected every paraphrase the cache existed to catch. No safe threshold existed, so I disabled the cache by default and wrote it up.
That conclusion still stands. Everything else about how I got there needed correcting.
Mistake one: two of my five negation pairs weren't negations
Giulio came back with a caveat about my own numbers: with five pairs per category, counts at a fixed threshold are fragile and readers will quote them as rates. He was right, and building a larger corpus to answer him is where I found the first problem.
Two of my five "negation" pairs were these:
- ¿Es obligatorio presentar la solicitud con anticipación? vs ¿No es obligatorio presentar la solicitud con anticipación?
- ¿Los contratos temporales tienen derecho a aguinaldo? vs ¿Los contratos temporales no tienen derecho a aguinaldo?
In Spanish, a negative interrogative like this is confirmatory. It asks for confirmation of the same fact rather than its opposite, and a correct system answers both identically. I had labelled them as pairs the cache must reject when they should have been accepts.
This was a linguistics error, not a code error, and no amount of testing would have caught it. The published minimum for my negation row, 0.9702, belongs to one of those invalid pairs. The count I'd reported as "9 of 15 adversarial pairs" is 7 of 13.
The conclusion survived because the highest adversarial similarity, 0.9984, belongs to a valid pair. But the row was wrong, so the report now carries an erratum and the two pairs live on as a separate confirmatory category — useful, as it turns out, because they're the cleanest possible control: same surface change as a negation, opposite required behaviour.
Mistake two: my control was too easy
This is the one worth the article. It's also the one I'd have defended if someone had raised it, right up until I sat down and wrote out the two populations I was comparing.
Here they are.
The pairs I wanted the cache to reject differed by a single token. Con against sin. Incluye against excluye. Fourteen words, thirteen of them identical, and the correct answer inverts.
The pairs I wanted the cache to accept differed by nearly all of their tokens. ¿Cómo solicito vacaciones? against ¿Cuál es el procedimiento para pedir vacaciones? — one content word in common, vacaciones, and the rest rewritten from scratch.
So one population was near-identical strings that must be rejected. The other was barely-overlapping strings that must be accepted. I measured cosine similarity across both, found that the first scored high and the second scored low, and published that as evidence that the embedder cannot tell a negation from a paraphrase.
Now ask what else would produce that exact table. A function that counted shared tokens and knew nothing about meaning would produce it. Character n-gram overlap would produce it. My experiment could not distinguish the embedder failing at semantics from the embedder succeeding at surface form while I misread the output as semantics — because I had built the two candidate explanations to predict the same result.
That's what a control is for, and mine wasn't one. A control population has to differ from the adversarial population in the thing you're testing and match it in everything else. Mine differed in both, so the contrast couldn't isolate anything. It looked like a clean result because the numbers were far apart, and distance between numbers is not the same as evidence for a claim.
The fix was to build five paraphrases with matched lexical overlap — one-token synonym swaps (solicito → pido, me corresponden → me tocan), the same surface distance as the negations, the opposite required behaviour — and to pre-specify that contrast as the primary one, in writing, before looking at any number it produced.
It mattered more than I expected — and the model that showed me how much wasn't mine either. Giulio's comment of 7 August recommended two: bge-m3 as a second embedder, and cross-encoder/ms-marco-MiniLM-L-6-v2 as a reranker, that one picked explicitly for running on CPU. I ran the embedder first, and it turned the control problem from an argument into a measurement.
Running the same adversarial pairs against both control populations with bge-m3:
- against low-overlap paraphrases: AUC 0.3556, p=0.4376
- against matched-overlap paraphrases: AUC 0.9333, p=0.0070
Same embedder, same negations, same nine rejects. The only thing that changed was how many words the accepted pairs shared with their twins, and the reading went from no usable information at this sample size to the ordering is right — which is emphatically not the same as the cache works, and I'll come back to that in a moment.
My original headline rested on the uncontrolled version.
The numbers that survive the control
Primary contrast, matched overlap, n=5 accepts against n=9 rejects. The p-values come from enumerating every one of the 2002 ways those labels could be reassigned to the same scores — no normal approximation, no distributional assumption, just counting how often chance produces a result this extreme.
| Scope | AUC | Margin | p (exact) |
|---|---|---|---|
| nomic-embed-text, Spanish | 0.1333 | −0.1017 | 0.0290 |
| bge-m3, Spanish | 0.9333 | −0.0086 | 0.0070 |
| nomic-embed-text, English | 0.4444 | −0.0707 | 0.7972 |
Three different readings, and the discipline is in not collapsing them:
nomic in Spanish is inverted, not merely blind. An adversarial pair outranks a genuine paraphrase most of the time. The error-minimising threshold is the degenerate one that accepts nothing — the optimal cache configuration is no cache. I'd rather lean on the margin of −0.1017 than on a p-value of 0.0290 that sits close to the line. Drop any single pair from the set and recompute, and the margin keeps its sign every time; the p-value is one borderline observation away from moving.
nomic in English doesn't reject the null. The honest statement is that the score carries no usable information in this contrast — not that English performs better, which the data doesn't support. Negation similarity averages 0.9520 across the nine translated pairs against 0.9821 for the same pairs in Spanish: lower, and still nowhere near low enough for a cut point to fit between the negations and the paraphrases. Whatever this is, it isn't about Spanish. I retracted that explanation from the original report.
bge-m3 is undetermined, and I can say how undetermined. It orders correctly, and it cleanly resolves the temporal and entity distinctions that nomic couldn't. But the margin is negative, and dropping one single pair flips its sign. Ordering well and cutting well are different problems; a cache needs a cut point, not a ranking. "We don't know" is the right answer here, and it took building the tooling to be able to say it precisely.
The row that isn't in the table
There is a fourth number, and it is the best one I have.
The unmatched contrast for nomic — the same nine negations, scored against the original low-overlap paraphrases — gives AUC 0.0000 at p=0.0010. Perfect inversion: every single adversarial pair outranks every single paraphrase. It is the lowest p-value anywhere in the run, sitting at the floor of what an exact test on fourteen pairs can report at all.
It is also the confounded one. It is the row my original report rested on, and it is the row this section exists to retract.
Leaving it out of the table is deliberate. Printed alongside three controlled contrasts it would read as the strongest of four findings, when what it actually measures is lexical distance wearing the costume of meaning. The number is in the artefacts, with the script that produced it, for anyone who wants to check that I'm characterising it fairly. It just isn't evidence for the thing I originally said it was evidence for — and a striking number that answers the wrong question is worse than no number, because it stops you looking.
A prediction that failed
The reranker was the other half of Giulio's recommendation, and the reason to try it is real: a cross-encoder scores the two texts jointly instead of comparing two embeddings computed in isolation, so it can see the token that inverts the meaning rather than averaging it away. A confirmation step before serving a cache hit is the obvious place to put that.
He also drew the boundary around his own suggestion. In his reranking benchmark, ms-marco-MiniLM-L-6-v2 improved recall@100 without converting that gain into top-5 — better at dragging the right candidate into the pool than at deciding which one wins. I tested it knowing that, which is the only reason a negative result was worth the run at all: negative, and significantly inverted in English.
I then predicted, in writing and before measuring, that BAAI/bge-reranker-v2-m3 would be more inverted, since it trains on the same relevance objective. It isn't. AUC 0.7667 in Spanish and 0.8222 in English, against MiniLM's 0.3333 and 0.0667 — wrong side of 0.50 in both languages. The prediction failed cleanly.
What I can't claim is that BGE works. Neither Spanish result reaches significance, English lands at p=0.0599, and its best available threshold in Spanish still costs 3 errors out of 14 — one false hit and two of five legitimate hits thrown away; in English, 2 out of 14. Registering the prediction beforehand is what makes the failure worth reporting; a prediction written after seeing the result isn't one.
For anyone weighing the cost: 571.1 ms per pair on CPU against MiniLM's 36.4, about 15.7×. Cost was never the obstacle. Signal was.
Mistake three: the literature had this in 2021
I designed and ran five versions of this experiment without a literature review. I did the review afterwards, and it changes what I can claim.
NevIR (EACL 2024) builds essentially my experiment at scale: it asks retrieval models to rank two documents that differ only by negation, and finds that most models — including state-of-the-art ones — perform at or below random ranking, with cross-encoders barely above chance and bi-encoders below it. My nomic result is a bi-encoder below chance, and my two cross-encoders land on opposite sides of chance, which is what you'd expect from a signal hovering around it.
Consistent with, not confirmed by. NevIR ranks two documents against one query; I'm asking whether two queries share an answer. Those aren't the same task, and fourteen pairs can't confirm anything about a paper's benchmark.
A 2021 biomedical paper found the inversion itself: across every model tested, mean cosine similarity for negation and antonym subsets ran higher than for sentence pairs human experts rated as highly similar. That's my finding, in another domain, five years earlier.
And HEROS offers a candidate for the mechanism I went looking for and didn't find: encoders fine-tuned on paraphrase datasets with contrastive learning come out highly sensitive to negation, while fine-tuning only on question-answer pairs leaves a model insensitive to it. On that account the training objective predicts the failure — not the architecture, and not the language. nomic-embed-text trains mostly on retrieval pairs, which puts it on the insensitive side, which is exactly where I measured it.
That's a tidy story and I haven't earned it, so I wrote down what would falsify it before running anything further: an NLI-tuned encoder should separate the primary contrast where nomic doesn't, AUC materially above 0.50 instead of 0.1333. If it doesn't separate, HEROS doesn't explain this case and the cause is still open. That prediction is dated, unmeasured, and sitting in the report where anyone can hold me to it.
So the phenomenon isn't mine. What none of these papers covers is the operating context I opened with: they measure ranking. Ranking failures are visible and recoverable. A cache hit is neither.
Three corrections, then, and not one of them moved the decision. Two pairs left the negation set; the control was rebuilt from scratch, and the same rebuilt corpus took my Spanish explanation down with it; the literature review shrank what was left to what nobody had published already — and the cache is disabled today for the same reason it was disabled on day one. That distinction is the whole point: the method was wrong and the answer was right, and I only know which was which because I measured it again instead of defending it.
What survived
The cache stays disabled by default, and the evidence for that is stronger now than when I published it, not weaker.
What changed is everything about how much I can claim. Not "cosine similarity fails at negation" — that was known. Just: with lexical overlap controlled, across three scopes, no usable cut point appeared, and in one of them the score was actively misleading.
If you're about to add a semantic cache to a retrieval system, the thing I'd take from this isn't the finding. It's that a control which shares no words with the thing it controls for isn't a control. Mine looked like evidence right up to the day after I published it. It was measuring string distance, and I only found out because Giulio asked a harder question about my own numbers than I had asked myself.
The pairs, the scripts, the raw scores and the erratum are in docs/experiments/. The JSON holds the similarities, so the reanalysis runs without Ollama and without repeating a single embedding.
This is a personal open-source project. It describes no institutional deployment and uses no data from any production system. Written in Spanish, self-translated, with Claude as a language editor; the experiments, the measurements and the mistakes are mine.
Top comments (1)
I like the cut point distinction here. A cache that ranks the right neighbors can still be unusable if the acceptance threshold moves when one pair leaves the sample. Small evals hide risk exactly there.