Vector databases
Store and search millions of embeddings quickly.
Create a free account Try part of it here
Why it matters
A NumPy matrix in memory works well for a small search index. It stops being enough when your documents grow past a few thousand chunks, when several apps need the same index, or when documents change every day. A vector database stores each embedding with its labels. It can search millions of them in milliseconds, and it lets you filter, update and delete. Knowing what it does, and when you don't need one, saves you money and saves you building more than you need.
What you'll be able to do
- Explain what a vector database adds beyond a NumPy array
- Build a small vector store with add, search, filtering and delete
- Describe approximate nearest-neighbor search and its trade-off
- Choose between an in-memory index, pgvector and a hosted vector database
What it covers
- From a matrix to a database
- Exact versus approximate search
- A vector store in 30 lines
- Worked example: filter first, then rank
- Which option should you pick?
From a matrix to a database
Think about the contacts app on your phone. Each contact has its own entry with a name, a number and maybe a label like "Work". You can add one person, fix a typo or delete an old number. You never rebuild the whole list. You can show only your "Work" contacts. And when your phone restarts, everything is still there.
In the last two lessons, your search index was a NumPy matrix plus a list of documents. That's more like a list written on a whiteboard. It works, but every change means rewriting it. Wipe the board, by restarting the program, and it's gone.
A vector database is the contacts app for embeddings. It does the same search job as your matrix, plus the parts a real product needs. Each entry is called a record. A record holds three things:
- an id, a unique name for the record, like
"refund-policy"; - the vector, the embedding itself;
- the metadata, extra labels about the document, such as its language, its date or its author.
Here's how the two compare:
| Need | NumPy matrix in memory | Vector database |
|---|---|---|
| Store vectors with metadata | You keep a separate list | Built in: each record has an id, a vector and metadata |
| Survive a restart | Rebuild every time | Saved on disk |
| Search millions of vectors fast | Slow: compares with every vector | Approximate nearest-neighbor indexes |
| Filter ("only 2026 reports") | Write it yourself | Built into the query |
| Update and delete | Rebuild the matrix | Insert, update, delete by id |
"Saved on disk" means the data is stored in a file, not only in the computer's short-term memory. So it's still there after the program stops.
Show the answer
It's gone, so the app must rebuild or reload it before it can search again
Memory is wiped when a program stops. A vector database saves records on disk, so they survive a restart.
Exact versus approximate search
So far, every search compared the query with every stored vector. That's what index @ query does. This is called exact search, because it can't miss: it checks everything.
Exact search is fine up to tens of thousands of vectors. That covers most company knowledge bases. But the work grows with the size of the index. Ten times more vectors means ten times more arithmetic for every search.
Picture finding a friend's house in a city you don't know. Exact search is knocking on every door. It always works, but it's slow in a big city. A faster plan is to take the highway to the right part of town, then main roads, then side streets. You check only a few houses near the end.
Vector databases do the same with approximate nearest-neighbor search, or ANN for short. A common ANN index is called HNSW. It links each vector to a few of its closest neighbors, like roads between houses. A search walks along those links toward the query instead of checking everything.
The catch is in the word "approximate". The walk finds the true best matches almost every time, but not always. The share of the true best results it finds is called recall:
recall = (true top results the index found) ÷ (number of true top results)
In plain words: run an exact search to get the real best results. Count how many of them the fast index also returned. Divide by how many there were. A recall of 1.0 means it missed nothing.
Say exact search's top 5 are documents d1 to d5. The ANN index returns d1, d2, d3, d5 and d9. It found 4 of the 5, so recall is 4 ÷ 5 = 0.8. Here's the same count in Python:
true_top = ["d1", "d2", "d3", "d4", "d5"] # exact search's top 5 ann_top = ["d1", "d2", "d3", "d5", "d9"] # what the ANN index returned found = sum(1 for d in ann_top if d in true_top) print("found", found, "of", len(true_top)) print("recall:", found / len(true_top))
It prints found 4 of 5 and recall: 0.8. Real indexes usually score much closer to 1.0 than this made-up one. Their settings let you trade a little recall for more speed.
Go deeper How HNSW walks the graph
HNSW stands for "hierarchical navigable small world". It's a stack of graphs. A graph here is a set of points with links between some of them.
- The top layer holds only a few vectors, with long links between them. That's the highway.
- Each layer below holds more vectors with shorter links.
- The bottom layer holds every vector.
A search starts at the top. It keeps hopping to whichever linked neighbor is closer to the query. When no neighbor is closer, it drops down a layer and carries on. On the bottom layer it returns the closest vectors it found.
Because it only looks at vectors along its path, it can miss a good match that sits off the path. That's why recall is a little below 1.0. Settings such as how many links each vector keeps, and how many candidates the search tracks, trade speed for recall. HNSW is one family of ANN index. Others, for example, sort vectors into buckets and search only the buckets nearest the query.
Show the answer
0.9
Recall is the true top results found divided by how many there are: 9 ÷ 10 = 0.9. Returning 10 results doesn't matter if one of them is the wrong one.
A vector store in 30 lines
The core of any vector database fits in a small class. Records live in a dictionary keyed by id. Search first keeps only the records that pass the filter. Then it builds a matrix from them and ranks them:
import numpy as np class TinyStore: def __init__(self): self.records = {} # id -> {"vector": ..., "metadata": ...} def add(self, id, vector, metadata): self.records[id] = {"vector": np.array(vector, dtype=float), "metadata": metadata} def search(self, query, k=3, where=None): items = [(id, r) for id, r in self.records.items() if where is None or all(r["metadata"].get(key) == value for key, value in where.items())] if not items: return [] matrix = np.array([r["vector"] for _, r in items]) scores = matrix @ np.array(query, dtype=float) order = np.argsort(scores)[::-1][:k] return [(items[i][0], round(float(scores[i]), 3)) for i in order] store = TinyStore() store.add("a", [1, 0], {"lang": "en"}) store.add("b", [0.8, 0.6], {"lang": "en"}) store.add("c", [0.9, 0.44], {"lang": "de"}) print(store.search([1, 0], k=2)) print(store.search([1, 0], k=2, where={"lang": "en"}))
Here's what each part does:
addputs a record in the dictionary under its id. Adding the same id again replaces the old record, like editing a contact.whereis the filter, a dictionary such as{"lang": "en"}. A record passes only if its metadata matches every key and value in it. With no filter, every record passes.- The scores are dot products, as in the similarity search lesson.
np.argsort(...)[::-1]puts the best first, and[:k]keeps the topk.
store = {"a": 0.9, "b": 0.2, "c": 0.7}
top = sorted(store, key=store.get, reverse=True)[:2]
print(top)Show the answer
['a', 'c']
sorted orders the keys by score, highest first, which gives a, c, b, and [:2] keeps ['a', 'c']. That's top-k search.
[1, 0], so each record's score is its first number: a is 1, b is 0.8, c is 0.9. What does the first print show?Show the answer
[('a', 1.0), ('c', 0.9)]
With no filter, all three records are ranked and the best two are kept: a (1.0), then c (0.9).
The second search adds where={"lang": "en"}. Record c is German, so it's dropped before ranking. The result is [('a', 1.0), ('b', 0.8)].
That order matters: filter before ranking. That's how vector databases apply metadata filters. Real databases add saving to disk, ANN indexes and many users at once. Underneath, the idea is the same.
Worked example: filter first, then rank
You store four campaign reports, each with a year in its metadata. You want the two reports from 2026 that best match a query. The query is [1, 0], so each report's score is the first number of its vector: 0.9, 0.8, 0.7 and 0.1. Watch what happens if you rank first and filter second.
import numpy as np reports = [ {"id": "spring-2025", "vector": [0.9, 0.1], "year": 2025}, {"id": "summer-2025", "vector": [0.8, 0.2], "year": 2025}, {"id": "spring-2026", "vector": [0.7, 0.3], "year": 2026}, {"id": "hiring-2026", "vector": [0.1, 0.9], "year": 2026}, ] query = np.array([1.0, 0.0]) def top_k(items, k): scores = np.array([r["vector"] for r in items]) @ query order = np.argsort(scores)[::-1][:k] return [items[i] for i in order] rank_first = [r["id"] for r in top_k(reports, 2) if r["year"] == 2026] filter_first = [r["id"] for r in top_k([r for r in reports if r["year"] == 2026], 2)] print("rank, then filter:", rank_first) print("filter, then rank:", filter_first)
Ranking first keeps the two best reports overall. Both are from 2025 (scores 0.9 and 0.8). The filter then throws both away, so you get an empty list. Filtering first keeps only the two 2026 reports. Ranking those gives ['spring-2026', 'hiring-2026'], with scores 0.7 and 0.1. You asked for two results and got two. This is why the filter must run before, or during, the ranking.
Show the answer
It can return fewer than 5 results, or none, even when good English matches exist
If the top 5 overall are mostly in other languages, filtering afterward leaves few or none. Filtering first ranks only English records, so you get the best 5 of those.
Which option should you pick?
There are three common kinds of vector database:
- pgvector is an add-on for PostgreSQL, a popular database. It adds vector columns and vector search. It's a great default if you already use Postgres. Supabase includes it.
- Hosted services such as Pinecone, Weaviate, Qdrant Cloud and Turbopuffer run everything for you. There's nothing to install or keep running yourself.
- Open-source engines such as Qdrant, Milvus and Chroma can run on your own servers or on your laptop.
Whichever you choose, the vectors still come from your embedding model. If you switch to a new embedding model, you have to embed every document again.
For marketers: you probably don't need a vector database for a 200-page help center. Start with an in-memory index. Move to pgvector or a hosted service when the data, the traffic or the number of apps sharing it grows.
Start with the simplest one that works, and move up when the numbers force you to.
Show the answer
Exact search over an in-memory matrix (or pgvector if you already use Postgres)
At 1,200 vectors, exact search takes milliseconds. Add more machinery when the size of your data calls for it.
Practice what you just read
- 3 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 4 review cards for spaced repetition
Try a quiz question
What does approximate nearest-neighbor (ANN) search trade away for speed?