Skip to content

Ley de Datos Personales — RAG

Un servicio RAG 100% local en Spring AI que responde preguntas sobre la Ley 21.719 chilena a partir del PDF real de la ley, sin enviar nada a servicios externos.

¿Qué dice realmente la Ley 21.719 sobre el derecho a la supresión de tus datos? En vez de leer el PDF completo o confiar en lo que un LLM “recuerda” de su entrenamiento, este proyecto le hace la pregunta directamente al texto de la ley — y responde citando el fragmento exacto de donde sacó la información.

Preguntarle a un LLM genérico sobre una ley chilena específica tiene dos problemas: no tiene el texto en su entrenamiento con la fidelidad necesaria, y aunque lo tuviera, alucina con total confianza. RAG (Retrieval Augmented Generation) resuelve esto separando el proceso en dos mitades independientes: una que ingesta el documento una sola vez, y otra que busca los fragmentos relevantes en cada pregunta y se los entrega al modelo como contexto obligatorio.

📄 Fuente única de verdad

Las respuestas se construyen a partir del PDF real de la ley, no de lo que el modelo “cree” recordar.

🔒 Todo en local

Modelos servidos con Ollama, base de datos con Docker. Nada sale de la máquina.

⚡ Streaming token a token

Server-Sent Events para que la respuesta aparezca mientras se genera, no al final.

💬 Memoria de conversación

Puedes encadenar preguntas — “¿y en qué artículo dice eso?” entiende el contexto anterior.

IngestionService corre una vez al levantar la aplicación:

  1. LecturaParagraphPdfDocumentReader recorre el PDF guiándose por su índice y produce un documento por párrafo lógico, conservando el título de la sección en los metadatos.
  2. Limpieza — se reduce todo el espaciado a espacios simples. La extracción del PDF genera rachas largas de espacios por la maquetación del original.
  3. FragmentaciónTokenTextSplitter corta en piezas de 400 tokens.
  4. Vectorización — cada fragmento se convierte en un vector de 768 dimensiones con nomic-embed-text y se guarda en public.vector_store (pgvector).

La ingesta es idempotente: si la tabla ya tiene contenido, la carga se omite.

  1. Llega la pregunta junto al identificador de conversación (UUID).
  2. MessageChatMemoryAdvisor antepone al prompt el historial de esa conversación.
  3. QuestionAnswerAdvisor vectoriza la pregunta con el mismo modelo de embeddings usado en la ingesta y busca por similitud coseno los 8 fragmentos más cercanos.
  4. Esos fragmentos se inyectan en el prompt como contexto.
  5. qwen3.5:4b redacta la respuesta y se emite token a token.
src/main/java/cl/goviedo/rag/ley_datos_personales/
├── controllers/ChatController.java # API de consulta (streaming y bloqueante)
├── services/IngestionService.java # ingesta del PDF al arrancar
└── repositories/VectorStoreRepository.java # contar y vaciar el vector store
src/main/resources/
├── docs/ley-datos-personales.pdf # el documento fuente
└── static/ # interfaz de chat, empaquetada en el jar
Versión / detalle
Java21+
Spring Boot4.1.1
Spring AI2.0.1
Base de datosPostgreSQL 16 + pgvector
Modelo de chatqwen3.5:4b (Ollama)
Modelo de embeddingsnomic-embed-text, 768 dimensiones

La interfaz web consume la ruta de streaming; la bloqueante existe para curl y scripts.

RutaRespuestaPara qué
GET /text/htmlinterfaz de chat
GET /api/statusapplication/jsonmodelo en uso y si el servicio está degradado
GET /api/chat/streamtext/event-streamrespuesta token a token
GET /api/chattext/plainrespuesta completa de una vez
POST /api/page-loadsapplication/jsonregistra una carga y devuelve el total

Ambos endpoints de chat reciben question (máximo 500 caracteres) y conversationId (UUID), ambos obligatorios — una pregunta vacía produciría un embedding sin significado, y conversationId es la clave con la que se indexa el historial en memoria.

Terminal window
CID=$(uuidgen)
curl -G --data-urlencode "question=¿Qué derechos tiene el titular de los datos?" \
--data-urlencode "conversationId=$CID" \
http://localhost:13000/api/chat

Dos modos de operación, con degradación visible

Section titled “Dos modos de operación, con degradación visible”

La aplicación elige al arrancar contra qué Ollama trabaja, sondeando primero el preferido y cayendo al de respaldo si no responde:

PreferidoRespaldo
Modelo de chatqwen3.5:4bqwen3.5:0.8b
Uso típicoequipo con GPU en la red locallocalhost, sin GPU

Cuando usa el respaldo, la interfaz muestra una franja permanente advirtiendo que las respuestas pueden contener errores o llegar en otro idioma. No es adorno: el modelo reducido, medido contra el mismo contexto de 8 fragmentos, no siempre encuentra el dato en la posición que le asigna la búsqueda por similitud.

Terminal window
curl http://localhost:13000/api/status
# {"modeloChat":"qwen3.5:4b","degradado":false}
Terminal window
cp .env.example .env # y define POSTGRES_PASSWORD
docker compose -f compose.deploy.yaml up -d --build

La imagen se construye en dos etapas (compila con JDK, corre sobre JRE con usuario sin privilegios). compose.deploy.yaml está separado de compose.yaml a propósito: este último lo levanta spring-boot-docker-compose al correr ./mvnw spring-boot:run, y si la app estuviera declarada ahí intentaría levantarse a sí misma.

Para Ollama hay dos caminos: dentro de la misma pila (--profile ollama, portable pero pesado en la primera descarga) o en la máquina anfitriona (requiere OLLAMA_HOST=0.0.0.0, porque por omisión Ollama solo escucha en 127.0.0.1 y ningún contenedor lo alcanza ahí).

La ingesta aborta la aplicación si Ollama no responde: es preferible fallar de forma visible a servir un RAG sin documentos. Combinado con restart: unless-stopped, eso se traduce en un contenedor reiniciando en bucle hasta que Ollama esté disponible — el síntoma correcto para diagnosticar el problema real.

  • El espaciado se limpia antes de fragmentar, no después. El 76,1% de cada fragmento del PDF era espacio en blanco antes de limpiar; 0,0% después. Limpiar después de que TokenTextSplitter ya cortó por tokens habría gastado el presupuesto del fragmento en relleno.
  • Fragmentos de 400 tokens, no los 800 por omisión — consecuencia directa de lo anterior: sin relleno, un fragmento de 800 tokens diluye una fecha o un número de artículo entre demasiado texto.
  • Se recuperan 8 fragmentos, no los 4 por omisión — con 4, el fragmento correcto quedaba en el puesto 7 de la búsqueda y nunca llegaba al modelo.
  • El razonamiento del modelo está desactivado. En una prueba, qwen3.5 gastó 59.754 caracteres “pensando” antes de responder, agotó el presupuesto de generación y devolvió un 200 OK con cuerpo vacío.
  • El contador de páginas es de cargas, nunca de visitas únicas. Identificar visitantes de forma única exigiría un dato personal — justo lo que regula la ley que la propia app explica.
  • El modelo de chat importa más que su tamaño. gemma:2b respondía “no hay información en el contexto” teniendo el dato delante, y en inglés a preguntas en español. qwen3.5:2b, más chico, acertaba con el mismo prompt.
Terminal window
./mvnw test

37 pruebas entre ChatControllerTest, IngestionServiceTest, VectorStoreRepositoryTest, PageLoadControllerTest y PageLoadRepositoryTest — ninguna necesita base de datos ni Ollama levantados. El test de contexto completo (@SpringBootTest) está deshabilitado a propósito porque spring-boot-docker-compose no entra al classpath de test; reactivarlo exige Testcontainers con pgvector/pgvector:pg16.