De fragmentos encontrados a respuestas con evidencia en RAG

Este artículo tiene un vídeo corto:YouTubeTikTok

1. Descripción general

Retrieval-Augmented Generation (RAG) a menudo se detiene en el paso “encontrar los top-k fragmentos más cercanos” y los lanza directamente al prompt. Esta forma es propensa a alucinaciones porque la búsqueda vectorial siempre devuelve algo más cercano, incluso cuando ningún fragmento realmente responde la pregunta. Este artículo disecciona el siguiente paso: transformar los candidatos en evidencia confiable, a través de búsqueda híbrida, reranking y un mecanismo de rechazo cuando los datos no son suficientes. Al terminar, construirás un pequeño pipeline en Spring Boot que simula todos estos pasos, junto con pruebas que verifican el comportamiento.

2. El problema: lo más cercano no significa lo correcto

Supongamos que tienes un chatbot para ayudar a empleados a consultar políticas de reembolso cuando vuelos se cancelan. La búsqueda vectorial siempre devolverá los 30 fragmentos “más cercanos” a la pregunta, aunque en la colección no haya ninguno que hable sobre cancelaciones. Si el sistema pasa directamente esos top-3 fragmentos al LLM, el modelo intentará “inventar” una respuesta que suene razonable basada en fragmentos que hablan sobre reservas de hotel ordinarias.

Dos problemas deben resolverse:

  1. Filtrado: entre docenas de candidatos, qué fragmentos son realmente útiles para la pregunta.
  2. Rechazo controlado: cuando ningún fragmento es lo suficientemente bueno, el sistema debe decir “no encontrado” en lugar de adivinar.

3. Pipeline general

Pregunta
  → Búsqueda vectorial (semántica)     ─┐
  → Búsqueda por palabra clave (BM25)   ├─→ Fusión (híbrida) → Rerank → Umbral confianza → Evidencia → LLM

Cada paso reduce gradualmente la cantidad y aumenta gradualmente la calidad: de docenas de candidatos brutos a solo algunos fragmentos con evidencia clara, junto con fuentes citadas.

4. Modelo de datos básico

public record Passage(
        String id,
        String text,
        String source,
        double score
) {
    public Passage withScore(double newScore) {
        return new Passage(id, text, source, newScore);
    }
}

Passage representa un fragmento de texto indexado, junto con source para citar y score para comparar/ordenar. withScore ayuda a crear una copia con una puntuación nueva después de cada paso de rerank sin mutar el objeto original.

5. Búsqueda híbrida: combinando semántica y palabra clave

Los embeddings son buenos para conectar significados (por ejemplo “vuelo cancelado” ↔ “interrupción de viaje”), pero fácilmente pierden coincidencias exactas como códigos de política POL-2024-07 o nombres propios. La búsqueda por palabra clave (BM25) compensa esta debilidad.

public interface VectorSearchClient {
    List<Passage> search(String query, int topK);
}

public interface KeywordSearchClient {
    List<Passage> search(String query, int topK);
}

@Service
public class HybridSearchService {

    private final VectorSearchClient vectorClient;
    private final KeywordSearchClient keywordClient;

    public HybridSearchService(VectorSearchClient vectorClient,
                                KeywordSearchClient keywordClient) {
        this.vectorClient = vectorClient;
        this.keywordClient = keywordClient;
    }

    public List<Passage> search(String query) {
        List<Passage> semantic = vectorClient.search(query, 20);
        List<Passage> keyword = keywordClient.search(query, 20);

        Map<String, Passage> merged = new LinkedHashMap<>();
        for (Passage p : semantic) {
            merged.put(p.id(), p);
        }
        for (Passage p : keyword) {
            merged.merge(p.id(), p, (existing, incoming) ->
                    existing.withScore(existing.score() + incoming.score()));
        }
        return new ArrayList<>(merged.values());
    }
}

merge suma puntuaciones cuando un fragmento aparece en ambas fuentes — esa es una señal fuerte de que el fragmento es tanto semánticamente relevante como coincide exactamente con palabras clave importantes. El resultado es una lista de candidatos fusionada, aún no ordenada por utilidad final.

6. Reranking: filtrado con un modelo de comparación de pares

La búsqueda vectorial compara la pregunta con cada fragmento de forma independiente (bi-encoder), por lo que no puede “leer” la relación profunda entre ellos. El reranker (usualmente un cross-encoder) recibe tanto la pregunta como el fragmento al mismo tiempo, puntuando de forma más precisa pero más lentamente — por eso solo se aplica a una pequeña cantidad de candidatos ya filtrados.

public interface Reranker {
    List<Passage> rerank(String query, List<Passage> candidates, int topN);
}

@Component
public class CrossEncoderReranker implements Reranker {

    private final CrossEncoderClient client; // llama al modelo de rerank externo

    public CrossEncoderReranker(CrossEncoderClient client) {
        this.client = client;
    }

    @Override
    public List<Passage> rerank(String query, List<Passage> candidates, int topN) {
        return candidates.stream()
                .map(p -> p.withScore(client.relevanceScore(query, p.text())))
                .sorted(Comparator.comparingDouble(Passage::score).reversed())
                .limit(topN)
                .toList();
    }
}

client.relevanceScore devuelve la puntuación de relevancia real (por ejemplo 0–1) entre la pregunta y cada fragmento. Después de ordenar en orden descendente, solo retienes topN fragmentos mejores — típicamente 3 a 5, suficientemente cortos para pasar al prompt sin diluir el contexto.

Nota: No reranquees la colección entera de miles de fragmentos — el cross-encoder es mucho más lento que la búsqueda vectorial, decenas a cientos de veces. Siempre limita la entrada de candidatos (20–50) antes de reranquear.

7. Construir evidencia con citas para el LLM

Después del reranking, tienes algunos fragmentos de alta calidad. El siguiente paso es empaquetarlos en un contexto claro, con fuentes, para que el LLM cite nuevamente en lugar de “hablar sin fundamento”.

@Component
public class EvidenceBuilder {

    public String build(List<Passage> topPassages) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < topPassages.size(); i++) {
            Passage p = topPassages.get(i);
            sb.append("[Fuente %d - %s]%n%s%n%n"
                    .formatted(i + 1, p.source(), p.text()));
        }
        return sb.toString();
    }
}

Cada fragmento está numerado y etiquetado con su nombre de fuente (source). Al incluirlo en el prompt del sistema, le pides al LLM que solo responda basándose en estos fragmentos y que cite el número de fuente correspondiente — esto ayuda a los usuarios a verificar la evidencia en lugar de confiar ciegamente en la respuesta.

[Fuente 1 - policy-2024-07.pdf]
El empleado recibe reembolso del 100% de gastos de hotel incurridos si el vuelo se cancela por condiciones climáticas...

[Fuente 2 - policy-2024-07.pdf]
Condiciones aplicables: el vuelo debe estar cancelado, no se aplica a retrasos menores a 6 horas...

8. Manejo del caso sin respuesta

Esta es la parte más fácilmente ignorada. Cada colección tiene “vecinos más cercanos” — incluso cuando ningún fragmento realmente responde la pregunta. Sin verificación, el LLM aprovechará el contexto no relacionado para deducir una respuesta que suena razonable pero es incorrecta.

Solución simple: establecer un umbral de confianza basado en la puntuación de reranking más alta.

@Service
public class AnswerService {

    private static final double CONFIDENCE_THRESHOLD = 0.55;

    private final HybridSearchService searchService;
    private final Reranker reranker;
    private final EvidenceBuilder evidenceBuilder;
    private final LlmClient llmClient;

    public AnswerService(HybridSearchService searchService,
                          Reranker reranker,
                          EvidenceBuilder evidenceBuilder,
                          LlmClient llmClient) {
        this.searchService = searchService;
        this.reranker = reranker;
        this.evidenceBuilder = evidenceBuilder;
        this.llmClient = llmClient;
    }

    public String answer(String question) {
        List<Passage> candidates = searchService.search(question);
        List<Passage> topPassages = reranker.rerank(question, candidates, 4);

        boolean hasEvidence = !topPassages.isEmpty()
                && topPassages.get(0).score() >= CONFIDENCE_THRESHOLD;

        if (!hasEvidence) {
            return "No encontré ninguna política que responda claramente esta pregunta. "
                    + "Deberías contactar al departamento de recursos humanos para confirmar.";
        }

        String context = evidenceBuilder.build(topPassages);
        return llmClient.generate(question, context);
    }
}

Si el fragmento mejor después del reranking tiene una puntuación por debajo de CONFIDENCE_THRESHOLD, el sistema rechaza responder en lugar de llamar al LLM para generar una respuesta sin fundamento. El umbral específico debe ajustarse con datos reales — demasiado bajo causará alucinaciones, demasiado alto rechazará preguntas que sí se pueden responder.

9. Pruebas de comportamiento

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
class AnswerServiceTest {

    @Test
    void shouldAbstainWhenBestScoreBelowThreshold() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage weakMatch = new Passage("p1", "Regulaciones para reserva de hotel ordinaria",
                "hotel-faq.pdf", 0.3);

        when(search.search(anyString())).thenReturn(List.of(weakMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(weakMatch));

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("¿Cómo se reembolsa cuando se cancela un vuelo?");

        assertThat(result).contains("no encontré");
        verifyNoInteractions(llm);
    }

    @Test
    void shouldAnswerWhenConfidenceIsHigh() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage strongMatch = new Passage("p2", "Reembolso del 100% si el vuelo se cancela",
                "policy-2024-07.pdf", 0.82);

        when(search.search(anyString())).thenReturn(List.of(strongMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(strongMatch));
        when(llm.generate(anyString(), anyString())).thenReturn("Recibes reembolso del 100% [Fuente 1].");

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("¿Cómo se reembolsa cuando se cancela un vuelo?");

        assertThat(result).contains("Fuente 1");
        verify(llm).generate(anyString(), anyString());
    }
}

La primera prueba verifica el comportamiento de rechazo cuando la puntuación es baja — llm nunca se llama, ahorrando costos y evitando alucinaciones. La segunda prueba confirma que cuando hay evidencia fuerte, el sistema llama al LLM y la respuesta tiene citas de fuentes.

10. Errores comunes

  • Ignorar búsqueda híbrida, solo usar vectores puros: fácilmente pierdes coincidencias exactas de códigos, números — cosas que los embeddings no diferencian bien.
  • Reranquear el conjunto completo de datos en lugar de un subconjunto filtrado: causa lentitud y gastos innecesarios.
  • No establecer umbral de confianza: siempre responde aunque el contexto no sea relevante, llevando a alucinaciones que los usuarios difícilmente detectan porque la respuesta suena “muy segura”.
  • Contexto demasiado largo: empacar 30 fragmentos originales en el prompt en lugar de 3–5 fragmentos ya reranqueados, diluyendo la señal e incrementando costos de tokens.

Nota: Si el dominio de tu caso tiene un vocabulario estable, pocos términos especializados (por ejemplo, chat conversacional ordinario), la búsqueda híbrida podría ser redundante — la búsqueda vectorial pura aún funciona y es más simple de operar.

11. Conclusión

RAG solo merece confianza cuando el paso de retrieval se detiene en el momento correcto: fusionando resultados semánticos y de palabras clave, reranqueando para elegir fragmentos realmente útiles, y sabiendo rechazar cuando no hay suficiente evidencia. El pequeño pipeline de este artículo — HybridSearchService, Reranker, EvidenceBuilder, AnswerService — es el esqueleto que puedes llenar con modelos reales (embeddings, índice BM25, cross-encoder) para tener un asistente que responda con citas, más honesto que una máquina de adivinar palabras.

ragllmspring-bootbúsquedajava