RAG管道检索质量评估指标详解

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

在之前的文章中,已经介绍了如何使用Python搭建一个基础的RAG管道,以及如何对大文本进行分块。我们还探讨了文档如何转换为嵌入向量,以便在向量数据库中快速搜索相似文档,以及如何使用重排序技术来识别最符合用户查询的文档。

现在,既然我们已经检索到了相关文档,就该将它们输入到大语言模型中进行生成步骤了。但在此之前,能够判断检索机制是否工作良好、能否成功识别出相关结果至关重要。毕竟,检索到包含用户查询答案的文本块是生成有意义答案的第一步。

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

为什么需要衡量检索性能

我们的目标是评估嵌入模型和向量数据库返回候选文本块的效果。本质上,我们试图弄清楚的问题是:“正确的文档是否出现在前k个检索结果集中?”或者说,我们的向量搜索是否返回了一堆无用的结果?我们可以利用几种不同的度量来回答这个问题,其中大多数源自信息检索领域。

在开始之前,区分两种类型的度量标准很有帮助:二元相关性和分级相关性度量。具体来说,二元度量将检索到的文本块定性为对回答用户查询是相关或无关的,没有中间状态。相反,分级度量则为每个检索到的文本块分配一个相关性值,范围从完全不相关到完全相关。

通常,二元度量将每个块定性为相关或不相关,命中或未命中,积极或消极。因此,在考虑二元检索度量时,我们可能面临以下情况之一:

  • 真阳性:结果出现在前k个中,并且确实与用户查询相关;它被正确地检索到了。
  • 假阳性:结果出现在前k个中,但实际上是无关的;它被错误地检索了。
  • 真阴性:结果未出现在前k个中,并且确实与用户查询无关;它被正确地未检索到。
  • 假阴性:结果未出现在前k个中,但它实际上是相关的;它被错误地未检索到。

可以想象,的情况——真阳性和真阴性——是我们所追求的。另一方面,的情况——假阴性和假阳性——是我们试图最小化的,但这本身就是一个相互冲突的目标。具体来说,为了包含所有存在的相关结果(即最小化假阴性),我们需要使搜索更具包容性,但通过使搜索更具包容性,我们也增加了增加假阳性的风险。

另一个可以做的区分是顺序无关顺序相关的相关性度量。顾名思义,顺序无关度量仅表示在前k个检索到的文本块中是否存在相关结果。相反,顺序相关度量除了考虑文本块是否出现在前k个中之外,还考虑了它出现的排名顺序。

所有检索评估度量都可以针对不同的k值进行计算,因此我们将其表示为“某度量@k”,例如HitRate@k或Precision@k。在本文的其余部分,我将探讨一些基本的二元、顺序无关的检索评估指标。

一些顺序无关的二元度量

用于评估检索的二元顺序无关度量是最直接和直观的。因此,它们是让我们理解究竟要衡量和评估什么的一个很好的起点。一些常见且有用的二元顺序无关度量包括HitRate@k、Recall@k、Precision@k和F1@k。让我们更详细地看看这些。

HitRate@K

HitRate@K是评估检索评估的最简单指标。它是一个二元度量,表示在前k个检索到的块中是否至少存在一个相关结果。因此,它只能取两个值:1(如果检索集中至少存在一个相关文档)或0(如果检索到的文档中没有一个在实际上是相关的)。这确实是人们能想象到的最基本的成功度量——至少用某个东西击中目标。对于单个查询和其相应的检索结果集,HitRate@k可以如下计算:

HitRate@k = 1,如果至少有一个相关文档在top-k中;否则为0。

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

可以说,命中率是最简单、最直接、最容易计算的检索指标;因此,它为评估RAG管道的检索步骤提供了一个良好的起点。

Recall@K

Recall@K表示相关文档在前k个检索文档中出现的频率。本质上,它评估了我们在避免假阴性方面做得如何。Recall@k的计算公式如下:

Recall@k = (在top-k中检索到的相关文档数) / (总相关文档数)

因此,它的范围可以从0到1,0表示我们只检索到了不相关的结果,1表示我们只检索到了相关的结果(没有假阴性)。这就像在问“在所有存在的项目中,我们得到了多少?”。它表示前k个结果中有多少是真正相关的。

召回率关注检索结果的数量——在所有相关结果中,我们设法找到了多少?因此,它在需要尽可能多地找到相关结果的场景中,即使检索结果中夹杂了一些不相关的内容,也能很好地作为检索度量。

因此,我们实现的Recall@k越高,就意味着在向量搜索中检索到的相关文档占所有真正存在的相关文档的比例越大。相反,使用糟糕的Recall@k分数检索文档对于RAG管道的检索步骤来说是一个相当糟糕的开始——如果一开始就没有适当的相关文档和相关信息,任何神奇的重排序或大语言模型都无法扭转局面。

Precision@k

Precision@k表示在前k个检索到的文档中,有多少确实是相关的。本质上,它评估了我们在不包含假阳性方面做得如何。Precision@k可以如下计算:

Precision@k = (在top-k中检索到的相关文档数) / k

换句话说,精确率是回答“在我们检索到的项目中,有多少是正确的?”这个问题的答案。它表示在所有真正相关的结果中,有多少成功地在前k个中被检索到。因此,它的范围可以从0到1,0表示我们只检索到了不相关的结果,1表示我们只检索到了相关的结果(没有检索到不相关的结果——没有假阳性)。

因此,Precision@k在很大程度上强调每个检索到的结果都是有效的,而不是详尽地寻找每个结果。换句话说,Precision@k可以很好地作为那些重视检索结果质量而非数量的场景的检索度量。也就是说,检索那些我们确定相关的结果,即使这意味着我们错误地拒绝了一些相关结果。

F1@K

但是,如果我们需要既正确又完整的结果——如果我们需要检索集在召回率和精确率上都获得高分呢?为了实现这一点,Recall@K和Precision@K可以合并成一个称为F1@K的单一指标,使我们能够创建一个同时平衡检索结果有效性和完整性的分数。具体来说,F1@k可以如下计算:

F1@k = 2 * (Precision@k * Recall@k) / (Precision@k + Recall@k)

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

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

现在让我们看看所有这些如何在《战争与和平》的例子中体现,再次回答我最喜欢的问题——“安娜·帕夫洛夫娜是谁?”。和之前的文章一样,我将再次使用《战争与和平》文本作为示例,该文本属于公共领域,可以通过某机构轻松获取。我们目前的代码如下:

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"

#%%
# 初始化大语言模型
llm = ChatOpenAI(openai_api_key=api_key, model="gpt-4o-mini", temperature=0.3)

# 初始化交叉编码器模型
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] # 用于交叉编码器的(查询,文档)对
    scores = cross_encoder.predict(pairs) # 来自交叉编码器模型的相关性分数
    
    ranked_indices = np.argsort(scores)[::-1] # 根据交叉编码器分数对文档排序(越高越好)
    ranked_docs = [relevant_docs[i] for i in ranked_indices]
    ranked_scores = [scores[i] for i in ranked_indices]
    
    return ranked_docs, ranked_scores

# 初始化嵌入模型
embeddings = OpenAIEmbeddings(openai_api_key=api_key)

# 加载用于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

# 归一化知识库嵌入向量
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索引
import faiss
dimension = doc_embeddings.shape[1]
index = faiss.IndexFlatIP(dimension)  # 内积索引
index.add(doc_embeddings)

# 使用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

        # 嵌入 + 归一化查询
        query_embedding = embeddings.embed_query(user_input)
        query_embedding = normalize([query_embedding]) 

        k_ = 10
        # 搜索FAISS索引
        D, I = index.search(query_embedding, k=k_)
        
        # 获取相关文档
        relevant_docs = [vector_store.docstore[i] for i in I[0]]
        
        # 使用我们的函数进行重排序
        reranked_docs, reranked_scores = rerank_with_cross_encoder(user_input, relevant_docs)
          
        # 获取顶部重排序后的块
        retrieved_context = "\n\n".join([doc.page_content for doc in reranked_docs[:5]])

        # 获取相关文档
        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包含内积分数 == 余弦相似度(因为已经归一化)
        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 = (
            "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 = [
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_input}
        ]

        # 生成响应
        response = llm.invoke(messages)
        assistant_message = response.content.strip()
        print(f"\nAssistant: {assistant_message}\n")

if __name__ == "__main__":
    main()

让我们稍微调整一下代码来计算一些检索指标。

首先,我们可以在脚本开头添加以下部分,以定义要计算的检索评估指标:

#%% 检索评估指标

# 文本归一化函数
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. “...客厅里的不同群体,用她惯常的技巧。那个大群体,其中有瓦西里亲王和将军们,得益于那位外交官。另一群人在茶桌旁。皮埃尔希望加入前者,但安娜·帕夫洛夫娜——她处于战场指挥官那种兴奋状态,有成千上万新的、绝妙的想法涌现,几乎没有时间付诸行动——看到皮埃尔,用手指碰了碰他的袖子,说道:”

这样,我们可以为这一个查询和包含可以回答问题信息的相应块定义真实答案如下:

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
        # 搜索FAISS索引
        D, I = index.search(query_embedding, k=k_)
        
        # 获取相关文档
        relevant_docs = [vector_store.docstore[i] for i in I[0]]
        
        # 使用我们的函数进行重排序
        reranked_docs, reranked_scores = rerank_with_cross_encoder(user_input, relevant_docs)
        
        # -- 新增部分 --
        
        # 使用指标评估重排序后的文档
        top_k_docs = reranked_docs[:k_]  # 或根据需要改变 `k`
        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)
        
        # -- 新增部分 --
        
        # 获取顶部重排序后的块
        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设置生成有意义答案的基础。
更多精彩内容 请关注我的个人公众号 公众号(办公AI智能小助手)或者 我的个人博客 https://blog.qife122.com/
对网络安全、黑客技术感兴趣的朋友可以关注我的安全公众号(网络安全技术点滴分享)

公众号二维码

公众号二维码

posted @ 2025-12-02 16:20  CodeShare  阅读(0)  评论(0)    收藏  举报