DEV Community

jidonglab
jidonglab

Posted on

Hard Negative Mining Breaks Embedding Fine-Tunes Without Denoising

You fine-tune a retriever on your own corpus. Round one uses random in-batch negatives, and nDCG@10 goes up four points. Nice. Round two you do the thing every paper says to do — mine hard negatives with the round-one model, take the top 15 non-gold results per query, retrain — and recall@10 drops below the off-the-shelf base model. The training loss looks beautiful. The eval is worse.

That's not a bug in your trainer. Hard negative mining did exactly what you asked: it found the documents your model ranks highest that aren't labeled positive. In a real corpus, a large share of those are correct answers with a missing label, and InfoNCE at a low temperature will spend most of its gradient teaching the model to push those away.

TL;DR

  • Hard negative mining works by feeding InfoNCE the negatives with the highest similarity scores — but in sparsely-labeled corpora the top-ranked "negatives" are frequently unlabeled positives (false negatives).
  • The InfoNCE gradient weight on each negative is softmax(s_i / τ). At the typical τ = 0.02, a negative just 0.1 cosine above its peers absorbs ~148× more gradient. One false negative dominates the entire batch's update.
  • Fix it with a positive-aware margin filter: drop any mined negative whose score exceeds ~95% of that query's positive score. Cheap, no extra model, kills most label noise.
  • Also mask duplicate documents across in-batch negatives, mine from a different model than the one you train, and sample a rank window (e.g. ranks 10–60) instead of the raw top-k.
  • Symptom to watch for: training loss falls, in-domain recall@100 holds, but nDCG@10 and top-1 accuracy degrade. That signature is label noise, not underfitting.

What does InfoNCE actually do with a hard negative?

InfoNCE is a softmax cross-entropy over similarity scores, so it is a competition between the positive and every negative in the row:

$$L = -\log \frac{e^{s^+/\tau}}{e^{s^+/\tau} + \sum_i e^{s_i^-/\tau}}$$

The gradient with respect to each negative score is exactly its softmax probability, p_i = exp(s_i/τ) / Z. That's the whole story: negatives are weighted by their own score, exponentially, scaled by 1/τ.

Modern retrieval recipes (E5, BGE, GTE and friends) train at τ between 0.01 and 0.05. Take τ = 0.02 and two negatives whose cosine similarities differ by 0.1 — a small gap in cosine space, well within noise for a decent encoder. The ratio of their gradient weights is:

exp(0.1 / 0.02) = e^5 ≈ 148
Enter fullscreen mode Exit fullscreen mode

At τ = 0.01 the same 0.1 gap gives e^10 ≈ 22,000×. Your loss is effectively a max, not a mean. Whichever single document scores highest gets essentially the whole negative gradient for that query.

That is the intended behavior — it's why hard negatives work at all. It's also why one mislabeled document poisons the update. Random negatives are safe precisely because they're all equally irrelevant, so the softmax spreads weight thin and no individual error matters.

Why do hard negatives break embedding fine-tuning?

Because "highest-scoring non-positive document" and "false negative" are nearly the same set in any corpus where annotation is sparse.

Think about how your labels were built. Someone clicked a search result, or an LLM generated one question per chunk, or you have one gold answer per query from a support ticket. Nothing in that process asserts the other 40,000 chunks are irrelevant — it asserts one is relevant. If your corpus has near-duplicate policy pages, versioned docs, or a FAQ that restates the same fact three ways, the top of the mined list is the unlabeled-positive list.

Now combine that with the τ math above. A false negative sitting at rank 1 typically scores higher than every true hard negative below it, so it wins the softmax and dominates the gradient. The model is explicitly optimized to separate two documents that should be adjacent. The damage lands on exactly the region of the embedding space you care most about: the top of the ranking.

That's why the failure shows up as top-heavy degradation. Recall@100 barely moves (the right doc is still somewhere in the candidate set), while nDCG@10 and top-1 collapse. If you only track recall@100, you'll ship it.

How do you filter false negatives without a cross-encoder?

Use a positive-aware threshold: for each query, reject mined negatives whose retrieval score is above a fraction of that query's positive score. The intuition is that the positive score calibrates "how similar do things look for this query" — a fixed global cutoff can't do that, because absolute similarity varies wildly per query.

import torch
import torch.nn.functional as F

def positive_aware_keep_mask(pos_scores, neg_scores, alpha=0.95):
    """
    pos_scores: (B,)   mining-model score of the labeled positive
    neg_scores: (B, N) mining-model scores of the mined candidates
    Keep a negative only if it scores clearly below the positive.
    """
    thresh = (pos_scores * alpha).unsqueeze(1)
    return neg_scores < thresh                    # (B, N) bool


def infonce(q, d_pos, d_neg, keep_mask, doc_ids=None, tau=0.02):
    q     = F.normalize(q, dim=-1)                # (B, D)
    d_pos = F.normalize(d_pos, dim=-1)            # (B, D)
    d_neg = F.normalize(d_neg, dim=-1)            # (B, N, D)

    pos = (q * d_pos).sum(-1, keepdim=True) / tau           # (B, 1)
    neg = torch.einsum("bd,bnd->bn", q, d_neg) / tau        # (B, N)

    # 1. drop suspected false negatives
    neg = neg.masked_fill(~keep_mask, float("-inf"))

    # 2. drop negatives that are literally another row's positive
    if doc_ids is not None:
        dup = doc_ids["neg"] == doc_ids["pos"].unsqueeze(1)  # (B, N)
        neg = neg.masked_fill(dup, float("-inf"))

    logits = torch.cat([pos, neg], dim=1)                    # (B, 1+N)
    target = torch.zeros(q.size(0), dtype=torch.long, device=q.device)
    return F.cross_entropy(logits, target)
Enter fullscreen mode Exit fullscreen mode

Two details that matter more than they look:

Masking with -inf before the softmax, not after. If you zero out the loss contribution afterwards, the filtered negative still sits in the partition function Z and still steals probability mass from the positive. Masking the logit removes it from the competition entirely. The positive logit is always present, so no row degenerates.

alpha is a real hyperparameter, not a constant. 0.95 is a sane starting point for cosine-similarity mining. Push it toward 0.9 for corpora with heavy near-duplication (versioned docs, legal boilerplate, product variants); relax toward 0.97–1.0 for corpora with dense, trustworthy labels where you'd otherwise throw away all your useful hard negatives. Log the rejection rate per epoch. If you're dropping 60% of mined candidates, your labels — not your alpha — are the problem.

Where should hard negatives come from?

Not from the model you're currently training, and not from the raw top of the list.

Mine with a different model than you train. Self-mining creates a feedback loop: the model's own confident mistakes become its training signal, and errors compound across rounds. Use a stronger off-the-shelf retriever, or a BM25 + dense ensemble, as the miner. Ensemble mining also breaks the correlation between "hard for the miner" and "hard for the trainee," which is what you actually want.

Sample a rank window, not the top-k. Ranks 1–5 for a decent miner are dominated by true positives and near-duplicates. A window like ranks 10–60, sampled rather than taken contiguously, gives you genuinely confusable documents with a far lower false-negative rate. This is cheap, and it composes with the margin filter.

If you can afford it, denoise with a cross-encoder. Score the mined candidates with a reranker and drop anything it rates above your positive. This is the highest-precision option and the reason RocketQA-style pipelines work — but it costs an inference pass over queries × candidates, so most teams should start with the margin filter and add the cross-encoder only if the rejection-rate logs say the labels are messy.

Does batch size still matter once you have hard negatives?

Yes, but less than people assume — and the two interact badly if you're careless.

In-batch negatives scale the denominator for free, which is why contrastive training loves 4k+ batch sizes. But once explicit hard negatives are in the row, they dominate the softmax anyway; the random in-batch terms contribute near-zero gradient. Going from 512 to 8192 buys much less when every row already has 8 mined negatives at high similarity.

The failure mode is duplicate positives. At batch size 4096 over a corpus of 50k chunks, the same document appears as a positive for multiple queries with near-certainty — and each occurrence becomes an in-batch negative for the others. That's a guaranteed, silent false negative, and the dup mask in the snippet above is the fix. It costs nothing and it's the single most common omission I see in custom training loops.

If you do need huge batches on limited VRAM, use GradCache (gradient caching with a two-pass encode) rather than truncating your negatives — but only after the masks are correct. Scaling a broken denominator just scales the noise.

How do you know the fix worked?

Hold out a manually verified slice — even 200 query-document pairs where a human confirmed the label. Track top-1 accuracy and nDCG@10 on it, not just recall@100. Then compare three runs: random negatives, raw mined negatives, filtered mined negatives. The signature of label noise is that run two beats run one on training loss and loses on top-1.

Second check: measure the score gap distribution. For each eval query, log s(positive) - s(top non-positive). Healthy hard-negative training widens that gap. Noisy training shrinks it toward zero, or makes it negative for near-duplicate-heavy queries — that's the model having been taught its own answers are wrong.

So does hard negative mining break embedding fine-tunes?

Hard negative mining breaks embedding fine-tunes when — and only when — false negatives reach the loss, because InfoNCE at τ = 0.02 concentrates its gradient exponentially on the highest-scoring negative in each row, and in a sparsely-labeled corpus that document is very often an unlabeled positive. The fix isn't to abandon hard negatives; they're still the largest single lever on top-of-ranking quality. It's to denoise before they hit the softmax: apply a positive-aware margin filter (~95% of the positive score), mask documents that are another row's positive, mine from a rank window with a model other than the one you're training, and mask filtered logits with -inf so they leave the partition function entirely. Then evaluate on top-1 and nDCG@10 against a human-verified slice — recall@100 will hide the damage every time.

Top comments (0)