NovedadIA

Cómo construir un sistema RAG desde cero y hacerlo útil

Guia

Guía práctica para construir un sistema RAG desde cero, desde la carga y división de documentos hasta la búsqueda vectorial, el Reranking y la generación de respuestas con un LLM.

¿Qué es RAG?

RAG (Retrieval-Augmented Generation, generación aumentada mediante recuperación) es una técnica que permite a un modelo de lenguaje responder preguntas utilizando documentos privados.

Un LLM convencional tiene una limitación fundamental: solo conoce la información que estaba disponible durante su entrenamiento. Si nunca ha visto el manual de producto de tu empresa, sus FAQ internas o su política de reembolsos, no puede conocer esa información.

Introducir el documento completo directamente en el Prompt tampoco es una buena solución. Los documentos largos pueden superar la ventana de contexto y, aunque quepan, demasiada información irrelevante puede hacer que el modelo pierda de vista los datos realmente importantes.

La idea de RAG es bastante sencilla:

  1. El usuario pregunta: «¿Cuál es la política de reembolsos de la empresa?»

  2. El sistema busca en los documentos los fragmentos más relacionados con la pregunta.

  3. Esos fragmentos se envían al LLM junto con la pregunta.

  4. El LLM genera una respuesta basándose en la información recuperada.

La diferencia fundamental entre RAG y preguntarle directamente a ChatGPT es el origen de la información. En una conversación normal, la respuesta depende principalmente de los datos aprendidos por el modelo; en RAG, la respuesta se basa en los documentos que tú proporcionas.

Esto permite trabajar con información privada, actualizar la base de conocimiento sin volver a entrenar el modelo y, en teoría, obtener respuestas más fiables que si dependieras únicamente de la memoria del LLM.

Qué vamos a construir

En este proyecto vamos a crear una base de conocimiento con IA capaz de responder preguntas a partir de documentos locales en PDF, Markdown y TXT.

El flujo final será similar a este:

Usuario:
¿Cuál es el plazo de reembolso?

Sistema RAG:
1. Convierte la pregunta en un vector
2. Busca los fragmentos más relevantes en la base de datos vectorial
3. Encuentra la información relacionada con la política de reembolsos
4. Envía la información + la pregunta al LLM
5. El LLM genera la respuesta a partir de los resultados recuperados

Arquitectura y tecnologías

Para este ejemplo utilizaremos:

  • Python: lenguaje de programación

  • sentence-transformers: modelo local de Embedding para convertir texto en vectores, con ejecución gratuita y offline

  • Chroma: base de datos vectorial ligera y embebida, adecuada para prototipos y proyectos que necesitan iterar rápidamente

  • API compatible con OpenAI: para llamar al LLM que genera la respuesta final, aunque también puede sustituirse por un modelo local

  • Text Splitter de LangChain: para dividir los documentos en fragmentos

La arquitectura completa puede dividirse en dos fases:

【Construcción de la base de conocimiento — offline】
Documentos → Análisis de texto → División en fragmentos → Embedding → Base de datos vectorial

【Consulta del usuario — online】
Pregunta → Embedding de la pregunta → Búsqueda por similitud → Fragmentos relevantes
→ Prompt + resultados recuperados → LLM → Respuesta final

Preparar los datos de prueba

Para probar el sistema crearemos tres documentos:

  • refund_policy.md: política de reembolsos, incluyendo plazos, condiciones y proceso

  • product_faq.md: preguntas frecuentes sobre el producto, como sistemas operativos compatibles y número de dispositivos permitidos por cuenta

  • service_agreement.md: acuerdo de servicio con algunas cláusulas legales que no están relacionadas con los reembolsos

También prepararemos tres tipos de preguntas:

  • Preguntas cuya respuesta existe claramente en los documentos: «¿Cuál es el plazo de reembolso?» o «¿Qué sistemas operativos son compatibles?»

  • Preguntas que no aparecen en los documentos: «¿Cuándo saldrá a bolsa vuestra empresa?»

  • Preguntas ambiguas: «¿Cómo se puede cancelar la suscripción?»

Paso 1: leer los documentos

import os
from pathlib import Path

def load_documents(doc_dir: str) -> list[dict]:
    """Carga todos los archivos Markdown y TXT del directorio"""
    documents = []
    for path in Path(doc_dir).rglob("*"):
        if path.suffix.lower() in (".md", ".txt"):
            content = path.read_text(encoding="utf-8")
            documents.append({
                "content": content,
                "source": str(path.name),
            })
    return documents

docs = load_documents("./knowledge_base")
print(f"Se cargaron {len(docs)} documentos")
for d in docs:
    print(f"  - {d['source']}: {len(d['content'])} caracteres")

¿Por qué no enviar directamente todo el PDF al LLM?

Imagina que tienes 10 documentos de 5.000 caracteres cada uno. En total serían unos 50.000 caracteres. Esa cantidad puede acercarse o incluso superar la ventana de contexto de determinados modelos.

Incluso si el modelo puede procesarlo, introducir tanta información irrelevante reduce la concentración sobre los datos importantes. Además, el consumo de tokens aumenta considerablemente, lo que también incrementa el coste.

Por eso RAG no intenta enviar toda la base documental en cada consulta. Primero busca la información que probablemente sea relevante.

Paso 2: dividir el texto en fragmentos

Esta es una de las partes más ignoradas de RAG y, al mismo tiempo, una de las más importantes.

Un documento de 5.000 caracteres no debería convertirse simplemente en un único vector. Puede contener muchos temas diferentes, por lo que una búsqueda basada en ese vector tendría una precisión muy baja.

Lo habitual es dividir el documento en fragmentos más pequeños, por ejemplo de 300 a 500 caracteres, procurando que cada fragmento se concentre en un tema concreto.

from langchain.text_splitter import RecursiveCharacterTextSplitter

def split_documents(documents: list[dict], chunk_size=500, chunk_overlap=50):
    """Divide los documentos en múltiples chunks"""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n## ", "\n### ", "\n\n", "\n", "。", " ", ""],
        length_function=len,
    )
    
    all_chunks = []
    for doc in documents:
        chunks = splitter.split_text(doc["content"])
        for i, chunk in enumerate(chunks):
            all_chunks.append({
                "text": chunk,
                "source": doc["source"],
                "chunk_id": i,
            })
    return all_chunks

chunks = split_documents(docs)
print(f"Se obtuvieron {len(chunks)} chunks después de la división")
for c in chunks[:3]:
    print(f"\n--- {c['source']} chunk {c['chunk_id']} ---")
    print(c["text"][:200])

Qué significan estos parámetros

  • chunk_size=500: cada chunk tiene como máximo 500 caracteres. Según el benchmark de 2026 citado en el artículo original, una división recursiva de 512 tokens obtuvo un 69 % de precisión en documentos generales, por encima de la división semántica.

  • chunk_overlap=50: los chunks consecutivos comparten 50 caracteres para reducir el riesgo de perder contexto cuando una frase queda dividida.

  • separators: intenta dividir primero por títulos Markdown y párrafos y solo utiliza signos de puntuación o caracteres individuales como último recurso. Esto ayuda a conservar la estructura natural del documento.

Un chunk demasiado grande reduce la precisión de la recuperación. Por ejemplo, un fragmento de 2.000 caracteres podría contener solo 200 caracteres realmente relacionados con la pregunta, pero el sistema enviaría los 2.000 al LLM.

Un chunk demasiado pequeño puede provocar el problema contrario: se pierde contexto porque una idea o una frase queda separada entre diferentes fragmentos.

Paso 3: generar Embeddings

from sentence_transformers import SentenceTransformer

embedding_model = SentenceTransformer("BAAI/bge-m3")

def create_embeddings(chunks):
    """Convierte cada chunk en un vector"""
    texts = [c["text"] for c in chunks]
    embeddings = embedding_model.encode(texts, normalize_embeddings=True)
    for i, chunk in enumerate(chunks):
        chunk["embedding"] = embeddings[i]
    return chunks

chunks = create_embeddings(chunks)
print(f"Embedding completado. Dimensión: {chunks[0]['embedding'].shape}")

¿Qué es un Embedding?

Un Embedding es una función que transforma un texto en un vector dentro de un espacio de alta dimensión.

La idea es que textos con significados similares queden cerca unos de otros en ese espacio.

Por ejemplo, «¿Cuál es el plazo de reembolso?» y «Información sobre la política de reembolsos» deberían estar relativamente cerca, mientras que «¿Qué sistemas operativos son compatibles?» debería encontrarse más lejos.

BGE-M3 es un modelo de Embedding de código abierto compatible con más de 100 idiomas. También puede ejecutarse localmente y sin conexión, lo que resulta especialmente útil cuando se trabaja con documentación privada.

Paso 4: crear la base de datos vectorial

import chromadb

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(
    name="knowledge_base",
    metadata={"hnsw:space": "cosine"},
)

def store_vectors(chunks, collection):
    """Guarda los chunks y sus vectores correspondientes en Chroma"""
    ids = [f"{c['source']}_{c['chunk_id']}" for c in chunks]
    texts = [c["text"] for c in chunks]
    embeddings = [c["embedding"].tolist() for c in chunks]
    metadatas = [{"source": c["source"], "chunk_id": c["chunk_id"]} for c in chunks]
    
    collection.add(ids=ids, documents=texts, embeddings=embeddings, metadatas=metadatas)
    print(f"Se almacenaron {len(chunks)} vectores")

store_vectors(chunks, collection)

Los Metadata, como source y chunk_id, son importantes porque permiten saber de dónde procede cada fragmento recuperado.

Esto facilita posteriormente verificar la respuesta y, si es necesario, mostrar al usuario la fuente utilizada.

Paso 5: implementar la recuperación

def retrieve(query: str, collection, top_k: int = 3):
    """Busca los chunks más relevantes para una pregunta"""
    query_embedding = embedding_model.encode([query], normalize_embeddings=True)
    results = collection.query(
        query_embeddings=query_embedding.tolist(),
        n_results=top_k,
    )
    return results

query = "¿Cuál es el plazo de reembolso?"
results = retrieve(query, collection)

for i, (doc, meta) in enumerate(zip(results["documents"][0], results["metadatas"][0])):
    print(f"\nTop {i+1} [{meta['source']} chunk {meta['chunk_id']}]")
    print(doc[:150])

El resultado real podría ser parecido a:

Top 1 [refund_policy.md chunk 1]
## Plazo de reembolso
El cliente puede solicitar un reembolso dentro de los 30 días posteriores a la recepción del producto. Para pedidos de más de 30 días, evaluaremos cada caso...

Top 2 [product_faq.md chunk 2]
## Servicio posventa
Para solicitar un cambio o devolución, asegúrate de que el embalaje del producto esté completo y presenta la solicitud a través del servicio de atención al cliente...

Top 3 [service_agreement.md chunk 5]
## Derechos y obligaciones del usuario
El usuario tiene derecho a solicitar la terminación del contrato durante el periodo de servicio y solicitar el reembolso correspondiente...

¿Qué significa Top K?

Top K indica el número máximo de resultados que queremos recuperar.

Si top_k=3, el sistema devuelve los tres chunks que considera más relevantes.

Un valor demasiado pequeño puede hacer que se pierda información importante. Un valor demasiado grande puede introducir contenido irrelevante y reducir la calidad del Prompt.

Paso 6: enviar los resultados recuperados al LLM

from openai import OpenAI

llm_client = OpenAI(base_url="https://api.openai.com/v1", api_key="your-key")

def build_prompt(query: str, retrieved_docs: list[str]) -> str:
    context = "\n\n---\n\n".join(retrieved_docs)
    return f"""Debes responder a la pregunta utilizando la siguiente información.

Información:
{context}

Pregunta:
{query}

Requisitos:
1. Da prioridad a la información incluida en los documentos
2. Si los documentos no contienen la información necesaria, responde directamente: "Según la información disponible, no puedo responder a esta pregunta"
3. No inventes información que no aparezca en los documentos
4. Cuando sea necesario, indica la fuente utilizada"""

def call_llm(prompt: str) -> str:
    response = llm_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{"role": "user", "content": prompt}],
        temperature=0.1,
    )
    return response.choices[0].message.content

Es importante entender que RAG no hace que el modelo aprenda estos documentos.

La información se introduce temporalmente en el contexto cuando el usuario realiza una consulta. El modelo utiliza esos datos para generar la respuesta, pero no está realizando un nuevo entrenamiento.

Las restricciones incluidas en el Prompt, como «si no encuentras la información, reconoce que no puedes responder», constituyen una primera barrera contra las alucinaciones.

Paso 7: unir todo el proceso

def rag_answer(query: str) -> str:
    """Flujo completo de preguntas y respuestas con RAG"""
    results = retrieve(query, collection, top_k=3)
    retrieved_docs = results["documents"][0]
    sources = [m["source"] for m in results["metadatas"][0]]
    
    prompt = build_prompt(query, retrieved_docs)
    answer = call_llm(prompt)
    
    print(f"\nPregunta: {query}")
    print(f"Se recuperaron {len(retrieved_docs)} fragmentos relevantes. Fuentes: {sources}")
    print(f"Respuesta: {answer}")
    return answer

rag_answer("¿Cuál es el plazo de reembolso?")

En este punto ya tenemos un flujo RAG básico:

Pregunta
   ↓
Embedding de la pregunta
   ↓
Búsqueda vectorial
   ↓
Chunks relevantes
   ↓
Prompt + contexto
   ↓
LLM
   ↓
Respuesta

Paso 8: probar el sistema

test_questions = [
    ("¿Qué sistemas operativos son compatibles?", "Con respuesta"),
    ("¿Cuál es el plazo de reembolso?", "Con respuesta"),
    ("¿Cuántos dispositivos pueden iniciar sesión simultáneamente con una cuenta?", "Con respuesta"),
    ("¿Dónde están vuestras oficinas?", "Sin respuesta"),
    ("¿Cómo se puede cancelar la suscripción?", "Ambigua"),
]

for q, category in test_questions:
    print(f"\n{'='*50}")
    print(f"[{category}] {q}")
    rag_answer(q)

Analizar los resultados

Preguntas con respuesta: la recuperación debería encontrar los fragmentos correctos y el LLM podrá extraer la información relevante para generar una respuesta.

Preguntas sin respuesta: por ejemplo, «¿Dónde están vuestras oficinas?». El sistema puede recuperar fragmentos que no tienen relación con la pregunta. Gracias a las instrucciones del Prompt, el LLM debería responder que no puede encontrar esa información en los documentos disponibles.

Esto demuestra que las restricciones del Prompt pueden ayudar a evitar respuestas inventadas.

Preguntas ambiguas: una pregunta como «¿Cómo se puede cancelar la suscripción?» podría recuperar tanto información de la política de reembolsos como del acuerdo de servicio.

Si los documentos no explican claramente el procedimiento específico para cancelar una suscripción, el modelo debería reconocer que la información disponible no es suficiente en lugar de construir una respuesta a partir de fragmentos parcialmente relacionados.

Paso 9: por qué un RAG puede funcionar mal

Cuando un sistema RAG genera una respuesta incorrecta, no necesariamente significa que el problema esté en el LLM.

El error puede producirse mucho antes, durante la recuperación de información.

Los chunks están mal divididos

Un párrafo sobre el proceso de reembolso puede quedar dividido por la mitad: la primera parte termina en el chunk 1 y la segunda empieza en el chunk 2.

Si la búsqueda solo recupera el chunk 1, el LLM recibe información incompleta.

La información importante queda separada

Las tablas son un caso especialmente problemático.

Si cada fila se convierte en un chunk independiente, puede perderse la información del encabezado. El modelo verá los valores, pero no necesariamente sabrá a qué columna corresponden.

Se recupera información parecida pero incorrecta

Supongamos que el usuario pregunta por el reembolso de una suscripción.

La búsqueda puede recuperar un fragmento sobre «beneficios de la membresía» y otro sobre la «política general de reembolsos».

Ambos contienen palabras relacionadas con la consulta, pero eso no significa que respondan realmente a la pregunta.

El Prompt no impone suficientes restricciones

Si el LLM encuentra información parcialmente relacionada pero no una respuesta completa, puede intentar completar los datos que faltan con una respuesta aparentemente razonable.

El resultado puede sonar convincente y, aun así, ser incorrecto.

Paso 10: optimizar el sistema RAG

Optimización 1: ajustar la estrategia de Chunking

Problema original: utilizar chunks fijos de 500 caracteres provoca que algunos procesos de reembolso queden divididos.

Cambio: reducir chunk_size a 300 y aumentar chunk_overlap a 100.

Los chunks más pequeños pueden mejorar la precisión de recuperación, mientras que un overlap mayor reduce el riesgo de perder información cuando una idea queda dividida.

Resultado: la búsqueda para una pregunta ambigua como «¿Cómo se puede cancelar la suscripción?» puede pasar de recuperar información general sobre beneficios de la membresía a encontrar una cláusula específica sobre el procedimiento de reembolso.

Optimización 2: añadir filtros mediante Metadata

Problema original: el sistema no distingue entre diferentes tipos de documentos. Las cláusulas generales del acuerdo de servicio pueden interferir con la búsqueda de políticas específicas.

Cambio: añadir un campo doc_type a los Metadata:

policy
faq
agreement

Después, el sistema puede utilizar el tipo de consulta para limitar la búsqueda a determinadas categorías de documentos.

Esto resulta especialmente útil cuando una base de conocimiento contiene cientos o miles de documentos de diferentes tipos.

Optimización 3: introducir un Reranker

Problema original: la búsqueda vectorial devuelve cinco resultados, pero el fragmento realmente más relevante aparece en la cuarta posición. Si utilizamos top_k=3, nunca llegará al LLM.

Cambio: primero recuperar los 10 candidatos más relevantes y después utilizar un Reranker, como BGE-Reranker, para volver a clasificarlos.

# Ejemplo de pseudocódigo
candidates = retrieve(query, collection, top_k=10)
reranked = reranker.rerank(query, candidates, top_n=3)

El Reranker analiza directamente la relación entre la pregunta y el contenido original del documento, por lo que puede conseguir una clasificación más precisa que una búsqueda basada únicamente en similitud vectorial.

El flujo pasa a ser:

Pregunta
   ↓
Búsqueda vectorial
   ↓
Top 10 candidatos
   ↓
Reranker
   ↓
Top 3 realmente relevantes
   ↓
LLM

Optimización 4: mejorar el Prompt

Problema original: cuando el documento contiene solo una parte de la información necesaria, el LLM puede intentar completar lo que falta.

Cambio: añadir una instrucción explícita:

«Si los documentos contienen solo una parte de la información, responde únicamente con los datos que aparecen de forma explícita. No deduzcas ni supongas información que no esté mencionada.»

Una instrucción tan sencilla puede reducir las respuestas inventadas cuando el contexto recuperado es incompleto.

Arquitectura final del RAG

Después de estas mejoras, la arquitectura queda así:

【Construcción de la base de conocimiento — offline, una vez】
Documentos
    ↓
Análisis
    ↓
Chunks de 300-500 caracteres
Overlap de 50-100 caracteres
    ↓
Embedding
    ↓
Base de datos vectorial


【Consultas — online, cada vez que el usuario pregunta】
Pregunta
    ↓
Embedding de la pregunta
    ↓
Búsqueda vectorial Top 10
    ↓
Reranker
    ↓
Top 3
    ↓
Contexto + Prompt
    ↓
LLM
    ↓
Respuesta final

Los puntos más importantes de un proyecto RAG

RAG no es lo mismo que una base de datos vectorial.

La base de datos vectorial es solo uno de los componentes. Un sistema RAG completo también incluye la extracción y preparación de documentos, la estrategia de Chunking, los Embeddings, el diseño del Prompt, la recuperación, el Reranking y la evaluación de resultados.

La calidad de la recuperación suele determinar buena parte de la calidad de la respuesta final.

Si el sistema no encuentra el documento correcto, ni siquiera un LLM muy potente puede responder correctamente basándose en ese contexto.

Cuando una respuesta es incorrecta, conviene revisar al menos tres elementos: la estrategia de Chunking, el modelo de Embedding y la configuración de Top K.

El Chunking es una de las partes más importantes y más fáciles de ignorar.

No existe una estrategia universal para todos los documentos.

Los contratos y documentos legales suelen funcionar mejor cuando se dividen por cláusulas. Los artículos largos pueden dividirse siguiendo límites semánticos. El código puede requerir estrategias específicas a nivel de tokens o estructuras sintácticas.

RAG no garantiza que desaparezcan completamente las alucinaciones.

La recuperación puede seleccionar información incorrecta y el LLM todavía puede realizar inferencias no justificadas a partir del contexto recuperado.

Las instrucciones del Prompt y las referencias a las fuentes son medidas importantes para reducir este problema, pero no lo eliminan por completo.

La calidad de los datos de la base de conocimiento puede ser más importante que utilizar un modelo más grande.

Si los documentos contienen información incorrecta, desactualizada o contradictoria, sustituir el LLM por un modelo más potente no resolverá necesariamente el problema.

RAG vs. Fine-tuning

ComparaciónRAGFine-tuning
Conocimiento externoAdecuadoNo es la opción principal
Actualización del conocimientoFácil, basta con actualizar los documentosRequiere volver a entrenar
Base de conocimiento privadaAdecuadoNo siempre es la mejor opción
Cambiar el comportamiento del modeloLimitadoMás adecuado
Dificultad de implementaciónRelativamente bajaMayor
CosteBajo, no requiere GPU para entrenamientoAlto, requiere GPU y tiempo de entrenamiento

Cuándo priorizar RAG

RAG suele ser una buena opción cuando necesitas:

  • conectar documentos privados;

  • trabajar con información que cambia con frecuencia;

  • actualizar la base de conocimiento rápidamente;

  • mantener los costes bajos;

  • proporcionar al modelo información externa sin modificar su comportamiento fundamental.

Cuándo RAG no es necesariamente la respuesta

RAG no siempre es la solución adecuada cuando necesitas cambiar la forma en que el modelo razona o responde.

Por ejemplo, si quieres imponer un estilo de atención al cliente muy concreto, modificar el comportamiento del modelo puede requerir otro enfoque.

Tampoco es necesariamente la mejor solución cuando necesitas que el modelo interiorice determinados patrones de conocimiento o comportamiento, como algunos escenarios especializados de diagnóstico médico.

Por último, si la recuperación continúa funcionando mal después de optimizar Chunking, Embeddings, filtros, Reranking y Prompt, el problema puede no resolverse simplemente añadiendo más documentos o utilizando un LLM más potente.

La idea fundamental es sencilla: RAG no consiste en enseñar un documento al modelo, sino en enseñarle dónde buscar la información correcta cuando necesita responder.