ZB Field Notes

Positional embeddings and vector addition

Positional embeddings and vector addition

The whole thing is one addition

I lost the better part of a day to positional embeddings. Not because the maths is hard — because everything is called “embedding,” and the shapes look like they should clash. When it finally clicked, the operation underneath turned out to be almost insultingly simple: you add two vectors. A token becomes what it is (its meaning vector) plus where it sits (a position vector). That’s the entire trick — a sum.

This is the maths under that sum, built from zero, with the slides I drew to teach it. It’s the intuition companion to the code-first walkthrough in The Lookup Table That Learns; here I only care about one question — why does adding a vector give a model a sense of order?

The problem: a lookup table is order-blind

An embedding is a pure lookup. The row you get back depends on the token id and nothing else — so the same id always returns the same vector, wherever it appears.

Feeding token id 345 twice returns byte-identical vectors (torch.equal is True); in 'the cat sat on the mat' both 'the's get the same vector, so the sentence is an unordered bag of tokens.
Same id in, same row out. Both “the”s are identical vectors, so the model can’t tell first from second — order is invisible.

To a model with only token embeddings, “the cat sat on the mat” and “mat the on sat cat the” are the same bag of vectors. Word order carries meaning — “dog bites man” is not “man bites dog” — so this has to be fixed before attention ever runs.

The fix: a second vector, added on

Ask two independent questions about every token. What word are you? — answered by the token embedding, a lookup in the big vocabulary table. Where do you sit? — answered by a second, separate embedding. Then add the two vectors together. Everything else is the mechanics of that add.

The mental model that unstuck me: chairs and stickers. A window has a fixed number of seats; each seat carries a sticker — a vector — and the sticker belongs to the seat, not to whoever sits in it.

Four chairs (slots 0-3), each holding a fixed sticker vector; whoever sits in a chair gets that chair's sticker added on top.
Chairs are positions; stickers are the position vectors. The sticker is glued to the seat — the same word in two seats picks up two different stickers.

Why the sizes never actually clash

Here’s where I got stuck for hours. The two tables are wildly different sizes — the word table is [50257, 256], the position table is [4, 256] — and I couldn’t see how you add them. The answer is that you never add the tables. They’re just dictionaries on a shelf. You add the rows you look up.

A table comparing the word table (50,257 rows x 256) and the position table (4 rows x 256); both share width 256, which is what lets a looked-up word vector and position vector be added.
Different heights, same width. For one window both lookups return [4, 256] — the shared 256 is the bridge that makes the sum legal.

The two numbers come from opposite places. The 4 is the window length (context_length = max_length): four tokens means four seats. The 256 is output_dim — and it isn’t chosen for the position’s sake at all. It’s forced to equal the token vector’s width, because you can only add two vectors element by element if they’re the same length. One number is your window; the other is dictated by the word vector.

The worked example: same word, different seat

This is the payoff, done by hand. Take the window "the cat the mat", with “the” in slot 0 and slot 2. Shrink the vectors to size 3 so every number is visible (the real width is 256, same logic).

The token vector for 'the' is identical in slot 0 and slot 2; adding chair 0's vector versus chair 2's vector produces two different final vectors.
Both “the”s start with the identical token vector; adding a different seat vector to each makes the two finals differ. Order is now encoded.

Before the addition, the two “the”s are byte-for-byte identical. After adding each seat’s vector, they differ — purely because slot 0 contributed slot 0’s vector and slot 2 contributed slot 2’s. Nothing about the word changed; the seat broke the tie.

One sum, across the whole batch

Scale it up and one detail makes the shapes click. The token embeddings for a batch are [8, 4, 256] (8 windows), but the position table is only [4, 256] — no batch dimension. The addition still just works, by broadcasting.

Token embeddings [8,4,256] plus a position table [4,256] broadcasts across all 8 windows to give input embeddings [8,4,256].
The same four seat vectors are added to every one of the 8 windows automatically — slot 0 means “slot 0” in every sentence, so one small table serves the batch.

Why a sum and not a concatenation?

A fair question from a datasheet mindset: why add position instead of tacking it on as extra dimensions? Concatenation would grow every token from 256 to 256-plus-however-many, ballooning every downstream matrix. Addition keeps the width at 256 and lets training decide how to share those 256 directions between “meaning” and “position” — the model learns to keep them separable enough to be useful. Cheaper, and it works. Both vectors are learned, so the network is free to carve out the space however it likes.

So the maths under positional embeddings is one line: input = token_vector + position_vector. A token is what it is, plus where it sits. Do that for all 8×4 tokens and you land on the [8, 4, 256] tensor that attention consumes next — the end of the input pipeline, and the start of the interesting part.