4.3 评估指标与调优方法 本节导读:本节将建立一套完整的 GraphRAG 系统评估体系,涵盖检索质量指标、系统性能指标、A/B 测试框架和参数调优方法。评估是系统迭代优化的基础——没有量化度量,优化就变成了凭直觉的盲目试错。我们将介绍如何在 GraphRAG 场景中计算标准 IR 指标,建立性能监控体系,设计 A/B 测试实验,并通过系统化的调优方法持续提升系统效果。 学习目标 掌握 Recall@K、MRR、NDCG 等标准检索质量指标在 GraphRAG 中的计算方法 理解 P50/P95/P99 延迟、QPS 等工程性能指标的监控方案 学会设计 A/B 测试框架进行在线效果对比 掌握基于网格搜索和贝叶斯优化的超参数调优实践 了解长期监控与预警机制的设计 检索质量指标
本节导读:本节将建立一套完整的 GraphRAG 系统评估体系,涵盖检索质量指标、系统性能指标、A/B 测试框架和参数调优方法。评估是系统迭代优化的基础——没有量化度量,优化就变成了凭直觉的盲目试错。我们将介绍如何在 GraphRAG 场景中计算标准 IR 指标,建立性能监控体系,设计 A/B 测试实验,并通过系统化的调优方法持续提升系统效果。
Recall@K 衡量的是在返回的 Top-K 个结果中,包含了多少比例的相关文档。
from typing import List, Set def recall_at_k(retrieved: List[str], relevant: Set[str], k: int) -> float: """ 计算 Recall@K Args: retrieved: 检索系统返回的结果列表(按相关性排序) relevant: 相关文档的标准答案集合 k: 截断位置 """ if not relevant: return 0.0 retrieved_top_k = set(retrieved[:k]) hits = len(retrieved_top_k & relevant) return hits / len(relevant) # 示例 retrieved = ["doc_a", "doc_b", "doc_c", "doc_d", "doc_e"] relevant = {"doc_a", "doc_c", "doc_f", "doc_g"} print(f"Recall@3: {recall_at_k(retrieved, relevant, 3):.2f}") # 0.50 (命中2个/总共4个) print(f"Recall@5: {recall_at_k(retrieved, relevant, 5):.2f}") # 0.50 (命中2个/总共4个) print(f"Recall@10: {recall_at_k(retrieved, relevant, 10):.2f}") # 0.50
GraphRAG 场景的特殊性:在 GraphRAG 中,"相关"不仅包括包含答案的文档,还包括知识图谱中相关的实体和路径。因此评估需要同时考虑文本检索和图谱检索两路结果。
def graphrag_recall_at_k(graph_results: List[str], vector_results: List[str], relevant_docs: Set[str], relevant_entities: Set[str], relevant_paths: Set[str], k: int) -> dict: """ GraphRAG 场景的综合 Recall@K 同时评估文档检索、实体检索和路径检索的召回率 """ merged = list(dict.fromkeys(graph_results + vector_results)) # 去重保序 doc_recall = recall_at_k(merged, relevant_docs, k) # 实体召回率 retrieved_entities = set(graph_results[:k]) # 图检索结果中提取实体 entity_recall = len(retrieved_entities & relevant_entities) / len(relevant_entities) if relevant_entities else 0 # 路径召回率:检索结果中是否包含了正确的关系路径 retrieved_paths = set() # 需要从检索结果中提取路径信息 path_recall = len(retrieved_paths & relevant_paths) / len(relevant_paths) if relevant_paths else 0 return { "document_recall": doc_recall, "entity_recall": entity_recall, "path_recall": path_recall, "overall_recall": (doc_recall * 0.4 + entity_recall * 0.3 + path_recall * 0.3) }
MRR 衡量的是第一个相关结果出现在结果列表中的位置。位置越靠前,MRR 越高。
def reciprocal_rank(retrieved: List[str], relevant: Set[str]) -> float: """计算单条查询的 Reciprocal Rank""" for i, doc in enumerate(retrieved): if doc in relevant: return 1.0 / (i + 1) return 0.0 def mean_reciprocal_rank(queries: List[List[str]], relevant_sets: List[Set[str]]) -> float: """计算 MRR""" total_rr = sum( reciprocal_rank(ret, rel) for ret, rel in zip(queries, relevant_sets) ) return total_rr / len(queries) # 示例 queries = [ ["doc_a", "doc_b", "doc_c"], # 第1个就命中 ["doc_x", "doc_a", "doc_y"], # 第2个命中 ["doc_x", "doc_y", "doc_z"], # 没命中 ] relevant = [{"doc_a"}, {"doc_a"}, {"doc_a"}] print(f"MRR: {mean_reciprocal_rank(queries, relevant):.3f}") # (1/1 + 1/2 + 0) / 3 = 0.5
NDCG 不仅考虑是否命中,还考虑命中的位置和文档的相关性等级(高度相关 vs 一般相关)。
import numpy as np def dcg_at_k(relevances: List[float], k: int) -> float: """计算 DCG@K""" relevances = relevances[:k] gains = [rel / np.log2(i + 2) for i, rel in enumerate(relevances)] return sum(gains) def ndcg_at_k(relevances: List[float], ideal_relevances: List[float], k: int) -> float: """计算 NDCG@K""" dcg = dcg_at_k(relevances, k) idcg = dcg_at_k(sorted(ideal_relevances, reverse=True), k) if idcg == 0: return 0.0 return dcg / idcg # 示例:相关性等级 2=高度相关, 1=一般相关, 0=不相关 relevances = [2, 1, 0, 2, 1] # 检索结果的相关性等级 ideal = [2, 2, 1, 1, 0] # 理想排序的相关性等级 print(f"NDCG@3: {ndcg_at_k(relevances, ideal, 3):.3f}") print(f"NDCG@5: {ndcg_at_k(relevances, ideal, 5):.3f}")
GraphRAG 中的 NDCG 扩展:在 GraphRAG 场景中,相关性评分可以综合文本相关性和图谱路径相关性:
def graphrag_relevance_score(doc: dict, query: str, graph_context: dict) -> float: """ 综合相关性评分 Args: doc: 文档信息 {text, entities, score} query: 用户查询 graph_context: 图谱上下文 {entity_similarity, path_relevance, hop_distance} """ # 文本相关性 (0-2) text_score = min(doc.get("score", 0) * 2, 2.0) # 图谱路径相关性 (0-2) path_score = graph_context.get("path_relevance", 0) # 实体匹配度 (0-1) entity_score = graph_context.get("entity_similarity", 0) # 跳数惩罚:路径越长,相关性略微降低 hop_penalty = 1.0 / (1 + graph_context.get("hop_distance", 0) * 0.1) return (text_score * 0.5 + path_score * 0.3 + entity_score * 0.2) * hop_penalty
构建高质量的评估数据集是准确度量的前提:
from dataclasses import dataclass, field from typing import List, Dict, Set, Optional @dataclass class EvaluationSample: """单条评估样本""" query: str # 用户查询 relevant_docs: Set[str] = field(default_factory=set) # 相关文档ID集合 relevant_entities: Set[str] = field(default_factory=set) # 相关实体集合 relevant_paths: List[str] = field(default_factory=list) # 正确的关系路径 expected_answer: str = "" # 期望的答案摘要 difficulty: str = "medium" # easy/medium/hard query_type: str = "descriptive" # entity/relation/descriptive/complex class EvaluationDatasetBuilder: """评估数据集构建器""" def build_from_query_logs(self, log_path: str, sample_size: int = 500) -> List[EvaluationSample]: """从查询日志中采样构建评估集""" # 1. 去重:去除重复查询 # 2. 分层采样:按查询类型和难度分层,确保样本多样性 # 3. 人工标注:标注相关文档、实体和路径 # 4. 质量审核:双人交叉标注,Kappa 一致性 > 0.8 pass def build_synthetic(self, kg_client, num_samples: int = 200) -> List[EvaluationSample]: """基于知识图谱自动生成评估样本""" samples = [] # 随机选择实体对,生成路径查询 entities = kg_client.get_random_entities(num_samples) for entity in entities: neighbors = kg_client.get_neighbors(entity) if neighbors: # 生成单跳查询 samples.append(EvaluationSample( query=f"{entity}的{neighbors[0]['relation']}是什么?", relevant_docs={neighbors[0]["source_doc"]}, relevant_entities={entity, neighbors[0]["target"]}, relevant_paths=[f"{entity}-{neighbors[0]['relation']}->{neighbors[0]['target']}"], query_type="relation", difficulty="easy" )) return samples
import time import statistics from dataclasses import dataclass from typing import List @dataclass class LatencyMetrics: """延迟指标""" p50_ms: float p90_ms: float p95_ms: float p99_ms: float avg_ms: float min_ms: float max_ms: float sample_count: int class LatencyTracker: """延迟追踪器""" def __init__(self, max_samples: int = 10000): self.samples: List[float] = [] self.max_samples = max_samples def record(self, latency_ms: float): """记录一次请求的延迟""" self.samples.append(latency_ms) if len(self.samples) > self.max_samples: self.samples = self.samples[-self.max_samples:] def get_metrics(self) -> LatencyMetrics: """计算延迟指标""" if not self.samples: return LatencyMetrics(0, 0, 0, 0, 0, 0, 0, 0) sorted_samples = sorted(self.samples) n = len(sorted_samples) return LatencyMetrics( p50_ms=sorted_samples[int(n * 0.5)], p90_ms=sorted_samples[int(n * 0.9)], p95_ms=sorted_samples[int(n * 0.95)], p99_ms=sorted_samples[min(int(n * 0.99), n - 1)], avg_ms=statistics.mean(self.samples), min_ms=self.samples[0], max_ms=self.samples[-1], sample_count=n ) def report(self) -> str: """生成延迟报告""" m = self.get_metrics() return ( f"延迟统计 (最近 {m.sample_count} 次请求):\n" f" P50: {m.p50_ms:.1f}ms | P90: {m.p90_ms:.1f}ms | " f"P95: {m.p95_ms:.1f}ms | P99: {m.p99_ms:.1f}ms\n" f" 平均: {m.avg_ms:.1f}ms | 范围: {m.min_ms:.1f}ms ~ {m.max_ms:.1f}ms" ) class PerformanceMonitor: """系统性能监控器""" def __init__(self): self.total_latency = LatencyTracker() self.graph_latency = LatencyTracker() self.vector_latency = LatencyTracker() self.llm_latency = LatencyTracker() async def track_query(self, query_fn, *args, **kwargs): """带监控的查询执行""" total_start = time.perf_counter() # 执行查询 result, timings = await query_fn(*args, **kwargs) total_elapsed = (time.perf_counter() - total_start) * 1000 # 记录各阶段延迟 self.total_latency.record(total_elapsed) if "graph_ms" in timings: self.graph_latency.record(timings["graph_ms"]) if "vector_ms" in timings: self.vector_latency.record(timings["vector_ms"]) if "llm_ms" in timings: self.llm_latency.record(timings["llm_ms"]) return result
class ThroughputTracker: """吞吐量追踪器""" def __init__(self, window_seconds: int = 60): self.window = window_seconds self.requests: List[float] = [] def record_request(self): """记录一次请求""" self.requests.append(time.time()) self._cleanup() def _cleanup(self): """清理过期记录""" cutoff = time.time() - self.window self.requests = [t for t in self.requests if t > cutoff] def get_qps(self) -> float: """获取当前 QPS(每秒查询数)""" self._cleanup() return len(self.requests) / self.window def report(self) -> str: return f"当前 QPS: {self.get_qps():.1f} (最近 {self.window}s 窗口)"
import psutil class ResourceMonitor: """资源利用率监控""" def snapshot(self) -> dict: """获取当前资源使用快照""" cpu = psutil.cpu_percent(interval=1) mem = psutil.virtual_memory() disk = psutil.disk_usage('/') return { "cpu_percent": cpu, "memory": { "total_gb": mem.total / (1024**3), "used_gb": mem.used / (1024**3), "percent": mem.percent }, "disk": { "total_gb": disk.total / (1024**3), "used_gb": disk.used / (1024**3), "percent": (disk.used / disk.total) * 100 } }
import random from dataclasses import dataclass from typing import Dict, List, Optional from enum import Enum class MetricType(Enum): RECALL = "recall" MRR = "mrr" NDCG = "ndcg" LATENCY_P95 = "latency_p95" USER_SATISFACTION = "user_satisfaction" @dataclass class ExperimentConfig: """实验配置""" name: str description: str control_config: Dict # 对照组配置 treatment_config: Dict # 实验组配置 traffic_split: float = 0.5 # 流量分配比例(实验组占比) metrics: List[MetricType] = None min_sample_size: int = 1000 # 最小样本量 confidence_level: float = 0.95 # 置信水平 @dataclass class ExperimentResult: """实验结果""" control_metrics: Dict[str, float] treatment_metrics: Dict[str, float] improvement: Dict[str, float] p_values: Dict[str, float] is_significant: Dict[str, bool] recommendation: str class ABTestFramework: """A/B 测试框架""" def __init__(self, config: ExperimentConfig): self.config = config self.control_results = [] self.treatment_results = [] def assign_group(self, user_id: str) -> str: """为用户分配实验组(基于用户ID的确定性分配)""" hash_val = int(hashlib.md5(user_id.encode()).hexdigest(), 16) if (hash_val % 100) / 100 < self.config.traffic_split: return "treatment" return "control" def record_result(self, group: str, metrics: Dict[str, float]): """记录实验结果""" if group == "control": self.control_results.append(metrics) else: self.treatment_results.append(metrics) def analyze(self) -> ExperimentResult: """分析实验结果""" from scipy import stats control_metrics = {} treatment_metrics = {} improvements = {} p_values = {} is_significant = {} for metric in self.config.metrics: control_values = [r[metric.value] for r in self.control_results if metric.value in r] treatment_values = [r[metric.value] for r in self.treatment_results if metric.value in r] control_mean = statistics.mean(control_values) if control_values else 0 treatment_mean = statistics.mean(treatment_values) if treatment_values else 0 control_metrics[metric.value] = control_mean treatment_metrics[metric.value] = treatment_mean # 计算提升幅度 if control_mean > 0: improvements[metric.value] = (treatment_mean - control_mean) / control_mean * 100 else: improvements[metric.value] = 0 # 统计显著性检验 if len(control_values) >= 30 and len(treatment_values) >= 30: _, p_value = stats.ttest_ind(control_values, treatment_values) p_values[metric.value] = p_value is_significant[metric.value] = p_value < (1 - self.config.confidence_level) else: p_values[metric.value] = 1.0 is_significant[metric.value] = False # 生成建议 significant_improvements = [k for k, v in is_significant.items() if v and improvements.get(k, 0) > 0] if significant_improvements: recommendation = f"建议上线实验版本。在 {', '.join(significant_improvements)} 指标上有显著提升。" elif any(v and improvements.get(k, 0) < -5 for k, v in is_significant.items()): recommendation = "实验版本有显著回退,建议不上线。" else: recommendation = "实验结果不显著,建议继续收集数据或调整实验参数。" return ExperimentResult( control_metrics=control_metrics, treatment_metrics=treatment_metrics, improvement=improvements, p_values=p_values, is_significant=is_significant, recommendation=recommendation )
# 实验场景:对比固定权重 vs 动态权重的检索融合策略 experiment = ABTestFramework(ExperimentConfig( name="dynamic_weight_vs_fixed_weight", description="对比动态权重调整与固定权重(0.6/0.4)的检索融合效果", control_config={"graph_weight": 0.6, "vector_weight": 0.4}, # 固定权重 treatment_config={"strategy": "dynamic"}, # 动态权重 traffic_split=0.5, metrics=[MetricType.RECALL, MetricType.MRR, MetricType.LATENCY_P95], min_sample_size=2000 )) # 模拟实验过程 import hashlib import statistics for i in range(2000): user_id = f"user_{i}" group = experiment.assign_group(user_id) if group == "control": metrics = {"recall": random.gauss(0.72, 0.08), "mrr": random.gauss(0.65, 0.1), "latency_p95": random.gauss(85, 15)} else: metrics = {"recall": random.gauss(0.78, 0.07), "mrr": random.gauss(0.70, 0.09), "latency_p95": random.gauss(90, 18)} experiment.record_result(group, metrics) result = experiment.analyze() print(result.recommendation)
from itertools import product class GridSearchOptimizer: """网格搜索超参数优化器""" def __init__(self, eval_fn, param_grid: Dict[str, List]): """ Args: eval_fn: 评估函数,接受参数字典,返回指标字典 param_grid: 参数搜索空间 {参数名: [候选值列表]} """ self.eval_fn = eval_fn self.param_grid = param_grid self.results = [] def search(self) -> Dict: """执行网格搜索""" param_names = list(self.param_grid.keys()) param_values = list(self.param_grid.values()) best_score = -float('inf') best_params = None for combination in product(*param_values): params = dict(zip(param_names, combination)) metrics = self.eval_fn(params) self.results.append({"params": params, "metrics": metrics}) score = metrics.get("recall", 0) * 0.5 + metrics.get("mrr", 0) * 0.5 if score > best_score: best_score = score best_params = params # 按分数排序 self.results.sort(key=lambda x: x["metrics"].get("combined", 0), reverse=True) return {"best_params": best_params, "best_score": best_score} # 使用示例 optimizer = GridSearchOptimizer( eval_fn=evaluate_graphrag_system, param_grid={ "graph_weight": [0.3, 0.4, 0.5, 0.6, 0.7], "max_hops": [1, 2, 3], "top_k": [5, 10, 20], "embedding_dim": [768, 1536] } ) result = optimizer.search() print(f"最佳参数: {result['best_params']}") print(f"最佳分数: {result['best_score']:.4f}")
对于连续参数(如图权重、温度参数),贝叶斯优化比网格搜索更高效:
from skopt import gp_minimize from skopt.space import Real, Integer from skopt.utils import use_named_args class BayesianOptimizer: """贝叶斯超参数优化器""" def __init__(self, eval_fn, n_calls: int = 50): self.eval_fn = eval_fn self.n_calls = n_calls def optimize(self) -> Dict: """执行贝叶斯优化""" space = [ Real(0.1, 0.9, name='graph_weight'), Integer(1, 5, name='max_hops'), Integer(3, 30, name='top_k'), Real(0.0, 1.0, name='temperature'), ] @use_named_args(space) def objective(**params): metrics = self.eval_fn(params) # 最小化负的加权分数 return -(metrics.get("recall", 0) * 0.5 + metrics.get("mrr", 0) * 0.3 + metrics.get("ndcg", 0) * 0.2) result = gp_minimize(objective, space, n_calls=self.n_calls, random_state=42) best_params = { "graph_weight": result.x[0], "max_hops": result.x[1], "top_k": result.x[2], "temperature": result.x[3] } return { "best_params": best_params, "best_score": -result.fun, "convergence": result.func_vals }
class QualityMonitor: """检索质量长期监控""" def __init__(self, alert_threshold: float = 0.05): self.alert_threshold = alert_threshold self.daily_scores: Dict[str, List[float]] = {} # {date: [scores]} def record_daily_score(self, date: str, scores: List[float]): """记录每日评估分数""" if date not in self.daily_scores: self.daily_scores[date] = [] self.daily_scores[date].extend(scores) def check_quality_drift(self) -> Optional[str]: """检查质量漂移""" dates = sorted(self.daily_scores.keys()) if len(dates) < 7: return None # 对比最近7天和之前7天的平均分数 recent_avg = statistics.mean([ s for d in dates[-7:] for s in self.daily_scores[d] ]) previous_avg = statistics.mean([ s for d in dates[-14:-7] for s in self.daily_scores[d] ]) if len(dates) >= 14 else recent_avg drift = (recent_avg - previous_avg) / previous_avg if previous_avg > 0 else 0 if drift < -self.alert_threshold: return f"⚠️ 质量下降预警:近7天平均分 {recent_avg:.4f},较前7天下降 {abs(drift)*100:.1f}%" return None
本节建立了完整的 GraphRAG 系统评估体系:
评估和优化是一个持续的过程——建立度量体系是第一步,基于数据的迭代优化是保持系统长期高质量运行的关键。
关键词:评估指标, Recall@K, MRR, NDCG, A/B测试, 参数调优, 贝叶斯优化, 性能监控, GraphRAG评估
难度:进阶
预计阅读:35 分钟