Semantic Search
Search by meaning, not keywords
Learning Objectives
By the end of this project, you will be able to:
- Explain why a brute-force cosine scan (Project 02) does not scale, and what a vector database adds on top of raw embeddings.
- Build a semantic search index with Chroma: create a collection, add documents (id + embedding + document + metadata), and query it.
- Explain approximate nearest neighbor (ANN) search and how HNSW achieves ~
O(log N)query time instead ofO(N)— and why it is approximate. - Convert correctly between distance and similarity and avoid the silent ranking-inversion bug (Chroma returns distances; lower = closer).
- Choose and justify a distance space (
cosinevsl2vsip) for text embeddings. - Compare ANN results against the exact brute-force baseline and measure recall, not just eyeball it.
- Explain the retrieve-then-rerank pattern (bi-encoder retrieves, cross-encoder re-ranks) and when it is worth the cost.
1. Motivation
Why This Exists
In Project 02 you ranked a corpus by computing cosine against every document — an exact O(N) scan. Fine for 6 sentences, hopeless for 6 million. Real semantic search needs two things the brute-force loop lacks: speed at scale and persistence (build the index once, query it forever). A vector database provides both.
Embeddings alone are just a pile of vectors. To make them useful you must store them, index them so search is fast, and query them with filters — exactly the work a vector DB removes.
What Breaks Without It
Get the distance-vs-similarity direction wrong and your "most relevant" result is actually your least relevant — a silent bug with no error. Pick the wrong distance space and rankings degrade quietly. Misunderstand ANN and you'll be surprised when the "nearest" neighbor is occasionally missed.
Real-World Stakes
Every production search, RAG pipeline, recommendation engine, and "find related" feature runs on a vector DB under the hood. The judgment built here (index design, metric choice, recall measurement, when to re-rank) is what separates a search box that feels magic from one that feels broken.
Yes — this is the first project that builds a component users actually pay for. Search quality is the product in countless apps. The value is the engineering judgment, not the database.
2. Mental Model
Explain Like I'm 12
In Project 02 you found the closest word by checking every single house in the city, one by one. That works for a small town. Now imagine the whole country. Instead of visiting every house, you use a map with zoom levels: zoom out, jump to the right region, zoom in, jump to the right city, zoom in again, until you're on the right street. You visited a handful of places, not the whole country — but you almost always land on the right house. That zoom-map is HNSW, and the filing cabinet that holds all the addresses is the vector database.
Explain Like I'm a Software Engineer
- A vector database is an index + storage + query API over embeddings. In Chroma a
collectionholds(id, embedding, document, metadata)records;collection.query(query_embeddings=..., n_results=k)returns the k nearest by distance. - The index is HNSW: a hierarchical set of proximity graphs searched by greedy routing from the top layer down — ~
O(log N)instead of theO(N)brute-force scan. It is approximate: it can occasionally miss the true nearest neighbor, trading a little recall for a lot of speed. - Chroma returns distances, not similarities — lower distance = more similar. With
space="cosine", cosine distance ≈1 − cosine_similarity.
Real-World Analogy
A vector DB is a library with a very good librarian, not a pile of books. The embeddings are the books' "meaning coordinates"; the HNSW index is the librarian who, instead of reading every spine, walks you straight to the right shelf. Brute-force search is reading every spine in the building — correct, but you'll be there all year.
How It Works (Diagram)
Build (once):
docs ──embed──► [v1 v2 ... vN] ──add──► Chroma collection ──► HNSW index on disk
Query (per request):
"monetary policy"
│ embed
▼
q-vector ──► HNSW greedy routing (top layer → bottom) ──► top-k ids + DISTANCES
(lower = closer)
compare to brute-force exact scan ──► measure recall (did ANN find the true top-k?)
3. Technical Explanation
Formal Definition
Vector search: given a query vector q and a set {v_1..v_N}, return the k vectors minimizing a distance d(q, v_i) (or maximizing similarity). Exact search evaluates all N (O(N)); approximate (ANN) search uses an index to evaluate far fewer.
HNSW builds a "multi-layer structure consisting from hierarchical set of proximity graphs (layers) for nested subsets of the stored elements," with layer membership drawn from "an exponentially decaying probability distribution," searched greedily from the top layer down — yielding "logarithmic complexity scaling."
How It Works Step by Step
- Build the index. Embed each document once, then
collection.add(ids=, embeddings=, documents=, metadatas=). Chroma inserts each vector into the HNSW graph (max_neighbors/M = links per node;ef_construction= build-time breadth). - Query.
collection.query(query_embeddings=[q], n_results=k)runs greedy routing (ef_search= candidates explored; higher = better recall, slower) and returnsids,documents,distances,metadatas. - Distance ↔ similarity. For
space="cosine",similarity = 1 − distance. Reporting raw distance as a "higher = better" score is the classic inversion bug.
Costs: brute-force exact search is O(N·d) per query; HNSW is ~O(d·log N) — the whole point. Cosine distance d_cos = 1 − (a·b)/(‖a‖‖b‖) ∈ [0, 2], so similarity = 1 − d ∈ [−1, 1].
Key Concepts
| Concept | Definition | Why It Matters |
|---|---|---|
| Vector database | A store for embeddings that indexes them for fast similarity search and supports metadata filtering | Turns "a list of vectors" into a queryable system; the operational home of embeddings. |
| Collection | A named set of records, each with an id, embedding, document, and metadata | The unit you add to and query in Chroma. |
| Approximate Nearest Neighbor (ANN) | Finding the closest vectors without comparing against every stored vector | Makes search sub-linear; the reason a vector DB is fast at scale. |
| HNSW | Hierarchical Navigable Small World graphs — the multi-layer graph index Chroma uses | Achieves ~O(log N) search via greedy routing through layered proximity graphs. |
| Distance vs. similarity | Distance: lower = closer. Cosine similarity: higher = closer | Chroma returns distances; treating them as similarities silently inverts rankings. |
| Distance space | The metric the index uses: l2 (default), cosine, or ip | Must match what your embedding model expects; text usually wants cosine. |
| Re-ranking | A second pass that re-scores the top-k candidates with a cross-encoder | Buys accuracy on the few results that matter, after cheap retrieval. |
A vector database does not "do something to the meaning." It stores and indexes the same embeddings from Project 02; it adds speed, persistence, and filtering. And it returns distances, not similarities — lower is closer.
4. Guided Examples
The lab stack: chromadb (vector DB / HNSW), litellm (embeddings via config.py, default local ollama/nomic-embed-text, 768-dim), numpy (the brute-force baseline).
Example 1: Simplest Case — build and query a collection
import chromadb
from embedding_helpers import embed_many # wraps litellm; returns list[list[float]]
docs = [
"The central bank raised interest rates this quarter.",
"A young wizard attends a school of magic.",
"Photosynthesis converts sunlight into energy in plants.",
]
client = chromadb.Client() # in-memory for the demo
col = client.create_collection("demo", metadata={"hnsw:space": "cosine"}) # text -> cosine
col.add(ids=["d0", "d1", "d2"], embeddings=embed_many(docs), documents=docs)
q = embed_many(["monetary policy and the economy"])[0] # query is lexically unlike doc 0
res = col.query(query_embeddings=[q], n_results=2)
print(res["documents"][0]) # most similar first
print(res["distances"][0]) # LOWER distance = MORE similar (not a similarity!)
The finance sentence ranks first despite sharing no words with the query — semantic, not lexical. And the returned numbers are distances (smaller is better), not the cosine similarities from Project 02.
Example 2: Real-World Case — distance → similarity, done right
def to_similarity(distance: float) -> float:
# Chroma space="cosine" returns cosine DISTANCE in [0, 2]; similarity = 1 - distance.
return 1.0 - distance
for doc, dist in zip(res["documents"][0], res["distances"][0]):
print(f"{to_similarity(dist):+.3f} {doc}") # +higher = more relevant, human-readable
Ranking by ascending distance and by descending 1 − distance give the same order. If you ever sort distances descending (thinking "higher = better"), you return the worst matches first — the silent inversion bug.
Example 3: When It Fails — ANN is approximate; measure it
import numpy as np
def cosine(a, b): return float(a @ b / (np.linalg.norm(a) * np.linalg.norm(b)))
# Exact brute-force ranking (Project 02 style) over the same vectors:
qv = np.array(q)
vecs = [np.array(v) for v in embed_many(docs)]
exact_order = [i for i, _ in sorted(enumerate(vecs), key=lambda t: -cosine(qv, t[1]))]
# recall@k = |ANN top-k ∩ exact top-k| / k (map Chroma ids back to indices)
ann_idx = [int(i[1:]) for i in res["ids"][0]]
k = len(ann_idx)
recall = len(set(ann_idx) & set(exact_order[:k])) / k
print("recall@%d = %.2f" % (k, recall))
On a tiny corpus HNSW and brute force agree perfectly (recall@k = 1.0). The point is the method: in production you confirm ANN quality by comparing against the exact baseline and watching recall as you change ef_search — not by trusting that "the database is correct."
5. Reflection Before Building
Before writing any code, fill in UNDERSTANDING.md in your own words. No copy-pasting from the lesson. No AI assistance for writing — only for checking. If you can't explain it simply, go back and re-read.
Knowledge Check — Answer These in UNDERSTANDING.md
- Explain, in your own words, what a vector database adds on top of the embeddings + cosine you wrote in Project 02. What problem does it actually solve?
- Draw the build-time vs. query-time data flow. What happens once (and is reused) vs. on every query?
- Predict: Chroma returns
distances. If you sort them descending to get "best first," what do you actually get, and why is there no error? - Why is HNSW search approximate? What would you measure to know whether the approximation is good enough?
- The default
spaceisl2. Why mightcosinebe the right choice for text embeddings — and how would you confirm it matters? - When is it worth adding a cross-encoder re-ranking stage, and why not just use the cross-encoder for everything?
After filling in UNDERSTANDING.md, use the AI mentor pattern in docs/meta/learning-flow.md to get feedback. Record the feedback in UNDERSTANDING_FEEDBACK.md.
6. Project Assignment
See PROJECT.md for the full project specification including file structure and extended requirements.
Core Requirement
Build a semantic search engine in code/ over a provided corpus:
indexer.py— embed a corpus and build a persistent Chroma collection (space="cosine") with text + metadata. Idempotent: re-running does not duplicate records.semantic_search.py— embed a query, runcollection.query, return ranked(document, similarity, metadata)with distance correctly converted to similarity.baseline.py— exact brute-force cosine ranking (reuse Project 02's cosine) to validate the vector DB, plus arecall_at_khelper.evaluate.py— run queries through both paths and report recall@k of the ANN index vs. the exact baseline.
Extended: add cross-encoder re-ranking (rerank.py); compare space="cosine" vs l2; sweep ef_search and record recall vs. latency.
The index is a persistent Chroma collection; distances are converted to similarities with no inversion; evaluate.py reports recall@k vs. the exact baseline as a number; the guiding tests in code/tests/ pass; UNDERSTANDING.md done before any code; FAILURE_ANALYSIS.md has ≥3 experiments; EVALUATION.md is quantitative; STARCALLOS_REFLECTION.md names ≥1 concrete pattern.
Open code/README.md for setup, the milestone build order, and the file roles (which files are provided vs. learner-owned). Run python -m pytest to see the failing guiding tests, then implement the learner-owned functions in milestone order until they pass.
7. Project Milestones
Work through these in order. Each milestone should produce runnable code before moving on.
M1 — Index
indexer.py builds a persistent Chroma collection from the corpus. Validation: collection count == number of docs; re-running does not duplicate.
M2 — Query
semantic_search.py returns top-k for a query. Validation: a semantically-related-but-lexically-different doc ranks first.
M3 — Distance → similarity
Convert Chroma distances to similarities correctly. Validation: ascending-distance order == descending-similarity order (no inversion).
M4 — Baseline
baseline.py reproduces exact cosine ranking from Project 02. Validation: baseline top-1 matches your hand-checked expectation.
M5 — Recall
evaluate.py computes recall@k of ANN vs. exact baseline. Validation: recall@k reported as a number across several queries.
M6 — Break / Extend
Re-ranking and/or space/ef_search experiments documented. Validation: at least one surprising result in FAILURE_ANALYSIS.md.
8. Self-Evaluation
After building, honestly evaluate your implementation against these criteria. Record your answers in EVALUATION.md.
| Criterion | Does your implementation... | Pass? |
|---|---|---|
| Real index | build and persist an actual Chroma collection (not an in-memory list)? | ☐ |
| Semantic win | rank a paraphrase/related doc above a keyword-overlapping but unrelated one? | ☐ |
| Direction correct | convert distance→similarity so higher = more relevant, no inversion? | ☐ |
| Metric choice | set space="cosine" deliberately for text, not rely on the l2 default? | ☐ |
| Measured, not eyeballed | report recall@k vs. the exact baseline as a number? | ☐ |
| Same model both sides | embed corpus and query with the identical model? | ☐ |
Your implementation may have problems if:
- You sort Chroma
distancesdescending and call the top result "best." - You re-embed the entire corpus on every query instead of indexing once.
- You compare embeddings produced by different models across add and query.
- You claim the search "works" with no recall number or baseline comparison.
- You run a cross-encoder over the whole corpus instead of re-ranking a small candidate set.
9. Common Mistakes
| Mistake | Why It Happens | Consequence | Fix |
|---|---|---|---|
| Treating distance as similarity | Project 02 returned similarity; Chroma returns distance | Rankings silently inverted (worst shown as best) | similarity = 1 − distance; sort by ascending distance |
Leaving space at default l2 for text | Default is l2; text wants cosine | Subtly worse rankings, no error | Set metadata={"hnsw:space": "cosine"} |
| Re-embedding the corpus every query | Forgetting the index is built once | Slow, expensive; defeats the vector DB | Index once with a PersistentClient; query many |
| Expecting ANN to be exact | "It's a database, it must be right" | Surprise when a true neighbor is missed | ANN is approximate; measure recall@k, tune ef_search |
| Mixing embedding models | Corpus and query embedded at different times/models | Meaningless distances, silent garbage | Pin one model for both sides |
| Cross-encoder over the whole corpus | "It's more accurate" | Far too slow at scale | Retrieve top-k cheaply, then re-rank those |
10. Connections
Builds On
Project 02 produced embeddings and ranked them by cosine by hand over a tiny corpus. Project 03 keeps the exact same embeddings and cosine intuition but moves storage and search into a vector database so it scales and persists. Your Project 02 cosine_similarity becomes the exact baseline you validate the database against.
Enables
This is the retrieval engine of Project 04 (RAG / PDF assistant) — RAG = this search step feeding retrieved chunks to an LLM. Project 05 (memory) stores and recalls past items by the same vector-DB lookup. The re-ranking pattern reappears wherever first-stage retrieval isn't precise enough.
Production Patterns
The standard architecture is two-stage retrieval: a bi-encoder + ANN index retrieves a broad candidate set fast; a cross-encoder re-ranks the top-k precisely. Add metadata filtering (where) for hard constraints, and persist the index so it survives restarts.
StarcallOS Relevance
Any StarcallOS "find related things" feature — recall a note, surface a command, match a request to a capability — is a vector-DB query underneath. The judgment built here (index once, choose the metric, measure recall, re-rank when it matters) decides whether that recall feels instant and right or slow and wrong.
Sources
See source/resources.md for the complete annotated source list.
Tier 1 — Official Documentation
sources/official-docs/chromadb.md— vector DB data model, query API, HNSW, distance vs similarity,spacesources/official-docs/scikit-learn-cosine-similarity.md— cosine, to reconcile similarity with Chroma's distance
Tier 2 — Foundational Papers
sources/papers/hnsw.md— Malkov & Yashunin 2016: why ANN search is ~O(log N) and approximatesources/papers/sentence-bert.md— bi- vs cross-encoder; encode-once / compare-many
Tier 3 — Engineering Guides
sources/articles/sbert-retrieve-rerank.md— retrieve (bi-encoder) then re-rank (cross-encoder) two-stage pattern