IA Générative, RAG, Évaluation
Le RAG (Retrieval Augmented Generation) est une technique qui améliore la génération de texte en combinant l’intelligence artificielle avec une base de connaissances externe. Avant de produire une réponse, le système RAG consulte cette base pour récupérer des informations pertinentes sur le sujet demandé. Cette approche permet d’obtenir des réponses plus précises et mieux informées, en s’appuyant sur des données à jour plutôt que sur les seules connaissances préalables du modèle. Nous expliquons dans ce document comment évaluer ces systèmes.
Étapes principales
La méthode d’évaluation repose sur le framework Ragas
https://docs.ragas.io/en/latest/index.html. Elle est composée des étapes suivantes :
- Se procurer ou simuler un ensemble de test
- Appliquer son RAG pipeline sur les questions de l’ensemble de test
- Appliquer les métriques d’évaluation à son ensemble de test
En cas d’amélioration des métriques d’évaluation d’un nouveau modèle, les résultats de celui-ci doivent toujours être vérifiés par un humain avant le passage en production.
Ensemble de test
Les métriques d’évaluation sont réparties en deux groupes.
Ensemble de test expert humain
Vous avez obtenu un ensemble d’au moins 50 questions/réponses (on appellera ces réponses ground truth) d’un expert du sujet portant sur le contenu de la base documentaire. Si cet ensemble de test ne comporte que des questions, vous ne pourrez pas utiliser toutes les métriques d’évaluation et vous devrez simuler un ensemble de test complet.
Simuler un ensemble de test expert LLM
Pour simuler un ensemble de test synthétique, il vous faut installer le package ragas
. Cette note est valide pour la version du package 0.1.7. Vous aurez également besoin de langchain
pour convertir l’ensemble de votre base documentaire sous la forme de LangchainDocument
.
poetry add ragas==0.1.7
Pour générer un ensemble de question/contexte/réponse en français, vous aurez besoin d’adapter les prompts de génération en téléchargeant le dossier des prompts traduits, qui est à mettre dans le même dossier que votre scripte/notebook. N’hésitez pas à changer les modèles LLM ou d’embeddings suivant les contraintes spécifiques à votre projet (confidentialité…)
Code
from ragas.testset.generator import TestsetGenerator
from ragas.testset.evolutions import simple, reasoning, multi_context, conditional
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
= ChatOpenAI(model="gpt-3.5-turbo-16k")
generator_llm = ChatOpenAI(model="gpt-4o")
critic_llm = "text-embedding-3-large"
embedding_model
= '.ragas_fr' # A commenter si la langue est l'anglais
cache_dir = "french" # A commenter si la langue est l'anglais
language
def build_testset(
= List[Langchaindocs],
documents
generator_llm: ChatOpenAI,
critic_llm: ChatOpenAI,int,
chunk_size: str,
embedding_model:dict[str, int],
distributions: int,
test_size: str,
language: = str) -> Dataset:
cache_dir
= OpenAIEmbeddings(
embeddings =embedding_model,
model=chunk_size,
chunk_size
)= TestsetGenerator.from_langchain(
generator
generator_llm,
critic_llm, =chunk_size)
embeddings, chunk_size
=[simple, reasoning,conditional,multi_context], cache_dir=cache_dir) # A commenter si la langue est l'anglais
generator.adapt(language, evolutions
= generator.generate_with_langchain_docs(
testset
documents,=test_size,
test_size=distributions,
distributions=False,
raise_exceptions=False,
with_debugging_logs
)return testset
Générer ensuite un ensemble de test de taille 30 pour chacun des 4 types de questions :
- simple : la réponse à la question est directement récupérable dans le contexte,
- reasoning : la réponse nécessite de faire un raisonnement à partir de l’information du contexte,
- conditional : la réponse est conditionnée à une autre information du contexte,
- multi-context : Le contexte est la concaténation de deux contextes différents.
Choisissez un chunk_size
grand si possible (en nombre de tokens), l’extraction de document faite au préalable vous donnera beaucoup de chunks de tailles plus petites, puis sauvegarder les données sur le disque.
Code
= {simple: 'simple',
evol_names 'reasoning',
reasoning: 'multi_context',
multi_context: 'conditional'}
conditional: = 30
test_size = 2048
chunk_size
for evol in [simple, reasoning, multi_context, conditional]:
={
distributions1
evol:
}= build_testset(documents, chunk_size, distributions, test_size, language, cache_dir)
testset f'my_save_folder/testset_{chunk_size}_{evol_names[evol]}') testset.to_dataset().save_to_disk(
Vous pouvez lire et mettre sous forme de dataframe votre ensemble de test de la manière suivante :
Code
from datasets import load_from_disk
from datasets import concatenate_datasets
= [load_from_disk('./my_save_folder/'+path) for path in os.listdir("./my_save_folder/")]
testsets
= concatenate_datasets([ts for ts in testsets])
testset = testset.to_pandas() test_df
Vérifier manuellement et éventuellement filtrer les réponses générées non pertinentes. Par exemple, de temps en temps, l’algorithme de génération renvoie 'nan'
.
Code
= test_df['ground_truth'] != 'nan'
index_nonan = test_df['question'][index_nonan].values.tolist()
test_questions = [[item] for item in test_df["ground_truth"][index_nonan].values.tolist()] test_gt
On obtient une liste de questions et une liste de ground truth (“vraies” réponses). Il faudra effectuer une dernière phase de filtrage avec les métriques d’évaluation. Celle-ci est décrite en Section Section 3.1.1.
Évaluation
Pour l’évaluation, on se sert à nouveau de la librairie ragas
(cf. Génération d’un ensemble de test). Il existe plusieurs métriques, dont l’intérêt est expliqué ci-dessous.
Code
from ragas.metrics import (
answer_relevancy,
faithfulness,
context_recall,
context_precision,# context_relevancy,
# context_entity_recall,
answer_similarity,
answer_correctness,
context_utilization
)
from ragas import adapt
from ragas.evaluation import evaluate
from datasets import Dataset
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
= ChatOpenAI(model="gpt-4o-2024-05-13", temperature=0) evaluation_llm
Pensez-bien à choisir la version du LLM d’évaluation, par exemple "gpt-4o-2024-05-13"
et non "gpt-4o"
afin que l’évaluation soit consistante au cours du temps.
Si votre ensemble de question/réponse est en français, alors il vous faudra changer les prompts qui sont utilisés dans le calcul des métriques d’évaluation avec la fonction adapt
et le répertoire de prompt que vous aurez téléchargé.
Évaluation avec ground truth
Vous disposez d’un ensemble de tests avec questions et réponses (expert ou synthétique). Nous utilisons alors les quatre métriques suivantes :
Code
= [answer_relevancy,
metrics_with_gt
faithfulness,
context_recall,
context_precision,
answer_correctness]
=metrics_with_gt, language="french", cache_dir='.ragas_fr') adapt(metrics
Pour l’évaluation, il faut fournir un dataset avec quatre champs :
"question" - List[str]
: La liste des questions de l’ensemble de test."ground_truth" - List[str]
: La liste des réponses générées ou obtenues lors de la création de l’ensemble de test."answer" - List[str]
: La liste des réponses générées par votre rag pipeline."contexts" - List[List[str]]
: La liste des contextes (liste de chunks) retrouvés votre rag pipeline.
Code
= Dataset.from_dict(
ds_with_gt
{"question": test_questions,
"answer": infered_answers, # La liste des réponses obtenu avec votre rag pipeline
"contexts": retrieved_contexts, # La liste des contextes (liste de chunks) retrouver votre rag pipeline
"ground_truth": [gt[0] for gt in test_gt],
}
)= evaluate(ds_with_gt, metrics=metrics_with_gt, llm = evaluation_llm)
ragas_eval_with_gt
'save_folder_results_ragas/ragas_eval_with_gt.json') ragas_eval_with_gt.to_pandas().to_json(
Filtrage automatique du jeu de test
Si le jeu de test a été généré synthétiquement, il est important de se prémunir des éventuelles hallucinations du LLM qui a généré l’ensemble de question réponse. Pour cela on va filtrer les questions pour ne garder que celles qui ont un score parfait dans les métriques d’évaluation context_recall
et faithfulness
(égales à 1). Pour cela, on suppose que notre réponse answer
est la ground_truth
.
Code
= [gt[0] for gt in test_gt]
ground_truth
= Dataset.from_dict(
ds_testset
{"question": test_questions,
"answer": ground_truth, # La liste des réponses obtenu avec votre rag pipeline
"contexts": retrieved_contexts, # La liste des contextes (liste de chunks) retrouver votre rag pipeline
"ground_truth": ground_truth,
}
)= evaluate(ds_with_gt, metrics=[faithfulness, context_recall], llm = evaluation_llm)
ragas_testset
= ragas_testset.to_pandas()
df_result_testset
= (df_result_testset['faithfulness']==1) &
id_faithful 'context_recall'] == 1)
(pdf_result_testset[f'my_save_folder/filtered/testset_{chunk_size}_{evol_names[evol]}') my_ragas_results[id_faithful].to_dataset().save_to_disk(
Evaluation sans ground truth
Vous disposez uniquement d’un ensemble de question de test. Il ne vous est alors pas possible d’évaluer finement la capacité de votre retriever. Nous utilisons alors les trois métriques suivantes :
Code
= [answer_relevancy,
metrics_wo_gt
faithfulness,
context_utilization]
=metrics_wo_gt, language="french", cache_dir='.ragas_fr') adapt(metrics
Pour l’évaluation, il faut fournir un dataset avec trois champs :
"question" - List[str]
: La liste des questions de l’ensemble de test."answer" - List[str]
: La liste des réponses obtenues avec votre rag pipeline."contexts" - List[List[str]]
: La liste des contextes (liste de chunks) retrouver votre rag pipeline.
Code
= Dataset.from_dict(
ds_wo_gt
{"question": test_questions,
"answer": infered_answers, # La liste des réponses obtenu avec votre rag pipeline
"contexts": retrieved_contexts, # La liste des contextes (liste de chunks) retrouver votre rag pipeline
}
)= evaluate(ds_wo_gt, metrics=metrics_wo_gt, llm = evaluation_llm)
result_with_gt
'save_folder_results_ragas/ragas_eval_wo_gt.json') ragas_eval_with_gt.to_pandas().to_json(
Analyse des résultats et conseil d’amélioration
On peut analyser les fichiers que les résultats de notre protocole d’évaluation. Cette partie fournit quelques (pseudo)-codes de visualisation et des recommandations d’amélioration.
Code
= pd.read_json('results_ragas/ragas_eval_with_gt1.json')
df_ragas1 = ...
df_ragas2
= df_ragas1. \
res1 =['question']). \
merge(test_df, on'evolution_type'). \
groupby(=True).\
mean(numeric_only= 2, chunk_size = 300). \
assign(k
reset_index()
= ...
res2 = pd.concat([res1, res2,...], ignore_index=True) results
Optimisation et tradeoff Précision-Recall
Le context_precision
et le context_recall
ne sont accessibles que dans le cas où il existe un ground_truth
. Ils quantifient la qualité de notre chaine de récupération de contexte. D’une manière générale, notre objectif est de retrouver le contexte le plus petit possible (pour des raisons d’efficacité mais aussi de coût et de rapidité) qui contiennent toute l’information dont on a besoin.
- Context recall Le rappel du contexte représente la proportion de fait présent dans le ground truth présent dans le contexte récupéré. Avoir un rappel élevé (proche de 1) est indispensable pour pouvoir générer une réponse exhaustive.
- Context precision La précision du contexte évalue la position dans le contexte des éléments utiles pour pouvoir retrouver le ground truth. Avoir une précision élevée (proche de 1) facilite la tâche du modèle de langage, mais n’est pas nécessaire, ni ne garantis, d’obtenir une réponse correcte.
Dans un premier temps, optimiser votre recall (au moins 0.9). Pour cela, augmenter la taille de votre contexte, en prenant des chunks plus grand ou bien en augmentant le k
du top_k
.
En augmentant le recall, on fait face à un trade-off precision-recall. Essayer de déterminer le point où le rappel est suffisamment élevé, mais où la précision n’a pas encore trop diminué.
Si le rappel reste faible ou si le tradeoff precision/recall est trop fort, il faut revoir toute la chaine de retrieval du rag. Adapter des stratégies plus complexes. N’hésitez pas à contacter la team R&D de Polynom !
Le calcul du recall nécessite qu’un LLM subdivise une réponse en fait unitaire. Cela implique une forte variance dans la métrique et un problème de reproductibilité. Il ne faut donc pas surinterpréter des petites différences.
Code
'context_recall', 'context_precision',
px.scatter(results, = 'evolution_type', facet_col= 'k', facet_row='chunk_size') color
Amélioration de la faithfulness et de l’answer relevancy/correctness
- Answer correctness Une réponse générée par notre RAG est correcte, si la réponse jugée proche du ground truth par un modèle de langage.
- Faithfulness Juge à quel point les informations contenues dans la réponse du système de conversation sont fidèles au contexte retrouvé.
- Answer relevancy Juge la capacité à retrouver la question originale à partir de la réponse et du contexte.
Par construction de l’embedding utilisé par la métrique, elle sera toujours élevée (au moins 0.6). De plus, la moyenne dépend beaucoup du champ applicatif. À n’utiliser que de manière relative !
Avec ground truth
Vous avez obtenu un recall élevé. Si vous avez également une bonne précision, alors votre chaine de retrieval est bonne. Essayez d’améliorer les prompts/prompt instructions.
Si votre contexte est très long et/ou que votre précision est mauvaise, il vous faudra probablement un LLM plus puissant pour générer la réponse.
Si vous avez un faible recall (et une mauvaise faithfulness) mais une bonne relevancy, alors votre LLM hallucine !
Code
'faithfulness', 'answer_relevancy',
px.scatter(results, = 'evolution_type', facet_col= 'k', facet_row='chunk_size') color
Attention avec la métrique d’answer correctness. Elle pénalise beaucoup les réponses réponses plus détaillées que la ground truth, cependant des études (https://tech.beatrust.com/entry/2024/05/02/RAG_Evaluation%3A_Assessing_the_Usefulness_of_Ragas) montrent que cette métrique est très corrélée avec de l’interprétation humaine quand on utilise un LLM puissant (gpt4) et qu’il vaut mieux l’utiliser à la place de l’answer relevancy.
Regardez des exemples à la main si ces deux métriques aboutissent à des conclusions différentes.
Code
= pd.concat([res_prompt1.assign(prompt= 'tlo'),
res_prompt = 'custom')], ignore_index=True)
res_prompt2.assign(prompt
='faithfulness', y= 'answer_relevancy',
px.scatter(res_prompt, x= 'evolution_type', symbol='prompt',
color =[0.8,1], range_x = [0.7,1])\
range_y={'size': 15}) .update_traces(marker
Code
= pd.concat([res_llm_new.assign(prompt= 'gpt-3.5-new'),
res_llm = 'gpt-3.5-old')], ignore_index=True)
res_llm_old.assign(prompt
='faithfulness', y= 'answer_relevancy',
px.scatter(res_prompt, x= 'evolution_type', symbol='llm',
color =[0.8,1], range_x = [0.7,1])\
range_y={'size': 15}) .update_traces(marker
Sans ground truth
Lorsque l’on ne dispose pas de ground truth, on se base sur la triade de métrique suivante :
- Faithfulness Juge à quel point les informations contenus dans la réponse du système de conversation sont fidèles au contexte retrouvé.
- Answer relevancy Juge la capacité à retrouver la question originale à partir de la réponse et du contexte.
- Context utilization L’équivalent de la précision, mais sans ground truth.
Il est beaucoup plus dur de cibler spécifiquement la chaine de retrieval, mais ces trois métriques peuvent être utilisées pour juger globalement la qualité de votre RAG sur un ensemble de questions communes (type extraction d’information par exemple).
Conclusion
Nous avons présenté des méthodes qui permettent d’évaluer la partie, récupération et génération d’une application RAG. Des études numériques ont permis de se forger une intuition de l’impact des différents éléments d’une chaîne de RAG simple sur les métriques. Bien entendu, des méthodes de RAG plus avancées permettent d’améliorer davantage les résultats.