RAG en Python sencillo: fragmentación, TF-IDF, similitud cosénica y generación de prompts
Construir generación mejorada por recuperación utilizando únicamente el módulo matemático: particionamiento en ventanas deslizantes, vectores TF-IDF, clasificación por coseno, un prompt fundamentado y sus limitaciones.
Fuentes: Building RAG From First Principles with just plain python .
Un tutorial típico sobre RAG te hace instalar cinco bibliotecas y llamar a .from_documents(). Diez minutos después la demostración funciona, pero no se entiende nada al respecto. Cuando la recuperación de información devuelve datos inútiles o el modelo genera respuestas erróneas, te quedas atascado, porque la biblioteca hizo todo el trabajo por ti.
Aquí el flujo se construye de manera opuesta, en Python puro, utilizando únicamente el módulo math y una sola llamada a un LLM de texto plano al final. Sin modelos de embedding, sin FAISS, sin Pinecone. Conocer los mecanismos implica que, cuando más adelante utilices LangChain o una base de datos vectorial real, entenderás qué ocurre en el fondo. Al final se obtiene un flujo funcional y, lo que es más útil, una comprensión de cada número que genera.
El problema que resuelve RAG
El conocimiento de un modelo de lenguaje se congela al final del entrenamiento y reside exclusivamente en sus pesos. Para que responda preguntas sobre documentos internos de la empresa, un código fuente o las noticias de esta mañana, las dos opciones obvias decepcionan tanto a la vez:
- Reentrenar el modelo. Es lento y costoso, además de que los cambios del día siguiente requieren otra ronda de entrenamiento.
- Pegar todo en el prompt. Este método no es escalable. Incluso una ventana de un millón de tokens no puede contener 50,000 documentos, y las investigaciones sobre modelos de contexto largo señalan repetidamente un efecto de “perderse en la mitad”, donde la información enterrada profundamente en un prompt enorme se utiliza peor aunque quepa. Además, más tokens significan mayor latencia y costo por llamada.
El verdadero truco de RAG es más sencillo de lo que parece. Los documentos permanecen en almacenamiento externo, y solo los fragmentos relevantes para esa pregunta se recuperan y se entregan al modelo. El LLM nunca contiene tus datos; recibe siempre un fragmento nuevo y relevante.
Por lo tanto, cualquier sistema RAG está formado por dos sistemas combinados: la recuperación, que encuentra el texto adecuado en una colección mucho más grande para una consulta determinada, y la generación, que pasa ese texto junto con la consulta a un LLM para obtener una respuesta. El resto de esta explicación describe cómo se construyen ambas partes a partir de elementos básicos.
Paso 1: fragmentación
Convertir un PDF completo de 50 páginas en un único vector elimina casi toda la información útil, ya que los detalles necesarios para responder a una pregunta se mezclan y pierden su significado. La primera tarea es dividir los documentos en fragmentos pequeños y específicos.
El método más sencillo es una ventana deslizante de tamaño fijo contada en palabras, con superposición entre los bloques adyacentes:
def chunk_text(text: str, chunk_size: int = 200, overlap: int = 50) -> list[str]:
if overlap >= chunk_size:
raise ValueError("overlap must be smaller than chunk_size")
words = text.split()
chunks = []
start = 0
while start < len(words):
end = start + chunk_size
chunks.append(" ".join(words[start:end]))
if end >= len(words):
break
start = end - overlap # step back so chunks share context
return chunks
Esa cláusula de protección no es solo decorativa. Si overlap alcanza o supera chunk_size, la posición de inicio deja de avanzar y el bucle nunca termina. Ajustar overlap hacia arriba “para tener más contexto” hace que esto sea un error fácil de cometer, por lo que la función se niega a ejecutarse en lugar de quedarse bloqueada en silencio.
El valor de overlap es más importante de lo que parece. Sin él, una oración que responde a una pregunta podría cruzar un límite entre bloques y dividirse, de modo que ninguno de los bloques contenga una idea completa que pueda ser recuperada. Con overlap, la región del límite permanece intacta en al menos un bloque.
Un caso límite importante: las ventanas de palabras fijas ignoran la estructura de oraciones y párrafos, por lo que pueden dividir una oración o una fila de tabla por la mitad. La división que tiene en cuenta las oraciones o los párrafos evita eso, pero genera tamaños de fragmentos desiguales, ya que una oración de 10 palabras y otra de 150 palabras se convierten en un mismo fragmento. Esto distorsiona el presupuesto de tokens y la influencia que tiene cada fragmento en los cálculos de recuperación posteriores. Ningún método de fragmentación está exento de compromisos, y la elección depende realmente de qué tipo de problema preferirías depurar.
Paso 2: convertir texto en números con TF-IDF
Una computadora no puede comparar directamente la similitud entre dos cadenas de texto. Cada paso posterior necesita vectores, listas de números sobre los cuales se pueden realizar operaciones aritméticas. La forma clásica y libre de dependencias para crearlos es TF-IDF, que es la frecuencia de término multiplicada por la frecuencia inversa de documento.
- TF indica con qué frecuencia aparece una palabra en este fragmento específico. Un valor alto para “kidney” sugiere que el fragmento trata sobre los riñones.
- IDF mide qué tan rara es una palabra en toda la colección. Palabras como “the” y “is” aparecen por todas partes y no proporcionan información sobre el fragmento que se está leyendo, por lo que obtienen un puntaje cercano a cero. Una palabra rara en un fragmento es una señal mucho más fuerte.
El código crea un vocabulario a partir de todos los fragmentos, calcula el IDF para cada término y luego convierte cada fragmento en un vector TF-IDF:
import math
from collections import Counter
def build_vocabulary(chunks: list[str]) -> list[str]:
vocab = set()
for chunk in chunks:
vocab.update(chunk.lower().split())
return sorted(vocab)
def compute_idf(chunks: list[str], vocab: list[str]) -> dict[str, float]:
num_chunks = len(chunks)
idf = {}
for word in vocab:
containing = sum(1 for c in chunks if word in c.lower().split())
idf[word] = math.log(num_chunks / (1 + containing))
return idf
def compute_tf(chunk: str, vocab: list[str]) -> dict[str, float]:
words = chunk.lower().split()
counts = Counter(words)
total = len(words)
return {word: counts.get(word, 0) / total for word in vocab}
def vectorize(chunk: str, vocab: list[str], idf: dict[str, float]) -> list[float]:
tf = compute_tf(chunk, vocab)
return [tf[word] * idf[word] for word in vocab]
Pruébalo con tres fragmentos y algo se hará claro. Una palabra presente en casi todos los fragmentos, como “the”, obtiene un IDF cercano a cero, por lo que repetirla no aporta nada al vector. Una palabra rara y específica del tema recibe un IDF claramente positivo. TF-IDF es, literalmente, una fórmula para determinar si una palabra ofrece información distintiva sobre un fragmento.
La desventaja es la escala. Con 100,000 documentos, el vocabulario puede contener más de 500,000 palabras únicas, por lo que cada vector de fragmento, incluso el de tres palabras, tiene 500,000 números y está compuesto en su mayor parte por ceros. Estos vectores dispersos son costosos de almacenar y comparar. Esa es la razón principal por la cual los sistemas en producción pasan a usar embeddings neuronales densos de 384 a 1536 dimensiones con casi ningún cero.
Paso 3: medir la relevancia con similitud coseno
Al considerar los fragmentos como vectores, la siguiente pregunta es cuán relevante es un fragmento para una consulta. La distancia bruta no es la herramienta adecuada, ya que en el caso del texto el ángulo entre los vectores es más importante que su longitud. Un fragmento que menciona “gato” una vez y otro que lo repite veinte veces siguen tratándose del mismo tema, y la similitud coseno los trata de manera similar sin importar cuál es más frecuente.
La implementación necesita un producto escalar, una magnitud y la relación entre ellos:
def dot_product(a, b):
return sum(x * y for x, y in zip(a, b))
def magnitude(v):
return math.sqrt(sum(x * x for x in v))
def cosine_similarity(a, b):
denom = magnitude(a) * magnitude(b)
return 0.0 if denom == 0 else dot_product(a, b) / denom
En principio, la similitud coseno varía de -1 a 1, y con esta configuración de TF-IDF casi siempre se mantiene en el rango de 0 a 1:
- 1.0 significa que los vectores apuntan exactamente en la misma dirección, lo que representa la máxima similitud.
- 0.0 significa que son ortogonales y no comparten ninguna dimensión significativa; en términos sencillos, esto indica que la consulta y el fragmento no tienen palabras en común que no sean triviales.
Una puntuación de 0 no es un error. Significa que el sistema informa correctamente de que el fragmento no tiene relación alguna con la consulta.
Un punto sobre la precisión: la fórmula de IDF suavizada puede asignar un valor ligeramente negativo a una palabra que aparece en todos los fragmentos, por lo que la similitud coseno ocasionalmente puede descender un poco por debajo de cero. En textos reales esto apenas tiene importancia, pero afirmar que nunca es negativa sería exagerado.
El punto ciego del TF-IDF con similitud coseno son los sinónimos. Una consulta que dice “automobile” frente a un documento que dice “car” obtiene una puntuación cercana a cero, ya que solo se cuenta la superposición literal del vocabulario y no existe concepto de significado. Los modelos de embedding denso, que colocan a los sinónimos cerca unos de otros, existen precisamente para cerrar esa brecha.
Paso 4: el recuperador
Una vez que la vectorización y la similitud están listas, la recuperación implica vectorizar cada fragmento una vez, vectorizar la consulta entrante, calcular la puntuación de cada fragmento en relación con ella, ordenarlos y conservar los k mejores:
class VectorStore:
def __init__(self):
self.chunks, self.vocab, self.idf, self.vectors = [], [], {}, []
def index(self, documents, chunk_size=50, overlap=10):
all_chunks = []
for doc in documents:
all_chunks.extend(chunk_text(doc, chunk_size, overlap))
self.chunks = all_chunks
self.vocab = build_vocabulary(all_chunks)
self.idf = compute_idf(all_chunks, self.vocab)
self.vectors = [vectorize(c, self.vocab, self.idf) for c in all_chunks]
def search(self, query, top_k=3):
query_vector = vectorize(query, self.vocab, self.idf)
scored = [(c, cosine_similarity(query_vector, v))
for c, v in zip(self.chunks, self.vectors)]
scored.sort(key=lambda pair: pair[1], reverse=True)
return scored[:top_k]
Un detalle puede arruinar todo en silencio. La consulta debe ser convertida utilizando el vocabulario y los valores IDF calculados a partir de los documentos, y utilizar un vocabulario derivado únicamente de la consulta en sí es un error. La similitud coseno presupone vectores de igual longitud, donde la posición i corresponde a la misma palabra en cada uno. Si se le da a la consulta su propio vocabulario, la posición 5 podría ser “heart” en un vector y “and” en otro, lo que hace que la puntuación de similitud carezca de sentido estructural, y no solo esté ligeramente desviada.
Paso 5: mejorar el prompt
Este paso no implica cálculos matemáticos, solo disciplina. Combina los fragmentos recuperados con la pregunta del usuario en un prompt que indica al modelo exactamente cómo comportarse:
def build_augmented_prompt(query: str, retrieved_chunks: list[str]) -> str:
context = "\n\n".join(
f"[Source {i+1}]: {chunk}" for i, chunk in enumerate(retrieved_chunks)
)
return f"""Answer the question using ONLY the context below.
If the answer is not contained in the context, say "I don't know based on the provided context."
Cite which source number(s) you used.
Context:
{context}
Question: {query}
Answer:"""
Dos detalles aquí no son meramente estéticos.
“Usar SOLO el contexto a continuación.” Sin esa regla explícita, el modelo mezcla los fragmentos obtenidos con lo que recuerda de su entrenamiento, y ya no se puede distinguir qué parte de una respuesta proviene de documentos fiables y cuál es una suposición. Al presentarse con confianza, tal mezcla causa más daño que omitir por completo el RAG, ya que parece tener una fuente cuando en realidad no la tiene.
Etiquetas numeradas de las fuentes e instrucciones de citación. Estas permiten rastrear las respuestas. Cuando una respuesta es incorrecta, lo cual ocurrirá, las fuentes etiquetadas muestran qué fragmento engañó al modelo, si el propio fragmento estaba desactualizado o incorrecto, o si el modelo interpretó mal uno correcto. Sin etiquetas, localizar la causa de una mala respuesta es en gran medida una conjetura.
Paso 6: integrarlo todo
El flujo completo consiste primero en la recuperación, luego en el aumento de datos y finalmente en una llamada al LLM. La decisión clave de diseño es mantener el indexado y las consultas como dos ciclos de vida separados, en lugar de que una sola función realice ambas tareas:
class RagPipeline:
def __init__(self, client, model="openai/gpt-oss-20b"):
self.store = VectorStore()
self.client = client
self.model = model
def build_index(self, documents: list[str]):
"""Run once, whenever documents are loaded or updated."""
self.store.index(documents, chunk_size=50, overlap=10)
def answer(self, query: str, top_k: int = 3) -> str:
"""Run on every user query — never re-indexes."""
results = self.store.search(query, top_k=top_k)
chunks = [c for c, _ in results]
prompt = build_augmented_prompt(query, chunks)
response = self.client.chat.completions.create(
model=self.model,
max_tokens=500,
messages=[{"role": "user", "content": prompt}],
)
return response.choices[0].message.content
Si la indexación se realizara dentro de la función por consulta, cada consulta dividiría nuevamente el texto, reconstruiría el vocabulario y volvería a calcular todos los vectores TF-IDF para toda la colección, aunque nada de eso cambia entre consultas. Con 10,000 documentos y 1,000 consultas, eso supone 999 reconstrucciones idénticas innecesarias. Construir el índice una sola vez y luego realizar muchas consultas es el enfoque que recomiendan FAISS, Chroma y Pinecone; se trata de la arquitectura correcta desde el principio, no de una optimización prematura.
¿Qué tiene de malo este sistema?
Las limitaciones merecen ser abordadas con sinceridad, ya que no son errores. Surgen directamente de las decisiones de diseño mencionadas anteriormente.
- Sin comprensión semántica. “Coche” y “automóvil” nunca coinciden, ya que TF-IDF solo considera la superposición literal del vocabulario.
Lecturas relacionadas
- Su primer agente LangGraph: un asistente de fútbol con herramientas y enrutamiento — Tutorial paso a paso de LangGraph: estado compartido, herramientas, un nodo planificador, bordes condicionales y un resumen en markdown, desarrollado alrededor de un asistente ficticio para estadísticas de fútbol.
- Agentes paralelos de LangGraph que revisan solicitudes de pull request mediante webhooks de FastAPI — Cómo un webhook, una cola de Redis y cuatro agentes de LangGraph convierten una solicitud de pull request en una revisión estructurada y puntuada por IA en aproximadamente 30 segundos, con ngrok para pruebas locales.
- Reclasificación en RAG: Cross-encoders frente a jueces LLM y su latencia — Cómo un reclasificador de segunda etapa corrige el orden de los candidatos en RAG, comparando cross-encoders con jueces LLM, los costos de latencia, los modos de fallo y cómo evaluarlos.
- Una app RAG mínima en Google Colab con LlamaIndex, Groq y Hugging Face — Indexar una página web con LlamaIndex, incrustarla localmente con un modelo de Hugging Face y responder preguntas mediante un LLM de código abierto alojado en Groq, todo dentro de Colab.
- RAG desde primeros principios: cómo funciona realmente la generación aumentada con recuperación de información — Una explicación desde los principios básicos sobre la generación aumentada con recuperación de información: por qué existe, cómo se compara con el ajuste fino, su evolución, los pipelines offline y online, y los modos de fallo.
- Búsqueda híbrida en Postgres para un asistente de salud: esquema, fragmentación, RRF y evaluaciones — Construcción de la capa de recuperación de un asistente de salud en Postgres: fragmentación consciente de la estructura, búsqueda híbrida con pgvector y RRF, cuantización binaria, umbrales y evaluaciones.
- Cambio de modelos de embedding RAG sin reindexar: cómo funciona rebasis — Cómo rebasis mapea los vectores de consulta de nuevos modelos a un índice de vectores antiguo mediante un pequeño adaptador, mide si la conexión es beneficiosa y realiza una migración segura cuando no lo es.
- Notas prácticas: Llevar RAG a producción: problemas end-to-end extraídos de casos reales — Guía paso a paso de las Notas prácticas: Llevar RAG a producción: problemas end-to-end extraídos de casos reales: contratos, verificaciones y espacios para código listo para usar para los equipos que implementan este patrón.
- Construir un pipeline RAG local en Python sin costos de API — Tutorial práctico para crear sistemas de generación mejorada mediante recuperación de información usando CPU, con herramientas como sentence-transformers, numpy y Flan-T5, incluyendo procesamiento en bloques, búsquedas y pruebas A/B.
- Chatea con cualquier repositorio de GitHub: Streamlit, LlamaIndex, Groq y embeddings locales — Crea un chatbot RAG a partir de un repositorio de GitHub, abarcando la ingesta de datos desde Git, el fragmentado en Markdown, modelos en caché, un prompt que admite la ignorancia y transmisión asíncrona en Streamlit.