RAG sin framework: cuándo el código plano gana
El coste oculto de las abstracciones y los casos en que doscientas líneas propias son la mejor decisión técnica.
Última entrega del bloque de plataformas, y de nivel avanzado. La anterior repasó los orquestadores y terminó con una advertencia. Aquí la desarrollamos hasta el final: qué hace realmente un RAG por dentro, por qué eso cabe en doscientas líneas y qué se gana cuando no hay nada entre tu código y las llamadas.
El sistema entero, en cuatro operaciones
Un RAG en tiempo de consulta hace exactamente esto:
- Una llamada HTTP a un modelo de embeddings, que devuelve una lista de números.
- Una consulta a una base de datos —SQL si es pgvector, HTTP si es un motor dedicado— que devuelve unas cuantas filas ordenadas por distancia.
- Un formato de cadena que mete esas filas en una plantilla de texto.
- Una llamada HTTP a un modelo generador con esa cadena.
Y en tiempo de indexación: leer ficheros, extraer texto, partirlo, llamar al modelo de embeddings por lotes e insertar filas. Eso es todo. No hay ninguna operación exótica, ninguna estructura de datos difícil, ningún algoritmo que implementar —el algoritmo está dentro de la base de datos, que no la escribes tú—.
Cuando alguien dice que un RAG es complicado, casi siempre se refiere a algo distinto: a decidir el tamaño de los fragmentos, a diseñar los metadatos, a escribir el prompt, a evaluar si funciona. Eso sí es difícil, y es difícil exactamente igual con framework y sin él. La dificultad está en las decisiones, no en el pegamento.
El esqueleto
Un RAG completo, con filtrado por metadatos y citas, sobre PostgreSQL con pgvector. Sin dependencias más allá del cliente de la base de datos y del cliente HTTP de los modelos.
import os, httpx, psycopg
from psycopg.rows import dict_row
API = "https://api.proveedor.example/v1"
CLAVE = os.environ["API_KEY"]
DSN = os.environ["POSTGRES_DSN"]
http = httpx.Client(
base_url=API,
headers={"Authorization": f"Bearer {CLAVE}"},
timeout=30.0,
)
# --- 1. Embeddings -----------------------------------------------
# Una llamada HTTP. Acepta lista para poder indexar por lotes;
# en consulta se usa con un solo texto.
def embeber(textos: list[str]) -> list[list[float]]:
r = http.post("/embeddings",
json={"model": "modelo-de-embeddings", "input": textos})
r.raise_for_status()
return [d["embedding"] for d in r.json()["data"]]
# --- 2. Recuperación ---------------------------------------------
# Una consulta SQL. El filtro por metadatos va DENTRO de la consulta,
# nunca después: filtrar en Python lo que ya trajo la base de datos
# devuelve menos resultados de los pedidos, en silencio.
SQL = """
SELECT id, texto, documento_id, seccion,
1 - (embedding <=> %(v)s::vector) AS similitud
FROM fragmentos
WHERE grupos && %(grupos)s
AND (%(desde)s::date IS NULL OR publicado_en >= %(desde)s::date)
ORDER BY embedding <=> %(v)s::vector
LIMIT %(k)s
"""
def recuperar(conn, consulta: str, grupos: list[str],
desde=None, k: int = 8) -> list[dict]:
v = embeber([consulta])[0]
with conn.cursor(row_factory=dict_row) as cur:
# ef_search es el mando recall/latencia; súbelo si el filtro
# es selectivo, porque el recorrido del índice se corta antes.
cur.execute("SET LOCAL hnsw.ef_search = 100")
cur.execute(SQL, {"v": str(v), "grupos": grupos,
"desde": desde, "k": k})
return cur.fetchall()
# --- 3. Construcción del prompt ----------------------------------
# Una plantilla. Aquí se toman las decisiones de calidad:
# cuántos fragmentos entran, en qué orden y qué se le prohíbe al modelo.
PLANTILLA = """Responde a la pregunta usando EXCLUSIVAMENTE los \
fragmentos numerados. Cita el número entre corchetes tras cada \
afirmación. Si la respuesta no está en los fragmentos, responde \
exactamente: «No dispongo de esa información».
FRAGMENTOS
{fragmentos}
PREGUNTA
{pregunta}
"""
def construir_prompt(pregunta: str, filas: list[dict],
max_caracteres: int = 12000) -> tuple[str, list[dict]]:
partes, usados, total = [], [], 0
for i, f in enumerate(filas, start=1):
bloque = f"[{i}] ({f['seccion']}) {f['texto']}"
if total + len(bloque) > max_caracteres:
break # presupuesto agotado: se corta, no se trunca a medias
partes.append(bloque)
usados.append(f)
total += len(bloque)
return PLANTILLA.format(fragmentos="\n\n".join(partes),
pregunta=pregunta), usados
# --- 4. Generación -----------------------------------------------
def generar(prompt: str) -> str:
r = http.post("/chat/completions", json={
"model": "modelo-generador",
"temperature": 0,
"messages": [{"role": "user", "content": prompt}],
})
r.raise_for_status()
return r.json()["choices"][0]["message"]["content"]
# --- El sistema completo -----------------------------------------
def responder(pregunta: str, grupos: list[str], desde=None) -> dict:
with psycopg.connect(DSN) as conn:
filas = recuperar(conn, pregunta, grupos, desde)
if not filas:
return {"respuesta": "No dispongo de esa información.",
"fuentes": []}
prompt, usados = construir_prompt(pregunta, filas)
texto = generar(prompt)
return {
"respuesta": texto,
"fuentes": [{"n": i, "documento_id": f["documento_id"],
"seccion": f["seccion"], "similitud": f["similitud"]}
for i, f in enumerate(usados, start=1)],
"prompt": prompt, # imprescindible para depurar y auditar
}Eso es un RAG funcional con permisos por grupo, filtro temporal, presupuesto de contexto, citas verificables y salida controlada cuando no hay resultados. Añadir búsqueda híbrida son veinte líneas más de SQL con dos CTE y fusión por rango recíproco; añadir un reranker es una llamada HTTP entre los pasos 2 y 3. El sistema no cambia de forma.
Fíjate especialmente en la última clave del diccionario devuelto. Devolver el prompt exacto que se envió es la decisión más rentable de todo el archivo: cuando una respuesta salga mal, sabrás en treinta segundos si el problema fue de recuperación o de generación, que es la distinción que la entrega 43 desarrollará entera.
El coste oculto de las abstracciones
Cuatro costes concretos, ninguno teórico.
Dependencias. El código de arriba tiene dos. Un orquestador con sus integraciones arrastra decenas o cientos de paquetes transitivos, cada uno con su ritmo de actualización, sus vulnerabilidades y sus conflictos de versión. En una organización que audita el suministro de software, esa diferencia se traduce en trabajo real y recurrente.
Cambios que rompen. Estas bibliotecas se han reorganizado, han cambiado interfaces recomendadas y han depreciado abstracciones enteras en plazos cortos. El resultado es mantenimiento provocado por razones ajenas a tu producto: actualizas porque una dependencia lo exige, no porque quieras nada nuevo. El código de arriba se rompe cuando cambia la API del proveedor de modelos, y no antes.
Depuración opaca. Es el coste que más duele. Con capas intermedias, averiguar el prompt exacto que llegó al modelo exige activar trazas, leer código ajeno o instrumentar. Sin ellas, es una variable. Y como el prompt es donde se manifiestan la mitad de los fallos de calidad, esa diferencia se paga en cada incidente.
Rendimiento y control. La latencia añadida es pequeña. Lo importante es otra cosa: las abstracciones toman por defecto decisiones que son de calidad, no de fontanería. Cuántos fragmentos entran, cómo se trunca lo que no cabe, en qué orden se colocan, qué pasa si el recuperador devuelve cero. Cuando esas decisiones son valores por defecto de una biblioteca, no las está tomando nadie de tu equipo.
Lo que sí se pierde
Escribir a mano no es gratis, y decir lo contrario sería vender humo.
Pierdes el catálogo de integraciones: cada nuevo formato de documento, cada nuevo proveedor y cada nuevo motor son código tuyo. Pierdes las estructuras de recuperación ya hechas —jerarquías, ventanas de frases, enrutamiento entre índices— que LlamaIndex trae resueltas. Pierdes el vocabulario compartido, que en un equipo grande importa: «un retriever» significa lo mismo para todos, mientras que tu módulo propio hay que explicarlo a cada persona que entra. Y pierdes las utilidades de gestión de estado y ciclos que hacen razonable un agente; si tu flujo tiene bucles, verificación y reanudación, escribirlo a mano es reimplementar LangGraph con menos cuidado.
Además hay una trampa clásica: el código propio empieza en doscientas líneas y crece. Si al cabo de un año tienes mil quinientas líneas de abstracciones propias mal documentadas, has construido un framework peor que los que rechazaste, y encima lo mantienes tú solo.
La regla de decisión
- Sin framework cuando el flujo es lineal, las piezas están decididas, el equipo es pequeño y el control del prompt y de la latencia importa. Es el caso de la mayoría de los RAG en producción, aunque no lo parezca por lo que se publica.
- Con framework cuando estás explorando y vas a cambiar de piezas varias veces, cuando el flujo tiene ciclos y estado, cuando el corpus exige estructuras de recuperación no triviales, o cuando el equipo es grande y la homogeneidad vale más que el control fino.
- Mixto, que es lo que más se ve en sistemas maduros: el camino crítico de consulta escrito a mano, porque es donde se depura y se optimiza, y las piezas periféricas —ingesta, conectores, evaluación— apoyadas en bibliotecas, porque ahí la comodidad no cuesta control.
Y un criterio final que resuelve la duda cuando ninguno de los anteriores decide: escribe primero el sistema a mano, aunque sea para tirarlo. Un día de trabajo con el código de arriba te enseña qué hace realmente cada paso, y esa comprensión es exactamente lo que necesitas para usar bien un framework si luego decides adoptarlo. Al revés no funciona: empezar por la abstracción deja huecos que se manifiestan el día que algo falla.
Qué viene ahora
Con esta entrega se cierra el bloque quinto. Hemos recorrido los criterios para elegir base de datos vectorial, pgvector dentro de PostgreSQL, los motores dedicados de código abierto, los servicios gestionados, las opciones embebidas, la comparativa completa y los orquestadores. La plataforma está decidida.
El bloque sexto se ocupa de la pregunta que hemos ido aplazando y que atraviesa toda la serie: cómo se sabe si el sistema funciona. Empezamos por las tres capas de métricas y por qué medir sólo la respuesta final engaña de forma sistemática, seguimos con las métricas de recuperación y de generación, revisamos las herramientas de evaluación, y terminamos con el golden dataset: el conjunto de preguntas con respuesta verificada que convierte cualquier cambio en el sistema en algo medible en lugar de opinable.
Los tres libros gratuitos de los que nace esta serie están disponibles en PDF y EPUB: RAG para tu negocio (nivel básico), RAG por dentro (intermedio) y RAG en producción (avanzado).
Aviso de contenido generado con inteligencia artificial.
Este artículo ha sido redactado con la asistencia de sistemas de inteligencia artificial y revisado antes de su publicación. Se informa de ello de forma expresa en cumplimiento de la normativa vigente sobre transparencia en el uso de IA.