What an embedding actually is, why similarity becomes arithmetic, and the exact corpus size at which exact search stops being an option — measured against a real Qdrant, including the two ways this part’s own lab was wrong while passing every check.
Search used to mean matching words. You typed cat, and the engine found documents
containing cat. That works until someone types where do small cats sleep and the
answer is a sentence with none of those words in it.
This part is about the trick that fixes it, and about the wall you hit soon after. We’ll turn sentences into numbers, find the closest ones with plain arithmetic, and then watch that arithmetic get too slow to use. Everything after it in this series is a response to that wall.
Every number here was measured on one machine, in containers, on the day of writing. The machine is a 12th Gen Intel Core i5-1235U, 12 cores, 15.3 GiB of RAM, running Qdrant 1.19.1. Treat the shapes as the lesson and the absolute values as this laptop’s.
Try this first
You have a million short documents. Each one is a list of 384 numbers. A query arrives, also 384 numbers, and you want the 10 documents closest to it.
You write the obvious thing: compare the query to all million, keep the best 10. No index, no database, just numpy doing the arithmetic properly.
Write down your guesses before reading on.
- How long does one query take?
- How much RAM do the million vectors need?
- If you shrink the corpus to a thousand documents, how much faster does it get?
Guess (3) as a multiplier. That one is the interesting one.
An embedding is a list of numbers where direction carries meaning
Start with five sentences and one query. No database yet.
from fastembed import TextEmbedding
import numpy as np
sentences = [
"A cat sat on the mat.",
"Kittens like warm places.",
"The compiler emits bytecode.",
"My laptop fan is very loud.",
"Felines nap in sunny spots.",
]
query = "where do small cats sleep"
model = TextEmbedding("BAAI/bge-small-en-v1.5")
vectors = np.array(list(model.embed(sentences)))
q = np.array(list(model.embed([query]))[0])
print(f"each sentence is now {vectors.shape[1]} numbers")
print(f"the first three of sentence 1: {vectors[0][:3].round(4)}")
print(f"length of sentence 1's vector: {np.linalg.norm(vectors[0]):.4f}")
scores = vectors @ q
for i in np.argsort(-scores):
print(f"{scores[i]:.4f} {sentences[i]}")
It prints:
each sentence is now 384 numbers
the first three of sentence 1: [ 0.0303 -0.0152 0.0153]
length of sentence 1's vector: 1.0000
0.7338 Felines nap in sunny spots.
0.6775 Kittens like warm places.
0.6003 A cat sat on the mat.
0.4447 The compiler emits bytecode.
0.4187 My laptop fan is very loud.
Look at the winner. Felines nap in sunny spots shares no word with where do
small cats sleep. Not one. A keyword search over these five sentences returns nothing
at all for that query. The vectors put it first, at 0.7338, ahead of two sentences that
do contain the word cat.
That is the whole trick. A model called an embedding model reads a piece of text and returns a fixed-length list of numbers — here 384 of them. Texts that mean similar things get similar lists. The meaning has been turned into a position.
Three things in that output are worth naming, because the rest of the series depends on them.
384 is the model’s choice, not yours. bge-small-en-v1.5 always returns 384 numbers,
whether it reads one word or a paragraph. A different model returns a different number —
768, 1024, 1536. That count is the dimension, and a collection of vectors has exactly
one dimension for all of them. Mixing models means mixing coordinate systems, and the
numbers stop meaning anything relative to each other.
The length is 1.0000, and that is deliberate. This model returns unit vectors: every one sits on the surface of a sphere. So all that distinguishes two vectors is direction.
vectors @ q is the entire search. That’s a matrix multiply — one dot product per
sentence. Because every vector has length 1, the dot product is the cosine of the angle
between them. There is no special similarity function. The similarity is arithmetic you
already know.
A dot product of unit vectors is the cosine of the angle between them. That identity is why cosine similarity is cheap, and why normalising your vectors is not an optional tidiness step.
Exact search is a matmul, and a matmul reads everything
That vectors @ q was over five sentences. Nothing about it changes at a million, except
how long it takes. Here it is done properly — float32, contiguous rows, warmed caches, one
query at a time:
import time
import numpy as np
DIM = 384
QUERIES = 100
def unit(rows):
return rows / np.linalg.norm(rows, axis=1, keepdims=True)
rng = np.random.default_rng(20260918)
queries = unit(rng.standard_normal((QUERIES, DIM), dtype=np.float32))
print(f"{'vectors':>10} {'RAM':>10} {'fastest':>10} {'typical':>10}")
for n in (1_000, 10_000, 100_000, 1_000_000):
corpus = unit(rng.standard_normal((n, DIM), dtype=np.float32))
for q in queries[:20]: # warm the caches
corpus @ q
times = []
for q in queries:
start = time.perf_counter()
scores = corpus @ q # every row, every time
top10 = np.argpartition(-scores, 10)[:10]
times.append((time.perf_counter() - start) * 1000)
times.sort()
mib = n * DIM * 4 / 1024**2
print(f"{n:>10,} {mib:>8.1f}MiB {times[0]:>8.2f}ms {times[len(times)//2]:>8.2f}ms")
del corpus
It prints:
vectors RAM fastest typical
1,000 1.5MiB 0.05ms 0.08ms
10,000 14.6MiB 1.00ms 3.04ms
100,000 146.5MiB 5.82ms 6.74ms
1,000,000 1464.8MiB 62.73ms 93.35ms
Measured by checks/part01_why_vectors/run.py against Qdrant 1.19.1 in a container on a 12-core i5-1235U. Exact search is linear in the corpus once the data leaves cache. Switch scenario to see where the time goes at one size: at 200,000 vectors the index answers in 6.316 ms, and 5.823 ms of that is a round trip to a database holding one point.
The lab behind this part runs the same measurement with 600 timed queries per size. Its numbers, on the same machine:
| vectors | raw vectors in RAM | fastest of 600 | typical (p50) | slowest 1% (p99) | queries per second |
|---|---|---|---|---|---|
| 1,000 | 1.5 MiB | 0.048 ms | 0.119 ms | 0.331 ms | 8,438 |
| 10,000 | 14.6 MiB | 1.062 ms | 4.876 ms | 11.062 ms | 205 |
| 100,000 | 146.5 MiB | 7.801 ms | 12.741 ms | 23.073 ms | 78 |
| 1,000,000 | 1,464.8 MiB | 75.096 ms | 109.197 ms | 165.053 ms | 9 |
Now the answers to the three guesses.
One query over a million vectors takes about 109 ms, and the slowest one in a hundred takes 165.053 ms. That is the whole latency budget of a web page spent on one search, doing nothing else — no filtering, no ranking, no fetching the documents themselves.
The million vectors need 1,464.8 MiB. That is 1,000,000 × 384 × 4 bytes, and it is arithmetic, not a measurement: a float32 is 4 bytes and there are 384 of them per vector. You can compute your own bill right now. Ten million vectors at 768 dimensions is 29 GiB before the database has stored a single document, ID or payload.
The third guess is the one that misleads people. A thousand times less data is not a thousand times faster — it’s better than that. Going from 1,000 vectors to 1,000,000, a 1,000× increase in data cost 1,564× more time on the fastest query at each size.
More than proportional, and the reason is the cache. 1,000 vectors is 1.5 MiB, which fits in this CPU’s L2 and L3, so the matmul runs at cache speed and looks wonderful. At 14.6 MiB it no longer fits, and from there every query is reading main memory. Measured only across the sizes that don’t fit in cache — 10,000 up to 1,000,000, a 100× increase — the time grew 71×, near enough proportional given how much a laptop’s timings wander.
That wandering is worth naming. Re-running this whole lab on a busier machine moved the 10,000-vector row by a factor of two, which is enough to make any single pair of adjacent rows tell whatever story you like. The wide span survives that; a step-by-step ratio does not.
So the shape of exact search is: linear in the number of vectors, once your data is too big for cache. Which is immediately. There’s no clever constant factor waiting to save you. Doubling your corpus doubles every query.
Why not a tree? The dimensions are the problem
The natural instinct is to reach for an index like the ones databases already have. A B-tree finds a row in a million by touching about twenty pages. Why not sort the vectors somehow and do the same?
Because that instinct comes from one dimension, where “sorted” means something. Spatial index structures — k-d trees, R-trees, and their many descendants — do work, and they work well in two or three dimensions. Then they stop.
The reason is that volume in high dimensions behaves nothing like our intuition. To capture a fixed fraction of the points near a query, the region you have to search grows until it covers nearly the whole space. A tree that has to visit nearly every branch has bought you nothing over reading everything in order, and it pays extra for the pointer-chasing.
Weber, Schek and Blott quantified this in 1998. They showed that partitioning and clustering schemes for high-dimensional vector spaces end up with linear complexity, and that above about 10 dimensions a plain sequential scan beat the index structures of the day on average. Their answer was not a better tree but a better scan — the VA-File, which approximates vectors so the unavoidable scan reads less.
We are at 384 dimensions. This is not a borderline case.
That leaves two honest options. Scan everything, and accept the times in the table above. Or give up on being exactly right.
Giving up on exactly right
This is the trade that makes vector search practical, and it is worth being blunt about what is being traded.
An approximate nearest neighbour index does not promise the 10 closest vectors. It
promises 10 close ones, quickly, and it is usually right. How usually is a number you
measure, called recall. If the true 10 nearest are {a..j} and the index returns eight
of them, recall@10 is 0.8.
Qdrant’s index is HNSW — a hierarchical navigable small world graph, from Malkov and Yashunin’s 2016 paper. Part 3 opens it up. For now, one sentence: it links each vector to a few of its neighbours, and a search walks that graph greedily towards the query instead of reading every row.
Here is the same database doing both, on the same 100,000 vectors, in the same process. The only difference is one flag.
def run(exact):
ids, times = [], []
params = models.SearchParams(exact=exact)
for q in queries[:20]:
client.query_points("demo", query=q.tolist(), limit=K, search_params=params)
for q in queries:
start = time.perf_counter()
hits = client.query_points("demo", query=q.tolist(), limit=K,
search_params=params, with_payload=False).points
times.append((time.perf_counter() - start) * 1000)
ids.append([h.id for h in hits])
times.sort()
return times[len(times) // 2], ids
exact_ms, truth = run(exact=True)
index_ms, found = run(exact=False)
agree = sum(len(set(t) & set(f)) for t, f in zip(truth, found)) / (QUERIES * K)
print(f"exact scan : {exact_ms:6.2f} ms")
print(f"HNSW index : {index_ms:6.2f} ms ({exact_ms / index_ms:.1f}x faster)")
print(f"the index returned {agree:.1%} of what the exact scan returned")
It prints:
100,000 points, 96,000 of them in the index
exact scan : 10.65 ms
HNSW index : 5.14 ms (2.1x faster)
the index returned 100.0% of what the exact scan returned
Two things in that output deserve more attention than the speedup.
“96,000 of them in the index.” Not 100,000. A Qdrant collection is stored as several
segments, and a segment only gets an HNSW graph once it holds enough vectors to be
worth indexing. The rest are searched by brute force and the results merged. So a real
collection is normally part index and part scan, and the indexed_vectors_count field is
how you find out which. A reader who assumes “GREEN status means fully indexed” will
misread every benchmark they run. Part 3 covers the threshold that decides this.
2.1× is a disappointing number, and it is the honest one for this setup. It is small for two reasons: 4% of the data is still being scanned, and — the bigger reason — most of those 5.14 ms is not searching at all.
Most of a fast query is not the search
To find out how much of a query is actually searching, the lab measures a floor: the same request — 384 floats encoded as JSON, sent over the socket, a reply decoded — against a collection holding exactly one point. Same client, same shapes, nothing to search.
Everything below is repeated 3 times and interleaved, and quoted as the mean with the range across those runs. The reason for that is the point of this section.
| what | typical (p50), mean of 3 runs | range across runs |
|---|---|---|
| a one-point collection: the round trip alone | 5.823 ms | 5.283 – 6.835 ms |
Qdrant, HNSW at ef=128 |
6.316 ms | 5.517 – 7.468 ms |
Qdrant, exact=True |
28.989 ms | 20.18 – 35.597 ms |
Read the first two rows together. An indexed search over 200,000 vectors costs 6.316 ms, and 5.823 ms of that is a round trip to a database with one point in it. The round trip is 92% of what the client waits for, and across runs it never fell below 89%.
The index is worth about 4.6× over the exact scan as the client experiences it — 3.66× to 5.97× across runs, and both rows include the same round trip, so that ratio is understated for the engine and about right for you.
What this measurement cannot tell you
Subtract the floor from the indexed search and you get 0.493 ms, ranging 0.167 – 0.679 ms across the 3 runs. It is tempting to call that “the search time”. Don’t.
An earlier version of this lab did exactly that and got a different answer every time,
because the quantity is a difference between two numbers that are each noisier than the
difference itself. Re-running the same comparison in a separate session gave a mean of
1.46 ms rather than 0.493 ms — a three-fold disagreement about a number that looks
precise to three decimal places. An even earlier version used the real collection with
ef=1 as its “floor”, which still walks the graph; that one occasionally reported the floor
as slower than the search it was supposed to bound, which is how the problem was found.
You cannot measure a sub-millisecond server-side operation by subtracting two client-side latencies. The noise is larger than the thing.
What survives is the part that doesn’t depend on the subtraction: the round trip is the large majority of the number, the index beats the scan by roughly 4.6× end to end, and the search itself is small enough to be hard to see. To actually time the search you need the server’s own instrumentation, which is Part 13’s territory.
What to do with that
Tuning ef down to make search faster, when 92% of your latency is the round
trip, buys you almost nothing and costs you recall. The thing to fix first is the number of
round trips — batching many queries per request, reusing connections, and gRPC instead of
REST. Part 10 comes back to this with the storage settings that genuinely do move p99.
One more row is worth keeping in view. The same exact search done by numpy inside this
Python process, with no network at all, takes 23.631 ms at p50 — squarely inside the
20.18 – 35.597 ms range Qdrant’s exact=True took, and both score recall 1.0.
Given the same instruction to compare against everything, the database does the same work at
broadly the same speed. The speed came from the index, not from the database. What the
database gives you is everything else — persistence, filtering, concurrent writes, snapshots,
replication — which is what the rest of the series is about.
Recall belongs to your data, not just to your settings
One more measurement, and it is the one most likely to save you from publishing a wrong number.
The lab indexes 100,000 vectors twice, with identical HNSW settings and identical ef=128,
changing only the shape of the data. Once with uniform random directions. Once with 400
clusters, which is roughly how real embeddings sit — clumpy, because real documents are
about a limited number of things.
The lab builds 3 separate corpora and 3 separate graphs for each shape, because Qdrant’s HNSW build is multithreaded and therefore not deterministic. The same code gives a slightly different graph, and a slightly different recall, every run.
| data shape | recall@10 (mean of 3) | range over the 3 | typical latency | best cosine to a same-cluster point | best cosine to any other point |
|---|---|---|---|---|---|
| uniform random | 0.5197 | 0.5190 – 0.5210 | 10.395 ms | — | — |
| 400 clusters | 1.0000 | 1.0000 – 1.0000 | 7.833 ms | 0.909 | 0.191 |
Same index, same parameters, same corpus size. Recall goes from about 0.52 to 1.00 because the data changed shape.
On uniform random data in 384 dimensions, every point is nearly the same distance from every other point. “Nearest” is barely a fact — the 10th nearest and the 500th nearest are almost tied, so a greedy walk has no gradient to follow and no reason to find one rather than the other. On clustered data the query lands in a cluster and its true neighbours are right there.
Neither number describes your production system. Real embeddings are clumpier than uniform noise and messier than 400 tidy Gaussians, so real recall sits between these two and has to be measured on your own vectors. Part 4 does that on a real corpus, and shows how to build the ground truth to measure it against.
The reason this section exists at all is that the first version of this lab got it wrong twice, and both times the lab ran clean and reproduced perfectly:
- The corpus was clustered and the queries were drawn uniformly at random. Recall came out at 0.35, which says nothing about HNSW and everything about asking for a point that lies nowhere near any cluster. Real queries come from the same distribution as the data. This is why benchmark datasets ship a query set instead of telling you to generate one.
- The cluster noise was scaled per dimension instead of by √384. At 384 dimensions the noise had length 6.86 against a unit-length centre, so the clusters were annihilated and both “shapes” were the same uniform data. The tell was in the data, not in the database: a same-cluster neighbour scored 0.102, worse than a random point from another cluster at 0.187. Fixing the scale moves those to 0.909 and 0.191.
Fixing the first changed recall by 0.00. That is what sent the investigation back to the generator.
A lab that reproduces perfectly can still be measuring its own instrument. Determinism is not correctness.
Explain it like I’m ten
Imagine a huge library where books are not on shelves by title. They’re placed by what they’re about. Books about cats are all in one corner. Books about volcanoes are in another. Nobody wrote the categories down — the books just drifted to where they belong.
Now you want books like the one in your hand. The slow way is to walk the whole library and compare every book to yours. You’ll definitely find the best match. You’ll also be walking for a very long time.
The fast way: ask a librarian who knows a few neighbours of every book. You say “I’m holding this one.” She points you to a book that’s a bit closer. You ask again. A few hops later you’re standing in the right corner, and it took ten questions instead of a million.
She might miss one. There could be a slightly better book one shelf over that nobody pointed at. Almost always she’s right, and she’s thousands of times faster, so you take the deal.
Where the analogy breaks: a real library is a room, and rooms have three dimensions. Ours has 384. In 384 dimensions “the next corner over” is not a place you can picture, and the volume grows so fast that a region big enough to hold the near books is nearly the whole library. That’s precisely why the walk-everything approach is so hard to beat, and why the librarian’s shortcut is a graph of remembered neighbours rather than a map.
The precise version
An embedding model maps text to a point in ℝ³⁸⁴, trained so that texts with similar
meaning map to points with a small angle between them. Because the model returns unit
vectors, cosine similarity equals the dot product, and the nearest-neighbour problem is:
given query q, find the k rows of matrix C maximising C·q.
Exact solution is a full matrix-vector product: Θ(n·d) work and Θ(n·d) bytes read, which
the table above shows as linear once the corpus exceeds cache. Space-partitioning indexes
that succeed at low d fail at d = 384, because the query region needed to guarantee
correctness approaches the whole space, so the index degenerates to a scan with worse
constants.
HNSW instead builds a navigable small world graph with a hierarchy of layers, and answers a
query with a greedy best-first search from a fixed entry point, keeping a candidate list of
size ef. It returns the true top-k only probabilistically. Recall@k is an empirical
property of the graph, the parameters and — as the table shows — the intrinsic structure of
the data.
Trade-offs
When exact search is the right answer. Below roughly 10,000 vectors the whole corpus is 1–15 MiB and a query is under a millisecond. A numpy array in your process is simpler than a database, has no round trip, is always exactly right, and needs no operational attention. Do not install a vector database to search 5,000 documents. The first table in this part is the argument for not using Qdrant, and it is a good argument at small sizes.
When approximate search is wrong. If being wrong is not acceptable — deduplication against a legal register, matching a biometric, anything where a missed match is a liability — approximate recall of 0.98 means 2% of matches silently disappear. Either scan exactly, or use the index to shortlist and verify exactly afterwards.
What you give up by going approximate. Reproducibility, partly. A multithreaded HNSW build is not deterministic, so two builds of the same data answer slightly differently. That is fine for a recommendation feed and awkward for a regression test. Test recall against a tolerance, not against a stored list of IDs.
What the database costs you. A round trip, which at this corpus size is most of your
latency. An in-process index like FAISS or hnswlib avoids it. You give up persistence,
filtering, concurrent writes, snapshots and replication to get that. That’s the trade, and
for most systems the round trip is the cheaper thing to lose.
Common mistakes
Benchmarking against a query set drawn from the wrong distribution. This part’s own lab did it, and reported recall 0.35 for a healthy index. If your queries don’t look like your data, your recall number is fiction.
Reading status: green as “fully indexed”. It isn’t. 96,000 of them in the index came
from a green collection. Check indexed_vectors_count against points_count before
quoting any latency.
Tuning ef when the round trip is the cost. At 200,000 vectors the round trip was
92% of what this client waited for. Lowering ef there trades recall for almost
nothing. Measure the floor before you tune above it.
Assuming ten times less data is ten times faster. From 1,000 to 10,000 vectors this machine got 14.9× slower for 10× the data, because 1,000 vectors fit in cache. Cache effects at small sizes make small benchmarks flatter than reality.
Forgetting that the vectors themselves are the RAM bill. 1,000,000 × 384 × 4 bytes is 1,464.8 MiB before Qdrant stores one payload, one ID or one graph edge. Work this out at design time. Part 5 is about getting it down.
Mixing embedding models in one collection. Two models produce two coordinate systems. The dot products between them are arithmetically valid and semantically meaningless. If you change model, you re-embed everything.
Interview questions
1. Why can’t we use a B-tree for vector search?
Answer
A B-tree orders keys along one dimension, and “closest” in 384 dimensions is not an ordering. The deeper problem is volume: to guarantee you’ve found the true nearest neighbour, the region you must search grows with dimension until it covers nearly the whole space, so the tree visits nearly every branch and pays pointer-chasing on top. Weber, Schek and Blott showed in 1998 that above about 10 dimensions, a sequential scan beats these structures on average. A strong answer names the mechanism (region volume approaching the whole space), not just “the curse of dimensionality” as a phrase. **Follow-up you’ll get:** so is a sequential scan optimal? No — it’s the best *exact* approach for a single query, but you can drop exactness (HNSW), compress the vectors (quantization, Part 5), or partition by an attribute you filter on anyway (Part 7).2. What is recall@10, and why can’t you just set it high?
Answer
Recall@10 is the fraction of the true 10 nearest neighbours that the index actually returned, averaged over a query set. It is measured, not configured: you compute the true answer by exact search, then compare. You can’t set it because it’s an outcome of the graph, the search parameters and the data. You can *influence* it — raising `ef` explores more candidates and raises recall at the cost of latency. But the same `ef` gives different recall on differently shaped data: this part’s lab measured 0.5197 on uniform random vectors and 1.0000 on clustered ones, same settings. **Follow-up:** how would you pick `ef` in production? Measure the recall/latency curve on your own vectors and your own query distribution, pick the recall the product needs, and take the lowest `ef` that holds it with margin. Part 4 builds that curve.3. How much RAM does a million 768-dimension vectors need?
Answer
1,000,000 × 768 × 4 bytes for float32, which is about 2.86 GiB for the raw vectors alone. Then add the HNSW graph edges, the payloads, the IDs and the ID-to-offset mapping. The point of the question is whether you compute it rather than guess. A strong answer also says what to do about it: quantization (Part 5) cuts the vector bytes by 4× for scalar and 32× for binary, at a recall cost you measure; or move vectors to disk with mmap (Part 10) and pay in latency instead. **Follow-up:** which would you reach for first? Scalar quantization, usually — roughly 4× less memory with a small recall loss that rescoring largely recovers.4. A search takes 6 ms. Where is the time going?
Answer
You don’t know until you measure the floor. Send the same request to a collection holding one point: same payload, same round trip, nothing to search. This part’s lab found that floor was 5.823 ms of a 6.316 ms query — 92% of it. A strong answer stops there rather than subtracting. The residual came out at 0.493 ms in one session and 1.46 ms in another, because it is a difference between two numbers each noisier than the difference. You can say the round trip dominates; you cannot get the search time this way. For that you need server-side timing. A candidate who starts tuning `ef` has optimised the 8%. **Follow-up:** how do you reduce the round trip? Batch multiple queries per request, reuse connections, use gRPC rather than REST, and co-locate the client with the database.5. Why does approximate search work at all?
Answer
Because the graph is navigable: neighbours-of-neighbours reach any point in a small number of hops, and the similarity landscape is smooth enough that moving to a better neighbour usually moves you towards the global best. HNSW’s layers let the search take long strides first and short ones near the target. It works *well* when the data has structure to exploit. On uniform random vectors in 384 dimensions there’s almost no gradient — every point is nearly equidistant — and recall falls to about 0.5 in this part’s measurement. Real embeddings are clustered, which is exactly the structure the graph needs. **Follow-up:** so what breaks it in production? A filter that removes almost everything, so the graph’s neighbours are nearly all ineligible. That’s Part 7.6. Your recall benchmark says 0.35. What do you check first?
Answer
The query set, before touching a single index parameter. If the queries aren’t drawn from the same distribution as the corpus, recall is meaningless — this part’s lab reported 0.35 for a perfectly healthy index for exactly that reason. Then, in order: is the ground truth actually exact (run `exact=True` and confirm it scores recall 1.0 against your own exact computation — if it doesn’t, the bug is in your comparison, not the index); is the data really the shape you think it is; and is the collection actually indexed (`indexed_vectors_count`). **Follow-up:** what if all of that checks out? Then look at the data’s intrinsic structure. A corpus with no clusters genuinely does give poor recall, and the fix is not a parameter.7. When would you not use a vector database?
Answer
Below about 10,000 vectors, where an in-process numpy array answers in under a millisecond, is always exactly right, and needs no operations. Also when exactness is a requirement, and when your retrieval problem is actually keyword matching — if users search for order IDs and part numbers, a dense embedding is the wrong tool and a text index is the right one. Part 9 is about the case where you need both. **Follow-up:** what changes your mind as you grow? Corpus size pushing scan latency past budget, the need to filter and search at once, concurrent writes, or needing the data to survive a restart.8. Why must all vectors in a collection come from the same model?
Answer
Because a model defines a coordinate system. Two models trained separately put “cats” in different places, and the dot product between a vector from one and a vector from the other is a valid number with no meaning. Even two models with the same 384 dimensions are not interchangeable. So changing embedding model means re-embedding the whole corpus and rebuilding the index. Part 11’s alias-swap pattern is how you do that without downtime. **Follow-up:** how do you avoid finding this out in production? Store the model name and version in the collection or its payloads, and refuse writes that don’t match.Sources
- Malkov, Y. and Yashunin, D., Efficient and Robust Approximate Nearest Neighbor Search Using Hierarchical Navigable Small World Graphs (arXiv:1603.09320). The HNSW algorithm Qdrant’s index implements.
- Weber, R., Schek, H.-J. and Blott, S., A Quantitative Analysis and Performance Study for Similarity-Search Methods in High-Dimensional Spaces, Proceedings of the 24th VLDB Conference, 1998, pages 194–205. The source of the ~10-dimension figure: it shows partitioning and clustering schemes become linear at high dimensionality, and introduces the VA-File as a faster scan rather than a better index.
- Qdrant documentation, https://qdrant.tech/documentation/ — collections, search
parameters,
exact,indexed_vectors_countand the segment model. - Xiao, S. et al., C-Pack: Packed Resources For General Chinese Embeddings
(arXiv:2309.07597). The BGE family, including
bge-small-en-v1.5used throughout. - This part’s own lab:
qdrant/checks/part01_why_vectors/, output inqdrant/checks/output/part01-why-vectors.json.
What to remember
Exact vector search is a matrix multiply. It is simple, always right, and linear in your corpus — 0.049 ms at a thousand vectors and 88 ms at a million on this laptop. The 384 dimensions rule out the tree-shaped indexes that rescue ordinary databases, so the only way past linear is to stop insisting on the exact answer. HNSW does that, and what you get back is measured as recall, which depends on your parameters and on the shape of your data.
And before you tune any of it: find out how much of your latency is the database thinking. At 200,000 vectors on this laptop, 92% of what the client waited for was the round trip to a database with one point in it.
Exact search is linear and always right. Everything else in vector search is a negotiation about how much of “always right” you’re willing to sell, and for how much speed.