Blog

Your First Collection: Points, Payloads and Distance Metrics

Create a collection, put points in it, get answers out — and find out what the distance metric really decides. On unit vectors all three metrics return the identical ranking; the moment lengths mean nothing, two of them break.

Part 1 ended with a problem: exact search is linear, and at a million vectors that is 88 ms a query. This part starts using the thing that fixes it.

We’ll build a collection, put points in it, and ask it questions. Then we’ll spend most of the part on the one decision you make at creation time and can never change afterwards: the distance metric. It looks like a detail in a constructor. It decides what “closest” means for the life of the collection.

Same machine as Part 1 — a 12th Gen Intel Core i5-1235U, 12 cores, 15.3 GiB of RAM, Qdrant 1.19.1 in a container.


Try this first

Three ways to measure how close two vectors are:

  • Cosine — the angle between them, ignoring how long they are.
  • Dot product — multiply matching numbers and add them up.
  • Euclidean — the straight-line distance between the two points.

Take one query and a handful of documents. Score them all three ways and rank them.

Write down your prediction: how many of the three rankings agree? All three, two, or none?

Most people say none, or two. Hold that thought.


A collection, in one screen

Three calls: create the collection, put points in, ask a question.

from fastembed import TextEmbedding
from qdrant_client import QdrantClient, models

docs = [
    ("cats",      "The cat sleeps on the windowsill in the afternoon sun."),
    ("cooking",   "Rest the meat before carving or the juices run out."),
    ("databases", "The write-ahead log is what makes a crash survivable."),
    ("running",   "Run the first kilometre slower than feels right."),
]

model = TextEmbedding("BAAI/bge-small-en-v1.5")
vectors = list(model.embed([text for _, text in docs]))

client = QdrantClient(url="http://localhost:6333")

# A collection fixes two things for every vector it will ever hold: how many
# numbers, and how closeness is measured. Neither can change later.
if client.collection_exists("notes"):
    client.delete_collection("notes")
client.create_collection(
    "notes",
    vectors_config=models.VectorParams(size=len(vectors[0]), distance=models.Distance.COSINE),
)

# A point is an id, a vector, and a payload: any JSON you want back with the hit.
client.upsert("notes", points=[
    models.PointStruct(id=i, vector=v.tolist(), payload={"subject": subject, "text": text})
    for i, (v, (subject, text)) in enumerate(zip(vectors, docs))
], wait=True)

info = client.get_collection("notes")
print(f"{info.points_count} points, {info.config.params.vectors.size} dimensions, "
      f"{info.config.params.vectors.distance.value} distance")

query = "why wait before slicing a roast"
q = list(model.embed([query]))[0]
print(f"query: {query!r}")
for hit in client.query_points("notes", query=q.tolist(), limit=3, with_payload=True).points:
    print(f"  {hit.score:.4f}  id={hit.id}  [{hit.payload['subject']}]  {hit.payload['text']}")

It prints:

4 points, 384 dimensions, Cosine distance

query: 'why wait before slicing a roast'
  0.6778  id=1  [cooking]  Rest the meat before carving or the juices run out.
  0.5700  id=2  [databases]  The write-ahead log is what makes a crash survivable.
  0.5642  id=3  [running]  Run the first kilometre slower than feels right.

That’s the whole shape. Four ideas in it are worth naming.

A collection is a box with two fixed properties. How many numbers each vector has, and how distance is measured. Neither can be changed later — to change either, you create a new collection and re-index everything. Part 11 shows how to do that without downtime.

A point is an id, a vector, and a payload. The vector is what gets searched. The payload is arbitrary JSON that comes back with the hit, and in Part 6 it becomes something you can filter on. Here it carries the original text, which is the usual pattern — the vector can’t be turned back into words, so if you want the text you store the text.

upsert means insert-or-replace. Run it twice with the same id and you don’t get two points.

Look at the second hit. The write-ahead log is what makes a crash survivable scores 0.5700 against a question about carving meat. It is not a good answer. It is just the second least-bad of four, and a search over four documents always returns something. Scores are relative, not absolute, and there is no threshold that means “good”. We’ll come back to this, because it’s the most common way people get burned.


Now the metric

Here’s the prediction from the top of the part, run properly. Four sentences, one query, scored three ways.

import numpy as np
from fastembed import TextEmbedding

texts = [
    "The cat sleeps on the windowsill in the afternoon sun.",
    "Kittens knead a blanket before they settle down.",
    "The compiler turns source text into machine instructions.",
    "Rest the meat before carving or the juices run out.",
]
model = TextEmbedding("BAAI/bge-small-en-v1.5")
V = np.array(list(model.embed(texts)))
q = np.array(list(model.embed(["where does a pet feline like to nap"]))[0])

print("every vector this model returns has length 1:")
print("  ", np.round(np.linalg.norm(V, axis=1), 6))

# Cosine and dot want the LARGEST value; euclidean distance wants the SMALLEST.
dot = V @ q
euclid = np.linalg.norm(V - q, axis=1)
cosine = dot / (np.linalg.norm(V, axis=1) * np.linalg.norm(q))

order = lambda a: [int(i) for i in a]
print("order by dot   :", order(np.argsort(-dot)))
print("order by euclid:", order(np.argsort(euclid)))
print("order by cosine:", order(np.argsort(-cosine)))

# Why they agree: for unit vectors, |a-b|^2 = 2 - 2(a.b).
print("|a-b|^2       :", np.round(euclid ** 2, 6))
print("2 - 2*(a.b)   :", np.round(2 - 2 * dot, 6))

It prints:

every vector this model returns has length 1:
   [1. 1. 1. 1.]
     dot   euclid   cosine   ranking by each
  0.6515   0.8348   0.6515   The cat sleeps on the windowsill in the afte
  0.5697   0.9277   0.5697   Kittens knead a blanket before they settle d
  0.3329   1.1551   0.3329   The compiler turns source text into machine 
  0.5201   0.9797   0.5201   Rest the meat before carving or the juices r

order by dot   : [0, 1, 3, 2]
order by euclid: [0, 1, 3, 2]
order by cosine: [0, 1, 3, 2]

|a-b|^2       : [0.696915 0.860668 1.334242 0.959745]
2 - 2*(a.b)   : [0.696915 0.860667 1.334242 0.959745]

All three agree. Identical order, every time. And the dot and cosine columns are not just ordered the same — they are the same numbers.

That is not luck. Every vector came back with length exactly 1, and for unit vectors the three metrics are the same measurement wearing different clothes:

  • Cosine is defined as the dot product divided by both lengths. Both lengths are 1, so cosine is the dot product.
  • Squared euclidean distance expands to |a|² + |b|² − 2(a·b), and with both lengths 1 that is 2 − 2(a·b). The last two printed rows show that holding to six decimal places.

One caution about that “length exactly 1”. It is what the run printed, not a promise. The bge-small-en-v1.5 model card documents 384 dimensions but does not state that its output is normalised — in Sentence-Transformers you pass normalize_embeddings=True to get it. FastEmbed, used here, returns normalised vectors. So this is a measured property of the pipeline in front of you, and the right habit is the one the code above uses: print the norms and look, rather than assume. If you change library or model, check again.

So euclidean distance is a decreasing function of the dot product. Whatever the dot product ranks first, euclidean distance ranks first. There is no input that can separate them.

On unit vectors, cosine, dot product and euclidean distance produce the same ranking. Not similar — identical. The metric only starts to matter when your vectors have different lengths.

Across the full 64-sentence corpus and 12 queries this part uses, all three metrics returned identical top-10 lists for every query, and the identity |a−b|² = 2 − 2(a·b) held to within 4.77e-07 — which is float32 rounding, not disagreement.


So when does it matter?

When length carries no meaning.

Take the least relevant sentence of the four — the compiler one, scoring 0.3329 — and make its vector 2.5 times longer. Not a different sentence. The same sentence, same direction, just a longer arrow.

Measured by checks/part02_first_collection/run.py against Qdrant 1.19.1. The distance metric changes nothing while every vector has length 1 — and starts deciding the answer the moment lengths vary. Switch scenario for the whole corpus rather than the one query.

# Document 2 again, at 2.5x the length, as a model that does not normalise might return it.
long_irrelevant = V[2] * 2.5
V2 = np.vstack([V, long_irrelevant])
labels = texts + ["(the compiler sentence again, 2.5x longer)"]

dot2 = V2 @ q
cos2 = dot2 / (np.linalg.norm(V2, axis=1) * np.linalg.norm(q))
for i, label in enumerate(labels):
    print(f"{dot2[i]:>8.4f} {cos2[i]:>8.4f}   {label[:50]}")
print("winner by dot   :", labels[int(np.argmax(dot2))][:50])
print("winner by cosine:", labels[int(np.argmax(cos2))][:50])

It prints:

lengths now    : [1.  1.  1.  1.  2.5]

     dot   cosine   
  0.6515   0.6515   The cat sleeps on the windowsill in the afternoon 
  0.5697   0.5697   Kittens knead a blanket before they settle down.
  0.3329   0.3329   The compiler turns source text into machine instru
  0.5201   0.5201   Rest the meat before carving or the juices run out
  0.8322   0.3329   (the compiler sentence again, 2.5x longer)

winner by dot   : (the compiler sentence again, 2.5x longer)
winner by cosine: The cat sleeps on the windowsill in the afternoon 

Ask where does a pet feline like to nap and the dot product answers with a sentence about compilers. Its score went from 0.3329 to 0.8322 — 2.5 times bigger, exactly the factor the vector was stretched by — while the cosine stayed at 0.3329 and it remained last.

The dot product rewards long vectors. Cosine divides the length out, so it can’t be fooled this way. Neither is broken; they answer different questions. Dot asks how much of this document points at my query, and a longer document has more of everything.

Measured over the whole corpus

One query is an anecdote. Here is the same thing over all 64 sentences and 12 queries, with the vectors scaled by sentence length — a real property of each document that has nothing to do with what any query means.

The reference ranking is cosine over the original unit vectors: the semantically correct answer. Everything is measured against that.

vectors metric top-10 overlap with the correct ranking same top result
unit length Cosine 1.0 1.0
unit length Dot 1.0 1.0
unit length Euclid 1.0 1.0
lengths from sentence length Cosine 1.0 1.0
lengths from sentence length Dot 0.7667 0.5
lengths from sentence length Euclid 0.7333 0.75

The lengths here span only 0.734× to 1.31× of the average. That is a mild spread — far milder than a corpus mixing tweets with articles — and it is already enough to change the top result on 6 of the 12 queries under the dot product, and on 3 of 12 under euclidean distance. Cosine is untouched, because normalising is exactly what it does.

Twelve queries is a small sample, so read those counts as the shape of the problem rather than a rate you can quote. The top-10 overlap figures in the table are steadier — they average over 120 judgements rather than 12 — and they point the same way.


Explain it like I’m ten

Imagine everyone in a field pointing at things. You want to find whoever is pointing at the same thing as you.

Cosine only looks at the direction of each arm. A tall adult and a small child pointing at the same tree look identical to it.

Dot product cares about direction and how far the arm reaches. The tall adult wins, even if the child is pointing more accurately.

Euclidean measures the distance between the fingertips. That mostly follows direction — unless one person’s arm is much longer, and then their fingertip ends up somewhere else entirely.

If everybody’s arms are exactly the same length, all three give the same answer. That’s what happens with a model that returns unit vectors, and it’s why the ranking didn’t budge.

Where the analogy breaks: arms point in three dimensions and these vectors point in 384. And “arm length” isn’t a person’s height — it’s whatever made the model’s output bigger, which is often just a longer document. Nothing about it means the document is a better answer.

The precise version

For vectors a, b ∈ ℝⁿ:

  • dot: a·b
  • cosine: a·b / (‖a‖‖b‖)
  • squared euclidean: ‖a−b‖² = ‖a‖² + ‖b‖² − 2(a·b)

Fix the query b and vary a over the corpus. If every ‖a‖ = 1 then cosine reduces to a·b, and ‖a−b‖² = 1 + ‖b‖² − 2(a·b), which is an affine decreasing function of a·b. All three therefore induce the same total order, and ranking by any one of them gives the same result.

Drop the constraint and ‖a‖² re-enters. Dot becomes ‖a‖·‖b‖·cos θ, so it scales linearly with ‖a‖ at fixed angle — a document with 2.5× the norm gets 2.5× the score, which is exactly what the run above shows. Euclidean penalises large ‖a‖ through the ‖a‖² term, so it is biased the other way. Only cosine is invariant to ‖a‖.

The practical rule: if your vectors are normalised, pick Cosine or Dot — Dot is marginally cheaper, since it skips a division. If they are not normalised, either normalise them before you store them, or use Cosine and let Qdrant do it.


Does it actually answer the question?

Twelve queries over the 64 sentences, cosine, top hit only. Did it land in the subject the question was about?

11 out of 12 — 0.9167. For eight subjects and sentences this short, that is what working looks like.

The interesting one is the failure:

query:    keeping data safe when the machine loses power
expected: databases

0.6512  [compilers]  Register allocation decides what stays in the CPU and what spills.
0.5994  [databases]  Replication lag meant the read returned stale data.
0.5934  [databases]  The write-ahead log is what makes a crash survivable.

The right answer is the write-ahead log is what makes a crash survivable. It came third. It lost to a sentence about register allocation.

Read the two again and you can see why. The query says machine, loses, power. The register allocation sentence says CPU, spills, stays. They share a vocabulary of hardware and things moving somewhere they shouldn’t. The embedding is matching the texture of the sentence, not answering the question. Nothing in the model knows that “write-ahead log” is the concept that means “survives power loss” — it only knows which words tend to appear together.

This is worth sitting with, because it’s the honest shape of the tool:

  • The model has no idea what your question is for.
  • Similarity is not relevance, and relevance is not correctness.
  • The fix is not a better metric. It’s the things in Part 9 (hybrid search, so an exact term like “write-ahead log” can be matched literally) and reranking, which reads the query and the document together instead of comparing two summaries.

Points: ids, payloads, and what upsert really does

Qdrant does not accept any id you like:

id accepted?
7 (unsigned integer) yes
"c3d4... (UUID string) yes
"doc-7" (any other string) no

The rejection is a 400, and it says exactly what it wants:

Format error in JSON body: value doc-7 is not a valid point ID, valid values

So your own identifiers — order-1182, a URL, a file path — cannot be point ids. Two ways round it, and you will use both:

  1. Hash your key to an integer, or derive a UUID from it deterministically (uuid.uuid5(namespace, your_key)), so the same key always produces the same point id.
  2. Keep the real key in the payload, and index it (Part 6) so you can filter and fetch by it.

And upsert is genuinely upsert. Writing id 7 a second time:

2 points -> 2 points, payload now {'kind': 'integer', 'revised': True}

The count doesn’t move, and the payload is replaced, not merged. If you want to change one field and keep the rest, that’s set_payload, not upsert.


Trade-offs

Cosine vs Dot on normalised vectors. They give identical rankings, so choose on cost. Dot skips a division per comparison. Cosine is the safer default because it stays correct if some vectors later arrive un-normalised — and something will eventually arrive un-normalised.

Storing the text in the payload. Convenient, and it means your search returns something you can show. It also means the payload is now your second copy of the corpus, with the memory and the consistency problem that implies. Part 10 covers moving payloads to disk; Part 8 covers when to store a key instead and fetch from your real database.

Integer ids vs UUIDs. Integers are compact and let you map a row number straight to a point. UUIDs derived from your key are stable across rebuilds and don’t need a counter. Integers are cheaper; UUIDs are harder to get wrong.

One collection per metric. The metric is fixed at creation, so “let’s try euclidean” means a second collection and a second index. Decide by measuring on your own vectors, once, before you load a hundred million of them.


Common mistakes

Treating the score as a quality threshold. The write-ahead log is what makes a crash survivable scored 0.5700 against a question about carving meat. A cosine of 0.57 sounds respectable and means nothing here. Thresholds have to be calibrated per model and per corpus, or you filter on a number you made up.

Assuming the metric changes the answer. On unit vectors it does not — all three gave identical top-10 lists for all 12 queries. People switch metric hoping for better results and get exactly the same ones, then conclude vector search doesn’t work.

Assuming the metric never changes the answer. With lengths varying only 0.734× to 1.31×, the dot product already got a different top result on 6 of 12 queries. Both mistakes are common, and which one is yours depends entirely on whether your vectors are normalised. Check — don’t assume.

Mixing normalised and un-normalised vectors in one collection under Dot. The un-normalised ones win every query they appear in, by arithmetic, regardless of meaning.

Expecting upsert to merge payloads. It replaces them. A partial update is set_payload.

Using your own string as a point id. It’s a 400. Derive a UUID from it and keep the original in the payload.


Interview questions

1. When does the choice of distance metric change the results?

Answer Only when the vectors have different lengths. On unit vectors, cosine equals the dot product, and squared euclidean distance equals 2 − 2(a·b), which is a decreasing function of the dot product — so all three produce the identical ranking. Most modern text embedding models return unit vectors, which is why switching metric on them changes nothing. When magnitudes vary, dot product favours long vectors and euclidean penalises them; only cosine is invariant. **Follow-up:** so which would you pick? Cosine by default. Dot if you’ve confirmed everything is normalised and want the marginal saving.

2. What can never be changed about a collection after you create it?

Answer The vector dimension and the distance metric. Both are fixed at creation, because the stored vectors and the index are built around them. Changing either means creating a new collection and re-indexing everything. The zero-downtime way to do that is to build the new collection alongside the old one and swap an alias, which is Part 11. **Follow-up:** what about HNSW parameters? Those *can* be updated, and updating `m` or `ef_construct` triggers a rebuild of the graph. `ef` is a per-query parameter, not a collection one.

3. A user says results are bad. The top hit scores 0.58. Is that good or bad?

Answer Unanswerable as stated. Cosine scores are relative to the model and the corpus — this part has a 0.5700 hit that’s plainly wrong and a 0.5934 hit that’s exactly right. What to do instead: build a small set of queries with known correct answers, and measure whether the right document is in the top k. That’s a retrieval quality measurement, not a threshold. If you need a cut-off, calibrate it against that labelled set and re-calibrate whenever the model changes. **Follow-up:** when is a threshold justified? Deduplication or near-duplicate detection, where you can measure the distribution of known duplicates against known non-duplicates and find genuine separation.

4. Why can’t "order-1182" be a point id?

Answer Qdrant accepts unsigned integers and UUIDs only; anything else is a 400 with *value … is not a valid point ID*. Fixed-width ids keep the internal id-to-offset mapping compact, which matters at a hundred million points. The workaround is to derive a stable UUID from your key with `uuid.uuid5`, and store the original key in the payload with an index on it so you can still filter and fetch by it. **Follow-up:** why derive it rather than generate a random one? So that re-ingesting the same document updates the same point instead of creating a duplicate.

5. What does upsert do that insert wouldn’t?

Answer It inserts if the id is new and replaces if it isn’t, so re-running an ingest is safe and the point count doesn’t grow. The lab writes id 7 twice and the collection stays at 2 points. The catch is that it replaces the **whole** payload rather than merging fields. To change one field and keep the others, use `set_payload`. **Follow-up:** why does that matter for a pipeline? Because a re-embedding job that upserts with only the new vector and a minimal payload will silently wipe every other payload field.

6. Your embedding model isn’t normalised and you’re using Dot. What happens?

Answer Long vectors win. The score scales linearly with the vector’s norm at a fixed angle, so a document with 2.5× the norm gets 2.5× the score — which is enough to put a sentence about compilers at the top of a question about cats, as this part’s lab shows. Even a mild spread does damage: lengths varying 0.734× to 1.31× changed the top result on 6 of 12 queries. Fix it by normalising before you store, or by using Cosine. **Follow-up:** how would you notice this in production? Your worst offenders would be systematically long documents. Look at whether the length distribution of your top hits is skewed against the corpus.

7. Should the source text go in the payload?

Answer Usually yes, because a vector cannot be turned back into words and you need something to show the user. It is the normal pattern. Be aware of what it costs: the payload is now a second copy of your corpus, kept in sync with the first. At scale the alternatives are storing only a key and fetching from the system of record, or moving payloads to disk so they don’t occupy RAM (Part 10). **Follow-up:** what else belongs in the payload? Anything you’ll filter on — tenant, date, language, permissions — because those become payload indexes in Part 6.

8. Cosine and dot product returned literally identical scores. Is something wrong?

Answer No — it confirms the vectors are unit length. Cosine divides the dot product by both norms; when both are 1, the division does nothing, so the numbers are equal, not merely correlated. It’s a useful sanity check in the other direction too: if cosine and dot *disagree* on a collection you believed was normalised, something upstream is not normalising, and that’s worth finding before it reaches a metric that cares. **Follow-up:** how would you assert that in a test? Check that every stored vector’s norm is 1 within float32 tolerance — about 1e-6 — as this part’s lab does.

Sources

  • Qdrant documentation, https://qdrant.tech/documentation/concepts/collections/ and https://qdrant.tech/documentation/concepts/points/ — collections, distance metrics, point id types, upsert semantics and set_payload.
  • Xiao, S., Liu, Z., Zhang, P., Muennighoff, N., Lian, D. and Nie, J.-Y., C-Pack: Packed Resources For General Chinese Embeddings (arXiv:2309.07597). The paper the bge-small-en-v1.5 model card names as its citation. Note the paper is about the Chinese resources; the English models are siblings released by the same group, and the card is the better reference for the model itself.
  • BAAI/bge-small-en-v1.5 model card, https://huggingface.co/BAAI/bge-small-en-v1.5 — 384 dimensions. It does not document the output as normalised, which is why this part measures the norms rather than asserting them.
  • This part’s own lab: qdrant/checks/part02_first_collection/, output in qdrant/checks/output/part02-first-collection.json.

What to remember

A collection fixes two things forever: how many numbers a vector has, and how closeness is measured. A point is an id, a vector, and a payload — and the id has to be an integer or a UUID.

The metric looks like the big decision and usually isn’t. On unit vectors, cosine, dot and euclidean give the identical ranking, and the whole question evaporates. It comes back the moment vector lengths vary, and then dot product quietly starts ranking by size.

And a score is not a verdict. The best match out of four documents is still the best match out of four documents.

Ask whether your vectors are normalised before you ask which metric to use. That one fact decides whether the question matters at all.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.