Search Tech Journey

Find topics, journeys and posts

6-month learning plan104 / 130
back to blog
mladvanced 50m read

S104 · Embeddings — word2vec, GloVe, Contrastive Learning

From one-hot to dense vectors — how 300 numbers per word powered the NLP revolution before Transformers, and how contrastive learning generalised the idea to images, audio, and code.

🤖Machine LearningM12 · Deep Learning· Session 104 of 130 90 min

🎯 Understand what an embedding is, how word2vec learned to place king-man+woman near queen, and why contrastive learning is the modern universal recipe for representation learning.

Why this session exists

Embeddings are the single most important idea in modern ML — every LLM, retrieval system, recommender, and multimodal model runs on them. Before 2013, words were one-hot vectors (50,000 dims, one 1). After word2vec, they were 300-dim dense vectors where king - man + woman ≈ queen. Ten years later, the same idea powers CLIP (images + text in one space), Sentence-BERT (retrieval), and every RAG pipeline in production. If you don't feel embeddings in your bones, everything after this session will feel like magic.

You will be able to
  • Explain why one-hot vectors are useless for meaning and why dense embeddings work.
  • Describe the skip-gram objective used in word2vec — negative sampling and all.
  • Compute cosine similarity by hand and explain why it's the standard for embedding comparison.
  • Recognise the difference between static (word2vec) and contextual (BERT) embeddings.
  • Train a small embedding model in PyTorch and query it for nearest neighbours.

Prerequisites

  • S100 · PyTorch Fundamentals — you need nn.Embedding and cosine similarity.
  • S099 · Backpropagation — embeddings are learned by gradient descent, same as any weight.
  • S097 · Linear Algebra for ML — dot products and cosine, the entire session assumes fluency.


(a) Intuition · 5 min

Words as coordinates on a semantic map
🌍 Real world

Imagine a giant map of a city where restaurants are placed by cuisine. Italian places cluster in one area, sushi in another, tacos in a third. If you know a restaurant's coordinates, you can guess its cuisine — even if you've never heard of it.

Now imagine the map is 300-dimensional. Every restaurant has 300 coordinates. Not just cuisine — price, ambience, kid-friendliness, hours, walking distance from a metro. Two restaurants close on the map share many attributes. Two far apart share few.

💻 Code world

An embedding puts words on exactly this kind of map. Every word has 300 numbers. Words with similar meaning end up close (cat, kitten, feline). Analogies work because directions on the map mean things — the vector from "man" to "woman" is roughly the same as from "king" to "queen." Move a word by that vector and you change its gender.

The magic is that nobody hand-labelled these coordinates. They emerged from the simple objective: "words that appear in similar contexts should have similar vectors."

Why dense vectors beat one-hot

The four wins of dense embeddings
  • Similarity — cosine(cat, dog) > cosine(cat, table). One-hot vectors are equidistant from each other; they carry no similarity information at all.
  • Generalisation — a model trained on 'dog' data can transfer to 'puppy' because their vectors are close. One-hot makes them entirely independent tokens.
  • Compactness — 300 dims for 50K words = 15M params. One-hot × any weight matrix = O(50K · d), 100× larger.
  • Arithmetic — king - man + woman ≈ queen, Paris - France + Italy ≈ Rome. Directions encode relationships. This is impossible in one-hot space.

The embedding revolution timeline

  1. 1998
    Latent Semantic Analysis
    SVD on term-document matrix. Words as vectors, but linear and shallow.
  2. 2013
    word2vec · Mikolov et al.
    Google researchers publish skip-gram + CBOW. First scalable neural word embeddings. king-man+woman≈queen goes viral.
  3. 2014
    GloVe · Stanford
    Matrix factorisation of global co-occurrence stats. Slightly better than word2vec on some tasks, tied on others.
  4. 2018
    ELMo + BERT · contextual embeddings
    Same word → different vector depending on sentence. 'bank' in 'river bank' vs 'money bank' finally distinguished.
  5. 2020
    SimCLR · contrastive vision
    Two augmentations of same image → close; different images → far. Learns image embeddings without labels.
  6. 2021
    CLIP · OpenAI
    Contrastive on 400M image-text pairs. One embedding space for images AND text. Zero-shot classification everywhere.

(b) Visual walkthrough · 15 min

The skip-gram architecture

Skip-gram objective: given a center word, predict the surrounding context words in a window (typically ±5). The two embedding matrices (center + context) are the entire model. After training, keep only the center matrix — those are your word embeddings.

Negative sampling — the trick that made it fast

Walking through a training step

1sample
Pick a window

Sample a random position in the corpus. Center word + words within ±5 positions = context.

2lookup
Lookup center

v_c = E_center[center_word_id]. One row of the embedding matrix.

3lookup
Lookup positives + negatives

For each context word: v_pos = E_context[ctx_id]. Also sample 5 random 'noise' words: v_neg = E_context[noise_ids].

4score
Score each

score_pos = σ(v_c · v_pos), score_neg_i = σ(-v_c · v_neg_i). Both should approach 1.

5learn
Loss + backprop

L = -log(σ(v_c·v_pos)) - Σ log(σ(-v_c·v_neg_i)). Update E_center, E_context via SGD.

6iterate
Repeat 10-100M times

Slide window across the corpus for several epochs. Embeddings converge to something meaningful in a few hours on CPU.

word2vec vs GloVe vs BERT vs CLIP

word2vec (2013)

Prediction-based

  • Skip-gram + neg sampling
  • 300-dim static vectors
  • 'bank' = one vector, ignoring context
  • Fast, small, good for baselines
GloVe (2014)

Count-based (matrix factorisation)

  • Factors global co-occurrence log-counts
  • Pre-trained vectors ubiquitous (Twitter, Wikipedia)
  • Similar quality to word2vec
  • Cheaper to train
BERT (2018)

Contextual

  • Same word gets different vector per sentence
  • 12-24 Transformer layers
  • Vectors capture syntax + semantics
  • Foundation of modern NLP
CLIP (2021)

Multimodal contrastive

  • Image encoder + text encoder
  • Trained: matching (img, caption) close, others far
  • One shared embedding space
  • Powers Stable Diffusion, zero-shot classification

Cosine similarity — the metric that runs the show

Why cosine, not Euclidean distance

cosine = A·B / (‖A‖·‖B‖)
Measures the ANGLE between vectors, ignoring magnitude. Range: -1 (opposite) to +1 (identical direction).
def
Magnitude is noise in embeddings
Word 'the' occurs 100× more than 'kumquat' — its embedding has larger norm. Cosine strips this out; only meaning-direction matters.
why
Also cheap to compute
Pre-normalise embeddings (divide by ‖·‖). Then cosine = pure dot product. A GPU can do millions per second.
fast
Basis for every vector DB
Pinecone, Weaviate, Milvus, pgvector — all default to cosine or its cousin (inner product on normalised vectors).
prod

Common misconception
✗ What most people think

"Embeddings capture meaning, so cosine similarity between two embeddings tells me how semantically similar the things are. If two items have similar vectors, they mean similar things."

✓ What is actually true

Embeddings encode whatever similarity the training objective rewarded, which is usually distributional co-occurrence rather than meaning. Word2vec-style training places antonyms extremely close together, because "good" and "bad" appear in nearly identical contexts. High cosine similarity means "interchangeable in the contexts this model was trained on" — which coincides with semantic similarity often enough to be useful, and diverges precisely where it matters most.

Why the myth is so sticky

The myth is sticky because the famous demonstrations are so compelling. king − man + woman ≈ queen genuinely works, and analogy arithmetic looks like proof that the space encodes meaning as geometry. But those examples are selected, the arithmetic is fragile (results often land on the input words themselves unless they are explicitly excluded), and the underlying training signal never saw a definition — only which words appeared near which. The distributional hypothesis is a good approximation to meaning, not meaning itself, and the antonym case is where the gap is unmissable.

Prove it to yourself

Check the two things everyone assumes and neither of which holds cleanly:

# 1. antonyms are near-neighbours, not distant
print(model.similarity('good', 'bad'))       # high
print(model.similarity('good', 'cheese'))    # low
# similar context != similar meaning

# 2. a static embedding has ONE vector per word,
#    so both senses collapse into one point
print(model.most_similar('bank')[:5])
# river senses and finance senses mixed in one neighbourhood
# -- the reason contextual embeddings exist
From first principles
Start with the question

Why does an embedding layer work at all, given it is literally a lookup table of learned vectors? It has no structure, no similarity metric built in, and is initialised randomly. Nothing tells it that "cat" and "dog" should end up near each other.

  1. 1
    An embedding layer is mathematically a one-hot vector multiplied by a weight matrix. The one-hot selects exactly one row, so the operation is a lookup — implemented as indexing purely to avoid materialising a huge sparse multiply.
    forced by · multiplying by a one-hot vector is row selection, and doing it literally would be enormously wasteful
  2. 2
    Since it is a weight matrix, it receives gradients like any other layer. The gradient for a row is non-zero only when that token appeared in the batch, so each vector is updated only by examples containing it.
    forced by · the unselected rows contribute nothing to the forward pass and therefore nothing to the gradient
  3. 3
    Now consider two tokens that appear in similar contexts and lead to similar downstream targets. Whenever either appears, the downstream network needs a similar input to produce the correct output, so both rows are pushed toward whatever value satisfies that requirement.
    forced by · gradient descent moves each row toward reducing the loss of the examples it participated in, and those losses are shaped by the same downstream weights
  4. 4
    Repeated across a corpus, tokens that are functionally interchangeable converge to nearby vectors — not because similarity was designed in, but because the downstream network cannot distinguish them and needs them to behave alike.
    forced by · tokens with identical downstream requirements have no gradient pressure separating them
  5. 5
    The geometry is therefore induced by the task, not intrinsic. Train the same lookup table against sentiment and "good" and "bad" separate sharply; train it against next-word prediction and they collapse together.
    forced by · the only force organising the space is the loss, so the space encodes exactly what the loss cared about
⇒ Therefore

Therefore embeddings are not a special mechanism — they are ordinary learned weights whose geometry is a side effect of the objective. "Similar" always means "similar with respect to the training task".

And note what this predicts: a token that appears rarely receives few gradient updates and keeps a vector close to its random initialisation, so rare-token embeddings are essentially noise. That is exactly why subword tokenisation exists — it guarantees every piece appears often enough to be trained. It also predicts that embeddings are only comparable within the model that produced them: two separately trained models place their axes arbitrarily, so cosine similarity across models is meaningless without an explicit alignment.

Mental modelA learned coordinate system where the task's notion of similarity becomes distance

One-hot encoding places every category on its own orthogonal axis, so every pair is exactly equidistant — the representation asserts that nothing is more similar to anything else. It also costs one dimension per category.

An embedding compresses those into a dense low-dimensional space where the model is free to choose the positions. Training arranges them so that items the task treats alike end up near each other. The dimensions have no inherent meaning; what carries information is the relative geometry — distances and directions — and only with respect to the objective that produced them.

  • Embedding dimension is a capacity knob, not a semantic one. Common heuristics scale it with cardinality (roughly the fourth root, or a small multiple of it) and then tune.
  • Cosine similarity for direction, dot product when magnitude carries information (often frequency or confidence). Normalise deliberately, and know which one your retrieval index assumes.
  • Static embeddings assign one vector per token, so polysemy collapses. Contextual embeddings from transformers produce a different vector per occurrence, which is the entire advance.
  • Embeddings are only comparable within the model and version that produced them. Re-training or upgrading the model invalidates every stored vector — which makes an embedding index a versioned artefact, not a cache.
🔔 Fires when you see

Fire this the moment you see: one-hot encoding of a high-cardinality column · cosine similarity compared across two different models · a vector index not versioned with the model that filled it · rare categories with untrained embeddings · an assumption that nearest neighbours are semantically similar rather than distributionally similar · embeddings used for a task very different from the one they were trained on.

The tradeoff

You need vector representations for a retrieval system. Do you use an off-the-shelf pretrained embedding model, fine-tune one on your domain, or train task-specific embeddings from scratch?

Off-the-shelf pretrained embeddings
+ you gain available immediately with no training data and no training infrastructure; trained on enormous corpora, so they encode broad general-purpose structure that a small in-house dataset could never produce; and swapping models is a config change, so you can benchmark several in an afternoon
− you pay the similarity notion is the provider's, not yours — domain jargon, product codes, and internal terminology may be tokenised into fragments and land nowhere useful; dimensions are fixed, so storage and index cost are not yours to control; and if it is a hosted API you have added a network dependency, a per-call cost, and a data-residency question to every indexing job
pick when general-domain text, a need to ship quickly, or as the baseline you must beat before justifying anything more expensive
Fine-tune a pretrained model on your domain
+ you gain keeps the general linguistic structure while adapting the geometry to your notion of relevance, typically the largest accuracy gain per unit of effort; with contrastive training on your own positive pairs — click logs, linked tickets, duplicate reports — you directly optimise the ranking you actually care about
− you pay needs labelled or weakly-labelled pairs, and mining good negatives is the hard part that determines whether it works at all; adds a training pipeline plus the versioning discipline that every re-embedding requires; and it can overfit to your training distribution and degrade on queries unlike anything in the logs
pick when you have interaction data implying relevance and the domain is far enough from general text that the off-the-shelf baseline is visibly weak
Train task-specific embeddings from scratch
+ you gain full control over dimensionality, vocabulary, and objective, so you can encode exactly your notion of similarity and keep vectors small — which matters when the index holds hundreds of millions of items and memory is the binding cost. Essential for non-linguistic entities (user IDs, SKUs, sessions) where no pretrained model exists.
− you pay needs a large volume of in-domain interaction data to learn anything beyond noise; rare entities remain badly represented no matter what; and you own the entire pipeline including cold-start handling for entities that appear after training
pick when the entities are not language at all — collaborative-filtering-style item and user embeddings — or your interaction data is large enough to support it
What a senior engineer actually does

Start with a pretrained model and measure retrieval quality on real queries before doing anything else. It is a strong baseline, and a surprising number of embedding projects end there because it was already sufficient. Fine-tune when you have relevance signal and a measured gap, since that is the intervention with the best return once the baseline exists.

The operational point that outlasts the model choice: an embedding index is tightly coupled to the model version that produced it. Changing models means re-embedding everything, so plan for that from the start — version the index, keep the embedding job idempotent and re-runnable, and never mix vectors from two model versions in one index. That coupling causes more production incidents than embedding quality ever does.


(c) Hands-on · 25 min

Train a mini skip-gram word2vec on a text corpus. Then query it for nearest neighbours and analogies.

# mini_w2v.py — a from-scratch skip-gram with negative sampling.
# Usage: uv run mini_w2v.py path/to/corpus.txt
import sys
import random
import re
from collections import Counter
 
import torch
import torch.nn as nn
import torch.nn.functional as F
 
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
EMB_DIM = 100
WINDOW = 5
NEG_SAMPLES = 5
EPOCHS = 5
BATCH = 512
LR = 5e-3
MIN_COUNT = 5
 
 
def tokenise(text: str) -> list[str]:
    return re.findall(r"[a-z']+", text.lower())
 
 
def build_vocab(tokens: list[str], min_count: int) -> tuple[dict[str, int], list[str], list[float]]:
    counts = Counter(tokens)
    vocab = [w for w, c in counts.most_common() if c >= min_count]
    stoi = {w: i for i, w in enumerate(vocab)}
    # Unigram distribution raised to 0.75 (Mikolov's trick) for negative sampling.
    freqs = torch.tensor([counts[w] for w in vocab], dtype=torch.float)
    weights = (freqs ** 0.75)
    weights /= weights.sum()
    return stoi, vocab, weights.tolist()
 
 
def make_pairs(tokens: list[str], stoi: dict[str, int], window: int) -> list[tuple[int, int]]:
    ids = [stoi[t] for t in tokens if t in stoi]
    pairs = []
    for i, center in enumerate(ids):
        w = random.randint(1, window)
        for j in range(max(0, i - w), min(len(ids), i + w + 1)):
            if i != j:
                pairs.append((center, ids[j]))
    return pairs
 
 
class SkipGram(nn.Module):
    def __init__(self, vocab_size: int, dim: int):
        super().__init__()
        self.center = nn.Embedding(vocab_size, dim)
        self.context = nn.Embedding(vocab_size, dim)
        nn.init.uniform_(self.center.weight, -0.5 / dim, 0.5 / dim)
        nn.init.zeros_(self.context.weight)
 
    def forward(
        self, center: torch.Tensor, pos: torch.Tensor, neg: torch.Tensor
    ) -> torch.Tensor:
        v_c = self.center(center)         # [B, D]
        v_pos = self.context(pos)         # [B, D]
        v_neg = self.context(neg)         # [B, K, D]
        pos_score = (v_c * v_pos).sum(dim=1)              # [B]
        neg_score = torch.bmm(v_neg, v_c.unsqueeze(2)).squeeze(2)  # [B, K]
        loss = -F.logsigmoid(pos_score).mean() - F.logsigmoid(-neg_score).mean()
        return loss
 
 
def train(path: str) -> None:
    text = open(path, "r", encoding="utf-8").read()
    tokens = tokenise(text)
    stoi, vocab, neg_weights = build_vocab(tokens, MIN_COUNT)
    print(f"vocab={len(vocab):,}  tokens={len(tokens):,}")
    pairs = make_pairs(tokens, stoi, WINDOW)
    print(f"training pairs: {len(pairs):,}")
 
    model = SkipGram(len(vocab), EMB_DIM).to(DEVICE)
    opt = torch.optim.Adam(model.parameters(), lr=LR)
    weights_t = torch.tensor(neg_weights)
 
    for epoch in range(1, EPOCHS + 1):
        random.shuffle(pairs)
        total = 0.0
        steps = 0
        for i in range(0, len(pairs), BATCH):
            batch = pairs[i : i + BATCH]
            if len(batch) < BATCH:
                continue
            c, p = zip(*batch)
            c = torch.tensor(c, device=DEVICE)
            p = torch.tensor(p, device=DEVICE)
            neg = torch.multinomial(weights_t, BATCH * NEG_SAMPLES, replacement=True)
            neg = neg.view(BATCH, NEG_SAMPLES).to(DEVICE)
            opt.zero_grad()
            loss = model(c, p, neg)
            loss.backward()
            opt.step()
            total += loss.item()
            steps += 1
        print(f"epoch {epoch}  loss={total/steps:.4f}")
 
    # Save + demo queries.
    emb = F.normalize(model.center.weight.detach(), dim=1)  # normalise for cosine
    itos = {i: w for w, i in stoi.items()}
    for word in ["king", "computer", "love", "war"]:
        if word not in stoi:
            continue
        v = emb[stoi[word]]
        sims = emb @ v
        top = sims.topk(6).indices.tolist()
        print(f"nearest to {word!r}: {[itos[i] for i in top if i != stoi[word]][:5]}")
 
 
if __name__ == "__main__":
    train(sys.argv[1])

Anatomy of the script

What the interesting lines do

counts.most_common() + min_count filter
Drop rare words (default: seen \<5 times). Prevents wasting param budget on words with no learnable signal. word2vec's original paper uses min_count=5.
vocab
weights = freqs ** 0.75
Mikolov's negative-sampling distribution. Raising counts to the 0.75 power boosts rare words vs unigram (freqs^1). Empirically the sweet spot.
trick
random.randint(1, window) per pair
Dynamic window size — nearby words weighted more (they're picked more often as the window shrinks). Cheap way to encode 'closer = more relevant'.
trick
self.center + self.context (two embeddings)
Skip-gram uses TWO matrices, not one. Keeps center vector for a word separate from its 'context' role. Only the center matrix is exported as the final embedding.
arch
torch.bmm(v_neg, v_c.unsqueeze(2))
Batched matrix multiply — computes B dot products in one call. Way faster than a Python loop.
vectorise
-logsigmoid(pos_score) - logsigmoid(-neg_score)
Binary cross-entropy per sample. Positive pairs push sigmoid → 1, negative pairs push sigmoid → 0. Standard neg-sampling loss.
loss
F.normalize(model.center.weight, dim=1)
Unit-norm each embedding. After this, dot product = cosine similarity. Ready for nearest-neighbour queries.
postproc
Try itExtend the script to answer analogies via vector arithmetic
def analogy(word_a: str, word_b: str, word_c: str, top: int = 5) -> list[str]:
    ids = {w: stoi[w] for w in (word_a, word_b, word_c) if w in stoi}
    if len(ids) < 3:
        return []
    v = emb[ids[word_a]] - emb[ids[word_b]] + emb[ids[word_c]]
    v = F.normalize(v, dim=0)
    sims = emb @ v
    excluded = set(ids.values())
    ranked = [i for i in sims.topk(top + 3).indices.tolist() if i not in excluded]
    return [itos[i] for i in ranked[:top]]

Small corpora produce noisy analogies; Wikipedia-scale ones nail them. This is exactly the demo Mikolov ran in 2013 that made word2vec go viral.

💡 Hint · Add a function `analogy(a, b, c)` that computes `emb[a] - emb[b] + emb[c]`, normalises, and returns the top-5 nearest words (excluding a, b, c). Try 'paris - france + italy' → 'rome', 'walking - walk + swim' → 'swimming'.

(d) Production reality · 15 min

War story Airbnb · listing recommendations· 2018150M+ users
🔥 What broke

Airbnb's search ranked listings using content features (price, room type, photos). Personalisation was weak — a user who'd only booked beach cabins would still see downtown apartments. The team tried word2vec on user click sessions (treating listings as 'words' and sessions as 'sentences') to get listing embeddings.

First version tanked in A/B tests. Listings with different countries were ending up close because they shared demographic click patterns — but a Paris apartment is not a substitute for a Tokyo hotel.

🧯 The fix
They added a global-market constraint to the negative sampling — negatives were sampled from the SAME market as the positive, forcing embeddings to encode style / vibe rather than just click co-occurrence. Booking conversion +21%. This is one of the most-cited industry applications of word2vec ever.
🎓 Lesson to steal
word2vec is not just for words. Any sequence — user sessions, product co-views, songs in playlists — can be embedded. But the sampling strategy is where the domain knowledge lives.
Post-mortem
War story Spotify · Discover Weekly· 2017100M+ users
🔥 What broke
Spotify's original Discover Weekly used collaborative filtering — recommend songs users with similar taste also listened to. Cold-start was terrible: new songs had no listens, so they never got recommended.
🧯 The fix
They embedded songs three ways: (1) collaborative filtering from listening logs, (2) NLP embeddings of song reviews/blog posts, (3) audio embeddings from a CNN on raw waveforms. Then they concatenated all three into a single 4096-dim vector. New songs get the audio + text embedding immediately, and slot into Discover Weekly on day one.
🎓 Lesson to steal
The best recommender doesn't pick between embeddings — it fuses several complementary embedding sources. Multimodal is not just an academic exercise.
Post-mortem
War story OpenAI · CLIP training· 2021400M image-text pairs
🔥 What broke
Early CLIP experiments used a caption-generation objective (predict caption text given image). Training was slow (LSTM decoder) and results were mediocre — the model learned to say generic captions but not to distinguish similar images.
🧯 The fix
Switched to contrastive loss: given a batch of N images and N captions, maximise cosine similarity of matching pairs and minimise it for the N²-N non-matching pairs. This was 12× more compute-efficient and produced dramatically better zero-shot classification. The paper's Figure 1 shows this exact ablation.
🎓 Lesson to steal
Contrastive objectives are much more sample-efficient than generative ones when you only need a good embedding, not a full generator. This lesson generalises: SimCLR, DINO, and audio-CLIP all use it.
Post-mortem

Where this shows up in the rest of the plan

Embeddings are the connective tissue of every downstream ML system
S106 · Tokenization
The mapping from text to token IDs — the input to every embedding lookup.
S111 · Full Transformer
Transformers begin with an embedding lookup. Everything else is transformations on top.
S125 · RAG
Retrieval-augmented generation runs on sentence embeddings + vector search. Session S104 is the foundation.
S126 · Vector DBs
Pinecone, pgvector, Weaviate — infrastructure specifically for storing + querying embeddings at scale.
S127 · CLIP + Multimodal
Image + text in one embedding space. Direct descendant of word2vec ideas.
S086 · Recommenders
Every modern recommender is an embedding-lookup + dot-product architecture.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

If you can't teach these three without notes, redo the session:

  1. Why do dense embeddings work for language when one-hot vectors don't?
  2. How does skip-gram + negative sampling actually learn embeddings?
  3. What is contrastive learning, and why is it the universal recipe for modern representation learning?

What comes next

Hub: The 6-Month Learning Plan


Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.