Новости и статьи об искусственном интеллекте и нейросетях. Мы собираем и обрабатываем самую актуальную информацию из мира AI. О проекте

Статьи

Оценка качества извлечения в RAG: Precision@k, Recall@k, F1@k

В статье разбираются ключевые метрики для оценки качества извлечения документов в конвейерах RAG: HitRate@k, Recall@k, Precision@k и F1@k. Объясняется их значение, формулы и применение на примере текста 'Войны и мира' с использованием FAISS и OpenAI. Это помогает понять, насколько хорошо векторный поиск находит релевантные фрагменты для последующей генерации ответов.

16 октября 2025 г.
12 мин
0

В предыдущих материалах мы рассмотрели создание простого конвейера RAG на Python, разбиение больших текстовых документов на фрагменты, преобразование документов в эмбеддинги для поиска похожих материалов в векторной базе данных, а также применение reranking для выбора наиболее подходящих документов к запросу пользователя.

После извлечения релевантных документов их передают в большую языковую модель для этапа генерации. Однако перед этим необходимо убедиться в эффективности механизма извлечения, который способен находить подходящие результаты. Ведь поиск фрагментов, содержащих ответ на запрос, является ключевым шагом для формирования осмысленного ответа.

В этой статье мы разберем популярные метрики для оценки производительности извлечения и reranking.

Зачем оценивать производительность извлечения

Цель оценки — понять, насколько хорошо модель эмбеддингов и векторная база данных возвращают подходящие фрагменты текста. По сути, мы проверяем, присутствуют ли правильные документы в топ-k извлеченных результатах или поиск выдает бесполезные данные. Для ответа на этот вопрос используются меры из области информационного поиска.

Полезно различать бинарные и градированные меры релевантности. Бинарные меры классифицируют извлеченный фрагмент как релевантный или нерелевантный для запроса — без промежуточных значений. Градированные меры присваивают степень релевантности от полной нерелевантности до полной релевантности.

В случае бинарных мер каждый фрагмент оценивается как релевантный или нет, успех или промах, положительный или отрицательный. Это приводит к следующим ситуациям:

  • True Positive: результат входит в топ-k и действительно релевантен запросу; он правильно извлечен.
  • False Positive: результат входит в топ-k, но на деле нерелевантен; он ошибочно извлечен.
  • True Negative: результат не входит в топ-k и действительно нерелевантен; он правильно не извлечен.
  • False Negative: результат не входит в топ-k, но на деле релевантен; он ошибочно не извлечен.

Желаемыми являются True Positive и True Negative. Ложные ситуации — False Negative и False Positive — нужно минимизировать, но это создает конфликт. Чтобы уменьшить False Negatives и захватить все релевантные результаты, поиск должен быть более всеобъемлющим, но это повышает риск False Positives.

Еще одно различие — между мерами, не учитывающими порядок, и учитывающими порядок. Меры без учета порядка проверяют наличие релевантного результата в топ-k. Меры с учетом порядка дополнительно оценивают позицию фрагмента в ранжированном списке.

Все метрики вычисляются для разных k и обозначаются как 'метрика@k', например, HitRate@k или Precision@k. В дальнейшем мы сосредоточимся на базовых бинарных мерах без учета порядка.

Бинарные меры без учета порядка

Такие метрики наиболее просты и интуитивны для понимания, что делает их хорошей отправной точкой для анализа. Среди распространенных бинарных мер без учета порядка выделяются HitRate@k, Recall@k, Precision@k и F1@k. Разберем их подробнее.

🎯 HitRate@k

HitRate@k — самая простая метрика оценки извлечения. Она бинарно указывает, есть ли хотя бы один релевантный результат в топ-k фрагментах: 1, если да, и 0, если нет. Это базовая проверка на попадание в цель. Для одного запроса и набора результатов формула выглядит так: если хотя бы один релевантный документ в топ-k, то 1, иначе 0.

Для тестового набора вычисляют HitRate@k для каждого запроса и усредняют по всему множеству.

HitRate — простая, прямая и легкая в расчете метрика, идеальная для начальной оценки этапа извлечения в конвейере RAG.

🎯 Recall@k

Recall@k показывает, насколько часто релевантные документы появляются в топ-k извлеченных. Она оценивает успех в избежании False Negatives. Формула: количество релевантных в топ-k, деленное на общее количество релевантных документов.

Значение варьируется от 0 (только нерелевантные) до 1 (все релевантные без False Negatives). Это ответ на вопрос: 'Из всех существующих релевантных элементов сколько мы нашли?'. Она фокусируется на количестве: сколько релевантных из топ-k действительно полезны.

Recall подходит для сценариев, где нужно найти максимум релевантных, даже с некоторыми нерелевантными. Высокий Recall@k означает, что векторный поиск захватил много релевантных документов из всех существующих. Низкий Recall@k — плохой старт для RAG, поскольку без нужных документов reranking или LLM не помогут.

🎯 Precision@k

Precision@k указывает долю релевантных документов в топ-k. Она оценивает успех в избежании False Positives. Формула: количество релевантных в топ-k, деленное на k.

Это ответ на вопрос: 'Из извлеченных элементов сколько правильных?'. Значение от 0 (только нерелевантные) до 1 (все релевантные без False Positives). Precision подчеркивает качество каждого результата, а не полноту поиска. Она полезна, когда важнее уверенность в релевантности, даже если некоторые релевантные пропущены.

🎯 F1@k

Если нужны и точные, и полные результаты, Recall@k и Precision@k комбинируют в F1@k для баланса. Формула: 2 * (Precision * Recall) / (Precision + Recall), если знаменатель больше 0, иначе 0.

F1@k от 0 до 1. Значение близкое к 1 — высокие Precision и Recall, результаты точны и полны. Низкое — хотя бы одна метрика слаба. F1@k — эффективная единая метрика для сбалансированной оценки, высока только при хороших Precision и Recall.

Насколько хорош наш векторный поиск?

Проверим метрики на примере 'Войны и мира', отвечая на вопрос 'Кто такая Анна Павловна?'. Используем текст 'Войны и мира' из Project Gutenberg, доступный в общественном достоянии. Код на данный момент включает инициализацию LLM, кросс-энкодера, эмбеддингов, загрузку и разбиение документов, нормализацию эмбеддингов, создание FAISS-индекса с внутренним произведением, векторное хранилище и функцию reranking.

import torch
from sentence_transformers import CrossEncoder
import os
from langchain.chat_models import ChatOpenAI
from langchain.document_loaders import TextLoader
from langchain.embeddings import OpenAIEmbeddings
from langchain.vectorstores import FAISS
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.docstore.document import Document
import faiss
api_key = "your_api_key"
#%%
# initialize LLM
llm = ChatOpenAI(openai_api_key=api_key, model="gpt-4o-mini", temperature=0.3)
# initialize cross-encoder model
cross_encoder = CrossEncoder('cross-encoder/ms-marco-TinyBERT-L-2', device='cuda' if torch.cuda.is_available() else 'cpu')
def rerank_with_cross_encoder(query, relevant_docs):
    pairs = [(query, doc.page_content) for doc in relevant_docs] # pairs of (query, document) for cross-encoder
    scores = cross_encoder.predict(pairs) # relevance scores from cross-encoder model
    ranked_indices = np.argsort(scores)[::-1] # sort documents based on cross-encoder score (the higher, the better)
    ranked_docs = [relevant_docs[i] for i in ranked_indices]
    ranked_scores = [scores[i] for i in ranked_indices]
    return ranked_docs, ranked_scores
# initialize embeddings model
embeddings = OpenAIEmbeddings(openai_api_key=api_key)
# loading documents to be used for RAG
text_folder = "RAG files"
documents = []
for filename in os.listdir(text_folder):
    if filename.lower().endswith(".txt"):
        file_path = os.path.join(text_folder, filename)
        loader = TextLoader(file_path)
        documents.extend(loader.load())
splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
split_docs = []
for doc in documents:
    chunks = splitter.split_text(doc.page_content)
    for chunk in chunks:
        split_docs.append(Document(page_content=chunk))
documents = split_docs
# normalize knowledge base embeddings
import numpy as np
def normalize(vectors):
    vectors = np.array(vectors)
    norms = np.linalg.norm(vectors, axis=1, keepdims=True)
    return vectors / norms
doc_texts = [doc.page_content for doc in documents]
doc_embeddings = embeddings.embed_documents(doc_texts)
doc_embeddings = normalize(doc_embeddings)
# faiss index with inner product
import faiss
dimension = doc_embeddings.shape[1]
index = faiss.IndexFlatIP(dimension) # inner product index
index.add(doc_embeddings)
# create vector database w FAISS
vector_store = FAISS(embedding_function=embeddings, index=index, docstore=None, index_to_docstore_id=None)
vector_store.docstore = {i: doc for i, doc in enumerate(documents)}
def main():
    print("Welcome to the RAG Assistant. Type 'exit' to quit.\n")
    while True:
        user_input = input("You: ").strip()
        if user_input.lower() == "exit":
            print("Exiting…")
            break
        # embedding + normalize query
        query_embedding = embeddings.embed_query(user_input)
        query_embedding = normalize([query_embedding])
        k_ = 10
        # search FAISS index
        D, I = index.search(query_embedding, k=k_)
        # get relevant documents
        relevant_docs = [vector_store.docstore[i] for i in I[0]]
        # rerank with our function
        reranked_docs, reranked_scores = rerank_with_cross_encoder(user_input, relevant_docs)
        # get top reranked chunks
        retrieved_context = "\n\n".join([doc.page_content for doc in reranked_docs[:5]])
        # get relevant documents
        relevant_docs = [vector_store.docstore[i] for i in I[0]]
        retrieved_context = "\n\n".join([doc.page_content for doc in relevant_docs])
        # D contains inner product scores == cosine similarities (since normalized)
        print("\nTop chunks and their cosine similarity scores:\n")
        for rank, (idx, score) in enumerate(zip(I[0], D[0]), start=1):
            print(f"Chunk {rank}:")
            print(f"Cosine similarity: {score:.4f}")
            print(f"Content:\n{vector_store.docstore[idx].page_content}\n{'-'*40}")
        # system prompt
        system_prompt = (
            "You are a helpful assistant. "
            "Use ONLY the following knowledge base context to answer the user. "
            "If the answer is not in the context, say you don't know.\n\n"
            f"Context:\n{retrieved_context}"
        )
        # messages for LLM
        messages = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_input}
        ]
        # generate response
        response = llm.invoke(messages)
        assistant_message = response.content.strip()
        print(f"\nAssistant: {assistant_message}\n")
if __name__ == "__main__":
    main()

Чтобы вычислить метрики извлечения, добавим в начало скрипта определения функций:

#%% retrieval evaluation metrics
# Function to normalize text
def normalize_text(text):
    return " ".join(text.lower().split())
# Hit Rate @ K
def hit_rate_at_k(retrieved_docs, ground_truth_texts, k):
    for doc in retrieved_docs[:k]:
        doc_norm = normalize_text(doc.page_content)
        if any(normalize_text(gt) in doc_norm or doc_norm in normalize_text(gt) for gt in ground_truth_texts):
            return True
    return False
# Precision @ k
def precision_at_k(retrieved_docs, ground_truth_texts, k):
    hits = 0
    for doc in retrieved_docs[:k]:
        doc_norm = normalize_text(doc.page_content)
        if any(normalize_text(gt) in doc_norm or doc_norm in normalize_text(gt) for gt in ground_truth_texts):
            hits += 1
    return hits / k
# Recall @ k
def recall_at_k(retrieved_docs, ground_truth_texts, k):
    matched = set()
    for i, gt in enumerate(ground_truth_texts):
        gt_norm = normalize_text(gt)
        for doc in retrieved_docs[:k]:
            doc_norm = normalize_text(doc.page_content)
            if gt_norm in doc_norm or doc_norm in gt_norm:
                matched.add(i)
                break
    return len(matched) / len(ground_truth_texts) if ground_truth_texts else 0
# F1 @ K
def f1_at_k(precision, recall):
    return 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0

Для расчета метрик требуется набор запросов с соответствующими релевантными фрагментами — это трудоемкий процесс. Здесь демонстрируем на одном запросе 'Кто такая Анна Павловна?' с релевантными фрагментами. Информацию храним в словаре ground truth, сопоставляющем запросы с ожидаемыми фрагментами.

Релевантные фрагменты для запроса:

  1. «Это было в июле 1805 года, и говорила известная Анна Павловна Шерер, фрейлина и любимица императрицы Марии Федоровны. С этими словами она приветствовала князя Василия Курагина, человека высокого ранга и значения, который первым прибыл на ее прием. У Анны Павловны уже несколько дней был кашель. Она, как она говорила, страдала от la grippe; grippe тогда было новым словом в Санкт-Петербурге, использовавшимся только элитой. Все ее приглашения без исключения, написанные по-французски и доставленные утром лакеем в алом ливрее, звучали так: «Если у вас нет ничего лучше, граф (или князь), и если перспектива провести вечер с бедной больной не слишком страшна, я буду очень очарована видеть вас сегодня между 7 и 10 — Аннет Шерер.»»
  2. «Прием Анны Павловны был похож на предыдущий, только новинка, которую она предлагала гостям на этот раз, была не Мортемар, а дипломат, только что прибывший из Берлина с самыми свежими деталями визита императора Александра в Потсдам и о том, как два августейших друга обязались в неразрывном союзе поддерживать дело справедливости против врага человеческого рода. Анна Павловна встретила Пьера с оттенком меланхолии, явно относящимся к недавней потере молодым человеком от смерти графа Безухова (все постоянно считали своим долгом уверить Пьера, что он сильно опечален смертью отца, которого он едва знал), и ее меланхолия была точно такой же августейшей меланхолией, которую она показывала при упоминании ее августейшего величества императрицы Марии Федоровны. Пьер почувствовал себя польщенным этим. Анна Павловна расставила разные группы в своей гостиной с привычным умением. Большая группа, в которой были»
  3. «гостиная с ее привычным умением. Большая группа, в которой были князь Василий и генералы, получила преимущество от дипломата. Другая группа была за чайным столом. Пьер хотел присоединиться к первой, но Анна Павловна — которая была в возбужденном состоянии командира на поле боя, которому приходят тысячи новых и блестящих идей, на которые едва хватает времени действовать, — увидев Пьера, коснулась его рукава пальцем, сказав:»

Таким образом, ground truth для запроса определяется как:

query = "Кто такая Анна Павловна?"
ground_truth_texts = [
    "Это было в июле, 1805, и говорила известная Анна Павловна Шерер, фрейлина и любимица императрицы Марии Федоровны. С этими словами она приветствовала князя Василия Курагина, человека высокого ранга и значения, который первым прибыл на ее прием. У Анны Павловны уже несколько дней был кашель. Она, как она говорила, страдала от la grippe; grippe тогда было новым словом в Санкт-Петербурге, использовавшимся только элитой. Все ее приглашения без исключения, написанные по-французски и доставленные утром лакеем в алом ливрее, звучали так: «Если у вас нет ничего лучше, граф (или князь), и если перспектива провести вечер с бедной больной не слишком страшна, я буду очень очарована видеть вас сегодня между 7 и 10 — Аннет Шерер.»",
    "Прием Анны Павловны был похож на предыдущий, только новинка, которую она предлагала гостям на этот раз, была не Мортемар, а дипломат, только что прибывший из Берлина с самыми свежими деталями визита императора Александра в Потсдам, и о том, как два августейших друга обязались в неразрывном союзе поддерживать дело справедливости против врага человеческого рода. Анна Павловна встретила Пьера с оттенком меланхолии, явно относящимся к недавней потере молодым человеком от смерти графа Безухова (все постоянно считали своим долгом уверить Пьера, что он сильно опечален смертью отца, которого он едва знал), и ее меланхолия была точно такой же августейшей меланхолией, которую она показывала при упоминании ее августейшего величества императрицы Марии Федоровны. Пьер почувствовал себя польщенным этим. Анна Павловна расставила разные группы в своей гостиной с привычным умением. Большая группа, в которой были",
    "гостиная с ее привычным умением. Большая группа, в которой были князь Василий и генералы, получила преимущество от дипломата. Другая группа была за чайным столом. Пьер хотел присоединиться к первой, но Анна Павловна — которая была в возбужденном состоянии командира на поле боя, которому приходят тысячи новых и блестящих идей, на которые едва хватает времени действовать, — увидев Пьера, коснулась его рукава пальцем, сказав:"
]

В функцию main() добавим раздел для расчета и вывода метрик:

... 
k_ = 10
# search FAISS index
D, I = index.search(query_embedding, k=k_)
# get relevant documents
relevant_docs = [vector_store.docstore[i] for i in I[0]]
# rerank with our function
reranked_docs, reranked_scores = rerank_with_cross_encoder(user_input, relevant_docs)
# -- NEW SECTION --
# Evaluate reranked docs using metrics
top_k_docs = reranked_docs[:k_] # or change `k` as needed
precision = precision_at_k(top_k_docs, ground_truth_texts, k=k_)
recall = recall_at_k(top_k_docs, ground_truth_texts, k=k_)
f1 = f1_at_k(precision, recall)
hit = hit_rate_at_k(top_k_docs, ground_truth_texts, k=k_)
print("\n--- Retrieval Evaluation Metrics ---")
print(f"Hit@6: {hit}")
print(f"Precision@6: {precision:.2f}")
print(f"Recall@6: {recall:.2f}")
print(f"F1@6: {f1:.2f}")
print("-" * 40)
# -- NEW SECTION --
# get top reranked chunks
retrieved_context = "\n\n".join([doc.page_content for doc in reranked_docs[:2]])
...

Оценка проводится после reranking. Поскольку метрики вроде Precision@k, Recall@k и F1@k не зависят от порядка, результаты одинаковы до и после reranking, если топ-k не меняется.

Для вопроса 'Кто такая Анна Павловна?' при k=10 получаем:

  • k=10: метрики на топ-10 фрагментах.
  • Hit@10 = True: хотя бы один правильный фрагмент в топ-10.
  • Precision@10 = 0.20: из 10 фрагментов 2 релевантны (2/10), 20% полезны, остальное нерелевантно.
  • Recall@10 = 0.67: 67% всех релевантных из ground truth в топ-10.
  • F1@10 = 0.31: общая оценка, умеренная из-за хорошего Recall и низкой Precision.

Метрики можно вычислять для любого k, превышающего число фрагментов в ground truth. Это позволяет тестировать систему при разном охвате результатов.

Размышления

Хотя метрики вроде Precision@k, Recall@k и F1@k вычисляются для одного запроса, в реальности они оцениваются на тестовом наборе запросов с ground truth для каждого. Метрики рассчитывают индивидуально и усредняют.

Понимание этих метрик критично для оценки и оптимизации конвейера RAG. Эффективное извлечение документов — основа для генерации качественных ответов.