Des passages trouvés aux réponses fondées en RAG
1. Aperçu
La génération augmentée par récupération (RAG) s’arrête généralement à l’étape « trouver les top-k passages les plus proches » puis les envoyer directement au prompt. Cette approche est sujette aux hallucinations car la recherche vectorielle retourne toujours quelque chose de plus proche, même si aucun passage ne répond réellement à la question. Cet article explore l’étape suivante : transformer les candidats en preuves fiables, via la recherche hybride, le reranking et un mécanisme de refus lorsque les données sont insuffisantes. À la fin, vous aurez construit un petit pipeline Spring Boot qui reproduit toutes ces étapes, accompagné de tests vérifiant le comportement.
2. Le problème : le plus proche ne signifie pas correct
Supposons que vous ayez un chatbot aidant les employés à consulter la politique de remboursement en cas d’annulation de vol. La recherche vectorielle retournera toujours les 30 passages « les plus proches » de la question, même si la collection ne contient aucun passage parlant d’annulation. Si le système passe directement les 3 meilleurs passages au LLM, le modèle tentera « d’inventer » une réponse qui semble raisonnable en se basant sur des passages parlant de réservation ordinaire d’hôtel.
Deux problèmes à résoudre :
- Sélection : parmi des dizaines de candidats, quel passage est vraiment utile pour la question.
- Refus contrôlé : quand aucun passage n’est assez bon, le système doit dire « non trouvé » au lieu de deviner.
3. Pipeline global
Question
→ Recherche vectorielle (sémantique) ─┐
→ Recherche par mots-clés (BM25/exact match) ├─→ Fusion (hybride) → Reranking → Seuil confiance → Preuves → LLM
Chaque étape réduit progressivement le nombre et augmente progressivement la qualité : de plusieurs dizaines de candidats bruts à quelques passages ayant une preuve claire, accompagnés de sources citées.
4. Modèle de données de base
public record Passage(
String id,
String text,
String source,
double score
) {
public Passage withScore(double newScore) {
return new Passage(id, text, source, newScore);
}
}
Passage représente un passage de texte indexé, avec source pour la citation et score pour la comparaison/tri. withScore aide à créer une copie avec un nouveau score après chaque étape de reranking sans muter l’objet original.
5. Recherche hybride : combiner sémantique et keywords
Les embeddings excellent à connecter le sens (par exemple « vol annulé » ↔ « interruption de voyage »), mais ratent facilement les tokens exacts comme les codes de politique POL-2024-07 ou les noms propres. La recherche par mots-clés (BM25) compense cette faiblesse.
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 ajoute des points quand un passage apparaît dans les deux sources — c’est un signal fort que ce passage est à la fois sémantiquement pertinent et correspond exactement aux mots-clés. Le résultat est une liste de candidats fusionnée, pas encore triée par utilité finale.
6. Reranking : filtrer avec un modèle de comparaison de paires
La recherche vectorielle compare la question avec chaque passage indépendamment (bi-encoder), elle ne peut donc pas « lire » la relation profonde entre eux. Un reranker (généralement un cross-encoder) reçoit à la fois la question et le passage, donne un score plus précis mais plus lent — donc appliqué seulement à un petit ensemble de candidats ayant déjà passé le filtre brut.
public interface Reranker {
List<Passage> rerank(String query, List<Passage> candidates, int topN);
}
@Component
public class CrossEncoderReranker implements Reranker {
private final CrossEncoderClient client;
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 retourne le score de pertinence réel (par exemple 0–1) entre la question et chaque passage. Après tri décroissant, on garde seulement les topN meilleurs passages — généralement 3 à 5, assez court pour entrer dans le prompt sans diluer le contexte.
Note : Ne reranquez pas des milliers de passages — un cross-encoder est des dizaines à des centaines de fois plus lent que la recherche vectorielle. Limitez toujours les candidats d’entrée (20–50) avant reranking.
7. Construire des preuves avec citations pour le LLM
Après reranking, vous avez quelques passages de haute qualité. L’étape suivante est de les empaqueter dans un contexte clair et source, pour que le LLM les cite en répondant au lieu de « parler à tort et à travers ».
@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("[Source %d - %s]%n%s%n%n"
.formatted(i + 1, p.source(), p.text()));
}
return sb.toString();
}
}
Chaque passage est numéroté et marqué avec le nom de la source (source). Lors de l’insertion dans le system prompt, vous demandez au LLM de répondre uniquement selon ces passages et de citer le numéro de source correspondant — cela aide l’utilisateur à vérifier les preuves plutôt que de faire confiance aveuglément à la réponse.
[Source 1 - policy-2024-07.pdf]
Les employés sont remboursés à 100 % des frais d'hôtel engagés si le vol est annulé en raison de conditions météorologiques...
[Source 2 - policy-2024-07.pdf]
Conditions d'application : le vol doit être annulé, ne s'applique pas aux retards de moins de 6 heures...
8. Gérer le cas sans réponse
C’est la partie la plus facile à ignorer. Chaque collection a un « voisin le plus proche » — même quand aucun passage ne répond réellement à la question. Sans vérifier, le LLM exploitera le contexte non pertinent pour déduire une réponse qui semble raisonnable mais fausse.
Solution simple : établir un seuil de confiance basé sur le score de reranking le plus élevé.
@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 "Je n'ai trouvé aucune politique qui répond clairement à votre question. "
+ "Vous devriez contacter le département RH pour confirmation.";
}
String context = evidenceBuilder.build(topPassages);
return llmClient.generate(question, context);
}
}
Si le meilleur passage après reranking a un score inférieur à CONFIDENCE_THRESHOLD, le système refuse de répondre au lieu d’appeler le LLM pour générer une réponse sans fondement. Le seuil spécifique doit être réglé sur des données réelles — trop bas causera des hallucinations, trop haut refusera même les questions qui devraient être répondues.
9. Tester le comportement
<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", "Quy định đặt phòng khách sạn thường",
"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("Hoàn tiền khi chuyến bay bị hủy thế nào?");
assertThat(result).contains("không tìm thấy chính sách");
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", "Hoàn 100% chi phí nếu chuyến bay bị hủy",
"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("Bạn được hoàn 100% [Nguồn 1].");
AnswerService service = new AnswerService(search, reranker, builder, llm);
String result = service.answer("Hoàn tiền khi chuyến bay bị hủy thế nào?");
assertThat(result).contains("Nguồn 1");
verify(llm).generate(anyString(), anyString());
}
}
Le premier test vérifie le comportement de refus quand le score est faible — le llm n’est jamais appelé, ce qui économise les coûts et évite les hallucinations. Le test suivant confirme que lorsqu’il y a une preuve forte, le système appelle le LLM et la réponse inclut des citations sources.
10. Erreurs courantes
- Ignorer la recherche hybride, utiliser uniquement la recherche vectorielle pure : on perd facilement les correspondances exactes sur les codes, les noms propres — ce que les embeddings ne distinguent pas bien.
- Reranquer l’ensemble du jeu de données au lieu du sous-ensemble filtré : cela ralentit et gaspille les ressources inutilement.
- Ne pas fixer de seuil de confiance : répondre toujours même si le contexte n’est pas pertinent, ce qui mène à des hallucinations difficiles à détecter car la réponse « sonne » très confiante.
- Contexte trop long : fourrer 30 passages bruts dans le prompt au lieu de 3–5 passages reranqués, ce qui dilue le signal et augmente les coûts en tokens.
Note : Si votre domaine a un vocabulaire stable, peu de mots-clés spécialisés (par exemple, un chat conversationnel ordinaire), la recherche hybride peut être redondante — une simple recherche vectorielle suffit et est plus simple à maintenir.
11. Conclusion
RAG n’est fiable que lorsque l’étape de retrieval s’arrête au bon moment : fusionner les résultats sémantiques et par mots-clés, reranker pour sélectionner les passages vraiment utiles, et savoir refuser quand il n’y a pas assez de preuve. Le petit pipeline de cet article — HybridSearchService, Reranker, EvidenceBuilder, AnswerService — est un squelette dans lequel vous pouvez brancher les vrais modèles (embedding, index BM25, cross-encoder) pour avoir un assistant qui répond avec citations, plus honnête qu’une simple machine à deviner.