Tokens And Embeddings

Cosine, Dot Product or Distance: Which One Compares Embeddings?

0 of 14 complete

0%

Contents

Back|Tokens And EmbeddingsCosine, Dot Product or Distance: Which One Compares Embeddings?
1/14
37 min left
Prerequisites
Splitting Text Into Pieces: Why Search Works Better on Small Chunksrequired
Related Topics
Chunking: The First Lever on Retrieval QualityRetrieval and RAG in ProductionIs There Really a Best Chunk Size? Measured on This Course's Own LessonsRetrieval and RAG in ProductionThe RAG Scale Cliff: What Breaks Between 100 and 5 Million DocumentsRetrieval and RAG in ProductionHybrid Retrieval: When Keyword Search Beats Your EmbeddingsRetrieval and RAG in ProductionThe RAG Retrieval Cliff: Engineering Recall Back at ScaleRetrieval and RAG in Production
1 of 14

One Pair, Two Very Different Scores

I asked one question, "How do I stop one user from sending too many requests?", and scored one short text about against it. Same model, same two texts. One score came out as 0.741. The other came out as 521.380.

A real screenshot of VS Code's terminal after running python three_ways.py. With the new endpoint /api/embed, every vector has length 1.00 and the rate-limiting text scores cosine 0.741, dot 0.741, distance 0.719. With the old endpoint /api/embeddings, the question's vector has length 26.60, and the rate-limiting text scores cosine 0.741, dot 521.380, distance 19.071.

Neither number is wrong. They come from two different ways of comparing , used on vectors of two different lengths. Both words, vector and length, are explained on the next slide. This lesson is about which way to compare, and the one check that tells you whether the choice matters.

A flat illustration of an engineer at a desk holding a clear protractor over two paper arrows, one short and one long, both pointing in almost the same direction, with a tape measure lying unused beside them. A line asks: two arrows can point the same way and still be different lengths; which one do you measure?

Two panels on the same question, the same text and the same model. Cosine: 0.741, from both endpoints. Dot product on the old endpoint /api/embeddings: 521.380. A note says this is the rate-limiting text scored against one question.

The Words You Need First

A hand-drawn word list. Vector: an embedding, a list of numbers, drawn as an arrow. Length: how long the arrow is. Dot product: multiply the two lists number by number, then add. Cosine: the dot product with both lengths divided out. Distance: the straight-line gap between the two arrow tips. Normalised: stretched or shrunk to length exactly 1. Endpoint: an address you send a request to, like /api/embed.

Vector. An is a list of numbers. It helps to picture it as an arrow from a starting point, pointing somewhere in a space with many directions.

Length. How long that arrow is. You get it by squaring every number, adding them up and taking the square root.

Dot product. Multiply the two lists number by number, then add everything up.

Cosine. The dot product divided by both lengths. It works out to the cosine of the angle between the two arrows: 1 when they point the same way, 0 when they are at a right angle. You met it in lesson 3 of this chapter as .

Distance. How far apart the tips of the two arrows are.

Normalised. A vector that has been stretched or shrunk to length exactly 1, without changing its direction. To normalise, divide every number in the vector by its length. A dot product on vectors that were not normalised first is called a raw dot product.

Endpoint. An address on a server that you send a request to. Ollama has two for embeddings: /api/embed, which the earlier lessons used, and an older one, /api/embeddings.

Run It Yourself

This is the file that printed those two scores, open in my VS Code. It asks for the same three texts from both endpoints and prints the length of each vector and all three scores.

A real screenshot of three_ways.py open in VS Code, 38 lines: a call function that posts to localhost port 11434, new_endpoint using /api/embed and old_endpoint using /api/embeddings with bge-m3, then length, dot, cosine and distance functions, a question about too many requests, two texts about rate limiting and caching, and a loop that prints each vector's length and the three scores for both endpoints.

To run it: install Ollama from ollama.com, open it and leave it running. Run ollama pull bge-m3 in a terminal (about 1.2 GB), save the file below as three_ways.py, and run python3 three_ways.py (on Windows: python three_ways.py). You need only Python 3. The "(venv)" in my screenshot is my own setup. If you see "Connection refused", open the Ollama app first.

# Cosine, dot product and distance on the same vectors, from Ollama's two embedding endpoints.
# Needs Ollama (ollama.com) running, and:  ollama pull bge-m3
import json, math, urllib.request

def call(path, body):
    req = urllib.request.Request("http://localhost:11434/api/" + path, data=json.dumps(body).encode(),
                                 headers={"Content-Type": "application/json"})
    return json.loads(urllib.request.urlopen(req).read())

def new_endpoint(text):   # /api/embed
    return call("embed", {"model": "bge-m3", "input": [text]})["embeddings"][0]

def old_endpoint(text):   # /api/embeddings, the older one
    return call("embeddings", {"model": "bge-m3", "prompt": text})["embedding"]

def length(v):
    return math.sqrt(sum(x * x for x in v))

def dot(a, b):
    return sum(x * y for x, y in zip(a, b))

def cosine(a, b):
    return dot(a, b) / (length(a) * length(b))

def distance(a, b):
    return math.sqrt(sum((x - y) ** 2 for x, y in zip(a, b)))

question = "How do I stop one user from sending too many requests?"
texts = ["Rate limiting: cap how many requests each user may send.",
         "Caching: keep a copy of data close to where it is used."]

for name, embed in [("new /api/embed", new_endpoint), ("old /api/embeddings", old_endpoint)]:
    q = embed(question)
    print(f"{name}: question vector length {length(q):.2f}")
    for t in texts:
        d = embed(t)
        print(f"  {t.split(':')[0]:<13} length {length(d):5.2f}   cosine {cosine(q, d):.3f}"
              f"   dot {dot(q, d):7.3f}   distance {distance(q, d):6.3f}")

Three Rulers for Two Arrows

Picture the question and a document as two arrows starting from the same point.

Two arrows from one starting point: a question arrow and a longer document arrow. An arc marks the angle between them and a dashed line marks the gap between their tips. A note says cosine looks only at the angle, distance measures the gap between the tips, and dot product grows with the angle's closeness and with both lengths.

  • Cosine looks only at the angle between the arrows. Two arrows pointing the same way score 1, whatever their lengths.
  • Distance measures the gap between the tips. A long arrow and a short arrow in the same direction are still far apart.
  • Dot product grows when the angle is small, and it also grows with both lengths. Make one arrow ten times longer and its dot product becomes ten times bigger.

The three measures in words, for two vectors a and b. Dot product: multiply number by number, add it all up. Cosine: dot product divided by length of a times length of b. Distance: subtract number by number, square each, add, square root. A note says only cosine divides the lengths out.

In an , the meaning of a text lives in the direction of its arrow. That is why cosine is the usual default. It is the only one of the three that ignores length.

When Every Arrow Has Length 1

Now suppose every vector has been normalised to length 1. Then:

Two hand-drawn boxes under the heading when every vector has length 1. Left: cosine equals dot, because dividing by 1 times 1 changes nothing. Right: distance squared equals 2 minus 2 times cosine, with the example 0.741 giving distance 0.719. A note says higher cosine always means smaller distance, so all three put results in the same order.

  • Cosine and dot product are the same number. Cosine divides by the two lengths, and both lengths are 1.
  • Distance follows from cosine. When both arrows have length 1, the squared gap between the tips works out to 1 + 1 minus 2 times the cosine, which is 2 minus 2 times the cosine. From the screenshot: a cosine of 0.741 gives a distance of the square root of 0.518, which is 0.72. The script printed 0.719; the small difference is rounding.

So a higher cosine always means a higher dot product and a smaller distance. On length-1 vectors, all three measures put the results in exactly the same order. So it does not matter which one you pick: you get the same results.

Ollama's Two Endpoints

The earlier lessons in this chapter used /api/embed. With Ollama 0.32.14, it returned vectors of length 1 for all 878 lesson texts used below, with both models, nomic-embed-text and bge-m3, to six decimal places. The older endpoint, /api/embeddings, returned the same directions at other lengths.

A sequence diagram with three columns: your code, Ollama and the model. Your code calls /api/embed, the text goes to the model and a vector comes out, and your code gets back a vector of length 1.000000. Your code calls /api/embeddings, the old one, and gets back a vector of length 18.2 to 27.8. A note says both point the same way; only the length differs.

I measured every one of the 878 lesson vectors used in the test below.

A bar chart of vector lengths from the old endpoint for the 878 lesson texts. nomic-embed-text: shortest 19.0, longest 22.1. bge-m3: shortest 25.0, longest 27.4. A note says the new endpoint gives 1.000000 for all 878.

Two things stand out. The lengths are not 1. And they are not all the same, so some texts get longer arrows than others. The lab also checked that, for the first 50 texts of each model, the old and new vectors point in the same direction: their cosine was 1.0000 every time.

Does It Change a Real Search?

Lengths that differ by up to about 16 percent (19.0 to 22.1 for nomic) sound harmless. So I tested it on a real search.

An isometric row of four blocks joined by arrows: 878 lesson texts, the old endpoint giving raw vectors, three rankings by cosine, dot and distance, and 60 searches per model. A note says these are lesson 4's documents and questions, unchanged.

  • Documents: the title and description of every lesson on this site, 878 of them, exactly as in lesson 4 (searching by meaning).
  • Questions: lesson 4's 10 questions, each in English, French, Spanish, Hindi, Japanese and Bengali. That is 60 searches per model.
  • Vectors: all from the old endpoint, so their lengths vary.
  • Three rankings: for every search, the top 5 lessons by cosine, by dot product and by distance.

Here is the lab's report, from the terminal.

A real terminal recording of dot_vs_cosine.py's report. 878 lesson texts, title plus description, 60 searches per model, vectors from the old endpoint. For each model it also shows /api/embed lengths of 0.999999 to 1.000001 for all 878. nomic-embed-text: lengths 19.0 to 22.1, old and new point the same way with cosine at least 1.0000; right lesson in the top 5 for cosine 9, dot 8, distance 6 of 60; same order as cosine: dot 1, distance 1; same five: dot 2, distance 3; lessons swapped in: dot 109, distance 132. bge-m3: lengths 25.0 to 27.4; right in top 5 for cosine 27, dot 25, distance 27; same order: dot 6, distance 11; same five: dot 18, distance 27; swapped in: dot 51, distance 38.

Most Searches Changed

A bar chart of lessons swapped into the top 5, of 300 places, compared with cosine. nomic-embed-text: dot product 109, distance 132. bge-m3: dot product 51, distance 38.

Each model ran 60 searches, and each search returns 5 lessons, so there are 300 places in all. Compared with cosine:

  • nomic-embed-text: a raw dot product put a different lesson into 109 of the 300 places. Distance changed 132.
  • bge-m3: dot product changed 51, distance 38.

Three boxes, of 60 searches per model: same five results as cosine, order ignored. nomic with dot product: 2 of 60. bge-m3 with dot product: 18 of 60. bge-m3 with distance: 27 of 60. A note says everything else had at least one different lesson.

Ignoring order, the dot product gave the same five lessons as cosine in only 2 of nomic's 60 searches, and in 18 of bge-m3's.

A bar chart of how often the right lesson was in the top 5, of 60 searches. nomic: cosine 9, dot 8, distance 6. bge-m3: cosine 27, dot 25, distance 27. A note says what changed is what else the user sees.

The number of right answers barely moved: 9, 8 and 6 for nomic, and 27, 25 and 27 for bge-m3. The lab did not grade the lessons that were swapped in, so I cannot tell you how much better or worse they were. What it does show is that a raw dot product on these vectors did not break search. It changed what the user sees, in most searches, for a reason that has nothing to do with meaning.

One Search, Up Close

Here is one bge-m3 search where the right lesson dropped out.

Two lists for the bge-m3 search on the old endpoint, most of my database traffic is reads, how do I spread that load. Cosine top 5: load-balancing, scalability, load-shedding, read-replicas, database-sharding. Dot product top 5: load-balancing, scalability, distributed-transactions, load-shedding, database-sharding. Below: read-replicas has cosine 0.6190 and length 26.11; distributed-transactions has cosine 0.6134 and length 26.85. A note says read-replicas points closer to the question, but its vector is shorter, so dot product ranks distributed-transactions above it.

The question was "Most of my database traffic is reads. How do I spread that load?" The right lesson is Read Replicas.

  • Read Replicas points closer to the question: cosine 0.6190. Its vector is 26.11 long.
  • Distributed Transactions points a little further away: cosine 0.6134. But its vector is 26.85 long.

The dot product is the cosine times both lengths. Read Replicas scored 428.9 and Distributed Transactions 436.9, so the longer vector wins. Read Replicas fell out of the top 5, and a lesson about something else took its place.

A hand-drawn sketch: a question box with a solid arrow to read-replicas, marked closer angle, and a dashed arrow to distributed-transactions, marked longer. Cosine wins with 0.6190; dot wins with 436.9. The Ollama logo sits below. A note says the numbers are from the example above, bge-m3, old endpoint.

Sometimes the opposite happened. On the same question with nomic, the dot product found Read Replicas in its top 5 and cosine did not. Here, length says nothing about meaning, so it moves results up or down by luck. That is exactly why you do not want it in your ranking.

Stretch an Arrow Yourself

This box uses three-number vectors, small enough to check by hand. Press Run. Then change STRETCH = 10 to 2 or 100 and run it again.

What you should see: cosine stays at 0.941 on every line. The dot product goes from 24 to 240 when the document is stretched ten times, and distance jumps from 1.73 to 46.31. Stretching changed neither vector's direction, only a length. On the last line both vectors have length 1, and the dot product equals the cosine again.

Which One to Use

A flowchart: are all your vectors length 1? If yes: any of the three gives the same order, so pick the one your database is fastest at. If no or unsure: use cosine, or normalise first. A note says one line of code checks the length.

  1. Check the length of one vector from your tool. That is one line of code, shown below, using the length function from three_ways.py. A result like 0.9999 counts as 1; the tiny difference is rounding.
  2. If it is 1, all three measures give the same order. Use whichever your database is fastest at. A dot product skips the division, so it does a little less arithmetic.
  3. If it is not 1, or you are not sure, use cosine, or normalise every vector to length 1 when you store it and again when you embed each question.
  4. Use the same measure everywhere. Vector databases ask you to choose one. pgvector, a vector search add-on for PostgreSQL, has one symbol for each: <-> for distance, <#> for the dot product with a minus sign in front, and <=> for 1 minus the cosine.

A card titled print the length, with the code print(length(vector)). Below: /api/embed gives 1.00; /api/embeddings, the old one, gives 26.60. A note says this is the question's vector from three_ways.py with bge-m3, and if it is not 1, do not use a raw dot product.

Some systems keep the length on purpose, because their model was trained to put meaning in it. There, a dot product is the intended measure. The model's documentation says which measure it expects. The two models here gave no sign of that: the lengths varied by at most about 16 percent, and the old and new endpoints pointed the same way on every text I checked.

What This Lesson Measured, and What It Did Not

Two columns. Measured: two models from Ollama, 878 short lesson texts, 60 searches per model. Not measured: other tools or libraries, long documents, vector databases' own settings.

Measured: two models served by Ollama 0.32.14, the lengths of their vectors from both endpoints, and how the top 5 changed across 878 short lesson texts, each a title and a one-line description, and 60 searches per model.

Not measured: other tools and libraries, which may or may not normalise for you; long documents; and the settings inside vector databases. The one rule that carries over is the check: print the length before you pick the measure.

What to Do Next

A hand-drawn list of four things to do: run three_ways.py and look at the lengths; print the length of one vector from your tool; choose, length 1 means any measure and not 1 means cosine; and use the same measure your database is set to. A note says measure the angle unless you know the lengths.

  1. Run three_ways.py and look at the lengths it prints.
  2. Print the length of one vector from whatever tool you use.
  3. Choose: length 1 means any of the three; anything else means cosine.
  4. Match your database to the same measure.

The number to keep: 109 of 300 top-5 places changed by a raw dot product, nomic, old endpoint. A note says on length-1 vectors, the three measures agree.

Knowledge Check

Knowledge Check

5 questions - Score 80% to pass

Q1

The same question and text scored cosine 0.741 on both endpoints, but dot product 0.741 on one and 521.380 on the other. What is different between the two endpoints' vectors?

Q2

Every vector you have stored has length exactly 1. Which measure puts your search results in a different order from cosine?

Q3

In the bge-m3 example, Read Replicas had cosine 0.6190 and length 26.11, while Distributed Transactions had cosine 0.6134 and length 26.85. Why did the dot product rank Distributed Transactions higher?

Q4

On the old endpoint's vectors, the right lesson reached the top 5 in 27, 25 and 27 of 60 bge-m3 searches with cosine, dot and distance. What is the fair summary?

Q5

You start with a new embedding tool. What is the one check to run before you choose between cosine and a dot product?

Read the output again. Cosine is 0.741 from both endpoints. So for these texts, the two endpoints give vectors that point the same way. The lab later checks this on 50 more texts. What differs is only their length: 1.00 from the new one, 26.60 for the question from the old one.

Two cards with logos: Ollama running nomic-embed-text and bge-m3, and Python computing the three measures. A note says it is free and local.