Fine-tuning an embedding model for RAG fails quietly when the hard negatives you mine are actually relevant passages. In the NV-Retriever paper, naive top-k mining produced false negative rates of 38.8% on NQ and 47% on StackExchange, and positive-aware filtering lifted average NDCG@10 from 0.5407 to 0.5856 when fine-tuning e5-large-unsupervised. Regulated corpora are worse because they are full of near-duplicates: superseded SOP versions, boilerplate clauses, the same active ingredient in two labels. In sentence-transformers 6.2.0, mine_hard_negatives excludes the positive only by exact text match, so set relative_margin=0.05 (the paper's 95% threshold), pass your full chunk list as corpus, and treat range_min as a secondary knob. Train with CachedMultipleNegativesRankingLoss to get large batches on one GPU, and remember BatchSamplers.NO_DUPLICATES removes exact duplicates only. Ship only after a evaluation gate on held-out real queries shows the fine-tune beating both the base model and the base model plus your reranker. Start by labelling a few dozen to a hundred real queries and running the base model through InformationRetrievalEvaluator.
You want to fine-tune an embedding model for RAG because the off-the-shelf one keeps missing clause numbers, drug names and part numbers that your users type every day. The usual recipe is short: generate synthetic queries for your chunks, train with MultipleNegativesRankingLoss, watch hit-rate rise. That recipe has a failure mode it does not measure. When you add mined hard negatives, a large share of them are passages that actually answer the query, and the training step pushes them away.
The size of that share is measured. In the NV-Retriever paper (arXiv 2407.15831), an LLM judge classified a sample of negatives mined by naive top-k retrieval and found a false negative rate of 38.8% on NQ and 47% on StackExchange. The paper also notes the rate grows with the number of unique passages in the corpus. Now picture a regulated corpus: twelve versions of the same SOP, a boilerplate indemnity clause in four hundred contracts, the same active ingredient described in two product labels. The passages most similar to a query are often the right answer in a slightly different document.
This guide covers the two parts the recipe skips, using sentence-transformers 6.2.0 (released 21 October 2026): mining hard negatives with positive-aware filters so near-duplicates do not become negatives, and a evaluation gate on held-out real queries that the fine-tune must pass before you re-embed anything. The case for why embedding quality matters more than your vector store is in why embedding quality beats your choice of vector database; this post is the how.
Run the baseline before you fine-tune an embedding model
Fine-tuning has a fixed cost that a hit-rate chart does not show: a new embedding model means re-embedding every chunk you have. So the first job is to find out whether you need one.
Build a held-out set of real user queries, not synthetic ones, and have a domain reviewer label which chunks answer each. Use exactly the chunking you run in production, because a fine-tune evaluated on different chunk boundaries is evaluated on a different retrieval problem. A few dozen to a hundred labelled queries is enough to see whether the base model is in the right neighbourhood. This set never enters training, mining or dev evaluation.
Score two arms on it: the base model alone, and the base model plus the reranker you already run or would run. If the second arm clears your target, stop. A reranker improves results without touching the index, and our guide to when reranking is worth adding to RAG covers that trade. Fine-tune only when the reranker cannot recover passages the bi-encoder never retrieves, which shows up as low recall@k at the depth you feed the reranker.
The target is yours to set, before you look at results. For labelling discipline and metric definitions, use the method in how to test RAG retrieval accuracy; the gate later in this post reuses the same set.
One scoping note: this is retriever fine-tuning only. A 2025 comparison of independent, joint and two-phase fine-tuning of the embedding model and the generator found they reached about equal EM and F1 improvement at very different compute cost (arXiv 2510.01600), which supports fine-tuning the retriever on its own.
Generate synthetic training pairs on-prem
Regulated teams cannot ship the corpus to a hosted fine-tuning API, and they do not need to. The published basis for training on an unlabelled private corpus is GPL (arXiv 2112.07577): a query generator writes queries for each passage and a cross-encoder pseudo-labels them. It outperformed an out-of-the-box dense retriever by up to 9.3 nDCG@10 points across six domain-specialized datasets. That is the paper's result on its datasets, not an expected gain on yours.
The on-prem version of that pipeline is a local instruction model on your own serving stack writing one or more queries per chunk. Deduplicate the generated queries, drop the ones that copy a sentence verbatim from the chunk (they teach lexical matching, not retrieval), and split off a synthetic dev set for training-time evaluation. The real held-out queries stay out of all of it.
Pick the base checkpoint on the baseline numbers before you train anything; which embedding model to use for RAG covers that choice.
Shape the dataset for mining
mine_hard_negatives takes a Hugging Face Dataset of (query, positive) columns. Two properties of that format are useful on versioned corpora: That gives you a clean way to handle document versions. Decide first whether a superseded version should ever be retrieved. If retrieval filters it out by metadata at query time, keep it out of the mining corpus entirely. If it stays retrievable (an auditor asking what the procedure said in 2024), add it as an extra positive row for the same query rather than letting it surface as a negative. Instruction-style models such as Qwen3-Embedding expect a query prompt. Pass it during mining with query_prompt_name or query_prompt, and keep it the same at training and inference time. Qwen's README says instructions typically improve downstream tasks by 1% to 5%, so the prompt is part of the model's interface, not decoration.
- A query that appears in several rows is treated as one query with several positives. The function's own wording: "If a dataset has duplicate queries, assume that all duplicates are positive pairs."
- When a query has several positives, the margin filters use the lowest positive score, so adding a weaker positive makes the filter more conservative, not less.
Hard negative mining for domain-specific retrieval: why top-k fails
Hard negatives are, by construction, the passages most similar to the query that are not the labelled positive. On web-scale data most of those are genuinely wrong. On a corpus built from versioned procedures, templated contracts and repeated label text, the nearest non-positive passage is frequently a near-copy of the positive.
sentence-transformers does not protect you from this by default. At 6.2.0, mine_hard_negatives builds its candidate pool by merging the corpus with the positives and deduplicating on exact text, then masks the positive's own index. A superseded SOP that differs from the current one by a revision date and two words is not the same string, so it is a normal candidate negative unless a score filter removes it.
Five of the mining methods NV-Retriever compared, each used to fine-tune e5-large-unsupervised with the top 4 mined negatives. NDCG@10, from the paper's Table 3:
The two positive-aware methods at the bottom set the threshold relative to each query's positive score: MarginPos subtracts an absolute margin, PercPos takes a percentage. In the paper's false-negative check, they mined 57% fewer false negatives than naive top-k on NQ and 50% fewer on StackExchange. Accuracy dropped when the PercPos threshold went above 95%.
Rank-skipping (the second row) helps, and it is still the weakest fix. The paper's own assessment is that shifting by N "might even throw away valuable hard-negatives or keep false negatives": a fixed rank cannot know whether rank 11 is a near-copy of the positive or an unrelated passage. A fixed absolute score threshold (TopK-Abs) ignores how relevant the positive itself is. Earlier work on MS MARCO, as summarised in the NV-Retriever paper, estimated that about 70% of the passages most similar to queries there should have been labelled positive. The problem is not new; the near-duplicate density of regulated corpora makes it sharper.
One more finding is worth carrying into your pipeline: in NV-Retriever's comparison, the top-4 negatives mined by four different teacher models overlapped poorly, with Jaccard similarity below 30%. The model you mine with decides which negatives you get, so record it alongside the dataset.
| Mining method | Avg | NQ | HotpotQA | FiQA |
|---|---|---|---|---|
| Naive Top-K | 0.5407 | 0.5445 | 0.6120 | 0.4658 |
| Top-K shifted by N (N=10) | 0.5695 | 0.6007 | 0.6384 | 0.4693 |
| TopK-Abs (0.7) | 0.5759 | 0.6133 | 0.6396 | 0.4748 |
| TopK-MarginPos (0.05) | 0.5835 | 0.6338 | 0.6400 | 0.4766 |
| TopK-PercPos (95%) | 0.5856 | 0.6369 | 0.6414 | 0.4784 |
Hard negative mining in Sentence Transformers 6.2.0
Every filter below is a parameter of mine_hard_negatives at 6.2.0. They run in a fixed order: positives masked, then absolute or relative margin, then max_score, then min_score, then the top range_max candidates kept, then the first range_min of those dropped, then sampling.
Two behaviours in the source are easy to get wrong.
from datasets import Dataset
from sentence_transformers import SentenceTransformer
from sentence_transformers.util import mine_hard_negatives
base = SentenceTransformer("/models/base-embedder") # local path, no Hub call
# queries[i] is a synthetic query written for chunk positives[i].
# A superseded version of the same clause goes in as an extra (query, positive) row,
# not as a candidate negative.
pairs = Dataset.from_dict({"query": queries, "positive": positives})
mined = mine_hard_negatives(
dataset=pairs,
model=base,
corpus=all_chunks, # mine from every chunk, not only the positive column
relative_margin=0.05, # drop candidates scoring above 95% of the positive
num_negatives=4,
sampling_strategy="top",
output_format="n-tuple", # query, positive, negative_1..negative_4
batch_size=128,
use_faiss=True,
)The cross-encoder only rescores when a filter is set
Passing cross_encoder= alone does nothing to the candidates. Rescoring runs only when absolute_margin, relative_margin, max_score or min_score is also set. When it runs, it overwrites both the candidate scores and the positive scores with cross-encoder outputs, so every threshold now applies on the reranker's scale. A max_score tuned on cosine similarity does not transfer. The v6.2.0 release also fixed a case where setting only min_score ignored the cross-encoder. If you rescored with a half-precision reranker on sentence-transformers 5.x, re-run that mining. The v6.0.0 release notes state that half precision "ties too many scores together to rank with" and that a half-precision reranker with the default sigmoid activation was essentially ranking at random before the fix. Their measurement on a 32M-parameter reranker in bfloat16, over three NanoBEIR datasets with 100 candidates per query, was mean NDCG@10 of 0.1849 on v5.7.0 against 0.6795 on v6.0.0. That is a scoring fix, not a training fix, but any filter that ran on those scores inherited the noise.
range_min runs last, and range_max is derived for you
range_min is applied after the score filters, to whatever survives them, which is one more reason to treat it as a secondary knob. If you set a margin or max_score (or min_score together with a cross_encoder) and leave range_max unset, it defaults to range_min plus num_negatives times 10 plus the maximum number of positives per query; otherwise it is only range_min plus num_negatives plus that maximum. On GPU, FAISS retrieves at most 2048 documents per query, which caps that derived value.
The mining call
This mines from the full chunk list with NV-Retriever's best setting as the docstring states it: relative_margin=0.05, top sampling, four negatives. use_faiss=True needs faiss-cpu or faiss-gpu installed. The docstring's other example (range_min=10, range_max=50, max_score=0.8, random sampling) is an illustration on Natural Questions, not the paper's recommended setting; do not copy it as a default.
| Parameter | What it does at 6.2.0 | Use it when |
|---|---|---|
| relative_margin | Drops candidates scoring above positive_score minus abs(positive_score) times the margin; 0.05 matches NV-Retriever's TopK-PercPos (95%) | Always. This is the primary control |
| absolute_margin | Drops candidates where score plus margin exceeds the positive score (TopK-MarginPos) | You prefer a fixed gap on a known score scale |
| max_score | Drops candidates above a fixed similarity | A hard ceiling on top of the margin, tuned on the same scale |
| min_score | Drops candidates below a fixed similarity | Negatives so easy they teach nothing |
| range_min / range_max | Skips the first range_min ranks, considers up to range_max | Secondary knob only; applied after the score filters |
| cross_encoder | Rescores candidates and positives with a reranker | You have an in-house reranker and also set a margin or score filter |
| corpus | The candidate pool; defaults to the positive column only | Always: pass every production chunk |
| sampling_strategy, num_negatives | "top" (default) or "random"; 3 negatives by default | "top" with 10 or fewer, per the docstring |
Choosing a loss to fine-tune embeddings in Sentence Transformers
Mining filters only clean the negatives you add explicitly. MultipleNegativesRankingLoss also treats every other row's positive in the batch as a negative for the current query. If two chunks of the same boilerplate clause land in one batch, each is a false negative for the other's query.
BatchSamplers.NO_DUPLICATES is recommended for this loss family, and it removes exact duplicate texts from a batch. It does nothing about near-duplicates. The in-batch counterpart of a mining margin is GISTEmbedLoss (arXiv 2402.16829): a guide model scores the in-batch candidates and any negative scoring above the positive (minus an absolute or relative margin, set with margin_strategy and margin) is dropped.
CachedMultipleNegativesRankingLoss implements gradient caching, so, in its docstring's words, memory that works with a batch size of 32 can process much larger batches. The block below uses the v6 import paths from the official training overview. learning_rate=2e-5 and warmup_steps=0.1 are that documentation example's starting values, not tuned recommendations: tune epochs and learning rate against your synthetic dev evaluator, never against the held-out set.
from sentence_transformers import (
SentenceTransformer,
SentenceTransformerTrainer,
SentenceTransformerTrainingArguments,
)
from sentence_transformers.sentence_transformer.losses import CachedMultipleNegativesRankingLoss
from sentence_transformers.sentence_transformer.training_args import BatchSamplers
model = SentenceTransformer("/models/base-embedder")
loss = CachedMultipleNegativesRankingLoss(model, mini_batch_size=32)
args = SentenceTransformerTrainingArguments(
output_dir="/models/embedder-ft",
num_train_epochs=1,
per_device_train_batch_size=256, # effective batch; memory is bounded by mini_batch_size
learning_rate=2e-5,
warmup_steps=0.1,
bf16=True,
batch_sampler=BatchSamplers.NO_DUPLICATES,
eval_strategy="steps",
eval_steps=0.1,
)
trainer = SentenceTransformerTrainer(
model=model,
args=args,
train_dataset=mined,
loss=loss,
evaluator=dev_evaluator, # synthetic dev split, never the held-out real queries
)
trainer.train()
model.save_pretrained("/models/embedder-ft/final")If you are coming from v5, the old paths (sentence_transformers.losses, sentence_transformers.evaluation, sentence_transformers.training_args) still resolve through a deprecation shim in 6.2.0; move to the new ones. The library also ships PEFT LoRA and Unsloth training examples if you want adapter training instead of full fine-tuning. Late-interaction (ColBERT-style) training uses the separate MultiVectorEncoder trainer added in v6.0.0 and is out of scope here.
| Your situation | Loss | Why |
|---|---|---|
| Mined n-tuples, one GPU | CachedMultipleNegativesRankingLoss | Large effective batch at mini-batch memory; roughly 2x to 2.4x slower to train per its docstring |
| Mined n-tuples, memory to spare | MultipleNegativesRankingLoss | Same objective, no caching overhead |
| Pairs only, many near-duplicates, stronger model in-house | CachedGISTEmbedLoss | Guide model filters in-batch false negatives |
| Pairs only, clean corpus | CachedMultipleNegativesRankingLoss | In-batch negatives are enough to start |
Evaluate the fine-tuned model against the base model and reranker
The gate runs once, on the held-out real queries from the baseline step, with production chunking unchanged. It has four arms:
InformationRetrievalEvaluator covers the first two. Its defaults report MRR@10, NDCG@10, accuracy and precision and recall at 1, 3, 5 and 10, and MAP@100. Calling it on a model returns a dict whose keys follow name, score function and metric, so a cosine model with name "heldout" yields heldout_cosine_ndcg@10. Queries with no labelled relevant chunk are skipped, so check your qrels coverage before trusting the averages.
from sentence_transformers import SentenceTransformer
from sentence_transformers.sentence_transformer.evaluation import InformationRetrievalEvaluator
gate = InformationRetrievalEvaluator(
queries=heldout_queries, # {qid: text}: real user queries, never used in training
corpus=chunks_by_id, # {cid: text}: production chunking, unchanged
relevant_docs=qrels, # {qid: {cid, ...}}: labelled by a domain reviewer
mrr_at_k=[10],
ndcg_at_k=[10],
precision_recall_at_k=[5, 10, 20],
name="heldout",
)
for path in ("/models/base-embedder", "/models/embedder-ft/final"):
scores = gate(SentenceTransformer(path))
print(path, scores["heldout_cosine_ndcg@10"], scores["heldout_cosine_recall@10"])For arms 3 and 4, use CrossEncoderRerankingEvaluator with samples that carry a "documents" list: each bi-encoder's own ranked results for the query. With "documents", it reports performance both before and after reranking. Set always_rerank_positives=False. The default (True) inserts every labelled positive into the list, which measures the reranker on passages your retriever may never have returned. False is the realistic setting, and it is the one that shows whether the fine-tune improved what reaches the reranker.
Then add a general-query slice to catch regressions on questions outside your training distribution. NanoBEIREvaluator reports the same metrics on downsized BEIR datasets, loaded from the Hugging Face Hub by default, so on an air-gapped network mirror the dataset internally first.
The merge option is new in 6.2.0: SentenceTransformer.merge interpolates checkpoints (linear, slerp, ties and DARE variants) with the [merge] extra installed. The library's own documentation warns that merging "can improve some tasks while making others worse", so the merged model is a fifth arm through the same gate, not a fix you assume.
| Gate result | Decision |
|---|---|
| Fine-tune beats base and base plus reranker on the domain set, holds the general slice | Re-embed the corpus and cut over |
| Fine-tune beats base, does not beat base plus reranker | Keep the base model and the reranker; skip re-embedding |
| Fine-tune wins the domain set, regresses the general slice | Try merging base and fine-tune, then re-run the gate |
| Fine-tune does not beat base | Revisit mining (margin, corpus, mining model) before touching hyperparameters |
Deploying a fine-tuned embedding model on-prem
A fine-tuned model writes vectors into a different space from its base, so every chunk gets re-embedded and the two sets of vectors never share an index. Build the new index beside the old one, point the gate at it, and cut over in one change-controlled step with the old index kept for rollback. The wider cost of switching embedding models, and the lock-in it creates, is covered in self-hosted vs API embeddings.
Everything in this pipeline runs inside the perimeter. Model loads come from local paths, query generation runs on your own serving stack, mining and training fit on one in-house GPU with the cached loss, and the gate runs on reviewer-labelled queries that never leave the building. For serving the result, see Text Embeddings Inference vs vLLM vs Infinity.
What changes in a regulated setting is that the fine-tuned model is a new artifact somebody has to validate. Version these together, as one bundle:
That bundle is what a model-risk or validation function reviews, and it is what lets you reproduce the model when a regulator or auditor asks how retrieval behaved on a given date. Whether fine-tuning changes your role under the EU AI Act is a separate question, covered in does fine-tuning make you a provider under the EU AI Act. More on building retrieval in-house is in our RAG systems guide.
How to start: label real queries this week
Pull a few dozen to a hundred real queries from your logs, have someone who knows the documents label the chunks that answer each, and run the base model and the base model plus your reranker through InformationRetrievalEvaluator with name="heldout". If the reranker arm clears your target, you are done and your index stays as it is. If recall@k at the reranker's input depth is the bottleneck, you have your reason to fine-tune, and the held-out set you just built is the gate it will have to pass.
FAQ
Quick answers to the questions this post tends to raise.



