The loss went to zero and the model had learned nothing
retrieval
embeddings
week-2
Week 2 was a two-tower retrieval system. Building it was straightforward. The part worth writing down is how convincingly the training loss lied about whether it was working.
Author
Rosh Beed
Published
September 18, 2026
Week 2: build the thing behind semantic search. Given a query, find the passage in a corpus that answers it.
The obvious approach is to take the query and a candidate passage, feed both into one model, and let it score the pair. That works well and is completely impractical, because you have to run it once for every passage in the corpus, for every query.
The dual encoder gets around it by refusing to let the query and the document meet. Two separate towers, one for each, both producing a vector in the same space. A query is relevant to a passage if their vectors are close.
That constraint is the whole point: because the document tower never sees the query, you can run it before any query exists. Embed the entire corpus once, offline, put the vectors in an index, and at query time you embed one short string and do a nearest-neighbour lookup.
Show the code
import syssys.path.insert(0, "..")import numpy as npfrom _style import COLOURS, MUTED, figure, style_axescorpus = np.logspace(2, 6, 200)fig, ax = figure(height=3.8)ax.plot(corpus, corpus, color=COLOURS[0], linewidth=2)ax.plot(corpus, np.ones_like(corpus), color=COLOURS[1], linewidth=2)ax.annotate("cross-encoder: score every passage", xy=(2000, 2000), xytext=(-4, 12), textcoords="offset points", fontsize=9, color=COLOURS[0])ax.annotate("dual encoder: embed the query, then look it up", xy=(2000, 1), xytext=(-4, 12), textcoords="offset points", fontsize=9, color=COLOURS[1])ax.set_xscale("log")ax.set_yscale("log")style_axes(ax, "Passages in the corpus", "Model runs per query")fig.tight_layout()
Figure 1: Model runs needed to answer one query. The cross-encoder has to score every passage; the dual encoder embeds the query once and lets an index do the rest.
Training it: triplet loss
The towers start out random, so you have to teach them what close means. The signal used in the project is a triplet: a query, a passage that answers it, and a passage that doesn’t. Push the first pair together and the second apart, by at least a margin \(m\):
\[\mathcal{L} = \max\big(0,\ m - s(q, d^{+}) + s(q, d^{-})\big)\]
If the positive is already more similar than the negative by the margin, the loss is zero and nothing happens. Otherwise both towers get nudged.
A toy corpus with free labels
Real retrieval training needs query-passage pairs, which is the expensive part. There’s a standard trick for getting them free: take a document, cut a piece out, and use the piece as the query. It’s called the inverse cloze task [1], and it’s how several retrieval models get pre-trained.
Here I take Hacker News titles of at least eight words and cut them in half. The first half is the query, the second half is the document it should retrieve. No labelling, and the two halves are genuinely about the same thing.
Show the code
import gzipimport jsonimport reimport zlibimport torchimport torch.nn as nnimport torch.nn.functional as Ffrom huggingface_hub import hf_hub_downloadREVISION ="f705fed08827ff6c36e3b5329495c943a5e544e8"path = hf_hub_download("roshbeed/ai-residency-blog-data", "hn/hn-sample.json.gz", repo_type="dataset", revision=REVISION)rows = json.load(gzip.open(path, "rt"))titles = [r["title"] for r in rows["train"]] + [r["title"] for r in rows["test"]]tokenise =lambda s: re.findall(r"[a-z0-9\+#]+", s.lower())pairs = []for title in titles: words = tokenise(title)iflen(words) >=8: half =len(words) //2 pairs.append((words[:half], words[half:]))print(f"{len(pairs):,} query/document pairs")print(f" query: {' '.join(pairs[0][0])}")print(f" document: {' '.join(pairs[0][1])}")
79,009 query/document pairs
query: california governor to deploy 500 surveillance
document: cameras to oakland to fight crime
Show the code
DIM =2**14def encode(sequences):"""Bag of hashed words. crc32 rather than hash() — Python's is salted per process.""" X = torch.zeros(len(sequences), DIM)for i, words inenumerate(sequences):for w in words: X[i, zlib.crc32(w.encode()) % DIM] +=1.0if words: X[i] /=len(words)return Xrng = np.random.default_rng(0)order = rng.permutation(len(pairs))train_ids, test_ids = order[:40_000], order[40_000:45_000]Q = encode([pairs[i][0] for i in train_ids])D = encode([pairs[i][1] for i in train_ids])Q_test = encode([pairs[i][0] for i in test_ids])D_test = encode([pairs[i][1] for i in test_ids])print(f"{len(Q):,} training pairs, {len(D_test):,} documents to search at test time")
40,000 training pairs, 5,000 documents to search at test time
Show the code
class Tower(nn.Module):"""Text in, a unit vector out. Query and document get one of these each."""def__init__(self, out=128):super().__init__()self.net = nn.Sequential(nn.Linear(DIM, 256), nn.ReLU(), nn.Linear(256, out))def forward(self, x):return F.normalize(self.net(x), dim=-1)def recall_at_10(query_tower, doc_tower):"""How often the right document is in the top 10 of all 5,000."""with torch.no_grad(): similarity = query_tower(Q_test) @ doc_tower(D_test).T top = similarity.topk(10, dim=1).indices correct = torch.arange(len(Q_test)).unsqueeze(1)return (top == correct).any(1).float().mean().item()CHANCE =10/len(D_test)print(f"chance recall@10 = {CHANCE:.4f}")
chance recall@10 = 0.0020
Show the code
from _arch import diagramdiagram(Tower(), input_shape=(1, DIM))
Figure 2: One tower. Both the query and the document side are this same shape, with separate weights, and the output is normalised to unit length so a dot product is a cosine.
epoch 1: loss 0.0121 recall@10 0.0020 (1.0x chance)
epoch 2: loss 0.0093 recall@10 0.0022 (1.1x chance)
epoch 3: loss 0.0123 recall@10 0.0022 (1.1x chance)
epoch 4: loss 0.0199 recall@10 0.0020 (1.0x chance)
epoch 5: loss 0.0142 recall@10 0.0020 (1.0x chance)
epoch 6: loss 0.0201 recall@10 0.0018 (0.9x chance)
epoch 7: loss 0.0179 recall@10 0.0020 (1.0x chance)
epoch 8: loss 0.0178 recall@10 0.0018 (0.9x chance)
The loss falls to near zero within one epoch, and recall never leaves chance. Out of 5,000 documents, the model finds the right one in its top ten about as often as picking ten at random would.
If you were watching the loss curve — which is the thing that’s easy to watch — you would conclude this was training beautifully.
Why it happens
A negative drawn at random from the corpus is about a different subject entirely. The towers only have to tell “machine learning” from “sourdough starter”, and they can do that almost immediately. Once the margin is satisfied, the loss is exactly zero, and a loss of zero has no gradient.
So the model stops learning while still being unable to do the thing you want, which is to tell the right passage from a thousand plausible ones.
The usual fix is to stop picking one negative and use the whole batch: every other document in the batch is a negative for this query, and the loss becomes a cross-entropy over which of them is correct. It’s a harder question, it stays hard as the model improves, and it costs nothing extra because those documents are already encoded.
Show the code
TEMPERATURE =0.05def in_batch(query_tower, doc_tower, b, perm, generator):"""Every other document in the batch is a negative for this query.""" q, docs = query_tower(Q[b]), doc_tower(D[b]) logits = (q @ docs.T) / TEMPERATUREreturn F.cross_entropy(logits, torch.arange(len(b)))batch_loss, batch_recall = train(in_batch)for epoch, (l, r) inenumerate(zip(batch_loss, batch_recall), 1):print(f"epoch {epoch}: loss {l:.4f} recall@10 {r:.4f} ({r / CHANCE:.1f}x chance)")
epoch 1: loss 5.3608 recall@10 0.0098 (4.9x chance)
epoch 2: loss 4.8804 recall@10 0.0166 (8.3x chance)
epoch 3: loss 4.0087 recall@10 0.0252 (12.6x chance)
epoch 4: loss 2.4321 recall@10 0.0302 (15.1x chance)
epoch 5: loss 0.9204 recall@10 0.0334 (16.7x chance)
epoch 6: loss 0.3301 recall@10 0.0342 (17.1x chance)
epoch 7: loss 0.1374 recall@10 0.0362 (18.1x chance)
epoch 8: loss 0.0757 recall@10 0.0362 (18.1x chance)
Figure 3: The same towers, the same data, the same number of steps. Only the choice of negatives differs.
Same towers, same data, same number of gradient steps. The only change is which documents count as negatives, and it’s the difference between a model that works and a model that doesn’t.
Note what the loss curves do. The triplet loss ends lower than the in-batch loss. If you ranked these two runs by final training loss you would pick the one that learned nothing.
Why this matters beyond the toy
Negative sampling is the part of contrastive training that looks like a detail and isn’t. The architecture — two towers, a shared space, an index — is the easy half and mostly writes itself. What decides whether the thing retrieves anything is the question you ask it during training, and “is this passage more relevant than one picked at random” is not a hard enough question to learn from.
That’s why the literature moved to in-batch negatives and then to actively mined hard negatives: passages that look plausible for the query and aren’t. Each step makes the training question harder in the same direction.
The general lesson I took from this one, and now apply everywhere: report a metric next to the number a model that learned nothing would get. Chance recall@10 over 5,000 documents is 0.002. Any recall figure without that 0.002 beside it is unreadable — and a falling loss beside it is worth nothing at all.
The full project, with the GRU towers, the sweep and the Redis index, is on GitHub.
[1] Lee, Chang, Toutanova. Latent Retrieval for Weakly Supervised Open Domain Question Answering. ACL 2019.