Train an Embedding Model: Contrastive Fine-Tuning for Retrieval
Hands-on lab · IDE in your browser

Train an Embedding Model: Contrastive Fine-Tuning for Retrieval

Fine-tune a small sentence encoder so it retrieves the right help article from the way people actually ask.

Time
75 min
Checked steps
5
Level
Advanced
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

The job

Tavola sells booking software to restaurants, and its staff search a help centre with plain, casual questions. An off-the-shelf encoder gets many of them, but misses the ones phrased unlike the articles. You fine-tune the encoder on question-and-article pairs so it learns the domain, and measure the retrieval it buys on questions it never trained on.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Measure retrieval

    A retrieval model turns a question and a document into vectors and scores them by how close the vectors point.

  2. 2

    The contrastive loss

    Fine-tuning a retriever means teaching it to pull each question towards the one article that answers it and away from every other article.

  3. 3

    Fine-tune the encoder

    Now run the loss over the training pairs and let the optimiser move the weights.

  4. 4

    Hard negatives

    In-batch negatives are whatever else happened to land in the batch, mostly easy.

  5. 5

    Keep the best checkpoint

    Train too long and the encoder starts fitting the training questions while dev retrieval drifts back down.

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Measure retrieval

Measure retrieval before you train

A retrieval model turns a question and a document into vectors and scores them by how close the vectors point. You cannot tell whether fine-tuning helped unless you can measure retrieval first, so you build the measurement before the training.

harness.py loads a small knowledge base for a restaurant booking product: 25 help articles, and questions paired with the article that answers each one, split into train, dev and test. H.embed(model, texts) returns one unit-length vector per text. H.new_encoder() gives you the frozen starting model.

In embedder.py:

  • scores(query_emb, doc_emb) returns the full score matrix: row per query, column per document, each entry the dot product of the two vectors. With unit vectors that dot product is cosine similarity. This is one line: query_emb @ doc_emb.T.
  • metrics(score_matrix, gold) takes that matrix and, for each query, the column index of the article that should win. Return {"r@1", "r@5", "mrr"}: the share of queries whose gold article scores highest, the share where it lands in the top five, and the mean of 1 / rank (recall that the top result is rank 1, not rank 0).

evaluate(model, split) is written for you: it embeds the split's questions and all documents, calls your scores, and reads off your metrics.

Run it to see the frozen encoder's dev numbers. That is the bar to beat.

embedder.py, the file you edit66 lines
"""Train an embedding model so Tavola's staff find the right help article from the way they actually ask.
The articles are a fixed corpus; you tune the encoder on (question, article) pairs with a contrastive objective."""
import numpy as np
import torch

import harness as H

DOCS, SPLITS = H.load()
DOC_TEXT = [d["text"] for d in DOCS]
DOC_ID = [d["id"] for d in DOCS]
DID_IX = {d: i for i, d in enumerate(DOC_ID)}


# ---------- Step 1: measure retrieval ----------

def scores(query_emb, doc_emb):
    """The similarity of every query to every document: query_emb @ doc_emb.T (both are already normalised)."""
    # TODO (Step 1): query_emb @ doc_emb.T.
    raise NotImplementedError("Step 1: write scores()")


def metrics(score_matrix, gold):
    """{"r@1", "r@5", "mrr"} for a (queries x docs) score matrix and gold[i] = the right document's column index.
    A query is a hit at k if its gold document is among the k highest-scoring; MRR averages 1 / rank."""
    # TODO (Step 1): per query, the rank of its gold column; r@1, r@5 and mean 1/rank.
    raise NotImplementedError("Step 1: write metrics()")


def evaluate(model, split):
    """Retrieval metrics of the model on one split: embed every document and every query, score, and score the ranks."""
    D = H.embed(model, DOC_TEXT)
    rows = SPLITS[split]
    Q = H.embed(model, [r["query"] for r in rows])
    return metrics(scores(Q, D).numpy(), [DID_IX[r["doc"]] for r in rows])


# ---------- Step 2: the contrastive loss ----------

def info_nce(q, d, temperature=0.05):
    """The in-batch contrastive loss (InfoNCE) for a batch of pairs: q[i] should be closest to d[i] and far from the
    other d's. Score every q against every d, divide by temperature, and take the cross-entropy that wants each row i
    to pick column i. Returns a scalar loss tensor."""
    raise NotImplementedError("info_nce() arrives in Step 2")


# ---------- Step 3: fine-tune ----------

def train_epoch(model, opt, pairs, epoch=0, temperature=0.05, batch_size=16):
    """One pass over the pairs in shuffled batches (seed the shuffle with epoch). For each batch: embed the queries
    and their gold documents with grad=True, compute info_nce, back-propagate, step. Return the mean loss."""
    raise NotImplementedError("train_epoch() arrives in Step 3")


# ---------- Step 4: hard negatives ----------

def hardest_negatives(model, pairs, k=1):
    """For each pair, the ids of the k documents that score highest but are not the pair's gold document: the ones the
    model is most likely to confuse it with. Returns a list of lists of doc ids."""
    raise NotImplementedError("hardest_negatives() arrives in Step 4")


# ---------- Step 5: keep the best checkpoint ----------

def best_epoch(history):
    """The epoch (an entry's "epoch") with the highest dev "mrr" in history; the earliest on a tie."""
    raise NotImplementedError("best_epoch() arrives in Step 5")
Provided for you:harness.pykb.jsontry_it.py

Frequently asked questions

What is contrastive learning for embeddings?

It trains an encoder so that matching pairs (a question and its answer) sit close in vector space while non-matching pairs sit far apart. With in-batch negatives, every other item in the batch acts as a negative for free, so one loss over a batch teaches the model to separate the right answer from many wrong ones.

What is InfoNCE loss?

InfoNCE scores every query against every document in a batch, divides by a temperature, and applies cross-entropy that wants each query to pick its own document on the diagonal. Lower temperature pushes harder on the negatives.

What are hard negatives and why mine them?

Hard negatives are documents the model scores highly but are wrong for a query. Training against them, rather than only easy in-batch negatives, is what sharpens a retriever, and mining them deliberately is how larger retrieval models are trained.

Training an embedding model for retrieval

A retrieval model turns a question and a document into vectors and ranks them by closeness. Off the shelf it is a strong baseline, but fine-tuning it on your own question-and-answer pairs, with a contrastive loss, is what lifts recall on the queries your users actually type. You measure retrieval on the frozen encoder, write the in-batch InfoNCE loss that pulls each question towards its answer and away from the rest of the batch, train the encoder, mine the hard negatives it confuses, and keep the checkpoint where dev retrieval peaks before it starts overfitting the training questions.