Agentes de preguntas y respuestas sobre varios documentos
Indexe un corpus documental y responda preguntas sobre el conjunto de documentos.
Agentes de preguntas y respuestas sobre varios documentos es una lección gratuita de AI Agents en CoddyKit. Esta es la lección 3 de 4. Puedes leer la lección completa abajo gratuitamente — luego la practicas en el navegador con un editor de código integrado y un tutor de IA 24/7. Forma parte de la ruta de aprendizaje de AI Agents, y tu progreso se sincroniza en la web y la app de CoddyKit. El curso de AI Agents incluye 4 lecciones en total.
Descripción general de las preguntas y respuestas sobre varios documentos
Un agente de preguntas y respuestas sobre varios documentos responde preguntas recuperando contenido relevante de una colección de N documentos, sintetizando una respuesta y atribuyendo cada afirmación a su fuente.
A diferencia de las preguntas y respuestas sobre un único documento, los agentes que trabajan con varios documentos deben gestionar información contradictoria entre las fuentes y razonar sobre qué documentos son más relevantes para la pregunta.
Indexación de varios documentos
Antes de responder a cualquier pregunta, todos los documentos deben estar indexados: analizados, divididos en fragmentos, convertidos en embeddings y almacenados en una base de datos vectorial. Cada fragmento se almacena con metadatos que lo vinculan con su documento de origen.
import chromadb
from chromadb.utils import embedding_functions
import os
client = chromadb.PersistentClient(path='./doc_index')
ef = embedding_functions.OpenAIEmbeddingFunction(
api_key=os.getenv('OPENAI_API_KEY'),
model_name='text-embedding-3-small'
)
collection = client.get_or_create_collection('documents', embedding_function=ef)
def index_document(doc_id, doc_path, doc_title):
# Parse and chunk
chunks = pdf_to_chunks(doc_path, chunk_size=800, overlap=150)
for i, chunk in enumerate(chunks):
chunk_id = f'{doc_id}_chunk_{i}'
collection.add(
ids=[chunk_id],
documents=[chunk['text']],
metadatas=[{
'doc_id': doc_id,
'title': doc_title,
'page': chunk['page'],
'source_file': doc_path
}]
)
print(f'Indexed {len(chunks)} chunks from: {doc_title}')Recuperación de fragmentos relevantes
Cuando reciba una pregunta, consulte el almacén vectorial para obtener los fragmentos semánticamente más similares de todos los documentos indexados. El parámetro n_results controla cuántos fragmentos se recuperan.
def retrieve_relevant_chunks(question, n_results=8):
results = collection.query(
query_texts=[question],
n_results=n_results,
include=['documents', 'metadatas', 'distances']
)
chunks = []
for i in range(len(results['documents'][0])):
chunks.append({
'text': results['documents'][0][i],
'metadata': results['metadatas'][0][i],
'distance': results['distances'][0][i],
'relevance': 1 - results['distances'][0][i] # cosine similarity proxy
})
# Sort by relevance
chunks.sort(key=lambda x: x['relevance'], reverse=True)
return chunksAtribución de fuentes en el prompt
Al proporcionar los fragmentos recuperados al LLM, etiquete cada fragmento con la fuente documental correspondiente. De este modo, el LLM puede citar las fuentes mediante referencias numeradas en la respuesta.
def format_chunks_for_prompt(chunks, max_chars=4000):
sections = []
used_chars = 0
for i, chunk in enumerate(chunks, 1):
meta = chunk['metadata']
header = f"[Source {i}: {meta['title']}, page {meta.get('page', '?')}]"
content = chunk['text'][:600]
entry = f'{header}\n{content}'
if used_chars + len(entry) > max_chars:
break
sections.append(entry)
used_chars += len(entry)
return '\n\n'.join(sections)
QA_PROMPT = '''Answer the question based on the provided document excerpts.
Cite sources as [Source N]. If sources conflict, mention both views.
{context}
Question: {question}
Answer:'''
def answer_question(question):
chunks = retrieve_relevant_chunks(question, n_results=6)
context = format_chunks_for_prompt(chunks)
return llm_call(QA_PROMPT.format(context=context, question=question))Razonamiento entre documentos
Algunas preguntas requieren sintetizar información de varios documentos, no solo encontrar un único fragmento coincidente. Por ejemplo: "¿Cuál de los tres contratos tiene la cláusula de penalización más baja?"
Use un enfoque de dos pasos: recupere fragmentos relevantes de cada documento y, después, pida al LLM que los compare y sintetice la información.
def cross_document_compare(question, doc_ids):
# Retrieve best chunks per document
per_doc_chunks = {}
for doc_id in doc_ids:
results = collection.query(
query_texts=[question],
n_results=3,
where={'doc_id': {'$eq': doc_id}} # filter by document
)
if results['documents'][0]:
per_doc_chunks[doc_id] = results['documents'][0]
# Format with document labels
context_parts = []
for doc_id, texts in per_doc_chunks.items():
doc_label = f'Document {doc_id}'
combined = ' '.join(texts[:2])[:600]
context_parts.append(f'== {doc_label} ==\n{combined}')
comparison_context = '\n\n'.join(context_parts)
return llm_call(f'Compare these documents to answer: {question}\n\n{comparison_context}')Gestión de información contradictoria
Distintos documentos pueden indicar datos contradictorios: un contrato afirma que el pago vence en 30 días y otro, en 60 días. El agente debe detectar y mostrar estos conflictos en lugar de elegir uno silenciosamente.
CONFLICT_PROMPT = '''You are analyzing multiple document sources.
Some may contain conflicting information.
For each factual claim you make:
1. Cite the source document
2. If another source contradicts it, explicitly note the conflict
3. Indicate which source you believe is more authoritative, if possible
Document excerpts:
{context}
Question: {question}
Answer (with conflict notes where applicable):'''
def answer_with_conflict_detection(question):
chunks = retrieve_relevant_chunks(question, n_results=8)
context = format_chunks_for_prompt(chunks)
return llm_call(CONFLICT_PROMPT.format(
context=context, question=question
))Filtrado por umbral de relevancia
No todos los fragmentos recuperados son realmente relevantes: la similitud vectorial implica un equilibrio entre cobertura y precisión. Establezca un umbral mínimo de relevancia para excluir los fragmentos que coincidan débilmente y puedan inducir al LLM a error.
MIN_RELEVANCE = 0.72 # cosine similarity threshold
def retrieve_above_threshold(question, n_results=10, threshold=MIN_RELEVANCE):
chunks = retrieve_relevant_chunks(question, n_results=n_results)
relevant = [c for c in chunks if c['relevance'] >= threshold]
print(f'Retrieved: {len(chunks)}, Above threshold: {len(relevant)}')
if not relevant:
# Fallback: use top 3 even if below threshold
return chunks[:3]
return relevant
def answer_with_threshold(question):
chunks = retrieve_above_threshold(question)
if not chunks:
return 'I could not find relevant information in the indexed documents.'
context = format_chunks_for_prompt(chunks)
return llm_call(QA_PROMPT.format(context=context, question=question))Generación de citas de fuentes
Después de generar una respuesta, extraiga los documentos de origen que se hayan citado y devuélvalos como una lista estructurada. Esto ayuda a los usuarios a encontrar los documentos originales para verificarlos.
import re
def extract_citations(answer_text, chunks):
# Find all [Source N] references in the answer
cited_nums = set(int(m) for m in re.findall(r'\[Source (\d+)\]', answer_text))
citations = []
for num in sorted(cited_nums):
idx = num - 1
if idx < len(chunks):
meta = chunks[idx]['metadata']
citations.append({
'source_num': num,
'title': meta.get('title', 'Unknown'),
'page': meta.get('page', 'N/A'),
'file': meta.get('source_file', '')
})
return citations
def answer_with_citations(question):
chunks = retrieve_above_threshold(question)
context = format_chunks_for_prompt(chunks)
answer = llm_call(QA_PROMPT.format(context=context, question=question))
citations = extract_citations(answer, chunks)
return {'answer': answer, 'citations': citations}Reordenación con un cross-encoder
La recuperación vectorial inicial utiliza bi-encoders (son rápidos, pero aproximados). Un cross-encoder vuelve a ordenar los resultados principales puntuando cada par (consulta, fragmento) conjuntamente: es más preciso, pero más lento. Este enfoque en dos etapas mejora la calidad de la respuesta final.
# pip install sentence-transformers
from sentence_transformers import CrossEncoder
reranker = CrossEncoder('cross-encoder/ms-marco-MiniLM-L-6-v2')
def rerank_chunks(question, chunks, top_k=4):
# Score each chunk against the question
pairs = [(question, c['text']) for c in chunks]
scores = reranker.predict(pairs)
# Attach scores and re-sort
scored_chunks = list(zip(scores, chunks))
scored_chunks.sort(key=lambda x: x[0], reverse=True)
top_chunks = [chunk for _, chunk in scored_chunks[:top_k]]
print(f'Re-ranked {len(chunks)} chunks -> kept top {top_k}')
return top_chunks
def answer_with_reranking(question):
# Retrieve more initially
initial_chunks = retrieve_relevant_chunks(question, n_results=12)
# Re-rank for precision
top_chunks = rerank_chunks(question, initial_chunks, top_k=4)
context = format_chunks_for_prompt(top_chunks)
return llm_call(QA_PROMPT.format(context=context, question=question))Filtrado de metadatos a nivel de documento
Cuando el usuario especifica un documento concreto o un intervalo de fechas, filtre a nivel de metadatos antes de realizar la búsqueda de embeddings. Esto evita que documentos irrelevantes contaminen los resultados.
def retrieve_filtered(question, filters=None, n_results=8):
query_kwargs = {
'query_texts': [question],
'n_results': n_results,
'include': ['documents', 'metadatas', 'distances']
}
# ChromaDB metadata filters
# Example: {'doc_id': 'contract_2024', 'year': {'$gte': 2023}}
if filters:
query_kwargs['where'] = filters
results = collection.query(**query_kwargs)
return [
{'text': t, 'metadata': m, 'relevance': 1 - d}
for t, m, d in zip(
results['documents'][0],
results['metadatas'][0],
results['distances'][0]
)
]
# Example usage
chunks = retrieve_filtered(
'What are the payment terms?',
filters={'doc_id': {'$in': ['contract_a', 'contract_b']}}
)Actualización del índice con documentos nuevos
Las colecciones de documentos cambian con el tiempo: se añaden archivos nuevos y se actualizan los antiguos. El pipeline de indexación debe admitir actualizaciones incrementales: añadir documentos nuevos, volver a indexar los actualizados y eliminar los que se hayan borrado.
import os
import hashlib
# Track indexed documents by file hash
index_registry = {} # {filepath: {hash, doc_id, indexed_at}}
def file_hash(filepath):
with open(filepath, 'rb') as f:
return hashlib.md5(f.read()).hexdigest()
def index_if_new_or_changed(filepath, title):
fhash = file_hash(filepath)
existing = index_registry.get(filepath)
if existing and existing['hash'] == fhash:
print(f'Skipping unchanged: {title}')
return existing['doc_id']
if existing:
# Remove old chunks from vector store
collection.delete(where={'doc_id': {'': existing['doc_id']}})
print(f'Re-indexing updated: {title}')
else:
print(f'Indexing new: {title}')
doc_id = hashlib.md5(filepath.encode()).hexdigest()[:8]
index_document(doc_id, filepath, title)
index_registry[filepath] = {'hash': fhash, 'doc_id': doc_id}
return doc_id
def sync_document_directory(directory):
pdf_files = [f for f in os.listdir(directory) if f.endswith('.pdf')]
for fname in pdf_files:
fpath = os.path.join(directory, fname)
title = fname.replace('.pdf', '').replace('_', ' ').title()
index_if_new_or_changed(fpath, title)
print(f'Sync complete: {len(pdf_files)} files processed')Comprobación de conocimientos
En un sistema de preguntas y respuestas sobre varios documentos que utiliza generación aumentada por recuperación, ¿qué evita el umbral de relevancia?
Resumen: agentes de preguntas y respuestas sobre varios documentos
Preguntas y respuestas sobre varios documentos: indexar todos los documentos (analizar → dividir en fragmentos → generar embeddings → almacenar con metadatos) → recuperar fragmentos relevantes de todos los documentos → darles formato con etiquetas de fuente → sintetizar la respuesta con citas.
Técnicas avanzadas: comparación entre documentos para preguntas comparativas, instrucciones para detectar conflictos, filtrado por umbral de relevancia, reordenación con cross-encoder para mejorar la precisión y filtrado de metadatos para limitar las consultas a documentos o intervalos de fechas específicos.
Preguntas frecuentes
¿La lección «Agentes de preguntas y respuestas sobre varios documentos» es gratis?
Sí — el texto completo de «Agentes de preguntas y respuestas sobre varios documentos» es gratis para leer aquí en la web. Para practicarla de forma interactiva (editor de código integrado y tutor de IA 24/7) y desbloquear el resto del curso de AI Agents, actualiza a CoddyKit PRO. El curso de AI Agents incluye 4 lecciones en total.
¿Qué aprenderé en «Agentes de preguntas y respuestas sobre varios documentos»?
Indexe un corpus documental y responda preguntas sobre el conjunto de documentos. Practicas AI Agents con código real que ejecutas directamente en el navegador, y un tutor de IA 24/7 responde tus preguntas mientras trabajas en la lección.
¿Necesito experiencia previa para empezar AI Agents?
No se requiere experiencia previa. AI Agents en CoddyKit está estructurado para principiantes hasta estudiantes avanzados, así que puedes empezar aquí o desde el inicio y avanzar a tu ritmo. Esta es la lección 3 de 4.
¿Cuánto tiempo toma la lección «Agentes de preguntas y respuestas sobre varios documentos»?
La mayoría de las lecciones de CoddyKit toman alrededor de 5–10 minutos. Cada una es compacta e interactiva, así que avanzas constantemente y retomas exactamente por donde dejaste en la web y la app.
¿Puedo escribir y ejecutar código en esta lección de AI Agents?
Sí. Cada lección de AI Agents incluye un editor de código integrado, así que escribes y ejecutas código real directamente en tu navegador y obtienes retroalimentación instantánea de IA — sin configuración local necesaria.
Todas las lecciones de este curso
- Análisis de PDF con PyMuPDF y pdfplumber
- OCR para documentos escaneados
- Agentes de preguntas y respuestas sobre varios documentos
- Clasificación y enrutamiento de documentos