En el artículo anterior vimos cómo integrar un modelo de lenguaje local con Spring Boot utilizando LangChain4j y Ollama. Aunque ese ejemplo era útil para comprender la integración, el modelo solo podía responder con el conocimiento con el que fue entrenado.
En aplicaciones reales esto rara vez es suficiente. Lo habitual es que necesitemos responder preguntas sobre información propia de la empresa: manuales, contratos, documentación técnica, políticas internas o cualquier otro contenido que el modelo nunca ha visto.
Aquí es donde aparece Retrieval-Augmented Generation (RAG).
La idea es sencilla: antes de preguntar al modelo, buscamos los fragmentos de información más relevantes dentro de nuestros documentos y se los enviamos junto con la consulta. De esta manera, el modelo genera una respuesta basada en información actual y controlada por nuestra aplicación.
En este artículo construiremos un pequeño sistema RAG completamente local utilizando:
Spring Boot
LangChain4j
Ollama
Llama 3.2
Un documento PDF
Todo se ejecutará en nuestra máquina, sin depender de servicios externos.
Empecemos: ¿Qué es RAG? Un error frecuente es pensar que un LLM "busca" información dentro de un documento. No funciona así.
Cuando preguntamos algo, ocurre un proceso similar al siguiente:
Usuario
│
▼
Pregunta
│
▼
Embeddings
│
▼
Vector Store
│
▼
Fragmentos relevantes
│
▼
LLM
│
▼
Respuesta
El modelo nunca recibe el documento completo.
Solo recibe algunos fragmentos que son relevantes para la pregunta.
Esto tiene varias ventajas:
- respuestas actualizadas
- menor consumo de tokens
- mejor precisión
- no es necesario volver a entrenar el modelo
Nuestro ejemplo será bastante pequeño.
+------------------+
| Spring Boot API |
+------------------+
|
|
LangChain4j
|
+--------------+--------------+
| |
| |
Embedding Model Chat Model
| |
| |
Ollama Ollama
|
|
InMemory Vector Store
|
|
PDF Loader
Para una demostración no necesitamos una base vectorial como ChromaDB o Qdrant.
LangChain4j incluye una implementación en memoria suficiente para aprender.
Además de las dependencias del artículo anterior agregamos:
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-easy-rag</artifactId>
</dependency>
<dependency>
<groupId>dev.langchain4j</groupId>
<artifactId>langchain4j-embedding</artifactId>
</dependency>
El chat utiliza un modelo.
Los embeddings utilizan otro.
ollama pull nomic-embed-text
Configurando los modelos:
@Bean
ChatLanguageModel chatModel() {
return OllamaChatModel.builder()
.baseUrl("http://localhost:11434")
.modelName("llama3.2")
.build();
}
@Bean
EmbeddingModel embeddingModel(){
return OllamaEmbeddingModel.builder()
.baseUrl("http://localhost:11434")
.modelName("nomic-embed-text")
.build();
}
Supongamos que tenemos un manual llamado : manual.pdf
Podemos leerlo muy fácilmente.
Path path = Path.of("manual.pdf");
Document document =
FileSystemDocumentLoader.loadDocument(path);
Hasta aquí solamente tenemos texto.
Necesitamos dividirlo.
Los modelos tienen un límite de contexto.
No podemos enviar un documento de cientos de páginas.
LangChain4j incluye distintos splitters.
DocumentSplitter splitter =
new DocumentByParagraphSplitter(500, 50);
List<TextSegment> segments =
splitter.split(document);
Ahora tenemos pequeños fragmentos del documento.
Por ejemplo:
Segmento 1
Introducción...
Segmento 2
Instalación...
Segmento 3
Configuración...
Cada fragmento se transforma en un vector.
EmbeddingStore<TextSegment> store =
new InMemoryEmbeddingStore<>();
EmbeddingStoreIngestor.ingest(
segments,
embeddingModel,
store
);
A partir de este momento nuestro documento ya es "buscable".
Aquí aparece la magia.
Assistant assistant =
AiServices.builder(Assistant.class)
.chatLanguageModel(chatModel)
.contentRetriever(
EmbeddingStoreContentRetriever.builder()
.embeddingModel(embeddingModel)
.embeddingStore(store)
.build())
.build();
Observemos que no cambiamos nuestra interfaz.
Solo agregamos un ContentRetriever.
Todo el proceso RAG queda oculto detrás de la API.
Supongamos que nuestro PDF contiene documentación sobre Spring Boot.
Preguntamos:
What port does the application use?
LangChain4j hace automáticamente:
- genera el embedding de la pregunta
- busca los segmentos más similares
- envía esos segmentos al modelo
- genera la respuesta
Nosotros simplemente hacemos:
assistant.chat(
"What port does the application use?");
¿Qué ocurre internamente?
El flujo completo puede resumirse así:
│
▼
Documento
│
▼
Splitter
│
▼
Segmentos
│
▼
Embeddings
│
▼
Vector Store
│
▼
─────────────────────────────────────
Pregunta
│
▼
Embedding
│
▼
Búsqueda vectorial
│
▼
Top 3 segmentos
│
▼
LLM
│
▼
Respuesta
Obsérvese que el LLM nunca consulta directamente el PDF. Lo único que recibe son los fragmentos recuperados por la búsqueda semántica.
¿Por qué embeddings? Podríamos pensar que basta con hacer un String.contains() o una búsqueda por palabras clave, pero eso tiene limitaciones evidentes.
Si el documento dice:
> "The application listens on port 8080."
y el usuario pregunta:
> "Which TCP port is the server using?"
No existe una coincidencia literal entre ambas frases. Sin embargo, sus embeddings estarán muy próximos en el espacio vectorial porque expresan la misma idea.
Esta es una de las principales ventajas de la búsqueda semántica: encuentra contenido por significado y no únicamente por coincidencia exacta de palabras.
Este proyecto es intencionalmente sencillo y deja fuera varios aspectos importantes que aparecen en aplicaciones reales:
- Persistir los embeddings en una base vectorial como Qdrant, ChromaDB o PgVector.
- Reindexar documentos cuando cambian.
- Aplicar filtros por usuario o permisos de acceso.
- Recuperar resultados desde múltiples fuentes, como PDFs, bases de datos o APIs.
- Ajustar el número de fragmentos recuperados (top-k) y su relevancia.
Aun así, la arquitectura presentada es la misma sobre la que se construyen la mayoría de las aplicaciones RAG modernas.
RAG permite ampliar el conocimiento de un modelo sin necesidad de reentrenarlo. En lugar de modificar el modelo, acercamos la información correcta en el momento de la consulta, combinando búsqueda semántica con generación de lenguaje natural.
Con LangChain4j este proceso queda encapsulado en unas pocas clases y puede integrarse fácilmente en una aplicación Spring Boot. Al combinarlo con Ollama, es posible desarrollar y ejecutar todo el flujo de forma local, manteniendo el control sobre los datos y evitando depender de servicios externos.
.jpeg)
.jpeg)








