Course ES

Chapter 10 of 22 · intermediate

Vector store and retrieval

What this session covers

Last week's search was a for loop over a Python list. It worked, and for a few dozen documents it'll keep working forever. Two things break as you scale, though. You recompute embeddings you already have, and every query rescans every document. A vector store fixes both. It's the loop given a home — an object you add documents to, embedding once and keeping the vector next to the text, then query by meaning. It also leaves room for the one piece a teaching example can't carry: an index that finds neighbors without scanning everything.

We build the smallest honest version, add and search, still a linear scan underneath but the exact interface a real database exposes. Then the part that actually matters: knowing where the toy ends. When you outgrow it you reach for pgvector, Qdrant, Pinecone, or something like them. They swap the scan for approximate-nearest-neighbor search and add persistence, and your code still calls add and search.

OpenAI

The store keeps a list of items, each carrying its text, optional metadata, and vector. add embeds a batch and appends. search embeds the query, optionally filters by metadata, and ranks the rest by cosine.

# A minimal vector store. add() embeds and remembers text + metadata + vector;
# search() ranks the store by cosine and returns the top k, optionally filtered
# by a metadata predicate first. A production database (pgvector, Pinecone,
# Qdrant) swaps the linear scan for an approximate-nearest-neighbor index — but
# the surface you code against is exactly add() and search().
class VectorStore:
    def __init__(self, embed_fn):
        self._embed = embed_fn
        self._items: list[dict] = []

    def add(self, docs: list[dict]) -> None:  # each doc: {id, text, meta?}
        vectors = self._embed([d["text"] for d in docs])
        for doc, vec in zip(docs, vectors):
            self._items.append({**doc, "vec": vec})

    def search(self, query: str, k: int = 3, where: dict | None = None) -> list[tuple]:
        qv = self._embed([query])[0]
        pool = [
            it for it in self._items
            if not where or all(it.get("meta", {}).get(f) == v for f, v in where.items())
        ]
        scored = sorted((( cosine(qv, it["vec"]), it) for it in pool), key=lambda t: t[0], reverse=True)
        return scored[:k]

Two design choices matter more than they look. First, the store takes an embed function instead of calling a provider itself, which is why the same class works for OpenAI and Gemini unchanged. Second, search filters by metadata before ranking. That filtering is what turns a similarity toy into something you'd actually ship: "find docs like this query, but only in the billing category, only from this customer, only since March." Structure you already know about shouldn't be left for the embeddings to rediscover the hard way.

Gemini

Same store, handed Gemini's embed function. Nothing else moves, which is the whole reason embed is a parameter and not a hardcoded call.

class VectorStore:
    def __init__(self, embed_fn):
        self._embed = embed_fn
        self._items: list[dict] = []

    def add(self, docs: list[dict]) -> None:
        vectors = self._embed([d["text"] for d in docs])
        for doc, vec in zip(docs, vectors):
            self._items.append({**doc, "vec": vec})

    def search(self, query: str, k: int = 3, where: dict | None = None) -> list[tuple]:
        qv = self._embed([query])[0]
        pool = [
            it for it in self._items
            if not where or all(it.get("meta", {}).get(f) == v for f, v in where.items())
        ]
        scored = sorted(((cosine(qv, it["vec"]), it) for it in pool), key=lambda t: t[0], reverse=True)
        return scored[:k]

The moment persistence and scale enter the picture, last week's caveat grows teeth. The vectors in your store are tied to the embedding model that produced them. Switch models and every stored vector is garbage; you re-embed the entire corpus and rebuild the index, no shortcuts. I treat the embedding model the way I treat a column type in a schema: it's part of the contract, and you version it.

Put it to work

Three docker-compose apps under code/showcase/<slug>/, each built on one shared store.py. Same drill: bash bootstrap-secrets.sh, docker compose up --build, http://localhost:3000. Each one hits the store from a different angle.

Showcase 1 — Document retriever

Index a chunked knowledge base, return the top passages for a question with their source ids. This is the retrieval step RAG is built on — finding the right context — with the generation left out on purpose so you can watch retrieval by itself. Next chapter feeds these passages to a model.

Showcase 2 — Metadata filter

The same store with topic tags. Prefix a query with billing: or account: and the store keeps only that topic before it ranks by meaning. This is the line between a real vector store and a similarity loop, and it's why "just cosine everything" stops being enough the moment you're in production.

Showcase 3 — Recommender

Read the store backwards: hand it an item, get its nearest neighbors. Describe an app and get similar ones, with the seed excluded. Content-based recommendation falls straight out of the same search call. No ratings, no user history, just meaning.

All three share store.py and differ only in what they put in it and how they query it. That's the lesson. The store is generic; the application is the corpus, the metadata, and the shape of the query.

Run it

The README in this folder has the Python version, the install line, the two environment variables, and the exact commands. Keys come from the untracked .env at the course root. The store is pure standard library, so the only things you install are the SDKs. And this blog runs its own retrieval on pgvector, if you want to see the production version of the same idea.

Takeaways

A vector store is add and search with the embeddings kept next to the text. Everything a hosted vector database piles on — an ANN index, persistence, metadata filters at scale — sits behind that same two-method interface, so the code you wrote against the toy is the code you keep. Two rules carry over from embeddings and bite harder here. The embedding model is part of your schema, so change it and you re-embed everything. And metadata filtering isn't optional polish; it's what makes similarity search usable on real data. You now have the piece RAG needs. Next week we bolt on the other half: hand the retrieved passages to a model and make it answer from them, honestly and with citations.