Crear para escala: manejar millones de docs
Cómo escalamos nuestro sistema RAG de 50 a 2 millones de documentos usando particionado de pgvector, jobs en segundo plano y caché de respuestas, recortando costes 84%.
Cómo escalamos nuestro sistema RAG de 50 a 2 millones de documentos usando particionado de pgvector, jobs en segundo plano y caché de respuestas, recortando costes 84%.
Cuando lanzamos Chatsy, nuestro primer cliente tenía 50 documentos. Hoy, nuestro mayor cliente enterprise tiene más de 2 millones. Así construimos un sistema que escala sin fricción a través de ese rango.
Resumen rápido:
- Escalar un sistema RAG significa resolver tres cuellos de botella a la vez: ingestión de documentos, búsqueda vectorial y generación con IA.
- Índices particionados por tenant mantienen la búsqueda vectorial en ~50 ms constantes sin importar el conteo total de documentos, y el procesamiento con jobs en segundo plano maneja ingestión a 50 docs/seg.
- Caché de respuestas, streaming y model routing inteligente recortaron el tiempo de respuesta API de 8 s a 2 s y los costes de infraestructura de $5,000 a $800/mes.
- Lección clave: particiona temprano, manda todo lo pesado a background, cachea agresivamente y haz streaming de respuestas para minimizar la latencia percibida.
Esta guía se basa en tres fuentes:
Cuando citamos números, enlazamos al caso de estudio fuente o indicamos la metodología detrás de la cifra. Las afirmaciones genéricas de vendors sin matemática de soporte se marcan con etiquetas VERIFY. Revisado por última vez: abril de 2026.
Escalar un sistema RAG (Retrieval-Augmented Generation) es especialmente difícil porque estás escalando tres cosas simultáneamente:
Cada una tiene características de escalado y cuellos de botella distintos. Una implementación ingenua que funciona con 1,000 documentos se romperá con 100,000. Y lo que funciona con 100K se frenará por completo con 2M. Las estrategias que necesitas cambian en cada orden de magnitud.
Nuestra arquitectura de producción separa responsabilidades en tres capas: manejo de solicitudes, persistencia de datos y procesamiento en segundo plano. Cada capa escala de forma independiente.
┌─────────────────────────────────────────────────────┐
│ Load Balancer │
│ (health checks, SSL term) │
└─────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ API Pod │ │ API Pod │ │ API Pod │
│ (stateless) │ │ (stateless) │ │ (stateless) │
└──────────────┘ └──────────────┘ └──────────────┘
│ │ │
└─────────────────┼─────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ PostgreSQL │ │ Redis │ │ Trigger │
│ + pgvector │ │ Cache │ │ .dev │
└──────────────┘ └──────────────┘ └──────────────┘
La decisión arquitectónica clave fue mantener todo en PostgreSQL en vez de ejecutar una base de datos vectorial separada. Esto simplifica operaciones, reduce costes de infraestructura y nos permite usar herramientas estándar de base de datos para backups, migraciones y monitoring. Nuestra migración de Pinecone a pgvector cubre el razonamiento en profundidad.
El procesamiento de documentos consume CPU y memoria:
Un solo PDF de 100 páginas puede tardar más de 30 segundos en procesarse. Cuando un cliente sube 500 documentos a la vez, eso supera 4 horas de procesamiento secuencial. Los usuarios esperan que su contenido sea buscable en minutos, no horas.
Usamos Trigger.dev para procesamiento en segundo plano fiable y escalable:
typescriptexport const processDocument = task({ id: "process-document", retry: { maxAttempts: 3 }, run: async ({ documentId }) => { // 1. Extraer texto const text = await extractText(documentId); // 2. Chunking inteligente const chunks = await chunkText(text, { maxTokens: 512, overlap: 50, }); // 3. Generar embeddings en lotes for (const batch of chunk(chunks, 100)) { const embeddings = await openai.embeddings.create({ model: "text-embedding-3-small", input: batch.map(c => c.text), }); // 4. Guardar en base de datos await prisma.chunk.createMany({ data: batch.map((chunk, i) => ({ documentId, content: chunk.text, embedding: embeddings.data[i].embedding, })), }); } }, });
Este enfoque nos da:
Cuando los clientes importan cientos de documentos a la vez, usamos una tarea de orquestación por lotes que distribuye trabajo entre varios workers:
typescriptexport const batchImport = task({ id: "batch-import", run: async ({ chatbotId, documentIds }) => { // Procesar en lotes paralelos de 10 const batches = chunk(documentIds, 10); for (const batch of batches) { await Promise.all( batch.map((docId) => processDocument.triggerAndWait({ documentId: docId }) ) ); // Actualizar progreso para la UI await updateImportProgress(chatbotId, { processed: batch.length, total: documentIds.length, }); } }, });
Con 10 workers concurrentes, una carga de 500 documentos termina en aproximadamente 25 minutos en lugar de 4 horas. Las actualizaciones de progreso alimentan una UI en tiempo real para que los clientes vean exactamente dónde está su importación.
A escala, necesitas manejar picos sin saturar servicios downstream. Nuestra estrategia de queue usa tres niveles de prioridad:
Cuando se acerca el rate limit de la API de embeddings, aplicamos backpressure reduciendo la tasa de dequeue en vez de fallar jobs. Los controles de concurrencia integrados de Trigger.dev manejan esto limpiamente.
A medida que crece el conteo de documentos, aumenta la latencia de búsqueda. Sin particionado, en un solo índice plano:
La razón es directa: los índices IVFFlat escanean un porcentaje fijo de clusters. Más vectores significa más datos que escanear por consulta.
Particionamos vectores por tenant (chatbot), una estrategia refinada durante nuestra migración de Pinecone a pgvector:
sql-- Crear tabla particionada CREATE TABLE chunks ( id UUID PRIMARY KEY, chatbot_id UUID NOT NULL, content TEXT, embedding vector(1536) ) PARTITION BY HASH (chatbot_id); -- Crear particiones CREATE TABLE chunks_p0 PARTITION OF chunks FOR VALUES WITH (MODULUS 16, REMAINDER 0); -- ... repetir para 16 particiones -- Indexar cada partición CREATE INDEX ON chunks_p0 USING ivfflat (embedding vector_cosine_ops);
Ahora las consultas solo escanean particiones relevantes:
sqlSELECT content, embedding <=> $1 AS distance FROM chunks WHERE chatbot_id = $2 -- Solo escanea la partición relevante ORDER BY distance LIMIT 10;
Resultado: consultas consistentes de ~50 ms sin importar el conteo total de documentos.
Con más de 2M documentos, incluso los índices particionados se benefician de ajuste cuidadoso. Nuestro enfoque de sharding sigue tres principios:
lists de IVFFlat escala con el número de vectores en cada partición. Ejecutamos un job nocturno que revisa tamaños de partición y reconstruye índices cuando el conteo de vectores cruza un umbral (por ejemplo, 100K vectores dispara un aumento de 100 a 250 lists).┌──────────┐ ┌────────────┐ ┌────────────────┐
│ API Pods │ ──▶ │ PgBouncer │ ──▶ │ PostgreSQL │
│ (50+) │ │ (pool: 20) │ │ (max_conn: 50)│
└──────────┘ └────────────┘ └────────────────┘
Empezamos con IVFFlat porque es más rápido de construir y funciona bien a escala moderada. A medida que crecen los tamaños de partición, HNSW se vuelve atractivo por su mejor tradeoff recall-latencia:
| Factor | IVFFlat | HNSW |
|---|---|---|
| Tiempo de build | Rápido (minutos) | Lento (horas en 1M+) |
| Latencia de consulta | Buena con ajuste | Consistentemente rápida |
| Uso de memoria | Menor | Mayor (~2x) |
| Rendimiento de insert | Rápido | Moderado |
| Ideal para | < 500K vectores/partición | > 500K vectores/partición |
Para la mayoría de nuestros tenants, IVFFlat es la elección correcta. Reservamos HNSW para clientes enterprise con 500K+ chunks donde la mejora marginal de latencia de consulta justifica el coste de memoria.
La caché es el mayor reductor de coste individual en nuestro stack. Cacheamos en tres niveles:
Generar embeddings cuesta dinero y añade latencia. Cuando el mismo chunk de texto se reindexa (por ejemplo, un documento se reimporta sin cambios), omitimos la llamada API:
typescriptasync function getEmbedding(text: string): Promise<number[]> { const cacheKey = `emb:${hash(text)}`; const cached = await redis.get(cacheKey); if (cached) return JSON.parse(cached); const result = await openai.embeddings.create({ model: "text-embedding-3-small", input: text, }); const embedding = result.data[0].embedding; await redis.setex(cacheKey, 86400 * 7, JSON.stringify(embedding)); // 7 días return embedding; }
Solo esto recortó nuestros costes de API de embeddings en 35% porque muchos clientes reimportan documentos durante pruebas y configuración.
Consultas idénticas al mismo chatbot suelen devolver los mismos resultados. Cacheamos los chunks recuperados por un TTL corto:
typescriptconst searchCacheKey = `search:${chatbotId}:${hash(queryEmbedding)}`; const cachedResults = await redis.get(searchCacheKey); if (cachedResults) return JSON.parse(cachedResults); const results = await vectorSearch(chatbotId, queryEmbedding); await redis.setex(searchCacheKey, 300, JSON.stringify(results)); // 5 min
El TTL corto (5 minutos) asegura frescura mientras captura el patrón común de varios usuarios preguntando lo mismo en una ventana corta.
La caché más impactante. Respuestas LLM completas para combinaciones idénticas de pregunta + contexto:
typescriptconst responseCacheKey = `resp:${hash(question + contextChunks)}`; const cached = await redis.get(responseCacheKey); if (cached) return cached; const response = await generateResponse(question, contextChunks); await redis.setex(responseCacheKey, 3600, response); // 1 hora return response;
Las tasas de acierto varían según el caso de uso: chatbots con muchas FAQ ven 40-60% de cache hits. Bots de soporte con preguntas únicas específicas de cuenta ven 10-15%. Incluso 10% ahorra coste significativo a escala.
Las llamadas API a LLM son:
1. Streaming de respuestas No esperes la respuesta completa: transmite tokens al cliente:
typescriptconst stream = await openai.chat.completions.create({ model: "gpt-4o", messages, stream: true, }); for await (const chunk of stream) { controller.enqueue(chunk.choices[0]?.delta?.content || ""); }
El streaming reduce drásticamente la latencia percibida. El usuario ve el primer token en ~300 ms en lugar de esperar 2-5 segundos a la respuesta completa. Esto marca más diferencia para la satisfacción del usuario que mejoras de velocidad cruda.
2. Model routing Usa modelos más baratos para preguntas simples:
typescriptconst complexity = await classifyComplexity(question); const model = complexity === "simple" ? "gpt-4o-mini" : "gpt-4o";
En la práctica, 60-70% de preguntas de soporte son lo bastante directas para GPT-4o-mini. El routing ahorra aproximadamente 50% en costes LLM frente a usar GPT-4o para todo.
3. Gestión de rate limits A escala, alcanzarás rate limits de OpenAI. Lo manejamos con una implementación token bucket que encola solicitudes al acercarse a límites y cae a un proveedor de modelo secundario si el primario está limitado.
| Métrica | Antes | Después |
|---|---|---|
| Velocidad de ingestión | 1 doc/seg | 50 docs/seg |
| Latencia de búsqueda (P99) | 2000 ms | 80 ms |
| Tiempo de respuesta API | 8 s | 2 s |
| Infraestructura mensual | $5,000 | $800 |
No puedes optimizar lo que no puedes medir. Nuestra configuración de monitoring rastrea cuatro categorías de métricas:
| Métrica | Herramienta | Umbral de alerta |
|---|---|---|
| Tiempo de respuesta API (P50, P95, P99) | Logs de aplicación | P99 > 3 s |
| Uso del pool de conexiones de base de datos | Stats de PgBouncer | > 80% utilización |
| Uso de memoria Redis | Redis INFO | > 75% de memoria máxima |
| Profundidad de queue de jobs en segundo plano | Dashboard Trigger.dev | > 1,000 jobs pendientes |
| Métrica | Qué te dice |
|---|---|
| Docs procesados por minuto | Salud del pipeline de ingestión |
| Latencia de búsqueda por tenant | Si un tenant grande está degradando rendimiento |
| Tasa de acierto de caché por nivel | Si tu estrategia de caché es eficaz |
| Coste LLM por conversación | Si model routing funciona |
Usamos un enfoque de alertas por niveles para evitar fatiga:
La alerta más útil que añadimos fue sobre latencia de búsqueda por tenant. Cuando un cliente importa un set masivo de documentos, el índice de su partición puede quedar mal ajustado. La alerta lo detecta antes de que el cliente lo note.
Así se ven nuestros números de producción en distintos niveles de escala:
| Nivel de escala | Docs | Chunks | Búsqueda promedio (P50) | Búsqueda (P99) | Tasa de ingestión |
|---|---|---|---|---|---|
| Starter | < 1K | < 10K | 8 ms | 25 ms | 5 docs/seg |
| Growth | 1K-50K | 10K-500K | 15 ms | 45 ms | 20 docs/seg |
| Scale | 50K-500K | 500K-5M | 25 ms | 65 ms | 50 docs/seg |
| Enterprise | 500K-2M+ | 5M-20M+ | 35 ms | 80 ms | 50 docs/seg |
Estos números se mantienen porque los vectores de cada tenant están aislados en su propia partición. Un solo tenant enterprise con 2M documentos no afecta la latencia de búsqueda de un tenant starter con 500 documentos.
Crear para escala no es manejar la carga de hoy, sino manejar la de mañana sin reescribir todo. Nuestra adopción de técnicas como búsqueda híbrida juega un rol clave en esa escalabilidad.
Omite el patrón de índices particionados, workers en segundo plano y caché de dos niveles si estás ejecutando un sistema RAG single-tenant que sirve menos de ~5,000 documentos y unos pocos miles de consultas al día: una sola instancia PostgreSQL con pgvector e ingestión síncrona es más simple, barata y fácil de depurar. Omítelo si tu tráfico de recuperación es bursty pero tu set de documentos es pequeño y estable: cachea las respuestas, no los embeddings. Y omítelo si tu equipo no tiene rotación on-call: la arquitectura de este post asume que alguien recibe page cuando una worker queue se atasca o un índice de tenant se degrada. Sin ese músculo operativo, un proveedor RAG gestionado (Vertex AI Search, Pinecone Assistant, Anthropic Claude con file search) encaja mejor hasta que lo superes.
Planifica para escala desde el inicio si esperas crecimiento significativo. Adaptar particionado después duele: la arquitectura de Chatsy usa índices particionados por tenant desde el primer día, lo que mantiene búsqueda vectorial en ~50 ms sin importar el conteo de documentos. Si estás creando un sistema RAG que podría crecer de cientos a millones de documentos, diseña particionado, jobs en segundo plano y caché desde el principio.
Los tres cuellos de botella principales son ingestión de documentos (intensiva en CPU y memoria: un solo PDF de 100 páginas puede tardar más de 30 segundos), latencia de búsqueda vectorial (que degrada de ~20 ms en 10K docs a ~2000 ms en 10M docs sin particionado) y coste y latencia de generación con IA (las llamadas LLM son caras y lentas). Cada uno requiere soluciones distintas: jobs en segundo plano para ingestión, índices particionados para búsqueda, y caché más model routing para generación.
La arquitectura escalada de Chatsy usa pods API con load balancing, PostgreSQL con pgvector para almacenamiento vectorial particionado, Redis para caché de respuestas y Trigger.dev para procesamiento de jobs en segundo plano. La clave es escalado horizontal de workers para ingestión (50 docs/seg), tablas particionadas para que las consultas solo escaneen datos relevantes del tenant, y Redis para cachear respuestas LLM y evitar llamadas API redundantes.
Escalar correctamente puede reducir costes drásticamente. Chatsy recortó infraestructura mensual de $5,000 a $800 implementando caché de respuestas, streaming y model routing inteligente, reduciendo el tiempo de respuesta API de 8 s a 2 s. La caché de respuestas reutiliza resultados para preguntas idénticas; model routing usa modelos más baratos (por ejemplo, GPT-4o-mini) para consultas simples. El tradeoff es inversión de ingeniería inicial para ahorro a largo plazo.
No puedes optimizar lo que no puedes medir. Monitorea throughput de ingestión (docs/seg), latencia de búsqueda (P99), tiempo de respuesta API y costes de infraestructura. Los resultados de Chatsy muestran el valor: rastrear estas métricas reveló el cuello de botella de ingestión (1 doc/seg → 50 docs/seg con jobs en segundo plano), pico de latencia de búsqueda (2000 ms → 80 ms con particionado) y coste API (8 s → 2 s con caché y routing).
Aprende a diseñar sistemas multiagente para soporte al cliente donde agentes especializados manejan facturación, problemas técnicos, envíos y devoluciones, con un router que orquesta conversaciones.