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

577 lines
19 KiB
Python
Raw Permalink 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.
"""
多维质量评估模块
对检索结果进行 4 维质量评估:
1. 相关性Relevance答案是否切题
2. 完整性Completeness信息是否充分
3. 准确性Accuracy是否有冲突信息
4. 覆盖率Coverage多角度覆盖
总阈值32/40 (80%)
使用方式:
from core.quality_assessor import QualityAssessor, assess_quality
assessor = QualityAssessor(llm_client)
result = assessor.assess(query, documents)
if result.total_score >= 32:
# 质量合格,继续生成
...
"""
from dataclasses import dataclass
from typing import List, Optional
from enum import Enum
from config import RAG_CHAT_MODEL
import logging
logger = logging.getLogger(__name__)
class QualityDimension(Enum):
"""质量评估维度"""
RELEVANCE = "relevance" # 相关性:答案是否切题
COMPLETENESS = "completeness" # 完整性:信息是否充分
ACCURACY = "accuracy" # 准确性:是否有冲突
COVERAGE = "coverage" # 覆盖率:多角度覆盖
@dataclass
class DimensionScore:
"""单个维度的评分"""
dimension: QualityDimension
score: int # 0-10 分
reason: str # 评分原因
issues: List[str] # 发现的问题
@dataclass
class QualityAssessment:
"""质量评估结果"""
relevance: DimensionScore
completeness: DimensionScore
accuracy: DimensionScore
coverage: DimensionScore
total_score: int # 总分0-40
is_sufficient: bool # 是否达标(>= 32
summary: str # 评估总结
recommendations: List[str] # 改进建议
class QualityAssessor:
"""
多维质量评估器
基于报告建议的 4 维评估框架,对检索结果进行全面质量检查。
"""
# 质量阈值(报告建议值)
QUALITY_THRESHOLD = 32 # 总分阈值40分制80%
DIMENSION_WEIGHTS = {
QualityDimension.RELEVANCE: 1.0,
QualityDimension.COMPLETENESS: 1.0,
QualityDimension.ACCURACY: 1.0,
QualityDimension.COVERAGE: 1.0,
}
def __init__(self, llm_client=None, model: str = None):
"""
初始化评估器
Args:
llm_client: LLM 客户端(用于语义评估)
model: 模型名称
"""
self.llm_client = llm_client
self.model = model or RAG_CHAT_MODEL
def assess(self, query: str, documents: List[str],
metadatas: List[dict] = None) -> QualityAssessment:
"""
评估检索结果质量
Args:
query: 用户查询
documents: 检索到的文档列表
metadatas: 文档元数据(可选)
Returns:
QualityAssessment: 质量评估结果
"""
if not documents:
return self._empty_assessment()
# 使用 LLM 进行语义评估
if self.llm_client:
return self._llm_assess(query, documents, metadatas)
else:
# 降级:基于规则的评估
return self._rule_based_assess(query, documents, metadatas)
def _llm_assess(self, query: str, documents: List[str],
metadatas: List[dict] = None) -> QualityAssessment:
"""
使用 LLM 进行语义质量评估
"""
# 构建文档摘要
doc_summary = self._summarize_documents(documents, metadatas)
prompt = f"""请对以下检索结果进行多维质量评估。
## 用户查询
{query}
## 检索到的文档摘要
{doc_summary}
## 评估要求
请从 4 个维度进行评分(每个维度 0-10 分):
1. **相关性Relevance**:文档内容是否直接回答用户问题?
- 10分完全相关直接回答
- 7-9分高度相关核心内容匹配
- 4-6分部分相关有侧面信息
- 0-3分几乎无关
2. **完整性Completeness**:信息是否充分完整?
- 10分信息完整可直接回答
- 7-9分基本完整缺少次要细节
- 4-6分部分完整缺少关键信息
- 0-3分信息严重不足
3. **准确性Accuracy**:文档之间是否有矛盾冲突?
- 10分信息一致无冲突
- 7-9分有轻微表述差异但不影响理解
- 4-6分有明显矛盾需要辨别
- 0-3分严重冲突信息不可靠
4. **覆盖率Coverage**:是否从多个角度/来源覆盖问题?
- 10分多来源、多角度全面覆盖
- 7-9分有多个相关来源
- 4-6分单一来源但有不同方面
- 0-3分覆盖角度单一
请以 JSON 格式返回评估结果:
```json
{{
"relevance": {{"score": 8, "reason": "原因", "issues": ["问题1"]}},
"completeness": {{"score": 7, "reason": "原因", "issues": []}},
"accuracy": {{"score": 9, "reason": "原因", "issues": []}},
"coverage": {{"score": 6, "reason": "原因", "issues": ["问题1"]}},
"summary": "整体评估总结",
"recommendations": ["建议1", "建议2"]
}}
```"""
try:
from core.llm_utils import call_llm
content = call_llm(
self.llm_client,
prompt,
self.model,
temperature=0.1,
max_tokens=800
)
if content is None:
return self._rule_based_assess(query, documents, metadatas)
return self._parse_llm_response(content)
except Exception as e:
logger.warning(f"LLM 质量评估失败: {e}")
return self._rule_based_assess(query, documents, metadatas)
def _parse_llm_response(self, content: str) -> QualityAssessment:
"""解析 LLM 返回的 JSON"""
import json
import re
# 提取 JSON 块
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', content)
if json_match:
json_str = json_match.group(1)
else:
# 尝试直接解析
json_str = content
try:
data = json.loads(json_str)
except json.JSONDecodeError:
# 解析失败,返回默认评估
return self._default_assessment()
# 构建维度评分
relevance = DimensionScore(
dimension=QualityDimension.RELEVANCE,
score=min(10, max(0, data.get("relevance", {}).get("score", 5))),
reason=data.get("relevance", {}).get("reason", ""),
issues=data.get("relevance", {}).get("issues", [])
)
completeness = DimensionScore(
dimension=QualityDimension.COMPLETENESS,
score=min(10, max(0, data.get("completeness", {}).get("score", 5))),
reason=data.get("completeness", {}).get("reason", ""),
issues=data.get("completeness", {}).get("issues", [])
)
accuracy = DimensionScore(
dimension=QualityDimension.ACCURACY,
score=min(10, max(0, data.get("accuracy", {}).get("score", 5))),
reason=data.get("accuracy", {}).get("reason", ""),
issues=data.get("accuracy", {}).get("issues", [])
)
coverage = DimensionScore(
dimension=QualityDimension.COVERAGE,
score=min(10, max(0, data.get("coverage", {}).get("score", 5))),
reason=data.get("coverage", {}).get("reason", ""),
issues=data.get("coverage", {}).get("issues", [])
)
total_score = relevance.score + completeness.score + accuracy.score + coverage.score
return QualityAssessment(
relevance=relevance,
completeness=completeness,
accuracy=accuracy,
coverage=coverage,
total_score=total_score,
is_sufficient=total_score >= self.QUALITY_THRESHOLD,
summary=data.get("summary", ""),
recommendations=data.get("recommendations", [])
)
def _rule_based_assess(self, query: str, documents: List[str],
metadatas: List[dict] = None) -> QualityAssessment:
"""
基于规则的质量评估(降级方案)
"""
# 相关性:基于关键词匹配
relevance_score = self._assess_relevance(query, documents)
# 完整性:基于文档长度和数量
completeness_score = self._assess_completeness(documents)
# 准确性:假设一致(降级方案无法检测冲突)
accuracy_score = 8 # 默认较高分数
# 覆盖率:基于来源多样性
coverage_score = self._assess_coverage(documents, metadatas)
total_score = relevance_score + completeness_score + accuracy_score + coverage_score
return QualityAssessment(
relevance=DimensionScore(
dimension=QualityDimension.RELEVANCE,
score=relevance_score,
reason=f"关键词匹配评估(规则降级)",
issues=[]
),
completeness=DimensionScore(
dimension=QualityDimension.COMPLETENESS,
score=completeness_score,
reason=f"基于文档长度和数量评估(规则降级)",
issues=[]
),
accuracy=DimensionScore(
dimension=QualityDimension.ACCURACY,
score=accuracy_score,
reason="降级方案:假设信息一致",
issues=[]
),
coverage=DimensionScore(
dimension=QualityDimension.COVERAGE,
score=coverage_score,
reason=f"基于来源多样性评估(规则降级)",
issues=[]
),
total_score=total_score,
is_sufficient=total_score >= self.QUALITY_THRESHOLD,
summary="基于规则的质量评估LLM 不可用)",
recommendations=["建议启用 LLM 进行更精确的语义评估"]
)
def _assess_relevance(self, query: str, documents: List[str]) -> int:
"""评估相关性(关键词匹配)"""
try:
import jieba
# 提取查询关键词
query_words = set()
for word in jieba.cut(query):
word = word.strip()
if len(word) >= 2:
query_words.add(word.lower())
if not query_words:
return 5
# 计算每个文档的关键词覆盖率
coverages = []
for doc in documents:
doc_lower = doc.lower()
matched = sum(1 for word in query_words if word in doc_lower)
coverages.append(matched / len(query_words))
avg_coverage = sum(coverages) / len(coverages) if coverages else 0
# 映射到 0-10 分
if avg_coverage >= 0.8:
return 9
elif avg_coverage >= 0.6:
return 7
elif avg_coverage >= 0.4:
return 5
elif avg_coverage >= 0.2:
return 3
else:
return 1
except ImportError:
return 5
def _assess_completeness(self, documents: List[str]) -> int:
"""评估完整性(基于文档长度和数量)"""
if not documents:
return 0
# 文档数量评估
doc_count_score = min(3, len(documents)) # 最多 3 分
# 文档长度评估
total_length = sum(len(doc) for doc in documents)
if total_length >= 2000:
length_score = 7
elif total_length >= 1000:
length_score = 5
elif total_length >= 500:
length_score = 3
else:
length_score = 1
return min(10, doc_count_score + length_score)
def _assess_coverage(self, documents: List[str],
metadatas: List[dict] = None) -> int:
"""评估覆盖率(来源多样性)"""
if not metadatas:
# 无元数据,基于文档内容差异度
if len(documents) >= 3:
return 7
elif len(documents) >= 2:
return 5
else:
return 3
# 统计来源多样性
sources = set()
for meta in metadatas:
if isinstance(meta, dict):
source = meta.get("source", "")
if source:
sources.add(source)
source_count = len(sources)
if source_count >= 3:
return 9
elif source_count >= 2:
return 7
elif source_count == 1:
return 5
else:
return 3
def _summarize_documents(self, documents: List[str],
metadatas: List[dict] = None) -> str:
"""生成文档摘要用于 LLM 评估"""
summary_parts = []
for i, doc in enumerate(documents[:5], 1): # 最多 5 个文档
source = ""
if metadatas and i <= len(metadatas):
meta = metadatas[i - 1]
if isinstance(meta, dict):
source = meta.get("source", "未知来源")
page = meta.get("page", "")
if page:
source += f" (第{page}页)"
content = doc[:300] + "..." if len(doc) > 300 else doc
summary_parts.append(f"### 文档 {i} ({source})\n{content}")
return "\n\n".join(summary_parts)
def _empty_assessment(self) -> QualityAssessment:
"""空评估结果"""
return QualityAssessment(
relevance=DimensionScore(
dimension=QualityDimension.RELEVANCE,
score=0,
reason="无检索结果",
issues=[]
),
completeness=DimensionScore(
dimension=QualityDimension.COMPLETENESS,
score=0,
reason="无检索结果",
issues=[]
),
accuracy=DimensionScore(
dimension=QualityDimension.ACCURACY,
score=0,
reason="无检索结果",
issues=[]
),
coverage=DimensionScore(
dimension=QualityDimension.COVERAGE,
score=0,
reason="无检索结果",
issues=[]
),
total_score=0,
is_sufficient=False,
summary="无检索结果可供评估",
recommendations=["请尝试其他查询方式"]
)
def _default_assessment(self) -> QualityAssessment:
"""默认评估结果(解析失败时)"""
return QualityAssessment(
relevance=DimensionScore(
dimension=QualityDimension.RELEVANCE,
score=5,
reason="评估解析失败,使用默认分数",
issues=[]
),
completeness=DimensionScore(
dimension=QualityDimension.COMPLETENESS,
score=5,
reason="评估解析失败,使用默认分数",
issues=[]
),
accuracy=DimensionScore(
dimension=QualityDimension.ACCURACY,
score=5,
reason="评估解析失败,使用默认分数",
issues=[]
),
coverage=DimensionScore(
dimension=QualityDimension.COVERAGE,
score=5,
reason="评估解析失败,使用默认分数",
issues=[]
),
total_score=20,
is_sufficient=False,
summary="LLM 评估解析失败",
recommendations=["请检查 LLM 响应格式"]
)
def get_threshold_info(self) -> dict:
"""获取阈值信息"""
return {
"quality_threshold": self.QUALITY_THRESHOLD,
"max_score": 40,
"pass_percentage": f"{self.QUALITY_THRESHOLD / 40 * 100}%",
"dimensions": ["relevance", "completeness", "accuracy", "coverage"]
}
def create_assessor() -> QualityAssessor:
"""
创建质量评估器实例
自动从配置获取 LLM 客户端。
Returns:
QualityAssessor: 质量评估器实例
"""
try:
from openai import OpenAI
try:
from config import API_KEY, BASE_URL, RAG_CHAT_MODEL
MODEL = RAG_CHAT_MODEL
except ImportError:
from config import API_KEY, BASE_URL
MODEL = "qwen3.5-flash" # fallback
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
return QualityAssessor(llm_client=client, model=MODEL)
except Exception as e:
logger.warning(f"创建质量评估器失败,使用降级模式: {e}")
return QualityAssessor()
def assess_quality(query: str, documents: List[str],
metadatas: List[dict] = None) -> QualityAssessment:
"""
便捷函数:评估检索结果质量
Args:
query: 用户查询
documents: 检索到的文档列表
metadatas: 文档元数据(可选)
Returns:
QualityAssessment: 质量评估结果
"""
assessor = create_assessor()
return assessor.assess(query, documents, metadatas)
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("多维质量评估测试")
print("=" * 60)
# 测试用例
test_cases = [
{
"query": "公司报销制度是怎样的?",
"documents": [
"公司报销制度规定员工可以报销差旅费用,需提供发票和审批单。报销流程:提交申请 -> 部门审批 -> 财务审核 -> 打款。",
"差旅报销标准高铁一等座、飞机经济舱、住宿每天500元内。超过标准需特批。",
"报销时限费用发生后30天内提交逾期不予受理。"
],
"description": "高质量检索结果"
},
{
"query": "量子计算的基本原理",
"documents": [
"文档中提到了一些技术细节...",
"另一个不相关的内容..."
],
"description": "低质量检索结果"
}
]
assessor = QualityAssessor() # 不使用 LLM 的规则评估
print(f"\n阈值配置: {assessor.get_threshold_info()}")
print()
for i, case in enumerate(test_cases, 1):
print(f"测试 {i}: {case['description']}")
print(f"查询: {case['query']}")
print(f"文档数: {len(case['documents'])}")
result = assessor.assess(case['query'], case['documents'])
print(f"\n评分结果:")
print(f" 相关性: {result.relevance.score}/10 - {result.relevance.reason}")
print(f" 完整性: {result.completeness.score}/10 - {result.completeness.reason}")
print(f" 准确性: {result.accuracy.score}/10 - {result.accuracy.reason}")
print(f" 覆盖率: {result.coverage.score}/10 - {result.coverage.reason}")
print(f"\n 总分: {result.total_score}/40")
print(f" 达标: {'✅ 是' if result.is_sufficient else '❌ 否'} (阈值: 32)")
print(f" 总结: {result.summary}")
print()