Index Ai Academy
Phase 1 · Building with LLMs · Context & retrieval

Embeddings

Meaning as a list of numbers, so text can be compared.

About 25 minutes · A free account opens the whole lesson

Create a free account Try part of it here

Already have an account? Sign in to open this lesson

Why it matters

Embeddings let a computer search by meaning instead of by matching words. A search for "rain jacket" can then find a page about waterproof coats. Embeddings power "find similar products" lists. They group thousands of survey answers by theme, and they spot duplicate leads. Most of all, they power retrieval-augmented generation (RAG). In RAG, you find the right page of your own documents and give it to a model before it answers. Every lesson in the next part of this phase builds on embeddings.

What you'll be able to do

  • Explain what an embedding is and why similar meanings get similar vectors
  • Request embeddings from an API and read the response
  • Measure how similar two texts are with cosine similarity
  • Name real embedding models and how they differ from chat models

What it covers

  1. Meaning as a list of numbers
  2. A practice model you can read
  3. Measuring similarity: cosine
  4. A shortcut for length-1 vectors
  5. Worked example: which messages mean the same thing?

Meaning as a list of numbers

Picture rating a meal on three scales from 0 to 10: how sweet, how spicy, how filling. A Thai green curry might get [2, 8, 6]. A Mexican chili might get [1, 7, 7]. The dishes have different names, but their scores are close, so you can tell they're alike.

An embedding does the same thing for text. It's a list of numbers that stands for what a piece of text means. A list of numbers like this is called a vector. A program called an embedding model turns text into these vectors.

You can draw a vector as an arrow. With two numbers, [3, 4] means "3 steps east, then 4 steps north". The model is trained so that texts with similar meanings get arrows that point in similar directions. This works even when the texts share no words:

  • "waterproof coat" and "rain jacket" → very similar vectors
  • "free delivery" and "shipping costs" → fairly similar
  • "waterproof coat" and "free delivery" → unrelated

Think of each number as a position on one scale of meaning, like "how spicy". Two texts are similar when their positions match on all the scales at once.

Real embedding models produce hundreds or thousands of numbers per text. Sizes of 1,024 and 1,536 are common. The single numbers don't have meanings a person can read. The model learns them from huge amounts of text, so nobody chose what each one means.

Quick check
An embedding model turns four texts into vectors. Which pair should get the most similar vectors?
  1. "Where is my parcel?" and "My delivery hasn't arrived"
  2. "Where is my parcel?" and "Where is the nearest store?"
  3. "My delivery hasn't arrived" and "My password hasn't arrived"
  4. All pairs score the same, because embeddings ignore meaning
Show the answer

"Where is my parcel?" and "My delivery hasn't arrived"

Both texts are about a late package, so they mean nearly the same thing. The other pairs share words but not meaning. Embeddings match on meaning, not on shared words.

A practice model you can read

The academy's practice API has a tiny embedding model called gradient-embed-mini. It makes vectors of only 24 numbers. Each position, or dimension, has a name, such as footwear, outerwear, shipping or discounts. It's built from a word list rather than learned. That makes it much cruder than a real model. In return, you can see exactly why two texts come out similar.

The code sends two texts to the API and gets one vector back for each. It also asks the API for the names of the 24 dimensions. Then it prints each vector's two biggest numbers.

import requests

response = requests.post(
    "https://api.gradient.test/v1/embeddings",
    json={"input": ["waterproof coat", "free delivery on orders over $50"]},
)
data = response.json()
names = requests.get("https://api.gradient.test/v1/embeddings/info").json()["names"]

for item in data["data"]:
    vector = item["embedding"]
    top = sorted(range(len(vector)), key=lambda i: vector[i], reverse=True)[:2]
    print(item["index"], len(vector), "numbers; strongest:", [(names[i], vector[i]) for i in top])
It runs right here in your browser, with nothing to install.

Here's how to read the output:

  • The response has one embedding per input text, in the same order. index tells you which text each one belongs to.
  • Each vector has 24 numbers.
  • "waterproof coat" puts all its weight on outerwear. Its second number is 0, so only one dimension counts.
  • The delivery text splits its weight between shipping ("delivery") and account ("orders"). Each gets about 0.707.

Which models make embeddings

Embedding models are separate from chat models. You send text and get vectors back, not words. Anthropic doesn't offer an embedding model. Its documentation points to providers such as Voyage AI. OpenAI, Google and Cohere sell embedding models too. Open-source models, often called sentence-transformers, can run on your own computer.

Whichever you pick, two rules apply:

  1. Use the same model for everything you compare. Vectors from different models live in different spaces and can't be compared.
  2. Re-embed everything if you switch models. Old vectors are meaningless to the new model.

Think of shoe sizes. A US size 9 and a European size 42 are both numbers, but you can't compare them directly. With embeddings it's worse, because there's no simple conversion chart.

Quick check
You embedded 10,000 product descriptions with one model, then switched providers. What do you need to do before comparing new queries against the old descriptions?
  1. Re-embed all 10,000 descriptions with the new model
  2. Convert the old vectors to base64
  3. Normalize the old vectors to length 1
  4. Nothing; vectors are universal
Show the answer

Re-embed all 10,000 descriptions with the new model

Each model has its own vector space. Comparing vectors from two different models gives meaningless scores.

Measuring similarity: cosine

You have two vectors. How alike are they? The standard measure is cosine similarity. It compares the directions two arrows point in and ignores how long they are. Think of two hikers reading their compasses. You care which way each one faces, not how far each has walked.

  • 1.0 means the same direction (very similar meaning)
  • 0 means unrelated directions
  • negative means opposite directions (rare for text)

You need two ingredients to work it out.

  • The dot product: multiply the matching numbers in the two lists, then add up the results.
  • The length of each vector: square each number, add the squares, then take the square root. The arrow [3, 4] is 5 long, because 3 × 3 + 4 × 4 = 25, and the square root of 25 is 5.

Cosine similarity is the dot product divided by the two lengths multiplied together.

In plain words: multiply the lists number by number and add up. Then divide by both lengths. Dividing removes the effect of length, so only direction is left.

Here's a worked example with small numbers. Take a = [1, 1, 0] and b = [1, 0, 0].

  1. Dot product: 1 × 1 + 1 × 0 + 0 × 0 = 1.
  2. Length of a: the square root of 1 + 1 + 0, which is the square root of 2, about 1.414.
  3. Length of b: the square root of 1 + 0 + 0, which is 1.
  4. Cosine: 1 ÷ (1.414 × 1) ≈ 0.707. The arrows point partly the same way.

The same steps in plain Python:

import math

a = [1, 1, 0]
b = [1, 0, 0]
dot = sum(x * y for x, y in zip(a, b))
length_a = math.sqrt(sum(x * x for x in a))
length_b = math.sqrt(sum(x * x for x in b))
print(dot, round(length_a, 3), length_b)
print(round(dot / (length_a * length_b), 3))

It prints 1 1.414 1.0, then 0.707. zip(a, b) pairs up the matching numbers.

NumPy does each step in one short piece of code. a @ b is the dot product, and np.linalg.norm(a) is the length of a:

import numpy as np

def cosine(a, b):
    a, b = np.array(a), np.array(b)
    return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))

print(round(cosine([1, 2, 0], [2, 4, 0]), 3))   # same direction, different length
print(round(cosine([1, 0, 0], [0, 1, 0]), 3))   # unrelated
print(round(cosine([1, 1, 0], [1, 0, 0]), 3))   # partly similar

It prints 1.0, 0.0 and 0.707. The first pair scores 1.0 even though the second arrow is twice as long. The last line matches the hand calculation above.

Go deeper Why length cancels out

Take [1, 2, 0] and [2, 4, 0]. The second is the first one doubled.

  • Dot product: 1 × 2 + 2 × 4 + 0 × 0 = 10.
  • Lengths: the square root of 5 (about 2.236) and the square root of 20 (about 4.472).
  • Lengths multiplied: 2.236 × 4.472 ≈ 10.

So the cosine is 10 ÷ 10 = 1. Doubling an arrow doubles the dot product, and it doubles the length you divide by. The two changes cancel, so only the direction counts.

Try it now

That's why embedding search finds what keyword search misses.

Quick check
Using the cosine function above, what does this print?
print(round(cosine([3, 4], [4, 3]), 3))
  1. 0.0
  2. 0.96
  3. 1.0
  4. 24.0
Show the answer

0.96

The dot product is 3 × 4 + 4 × 3 = 24. Both arrows are 5 long, so you divide by 5 × 5 = 25. 24 ÷ 25 = 0.96: nearly the same direction, but not quite. 24.0 forgets to divide by the lengths.

A shortcut for length-1 vectors

Most embedding APIs return vectors that are already exactly 1 long. The practice API does too. Look back at the delivery text. Its two big numbers give 0.707 × 0.707 + 0.707 × 0.707, which is about 1. The square root of 1 is 1.

When both lengths are 1, dividing by them changes nothing. So the cosine is the same as the dot product, a @ b. Skipping the division is faster when you compare thousands of vectors.

Phase 2 explains the math behind this properly. For now, "closer to 1 means more similar" is enough.

Worked example

Worked example: which messages mean the same thing?

Embed five customer messages and compare every pair. The code scores each pair with a dot product, then prints the three highest scores. Look for pairs that share meaning but few words.

import itertools
import numpy as np
import requests

messages = [
    "Where is my parcel?",
    "My delivery hasn't arrived",
    "Can I get a refund for this jacket?",
    "I want to send my coat back",
    "Do you have this boot in size 9?",
]
vectors = np.array([item["embedding"] for item in requests.post(
    "https://api.gradient.test/v1/embeddings", json={"input": messages}).json()["data"]])

pairs = []
for i, j in itertools.combinations(range(len(messages)), 2):
    pairs.append((float(vectors[i] @ vectors[j]), messages[i], messages[j]))
for score, a, b in sorted(pairs, reverse=True)[:3]:
    print(f"{score:.2f}  {a!r}  ~  {b!r}")
In the lesson you can change this and run it.

"Where is my parcel?" and "My delivery hasn't arrived" share no important words. They still score 1.00, because both land on the shipping dimension. That's the point of embeddings: matching on meaning.

The refund question and "I want to send my coat back" score 0.71. They match because both mention a piece of outerwear. The practice model only knows single words from its list, so it misses that "send my coat back" means a return. Real models learn from how phrases are used, so they can catch that too. Their scores are subtler, but the idea is the same.

Every other pair scores about 0. A support team could use exactly this to route or deduplicate tickets.

Quick check
In the worked example, why do "Where is my parcel?" and "My delivery hasn't arrived" score 1.00?
  1. They have the same number of words
  2. The model compares the letters in each message
  3. They were sent to the API in the same request
  4. Both land on the same dimension of meaning: shipping
Show the answer

Both land on the same dimension of meaning: shipping

"Parcel" and "delivery" both count toward shipping, so the two vectors point the same way. Word count and letters don't matter, and every text in a request gets its own separate vector.

Practice what you just read

  • 4 code examples you can edit and run
  • a practice exercise that checks your code
  • a 5-question quiz
  • 4 review cards for spaced repetition

Create a free account

Try a quiz question

What does an embedding model return?

  1. A list of numbers representing the text's meaning
  2. A rewritten version of the text, in simpler words
  3. A yes or no answer about whether the text is relevant
  4. The text's token count, and the tokens that made it up

Answer it at the end of the lesson to see if you're right, and why.

Before this lesson