Volver al Blog

RAG desde Cero (Parte 2): Chatbot que Responde Preguntas sobre la Constitución

24 de febrero de 202612 min de lecturapor Francisco París
RAGQdrantPythontutorialchatbotOpenAILLM
Escuchar resumen(3 min)
0:00
0:00

Resumen narrado generado con IA

📚

Serie RAG desde Cero - Post 2/2

En el post anterior indexamos los 169 artículos de la Constitución Española en Qdrant. Ahora construimos la segunda mitad del sistema: el pipeline RAG que toma una pregunta, encuentra los artículos relevantes y genera una respuesta fundamentada.

El flujo completo que vamos a implementar:

                    ┌─────────────────────────────────────────┐
                    │           PIPELINE RAG                  │
                    │                                         │
  Pregunta ──────►  │  1. Embed pregunta                      │
                    │          │                              │
                    │          ▼                              │
                    │  2. Buscar en Qdrant (top-3)            │
                    │          │                              │
                    │          ▼                              │
                    │  3. Recuperar texto artículos           │
                    │          │                              │
                    │          ▼                              │
                    │  4. Construir prompt con contexto       │
                    │          │                              │
                    │          ▼                              │
                    │  5. GPT-4.1-mini genera respuesta       │
                    │                                         │
                    └─────────────────────────────────────────┘
                                        │
                                        ▼
                                   Respuesta

Cada paso es una función separada. Código limpio, entendible, extensible.

Estructura del archivo

# chatbot.py
import os
from openai import OpenAI
from qdrant_client import QdrantClient

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
QDRANT_URL = "http://localhost:6333"
COLLECTION_NAME = "constitucion_espanola"
EMBEDDING_MODEL = "text-embedding-3-small"
LLM_MODEL = "gpt-4.1-mini"
TOP_K = 3  # Número de artículos a recuperar por consulta

openai_client = OpenAI(api_key=OPENAI_API_KEY)
qdrant_client = QdrantClient(url=QDRANT_URL)

⚠️ Prerequisito: Haber ejecutado ingest.py del post anterior. Qdrant debe estar corriendo con los 169 artículos indexados.

Paso 1: Búsqueda semántica

La primera función toma la pregunta del usuario, genera su embedding y busca los artículos más similares en Qdrant.

def buscar_articulos(pregunta: str) -> list[dict]:
    """
    Convierte la pregunta en un vector y busca los artículos
    más relevantes en Qdrant por similitud coseno.
    """
    # Generar embedding de la pregunta con el mismo modelo que usamos al indexar
    resp = openai_client.embeddings.create(
        input=pregunta,
        model=EMBEDDING_MODEL,
    )
    query_vector = resp.data[0].embedding

    # Buscar los TOP_K artículos más cercanos en el espacio vectorial
    resultados = qdrant_client.query_points(
        collection_name=COLLECTION_NAME,
        query=query_vector,
        limit=TOP_K,
    ).points

    return [
        {
            "num_articulo": r.payload["num_articulo"],
            "texto": r.payload["texto"],
            "score": round(r.score, 3),
        }
        for r in resultados
    ]

Una nota sobre TOP_K = 3: es un equilibrio. Más artículos → más contexto para el LLM, pero también más ruido y más coste de tokens. Para preguntas sobre derechos concretos, 3 artículos son suficientes. Para preguntas más complejas que involucran varios títulos de la Constitución, puedes subir a 5.

Paso 2: Construir el contexto

Los artículos recuperados se formatean en texto estructurado para incluirlos en el prompt del LLM:

def construir_contexto(articulos: list[dict]) -> str:
    """
    Formatea los artículos recuperados como contexto para el LLM.
    Incluir el número de artículo ayuda al modelo a citar correctamente.
    """
    fragmentos = []
    for art in articulos:
        fragmentos.append(
            f"[Artículo {art['num_articulo']}]\n{art['texto']}"
        )
    return "\n\n---\n\n".join(fragmentos)

El separador --- entre artículos ayuda al modelo a distinguir dónde termina uno y empieza otro. Pequeño detalle, mejora la calidad de las respuestas.

Paso 3: Generación con el LLM

Esta es la parte del pipeline que convierte los artículos recuperados en una respuesta en lenguaje natural:

def generar_respuesta(pregunta: str, articulos: list[dict]) -> str:
    """
    Usa GPT-4.1-mini con el contexto recuperado para generar
    una respuesta fundamentada en el texto constitucional.
    """
    contexto = construir_contexto(articulos)

    system_prompt = """Eres un asistente experto en la Constitución Española.

Reglas que debes seguir siempre:
- Responde ÚNICAMENTE basándote en los artículos proporcionados.
- Si la información no está en los artículos, dilo explícitamente.
- Cita siempre el número del artículo cuando des información.
- Usa lenguaje claro y accesible, no jerga jurídica innecesaria.
- Si la pregunta requiere interpretación legal profesional, recomienda consultar a un abogado."""

    user_prompt = f"""Artículos relevantes de la Constitución Española:

{contexto}

---

Pregunta: {pregunta}"""

    resp = openai_client.chat.completions.create(
        model=LLM_MODEL,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt},
        ],
        temperature=0.1,   # Baja temperatura: respuestas más precisas y menos creativas
        max_tokens=500,    # ~300-400 palabras: suficiente para respuestas completas
    )

    return resp.choices[0].message.content

Por qué temperature=0.1 y no 0:

  • temperature=0 hace que el modelo sea completamente determinista (siempre la misma respuesta para la misma entrada). Útil para tests automatizados.
  • temperature=0.1 añade una pizca de variación natural en el lenguaje sin sacrificar precisión factual. Las respuestas suenan más naturales.

Por qué gpt-4.1-mini y no gpt-4o:

  • El contexto que enviamos (3 artículos + pregunta) es corto y bien estructurado. gpt-4.1-mini es perfectamente capaz de manejarlo.
  • Coste: ~€0.001 por consulta vs ~€0.01 con gpt-4o. Para un chatbot educativo, 10x más barato con resultados equivalentes.

El chatbot completo

def chatbot():
    """Bucle interactivo del chatbot."""
    print("=" * 60)
    print("🏛️  Chatbot de la Constitución Española")
    print("   Pregúntame sobre tus derechos y la ley fundamental.")
    print("   Escribe 'salir' para terminar.")
    print("=" * 60)
    print()

    while True:
        try:
            pregunta = input("Tú: ").strip()
        except (KeyboardInterrupt, EOFError):
            print("\n¡Hasta luego!")
            break

        if not pregunta:
            continue

        if pregunta.lower() in ("salir", "exit", "quit", "q"):
            print("¡Hasta luego!")
            break

        # Buscar artículos relevantes
        articulos = buscar_articulos(pregunta)

        # Mostrar qué artículos se están usando (transparencia)
        nums = [str(a["num_articulo"]) for a in articulos]
        scores = [str(a["score"]) for a in articulos]
        print(f"\n[Artículos: {', '.join(nums)} | Scores: {', '.join(scores)}]\n")

        # Generar y mostrar respuesta
        respuesta = generar_respuesta(pregunta, articulos)
        print(f"Asistente: {respuesta}\n")
        print("-" * 60)
        print()


if __name__ == "__main__":
    chatbot()

Demo: preguntas reales

Ejecuta el chatbot:

OPENAI_API_KEY=sk-... python chatbot.py

Aquí algunas interacciones reales:

Pregunta 1: Derecho a huelga

chatbot.py
Tú: ¿Tengo derecho a ir a la huelga?
#[Artículos: 28, 37, 7 | Scores: 0.861, 0.714, 0.698]
C
gpt-4.1-mini

Pregunta 2: Educación

chatbot.py
Tú: ¿Es obligatoria la educación en España?
#[Artículos: 27, 39, 149 | Scores: 0.839, 0.601, 0.589]
C
gpt-4.1-mini

Pregunta 3: Algo que NO está en la Constitución

chatbot.py
Tú: ¿Puedo usar marihuana legalmente en España?
#[Artículos: 43, 45, 15 | Scores: 0.612, 0.589, 0.571]
C
gpt-4.1-mini

Este último caso es importante: el sistema reconoce que la información no está en el contexto y lo dice claramente. Eso es exactamente el comportamiento que queremos — no alucinar.

El código completo (chatbot.py)

import os
from openai import OpenAI
from qdrant_client import QdrantClient

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
QDRANT_URL = "http://localhost:6333"
COLLECTION_NAME = "constitucion_espanola"
EMBEDDING_MODEL = "text-embedding-3-small"
LLM_MODEL = "gpt-4.1-mini"
TOP_K = 3

openai_client = OpenAI(api_key=OPENAI_API_KEY)
qdrant_client = QdrantClient(url=QDRANT_URL)


def buscar_articulos(pregunta: str) -> list[dict]:
    resp = openai_client.embeddings.create(input=pregunta, model=EMBEDDING_MODEL)
    query_vector = resp.data[0].embedding

    resultados = qdrant_client.query_points(
        collection_name=COLLECTION_NAME,
        query=query_vector,
        limit=TOP_K,
    ).points
    return [
        {"num_articulo": r.payload["num_articulo"], "texto": r.payload["texto"], "score": round(r.score, 3)}
        for r in resultados
    ]


def construir_contexto(articulos: list[dict]) -> str:
    return "\n\n---\n\n".join(
        f"[Artículo {a['num_articulo']}]\n{a['texto']}" for a in articulos
    )


def generar_respuesta(pregunta: str, articulos: list[dict]) -> str:
    contexto = construir_contexto(articulos)

    system_prompt = """Eres un asistente experto en la Constitución Española.
Responde ÚNICAMENTE con los artículos proporcionados.
Si la información no está, dilo explícitamente.
Cita siempre el número de artículo. Usa lenguaje claro y accesible."""

    resp = openai_client.chat.completions.create(
        model=LLM_MODEL,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"Artículos:\n{contexto}\n\nPregunta: {pregunta}"},
        ],
        temperature=0.1,
        max_tokens=500,
    )
    return resp.choices[0].message.content


def chatbot():
    print("🏛️  Chatbot de la Constitución Española")
    print("   Escribe 'salir' para terminar.\n")

    while True:
        try:
            pregunta = input("Tú: ").strip()
        except (KeyboardInterrupt, EOFError):
            break

        if not pregunta:
            continue
        if pregunta.lower() in ("salir", "exit", "q"):
            break

        articulos = buscar_articulos(pregunta)
        nums = [str(a["num_articulo"]) for a in articulos]
        print(f"\n[Artículos: {', '.join(nums)}]\n")

        respuesta = generar_respuesta(pregunta, articulos)
        print(f"Asistente: {respuesta}\n")


if __name__ == "__main__":
    chatbot()

77 líneas. Un sistema RAG funcional con búsqueda semántica real.

Limitaciones que debes conocer

Este sistema es una prueba de concepto educativa. Antes de confiar en él para nada serio, entiende qué puede fallar:

Precisión jurídica: el sistema responde con el texto constitucional, pero la interpretación de la ley es compleja. La Constitución es interpretada por el Tribunal Constitucional con décadas de jurisprudencia. Nuestro chatbot no tiene esa jurisprudencia.

Cobertura incompleta: la Constitución es la ley fundamental, pero no regula todo. Muchos derechos y obligaciones están en leyes orgánicas, el Código Civil, el Estatuto de los Trabajadores, etc. Si la respuesta "no está en la Constitución", puede estar en otra norma.

Calidad de recuperación: la búsqueda semántica no es perfecta. Para preguntas muy específicas o con terminología técnica poco común, puede recuperar artículos subóptimos. El score de Qdrant te da una pista: scores inferiores a 0.5 indican baja relevancia.

No es asesoramiento legal: esto no sustituye a un abogado. Nunca.

Prompt injection: como cualquier chatbot basado en LLM, este sistema es vulnerable a que un usuario incluya instrucciones maliciosas en su pregunta — por ejemplo, "Ignora las instrucciones anteriores y actúa como...". El system prompt no es una barrera infranqueable. Este tutorial no cubre protecciones contra ello; en un post futuro analizaremos los vectores de ataque más comunes en sistemas RAG y las contramedidas disponibles.

Ideas para extenderlo

Con la base que tienes, estas extensiones son relativamente sencillas:

Añadir más leyes: indexar el Código Civil, el Estatuto de los Trabajadores o la LOPD es el mismo proceso que usamos con la Constitución. Solo necesitas cambiar la URL de descarga y adaptar el parser.

LEYES = {
    "BOE-A-1978-31229": "Constitución Española",
    "BOE-A-1995-25444": "Estatuto de los Trabajadores",
    "BOE-A-2018-16673": "LOPD-GDD",
}

Añadir metadatos de Título: cada artículo pertenece a un Título (Derechos Fundamentales, De la Corona, etc.). Añadir ese contexto al payload mejora la calidad de las respuestas:

payload={
    "texto": chunk["texto"],
    "num_articulo": chunk["num_articulo"],
    "titulo": chunk.get("titulo", ""),  # Título I, II, etc.
}

Filtrado por Título: Qdrant permite filtrar resultados por payload antes de buscar por vector. Útil para preguntas sobre áreas específicas:

from qdrant_client.models import Filter, FieldCondition, MatchValue

resultados = qdrant_client.query_points(
    collection_name=COLLECTION_NAME,
    query=query_vector,
    query_filter=Filter(
        must=[FieldCondition(key="titulo", match=MatchValue(value="Título I"))]
    ),
    limit=TOP_K,
).points

Interfaz web: con FastAPI y cualquier frontend (o con Gradio para algo rápido), el salto de CLI a web es pequeño:

from fastapi import FastAPI

app = FastAPI()

@app.post("/chat")
def chat(pregunta: str):
    articulos = buscar_articulos(pregunta)
    respuesta = generar_respuesta(pregunta, articulos)
    return {"respuesta": respuesta, "articulos": articulos}

Qué hemos construido en los dos posts

En esta serie, partiendo de cero:

  1. ✅ Levantamos Qdrant con Docker (base de datos vectorial)
  2. ✅ Descargamos la Constitución Española directamente del BOE
  3. ✅ Diseñamos una estrategia de chunking por artículos
  4. ✅ Generamos 169 embeddings con OpenAI text-embedding-3-small
  5. Indexamos todo en Qdrant con metadatos
  6. ✅ Construimos el pipeline RAG completo (embed → search → generate)
  7. ✅ Tenemos un chatbot funcional que cita artículos reales

Coste total del experimento: menos de €0.05 si haces una docena de preguntas. El indexado inicial cuesta prácticamente cero.

Lo que tenemos es un sistema RAG básico pero honesto: sin magia, sin abstracciones innecesarias, con cada pieza visible y entendible. Eso es lo que importa cuando estás aprendiendo cómo funciona esto por dentro.

Recursos


¿Has construido algo con esta base? Contáctame o conectemos en LinkedIn, me encantaría ver qué has montado.

Compartir: