Módulo 8: Proyecto Integrador RAG con ChromaDB

Cápsula 02: Arquitectura del Proyecto

Descripción de la cápsula

Definirás la arquitectura del sistema antes de codificar. Esta decisión evita retrabajo y facilita pruebas, monitoreo y mantenimiento. Un diseño claro permite escalar ingestion y serving de forma independiente, migrar componentes sin romper todo y mantener contratos estables entre partes.


Componentes principales

El sistema RAG se compone de cinco bloques lógicos:

ComponenteResponsabilidadEntradaSalida
ingestionCarga, limpieza, chunking y embeddingsDocumentos raw (PDF, TXT, MD)Vectores + metadata en ChromaDB
vector_storePersistencia y consulta de vectoresVectores, IDs, metadataSimilarity search results
retrievalTop-k + filtros de metadataQuery de usuarioDocumentos rankeados con scores
generationEnsamblado de contexto y respuesta del LLMQuery + documentos recuperadosRespuesta generada + citas
apiEndpoints para ingest, search, askHTTP requestsJSON responses

Responsabilidades detalladas por componente

Ingestion

  • Carga: Soporta TXT, MD, RST (y PDF con pypdf). Detecta encoding (UTF-8, Latin-1).
  • Limpieza: Normalización de espacios, manejo de caracteres especiales.
  • Chunking: RecursiveCharacterTextSplitter con separadores \n\n, \n, . , .
  • Metadata: Extrae y asigna doc_id, source, chunk_index, title a cada chunk.
  • Embeddings: Llama a OpenAI en batches; maneja rate limits y retries.
  • Persistencia: Add a ChromaDB en batches; no debe mezclar lógica de negocio de retrieval.

Vector Store (ChromaDB)

  • Almacenamiento: Persistencia en disco (PersistentClient).
  • Indexación: HNSW por defecto; no requiere configuración adicional para 1K-10K vectores.
  • Query: Similarity search con query_embeddings, n_results, where para filtros.
  • Sin lógica de negocio: Solo recibe vectores y devuelve resultados; no decide qué es "relevante".

Retrieval

  • Embedding de query: Misma función/modelo que ingestion para consistencia.
  • Búsqueda: Llama a ChromaDB con top_k y filtros opcionales.
  • Post-procesado: Aplica score threshold; descarta resultados por debajo del umbral.
  • Formato de salida: Estructura estandarizada para que generation no dependa de ChromaDB.

Generation

  • Context assembly: Concatena chunks con separadores y metadatos de fuente.
  • Prompt engineering: Instrucciones claras: "responde solo con el contexto", "no inventes".
  • LLM call: GPT-3.5-turbo con temperatura baja (0.2) para consistencia.
  • Post-proceso: Formatea fuentes para el contrato de respuesta; no modifica el contenido generado.

API

  • Routing: FastAPI con routers por dominio (ask, search, ingest, collections).
  • Validación: Pydantic para request bodies; límites de longitud.
  • Respuestas: Contrato JSON estable; trace_id en toda respuesta.
  • Documentación: Swagger y ReDoc automáticos.

Diagrama de arquitectura (ASCII)

┌─────────────────────────────────────────────────────────────────────────────────────────┐
│                              SISTEMA RAG - ARQUITECTURA                                  │
└─────────────────────────────────────────────────────────────────────────────────────────┘

  ┌──────────────┐     ┌─────────────────────────────────────────────┐     ┌──────────────┐
  │  Documentos  │     │              INGESTION PIPELINE             │     │   ChromaDB   │
  │  (PDF, TXT,  │────▶│  Load → Chunk (512) → Embed → Metadata      │────▶│   (vector    │
  │   MD, etc)   │     │  Batch: 1K-2K docs, progress, error handling│     │   store)     │
  └──────────────┘     └─────────────────────────────────────────────┘     └──────┬───────┘
                                                                                │
  ┌──────────────┐     ┌─────────────────────────────────────────────┐         │
  │   Usuario    │     │              RETRIEVAL PIPELINE               │         │
  │   (cliente)  │────▶│  Query → Embed → Similarity Search → Top-K   │◀────────┘
  │              │     │  Metadata filtering, score threshold         │
  └──────────────┘     └────────────────────┬────────────────────────┘
                                             │
                                             ▼
  ┌──────────────┐     ┌─────────────────────────────────────────────┐
  │   Respuesta  │     │             GENERATION PIPELINE              │
  │   + fuentes  │◀────│  Context assembly → Prompt → GPT-3.5-turbo   │
  │   + trace_id │     │  Citation generation, fallback si no hay ctx │
  └──────────────┘     └─────────────────────────────────────────────┘
         ▲
         │
  ┌──────┴───────────────────────────────────────────────────────────┐
  │                      FastAPI REST API                             │
  │  POST /ingest | GET /search | POST /ask | GET /collections | ...   │
  └──────────────────────────────────────────────────────────────────┘

Flujo de datos detallado

Flujo de Ingestion (offline o bajo demanda)

Documentos fuente
    → Document Loader (PDF, TXT, MD según tipo)
    → Text cleaning (normalización, encoding)
    → RecursiveCharacterTextSplitter (chunk_size=512, overlap=50)
    → Chunks con metadata (source, doc_id, chunk_index)
    → OpenAI text-embedding-3-small (batch 100-200)
    → ChromaDB add (batch 1000-2000)
    → Validación: conteo, metadata obligatoria, sin duplicados

Flujo de Query (online)

Pregunta del usuario
    → Validación de input (longitud, formato)
    → Embedding de la query (OpenAI text-embedding-3-small)
    → ChromaDB query (top_k=5, where filters opcionales)
    → Score threshold (descartar < 0.5 o similar)
    → Ensamblado de contexto (concatenar chunks)
    → Prompt engineering (instrucciones + contexto + pregunta)
    → GPT-3.5-turbo (temperature=0.2)
    → Post-proceso: extraer citas, formato de fuentes
    → Respuesta JSON: answer, sources, confidence, trace_id

Decisiones de diseño y justificación

¿Por qué ChromaDB?

  • Sin coste de infraestructura: Open-source, corre localmente.
  • Rápido para prototipado: Persistencia en disco con PersistentClient, no requiere servidor externo.
  • Suficiente para 1K-10K documentos: HNSW por defecto, metadata filtering.
  • Base de aprendizaje: Los conceptos (CRUD, similarity search, metadata) se transfieren a Pinecone, Weaviate, Qdrant.

Para producción a gran escala (millones de vectores, multi-tenant), migrarías a Pinecone o Weaviate; ChromaDB es óptimo para este proyecto.

Comparación rápida:

CriterioChromaDBPineconeWeaviate
Costo infraGratis (self-hosted)Por uso (serverless) o planSelf-hosted o cloud
Escala típica1K-100K vectoresMillonesMillones
Setuppip installCuenta, API keyDocker o cloud
Uso en este proyecto✅ Learning, portfolioGuía #8Alternativa

¿Por qué chunk_size=512 (tokens aproximados)?

  • Balance contexto vs granularidad: Chunks muy grandes diluyen la relevancia; muy pequeños pierden contexto.
  • Embedding model: text-embedding-3-small soporta hasta 8191 tokens; 512 deja margen y evita truncado.
  • Retrieval: 5 chunks × 512 ≈ 2500 tokens de contexto para el LLM, dentro del límite razonable de GPT-3.5 (4K context window para generación).
  • Estándar en RAG: Muchos tutoriales usan 500-1000; 512 es un punto medio probado.

¿Por qué chunk_overlap=50?

  • Continuidad semántica: Evita cortar frases o párrafos a la mitad; el overlap permite que una idea que cruza el boundary siga siendo recuperable.
  • 50 tokens: Suficiente para no perder contexto, sin duplicar demasiado contenido (menos vectores, menor costo de storage).

¿Por qué GPT-3.5-turbo?

  • Costo: Más económico que GPT-4 para un proyecto de aprendizaje y portfolio.
  • Latencia: Más rápido que GPT-4.
  • Suficiente para RAG: Con buen retrieval y prompt engineering, GPT-3.5-turbo produce respuestas correctas la mayoría del tiempo.
  • Upgrade path: Puedes cambiar a GPT-4 o GPT-4o por variable de entorno sin cambiar el código.

Cuándo usar GPT-4: Si el dominio es muy técnico o necesitas razonamiento más profundo, sube a GPT-4. Para la mayoría de RAG con buen retrieval, GPT-3.5-turbo es suficiente.


¿Por qué separar ingestion de serving?

  • Escalado independiente: Ingestion puede ser batch/job pesado; serving debe ser low-latency.
  • Recursos: Ingestion consume CPU/memoria para chunking y llamadas a embedding API; serving es más I/O-bound (ChromaDB query + LLM).
  • Deployment: Puedes correr ingestion como script o worker separado; la API solo sirve queries.

Contratos entre componentes

Ingestion → Vector Store

CampoTipoObligatorioDescripción
idslist[str]IDs únicos y determinísticos (ej: {doc_id}_{chunk_idx})
documentslist[str]Texto del chunk
embeddingslist[list[float]]Vectores de text-embedding-3-small
metadataslist[dict]Al menos source, doc_id, chunk_index

Retrieval → Generation

CampoTipoDescripción
documentslist[str]Chunks recuperados
metadataslist[dict]source, doc_id, score
idslist[str]IDs de ChromaDB

API → Cliente

{
  "answer": "string",
  "sources": [
    {"doc_id": "string", "source": "string", "title": "string", "score": 0.82}
  ],
  "confidence": 0.78,
  "trace_id": "uuid",
  "fallback_reason": null
}

Anti-patrones a evitar

Anti-patrónProblemaSolución
Mezclar retrieval y generation en una función monolíticaDifícil de testear, acopladoFunciones separadas: retrieve() y generate()
Configuración sensible hardcodeadaSeguridad, no portableVariables de entorno: OPENAI_API_KEY, CHROMA_PATH
Cambiar esquema de metadata sin versionadoRompe ingestion previa, inconsistenciaDocumentar schema, usar versionado si cambias
IDs aleatorios o no determinísticosDuplicados al re-ingestarf"{doc_id}_{chunk_index}"
Sin fallback cuando retrieval devuelve pocoRespuestas inventadasUmbral de score, mensaje explícito "no tengo información"

Puntos de fallo y mitigación

Punto de falloSíntomaMitigación
OpenAI API caídaTimeout o 5xx en embeddings/generationRetry con backoff; mensaje claro al usuario
ChromaDB disco llenoError al addMonitorear espacio; alertas
Rate limit OpenAI429 en embeddingsBatch size adecuado; cola con backoff
Documentos corruptosAlgunos chunks vacíos o inválidosValidar antes de embed; skip y log
Query muy largaToken limit exceededLimitar longitud en API; truncar si es necesario
Sin documentos en DBRetrieval vacío siempreHealth check que valide count > 0; mensaje en /ask

Alternativas de arquitectura

Monolítico: Un script que hace load → chunk → embed → add. Más simple, pero difícil de escalar.

Microservicios: Ingestion, API y ChromaDB como servicios separados. Overkill para 1K docs.

Librería + CLI + API: Código reutilizable; CLI para ingestion; API para serving. Recomendable si planeas publicar.

Para este módulo: Usa los 5 componentes como capas lógicas dentro del mismo repo. No necesitas microservicios físicos.


Consideraciones de testing por componente

ComponenteQué testearCómo
IngestionChunking correcto, metadata completaUnit test con doc de ejemplo; assert chunk count y keys
IngestionBatch add sin erroresIntegration test con ChromaDB en memoria
RetrievalQuery devuelve resultados ordenadosMock ChromaDB o fixture con datos conocidos
GenerationRespuesta con contexto, sin inventarMock retrieval; assert que respuesta contiene fragmentos del contexto
API/ask end-to-endTest client FastAPI; mock OpenAI si es necesario
APIFallback cuando retrieval vacíoLlamar /ask con DB vacía; assert fallback_reason

La separación de componentes permite testear cada uno de forma aislada con mocks, y luego un test de integración end-to-end con servicios reales (o emulados).


Configuración por entorno

VariableDesarrolloProducción
CHROMA_PATH./chroma_data (local)/var/data/chroma o volumen Docker
OPENAI_API_KEY.env localSecrets manager, env inyectado
TOP_K5-10 (explorar más)5 (balance recall/latencia)
SCORE_THRESHOLD0.3 (más permisivo)0.5 (evitar ruido)
LLM_MODELgpt-3.5-turbogpt-3.5-turbo o gpt-4 según budget
Log levelDEBUGINFO o WARN

Centraliza todo en un módulo config.py que lee de os.getenv. Nunca hardcodees valores que cambien entre entornos.


Plantilla de Decision Log

Para documentar decisiones de diseño, usa una estructura como esta en DESIGN.md:

## Decision Log

### DL-001: ChromaDB como vector store
**Fecha:** 2025-03
**Contexto:** Necesitamos vector store para RAG, presupuesto limitado.
**Decisión:** Usar ChromaDB self-hosted.
**Alternativas:** Pinecone (coste), Weaviate (más setup).
**Consecuencias:** Gratis, fácil; escalar a 100K+ requerirá evaluación.

### DL-002: chunk_size=512
**Contexto:** Balance entre granularidad y contexto para LLM.
**Decisión:** 512 caracteres (~128 tokens).
**Consecuencias:** Ajustar si retrieval es demasiado fragmentado o demasiado largo.

Mantener un log así facilita explicar "por qué" a otros desarrolladores o a tu yo futuro.


Variables de entorno del proyecto

Lista completa de variables que usarás (documenta en README):

VariableDescripciónDefault
OPENAI_API_KEYAPI key de OpenAI(requerido)
CHROMA_PATHRuta de persistencia ChromaDB./chroma_data
COLLECTION_NAMENombre de la colecciónrag_docs
CHUNK_SIZETamaño de chunk en caracteres512
CHUNK_OVERLAPSolapamiento entre chunks50
BATCH_SIZE_CHROMADBDocs por batch en add1000
BATCH_SIZE_EMBEDDINGSTextos por llamada a OpenAI100
EMBEDDING_MODELModelo de embeddingstext-embedding-3-small
LLM_MODELModelo para generacióngpt-3.5-turbo
TOP_KResultados en retrieval5
SCORE_THRESHOLDUmbral mínimo de score0.5

Ejercicios de diseño

Ejercicio 1: Dibuja tu arquitectura

Con lápiz y papel o herramienta de diagramas, dibuja:

  • Los 5 componentes (ingestion, vector_store, retrieval, generation, api).
  • Flujos de datos con flechas (documentos → ingestion → ChromaDB; query → retrieval → generation → response).
  • Puntos de fallo (¿qué pasa si OpenAI falla? ¿si ChromaDB está caído?).
  • Estrategia de observabilidad por componente (logs, métricas, trace_id).

Solución: Usa el diagrama ASCII de esta cápsula como base. Añade:

  • Punto de fallo: Si OpenAI embedding falla → retry con backoff, luego fallar con mensaje claro.
  • ChromaDB caído → health check devuelve 503.
  • Por componente: ingestion loguea docs procesados/errores; retrieval loguea query + top_k; generation loguea tokens usados; api loguea trace_id en cada request.

Ejercicio 2: Define el esquema de metadata

Lista los campos de metadata que guardarás por chunk. Justifica cada uno.

Solución sugerida:

  • source: ruta o identificador del documento original (para citas).
  • doc_id: ID único del documento (para filtrar por doc).
  • chunk_index: índice del chunk dentro del doc (para ordenar).
  • title: título del documento si está disponible (para mostrar en fuentes).

Opcional: created_at, category según dominio.


Ejercicio 3: Diseña la estrategia de ids

¿Cómo generar IDs para que la ingestion sea idempotente (re-ejecutar no duplique)?

Solución: doc_id = hash del path o identificador estable del documento. chunk_id = f"{doc_id}_chunk_{chunk_index}". Al re-ingestar, usas upsert (si ChromaDB lo soporta) o borras la colección antes. Alternativamente: verificar existencia antes de add.


Ejercicio 4: Fallback cuando retrieval es débil

Define reglas: ¿Qué hacer si el score máximo es < 0.5? ¿Si solo hay 1 documento? ¿Si hay 0?

Solución:

  • Score máximo < 0.5: no usar contexto, responder "No encontré información relevante para tu pregunta".
  • 1 documento: usar si score > 0.4; indicar baja confidence.
  • 0 documentos: siempre fallback, "No tengo datos para responder".

Ejercicio 5: Prioriza observabilidad

De logs, métricas y traces, ¿qué implementarías primero y por qué?

Solución:

  1. Logs estructurados (JSON): request_id, componente, mensaje. Mínimo esfuerzo, máximo valor para debugging.
  2. trace_id en respuesta: permite correlacionar log del backend con lo que vio el usuario.
  3. Métricas de latencia: p50, p95 por endpoint; cuello de botella típico en /ask.
  4. Traces distribuidos: opcional para v2; con un solo servicio puede bastar trace_id en logs.

Ejercicio 6: Escalado de ingestion

Si tuvieras 100,000 documentos, ¿cómo adaptarías el pipeline?

Solución:

  • Batch más grande (2000-5000) para reducir overhead de ChromaDB.
  • Paralelización: múltiples workers para embedding (respetando rate limits de OpenAI).
  • Queue (Redis, SQS) para ingestion asíncrona.
  • Checkpointing: guardar progreso para reanudar si falla.
  • Considerar ChromaDB en modo servidor o migrar a Pinecone para esa escala.

Ejercicio 7: Diagrama de secuencia para /ask

Dibuja un diagrama de secuencia (Usuario → API → Retrieval → ChromaDB; API → Generation → OpenAI) mostrando el orden de llamadas para un request a /ask. Incluye qué pasa cuando retrieval devuelve 0 resultados.

Solución (texto):

  1. Usuario envía POST /ask con {"question": "..."}.
  2. API valida input, genera trace_id.
  3. API llama a Retrieval con query.
  4. Retrieval hace embed de query (OpenAI).
  5. Retrieval llama a ChromaDB.query con embedding.
  6. ChromaDB devuelve resultados (o vacío).
  7. Si vacío: Retrieval devuelve []; API devuelve fallback sin llamar a Generation.
  8. Si hay resultados: Retrieval devuelve docs; API llama a Generation con docs.
  9. Generation construye prompt, llama a OpenAI, recibe respuesta.
  10. API formatea respuesta con sources, trace_id, la devuelve al usuario.

Ejercicio 8: Interfaz abstracta para Vector Store

Define una interfaz (ABC o Protocol en Python) que encapsule las operaciones que necesitas del vector store: add(ids, documents, embeddings, metadatas) y query(embedding, top_k, where). ¿Qué cambiaría si mañana usas Pinecone?

Solución:

from abc import ABC, abstractmethod
from typing import List, Optional

class VectorStoreProtocol(ABC):
    @abstractmethod
    def add(self, ids: List[str], documents: List[str], embeddings: List[List[float]], metadatas: List[dict]) -> None: ...

    @abstractmethod
    def query(self, embedding: List[float], top_k: int = 5, where: Optional[dict] = None) -> dict: ...

Implementas ChromaDBStore(VectorStoreProtocol) para ChromaDB. Para Pinecone, implementas PineconeStore(VectorStoreProtocol). El retrieval solo depende de la interfaz, no del backend concreto.


Troubleshooting de arquitectura

"Todo funciona, pero no sabemos dónde falla"

Causa: Falta separación de responsabilidades e instrumentación por etapa.

Solución: Separa claramente ingestion, retrieval y generation. Añade logs en cada frontera (ej: "retrieve returned 5 docs", "generate called with 3 chunks"). Usa trace_id para seguir un request por todo el pipeline.


"Cada cambio rompe otra parte"

Causa: Contratos implícitos, acoplamiento alto.

Solución: Define contratos explícitos (esquemas, tipos). Escribe pruebas de integración por flujo (ingestion → retrieval → generate). Si cambias un contrato, actualiza tests y documentación.


"No sabemos cómo escalar ingestion"

Causa: Ingestion acoplada al serving, sin batches ni paralelismo.

Solución: Desacopla: ingestion como script/job independiente. Usa batches de 1K-2K, mide throughput. Considera workers paralelos para embeddings (con rate limiting).


"ChromaDB se llena o va lento"

Causa: Demasiados vectores, metadata pesada, o sin índices adecuados.

Solución: Revisa chunk_size (más grande = menos vectores). Reduce metadata a lo esencial. ChromaDB usa HNSW por defecto; para millones de vectores, evalúa migrar a Pinecone/Weaviate.


"Respuestas inventadas cuando no hay contexto"

Causa: Generation no valida calidad del retrieval, sigue generando sin evidencia.

Solución: Umbral de score en retrieval (ej: descartar < 0.5). En generation, si no hay chunks válidos, devolver mensaje explícito y no llamar al LLM para inventar.


Resumen

  • La arquitectura tiene 5 componentes: ingestion, vector_store, retrieval, generation, api.
  • El flujo de datos es lineal: documentos → ingestion → ChromaDB → retrieval → generation → API response.
  • Decisiones clave: ChromaDB (gratis, aprendizaje), chunk_size=512 (balance), chunk_overlap=50 (continuidad), GPT-3.5-turbo (costo/latencia).
  • Contratos explícitos entre componentes facilitan pruebas y evolución.
  • Evita anti-patrones: funciones monolíticas, config hardcodeada, IDs no determinísticos, respuestas sin fallback.
  • Documenta el diseño para facilitar migración a Guía #8 y onboarding de otros desarrolladores.
  • Considera testing por componente: cada capa se testea aislada antes del test end-to-end.
  • Usa configuración por entorno (dev vs prod) y un Decision Log para trazabilidad.

Checklist antes de implementar

Antes de pasar a la cápsula 03 (ingestion), verifica:

  • Tienes el diagrama de arquitectura (papel o digital).
  • Definiste el esquema de metadata (doc_id, source, chunk_index, title).
  • Sabes qué hacer cuando retrieval devuelve 0 o scores bajos.
  • Tienes config externalizada (variables de entorno).
  • Documentaste al menos 2-3 decisiones en un DESIGN.md o README.

Si cumples los 5 puntos, estás listo para implementar el pipeline de ingestion.


Diagrama de dependencias entre módulos Python

api/
  main.py          → routes (ask, search, ingest, collections)
  routes/
    ask.py         → services.retrieval, services.generation
    search.py      → services.retrieval
    ingest.py      → ingestion.pipeline
  services/
    retrieval.py   → chromadb, openai
    generation.py  → openai

ingestion/
  pipeline.py      → loaders, chunking, embeddings, chromadb
  loaders.py       → (solo pathlib, pypdf si PDF)
  chunking.py      → langchain_text_splitters
  embeddings.py    → openai

Regla: Los servicios (retrieval, generation) no importan de ingestion. La API orquesta ambos. Así ingestion y serving pueden evolucionar de forma independiente.


Ejemplo de flujo de un request /ask (pseudocódigo)

request = {"question": "¿Qué es X?"}
trace_id = uuid4()

# 1. Validar
validate_length(question, max=2000)

# 2. Retrieval
query_embedding = openai.embeddings.create(input=[question])
results = chroma.query(query_embedding[0], n_results=5)

# 3. Filtrar por score
filtered = [r for r in results if r.score >= 0.5]
if not filtered:
    return fallback_response(trace_id)

# 4. Generation
context = build_context(filtered.documents, filtered.metadatas)
prompt = f"Contexto:\n{context}\n\nPregunta: {question}"
answer = openai.chat.completions.create(messages=[...], prompt=prompt)

# 5. Respuesta
return {
  "answer": answer,
  "sources": format_sources(filtered),
  "confidence": avg(filtered.scores),
  "trace_id": trace_id,
}

Este pseudocódigo resume lo que implementarás en la cápsula 04. La separación en pasos claros facilita debugging y tests.


Próximos pasos

En la cápsula 03 implementarás el pipeline de ingestion: loaders para TXT/MD/RST, chunking con RecursiveCharacterTextSplitter, embeddings con OpenAI y almacenamiento en ChromaDB. Usa la arquitectura que definiste aquí como guía; si algún componente no encaja, ajusta el diseño antes de codear en exceso. La coherencia entre diseño e implementación evita refactors costosos.


Recursos adicionales


Tiempo estimado: 25-30 minutos
Siguiente: 03-pipeline-ingestion-1000-docs.md