598 lines
25 KiB
Python
598 lines
25 KiB
Python
|
|
import asyncio
|
|||
|
|
import glob
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import uuid
|
|||
|
|
from datetime import datetime
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from server.services.tasker import TaskContext, tasker
|
|||
|
|
from src.knowledge import knowledge_base
|
|||
|
|
from src.models import select_model
|
|||
|
|
from src.utils import logger
|
|||
|
|
from src.utils.evaluation_metrics import EvaluationMetricsCalculator
|
|||
|
|
|
|||
|
|
|
|||
|
|
class EvaluationService:
|
|||
|
|
"""RAG评估服务 - 基于文件存储的版本"""
|
|||
|
|
|
|||
|
|
def __init__(self):
|
|||
|
|
# 使用环境变量 DATA_DIR 或默认 'saves'
|
|||
|
|
self.data_dir = os.environ.get("DATA_DIR", "saves")
|
|||
|
|
self.root_eval_dir = os.path.join(self.data_dir, "evaluation")
|
|||
|
|
|
|||
|
|
def _get_benchmark_dir(self, db_id: str) -> str:
|
|||
|
|
path = os.path.join(self.root_eval_dir, db_id, "benchmarks")
|
|||
|
|
os.makedirs(path, exist_ok=True)
|
|||
|
|
return path
|
|||
|
|
|
|||
|
|
def _get_result_dir(self, db_id: str) -> str:
|
|||
|
|
path = os.path.join(self.root_eval_dir, db_id, "results")
|
|||
|
|
os.makedirs(path, exist_ok=True)
|
|||
|
|
return path
|
|||
|
|
|
|||
|
|
def _find_benchmark_location(self, benchmark_id: str) -> tuple:
|
|||
|
|
"""
|
|||
|
|
高效查找基准文件位置,返回 (db_id, meta_file_path)
|
|||
|
|
避免全局搜索,先检查是否有索引映射
|
|||
|
|
"""
|
|||
|
|
# 由于当前文件结构限制,仍然需要搜索
|
|||
|
|
# 但可以优化搜索顺序和错误处理
|
|||
|
|
try:
|
|||
|
|
# 搜索所有 DB 目录找到该 benchmark
|
|||
|
|
pattern = os.path.join(self.root_eval_dir, "*", "benchmarks", f"{benchmark_id}.meta.json")
|
|||
|
|
matches = glob.glob(pattern)
|
|||
|
|
|
|||
|
|
if not matches:
|
|||
|
|
raise ValueError(f"评估基准 {benchmark_id} 不存在")
|
|||
|
|
|
|||
|
|
meta_file_path = matches[0]
|
|||
|
|
# 从路径推断 db_id (parent of parent)
|
|||
|
|
# path: .../{db_id}/benchmarks/{bid}.meta.json
|
|||
|
|
db_id = os.path.basename(os.path.dirname(os.path.dirname(meta_file_path)))
|
|||
|
|
|
|||
|
|
return db_id, meta_file_path
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"查找基准文件失败: {e}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
def _find_result_location(self, task_id: str) -> tuple:
|
|||
|
|
"""
|
|||
|
|
高效查找评估结果文件位置,返回 (db_id, result_file_path)
|
|||
|
|
"""
|
|||
|
|
try:
|
|||
|
|
pattern = os.path.join(self.root_eval_dir, "*", "results", f"{task_id}.json")
|
|||
|
|
matches = glob.glob(pattern)
|
|||
|
|
|
|||
|
|
if not matches:
|
|||
|
|
raise ValueError(f"评估结果 {task_id} 不存在")
|
|||
|
|
|
|||
|
|
result_file_path = matches[0]
|
|||
|
|
# 从路径推断 db_id
|
|||
|
|
db_id = os.path.basename(os.path.dirname(os.path.dirname(result_file_path)))
|
|||
|
|
|
|||
|
|
return db_id, result_file_path
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"查找评估结果文件失败: {e}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def upload_benchmark(
|
|||
|
|
self, db_id: str, file_content: bytes, filename: str, name: str, description: str, created_by: str
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""上传评估基准文件"""
|
|||
|
|
try:
|
|||
|
|
content_str = file_content.decode("utf-8")
|
|||
|
|
questions = []
|
|||
|
|
has_gold_chunks = False
|
|||
|
|
has_gold_answers = False
|
|||
|
|
|
|||
|
|
# 解析 JSONL
|
|||
|
|
for line_num, line in enumerate(content_str.strip().split("\n"), 1):
|
|||
|
|
if not line.strip():
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
item = json.loads(line)
|
|||
|
|
if "query" not in item:
|
|||
|
|
raise ValueError(f"第{line_num}行缺少必需的'query'字段")
|
|||
|
|
if item.get("gold_chunk_ids"):
|
|||
|
|
has_gold_chunks = True
|
|||
|
|
if item.get("gold_answer"):
|
|||
|
|
has_gold_answers = True
|
|||
|
|
questions.append(item)
|
|||
|
|
except json.JSONDecodeError as e:
|
|||
|
|
raise ValueError(f"第{line_num}行JSON格式错误: {str(e)}")
|
|||
|
|
|
|||
|
|
if not questions:
|
|||
|
|
raise ValueError("文件中没有有效的问题数据")
|
|||
|
|
|
|||
|
|
benchmark_id = f"benchmark_{uuid.uuid4().hex[:8]}"
|
|||
|
|
benchmark_dir = self._get_benchmark_dir(db_id)
|
|||
|
|
|
|||
|
|
# 保存数据文件 (.jsonl)
|
|||
|
|
data_file_path = os.path.join(benchmark_dir, f"{benchmark_id}.jsonl")
|
|||
|
|
with open(data_file_path, "w", encoding="utf-8") as f:
|
|||
|
|
f.write(content_str)
|
|||
|
|
|
|||
|
|
# 保存元数据文件 (.meta.json)
|
|||
|
|
meta = {
|
|||
|
|
"id": benchmark_id, # 前端期望字段可能是 id
|
|||
|
|
"benchmark_id": benchmark_id,
|
|||
|
|
"name": name,
|
|||
|
|
"description": description,
|
|||
|
|
"db_id": db_id,
|
|||
|
|
"question_count": len(questions),
|
|||
|
|
"has_gold_chunks": has_gold_chunks,
|
|||
|
|
"has_gold_answers": has_gold_answers,
|
|||
|
|
"created_by": created_by,
|
|||
|
|
"created_at": datetime.utcnow().isoformat(),
|
|||
|
|
"updated_at": datetime.utcnow().isoformat(),
|
|||
|
|
}
|
|||
|
|
meta_file_path = os.path.join(benchmark_dir, f"{benchmark_id}.meta.json")
|
|||
|
|
with open(meta_file_path, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(meta, f, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
return meta
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"上传评估基准失败: {e}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def get_benchmarks(self, db_id: str) -> list[dict[str, Any]]:
|
|||
|
|
"""获取知识库的评估基准列表"""
|
|||
|
|
try:
|
|||
|
|
benchmark_dir = self._get_benchmark_dir(db_id)
|
|||
|
|
benchmarks = []
|
|||
|
|
|
|||
|
|
# 查找所有 .meta.json 文件
|
|||
|
|
meta_files = glob.glob(os.path.join(benchmark_dir, "*.meta.json"))
|
|||
|
|
for meta_file in meta_files:
|
|||
|
|
try:
|
|||
|
|
with open(meta_file, encoding="utf-8") as f:
|
|||
|
|
meta = json.load(f)
|
|||
|
|
benchmarks.append(meta)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Failed to load benchmark meta {meta_file}: {e}")
|
|||
|
|
|
|||
|
|
# 按创建时间倒序
|
|||
|
|
benchmarks.sort(key=lambda x: x.get("created_at", ""), reverse=True)
|
|||
|
|
return benchmarks
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"获取评估基准列表失败: {e}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def get_benchmark_detail(self, benchmark_id: str) -> dict[str, Any]:
|
|||
|
|
"""获取评估基准详情 (包含问题列表)"""
|
|||
|
|
try:
|
|||
|
|
# 使用优化的查找方法
|
|||
|
|
db_id, meta_file_path = self._find_benchmark_location(benchmark_id)
|
|||
|
|
|
|||
|
|
with open(meta_file_path, encoding="utf-8") as f:
|
|||
|
|
found_meta = json.load(f)
|
|||
|
|
|
|||
|
|
# 加载数据文件
|
|||
|
|
data_file_path = os.path.join(os.path.dirname(meta_file_path), f"{benchmark_id}.jsonl")
|
|||
|
|
questions = []
|
|||
|
|
if os.path.exists(data_file_path):
|
|||
|
|
with open(data_file_path, encoding="utf-8") as f:
|
|||
|
|
for line in f:
|
|||
|
|
if line.strip():
|
|||
|
|
questions.append(json.loads(line))
|
|||
|
|
|
|||
|
|
found_meta["questions"] = questions
|
|||
|
|
return found_meta
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"获取评估基准详情失败: {e}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def delete_benchmark(self, benchmark_id: str) -> None:
|
|||
|
|
"""删除评估基准"""
|
|||
|
|
try:
|
|||
|
|
# 使用优化的查找方法
|
|||
|
|
_, meta_file_path = self._find_benchmark_location(benchmark_id)
|
|||
|
|
data_file_path = meta_file_path.replace(".meta.json", ".jsonl")
|
|||
|
|
|
|||
|
|
if os.path.exists(meta_file_path):
|
|||
|
|
os.remove(meta_file_path)
|
|||
|
|
if os.path.exists(data_file_path):
|
|||
|
|
os.remove(data_file_path)
|
|||
|
|
|
|||
|
|
logger.info(f"成功删除评估基准: {benchmark_id}")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"删除评估基准失败: {e}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def delete_evaluation_result(self, task_id: str) -> None:
|
|||
|
|
"""删除评估结果"""
|
|||
|
|
try:
|
|||
|
|
# 使用优化的查找方法
|
|||
|
|
_, result_file_path = self._find_result_location(task_id)
|
|||
|
|
|
|||
|
|
# 删除结果文件
|
|||
|
|
os.remove(result_file_path)
|
|||
|
|
|
|||
|
|
logger.info(f"成功删除评估结果: {task_id}")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"删除评估结果失败: {e}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def generate_benchmark(self, db_id: str, params: dict[str, Any], created_by: str) -> dict[str, Any]:
|
|||
|
|
"""自动生成评估基准 (Stub - Temporarily Disabled)"""
|
|||
|
|
# 保持与之前的逻辑一致:暂不支持自动生成
|
|||
|
|
# 我们可以保留接口但只返回错误,或者像之前一样进入 task 然后报错
|
|||
|
|
|
|||
|
|
task_id = f"gen_benchmark_{uuid.uuid4().hex[:8]}"
|
|||
|
|
|
|||
|
|
await tasker.enqueue(
|
|||
|
|
name="生成评估基准(Disabled)",
|
|||
|
|
task_type="benchmark_generation",
|
|||
|
|
payload={"task_id": task_id, "db_id": db_id, "created_by": created_by, **params},
|
|||
|
|
coroutine=self._generate_benchmark_task,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return {"task_id": task_id, "message": "基准生成任务已提交"}
|
|||
|
|
|
|||
|
|
async def _generate_benchmark_task(self, context: TaskContext):
|
|||
|
|
"""生成任务实现"""
|
|||
|
|
await context.set_progress(0, "初始化")
|
|||
|
|
raise NotImplementedError("自动生成基准功能暂时不可用,请手动上传基准文件。")
|
|||
|
|
|
|||
|
|
async def run_evaluation(
|
|||
|
|
self, db_id: str, benchmark_id: str, retrieval_config: dict[str, Any], created_by: str
|
|||
|
|
) -> str:
|
|||
|
|
"""运行RAG评估"""
|
|||
|
|
try:
|
|||
|
|
task_id = f"eval_{uuid.uuid4().hex[:8]}"
|
|||
|
|
|
|||
|
|
# 获取基准元数据以验证是否存在
|
|||
|
|
# 使用优化的查找方法
|
|||
|
|
_, meta_file_path = self._find_benchmark_location(benchmark_id)
|
|||
|
|
with open(meta_file_path, encoding="utf-8") as f:
|
|||
|
|
benchmark_meta = json.load(f)
|
|||
|
|
|
|||
|
|
# 初始化结果文件 (Status: running)
|
|||
|
|
result_dir = self._get_result_dir(db_id)
|
|||
|
|
result_file_path = os.path.join(result_dir, f"{task_id}.json")
|
|||
|
|
|
|||
|
|
initial_result = {
|
|||
|
|
"id": task_id, # for compatibility
|
|||
|
|
"task_id": task_id,
|
|||
|
|
"benchmark_id": benchmark_id,
|
|||
|
|
"db_id": db_id,
|
|||
|
|
"retrieval_config": retrieval_config,
|
|||
|
|
"metrics": {},
|
|||
|
|
"status": "running",
|
|||
|
|
"total_questions": benchmark_meta.get("question_count", 0),
|
|||
|
|
"completed_questions": 0,
|
|||
|
|
"started_at": datetime.utcnow().isoformat(),
|
|||
|
|
"completed_at": None,
|
|||
|
|
"interim_results": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
with open(result_file_path, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(initial_result, f, ensure_ascii=False, indent=2)
|
|||
|
|
|
|||
|
|
await tasker.enqueue(
|
|||
|
|
name=f"RAG评估({benchmark_meta.get('name')})",
|
|||
|
|
task_type="rag_evaluation",
|
|||
|
|
payload={
|
|||
|
|
"task_id": task_id,
|
|||
|
|
"db_id": db_id,
|
|||
|
|
"benchmark_id": benchmark_id,
|
|||
|
|
"retrieval_config": retrieval_config,
|
|||
|
|
"created_by": created_by,
|
|||
|
|
},
|
|||
|
|
coroutine=self._run_evaluation_task,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
return task_id
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"启动评估失败: {e}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def _run_evaluation_task(self, context: TaskContext):
|
|||
|
|
"""运行评估任务"""
|
|||
|
|
try:
|
|||
|
|
task = context._tasker._tasks.get(context.task_id)
|
|||
|
|
if not task:
|
|||
|
|
raise ValueError("Task not found")
|
|||
|
|
payload = task.payload
|
|||
|
|
|
|||
|
|
task_id = payload["task_id"]
|
|||
|
|
db_id = payload["db_id"]
|
|||
|
|
benchmark_id = payload["benchmark_id"]
|
|||
|
|
retrieval_config = payload["retrieval_config"]
|
|||
|
|
|
|||
|
|
# 加载基准数据
|
|||
|
|
await context.set_progress(5, "加载基准数据")
|
|||
|
|
# 这里我们需要重新找到 benchmark file,因为 payload 里可能没有完整路径
|
|||
|
|
try:
|
|||
|
|
_, meta_path = self._find_benchmark_location(benchmark_id)
|
|||
|
|
data_path = meta_path.replace(".meta.json", ".jsonl")
|
|||
|
|
except ValueError:
|
|||
|
|
raise ValueError("Benchmark file not found")
|
|||
|
|
|
|||
|
|
with open(meta_path, encoding="utf-8") as f:
|
|||
|
|
benchmark_meta = json.load(f)
|
|||
|
|
|
|||
|
|
benchmark_data = []
|
|||
|
|
with open(data_path, encoding="utf-8") as f:
|
|||
|
|
for line in f:
|
|||
|
|
if line.strip():
|
|||
|
|
benchmark_data.append(json.loads(line))
|
|||
|
|
|
|||
|
|
# 开始评估
|
|||
|
|
kb_instance = knowledge_base.get_kb(db_id)
|
|||
|
|
if not kb_instance:
|
|||
|
|
raise ValueError(f"Knowledge Base {db_id} not found")
|
|||
|
|
|
|||
|
|
if kb_instance.kb_type == "lightrag":
|
|||
|
|
raise ValueError("暂不支持对 LightRAG 类型的知识库进行 RAG 评估")
|
|||
|
|
|
|||
|
|
# 初始化 Judge LLM
|
|||
|
|
judge_llm = None
|
|||
|
|
if benchmark_meta.get("has_gold_answers"):
|
|||
|
|
# 优先使用配置中的 judge_llm,否则回退到 answer_llm,或者默认
|
|||
|
|
judge_model_spec = retrieval_config.get("judge_llm") or retrieval_config.get("answer_llm")
|
|||
|
|
if judge_model_spec:
|
|||
|
|
try:
|
|||
|
|
logger.debug(f"Initializing Judge LLM: {judge_model_spec}")
|
|||
|
|
judge_llm = select_model(model_spec=judge_model_spec)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Failed to load judge LLM: {e}")
|
|||
|
|
|
|||
|
|
total_questions = len(benchmark_data)
|
|||
|
|
interim_results = []
|
|||
|
|
all_retrieval_metrics = []
|
|||
|
|
all_answer_metrics = []
|
|||
|
|
|
|||
|
|
# 更新结果文件 helper
|
|||
|
|
result_file_path = os.path.join(self._get_result_dir(db_id), f"{task_id}.json")
|
|||
|
|
|
|||
|
|
def update_result_file(status="running", completed=0, metrics=None, interim=None, final_score=None):
|
|||
|
|
try:
|
|||
|
|
if os.path.exists(result_file_path):
|
|||
|
|
with open(result_file_path, encoding="utf-8") as f:
|
|||
|
|
data = json.load(f)
|
|||
|
|
else:
|
|||
|
|
data = {} # Should have been created in run_evaluation
|
|||
|
|
|
|||
|
|
data["status"] = status
|
|||
|
|
data["completed_questions"] = completed
|
|||
|
|
if metrics:
|
|||
|
|
data["metrics"] = metrics
|
|||
|
|
if interim is not None:
|
|||
|
|
data["interim_results"] = interim
|
|||
|
|
if final_score is not None:
|
|||
|
|
data["overall_score"] = final_score
|
|||
|
|
if status in ["completed", "failed"]:
|
|||
|
|
data["completed_at"] = datetime.utcnow().isoformat()
|
|||
|
|
|
|||
|
|
with open(result_file_path, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(data, f, ensure_ascii=False, indent=2)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Failed to update result file: {e}")
|
|||
|
|
|
|||
|
|
for i, question_data in enumerate(benchmark_data):
|
|||
|
|
# 检查任务是否被取消
|
|||
|
|
await context.raise_if_cancelled()
|
|||
|
|
progress = 10 + (i / total_questions) * 80
|
|||
|
|
await context.set_progress(progress, f"评估 {i + 1}/{total_questions}")
|
|||
|
|
|
|||
|
|
# 执行查询
|
|||
|
|
query_result = await kb_instance.aquery(question_data["query"], db_id, **retrieval_config)
|
|||
|
|
|
|||
|
|
# 处理结果
|
|||
|
|
if isinstance(query_result, dict):
|
|||
|
|
generated_answer = query_result.get("answer", "")
|
|||
|
|
retrieved_chunks = query_result.get("retrieved_chunks", [])
|
|||
|
|
else:
|
|||
|
|
retrieved_chunks = query_result if isinstance(query_result, list) else []
|
|||
|
|
generated_answer = ""
|
|||
|
|
|
|||
|
|
# 如果没有生成的答案,但有检索结果且配置了 LLM,则生成答案
|
|||
|
|
if not generated_answer and retrieved_chunks and retrieval_config.get("answer_llm"):
|
|||
|
|
logger.debug(f"使用 LLM {retrieval_config.get('answer_llm')} 生成答案...")
|
|||
|
|
try:
|
|||
|
|
# 从配置中获取 LLM
|
|||
|
|
model_spec = retrieval_config["answer_llm"]
|
|||
|
|
llm = select_model(model_spec=model_spec)
|
|||
|
|
|
|||
|
|
# 构建上下文
|
|||
|
|
context_docs = []
|
|||
|
|
for idx, chunk in enumerate(retrieved_chunks[:5]): # 使用前5个最相关的文档
|
|||
|
|
content = chunk.get("content", "")
|
|||
|
|
if content:
|
|||
|
|
context_docs.append(f"文档 {idx + 1}:\n{content}")
|
|||
|
|
|
|||
|
|
context_text = "\\n\\n".join(context_docs)
|
|||
|
|
|
|||
|
|
# 构建提示词
|
|||
|
|
prompt = (
|
|||
|
|
f"基于以下上下文信息,请回答用户的问题。\n\n"
|
|||
|
|
f"上下文信息:{context_text}\n\n"
|
|||
|
|
f"用户问题:{question_data["query"]}\n\n"
|
|||
|
|
"请根据上下文信息准确回答问题。如果上下文中没有相关信息,请说明。\n\n"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 生成答案 - 使用 asyncio.to_thread 避免阻塞事件循环
|
|||
|
|
response = await asyncio.to_thread(llm.call, prompt, stream=False)
|
|||
|
|
generated_answer = response.content if response else ""
|
|||
|
|
logger.debug(f"LLM 生成的答案长度: {len(generated_answer) if generated_answer else 0}")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"LLM 生成答案失败: {e}")
|
|||
|
|
generated_answer = ""
|
|||
|
|
|
|||
|
|
# 计算指标
|
|||
|
|
current_metrics = {}
|
|||
|
|
retrieval_scores = {}
|
|||
|
|
answer_scores = {}
|
|||
|
|
|
|||
|
|
if benchmark_meta.get("has_gold_chunks") and question_data.get("gold_chunk_ids"):
|
|||
|
|
retrieval_scores = EvaluationMetricsCalculator.calculate_retrieval_metrics(
|
|||
|
|
retrieved_chunks, question_data["gold_chunk_ids"]
|
|||
|
|
)
|
|||
|
|
current_metrics.update(retrieval_scores)
|
|||
|
|
all_retrieval_metrics.append(retrieval_scores)
|
|||
|
|
|
|||
|
|
if benchmark_meta.get("has_gold_answers") and question_data.get("gold_answer"):
|
|||
|
|
if judge_llm:
|
|||
|
|
# 评判过程包含 LLM 调用,使用 asyncio.to_thread 避免阻塞
|
|||
|
|
answer_scores = await asyncio.to_thread(
|
|||
|
|
EvaluationMetricsCalculator.calculate_answer_metrics,
|
|||
|
|
query=question_data["query"],
|
|||
|
|
generated_answer=generated_answer,
|
|||
|
|
gold_answer=question_data["gold_answer"],
|
|||
|
|
judge_llm=judge_llm,
|
|||
|
|
)
|
|||
|
|
current_metrics.update(answer_scores)
|
|||
|
|
all_answer_metrics.append(answer_scores)
|
|||
|
|
else:
|
|||
|
|
logger.warning("需要计算答案指标但未配置 Judge LLM")
|
|||
|
|
|
|||
|
|
interim_results.append(
|
|||
|
|
{
|
|||
|
|
"query": question_data["query"],
|
|||
|
|
"gold_chunk_ids": question_data.get("gold_chunk_ids"),
|
|||
|
|
"gold_answer": question_data.get("gold_answer"),
|
|||
|
|
"generated_answer": generated_answer,
|
|||
|
|
"retrieved_chunks": retrieved_chunks,
|
|||
|
|
"metrics": current_metrics,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 计算当前累计指标
|
|||
|
|
current_overall_metrics = {}
|
|||
|
|
if all_retrieval_metrics:
|
|||
|
|
keys = all_retrieval_metrics[0].keys()
|
|||
|
|
for k in keys:
|
|||
|
|
current_overall_metrics[k] = sum(m.get(k, 0) for m in all_retrieval_metrics) / len(
|
|||
|
|
all_retrieval_metrics
|
|||
|
|
)
|
|||
|
|
if all_answer_metrics:
|
|||
|
|
scores = [m.get("score", 0) for m in all_answer_metrics]
|
|||
|
|
current_overall_metrics["answer_correctness"] = sum(scores) / len(scores) if scores else 0.0
|
|||
|
|
|
|||
|
|
# 更新 Tasker 的 result 以便实时获取当前指标
|
|||
|
|
await context.set_result(
|
|||
|
|
{
|
|||
|
|
"current_metrics": current_overall_metrics,
|
|||
|
|
"completed_questions": i + 1,
|
|||
|
|
"total_questions": total_questions,
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 定期更新文件 (每5个或最后一个)
|
|||
|
|
if (i + 1) % 5 == 0 or (i + 1) == total_questions:
|
|||
|
|
update_result_file(completed=i + 1, interim=interim_results)
|
|||
|
|
|
|||
|
|
# 最终计算
|
|||
|
|
await context.set_progress(95, "计算最终指标")
|
|||
|
|
|
|||
|
|
# 汇总指标
|
|||
|
|
overall_metrics = {}
|
|||
|
|
|
|||
|
|
# 检索指标平均值
|
|||
|
|
if all_retrieval_metrics:
|
|||
|
|
keys = all_retrieval_metrics[0].keys()
|
|||
|
|
for k in keys:
|
|||
|
|
overall_metrics[k] = sum(m.get(k, 0) for m in all_retrieval_metrics) / len(all_retrieval_metrics)
|
|||
|
|
|
|||
|
|
# 答案指标平均值
|
|||
|
|
if all_answer_metrics:
|
|||
|
|
scores = [m.get("score", 0) for m in all_answer_metrics]
|
|||
|
|
overall_metrics["answer_correctness"] = sum(scores) / len(scores) if scores else 0.0
|
|||
|
|
|
|||
|
|
overall_score = EvaluationMetricsCalculator.calculate_overall_score(
|
|||
|
|
all_retrieval_metrics, all_answer_metrics
|
|||
|
|
)
|
|||
|
|
overall_metrics["overall_score"] = overall_score
|
|||
|
|
|
|||
|
|
update_result_file(
|
|||
|
|
status="completed",
|
|||
|
|
completed=total_questions,
|
|||
|
|
metrics=overall_metrics,
|
|||
|
|
interim=interim_results,
|
|||
|
|
final_score=overall_score,
|
|||
|
|
)
|
|||
|
|
await context.set_progress(100, "完成")
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Task failed: {e}")
|
|||
|
|
# Try to update status to failed
|
|||
|
|
try:
|
|||
|
|
# Need to find the file path again or pass it around.
|
|||
|
|
# Re-deriving from payload if available
|
|||
|
|
if "payload" in locals():
|
|||
|
|
path = os.path.join(self._get_result_dir(payload["db_id"]), f"{payload['task_id']}.json")
|
|||
|
|
if os.path.exists(path):
|
|||
|
|
with open(path, encoding="utf-8") as f:
|
|||
|
|
d = json.load(f)
|
|||
|
|
d["status"] = "failed"
|
|||
|
|
d["error"] = str(e)
|
|||
|
|
with open(path, "w", encoding="utf-8") as f:
|
|||
|
|
json.dump(d, f, ensure_ascii=False, indent=2)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Error updating result file: {e}")
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
await context.set_message(f"Error: {str(e)}")
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
async def get_evaluation_results(self, task_id: str) -> dict[str, Any]:
|
|||
|
|
"""获取评估结果"""
|
|||
|
|
try:
|
|||
|
|
# 使用优化的查找方法
|
|||
|
|
_, result_file_path = self._find_result_location(task_id)
|
|||
|
|
|
|||
|
|
with open(result_file_path, encoding="utf-8") as f:
|
|||
|
|
return json.load(f)
|
|||
|
|
except ValueError:
|
|||
|
|
# 可能是内存中的任务状态?如果文件没创建(极早失败),检查 tasker
|
|||
|
|
task = await tasker.get_task(task_id)
|
|||
|
|
if task:
|
|||
|
|
return {"task_id": task_id, "status": task.status, "progress": task.progress, "message": task.message}
|
|||
|
|
raise ValueError(f"Result not found for task {task_id}")
|
|||
|
|
|
|||
|
|
async def get_evaluation_history(self, db_id: str) -> list[dict[str, Any]]:
|
|||
|
|
"""获取知识库的评估历史记录"""
|
|||
|
|
try:
|
|||
|
|
result_dir = self._get_result_dir(db_id)
|
|||
|
|
history = []
|
|||
|
|
|
|||
|
|
# 查找所有 .json 文件
|
|||
|
|
result_files = glob.glob(os.path.join(result_dir, "*.json"))
|
|||
|
|
for result_file in result_files:
|
|||
|
|
try:
|
|||
|
|
with open(result_file, encoding="utf-8") as f:
|
|||
|
|
data = json.load(f)
|
|||
|
|
# 只返回摘要信息,不返回详细的interim_results
|
|||
|
|
summary = {
|
|||
|
|
"task_id": data.get("task_id"),
|
|||
|
|
"benchmark_id": data.get("benchmark_id"),
|
|||
|
|
"status": data.get("status"),
|
|||
|
|
"started_at": data.get("started_at"),
|
|||
|
|
"completed_at": data.get("completed_at"),
|
|||
|
|
"total_questions": data.get("total_questions"),
|
|||
|
|
"completed_questions": data.get("completed_questions"),
|
|||
|
|
"overall_score": data.get("overall_score"),
|
|||
|
|
# 也可以带上部分 metrics 摘要
|
|||
|
|
"metrics": data.get("metrics"),
|
|||
|
|
}
|
|||
|
|
history.append(summary)
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"Failed to load result file {result_file}: {e}")
|
|||
|
|
|
|||
|
|
# 按开始时间倒序
|
|||
|
|
history.sort(key=lambda x: x.get("started_at", ""), reverse=True)
|
|||
|
|
return history
|
|||
|
|
|
|||
|
|
except Exception as e:
|
|||
|
|
logger.error(f"获取评估历史失败: {e}")
|
|||
|
|
raise
|