Course EN

Capítulo 10 de 22 · intermedio

Vector stores y recuperación

Qué cubre esta sesión

La búsqueda de la semana pasada era un loop for sobre una lista de Python. Funcionaba, y para unas cuantas docenas de documentos va a seguir funcionando para siempre. Pero dos cosas se rompen conforme escalas: recomputas embeddings que ya tienes, y cada query reescanea cada documento. Un vector store arregla las dos. Es el loop con casa propia — un objeto al que le agregas documentos (embebiendo una vez, guardando el vector junto al texto) y consultas por significado, con espacio para la única pieza que no cabe en un ejemplo de enseñanza: un índice que encuentra vecinos sin escanear todo.

Construimos la versión más pequeña que sea honesta: add y search, todavía un escaneo lineal por debajo, pero exactamente la interfaz que una base de datos real expone. Luego la parte importante — saber dónde termina el juguete. Cuando lo superes echas mano de pgvector, Qdrant, Pinecone o similares; ellos cambian el escaneo por búsqueda de vecino más cercano aproximado y agregan persistencia, pero tu código sigue llamando a add y search.

OpenAI

El store guarda una lista de items, cada uno cargando su texto, metadata opcional y vector. add embebe un batch y lo agrega; search embebe el query, opcionalmente filtra por metadata, y rankea el resto por coseno.

# 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]

Dos decisiones de diseño importan más de lo que parecen. Primero, el store recibe una función embed en lugar de llamar a un proveedor por sí mismo — por eso la misma clase funciona para OpenAI y Gemini sin cambios. Segundo, search filtra por metadata antes de rankear. El filtrado por metadata es la feature que convierte un juguete de similitud en algo usable: "encuentra docs como este query, pero solo en la categoría de billing, solo de este cliente, solo desde marzo". La estructura que ya conoces no debería quedar a que los embeddings la redescubran.

Gemini

El mismo store, entregándole la función embed de Gemini. Nada más cambia, que es todo el punto de recibir embed como parámetro.

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]

En el momento en que tienes persistencia y escala en mente, una advertencia de la semana pasada saca los dientes: los vectores en tu store están atados al modelo de embeddings que los produjo. Cambia de modelo y cada vector guardado queda sin sentido — tienes que re-embeber el corpus entero y reconstruir el índice. Trata al modelo de embeddings como parte de tu schema, y versiónalo.

Ponlo a trabajar

Tres apps de docker-compose bajo code/showcase/<slug>/, cada una construida sobre un mismo store.py compartido. La misma rutina: bash bootstrap-secrets.sh, docker compose up --build, http://localhost:3000. Juntas muestran el store desde tres ángulos.

Showcase 1 — Recuperador de documentos

Indexa una base de conocimiento en chunks, regresa los mejores pasajes para una pregunta con sus ids de origen. Este es exactamente el paso de retrieval sobre el que se construye RAG — encontrar el contexto correcto — con la generación dejada fuera a propósito para que veas el retrieval por su cuenta. El próximo capítulo le pasa estos pasajes a un modelo.

Showcase 2 — Filtro de metadata

El mismo store con tags de topic. Antepón un query con billing: o account: y el store se queda solo con ese topic antes de rankear por significado. Esta es la feature que separa a un vector store real de un loop de similitud — y la razón por la que "solo hazle coseno a todo" deja de ser suficiente en producción.

Showcase 3 — Recomendador

Lee el store al revés: entrégale un item, obtén sus vecinos más cercanos. Describe una app y obtén otras parecidas, con la semilla excluida. La recomendación basada en contenido cae de la misma llamada a search — sin ratings, sin historial de usuario, solo significado.

Las tres comparten store.py y difieren solo en qué le meten y cómo consultan — que es la lección. El store es genérico; la aplicación es el corpus, la metadata y la forma del query.

Córrelo

El README de esta carpeta tiene la versión de Python, el comando de instalación, las dos variables de entorno y los comandos exactos. Las keys vienen del .env sin trackear en la raíz del curso. El store es puro standard library — los únicos installs son los SDKs — y este mismo blog corre retrieval sobre pgvector si quieres ver la versión de producción.

Lo que te llevas

Un vector store es add y search con los embeddings guardados junto al texto; todo lo que una base de datos vectorial hosteada agrega — un índice ANN, persistencia, filtros de metadata a escala — se sienta detrás de esa misma interfaz de dos métodos, así que el código que escribes contra el juguete es el código que conservas. Dos reglas se traen de los embeddings y aquí importan más: el modelo de embeddings es parte de tu schema (cámbialo, re-embebe todo), y el filtrado por metadata no es un pulido opcional — es lo que hace usable la búsqueda por similitud sobre datos reales. Ya tienes la pieza que RAG necesita. La próxima semana agregamos la otra mitad: entregarle los pasajes recuperados a un modelo y hacer que responda a partir de ellos, con honestidad y con citas.