De trechos encontrados a respostas com evidências em RAG
1. Visão geral
Retrieval-Augmented Generation (RAG) costuma parar no passo “encontrar os k trechos mais próximos” e joga tudo direto no prompt. Esse jeito é fácil de gerar alucinações porque busca vetorial sempre retorna algo próximo, mesmo que nenhum trecho realmente responda a pergunta. Este artigo disseca o próximo passo: transformar candidatos em evidências confiáveis, por meio de busca híbrida, reranking e um mecanismo de recusa quando os dados são insuficientes. Ao terminar de ler, você terá construído um pequeno pipeline em Spring Boot que simula todos esses passos, com testes que verificam o comportamento.
2. O problema: próximo não significa correto
Suponha que você tenha um chatbot para ajudar funcionários a consultar políticas de reembolso quando um voo é cancelado. Busca vetorial sempre retorna os 30 trechos “mais próximos” da pergunta, mesmo que a coleção não tenha nenhum sobre cancelamento de voos. Se o sistema entregar direto os 3 trechos principais para a LLM, o modelo vai tentar “inventar” uma resposta que pareça razoável, baseado em trechos sobre reserva de hotéis comum.
Dois problemas precisam ser resolvidos:
- Seleção: entre dezenas de candidatos, qual trecho é realmente útil para a pergunta.
- Recusa controlada: quando nenhum trecho é bom o suficiente, o sistema deve dizer “não encontrado” em vez de adivinhar.
3. Pipeline geral
Pergunta
→ Busca vetorial (semântica) ─┐
→ Busca por palavra-chave (BM25) ├─→ Merging (híbrido) → Rerank → Limiar de confiança → Evidência → LLM
Cada passo reduz gradualmente a quantidade e aumenta a qualidade: de dezenas de candidatos brutos até apenas alguns trechos com evidências claras, com citações de fonte.
4. Modelo de dados 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 um segmento de texto indexado, com source para citação e score para comparação/ordenação. withScore ajuda a criar uma cópia com nova pontuação após cada passo de rerank, sem mutar o objeto original.
5. Busca híbrida: combinando semântica e palavras-chave
Embeddings são bons em conectar significado (por exemplo “voo cancelado” ↔ “interrupção de viagem”), mas fáceis de perder tokens exatos como códigos de política POL-2024-07 ou nomes próprios. Busca por palavra-chave (BM25) compensa essa fraqueza.
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 soma pontos quando um trecho aparece nos dois resultados — é um sinal forte de que o trecho é relevante tanto em significado quanto em correspondência de palavras-chave exatas. O resultado é uma lista de candidatos fundidos, ainda não ordenada por utilidade final.
6. Reranking: filtrando com um modelo de comparação em pares
Busca vetorial compara a pergunta com cada trecho independentemente (bi-encoder), então não consegue “ler” as relações profundas entre eles. Reranker (geralmente um cross-encoder) recebe a pergunta e o trecho juntos, atribuindo uma pontuação mais precisa, mas mais lento — por isso é aplicado apenas a um pequeno conjunto de candidatos já filtrados.
public interface Reranker {
List<Passage> rerank(String query, List<Passage> candidates, int topN);
}
@Component
public class CrossEncoderReranker implements Reranker {
private final CrossEncoderClient client; // chama modelo externo de rerank
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 retorna a pontuação de relevância real (por exemplo 0–1) entre a pergunta e cada trecho. Após ordenar em ordem decrescente, você mantém apenas topN trechos melhores — geralmente 3 a 5, compactos o suficiente para entrar no prompt sem diluir o contexto.
Aviso: Não faça rerank em milhares de trechos — cross-encoder é dezenas a centenas de vezes mais lento que busca vetorial. Sempre limite os candidatos de entrada (20–50) antes de rerankar.
7. Construindo evidência com citações para a LLM
Após reranking, você tem alguns trechos de alta qualidade. O próximo passo é empacotá-los em um contexto claro, com fonte, para que a LLM cite novamente ao responder, em vez de “falar ao acaso”.
@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("[Fonte %d - %s]%n%s%n%n"
.formatted(i + 1, p.source(), p.text()));
}
return sb.toString();
}
}
Cada trecho é numerado e recebe o nome da fonte (source). Ao incorporar no prompt do sistema, você pede à LLM que responda apenas com base nesses trechos e cite o número de fonte correspondente — isso ajuda o usuário a verificar as evidências em vez de confiar cegamente na resposta.
[Fonte 1 - policy-2024-07.pdf]
Funcionários recebem reembolso de 100% dos gastos com hotel se o voo for cancelado por razões climáticas...
[Fonte 2 - policy-2024-07.pdf]
Condições de aplicação: o voo deve ser cancelado, não se aplica a atrasos menores de 6 horas...
8. Tratando o caso sem resposta
Esta é a parte mais fácil de negligenciar. Toda coleção tem um “vizinho mais próximo” — mesmo quando nenhum trecho realmente responde a pergunta. Se você não verificar, a LLM usará contexto irrelevante para inferir uma resposta que parece razoável, mas está errada.
Solução simples: defina um limiar de confiança (confidence threshold) baseado na pontuação mais alta do reranker.
@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 "Não consegui encontrar uma política que responda claramente sua pergunta. "
+ "Você deveria contatar o departamento de RH para confirmar.";
}
String context = evidenceBuilder.build(topPassages);
return llmClient.generate(question, context);
}
}
Se o melhor trecho após reranking tiver pontuação abaixo de CONFIDENCE_THRESHOLD, o sistema responde com recusa em vez de chamar a LLM para gerar uma resposta sem fundamento. O limiar específico deve ser ajustado com dados reais — muito baixo causa alucinações, muito alto rejeita perguntas que deveriam ser respondidas.
9. Testando o comportamento
<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", "Regulamentos de reserva de hotel comum",
"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("Como funciona o reembolso quando um voo é cancelado?");
assertThat(result).contains("não consegui encontrar");
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 de 100% dos gastos se o voo for cancelado",
"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("Você recebe reembolso de 100% [Fonte 1].");
AnswerService service = new AnswerService(search, reranker, builder, llm);
String result = service.answer("Como funciona o reembolso quando um voo é cancelado?");
assertThat(result).contains("Fonte 1");
verify(llm).generate(anyString(), anyString());
}
}
O primeiro teste verifica o comportamento de recusa quando a pontuação é baixa — llm nunca é chamado, economizando custos e evitando alucinações. O segundo teste confirma que quando há evidências fortes, o sistema chama a LLM e a resposta tem citação de fonte.
10. Erros comuns
- Pular busca híbrida, usar apenas embedding puro: fácil perder correspondências exatas em códigos, números, nomes próprios — coisas que embeddings não diferenciam bem.
- Rerankar todo o conjunto de dados em vez de um subconjunto filtrado: causa lentidão e despesa desnecessária.
- Não definir limiar de confiança: sempre responde mesmo quando o contexto é irrelevante, levando a alucinações que o usuário acha difícil detectar porque a resposta “soa” muito confiante.
- Contexto muito longo: colocar 30 trechos originais no prompt em vez de 3–5 trechos já reranqueados, diluindo o sinal e aumentando custo de tokens.
Dica: Se o domínio tem vocabulário estável e poucos termos-chave especializados (por exemplo chat conversacional), busca híbrida pode ser redundante — apenas busca vetorial ainda é suficiente e mais simples de operar.
11. Conclusão
RAG merece confiança apenas quando retrieval sabe quando parar: mescla resultados semânticos e por palavra-chave, rerank para escolher trechos realmente úteis, e sabe se recusar quando não há evidência suficiente. O pequeno pipeline neste artigo — HybridSearchService, Reranker, EvidenceBuilder, AnswerService — é o esqueleto que você pode conectar a modelos reais (embedding, índice BM25, cross-encoder) para ter um assistente que responde com citações, mais honesto do que uma máquina de adivinhar palavras.