Módulo 1: Por qué Vector Databases para AI Engineers

Cápsula 02: El problema de fondo — RAG necesita encontrar 5 docs entre millones en milisegundos

Descripción de la cápsula

Vas a construir un sistema de soporte técnico con IA. La idea suena simple: cuando un usuario pregunta algo, el sistema busca en tu documentación los 5 artículos más relevantes y se los pasa a un LLM para que redacte una respuesta. Total: 4 pasos, ¿qué puede salir mal?

El paso 2 — "buscar los 5 más relevantes" — es donde se hunde 90% de los proyectos RAG en producción. La pregunta que casi nadie hace antes de empezar a codear es: ¿qué tan rápido puede encontrar esos 5 documentos cuando tu base tiene 1 millón de chunks?

Si tardás 50ms, tu sistema es production-ready. Si tardás 5 segundos, es inutilizable y nadie te va a explicar la diferencia hasta que sea tarde — porque la diferencia no está en el código que vas a escribir, está en la estructura de datos que uses para almacenar los vectores. Esta cápsula construye el modelo mental de por qué retrieval es el cuello de botella crítico de RAG y por qué herramientas convencionales (numpy, pandas, SQL) colapsan al primer signo de escala.

Al finalizar esta cápsula serás capaz de:

  • ✅ Explicar las cuatro fases del pipeline RAG y por qué retrieval es el cuello de botella
  • ✅ Calcular el tiempo de respuesta total esperado de un RAG (embedding + retrieval + generation)
  • ✅ Predecir cuándo brute force con numpy va a colapsar (umbral típico: 100K vectores)
  • ✅ Explicar el trade-off accuracy vs speed de Approximate Nearest Neighbors (ANN)
  • ✅ Justificar con números por qué necesitás herramienta especializada (vector database) sobre numpy/pandas
  • ✅ Distinguir entre exact nearest neighbor y approximate — y entender por qué para RAG el approximate es lo correcto

Tiempo estimado: 25-30 minutos


El pipeline RAG en cuatro fases

Antes de hablar del cuello de botella, asegurémonos de que estás viendo todo el sistema. RAG (Retrieval-Augmented Generation) tiene cuatro fases distintas, y solo dos de ellas afectan la latencia que el usuario percibe:

FASE 1 — INGESTION (corre una vez, o cuando llegan datos nuevos)
  Documentos → Chunking → Embeddings → Storage
                                            │
                                            ▼
FASE 2 — INDEXING (corre una vez, en background)
  Vectores → Indexing Algorithm → Vector Database

═══════════════════════════════════════════════════════
  ↑ Fases offline. El usuario no las ve.
═══════════════════════════════════════════════════════
  ↓ Fases online. Cada query del usuario las ejecuta.

FASE 3 — RETRIEVAL (el cuello de botella)
  Query del usuario → Query embedding → Búsqueda → Top-K chunks

FASE 4 — GENERATION
  Top-K chunks → Prompt al LLM → Respuesta generada

El insight crítico: las fases 1 y 2 pueden ser tan lentas como necesites — corren en background. Las fases 3 y 4 son las que el usuario espera, y juntas definen la latencia percibida.

El presupuesto de tiempo del usuario

Estudios de UX consistentemente muestran que:

  • <1 segundo total: experiencia fluida
  • 1-3 segundos: aceptable, el usuario espera
  • 3-5 segundos: empieza a frustrarse
  • 5 segundos: abandona o reformula la pregunta

Si tu objetivo es <3 segundos total, tenés que distribuir ese presupuesto entre las dos fases online:

Presupuesto total:           3,000 ms
├─ Query embedding (OpenAI):    150 ms  (típico, varía 100-200ms)
├─ Retrieval:                    ??? ms  ← lo que estamos analizando
└─ Generation (GPT-4o-mini):  1,200 ms  (típico para respuesta de ~300 tokens)

Disponible para retrieval = 3,000 - 150 - 1,200 = 1,650 ms

Eso suena holgado. Hasta que medís retrieval con datos reales.


Escenario realista: la matemática que duele

Caso: chatbot de soporte para una empresa SaaS con documentación técnica.

Datos:

  • 100,000 artículos de documentación
  • Cada artículo se chunkea en ~5 piezas → 500,000 chunks
  • Cada chunk se embebe en un vector de 1,536 dimensiones (OpenAI text-embedding-3-small)
  • Total: 500,000 vectores de 1,536 dimensiones cada uno

Cargas operativas:

  • 200 usuarios concurrentes en hora pico
  • Cada usuario hace 3-5 queries por sesión
  • ~600 queries simultáneas por minuto

Pregunta clave: ¿cuánto tarda buscar los 5 chunks más similares a una query?

Y más importante: ¿cómo cambia esa respuesta según la herramienta que uses?


Brute force con numpy — la solución obvia que falla

El primer instinto de cualquiera que sabe Python es:

import numpy as np

def naive_search(query_vector, all_vectors, k=5):
    """
    Calcula similaridad coseno con todos los vectores.
    Devuelve los k más similares.
    """
    # Normalizar para que dot product = cosine similarity
    query_normalized = query_vector / np.linalg.norm(query_vector)
    all_normalized = all_vectors / np.linalg.norm(all_vectors, axis=1, keepdims=True)

    # Similaridad coseno con TODOS los vectores
    similarities = np.dot(all_normalized, query_normalized)

    # Top-k indices
    top_k_indices = np.argsort(similarities)[-k:][::-1]
    return top_k_indices, similarities[top_k_indices]

¿Funciona? Sí. ¿Escala? Vamos a medirlo.

Benchmark real

import numpy as np
import time

DIMENSIONS = 1536  # OpenAI text-embedding-3-small

def benchmark_brute_force(n_vectors):
    # Generar vectores random (proxy de embeddings reales)
    database = np.random.randn(n_vectors, DIMENSIONS).astype('float32')
    query = np.random.randn(DIMENSIONS).astype('float32')

    # Normalizar
    db_norm = database / np.linalg.norm(database, axis=1, keepdims=True)
    q_norm = query / np.linalg.norm(query)

    # Medir 5 ejecuciones, tomar la mediana
    times = []
    for _ in range(5):
        start = time.perf_counter()
        sims = np.dot(db_norm, q_norm)
        top_5 = np.argsort(sims)[-5:][::-1]
        times.append((time.perf_counter() - start) * 1000)

    return np.median(times)


for n in [10_000, 100_000, 500_000, 1_000_000, 5_000_000]:
    latency_ms = benchmark_brute_force(n)
    print(f"n={n:>10,}: {latency_ms:>7.1f} ms")

Output típico (Macbook M2, 16 GB RAM):

n=    10,000:    12.5 ms     ✅ Excelente
n=   100,000:   125.3 ms     ⚠️  Aceptable, ya empieza a doler
n=   500,000:   642.8 ms     ❌ Inutilizable
n= 1,000,000:  1,287.2 ms    ❌ Imposible para producción
n= 5,000,000:  6,490.5 ms    ❌ Ridículo

Patrón inequívoco: la latencia crece linealmente con el número de vectores. Es lo que la teoría predice — la complejidad de brute force es O(n), donde n es el número de vectores.

Por qué tu sistema RAG con numpy va a fallar

Volvé al escenario del chatbot SaaS: 500,000 chunks. Brute force tarda ~640ms.

Sumá al pipeline:

Query embedding:    150 ms  (OpenAI)
Retrieval (numpy):  640 ms  (brute force sobre 500K)
Generation:       1,200 ms  (GPT)
─────────────────────────
Total:            1,990 ms  ← cerca del límite

Eso ya sobrepasa los 2 segundos solo en el caso feliz. Si la concurrencia genera contención de CPU, la latencia se dispara. Si tu dataset crece a 1M chunks, ya estás en 2.6 segundos solo de retrieval — totalmente fuera del presupuesto.

Y todo esto antes de que aparezca el problema de memoria: 500K vectores × 1536 dim × 4 bytes (float32) = 3 GB en RAM. 5M vectores = 30 GB. Tu servidor de aplicación no soporta eso, y aunque lo soportara, querés esa RAM para otras cosas (caché, sesiones, conexiones).


La idea clave: Approximate Nearest Neighbors (ANN)

La pregunta real no es "¿cómo hago brute force más rápido?". Es "¿realmente necesito el resultado exacto?"

El insight contraintuitivo

En RAG, no necesitás los 5 vectores absolutamente más cercanos. Necesitás 5 vectores suficientemente cercanos que el LLM pueda usar para responder bien. Esos dos sets coinciden 95-99% del tiempo, y la diferencia rara vez importa.

Trade-off explícito:

ApproachAccuracyLatencyViable a escala
Exact NN (brute force)100%O(n)Hasta ~10K vectores
Approximate NN (ANN)95-99%O(log n)Millones de vectores

Sacrificás 1-5% de accuracy a cambio de 100-1000x menos latencia. Para RAG, ese trade-off es obvio.

Analogía: Google Maps

Imaginá buscar "restaurante italiano cercano". Podrías:

  • Approach exacto: medir distancia GPS a CADA restaurante italiano de Buenos Aires (8000 restaurantes), ordenar por distancia, mostrarte el más cercano. Resultado garantizado correcto, pero tarda 30 segundos en tu celular.

  • Approach approximate: Google divide la ciudad en zonas. Identifica en qué zona estás. Solo busca en restaurantes de tu zona y zonas adyacentes (200 restaurantes). Te muestra el más cercano de esos. 99% de las veces es el correcto. Tarda 100ms.

¿Cuál preferís? Obvio el segundo, porque el 1% de las veces que devuelve un restaurante a 50 metros más en lugar del realmente más cercano, no te importa — la respuesta es "suficientemente buena". ANN funciona exactamente con esa lógica para vectores.

Cómo lo logran los algoritmos ANN (vista panorámica)

Hay tres familias principales que vas a ver en producción:

HNSW (Hierarchical Navigable Small World):

  • Construye un grafo en capas. Las capas altas tienen pocos nodos con saltos largos. Las capas bajas tienen muchos nodos con saltos cortos.
  • Para buscar, empezás arriba y vas descendiendo, "haciendo zoom" hacia el cluster correcto.
  • Complejidad: O(log n)
  • Accuracy típico: 95-99%
  • Es lo que usan ChromaDB, Weaviate, Qdrant, Pinecone

IVF (Inverted File Index):

  • Agrupa vectores en clusters (k-means). Para buscar, encontrás primero el cluster más cercano a tu query, después buscás dentro de ese cluster.
  • Complejidad: O(√n) aproximado
  • Accuracy típico: 90-95%
  • Lo usa Faiss (Meta), Milvus

PQ (Product Quantization):

  • Comprime cada vector en un código corto. Pierde precisión a cambio de 4-8x menos memoria.
  • Suele combinarse con IVF para escala extrema.
  • Accuracy típico: 85-90%
  • Para datasets de cientos de millones de vectores donde memoria es la restricción

El Módulo 2 entero es sobre cómo funcionan estos algoritmos por dentro. Por ahora, el insight que importa: existen, son rápidos, y son la razón por la que vector databases hacen lo que hacen.


Comparación numérica: numpy vs ANN

Sobre los mismos 1M vectores de 1536 dimensiones:

MétodoLatency p50AccuracyMemoria
Brute force (numpy)1,287 ms100%6 GB
HNSW (ChromaDB)14 ms98%8 GB
IVF (Faiss)48 ms94%6 GB
PQ (Faiss compressed)78 ms89%1 GB

Lecturas:

  1. HNSW es ~90x más rápido que brute force, con 98% accuracy. Para casi cualquier RAG, esto es lo correcto.
  2. IVF es ~25x más rápido, ~5% menos accuracy. Buen balance cuando memoria es ajustada.
  3. PQ es 16x más rápido y usa 6x menos memoria. Apropiado cuando tu dataset crece a 10M+ vectores.

Aplicado al pipeline RAG:

Pipeline con HNSW:
Query embedding:    150 ms
Retrieval:           14 ms  ← ANN
Generation:       1,200 ms
─────────────────────────
Total:            1,364 ms  ✅ Excelente

Pipeline con brute force:
Query embedding:    150 ms
Retrieval:        1,287 ms  ← brute force
Generation:       1,200 ms
─────────────────────────
Total:            2,637 ms  ⚠️ Cerca del límite de 3s

Y eso es para una sola query. Con 200 queries concurrentes, brute force colapsa el servidor mientras HNSW responde sin sudar.


Por qué herramientas convencionales fallan

Cerremos la cápsula con una tabla resumen — vas a ver detalles de cada uno en las cápsulas siguientes:

HerramientaLatency con 1M vecsAlgoritmoCuándo elegirlo
numpy~1,300 msBrute forcePrototipo con <10K vectores
pandas~5,000 msIteración + similarityCasi nunca
PostgreSQL + pgvector~80 msHNSW (desde v0.5.0)Si ya usás Postgres y necesitás funcionalidad mixta
MongoDB Atlas Vector Search~120 msHNSWSi ya usás MongoDB Atlas
Elasticsearch (dense_vector)~150 msHNSW (desde 8.x)Hybrid search keyword + vector
Vector DB dedicada (ChromaDB, Pinecone, etc.)10-50 msHNSW optimizadoDefault para RAG production

Punto: las opciones "ya tengo X y le agrego búsqueda de vectores" funcionan, pero son consistentemente más lentas que vector DBs dedicadas — porque su arquitectura está optimizada para otra cosa (queries SQL, búsqueda full-text, etc.).

Las cápsulas 03 y 04 cubren por qué SQL/NoSQL fallan en detalle, y por qué numpy/pandas no escalan más allá del prototipo.


Trampas y errores comunes

Trampa 1: medir solo el caso optimista

El error: medís brute force con 10K vectores, ves 12ms, decidís "esto es rápido, sigo con numpy".

Síntoma: semanas después, cuando metés 200K documentos a producción, el sistema empieza a tardar segundos. No entendés por qué — "antes andaba bien".

Cómo prevenir: desde el inicio, medir con el tamaño de dataset esperado en 6-12 meses, no el tamaño actual. Si proyectás 1M vectores, benchmarkeá con 1M de vectores sintéticos antes de elegir la arquitectura.

Trampa 2: confundir "exact NN" con "respuesta correcta del LLM"

El error: asumís que como ANN tiene 98% accuracy, tu RAG va a tener 98% de respuestas correctas.

Realidad: la accuracy del retrieval es solo uno de los componentes de la calidad final. El LLM puede dar una respuesta perfecta con chunks "casi pero no exactamente correctos", o una respuesta mala con los chunks perfectos. La accuracy de retrieval solo importa si afecta la calidad de la respuesta.

Cómo prevenir: medir accuracy end-to-end (¿el sistema responde bien?) además de retrieval accuracy. La guía #12 (Evaluation Frameworks) cubre esto.

Trampa 3: optimizar latencia sin medir generation

El error: dedicás 3 semanas a bajar retrieval de 100ms a 20ms. El usuario no nota la diferencia.

Síntoma: retrieval es 100ms, generation es 2500ms. La latencia total pasa de 2750ms a 2670ms — 3% de mejora invisible.

Cómo prevenir: medí qué porcentaje del tiempo total cada componente consume. Optimizá el más caro primero. Para RAG típico es generation, no retrieval — y para optimizar generation se usa caching, modelos más pequeños o streaming, no cambiar de vector database.

Trampa 4: asumir que más vectores = mejor RAG

El error: "metamos toda la documentación que tengamos, más datos = mejor". Empezás con 50K, terminás con 5M.

Síntoma: retrieval baja de calidad porque hay demasiados docs irrelevantes "diluyendo" el resultado. Las queries devuelven chunks tangenciales en vez de los exactos.

Cómo prevenir: curar el dataset. Más datos solo ayuda si los datos nuevos son relevantes a las queries esperadas. Documentos viejos, duplicados o irrelevantes empeoran el sistema, no lo mejoran.


Ejercicio aplicado

Escenario: un compañero te muestra su prototipo de RAG. Lo construyó así:

# rag_prototype.py
import numpy as np
import openai
import pandas as pd

# Carga 80,000 chunks pre-embebidos en pandas
df = pd.read_parquet("embeddings.parquet")  # columnas: chunk_id, text, embedding

def search(query: str, k: int = 5):
    query_emb = openai.embeddings.create(
        input=query, model="text-embedding-3-small"
    ).data[0].embedding

    # Iterar fila por fila para calcular similaridad
    similarities = []
    for _, row in df.iterrows():
        sim = np.dot(query_emb, row['embedding']) / (
            np.linalg.norm(query_emb) * np.linalg.norm(row['embedding'])
        )
        similarities.append(sim)

    df['similarity'] = similarities
    return df.nlargest(k, 'similarity')[['chunk_id', 'text', 'similarity']]

Te dice: "Anda en mi máquina. Quiero deployar a producción la semana que viene. Va a recibir ~30 queries por minuto. ¿Listo?"

Tu trabajo: identificá los tres problemas críticos de este código y proponé una solución para cada uno, con justificación numérica.

Solución

Problema 1: df.iterrows() con cosine similarity calculada manualmente

iterrows() itera fila por fila en Python (no vectorizado). Para 80K filas con cálculos de similaridad, esto va a tardar decenas de segundos por query.

Cálculo aproximado: ~50µs por iteración × 80,000 = 4 segundos por query, solo para retrieval. Si vas a producción, la primera query va a romper el SLA.

Solución inmediata: vectorizar con numpy.

# Stack de embeddings como matriz (una sola vez al cargar)
embeddings_matrix = np.vstack(df['embedding'].values)
embeddings_norm = embeddings_matrix / np.linalg.norm(embeddings_matrix, axis=1, keepdims=True)

def search(query: str, k: int = 5):
    query_emb = get_query_embedding(query)
    query_norm = query_emb / np.linalg.norm(query_emb)
    similarities = np.dot(embeddings_norm, query_norm)
    top_k_idx = np.argsort(similarities)[-k:][::-1]
    return df.iloc[top_k_idx]

Latencia esperada: ~80ms para 80K vectores. 50x más rápido.

Pero atención: esto sigue siendo brute force. Funciona ahora con 80K, va a colapsar cuando lleguen a 500K.


Problema 2: arquitectura no escalable (brute force aún optimizado)

Aún con la versión vectorizada, brute force es O(n). Si el dataset crece a 500K (probable en pocos meses si la app crece), tardás ~500ms por query. Con 30 queries/minuto compitiendo por CPU, las latencias se vuelven impredecibles.

Solución estructural: usar vector database desde el inicio.

import chromadb
from chromadb.utils import embedding_functions

openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key=os.getenv("OPENAI_API_KEY"),
    model_name="text-embedding-3-small"
)
client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
    name="docs",
    embedding_function=openai_ef
)

def search(query: str, k: int = 5):
    return collection.query(query_texts=[query], n_results=k)

Latencia esperada: ~15ms (HNSW), independiente del tamaño del dataset hasta ~10M vectores. Migración necesaria antes de deploy, no después.


Problema 3: regenerar embedding de la query con OpenAI cada vez (sin cache)

openai.embeddings.create() tarda ~150ms por llamada. Si los usuarios repiten queries similares (común en chatbots de soporte: "cómo cambio mi contraseña"), cada vez se paga ese costo.

Cálculo aproximado: 30 queries/min × 150ms = 4.5 segundos de embedding por minuto. Multiplicado por concurrencia de usuarios, OpenAI puede convertirse en cuello de botella.

Solución: cache LRU de query embeddings.

from functools import lru_cache
import hashlib

@lru_cache(maxsize=10_000)
def get_query_embedding_cached(query: str):
    # Cache hit: 0.1ms. Cache miss: 150ms (llamada a OpenAI)
    return openai.embeddings.create(
        input=query, model="text-embedding-3-small"
    ).data[0].embedding

Para queries comunes (top 1000), el hit rate llega a 70-80% en sistemas de soporte. Latencia promedio del embedding baja de 150ms a ~30-50ms.


Resumen para el compañero:

"El prototipo funciona pero no es production-ready. Tres cambios críticos antes de deployar:

1. Reemplazar iterrows() por numpy vectorizado — 50x más rápido, cambio trivial. 2. Migrar a ChromaDB con HNSW — desde 80K hasta 5M+ vectores con latencia constante <50ms. Cambio de ~2 horas usando ChromaDB. 3. Agregar cache LRU para query embeddings — reduce calls a OpenAI 70-80%, baja latencia y costo.

Sin estos cambios, el sistema va a fallar SLA en pocas semanas cuando crezca el dataset. Mejor invertir 1-2 días ahora que migrar bajo presión después."


Resumen y siguiente paso

Lo que aprendiste:

  • RAG tiene cuatro fases. Las dos online (retrieval + generation) definen la latencia que el usuario percibe.
  • El presupuesto típico para retrieval es 200-500ms. El resto se va en query embedding y generation.
  • Brute force con numpy es O(n) y colapsa con 100K+ vectores. Para 1M vectores tarda >1 segundo, inaceptable.
  • ANN (Approximate Nearest Neighbors) sacrifica 1-5% de accuracy por 100-1000x menos latencia. Para RAG, el trade-off es claramente positivo.
  • HNSW, IVF y PQ son las tres familias principales de algoritmos ANN. ChromaDB y la mayoría de vector DBs usan HNSW por default.
  • Optimizar retrieval no sirve si generation es el cuello de botella. Medí antes de optimizar.

Checkpoint: antes de avanzar, deberías poder:

  • Calcular el presupuesto de retrieval para un sistema con SLA <2 segundos.
  • Predecir cuándo brute force con numpy va a fallar (umbral típico: 100K vectores).
  • Explicar por qué 98% accuracy de ANN es suficiente para RAG en la práctica.

Siguiente cápsula: 03 — Por qué SQL/NoSQL no sirven para semantic search.

Acabás de entender que RAG necesita herramientas especializadas. La siguiente cápsula explica por qué las herramientas que ya tenés en tu stack (Postgres, MongoDB, Elasticsearch) no son la respuesta — aunque "casi" funcionen. Vas a ver dónde fallan, cuándo "casi" alcanza, y cuándo definitivamente necesitás vector database dedicada.


Recursos

  1. RAG Paper Original (Lewis et al., 2020) — El paper que definió el patrón
  2. Approximate Nearest Neighbors Explained (Simon Willison) — Explicación accesible sin matemáticas
  3. HNSW Algorithm (Malkov & Yashunin, 2018) — Paper técnico, opcional
  4. ANN Benchmarks — Comparación reproducible de algoritmos
  5. Pinecone — Vector Database Fundamentals — Overview del mercado
  6. LangChain — RAG from Scratch — Aplicación práctica del concepto

Tiempo estimado: 25-30 minutos Siguiente: 03-por-que-sql-nosql-no-sirven.md