init: RAG 知识库服务初始提交

- 后端 API(Flask + Gunicorn)
- RAG 引擎(混合检索 + 云端 Reranker + 引用溯源)
- 文档解析(MinerU + 多格式支持)
- Docker 生产部署配置
- 排除前端项目、敏感配置、模型文件
This commit is contained in:
lacerate551
2026-06-04 17:35:27 +08:00
commit 100d1a06eb
158 changed files with 64534 additions and 0 deletions

View File

@@ -0,0 +1,354 @@
# -*- coding: utf-8 -*-
"""
RAG 系统测试脚本
自动执行 40 个测试问题并记录结果
"""
import sys
import os
import json
import time
from datetime import datetime
# 添加项目根目录到路径
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
# 测试问题列表
TEST_QUESTIONS = [
# 一、精确匹配测试10题
{"id": 1, "category": "精确匹配", "question": "智启科技成立于哪一年?", "expected": "2015年3月", "source": "公司简介.txt / 员工手册.txt"},
{"id": 2, "category": "精确匹配", "question": "公司的客服热线是多少?", "expected": "400-888-8888", "source": "公司简介.txt"},
{"id": 3, "category": "精确匹配", "question": "公司总部位于哪里?", "expected": "北京市海淀区中关村大街1号科技大厦15层", "source": "公司简介.txt"},
{"id": 4, "category": "精确匹配", "question": "ZDAP平台标准版支持多少并发用户", "expected": "50人", "source": "产品手册.txt"},
{"id": 5, "category": "精确匹配", "question": "企业版单表最大支持多少行数据?", "expected": "1亿行", "source": "产品手册.txt"},
{"id": 6, "category": "精确匹配", "question": "年假满10年不满20年可以休多少天", "expected": "10天", "source": "员工手册.txt / 常见问题.txt"},
{"id": 7, "category": "精确匹配", "question": "产假可以休多少天?", "expected": "158天", "source": "员工手册.txt / 常见问题.txt"},
{"id": 8, "category": "精确匹配", "question": "技术研发中心的负责人是谁?", "expected": "张明远(技术总监)", "source": "公司简介.txt / 组织架构说明.txt"},
{"id": 9, "category": "精确匹配", "question": "市场营销部有多少人?", "expected": "120人", "source": "公司简介.txt / 组织架构说明.txt"},
{"id": 10, "category": "精确匹配", "question": "上海分公司的负责人是谁?", "expected": "华东区总经理 张华", "source": "组织架构说明.txt"},
# 二、语义理解测试6题
{"id": 11, "category": "语义理解", "question": "公司的愿景是什么?", "expected": "成为全球领先的智能数据服务提供商", "source": "公司简介.txt / 员工手册.txt"},
{"id": 12, "category": "语义理解", "question": "ZDAP的智能预警功能有哪些通知方式", "expected": "邮件、短信、企业微信、钉钉", "source": "产品手册.txt"},
{"id": 13, "category": "语义理解", "question": "什么是直连数据集?", "expected": "直接查询源数据库,实时性强", "source": "产品手册.txt"},
{"id": 14, "category": "语义理解", "question": "入职当天需要做什么?", "expected": "签订劳动合同、领取工牌、开通账号、参观、培训等", "source": "常见问题.txt"},
{"id": 15, "category": "语义理解", "question": "请假4天需要谁审批", "expected": "部门负责人 + 人力资源部审批", "source": "常见问题.txt"},
{"id": 16, "category": "语义理解", "question": "如何申请外部培训?", "expected": "OA系统提交申请→填写信息→部门负责人审批→人力资源部审批→超5000元签服务协议", "source": "常见问题.txt"},
# 三、跨文档关联测试4题
{"id": 17, "category": "跨文档关联", "question": "公司有哪些分公司,分别在哪些城市?", "expected": "上海、深圳、成都、武汉四家分公司", "source": "公司简介.txt + 组织架构说明.txt"},
{"id": 18, "category": "跨文档关联", "question": "技术研发中心下设哪些团队,各自的职责是什么?", "expected": "AI算法组、数据工程组、平台开发组、前端开发组、测试组", "source": "公司简介.txt + 组织架构说明.txt"},
{"id": 19, "category": "跨文档关联", "question": "公司的薪酬由哪些部分组成?绩效奖金的范围是多少?", "expected": "基本工资+绩效奖金+年终奖金+津贴补贴绩效奖金0-30%基本工资", "source": "员工手册.txt + 常见问题.txt"},
{"id": 20, "category": "跨文档关联", "question": "病假工资怎么算?不同工龄有什么区别?", "expected": "按工龄60%-100%发放", "source": "常见问题.txt"},
# 四、复杂推理测试3题
{"id": 21, "category": "复杂推理", "question": "一个入职3年的员工累计病假1个月能拿到多少病假工资", "expected": "工龄2-4年按基本工资70%发放", "source": "常见问题.txt"},
{"id": 22, "category": "复杂推理", "question": "如果我绩效考核连续两个月是D级会怎样", "expected": "进入绩效改进期(PIP)1-3个月改进期仍不达标可调岗或解除合同", "source": "常见问题.txt"},
{"id": 23, "category": "复杂推理", "question": "ZDAP企业版的简单查询响应时间要求是多少", "expected": "<1秒", "source": "产品手册.txt"},
# 五、表格数据测试3题
{"id": 24, "category": "表格数据", "question": "P4级工程师的年薪范围是多少", "expected": "25-35万元", "source": "组织架构说明.txt"},
{"id": 25, "category": "表格数据", "question": "M3级经理对应的职称是什么", "expected": "高级经理", "source": "组织架构说明.txt"},
{"id": 26, "category": "表格数据", "question": "数据工程组有多少人?负责人是谁?", "expected": "80人负责人王工", "source": "组织架构说明.txt"},
# 六、否定性测试3题
{"id": 27, "category": "否定性测试", "question": "公司有员工宿舍吗?", "expected": "没有员工宿舍但为外地新员工提供15天免费过渡住宿", "source": "常见问题.txt"},
{"id": 28, "category": "否定性测试", "question": "公司的股票代码是什么?", "expected": "文档中未提及", "source": ""},
{"id": 29, "category": "否定性测试", "question": "公司有食堂吗?", "expected": "没有食堂,但提供早餐和午餐", "source": "常见问题.txt"},
# 七、关键词检索测试3题
{"id": 30, "category": "关键词检索", "question": "五险一金缴纳比例是多少?", "expected": "养老保险个人8%公司16%医疗保险个人2%公司10%", "source": "常见问题.txt"},
{"id": 31, "category": "关键词检索", "question": "Kong Gateway在系统架构中的作用是什么", "expected": "API网关层JWT认证、限流熔断", "source": "产品手册.txt"},
{"id": 32, "category": "关键词检索", "question": "ClickHouse用于什么用途", "expected": "分析引擎", "source": "产品手册.txt"},
# 八、长文档/分块测试2题
{"id": 33, "category": "长文档测试", "question": "三峡工程2024年发电量是多少", "expected": "需从三峡公报PDF中检索", "source": "三峡公报_*.pdf"},
{"id": 34, "category": "长文档测试", "question": "三峡水库的水位调节范围是多少?", "expected": "需从三峡公报PDF中检索", "source": "三峡公报_*.pdf"},
# 九、学术论文测试2题
{"id": 35, "category": "学术论文", "question": "这篇论文的主要贡献是什么?", "expected": "需从论文PDF中提取", "source": "2604.09205v1.pdf"},
{"id": 36, "category": "学术论文", "question": "论文使用了什么方法或模型?", "expected": "需从论文PDF中提取", "source": "2604.09205v1.pdf"},
# 十、边缘案例测试4题
{"id": 37, "category": "边缘案例", "question": "如果我要报销5000元以内的费用需要谁审批", "expected": "主管审批,备案即可", "source": "组织架构说明.txt"},
{"id": 38, "category": "边缘案例", "question": "公司的核心工作时间是什么时候?", "expected": "10:00-16:00必须到岗", "source": "常见问题.txt"},
{"id": 39, "category": "边缘案例", "question": "ZDAP支持哪些图表类型", "expected": "柱状图、折线图、饼图、散点图、热力图、地图、雷达图、漏斗图", "source": "产品手册.txt"},
{"id": 40, "category": "边缘案例", "question": "技术支持热线的服务时间是怎样的?", "expected": "工作日9:00-21:00周末及节假日10:00-18:00", "source": "产品手册.txt"},
]
def call_rag_api(question: str, kb_name: str = "public", top_k: int = 5) -> dict:
"""
调用 RAG API 进行问答
Args:
question: 问题内容
kb_name: 知识库名称
top_k: 返回结果数量
Returns:
包含答案和检索上下文的字典
"""
import requests
url = "http://localhost:5001/rag"
headers = {
"Content-Type": "application/json",
"Authorization": "Bearer mock-token-admin"
}
data = {
"message": question # 使用 message 字段
}
try:
response = requests.post(url, json=data, headers=headers, timeout=120)
response.raise_for_status()
result = response.json()
return {
"answer": result.get("answer"),
"sources": result.get("sources", []),
"contexts": [] # /rag 接口不返回 contexts但有 sources
}
except requests.exceptions.RequestException as e:
return {"error": str(e), "answer": None, "sources": [], "contexts": []}
def evaluate_answer(actual: str, expected: str) -> dict:
"""
评估答案准确性
Args:
actual: 实际答案
expected: 预期答案
Returns:
评估结果字典
"""
if not actual:
return {"score": 0, "grade": "不合格", "reason": "未获取到答案", "matched_keywords": []}
actual_lower = actual.lower()
expected_lower = expected.lower()
# 使用 jieba 分词(中文友好)
try:
import jieba
expected_keywords = set(jieba.cut(expected_lower))
actual_keywords = set(jieba.cut(actual_lower))
except ImportError:
# jieba 未安装时的降级方案
expected_keywords = set(expected_lower.replace("", " ").replace("", " ").replace("", " ").split())
actual_keywords = set(actual_lower.replace("", " ").replace("", " ").replace("", " ").split())
# 过滤停用词和短词
stop_words = {"", "", "", "", "", "", "", "", "", "", "", "", "", "可以", "需要", "应该"}
expected_keywords = {w for w in expected_keywords if len(w) >= 2 and w not in stop_words}
actual_keywords = {w for w in actual_keywords if len(w) >= 2 and w not in stop_words}
matched_keywords = expected_keywords & actual_keywords
if len(expected_keywords) == 0:
keyword_score = 50
else:
keyword_score = int(len(matched_keywords) / len(expected_keywords) * 100)
# 完全匹配加分
if expected_lower in actual_lower:
keyword_score = min(100, keyword_score + 20)
# 评分等级
if keyword_score >= 80:
grade = "优秀"
elif keyword_score >= 60:
grade = "良好"
elif keyword_score >= 40:
grade = "合格"
else:
grade = "不合格"
return {
"score": keyword_score,
"grade": grade,
"matched_keywords": list(matched_keywords),
"reason": f"关键词匹配度 {keyword_score}%"
}
def run_tests():
"""执行所有测试"""
print("=" * 80)
print("RAG 系统测试开始")
print(f"测试时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"测试问题数量: {len(TEST_QUESTIONS)}")
print("=" * 80)
print()
results = []
for i, test in enumerate(TEST_QUESTIONS):
print(f"[{i+1}/{len(TEST_QUESTIONS)}] 测试类别: {test['category']}")
print(f"问题: {test['question']}")
# 调用 API
start_time = time.time()
response = call_rag_api(test['question'])
elapsed_time = time.time() - start_time
# 提取答案和上下文
answer = response.get("answer", "")
sources = response.get("sources", [])
# 评估答案
evaluation = evaluate_answer(answer, test['expected'])
result = {
"id": test['id'],
"category": test['category'],
"question": test['question'],
"expected": test['expected'],
"expected_source": test['source'],
"actual_answer": answer,
"actual_sources": sources,
"response_time": round(elapsed_time, 2),
"evaluation": evaluation
}
results.append(result)
# 打印结果
print(f"预期答案: {test['expected']}")
# 过滤 emoji 和特殊字符,避免编码问题
import re
def sanitize_text(text):
if not text:
return "无答案"
# 移除 emoji 和特殊 Unicode 字符
text = re.sub(r'[\U00010000-\U0010ffff]', '', text)
return text[:200] + "..." if len(text) > 200 else text
answer_display = sanitize_text(answer)
print(f"实际答案: {answer_display}")
print(f"检索来源: {sources[:3]}..." if len(sources) > 3 else f"检索来源: {sources}")
print(f"评分: {evaluation['score']} ({evaluation['grade']}) - {evaluation['reason']}")
print(f"响应时间: {elapsed_time:.2f}s")
print("-" * 80)
return results
def generate_report(results: list) -> str:
"""生成测试报告"""
# 统计各类别得分
category_stats = {}
for r in results:
cat = r['category']
if cat not in category_stats:
category_stats[cat] = {"total": 0, "scores": [], "times": []}
category_stats[cat]['total'] += 1
category_stats[cat]['scores'].append(r['evaluation']['score'])
category_stats[cat]['times'].append(r['response_time'])
# 计算总体统计
all_scores = [r['evaluation']['score'] for r in results]
all_times = [r['response_time'] for r in results]
# 生成报告
report = []
report.append("# RAG 系统测试报告")
report.append("")
report.append(f"**测试时间**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
report.append(f"**测试问题数量**: {len(results)}")
report.append("")
# 总体统计
report.append("## 一、总体统计")
report.append("")
report.append(f"- **平均得分**: {sum(all_scores)/len(all_scores):.1f}")
report.append(f"- **最高得分**: {max(all_scores)}")
report.append(f"- **最低得分**: {min(all_scores)}")
report.append(f"- **平均响应时间**: {sum(all_times)/len(all_times):.2f}")
report.append(f"- **优秀率 (≥80分)**: {len([s for s in all_scores if s >= 80])/len(all_scores)*100:.1f}%")
report.append(f"- **合格率 (≥40分)**: {len([s for s in all_scores if s >= 40])/len(all_scores)*100:.1f}%")
report.append("")
# 分类统计
report.append("## 二、分类统计")
report.append("")
report.append("| 测试类别 | 题数 | 平均分 | 最高分 | 最低分 | 平均响应时间 |")
report.append("|----------|------|--------|--------|--------|--------------|")
for cat, stats in category_stats.items():
avg_score = sum(stats['scores']) / len(stats['scores'])
avg_time = sum(stats['times']) / len(stats['times'])
report.append(f"| {cat} | {stats['total']} | {avg_score:.1f} | {max(stats['scores'])} | {min(stats['scores'])} | {avg_time:.2f}s |")
report.append("")
# 详细结果
report.append("## 三、详细测试结果")
report.append("")
for r in results:
report.append(f"### Q{r['id']}: {r['question']}")
report.append("")
report.append(f"- **测试类别**: {r['category']}")
report.append(f"- **预期答案**: {r['expected']}")
report.append(f"- **预期来源**: {r['expected_source']}")
report.append(f"- **实际答案**: {r['actual_answer']}")
# 处理 sources 格式(可能是 dict 列表或 str 列表)
sources = r['actual_sources']
if sources and isinstance(sources[0], dict):
sources_str = ', '.join([s.get('source', str(s)) for s in sources[:5]])
else:
sources_str = ', '.join([str(s) for s in sources[:5]]) if sources else ''
report.append(f"- **检索来源**: {sources_str}")
report.append(f"- **评分**: {r['evaluation']['score']} ({r['evaluation']['grade']})")
report.append(f"- **响应时间**: {r['response_time']}s")
report.append("")
# 问题与建议
report.append("## 四、问题分析")
report.append("")
# 低分问题
low_score_questions = [r for r in results if r['evaluation']['score'] < 40]
if low_score_questions:
report.append("### 低分问题 (< 40分)")
report.append("")
for r in low_score_questions:
report.append(f"- Q{r['id']}: {r['question']} (得分: {r['evaluation']['score']})")
report.append("")
return "\n".join(report)
def main():
"""主函数"""
# Windows 控制台编码处理
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
sys.stderr.reconfigure(encoding='utf-8', errors='replace')
# 执行测试
results = run_tests()
# 生成报告
report = generate_report(results)
# 保存报告
report_file = "rag_test_report.md"
with open(report_file, "w", encoding="utf-8") as f:
f.write(report)
print("\n" + "=" * 80)
print("测试完成!")
print(f"报告已保存到: {report_file}")
print("=" * 80)
# 保存详细结果 JSON
json_file = "rag_test_results.json"
with open(json_file, "w", encoding="utf-8") as f:
json.dump(results, f, ensure_ascii=False, indent=2)
print(f"详细结果已保存到: {json_file}")
if __name__ == "__main__":
main()