Encoders and embedding models
BERT-style models and where they fit.
Create a free account Try part of it here
Why it matters
GPT-style decoders get the headlines. Encoders do much of the quiet work in applied AI. They make the embeddings behind the RAG systems from Phase 1. They power rerankers that sort search results. They run classifiers that tag millions of support tickets cheaply. An encoder is built from the same transformer blocks as a decoder, with one change: its attention looks both ways. It also learns through a different training game.
What you'll be able to do
- Contrast encoders (bidirectional attention) with decoders (causal attention)
- Explain masked language modeling, the training objective behind BERT
- Describe how encoders produce embeddings, classifications and reranking scores
- Choose between encoder, decoder and encoder-decoder models for a task
What it covers
- Reading in both directions
- A new training game: fill in the blanks
- Turning a whole text into one vector
- Worked example: averaging without the padding
- Classifiers and rerankers
- Encoder-decoder models
Reading in both directions
Try to fill the gap in this sentence: "The ___ of the river was muddy after the storm." You can't guess the word from "The" alone. The clue, "of the river", comes after the gap. To understand a word, you often need the words on both sides of it.
GPT-style models can't do that. A decoder, such as GPT, uses a causal mask. Each token may only look at the tokens before it. That rule is necessary for writing text from left to right, one token at a time.
An encoder is a transformer that drops the mask. Every token may look at every other token, before it and after it. This is called bidirectional attention, meaning "both directions". Everything else is the same transformer block you already know.
The code below tests the difference. It makes six random token vectors and runs attention on them twice: once with the causal mask, once without. Then it changes only the last token and checks which outputs moved.
import math import torch def attention(x, W_q, W_k, W_v, causal): q, k, v = x @ W_q, x @ W_k, x @ W_v s = q @ k.T / math.sqrt(q.shape[-1]) if causal: T = x.shape[0] s = s.masked_fill(~torch.tril(torch.ones(T, T)).bool(), float("-inf")) return torch.softmax(s, dim=-1) @ v torch.manual_seed(0) x = torch.randn(6, 8) W = [torch.randn(8, 8) for _ in range(3)] x2 = x.clone() x2[5] = torch.randn(8) # change only the LAST token for name, causal in [("decoder (causal)", True), ("encoder (bidirectional)", False)]: a, b = attention(x, *W, causal), attention(x2, *W, causal) changed = [i for i in range(6) if (a[i] - b[i]).abs().max().item() > 1e-6] print(f"{name:<24} outputs that changed: {changed}")
Here's what each piece does.
attentionis the scaled dot-product attention from earlier lessons. Thecausalswitch turns the mask on or off.x2is a copy of the six tokens. Only token 5, the last one, gets new random numbers.- The loop runs each version on the old tokens and the new ones. It lists every position whose output moved by more than one millionth.
The decoder prints [5]. Only the last output changed, because no earlier token can see the last one. The encoder prints all six positions. Changing the last word changed every output, the first one included.
That's what you want for understanding. In "the bank of the river", the meaning of "bank" depends on words that come after it. It's also why an encoder can't write text left to right. If it tried to guess the next word, it would already see the answer. (torch here is the course's in-browser PyTorch subset.)
Show the answer
All six positions
With no causal mask, every token looks at token 2, so every output can move. In a decoder, only positions 2 to 5 could change.
A new training game: fill in the blanks
A decoder learns by guessing the next token. That game doesn't work for an encoder. It already sees the whole sentence, so the next token is sitting right there in its input. It could copy the answer and learn nothing.
So encoders play a different game. Think of a fill-in-the-blanks quiz at school. Some words are blanked out, and you use the words around each gap to fill it in. BERT, an encoder released by Google in 2018, introduced this for transformers. The method is called masked language modeling. About 15% of the tokens are chosen and hidden, usually behind a special [MASK] token. The model must guess each hidden word from the context on both sides.
The code below picks the blanks for one product sentence.
import re import torch sentence = "The Cedar rain jacket is made from recycled nylon . It is light and fully waterproof ." tokens = sentence.split() torch.manual_seed(3) masked = torch.rand(len(tokens)) < 0.15 inputs = [("[MASK]" if m else t) for t, m in zip(tokens, masked.tolist())] targets = [t for t, m in zip(tokens, masked.tolist()) if m] print("model sees: ", " ".join(inputs)) print("must predict: ", targets) print(f"training signals from this sentence: {len(targets)} of {len(tokens)} tokens")
Here's what each piece does.
sentence.split()cuts the sentence at every space, giving 17 tokens. Real models use a tokenizer, as in the tokenization lesson. Splitting at spaces keeps the example easy to read.torch.rand(len(tokens))draws a random number between 0 and 1 for each token. A token is hidden when its number is below 0.15. So each token has a 15% chance.inputsis what the model sees, with[MASK]in place of each hidden token.targetslists the hidden words it must guess.
Each token is picked by chance, so one short sentence can get more or fewer than 15% hidden. Over millions of sentences, the share settles very close to 15%.
Here's the catch. Only the hidden positions give a training signal: a guess that gets graded. That's about 15% of the tokens. A GPT-style decoder makes a graded guess at every position. So masked training learns less from each sentence. That's one reason decoder-style pretraining won out for the largest models. (Pretraining is the first, longest stage of training, on huge amounts of text.)
Go deeper Why BERT doesn't always use [MASK]
BERT doesn't always swap a chosen token for [MASK]. Sometimes it puts in a random word. Sometimes it leaves the word as it was. In the original BERT paper, the split was 80% [MASK], 10% random word and 10% unchanged.
The reason: [MASK] never shows up in the real text the model reads later. If every chosen spot said [MASK], the model could learn to pay close attention only there. Mixing things up means any token might be one it gets graded on. So it has to build a good picture of every token.
Show the answer
Its attention looks at the whole input, including the positions it would be predicting
Attention in both directions is great for understanding. But when guessing the next word, it would let the model peek at the future.
Turning a whole text into one vector
In Phase 1 you used embeddings. An embedding is a list of numbers that captures what a text means, so you can compare texts by meaning. Most embedding models are encoders underneath.
An encoder gives back one vector per token. A sentence of 12 tokens gives 12 vectors. Search needs one vector per text, so the model squashes the token vectors into one. This step is called pooling. The most common way is mean pooling: take the average. Add up the vectors number by number, then divide by how many there are.
Think of a team sheet. Each player has a score for speed and a score for strength. The team average gives one speed number and one strength number for the whole team. Mean pooling does the same with token vectors.
A plain encoder doesn't make a good embedding model straight away. It's trained further with contrastive learning. The model sees pairs that belong together, such as a question and its answer. Training pulls their vectors closer. It pushes the vectors of unrelated texts apart. The result is an embedding model like the ones used for semantic search and RAG.
In plain words: an embedding model is an encoder plus an averaging step. It's trained so that texts which mean the same thing end up with similar vectors.
vecs = [[2.0, 4.0], [4.0, 0.0]] print([(a + b) / 2 for a, b in zip(*vecs)])
Show the answer
[3.0, 2.0]
Average the first numbers: (2 + 4) / 2 = 3.0. Average the second numbers: (4 + 0) / 2 = 2.0. Pooling works number by number, not token by token.
Worked example: averaging without the padding
Texts in a batch have different lengths. Shorter ones get filler tokens, called padding, so they all line up. Padding carries no meaning, so pooling must skip it. Here a text of three tokens has one padding token at the end. Each token vector has two numbers. Work out the mean of the real tokens. Then see what happens if the padding sneaks in.
tokens = [[1.0, 2.0], [3.0, 4.0], [5.0, 0.0], [0.0, 0.0]] # the last row is padding keep = [True, True, True, False] kept = [vec for vec, k in zip(tokens, keep) if k] mean = [sum(vec[d] for vec in kept) / len(kept) for d in range(2)] wrong = [sum(vec[d] for vec in tokens) / len(tokens) for d in range(2)] print("mean of the real tokens:", mean) print("mean with padding: ", wrong)
The real tokens are [1, 2], [3, 4] and [5, 0]. The first numbers add up to 1 + 3 + 5 = 9, and 9 / 3 = 3. The second numbers add up to 2 + 4 + 0 = 6, and 6 / 3 = 2. So the text's vector is [3.0, 2.0].
Counting the padding adds a row of zeros and divides by 4 instead of 3. That gives [2.25, 1.5], a vector dragged toward zero. The same text would get a different vector depending on how much padding its batch needed. The keep list is the fix. It marks real tokens with True and padding with False. You'll build the same thing with tensors in the exercise.
Classifiers and rerankers
Classifiers. A classifier sorts texts into labels you choose. Is a review positive or negative? Which topic is this email about? Is this comment abusive? To build one, add a small layer on top of the pooled vector that picks a label. Then fine-tune: train the whole model a little more on your own labeled examples. Small encoders do this fast and cheaply. That matters when you have millions of texts.
Rerankers. You met reranking in Phase 1. A search first fetches candidates by comparing embeddings. Then a reranker re-scores the best few. Most rerankers are cross-encoders. A cross-encoder reads the query and one document together, as a single input. It outputs one relevance score.
Why is that more accurate? With separate embeddings, the query and the document never meet. Each is squashed into a vector on its own, and then the two vectors are compared. In a cross-encoder, every query token can attend to every document token. It can notice that "waterproof" in the query matches "fully waterproof" in a product page.
The cost is speed. Document embeddings can be worked out once, ahead of time, and stored. A cross-encoder has to run again for every pair of query and document. So pipelines fetch candidates with embeddings and rerank only the top few with a cross-encoder.
Show the answer
It must run once for every query and document pair, so it's too slow for everything
Embeddings for documents can be stored ahead of time. A cross-encoder reads each query and document together, so it has to run fresh for every pair. That's accurate but slow, so it gets a short list.
Encoder-decoder models
The original transformer, from 2017, had both halves. Picture an interpreter. First they hear the whole sentence. Then they speak the translation, glancing back at what they heard.
The encoder reads the source sentence, with attention in both directions. The decoder writes the translation one token at a time, with a causal mask. To look back at the source, the decoder uses cross-attention. The queries come from the decoder. The keys and values come from the encoder's outputs. So each word being written can look up the parts of the source it needs.
T5 and BART are well-known encoder-decoder models. Today most general assistants are decoder-only. Encoder-decoders are still common in translation and speech recognition.
Choosing a model
| Task | Typical choice |
|---|---|
| Generate text, chat, write code | decoder (GPT, Claude, Llama) |
| Embeddings for search / RAG | encoder-based embedding model |
| High-volume classification | small fine-tuned encoder (or an LLM prompt if volume is low) |
| Reranking retrieved passages | cross-encoder |
| Translation, speech-to-text | encoder-decoder |
For marketers: say you tag a million product reviews by theme every night. That's a job for a small fine-tuned encoder, or for embeddings plus clustering (grouping similar vectors). It's not a job for a chat model at a fraction of a cent per call, a million times a night.
The difference comes down to who can see what.
Show the answer
A small fine-tuned encoder
High-volume classification is a classic encoder job: fast, cheap and consistent. A prompted chat model would work, but at 2 million calls a night it's slow and expensive.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
What does an encoder's attention look like compared with a decoder's?