RAG desde Cero (Parte 1): Setup de Qdrant e Indexado de la Constitución Española
Serie RAG desde Cero - Post 1/2
Los LLMs tienen un problema fundamental: para textos estables como la Constitución, el training cutoff no es el mayor riesgo. El riesgo real es que alucinan detalles: citan artículos que no existen, mezclan párrafos, confunden apartados. GPT-4, Claude, Gemini... sin el texto delante, ninguno puede verificar lo que dice. Y tú tampoco puedes saber si lo que responden es correcto o inventado.
RAG (Retrieval-Augmented Generation) resuelve exactamente este problema: en vez de confiar en lo que el modelo "recuerda", le damos el texto real en el momento de la consulta.
En esta serie vamos a construir un chatbot que responde preguntas sobre la Constitución Española consultando el texto oficial del BOE. Sin frameworks mágicos. Sin LangChain. Con Python, Qdrant y la API de OpenAI.
Al final de los dos posts tendrás esto funcionando en tu máquina:
¿Qué es RAG y por qué importa?
Antes de picar código, vale la pena entender la arquitectura. Un sistema RAG tiene dos fases bien diferenciadas:
Fase de indexado (hacemos esto una vez, en este post):
Texto fuente → Chunks → Embeddings → Base de datos vectorial
Fase de consulta (en tiempo real, en el post 2):
Pregunta → Embedding → Búsqueda semántica → Contexto + LLM → Respuesta
Lo clave es la búsqueda semántica: en vez de buscar por palabras exactas ("huelga"), buscamos por similitud de significado. Una pregunta como "¿puedo no ir a trabajar para protestar?" encontrará el artículo sobre el derecho a la huelga aunque no contenga esa palabra.
Eso lo hacen los embeddings: representaciones numéricas del texto que capturan su significado. Textos similares → vectores cercanos en el espacio.
Un vector es simplemente una lista de números. Un embedding es un vector especial: lo genera un modelo de ML que ha aprendido a codificar significado en esas dimensiones. "perro" y "gato" tendrán vectores parecidos; "perro" y "contrato" los tendrán muy distintos. Con 1536 números por texto, text-embedding-3-small captura matices semánticos que una búsqueda por palabras clave no puede.
Y antes de embeddear, necesitas dividir el texto en chunks (fragmentos manejables) porque no embeddeas documentos enteros, sino trozos con sentido propio. Más adelante veremos cómo hacerlo bien.
Por qué la Constitución Española
Podría haber elegido Wikipedia, el Quijote o documentos farmacéuticos de la EMA. Elegí la Constitución por tres razones:
- Caso de uso real e inmediato: "¿Qué dice la ley sobre X?" es una pregunta que hace todo el mundo.
- Estructura perfecta para chunking: 169 artículos numerados = chunks naturales.
- Texto oficial y gratuito: disponible en el BOE con licencia de reutilización libre.
El dataset tiene unos 85.000 caracteres (~21.000 tokens). Generar todos los embeddings costará menos de 0,05 céntimos de euro.
Prerrequisitos
| Requisito | Versión | Obligatorio |
|---|---|---|
| Python | 3.11+ | Sí |
| Docker Desktop | Último | Sí |
| OpenAI API key | — | Sí |
| Conocimientos Python | Básico | Sí |
💰 Costes reales: Los embeddings de todo el dataset cuestan ~€0,0004. Las consultas del chatbot cuestan ~€0,001 cada una con
gpt-4.1-mini. Esto es esencialmente gratis para aprender.
Estructura del proyecto
Vamos a crear un directorio limpio:
mkdir rag-constitucion-espanola && cd rag-constitucion-espanola
python -m venv venv
source venv/bin/activate # Windows: venv\Scripts\activate
pip install qdrant-client openai requests python-dotenv
La estructura final quedará así:
rag-constitucion/
├── ingest.py # Descarga, chunking e indexado (este post)
├── chatbot.py # Pipeline RAG + chatbot (post 2)
├── .env # OPENAI_API_KEY=sk-...
└── requirements.txt
Setup de Qdrant con Docker
Qdrant es una base de datos vectorial open source escrita en Rust. Rápida, fácil de usar localmente y con un cliente Python oficial excelente.
Levantarlo con Docker es un one-liner:
docker run -d \
--name qdrant \
-p 6333:6333 \
-v €(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant
Esto levanta Qdrant en http://localhost:6333 con persistencia en ./qdrant_storage. Si reinicias el ordenador, los datos siguen ahí.
Verifica que funciona:
curl http://localhost:6333/collections
# {"result":{"collections":[]},"status":"ok","time":5.691e-6}
💡 Dashboard visual: Qdrant incluye una UI en
http://localhost:6333/dashboard. Puedes explorar colecciones, ver puntos y hacer búsquedas de prueba directamente desde el navegador.
Descargando la Constitución del BOE
El BOE tiene una API de datos abiertos que expone cada artículo como un bloque independiente. Mucho mejor que scraping HTML: los endpoints son estables, están documentados y devuelven datos estructurados.
El flujo es:
- Llamamos al índice JSON → nos dice qué bloques existen y sus IDs (
a1,a2...a169) - Por cada artículo, llamamos al endpoint de bloque en XML → obtenemos su texto limpio
# ingest.py
import os
import re
import time
import requests
import xml.etree.ElementTree as ET
from openai import OpenAI
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from dotenv import load_dotenv
load_dotenv()
OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
QDRANT_URL = "http://localhost:6333"
COLLECTION_NAME = "constitucion_espanola"
EMBEDDING_MODEL = "text-embedding-3-small"
EMBEDDING_DIM = 1536
BOE_ID = "BOE-A-1978-31229"
BOE_API = f"https://www.boe.es/datosabiertos/api/legislacion-consolidada/id/{BOE_ID}"
openai_client = OpenAI(api_key=OPENAI_API_KEY)
qdrant_client = QdrantClient(url=QDRANT_URL)
def obtener_ids_articulos() -> list[str]:
"""
Llama al índice JSON de la API del BOE y devuelve los IDs de los artículos.
Los artículos tienen IDs con formato 'a1', 'a2', ..., 'a169'.
"""
r = requests.get(
f"{BOE_API}/texto/indice",
headers={"Accept": "application/json"},
timeout=15,
)
r.raise_for_status()
data = r.json()
return [
bloque["id"]
for grupo in data.get("data", [])
for bloque in grupo.get("bloque", [])
if re.fullmatch(r"a\d+", bloque.get("id", ""))
]
def obtener_texto_bloque(bloque_id: str) -> str:
"""
Descarga el XML de un artículo y extrae su texto completo.
"""
r = requests.get(
f"{BOE_API}/texto/bloque/{bloque_id}",
headers={"Accept": "application/xml"},
timeout=15,
)
r.raise_for_status()
root = ET.fromstring(r.content)
parrafos = [
p.text.strip()
for p in root.iter("p")
if p.text and p.text.strip()
]
return " ".join(parrafos)
Estrategia de chunking: por artículos
El chunking es probablemente la decisión más importante en un sistema RAG. Chunks demasiado pequeños pierden contexto; demasiado grandes meten ruido y desperdician la ventana de contexto del LLM.
La Constitución nos lo pone fácil: cada artículo es ya una unidad semántica natural. El artículo 28 habla del derecho de sindicación. El artículo 37, de la negociación colectiva. Tienen sentido completo por sí solos.
Y la API del BOE alinea perfectamente: cada artículo es un bloque independiente con su propio endpoint. El "chunking" es simplemente llamar a la API una vez por artículo:
def descargar_constitucion() -> list[dict]:
"""
Descarga la Constitución artículo a artículo usando la API del BOE.
Devuelve una lista de chunks listos para indexar.
"""
print(" Obteniendo índice de artículos...")
ids_articulos = obtener_ids_articulos()
print(f" {len(ids_articulos)} artículos encontrados.")
chunks = []
for i, bloque_id in enumerate(ids_articulos):
num_articulo = int(bloque_id[1:]) # 'a28' → 28
print(f" [{i+1}/{len(ids_articulos)}] Artículo {num_articulo}...", end="\r")
texto = obtener_texto_bloque(bloque_id)
chunks.append({
"id": i,
"num_articulo": num_articulo,
"texto": texto,
})
time.sleep(0.1) # Respetar rate limits del BOE
print()
return chunks
¿Por qué no usar un RecursiveCharacterTextSplitter fijo de 500 tokens como en la mayoría de tutoriales? Porque partiría artículos por la mitad, mezclando derechos fundamentales con sus excepciones o con el artículo siguiente. Respetar la estructura original del documento da mejores resultados de recuperación.
💡 Regla de chunking: usa la estructura semántica del documento cuando existe. En legislación → artículos. En documentación técnica → secciones. En emails → cada email completo. Recurre a chunking por tokens solo cuando el texto no tiene estructura.
Generando embeddings con OpenAI
Los embeddings de text-embedding-3-small tienen 1536 dimensiones y cuestan €0,02 por millón de tokens. Para todo el dataset de la Constitución esto sale a menos de medio céntimo de euro.
def crear_embedding(texto: str) -> list[float]:
"""Genera un vector de 1536 dimensiones para el texto dado."""
resp = openai_client.embeddings.create(
input=texto,
model=EMBEDDING_MODEL,
)
return resp.data[0].embedding
💡 Alternativa gratuita: Si no quieres usar la API de OpenAI para el indexado, puedes usar
sentence-transformerscon el modeloparaphrase-multilingual-mpnet-base-v2(768 dims, buen soporte para español). Ten en cuenta que deberás usar el mismo modelo también en el chatbot (Post 2):from sentence_transformers import SentenceTransformer model = SentenceTransformer("paraphrase-multilingual-mpnet-base-v2") EMBEDDING_DIM = 768 # Ajusta también este valor en crear_coleccion() def crear_embedding(texto: str) -> list[float]: return model.encode(texto, normalize_embeddings=True).tolist()Corre en CPU sin coste, aunque es más lento (~25s en un i7 estándar vs ~15s vía API).
Creando la colección en Qdrant
Antes de insertar vectores necesitamos crear una colección en Qdrant, que es equivalente a una tabla en SQL pero optimizada para búsqueda vectorial.
Los parámetros clave son:
size: dimensiones del vector (1536 paratext-embedding-3-small)distance: métrica de similitud (COSINEpara embeddings de texto)
def crear_coleccion():
"""Crea (o recrea) la colección en Qdrant."""
colecciones_existentes = [
c.name for c in qdrant_client.get_collections().collections
]
if COLLECTION_NAME in colecciones_existentes:
print(f" Colección '{COLLECTION_NAME}' ya existe, recreando...")
qdrant_client.delete_collection(COLLECTION_NAME)
qdrant_client.create_collection(
collection_name=COLLECTION_NAME,
vectors_config=VectorParams(size=EMBEDDING_DIM, distance=Distance.COSINE),
)
print(f" Colección '{COLLECTION_NAME}' lista.")
Indexando los artículos
Con la colección creada, generamos el embedding de cada artículo y lo guardamos en Qdrant junto con el texto original como payload (metadatos).
El payload es fundamental: cuando Qdrant nos devuelva los artículos más relevantes para una pregunta, necesitamos recuperar el texto original para enviárselo al LLM.
def indexar_chunks(chunks: list[dict]):
"""Genera embeddings e inserta todos los chunks en Qdrant por lotes."""
puntos = []
total = len(chunks)
for i, chunk in enumerate(chunks):
print(f" [{i+1}/{total}] Artículo {chunk['num_articulo']}...", end="\r")
embedding = crear_embedding(chunk["texto"])
puntos.append(
PointStruct(
id=chunk["id"],
vector=embedding,
payload={
"texto": chunk["texto"],
"num_articulo": chunk["num_articulo"],
},
)
)
# Respetar rate limits de la API de OpenAI
time.sleep(0.05)
# Insertar todos los puntos de una vez (más eficiente que uno a uno)
qdrant_client.upsert(
collection_name=COLLECTION_NAME,
points=puntos,
)
print(f"\n ✅ {len(puntos)} artículos indexados.")
Usamos upsert en vez de insert para que sea idempotente: si ejecutas el script dos veces, actualiza los puntos existentes en vez de fallar.
El script completo
Juntamos todo en el bloque main:
if __name__ == "__main__":
print("📥 Descargando Constitución del BOE (API)...")
chunks = descargar_constitucion()
print("🗄️ Preparando colección en Qdrant...")
crear_coleccion()
print("🧠 Generando embeddings e indexando...")
print(" (Esto tardará ~3 minutos y costará <$0.001)")
indexar_chunks(chunks)
print("\n🎉 ¡Listo! Base vectorial lista en Qdrant.")
print(f" Colección: {COLLECTION_NAME}")
print(f" Puntos indexados: {len(chunks)}")
print(f" Dashboard: http://localhost:6333/dashboard")
Ejecútalo:
python ingest.py
Verás algo así:
📥 Descargando Constitución del BOE (API)...
Obteniendo índice de artículos...
169 artículos encontrados.
[169/169] Artículo 169...
🗄️ Preparando colección en Qdrant...
Colección 'constitucion_espanola' lista.
🧠 Generando embeddings e indexando...
(Esto tardará ~3 minutos y costará <$0.001)
[169/169] Artículo 169...
✅ 169 artículos indexados.
🎉 ¡Listo! Base vectorial lista en Qdrant.
Colección: constitucion_espanola
Puntos indexados: 169
Dashboard: http://localhost:6333/dashboard
Verificando el indexado: primera búsqueda semántica
Antes de construir el chatbot, comprobemos que la búsqueda vectorial funciona con una consulta directa a Qdrant:
# Puedes ejecutar esto en un script de prueba o en un Python REPL
def busqueda_rapida(pregunta: str, top_k: int = 3):
"""Búsqueda semántica directa sin LLM."""
# 1. Generar embedding de la pregunta
resp = openai_client.embeddings.create(
input=pregunta,
model=EMBEDDING_MODEL,
)
query_vector = resp.data[0].embedding
# 2. Buscar en Qdrant
resultados = qdrant_client.query_points(
collection_name=COLLECTION_NAME,
query=query_vector,
limit=top_k,
).points
# 3. Mostrar resultados
print(f"\nPregunta: {pregunta}\n")
for r in resultados:
print(f" [Art. {r.payload['num_articulo']}] Score: {r.score:.3f}")
print(f" {r.payload['texto'][:150]}...")
print()
# Prueba
busqueda_rapida("¿Tengo derecho a la educación gratuita?")
Resultado esperado:
Pregunta: ¿Tengo derecho a la educación gratuita?
[Art. 27] Score: 0.847
Artículo 27. Todos tienen el derecho a la educación. Se reconoce la libertad
de enseñanza. La educación tendrá por objeto el pleno desarrollo de la...
[Art. 39] Score: 0.621
Artículo 39. Los poderes públicos aseguran la protección social, económica
y jurídica de la familia...
[Art. 49] Score: 0.598
Artículo 49. Los poderes públicos realizarán una política de previsión,
tratamiento, rehabilitación e integración de las personas con discapacidad...
El artículo 27 (derecho a la educación) aparece primero con score 0.847. Los demás artículos tienen menor similitud semántica. Exactamente lo que queremos.
Lo que hemos construido
En este post hemos:
- ✅ Levantado Qdrant localmente con Docker
- ✅ Descargado el texto oficial de la Constitución del BOE
- ✅ Diseñado una estrategia de chunking por artículos (respetando la semántica)
- ✅ Generado 169 embeddings con
text-embedding-3-small - ✅ Indexado todo en Qdrant con metadatos (número de artículo + texto)
- ✅ Verificado que la búsqueda semántica funciona
Lo que falta es la segunda mitad: tomar esos artículos recuperados y usarlos como contexto para que un LLM genere una respuesta en lenguaje natural. Eso es el pipeline RAG completo, y lo construimos en el siguiente post.
Consideraciones y limitaciones del indexado
Antes de continuar, honestidad sobre lo que puede fallar:
Calidad del chunking: artículos muy cortos (algunos tienen solo 2 líneas) o muy largos (el artículo 149 lista 32 competencias) pueden dar resultados subóptimos. Para un sistema en producción, considerar chunking con overlap o enriquecer los chunks con contexto del Título al que pertenecen.
Embeddings para texto legal: text-embedding-3-small es un modelo general de uso, no específico para texto jurídico en español. Funciona bien, pero modelos como multilingual-e5-large o embeddings específicos para derecho pueden dar mejor recall en consultas técnicas.
El código completo está en github.com/fparis-sp/rag-constitucion-espanola — clona, instala dependencias y ejecuta python ingest.py.
¿Tienes preguntas sobre el setup? Contáctame o conectemos en LinkedIn.