From a Python list to a real store
You can build a working retriever with a list of vectors and a loop that computes cosine similarity - that is exactly what b1 did. It even scales to a few thousand chunks. But the moment you want persistence across restarts, sub-second search over millions of chunks, and the ability to filter by "only board notes from Q3", you need a vector database. The good news: they all wrap the same embed-and-rank idea you already understand. We start with the one that is easiest to run.
Chroma, end to end 10 min live
Chroma is the friendliest place to start because it runs locally with one pip install, embeds your text for you, and persists to disk automatically. The whole lifecycle is four moves: create a client, create a collection, add documents, query. Learn these four and you understand every vector database - the others just change the words.
LiveThe whole store in a dozen lines5 min▶
Here is Recall's store, from empty to answering a filtered query. Note the two client choices: PersistentClient writes to disk so your vectors survive a restart, while chromadb.Client() is in-memory and vanishes when the process ends.
Chroma embeds both your documents and your query for you with a built-in model, so you never call an embedding API by hand here - though you can plug in your own. The ids let you update or delete specific chunks later; the metadatas are what make the where filter possible.
LiveMetadata filters: retrieval with a WHERE clause3 min▶
Real corpora are not flat. You want "the answer, but only from board notes" or "only incidents marked SEV1". Chroma's where filter narrows the search to matching metadata before ranking by similarity - the same instinct as a SQL WHERE, applied to vector search.
- Operators. Chroma's
wheresupports$eq $ne $gt $gte $lt $lte $and $or $in $nin $contains $not_contains- enough to express most real filters. - Filter first, rank second. The filter prunes the candidate set, then similarity ranks what survives. This is faster and more precise than ranking everything and filtering after.
- Metadata is a design choice. Whatever you might want to filter on later - source, date, author, severity - attach it at
add()time. You cannot filter on what you did not store.
Self-studyUpdate and delete: a store is not write-once3 min read▶
Corpora change - notes get corrected, incidents get resolved, pages get rewritten. Because every chunk went in with a stable id, Chroma lets you revise the store in place rather than rebuilding it from scratch.
This is why id discipline matters from day one. If your chunk ids are derived from something stable - a document id plus a chunk index, say - then re-ingesting an updated document cleanly overwrites its old chunks instead of leaving duplicates behind. Sloppy ids are how stores quietly fill with stale answers.
pgvector and FAISS - same idea, different homes 9 min live
Chroma is one house for your vectors. Two others come up constantly: pgvector, when you already run PostgreSQL and want vectors to live beside your relational data, and FAISS, when you want raw in-process speed and full control. The vectors and the cosine ranking are identical; what changes is where they live and how they are indexed.
Self-studypgvector: vectors as a Postgres column4 min read▶
pgvector adds a vector type to PostgreSQL, so your embeddings become just another column - queryable with SQL, joinable with your existing tables, backed up with your existing database. If you already run Postgres, this is often the lowest-friction store you can pick.
The operators encode the distance metric: <-> is L2, <=> is cosine, <#> is negative inner product, and <+> is L1. One rule trips everyone up: the index opclass must match the operator you query with - vector_cosine_ops goes with <=>. Mismatch them and Postgres quietly ignores your index and scans the whole table.
Self-studyFAISS: raw speed, in your process4 min read▶
FAISS is a library, not a server - it holds vectors in memory and searches them extremely fast, right inside your Python process. It is the tool when you want maximum control and speed and are willing to manage persistence and metadata yourself.
Swap the index class to change the trade-off: IndexIVFFlat needs .train() on sample data before .add(), and its nprobe setting tunes speed against accuracy; IndexHNSWFlat is the graph index and needs no training. One catch to design around: FAISS returns integer ids only - it stores no documents or metadata, so you keep a side table mapping ids back to your chunks.
LiveChoosing a store, and choosing an index3 min▶
Two decisions, made in order. First, the store:
- Chroma - starting out, prototyping, or you want batteries-included with automatic embedding and metadata. Recall's choice.
- pgvector - you already run Postgres and want vectors beside relational data, one backup, one set of ops.
- FAISS - you want the fastest in-process search and full control, and can manage persistence and metadata yourself.
Then, the index type inside whichever store:
- Flat - exact, minimal memory, linear scan. Perfect up to tens of thousands of vectors.
- IVF - lower memory, but you must train it on your data first. Good middle ground at larger scale.
- HNSW - fastest queries and best recall, no training, but the highest memory use. The common default when scale and latency matter.
Live★ Reinforce: a query still returns ranked chunks4 min▶
Whichever store and index you pick, the thing you get back is unchanged from b1: a ranked list of chunks. Run a query against Recall's Corpus A and hold that in mind - Chroma's query(), pgvector's ORDER BY ... LIMIT, and FAISS's search() all produce exactly this shape.
Run Chroma locally and query it ★ 12 min
Time to put Recall's chunks in a real store. This runs on your laptop with one install - no server, no API key.
Install and create. pip install chromadb, then create a PersistentClient(path="./recall_db") and a get_or_create_collection("md_notes").
Add Corpus-A-like notes. Write six short notes of your own - a couple about "board decisions", a couple about "travel", a couple about "people". Give each an id and a {"source": ...} metadata tag, and add() them.
Query with and without a filter. Run the same query twice: once plain, once with where={"source": "board"}. Confirm the filter changes which chunks come back.
Prove persistence. Restart Python, re-open the same client and collection, and query again without re-adding. Your vectors are still there - that is what a store buys you.
Before session b4 ◐ 40 min total
- Load one of the chunked documents from b2 into your Chroma collection, keeping the source metadata on each chunk. Query it and confirm the right chunk plus the right filter behave together.
- Read the pgvector README section on operators and opclasses. Write down which operator and opclass you would pair for cosine search - the b3 quiz will ask.
- Skim the FAISS wiki page on index types. Note why
IndexIVFFlatneeds training andIndexHNSWFlatdoes not. - Keep your Chroma collection - in b4 we build the full retrieve-and-generate loop on top of it.
Official sources covered
Taught from official docs. This page covers ~80% of their working content on getting started, indexing, and querying - the rest (hosted/cloud tiers, sharding, tuning) stays with the source.
Three questions before you go 🎯 ◐ 90 seconds
1 · In Chroma, what is the difference between PersistentClient and chromadb.Client()?
PersistentClient(path=...) persists to disk automatically. chromadb.Client() keeps everything in memory only, so it is lost on exit.
2 · In pgvector, which operator ranks by cosine distance - and what must match it?
<=> is the cosine distance operator. The index opclass (vector_cosine_ops) must match it, or Postgres ignores the index.
3 · Which index type is fastest with the best recall, but uses the most memory and needs no training?
HNSW gives the fastest queries and best recall with no training, at the cost of high memory. IVF is lower-memory but must train; Flat is exact but linear.