RAG de A a Z: obtén documentos y luego responde
1. Resumen general
Los LLM solo saben lo que hay en sus datos de entrenamiento. No conocen la política de devoluciones de tu tienda ni los procesos internos de tu empresa. Cuando se les pregunta sobre esas cosas, suelen inventar respuestas. RAG (Retrieval-Augmented Generation) soluciona esto: el sistema obtiene primero los documentos relevantes, y luego permite que el LLM responda basándose en esos documentos.
En este artículo, construirás tu propio pipeline RAG completo con Spring Boot 3 y Spring AI. El pipeline incluye pasos para cargar documentos, particionar en chunks, crear embeddings, buscar, ensamblar prompts con fuentes y escribir pruebas. Al final hay una sección sobre errores comunes y casos donde no deberías usar RAG.
2. Cómo funciona RAG
RAG tiene dos fases separadas.
La fase de ingestion se ejecuta una vez o periódicamente:
- Lee documentos (Markdown, PDF, HTML…).
- Divide en chunks de cientos de tokens.
- Llama al modelo de embedding para convertir cada chunk en un vector numérico.
- Guarda el vector y el contenido del chunk en un almacén de vectores (vector store).
La fase de consulta se ejecuta cada vez que el usuario hace una pregunta:
- Convierte la pregunta en un embedding vectorial.
- Encuentra los chunks con vectores más similares (búsqueda de similitud).
- Inserta esos chunks en el prompt. Este es el paso augment.
- Envía el prompt al LLM para generar la respuesta. Este es el paso generate.
La idea central es muy simple. El LLM no necesita “memorizar” tus documentos, solo necesita leer la sección correcta del documento directamente en el prompt.
3. Preparar el proyecto
Añades Spring AI BOM y el starter de OpenAI. Este starter proporciona tanto el modelo de chat como el modelo de embedding.
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.0.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
Tienes que configurar el modelo:
spring.ai.openai.api-key=${OPENAI_API_KEY}
spring.ai.openai.chat.options.model=gpt-4o-mini
spring.ai.openai.chat.options.temperature=0.1
spring.ai.openai.embedding.options.model=text-embedding-3-small
Debes establecer temperature bajo. Con RAG, el objetivo es responder apegándose al documento, sin necesidad de creatividad.
Finalmente, declara dos beans: un almacén de vectores y un ChatClient.
@Configuration
public class RagConfig {
@Bean
VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
@Bean
ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
}
SimpleVectorStore almacena todos los vectores en memoria. Es adecuado para aprender y hacer demos. EmbeddingModel es configurado automáticamente por Spring AI a partir del starter de OpenAI. Cada vez que añades un documento, el almacén llama a este modelo para crear el vector.
Nota:
SimpleVectorStorepierde todos los datos cuando la aplicación se reinicia y realiza búsquedas lineales. Para producción, deberías usar PGvector, Qdrant, Elasticsearch… Spring AI tiene starters para cada uno, y la interfazVectorStorepermanece igual, por lo que el código siguiente no necesita cambios.
4. Cargar documentos
Supongamos que tienes un archivo src/main/resources/docs/return-policy.md:
# Política de devoluciones
Los clientes pueden devolver productos dentro de 30 días desde la recepción.
El producto debe tener etiquetas, sellos y recibo intactos.
El reembolso se transferirá a la cuenta en 5-7 días hábiles.
La ropa interior y productos con descuento superior al 50% no se pueden devolver.
4.1. Leer y particionar en chunks
@Component
public class DocumentIngestor implements ApplicationRunner {
private final VectorStore vectorStore;
private final ResourcePatternResolver resolver;
public DocumentIngestor(VectorStore vectorStore, ResourcePatternResolver resolver) {
this.vectorStore = vectorStore;
this.resolver = resolver;
}
@Override
public void run(ApplicationArguments args) throws IOException {
// chunkSize=300 token, minChunkSizeChars=100, minChunkLengthToEmbed=5,
// maxNumChunks=10000, keepSeparator=true
var splitter = new TokenTextSplitter(300, 100, 5, 10_000, true);
for (Resource file : resolver.getResources("classpath:docs/*.md")) {
var reader = new TextReader(file);
reader.getCustomMetadata().put("source", file.getFilename());
List<Document> chunks = splitter.apply(reader.get());
vectorStore.add(chunks);
System.out.printf("Cargado %s: %d chunks%n", file.getFilename(), chunks.size());
}
}
}
El código anterior hace lo siguiente:
ApplicationRunnerse ejecuta una vez después de que el contexto se haya inicializado, por lo que los documentos siempre se cargan antes de la primera solicitud.TextReaderlee el archivo en unDocument. Adjuntamos metadatasourcecon el nombre del archivo para usarlo al extraer la fuente en pasos posteriores.TokenTextSplitterdivide el texto en chunks de aproximadamente 300 tokens. Los metadatos se copian a cada chunk.vectorStore.add(...)llama al modelo de embedding para cada chunk y guarda el vector.
Al iniciar, verás:
Cargado return-policy.md: 1 chunk
Cargado shipping.md: 3 chunks
4.2. Elegir el tamaño del chunk
El tamaño del chunk afecta directamente la calidad de las respuestas:
- Chunks demasiado grandes: un chunk contiene muchos temas, el vector se “diluye” por lo que es difícil hacer coincidencias precisas. El prompt también consume más tokens.
- Chunks demasiado pequeños: las oraciones pierden contexto al cortarse. Por ejemplo, “dentro de 30 días” está en un chunk, mientras que “devolver” está en otro.
200-500 tokens es un punto de partida razonable para documentos de prosa. Para documentos bien estructurados (FAQ, tabla de contenidos), deberías particionar según los encabezados para que cada chunk sea una idea completa.
5. Recuperar documentos relevantes
El paso de recuperación requiere solo una llamada:
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query("¿En cuánto tiempo puedo devolver?")
.topK(4)
.similarityThreshold(0.5)
.build());
query: la pregunta. El almacén de vectores automáticamente crea el embedding de la pregunta usando el mismo modelo que usó al cargar.topK(4): obtiene como máximo 4 chunks más similares.similarityThreshold(0.5): excluye los chunks con similitud del coseno por debajo de 0.5. Sin un umbral, el almacén siempre devuelvetopKresultados, incluso si no tienen relación alguna con la pregunta.
No hay un umbral óptimo para todos los casos. Debes registrar las puntuaciones con varias decenas de preguntas reales, y luego elegir un umbral que separe los resultados correctos del ruido.
6. Ensamblar el prompt y generar respuestas
6.1. Constructor de prompts
Separar la parte de ensamblaje del prompt en una función pura facilitará las pruebas:
public final class PromptBuilder {
private static final String TEMPLATE = """
Eres un asistente de atención al cliente.
Responde solo basándote en el CONTEXTO de abajo.
Si el contexto no contiene la información, responde exactamente:
"No encontré esta información en los documentos."
Al final de la respuesta, indica la fuente entre corchetes, por ejemplo [return-policy.md].
CONTEXTO:
{context}
PREGUNTA: {question}
""";
private PromptBuilder() {}
public static String build(String question, List<Document> docs) {
String context = docs.stream()
.map(d -> "[" + d.getMetadata().getOrDefault("source", "unknown") + "]\n" + d.getText())
.collect(Collectors.joining("\n---\n"));
return TEMPLATE.replace("{context}", context)
.replace("{question}", question);
}
}
Hay tres puntos importantes en la plantilla:
- “Responde solo basándote en el CONTEXTO” limita el LLM a los documentos, reduciendo las alucinaciones.
- Una frase de rechazo fija da al LLM una forma válida de rechazar cuando los documentos no tienen la información.
- Cada chunk viene con su nombre de fuente, permitiendo que el LLM extraiga la fuente y que los usuarios verifiquen la información.
Aquí usamos replace en lugar de String.formatted. La razón: el contenido del documento puede contener el carácter % (por ejemplo, “descuento superior al 50%”), y formatted lanzaría UnknownFormatConversionException al encontrarlo.
6.2. RagService
@Service
public class RagService {
static final String NOT_FOUND = "No encontré esta información en los documentos.";
private final VectorStore vectorStore;
private final ChatClient chatClient;
public RagService(VectorStore vectorStore, ChatClient chatClient) {
this.vectorStore = vectorStore;
this.chatClient = chatClient;
}
public String answer(String question) {
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(4)
.similarityThreshold(0.5)
.build());
if (docs == null || docs.isEmpty()) {
return NOT_FOUND;
}
return chatClient.prompt()
.user(PromptBuilder.build(question, docs))
.call()
.content();
}
}
Cuando no se encuentra ningún chunk, el servicio rechaza inmediatamente sin llamar al LLM. Esto ahorra costos y elimina completamente la posibilidad de que el LLM alucine basándose en su conocimiento general.
Finalmente, el controlador:
@RestController
public class AskController {
private final RagService ragService;
public AskController(RagService ragService) {
this.ragService = ragService;
}
@GetMapping("/ask")
public String ask(@RequestParam String q) {
return ragService.answer(q);
}
}
Ahora prueba la API:
curl -G "http://localhost:8080/ask" --data-urlencode "q=¿En cuánto tiempo puedo devolver?"
curl -G "http://localhost:8080/ask" --data-urlencode "q=¿Venden laptops en la tienda?"
Puedes devolver en 30 días desde la recepción, siempre que el producto tenga etiquetas, sellos y recibo intactos. [return-policy.md]
No encontré esta información en los documentos.
La primera respuesta se adhiere al documento y tiene una fuente. La segunda no tiene documentos coincidentes, por lo que el sistema la rechaza en lugar de inventar una respuesta.
7. Escribir pruebas
No deberías llamar al LLM real en pruebas unitarias porque es lento, costoso e impredecible. En su lugar, prueba dos partes determinísticas: el constructor de prompts y la rama de rechazo cuando no hay documentos.
class RagServiceTest {
@Test
void promptContainsContextSourceAndQuestion() {
var doc = new Document("Devuelve en 30 días.", Map.of("source", "return-policy.md"));
String prompt = PromptBuilder.build("¿Cuándo devolver?", List.of(doc));
assertThat(prompt)
.contains("[return-policy.md]")
.contains("Devuelve en 30 días.")
.contains("PREGUNTA: ¿Cuándo devolver?")
.doesNotContain("{context}", "{question}");
}
@Test
void percentSignInDocumentDoesNotBreakPrompt() {
var doc = new Document("Descuento superior al 50% no se puede devolver.", Map.of("source", "return-policy.md"));
assertThat(PromptBuilder.build("¿Hay descuentos?", List.of(doc)))
.contains("superior al 50% no");
}
@Test
void returnsNotFoundWithoutCallingLlmWhenNoDocuments() {
VectorStore vectorStore = mock(VectorStore.class);
ChatClient chatClient = mock(ChatClient.class);
when(vectorStore.similaritySearch(any(SearchRequest.class))).thenReturn(List.of());
String answer = new RagService(vectorStore, chatClient).answer("¿Venden laptops?");
assertThat(answer).isEqualTo(RagService.NOT_FOUND);
verifyNoInteractions(chatClient);
}
}
La tercera prueba verifica dos cosas. Primero, la respuesta correcta es el rechazo. Segundo, ChatClient no se llama en absoluto. Si alguien elimina accidentalmente la rama isEmpty() más adelante, la prueba fallará.
Para evaluar la calidad de la recuperación, puedes escribir pruebas de integración adicionales. Prepara un conjunto de preguntas con el nombre de archivo de fuente esperado, y luego verifica si ese archivo está en los resultados topK. Este conjunto de pruebas debería ejecutarse periódicamente, no en cada compilación.
8. Simplificar con QuestionAnswerAdvisor
Una vez que entiendas cada paso, puedes usar el advisor predefinido de Spring AI. Este advisor encapsula la recuperación y el ensamblaje del prompt. Necesitas agregar la dependencia:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-advisors-vector-store</artifactId>
</dependency>
String answer = chatClient.prompt()
.advisors(QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder().topK(4).similarityThreshold(0.5).build())
.build())
.user("¿En cuánto tiempo puedo devolver?")
.call()
.content();
El advisor automáticamente crea el embedding de la pregunta, encuentra chunks e los inserta en el prompt usando una plantilla predeterminada. Es conveniente, pero tienes poco control sobre el formato de la fuente y el rechazo temprano. Por lo tanto, el enfoque manual de la sección 6 sigue siendo válido si necesitas esos requisitos.
9. Errores comunes y cuándo no usar RAG
Cambiar el modelo de embedding sin recargar datos. Los vectores de modelos diferentes no son comparables. Cada vez que cambies de modelo, debes re-embeddir todos los documentos.
No establecer similarityThreshold. El almacén de vectores siempre devuelve topK resultados. El LLM recibe contexto irrelevante y da respuestas incorrectas con mucha confianza.
Recargar documentos en cada inicio con un almacén persistente. Con PGvector, el ApplicationRunner anterior creará duplicados después de cada despliegue. Debes asignar ID fijos a cada chunk, como hash del contenido y nombre de archivo, o eliminar por source antes de recargar.
Meter demasiados chunks en el prompt. Aumentar topK a 20 no mejora las respuestas. El LLM tiende a pasar por alto información en el medio de contextos largos, y el costo aumenta con los tokens.
Confiar ciegamente en el contenido de los documentos. Si los documentos provienen de usuarios, pueden contener instrucciones como “ignora todas las instrucciones anteriores” (inyección de prompt). Necesitas controlar las fuentes de datos que se pueden cargar en el almacén.
Nota: RAG no es apropiado para todos los problemas. Las preguntas que requieren sintetizar todos los datos, como “¿cuántas órdenes fueron devueltas en septiembre?”, deberían convertirse en consultas SQL. La búsqueda de similitud solo devuelve unos pocos chunks, no puede contar ni agregar. Si toda tu documentación cabe en pocas páginas, a menudo es más simple y preciso pasarla directamente al prompt que implementar un pipeline RAG completo.
10. Conclusión
RAG separa el conocimiento del modelo. Los documentos viven en el almacén de vectores, y el LLM solo lee la parte relevante en cada pregunta. En este artículo, has construido ambas fases: ingestion (leer, particionar, embeddir, guardar) y consulta (buscar con umbral, ensamblar prompt con fuentes, llamar al LLM). También escribiste pruebas para la construcción del prompt y la rama de rechazo.
Desde esta base, puedes avanzar en varias direcciones: reemplaza SimpleVectorStore con PGvector, particiona por encabezados, filtra por metadatos (por ejemplo, solo busca en documentos de un departamento específico), o añade un paso de reranking para ordenar los resultados antes de pasarlos al prompt.