📄 Fuente única de verdad
Las respuestas se construyen a partir del PDF real de la ley, no de lo que el modelo “cree” recordar.
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:
ParagraphPdfDocumentReader 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.TokenTextSplitter corta en piezas de 400 tokens.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.
MessageChatMemoryAdvisor antepone al prompt el historial de esa conversación.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.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 storesrc/main/resources/├── docs/ley-datos-personales.pdf # el documento fuente└── static/ # interfaz de chat, empaquetada en el jar| Versión / detalle | |
|---|---|
| Java | 21+ |
| Spring Boot | 4.1.1 |
| Spring AI | 2.0.1 |
| Base de datos | PostgreSQL 16 + pgvector |
| Modelo de chat | qwen3.5:4b (Ollama) |
| Modelo de embeddings | nomic-embed-text, 768 dimensiones |
La interfaz web consume la ruta de streaming; la bloqueante existe para curl y scripts.
| Ruta | Respuesta | Para qué |
|---|---|---|
GET / | text/html | interfaz de chat |
GET /api/status | application/json | modelo en uso y si el servicio está degradado |
GET /api/chat/stream | text/event-stream | respuesta token a token |
GET /api/chat | text/plain | respuesta completa de una vez |
POST /api/page-loads | application/json | registra 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.
CID=$(uuidgen)
curl -G --data-urlencode "question=¿Qué derechos tiene el titular de los datos?" \ --data-urlencode "conversationId=$CID" \ http://localhost:13000/api/chatLa aplicación elige al arrancar contra qué Ollama trabaja, sondeando primero el preferido y cayendo al de respaldo si no responde:
| Preferido | Respaldo | |
|---|---|---|
| Modelo de chat | qwen3.5:4b | qwen3.5:0.8b |
| Uso típico | equipo con GPU en la red local | localhost, 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.
curl http://localhost:13000/api/status# {"modeloChat":"qwen3.5:4b","degradado":false}cp .env.example .env # y define POSTGRES_PASSWORDdocker compose -f compose.deploy.yaml up -d --buildLa 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.
TokenTextSplitter ya cortó por tokens habría gastado el presupuesto del fragmento en relleno.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.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../mvnw test37 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.