hard-negative-mining

v2026.09.24

Deep hard-negative mining for dense retrievers: in-batch vs mined, BM25 / dense / cross-encoder mining, TAS-B, MarginMSE distillation, curriculum schedules, memory banks, false-negative filtering. USE WHEN: user mentions "hard negatives", "negative mining", "TAS-B", "MarginMSE", "distillation from cross-encoder", "curriculum negatives", "memory bank", "MoCo", "false negative filter" DO NOT USE FOR: basic fine-tuning setup - use `embedding-fine-tuning`; reranker training - out of scope; picking a pretrained model - use `embedding-models`

GitHub
安装命令
npx skhub add claude-dev-suite/hard-negative-mining
Markdown
SKILL.md

Hard Negative Mining (Deep Dive)

Negative Type Taxonomy

TypeSourceDifficultyRisk
RandomUniform sample from corpusTrivialWasted capacity
In-batchOther positives in the same batchEasy-mediumFine as baseline
BM25-minedLexical matches minus true positivesMediumMisses semantic lookalikes
Dense-minedNearest neighbors under a weak embedderHardHigh false-negative rate
Cross-encoder-scoredTop-k reranked by a teacherVery hardNeeds teacher model
Self-mined (ANCE)Nearest neighbors under the model being trainedAdaptiveCan collapse without filtering

Most production recipes mix in-batch + cross-encoder-filtered dense-mined.

In-Batch Negatives (Baseline)

MultipleNegativesRankingLoss (MNR) turns every other positive in the batch into a negative. Free and strong when batch sizes are large (>= 64).

from sentence_transformers import SentenceTransformer
from sentence_transformers.losses import MultipleNegativesRankingLoss
from sentence_transformers.training_args import BatchSamplers

model = SentenceTransformer("BAAI/bge-base-en-v1.5")
loss  = MultipleNegativesRankingLoss(model)

# BatchSamplers.NO_DUPLICATES ensures no leakage of positives across the batch.

To scale batch to 512+ on small GPUs use CachedMultipleNegativesRankingLoss, which caches forward activations and recomputes gradients in mini-batches.

BM25-Mined Negatives

Cheap, lexical, no GPU needed — good for bootstrapping.

from rank_bm25 import BM25Okapi

tokenized = [d.lower().split() for d in corpus]
bm25 = BM25Okapi(tokenized)

def bm25_negatives(query: str, positive_idx: int, k: int = 5):
    scores = bm25.get_scores(query.lower().split())
    ranked = scores.argsort()[::-1]
    return [corpus[i] for i in ranked if i != positive_idx][:k]

Dense-Mined Negatives with False-Negative Filtering

from sentence_transformers import SentenceTransformer, CrossEncoder, util
import torch

miner   = SentenceTransformer("BAAI/bge-base-en-v1.5")
teacher = CrossEncoder("BAAI/bge-reranker-large")

corpus_emb = miner.encode(corpus, convert_to_tensor=True, normalize_embeddings=True,
                          show_progress_bar=True, batch_size=128)

def mine_filtered(query: str, positive_text: str, positive_idx: int,
                  n_candidates: int = 50, n_keep: int = 5,
                  ce_keep_max: float = 0.4):
    """Return hard negatives that the teacher scores BELOW ce_keep_max."""
    q = miner.encode(query, convert_to_tensor=True, normalize_embeddings=True)
    hits = util.semantic_search(q, corpus_emb, top_k=n_candidates)[0]
    candidates = [h for h in hits if h["corpus_id"] != positive_idx]

    cand_texts = [corpus[h["corpus_id"]] for h in candidates]
    ce_scores = teacher.predict([(query, t) for t in cand_texts])

    negatives = [t for t, s in zip(cand_texts, ce_scores) if s < ce_keep_max]
    return negatives[:n_keep]

Rule of thumb for BGE-reranker: scores above 0.7 are likely true positives (do not use as negatives), 0.3-0.7 are ambiguous (skip or use with low weight), below 0.3 are safe hard negatives.

TAS-B: Topic-Aware Sampling

TAS-B clusters queries by topic, then samples a batch from one cluster so in-batch negatives are topically related (harder). Paired with MarginMSE distillation from a cross-encoder teacher.

from sklearn.cluster import KMeans

query_emb = miner.encode(queries, normalize_embeddings=True, batch_size=128)
km = KMeans(n_clusters=2048, n_init="auto").fit(query_emb)
cluster_of = {q: c for q, c in zip(queries, km.labels_)}

def make_tas_batch(batch_size: int, cluster_id: int, qs_per_cluster):
    """Sample a batch from one cluster so in-batch negatives share a topic."""
    import random
    sample = random.sample(qs_per_cluster[cluster_id], batch_size)
    return sample

MarginMSE Loss with Cross-Encoder Distillation

Distills a cross-encoder's ranking into a bi-encoder. State-of-the-art for supervised dense retrieval.

from sentence_transformers.losses import MarginMSELoss
from sentence_transformers import InputExample

def build_margin_examples(triplets, teacher: CrossEncoder):
    """triplets: list of (query, positive, negative) -> InputExample with label = score margin"""
    examples = []
    for q, pos, neg in triplets:
        s_pos, s_neg = teacher.predict([(q, pos), (q, neg)])
        margin = float(s_pos - s_neg)
        examples.append(InputExample(texts=[q, pos, neg], label=margin))
    return examples

loss = MarginMSELoss(model)
# Train with a DataLoader of InputExample rows as usual.

MarginMSE typically beats TripletLoss by 2-4% MRR@10 on MS MARCO-like tasks because it transfers graded relevance, not a hard boundary.

Curriculum Mining (Easy to Hard)

Start with random / BM25 negatives for 1 epoch, then switch to dense-mined from the partially trained model. Prevents early collapse.

schedule = [
    ("warmup",     0, 1, "bm25",            5),
    ("medium",     1, 2, "dense_ce_0.5",    5),
    ("hard",       2, 4, "dense_ce_0.3",    7),
]

for name, start_e, end_e, mode, n in schedule:
    train_ds = rebuild_dataset(queries, positives, mode=mode, n_neg=n)
    args.num_train_epochs = end_e - start_e
    trainer.train_dataset = train_ds
    trainer.train()

Memory Bank (MoCo-style)

Maintains a queue of past document encodings so each batch sees thousands of negatives without needing huge GPU memory. Rarely used in sentence-transformers directly but worth knowing.

import torch
from collections import deque

class MemoryBank:
    def __init__(self, size: int = 16384, dim: int = 768):
        self.buf = deque(maxlen=size)
        self.dim = dim

    def push(self, vecs: torch.Tensor):
        for v in vecs.detach().cpu():
            self.buf.append(v)

    def sample(self, n: int) -> torch.Tensor:
        import random
        return torch.stack(random.sample(list(self.buf), min(n, len(self.buf))))

Use bank samples as extra negatives concatenated with in-batch negatives.

False-Negative Risks by Mining Method

MethodFalse-negative rateMitigation
Random< 0.1%Ignore
BM251-3%Usually ignorable
Dense top-505-15%Cross-encoder filter, required
Self-mined (ANCE)10-25%CE filter + schedule
Nearest-1 dense20%+Never use without filtering

A 10% false-negative rate is enough to cap the retriever at ~85% recall@10 no matter how long you train.

Combining Mining Methods

def combined_negatives(query, positive, positive_idx, k_total=7):
    bm25 = bm25_negatives(query, positive_idx, k=3)
    dense = mine_filtered(query, positive, positive_idx, n_keep=4)
    return bm25 + dense

Mixing lexical and semantic negatives produces more diverse gradients than either alone.

Evaluating Mining Quality

Before training, score your mined triplets with the teacher:

  • s_pos - s_neg mean margin should be > 2.0.
  • Fewer than 5% triplets with s_neg > s_pos (obvious false negatives).
  • Plot margin distribution; suspicious if bimodal near zero.

Anti-Patterns

Anti-PatternFix
Mining negatives without false-negative filterAlways pass through a cross-encoder
Using single nearest neighbor as negativeSample from top-20 to top-100 range
Same miner model used during training (no curriculum)Warmup with easy negatives, then harden
Ignoring batch-sampler duplicates with MNRUse BatchSamplers.NO_DUPLICATES
Distilling from a weak rerankerTeacher must be meaningfully stronger than the student
No per-query negative budget capCap at 5-8 negatives per query; more overfits
Mining once and freezingRe-mine every N epochs with current model for self-mining schedules

Production Checklist

  • Teacher cross-encoder picked (BGE-reranker-large or stronger)
  • False-negative filter threshold set and validated on 100 labeled pairs
  • Mining mix (BM25 + dense) balanced; per-query cap enforced
  • Curriculum schedule defined if training > 2 epochs
  • Margin distribution plotted before training kicks off
  • Batch size tuned; CachedMNR if GPU-constrained
  • Held-out IR eval run after each mining regime change
  • Mined triplets versioned alongside the resulting model
发现
标签

此技能尚未发布标签。

版本
最新版本元数据

版本

v2026.09.24

发布时间

2026年9月24日

分类

未分类

许可证

MIT

源路径

skills/embeddings/hard-negative-mining

默认分支

main

最新提交

9496306

Tree SHA

fe4e2f1