← learning.n0tls.com
retrieval research · part four

The Best Wrong Answer

A hard negative is a document that looks right and isn't — and it turns out to matter more to a contrastive loss than almost anything else in the training set. Here's the math for why, and what it implies for tuning three different base models instead of one.

The last checkpoint left four things deliberately unbuilt. This picks up the second: hard-negative mining and per-model hyperparameter tuning, held off on last time rather than tuned blind. The recipe that produced the September checkpoint — 1,372 synthetic pairs, one LLM pass, in-batch negatives only, one hyperparameter setting shared across three different base models — helped MiniLM on every metric and hurt bge-small on every metric except recall@5. Before changing anything, it's worth understanding why a training recipe can point in opposite directions for two models doing the same job.

The short version: almost none of the 1,372 pairs' in-batch negatives were doing any work. The gradient a contrastive loss produces is concentrated almost entirely on the few negatives that are close enough to the right answer to be confusable — and how close "close enough" is depends on a hyperparameter that was never tuned per model. The rest of this page derives that claim from the loss function itself.


01Vectors, angles, and one number

A bi-encoder turns a query and a document into two vectors, q and d, in the same space — 384 dimensions for the small models in this experiment. Both are normalized to length 1, so they live on the surface of a hypersphere rather than filling the space around it. That normalization is what makes a single number, cosine similarity, stand in for "how related are these two":

sim(q, d) = q · d = q‖ ‖d cos θ = cos θ
The dot product and the cosine coincide once both vectors have unit length — the norms drop out. θ is the angle between the two vectors; smaller angle, larger similarity, ceiling of 1 when they point the same direction.

That single scalar is the entire interface between "what the model believes" and "what the loss function can push on." Everything below is about what happens to that number, and its neighbors, during one training step.

Four candidate documents on a unit circle around a query A query vector points up. A positive document sits 14 degrees away, a mined hard negative 26 degrees away, a moderate negative 55 degrees away, and an easy negative 150 degrees away, each labeled with its cosine similarity. E M H + Q
Qquery +positive · cos θ = 0.97 Hmined hard negative · cos θ = 0.90 Mmoderate negative · cos θ = 0.57 Eeasy negative · cos θ = −0.87
fig. 1Four candidate documents at four angles from the same query. H is the one that matters: close enough to the query that it's genuinely confusable with the right answer, which is exactly why it's worth deliberately finding rather than hoping for.

02Why the loss barely notices most negatives

Set the equations aside for a second. Every training step is really a multiple-choice question: the model is handed a query, the one true document, and 31 other documents that happened to land in the same batch of 32. It already has a similarity score for each of the 32 — the cosine number from section 1. The only question left is what the model does with those 32 numbers.

Step one: scores become percentages

A rule called softmax turns the 32 raw scores into 32 percentages that add up to 100% — read each one as "how confident the model is that this particular document is the right one." A higher score gets a bigger share, and the gap gets stretched on the way there: a candidate whose score is only a little higher than another's can end up with a lot more of the confidence, not just a little more.

Run fig. 1's four candidates through this rule and the shape of the problem is already visible:

Softmax confidence per candidate Bar chart: the true document gets 80.3% confidence, the mined hard negative 19.7%, the moderate negative 0.03%, the easy negative effectively 0%. 100% 50% 80.3% + 19.7% H 0.03% M ~0.0% E
fig. 2All four candidates have a real, different similarity score. Once softmax turns those into confidence percentages, only two of the four numbers are large enough to be anything but noise — the true document, and whichever negative was closest to it.

Step two: grade the guess

The loss itself is almost an afterthought once step one is done. It's just: how far was the confidence on the correct answer from 100%?

L = −log(confidence assigned to the true document)
If the model puts 80.3% confidence on the true document, as in fig. 2, the loss is −log(0.803) ≈ 0.22 — small, because the guess was mostly right. If it had put only 5% there, the loss would jump to −log(0.05) ≈ 3.0. Confident and right costs almost nothing; confident and wrong costs a lot.

One dial sits inside step one, before any of this: a temperature, written τ (sentence-transformers exposes its inverse as scale, defaulting to 20). It controls how aggressively softmax stretches small score gaps into large confidence gaps. Fig. 2 already used the library's default; section 4 comes back to what changes when that dial turns.

Step three: what actually moves

Here's the one piece of calculus worth sitting with, because everything else in this page follows from it. Training doesn't mark each candidate "right" or "wrong" — it nudges every candidate's score by a specific amount, and that amount is just the gap between the confidence it got and the confidence it should have gotten (100% for the true document, 0% for everything else):

nudge on candidate j = pj yj
pj is candidate j's confidence from fig. 2. yj is 1 for the true document, 0 for every negative. This is L / sj if you want the calculus notation for it — "how much the loss changes per unit increase in candidate j's score" — but the plain reading is what matters: the size of the nudge equals the size of the mistake.

Plug fig. 2's numbers in and the nudges are: true document, 0.803 − 1 = −0.197 (pulled up, since the gradient is negative); mined hard negative, 0.197 − 0 = 0.197 (pushed down); moderate negative, 0.0003 (pushed down, but by almost nothing); easy negative, a number with eight zeros after the decimal (pushed down by an amount too small to matter). Two candidates get moved. Two don't.

The same four candidates after one training step The positive vector is nudged toward the query, the mined hard negative is nudged away from it, and the moderate and easy negatives barely move. E M H + Q
+pulled toward the query · nudge ≈ 0.197 Hpushed away from it · nudge ≈ 0.197 Mbarely moves · nudge ≈ 0.0003 Edoesn't move · nudge ≈ 0.0
fig. 3The small curved arrows are the entire training step. M and E sit on the circle exactly where they started — the batch spent a whole example on each of them and got back almost nothing.
the part that isn't obvious

Look again at the true document's own nudge, 0.197 in fig. 3. That number only exists because H was in the batch, competitive enough to take 19.7% of the confidence for itself. Drop H and keep only M and E, and the true document's confidence would sit above 99.9% — the model already "won" that comparison trivially, so its own nudge would shrink to almost nothing too. A hard negative isn't just teaching the model to push one distractor away. It's what stops the true document's own gradient from disappearing. Without one, a batch full of easy negatives can leave a whole training step doing close to nothing — even for pairs the model already gets right.


03Why in-batch negatives undersell themselves

The 1,372-pair recipe never chose its negatives — each query's negatives were whatever else happened to land in the same 32-example batch, drawn from a 457-document corpus with real topic clustering (installation docs next to other installation docs, API references next to other API references). Most of the time, a random draw from a 457-document corpus lands on something topically unrelated to the query: an E-type easy negative from fig. 2, contributing a nudge too small to matter. Batch size 32 gives 31 candidate negatives per query, but per the math above, it's not the count that matters — it's whether any of the 31 happen to be confusable, like fig. 2 and fig. 3's H. On a corpus this size, with random sampling, that's a matter of luck per batch rather than a designed property of the training set.

That's the case for mining negatives rather than accepting whatever the batch hands over: deliberately search for documents that are close enough to be worth a gradient, instead of waiting for one to show up by chance.

How mining actually works, mechanically

  1. Encode the whole corpus with the current (or base) model — the same 457 vectors eval_embed.py already produces.
  2. For each synthetic query, retrieve its nearest neighbors by cosine similarity, same brute-force matrix multiply already in the eval script.
  3. Discard the true document from that list, and treat the next few — the documents the model itself currently confuses with the right answer — as hard negatives for that query.

This is the mechanism H represents concretely, back in fig. 1 through fig. 3: not a random draw, but specifically the runner-up the model was most tempted by. It's also why mining needs a trained-enough model to start from — mining against an untrained model's arbitrary embedding space would surface arbitrary neighbors, not meaningfully confusable ones. The base checkpoints (MiniLM, bge-small, e5-small) are exactly good enough for this: they weren't trained on this corpus, but they weren't trained on nothing, either.

the risk this creates

A "hard negative" mined this way might not be a negative at all — the eval set already distinguishes an ideal_doc from a broader list of acceptable_docs for exactly this reason: some queries have more than one legitimately correct document. A mined neighbor that happens to be a real acceptable answer, labeled as a negative and trained to be pushed away, actively teaches the model something false. Any mining pass needs to check candidates against known acceptable-document lists before treating them as negatives, and where that list doesn't cover a corpus's full structure, some false negatives should be expected rather than assumed away.


04One temperature, three different spaces

τ (or its inverse, scale) is the dial from section 2, and turning it changes fig. 2's percentages without touching a single similarity score. High scale exaggerates small score gaps into large confidence gaps — that's what produced fig. 2's 80.3% / 19.7% split at the library's default of 20, even with a genuinely close competitor at 0.90. Turn the dial down to a gentler scale of 8, and the same four scores land very differently:

Softmax confidence per candidate at two temperature settings Bar chart comparing confidence share for the true document, a mined hard negative, a moderate negative, and an easy negative, at scale 20 and scale 8. 100% 50% true doc 0.97 mined hard 0.90 in-batch, moderate 0.57 in-batch, easy −0.87
solid = scale 20 (default) faded = scale 8
fig. 4Lower the scale, and the mined hard negative's share almost doubles (19.7% → 35.4%) while the moderate negative finally gets a nudge worth mentioning (0.03% → 2.5%). The easy negative still gets nothing at either setting — the dial redistributes gradient among the candidates already in contention, it doesn't manufacture contention out of nothing.

Low scale spreads confidence out across more of the field; high scale hands nearly all of it to whichever candidate is currently winning. Neither is free: fig. 4's gentler setting gives more of the field a voice, but it also gives more voice to noise if a "moderate" candidate only looks related by coincidence.

Nothing about the correct scale is universal — it depends on how similarity scores are distributed in a particular base model's embedding space before training even starts, and that distribution is a property of how each model was pretrained, not something the fine-tuning recipe controls. bge-small was the strongest base model of the three, and the only one that beat every agent baseline off the shelf. A model whose pretraining already spread its cosine similarities out sensibly, across a range that generally tracks relevance, doesn't need aggressive sharpening — and pushing it hard against noisy, single-pass synthetic pairs is one plausible explanation for why fine-tuning made bge-small worse on every exact-match metric while it made MiniLM, the weakest base model, better on all of them. A model with more room to improve has more to gain from a large gradient on a modest amount of possibly-noisy training signal; a model that already had this figured out has more to lose.

That's a hypothesis, not a finding — the last checkpoint deliberately didn't test it. It does turn "why did one recipe help one model and hurt another" from a mystery into a specific, checkable claim: look at scale and hard-negative ratio per model, not one setting shared across all three.


05What actually changes in the pipeline

Concretely, three things get added to what train_biencoder.py and eval_embed.py already do:

None of that is built yet. This page is the part that had to be understood before touching the pair-generation or training scripts — the next step is writing the mining pass itself.


Earlier in this series:
· Teaching a Small Model to Find Documents — bi-encoders, cross-encoders, contrastive training
· The Retrieval Pipeline, Line by Line — this repo's code, mapped to those concepts
· Inside the Transformer — tokenization through pretraining
Check your understanding— optional, 5 questions

1. Why does normalizing q and d to unit length let cosine similarity be computed as a plain dot product?

2. In fig. 2/3, why do the "moderate" and "easy" negatives get almost no gradient nudge?

3. What's the practical case for mining hard negatives instead of waiting for a random batch to include a confusable document?

4. Turning the temperature/scale dial down (e.g. 20 → 8) does what to the softmax distribution?

5. Why might a well-calibrated pretrained model like bge-small benefit less from aggressive hard-negative mining or a high scale than a weaker model like MiniLM?