RAG — Módulo 5: búsqueda vectorial y bases de datos vectoriales
Módulo 5 del Curso de RAG: por qué la fuerza bruta no escala, qué es ANN y el trade-off recall vs latencia, HNSW e IVF explicados por dentro con sus parámetros, cuantización de vectores, pre-filter vs post-filter, y qué vector DB elegir — con pgvector y Qdrant en código real.
☰ Índice del contenido
- La tesis del módulo
- El punto de partida: fuerza bruta
- ANN: cambiar exactitud por velocidad
- HNSW: capas de atajos
- IVF: dividir el espacio en barrios
- Cuantización: que el índice quepa en RAM
- La tabla de algoritmos
- Filtrado por metadata: donde se rompen los sistemas reales
- Elegir base de datos vectorial
- Código: pgvector
- Código: Qdrant
- Errores comunes
- La frase para llevarse
- Qué viene
- Material relacionado
El módulo anterior terminó con una línea de código que era la búsqueda completa: vecs @ q, un producto matriz-vector que compara la pregunta contra todos los chunks. Es correcto, es exacto y es lo que deberías usar mientras tengas pocos datos. Este módulo trata de qué pasa cuando esa línea deja de servir — a partir de qué escala, qué se pone en su lugar, y por qué la respuesta implica renunciar deliberadamente a un poco de exactitud. También vamos a elegir base de datos, que es la decisión de infraestructura más sobrevendida del ecosistema RAG.
La tesis del módulo
La búsqueda vectorial a escala no es exacta, y está bien. Toda base de datos vectorial vende lo mismo: recall a cambio de latencia y memoria. Entender ese triángulo — y saber qué parámetro mueve cada vértice — es la diferencia entre configurar un índice y copiar una configuración de un blog.
El punto de partida: fuerza bruta
La búsqueda exacta de k vecinos más cercanos (kNN por fuerza bruta, o flat) compara la query contra cada vector del índice, sin excepción, y devuelve los k mejores. Es imposible que se equivoque: por definición encuentra los verdaderos k más cercanos.
import numpy as np
def buscar_fuerza_bruta(vectores: np.ndarray, query: np.ndarray, k: int = 5):
"""Busqueda exacta. `vectores` es (N, D) normalizado, `query` es (D,).
Con vectores normalizados el producto punto ES la similitud coseno,
asi que un solo producto matriz-vector da todos los scores.
"""
scores = vectores @ query # (N,) — N productos punto
# argpartition deja los k mejores al frente sin ordenar el resto:
# O(N) en vez de O(N log N) del sort completo.
idx = np.argpartition(-scores, k)[:k]
idx = idx[np.argsort(-scores[idx])] # ahora si, ordenar solo esos k
return [(int(i), float(scores[i])) for i in idx]
# Simulacion de escala: 100 mil vectores de 1536 dimensiones
N, D = 100_000, 1536
vectores = np.random.randn(N, D).astype(np.float32)
vectores /= np.linalg.norm(vectores, axis=1, keepdims=True)
query = vectores[42]
print(buscar_fuerza_bruta(vectores, query, k=3)[0]) # (42, 1.0)
El costo es N × D multiplicaciones por consulta. Vale la pena verlo en números, porque el momento de romperse no es donde la gente cree.
| Vectores (D=1536) | Multiplicaciones/query | Latencia aprox (numpy, 1 core) | RAM (float32) |
|---|---|---|---|
| 10 mil | 15 millones | ~5 ms | 61 MB |
| 100 mil | 154 millones | ~50 ms | 614 MB |
| 1 millón | 1.500 millones | ~500 ms | 6,1 GB |
| 10 millones | 15 mil millones | ~5 s | 61 GB |
Hasta 100 mil vectores la fuerza bruta es perfectamente usable y no necesitas nada más. Sobre un millón se vuelve intolerable, y sobre diez millones el problema ya no es la latencia sino que los datos no caben en la RAM de una máquina normal.
ANN: cambiar exactitud por velocidad
ANN (Approximate Nearest Neighbors, vecinos más cercanos aproximados) es la familia de algoritmos que renuncia a la garantía de exactitud a cambio de órdenes de magnitud de velocidad. En vez de mirar los N vectores, mira una fracción inteligentemente elegida.
La métrica que mide cuánto se pierde es el recall@k: de los k resultados verdaderos que habría devuelto la fuerza bruta, cuántos devolvió el índice aproximado. Un recall@10 de 0,95 significa que en promedio 9,5 de los 10 resultados correctos aparecieron.
más ef_search / nprobe
┌──────────┐ ┌──────────┐ ┌──────────┐
│ recall │ ──► │ recall │ ──► │ recall │
│ 0.90 │ │ 0.95 │ │ 0.99 │
│ 10 ms │ │ 20 ms │ │ 200 ms │
└──────────┘ └──────────┘ └──────────┘
barato razonable carísimo
La curva es siempre así: los primeros puntos de recall son baratos, los últimos son carísimos. Pasar de 0,90 a 0,95 puede costar el doble de latencia; pasar de 0,95 a 0,99 puede costar diez veces más. Por eso casi ningún sistema en producción busca recall 1,0.
Y el matiz importante: en RAG el recall del índice no es el recall del sistema. Si vas a reordenar con un reranker (módulo 8) y a combinar con BM25 (módulo 7), un recall@50 de 0,95 en la etapa vectorial es más que suficiente: los candidatos perdidos suelen ser los del fondo de la lista, que igual iban a quedar fuera.
HNSW: capas de atajos
HNSW (Hierarchical Navigable Small World) es el algoritmo dominante hoy. La idea es un grafo de varios pisos.
En el piso de abajo están todos los vectores, cada uno conectado a sus vecinos más parecidos. En los pisos de arriba hay cada vez menos nodos, pero con conexiones que cubren distancias largas: son atajos. Buscar es entrar por el piso más alto, avanzar en línea recta hacia la zona correcta con saltos grandes, bajar un piso, afinar, y así hasta el piso 0 donde se hace la búsqueda fina.
Es la misma lógica que viajar por Chile: primero eliges la región en avión, después la ciudad en bus, después la calle caminando. Nadie recorre el país caminando para llegar a una dirección.
capa 2 ┌───┐ ┌───┐
│ A │ ──────────────────────── │ Z │
└─┬─┘ └─┬─┘
│ bajar │
capa 1 ┌─▼─┐ ┌───┐ ┌─▼─┐
│ A │ ─────── │ M │ ─────────── │ Z │
└─┬─┘ └───┘ └───┘
│ bajar
capa 0 ┌─▼─┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐ ┌───┐
│ A │──│ B │──│ C │──│ M │──│ P │──│ Z │
└───┘ └───┘ └───┘ └───┘ └───┘ └───┘
(todos los vectores, vecinos cercanos)
Tres parámetros lo controlan, y conviene saber exactamente qué mueve cada uno:
M: cuántas conexiones tiene cada nodo. MásMes mejor recall y más memoria (el grafo pesa). Típico 16-64. Es un parámetro de construcción: cambiarlo obliga a reconstruir.ef_construction: cuántos candidatos se evalúan al insertar cada vector. Más alto es un grafo de mejor calidad y una construcción más lenta. Típico 100-400. También de construcción.ef_search: cuántos candidatos se mantienen vivos durante la búsqueda. Más alto es mejor recall y más latencia. Típico 40-200. Es el único de los tres que se ajusta en caliente, por consulta.
Esa última distinción es la que se usa a diario: ef_search es la perilla de recall vs latencia que puedes mover en producción sin reindexar. ef_search debe ser mayor o igual a top_k; si pides 50 resultados con ef_search=40, el recall se desploma.
Debilidad de HNSW: la memoria. El grafo completo vive en RAM y su tamaño crece con M. Y borrar es incómodo — muchas implementaciones marcan como borrado (tombstone) y solo liberan espacio al compactar.
IVF: dividir el espacio en barrios
IVF (Inverted File Index) parte de una idea distinta: en vez de un grafo, agrupa los vectores en celdas.
Durante la construcción se corre k-means sobre el corpus para encontrar nlist centroides — puntos representativos de cada zona del espacio. Cada vector se asigna a la celda de su centroide más cercano. Si k-means te suena lejano, es el mismo algoritmo del módulo 10 del curso de ML.
Al buscar, se compara la query solo contra los nlist centroides (barato), se eligen los nprobe más cercanos, y se hace fuerza bruta únicamente dentro de esas celdas.
centroides (nlist=4) query
┌──────────┬──────────┐ │
│ c1 │ c2 ● │ ◄───────┘
│ · · · │ · · · │
├──────────┼──────────┤
│ c3 │ c4 │
│ · · · │ · · · │
└──────────┴──────────┘
nprobe=1 ──► solo se revisa la celda c2
nprobe=2 ──► c2 y su vecina más cercana
Ahí se ve el riesgo estructural de IVF: si el vecino verdadero quedó justo al otro lado de la frontera entre celdas, no lo encuentras con nprobe=1. Por eso nprobe es la perilla de recall de IVF, análoga a ef_search en HNSW. Regla práctica: nlist ≈ raíz de N y nprobe entre 1% y 10% de nlist.
Ventaja de IVF: construir es mucho más rápido y barato en memoria que HNSW. Desventaja: necesita entrenamiento (correr k-means) sobre una muestra representativa antes de poder insertar, y si el corpus cambia mucho de distribución hay que reentrenar los centroides.
Cuantización: que el índice quepa en RAM
Diez millones de vectores de 1536 dimensiones en float32 son 61 GB. Comprimirlos no es opcional a esa escala.
PQ (Product Quantization) corta cada vector en M subvectores, entrena un pequeño codebook (diccionario de centroides) para cada trozo, y reemplaza cada subvector por el identificador de su centroide más parecido — típicamente 1 byte. Un vector de 1536 dimensiones (6144 bytes) queda en 96 bytes con M=96. Es una compresión de 64x, con pérdida.
Binary quantization es más brutal y sorprendentemente efectiva: cada dimensión se reduce a 1 bit (positiva o negativa). Un vector de 1536 dimensiones cabe en 192 bytes, 32x menos. La similitud se calcula con distancia de Hamming, que en CPU moderna es una operación de conteo de bits, brutalmente rápida.
Es la misma familia de ideas que la cuantización de pesos de modelos del módulo 4 del curso de GPU: menos bits por número, un poco de error, muchísima menos memoria.
El patrón que hace todo esto viable se llama rescoring (o refinement): buscar rápido sobre los vectores comprimidos para traer, digamos, 200 candidatos, y reordenar esos 200 con los vectores originales en precisión completa. Recuperas casi todo el recall perdido a un costo despreciable, porque 200 comparaciones exactas no le hacen daño a nadie. Qdrant, Milvus y Weaviate lo traen incorporado.
La tabla de algoritmos
| Algoritmo | Recall | Latencia | Memoria | Tiempo de build | Cuándo usarlo |
|---|---|---|---|---|---|
| Flat (fuerza bruta) | 1,0 exacto | Alta con N grande | Alta (vectores crudos) | Cero | <100 mil vectores, o cuando el recall exacto es requisito |
| HNSW | 0,95-0,99 | Muy baja | Muy alta (grafo + vectores) | Lento | El default para <50M vectores con RAM disponible |
| IVF | 0,85-0,95 | Baja | Media | Rápido (requiere entrenar) | Corpus grande, RAM justa, actualizaciones por lote |
| IVF-PQ | 0,70-0,90 | Muy baja | Muy baja | Medio | Cientos de millones de vectores; usar con rescoring |
| HNSW + binary quant | 0,90-0,97 | Muy baja | Baja | Lento | Escala alta con latencia estricta; casi siempre con rescoring |
Si solo te vas a quedar con una fila: HNSW es el default correcto hasta que la memoria te obligue a cuantizar.
Filtrado por metadata: donde se rompen los sistemas reales
Casi ninguna consulta real es “búscame lo más parecido”. Es “búscame lo más parecido dentro de los documentos de este cliente”, o “publicados este año”, o “del área legal”. Esa restricción es el filtrado por metadata, y tiene dos formas de implementarse con consecuencias muy distintas.
Post-filter: buscar los top-k vectoriales y después descartar los que no cumplen el filtro.
QUERY ──► ┌──────────┐ ──► ┌──────────┐ ──► 2 resultados
│ índice │ │ filtro │
│ top 10 │ │ tenant │
└──────────┘ └──────────┘
pediste 10, llegan 2
Ese es el problema: si el filtro es selectivo (el cliente X tiene el 3% del corpus), de los 10 más parecidos globales quizás ninguno era suyo. Te devuelve una lista vacía o casi, y el usuario ve “no encontré nada” cuando la información existía.
Pre-filter: restringir primero el conjunto candidato y buscar solo ahí. Devuelve siempre k resultados, pero implementarlo bien dentro de un grafo HNSW es difícil — al recortar nodos el grafo se desconecta y la navegación falla.
La solución que usan los motores serios es el filtrado nativo durante el recorrido del grafo: se evalúa el predicado mientras se navega, y si el filtro es muy selectivo el motor cae automáticamente a fuerza bruta sobre el subconjunto (que es chico, así que es rápido). Qdrant, Weaviate, Milvus y pgvector lo hacen. Esto es exactamente lo que hace posible el aislamiento multi-tenant del módulo 12: un filtro por tenant_id que nunca puede fallar.
Ojo: verifica cómo filtra tu motor antes de diseñar el esquema de metadata. Un sistema que hace post-filter con un filtro de tenant no es solo lento: es un sistema que a veces le responde “no hay información” a un cliente sobre sus propios documentos. Y ese bug aparece en producción, no en la demo con 100 documentos donde todo entra en el top-10.
Elegir base de datos vectorial
| Motor | Modelo | Filtrado | Híbrido nativo | Precio | Cuándo elegirlo |
|---|---|---|---|---|---|
| pgvector | Extensión de Postgres | Sí (con índice) | Con tsvector, manual | El de tu Postgres | Ya tienes Postgres. El default honesto |
| Qdrant | Self-host u OSS + cloud | Nativo, muy bueno | Sí (sparse vectors) | Gratis self-host | Filtrado complejo, híbrido, cuantización |
| Weaviate | Self-host u OSS + cloud | Nativo | Sí (BM25 integrado) | Gratis self-host | Híbrido out-of-the-box, esquema tipado |
| Milvus | Self-host, distribuido | Nativo | Sí | Gratis self-host | Cientos de millones de vectores, cluster |
| Chroma | Embebido / servidor | Básico | Limitado | Gratis | Prototipos y notebooks. No para producción seria |
| LanceDB | Embebido, sobre archivos | Sí | Sí (FTS) | Gratis | Serverless, datos en S3, sin servidor que operar |
| Pinecone | Managed puro | Nativo | Sí (sparse) | Por uso, no barato | No quieres operar nada y el presupuesto lo permite |
| Elasticsearch / OpenSearch | Self-host o managed | Excelente | Sí, es su fuerte | Variable | Ya tienes el cluster; BM25 de primera clase |
| FAISS | Librería, no DB | No incluido | No | Gratis | Dentro de otro sistema; no persiste ni sirve queries |
Esa última fila merece énfasis: FAISS no es una base de datos. Es una librería de índices ANN de Meta, excelente y usada por debajo de varios de los motores de arriba. No tiene servidor, no tiene persistencia transaccional, no tiene filtrado de metadata ni control de acceso. Si alguien propone “usamos FAISS” como arquitectura, está proponiendo escribir una base de datos.
La recomendación ordenada, sin rodeos:
- Empieza con pgvector. Si ya tienes Postgres, tienes vector DB. Un solo backup, una sola conexión, joins con tus tablas de negocio, transacciones reales. Aguanta cómodamente varios millones de vectores.
- Salta a un motor dedicado cuando se cumpla al menos una: más de ~10 millones de vectores, filtrado complejo a alta QPS (consultas por segundo), o necesitas híbrido denso+disperso nativo sin escribirlo tú.
- Managed solo si el costo de operar es mayor que el de la licencia. Con un corpus chico casi nunca lo es.
La cantidad de proyectos que adoptaron una vector DB dedicada para 40 mil chunks es enorme. Es infraestructura extra, un backup extra y un punto de falla extra para un problema que pgvector resolvía en una tabla.
Código: pgvector
-- 1. Habilitar la extension (una vez por base de datos)
CREATE EXTENSION IF NOT EXISTS vector;
-- 2. La tabla: vector + metadata en la MISMA fila.
-- Esto es la ventaja de pgvector: no hay dos sistemas que sincronizar.
CREATE TABLE chunks (
id bigserial PRIMARY KEY,
tenant_id text NOT NULL,
fuente text NOT NULL,
pagina int,
texto text NOT NULL,
modelo text NOT NULL, -- regla dura del modulo 4
creado_en timestamptz NOT NULL DEFAULT now(),
embedding vector(1536) NOT NULL -- dimension FIJA, debe calzar
);
-- 3. Indice HNSW.
-- vector_cosine_ops porque comparamos por coseno (vectores normalizados).
-- m y ef_construction son parametros de CONSTRUCCION: cambiarlos = reindexar.
CREATE INDEX chunks_embedding_hnsw
ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
-- 4. Indice B-tree para el filtro de metadata.
-- Sin esto el planner no puede pre-filtrar de forma eficiente.
CREATE INDEX chunks_tenant_idx ON chunks (tenant_id);
Y la consulta:
-- ef_search se ajusta por sesion: es la perilla recall/latencia en caliente.
SET hnsw.ef_search = 100;
SELECT id,
fuente,
pagina,
texto,
-- `<=>` es distancia coseno: 0 = identico, 2 = opuesto.
-- Se convierte a similitud para que "mas alto = mejor".
1 - (embedding <=> $1::vector) AS similitud
FROM chunks
WHERE tenant_id = $2 -- filtro obligatorio, nunca opcional
AND creado_en > now() - interval '2 years'
ORDER BY embedding <=> $1::vector -- este ORDER BY activa el indice HNSW
LIMIT 20;
Dos detalles que se equivocan seguido. El ORDER BY tiene que usar el mismo operador que el índice (<=> para coseno, <-> para L2, <#> para producto punto negativo); si usas otro, Postgres ignora el índice y hace un scan secuencial completo, silenciosamente. Y el LIMIT es obligatorio para que el planner elija el índice.
Verifica siempre con EXPLAIN ANALYZE que aparezca Index Scan using chunks_embedding_hnsw. Si ves Seq Scan, el índice no se está usando y tu latencia es la de fuerza bruta con pasos extra.
Ojo: crea el índice HNSW después de cargar los datos, no antes. Insertar con el índice ya creado hace que cada fila pague el costo de inserción en el grafo, y cargar un millón de filas puede tardar horas en vez de minutos. El orden correcto es: crear tabla,
COPYmasivo, crear índice, subirmaintenance_work_mempara que el build ocurra en memoria.
Código: Qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct,
Filter, FieldCondition, MatchValue, ScalarQuantization,
ScalarQuantizationConfig, ScalarType, SearchParams, QuantizationSearchParams,
)
client = QdrantClient(url="http://localhost:6333")
# 1. Crear la coleccion con HNSW + cuantizacion escalar.
# `always_ram=True` mantiene los vectores comprimidos en memoria y deja
# los originales en disco: se usan solo para el rescoring.
# (`recreate_collection` quedo deprecado: si existe, se borra antes.)
if client.collection_exists("chunks"):
client.delete_collection("chunks")
client.create_collection(
collection_name="chunks",
vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
quantization_config=ScalarQuantization(
scalar=ScalarQuantizationConfig(
type=ScalarType.INT8,
quantile=0.99,
always_ram=True,
)
),
)
# 2. Indice de payload para que el filtro sea eficiente.
# Sin esto, filtrar por tenant_id recorre todos los payloads.
client.create_payload_index(
collection_name="chunks",
field_name="tenant_id",
field_schema="keyword",
)
# 3. Insertar. El payload va junto al vector, no en otra base.
client.upsert(
collection_name="chunks",
points=[
PointStruct(
id=1,
vector=vector_normalizado, # lista de 1536 floats
payload={
"tenant_id": "acme",
"fuente": "manual.pdf",
"pagina": 12,
"texto": "La garantía del equipo cubre 24 meses...",
"modelo": "text-embedding-3-small",
},
)
],
)
# 4. Buscar con filtro NATIVO: el predicado se evalua durante el
# recorrido del grafo, no despues. Siempre devuelve `limit` resultados
# si existen, aunque el tenant sea el 1% del corpus.
resultados = client.query_points( # search() ya no existe en el cliente
collection_name="chunks",
query=query_vec,
query_filter=Filter(
must=[FieldCondition(key="tenant_id", match=MatchValue(value="acme"))]
),
limit=20,
search_params=SearchParams(
hnsw_ef=128, # equivalente a ef_search
quantization=QuantizationSearchParams(
rescore=True, # reordena con vectores originales
oversampling=2.0, # trae 2x candidatos para rescatar recall
),
),
with_payload=True,
)
for r in resultados.points:
print(round(r.score, 4), r.payload["fuente"], r.payload["pagina"])
Ese bloque de quantization con rescore=True y oversampling=2.0 es el patrón de la sección de cuantización, aplicado: buscar barato sobre int8, recuperar el doble de candidatos, reordenar con los vectores reales.
Errores comunes
- Adoptar una vector DB dedicada con 50 mil chunks. Es infraestructura, backup y punto de falla extra para un problema que resuelve una tabla de Postgres. Empieza con pgvector y migra cuando un número concreto te lo pida.
- Crear el índice HNSW antes de la carga masiva. Cada insert paga el costo de insertar en el grafo. Carga primero, indexa después, y sube
maintenance_work_memdurante el build. - Confiar en post-filter para multi-tenant. Pedir 10 y recibir 2 no es una molestia de UX: es información existente que el sistema declara inexistente. Exige filtrado nativo.
- Subir
top_kpara tapar mala calidad. Si contop_k=5no aparece la respuesta y contop_k=50sí, el problema es de ranking, no de cantidad. Traer más basura al prompt empeora la generación y aumenta el costo. La solución es reranking, no volumen. - Olvidar que
ef_searchdebe ser mayor o igual atop_k. Pedir 50 resultados conef_search=40degrada el recall de forma severa y difícil de notar. - No medir recall contra la fuerza bruta. El índice ANN no avisa cuando está devolviendo mal. Guarda una muestra de mil queries, corre flat sobre un subconjunto y compara: sin ese número, tu
ef_searches una superstición.
La frase para llevarse
“Toda base de datos vectorial vende lo mismo: recall a cambio de latencia y memoria. Elige tu punto en esa curva a propósito, no por el default del tutorial.”
Qué viene
Ya sabes buscar por significado a escala. Y de todas formas tu sistema va a fallar cuando alguien pregunte por SKU-4471-B o por el error ERR_CONN_REFUSED, porque el embedding entiende el sentido pero no recuerda el token exacto. El módulo 6 es el antídoto: BM25, el índice invertido y la búsqueda léxica — el algoritmo “viejo” que sigue ganándole a sistemas vectoriales caros y que es la mitad indispensable del híbrido.
Material relacionado
Preguntas sobre este curso
Regístrate gratis para hacer preguntas y guardar tu progreso.
✉ Entérate de lo nuevo
Recibe un aviso cuando publique nuevos cursos, workshops o investigaciones. Sin spam.