Embeddings and Attention
Table of Contents
- Why Words Need to Become Numbers
- A Word as a Point in Space
- Hand-Made Word Vectors
- Dot Product and Norm
- Cosine Similarity: How Close Are Two Words?
- Nearest Neighbours
- Real Embeddings Are Learned
- Searching by Meaning
- The Problem: One Vector per Word Is Not Enough
- Attention: Words Looking at Words
- Attention in Code
- Reading the Attention Table
- Context Changes the Meaning
- The Real Transformer, Honestly
- Recipe: The embeddings and attention Modules
- Try It Yourself
- Quick Review: Common Questions
1. Why Words Need to Become Numbers
A computer cannot do arithmetic on the word "coffee". In
Tokens and Tokenizers
you turned text into token numbers, and in
How a Model Is Trained
a word became a row number in a table. But a row number is just a label.
Word number 12 is not "close" to word number 13 in any useful way, and
the model has no idea that coffee and tea are alike while coffee
and dog are not.
We want something better: a representation where similar words get
similar numbers. Then whatever the model learns about coffee automatically
helps with tea, because their numbers are near each other. That
representation is called an embedding: a word (or token) turned into
a vector, which is just a list of numbers.
This idea is one of the most useful in all of modern AI. It is how a model begins to get at meaning, and it also powers search by meaning, which you will use in later series.
2. A Word as a Point in Space
You already know a vector with two numbers: a point on a map, (x, y).
Two numbers place a point on a flat page. Three numbers place a point in
a room. Four, ten, or a thousand numbers place a point in a space we
cannot picture, but the arithmetic works the same way.
Imagine a map where similar things are placed near each other:
animal
^
cat dog |
puppy |
|
+------------> drink
tea
coffee milk
cat, dog and puppy cluster together on one side. coffee, tea and
milk cluster on another. The direction and distance between points
carry meaning. A real embedding does this with hundreds or thousands of
dimensions, but here we will use four, so you can read every number.
3. Hand-Made Word Vectors
In a real model nobody writes the numbers by hand. For learning, we will,
so you can see why they work. Each word gets four numbers. We decide the
four dimensions mean [drink, animal, tech, person]: how much the word
is about each topic, from 0.0 to 1.0.
Create src/embeddings.js:
// src/embeddings.js
// Hand-made 4-number vectors. Dimensions: [drink, animal, tech, person].
// Real models LEARN their vectors, with hundreds or thousands of numbers.
export const vectors = {
coffee: [0.9, 0.0, 0.1, 0.0],
tea: [0.8, 0.0, 0.0, 0.1],
milk: [0.7, 0.2, 0.0, 0.0],
cat: [0.1, 0.9, 0.0, 0.1],
dog: [0.0, 0.9, 0.0, 0.2],
puppy: [0.0, 0.8, 0.0, 0.3],
phone: [0.0, 0.0, 0.9, 0.1],
laptop: [0.0, 0.0, 0.9, 0.2],
app: [0.0, 0.0, 0.8, 0.3],
student: [0.1, 0.0, 0.2, 0.9],
teacher: [0.0, 0.0, 0.2, 0.9],
sokha: [0.2, 0.0, 0.1, 0.9],
};
Twelve words, four numbers each. coffee is mostly "drink" with a hint of
"tech" (laptops and coffee shops, a bit of cheating for fun). puppy
is mostly "animal" with a little "person". Words about the same topic
get similar rows of numbers.
In a real model the dimensions have no names. Nobody decides that dimension 3 means "tech". The numbers are learned, and the meaning is spread over all dimensions together. We label ours only so you can follow the idea.
4. Dot Product and Norm
To measure how related two vectors are, we need two small tools.
The dot product multiplies two vectors position by position and adds up the results:
dot([1, 2, 3], [4, 5, 6]) = 1*4 + 2*5 + 3*6 = 4 + 10 + 18 = 32
Vectors that are big in the same positions give a large dot product. Vectors that are big in different positions give a small one. So the dot product is a rough score of "how much do these two agree".
The norm is the length of a vector, from Pythagoras: square every
number, add them, take the square root. For [3, 4] it is
sqrt(9 + 16) = 5, the same as the diagonal of a 3-4-5 triangle.
Add both functions to src/embeddings.js:
export function dot(a, b) {
return a.reduce((sum, x, i) => sum + x * b[i], 0);
}
export function norm(a) {
return Math.sqrt(dot(a, a));
}
(norm is the square root of the dot product of a vector with itself:
3*3 + 4*4 = 25, and sqrt(25) = 5.) Check them:
// demo-dot.js
import { dot, norm } from "./src/embeddings.js";
console.log(dot([1, 2, 3], [4, 5, 6]));
console.log(norm([3, 4]));
// 32
// 5
Both lines are exact and stable.
5. Cosine Similarity: How Close Are Two Words?
The raw dot product has a flaw: a long vector gives a large dot product just because it is long. We care about direction, "do these point the same way?", not length. So we divide by both lengths. The result is cosine similarity:
// 1 = same direction, 0 = unrelated, -1 = opposite.
export function cosine(a, b) {
return dot(a, b) / (norm(a) * norm(b));
}
Read the output as a similarity score. 1 means pointing exactly the
same way, 0 means nothing in common, and (with vectors that can go
negative) -1 means opposite. Our hand-made vectors have no negative
numbers, so ours will stay between 0 and 1.
// demo-cosine.js
import { vectors, cosine } from "./src/embeddings.js";
console.log(cosine(vectors.coffee, vectors.tea).toFixed(3));
console.log(cosine(vectors.coffee, vectors.dog).toFixed(3));
console.log(cosine(vectors.cat, vectors.dog).toFixed(3));
console.log(cosine(vectors.phone, vectors.laptop).toFixed(3));
// 0.986
// 0.000
// 0.988
// 0.994
coffee and tea score 0.986: nearly the same direction. coffee and
dog score exactly 0.000: nothing in common. cat with dog and
phone with laptop also score above 0.98. The numbers say what you
would say: coffee is like tea, a cat is like a dog, a phone is like a
laptop, and coffee is not like a dog.
All four lines are stable, since there is no randomness. The values are our hand-made ones, so they prove nothing about real language, only that the method works when the vectors are good.
6. Nearest Neighbours
Given any vector, we can find the closest words by scoring every word and
sorting. That is nearest:
// The k words whose vectors are most similar to vec.
export function nearest(vec, k = 3, table = vectors) {
return Object.entries(table)
.map(([word, v]) => ({ word, score: cosine(vec, v) }))
.sort((a, b) => b.score - a.score)
.slice(0, k);
}
It returns k objects like { word: "tea", score: 0.986 }. The closest
word to a word's own vector is always the word itself (score 1), so to
see its neighbours we ask for k + 1 and skip the first:
// demo-nearest.js
import { vectors, nearest } from "./src/embeddings.js";
const fmt = (r) => `${r.word} ${r.score.toFixed(2)}`;
for (const w of ["coffee", "dog", "laptop", "teacher"]) {
console.log(w, "->", nearest(vectors[w], 4).slice(1).map(fmt).join(", "));
}
// A made-up query: "something to drink", all weight on dimension 0.
console.log("query [1,0,0,0] ->", nearest([1, 0, 0, 0], 3).map(fmt).join(", "));
// coffee -> tea 0.99, milk 0.96, sokha 0.23
// dog -> puppy 0.99, cat 0.99, milk 0.27
// laptop -> phone 0.99, app 0.99, teacher 0.42
// teacher -> student 0.99, sokha 0.97, app 0.55
// query [1,0,0,0] -> coffee 0.99, tea 0.99, milk 0.96
The nearest neighbours of coffee are tea and milk; of dog,
puppy and cat; of laptop, phone and app. After the first two
or three, the scores drop sharply: the third neighbour of coffee is
sokha at 0.23, which is basically "nothing in common".
The last line is the interesting one. We did not look up a word. We made up a direction ("drink") and asked: what is close to it? The answer is the three drinks. This "find what is close to this vector" operation is the heart of vector search.
7. Real Embeddings Are Learned
Where do real vectors come from? From training, using the same loop
you saw in the last chapter. The vectors are parameters, a table with
one row per token, and they start as random numbers. As the model trains
on text, gradient descent nudges them. Words used in similar contexts
("a cup of ___", "I drank some ___") receive similar nudges and drift
toward each other. After enough training, coffee and tea end up close
because the model needed them close to predict text well.
Three honest points:
- Nobody labels the dimensions. There is no "drink" axis. The structure emerges from data.
- They carry the data's habits. If a corpus uses some words together in a biased way, the vectors reflect that. This is one way the biases from the training chapter reach the model.
- There are different kinds. Some embedding models are built only to turn text into vectors for search. The vectors inside a chat model are built to help predict the next token. Both are learned, with different goals.
Real vectors usually have hundreds to thousands of numbers, not four, and real models store one for every token in their vocabulary. Nothing about our four-number toy is the real structure, but the operations (dot, cosine, nearest) are exactly what is used.
8. Searching by Meaning
A keyword search finds documents that contain the same letters as your query. Search for "puppy" and a document that only says "dog" is missed. Embeddings let you search by meaning: turn both the query and every document into vectors, then find the nearest ones.
For a quick sentence vector we use the simplest trick: average the word vectors.
// demo-search.js
import { vectors, cosine } from "./src/embeddings.js";
// A sentence vector: the average of its word vectors (words we know).
function embed(text) {
const vecs = text.split(" ").filter((w) => vectors[w]).map((w) => vectors[w]);
return vecs[0].map((_, d) => vecs.reduce((s, v) => s + v[d], 0) / vecs.length);
}
const docs = [
"coffee tea milk",
"cat dog puppy",
"phone laptop app",
"student teacher sokha",
];
function search(query) {
const q = embed(query);
return docs
.map((doc) => ({ doc, score: cosine(q, embed(doc)) }))
.sort((a, b) => b.score - a.score);
}
for (const query of ["puppy", "tea", "laptop app", "teacher"]) {
const best = search(query)[0];
console.log(query.padEnd(11), "->", best.doc, best.score.toFixed(2));
}
// puppy -> cat dog puppy 0.99
// tea -> coffee tea milk 0.99
// laptop app -> phone laptop app 1.00
// teacher -> student teacher sokha 0.99
The query puppy finds the document cat dog puppy, and teacher finds the
people document, even though the query word is not the whole document.
Real systems do the same with a learned embedding model and far more
documents.
This one idea, embed everything, then find the nearest vectors, is how many AI applications let a model look things up in your own files. It is the basis of a technique called retrieval-augmented generation (RAG), which a later series covers. For now, remember that it is plain cosine similarity, exactly what you just wrote.
9. The Problem: One Vector per Word Is Not Enough
Embeddings are a great start, but they have a limit. Each word has one
vector, no matter where it appears. Think of the word mouse. In "the
mouse ran away from the cat" it is an animal. In "click the mouse to
open the laptop" it is a device. A fixed vector cannot be both. We could
give mouse a mixed vector, half animal and half tech, like
[0.0, 0.5, 0.5, 0.0], but then it is only halfway right everywhere.
Language is full of this: "bank" (river or money), "light" (not heavy or
not dark), "match" (a game or a fire). What we need is a way for a word's
vector to change depending on the words around it. The mouse near
cat should lean toward animal, the mouse near laptop toward tech.
That is exactly what attention does.
10. Attention: Words Looking at Words
The idea of attention is simple to say in plain words:
For each word, look at all the other words in the sentence, decide how much each one matters to this word, and blend their information in using those amounts.
Three steps, all of which you already know:
- Score. For one word (the one doing the looking), compute the dot product with every word in the sentence, including itself. A high score means "these two are related".
- Weights. Pass the scores through softmax (from Probabilities, Temperature and Sampling) so they become percentages that add up to 1. These are the attention weights.
- Blend. Make a new vector for the word: a weighted average of
all the word vectors, using those weights. A word with weight
0.8contributes 80% of the new vector.
The new vector is the same word, in context. The word has looked at its neighbours and absorbed something from them. A real Transformer does this for every word at once, in every layer.
11. Attention in Code
Create src/attention.js. It imports dot from the file you just wrote
and softmax from the sampling chapter:
// src/attention.js
import { dot } from "./embeddings.js";
import { softmax } from "./sampling.js";
// For each word, score every word (dot product), turn scores into
// weights with softmax, then blend the vectors using those weights.
export function attention(sentence, table, scale = 1) {
const vecs = sentence.map((w) => table[w]);
const weights = [];
const blended = [];
for (const q of vecs) {
const scores = vecs.map((k) => dot(q, k) * scale);
const w = softmax(scores);
weights.push(w);
blended.push(
q.map((_, d) => w.reduce((sum, wi, i) => sum + wi * vecs[i][d], 0)),
);
}
return { weights, blended };
}
export function printTable(sentence, weights) {
const pad = Math.max(...sentence.map((w) => w.length)) + 1;
console.log("".padEnd(pad) + sentence.map((w) => w.padStart(pad)).join(""));
sentence.forEach((w, i) => {
console.log(
w.padEnd(pad) + weights[i].map((x) => x.toFixed(2).padStart(pad)).join(""),
);
});
}
attention(sentence, table, scale) takes an array of words, a lookup from
word to vector, and a scale. For each word q in the sentence it:
- scores every word
kwithdot(q, k), multiplied byscale; - turns the scores into
wwithsoftmax; - builds the blended vector: for each dimension
d, the sum ofweight * that word's number.
It returns two things: weights (one row of percentages per word) and
blended (the new, context-aware vectors).
The scale is a sharpness knob: a bigger value makes the softmax pick its
favourites more strongly. Real Transformers have a fixed scaling factor
built in (they divide by something related to the vector length) so you
can think of it as part of the recipe.
12. Reading the Attention Table
Try it on a three-word sentence. We add a vector for drinks, because it
is not in our word list:
// demo-attention.js
import { vectors } from "./src/embeddings.js";
import { attention, printTable } from "./src/attention.js";
const table = { ...vectors, drinks: [0.8, 0.0, 0.0, 0.3] };
const sentence = ["sokha", "drinks", "tea"];
console.log("scale 1:");
printTable(sentence, attention(sentence, table, 1).weights);
console.log("scale 4:");
const { weights, blended } = attention(sentence, table, 4);
printTable(sentence, weights);
console.log("tea before:", table.tea.join(" "));
console.log("tea after: ", blended[2].map((x) => x.toFixed(2)).join(" "));
// scale 1:
// sokha drinks tea
// sokha 0.46 0.30 0.25
// drinks 0.28 0.37 0.35
// tea 0.25 0.38 0.37
// scale 4:
// sokha drinks tea
// sokha 0.79 0.14 0.07
// drinks 0.14 0.48 0.38
// tea 0.09 0.47 0.44
// tea before: 0.8 0 0 0.1
// tea after: 0.75 0.00 0.01 0.27
Each row is one word doing the looking. Each column is a word
being looked at. A row adds up to 1 (to rounding). Read the scale 4
table:
- Row
tea: when processing "tea", 47% of the attention goes todrinks, 44% to itself, and only 9% tosokha. The wordtealooks mostly at the verbdrinksand itself, because those are both "drink" words with similar numbers. - Row
sokha: the person word mostly looks at itself (79%), because nothing else in the sentence is about a person.
At scale 1 the table is nearly flat (everything gets about a third),
because our vectors are small numbers and the scores do not differ much.
A bigger scale sharpens the picture. Real models also learn how to
look, which we skip.
Now the effect. tea started as 0.8 0 0 0.1. After attention it is
0.75 0.00 0.01 0.27. It gained some "person" (from drinks, which has
a person-like component, and sokha): it now carries a trace of "this
tea is being drunk by someone". The word was changed by its
context.
You can check one row by hand. For tea, the three scores are the dot
products with sokha, drinks and tea:
// demo-hand.js
import { vectors, dot } from "./src/embeddings.js";
import { softmax } from "./src/sampling.js";
const tea = vectors.tea;
const sentence = ["sokha", "drinks", "tea"];
const table = { ...vectors, drinks: [0.8, 0.0, 0.0, 0.3] };
const scores = sentence.map((w) => dot(tea, table[w]));
console.log("scores :", scores.map((s) => s.toFixed(2)).join(" "));
console.log("scaled :", scores.map((s) => (s * 4).toFixed(2)).join(" "));
console.log("weights:", softmax(scores.map((s) => s * 4)).map((p) => p.toFixed(2)).join(" "));
// scores : 0.25 0.67 0.65
// scaled : 1.00 2.68 2.60
// weights: 0.09 0.47 0.44
These are the same 0.09 0.47 0.44 as the tea row above. No magic:
dot product, softmax, weighted average. Every number in these outputs is
stable across runs.
13. Context Changes the Meaning
Now the payoff, with the ambiguous word mouse from section 9. Give it
the mixed vector, half animal and half tech, then put it in two different
two-word contexts:
// demo-mouse.js
import { vectors, nearest } from "./src/embeddings.js";
import { attention, printTable } from "./src/attention.js";
const table = { ...vectors, mouse: [0.0, 0.5, 0.5, 0.0] };
const fmt = (x) => `${x.word} ${x.score.toFixed(2)}`;
console.log("mouse alone:", nearest(table.mouse, 3).map(fmt).join(", "));
for (const other of ["cat", "laptop"]) {
const s = ["mouse", other];
const r = attention(s, table, 4);
console.log(`\n"${s.join(" ")}"`);
printTable(s, r.weights);
console.log("mouse now closest to:", nearest(r.blended[0], 2).map(fmt).join(", "));
}
// mouse alone: phone 0.70, cat 0.70, dog 0.69
//
// "mouse cat"
// mouse cat
// mouse 0.55 0.45
// cat 0.18 0.82
// mouse now closest to: cat 0.93, dog 0.91
//
// "mouse laptop"
// mouse laptop
// mouse 0.55 0.45
// laptop 0.17 0.83
// mouse now closest to: phone 0.93, laptop 0.92
On its own, mouse is equally near phone and cat (about 0.70), so a
fixed vector cannot choose. After attention in "mouse cat", the new
mouse vector is closest to cat and dog. After attention in
"mouse laptop", it is closest to phone and laptop. Same word,
different surroundings, different vector. That is how a Transformer
can handle "bank" in a river sentence and in a money sentence.
This is why attention is such a big deal. It makes meaning depend on context, using only the simple steps you just ran.
14. The Real Transformer, Honestly
What you built is a simplified picture of the idea at the heart of the Transformer, the design behind modern LLMs. Here is what is different in the real thing. The details are conceptual; do not take our toy as an implementation guide.
- Learned lookups. In our demo, each word is scored using its own vector. A real model first passes every vector through learned matrices to make three versions of it (usually called query, key and value): one for "what I am looking for", one for "what I offer", one for "what I will contribute". These matrices are parameters, trained like everything else.
- Many heads. Real models run many attention operations in parallel, called heads, each free to learn a different kind of relationship (one might track grammar, another which noun a pronoun refers to). We built one head, with no learning.
- Many layers. The result of attention goes through more learned arithmetic and then into another round of attention, stacked dozens of times. Early layers tend to capture simpler patterns and later ones more abstract ones, though how exactly is an active research area.
- Order matters. Our toy ignores word order. Real models add information about each token's position, or "dog bites man" would look the same as "man bites dog".
- No peeking ahead. When predicting the next token, a model is only allowed to attend to tokens before the current one, not future ones.
- Scale. Real vectors have hundreds or thousands of numbers, and the vocabulary has tens of thousands of tokens.
The pipeline from the series overview now has more shape: text is split into tokens, each token becomes an embedding, stacked attention layers blend context into every vector, and the last vector is turned by one more learned step into the scores for every possible next token. Those scores go through softmax and sampling, exactly as in chapter 4.
Be careful with the intuition, too. It is tempting to say "attention is how the model understands", but that is a story we tell. Researchers study what attention heads do, and the picture is partial: some heads do seem to do something easy to describe, many do not. Treat the table you just printed as a teaching illustration of a mechanism, not a window into a mind.
15. Recipe: The embeddings and attention Modules
You created both files as you went. Together:
src/embeddings.jsexportsvectors,dot(a, b),norm(a),cosine(a, b)andnearest(vec, k = 3, table = vectors).src/attention.jsexportsattention(sentence, table, scale = 1)andprintTable(sentence, weights). It needssoftmaxfromsrc/sampling.js, from Probabilities, Temperature and Sampling.
Every demo in this chapter is a small file next to src/ (for example
demo-nearest.js) that you run with node demo-nearest.js. Make sure
"type": "module" is set in package.json, as in chapter 1, so import
works.
16. Try It Yourself
- Add a word. Add
juice: [0.8, 0.0, 0.0, 0.0]andtablet(pick its numbers). Printnearestfor each. Do they land where you expected? - Break the vectors. Give
teathe numbers[0.0, 0.0, 0.9, 0.0](making it look like tech) and printcosine(coffee, tea)again. What happens to search results for "tea"? What does this tell you about the importance of good embeddings? - Odd one out. Write
oddOneOut(words)that returns the word with the lowest average cosine similarity to the others. Try["coffee", "tea", "milk", "laptop"]. - More documents. Add four more documents to the search demo,
including one with a word that is not in
vectors(the code skips unknown words). What breaks if a document contains only unknown words, and how would you guard against it? - Change the scale. Run the
"sokha drinks tea"attention table withscaleset to0.1,1,10and50. Describe what happens to each row. At very large scale, what does each word attend to? - New ambiguous word. Invent
bankwith a vector halfway between[drink, animal, tech, person]dimensions of your choice, and test it next to a "money" word and a "river" word you also invent. Print the nearest words after attention, as in section 13.
17. Quick Review: Common Questions
Questions beginners often ask about this chapter. Try answering each one yourself first, then click the question to open the answer.
"What is an embedding, in one sentence?"
It is a list of numbers that represents a word, token, or whole text, so that things with similar meaning get similar numbers. That lets a computer measure "how alike are these two things" with arithmetic.
"Who decides what the numbers mean?"
Nobody does. They are learned during training: the model nudges the numbers so that its predictions get better, and words used in similar ways end up with similar numbers. The individual dimensions usually have no human-readable meaning.
"What is attention?"
It is the way a model lets each word look at the other words and mix in information from the ones that matter most. Mathematically it is a score for every pair of words, turned into percentages, used to take a weighted average. It is why the same word can mean different things in different sentences.
"Is this exactly how ChatGPT works?"
It is the same core idea, scaled up and with a lot added: learned query/key/value matrices, many heads and layers, position information, and billions of parameters. Our code is a teaching toy. It shows the mechanism, not a production implementation, and we cannot say details of any specific product.
"Does the model understand meaning if words become vectors?"
It captures real patterns of meaning, because related words end up near each other and the model uses that to predict well. Whether that counts as "understanding" is a debated question that scientists and philosophers do not agree on, and we come back to it at the end of the series.
"How does a search engine find results by meaning?"
It converts your query and each document into embeddings, then returns the documents whose vectors are nearest to the query vector, often using cosine similarity. That is what you built in section 8, just with learned vectors and far more data.
"Why can't the model just use the word as it is?"
Because computers calculate with numbers, and because a word on its own has no notion of similarity: "coffee" and "tea" are as unrelated as "coffee" and "dog" as plain text. Vectors give the model a space where similarity is measurable.
Where to go next. You can now explain how words become numbers and how words look at each other. A model can only look at a limited amount of text at once, though, and it forgets everything between requests unless you resend it. That limit, and the tricks used to live with it, are the subject of Context Windows and Memory.
Buy Me a Coffee
Found this tutorial helpful? Consider buying me a coffee to support more free content!