About this session
| Session | Details |
|---|---|
| Duration | 2–3 hours, including exercises |
| You will build | A local Chroma database with a collection of IT runbook snippets, searchable by meaning, with filters by category and severity |
| You will learn | Embeddings, vectors and distance, collections, the default embedding function, add/query/get/update/upsert/delete, where and where_document filters, persistence, choosing the distance metric, a minimal RAG retrieval function |
| Prerequisites | Basic Python (lists, dictionaries, functions, pip install); no machine learning background needed |
By the end you will be able to store text in Chroma and find the most relevant pieces for a question, which is the retrieval half of every RAG (retrieval-augmented generation) application. The advanced session takes this to a server deployment with tuning, multi-tenancy, backups and monitoring.
Module 1: the ideas you need first
Why a vector database?
A normal database finds rows that match exactly: WHERE title LIKE '%disk full%'. That fails when the user asks "my server ran out of space" and the document says "volume at 100%". The words differ, the meaning is the same.
A vector database finds rows that are similar in meaning. It does this with embeddings.
Embeddings
An embedding model turns a piece of text into a list of numbers (a vector), for example 384 numbers. Texts with similar meaning get vectors that are close together.
| Text | Vector (shortened) |
|---|---|
| "disk is full on the database server" | [0.12, -0.03, 0.88, ...] |
| "volume at 100% on the DB host" | [0.10, -0.01, 0.85, ...] |
| "how to reset a user password" | [-0.54, 0.40, 0.02, ...] |
The first two are close; the third is far away.
Distance
"Close" is measured with a distance function:
| Metric | Chroma name | Notes |
|---|---|---|
| Squared Euclidean | l2 | Chroma's default |
| Cosine distance | cosine | The usual choice for text embeddings |
| Inner product | ip | For models that expect dot product |
Smaller distance = more similar. You choose the metric when you create a collection, and you can't change it later (see Module 7).
Chroma's building blocks
| Term | Meaning |
|---|---|
| Client | Your connection to Chroma (in-memory, on-disk, or a remote server) |
| Collection | A named set of items, like a table. All items in one collection use the same embedding model and distance |
| Item | An id, the document text, its embedding and optional metadata (a small dictionary) |
| Embedding function | The code Chroma calls to turn text into vectors |
Approximate search
To be fast, Chroma uses an HNSW index (a graph of neighbours). It returns the approximately nearest items very quickly instead of comparing your query with every item. For learning-sized data the results are effectively exact.
Module 2: install Chroma
Create a virtual environment and install the client:
python -m venv .venv
# Windows
.venv\Scripts\activate
# Linux / macOS
source .venv/bin/activate
pip install chromadb
python -c "import chromadb; print(chromadb.__version__)"
Note
The first time you add documents without your own embedding function, Chroma downloads a small default model (all-MiniLM-L6-v2, about 80 MB) and runs it locally with ONNX Runtime. You need internet access once. No API key is required.
Module 3: your first collection
Create lab1.py:
import chromadb
client = chromadb.PersistentClient(path="./chroma-lab") # data saved in this folder
print(client.heartbeat()) # a number = Chroma is alive
col = client.get_or_create_collection(
name="runbooks",
configuration={"hnsw": {"space": "cosine"}}, # 1.x syntax
)
# Chroma 0.5/0.6: metadata={"hnsw:space": "cosine"} instead of configuration
print(col.name, col.count())
python lab1.py
You now have a folder chroma-lab containing a SQLite file and index files. That folder is the database.
| Client | When to use |
|---|---|
chromadb.Client() / EphemeralClient() | In-memory, gone when the script ends; good for tests |
chromadb.PersistentClient(path=...) | On disk, one process; good for learning and small apps |
chromadb.HttpClient(host=..., port=...) | Connects to a Chroma server; for anything shared (advanced session) |
Module 4: add documents
Create lab2_add.py:
import chromadb
client = chromadb.PersistentClient(path="./chroma-lab")
col = client.get_or_create_collection("runbooks", configuration={"hnsw": {"space": "cosine"}})
col.add(
ids=["rb1", "rb2", "rb3", "rb4", "rb5", "rb6"],
documents=[
"SQL Server transaction log is full, error 9002. Take a log backup and check log_reuse_wait_desc.",
"PostgreSQL refuses connections: too many clients already. Use a pooler such as PgBouncer.",
"Linux root filesystem at 100 percent. Find large files with du and clear old logs.",
"Azure VM will not boot after a kernel update. Use the serial console and the repair VM.",
"Kubernetes pods stuck in ImagePullBackOff because the registry credentials expired.",
"Reset a forgotten Windows local administrator password with the VM access extension.",
],
metadatas=[
{"category": "databases", "severity": 1, "engine": "sql-server"},
{"category": "databases", "severity": 2, "engine": "postgresql"},
{"category": "linux", "severity": 1},
{"category": "azure", "severity": 1},
{"category": "kubernetes", "severity": 2},
{"category": "windows", "severity": 3},
],
)
print("items:", col.count())
Rules worth knowing:
idsmust be unique strings. Adding an existing id is ignored or raises an error depending on the version; useupsert(Module 6) when you mean "insert or replace".- Metadata values must be simple types: string, integer, float or boolean. Lists are supported in recent 1.x versions; check your version before relying on them.
- If you pass
documentsonly, Chroma creates the embeddings. You can also pass your ownembeddings=[[...], ...].
Module 5: search by meaning
Create lab3_query.py:
import chromadb
client = chromadb.PersistentClient(path="./chroma-lab")
col = client.get_collection("runbooks")
res = col.query(
query_texts=["my disk ran out of space"],
n_results=3,
include=["documents", "metadatas", "distances"],
)
for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0]):
print(f"{dist:.3f} {meta['category']:<11} {doc[:70]}")
Expected output (numbers will differ):
0.38 linux Linux root filesystem at 100 percent. Find large files with du an
0.62 databases SQL Server transaction log is full, error 9002. Take a log backup
0.71 azure Azure VM will not boot after a kernel update. Use the serial cons
The query never mentions "filesystem" or "100 percent", yet the right runbook comes first. That is semantic search.
The result is a dictionary of lists of lists: one inner list per query text. You can send several questions at once:
res = col.query(query_texts=["database won't accept connections", "forgot admin password"], n_results=1)
print(res["ids"]) # [['rb2'], ['rb6']]
Module 6: filters, get, update and delete
Metadata filters with where
res = col.query(
query_texts=["database problem"],
n_results=3,
where={"category": "databases"},
)
res = col.query(
query_texts=["urgent outage"],
n_results=5,
where={"$and": [{"severity": {"$lte": 1}}, {"category": {"$in": ["linux", "azure"]}}]},
)
| Operator | Meaning |
|---|---|
$eq, $ne | Equal, not equal (a plain value means $eq) |
$gt, $gte, $lt, $lte | Numeric comparisons |
$in, $nin | In / not in a list |
$and, $or | Combine conditions |
Text filters with where_document
res = col.query(query_texts=["fix it"], n_results=3, where_document={"$contains": "PgBouncer"})
Get items without searching
print(col.get(ids=["rb1"]))
print(col.get(where={"severity": 1}, include=["metadatas"]))
print(col.peek(limit=2))
Update, upsert and delete
col.update(ids=["rb5"], metadatas=[{"category": "kubernetes", "severity": 1}])
col.upsert(
ids=["rb7"],
documents=["Redis OOM command not allowed: maxmemory reached. Check big keys and eviction policy."],
metadatas=[{"category": "databases", "severity": 2, "engine": "redis"}],
)
col.delete(ids=["rb6"])
col.delete(where={"category": "windows"})
print(col.count())
When you update a document, Chroma re-computes its embedding automatically.
Module 7: choose the embedding function and distance deliberately
Two settings decide the quality of your search, and both are fixed per collection.
Distance: set at creation. To change it, create a new collection and copy the data:
old = client.get_collection("runbooks")
data = old.get(include=["documents", "metadatas", "embeddings"])
new = client.create_collection("runbooks_l2", configuration={"hnsw": {"space": "l2"}})
new.add(ids=data["ids"], documents=data["documents"], metadatas=data["metadatas"], embeddings=data["embeddings"])
Embedding function: the same model must be used for adding and querying. Mixing models gives meaningless distances. To use a different local model:
from chromadb.utils import embedding_functions
ef = embedding_functions.SentenceTransformerEmbeddingFunction(model_name="all-mpnet-base-v2")
col2 = client.get_or_create_collection("runbooks_mpnet", embedding_function=ef,
configuration={"hnsw": {"space": "cosine"}})
(Requires pip install sentence-transformers.) Hosted options such as OpenAIEmbeddingFunction exist too; they need an API key and send your text to that provider.
Warning
If you open an existing collection later with a different
embedding_function, recent versions raise an error or warn; older versions silently produce bad results. Always create and open a collection with the same embedding function.
Module 8: a mini RAG retrieval step
RAG = find relevant text, then give it to an LLM with the question. Here is the retrieval part, ready to plug into any LLM call:
import chromadb
client = chromadb.PersistentClient(path="./chroma-lab")
col = client.get_collection("runbooks")
def retrieve(question: str, k: int = 3, max_distance: float = 0.7, category: str | None = None):
where = {"category": category} if category else None
res = col.query(query_texts=[question], n_results=k, where=where,
include=["documents", "metadatas", "distances"])
hits = []
for doc, meta, dist in zip(res["documents"][0], res["metadatas"][0], res["distances"][0]):
if dist <= max_distance: # drop weak matches
hits.append({"text": doc, "meta": meta, "distance": round(dist, 3)})
return hits
def build_prompt(question: str) -> str:
hits = retrieve(question)
if not hits:
return f"Say you don't have a runbook for this. Question: {question}"
context = "\n".join(f"- {h['text']}" for h in hits)
return f"Answer using only these runbook notes:\n{context}\n\nQuestion: {question}"
print(build_prompt("postgres says too many clients"))
The max_distance cut-off is important: without it, the assistant always gets some context, even when nothing relevant exists, which encourages made-up answers. Pick the value by looking at distances for good and bad matches on your own data.
Chunking real documents
Real runbooks are long. Split them into chunks of a few hundred words with a small overlap, and store the source in metadata:
def chunk(text, size=800, overlap=100):
out, start = [], 0
while start < len(text):
out.append(text[start:start + size])
start += size - overlap
return out
doc = open("sql-server-runbook.txt", encoding="utf-8").read()
parts = chunk(doc)
col.upsert(
ids=[f"sqlrb-{i}" for i in range(len(parts))],
documents=parts,
metadatas=[{"category": "databases", "source": "sql-server-runbook.txt", "chunk": i} for i in range(len(parts))],
)
Stable ids (source + chunk number) mean re-running the load replaces chunks instead of duplicating them.
Module 9: housekeeping
print(client.list_collections())
client.delete_collection("runbooks_l2")
To back up a learning database, stop your script and copy the chroma-lab folder. To start again, delete the folder.
Caution
PersistentClientis meant for one process. Two scripts or app workers writing to the same folder can causedatabase is lockederrors or lost writes. For anything shared, run Chroma as a server (advanced session). See Chroma "database is locked" with several workers.
Exercises
- Add ten runbook snippets from your own job, with
category,severityandownermetadata. - Write five realistic questions. For each, record the top result and its distance. Which ones failed, and why?
- Find a distance threshold that keeps good matches and drops irrelevant ones for your data.
- Create the same collection with
l2and withcosine. Do the rankings change? - Switch to
all-mpnet-base-v2in a new collection and compare results on your five questions. - Write a function that returns only severity-1 runbooks for a question, and test it.
Knowledge check
- What does an embedding model produce, and why are similar texts close together?
- Which distance metric is Chroma's default, and which is usual for text embeddings?
- Can you change a collection's distance metric after creation? What do you do instead?
- What is the difference between
add,updateandupsert? - What is the difference between
whereandwhere_document? - Why must the same embedding function be used for adding and querying?
- Why add a distance threshold to a RAG retrieval function?
- Why shouldn't several app workers share one
PersistentClientfolder?
Answers
- A vector of numbers; the model was trained so that texts with similar meaning map to nearby vectors.
l2is the default;cosineis the usual choice for text.- No. Create a new collection with the right metric and copy ids, documents, metadata and embeddings.
addinserts new ids;updatechanges existing ids;upsertinserts or replaces.wherefilters on metadata fields;where_documentfilters on the document text (for example$contains).- Each model has its own vector space; vectors from different models can't be compared.
- To avoid feeding irrelevant context to the LLM when nothing relevant exists.
- The embedded store isn't multi-process safe; concurrent writers can lock or lose data. Use a Chroma server.
Summary
- Vector databases find text by meaning using embeddings and a distance metric.
- A Chroma collection fixes its embedding function and distance at creation; choose
cosinefor text. add,query,get,update,upsertanddeletecover daily work;whereandwhere_documentnarrow results.- A RAG retrieval step is a filtered query with a distance cut-off, over well-chunked documents with stable ids.
PersistentClientis for single-process learning and small apps; servers come next.
Further reading
- Chroma cheat sheet: clients, collections, filters and housekeeping
- Chroma returns poor matches: distance and HNSW settings fixed at creation
- Learning: Chroma advanced
- Chroma documentation: docs.trychroma.com