Módulo 8: Document Analyzer Multimodal
5. RAG y Q&A
Descripción
El RAGModule es el componente que le da memoria al Document Analyzer. Mientras los otros módulos procesan un documento una vez y generan resultados inmediatos, RAG indexa el contenido para que puedas hacer preguntas después — incluso sobre múltiples documentos. Un usuario sube una factura hoy, un contrato mañana, y puede preguntar "¿cuánto pagamos a Tech Solutions en total?" y obtener respuesta con fuentes.
Por qué importa: Sin RAG, cada pregunta requiere reprocesar el documento completo. Con RAG, el documento se procesa una vez, se indexa en una base de datos vectorial, y las preguntas se responden en milisegundos consultando los chunks más relevantes. Esto es especialmente valioso cuando tienes decenas o cientos de documentos indexados.
Conexión con el módulo: En el Módulo 6 construiste un sistema RAG básico con ChromaDB y embeddings. Aquí lo integras en el Document Analyzer con mejoras: indexación de texto Y descripciones de imágenes (RAG multimodal), metadata por página y documento, retrieval híbrido con filtros, y generación de respuestas con fuentes citadas.
Arquitectura del RAGModule
Responsabilidades
RAGModule
├── index() → Indexar documento procesado en ChromaDB
├── query() → Buscar chunks relevantes y generar respuesta
├── delete() → Eliminar documento del índice
├── list_documents() → Listar documentos indexados
└── get_stats() → Estadísticas del índice
Flujo de indexación
ProcessedDocument
│
├── Páginas de texto → TextChunker → Chunks con metadata
│ │
│ ▼
│ ChromaDB.add(documents, metadatas, ids)
│
└── Páginas de imagen → VisionAnalyzer.describe_for_rag()
│
▼
Descripciones textuales
│
▼
ChromaDB.add(descriptions, metadatas, ids)
Flujo de Q&A
Pregunta del usuario
│
▼
ChromaDB.query(question, n_results=5)
│
▼
Chunks relevantes + metadata (página, tipo, documento)
│
▼
LLM: "Dado este contexto, responde la pregunta"
│
▼
Respuesta + fuentes citadas
Modelo de Datos para RAG
Chunk indexado
from pydantic import BaseModel
from typing import Optional
class IndexedChunk(BaseModel):
chunk_id: str
doc_id: str
text: str
page_number: int
content_type: str # "text" | "image_description"
metadata: dict = {}
class QAResult(BaseModel):
question: str
answer: str
sources: list[dict] = []
chunks_used: int = 0
confidence: Optional[float] = None
Estructura de metadata en ChromaDB
Cada chunk se almacena con metadata que permite filtrar por documento, página y tipo:
{
"doc_id": "a1b2c3d4",
"page_number": 3,
"content_type": "text", # "text" | "image_description"
"filename": "factura_marzo.pdf",
"document_type": "factura", # clasificación del VisionAnalyzer
"chunk_index": 2,
"char_count": 1423
}
Implementación: RAGModule
Clase completa
import logging
import os
from typing import Optional
import chromadb
from chromadb.utils import embedding_functions
from openai import OpenAI
logger = logging.getLogger(__name__)
DEFAULT_COLLECTION = "document_analyzer"
DEFAULT_N_RESULTS = 5
CHUNK_SIZE = 1500
CHUNK_OVERLAP = 200
MIN_CHUNK_SIZE = 100
class RAGModule:
def __init__(
self,
collection_name: str = DEFAULT_COLLECTION,
persist_directory: Optional[str] = None
):
self.openai_client = OpenAI()
self.embedding_fn = embedding_functions.OpenAIEmbeddingFunction(
api_key=os.getenv("OPENAI_API_KEY"),
model_name="text-embedding-3-small"
)
if persist_directory:
self.chroma_client = chromadb.PersistentClient(path=persist_directory)
else:
self.chroma_client = chromadb.Client()
self.collection = self.chroma_client.get_or_create_collection(
name=collection_name,
embedding_function=self.embedding_fn,
metadata={"hnsw:space": "cosine"}
)
logger.info(
f"RAGModule inicializado. Colección: {collection_name}, "
f"documentos existentes: {self.collection.count()}"
)
def index(
self,
doc_id: str,
content,
filename: str = "",
document_type: str = "otro",
image_descriptions: Optional[list[str]] = None
) -> dict:
chunks_indexed = 0
if content.has_text_pages and content.full_text:
text_chunks = self._chunk_text(content.full_text, doc_id)
if text_chunks:
self.collection.add(
documents=[c["text"] for c in text_chunks],
ids=[c["id"] for c in text_chunks],
metadatas=[{
"doc_id": doc_id,
"page_number": 0,
"content_type": "text",
"filename": filename,
"document_type": document_type,
"chunk_index": c["index"],
"char_count": len(c["text"])
} for c in text_chunks]
)
chunks_indexed += len(text_chunks)
logger.info(f"Indexados {len(text_chunks)} chunks de texto para {doc_id}")
if image_descriptions:
desc_ids = [f"{doc_id}_imgdesc_{i}" for i in range(len(image_descriptions))]
self.collection.add(
documents=image_descriptions,
ids=desc_ids,
metadatas=[{
"doc_id": doc_id,
"page_number": i + 1,
"content_type": "image_description",
"filename": filename,
"document_type": document_type,
"chunk_index": i,
"char_count": len(desc)
} for i, desc in enumerate(image_descriptions)]
)
chunks_indexed += len(image_descriptions)
logger.info(f"Indexadas {len(image_descriptions)} descripciones de imagen para {doc_id}")
return {
"doc_id": doc_id,
"chunks_indexed": chunks_indexed,
"total_in_collection": self.collection.count()
}
def query(
self,
question: str,
doc_id: Optional[str] = None,
n_results: int = DEFAULT_N_RESULTS,
content_type: Optional[str] = None
) -> QAResult:
where_filter = self._build_filter(doc_id, content_type)
query_kwargs = {
"query_texts": [question],
"n_results": min(n_results, self.collection.count() or 1),
}
if where_filter:
query_kwargs["where"] = where_filter
try:
results = self.collection.query(**query_kwargs)
except Exception as e:
logger.error(f"Error en query ChromaDB: {e}")
return QAResult(
question=question,
answer="Error al buscar en el índice.",
sources=[], chunks_used=0
)
if not results["documents"] or not results["documents"][0]:
return QAResult(
question=question,
answer="No se encontraron documentos relevantes para responder la pregunta.",
sources=[], chunks_used=0
)
documents = results["documents"][0]
metadatas = results["metadatas"][0] if results.get("metadatas") else [{}] * len(documents)
distances = results["distances"][0] if results.get("distances") else [0] * len(documents)
context = self._build_context(documents, metadatas)
answer = self._generate_answer(question, context)
sources = []
for i, (doc_text, meta, dist) in enumerate(zip(documents, metadatas, distances)):
sources.append({
"text": doc_text[:200] + "..." if len(doc_text) > 200 else doc_text,
"page": meta.get("page_number", "?"),
"type": meta.get("content_type", "?"),
"filename": meta.get("filename", "?"),
"relevance": round(1 - dist, 3) if dist else None
})
avg_relevance = sum(s["relevance"] for s in sources if s["relevance"]) / len(sources) if sources else 0
return QAResult(
question=question,
answer=answer,
sources=sources,
chunks_used=len(documents),
confidence=round(avg_relevance, 2)
)
def delete(self, doc_id: str) -> dict:
all_items = self.collection.get(where={"doc_id": doc_id})
if all_items["ids"]:
self.collection.delete(ids=all_items["ids"])
logger.info(f"Eliminados {len(all_items['ids'])} chunks de {doc_id}")
return {"deleted": len(all_items["ids"]), "doc_id": doc_id}
return {"deleted": 0, "doc_id": doc_id}
def list_documents(self) -> list[dict]:
all_items = self.collection.get()
doc_ids = set()
docs = {}
for meta in all_items.get("metadatas", []):
did = meta.get("doc_id", "unknown")
if did not in docs:
docs[did] = {
"doc_id": did,
"filename": meta.get("filename", "?"),
"document_type": meta.get("document_type", "?"),
"chunks": 0
}
docs[did]["chunks"] += 1
return list(docs.values())
def get_stats(self) -> dict:
total = self.collection.count()
docs = self.list_documents()
return {
"total_chunks": total,
"total_documents": len(docs),
"documents": docs
}
def _chunk_text(self, text: str, doc_id: str) -> list[dict]:
chunks = []
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
current = ""
idx = 0
for para in paragraphs:
if len(current) + len(para) + 2 <= CHUNK_SIZE:
current += ("\n\n" + para if current else para)
else:
if len(current) >= MIN_CHUNK_SIZE:
chunks.append({
"id": f"{doc_id}_chunk_{idx}",
"text": current,
"index": idx
})
idx += 1
overlap = current[-CHUNK_OVERLAP:] if current else ""
current = overlap + "\n\n" + para if overlap else para
if len(current) >= MIN_CHUNK_SIZE:
chunks.append({
"id": f"{doc_id}_chunk_{idx}",
"text": current,
"index": idx
})
return chunks
def _build_filter(
self, doc_id: Optional[str], content_type: Optional[str]
) -> Optional[dict]:
conditions = []
if doc_id:
conditions.append({"doc_id": {"$eq": doc_id}})
if content_type:
conditions.append({"content_type": {"$eq": content_type}})
if len(conditions) == 0:
return None
if len(conditions) == 1:
return conditions[0]
return {"$and": conditions}
def _build_context(self, documents: list[str], metadatas: list[dict]) -> str:
parts = []
for i, (doc, meta) in enumerate(zip(documents, metadatas)):
source_info = f"[Fuente {i+1}: {meta.get('filename', '?')}, página {meta.get('page_number', '?')}]"
parts.append(f"{source_info}\n{doc}")
return "\n\n---\n\n".join(parts)
def _generate_answer(self, question: str, context: str) -> str:
r = self.openai_client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": (
"Eres un asistente que responde preguntas sobre documentos. "
"Responde SOLO con base en el contexto proporcionado. "
"Si la información no está en el contexto, di que no tienes información suficiente. "
"Cita las fuentes cuando sea relevante (e.g., '[Fuente 1]')."
)
},
{
"role": "user",
"content": f"Contexto:\n{context}\n\nPregunta: {question}"
}
],
max_tokens=500,
temperature=0
)
return r.choices[0].message.content
Indexación Multimodal
El concepto
RAG multimodal indexa tanto texto directo como descripciones generadas de imágenes. Cuando un usuario pregunta sobre una tabla que estaba en una página escaneada, el sistema encuentra la descripción de esa imagen y la usa como contexto.
Documento de 10 páginas
├── Páginas 1-7 (texto) → 12 chunks de texto indexados
└── Páginas 8-10 (escaneadas) → 3 descripciones de imagen indexadas
Total en ChromaDB: 15 chunks
Integración con VisionAnalyzer
def index_full_document(
rag: RAGModule,
analyzer: VisionAnalyzer,
doc_id: str,
content,
filename: str,
document_type: str
) -> dict:
image_descriptions = None
if content.has_image_pages:
images = content.get_images_for_vision()
image_descriptions = analyzer.describe_for_rag(images)
return rag.index(
doc_id=doc_id,
content=content,
filename=filename,
document_type=document_type,
image_descriptions=image_descriptions
)
Retrieval con Filtros
Casos de uso de filtros
| Escenario | Filtro |
|---|---|
| Pregunta sobre un documento específico | doc_id="factura_001" |
| Pregunta sobre todas las facturas | document_type="factura" |
| Solo buscar en texto (no imágenes) | content_type="text" |
| Solo buscar en descripciones de imágenes | content_type="image_description" |
| Sin filtro (buscar en todo) | None |
Ejemplo de queries filtradas
rag = RAGModule(persist_directory="./chroma_data")
result = rag.query(
question="¿Cuál es el total de la factura de marzo?",
doc_id="factura_marzo_001"
)
print(f"Respuesta: {result.answer}")
print(f"Fuentes: {len(result.sources)}")
result_all = rag.query(
question="¿Cuánto hemos pagado en total este trimestre?",
doc_id=None # busca en todos los documentos
)
print(f"Respuesta: {result_all.answer}")
result_images = rag.query(
question="¿Qué muestran los gráficos del informe?",
content_type="image_description"
)
print(f"Respuesta: {result_images.answer}")
Persistencia del Índice
Por qué persistir
Sin persistencia, el índice se pierde al reiniciar el servicio. Cada documento se re-indexaría en cada request.
# Sin persistencia (solo para desarrollo)
rag = RAGModule()
# Con persistencia (producción)
rag = RAGModule(persist_directory="./chroma_data")
Gestión del índice persistente
stats = rag.get_stats()
print(f"Total chunks: {stats['total_chunks']}")
print(f"Total documentos: {stats['total_documents']}")
for doc in stats["documents"]:
print(f" {doc['doc_id']}: {doc['filename']} ({doc['chunks']} chunks)")
rag.delete("documento_viejo_001")
Troubleshooting
"Las respuestas no son relevantes"
Causa probable: Chunks demasiado grandes diluyen la relevancia. O embeddings de baja calidad.
Solución: Reducir CHUNK_SIZE:
CHUNK_SIZE = 800 # más pequeño = más preciso
CHUNK_OVERLAP = 100 # ajustar overlap
O aumentar n_results para dar más contexto al LLM:
result = rag.query(question="...", n_results=10)
"ChromaDB lanza error de dimensión"
Causa probable: Mezclaste embeddings de diferentes modelos en la misma colección.
Solución: Crear una nueva colección o borrar la existente:
self.chroma_client.delete_collection("document_analyzer")
self.collection = self.chroma_client.create_collection(
name="document_analyzer",
embedding_function=self.embedding_fn
)
"La indexación es lenta"
Causa probable: Muchos chunks pequeños generan muchas llamadas a embeddings.
Solución: ChromaDB envía embeddings en batch automáticamente, pero puedes indexar chunks en lotes:
BATCH_SIZE = 100
for i in range(0, len(all_chunks), BATCH_SIZE):
batch = all_chunks[i:i + BATCH_SIZE]
self.collection.add(
documents=[c["text"] for c in batch],
ids=[c["id"] for c in batch],
metadatas=[c["meta"] for c in batch]
)
"No encuentra documentos recién indexados"
Causa probable: Usando ChromaDB in-memory y se reinició el proceso.
Solución: Usar PersistentClient:
self.chroma_client = chromadb.PersistentClient(path="./chroma_data")
Uso del RAGModule
Ejemplo completo end-to-end
processor = DocumentProcessor()
analyzer = VisionAnalyzer()
rag = RAGModule(persist_directory="./chroma_data")
doc = processor.process("contrato_servicios.pdf")
doc_type = analyzer.classify(doc)
image_descriptions = None
if doc.has_image_pages:
image_descriptions = analyzer.describe_for_rag(doc.get_images_for_vision())
index_result = rag.index(
doc_id="contrato_001",
content=doc,
filename="contrato_servicios.pdf",
document_type=doc_type,
image_descriptions=image_descriptions
)
print(f"Indexados: {index_result['chunks_indexed']} chunks")
qa = rag.query(
question="¿Cuáles son las partes del contrato?",
doc_id="contrato_001"
)
print(f"\nPregunta: {qa.question}")
print(f"Respuesta: {qa.answer}")
print(f"Confianza: {qa.confidence}")
print(f"Fuentes usadas: {qa.chunks_used}")
for source in qa.sources:
print(f" - [{source['type']}] Página {source['page']}: {source['text'][:80]}...")
Output esperado
Indexados: 8 chunks
Pregunta: ¿Cuáles son las partes del contrato?
Respuesta: Las partes del contrato son: (1) Tech Solutions S.A. como prestador
de servicios, y (2) Empresa ABC S.A. de C.V. como contratante [Fuente 1].
Confianza: 0.87
Fuentes usadas: 5
- [text] Página ?: CONTRATO DE PRESTACIÓN DE SERVICIOS que celebran...
- [text] Página ?: Las partes acuerdan los siguientes términos...
Ejercicios
Ejercicio 1: Respuesta con fuentes formateadas
Modifica el query() para que la respuesta incluya las fuentes citadas en formato legible. El LLM debe citar "[Fuente N]" en su respuesta, y el resultado debe incluir un mapeo de fuentes con su texto y metadata.
Ver solución
def query_with_formatted_sources(
self,
question: str,
doc_id: Optional[str] = None,
n_results: int = 5
) -> dict:
result = self.query(question, doc_id, n_results)
source_map = {}
for i, source in enumerate(result.sources):
key = f"Fuente {i + 1}"
source_map[key] = {
"texto": source["text"],
"archivo": source["filename"],
"pagina": source["page"],
"tipo": source["type"],
"relevancia": source.get("relevance")
}
return {
"pregunta": result.question,
"respuesta": result.answer,
"confianza": result.confidence,
"fuentes_citadas": source_map,
"total_fuentes": len(source_map)
}
rag = RAGModule()
formatted = rag.query_with_formatted_sources(
"¿Cuál es la fecha de firma?", doc_id="contrato_001"
)
print(f"Respuesta: {formatted['respuesta']}")
print(f"\nFuentes citadas:")
for key, src in formatted["fuentes_citadas"].items():
print(f" [{key}] {src['archivo']}, pág {src['pagina']}: {src['texto'][:60]}...")
Ejercicio 2: Búsqueda cross-document
Implementa una función que busque en todos los documentos indexados y agrupe las fuentes por documento. Útil para preguntas como "¿cuánto pagamos en total este trimestre?" que requieren información de múltiples facturas.
Ver solución
def cross_document_query(
self,
question: str,
n_results: int = 10
) -> dict:
result = self.query(question, doc_id=None, n_results=n_results)
by_document: dict[str, list] = {}
for source in result.sources:
doc_key = source.get("filename", "unknown")
if doc_key not in by_document:
by_document[doc_key] = []
by_document[doc_key].append(source)
return {
"pregunta": result.question,
"respuesta": result.answer,
"confianza": result.confidence,
"documentos_consultados": len(by_document),
"fuentes_por_documento": {
doc: {
"cantidad_fuentes": len(sources),
"fuentes": [s["text"][:100] for s in sources]
}
for doc, sources in by_document.items()
}
}
rag = RAGModule(persist_directory="./chroma_data")
cross = rag.cross_document_query("¿Cuánto pagamos en total este trimestre?")
print(f"Respuesta: {cross['respuesta']}")
print(f"Documentos consultados: {cross['documentos_consultados']}")
for doc_name, info in cross["fuentes_por_documento"].items():
print(f" {doc_name}: {info['cantidad_fuentes']} fuentes")
Resumen
- El RAGModule indexa documentos para Q&A: texto como chunks, imágenes como descripciones textuales.
- Usa ChromaDB con embeddings de OpenAI (
text-embedding-3-small). - Retrieval con filtros: por documento, por tipo de contenido, o sin filtro (cross-document).
- Generación de respuesta: LLM con contexto recuperado, instrucciones de citar fuentes.
- Persistencia:
PersistentClientpara no perder el índice entre reinicios. - Gestión: listar documentos, eliminar, estadísticas del índice.
- La indexación multimodal permite Q&A sobre contenido visual a través de descripciones.
Recursos Adicionales
- ChromaDB Documentation — Referencia completa
- OpenAI Embeddings — Modelos de embeddings
- RAG Best Practices — Patrones RAG con LangChain
- Módulo 6 de esta guía — Base de RAG multimodal