Files
rag/scripts/evaluate_answer.py
lacerate551 100d1a06eb init: RAG 知识库服务初始提交
- 后端 API(Flask + Gunicorn)
- RAG 引擎(混合检索 + 云端 Reranker + 引用溯源)
- 文档解析(MinerU + 多格式支持)
- Docker 生产部署配置
- 排除前端项目、敏感配置、模型文件
2026-06-04 17:35:27 +08:00

554 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""
RAG 答案层评测脚本
评测指标:
- LLM Score: 使用LLM对答案质量打分 (0-1)
- ROUGE-L: 最长公共子序列相似度
- Semantic Similarity: 语义相似度使用embedding
用法:
python scripts/evaluate_answer.py --eval_dataset data/eval_dataset.json
python scripts/evaluate_answer.py --topk 5 --output results/answer_results.json
"""
import argparse
import json
import logging
import sys
import time
from pathlib import Path
from typing import Optional
import numpy as np
# 添加项目根目录到路径
PROJECT_ROOT = Path(__file__).parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
# 配置日志
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
logger = logging.getLogger(__name__)
class AnswerEvaluator:
"""答案层评测器"""
def __init__(self, engine=None):
"""
初始化评测器
Args:
engine: RAG引擎实例如果为None则自动创建
"""
self.engine = engine
self._llm_client = None
self._embedding_model = None
def _get_engine(self):
"""延迟加载引擎"""
if self.engine is None:
from core.engine import get_engine
self.engine = get_engine()
return self.engine
def _get_llm_client(self):
"""延迟加载LLM客户端"""
if self._llm_client is None:
try:
from openai import OpenAI
from config import API_KEY, BASE_URL, MODEL
self._llm_client = OpenAI(
api_key=API_KEY,
base_url=BASE_URL
)
self._llm_model = MODEL
except Exception as e:
logger.warning(f"LLM客户端初始化失败: {e}")
self._llm_client = None
return self._llm_client
def _get_embedding_model(self):
"""延迟加载embedding模型"""
if self._embedding_model is None:
try:
from sentence_transformers import SentenceTransformer
from config import EMBEDDING_MODEL_PATH
self._embedding_model = SentenceTransformer(EMBEDDING_MODEL_PATH)
except Exception as e:
logger.warning(f"Embedding模型初始化失败: {e}")
self._embedding_model = None
return self._embedding_model
def rouge_l(self, generated: str, reference: str) -> float:
"""
计算 ROUGE-L 分数
Args:
generated: 生成的答案
reference: 参考答案
Returns:
ROUGE-L F1 分数 (0-1)
"""
if not generated or not reference:
return 0.0
# 简单分词(按字符)
gen_tokens = list(generated)
ref_tokens = list(reference)
# 计算最长公共子序列长度
m, n = len(gen_tokens), len(ref_tokens)
if m == 0 or n == 0:
return 0.0
# DP计算LCS
dp = [[0] * (n + 1) for _ in range(m + 1)]
for i in range(1, m + 1):
for j in range(1, n + 1):
if gen_tokens[i - 1] == ref_tokens[j - 1]:
dp[i][j] = dp[i - 1][j - 1] + 1
else:
dp[i][j] = max(dp[i - 1][j], dp[i][j - 1])
lcs_len = dp[m][n]
# 计算Precision和Recall
precision = lcs_len / m if m > 0 else 0.0
recall = lcs_len / n if n > 0 else 0.0
# F1分数
if precision + recall == 0:
return 0.0
f1 = 2 * precision * recall / (precision + recall)
return f1
def semantic_similarity(self, generated: str, reference: str) -> float:
"""
计算语义相似度
Args:
generated: 生成的答案
reference: 参考答案
Returns:
语义相似度 (0-1)
"""
model = self._get_embedding_model()
if model is None:
return 0.0
try:
embeddings = model.encode([generated, reference])
# 余弦相似度
similarity = np.dot(embeddings[0], embeddings[1]) / (
np.linalg.norm(embeddings[0]) * np.linalg.norm(embeddings[1])
)
return float(max(0, min(1, similarity))) # 确保在0-1范围内
except Exception as e:
logger.warning(f"计算语义相似度失败: {e}")
return 0.0
def llm_score(self, query: str, generated: str, reference: str) -> float:
"""
使用LLM对答案质量打分
Args:
query: 用户问题
generated: 生成的答案
reference: 参考答案
Returns:
LLM评分 (0-1)
"""
client = self._get_llm_client()
if client is None:
logger.warning("LLM客户端不可用跳过LLM评分")
return 0.0
prompt = f"""请作为专业评测员对RAG系统的回答质量进行评分。
【用户问题】
{query}
【参考答案】
{reference}
【系统回答】
{generated}
【评分标准】
请从以下维度评分每项0-10分
1. 准确性:回答是否与参考答案的核心信息一致
2. 完整性:回答是否覆盖了参考答案的关键要点
3. 相关性:回答是否直接回答了用户问题
4. 流畅性:回答是否通顺、易于理解
请以JSON格式返回评分
{{"accuracy": X, "completeness": X, "relevance": X, "fluency": X, "overall": X}}
只返回JSON不要其他内容。"""
try:
response = client.chat.completions.create(
model=self._llm_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.1,
max_tokens=200
)
result_text = response.choices[0].message.content.strip()
# 尝试解析JSON
import re
json_match = re.search(r'\{[^}]+\}', result_text)
if json_match:
scores = json.loads(json_match.group())
overall = scores.get('overall', 0)
return min(1.0, max(0.0, overall / 10.0))
else:
# 尝试从文本中提取数字
numbers = re.findall(r'\d+', result_text)
if numbers:
return min(1.0, max(0.0, int(numbers[-1]) / 10.0))
return 0.5
except Exception as e:
logger.warning(f"LLM评分失败: {e}")
return 0.0
def generate_answer(self, query: str, top_k: int = 5, collections: list = None) -> str:
"""
使用RAG引擎生成答案
Args:
query: 用户问题
top_k: 检索数量
collections: 目标向量库列表
Returns:
生成的答案
"""
engine = self._get_engine()
try:
# 先检索
results = engine.search_knowledge(
query=query,
top_k=top_k,
collections=collections
)
# 提取文档内容
if isinstance(results, dict):
documents = results.get('documents', [])
if documents and isinstance(documents[0], list):
documents = documents[0]
else:
documents = []
if not documents:
return "抱歉,未找到相关信息。"
# 简单拼接作为答案实际应该用LLM生成
# 这里为了评测,我们用检索到的内容拼接
context = "\n\n".join(documents[:3])
# 使用LLM生成答案
client = self._get_llm_client()
if client:
prompt = f"""基于以下检索到的信息回答用户问题。请简洁准确地回答。
【用户问题】
{query}
【检索到的信息】
{context}
请直接回答问题,不要重复问题本身:"""
response = client.chat.completions.create(
model=self._llm_model,
messages=[{"role": "user", "content": prompt}],
temperature=0.3,
max_tokens=500
)
return response.choices[0].message.content.strip()
else:
# 没有LLM时直接返回最相关的文档片段
return documents[0] if documents else "无法生成答案"
except Exception as e:
logger.error(f"生成答案失败: {e}")
return f"生成答案时出错: {str(e)}"
def evaluate_query(
self,
query: str,
reference_answer: str,
top_k: int = 5,
collections: list = None,
use_llm: bool = True
) -> dict:
"""
评估单个查询的答案质量
Args:
query: 用户问题
reference_answer: 参考答案
top_k: 检索数量
collections: 目标向量库列表
use_llm: 是否使用LLM评分
Returns:
评测结果字典
"""
# 生成答案
generated_answer = self.generate_answer(query, top_k=top_k, collections=collections)
# 计算各项指标
metrics = {
'rouge_l': self.rouge_l(generated_answer, reference_answer),
'semantic_similarity': self.semantic_similarity(generated_answer, reference_answer),
'generated_answer': generated_answer[:500], # 截断保存
'reference_answer': reference_answer[:500]
}
# LLM评分可选因为较慢
if use_llm:
metrics['llm_score'] = self.llm_score(query, generated_answer, reference_answer)
return metrics
def evaluate_dataset(
self,
eval_dataset_path: str,
top_k: int = 5,
collections: list = None,
use_llm: bool = True,
sample_size: int = None
) -> dict:
"""
评估整个数据集
Args:
eval_dataset_path: 评测数据集路径
top_k: 检索数量
collections: 目标向量库列表
use_llm: 是否使用LLM评分
sample_size: 采样数量(用于快速测试)
Returns:
汇总评测结果
"""
# 加载数据集
with open(eval_dataset_path, 'r', encoding='utf-8') as f:
dataset = json.load(f)
queries = dataset.get('queries', [])
# 采样(如果指定)
if sample_size and sample_size < len(queries):
import random
queries = random.sample(queries, sample_size)
# 存储每个查询的结果
all_metrics = {
'rouge_l': [],
'semantic_similarity': [],
'llm_score': []
}
# 按查询类型分组
by_type = {}
# 按难度分组
by_difficulty = {}
logger.info(f"开始答案层评测,共 {len(queries)} 条查询...")
for i, q in enumerate(queries, 1):
query_text = q['query']
query_type = q.get('query_type', 'unknown')
difficulty = q.get('difficulty', 'medium')
reference_answer = q.get('reference_answer', '')
if not reference_answer:
logger.warning(f"查询 {q['id']} 没有参考答案,跳过")
continue
# 执行评测
metrics = self.evaluate_query(
query=query_text,
reference_answer=reference_answer,
top_k=top_k,
collections=collections,
use_llm=use_llm
)
# 收集结果
all_metrics['rouge_l'].append(metrics['rouge_l'])
all_metrics['semantic_similarity'].append(metrics['semantic_similarity'])
if 'llm_score' in metrics:
all_metrics['llm_score'].append(metrics['llm_score'])
# 按类型分组
if query_type not in by_type:
by_type[query_type] = {k: [] for k in all_metrics}
by_type[query_type]['rouge_l'].append(metrics['rouge_l'])
by_type[query_type]['semantic_similarity'].append(metrics['semantic_similarity'])
if 'llm_score' in metrics:
by_type[query_type]['llm_score'].append(metrics['llm_score'])
# 按难度分组
if difficulty not in by_difficulty:
by_difficulty[difficulty] = {k: [] for k in all_metrics}
by_difficulty[difficulty]['rouge_l'].append(metrics['rouge_l'])
by_difficulty[difficulty]['semantic_similarity'].append(metrics['semantic_similarity'])
if 'llm_score' in metrics:
by_difficulty[difficulty]['llm_score'].append(metrics['llm_score'])
# 打印进度
llm_str = f", LLM={metrics.get('llm_score', 0):.2f}" if 'llm_score' in metrics else ""
logger.info(f" [{i}/{len(queries)}] {q['id']}: ROUGE-L={metrics['rouge_l']:.2f}, SemSim={metrics['semantic_similarity']:.2f}{llm_str}")
# 计算平均值
results = {
'overall': {
key: np.mean(values) if values else 0.0
for key, values in all_metrics.items()
},
'by_type': {
qtype: {key: np.mean(vals) if vals else 0.0 for key, vals in metrics.items()}
for qtype, metrics in by_type.items()
},
'by_difficulty': {
diff: {key: np.mean(vals) if vals else 0.0 for key, vals in metrics.items()}
for diff, metrics in by_difficulty.items()
},
'total_queries': len(queries),
'evaluated_queries': len(all_metrics['rouge_l'])
}
return results
def print_results(self, results: dict):
"""打印评测结果"""
print("\n" + "=" * 60)
print(" RAG 答案层评测结果")
print("=" * 60)
# 整体结果
print("\n【整体指标】")
overall = results.get('overall', {})
print(f" ROUGE-L: {overall.get('rouge_l', 0):.4f}")
print(f" Semantic Sim: {overall.get('semantic_similarity', 0):.4f}")
if overall.get('llm_score'):
print(f" LLM Score: {overall.get('llm_score', 0):.4f}")
# 按查询类型
print("\n【按查询类型】")
by_type = results.get('by_type', {})
for qtype, metrics in sorted(by_type.items()):
print(f" {qtype}:")
print(f" ROUGE-L: {metrics.get('rouge_l', 0):.4f}")
print(f" SemSim: {metrics.get('semantic_similarity', 0):.4f}")
# 按难度
print("\n【按难度】")
by_difficulty = results.get('by_difficulty', {})
for diff, metrics in sorted(by_difficulty.items()):
print(f" {diff}:")
print(f" ROUGE-L: {metrics.get('rouge_l', 0):.4f}")
print(f" SemSim: {metrics.get('semantic_similarity', 0):.4f}")
# 统计信息
print("\n【统计信息】")
print(f" 总查询数: {results.get('total_queries', 0)}")
print(f" 已评测数: {results.get('evaluated_queries', 0)}")
print("\n" + "=" * 60)
def main():
parser = argparse.ArgumentParser(description='RAG 答案层评测脚本')
parser.add_argument(
'--eval_dataset',
type=str,
default='data/eval_dataset.json',
help='评测数据集路径'
)
parser.add_argument(
'--topk',
type=int,
default=5,
help='检索返回数量 (默认: 5)'
)
parser.add_argument(
'--collections',
type=str,
nargs='+',
default=None,
help='目标向量库列表'
)
parser.add_argument(
'--no_llm',
action='store_true',
help='跳过LLM评分更快但指标较少'
)
parser.add_argument(
'--sample',
type=int,
default=None,
help='采样数量(用于快速测试)'
)
parser.add_argument(
'--output',
type=str,
default=None,
help='结果输出文件路径 (JSON格式)'
)
args = parser.parse_args()
# 检查数据集文件
eval_path = PROJECT_ROOT / args.eval_dataset
if not eval_path.exists():
logger.error(f"评测数据集不存在: {eval_path}")
sys.exit(1)
# 创建评测器
logger.info("初始化答案层评测器...")
evaluator = AnswerEvaluator()
# 执行评测
start_time = time.time()
results = evaluator.evaluate_dataset(
eval_dataset_path=str(eval_path),
top_k=args.topk,
collections=args.collections,
use_llm=not args.no_llm,
sample_size=args.sample
)
elapsed_time = time.time() - start_time
# 打印结果
evaluator.print_results(results)
print(f"\n评测耗时: {elapsed_time:.2f}")
# 保存结果
if args.output:
output_path = PROJECT_ROOT / args.output
with open(output_path, 'w', encoding='utf-8') as f:
json.dump(results, f, ensure_ascii=False, indent=2)
logger.info(f"结果已保存到: {output_path}")
if __name__ == '__main__':
main()