Cómo diseñar una arquitectura RAG para producción sin alucinaciones
Aprendé a diseñar una arquitectura RAG para producción paso a paso: chunking, bases vectoriales, re-ranking y evaluación para evitar alucinaciones.
Querés armar un asistente para que tu equipo de soporte responda tickets con la documentación interna, pero el modelo inventa funciones que no existen. Databricks dice que los agentes multi-paso le ganan a RAG por 20 puntos en tareas complejas. Esos agentes razonan bien en varios pasos, pero una buena arquitectura rag sigue siendo la opción directa para mandar IA a producción hoy con datos propios y costos controlados. En este post te muestro cómo diseñar una arquitectura rag para producción, qué tradeoffs vas a enfrentar y cómo armar cada componente con el ejemplo de ese asistente de soporte.
El mecanismo y la arquitectura rag: desarmando la magia
RAG le entrega al modelo información actual desde una base externa en vez de dejarlo responder sólo con su pre-entrenamiento, tal como propuso el paper original de Lewis et al. en arXiv. El sistema reduce las alucinaciones porque ancla el contexto: le pedís al modelo que responda sólo con los fragmentos provistos.
El proceso tiene dos fases:
- Retrieval (Recuperación): Tu consulta se convierte en un vector numérico (embedding) para buscar los fragmentos más relevantes en tu base. La calidad del embedding define si captura la similitud semántica con sinónimos o paráfrasis en tu dominio. La base vectorial aporta eficiencia: maneja el throughput (consultas por segundo), la latencia (tiempo de respuesta), el costo por query, la escala y la resiliencia.
- Generation (Generación): El LLM recibe los fragmentos recuperados junto con la consulta original. Con ese contexto extendido, genera la respuesta anclada en tus datos reales.
La columna vertebral: tu base de conocimiento y estrategias de chunking
Tu base de conocimiento no es una carpeta llena de PDFs. Requiere una estrategia clara de datos para procesar esa documentación de soporte.
1. Chunking: el arte de cortar bien
Dividir los documentos en fragmentos o chunks define la calidad del sistema. Si el chunk es enorme, metés ruido y diluís lo importante. Si es muy chico, perdés el contexto de la frase. Tenés que buscar un equilibrio donde cada bloque sea coherente por sí solo.
| Estrategia | Pros | Contras | Casos de Uso Ideales | Complejidad | Evaluación de Efectividad |
|---|---|---|---|---|---|
| Fijo con Overlap | Simple de implementar, funciona con texto no estructurado | Puede cortar frases o ideas a la mitad, pierde contexto semántico | Blogs, artículos genéricos, texto plano | Baja | Recall de chunks, longitud promedio del contexto |
| Por Estructura | Preserva contexto semántico (sección, párrafo) | Más complejo para automatizar en contenido inconsistente | Documentación técnica, manuales, libros | Media | Context precision, faithfulness, human review |
| Semántico | Chunks basados en el significado, no en el tamaño | Requiere modelos de lenguaje para identificar límites, más lento | Conversaciones, resúmenes, documentos complejos | Alta | Context recall, coherencia semántica |
| Recursivo | Prueba diferentes tamaños hasta encontrar óptimo | Iterativo, puede ser lento para indexación masiva | Cualquier tipo de documento, adaptable | Media | Métricas Ragas (context recall, precision) |
# Ejemplo de chunking con overlap (pseudocódigo en Python)
# Este es un punto de partida. En producción, considerá la semántica y la evaluación.
def chunk_document_fixed_overlap(text: str, chunk_size: int = 500, overlap_size: int = 100) -> list[str]:
chunks = []
start = 0
while start < len(text):
end = min(start + chunk_size, len(text))
chunk = text[start:end]
chunks.append(chunk)
# Mover el inicio para el siguiente chunk, considerando el solapamiento
start += (chunk_size - overlap_size)
# Asegurar que no se cree un chunk vacío al final si el solapamiento es muy grande
if start >= len(text) and len(chunks[-1]) < chunk_size // 2: # Heurística para evitar chunks residuales muy pequeños
if len(chunks) > 1: # Si hay al menos un chunk anterior, unir el último con el anterior
chunks[-2] = chunks[-2] + chunks.pop()
else: # Si es el único chunk, dejarlo como está
pass
return chunks
# Para un chunking más avanzado, librerías como LangChain o LlamaIndex ofrecen RecursiveCharacterTextSplitter
# que es muy útil para probar diferentes separadores (\n\n, \n, ' ', '') y tamaños.
2. La base de datos vectorial: ¿dónde guardo los embeddings?
Para escalar a millones de documentos y sostener baja latencia, servicios como Pinecone o Weaviate resuelven varios frentes:
- Indexación distribuida: dividen grandes volúmenes de datos entre varios nodos.
- Búsqueda ANN: usan algoritmos de vecinos cercanos optimizados para alta dimensionalidad.
- Infraestructura gestionada: resuelven alta disponibilidad y escalado sin sumar mantenimiento operativo.
- Tolerancia a fallos: mantienen la continuidad del servicio ante caídas.
| Característica | pgvector (PostgreSQL) | Pinecone/Weaviate (Managed) | Chroma (Embeddings local) |
|---|---|---|---|
| Complejidad | Baja/Media (si ya usás PG) | Baja | Muy Baja |
| Escalabilidad | Media (depende del PG) | Alta (cloud nativo) | Baja (ideal para dev/POC) |
| Costo | Bajo/Medio (infra existente) | Medio/Alto (por uso) | Muy Bajo (solo almacenamiento) |
| Control | Alto | Bajo | Alto |
| Latencia | Media | Baja | Baja (para datasets chicos) |
| Uso Típico | Prototipos, datasets medios | Producción, big data | Desarrollo, testing, apps pequeñas |
El cerebro del sistema: retrieval y re-ranking
Tener buenos chunks no alcanza si los recuperás mal. El retrieval necesita modelos de embedding precisos para el dominio de tus datos.
1. Búsqueda inicial (Similarity Search): buscás por cercanía vectorial con modelos como all-MiniLM-L6-v2 o las opciones de OpenAI y Cohere. La elección balancea costo, velocidad y precisión.
2. Re-ranking: filtrando el ruido para la precisión
La primera búsqueda suele traer texto parecido que no responde la duda puntual. Un re-ranker ordena esos resultados con un modelo cross-encoder liviano:
- Mecanismo: toma la consulta del usuario junto con cada fragmento recuperado. Evalúa el par completo y calcula un puntaje de relevancia directo antes de pasárselo al LLM.
- Tradeoff: suma unos milisegundos de latencia, pero sube la precisión y evita que el modelo procese información irrelevante. En sistemas corporativos donde el dato exacto importa, este paso es indispensable.
La voz: generación con LLMs y control de alucinaciones
Con los fragmentos reordenados, armás el prompt para el modelo.
Ingeniería de prompt para RAG:
Delimitá las instrucciones para controlar las respuestas:
- Instrucciones claras: pedile que responda usando únicamente el contexto provisto y que avise si el dato no aparece.
- Contexto delimitado: encerrá el texto en etiquetas como
<context>...</context>para separar datos de instrucciones. - Manejo de incertidumbre: exigí que declare cuándo no sabe la respuesta para no perder confiabilidad.
Pregunta del usuario: {query}
Contexto:
<context>
{chunk1}
{chunk2}
{chunk3}
</context>
Basándote estrictamente en el contexto proporcionado, respondé la pregunta. Si la información no está disponible en el contexto, decí "No tengo suficiente información para responder a eso basándome en los documentos proporcionados."
Tradeoffs del LLM: podés usar un modelo de frontera costoso o uno abierto más chico, como vimos al comparar modelos generalistas vs. especializados. Para muchas consultas de soporte alcanza con un modelo mediano. Si hacés fine-tuning, lográs que respete el formato y use menos tokens por consulta, bajando costos y tiempos de espera.
Orquestación: armar el rompecabezas en producción
Un RAG productivo funciona como un conjunto de microservicios coordinados.
graph TD
A[Usuario/Aplicación] --> B(API Gateway/Load Balancer)
B --> C{Servicio de Orquestación RAG}
C --> D[Servicio de Embedding]
C --> E[Servicio de Vector Store]
C --> G[Servicio de Re-ranking]
C --> H["Servicio LLM (OpenAI, HuggingFace, etc.)"]
H --> I["Cache de Respuestas (Redis)"]
I -- Cache Hit --> A
H -- Cache Miss --> C
C --> A
subgraph Pipeline de Indexación
J["Fuente de Datos (DB, S3, Web)"] --> K(Extracción y Limpieza)
K --> L(Chunking Estratégico)
L --> D
D --> E
E -- Metadata --> K
end
style C fill:#f9f,stroke:#333,stroke-width:2px
style E fill:#f9f,stroke:#333,stroke-width:2px
Para operar esta arquitectura necesitás contemplar cuatro áreas:
- Observabilidad: guardá logs, métricas y trazas de latencia en cada paso, aplicando distributed tracing con OpenTelemetry para ubicar cuellos de botella entre el vector store y el LLM. Podés medir alucinaciones con un segundo LLM evaluando la fidelidad (faithfulness) contra el contexto o mediante reglas heurísticas.
- Escalabilidad: controlá si la base vectorial aguanta la carga y si tu proveedor de LLM tiene límites de tasa (rate limits). Medí la calidad del embedding con benchmarks del dominio, recall de consultas reales o pruebas A/B.
- Seguridad: anonimizá datos personales (PII) antes de indexar, aplicá filtros por permisos de usuario sobre la metadata del chunk y asegurá el cifrado en tránsito y en reposo.
- Pipeline de indexación: armá un proceso ETL continuo que resuelva la idempotencia, la concurrencia y la actualización constante de los documentos.
Un caso concreto: asistente de soporte para SaaS
Imaginá que sos el CTO de una empresa SaaS y necesitás un asistente interno para que el equipo de soporte resuelva tickets consultando manuales técnicos, FAQs y casos viejos.
Solución RAG propuesta:
- Fuentes de datos: documentación interna (Confluence, Markdown), tickets resueltos (Zendesk, JIRA) y FAQs públicas.
- Pipeline de indexación: un job diario procesa el texto, divide los manuales por sección y los tickets por respuesta con overlap. Genera vectores con
sentence-transformersy los guarda en Pinecone con metadata (ID, fecha, tipo de documento y permisos). - Servicio de RAG (FastAPI): expone un endpoint
/askque ejecuta este flujo:- Recibe la consulta del agente de soporte.
- Vectoriza la pregunta con el modelo de embeddings.
- Pide a Pinecone entre 10 y 20 chunks aplicando filtros según los permisos del usuario.
- Reordena los fragmentos con un cross-encoder como
cross-encoder/ms-marco-TinyBERT-L2-v2y se queda con los 3 a 5 mejores. - Envía la pregunta con los chunks a un modelo generalista de frontera o a un modelo mediano abierto con fine-tuning para soporte.
- Devuelve la respuesta al agente junto con los enlaces a los documentos fuente.
Tradeoffs de este caso:
- Costo: Pinecone y un modelo de frontera suben la factura mensual. Se justifica por el tiempo ganado por ticket, aunque podés arrancar con un modelo más económico.
- Latencia: el re-ranking y la inferencia tardan entre 1 y 3 segundos. Para un agente humano es un tiempo cómodo; para un chatbot en vivo con clientes puede sentirse lento.
- Mantenimiento: necesitás monitorear el pipeline ETL, evaluar el recall del embedding periódicamente con preguntas de prueba y verificar la fidelidad de las respuestas con validadores automáticos.
Qué hacer el lunes: pasos accionables para tu primer RAG en producción
- Definí el caso de uso y tus KPIs: marcá objetivos como bajar los tiempos de primera respuesta, subir la precisión del equipo de soporte o reducir alucinaciones.
- Empezá simple, pero pensando en grande: arrancá con pgvector para validar rápido y proyectá la migración a Pinecone o Weaviate cuando crezca el volumen.
- Experimentá con chunking y embeddings: probá distintos tamaños de bloque y solapamiento con librerías como Ragas, LlamaIndex o LangChain midiendo context precision y context recall.
- Implementá evals desde el día uno: armá un set de prueba con preguntas reales y medí la fidelidad de las respuestas contra el contexto recuperado, sumando validaciones en tu pipeline como hacemos en CI/CD para agentes de IA.
# Pseudocódigo para evaluar 'Faithfulness' (ejemplo conceptual) def evaluate_faithfulness(question: str, context: str, answer: str, llm_evaluator) -> float: # Instruir a un LLM evaluador para que determine si cada afirmación en la 'answer' # está directamente soportada por el 'context'. prompt = f""" Evalúa la siguiente respuesta basada estrictamente en el contexto proporcionado. Para cada afirmación en la respuesta, indica si está soportada por el contexto (SÍ/NO). Si alguna afirmación no está soportada, la respuesta no es fiel. Pregunta: {question} Contexto: {context} Respuesta: {answer} Ejemplo de formato de salida: Soporte: SÍ/NO. Razonamiento: <explicación> """ evaluation_result = llm_evaluator.generate(prompt) # Procesar evaluation_result para obtener una puntuación (e.g., % de afirmaciones soportadas) # En la práctica, se usarían librerías como Ragas o LlamaIndex para esto. return parse_faithfulness_score(evaluation_result) - Considerá un re-ranker: sumá un paso de reordenamiento para filtrar fragmentos irrelevantes, achicar el prompt y gastar menos en tokens.
- Planificá tu pipeline de indexación: programá un ETL que actualice documentos modificados y borre los obsoletos de tu base vectorial.
- No olvides la observabilidad y la seguridad: agregá métricas de latencia por paso, protegé los accesos a nivel de chunk y cifrá la información confidencial.