Chroma for Local RAG
Theme
Palette
Download
Caption (tap to copy)
📖 Deep dive (full written explanation)
This cover frames the whole day. The single most useful mental model for Chroma is that it is the lowest-friction way to turn text into a working retriever. Where most vector databases assume you already have an embedding pipeline and a place to run a server, Chroma assumes the opposite: you have some text, a laptop, and an idea you want to test today.
The rest of this post builds that model deliberately: what Chroma is, what 'local' and 'in-process' actually mean, the core data concepts of collections and documents, why it has become the default choice for prototyping retrieval-augmented generation, and the honest signals that tell you when you've outgrown it.
The precise definition matters because 'vector database' covers a huge range of products. Chroma's distinguishing choices are that it is embeddings-first and local-first. Embeddings-first means the primary thing it stores and reasons about is embeddings, and it will generate them for you rather than demanding you bring your own. Local-first means the default deployment is in your process, persisting to a directory, with no separate service.
It still stores the three things every vector store needs — the original document, its embedding, and metadata — and supports similarity search plus metadata filtering. But the design center is developer ergonomics on a single machine, which is exactly what RAG prototyping needs.
These two terms confuse newcomers, so it's worth pinning down. 'In-process' means Chroma is a Python library that runs inside your application's own process; calling collection.add() is a function call, not a network request to a database server. That eliminates an entire category of setup — no ports, no connection strings, no containers to keep running.
'Local' refers to where the data lives. With PersistentClient(path=...), Chroma writes its SQLite database and index files into the folder you name, so your corpus and index survive program restarts. The combination is what makes Chroma feel so light: starting it is importing a library, and persisting it is choosing a directory.
The embeddings-first philosophy is Chroma's biggest ergonomic win and worth understanding clearly. With most vector databases, you are responsible for producing vectors: you call an embedding model yourself, then pass the resulting floats to the store. Chroma inverts this — you hand it text, and it runs a configured embedding function for you, defaulting to a small local sentence-transformer model.
This is why you can have a working retriever in five lines without choosing an embedding provider at all. When you're ready, you swap the default for an OpenAI, Cohere, or custom embedding function on the collection, and the rest of your code is unchanged. The choice of embeddings becomes a one-line decision you can defer until it matters.
Collections are Chroma's organizing unit, equivalent to a table. A collection has a name, a single embedding function, and a single distance space (cosine by default). Every document you add to it is embedded by that function and compared in that space, which is why mixing embedding models within a collection is meaningless — the whole point of a collection is that its vectors are comparable.
Most small projects need exactly one collection per corpus. You might create separate collections to keep distinct datasets apart, to use different embedding models, or to apply different distance metrics. But the simplest mental model is: one body of documents, one collection.
These four fields are the anatomy of every Chroma record, and knowing them prevents most confusion later. The document is the raw chunk of text. The embedding is its vector representation, which Chroma generates unless you supply one. The metadata is an arbitrary dict — source, page, date, tags — that you can filter on at query time. The id is a unique string you assign, used to update or delete the record later.
You are responsible for the document and the id; metadata is optional but extremely valuable; the embedding is filled in for you. Treating ids and metadata as first-class from the start — rather than an afterthought — is what makes a corpus maintainable and answers citable.
This diagram makes the record concrete by stacking its four parts. At the top is the id, the unique key you use to address the record. Below it sits the document text — the actual chunk you'll show or feed to an LLM. Beneath that is the embedding, a fixed-length vector (384 floats for the default MiniLM model) that lives in the HNSW index. At the bottom is the metadata dict that powers where-filters.
Reading it top to bottom is the shape of one stored item; reading the embedding and metadata together explains how a single query can both rank by similarity (via the vector) and constrain by attribute (via the metadata) at once.
These are the concrete reasons Chroma became the default for prototyping RAG. Zero infrastructure means the gap between deciding to try retrieval and having a working store is one pip install. Built-in embedding means you don't have to choose or wire up a model to get started. One-line persistence means your experiments don't evaporate. And the API is the same whether you run in-process or, later, against a Chroma server — so prototyping doesn't lock you into throwaway code.
Taken together these remove the activation energy that kills so many retrieval experiments before they start. The fastest way to learn whether RAG solves your problem is to have a retriever running in minutes, and that is precisely what Chroma optimizes for.
This is the smallest end-to-end example: install, connect to a persistent store, create a collection, add two documents, and query. Notice that add() receives only documents and ids — Chroma runs the default embedding function to produce the vectors automatically. The query asks for the single nearest result to 'feline sounds'.
The payoff is that 'feline sounds' retrieves 'cats purr' despite sharing no words — the embeddings encode that they mean similar things. PersistentClient(path='./chroma_db') means this data is written to disk and will still be there next run. This five-line shape is the seed of every Chroma program; everything else is adding chunking, metadata, and a real embedding model around it.
It's important to be honest about Chroma's envelope. It is excellent from first prototype through mid-size, single-node workloads — comfortably handling hundreds of thousands to low millions of vectors on a capable machine. Where it starts to strain is at very large corpora that exceed one machine's memory, at heavy concurrent write traffic, and at requirements for multi-node high availability or horizontal scaling.
The healthy pattern is to start on Chroma and migrate only when a concrete scale or operational requirement forces it — to Weaviate, Qdrant, pgvector, or a managed service. Because good RAG code separates retrieval behind a small interface, that migration is usually a localized change, not a rewrite. Starting simple costs you nothing and saves you premature complexity.
This flow shows where Chroma sits in a RAG system. Chunked documents flow into Chroma, which embeds, stores, and indexes them. At query time the user's question is embedded and Chroma returns the top-k most relevant chunks. Those chunks become context in a prompt sent to an LLM, which produces a grounded answer.
The takeaway is that Chroma is the retrieval layer — it is not the LLM and not the application. Its job is to reliably return the most relevant pieces of your corpus for a given query. Everything good about RAG downstream depends on that retrieval being accurate, which is why the rest of the day focuses on getting it right.
The teaser points to Day 90. Having established what Chroma is, the natural next question is why a local-first store is worth choosing at all — which is exactly what the next post (Why It Matters) tackles, contrasting the friction, cost, and privacy of local versus managed retrieval.