Files
wehub-resource-sync 1443d3fdf9
Ruff Format Check / Ruff Format & Lint (push) Failing after 7m39s
Deploy VitePress site to Pages / build (push) Failing after 9m11s
Deploy VitePress site to Pages / Deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:32:26 +08:00

723 lines
30 KiB
Python

import json
import re
import uuid
from typing import Any
from yuxi.knowledge import knowledge_base
from yuxi.knowledge.eval.benchmark_generation import (
dump_benchmark_item,
iter_generated_benchmark_items,
normalize_generation_concurrency_count,
)
from yuxi.knowledge.eval.evaluator import aggregate_metrics, evaluate_question
from yuxi.models import select_model
from yuxi.repositories.evaluation_repository import EvaluationRepository
from yuxi.repositories.knowledge_base_repository import KnowledgeBaseRepository
from yuxi.repositories.knowledge_chunk_repository import KnowledgeChunkRepository
from yuxi.repositories.task_repository import TaskRepository
from yuxi.services.task_service import TaskContext, tasker
from yuxi.utils import logger
from yuxi.utils.datetime_utils import format_utc_datetime, utc_now_naive
def build_evaluation_run_name(started_at=None, hash_value: str | None = None) -> str:
date_part = (started_at or utc_now_naive()).strftime("%Y%m%d")
hash_part = re.sub(r"[^a-fA-F0-9]", "", hash_value or uuid.uuid4().hex).lower()[:6]
if len(hash_part) < 6:
hash_part = (hash_part + uuid.uuid4().hex)[:6]
return f"eval-{date_part}-{hash_part}"
class EvaluationService:
"""RAG评估服务"""
def __init__(self):
self.eval_repo = EvaluationRepository()
self.kb_repo = KnowledgeBaseRepository()
self.chunk_repo = KnowledgeChunkRepository()
self.task_repo = TaskRepository()
def _dataset_to_dict(self, row) -> dict[str, Any]:
return {
"id": row.dataset_id,
"dataset_id": row.dataset_id,
"name": row.name,
"description": row.description,
"kb_id": row.kb_id,
"item_count": row.item_count,
"has_gold_chunks": row.has_gold_chunks,
"has_gold_answers": row.has_gold_answers,
"build_metadata": row.build_metadata or {},
"created_by": row.created_by,
"created_at": format_utc_datetime(row.created_at),
"updated_at": format_utc_datetime(row.updated_at),
}
def _dataset_item_to_dict(self, item) -> dict[str, Any]:
return {
"item_id": item.item_id,
"item_index": item.item_index,
"query": item.query_text,
"gold_chunk_ids": item.gold_chunk_ids or [],
"gold_answer": item.gold_answer,
}
def _run_item_to_dict(self, item) -> dict[str, Any]:
return {
"query": item.query_text,
"gold_chunk_ids": item.gold_chunk_ids,
"gold_answer": item.gold_answer,
"generated_answer": item.generated_answer,
"retrieved_chunks": item.retrieved_chunks,
"metrics": item.metrics or {},
}
def _is_error_run_item(self, item) -> bool:
metrics = item.metrics or {}
return metrics.get("score", 1.0) <= 0.5 or any(
metrics.get(key, 1.0) < 0.3 for key in metrics if key.startswith("recall@")
)
def _normalize_run_name(self, name: str | None, run_id: str) -> str:
run_name = (name or "").strip()
if run_name:
return run_name
return build_evaluation_run_name(hash_value=run_id.removeprefix("run_"))
def _run_name_from_row(self, row) -> str:
name = (getattr(row, "name", None) or "").strip()
if name:
return name
return build_evaluation_run_name(row.started_at, hash_value=row.run_id.removeprefix("run_"))
async def _sync_dataset_build_metadata(self, row) -> None:
metadata = dict(row.build_metadata or {})
if metadata.get("source") != "generated" or metadata.get("status") not in {"pending", "running"}:
return
task_id = metadata.get("task_id")
task = await self.task_repo.get_by_id(task_id) if task_id else None
if task is None:
metadata.pop("progress", None)
metadata.update(status="failed", message="生成任务不存在")
elif task.status == "success":
metadata.update(status="completed", progress=100, message=task.message or "完成")
elif task.status in {"failed", "cancelled"}:
metadata.pop("progress", None)
metadata.update(status="failed", message=task.error or task.message or "生成任务失败")
else:
metadata.update(status=task.status, progress=task.progress, message=task.message)
if metadata != (row.build_metadata or {}):
await self.eval_repo.update_dataset(row.dataset_id, {"build_metadata": metadata})
row.build_metadata = metadata
def _build_dataset_items(
self, dataset_id: str, kb_id: str, questions: list[dict[str, Any]]
) -> list[dict[str, Any]]:
return [
{
"item_id": f"dataset_item_{uuid.uuid4().hex[:12]}",
"dataset_id": dataset_id,
"kb_id": kb_id,
"item_index": index,
"query_text": item["query"],
"gold_chunk_ids": item.get("gold_chunk_ids") or [],
"gold_answer": item.get("gold_answer"),
}
for index, item in enumerate(questions)
]
def _build_jsonl_content(self, items: list[Any]) -> str:
lines = []
for item in items:
payload = {"query": item.query_text}
if item.gold_chunk_ids:
payload["gold_chunk_ids"] = item.gold_chunk_ids
if item.gold_answer:
payload["gold_answer"] = item.gold_answer
lines.append(dump_benchmark_item(payload).rstrip("\n"))
return "\n".join(lines) + ("\n" if lines else "")
def _safe_jsonl_filename(self, name: str | None, fallback: str) -> str:
filename = (name or "").strip() or fallback
filename = re.sub(r"[\\/:*?\"<>|]+", "_", filename).strip()
if not filename or filename in {".", ".."}:
filename = fallback
return filename if filename.endswith(".jsonl") else f"{filename}.jsonl"
def _parse_jsonl_questions(self, file_content: bytes) -> tuple[list[dict[str, Any]], bool, bool]:
questions = []
has_gold_chunks = False
has_gold_answers = False
content = file_content.decode("utf-8")
for line_num, line in enumerate(content.strip().split("\n"), 1):
if not line.strip():
continue
try:
item = json.loads(line)
except json.JSONDecodeError as e:
raise ValueError(f"第{line_num}行JSON格式错误: {str(e)}")
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)
if not questions:
raise ValueError("文件中没有有效的问题数据")
return questions, has_gold_chunks, has_gold_answers
async def upload_dataset(
self, kb_id: str, file_content: bytes, filename: str, name: str, description: str, created_by: str
) -> dict[str, Any]:
try:
questions, has_gold_chunks, has_gold_answers = self._parse_jsonl_questions(file_content)
dataset_id = f"dataset_{uuid.uuid4().hex[:8]}"
dataset_name = name.strip() or filename or dataset_id
row = await self.eval_repo.create_dataset_with_items(
{
"dataset_id": dataset_id,
"kb_id": kb_id,
"name": dataset_name,
"description": description,
"item_count": len(questions),
"has_gold_chunks": has_gold_chunks,
"has_gold_answers": has_gold_answers,
"build_metadata": {
"source": "upload",
"status": "completed",
"progress": 100,
"filename": filename,
},
"created_by": created_by,
},
self._build_dataset_items(dataset_id, kb_id, questions),
)
return self._dataset_to_dict(row)
except Exception as e:
logger.error(f"上传评估数据集失败: {e}")
raise
async def list_datasets(self, kb_id: str) -> list[dict[str, Any]]:
try:
rows = await self.eval_repo.list_datasets(kb_id)
for row in rows:
await self._sync_dataset_build_metadata(row)
return [self._dataset_to_dict(row) for row in rows]
except Exception as e:
logger.error(f"获取评估数据集列表失败: {e}")
raise
async def get_dataset_detail(
self, kb_id: str, dataset_id: str, page: int = 1, page_size: int = 10
) -> dict[str, Any]:
try:
row = await self.eval_repo.get_dataset(dataset_id)
if row is None or row.kb_id != kb_id:
raise ValueError("Dataset not found")
if (row.build_metadata or {}).get("status", "completed") != "completed":
raise ValueError("Dataset is not ready")
total_items = await self.eval_repo.count_dataset_items(dataset_id)
items = await self.eval_repo.list_dataset_items(dataset_id, (page - 1) * page_size, page_size)
total_pages = (total_items + page_size - 1) // page_size
data = self._dataset_to_dict(row)
data.update(
{
"items": [self._dataset_item_to_dict(item) for item in items],
"pagination": {
"current_page": page,
"page_size": page_size,
"total_items": total_items,
"total_pages": total_pages,
"has_next": page < total_pages,
"has_prev": page > 1,
},
}
)
return data
except Exception as e:
logger.error(f"获取评估数据集详情失败: {e}")
raise
async def export_dataset_jsonl(self, dataset_id: str) -> dict[str, str]:
row = await self.eval_repo.get_dataset(dataset_id)
if row is None:
raise ValueError("Dataset not found")
if (row.build_metadata or {}).get("status", "completed") != "completed":
raise ValueError("Dataset is not ready")
items = await self.eval_repo.list_all_dataset_items(dataset_id)
return {
"filename": self._safe_jsonl_filename(row.name, row.dataset_id),
"content": self._build_jsonl_content(items),
}
async def delete_dataset(self, dataset_id: str) -> None:
try:
row = await self.eval_repo.get_dataset(dataset_id)
if row is None:
raise ValueError("Dataset not found")
await self.eval_repo.delete_dataset(dataset_id)
logger.info(f"成功删除评估数据集: {dataset_id}")
except Exception as e:
logger.error(f"删除评估数据集失败: {e}")
raise
async def generate_dataset(
self,
kb_id: str,
name: str,
description: str,
count: int,
neighbors_count: int,
concurrency_count: int,
llm_model_spec: str,
generation_mode: str = "vector",
graph_expand_top_k: int = 1,
created_by: str = "system",
) -> dict[str, Any]:
dataset_id = f"dataset_{uuid.uuid4().hex[:8]}"
count = int(count)
neighbors_count = int(neighbors_count)
concurrency_count = normalize_generation_concurrency_count(concurrency_count)
graph_expand_top_k = min(max(1, int(graph_expand_top_k)), 3)
if generation_mode not in {"vector", "graph_enhanced"}:
raise ValueError("不支持的评估基准生成方式")
if generation_mode == "graph_enhanced":
indexed_count = await self.chunk_repo.count_graph_indexed_by_kb_id(kb_id)
if indexed_count <= 0:
raise ValueError("当前知识库尚未完成图索引,无法使用图增强构建")
build_metadata = {
"source": "generated",
"status": "pending",
"progress": 0,
"params": {
"count": count,
"neighbors_count": neighbors_count,
"concurrency_count": concurrency_count,
"llm_model_spec": llm_model_spec,
"generation_mode": generation_mode,
"graph_expand_top_k": graph_expand_top_k,
},
}
await self.eval_repo.create_dataset(
{
"dataset_id": dataset_id,
"kb_id": kb_id,
"name": name,
"description": description,
"item_count": 0,
"has_gold_chunks": True,
"has_gold_answers": True,
"build_metadata": build_metadata,
"created_by": created_by,
}
)
task = await tasker.enqueue(
name="生成评估数据集",
task_type="dataset_generation",
payload={
"dataset_id": dataset_id,
"kb_id": kb_id,
"created_by": created_by,
"name": name,
"description": description,
"count": count,
"neighbors_count": neighbors_count,
"concurrency_count": concurrency_count,
"llm_model_spec": llm_model_spec,
"generation_mode": generation_mode,
"graph_expand_top_k": graph_expand_top_k,
},
coroutine=self._generate_dataset_task,
)
build_metadata["task_id"] = task.id
await self.eval_repo.update_dataset(dataset_id, {"build_metadata": build_metadata})
return {"dataset_id": dataset_id, "task_id": task.id, "message": "评估数据集生成任务已提交"}
async def _update_dataset_build_metadata(
self, dataset_id: str, metadata: dict[str, Any], **updates
) -> dict[str, Any]:
metadata.update(updates)
await self.eval_repo.update_dataset(dataset_id, {"build_metadata": metadata})
return metadata
async def _generate_dataset_task(self, context: TaskContext):
await context.set_progress(0, "初始化")
payload = context.payload
dataset_id = payload.get("dataset_id")
kb_id = payload.get("kb_id")
count = int(payload.get("count", 10))
neighbors_count = int(payload.get("neighbors_count", 1))
concurrency_count = normalize_generation_concurrency_count(payload.get("concurrency_count"))
llm_model_spec = payload.get("llm_model_spec")
generation_mode = payload.get("generation_mode") or "vector"
graph_expand_top_k = min(max(1, int(payload.get("graph_expand_top_k", 1))), 3)
build_metadata = {
"source": "generated",
"status": "running",
"progress": 0,
"task_id": context.task_id,
"params": {
"count": count,
"neighbors_count": neighbors_count,
"concurrency_count": concurrency_count,
"llm_model_spec": llm_model_spec,
"generation_mode": generation_mode,
"graph_expand_top_k": graph_expand_top_k,
},
}
await self._update_dataset_build_metadata(dataset_id, build_metadata)
async def report_progress(progress: float, message: str | None = None) -> None:
await context.set_progress(progress, message)
await self._update_dataset_build_metadata(
dataset_id,
build_metadata,
progress=max(0, min(round(progress), 100)),
message=message or build_metadata.get("message", ""),
)
try:
kb_instance = await knowledge_base.aget_kb(kb_id)
if not kb_instance:
await report_progress(100, "知识库不存在")
raise ValueError("Knowledge Base not found")
if kb_instance.kb_type != "milvus":
await report_progress(100, "仅支持 commonrag/Milvus 类型知识库生成评估数据集")
raise ValueError("Unsupported KB type for dataset generation")
questions = []
try:
async for item in iter_generated_benchmark_items(
kb_instance=kb_instance,
kb_id=kb_id,
count=count,
neighbors_count=neighbors_count,
llm_model_spec=llm_model_spec,
concurrency_count=concurrency_count,
generation_mode=generation_mode,
graph_expand_top_k=graph_expand_top_k,
progress_cb=report_progress,
cancel_cb=context.raise_if_cancelled,
):
questions.append(item)
except ValueError as e:
if str(e) == "No chunks found in knowledge base":
await report_progress(100, "知识库为空或未解析到chunks")
raise
if not questions:
raise ValueError("未生成有效评估题目")
await self.eval_repo.add_dataset_items(self._build_dataset_items(dataset_id, kb_id, questions))
await self.eval_repo.update_dataset(dataset_id, {"item_count": len(questions)})
await self._update_dataset_build_metadata(
dataset_id,
build_metadata,
status="completed",
progress=100,
message="完成",
)
await context.set_progress(100, "完成")
except Exception as e:
await self._update_dataset_build_metadata(
dataset_id,
build_metadata,
status="failed",
progress=100,
error_message=str(e),
message=str(e),
)
raise
async def run_evaluation(
self,
kb_id: str,
dataset_id: str,
name: str | None = None,
model_config: dict[str, Any] = None,
created_by: str = "system",
) -> str:
try:
run_id = f"run_{uuid.uuid4().hex[:8]}"
run_name = self._normalize_run_name(name, run_id)
dataset_row = await self.eval_repo.get_dataset(dataset_id)
if dataset_row is None or dataset_row.kb_id != kb_id:
raise ValueError("Dataset not found")
if (dataset_row.build_metadata or {}).get("status", "completed") != "completed":
raise ValueError("Dataset is not ready")
retrieval_config = {}
try:
kb_row = await self.kb_repo.get_by_kb_id(kb_id)
query_params = (kb_row.query_params if kb_row else None) or {}
retrieval_config = query_params.get("options", {}) if isinstance(query_params, dict) else {}
if not retrieval_config:
kb_instance = await knowledge_base.aget_kb(kb_id)
if kb_instance:
retrieval_config = kb_instance._get_default_query_params(kb_id).get("options", {})
logger.info(f"从知识库 {kb_id} 加载检索配置: {list(retrieval_config.keys())}")
except Exception as e:
logger.error(f"获取知识库检索配置失败: {e}")
if model_config:
retrieval_config.update(model_config)
await self.eval_repo.create_run(
{
"run_id": run_id,
"name": run_name,
"kb_id": kb_id,
"dataset_id": dataset_id,
"status": "running",
"retrieval_config": retrieval_config,
"metrics": {},
"overall_score": None,
"total_items": dataset_row.item_count or 0,
"completed_items": 0,
"started_at": utc_now_naive(),
"completed_at": None,
"created_by": created_by,
}
)
await tasker.enqueue(
name=f"RAG评估({run_name})",
task_type="rag_evaluation",
payload={
"run_id": run_id,
"name": run_name,
"kb_id": kb_id,
"dataset_id": dataset_id,
"retrieval_config": retrieval_config,
"created_by": created_by,
},
coroutine=self._run_evaluation_task,
)
return run_id
except Exception as e:
logger.error(f"启动评估失败: {e}")
raise
async def _run_evaluation_task(self, context: TaskContext):
try:
payload = context.payload
run_id = payload["run_id"]
kb_id = payload["kb_id"]
dataset_id = payload["dataset_id"]
retrieval_config = payload["retrieval_config"]
await context.set_progress(5, "加载评估数据集")
dataset_row = await self.eval_repo.get_dataset(dataset_id)
if dataset_row is None or dataset_row.kb_id != kb_id:
raise ValueError("Dataset not found")
dataset_items = await self.eval_repo.list_all_dataset_items(dataset_id)
if not dataset_items:
raise ValueError("Dataset has no items")
kb_instance = await knowledge_base.aget_kb(kb_id)
if not kb_instance:
raise ValueError(f"Knowledge Base {kb_id} not found")
judge_llm = None
if dataset_row.has_gold_answers:
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}")
all_retrieval_metrics = []
all_answer_metrics = []
total_items = len(dataset_items)
async def update_run_db(status=None, completed=None, metrics=None, final_score=None):
data = {}
if status is not None:
data["status"] = status
if status in ["completed", "failed"]:
data["completed_at"] = utc_now_naive()
if completed is not None:
data["completed_items"] = completed
if metrics is not None:
data["metrics"] = metrics
if final_score is not None:
data["overall_score"] = final_score
if data:
await self.eval_repo.update_run(run_id, data)
for index, item in enumerate(dataset_items):
await context.raise_if_cancelled()
progress = 10 + (index / total_items) * 80
await context.set_progress(progress, f"评估 {index + 1}/{total_items}")
question_data = {
"query": item.query_text,
"gold_chunk_ids": item.gold_chunk_ids or [],
"gold_answer": item.gold_answer,
}
question_result = await evaluate_question(
kb_instance=kb_instance,
kb_id=kb_id,
question_data=question_data,
retrieval_config=retrieval_config,
has_gold_chunks=dataset_row.has_gold_chunks,
has_gold_answers=dataset_row.has_gold_answers,
judge_llm=judge_llm,
select_model_fn=select_model,
)
if dataset_row.has_gold_chunks and question_data.get("gold_chunk_ids"):
all_retrieval_metrics.append(question_result["retrieval_scores"])
if dataset_row.has_gold_answers and question_data.get("gold_answer") and judge_llm:
all_answer_metrics.append(question_result["answer_scores"])
await self.eval_repo.upsert_run_item(
run_id=run_id,
item_index=index,
data={"dataset_item_id": item.item_id, **question_result["detail"]},
)
if (index + 1) % 5 == 0 or (index + 1) == total_items:
current_metrics, _ = aggregate_metrics(all_retrieval_metrics, all_answer_metrics)
await context.set_result(
{"current_metrics": current_metrics, "completed_items": index + 1, "total_items": total_items}
)
await update_run_db(completed=index + 1)
await context.set_progress(95, "计算最终指标")
overall_metrics, overall_score = aggregate_metrics(
all_retrieval_metrics, all_answer_metrics, include_overall_score=True
)
await update_run_db(
status="completed",
completed=total_items,
metrics=overall_metrics,
final_score=overall_score,
)
await context.set_progress(100, "完成")
except Exception as e:
logger.error(f"Task failed: {e}")
try:
if "payload" in locals():
await self.eval_repo.update_run(
payload["run_id"],
{"status": "failed", "metrics": {"error": str(e)}, "completed_at": utc_now_naive()},
)
except Exception as exc:
logger.error(f"Error updating run record: {exc}")
await context.set_message(f"Error: {str(e)}")
raise
async def list_runs(self, kb_id: str) -> list[dict[str, Any]]:
try:
rows = await self.eval_repo.list_runs(kb_id)
running_run_ids = {row.run_id for row in rows if row.status == "running"}
task_by_run_id = {}
if running_run_ids:
tasks = await self.task_repo.list_all()
task_by_run_id = {
(task.payload or {}).get("run_id"): task
for task in tasks
if task.type == "rag_evaluation"
and task.status in {"pending", "running"}
and (task.payload or {}).get("run_id") in running_run_ids
}
runs = []
for row in rows:
run = {
"run_id": row.run_id,
"name": self._run_name_from_row(row),
"dataset_id": row.dataset_id,
"status": row.status,
"started_at": format_utc_datetime(row.started_at),
"completed_at": format_utc_datetime(row.completed_at),
"total_items": row.total_items,
"completed_items": row.completed_items,
"overall_score": row.overall_score,
"retrieval_config": row.retrieval_config or {},
"metrics": row.metrics or {},
}
if row.status == "running":
task = task_by_run_id.get(row.run_id)
if task:
run.update(progress=task.progress, message=task.message)
runs.append(run)
return runs
except Exception as e:
logger.error(f"获取评估运行历史失败: {e}")
raise
async def get_run_results(
self, kb_id: str, run_id: str, page: int = 1, page_size: int = 20, error_only: bool = False
) -> dict[str, Any]:
if not re.match(r"^run_[a-f0-9]{8}$", run_id):
raise ValueError("Invalid run_id format")
row = await self.eval_repo.get_run(run_id)
if row is None or row.kb_id != kb_id:
task = await tasker.get_task(run_id)
if task:
return {"run_id": run_id, "status": task.status, "progress": task.progress, "message": task.message}
raise ValueError(f"Run not found for {run_id}")
start_idx = (page - 1) * page_size
if error_only:
total = 0
paged_items = []
offset = 0
batch_size = 200
while True:
batch = await self.eval_repo.list_run_items(run_id, offset, batch_size)
if not batch:
break
for item in batch:
if not self._is_error_run_item(item):
continue
if start_idx <= total < start_idx + page_size:
paged_items.append(self._run_item_to_dict(item))
total += 1
offset += batch_size
else:
total = await self.eval_repo.count_run_items(run_id)
details = await self.eval_repo.list_run_items(run_id, start_idx, page_size)
paged_items = [self._run_item_to_dict(item) for item in details]
return {
"run_id": row.run_id,
"name": self._run_name_from_row(row),
"status": row.status,
"started_at": format_utc_datetime(row.started_at),
"completed_at": format_utc_datetime(row.completed_at),
"total_items": row.total_items or 0,
"completed_items": row.completed_items or 0,
"overall_score": row.overall_score,
"retrieval_config": row.retrieval_config or {},
"items": paged_items,
"pagination": {
"current_page": page,
"page_size": page_size,
"total": total,
"total_pages": (total + page_size - 1) // page_size,
"error_only": error_only,
},
}
async def delete_run(self, kb_id: str, run_id: str) -> None:
if not re.match(r"^run_[a-f0-9]{8}$", run_id):
raise ValueError("Invalid run_id format")
row = await self.eval_repo.get_run(run_id)
if row is None or row.kb_id != kb_id:
raise ValueError("Run not found")
await self.eval_repo.delete_run(run_id)
logger.info(f"成功删除评估运行: {run_id}")