RAG: cómo darle memoria a un LLM con tus propios documentos
¿Qué es RAG?
- Retrieval: recuperar los fragmentos de texto más relevantes de una base de conocimiento propia.
- Augmented: aumentar el prompt del LLM con ese contexto recuperado.
- Generation: el LLM genera la respuesta basándose en ese contexto aumentado, no solo en su entrenamiento.
El pipeline RAG
Fase de ingesta (offline)
Ingesta: de documentos a vectores
Fase de query (online)
Query: de pregunta a respuesta con contexto
- Pregunta: el usuario escribe su consulta en lenguaje natural.
- Embedding: la pregunta pasa por el mismo modelo de embeddings que se usó en la ingesta. Es clave que sea el mismo: ambos vectores deben vivir en el mismo espacio dimensional para que la comparación tenga sentido.
- Búsqueda: el vector de la pregunta se compara contra el Vector Store con similitud coseno y se recuperan los k fragmentos más cercanos semánticamente.
- Chunks: esos fragmentos traen el contexto que el LLM necesita para responder con precisión.
- Prompt aumentado: la pregunta original se combina con los chunks recuperados en un prompt estructurado: primero el contexto, al final la pregunta.
- LLM: el modelo recibe ese prompt y genera la respuesta apoyándose en el contexto inyectado.
- Respuesta: una respuesta basada en los documentos de la empresa, no en suposiciones generales del entrenamiento.
Embeddings: el idioma de los vectores
Representaciones numéricas de texto como vectores en un espacio de alta dimensionalidad. Texto con significado similar produce vectores cercanos en ese espacio, aunque no compartan palabras exactas.
Similitud coseno y búsqueda vectorial
Similitud coseno: vectores similares apuntan en la misma dirección
FAISS y los índices de búsqueda vectorial
Facebook AI Similarity Search. Librería de Meta para búsqueda eficiente en espacios de alta dimensionalidad. Incluye índices que encuentran vectores aproximadamente cercanos en una fracción del tiempo que tomaría la búsqueda exacta.
IVF e HNSW: dos formas de organizar vectores para búsqueda rápida
FAISS vs pgvector
| Característica | FAISS | pgvector |
|---|---|---|
| Dónde vive | En memoria (RAM) | PostgreSQL |
| Velocidad | Extremadamente rápido (ANN optimizado) | Rápido para volúmenes medianos |
| Escala | Millones a miles de millones | Hasta ~1-2M cómodamente |
| Persistencia | Manual (serializar a disco) | Nativa (PostgreSQL) |
| Transacciones | No | Sí (ACID) |
| Infraestructura extra | No | Necesita PostgreSQL |
| Ideal para | Búsqueda masiva, latencia crítica | Apps con PostgreSQL existente |
RAG vs MCP: no son lo mismo
Model Context Protocol. Protocolo abierto de Anthropic que permite a los LLMs conectarse a herramientas externas (APIs, bases de datos, sistemas de archivos) y decidir cuándo y cómo llamarlas durante una conversación.
RAG vs MCP: quién decide qué buscar
Ahora manos a la obra
Stack del tutorial
- Embeddings: OpenAI
text-embedding-3-smallvía LiteLLM - Generación: Anthropic
claude-3-haiku-20240307vía LiteLLM - Vector store: PostgreSQL + pgvector (Docker)
- Por qué LiteLLM: Claude no tiene un endpoint de embeddings nativo. LiteLLM permite mezclar proveedores con una sola API.
Setup: Docker Compose
pgvector/pgvector:pg16 incluye todo preconfigurado.services:
postgres:
image: pgvector/pgvector:pg16
environment:
POSTGRES_DB: ragdb
POSTGRES_USER: raguser
POSTGRES_PASSWORD: ragpass
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
volumes:
pgdata:docker compose up -dpip install fastapi uvicorn litellm psycopg2-binary
export OPENAI_API_KEY=sk-...
export ANTHROPIC_API_KEY=sk-ant-...Script de ingesta
vector(1536), divide los documentos en chunks, genera el embedding de cada chunk con LiteLLM y los inserta en pgvector.import psycopg2
from psycopg2.extras import execute_values
import litellm
# Fictional TechCorp internal documents
DOCUMENTS = [
{
"title": "Vacation Policy",
"content": """TechCorp grants 15 paid vacation days per year to all full-time employees.
Days accrue at 1.25 days per month. Unused days roll over up to 30 days maximum.
Requests must be submitted at least 2 weeks in advance.
Requests during December require 4 weeks notice.
Vacation pay is calculated based on the employee base salary."""
},
{
"title": "Onboarding Guide",
"content": """Welcome to TechCorp! Your first week:
Day 1: IT setup, equipment pickup, and access provisioning.
Day 2: HR orientation and benefits enrollment.
Day 3-4: Team introduction and codebase walkthrough.
Day 5: First task assignment and buddy system pairing.
Tools: Slack for communication, Linear for project tracking,
GitHub for version control, Notion for documentation.
Contact IT helpdesk at it@techcorp.com for technical issues."""
},
{
"title": "Expense Policy",
"content": """TechCorp reimburses pre-approved business expenses.
Meals: up to $50 per person, $150 per team event.
Travel: economy class flights, hotels up to $200 per night.
Equipment: pre-approval required for purchases over $500.
Coworking: up to $30 per day when working remotely.
Submit expense reports within 30 days via Expensify.
Attach all receipts. Expenses over $100 require manager approval.
Reimbursements processed within 2 business weeks."""
}
]
def chunk_text(text: str, chunk_size: int = 250) -> list[str]:
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
chunks, current, length = [], [], 0
for para in paragraphs:
if length + len(para) > chunk_size and current:
chunks.append(" ".join(current))
current, length = [para], len(para)
else:
current.append(para)
length += len(para)
if current:
chunks.append(" ".join(current))
return chunks
def get_embedding(text: str) -> list[float]:
# Claude (Anthropic) has no native embeddings endpoint.
# LiteLLM lets us use OpenAI embeddings here and Claude for generation.
response = litellm.embedding(model="text-embedding-3-small", input=text)
return response.data[0].embedding
def setup_db(conn) -> None:
with conn.cursor() as cur:
cur.execute("CREATE EXTENSION IF NOT EXISTS vector;")
cur.execute("""
CREATE TABLE IF NOT EXISTS documents (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536)
);
""")
cur.execute("TRUNCATE documents;")
conn.commit()
def main() -> None:
conn = psycopg2.connect(
host="localhost", dbname="ragdb", user="raguser", password="ragpass"
)
print("Setting up database...")
setup_db(conn)
rows = []
for doc in DOCUMENTS:
chunks = chunk_text(doc["content"])
print(f"Processing '{doc['title']}': {len(chunks)} chunks")
for chunk in chunks:
rows.append((doc["title"], chunk, get_embedding(chunk)))
with conn.cursor() as cur:
execute_values(
cur,
"INSERT INTO documents (title, content, embedding) VALUES %s",
rows,
template="(%s, %s, %s::vector)"
)
conn.commit()
conn.close()
print(f"Done. Inserted {len(rows)} chunks.")
if __name__ == "__main__":
main()python ingest.py
Setting up database...
Processing 'Vacation Policy': 2 chunks
Processing 'Onboarding Guide': 3 chunks
Processing 'Expense Policy': 3 chunks
Done. Inserted 8 chunks.API REST con FastAPI
POST /ask recibe la pregunta, genera su embedding, busca los chunks más similares en pgvector con el operador <=> (distancia coseno), arma el prompt aumentado y llama a claude-haiku. Además de la respuesta, la API devuelve las fuentes usadas, útil para citar o debuggear.import psycopg2
import litellm
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
app = FastAPI(title="TechCorp RAG API")
DB_CONFIG = {
"host": "localhost",
"dbname": "ragdb",
"user": "raguser",
"password": "ragpass",
}
class AskRequest(BaseModel):
question: str
top_k: int = 3
class SourceChunk(BaseModel):
title: str
content: str
similarity: float
class AskResponse(BaseModel):
answer: str
sources: list[SourceChunk]
def get_embedding(text: str) -> list[float]:
response = litellm.embedding(model="text-embedding-3-small", input=text)
return response.data[0].embedding
def retrieve(conn, embedding: list[float], top_k: int) -> list[dict]:
with conn.cursor() as cur:
# <=> is pgvector's cosine distance operator (lower = more similar)
# 1 - distance = cosine similarity
cur.execute("""
SELECT title, content, 1 - (embedding <=> %s::vector) AS similarity
FROM documents
ORDER BY embedding <=> %s::vector
LIMIT %s;
""", (embedding, embedding, top_k))
rows = cur.fetchall()
return [{"title": r[0], "content": r[1], "similarity": round(r[2], 3)} for r in rows]
@app.get("/health")
def health() -> dict:
return {"status": "ok"}
@app.post("/ask", response_model=AskResponse)
def ask(req: AskRequest) -> AskResponse:
try:
conn = psycopg2.connect(**DB_CONFIG)
embedding = get_embedding(req.question)
chunks = retrieve(conn, embedding, req.top_k)
conn.close()
except Exception as exc:
raise HTTPException(status_code=500, detail=str(exc))
context = "\n\n".join(f"[{c['title']}]\n{c['content']}" for c in chunks)
prompt = f"""You are an internal assistant for TechCorp.
Answer ONLY using the context below. If the information is not there, say so clearly.
Context:
{context}
Question: {req.question}
Answer:"""
response = litellm.completion(
model="anthropic/claude-3-haiku-20240307",
messages=[{"role": "user", "content": prompt}],
temperature=0,
)
return AskResponse(
answer=response.choices[0].message.content.strip(),
sources=[SourceChunk(**c) for c in chunks],
)Demo en acción
http://localhost:8000/docs:uvicorn query:app --reloadcurl:curl -X POST http://localhost:8000/ask \
-H "Content-Type: application/json" \
-d '{"question": "How many vacation days do I get per year?"}'{
"answer": "According to TechCorp's Vacation Policy, you receive 15 paid vacation days per year.",
"sources": [
{
"title": "Vacation Policy",
"content": "TechCorp grants 15 paid vacation days per year to all full-time employees. Days accrue at 1.25 days per month...",
"similarity": 0.847
},
{
"title": "Vacation Policy",
"content": "Requests must be submitted at least 2 weeks in advance...",
"similarity": 0.612
}
]
}<=> en la query SQL es el operador de distancia coseno de pgvector. Un valor menor significa mayor similitud. La expresión 1 - (embedding <=> query) convierte esa distancia en similitud, donde 1 es idéntico y 0 es sin relación.Limitaciones a tener en cuenta
- El tamaño del chunk importa: chunks muy grandes diluyen la señal semántica; chunks muy pequeños pierden contexto. Experimenta entre 200 y 500 tokens según tu caso.
- RAG no es magia: si el documento no tiene la respuesta, el LLM puede inventarla de todas formas. Usa
temperature=0e instrucciones explícitas como “responde solo con el contexto provisto”. - Escala de pgvector: funciona bien hasta ~1-2M vectores. Para más escala, considera FAISS, Qdrant o Pinecone.
- Mismo modelo de embeddings siempre: usa el mismo modelo para indexar y para consultar. Mezclar modelos produce resultados incoherentes.