如何评估-RAG-管道中的检索质量-Precision-k-Recall-k-和-F1-k

如何评估 RAG 管道中的检索质量:Precision@k、Recall@k 和 F1@k

原文:towardsdatascience.com/how-to-evaluate-retrieval-quality-in-rag-pipelines-precisionk-recallk-and-f1k/

在我的之前的文章中,我已经向您介绍了如何在 Python 中构建一个非常基本的 RAG 管道(putting together a very basic RAG pipeline in Python),以及如何对大型文本文档进行分块。我们还探讨了如何将文档转换为嵌入,使我们能够快速在向量数据库中搜索相似文档,以及如何使用重排序来识别回答用户查询的最合适的文档。

现在,我们已经检索到了相关文档,是时候将它们传递给 LLM 进行生成步骤了。但在那之前,能够判断检索机制是否工作良好并且能够成功识别相关结果是很重要的。毕竟,检索到包含用户查询答案的块是生成有意义的答案的第一步。

因此,这正是我们今天要探讨的内容。特别是,我们将探讨一些用于评估检索和重排序性能的最流行指标。

🍨DataCream 是一个提供关于 AI、数据、技术的故事和教程的通讯。如果你对这些主题感兴趣,在此订阅.

. . .

为什么关注度量检索性能

因此,我们的目标是评估我们的嵌入模型和向量数据库检索候选文本块的效果如何。本质上,我们在这里试图弄清楚的是“在检索到的前 k 个文档中是否有正确的文档?”,或者我们的向量搜索是否返回了完全无用的信息?🙃我们可以利用几种不同的度量标准来回答这个问题。其中大部分源自信息检索领域。

在我们开始之前,区分两种类型的度量标准是有用的——这些是二元分级的相关性度量标准。更具体地说,二元度量标准将检索到的文本块描述为对回答用户查询相关或不相关——没有中间状态。相反,分级度量标准为检索到的每个文本块分配一个相关性值,这个值在从完全不相关到完全相关的一系列范围内。

通常,二进制度量将每个片段描述为相关或不相关,命中或未命中,正面或负面。因此,在考虑二进制检索度量时,我们可能会遇到以下情况之一:

  • 真实正例 ➔ 一个结果被检索到前 k 个位置,并且确实与用户的查询相关;它被正确检索。

  • 假正例 ➔ 一个结果被检索到前 k 个位置,但实际上并不相关;它被错误地检索。

  • 真实负例 ➔ 一个结果没有被检索到前 k 个位置,并且确实与用户的查询不相关;它被正确地未检索。

  • 假负例 ➔ 一个结果没有被检索到前 k 个位置,但实际上它是相关的;它被错误地未检索。

图片由作者提供

如您所想,真实情况——真实正例和真实负例——是我们所寻求的。另一方面,虚假情况——假负例和假正例——是我们试图最小化的,但这是一个相当矛盾的目标。更具体地说,为了包含所有存在的相关结果(即,最小化假负例),我们需要使我们的搜索更加全面,但通过使搜索更加全面,我们也冒着增加假正例的风险。

我们还可以区分无序相关度量与有序相关度量。正如它们的名称所暗示的,无序度量只表达是否有一个相关结果存在于前 k 个检索到的文本片段中,或者不。另一方面,有序度量还考虑了文本片段出现的排名,而不仅仅是它是否出现在前 k 个片段中。

所有检索评估指标都可以针对不同的 k 值进行计算,因此我们用“某个度量’@k”来表示它们,如 HitRate@k 或 Precision@k(当然!)。无论如何,在本文的其余部分,我将探讨一些基本的二进制无序检索评估指标。

. . .

一些无序二进制度量

用于评估检索的二进制无序度量是最直接且最直观易懂的。因此,它们是我们理解我们试图测量和评估的内容的绝佳起点。一些常见且有用的二进制无序度量包括 HitRate@kRecall@kPrecision@k 和 F1@k。但让我们更详细地看看所有这些。

🎯 HitRate@K

HitRate@K 是所有检索评估指标中最简单的一个。它是一个二元指标,表示是否在前 k 个检索到的片段中至少有一个相关结果。因此,它只能取两个值:要么是 1(如果检索集中至少有一个相关文档),要么是 0(如果检索到的文档实际上都不相关)。这真的是可以想象的最基本的成功指标——至少用某物击中目标。对于一个查询及其相应的检索结果集,HitRate@k 可以按以下方式计算:

图片由作者提供

这样,我们可以计算测试集中所有查询和检索结果的不同的命中率,并最终计算整个测试集的平均 HitRate@K。

图片由作者提供

争议性地讲,命中率可能是最简单、最直接且最容易计算的检索指标;因此,它为我们 RAG 管道的检索步骤提供了一个良好的起点。

🎯 回收率@K

回收率@K 表示相关文档在检索到的前 k 个文档中出现的频率。本质上,它评估了我们避免错误负例的表现。回收率@k 的计算方式如下:

图片由作者提供

因此,它可以从 0 到 1 变化,0 表示我们只检索到了不相关的结果,1 表示我们只检索到了相关的结果(没有错误负例)。这就像问“在所有存在的项目中,我们得到了多少?”。它表示在 top k 结果中有多少是真正相关的。

回收率关注检索到的结果的数量——在所有相关结果中,我们找到了多少?因此,它作为在需要尽可能找到尽可能多的相关结果,即使这意味着检索到一些不相关结果的场景下的检索度量非常有效。

因此,我们实现的回收率@k 越高,通过向量搜索检索到的相关文档就越多,相对于真正存在的所有相关文档。相反,如果检索到的文档的回收率@k 得分很低,那么对于我们 RAG 管道检索步骤来说是一个相当糟糕的开始——如果一开始就没有适当的相关文档和相关信息,那么任何神奇的重新排序或 LLM 模型都无法解决这个问题。

🎯 精确率@k

精确率@k 表示在检索到的前 k 个文档中有多少确实是相关的。本质上,它评估了我们不包括错误正例的表现。精确率@k 可以按以下方式计算:

图片由作者提供

换句话说,精确率是回答“在我们检索到的项目中有多少是正确的?”的问题的答案。它表示在所有真正相关的结果中,有多少在 top k 中被成功检索。因此,它可以从 0 到 1 变化,0 表示我们只检索到了不相关的结果,1 表示我们只检索到了相关的结果(没有检索到不相关的结果——没有错误正例)。

因此,精确率@k 主要强调每个检索到的结果的有效性,而不是全面地找到每一个结果。换句话说,精确率@k 可以很好地作为在重视检索结果质量而非数量的场景下的检索度量。也就是说,检索出我们确信相关的结果,即使这意味着我们错误地拒绝了某些相关结果。

🎯 F1@K

但如果我们需要正确且完整的结果——如果我们需要检索集在召回率和精确度上都得分高呢?为了实现这一点,可以将 Recall@K 和 Precision@K 合并成一个单一指标,称为 F1@K,这样我们就可以创建一个同时平衡检索结果的有效性和完整性的分数。特别是,F1@k 可以按以下方式计算:

图片

图片由作者提供

再次强调,F1@k 的值可以从 0 到 1。F1@k 值接近 1 意味着 Precision@k 和 Recall@k 都很高,意味着检索到的结果既准确又全面。另一方面,F1@k 值接近零意味着 Recall@k 或 Precision@k 很低,甚至两者都低。这样,F1@k 作为一个有效的单一指标,可以用来评估平衡检索,因为它只有在精确度和召回率都很高时才会很高。

. . .

那么,我们的向量搜索效果如何呢?

现在,让我们通过再次回答我最喜欢的问题——“谁是安娜·帕夫洛夫娜?”来查看所有这些在“战争与和平”示例中的表现。正如我之前的帖子一样,我将继续使用战争与和平文本作为示例,该文本作为公共领域作品发布,并通过Project Gutenberg轻松访问。到目前为止,我们的代码如下所示:

 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 

为了计算任何这些评估指标,我们首先需要定义一组查询和相应的真正相关片段。这是一个相当繁重的练习;因此,我将只演示一个查询的过程——“谁是安娜·帕夫洛夫娜?”以及应该理想地检索的相关文本片段。无论如何,这些信息——无论是针对单个查询还是针对现实生活中的评估集——都可以以真实字典的形式定义,使我们能够将各种测试查询映射到预期的相关文本片段。

尤其是对于我们的查询“谁是安娜·帕夫洛夫娜?”而言,应该包含在真实字典中的相关片段如下:

  1. “那是 1805 年 7 月,说话者是著名的安娜·帕夫洛夫娜·谢耶尔,是女王的荣誉侍女和玛利亚·费多罗夫娜女皇的宠儿。用这些话,她向第一个到达她接待会的瓦西里·库拉金公爵,一个地位高、重要的人物致意。安娜·帕夫洛夫娜已经咳嗽了好几天。她说,她正在忍受流感;流感在当时是圣彼得堡的一个新词,只有精英阶层使用。她所有的邀请函,无一例外,都是用法语写的,由那天早上穿着猩红色制服的仆人递送,内容如下:“如果您没有什么更好的事情可做,伯爵(或公爵),如果您觉得和一个可怜的病人共度一个晚上并不太可怕,我将非常高兴在今晚 7 点到 10 点之间见到您——安妮特·谢耶尔。” “

  2. “安娜·帕夫洛夫娜的‘在家’就像以前一样,但她这次提供给客人的新东西不是莫尔特马尔,而是一位刚从柏林来的外交官,他带来了关于皇帝亚历山大访问波茨坦的最新细节,以及两位尊贵的朋友如何承诺结成不可解的联盟,维护正义的事业,反对人类公敌。安娜·帕夫洛夫娜带着一丝忧郁接待皮埃尔,显然这与年轻人最近因贝祖霍夫伯爵去世而遭受的损失有关(每个人都认为向皮埃尔保证他深受父亲去世之痛是一种责任),她的忧郁就像她在提到她最尊贵的玛利亚·费多罗夫娜女皇时表现出的那种庄严的忧郁。皮埃尔为此感到受宠若惊。安娜·帕夫洛夫娜用她习惯的技巧在她的客厅里安排了不同的群体。其中有一大群人,包括”

  3. “用她习惯的技巧画室。其中有一大群人,包括瓦西里公爵和将军们,他们得益于外交官。另一群人在茶桌旁。皮埃尔想加入前者,但安娜·帕夫洛夫娜——她正处于战场指挥官的兴奋状态,成千上万的新鲜而精彩的想法不断涌现,几乎没有时间付诸行动——看到皮埃尔,用手指碰了碰他的袖子,说:”

这样,我们可以定义这个查询的 ground truth 以及包含可以回答问题的信息的相应块,如下所示:

query = "Who is Anna Pávlovna?"

ground_truth_texts = [
    "It was in July, 1805, and the speaker was the well-known Anna Pávlovna Schérer, maid of honor and favorite of the Empress Márya Fëdorovna. With these words she greeted Prince Vasíli Kurágin, a man of high rank and importance, who was the first to arrive at her reception. Anna Pávlovna had had a cough for some days. She was, as she said, suffering from la grippe; grippe being then a new word in St. Petersburg, used only by the elite. All her invitations without exception, written in French, and delivered by a scarlet-liveried footman that morning, ran as follows: “If you have nothing better to do, Count (or Prince), and if the prospect of spending an evening with a poor invalid is not too terrible, I shall be very charmed to see you tonight between 7 and 10—Annette Schérer.”",

    "Anna Pávlovna’s “At Home” was like the former one, only the novelty she offered her guests this time was not Mortemart, but a diplomatist fresh from Berlin with the very latest details of the Emperor Alexander’s visit to Potsdam, and of how the two august friends had pledged themselves in an indissoluble alliance to uphold the cause of justice against the enemy of the human race. Anna Pávlovna received Pierre with a shade of melancholy, evidently relating to the young man’s recent loss by the death of Count Bezúkhov (everyone constantly considered it a duty to assure Pierre that he was greatly afflicted by the death of the father he had hardly known), and her melancholy was just like the august melancholy she showed at the mention of her most august Majesty the Empress Márya Fëdorovna. Pierre felt flattered by this. Anna Pávlovna arranged the different groups in her drawing room with her habitual skill. The large group, in which were",

    "drawing room with her habitual skill. The large group, in which were Prince Vasíli and the generals, had the benefit of the diplomat. Another group was at the tea table. Pierre wished to join the former, but Anna Pávlovna—who was in the excited condition of a commander on a battlefield to whom thousands of new and brilliant ideas occur which there is hardly time to put in action—seeing Pierre, touched his sleeve with her finger, saying:"
] 

最后,我们还可以将以下部分添加到我们的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]])

        ...

注意到我们在重排序之后运行评估。因为我们计算的指标——如 Precision@K、Recall@K 和 F1@K——是无序的,所以在重排序前后对前 k 个检索到的块进行评估,只要前 k 个项的集合保持不变,结果都是相同的。

因此,对于我们的问题“谁是安娜·帕夫洛夫娜?”和@k = 10,我们得到以下分数:

图片由作者提供

但这个意义是什么?

  • @k = 10,意味着我们在检索到的前 10 个块上计算所有评估指标。

  • Hit@10 = True,意味着在检索到的前 10 个块中至少找到了一个正确的(真实)块。

  • Precision@10 = 0.20,意味着在检索到的 10 个块中,只有 2 个是正确的(0.20 = 2/10)。换句话说,检索器也带回了不相关信息;它检索到的只有 20%是有用的。

  • Recall@10 = 0.67,意味着我们在前 10 个文档中检索到了所有相关块中 67%的块。

  • F1@10 = 0.31,这表明结合了精确度和召回率的整体检索质量。F1 得分为 0.31 表示中等性能,我们知道这是由于召回率尚可但精确度较低造成的。

如前所述,我们可以为任何 k 值计算这些指标——只计算大于真实查询中每个查询块数的 k 值是有意义的。这样,我们可以尝试不同的 k 值,并了解随着检索结果范围的扩大或缩小,我们的检索系统是如何表现的。

在我心中

虽然,Precision@K、Recall@K 和 F1@K 等指标可以针对单个查询及其对应的检索块集(如我们这里所做的那样)进行计算,但在现实世界的评估中,它们通常是在查询集合上评估的,这个集合被称为测试集。更精确地说,测试集中的每个查询都与它自己的真实相关块集相关联。然后我们为每个查询单独计算检索指标,然后对所有查询的结果进行平均。

最终,理解可以计算的各种检索指标的意义对于有效地评估和微调一个 RAG 管道来说非常重要。最重要的是,一个有效的检索机制——找到适当的文档——是使用 RAG 设置生成有意义的答案的基础。

. . .

喜欢这篇帖子?让我们成为朋友!加入我:

📰Substack 💌* Medium* 💼LinkedIn请我喝杯咖啡!

. . .

那么,pialgorithms 算法呢?

想要将 RAG 的力量带入您的组织?

pialgorithms 可以为您做到这一切 👉 预约演示 今天

posted @ 2026-03-28 09:29  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报