RAG desde Cero (Parte 2): Chatbot que Responde Preguntas sobre la Constitución
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.pydel 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=0hace que el modelo sea completamente determinista (siempre la misma respuesta para la misma entrada). Útil para tests automatizados.temperature=0.1añ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-minies 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
Pregunta 2: Educación
Pregunta 3: Algo que NO está en la Constitución
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:
- ✅ Levantamos Qdrant con Docker (base de datos vectorial)
- ✅ Descargamos la Constitución Española directamente del BOE
- ✅ Diseñamos una estrategia de chunking por artículos
- ✅ Generamos 169 embeddings con OpenAI
text-embedding-3-small - ✅ Indexamos todo en Qdrant con metadatos
- ✅ Construimos el pipeline RAG completo (embed → search → generate)
- ✅ 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
- Código completo en GitHub —
ingest.py+chatbot.pylistos para clonar y ejecutar - Documentación oficial de Qdrant
- qdrant-client Python SDK en GitHub
- OpenAI Embeddings Guide
- API de Datos Abiertos del BOE
- RAG desde Cero - Parte 1: Setup e Indexado
¿Has construido algo con esta base? Contáctame o conectemos en LinkedIn, me encantaría ver qué has montado.