Fine-Tuning Embeddings for Your Domain

AIMachine LearningEmbeddingsFine-Tuning
Share on LinkedIn

Generic text-embedding-3-large returns plausible neighbors for "reset OAuth client secret" but ranks your internal runbook below a Stack Overflow thread about unrelated OAuth libraries. The geometry of a general embedding space optimizes for broad semantic similarity on internet text — not for your ticket taxonomy, product catalog, or compliance document structure. Fine-tuning embeddings with contrastive pairs reshapes the space so queries land near documents your humans would label relevant, which is the whole point of RAG retrieval.

Define similarity for your domain

Before training, write down what "relevant" means:

Collect (query, positive_passage, negative_passages) from:

Clean aggressively: near-duplicate positives collapse diversity; false positives poison the space worse than missing data.

Contrastive training with sentence-transformers

from sentence_transformers import SentenceTransformer, InputExample, losses
from torch.utils.data import DataLoader

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

train_examples = [
    InputExample(texts=[query, positive, negative1, negative2]),
    # MultipleNegativesRankingLoss uses in-batch negatives too
]

loader = DataLoader(train_examples, shuffle=True, batch_size=32)
loss = losses.MultipleNegativesRankingLoss(model)

model.fit(
    train_objectives=[(loader, loss)],
    epochs=3,
    warmup_steps=100,
    output_path="models/support-embeddings-v1",
)

MultipleNegativesRankingLoss treats other batch items as negatives — batch size directly affects difficulty. For hard negatives mined offline, CachedMultipleNegativesRankingLoss or triplet loss with margin helps.

Hard negative mining loop

  1. Embed corpus with baseline model.
  2. For each query, retrieve top-50 excluding known positive.
  3. Label or heuristic-filter false positives out.
  4. Retrain; repeat two to three iterations.

Hard negatives that are semantically close but wrong force the model to learn fine distinctions — easy random negatives barely move weights.

Evaluation that matches production

Offline metrics on a held-out query set:

def mrr_at_k(ranked, relevant, k=10):
    for rank, doc_id in enumerate(ranked[:k], start=1):
        if doc_id in relevant:
            return 1.0 / rank
    return 0.0

Also measure:

Evaluate with the same chunk size, preprocessing, and metadata filters as production. Fine-tuning on titles only but retrieving body chunks at inference is a common mismatch.

Deployment considerations

Monitor retrieval click-through and human thumbs-down after deploy — offline MRR can improve while user satisfaction drops if labels were biased.

When not to fine-tune

Try hybrid search (BM25 + vectors), metadata filters, and query expansion first. Fine-tuning adds ML ops surface: training pipelines, eval sets, embedding regeneration, and model drift tracking. Earn that complexity with measured recall gaps on real queries.

Hard negative mining strategies

Random in-batch negatives are easy — hard negatives (similar but wrong documents) improve discrimination:

from sentence_transformers import SentenceTransformer, losses

model = SentenceTransformer('BAAI/bge-base-en-v1.5')

# Mine hard negatives: top-k similar docs that aren't the correct answer
def mine_hard_negatives(corpus, queries, model, k=5):
    index = faiss.IndexFlatIP(768)
    index.add(model.encode(corpus))
    hard_negs = []
    for q in queries:
        _, indices = index.search(model.encode([q]), k + 1)
        hard_negs.append([corpus[i] for i in indices[0] if corpus[i] != correct_doc])
    return hard_negs

Use MultipleNegativesRankingLoss with mined hard negatives for 10–20% recall improvement over random negatives on domain benchmarks.

Training data format for domain embeddings

Contrastive pairs need domain-specific structure:

[
  {
    "anchor": "What is the refund policy for annual subscriptions?",
    "positive": "Annual subscriptions are refundable within 30 days of purchase...",
    "negative": "Monthly subscriptions renew automatically on the 1st of each month..."
  }
]

Anchor = user query phrasing. Positive = correct document chunk. Negative = plausible but wrong chunk (hard negative). Generate anchors from real user queries, not synthetic paraphrases — distribution match matters.

Aim for 1,000–5,000 triplets for domain fine-tune. More data helps, but quality of negatives matters more than quantity.

Hybrid search after fine-tuning

Fine-tuned embeddings improve semantic recall; BM25 still wins on exact keyword matches:

def hybrid_search(query, alpha=0.7):
    bm25_scores = bm25_index.search(query)
    vector_scores = vector_index.search(embed_model.encode(query))
    combined = alpha * normalize(vector_scores) + (1 - alpha) * normalize(bm25_scores)
    return top_k(combined, k=10)

Deploy hybrid search alongside fine-tuned embeddings — don't replace BM25 entirely. Tune alpha on held-out query set.

Failure modes

Production checklist

Hold out 20% of domain pairs for eval during embedding fine-tune — training metrics lie when you optimize on the full set.

Resources

Frequently asked questions

When is fine-tuning embeddings worth it versus using a general model?

Fine-tune when your domain vocabulary and similarity notion diverge from general web text — legal clauses, medical codes, internal SKU descriptions, or support tickets with company jargon. If general models already achieve strong recall@10 on your eval queries, fine-tuning adds cost without gain.

How much training data do I need?

Contrastive fine-tuning can show gains with a few thousand (query, positive document) pairs if negatives are hard and diverse. More data helps tail queries. Synthetic pairs from click logs need deduplication and label noise filtering — garbage pairs teach garbage geometry.

Should I fine-tune the full model or only add a projection head?

Full fine-tuning of smaller models (100M–400M params) often gives the best retrieval quality on domain data. LoRA on larger models reduces GPU memory and preserves some general capability. Train both and compare on a held-out query set with the same chunking pipeline you use in production.

Hiring a senior Android / Flutter engineer?

I architect and ship production mobile software — Kotlin, Jetpack Compose, Flutter — for robotics, EV infrastructure, fintech, and real-time systems. Open to remote roles in Europe and the US.

Get in touch →