Build and search a vector index¶
What you'll build¶
In this tutorial, you build the exact vector index this repository's semantic retriever searches. You store chunk vectors, rank them by cosine similarity, and persist an index that rejects vectors from an incompatible model or dimension.
Time: About 30 minutes.
Before you begin¶
You need:
- Python 3.12 or later.
- uv.
- A local clone with dependencies installed.
- Implement Okapi BM25 from scratch.
- Familiarity with the repository's retrieval architecture.
You do not need an API key. Computing real embeddings requires a paid Voyage API call, so every command in this tutorial searches small, hand-written vectors instead.
See it work¶
Run the tests that already exercise the finished class, and read the three behaviors they confirm:
test_vector_search_ranks_cosine_similarity_and_breaks_ties PASSED
test_vector_index_round_trips_and_validates_manifest PASSED
test_vector_index_rejects_invalid_vectors PASSED
3 passed
Each test name states a guarantee this tutorial's class provides: ranking by similarity with a deterministic tie-break, surviving a save-and-reload round trip while catching a stale configuration, and rejecting vectors that don't fit the index. The rest of this tutorial rebuilds the class that already passes these tests.
How a vector index works¶
A vector index pairs each chunk with a fixed-length list of numbers, its
vector, so a query can be turned into the same kind of list and compared
against every stored entry. Unlike Okapi BM25's keyword counts, a vector
index says nothing about which words a chunk contains; it only stores
numbers, typically produced by an embedding model that maps similar meanings
to nearby vectors. This tutorial's VectorIndex never computes those numbers
itself. It exact-searches whichever vectors you hand it, which is why every
command in this tutorial runs without an API key.
Cosine similarity compares two vectors by the angle between them, not
their length. Given two vectors, cosine similarity is their dot product
divided by the product of their lengths. That division cancels out
completely only when both vectors already have length one, called unit
vectors. VectorIndex normalizes every vector once, when the index is
built, so search() computes a plain dot product instead of dividing on
every comparison. Cosine similarity is always contained in the range -1 to 1,
no matter how many vectors the index holds.
Dimension compatibility is the requirement that every vector entering the
index has the same length, and that a saved index is only ever compared
against vectors from the model that created it. Two vectors of different
lengths have no defined dot product, and two vectors from different
embedding models can share a length while measuring entirely different
notions of similarity. VectorIndex checks both: once per vector when it's
normalized, and once per file when a saved index reloads.
Preserve the reference implementation¶
The finished implementation already exists so that you can run it before rebuilding it. Work on a disposable branch or temporary copy of the repository.
In PowerShell, preserve the file and clear the original:
Copy-Item app/rag/index_vector.py app/rag/index_vector_reference.txt
Clear-Content app/rag/index_vector.py
In Bash, run the equivalent commands:
Build the index¶
-
In
app/rag/index_vector.py, add the module docstring, imports, the schema version, and the error type:"""Exact vector search and persistence for small local corpora.""" from __future__ import annotations import json import math from pathlib import Path from app.rag.models import Chunk, SearchResult _INDEX_SCHEMA_VERSION = 1 class VectorIndexError(RuntimeError): """Raised when a vector index is malformed or incompatible."""_INDEX_SCHEMA_VERSIONnames the on-disk file format. RaisingVectorIndexErrorfor every malformed or incompatible index gives callers one exception type to catch, instead of several unrelated built-in errors. -
Add the class and validate that every chunk has a matching vector:
-
Still in
__init__, normalize and store each vector, then record the model and dimension for later comparisons:self._chunks = list(chunks) self._vectors = [self._normalize(vector, dimension) for vector in vectors] self.model = model self.dimension = dimension_normalize()doesn't exist yet; you add it in Enforce dimension compatibility. Python resolvesself._normalizewhen__init__runs, not when the class body is read, so the forward reference is safe once the method exists anywhere in the class. -
Add
__len__, so an index reports its own size like a built-in collection:
Search by cosine similarity¶
-
Add
search()and guard against an invalid result count: -
Score every stored vector against the normalized query:
scored = [ (sum(left * right for left, right in zip(normalized_query, vector, strict=True)), chunk) for chunk, vector in zip(self._chunks, self._vectors, strict=True) ]Both
normalized_queryand every stored vector already have length one, so their dot product equals the cosine of the angle between them. No separate division happens at search time. -
Sort by descending score with a deterministic tie-break, and return the top results:
scored.sort(key=lambda item: (-item[0], item[1].chunk_id)) return [ SearchResult(chunk=chunk, score=score, rank=rank) for rank, (score, chunk) in enumerate(scored[:top_k], start=1) ]Sorting by
chunk_idafter the score keeps the ranking identical across runs whenever two chunks tie exactly.
Persist and reload the index¶
-
Add
save()to write vectors and chunk metadata as deterministic JSON:def save(self, path: Path) -> None: """Persist vectors and source metadata as deterministic JSON.""" path.parent.mkdir(parents=True, exist_ok=True) payload = { "schema_version": _INDEX_SCHEMA_VERSION, "model": self.model, "dimension": self.dimension, "entries": [ { "chunk": { "chunk_id": chunk.chunk_id, "text": chunk.text, "source_path": chunk.source_path, "file_type": chunk.file_type, "symbol": chunk.symbol, }, "vector": vector, } for chunk, vector in zip(self._chunks, self._vectors, strict=True) ], } path.write_text(json.dumps(payload, indent=2), encoding="utf-8") -
Add the
load()classmethod, and reject a file whose model or dimension doesn't match what the caller expects:@classmethod def load( cls, path: Path, *, expected_model: str | None = None, expected_dimension: int | None = None, ) -> VectorIndex: """Load an index and reject stale model or dimension configurations.""" try: payload = json.loads(path.read_text(encoding="utf-8")) if payload["schema_version"] != _INDEX_SCHEMA_VERSION: raise VectorIndexError( "Unsupported vector index schema version") model = payload["model"] dimension = payload["dimension"] if expected_model is not None and model != expected_model: raise VectorIndexError( f"Vector index model mismatch: expected {expected_model}, got {model}" ) if expected_dimension is not None and dimension != expected_dimension: raise VectorIndexError( "Vector index dimension mismatch: " f"expected {expected_dimension}, got {dimension}" ) chunks = [Chunk(**entry["chunk"]) for entry in payload["entries"]] vectors = [entry["vector"] for entry in payload["entries"]] except VectorIndexError: raise except (OSError, json.JSONDecodeError, KeyError, TypeError) as exc: raise VectorIndexError( f"Could not load vector index: {exc}") from exc return cls(chunks, vectors, model=model, dimension=dimension)expected_modelandexpected_dimensiondefault toNone, so a caller that hasn't decided which embedder to require yet can still load the file. Passing both turns a silent mismatch into an immediateVectorIndexError.
Enforce dimension compatibility¶
-
Add the static
_normalize()helper that every constructor and search call relies on:@staticmethod def _normalize(vector: list[float], dimension: int) -> list[float]: if len(vector) != dimension: raise VectorIndexError( f"Expected a {dimension}-dimensional vector, got {len(vector)}" ) norm = math.sqrt(sum(value * value for value in vector)) if norm == 0: raise VectorIndexError( "Cannot index or search with a zero-length vector") return [float(value / norm) for value in vector]This one helper enforces dimension compatibility for every vector that enters the index and produces the unit vectors that make the search's plain dot product equal to cosine similarity. A zero-length vector has no direction to compare, so
_normalize()rejects it instead of dividing by zero.
Verify your work¶
Run the focused tests:
Check the implementation and test with Ruff:
After your implementation matches the reference behavior, remove the temporary copy:
Break it on purpose¶
Run the focused test that deliberately passes incompatible vectors:
The test first gives a two-dimensional index a one-dimensional vector and
asserts that VectorIndexError reports the dimension mismatch. It then gives
the index an all-zero vector and asserts that the missing direction is rejected.
Both failures protect the cosine-similarity calculation from inputs for which
it is undefined.
Even for valid vectors, cosine similarity stays in the range -1 to 1. Okapi BM25 has no fixed ceiling, so lexical and vector scores remain incomparable. Adding or averaging them directly would let whichever retriever produces larger raw numbers dominate. Combining the rankings safely means comparing ranks, not raw scores.
Troubleshooting¶
| Symptom | Resolution | Source |
|---|---|---|
VectorIndexError: chunks and vectors must have the same length |
Pass equal-length chunks and vectors lists to VectorIndex. |
Observed |
VectorIndexError: Expected a <dimension>-dimensional vector, got <n> |
Confirm every vector matches the index's configured dimension. |
Observed |
VectorIndexError: Cannot index or search with a zero-length vector |
Drop or re-embed chunks that produced an all-zero vector. | Observed |
VectorIndexError: Vector index model mismatch: expected ... got ... |
Rebuild the index with the same embedding model you pass as expected_model. |
Observed |
ModuleNotFoundError: No module named 'app' |
Run the command from the repository root with uv run. |
Observed |
Next steps¶
Continue with Merge rankings with Reciprocal Rank Fusion to combine this index's cosine-similarity ranking with the BM25 ranking by comparing ranks instead of the incomparable raw scores this tutorial exposed.
For the underlying formula, read the Cosine similarity reference.
Tested against¶
| Item | Value |
|---|---|
| Python | 3.13.15; the repository requires 3.12 or later |
| pytest | 9.1.1 |
| Verified | 2026-08-17 |