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.