RAG sin framework: fragmentar, puntuar, presupuestar, preguntar
RAG explicado como simple selección de tokens: un breve esbozo en Python con puntuación por solapamiento de palabras, un límite de presupuesto de tokens y lo que de verdad aportan los embeddings y los almacenes vectoriales.
RAG es selección de tokens
Un modelo no puede conocer tu wiki, tu código ni los tickets de soporte de ayer. Sus pesos se congelaron durante el entrenamiento, y un gigabyte de texto nunca cabrá en una ventana de 128k tokens. El ajuste fino lleva tiempo y dinero, y no es la herramienta adecuada para datos que cambian cada semana. Queda una opción, y es casi vergonzosamente directa: buscar en tus documentos los pocos fragmentos que importan para esta pregunta, pegarlos en el prompt y decirle al modelo que responda a partir de ellos.
Eso es todo lo que es RAG: recuperar y luego generar. No es un tipo nuevo de modelo, ni memoria, ni magia. Es una forma de elegir qué tokens entran en el bucle habitual de predicción del siguiente token. El modelo sigue prediciendo tokens sobre el contexto que hayas construido; RAG solo mejora lo que construyes.
Todo sistema RAG, de juguete o en producción, se reduce a cuatro pasos:
- Dividir los documentos en fragmentos.
- Puntuar cada fragmento frente a la pregunta.
- Quedarse con los mejores que quepan en el presupuesto de tokens.
- Construir un prompt con esas notas y la pregunta.
Los frameworks se diferencian en cómo hacen el paso de puntuación. La forma general nunca cambia.
La idea en código sencillo
El esbozo siguiente usa el solapamiento de palabras en lugar de una búsqueda real. Es primitivo a propósito, porque lo que importa es la estructura.
DOCS = [
"Tokens are subword chunks; a model "
"sees token IDs, never words.",
"The context window caps prompt plus "
"output, measured in tokens.",
"Temperature rescales the next-token "
"distribution before sampling.",
"Ollama serves local models behind an "
"OpenAI-style API on port 11434.",
]
def score(q: str, doc: str) -> int:
qw = set(q.lower().split())
return len(qw & set(doc.lower().split()))
def retrieve(q: str, k: int = 2) -> list:
ranked = sorted(
DOCS,
key=lambda d: score(q, d),
reverse=True,
)
return ranked[:k]
q = "What does the context window cap?"
notes = retrieve(q)
prompt = (
"Answer using only these notes:\n"
+ "\n".join(f"- {n}" for n in notes)
+ f"\n\nQuestion: {q}"
)
print(prompt)
Lo que de verdad importa es la cadena que se construye al final. Contiene las instrucciones, luego las notas recuperadas y después la pregunta, y esa cadena es RAG. Pásasela al cliente de chat que prefieras y ya tienes generación aumentada por recuperación. La frase «usa solo estas notas» también cuenta. Es tu principal protección frente a un modelo que preferiría responder con seguridad a partir de sus datos de entrenamiento antes que de tus documentos.
Los sistemas reales mejoran exactamente una función: score(). El solapamiento de palabras no detecta sinónimos, así que «laptop» nunca coincide con «notebook». En producción se usan embeddings, vectores que ordenan por significado, junto con un almacén vectorial para buscarlos a escala, y el texto se fragmenta de forma más inteligente que una frase por documento. Todo eso son mejoras del paso de puntuación. El pipeline que acabas de leer es toda la arquitectura.
Comprobar el presupuesto
¿Por qué quedarse con los k mejores fragmentos en lugar de todos los relevantes? Porque la ventana de contexto es un presupuesto y la recuperación puede sobrepasarlo. Incluso el texto que cabe no sale gratis: cada token cuesta dinero, y un contexto abarrotado hace que la atención del modelo sea menos fiable. Por eso el código RAG real cuenta los tokens antes de hacer la llamada.
import tiktoken
enc = tiktoken.get_encoding("o200k_base")
def fits(prompt: str, window: int = 8_000,
reserve: int = 500) -> bool:
used = len(enc.encode(prompt))
print(f"prompt uses {used} tokens")
return used <= window - reserve
if fits(prompt):
pass # call the API (Parts 2-5)
else:
notes = retrieve(q, k=1) # shrink, retry
Ese if es lo que separa una demo de un sistema. Los errores de truncado, el contexto que se descarta sin avisar y las respuestas que ignoran tus documentos suelen deberse a saltarse esta comprobación. Deja también un margen. La ventana tiene que albergar el prompt y la salida, así que reserva espacio para la respuesta que has pedido.
Ejecutar el ejemplo
pip install -r requirements.txt
python examples/part06_rag_idea.py
El script ejecuta el ejemplo completo. Muestra las puntuaciones de recuperación para varias preguntas, incluida una en la que el solapamiento de palabras falla estrepitosamente (una buena demostración de por qué existen los embeddings), y después enseña cómo se monta el prompt y cómo actúa el límite de presupuesto. Funciona sin conexión, sin clave y sin llamadas a ninguna API, porque la lección es la idea. Conectar el prompt montado a un modelo real es una única llamada de función desde cualquier cliente de chat básico, y el script indica el punto exacto donde iría esa llamada.
Lo que se dejó fuera a propósito
Los embeddings, los almacenes vectoriales, la estrategia de fragmentación y el reranking son la maquinaria que hace que retrieve() funcione bien, y pertenecen a una implementación RAG en condiciones, que es donde LangChain empieza a justificar su instalación. Aprender primero esa maquinaria tiene una pega. Los ingenieros que empiezan por ahí tienden a ver RAG como un producto que se configura. Los que empiezan por la idea ven una docena de líneas de flujo de control alrededor de una función de búsqueda, y pueden inspeccionar cada pieza cuando la calidad de la recuperación decepciona.
En resumen
Reducido a lo esencial, RAG elige qué tokens llega a ver el modelo. Divide los documentos, ordena los fragmentos frente a la pregunta, pega los mejores en un prompt que respete el presupuesto y el bucle habitual de predicción del siguiente token hace el resto. Nada de eso requiere un framework, y una vez que lo hayas visto lo reconocerás dentro de cualquier framework. El siguiente paso es integrar RAG en un pipeline real con los patrones ReAct y Plan-and-Execute, y ver dónde encajan LangChain y LangGraph.