~8–12 hrs
Requires: Project 02
Project 03 of 9

Semantic Search

Search by meaning, not keywords

📖 Read the lesson ✍ Fill in understanding 🛠 Build the project 🚫 Break it 📊 Evaluate it

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 of O(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 (cosine vs l2 vs ip) 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.

The Core Problem

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.

Startup Lens

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 collection holds (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 the O(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

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

  1. 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).
  2. Query. collection.query(query_embeddings=[q], n_results=k) runs greedy routing (ef_search = candidates explored; higher = better recall, slower) and returns ids, documents, distances, metadatas.
  3. 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

ConceptDefinitionWhy It Matters
Vector databaseA store for embeddings that indexes them for fast similarity search and supports metadata filteringTurns "a list of vectors" into a queryable system; the operational home of embeddings.
CollectionA named set of records, each with an id, embedding, document, and metadataThe unit you add to and query in Chroma.
Approximate Nearest Neighbor (ANN)Finding the closest vectors without comparing against every stored vectorMakes search sub-linear; the reason a vector DB is fast at scale.
HNSWHierarchical Navigable Small World graphs — the multi-layer graph index Chroma usesAchieves ~O(log N) search via greedy routing through layered proximity graphs.
Distance vs. similarityDistance: lower = closer. Cosine similarity: higher = closerChroma returns distances; treating them as similarities silently inverts rankings.
Distance spaceThe metric the index uses: l2 (default), cosine, or ipMust match what your embedding model expects; text usually wants cosine.
Re-rankingA second pass that re-scores the top-k candidates with a cross-encoderBuys accuracy on the few results that matter, after cheap retrieval.
Common Misconception

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!)
What to Observe

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
What to Observe

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))
Why This "Fails"

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

Stop Here

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

  1. 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?
  2. Draw the build-time vs. query-time data flow. What happens once (and is reused) vs. on every query?
  3. Predict: Chroma returns distances. If you sort them descending to get "best first," what do you actually get, and why is there no error?
  4. Why is HNSW search approximate? What would you measure to know whether the approximation is good enough?
  5. The default space is l2. Why might cosine be the right choice for text embeddings — and how would you confirm it matters?
  6. 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, run collection.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 a recall_at_k helper.
  • 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.

Definition of Done

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.

Start building

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.

1

M1 — Index

indexer.py builds a persistent Chroma collection from the corpus. Validation: collection count == number of docs; re-running does not duplicate.

2

M2 — Query

semantic_search.py returns top-k for a query. Validation: a semantically-related-but-lexically-different doc ranks first.

3

M3 — Distance → similarity

Convert Chroma distances to similarities correctly. Validation: ascending-distance order == descending-similarity order (no inversion).

4

M4 — Baseline

baseline.py reproduces exact cosine ranking from Project 02. Validation: baseline top-1 matches your hand-checked expectation.

5

M5 — Recall

evaluate.py computes recall@k of ANN vs. exact baseline. Validation: recall@k reported as a number across several queries.

6

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.

CriterionDoes your implementation...Pass?
Real indexbuild and persist an actual Chroma collection (not an in-memory list)?
Semantic winrank a paraphrase/related doc above a keyword-overlapping but unrelated one?
Direction correctconvert distance→similarity so higher = more relevant, no inversion?
Metric choiceset space="cosine" deliberately for text, not rely on the l2 default?
Measured, not eyeballedreport recall@k vs. the exact baseline as a number?
Same model both sidesembed corpus and query with the identical model?
Red Flags

Your implementation may have problems if:

  • You sort Chroma distances descending 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

MistakeWhy It HappensConsequenceFix
Treating distance as similarityProject 02 returned similarity; Chroma returns distanceRankings silently inverted (worst shown as best)similarity = 1 − distance; sort by ascending distance
Leaving space at default l2 for textDefault is l2; text wants cosineSubtly worse rankings, no errorSet metadata={"hnsw:space": "cosine"}
Re-embedding the corpus every queryForgetting the index is built onceSlow, expensive; defeats the vector DBIndex 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 missedANN is approximate; measure recall@k, tune ef_search
Mixing embedding modelsCorpus and query embedded at different times/modelsMeaningless distances, silent garbagePin one model for both sides
Cross-encoder over the whole corpus"It's more accurate"Far too slow at scaleRetrieve 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

StarcallOS Connection

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, space
  • sources/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 approximate
  • sources/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