В вводной части обзора мы познакомились с концепцией Retrieval Augmented Generation (RAG) и её расширением через методологию RAGAS (Retrieval Augmented Generation Automated Scoring). Мы разобрались, как RAGAS подходит к процессу оценки эффективности и точности RAG-систем.
Если после перечисления всех этих аббревиатур вам вдруг резко захотелось прекратить чтение, то не торопитесь. Всё это необходимо для разработки качественных чат-ботов. Но что на самом деле означает "качественный", и как можно измерить это качество?
Обычно оценка качества производится путём анализа обратной связи от пользователей, либо пользователь голосует рублем. Допустим, вы разработали чат-бота и обнаружили, что юзеры не в восторге от его ответов. Вы вносите изменения, например, заменяете одну LLM на другую и надеетесь, что теперь-то ответы всех устроят. Это можно сделать ещё более умно через A/B-тестирование. Но можно ли ускорить релизный цикл, заранее оценив влияние внесённых изменений? RAGAS как раз предлагает ответ на этот вопрос.
В этой части мы более подробно рассмотрим техническую сторону RAGAS. Как обычно, начнем с более простых и интуитивно понятных примеров, потом перейдем к более сложным сценариям. Статья будет сопровождаться примерами кода на python 3.11. Подразумевается, что мы стартуем в новом окружении (я использую conda).
Разбираемся в БАЗЕ
Поскольку фреймворк включает множество метрик, целесообразно начать процесс освоения их основ на примере небольшого датасета. Это позволит не только понять методы расчета каждой метрики, но и оценить их практическую применимость и взаимосвязь.
Для начала установим библиотеку:
pip install git+https://github.com/explodinggradients/ragas.git
Под капотом будут работать модельки OpenAI, поэтому нужно будет установить ключ, если вы работаете из России (или другого неугодного региона), то следует также прописать прокси:
import os
os.environ['http_proxy'] = "http://****:***@**.**.**.**:****"
os.environ['HTTP_PROXY'] = "http://****:***@**.**.**.**:****"
os.environ['https_proxy'] = "http://****:***@**.**.**.**:****"
os.environ['HTTPS_PROXY'] = "http://****:***@**.**.**.**:****"
os.environ["OPENAI_API_KEY"] = "sk-0sE3fv***"
Далее можно скачать специально подготовленный датасет для вычисления метрик:
from datasets import load_dataset
ragas_eval = load_dataset('explodinggradients/fiqa', 'ragas_eval')
ragas_eval_pd = fiqa_eval['baseline'].to_pandas()
ragas_eval_pd.head()
Датасет содержит вопросы, ответы, эталонные ответы (ground truths), а также контекст, на основе которого ваша языковая модель должна сформулировать ответ. Контекст может включать не один документ, а сразу несколько, что зависит от настроек конкретного ретривера. Основной вопрос, конечно, заключается в получении ground truths. В самом фреймворке это реализовано с использованием наиболее мощной модели OpenAI — gpt-4
. То есть, эти эталонные ответы создаются синтетически (на самом деле и вопросы будут создаваться синтетически). По сути, вы будете стремиться приблизить ответы более слабой модели (например, LLaMa) к ответам более сильной. Вы также можете добавить в этот датасет примеры, размеченные вручную.
Попробуем вычислить наши метрики на этом датасете:
from ragas.metrics import (
answer_relevancy,
faithfulness,
context_recall,
context_precision,
)
from ragas import evaluate
result = evaluate(
ragas_eval['baseline'].select(range(5)),
metrics=[
context_precision,
context_recall,
faithfulness,
answer_relevancy,
]
).to_pandas()
Цифры получены, осталось лишь понять, что они означают.
В RAG-пайплайне условно можно выделить две базовые части: ретривер и языковую модель генерации. Ретривер отвечает за формирование контекста, ну а языковая модель уже генерирует на его основе ответ. Логично оценивать эти два этапа независимо. За качество ретривера в RAGAS отвечают метрики context precision
и context recall
. Давайте подробно разберемся в каждой из них.
context precision
Цель состоит в количественной оценке точности извлечённого контекста. Это помогает оптимизировать размер блоков данных.
Процесс включает в себя идентификацию и извлечение предложений из данного контекста, которые необходимы для ответа на заданный вопрос.
Итоговая оценка рассчитывается как отношение числа извлечённых предложений к общему числу предложений в данном контексте.
Если заглянуть под капот вычислений этой метрики, то, во первых, мы увидим, что true_positives
и false_positives
определяются LLM. Далее метрика учитывает не только количество правильных ответов, но и их порядок. У нас формируется verdict_list
- список оценок, где 1 означает полезный контекст.
Расчет context precision:
verdict_list = [1, 0, 1, 1]
Первая оценка "1": Сумма до первого элемента включительно (1) / 1 (позиция) = 1.
Вторая оценка "0": Не учитывается, так как оценка "0".
Третья оценка "1": Сумма до третьего элемента включительно (2) / 3 (позиция) = 2/3.
Четвертая оценка "1": Сумма до четвертого элемента включительно (3) / 4 (позиция) = 3/4.
Числитель: 1 + 0 + 2/3 + 3/4 = 29/12
Знаменатель: 1 + 0 + 1 + 1 = 3
Значение метрики: 29/36 = 0.81
context recall
Эта метрика оценивает, насколько хорошо каждое предложение в ответе может быть отнесено к данному контексту.
Итоговая оценка рассчитывается как отношение TP к сумме TP и FN (TP / (TP + FN)).
В качестве иллюстрации расчета я возьму пример из документации
Контекст:
"Альберт Эйнштейн (14 марта 1879 года – 18 апреля 1955 года) был немецким теоретическим физиком, широко считающимся одним из величайших и самых влиятельных ученых всех времен. Наиболее известен разработкой теории относительности, он также внес важный вклад в квантовую механику и был центральной фигурой в революционном переосмыслении научного понимания природы, которое современная физика осуществила в первые десятилетия двадцатого века. Его формула эквивалентности массы и энергии E = mc², возникшая из теории относительности, была названа 'самым известным уравнением в мире'. Он получил Нобелевскую премию по физике 1921 года 'за его заслуги перед теоретической физикой и, особенно, за его открытие закона фотоэлектрического эффекта', важный этап в разработке квантовой теории. Его работа также известна своим влиянием на философию науки. В опросе 130 ведущих физиков мира, проведенном в 1999 году британским журналом Physics World, Эйнштейн был признан величайшим физиком всех времен. Его интеллектуальные достижения и оригинальность сделали Эйнштейна синонимом гения".
Вопрос:
Что вы можете рассказать мне об Альберте Эйнштейне?
GT:
"Альберт Эйнштейн, родившийся 14 марта 1879 года, был немецким теоретическим физиком, широко признанным одним из величайших и самых влиятельных ученых всех времен. Он получил Нобелевскую премию по физике в 1921 году за свои заслуги в области теоретической физики. Он опубликовал 4 статьи в 1905 году. Эйнштейн переехал в Швейцарию в 1895 году".
Утверждение о дате рождения и вкладе в физику:
Соответствие: "1" (прямо упоминается в контексте).
Утверждение о Нобелевской премии:
Соответствие: "1" (прямо упоминается в контексте).
Утверждение о публикации работ в 1905 году:
Соответствие: "0" (не упоминается в контексте).
Утверждение о переезде в Швейцарию:
Соответствие: "0" (не упоминается в контексте).
Таким образом только половина утверждений ответа соответствует информации контекста, поэтому значение метрики будет 0.5.
Теперь, когда мы разобрались с расчётом метрик, возникает вопрос об их значимости для оценки финального ответа. Означает ли низкое значение context precision
, что с нашим ретривером что-то не так? На самом деле не все так однозначно. Например, низкая метрика может указывать на то, что собранный контекст слишком обширен для корректного ответа на вопрос, хотя модель всё равно выдаёт правильный ответ. В этом случае можно попытаться сократить контекст, например, выбирая не пять, а три топовых документа. Если вы используете платную модель, то таким образом можно сэкономить и на токенах. Аналогичная ситуация может быть и с context recall
. Возможно, проблема кроется в неправильно размеченных ground truth данных, когда в корпусе вообще отсутствует информация, соответствующая эталонным ответам.
Давайте теперь посмотрим, как настройки ретривера могут влиять на эти метрики.
Тестируем ретривер
В качестве примеров документов я возьму свои статьи про langchain, тем более, что мы будем активно использовать эту библиотеку для тестирования элементов RAG.
from langchain.document_loaders import WebBaseLoader
from langchain_community.chat_models import ChatOpenAI
from langchain.chains import RetrievalQA
from langchain.vectorstores import FAISS
from langchain.embeddings import OpenAIEmbeddings
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Загружаем статьи
habr_loader = WebBaseLoader(
['https://habr.com/ru/articles/729664/',
'https://habr.com/ru/articles/733262/',
'https://habr.com/ru/articles/734146/',
'https://habr.com/ru/articles/735920/'
]
)
habr_docs = habr_loader.load()
Далее построим индекс на основе этого корпуса.
# используем вектора OpenAI
embeddings_model = OpenAIEmbeddings()
# разбиваем документ на чанки
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500)
chunks = text_splitter.split_documents(habr_docs)
# индексируем в векторном хранилище
vectorstore = FAISS.from_documents(chunks, embeddings_model)
llm = ChatOpenAI(model_name='gpt-3.5-turbo-1106')
# задаем параметры ретривера
qa_chain = RetrievalQA.from_chain_type(
llm,
retriever=vectorstore.as_retriever(search_kwargs={'k': 5}),
return_source_documents=True
)
result = qa_chain({'query': 'Что такое langchain?'})
Создаем синтетические данные
Для тестирования ретривера (а позже и языковой модели LLM), нам необходимо создать синтетические данные. Это можно сделать вручную, однако в самом фреймворке предусмотрена функция автоматической генерации таких данных. Автоматическая генерация включает создание вопросов различных типов — от простых и прямых до сложных многошаговых, требующих глубокого понимания контекста.
Запустим код генерации:
from ragas.testset import TestsetGenerator
# нужно добавить поле file_name, иначе генерация не запустится
for d in habr_docs:
d.metadata['file_name'] = d.metadata['title']
testsetgenerator = TestsetGenerator.from_default()
test_size = 50 # устанавливаем небольшой размер, тк генерация ест токены
testset = testsetgenerator.generate(habr_docs, test_size=test_size)
testset_pd = testset.to_pandas()
testset_pd.head()
Сразу становится заметно, что все вопросы и ответы созданы на английском языке. Это обусловлено тем, что промпты для генерации оптимизированы именно под английский. Как писал классик, получилась смесь французского с нижегородским, но пока оставим параметры генерации как есть.
Если говорить про логику создания синтетики, то она примерно следующая:
Из доступных узлов выбирается узел для генерации вопроса.
Оценивается, подходит ли контекст для формирования вопроса.
Генерируются начальные вопросы (seed question), которые затем могут быть переформулированы или изменены в соответствии с определенными правилами.
Вопросы фильтруются и, при необходимости, переформулируются.
Вопросы классифицируются в соответствии с их типом (например, условные вопросы, вопросы с множественным контекстом и т.д.).
Сформированные вопросы и их контексты упаковываются в формат, который подходит для тестирования.
Можно увидеть, как были переформулированы начальные вопросы:
В нашем датасете есть колонка evolution_elimination
, которая как раз сообщает, насколько модели удалось создать действительно новые, уникальные вопросы, или же они остаются схожими друг другу.
Пока этот датасет не готов для подачи в ragas, нужны небольшие корректировки.
import pandas as pd
from datasets import Dataset
from tqdm import tqdm
from ragas.metrics import (
context_recall,
context_precision,
answer_relevancy,
faithfulness,
)
from ragas import evaluate
# Ответ нашей LLM (gpt-4) возьмем за ground truth
validation_set = testset_pd[['question', 'answer']].rename(columns={'answer' : 'ground_truth'})
# создаем датасет для подачи в ragas
def create_data_for_ragas(qa_chain, eval_dataset):
rag_dataset = []
for row in tqdm(eval_dataset):
answer = qa_chain({'query' : row['question']})
rag_dataset.append(
{"question" : row['question'],
"answer" : answer['result'],
"contexts" : [context.page_content for context in answer['source_documents']],
"ground_truths" : [row['ground_truth']]
} ) rag_df = pd.DataFrame(rag_dataset)
rag_eval_dataset = Dataset.from_pandas(rag_df)
return rag_eval_dataset
qa_ragas_baseline = create_data_for_ragas(qa_chain, eval_dataset)
baseline_result = evaluate(
qa_ragas_baseline,
metrics=[
context_precision,
context_recall,
faithfulness,
answer_relevancy,
]
)
Что ж, теперь представим, что у нас возникла идея подавать в языковую модель более короткий контекст (не top 5 чанков, а 3.) Посмотрим, как это скажется на наших метриках.
qa_chain_short = RetrievalQA.from_chain_type(
llm,
retriever=vectorstore.as_retriever(search_kwargs={'k': 3}),
return_source_documents=True
)
qa_ragas_short = create_data_for_ragas(qa_chain_short, eval_dataset)
short_retrieval_result = evaluate(
qa_ragas_short,
metrics=[
context_precision,
context_recall,
faithfulness,
answer_relevancy,
]
)
Теперь мы видим, что немного подрос скор по context_precision
, что выглядит вполне логичным, но при этом у нас просели метрики faithfulness
, answer_relevancy
. Почему так произошло, обсудим уже в следующей части обзора.
Спасибо за внимание!
Пишу про AI и NLP в телеграм.