No «que procura no inventárselas»: que no puede, por construcción.
El modelo nunca escribe una referencia legal — escribe un hueco numerado, y es el código quien lo
resuelve contra lo que la búsqueda trajo.
La respuesta llega token a token, aparece el hueco [[REF:1]], y el código lo
resuelve al artículo 82 · ver la demo completa (35 s)
Qué resuelve · Arranque · Uso · Arquitectura · Los números · Lo que no garantiza · Stack
Un RAG convencional recupera documentos y deja que el modelo redacte la respuesta mencionando sus fuentes. El fallo vive en esa última palabra: mencionar es escribir. Nada impide que el modelo escriba «artículo 47» cuando el 47 no dice eso, y una referencia que suena bien es indistinguible de una correcta para quien la lee.
En un tutor de normativa eso no es un defecto estético: un estudiante que memoriza un artículo equivocado suspende, y no tiene forma de saber que la cita era falsa.
| RAG convencional | Citebound | |
|---|---|---|
| Quién escribe la referencia | el modelo | el código |
| Qué puede citar | cualquier cosa que sepa escribir | solo lo que la búsqueda trajo |
| El fragmento citado | lo redacta el modelo | se copia del BOE, literal |
| Si no hay respuesta | improvisa | se abstiene, y dice su motivo |
| Python | 3.12, fijado — ver STACK.md |
| Gestor | uv |
| Docker | solo para PostgreSQL con pgvector |
| Modelos | Ollama, en el host o en otra máquina de la red |
uv sync # dependencias, desde el lockfile
ollama pull qwen3.5:4b # generador
ollama pull qwen3-embedding:0.6b # recuperación
export OLLAMA_URL=http://localhost:11434 # lo leen `make up` y `make warm`
make up # PostgreSQL + pgvector, y espera a que esté sano
make ingest # indexa el corpus congelado (RD 1428/2003)
make smoke-f0 # humo: ingesta + una cita que existe de verdadmake api # el motor, en :8000 (CITEBOUND_API_PORT=8100 si el puerto está ocupado)
make ui # la web, en :8080 — en otra terminalY abrir http://localhost:8080.
Note
Los modelos no corren dentro de Docker: el runtime vive en el host y el reordenador en
proceso — decidido y medido en docs/STACK.md. El reordenador
(BAAI/bge-reranker-v2-m3) se descarga solo la primera vez.
Arráncalo con make y no con uvicorn a pelo: el Makefile exporta CITEBOUND_MODELO, y sin
esa variable el código pide qwen3.5:4b-mlx, otro tag que hay que descargar aparte.
Si los modelos corren en otra máquina de la red
Hacen falta las tres variables: OLLAMA_URL la leen los objetivos del Makefile, y las otras dos
el código.
export OLLAMA_URL=http://192.168.1.50:11434
export OPENAI_BASE_URL=http://192.168.1.50:11434/v1
export CITEBOUND_MODELO=qwen3.5:4bUn examen tipo test, pregunta a pregunta. Cada respuesta trae el fragmento del reglamento que la sostiene con su referencia exacta, y un modelo bayesiano estima tu dominio por materia para elegir la siguiente pregunta por la que menos se puede predecir — la que más información aporta sobre lo que sabes.
El motor habla HTTP y emite SSE, así que la respuesta llega mientras se escribe.
curl -N -X POST 'http://localhost:8000/ask/stream?pregunta=¿Límite%20en%20vía%20urbana?'event: sources
data: {"fuentes": [{"n": 1, "legal_ref": "RD-1428/2003#art50.1", "titulo": "Artículo 50…"}]}
event: token
data: {"texto": "El límite genérico en vía urbana es de "}
event: citations
data: {"citas": [{"n": 1, "legal_ref": "RD-1428/2003#art50.1", "quote": "…"}]}
event: done
data: {"latencias_ms": {"ttfs": 553, "ttft": 1188}, "modelo": "qwen3.5:4b", …}
Siete eventos con contrato congelado en docs/RULES.md §2.2 y su snapshot en
tests/contract/: sources, token, retract, citations, abstain, done, error.
GET /metrics expone Prometheus y GET /health el chequeo de vida. Ninguno pasa por el límite de
tasa, porque el día que el sistema está saturado el recolector no puede llevarse un 429.
uv run citebound ask "¿Cuándo hay que usar la luz antiniebla trasera?"Important
Es el recuperador desnudo, no el producto: imprime el artículo que la búsqueda trajo, y lo
dice en su última línea. La cita cerrada —con verificación literal, retractaciones y abstención—
se sirve solo por POST /ask/stream.
Tres decisiones sostienen la garantía, y cada una está donde está por un motivo medido.
|
1 · El modelo escribe huecos Se le dan cinco artículos numerados Si aparece un número fuera de rango, el guardia corta en el token en que aparece, no al final: el marcador malo nunca llega a la pantalla. |
2 · El fragmento se copia El modelo señala qué parte le respalda; el texto lo copia el programa del BOE y lo coteja carácter a carácter, tras una normalización declarada. Una cita no literal no se publica. Es un invariante, no una métrica: no tiene umbral que bajar. |
3 · Callarse es una salida Cuando lo recuperado no viene a cuento, el sistema se abstiene con su motivo. Y se mide en los dos sentidos: callarse cuando había respuesta es un fallo, y responder cuando no la había, otro. |
Híbrida: vectorial (HNSW coseno sobre pgvector) y léxica (ts_rank_cd de PostgreSQL — se llama
así, no BM25, porque no hay extensión BM25 instalada), fusionadas con RRF y reordenadas por un
cross-encoder que corre en proceso.
| TDD por zonas | domain/ e ingest/ exigen test primero y propiedades con Hypothesis; api/ y providers/ lo prohíben y lo sustituyen por contrato y grabación |
| Mutación | mutmut sobre las zonas de TDD obligatorio — lo único que distingue cobertura de verificación |
| Una sola definición de «hecho» | make done MILESTONE=N devuelve 0 o 1. No hay «casi hecho» |
| Suite adversarial permanente | chunk envenenado, fuga de prompt, fuera de dominio y homoglifos. El chunk envenenado no se borra: es el test, no un residuo |
| Determinismo | dos corridas de make eval producen el mismo informe byte a byte |
make gate-fast # lint + tipos + tests rápidos + anti-gaming
make gate-full # + integración + contrato + secretos
make done MILESTONE=6 # + metas, lock y mutación. La única definición de «hecho»Medido con qwen3.5:4b sobre el golden set v2, 274 preguntas, anotado a mano por una persona
—15,3 h de revisión sobre la cola de 304 casos de la que salió la v1— e índice apartado-v1.
Cada número lleva la versión de prompt con la que se midió. No todos se remidieron a la vez, y poner una sola versión para toda la tabla sería falso.
| valor | n |
prompt | qué significa | |
|---|---|---|---|---|
G-HALLUC |
0 | 207 | v22 | referencias inventadas. Cota superior al 95 %: ≤ 1,44 % |
G-HALLUC-AMPLIO |
0 | 2.000 | v20 | lo mismo sobre 2.000 preguntas sintéticas. Cota: ≤ 0,15 % |
G-QUOTE-LIT |
1,00 | 207 | v22 | fragmentos que están literalmente en su artículo |
G-INJECT |
0 | 13 | v20 | ataques de inyección obedecidos |
Warning
Un cero sin su n no dice nada. Cero de 2.000 no autoriza a decir «no alucina nunca»:
autoriza a decir «menos de un 0,15 %, con un 95 % de confianza». Es la diferencia entre una
medida y un eslogan.
| valor | n |
prompt | |
|---|---|---|---|
G-CITA-PRECISION |
0,700 | 207 | v22 |
G-COBERTURA |
0,912 | 216 | v22 |
G-FAITH-JUEZ |
0,705 | 207 | v22 |
G-ABST-FP · G-ABST-FN |
0,088 · 0,172 | 216 · 58 | v22 |
G-RECALL5 · G-RECALL30 |
0,792 · 0,963 | 216 | — |
G-TTFT p95 hasta el primer token |
1.188 ms | 55×3 | v22 |
G-COLD-CACHE de un clon a la primera cita |
18,7 s | 1 | v20 |
Los dos de recall no llevan versión de prompt: miden la búsqueda, que es anterior al generador.
Latencias con el protocolo de bench/protocol.md: 60 peticiones, se descartan
las 5 primeras, 3 repeticiones, se publica el máximo de los tres p95, con el hardware declarado
en docs/GOALS.yaml.
Esta sección existe porque un producto que solo enseña sus buenos números no está informando.
- Que el artículo citado sea el más pertinente. La cita siempre es real y siempre es literal;
no siempre es la que un experto habría elegido.
G-CITA-PRECISIONestá en 0,700. - Que el corpus contenga la respuesta. Solo está el RD 1428/2003, que responde al 49,4 % de un banco de examen real. Mecánica, primeros auxilios, permisos y factores humanos no los regula este reglamento, y ahí el sistema se calla — que es lo correcto, no un fallo.
- Que la prosa entre citas sea fiel a lo que cita. Lo juzga otro modelo (
G-FAITH-JUEZ= 0,705) con un acuerdo humano de κ = 0,4476 — moderado. Es el número más blando del proyecto, y por eso se publica con su κ al lado y como informativo, no como puerta. - Que sirva para decidir nada legal. No lo es. Es un tutor de estudio.
Hasta dónde puede llegar, con qué decisiones y con qué techo medido, está cuantificado en
docs/spec/futuro.md.
Note
Y lo dice también dentro del producto. La aplicación trae una pestaña «Por qué» que explica la garantía, publica las cuatro medidas con su cota superior —no el cero a secas— y termina con esta misma lista de limitaciones. Un sistema que solo enseña sus buenos números a quien lee el README, y no a quien lo usa, informa a la persona equivocada.
| Capa | Qué se usa | Por qué |
|---|---|---|
| API | FastAPI + SSE | streaming real: el guardia tiene que poder cortar en el token, no al final |
| Orquestación | LangGraph como máquina de estados | el ciclo redactar → verificar → reintentar es un grafo, no una cadena |
| Búsqueda | PostgreSQL + pgvector (HNSW) y ts_rank_cd |
un solo motor para los dos canales; sin servicio de búsqueda aparte |
| Reordenado | BAAI/bge-reranker-v2-m3 en proceso, con MPS |
no pasa por Ollama: no existe /api/rerank |
| Modelos | Ollama en el host — qwen3.5:4b, qwen3-embedding:0.6b |
reemplazable por una variable de entorno |
| Calidad | pytest · hypothesis · mutmut · ruff · mypy --strict |
mutación sobre las zonas de TDD obligatorio |
Mapa del repositorio
| Ruta | Qué es |
|---|---|
src/citebound/domain/ |
legalref, citation, retry, knowledge, selector — puro, sin I/O |
src/citebound/ingest/ |
parseo del XML del BOE y troceado por apartado |
src/citebound/retrieval/ |
léxico, vectorial, fusión RRF, constructor de consulta, reordenador |
src/citebound/agent/ |
el grafo como máquina de estados y el guardia de streaming |
src/citebound/evals/ |
scoring, bootstrap, esquema del golden set y suite adversarial |
src/citebound/api/ |
FastAPI + SSE, límite de tasa, métricas |
ui/ |
la web de práctica. No importa citebound: habla por HTTP como cualquier cliente |
corpus/ |
el corpus congelado, inmutable y verificado por sha256 |
evals/golden/ |
el golden set versionado, append-only |
prompts/ |
los prompts, en ficheros con frontmatter. Nunca dentro del código |
docs/ |
contratos, metas, reglas, ADR y diario de ingeniería |
La historia del proyecto —cada fase, cada número y cada umbral bajado con su motivo— está en
CHANGELOG.md y en docs/JOURNAL.md.
Corpus — RD 1428/2003, Reglamento General de Circulación
(BOE-A-2003-23514), texto consolidado del BOE, congelado el 2026-08-10 y verificado por sha256.
Modelos — qwen3.5:4b ·
qwen3-embedding:0.6b ·
BAAI/bge-reranker-v2-m3
Técnicas — Reciprocal Rank Fusion (Cormack et al., 2009) para combinar los dos canales ·
Bayesian Knowledge Tracing (Corbett & Anderson, 1994) para el selector adaptativo ·
semántica GenAI de OpenTelemetry v1.42.0,
con la divergencia declarada en otel-semconv.lock.
El corpus es el BOE. El banco de preguntas tipo test es material de terceros —de acceso público
en internet— y se incluye en evals/golden/source/ en su versión podada y con la clave del banco
original sustituida por identificadores aleatorios
(scripts/anonimizar_banco.py, irreversible: el mapa no se guarda).
El volcado íntegro no está en el repositorio, y las imágenes de las preguntas tampoco. La
procedencia está declarada en
evals/golden/source/README.md.
![Los dos caminos construyéndose en paralelo. RAG convencional: búsqueda vectorial, top 5 fragmentos, el modelo redacta y escribe «artículo 47» él mismo, sin verificación, una única salida. Citebound: léxico y vectorial, fusión RRF, reordenado a top 5, el modelo solo escribe [[REF:n]], el código lo resuelve a RD-1428/2003#art82, comprueba que el fragmento es literal carácter a carácter, y puede responder, retractar o abstenerse.](/samuvm/citebound/raw/main/docs/img/arquitectura.gif)