"""RelevanceScorer - 检索结果相关性自动评估 对检索结果逐文档评估与查询的相关性,用于 CRAG 自纠正循环的评估阶段。 """ from __future__ import annotations import logging import math import re from dataclasses import dataclass from enum import Enum from typing import Any from agentkit.memory.base import MemoryItem logger = logging.getLogger(__name__) class RelevanceVerdict(str, Enum): """相关性判定结果""" CORRECT = "correct" AMBIGUOUS = "ambiguous" INCORRECT = "incorrect" @dataclass class RelevanceScore: """单个文档的相关性评分""" item: MemoryItem score: float # 0.0 ~ 1.0 verdict: RelevanceVerdict reason: str = "" @dataclass class RetrievalEvaluation: """一次检索的整体评估结果""" scores: list[RelevanceScore] overall_verdict: RelevanceVerdict avg_score: float relevant_count: int total_count: int class RelevanceScorer: """检索结果相关性评估器 基于查询-文档语义相似度和关键词重叠的轻量级评估器。 不依赖 LLM 调用,适用于生产环境的低延迟评估。 评分策略: 1. 关键词重叠率(Jaccard 相似度) 2. 查询词覆盖率(query term coverage) 3. 原始检索分数加权 4. 长度惩罚(过短或过长的文档降分) """ def __init__( self, correct_threshold: float = 0.6, ambiguous_threshold: float = 0.35, keyword_weight: float = 0.3, coverage_weight: float = 0.3, retrieval_weight: float = 0.3, length_weight: float = 0.1, min_doc_length: int = 20, max_doc_length: int = 5000, ): self._correct_threshold = correct_threshold self._ambiguous_threshold = ambiguous_threshold self._keyword_weight = keyword_weight self._coverage_weight = coverage_weight self._retrieval_weight = retrieval_weight self._length_weight = length_weight self._min_doc_length = min_doc_length self._max_doc_length = max_doc_length def score_item(self, query: str, item: MemoryItem) -> RelevanceScore: """评估单个检索结果与查询的相关性""" doc_text = str(item.value) # 1. Keyword overlap (Jaccard similarity) query_terms = self._tokenize(query) doc_terms = self._tokenize(doc_text) keyword_score = self._jaccard_similarity(query_terms, doc_terms) # 2. Query term coverage coverage_score = self._query_coverage(query_terms, doc_terms) # 3. Original retrieval score retrieval_score = min(max(item.score, 0.0), 1.0) # 4. Length penalty length_score = self._length_score(len(doc_text)) # Weighted combination final_score = ( keyword_score * self._keyword_weight + coverage_score * self._coverage_weight + retrieval_score * self._retrieval_weight + length_score * self._length_weight ) # Determine verdict verdict = self._determine_verdict(final_score) reason = ( f"keyword={keyword_score:.2f}, coverage={coverage_score:.2f}, " f"retrieval={retrieval_score:.2f}, length={length_score:.2f}" ) return RelevanceScore( item=item, score=final_score, verdict=verdict, reason=reason, ) def evaluate( self, query: str, items: list[MemoryItem] ) -> RetrievalEvaluation: """评估一次检索的整体质量""" if not items: return RetrievalEvaluation( scores=[], overall_verdict=RelevanceVerdict.INCORRECT, avg_score=0.0, relevant_count=0, total_count=0, ) scores = [self.score_item(query, item) for item in items] relevant_count = sum( 1 for s in scores if s.verdict != RelevanceVerdict.INCORRECT ) avg_score = sum(s.score for s in scores) / len(scores) # Overall verdict based on average score and relevant ratio relevant_ratio = relevant_count / len(scores) if avg_score >= self._correct_threshold and relevant_ratio >= 0.5: overall_verdict = RelevanceVerdict.CORRECT elif avg_score >= self._ambiguous_threshold or relevant_ratio >= 0.3: overall_verdict = RelevanceVerdict.AMBIGUOUS else: overall_verdict = RelevanceVerdict.INCORRECT return RetrievalEvaluation( scores=scores, overall_verdict=overall_verdict, avg_score=avg_score, relevant_count=relevant_count, total_count=len(scores), ) def _determine_verdict(self, score: float) -> RelevanceVerdict: """根据分数判定相关性""" if score >= self._correct_threshold: return RelevanceVerdict.CORRECT elif score >= self._ambiguous_threshold: return RelevanceVerdict.AMBIGUOUS else: return RelevanceVerdict.INCORRECT @staticmethod def _tokenize(text: str) -> set[str]: """分词:中文按字符,英文按空格,统一小写""" tokens: set[str] = set() # Extract English words en_words = re.findall(r"[a-zA-Z]+", text.lower()) tokens.update(en_words) # Extract Chinese characters (individual chars + bigrams) cn_chars = re.findall(r"[\u4e00-\u9fff]", text) tokens.update(cn_chars) # Add Chinese bigrams for better matching for i in range(len(cn_chars) - 1): tokens.add(cn_chars[i] + cn_chars[i + 1]) return tokens @staticmethod def _jaccard_similarity(set_a: set[str], set_b: set[str]) -> float: """Jaccard 相似度""" if not set_a or not set_b: return 0.0 intersection = len(set_a & set_b) union = len(set_a | set_b) if union == 0: return 0.0 return intersection / union @staticmethod def _query_coverage(query_terms: set[str], doc_terms: set[str]) -> float: """查询词覆盖率:文档中出现的查询词比例""" if not query_terms: return 0.0 covered = len(query_terms & doc_terms) return covered / len(query_terms) def _length_score(self, length: int) -> float: """长度评分:过短或过长的文档降分""" if length < self._min_doc_length: # Too short — likely insufficient context ratio = length / self._min_doc_length return ratio * 0.5 elif length > self._max_doc_length: # Too long — may contain irrelevant information excess = (length - self._max_doc_length) / self._max_doc_length return max(0.3, 1.0 - excess * 0.5) else: # Good length range return 1.0