Los LLM necesitan pruebas para responder bien: RAG explicado
1. Descripción general
Ningún LLM, por muy potente que sea, conoce automáticamente el contenido de los documentos internos de tu empresa; solo conoce lo que se le proporciona en la entrada. Este artículo explica el mecanismo de Retrieval-Augmented Generation (RAG): por qué lo necesitas, cómo funciona el retrieval, y por qué la calidad de las respuestas depende casi completamente del paso de búsqueda anterior a la generación de texto. Después de leerlo, entenderás claramente el flujo de datos en un sistema RAG y podrás escribir por ti mismo un pipeline de retrieval simple en Java.
2. El problema: los LLM no tienen “memoria” sobre datos privados
El modelo fue entrenado en un conjunto de datos públicos, congelado en un momento específico. No conoce la nueva política de reembolsos emitida la semana pasada por tu empresa, mucho menos el contenido de algún contrato interno.
Hay dos formas de introducir información privada:
- Insertar directamente en el prompt — solo es viable cuando el documento es pequeño y cabe en la ventana de contexto.
- Recuperación (retrieval) — cuando el conjunto de documentos es grande, la aplicación solo selecciona los fragmentos más relevantes y luego los introduce en el prompt.
// Enfoque 1: insertar todo el documento — solo funciona bien con texto corto
String prompt = """
Basándote en la siguiente política, responde la pregunta del cliente.
Política: %s
Pregunta: %s
""".formatted(fullPolicyText, userQuestion);
Este enfoque es simple pero no escala: la ventana de contexto tiene un límite de tokens, y cuanto más texto irrelevante se inserte, más probable es que el modelo se “pierda” (lost in the middle). Cuando el conjunto de documentos tiene miles de páginas, debes usar retrieval.
3. Qué es RAG y cómo funciona
RAG (Retrieval-Augmented Generation) divide el problema en dos etapas independientes:
- Retrieval: encontrar los fragmentos de texto correctos relacionados con la pregunta.
- Generation: el LLM usa esos fragmentos para generar la respuesta, con citas de la fuente.
Pregunta del usuario
│
▼
[Embedding model] ── convierte la pregunta en un vector
│
▼
[Vector database] ── encuentra los fragmentos de texto semánticamente más cercanos
│
▼
[Fragmentos originales] ── introduce el texto original en el prompt
│
▼
[LLM] ── genera la respuesta + citas de la fuente
El punto clave: el LLM no “consulta” directamente la base de datos de vectores. Solo recibe texto plano (plain text) proporcionado por la aplicación después de que se completa el retrieval. La base de datos de vectores es solo una herramienta de búsqueda, no un lugar que almacene el “conocimiento” que el modelo pueda leer.
3.1. Embedding — convierte la pregunta en números
El modelo de embedding convierte un fragmento de texto en un vector de números reales (por ejemplo, de 1536 dimensiones). Dos fragmentos de texto con significado similar producirán vectores cercanos en ese espacio.
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
@Service
public class PolicyEmbeddingService {
private final EmbeddingModel embeddingModel;
public PolicyEmbeddingService(EmbeddingModel embeddingModel) {
this.embeddingModel = embeddingModel;
}
public float[] embed(String text) {
EmbeddingResponse response = embeddingModel.embedForResponse(List.of(text));
return response.getResult().getOutput();
}
}
embed() recibe un fragmento de texto (por ejemplo: el contenido de la pregunta “¿Cuál es la política de cancelación del hotel?”) y devuelve un array float[] — un vector que representa la semántica de esa oración. Este es el vector que se comparará con los vectores ya almacenados en la base de datos de vectores.
3.2. Base de datos de vectores — encuentra fragmentos semánticamente similares
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
<version>1.0.0</version>
</dependency>
@Service
public class PolicyRetrievalService {
private final VectorStore vectorStore;
public PolicyRetrievalService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void indexPolicy(String policyId, String content) {
Document document = new Document(content, Map.of("policyId", policyId));
vectorStore.add(List.of(document));
}
public List<Document> search(String question) {
SearchRequest request = SearchRequest.query(question).withTopK(3);
return vectorStore.similaritySearch(request);
}
}
- indexPolicy() almacena un fragmento de política en la base de datos de vectores, junto con metadatos (policyId) para que posteriormente sepas a qué documento pertenece ese fragmento de texto.
- search() recibe la pregunta del usuario, crea automáticamente embeddings internos y luego busca los 3 fragmentos semánticamente más cercanos (topK(3)).
Resultado de search("¿Cuál es la política de cancelación del hotel?"):
1. "Los clientes pueden cancelar la habitación de forma gratuita 24 horas antes..."
2. "Casos de reserva no reembolsable (non-refundable)..."
3. "La tarifa de cancelación aplica a las reservas grupales de 5 o más personas..."
3.3. Generación — el LLM responde basándose en pruebas
@Service
public class PolicyAnswerService {
private final ChatClient chatClient;
private final PolicyRetrievalService retrievalService;
public PolicyAnswerService(ChatClient.Builder builder,
PolicyRetrievalService retrievalService) {
this.chatClient = builder.build();
this.retrievalService = retrievalService;
}
public String answer(String question) {
List<Document> passages = retrievalService.search(question);
String context = passages.stream()
.map(Document::getContent)
.collect(Collectors.joining("\n---\n"));
String prompt = """
Responde solo basándote en los siguientes fragmentos de texto. Si no hay información suficiente, indica claramente que no lo sabes.
Fragmentos de texto:
%s
Pregunta: %s
""".formatted(context, question);
return chatClient.prompt(prompt).call().content();
}
}
Nota la instrucción “Responde solo basándote en los siguientes fragmentos de texto” — esta es una restricción importante para reducir las alucinaciones: se requiere que el modelo se adhiera estrictamente a las pruebas en lugar de inventar más información basada en su conocimiento de entrenamiento.
Nota: Si no restringes claramente con “responde solo basándote en el contexto”, el modelo aún puede mezclar su conocimiento base con el contenido recuperado, lo que lleva a respuestas que suenan plausibles pero que se desvían de los documentos reales.
4. Por qué si el retrieval es malo, la respuesta también es mala
Esta es la parte que a menudo se pasa por alto: el LLM solo es bueno para interpretar lo que recibe, no tiene forma de verificar automáticamente si esa información es correcta o suficiente.
Dos escenarios comunes:
Escenario 1 — El retrieval obtiene documentos desactualizados. Si la base de datos de vectores aún indexa la versión antigua de una política (no actualizada/eliminada), el sistema devolverá fragmentos de texto antiguos. El LLM lee ese fragmento y responde con total confianza — pero esa confianza es errónea.
Escenario 2 — El retrieval carece de contexto importante. Supongamos que el documento original tiene esta estructura:
Artículo 5. Política de cancelación
Los clientes pueden cancelar la habitación de forma gratuita 24 horas antes de la hora de llegada.
Artículo 5.1. Excepción
Para reservas durante las festividades de Año Nuevo Lunar, no aplica cancelación gratuita;
la tarifa de cancelación fija es el 50% del valor de la reserva.
Si el paso de división de documentos (chunking) separa el “Artículo 5” y “Artículo 5.1” en dos chunks independientes, y el retrieval solo obtiene el chunk del “Artículo 5” (porque la pregunta no contiene palabras como “Año Nuevo Lunar” o “excepción”), el LLM responderá “puede cancelar de forma gratuita 24 horas antes” — suena muy plausible, pero omite la condición de excepción importante.
@Test
void cuandoFaltaChunkDeExcepcion_LLMOmiteLaCondicion() {
// Simula que el retrieval solo devuelve 1 chunk, sin la sección de excepción
List<Document> incompletePassages = List.of(
new Document("Los clientes pueden cancelar la habitación de forma gratuita 24 horas antes de la hora de llegada.")
);
String answer = fakeAnswerService.answerFromPassages(
"Tengo una reserva durante el Año Nuevo Lunar, ¿cancelo sin perder dinero?",
incompletePassages
);
// La respuesta será incorrecta porque faltan pruebas sobre la excepción de festividades
assertThat(answer).doesNotContain("50%");
}
Este test no pretende atrapar un error en el LLM — ilustra que el problema está en el retrieval, no en la capacidad de razonamiento del modelo. Si proporcionas suficientes pruebas (ambos chunks), la respuesta será correcta incluso con un modelo de gama media.
5. Chunking: la clave para un retrieval “con el nivel de detalle correcto”
Dado que el ejemplo anterior muestra que el chunking incorrecto tiene consecuencias, este es el lugar donde debes invertir la mayor parte del esfuerzo al construir un sistema RAG.
@Service
public class PolicyChunkingService {
public List<String> splitByHeading(String document) {
// Divide según el patrón "Artículo X" o "Artículo X.Y" para preservar el contexto padre-hijo
String[] rawChunks = document.split("(?=Điều \\d+)");
return Arrays.stream(rawChunks)
.map(String::trim)
.filter(chunk -> !chunk.isBlank())
.toList();
}
}
En lugar de dividir según un número fijo de caracteres (por ejemplo, cortar cada 500 caracteres — fácilmente cortará en medio de una cláusula y una excepción), esta función divide según límites semánticos (“Artículo X”). Este enfoque ayuda a que cada chunk mantenga íntegra una unidad de información, reduciendo el riesgo de separar una regla principal de sus excepciones.
@Test
void divisionPorEncabezado_DebeIncluirPrincipalYExcepcion() {
String document = """
Artículo 5. Política de cancelación
Los clientes pueden cancelar de forma gratuita 24 horas antes.
Artículo 5.1. Excepción
Durante las festividades de Año Nuevo Lunar, la tarifa de cancelación fija es 50%.
""";
List<String> chunks = chunkingService.splitByHeading(document);
assertThat(chunks).hasSize(2);
assertThat(chunks.get(1)).contains("Excepción", "50%");
}
Nota: La división por encabezado es efectiva solo cuando el documento tiene una estructura clara (contratos, regulaciones, documentación técnica). Para texto libre (correos electrónicos, registros de chat), necesitas una estrategia diferente — por ejemplo, dividir por párrafos con solapamiento, o usar semantic chunking basado en la similitud entre oraciones consecutivas.
6. Cuándo NO debes usar RAG
RAG no es una solución universal:
- Conjunto de datos pequeño, fijo: si toda la documentación es solo unos pocos miles de tokens, insertar directamente en el prompt es más simple y menos propenso a errores que construir todo un pipeline de retrieval.
- Preguntas que requieren razonamiento complejo de múltiples pasos donde la respuesta no se encuentra directamente en un fragmento único — RAG busca “fragmentos similares a la pregunta”, no “fragmentos que contengan la respuesta deducida”.
- Datos que cambian continuamente segundo a segundo (precios de acciones, inventario en tiempo real): deberías llamar directamente a la API (function calling) en lugar de reindexar la base de datos de vectores continuamente.
7. Conclusión
Los LLM nunca “conocen” tus datos privados — solo son buenos interpretando las pruebas que se les proporcionan. RAG resuelve este problema separando el retrieval (encontrar los fragmentos de texto correctos) de la generation (generar la respuesta), con el modelo de embedding y la base de datos de vectores actuando como puentes. La calidad final de la respuesta depende casi completamente de lo bien que indexes y dividas tus datos — ni siquiera un LLM potente puede salvar un paso de retrieval deficiente. Si estás construyendo un sistema RAG, dedica tiempo a probar el chunking y la proporción de aciertos/errores en el retrieval antes de pensar en cambiar a un modelo más grande.