RAG con Python desde cero — Tu primer asistente con memoria sobre tus PDFs

ChatGPT no sabe nada sobre tus documentos. Si le preguntas por un PDF que tienes en el ordenador, no lo conoce. Y si copias y pegas el PDF entero en el chat, te quedas sin contexto a la quinta pregunta — el modelo tiene un límite de tokens y los precios escalan con cada conversación.
RAG (Retrieval-Augmented Generation) soluciona esto: en lugar de pasarle al LLM todo tu corpus, le pasas solo los fragmentos relevantes para cada pregunta. Tienes un asistente que “sabe” sobre tus documentos sin pelearte con límites de contexto ni costes desbordados.
En esta entrada te enseño a construir tu primer RAG en Python desde cero: extraer texto de PDFs, partirlo en chunks, generar embeddings, guardar en un vector store y consultar con un LLM. Stack mínimo, código copy-paste que funciona, ~80 líneas en total.
Contenido
- 1 La idea en 30 segundos
- 2 Stack mínimo
- 3 Paso 1 — Extraer texto de un PDF
- 4 Paso 2 — Partir en chunks
- 5 Paso 3 — Generar embeddings
- 6 Paso 4 — Guardar en ChromaDB
- 7 Paso 5 — Consultar (recuperar chunks relevantes)
- 8 Paso 6 — Pasarle el contexto al LLM
- 9 Pipeline completo
- 10 Casos reales típicos
- 11 Errores típicos al empezar
- 12 Cuándo NO te merece la pena RAG
- 13 Resumen — los 6 pasos del pipeline
- 14 Tu siguiente paso
La idea en 30 segundos
Un sistema RAG tiene dos fases:
Fase 1 — Indexar (una vez):
- Extraer texto de tus documentos.
- Partirlo en chunks (trozos manejables).
- Generar embeddings (vectores numéricos que representan el significado).
- Guardar los chunks + embeddings en un vector store.
Fase 2 — Consultar (cada pregunta):
- Generar embedding de la pregunta.
- Buscar los N chunks más similares en el vector store.
- Pasar esos chunks como contexto al LLM junto con la pregunta.
- El LLM responde basándose en el contexto recuperado.
Documentos → Chunks → Embeddings → Vector Store
↓
Pregunta del usuario
↓
Embedding pregunta + búsqueda similitud
↓
Top N chunks relevantes + pregunta
↓
LLM
↓
Respuesta
Stack mínimo
pip install openai chromadb pypdf
openai— para embeddings y LLM. (También sirveanthropicpara Claude.)chromadb— vector store local sencillo. Sin servidor, archivos en disco.pypdf— extraer texto de PDFs.
💡 ¿Sin venv?
venvypipsin liarte. Antes de instalar nada serio.
Paso 1 — Extraer texto de un PDF
from pypdf import PdfReader
from pathlib import Path
def extraer_texto_pdf(ruta: Path) -> str:
reader = PdfReader(str(ruta))
paginas = [p.extract_text() or "" for p in reader.pages]
return "\n\n".join(paginas)
texto = extraer_texto_pdf(Path("manual.pdf"))
print(f"Extraídos {len(texto)} caracteres")
⚠️ PDFs con texto escaneado (imágenes) no extraen texto así. Para eso, OCR con
pytesseractantes. Pero el 90% de PDFs reales son de texto plano ypypdfbasta.
📥 Llévate el cheatsheet de Python (gratis)
PDF de 6 páginas con lo esencial: tipos, condicionales, bucles, estructuras de datos, funciones y los errores que más vas a cometer. Para tener al lado mientras programas.
Sin spam. Te apuntas a la lista, descargas el cheatsheet y recibes contenido de Python cada semana.
Paso 2 — Partir en chunks
Los LLMs tienen límite de contexto. Y los embeddings funcionan mejor con trozos cortos y coherentes (un párrafo, no un libro entero).
Estrategia simple: chunks de N palabras con un solapamiento que mantenga contexto entre fragmentos.
def chunkear(texto: str, palabras_por_chunk: int = 300, solape: int = 50) -> list[str]:
palabras = texto.split()
chunks = []
i = 0
while i < len(palabras):
chunk = " ".join(palabras[i:i + palabras_por_chunk])
chunks.append(chunk)
i += palabras_por_chunk - solape
return chunks
chunks = chunkear(texto, palabras_por_chunk=300, solape=50)
print(f"Total chunks: {len(chunks)}")
💡 Esto es chunking básico. Para producción seria, librerías como
langchaintienen splitters más inteligentes que respetan frases y párrafos. Para empezar, esto basta.
Paso 3 — Generar embeddings
Un embedding es un vector de números (típicamente 1536 dimensiones con text-embedding-3-small) que representa el “significado” del texto. Textos parecidos tienen vectores parecidos. Es lo que permite “buscar por similitud” sin keyword matching.
import os
from openai import OpenAI
cliente = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
def generar_embedding(texto: str) -> list[float]:
respuesta = cliente.embeddings.create(
model="text-embedding-3-small",
input=texto,
)
return respuesta.data[0].embedding
def generar_embeddings_batch(textos: list[str]) -> list[list[float]]:
respuesta = cliente.embeddings.create(
model="text-embedding-3-small",
input=textos,
)
return [d.embedding for d in respuesta.data]
⚡ Tip-friki: la API acepta listas, así que mete cien chunks en una llamada en vez de cien llamadas. Mucho más rápido y más barato.
Paso 4 — Guardar en ChromaDB
import chromadb
cliente_chroma = chromadb.PersistentClient(path="./chroma_db")
coleccion = cliente_chroma.get_or_create_collection("mis_documentos")
def indexar_chunks(chunks: list[str], origen: str) -> None:
embeddings = generar_embeddings_batch(chunks)
ids = [f"{origen}-{i}" for i in range(len(chunks))]
metadatos = [{"origen": origen, "chunk_idx": i} for i in range(len(chunks))]
coleccion.add(
ids=ids,
embeddings=embeddings,
documents=chunks,
metadatas=metadatos,
)
indexar_chunks(chunks, origen="manual.pdf")
print(f"Indexados {coleccion.count()} chunks")
PersistentClient(path="./chroma_db") crea una BBDD local en disco. La indexación se hace una sola vez por documento; las consultas posteriores son rápidas.
Paso 5 — Consultar (recuperar chunks relevantes)
def buscar_chunks_relevantes(pregunta: str, top_k: int = 4) -> list[str]:
embedding_pregunta = generar_embedding(pregunta)
resultados = coleccion.query(
query_embeddings=[embedding_pregunta],
n_results=top_k,
)
return resultados["documents"][0]
coleccion.query busca por similitud de coseno y devuelve los top_k chunks más cercanos al embedding de la pregunta.
Paso 6 — Pasarle el contexto al LLM
def responder(pregunta: str) -> str:
chunks_relevantes = buscar_chunks_relevantes(pregunta, top_k=4)
contexto = "\n\n---\n\n".join(chunks_relevantes)
prompt = (
"Responde la pregunta del usuario basándote EXCLUSIVAMENTE en el contexto. "
"Si la respuesta no está en el contexto, di 'No tengo esa información en los documentos'. "
"Cita literalmente cuando sea relevante.\n\n"
f"CONTEXTO:\n{contexto}\n\n"
f"PREGUNTA: {pregunta}"
)
respuesta = cliente.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Eres un asistente preciso que responde con base en documentos."},
{"role": "user", "content": prompt},
],
)
return respuesta.choices[0].message.content
print(responder("¿Cuáles son los pasos para configurar el sistema?"))
El system prompt es clave. Le dices al LLM explícitamente que se ciña al contexto. Sin esto, el modelo se inventa respuestas plausibles cuando no hay información (“hallucinations”).
Pipeline completo
Juntando todo:
import os
from pathlib import Path
from openai import OpenAI
from pypdf import PdfReader
import chromadb
cliente_llm = OpenAI(api_key=os.environ["OPENAI_API_KEY"])
cliente_chroma = chromadb.PersistentClient(path="./chroma_db")
coleccion = cliente_chroma.get_or_create_collection("docs")
def extraer_texto(ruta: Path) -> str:
reader = PdfReader(str(ruta))
return "\n\n".join(p.extract_text() or "" for p in reader.pages)
def chunkear(texto: str, palabras_por_chunk: int = 300, solape: int = 50) -> list[str]:
palabras = texto.split()
chunks, i = [], 0
while i < len(palabras):
chunks.append(" ".join(palabras[i:i + palabras_por_chunk]))
i += palabras_por_chunk - solape
return chunks
def embeddings_batch(textos: list[str]) -> list[list[float]]:
r = cliente_llm.embeddings.create(model="text-embedding-3-small", input=textos)
return [d.embedding for d in r.data]
def indexar_pdf(ruta: Path) -> None:
texto = extraer_texto(ruta)
chunks = chunkear(texto)
embs = embeddings_batch(chunks)
coleccion.add(
ids=[f"{ruta.name}-{i}" for i in range(len(chunks))],
embeddings=embs,
documents=chunks,
metadatas=[{"origen": ruta.name} for _ in chunks],
)
def preguntar(pregunta: str, top_k: int = 4) -> str:
emb = embeddings_batch([pregunta])[0]
resultados = coleccion.query(query_embeddings=[emb], n_results=top_k)
contexto = "\n\n---\n\n".join(resultados["documents"][0])
prompt = (
"Responde la pregunta basándote EXCLUSIVAMENTE en el contexto. "
"Si la respuesta no está, di que no tienes esa información.\n\n"
f"CONTEXTO:\n{contexto}\n\n"
f"PREGUNTA: {pregunta}"
)
r = cliente_llm.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "Asistente preciso, basado en documentos."},
{"role": "user", "content": prompt},
],
)
return r.choices[0].message.content
if __name__ == "__main__":
# Indexar (solo la primera vez)
indexar_pdf(Path("manual.pdf"))
indexar_pdf(Path("manual_v2.pdf"))
# Consultar
print(preguntar("¿Qué cambios introduce la versión 2?"))
~80 líneas de Python real. Con python-dotenv para variables de entorno y un wrapper CLI o de Telegram, lo conviertes en una herramienta que usas a diario.
💡 ¿Quieres meterlo en un bot de Telegram? Lo combinas con el bot de Telegram en Python y tienes un asistente sobre tus documentos en el móvil.
Casos reales típicos
- Asistente sobre la documentación interna de la empresa: políticas, manuales técnicos, código.
- Asistente legal/regulatorio: consultar contratos, leyes, normativas.
- Buscador semántico sobre tu propio segundo cerebro (Notion, Obsidian, Markdown).
- Soporte automatizado sobre la documentación de un producto.
- Exploración de papers/artículos científicos en PDF.
Errores típicos al empezar
# 1. Chunking muy grande → embeddings imprecisos, contexto LLM saturado
chunkear(texto, palabras_por_chunk=2000) # ❌
chunkear(texto, palabras_por_chunk=300) # ✓
# 2. Sin solape → contexto cortado entre chunks
chunkear(texto, solape=0)
# ❌ una frase a caballo entre chunks pierde sentido
# 3. Indexar el documento entero como un solo chunk
coleccion.add(documents=[texto_entero], embeddings=[emb])
# ❌ embedding poco específico, recuperación inútil
# 4. No persistir entre ejecuciones
chromadb.Client() # ❌ in-memory, se pierde al cerrar
chromadb.PersistentClient(path="./chroma_db") # ✓
# 5. Pasar TODOS los chunks al LLM
contexto = "\n".join(todos_los_chunks)
# ❌ pierdes el sentido del RAG, te limita la ventana de contexto y dispara el coste
# 6. Sin instrucción "cíñete al contexto" en el system prompt
# El LLM inventa cuando no encuentra. Siempre instrúyele explícitamente.
# 7. Generar embeddings uno a uno
for chunk in chunks:
embedding = generar_embedding(chunk) # ❌ N llamadas API
embeddings = embeddings_batch(chunks) # ✓ una llamada con todos
# 8. Mezclar idiomas sin tenerlo en cuenta
# Los embeddings cross-lingual de OpenAI funcionan, pero si tus documentos
# son ES y preguntas en EN, mejor traduce antes para mejor relevancia.
Cuándo NO te merece la pena RAG
- Pocas páginas de documentación. Pásalo entero como contexto al LLM directamente. Sin BBDD ni embeddings. Más simple, mismo resultado.
- Datos muy estructurados (tablas, BBDD relacional). Mejor SQL + LLM como interfaz, no RAG.
- Información que cambia constantemente. RAG asume indexación batch. Si tu corpus cambia minuto a minuto, tienes que actualizar el índice constantemente.
- Necesitas razonamiento complejo sobre los documentos, no recuperación de hechos. RAG ayuda con “¿qué dice X sobre Y?”, no con “haz un análisis crítico de las contradicciones entre X e Y”.
Resumen — los 6 pasos del pipeline
| Paso | Qué |
|---|---|
| 1 | Extraer texto (PyPDF, BeautifulSoup, pandas, etc.) |
| 2 | Chunkear (300 palabras + 50 solape como base) |
| 3 | Embeddings batch con text-embedding-3-small |
| 4 | chromadb.PersistentClient + coleccion.add(...) |
| 5 | Embedding pregunta + coleccion.query(top_k=4) |
| 6 | Pasar contexto al LLM con system prompt “cíñete al contexto” |
¿Te ha valido esto?
Si te ha resultado útil, llévate el cheatsheet de Python en PDF — 6 páginas con tipos, condicionales, bucles, estructuras de datos, funciones y los errores típicos. Para tener al lado mientras programas. Gratis.
Sin spam. Email + cheatsheet + un correo por semana con tutoriales nuevos.
Tu siguiente paso
Si has llegado hasta aquí, ya tienes un RAG funcional sobre tus documentos. La próxima vez que necesites un “asistente que sepa sobre X”, sabes exactamente cómo se construye. Y cuando llegue la hora de mejorarlo (chunking inteligente con LangChain, re-ranking, evaluación con métricas, multi-modal con imágenes), tienes la base.
Si quieres aprender Python desde la base hasta proyectos reales con APIs, IA y deploy, en El Pythonista lo enseño paso a paso.
Un abrazo,
Oscar
¿Quieres aprender Python en orden, no a saltos?
Esto que has leído es solo una pieza. En El Pythonista lo verás todo encadenado: 11 módulos, 37+ horas de vídeo, 734 actividades y un proyecto real (MovieTracker) que crece contigo desde la primera variable hasta el deploy a producción.
37+ horas · 734 actividades · Proyecto real · Acceso de por vida · 14 días de garantía
