Vector databases: pgvector, Chroma and friends
Store embeddings in a vector database so search is persistent, filterable and fast. Build the same menu search with Chroma and with PostgreSQL plus pgvector, and learn how to choose between them.
What you will build: the same menu search from the embeddings lesson, but with the vectors stored in a vector database, so they survive restarts and search stays fast with many documents. You will do it twice: with Chroma (nothing to install except a Python package) and with PostgreSQL plus pgvector (the choice when you already use Postgres).
Prerequisites: *Embeddings and semantic search* (you need its embed_util.py helper file), basic SQL, and, for the pgvector half, Docker Desktop or a Postgres you can add extensions to.
Why not just keep vectors in a list?
In the last lesson the vectors lived in a NumPy array. That works for a few thousand texts, but it has problems: everything is lost when the program stops, every search compares against every vector, and you cannot easily filter ("only bakery items") or update one document.
A vector database stores vectors with their text and metadata, and answers the question "which stored vectors are nearest to this one?" quickly. For large collections it uses an approximate nearest neighbour (ANN) index such as HNSW. "Approximate" means it may occasionally miss the true best match in exchange for being much faster.
The landscape (names as of 2026)
| Option | Type | Good for | Trade-off |
|---|---|---|---|
| Chroma | Embedded or server, open source | Learning, prototypes, small apps | You operate and back it up yourself |
| pgvector | Extension for PostgreSQL | Apps already on Postgres; SQL filters and joins; one database to run | Needs Postgres tuning at large scale |
| Qdrant, Weaviate, Milvus | Dedicated open-source servers | Large scale, advanced filtering | Another service to run |
| Pinecone and other managed services | Hosted | No operations work | Paid beyond free tiers, vendor lock-in |
| FAISS | Library, not a database | Fast in-memory search in research code | No storage, filters or server |
There is no single best choice. A common, sensible path: Chroma while learning, pgvector in production if you already have Postgres, and a dedicated vector database only when scale or features demand it.
Part 1: Chroma
pip install -U chromadbAlso keep the packages from the last lesson: google-genai, numpy, python-dotenv.
import chromadb
from embed_util import embed
# A persistent client stores data in this folder, so it survives restarts
client = chromadb.PersistentClient(path="chroma_data")
collection = client.get_or_create_collection(
name="menu",
metadata={"hnsw:space": "cosine"}, # use cosine distance
)
ITEMS = [
{"id": "espresso", "text": "Espresso: a short, intense shot of coffee.", "category": "coffee"},
{"id": "cappuccino", "text": "Cappuccino: espresso with steamed milk and a thick layer of foam.", "category": "coffee"},
{"id": "cold-brew", "text": "Cold Brew: coffee steeped slowly in cold water, smooth, bold and unsweetened.", "category": "coffee"},
{"id": "filter-coffee", "text": "Filter Coffee: South Indian style coffee with hot milk, served in a steel tumbler.", "category": "coffee"},
{"id": "masala-chai", "text": "Masala Chai: black tea simmered with milk and spices like ginger and cardamom.", "category": "tea"},
{"id": "croissant", "text": "Butter Croissant: flaky, buttery pastry baked fresh every morning.", "category": "bakery"},
{"id": "banana-bread", "text": "Banana Bread: moist, lightly sweet loaf made with ripe bananas.", "category": "bakery"},
{"id": "veg-sandwich", "text": "Veg Grilled Sandwich: grilled bread with vegetables and cheese.", "category": "food"},
]
vectors = embed([i["text"] for i in ITEMS], "RETRIEVAL_DOCUMENT")
# upsert = insert, or replace if the id already exists (safe to re-run)
collection.upsert(
ids=[i["id"] for i in ITEMS],
documents=[i["text"] for i in ITEMS],
embeddings=vectors.tolist(),
metadatas=[{"category": i["category"]} for i in ITEMS],
)
print("Stored:", collection.count())Run once to build the index. Using ids you choose (the menu IDs) means re-running updates instead of duplicating.
import chromadb
from embed_util import embed
collection = chromadb.PersistentClient(path="chroma_data").get_collection("menu")
def search(query, k=3, category=None):
q = embed([query], "RETRIEVAL_QUERY")[0].tolist()
result = collection.query(
query_embeddings=[q],
n_results=k,
where={"category": category} if category else None, # metadata filter
)
# Results are lists of lists: one inner list per query
for doc, dist in zip(result["documents"][0], result["distances"][0]):
print(f" similarity {1 - dist:.2f} {doc}")
print("Anything warm and spicy?")
search("anything warm and spicy?")
print("\nSomething sweet, bakery only")
search("something sweet", category="bakery")Cosine distance is 1 minus cosine similarity, so we print 1 - dist. The where filter restricts the search to one category.
Anything warm and spicy?
similarity 0.68 Masala Chai: black tea simmered with milk and spices like ginger and cardamom.
...
Something sweet, bakery only
similarity 0.62 Banana Bread: moist, lightly sweet loaf made with ripe bananas.
...Use stable ids
Run chroma_index.py twice and check collection.count() stays at 8. That is the benefit of stable ids with upsert. If you had used random ids, you would get duplicates on every run.
Part 2: PostgreSQL with pgvector
pgvector adds a vector column type and distance operators to Postgres. Your vectors then live next to your normal tables, and you can use WHERE, JOIN and transactions as usual. This is a big reason teams choose it.
Plan
- 1Start a Postgres that already includes pgvector, using Docker. The password here is for local learning only.
- 2Install the Python packages
psycopg(the Postgres driver) andpgvector(helper that converts NumPy arrays). - 3Enable the extension, create a table with a
vector(768)column, and add an index. - 4Insert the menu rows with their vectors, then query by distance.
docker run --name pgvec -e POSTGRES_PASSWORD=postgres -p 5432:5432 -d pgvector/pgvector:pg17
pip install -U "psycopg[binary]" pgvectorThe image pgvector/pgvector is Postgres with the extension installed. If you already run Postgres, install pgvector on it instead and run CREATE EXTENSION vector;.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE IF NOT EXISTS menu_docs (
id text PRIMARY KEY,
content text NOT NULL,
category text NOT NULL,
embedding vector(768) NOT NULL -- must match the embedding size you use
);
-- Approximate index for fast cosine search on large tables.
-- With only 8 rows Postgres will not use it, but this is what you add at scale.
CREATE INDEX IF NOT EXISTS menu_docs_embedding_idx
ON menu_docs USING hnsw (embedding vector_cosine_ops);The three distance operators are <-> (Euclidean), <#> (negative inner product) and <=> (cosine distance). The index operator class must match the operator you query with.
import psycopg
from pgvector.psycopg import register_vector
from chroma_index import ITEMS # reuse the same menu list (this re-runs chroma_index.py, which is harmless)
from embed_util import embed
DSN = "postgresql://postgres:postgres@localhost:5432/postgres"
with psycopg.connect(DSN, autocommit=True) as conn:
conn.execute("CREATE EXTENSION IF NOT EXISTS vector") # before register_vector
register_vector(conn) # lets psycopg send and read NumPy vectors
conn.execute(open("schema.sql", encoding="utf-8").read())
vectors = embed([i["text"] for i in ITEMS], "RETRIEVAL_DOCUMENT")
for item, vec in zip(ITEMS, vectors):
conn.execute(
"""INSERT INTO menu_docs (id, content, category, embedding)
VALUES (%s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE
SET content = EXCLUDED.content,
category = EXCLUDED.category,
embedding = EXCLUDED.embedding""",
(item["id"], item["text"], item["category"], vec),
)
print("Rows:", conn.execute("SELECT count(*) FROM menu_docs").fetchone()[0])Creates the table and inserts the vectors. ON CONFLICT ... DO UPDATE makes it safe to re-run.
import psycopg
from pgvector.psycopg import register_vector
from embed_util import embed
DSN = "postgresql://postgres:postgres@localhost:5432/postgres"
def search(query, k=3, category=None):
q = embed([query], "RETRIEVAL_QUERY")[0]
with psycopg.connect(DSN) as conn:
register_vector(conn)
rows = conn.execute(
"""SELECT content, 1 - (embedding <=> %s) AS similarity
FROM menu_docs
WHERE (%s::text IS NULL OR category = %s)
ORDER BY embedding <=> %s
LIMIT %s""",
(q, category, category, q, k),
).fetchall()
for content, similarity in rows:
print(f" similarity {similarity:.2f} {content}")
if __name__ == "__main__":
search("anything warm and spicy?")
search("something sweet", category="bakery")ORDER BY embedding <=> query sorts by cosine distance, so the nearest rows come first. The WHERE line is an optional SQL filter.
Dimensions must match
The embedding size in vector(768) must match the size your embedding code produces. If you change the model or DIMS, the insert fails with a dimension error. Create a new column or table and re-embed. Also note that pgvector's regular vector index type has a limit on dimensions (around 2,000), so very large embeddings may need shortening or another type; check the pgvector README.
Choosing between them
Decision guide
- Do you already run Postgres and want one system? Use pgvector.
- Do you need a quick local prototype with no server? Use Chroma.
- Do you need to join vectors with users, orders or permissions? pgvector (SQL joins).
- Over millions of vectors or heavy filtering? Look at a dedicated vector database and benchmark with your own data.
- Do you want zero operations work and accept a paid service? A managed vector database.
Whatever you pick, store three things with every vector: the source text, metadata (where it came from, category, date, who may see it) and the embedding model name. Metadata powers filters and citations; the model name tells you when a re-embed is needed.
Common mistakes
Pitfalls
| Mistake | Result | Fix |
|---|---|---|
| Embedding queries and documents with different models | Poor or random matches | One model for both; record it |
| Forgetting the distance metric | Wrong ordering | Match the index operator class to the query operator |
| No stable ids | Duplicates after every re-run | Use ids from your data and upsert |
| Returning too many results | Slow, costly prompts later | Start with 3 to 5 |
| Ignoring permissions | Users see other people's documents | Filter by owner or tenant in the same query |
How this comes up in interviews
Common questions: "What is a vector database and why not use a normal database?" (nearest-neighbour search over vectors, with ANN indexes), "What is HNSW?" (a graph-based index that trades a little accuracy for speed), "Exact versus approximate search?" (exact is fine for small data; ANN for large), "How do you keep results secure in a multi-user app?" (metadata filters applied inside the query). For a deeper walk through retrieval itself, continue to *Retrieval-augmented generation (RAG) end to end*.
I am building [describe app, e.g. a college notes search / support bot]. Data: about [number] documents, [number] users, hosted on [cloud or laptop], team skills: [skills]. Existing database: [Postgres / MySQL / none]. Recommend Chroma, pgvector, a dedicated vector database or a managed service. Give the reasons, the risks and what I should measure before committing.What to expect: Check that the answer uses your numbers and existing stack. Verify any claim about limits or pricing on the product's official page.
Try it: Add a score threshold
Change chroma_search.py (or pg_search.py) so results with similarity below 0.5 are dropped and the program prints "No good match" when nothing remains. Then test with "how do I fix my bike?".
Key takeaways
- A vector database stores vectors with text and metadata and finds the nearest ones quickly, usually with an approximate index such as HNSW.
- Chroma is the easiest start; pgvector keeps vectors inside Postgres so you can use SQL filters and joins.
- Use stable ids and upsert so re-indexing never duplicates data.
- Match the distance metric: cosine distance is 1 minus cosine similarity; the pgvector cosine operator is
<=>. - The vector column size must match the embedding model's output size; store the model name too.
- Apply permission filters inside the search query, and drop low-similarity results.
Check yourself
Finishing the quiz marks the lesson as done.
1. Why use a vector database instead of a Python list of vectors?
2. In pgvector, which operator orders rows by cosine distance?
3. What does the 'approximate' in approximate nearest neighbour search mean?
4. You change the embedding output size from 768 to 1536 but your table has vector(768). What happens?
5. How should a multi-user app stop one user from retrieving another user's documents?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.