Compare commits
7 Commits
858ee40e5b
...
server-bas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
279e2bf47c | ||
|
|
acb84b804d | ||
|
|
0b2ef8c161 | ||
|
|
8e3e9832ff | ||
|
|
43261e9aff | ||
|
|
a340eaaeee | ||
|
|
8af8d38c01 |
@@ -132,10 +132,6 @@ def create_app() -> 'Flask':
|
||||
from api.image_routes import image_bp
|
||||
app.register_blueprint(image_bp)
|
||||
|
||||
# 异步任务查询
|
||||
from api.task_routes import task_bp
|
||||
app.register_blueprint(task_bp)
|
||||
|
||||
# 健康检查
|
||||
from api.auth_routes import auth_bp
|
||||
app.register_blueprint(auth_bp)
|
||||
@@ -258,5 +254,4 @@ def _print_startup_info(app: 'Flask') -> None:
|
||||
logger.info(" 切片管理: /chunks/*")
|
||||
logger.info(" 同步服务: /sync, /sync/status")
|
||||
logger.info(" 图片服务: /images/*")
|
||||
logger.info(" 任务查询: /tasks, /tasks/<id>, /tasks/<id>/progress")
|
||||
logger.info(" 健康检查: /health")
|
||||
|
||||
@@ -11,8 +11,6 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
from auth.gateway import require_gateway_auth
|
||||
from data.db import get_connection
|
||||
from core.status_codes import SUCCESS, INTERNAL_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
audit_bp = Blueprint('audit', __name__)
|
||||
|
||||
@@ -96,11 +94,11 @@ def get_audit_logs():
|
||||
"timestamp": row[10]
|
||||
})
|
||||
|
||||
return success_response(data={"logs": logs, "total": total})
|
||||
return jsonify({"logs": logs, "total": total})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"审计查询异常: {e}")
|
||||
return error_response("QUERY_FAILED", INTERNAL_ERROR, "查询失败", http_status=500)
|
||||
return jsonify({"error": "查询失败", "logs": [], "total": 0}), 500
|
||||
|
||||
|
||||
def log_audit_event(user_id: str, username: str, action: str,
|
||||
|
||||
@@ -10,9 +10,7 @@
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from auth.gateway import require_gateway_auth, require_role, get_user_permissions, MOCK_USERS
|
||||
from core.status_codes import SUCCESS, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, PERMISSION_DENIED, INTERNAL_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
from config import DEV_MODE
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
@@ -51,8 +49,8 @@ def _check_rate_limit(client_ip: str) -> bool:
|
||||
|
||||
|
||||
def _is_dev_mode() -> bool:
|
||||
"""统一的开发模式判断(由 config.py 集中管理)"""
|
||||
return DEV_MODE
|
||||
"""统一的开发模式判断"""
|
||||
return os.environ.get('DEV_MODE', 'true').lower() != 'false'
|
||||
|
||||
|
||||
@auth_bp.route('/auth/login', methods=['POST'])
|
||||
@@ -85,16 +83,12 @@ def mock_login():
|
||||
- user / test123 (普通用户,技术部)
|
||||
"""
|
||||
if not _is_dev_mode():
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用,请在 .env 中设置 DEV_MODE=true", http_status=403)
|
||||
return jsonify({"error": "仅开发环境可用,请设置 DEV_MODE=true"}), 403
|
||||
|
||||
# 速率限制检查
|
||||
client_ip = request.remote_addr or 'unknown'
|
||||
if not _check_rate_limit(client_ip):
|
||||
return error_response(
|
||||
"RATE_LIMITED", FORBIDDEN,
|
||||
f"登录尝试过于频繁,请 {_RATE_LIMIT_WINDOW // 60} 分钟后再试",
|
||||
http_status=429
|
||||
)
|
||||
return jsonify({"error": f"登录尝试过于频繁,请 {_RATE_LIMIT_WINDOW // 60} 分钟后再试"}), 429
|
||||
|
||||
data = request.json or {}
|
||||
username = data.get('username')
|
||||
@@ -102,9 +96,9 @@ def mock_login():
|
||||
|
||||
user = MOCK_USERS.get(username)
|
||||
if not user or user['password'] != password:
|
||||
return error_response("UNAUTHORIZED", UNAUTHORIZED, "用户名或密码错误", http_status=401)
|
||||
return jsonify({"error": "用户名或密码错误"}), 401
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"token": f"mock-token-{username}",
|
||||
"user": {
|
||||
"user_id": user['user_id'],
|
||||
@@ -123,8 +117,8 @@ def get_stats():
|
||||
from flask import current_app
|
||||
session_manager = current_app.config.get('SESSION_MANAGER')
|
||||
if not session_manager:
|
||||
return error_response("UNAVAILABLE", INTERNAL_ERROR, "会话管理器未启用", http_status=503)
|
||||
return success_response(data=session_manager.get_stats())
|
||||
return jsonify({"error": "会话管理器未启用"}), 503
|
||||
return jsonify(session_manager.get_stats())
|
||||
|
||||
|
||||
@auth_bp.route('/health', methods=['GET'])
|
||||
@@ -147,7 +141,7 @@ def get_current_user():
|
||||
开发模式下支持模拟用户,生产模式下用户信息由后端控制。
|
||||
"""
|
||||
user = request.current_user
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"user_id": user["user_id"],
|
||||
"username": user["username"],
|
||||
"role": user["role"],
|
||||
@@ -176,7 +170,7 @@ def get_users():
|
||||
}
|
||||
"""
|
||||
if not _is_dev_mode():
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用", http_status=403)
|
||||
return jsonify({"error": "仅开发环境可用"}), 403
|
||||
|
||||
users = []
|
||||
for username, info in MOCK_USERS.items():
|
||||
@@ -188,7 +182,7 @@ def get_users():
|
||||
"is_active": True # 模拟用户默认都是活跃状态
|
||||
})
|
||||
|
||||
return success_response(data={"users": users})
|
||||
return jsonify({"users": users})
|
||||
|
||||
|
||||
@auth_bp.route('/auth/users/<user_id>', methods=['PUT'])
|
||||
@@ -203,7 +197,7 @@ def update_user(user_id):
|
||||
}
|
||||
"""
|
||||
if not _is_dev_mode():
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用", http_status=403)
|
||||
return jsonify({"error": "仅开发环境可用"}), 403
|
||||
|
||||
# 验证目标用户是否存在
|
||||
target_user = None
|
||||
@@ -213,11 +207,11 @@ def update_user(user_id):
|
||||
break
|
||||
|
||||
if not target_user:
|
||||
return error_response("NOT_FOUND", BAD_REQUEST, f"用户 {user_id} 不存在", http_status=404)
|
||||
return jsonify({"error": f"用户 {user_id} 不存在"}), 404
|
||||
|
||||
data = request.json or {}
|
||||
# 模拟操作:记录请求但不实际执行(mock 用户数据是静态的)
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"message": "操作成功(模拟)",
|
||||
"user_id": user_id,
|
||||
"applied_changes": data
|
||||
@@ -237,24 +231,24 @@ def change_password():
|
||||
}
|
||||
"""
|
||||
if not _is_dev_mode():
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用", http_status=403)
|
||||
return jsonify({"error": "仅开发环境可用"}), 403
|
||||
|
||||
data = request.json or {}
|
||||
old_password = data.get('old_password')
|
||||
new_password = data.get('new_password')
|
||||
|
||||
if not old_password or not new_password:
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "请提供旧密码和新密码", http_status=400)
|
||||
return jsonify({"error": "请提供旧密码和新密码"}), 400
|
||||
|
||||
if len(new_password) < 6:
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, "新密码至少6位", http_status=400)
|
||||
return jsonify({"error": "新密码至少6位"}), 400
|
||||
|
||||
# 验证当前用户的旧密码
|
||||
user = request.current_user
|
||||
username = user.get('username', '')
|
||||
mock_user = MOCK_USERS.get(username)
|
||||
if mock_user and mock_user['password'] != old_password:
|
||||
return error_response("UNAUTHORIZED", UNAUTHORIZED, "旧密码错误", http_status=401)
|
||||
return jsonify({"error": "旧密码错误"}), 401
|
||||
|
||||
# 模拟环境返回成功(不实际修改密码,mock 数据是静态的)
|
||||
return success_response(message="密码修改成功(模拟)")
|
||||
return jsonify({"message": "密码修改成功(模拟)"})
|
||||
|
||||
@@ -37,8 +37,6 @@ import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from auth.gateway import require_gateway_auth
|
||||
from core.status_codes import SUCCESS, BAD_REQUEST, NOT_FOUND, INTERNAL_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
from auth.security import validate_query, filter_response
|
||||
from config import RAG_CHAT_MODEL
|
||||
@@ -493,305 +491,6 @@ def _section_similarity(section_a: str, section_b: str) -> float:
|
||||
return overlap / union if union > 0 else 0.0
|
||||
|
||||
|
||||
def _rescue_bm25_divergence(contexts: List[Dict], search_result: dict,
|
||||
min_score: float) -> List[Dict]:
|
||||
"""
|
||||
BM25-CrossEncoder 分歧检测救援:当 BM25 排名靠前(top-3)的切片
|
||||
被 CrossEncoder rerank 压制(分数低于 min_score 或被截断)时,
|
||||
将其分数提升至保底值,使其通过后续的 min_score 过滤。
|
||||
|
||||
适用场景:
|
||||
- BM25 top-1/top-2 的切片(精确关键词匹配强信号)被 rerank 评分极低
|
||||
- 正确切片在 rerank 后被截断,根本不在 contexts 中
|
||||
|
||||
Args:
|
||||
contexts: 全部上下文切片
|
||||
search_result: engine.search_hybrid() 返回的原始结果(含 _bm25_top3)
|
||||
min_score: 最低分数阈值
|
||||
|
||||
Returns:
|
||||
修改后的 contexts
|
||||
"""
|
||||
if not contexts:
|
||||
return contexts
|
||||
|
||||
from config import (
|
||||
BM25_DIVERGENCE_RESCUE_ENABLED, BM25_DIVERGENCE_MAX_RANK,
|
||||
CLUSTER_RESCUE_FLOOR,
|
||||
)
|
||||
|
||||
if not BM25_DIVERGENCE_RESCUE_ENABLED:
|
||||
return contexts
|
||||
|
||||
bm25_top3 = search_result.get('_bm25_top3', [])
|
||||
if not bm25_top3:
|
||||
return contexts
|
||||
|
||||
# 构建 contexts 中已有切片的 ID 索引,用于快速查找
|
||||
existing_ids = {}
|
||||
for i, ctx in enumerate(contexts):
|
||||
chunk_id = ctx.get('meta', {}).get('chunk_id') or ctx.get('id')
|
||||
if chunk_id:
|
||||
existing_ids[chunk_id] = i
|
||||
|
||||
# 计算 contexts 中的多数 source(用于情况 B 注入校验)
|
||||
# 防止跨文档注入无关切片(如 q013 场景:2.docx 的吸烟场所切片被注入到 1.docx 的查询中)
|
||||
source_counter: Dict[str, int] = {}
|
||||
for ctx in contexts:
|
||||
src = ctx.get('meta', {}).get('source', '')
|
||||
if src:
|
||||
source_counter[src] = source_counter.get(src, 0) + 1
|
||||
majority_source = max(source_counter, key=source_counter.get) if source_counter else None
|
||||
|
||||
rescued_count = 0
|
||||
|
||||
for bm25_item in bm25_top3:
|
||||
rank = bm25_item.get('rank', 99)
|
||||
if rank > BM25_DIVERGENCE_MAX_RANK:
|
||||
continue
|
||||
|
||||
bm25_id = bm25_item.get('id')
|
||||
bm25_meta = bm25_item.get('meta', {})
|
||||
bm25_doc = bm25_item.get('doc', '')
|
||||
|
||||
# 情况 A:切片在 contexts 中但 score < min_score
|
||||
if bm25_id and bm25_id in existing_ids:
|
||||
ctx = contexts[existing_ids[bm25_id]]
|
||||
if ctx.get('score', 0) < min_score:
|
||||
ctx['score'] = CLUSTER_RESCUE_FLOOR
|
||||
rescued_count += 1
|
||||
logger.debug(
|
||||
f"BM25 分歧救援 (情况A): rank={rank}, "
|
||||
f"source={bm25_meta.get('source', '')}, "
|
||||
f"section={bm25_meta.get('section', '')}, "
|
||||
f"原score→{CLUSTER_RESCUE_FLOOR}"
|
||||
)
|
||||
continue
|
||||
|
||||
# 情况 B:切片不在 contexts 中(被 rerank 截断或已被过滤)
|
||||
# 从 _bm25_top3 备份中注入,但需校验 source 一致性
|
||||
if bm25_doc and bm25_meta:
|
||||
bm25_source = bm25_meta.get('source', '')
|
||||
# source 一致性校验:只注入与 contexts 多数 source 一致的切片
|
||||
# 避免跨文档注入无关内容(如 2.docx 的吸烟场所切片混入 1.docx 的投放查询)
|
||||
if majority_source and bm25_source and bm25_source != majority_source:
|
||||
logger.debug(
|
||||
f"BM25 分歧救援 (情况B-跳过): rank={rank}, "
|
||||
f"source={bm25_source} != majority={majority_source}, "
|
||||
f"section={bm25_meta.get('section', '')}"
|
||||
)
|
||||
continue
|
||||
injected_ctx = {
|
||||
'doc': bm25_doc,
|
||||
'meta': bm25_meta,
|
||||
'score': CLUSTER_RESCUE_FLOOR,
|
||||
}
|
||||
contexts.append(injected_ctx)
|
||||
rescued_count += 1
|
||||
logger.debug(
|
||||
f"BM25 分歧救援 (情况B-注入): rank={rank}, "
|
||||
f"source={bm25_meta.get('source', '')}, "
|
||||
f"section={bm25_meta.get('section', '')}"
|
||||
)
|
||||
|
||||
if rescued_count > 0:
|
||||
logger.info(f"BM25 分歧救援: 共救援 {rescued_count} 个切片")
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
def _rescue_lexical_match(contexts: List[Dict], retrieval_query: str,
|
||||
min_score: float) -> List[Dict]:
|
||||
"""
|
||||
词法匹配救援:当切片文本精确包含查询的核心关键词但 CrossEncoder 评分很低时,
|
||||
将其分数提升至保底值,并同时救援同 source 下 chunk_index 相邻的切片。
|
||||
|
||||
适用场景:
|
||||
1. 某个 section 只有一个正确切片(无法触发聚类救援)
|
||||
2. 枚举类问题的 header 切片被词法匹配救援后,其后续子条目也应被一并保留
|
||||
|
||||
Args:
|
||||
contexts: 全部上下文切片
|
||||
retrieval_query: 检索查询
|
||||
min_score: 最低分数阈值
|
||||
|
||||
Returns:
|
||||
修改后的 contexts
|
||||
"""
|
||||
if not contexts or not retrieval_query:
|
||||
return contexts
|
||||
|
||||
import re
|
||||
from config import CLUSTER_RESCUE_FLOOR
|
||||
|
||||
# 清理查询:去除 markdown 格式和标点
|
||||
clean_query = re.sub(r'\*+|#+|`', '', retrieval_query)
|
||||
clean_query = re.sub(r'[??!!。,,、;;::"""\'\s]+', ' ', clean_query).strip()
|
||||
|
||||
if len(clean_query) < 2:
|
||||
return contexts
|
||||
|
||||
# 提取查询中的有意义 bigram(连续两字组)
|
||||
query_bigrams = set()
|
||||
for i in range(len(clean_query) - 1):
|
||||
w = clean_query[i:i+2].strip()
|
||||
if len(w) == 2:
|
||||
query_bigrams.add(w)
|
||||
|
||||
if not query_bigrams:
|
||||
return contexts
|
||||
|
||||
# Phase 1: 词法匹配救援——找到高分匹配的切片
|
||||
rescued_seeds = [] # [(source, chunk_index)]
|
||||
for ctx in contexts:
|
||||
if ctx.get('score', 0) >= min_score:
|
||||
continue
|
||||
|
||||
doc = ctx.get('doc', '') or ''
|
||||
meta = ctx.get('meta', {})
|
||||
section = meta.get('section', '') or meta.get('section_path', '')
|
||||
combined = doc + ' ' + section
|
||||
|
||||
matched = sum(1 for w in query_bigrams if w in combined)
|
||||
match_ratio = matched / len(query_bigrams)
|
||||
|
||||
if match_ratio > 0.35:
|
||||
ctx['score'] = max(ctx.get('score', 0), CLUSTER_RESCUE_FLOOR)
|
||||
source = meta.get('source', '')
|
||||
chunk_index = meta.get('chunk_index')
|
||||
if source and chunk_index is not None:
|
||||
try:
|
||||
rescued_seeds.append((source, int(chunk_index)))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# Phase 2: 邻居救援——对每个被词法匹配救援的种子,
|
||||
# 同时救援同 source 下 chunk_index 后续相邻的切片(枚举子条目)
|
||||
if rescued_seeds:
|
||||
for source, seed_idx in rescued_seeds:
|
||||
for ctx in contexts:
|
||||
if ctx.get('score', 0) >= min_score:
|
||||
continue
|
||||
meta = ctx.get('meta', {})
|
||||
if meta.get('source') != source:
|
||||
continue
|
||||
try:
|
||||
n_idx = int(meta.get('chunk_index', -1))
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
# 救援种子后续 8 个相邻切片(覆盖大多数枚举/条款模式)
|
||||
if seed_idx < n_idx <= seed_idx + 8:
|
||||
ctx['score'] = max(ctx.get('score', 0), CLUSTER_RESCUE_FLOOR)
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
def _normalize_section_for_rescue(section_path: str, levels: int = 2) -> str:
|
||||
"""归一化 section_path:取前 N 级路径用于分组"""
|
||||
if not section_path:
|
||||
return ''
|
||||
parts = [p.strip() for p in section_path.split('>')]
|
||||
return ' > '.join(parts[:levels])
|
||||
|
||||
|
||||
def _rescue_section_cluster(contexts: List[Dict], retrieval_query: str,
|
||||
min_score: float) -> List[Dict]:
|
||||
"""
|
||||
路由层章节聚类救援:当某个 section 有多个候选切片但全部低于 min_score 时,
|
||||
给该 section 的切片分配保底分数,使其通过后续的 min_score 过滤。
|
||||
|
||||
作为引擎层 _section_cluster_boost 的二次安全网,防止极端情况下所有正确切片被过滤。
|
||||
|
||||
Args:
|
||||
contexts: engine 返回的全部上下文切片(每个含 doc, meta, score)
|
||||
retrieval_query: 改写后的检索查询
|
||||
min_score: 当前的最低分数阈值
|
||||
|
||||
Returns:
|
||||
修改后的 contexts(部分切片 score 被提升至保底分数)
|
||||
"""
|
||||
if not contexts:
|
||||
return contexts
|
||||
|
||||
from config import (
|
||||
CLUSTER_MIN_MEMBERS, CLUSTER_MIN_TYPES, CLUSTER_RESCUE_FLOOR,
|
||||
CLUSTER_MAX_SECTIONS, CLUSTER_MAX_RESCUE_PER_SECTION,
|
||||
CLUSTER_SECTION_PREFIX_LEVELS,
|
||||
)
|
||||
|
||||
# 1. 按 (source, normalized_section) 分组
|
||||
from collections import defaultdict
|
||||
section_groups = defaultdict(list) # key → [index_in_contexts]
|
||||
|
||||
for i, ctx in enumerate(contexts):
|
||||
meta = ctx.get('meta', {})
|
||||
source = meta.get('source', '')
|
||||
section_path = meta.get('section', '') or meta.get('section_path', '')
|
||||
norm_section = _normalize_section_for_rescue(section_path, CLUSTER_SECTION_PREFIX_LEVELS)
|
||||
if not source or not norm_section:
|
||||
continue
|
||||
key = (source, norm_section)
|
||||
section_groups[key].append(i)
|
||||
|
||||
# 2. 检测"全灭 section"并计算聚类强度
|
||||
rescue_candidates = [] # (strength, key, member_indices)
|
||||
|
||||
for key, indices in section_groups.items():
|
||||
if len(indices) < CLUSTER_MIN_MEMBERS:
|
||||
continue
|
||||
|
||||
# 检查是否所有成员都低于 min_score("全灭")
|
||||
scores = [contexts[i].get('score', 0) for i in indices]
|
||||
if any(s >= min_score for s in scores):
|
||||
continue # 已有成员通过阈值,无需救援
|
||||
|
||||
# 计算聚类强度
|
||||
chunk_types = set(contexts[i].get('meta', {}).get('chunk_type', 'text') for i in indices)
|
||||
type_diversity = len(chunk_types)
|
||||
if type_diversity < CLUSTER_MIN_TYPES:
|
||||
continue # 类型不够多样,可能是噪音
|
||||
|
||||
# 查询与 section_path 的字符重叠率
|
||||
source, norm_section = key
|
||||
query_chars = set(retrieval_query)
|
||||
section_chars = set(norm_section)
|
||||
overlap = len(query_chars & section_chars) / max(len(query_chars), 1)
|
||||
|
||||
# 聚类强度 = 成员数 × 类型多样性 × (1 + 查询匹配度)
|
||||
strength = len(indices) * type_diversity * (1.0 + overlap)
|
||||
rescue_candidates.append((strength, key, indices))
|
||||
|
||||
# 3. 按强度降序,救援 top-N section
|
||||
rescue_candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
|
||||
rescued_sections = []
|
||||
total_rescued = 0
|
||||
|
||||
for strength, key, indices in rescue_candidates[:CLUSTER_MAX_SECTIONS]:
|
||||
# 对组内切片分配保底分数(仅低于 min_score 的)
|
||||
rescue_count = 0
|
||||
for idx in indices:
|
||||
if rescue_count >= CLUSTER_MAX_RESCUE_PER_SECTION:
|
||||
break
|
||||
ctx = contexts[idx]
|
||||
if ctx.get('score', 0) < min_score:
|
||||
ctx['score'] = CLUSTER_RESCUE_FLOOR
|
||||
rescue_count += 1
|
||||
total_rescued += 1
|
||||
|
||||
if rescue_count > 0:
|
||||
source, norm_section = key
|
||||
rescued_sections.append({
|
||||
'source': source,
|
||||
'section': norm_section,
|
||||
'members': len(indices),
|
||||
'rescued': rescue_count,
|
||||
'strength': round(strength, 2)
|
||||
})
|
||||
|
||||
return contexts
|
||||
|
||||
|
||||
def _rescue_table_chunks(contexts: List[Dict], context_text: str,
|
||||
retrieval_query: str, max_rescue_chars: int = 3000) -> str:
|
||||
"""
|
||||
@@ -1859,7 +1558,54 @@ def chat_with_llm(message: str, history: List[Dict] = None, enable_web_search: b
|
||||
}
|
||||
|
||||
|
||||
def search_hybrid(query: str, top_k: int = 5,
|
||||
def reciprocal_rank_fusion(results_list, weights=None, k=60):
|
||||
"""
|
||||
倒数排名融合算法
|
||||
|
||||
Args:
|
||||
results_list: 多个检索结果列表
|
||||
weights: 各结果权重
|
||||
k: RRF 参数
|
||||
|
||||
Returns:
|
||||
融合后的排序结果
|
||||
"""
|
||||
if weights is None:
|
||||
weights = [1.0] * len(results_list)
|
||||
|
||||
fused_scores = {}
|
||||
doc_data = {}
|
||||
|
||||
for results, weight in zip(results_list, weights):
|
||||
if not results or not results.get('ids'):
|
||||
continue
|
||||
|
||||
ids = results['ids'][0]
|
||||
docs = results['documents'][0] if results.get('documents') else [''] * len(ids)
|
||||
metas = results['metadatas'][0] if results.get('metadatas') else [{}] * len(ids)
|
||||
distances = results['distances'][0] if results.get('distances') else [0] * len(ids)
|
||||
|
||||
for rank, (doc_id, doc, meta, dist) in enumerate(zip(ids, docs, metas, distances)):
|
||||
if doc_id not in fused_scores:
|
||||
fused_scores[doc_id] = 0
|
||||
doc_data[doc_id] = {'doc': doc, 'meta': meta, 'dist': dist}
|
||||
|
||||
# RRF 分数
|
||||
fused_scores[doc_id] += weight / (rank + k)
|
||||
|
||||
# 按分数排序
|
||||
sorted_ids = sorted(fused_scores.keys(), key=lambda x: fused_scores[x], reverse=True)
|
||||
|
||||
return {
|
||||
'ids': sorted_ids,
|
||||
'documents': [doc_data[i]['doc'] for i in sorted_ids],
|
||||
'metadatas': [doc_data[i]['meta'] for i in sorted_ids],
|
||||
'scores': [fused_scores[i] for i in sorted_ids],
|
||||
'distances': [doc_data[i]['dist'] for i in sorted_ids]
|
||||
}
|
||||
|
||||
|
||||
def search_hybrid(query: str, top_k: int = 5, candidates: int = 15,
|
||||
allowed_levels: list = None, allowed_collections: list = None,
|
||||
sub_queries: list = None):
|
||||
"""
|
||||
@@ -1868,6 +1614,7 @@ def search_hybrid(query: str, top_k: int = 5,
|
||||
Args:
|
||||
query: 查询文本
|
||||
top_k: 返回数量
|
||||
candidates: 候选数量(用于 RERANK_CANDIDATES,由 config 控制)
|
||||
allowed_levels: 允许的安全级别
|
||||
allowed_collections: 允许的向量库列表
|
||||
sub_queries: 意图分析器生成的子查询列表(对比类查询用)
|
||||
@@ -1929,12 +1676,12 @@ def chat():
|
||||
history = data.get('history', [])
|
||||
|
||||
if not message:
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 message", http_status=400)
|
||||
return jsonify({"error": "缺少 message"}), 400
|
||||
|
||||
# 输入安全验证
|
||||
is_valid, reason = validate_query(message)
|
||||
if not is_valid:
|
||||
return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
|
||||
return jsonify({"error": reason}), 400
|
||||
|
||||
# 智能聊天
|
||||
result = chat_with_llm(message, history)
|
||||
@@ -1942,7 +1689,7 @@ def chat():
|
||||
# 过滤敏感信息
|
||||
answer = filter_response(result["answer"])
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"answer": answer,
|
||||
"mode": "chat",
|
||||
"sources": result.get("sources", []),
|
||||
@@ -1975,17 +1722,14 @@ def rag():
|
||||
import re
|
||||
from config import (
|
||||
IS_PROD, IS_DEV, ENABLE_SESSION,
|
||||
RAG_SEARCH_TOP_K,
|
||||
RAG_SEARCH_TOP_K, RAG_SEARCH_CANDIDATES,
|
||||
MAX_CONTEXT_CHUNKS, MAX_SOURCES_RETURNED,
|
||||
LLM_TEMPERATURE, LLM_MAX_TOKENS,
|
||||
MAX_HISTORY_ROUNDS, IMAGE_CONTEXT_HISTORY,
|
||||
DIRECT_CONTEXT_MAX_CHARS,
|
||||
RERANK_CONTEXT_MIN_SCORE,
|
||||
CONTEXT_MAX_CHARS, CONTEXT_SOFT_LIMIT,
|
||||
CONFIDENCE_WARN_THRESHOLD, CONFIDENCE_CAUTION_THRESHOLD,
|
||||
SECTION_CLUSTER_RESCUE_ENABLED, CLUSTER_MIN_MEMBERS, CLUSTER_MIN_TYPES,
|
||||
CLUSTER_RESCUE_FLOOR, CLUSTER_MAX_SECTIONS, CLUSTER_MAX_RESCUE_PER_SECTION,
|
||||
CLUSTER_SECTION_PREFIX_LEVELS
|
||||
CONFIDENCE_WARN_THRESHOLD, CONFIDENCE_CAUTION_THRESHOLD
|
||||
)
|
||||
|
||||
data = request.json or {}
|
||||
@@ -2003,16 +1747,19 @@ def rag():
|
||||
session_id = data.get('session_id')
|
||||
|
||||
if not message:
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 message", http_status=400)
|
||||
return jsonify({"error": "缺少 message"}), 400
|
||||
|
||||
# 生产环境强制校验 chat_history
|
||||
if IS_PROD and history is None:
|
||||
return error_response("MISSING_HISTORY", BAD_REQUEST, "chat_history is required in production", http_status=400)
|
||||
return jsonify({
|
||||
"error": "chat_history is required in production",
|
||||
"code": "MISSING_HISTORY"
|
||||
}), 400
|
||||
|
||||
# 输入安全验证
|
||||
is_valid, reason = validate_query(message)
|
||||
if not is_valid:
|
||||
return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
|
||||
return jsonify({"error": reason}), 400
|
||||
|
||||
# 如果没有指定 collections,使用默认的公开库
|
||||
if not collections:
|
||||
@@ -2160,51 +1907,6 @@ def rag():
|
||||
# 构建检索查询:使用改写后的完整问题(解决追问偏离问题)
|
||||
retrieval_query = intent.rewritten_query if (intent and intent.rewritten_query) else message
|
||||
|
||||
# 1.5 语义缓存检查(跳过检索+生成全流程)
|
||||
_semantic_cache_emb = None
|
||||
try:
|
||||
from core.semantic_cache import get_semantic_cache
|
||||
from core.engine import get_engine as _get_eng
|
||||
_sc = get_semantic_cache()
|
||||
_eng = _get_eng()
|
||||
if _sc and _eng and hasattr(_eng, 'embedding_model'):
|
||||
# 缓存 key 使用 retrieval_query + collections,避免不同上下文的追问命中错误缓存
|
||||
_cache_collections = ','.join(sorted(collections)) if collections else ''
|
||||
_cache_key_text = f"{retrieval_query}|{_cache_collections}"
|
||||
_semantic_cache_emb = _eng.embedding_model.encode(_cache_key_text)
|
||||
cached = _sc.get(_semantic_cache_emb)
|
||||
# 防御性校验:必须是 RAG 回答缓存(非 intent_analyzer 缓存),且回答非空
|
||||
if cached is not None and cached.get("cache_type") == "rag_answer" and cached.get("answer"):
|
||||
logger.info(f"[语义缓存] 命中: {message[:50]}...")
|
||||
cached_answer = cached.get("answer", "")
|
||||
# 流式返回缓存的答案
|
||||
yield f"data: {json.dumps({'type': 'start', 'message': '正在检索知识库...'}, ensure_ascii=False)}\n\n"
|
||||
# 分块发送缓存答案
|
||||
chunk_size = 20
|
||||
for i in range(0, len(cached_answer), chunk_size):
|
||||
chunk = cached_answer[i:i+chunk_size]
|
||||
full_answer.append(chunk)
|
||||
yield f"data: {json.dumps({'type': 'chunk', 'content': chunk}, ensure_ascii=False)}\n\n"
|
||||
finish_event = {
|
||||
"type": "finish",
|
||||
"answer": cached_answer,
|
||||
"mode": "rag",
|
||||
"session_id": session_id,
|
||||
"sources": cached.get("sources", []),
|
||||
"citations": cached.get("citations", []),
|
||||
"images": cached.get("images", []),
|
||||
"tables": cached.get("tables", []),
|
||||
"sections": [],
|
||||
"duration_ms": int((_time.time() - start_time) * 1000),
|
||||
"confidence_score": 1.0,
|
||||
"semantic_cache_hit": True
|
||||
}
|
||||
yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n"
|
||||
return
|
||||
except Exception as e:
|
||||
logger.warning(f"[语义缓存] 检查失败: {e}")
|
||||
_semantic_cache_emb = None
|
||||
|
||||
# 1. 发送开始事件
|
||||
yield f"data: {json.dumps({'type': 'start', 'message': '正在检索知识库...'}, ensure_ascii=False)}\n\n"
|
||||
|
||||
@@ -2217,6 +1919,7 @@ def rag():
|
||||
search_result = search_hybrid(
|
||||
retrieval_query,
|
||||
top_k=RAG_SEARCH_TOP_K,
|
||||
candidates=RAG_SEARCH_CANDIDATES,
|
||||
allowed_collections=collections,
|
||||
sub_queries=sub_queries
|
||||
)
|
||||
@@ -2226,11 +1929,6 @@ def rag():
|
||||
debug_info = search_result.get('_debug', {})
|
||||
yield f"data: {json.dumps({'type': 'retrieval_debug', 'data': {'steps': debug_info.get('steps', []), 'collections_searched': collections, 'total_candidates': len(search_result.get('ids', [[]])[0])}}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 提取章节聚类提升事件(引擎层)单独发送
|
||||
for step in debug_info.get('steps', []):
|
||||
if step.get('name') == 'section_cluster_boost':
|
||||
yield f"data: {json.dumps({'type': 'section_cluster_boost', 'data': step}, ensure_ascii=False)}\n\n"
|
||||
|
||||
# 提取上下文
|
||||
contexts = []
|
||||
sources = []
|
||||
@@ -2499,35 +2197,6 @@ def rag():
|
||||
|
||||
# 4. 构建 prompt(Phase 6:LLM 图片感知)
|
||||
# Bug 1 修复:文本切片用于 top 5 名额竞争,图片描述不参与竞争
|
||||
|
||||
# BM25 分歧检测救援:BM25 top-3 但 rerank 压制的切片
|
||||
contexts = _rescue_bm25_divergence(contexts, search_result, RERANK_CONTEXT_MIN_SCORE)
|
||||
|
||||
# 词法匹配救援:当切片文本精确包含查询关键词但 CrossEncoder 评分低时,
|
||||
# 提升分数使其通过 min_score 过滤(适用于独立切片无法触发聚类救援的场景)
|
||||
contexts = _rescue_lexical_match(contexts, retrieval_query, RERANK_CONTEXT_MIN_SCORE)
|
||||
|
||||
# 章节聚类救援(路由层安全网):在 min_score 过滤前,
|
||||
# 检测"全灭 section"并分配保底分数
|
||||
_rescue_debug = None
|
||||
if SECTION_CLUSTER_RESCUE_ENABLED:
|
||||
_before_scores = {id(ctx): ctx.get('score', 0) for ctx in contexts}
|
||||
contexts = _rescue_section_cluster(contexts, retrieval_query, RERANK_CONTEXT_MIN_SCORE)
|
||||
# 统计救援结果
|
||||
_rescued = [ctx for ctx in contexts if id(ctx) in _before_scores
|
||||
and ctx.get('score', 0) > _before_scores[id(ctx)]]
|
||||
if _rescued and IS_DEV:
|
||||
_rescue_sections = set()
|
||||
for ctx in _rescued:
|
||||
meta = ctx.get('meta', {})
|
||||
sp = meta.get('section', '') or meta.get('section_path', '')
|
||||
_rescue_sections.add(_normalize_section_for_rescue(sp, CLUSTER_SECTION_PREFIX_LEVELS))
|
||||
_rescue_debug = {
|
||||
'rescued_count': len(_rescued),
|
||||
'rescued_sections': list(_rescue_sections)
|
||||
}
|
||||
yield f"data: {json.dumps({'type': 'section_cluster_rescue', 'data': _rescue_debug}, ensure_ascii=False)}\n\n"
|
||||
|
||||
text_contexts = _order_text_contexts_for_prompt(contexts, retrieval_query, MAX_CONTEXT_CHUNKS,
|
||||
min_score=RERANK_CONTEXT_MIN_SCORE)
|
||||
# Phase 2:按字符预算构建上下文
|
||||
@@ -2766,31 +2435,6 @@ def rag():
|
||||
"rerank_cached": rerank_cached,
|
||||
"total_ms": duration_ms
|
||||
}
|
||||
# 10.5 写入语义缓存
|
||||
try:
|
||||
from core.semantic_cache import get_semantic_cache
|
||||
_sc = get_semantic_cache()
|
||||
if _sc and filtered_answer:
|
||||
if _semantic_cache_emb is None:
|
||||
from core.engine import get_engine as _get_eng
|
||||
_eng = _get_eng()
|
||||
if _eng and hasattr(_eng, 'embedding_model'):
|
||||
_cache_collections = ','.join(sorted(collections)) if collections else ''
|
||||
_cache_key_text = f"{retrieval_query}|{_cache_collections}"
|
||||
_semantic_cache_emb = _eng.embedding_model.encode(_cache_key_text)
|
||||
if _semantic_cache_emb is not None:
|
||||
_sc.set(_semantic_cache_emb, {
|
||||
"cache_type": "rag_answer",
|
||||
"answer": filtered_answer,
|
||||
"sources": sources,
|
||||
"citations": citation_result.get("citations", []),
|
||||
"images": rich_media.get("images", []),
|
||||
"tables": rich_media.get("tables", []),
|
||||
})
|
||||
logger.debug(f"[语义缓存] 写入成功: {message[:50]}...")
|
||||
except Exception as e:
|
||||
logger.info(f"[语义缓存] 写入失败: {e}")
|
||||
|
||||
yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
@@ -2835,12 +2479,12 @@ def search():
|
||||
collections = data.get('collections') # 后端传入的知识库列表
|
||||
|
||||
if not query:
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "query is required", http_status=400)
|
||||
return jsonify({'error': 'query is required'}), 400
|
||||
|
||||
# 输入安全校验(注入检测、违禁词、长度限制)
|
||||
is_valid, reason = validate_query(query)
|
||||
if not is_valid:
|
||||
return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
|
||||
return jsonify({'error': reason}), 400
|
||||
|
||||
# top_k 范围校验
|
||||
try:
|
||||
@@ -2854,73 +2498,8 @@ def search():
|
||||
|
||||
results = search_hybrid(query, top_k=top_k, allowed_collections=collections)
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
'contexts': results['documents'][0],
|
||||
'metadatas': results['metadatas'][0],
|
||||
'scores': results['scores'][0]
|
||||
})
|
||||
|
||||
|
||||
# ==================== 缓存调试接口(临时) ====================
|
||||
|
||||
@chat_bp.route('/cache/stats', methods=['GET'])
|
||||
def cache_stats():
|
||||
"""缓存统计接口(调试用)"""
|
||||
stats = {}
|
||||
try:
|
||||
from core.cache import get_cache_manager
|
||||
cache = get_cache_manager()
|
||||
all_stats = cache.get_all_stats()
|
||||
for name, s in all_stats.items():
|
||||
stats[name] = {
|
||||
'total_entries': s.total_entries,
|
||||
'hits': s.hits,
|
||||
'misses': s.misses,
|
||||
'hit_rate': f"{s.hit_rate:.2%}",
|
||||
'evictions': s.evictions,
|
||||
}
|
||||
except Exception as e:
|
||||
stats['_error'] = str(e)
|
||||
|
||||
# 语义缓存统计(全局单例 + 意图分析器实例)
|
||||
try:
|
||||
from core.semantic_cache import get_semantic_cache
|
||||
sc = get_semantic_cache()
|
||||
if sc:
|
||||
stats['semantic_cache'] = sc.get_stats()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from core.intent_analyzer import IntentAnalyzer
|
||||
if hasattr(IntentAnalyzer, '_instance'):
|
||||
ia = IntentAnalyzer._instance
|
||||
if hasattr(ia, 'semantic_cache') and ia.semantic_cache:
|
||||
stats['semantic_cache_intent'] = ia.semantic_cache.get_stats()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return jsonify(stats)
|
||||
|
||||
|
||||
@chat_bp.route('/cache/clear', methods=['POST'])
|
||||
def cache_clear():
|
||||
"""缓存清空接口(调试用)"""
|
||||
result = {}
|
||||
try:
|
||||
from core.cache import get_cache_manager
|
||||
cache = get_cache_manager()
|
||||
cache.clear_all()
|
||||
result['lru_cache'] = 'cleared'
|
||||
except Exception as e:
|
||||
result['lru_cache'] = f'error: {e}'
|
||||
|
||||
try:
|
||||
from core.semantic_cache import get_semantic_cache
|
||||
sc = get_semantic_cache()
|
||||
sc.clear()
|
||||
result['semantic_cache'] = 'cleared'
|
||||
except Exception:
|
||||
result['semantic_cache'] = 'not_available'
|
||||
|
||||
return jsonify({'status': 'ok', 'cleared': result})
|
||||
|
||||
@@ -39,7 +39,7 @@ import re
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional, Tuple, Any, List, Dict
|
||||
from flask import Blueprint, request
|
||||
from flask import Blueprint, request, jsonify
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -47,11 +47,9 @@ from werkzeug.utils import secure_filename
|
||||
from auth.gateway import require_gateway_auth
|
||||
from config import DEV_MODE
|
||||
from core.status_codes import (
|
||||
UPLOAD_SUCCESS, BATCH_UPLOAD_SUCCESS, BAD_REQUEST, SUCCESS,
|
||||
NO_FILE, NO_FILE_SELECTED, NO_COLLECTION, NOT_FOUND,
|
||||
FILE_TOO_LARGE, UNSUPPORTED_FORMAT, INTERNAL_ERROR,
|
||||
SERVICE_UNAVAILABLE, DELETE_SUCCESS, UPDATE_SUCCESS, NO_CONTENT,
|
||||
FORBIDDEN
|
||||
UPLOAD_SUCCESS, BATCH_UPLOAD_SUCCESS, BAD_REQUEST,
|
||||
NO_FILE, NO_FILE_SELECTED, NO_COLLECTION,
|
||||
FILE_TOO_LARGE, UNSUPPORTED_FORMAT, INTERNAL_ERROR
|
||||
)
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
@@ -175,7 +173,7 @@ def serve_document_file(doc_path: str) -> Tuple[Any, int]:
|
||||
仅在 DEV_MODE=true 时可用(需在 .env 中显式设置)
|
||||
"""
|
||||
if not DEV_MODE:
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "仅开发环境可用", http_status=403)
|
||||
return jsonify({"error": "仅开发环境可用"}), 403
|
||||
|
||||
from config import DOCUMENTS_PATH
|
||||
from flask import send_from_directory
|
||||
@@ -184,9 +182,9 @@ def serve_document_file(doc_path: str) -> Tuple[Any, int]:
|
||||
try:
|
||||
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
|
||||
except ValueError:
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "非法路径", http_status=403)
|
||||
return jsonify({"error": "非法路径"}), 403
|
||||
if not os.path.exists(filepath):
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "文件不存在", http_status=404)
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
|
||||
directory = os.path.dirname(filepath)
|
||||
filename = os.path.basename(filepath)
|
||||
@@ -337,24 +335,10 @@ def upload_document() -> Tuple[Any, int]:
|
||||
except Exception as e:
|
||||
logger.warning(f"标记旧版本失败: {e}")
|
||||
|
||||
# 6. 触发向量化(异步任务)
|
||||
# 6. 触发向量化
|
||||
sync_status = "已保存,等待手动同步"
|
||||
sync_service = _get_sync_service()
|
||||
task_id = None
|
||||
|
||||
if sync_service:
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
task = registry.create_task('upload', f"向量化: {filename}")
|
||||
task_id = task.id
|
||||
|
||||
def _do_vectorize(task, svc, change_obj, fname):
|
||||
"""后台执行向量化"""
|
||||
registry.update_progress(task.id, stage='向量化', message=f"正在处理: {fname}")
|
||||
svc.process_change(change_obj)
|
||||
return {'filename': fname, 'sync_status': '已添加到向量库'}
|
||||
|
||||
try:
|
||||
from knowledge.sync import DocumentChange, ChangeType
|
||||
change = DocumentChange(
|
||||
@@ -365,12 +349,11 @@ def upload_document() -> Tuple[Any, int]:
|
||||
new_hash=sync_service.calculate_file_hash(filepath),
|
||||
change_time=datetime.now()
|
||||
)
|
||||
registry.start_task(task.id, _do_vectorize, sync_service, change, filename)
|
||||
sync_status = "已保存,向量化任务已启动"
|
||||
sync_service.process_change(change)
|
||||
sync_status = "已保存并添加到向量库"
|
||||
except Exception as e:
|
||||
logger.warning(f"创建向量化任务失败: {e}")
|
||||
registry.fail_task(task.id, str(e))
|
||||
sync_status = "已保存,向量化任务创建失败"
|
||||
logger.warning(f"向量化失败: {e}")
|
||||
sync_status = "已保存,向量化失败"
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
@@ -381,8 +364,7 @@ def upload_document() -> Tuple[Any, int]:
|
||||
"size": file_size,
|
||||
"replaced": replaced
|
||||
},
|
||||
"sync_status": sync_status,
|
||||
"task_id": task_id
|
||||
"sync_status": sync_status
|
||||
},
|
||||
status_code=UPLOAD_SUCCESS,
|
||||
message=f"文件上传成功,{sync_status}"
|
||||
@@ -525,55 +507,14 @@ def batch_upload_documents() -> Tuple[Any, int]:
|
||||
"message": "上传处理失败"
|
||||
})
|
||||
|
||||
success_count = len([r for r in results if r["status"] == "success"])
|
||||
task_id = None
|
||||
|
||||
# 批量上传完成后自动触发向量化(异步任务)
|
||||
sync_service = _get_sync_service()
|
||||
if sync_service and success_count > 0:
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
running = registry.list_tasks(status='running', task_type='batch_upload', limit=1)
|
||||
if not running:
|
||||
task = registry.create_task('batch_upload', f"批量向量化: {success_count} 个文件", total=success_count)
|
||||
task_id = task.id
|
||||
|
||||
def _do_batch_vectorize(task, svc, count):
|
||||
processed = [0]
|
||||
|
||||
def on_change(change):
|
||||
processed[0] += 1
|
||||
registry.update_progress(
|
||||
task.id, current=processed[0], total=count,
|
||||
stage='批量向量化',
|
||||
message=f"已处理: {change.document_name if hasattr(change, 'document_name') else change.document_id}"
|
||||
)
|
||||
|
||||
old_cb = svc.on_change_callback
|
||||
svc.on_change_callback = on_change
|
||||
try:
|
||||
registry.update_progress(task.id, stage='扫描文档', message='正在检测变更...')
|
||||
result = svc.sync_now()
|
||||
return {
|
||||
'synced': result.documents_processed,
|
||||
'added': result.documents_added,
|
||||
'errors': result.errors,
|
||||
}
|
||||
finally:
|
||||
svc.on_change_callback = old_cb
|
||||
|
||||
registry.start_task(task.id, _do_batch_vectorize, sync_service, success_count)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
"total": len(results),
|
||||
"success_count": success_count,
|
||||
"results": results,
|
||||
"task_id": task_id
|
||||
"success_count": len([r for r in results if r["status"] == "success"]),
|
||||
"results": results
|
||||
},
|
||||
status_code=BATCH_UPLOAD_SUCCESS,
|
||||
message=f"批量上传完成,成功 {success_count}/{len(results)} 个文件"
|
||||
message=f"批量上传完成,成功 {len([r for r in results if r['status'] == 'success'])}/{len(results)} 个文件"
|
||||
)
|
||||
|
||||
|
||||
@@ -650,7 +591,10 @@ def list_documents() -> Tuple[Any, int]:
|
||||
# 按修改时间倒序
|
||||
documents.sort(key=lambda x: x['last_modified'], reverse=True)
|
||||
|
||||
return success_response(data={"documents": documents, "total": len(documents)})
|
||||
return jsonify({
|
||||
"documents": documents,
|
||||
"total": len(documents)
|
||||
})
|
||||
|
||||
|
||||
@document_bp.route('/documents/<path:doc_path>/status', methods=['GET'])
|
||||
@@ -674,12 +618,12 @@ def get_document_status(doc_path: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径", http_status=400)
|
||||
return jsonify({"error": "无效的文档路径"}), 400
|
||||
|
||||
subdir = parts[0]
|
||||
filename = '/'.join(parts[1:])
|
||||
@@ -695,14 +639,16 @@ def get_document_status(doc_path: str) -> Tuple[Any, int]:
|
||||
|
||||
if not doc_info:
|
||||
if file_on_disk:
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"status": "unprocessed",
|
||||
"chunk_count": 0,
|
||||
"last_processed": None
|
||||
})
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "文档不存在", http_status=404)
|
||||
return jsonify({"error": "文档不存在"}), 404
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"status": doc_info.get("status", "unknown"),
|
||||
"chunk_count": doc_info.get("total_chunks", 0),
|
||||
"last_processed": doc_info.get("effective_date") or doc_info.get("version")
|
||||
@@ -729,35 +675,35 @@ def update_document(doc_path: str) -> Tuple[Any, int]:
|
||||
from config import DOCUMENTS_PATH
|
||||
|
||||
if 'file' not in request.files:
|
||||
return error_response("NO_FILE", NO_FILE, "没有上传文件", http_status=400)
|
||||
return jsonify({"error": "没有上传文件"}), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return error_response("NO_FILE_SELECTED", NO_FILE_SELECTED, "没有选择文件", http_status=400)
|
||||
return jsonify({"error": "没有选择文件"}), 400
|
||||
|
||||
# 文件类型校验
|
||||
ext = os.path.splitext(file.filename)[1].lower()
|
||||
if ext not in ALLOWED_EXTENSIONS:
|
||||
return error_response("UNSUPPORTED_FORMAT", UNSUPPORTED_FORMAT, f"不支持的文件类型: {ext}", http_status=400)
|
||||
return jsonify({"error": f"不支持的文件类型: {ext}"}), 400
|
||||
|
||||
# 文件大小校验
|
||||
file.seek(0, 2) # 跳到文件末尾获取大小
|
||||
file_size = file.tell()
|
||||
file.seek(0) # 回到文件开头
|
||||
if file_size > MAX_FILE_SIZE:
|
||||
return error_response("FILE_TOO_LARGE", FILE_TOO_LARGE, f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB)", http_status=400)
|
||||
return jsonify({"error": f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB)"}), 400
|
||||
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径", http_status=400)
|
||||
return jsonify({"error": "无效的文档路径"}), 400
|
||||
|
||||
try:
|
||||
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
|
||||
except ValueError:
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "非法路径", http_status=403)
|
||||
return jsonify({"error": "非法路径"}), 403
|
||||
if not os.path.exists(filepath):
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "文件不存在", http_status=404)
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
|
||||
# 覆盖文件
|
||||
file.save(filepath)
|
||||
@@ -781,7 +727,10 @@ def update_document(doc_path: str) -> Tuple[Any, int]:
|
||||
except Exception as e:
|
||||
logger.warning(f"重新向量化失败: {e}")
|
||||
|
||||
return success_response(message="文件已更新")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "文件已更新"
|
||||
})
|
||||
|
||||
|
||||
@document_bp.route('/documents/<path:doc_path>', methods=['DELETE'])
|
||||
@@ -806,7 +755,7 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径", http_status=400)
|
||||
return jsonify({"error": "无效的文档路径"}), 400
|
||||
|
||||
subdir = parts[0]
|
||||
filename = '/'.join(parts[1:])
|
||||
@@ -817,9 +766,9 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
|
||||
try:
|
||||
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
|
||||
except ValueError:
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "非法路径", http_status=403)
|
||||
return jsonify({"error": "非法路径"}), 403
|
||||
if not os.path.exists(filepath):
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "文件不存在", http_status=404)
|
||||
return jsonify({"error": "文件不存在"}), 404
|
||||
|
||||
try:
|
||||
# 1. 从向量库删除(source 存的是文件名,不是完整路径)
|
||||
@@ -830,11 +779,14 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
|
||||
# 2. 删除文件
|
||||
os.remove(filepath)
|
||||
|
||||
return success_response(status_code=DELETE_SUCCESS, message="文档已删除")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "文档已删除"
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"删除文档异常: {e}")
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "删除失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "删除失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@document_bp.route('/documents/<path:doc_path>/chunks', methods=['GET'])
|
||||
@@ -859,12 +811,12 @@ def list_document_chunks(doc_path: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径", http_status=400)
|
||||
return jsonify({"error": "无效的文档路径"}), 400
|
||||
|
||||
subdir = parts[0]
|
||||
# 目录名即向量库名
|
||||
@@ -872,7 +824,8 @@ def list_document_chunks(doc_path: str) -> Tuple[Any, int]:
|
||||
|
||||
chunks = kb_manager.get_document_chunks(collection, os.path.basename(doc_path))
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"document_id": doc_path,
|
||||
"collection": collection,
|
||||
"chunks": chunks,
|
||||
@@ -908,12 +861,12 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
|
||||
# 解析路径
|
||||
parts = doc_path.split('/')
|
||||
if len(parts) < 2:
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的文档路径,格式: collection/filename", http_status=400)
|
||||
return jsonify({"error": "无效的文档路径,格式: collection/filename"}), 400
|
||||
|
||||
collection = parts[0]
|
||||
filename = os.path.basename(doc_path)
|
||||
@@ -928,7 +881,7 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
# 获取所有切片
|
||||
all_chunks = kb_manager.get_document_chunks(collection, filename)
|
||||
if not all_chunks:
|
||||
return error_response("NOT_FOUND", NOT_FOUND, f"文档 '{filename}' 不存在或无切片", http_status=404)
|
||||
return jsonify({"error": f"文档 '{filename}' 不存在或无切片"}), 404
|
||||
|
||||
total = len(all_chunks)
|
||||
|
||||
@@ -937,7 +890,8 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
preview_chunks = all_chunks[:5]
|
||||
for c in preview_chunks:
|
||||
c['is_target'] = False
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"collection": collection,
|
||||
"source": filename,
|
||||
"total_chunks": total,
|
||||
@@ -949,7 +903,7 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
try:
|
||||
target_chunk_index = int(chunk_index_str)
|
||||
except ValueError:
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "chunk_index 必须为整数", http_status=400)
|
||||
return jsonify({"error": "chunk_index 必须为整数"}), 400
|
||||
|
||||
# 按 chunk_index 排序(Chroma 返回顺序不保证有序)
|
||||
all_chunks.sort(key=lambda c: c.get('metadata', {}).get('chunk_index', 0))
|
||||
@@ -970,7 +924,9 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
(c.get('metadata', {}).get('chunk_index', 0) for c in all_chunks),
|
||||
default=total - 1
|
||||
)
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, f"chunk_index={target_chunk_index} 未找到对应切片 (可用范围 0-{max_idx})", http_status=400)
|
||||
return jsonify({
|
||||
"error": f"chunk_index={target_chunk_index} 未找到对应切片 (可用范围 0-{max_idx})"
|
||||
}), 400
|
||||
|
||||
# 截取上下文窗口
|
||||
start = max(0, target_pos - context_count)
|
||||
@@ -981,7 +937,8 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
|
||||
for i, c in enumerate(window):
|
||||
c['is_target'] = (start + i == target_pos)
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"collection": collection,
|
||||
"source": filename,
|
||||
"total_chunks": total,
|
||||
@@ -1012,7 +969,7 @@ def create_chunk() -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
|
||||
data = request.json or {}
|
||||
collection = data.get('collection')
|
||||
@@ -1020,13 +977,17 @@ def create_chunk() -> Tuple[Any, int]:
|
||||
metadata = data.get('metadata', {})
|
||||
|
||||
if not collection:
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定向量库 (collection)", http_status=400)
|
||||
return jsonify({"error": "请指定向量库 (collection)"}), 400
|
||||
if not content:
|
||||
return error_response("NO_CONTENT", NO_CONTENT, "切片内容不能为空", http_status=400)
|
||||
return jsonify({"error": "切片内容不能为空"}), 400
|
||||
|
||||
chunk_id = kb_manager.add_chunk(collection, content, metadata)
|
||||
|
||||
return success_response(data={"chunk_id": chunk_id}, message="切片已添加")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"chunk_id": chunk_id,
|
||||
"message": "切片已添加"
|
||||
})
|
||||
|
||||
|
||||
@document_bp.route('/chunks/<chunk_id>', methods=['PUT'])
|
||||
@@ -1052,7 +1013,7 @@ def update_chunk(chunk_id: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
|
||||
data = request.json or {}
|
||||
collection = data.get('collection')
|
||||
@@ -1060,13 +1021,13 @@ def update_chunk(chunk_id: str) -> Tuple[Any, int]:
|
||||
metadata = data.get('metadata')
|
||||
|
||||
if not collection:
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定向量库 (collection)", http_status=400)
|
||||
return jsonify({"error": "请指定向量库 (collection)"}), 400
|
||||
|
||||
success = kb_manager.update_chunk(collection, chunk_id, content=content, metadata=metadata)
|
||||
|
||||
if success:
|
||||
return success_response(message="切片已更新")
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "更新失败", http_status=500)
|
||||
return jsonify({"success": True, "message": "切片已更新"})
|
||||
return jsonify({"error": "更新失败"}), 500
|
||||
|
||||
|
||||
@document_bp.route('/chunks/<chunk_id>', methods=['DELETE'])
|
||||
@@ -1094,11 +1055,11 @@ def delete_chunk(chunk_id: str) -> Tuple[Any, int]:
|
||||
collection = request.args.get('collection')
|
||||
|
||||
if not collection:
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定向量库 (collection)", http_status=400)
|
||||
return jsonify({"error": "请指定向量库 (collection)"}), 400
|
||||
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
|
||||
# 删除切片,返回 (success, source_file)
|
||||
success, source_file = kb_manager.delete_chunk(collection, chunk_id)
|
||||
@@ -1123,8 +1084,8 @@ def delete_chunk(chunk_id: str) -> Tuple[Any, int]:
|
||||
logger.warning(f"清理哈希记录失败: {e}")
|
||||
|
||||
if success:
|
||||
return success_response(status_code=DELETE_SUCCESS, message="切片已删除")
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "删除失败", http_status=500)
|
||||
return jsonify({"success": True, "message": "切片已删除"})
|
||||
return jsonify({"error": "删除失败"}), 500
|
||||
|
||||
|
||||
@document_bp.route('/chunks/batch', methods=['DELETE'])
|
||||
@@ -1150,13 +1111,13 @@ def delete_chunks_by_source() -> Tuple[Any, int]:
|
||||
source = data.get('source')
|
||||
|
||||
if not collection:
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "请指定向量库 (collection)", http_status=400)
|
||||
return jsonify({"error": "请指定向量库 (collection)"}), 400
|
||||
if not source:
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "请指定文件名 (source)", http_status=400)
|
||||
return jsonify({"error": "请指定文件名 (source)"}), 400
|
||||
|
||||
kb_manager = _get_kb_manager()
|
||||
if not kb_manager:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "知识库管理器未初始化", http_status=503)
|
||||
return jsonify({"error": "知识库管理器未初始化"}), 503
|
||||
|
||||
# 批量删除该文件的所有切片
|
||||
try:
|
||||
@@ -1174,7 +1135,11 @@ def delete_chunks_by_source() -> Tuple[Any, int]:
|
||||
except Exception as e:
|
||||
logger.warning(f"清理哈希记录失败: {e}")
|
||||
|
||||
return success_response(data={"deleted_count": deleted_count}, message=f"已删除 {deleted_count} 个切片")
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"deleted_count": deleted_count,
|
||||
"message": f"已删除 {deleted_count} 个切片"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
@@ -33,11 +33,6 @@
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from auth.gateway import require_gateway_auth, require_role
|
||||
from core.status_codes import (
|
||||
SUCCESS, CREATED, DELETE_SUCCESS, UPDATE_SUCCESS,
|
||||
BAD_REQUEST, NOT_FOUND, INTERNAL_ERROR
|
||||
)
|
||||
from api.response_utils import success_response, error_response
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -80,10 +75,10 @@ def submit_feedback():
|
||||
user_id = data.get('user_id', '')
|
||||
|
||||
if not session_id or not query or rating is None:
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少必要参数", http_status=400)
|
||||
return jsonify({"error": "缺少必要参数"}), 400
|
||||
|
||||
if rating not in [1, -1]:
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, "rating 必须是 1 或 -1", http_status=400)
|
||||
return jsonify({"error": "rating 必须是 1 或 -1"}), 400
|
||||
|
||||
try:
|
||||
feedback_service = _get_feedback_service()
|
||||
@@ -96,14 +91,15 @@ def submit_feedback():
|
||||
reason=reason,
|
||||
user_id=user_id
|
||||
)
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"feedback_id": result['feedback_id'],
|
||||
"faq_suggested": result.get('faq_suggested', False),
|
||||
"suggestion_id": result.get('suggestion_id')
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/feedback/stats', methods=['GET'])
|
||||
@@ -116,12 +112,13 @@ def get_feedback_stats():
|
||||
try:
|
||||
feedback_db = _get_feedback_db()
|
||||
stats = feedback_db.get_feedback_stats(start_date, end_date)
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"stats": stats
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/feedback/list', methods=['GET'])
|
||||
@@ -143,13 +140,14 @@ def get_feedback_list():
|
||||
end_date=end_date,
|
||||
limit=limit
|
||||
)
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"feedbacks": feedbacks,
|
||||
"total": len(feedbacks)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/reports/weekly', methods=['GET'])
|
||||
@@ -159,12 +157,13 @@ def get_weekly_report():
|
||||
try:
|
||||
feedback_service = _get_feedback_service()
|
||||
report = feedback_service.generate_report("weekly")
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"report": report.to_dict()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/reports/monthly', methods=['GET'])
|
||||
@@ -174,12 +173,13 @@ def get_monthly_report():
|
||||
try:
|
||||
feedback_service = _get_feedback_service()
|
||||
report = feedback_service.generate_report("monthly")
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"report": report.to_dict()
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/faq', methods=['GET'])
|
||||
@@ -192,13 +192,14 @@ def get_faq_list():
|
||||
try:
|
||||
feedback_db = _get_feedback_db()
|
||||
faqs = feedback_db.get_faqs(status=status, limit=limit)
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"faqs": faqs,
|
||||
"total": len(faqs)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/faq', methods=['POST'])
|
||||
@@ -219,7 +220,7 @@ def create_faq():
|
||||
answer = data.get('answer')
|
||||
|
||||
if not question or not answer:
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少问题或答案", http_status=400)
|
||||
return jsonify({"error": "缺少问题或答案"}), 400
|
||||
|
||||
try:
|
||||
from services.feedback import FAQ
|
||||
@@ -234,14 +235,15 @@ def create_faq():
|
||||
)
|
||||
faq_id = feedback_db.add_faq(faq)
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"faq_id": faq_id,
|
||||
"status": "draft",
|
||||
"message": "FAQ已创建,请通过 /faq/<id>/approve 接口确认后生效"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/<int:faq_id>/approve', methods=['POST'])
|
||||
@@ -261,10 +263,10 @@ def approve_faq(faq_id):
|
||||
# 检查 FAQ 状态
|
||||
faq = feedback_db.get_faq(faq_id)
|
||||
if not faq:
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "FAQ不存在", http_status=404)
|
||||
return jsonify({"error": "FAQ不存在"}), 404
|
||||
|
||||
if faq.get('status') == 'approved':
|
||||
return success_response(message="FAQ已经是批准状态")
|
||||
return jsonify({"success": True, "message": "FAQ已经是批准状态"})
|
||||
|
||||
# 更新状态为 approved
|
||||
feedback_db.update_faq(faq_id, {"status": "approved"})
|
||||
@@ -277,14 +279,15 @@ def approve_faq(faq_id):
|
||||
answer=faq['answer']
|
||||
)
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"faq_id": faq_id,
|
||||
"sync_status": "synced" if sync_success else "sync_failed",
|
||||
"message": "FAQ已批准并同步到知识库"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/<int:faq_id>', methods=['PUT'])
|
||||
@@ -300,11 +303,11 @@ def update_faq(faq_id):
|
||||
# 获取更新前的 FAQ 信息(用于判断是否需要重新同步)
|
||||
old_faq = feedback_db.get_faq(faq_id)
|
||||
if not old_faq:
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "FAQ不存在", http_status=404)
|
||||
return jsonify({"error": "FAQ不存在"}), 404
|
||||
|
||||
updated = feedback_db.update_faq(faq_id, data)
|
||||
if not updated:
|
||||
return error_response("UPDATE_FAILED", INTERNAL_ERROR, "FAQ更新失败", http_status=500)
|
||||
return jsonify({"error": "FAQ更新失败"}), 500
|
||||
|
||||
# 检查是否需要重新同步向量库(question 或 answer 变更时)
|
||||
need_sync = False
|
||||
@@ -328,13 +331,14 @@ def update_faq(faq_id):
|
||||
)
|
||||
sync_status = "synced" if sync_success else "sync_failed"
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": "FAQ更新成功",
|
||||
"sync_status": sync_status
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/<int:faq_id>', methods=['DELETE'])
|
||||
@@ -361,10 +365,13 @@ def delete_faq(faq_id):
|
||||
feedback_service = _get_feedback_service()
|
||||
feedback_service._delete_faq_vectors(faq_id)
|
||||
|
||||
return success_response(data={"deleted": deleted}, message="FAQ删除成功" if deleted else "FAQ不存在")
|
||||
return jsonify({
|
||||
"success": deleted,
|
||||
"message": "FAQ删除成功" if deleted else "FAQ不存在"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/suggestions', methods=['GET'])
|
||||
@@ -378,13 +385,14 @@ def get_faq_suggestions():
|
||||
try:
|
||||
feedback_db = _get_feedback_db()
|
||||
suggestions = feedback_db.get_faq_suggestions(status=status, limit=limit)
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"suggestions": suggestions,
|
||||
"total": len(suggestions)
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/approve', methods=['POST'])
|
||||
@@ -410,16 +418,17 @@ def approve_faq_suggestion(suggestion_id):
|
||||
)
|
||||
|
||||
if result.get('success'):
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"faq_id": result['faq_id'],
|
||||
"sync_status": result.get('sync_status'),
|
||||
"message": "FAQ建议已批准并同步到知识库"
|
||||
})
|
||||
else:
|
||||
return error_response("APPROVE_FAILED", BAD_REQUEST, result.get('error', '批准失败'), http_status=400)
|
||||
return jsonify({"error": result.get('error', '批准失败')}), 400
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/reject', methods=['POST'])
|
||||
@@ -430,13 +439,13 @@ def reject_faq_suggestion(suggestion_id):
|
||||
try:
|
||||
feedback_db = _get_feedback_db()
|
||||
rejected = feedback_db.reject_faq_suggestion(suggestion_id)
|
||||
return success_response(data={
|
||||
"rejected": rejected,
|
||||
return jsonify({
|
||||
"success": rejected,
|
||||
"message": "FAQ建议已拒绝" if rejected else "建议不存在"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
# ==================== Bad Case 分析接口 ====================
|
||||
@@ -468,7 +477,8 @@ def get_bad_cases():
|
||||
for case in bad_cases:
|
||||
case['status'] = 'pending' # pending/resolved/ignored
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"bad_cases": bad_cases,
|
||||
"blacklisted_sources": blacklisted_sources,
|
||||
"suggestions": [
|
||||
@@ -479,7 +489,7 @@ def get_bad_cases():
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@feedback_bp.route('/feedback/blacklist', methods=['GET'])
|
||||
@@ -497,11 +507,12 @@ def get_chunk_blacklist():
|
||||
feedback_service = _get_feedback_service()
|
||||
blacklist = feedback_service.get_chunk_blacklist(min_dislikes=min_dislikes)
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"blacklist": list(blacklist),
|
||||
"count": len(blacklist),
|
||||
"usage": "在检索时过滤这些来源以提升回答质量"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"反馈操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
@@ -11,8 +11,6 @@
|
||||
import os
|
||||
import logging
|
||||
from flask import Blueprint, send_file, jsonify, current_app
|
||||
from core.status_codes import SUCCESS, BAD_REQUEST, NOT_FOUND, INTERNAL_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -53,7 +51,7 @@ def get_image(image_id: str):
|
||||
|
||||
# 安全检查:防止路径遍历攻击
|
||||
if '..' in image_id or '/' in image_id or '\\' in image_id:
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的图片 ID", http_status=400)
|
||||
return jsonify({"error": "无效的图片 ID"}), 400
|
||||
|
||||
images_path = get_images_base_path()
|
||||
|
||||
@@ -76,9 +74,9 @@ def get_image(image_id: str):
|
||||
return send_file(image_path, mimetype=mimetype)
|
||||
except Exception as e:
|
||||
logger.error(f"读取图片异常: {e}")
|
||||
return error_response("READ_ERROR", INTERNAL_ERROR, "读取图片失败", http_status=500)
|
||||
return jsonify({"error": "读取图片失败"}), 500
|
||||
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "图片不存在", http_status=404, image_id=image_id)
|
||||
return jsonify({"error": "图片不存在", "image_id": image_id}), 404
|
||||
|
||||
|
||||
@image_bp.route('/images/<image_id>/info', methods=['GET'])
|
||||
@@ -98,7 +96,7 @@ def get_image_info(image_id: str):
|
||||
|
||||
# 安全检查
|
||||
if '..' in image_id or '/' in image_id or '\\' in image_id:
|
||||
return error_response("BAD_REQUEST", BAD_REQUEST, "无效的图片 ID", http_status=400)
|
||||
return jsonify({"error": "无效的图片 ID"}), 400
|
||||
|
||||
images_path = get_images_base_path()
|
||||
supported_formats = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']
|
||||
@@ -111,7 +109,7 @@ def get_image_info(image_id: str):
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"image_id": image_id,
|
||||
"width": img.width,
|
||||
"height": img.height,
|
||||
@@ -122,16 +120,16 @@ def get_image_info(image_id: str):
|
||||
})
|
||||
except ImportError:
|
||||
# PIL 未安装,返回基本信息
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"image_id": image_id,
|
||||
"size_bytes": os.path.getsize(image_path),
|
||||
"url": f"/images/{image_id}"
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"读取图片信息异常: {e}")
|
||||
return error_response("READ_ERROR", INTERNAL_ERROR, "读取图片信息失败", http_status=500)
|
||||
return jsonify({"error": "读取图片信息失败"}), 500
|
||||
|
||||
return error_response("NOT_FOUND", NOT_FOUND, "图片不存在", http_status=404, image_id=image_id)
|
||||
return jsonify({"error": "图片不存在", "image_id": image_id}), 404
|
||||
|
||||
|
||||
@image_bp.route('/images/list', methods=['GET'])
|
||||
@@ -154,7 +152,7 @@ def list_images():
|
||||
images_path = get_images_base_path()
|
||||
|
||||
if not os.path.exists(images_path):
|
||||
return success_response(data={"images": [], "total": 0})
|
||||
return jsonify({"images": [], "total": 0})
|
||||
|
||||
supported_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'}
|
||||
images = []
|
||||
@@ -178,7 +176,7 @@ def list_images():
|
||||
total = len(images)
|
||||
images = images[offset:offset + limit]
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"images": images,
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
@@ -187,7 +185,7 @@ def list_images():
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"列出图片异常: {e}")
|
||||
return error_response("LIST_ERROR", INTERNAL_ERROR, "列出图片失败", http_status=500)
|
||||
return jsonify({"error": "列出图片失败"}), 500
|
||||
|
||||
|
||||
@image_bp.route('/images/stats', methods=['GET'])
|
||||
@@ -201,7 +199,7 @@ def image_stats():
|
||||
images_path = get_images_base_path()
|
||||
|
||||
if not os.path.exists(images_path):
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"total_images": 0,
|
||||
"total_size_bytes": 0,
|
||||
"supported_formats": ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp']
|
||||
@@ -221,7 +219,7 @@ def image_stats():
|
||||
total_size += os.path.getsize(filepath)
|
||||
format_counts[ext] = format_counts.get(ext, 0) + 1
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"total_images": total_count,
|
||||
"total_size_bytes": total_size,
|
||||
"total_size_mb": round(total_size / (1024 * 1024), 2),
|
||||
@@ -231,4 +229,4 @@ def image_stats():
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"获取统计信息异常: {e}")
|
||||
return error_response("STATS_ERROR", INTERNAL_ERROR, "获取统计信息失败", http_status=500)
|
||||
return jsonify({"error": "获取统计信息失败"}), 500
|
||||
|
||||
255
api/kb_routes.py
255
api/kb_routes.py
@@ -37,13 +37,6 @@ from typing import Tuple, Optional, Any
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
import logging
|
||||
|
||||
from core.status_codes import (
|
||||
SUCCESS, CREATED, DELETE_SUCCESS, UPDATE_SUCCESS, SYNC_SUCCESS,
|
||||
BAD_REQUEST, NOT_FOUND, COLLECTION_NOT_FOUND, NO_COLLECTION, TASK_CONFLICT,
|
||||
INTERNAL_ERROR, SERVICE_UNAVAILABLE, SYNC_ERROR, REINDEX_ERROR
|
||||
)
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from auth.gateway import require_gateway_auth
|
||||
|
||||
@@ -140,7 +133,10 @@ def list_collections() -> Tuple[Any, int]:
|
||||
"description": coll.description
|
||||
})
|
||||
|
||||
return success_response(data={"collections": result, "total": len(result)})
|
||||
return jsonify({
|
||||
"collections": result,
|
||||
"total": len(result)
|
||||
})
|
||||
|
||||
|
||||
@kb_bp.route('/collections', methods=['POST'])
|
||||
@@ -176,19 +172,22 @@ def create_collection() -> Tuple[Any, int]:
|
||||
description = data.get('description', '')
|
||||
|
||||
if not name:
|
||||
return error_response("INVALID_NAME", BAD_REQUEST, "向量库名称不能为空", http_status=400)
|
||||
return jsonify({"error": "向量库名称不能为空"}), 400
|
||||
|
||||
# 验证名称格式(ChromaDB 限制)
|
||||
if not name.replace('_', '').replace('-', '').isalnum():
|
||||
return error_response("INVALID_NAME_FORMAT", BAD_REQUEST, "向量库名称只能包含字母、数字、下划线和连字符", http_status=400)
|
||||
return jsonify({
|
||||
"error": "名称格式错误",
|
||||
"message": "向量库名称只能包含字母、数字、下划线和连字符"
|
||||
}), 400
|
||||
|
||||
success, message = kb_manager.create_collection(
|
||||
name, display_name, department, description
|
||||
)
|
||||
|
||||
if success:
|
||||
return success_response(data={"name": name}, status_code=CREATED, message=message, http_status=201)
|
||||
return error_response("CREATE_FAILED", BAD_REQUEST, message, http_status=400)
|
||||
return jsonify({"success": True, "message": message, "name": name}), 201
|
||||
return jsonify({"error": message}), 400
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>', methods=['PUT'])
|
||||
@@ -221,7 +220,7 @@ def update_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
# 检查向量库是否存在
|
||||
collections = kb_manager.list_collections()
|
||||
if not any(c.name == kb_name for c in collections):
|
||||
return error_response("COLLECTION_NOT_FOUND", COLLECTION_NOT_FOUND, f"向量库 '{kb_name}' 不存在", http_status=404)
|
||||
return jsonify({"error": f"向量库 '{kb_name}' 不存在"}), 404
|
||||
|
||||
# 更新元数据
|
||||
success = kb_manager.update_collection_metadata(
|
||||
@@ -231,8 +230,8 @@ def update_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
)
|
||||
|
||||
if success:
|
||||
return success_response(data=None, status_code=UPDATE_SUCCESS, message="向量库信息已更新")
|
||||
return error_response("UPDATE_FAILED", INTERNAL_ERROR, "更新失败", http_status=500)
|
||||
return jsonify({"success": True, "message": "向量库信息已更新"})
|
||||
return jsonify({"error": "更新失败"}), 500
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>', methods=['DELETE'])
|
||||
@@ -261,8 +260,12 @@ def delete_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
success, message = kb_manager.delete_collection(kb_name, delete_documents)
|
||||
|
||||
if success:
|
||||
return success_response(data={"deleted_documents": delete_documents}, status_code=DELETE_SUCCESS, message=message)
|
||||
return error_response("DELETE_FAILED", BAD_REQUEST, message, http_status=400)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": message,
|
||||
"deleted_documents": delete_documents
|
||||
})
|
||||
return jsonify({"error": message}), 400
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/documents', methods=['GET'])
|
||||
@@ -283,7 +286,11 @@ def list_collection_documents(kb_name: str) -> Tuple[Any, int]:
|
||||
|
||||
documents = kb_manager.list_documents(kb_name)
|
||||
|
||||
return success_response(data={"collection": kb_name, "documents": documents, "total": len(documents)})
|
||||
return jsonify({
|
||||
"collection": kb_name,
|
||||
"documents": documents,
|
||||
"total": len(documents)
|
||||
})
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/chunks', methods=['GET'])
|
||||
@@ -314,16 +321,21 @@ def list_collection_chunks(kb_name: str) -> Tuple[Any, int]:
|
||||
|
||||
chunks = kb_manager.list_chunks(kb_name, document_id=document_id, limit=limit, offset=offset)
|
||||
|
||||
return success_response(data={"collection": kb_name, "chunks": chunks, "total": len(chunks)})
|
||||
return jsonify({
|
||||
"collection": kb_name,
|
||||
"chunks": chunks,
|
||||
"total": len(chunks)
|
||||
})
|
||||
|
||||
|
||||
@kb_bp.route('/documents/sync', methods=['POST'])
|
||||
@require_gateway_auth
|
||||
def sync_documents() -> Tuple[Any, int]:
|
||||
"""
|
||||
触发文档向量化同步(异步任务)
|
||||
触发文档向量化同步
|
||||
|
||||
立即返回 task_id,后台线程执行同步。
|
||||
扫描文档目录,检测新增、修改、删除的文件,
|
||||
自动更新向量库索引。
|
||||
|
||||
请求体:
|
||||
{
|
||||
@@ -331,7 +343,11 @@ def sync_documents() -> Tuple[Any, int]:
|
||||
}
|
||||
|
||||
Returns:
|
||||
{"success": true, "data": {"task_id": "xxx", "message": "..."}}
|
||||
{
|
||||
"success": true,
|
||||
"results": [{"collection": "...", "status": "...", ...}],
|
||||
"synced_count": N
|
||||
}
|
||||
"""
|
||||
kb_manager, _, err = _require_multi_kb()
|
||||
if err:
|
||||
@@ -339,6 +355,7 @@ def sync_documents() -> Tuple[Any, int]:
|
||||
|
||||
from config import DOCUMENTS_PATH
|
||||
|
||||
user = request.current_user
|
||||
data = request.json or {}
|
||||
target_collection = data.get('collection')
|
||||
|
||||
@@ -346,67 +363,54 @@ def sync_documents() -> Tuple[Any, int]:
|
||||
if target_collection:
|
||||
collections_to_sync = [target_collection]
|
||||
else:
|
||||
# 同步所有向量库
|
||||
all_collections = kb_manager.list_collections()
|
||||
collections_to_sync = [c.name for c in all_collections]
|
||||
|
||||
if not collections_to_sync:
|
||||
return error_response("NO_COLLECTION", NO_COLLECTION, "没有可同步的向量库", http_status=400)
|
||||
return jsonify({"error": "没有可同步的向量库"}), 400
|
||||
|
||||
# 执行同步
|
||||
results = []
|
||||
|
||||
# 使用 sync_service 执行同步
|
||||
sync_service = current_app.config.get('SYNC_SERVICE')
|
||||
if not sync_service:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "同步服务不可用", http_status=503,
|
||||
results=[{"collection": c, "status": "warning", "message": "同步服务不可用"} for c in collections_to_sync],
|
||||
synced_count=0
|
||||
)
|
||||
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
# 检查是否有正在运行的同步任务
|
||||
running = registry.list_tasks(status='running', task_type='sync', limit=1)
|
||||
if running:
|
||||
return error_response("TASK_RUNNING", TASK_CONFLICT, f"同步任务正在执行中 (task_id: {running[0].id})", http_status=409)
|
||||
|
||||
desc = f"文档同步: {target_collection or '所有向量库'}"
|
||||
task = registry.create_task('sync', desc)
|
||||
|
||||
def _do_sync(task, sync_svc):
|
||||
processed = [0]
|
||||
|
||||
def on_change(change):
|
||||
processed[0] += 1
|
||||
registry.update_progress(
|
||||
task.id, current=processed[0],
|
||||
stage='处理文件',
|
||||
message=f"已处理: {change.document_name if hasattr(change, 'document_name') else change.document_id}"
|
||||
)
|
||||
|
||||
old_callback = sync_svc.on_change_callback
|
||||
sync_svc.on_change_callback = on_change
|
||||
if sync_service:
|
||||
try:
|
||||
registry.update_progress(task.id, stage='扫描文档', message='正在检测变更...')
|
||||
sync_result = sync_svc.sync_now()
|
||||
return {
|
||||
'collection': target_collection or 'all',
|
||||
'status': 'success',
|
||||
'message': f"同步完成: 处理 {sync_result.documents_processed} 个文档",
|
||||
'details': {
|
||||
'added': sync_result.documents_added,
|
||||
'modified': sync_result.documents_modified,
|
||||
'deleted': sync_result.documents_deleted,
|
||||
'errors': sync_result.errors,
|
||||
sync_result = sync_service.sync_now()
|
||||
results.append({
|
||||
"collection": "all",
|
||||
"status": "success",
|
||||
"message": f"同步完成: 处理 {sync_result.documents_processed} 个文档",
|
||||
"details": {
|
||||
"added": sync_result.documents_added,
|
||||
"modified": sync_result.documents_modified,
|
||||
"deleted": sync_result.documents_deleted,
|
||||
"errors": sync_result.errors
|
||||
}
|
||||
}
|
||||
finally:
|
||||
sync_svc.on_change_callback = old_callback
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"知识库操作异常: {e}")
|
||||
results.append({
|
||||
"collection": "all",
|
||||
"status": "error",
|
||||
"message": "操作失败"
|
||||
})
|
||||
else:
|
||||
# 没有 sync_service,返回提示
|
||||
for coll_name in collections_to_sync:
|
||||
results.append({
|
||||
"collection": coll_name,
|
||||
"status": "warning",
|
||||
"message": "同步服务不可用,请使用 POST /sync 端点"
|
||||
})
|
||||
|
||||
registry.start_task(task.id, _do_sync, sync_service)
|
||||
|
||||
return success_response(
|
||||
data={"task_id": task.id, "message": f"同步任务已启动,通过 GET /tasks/{task.id} 查询进度"},
|
||||
status_code=SYNC_SUCCESS,
|
||||
message="同步任务已启动"
|
||||
)
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"results": results,
|
||||
"synced_count": len([r for r in results if r["status"] == "success"])
|
||||
})
|
||||
|
||||
|
||||
@kb_bp.route('/debug/scan', methods=['GET'])
|
||||
@@ -469,16 +473,22 @@ def debug_scan() -> Tuple[Any, int]:
|
||||
@require_gateway_auth
|
||||
def reindex_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
强制重新向量化指定集合的所有文档(异步任务)
|
||||
强制重新向量化指定集合的所有文档
|
||||
|
||||
清除该集合的文档哈希记录,触发完整重新索引。
|
||||
立即返回 task_id,后台线程执行重建。
|
||||
适用于文档内容更新后需要重建索引的场景。
|
||||
|
||||
Args:
|
||||
kb_name: 向量库名称
|
||||
|
||||
Returns:
|
||||
{"success": true, "data": {"task_id": "xxx", "message": "..."}}
|
||||
{
|
||||
"success": true,
|
||||
"message": "...",
|
||||
"documents_processed": N,
|
||||
"documents_added": N,
|
||||
"errors": [...]
|
||||
}
|
||||
"""
|
||||
kb_manager, _, err = _require_multi_kb()
|
||||
if err:
|
||||
@@ -486,12 +496,14 @@ def reindex_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
|
||||
from config import DOCUMENTS_PATH
|
||||
|
||||
# 清除该集合的文档哈希记录(同步完成,很快)
|
||||
# 清除该集合的文档哈希记录
|
||||
try:
|
||||
from data.db import get_connection
|
||||
with get_connection("knowledge") as conn:
|
||||
cursor = conn.cursor()
|
||||
# 转义 LIKE 通配符,防止 kb_name 中的 % 或 _ 导致非预期匹配
|
||||
escaped_kb = kb_name.replace('%', '\\%').replace('_', '\\_')
|
||||
# 删除以 "{kb_name}/" 或 "{kb_name}\" 开头的文档哈希(兼容 Windows 和 Linux)
|
||||
cursor.execute("DELETE FROM document_hashes WHERE document_id LIKE ? ESCAPE '\\' OR document_id LIKE ? ESCAPE '\\'",
|
||||
(f"{escaped_kb}/%", f"{escaped_kb}\\%"))
|
||||
deleted = cursor.rowcount
|
||||
@@ -499,54 +511,23 @@ def reindex_collection(kb_name: str) -> Tuple[Any, int]:
|
||||
except Exception as e:
|
||||
logger.warning(f"清除哈希记录失败: {e}")
|
||||
|
||||
# 触发同步
|
||||
sync_service = current_app.config.get('SYNC_SERVICE')
|
||||
if not sync_service:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "同步服务不可用", http_status=503)
|
||||
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
# 检查是否有正在运行的重建任务
|
||||
running = registry.list_tasks(status='running', task_type='reindex', limit=1)
|
||||
if running:
|
||||
return error_response("TASK_RUNNING", TASK_CONFLICT, f"重建任务正在执行中 (task_id: {running[0].id})", http_status=409)
|
||||
|
||||
task = registry.create_task('reindex', f'重建索引: {kb_name}')
|
||||
|
||||
def _do_reindex(task, sync_svc, kb):
|
||||
"""后台执行重建索引"""
|
||||
processed = [0]
|
||||
|
||||
def on_change(change):
|
||||
processed[0] += 1
|
||||
registry.update_progress(
|
||||
task.id,
|
||||
current=processed[0],
|
||||
stage='重新索引',
|
||||
message=f"已处理: {change.document_name if hasattr(change, 'document_name') else change.document_id}"
|
||||
)
|
||||
|
||||
old_callback = sync_svc.on_change_callback
|
||||
sync_svc.on_change_callback = on_change
|
||||
if sync_service:
|
||||
try:
|
||||
registry.update_progress(task.id, stage='扫描文档', message='正在检测变更...')
|
||||
result = sync_svc.sync_now()
|
||||
return {
|
||||
'message': f"重新索引完成: 处理 {result.documents_processed} 个文档",
|
||||
'documents_processed': result.documents_processed,
|
||||
'documents_added': result.documents_added,
|
||||
'errors': result.errors,
|
||||
}
|
||||
finally:
|
||||
sync_svc.on_change_callback = old_callback
|
||||
|
||||
registry.start_task(task.id, _do_reindex, sync_service, kb_name)
|
||||
|
||||
return success_response(
|
||||
data={"task_id": task.id, "message": f"重建索引任务已启动: {kb_name},通过 GET /tasks/{task.id} 查询进度"},
|
||||
status_code=SYNC_SUCCESS,
|
||||
message="重建索引任务已启动"
|
||||
)
|
||||
result = sync_service.sync_now()
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"重新索引完成: 处理 {result.documents_processed} 个文档",
|
||||
"documents_processed": result.documents_processed,
|
||||
"documents_added": result.documents_added,
|
||||
"errors": result.errors
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"reindex 异常: {e}")
|
||||
return jsonify({"error": "操作失败,请稍后重试"}), 500
|
||||
else:
|
||||
return jsonify({"error": "同步服务不可用"}), 503
|
||||
|
||||
|
||||
@kb_bp.route('/kb/route', methods=['POST'])
|
||||
@@ -586,7 +567,7 @@ def test_routing() -> Tuple[Any, int]:
|
||||
query = data.get('query', '')
|
||||
|
||||
if not query:
|
||||
return error_response("MISSING_PARAMS", BAD_REQUEST, "请提供查询内容", http_status=400)
|
||||
return jsonify({"error": "请提供查询内容"}), 400
|
||||
|
||||
# 获取路由结果
|
||||
target_kbs = route_query(
|
||||
@@ -598,7 +579,7 @@ def test_routing() -> Tuple[Any, int]:
|
||||
# 获取意图分析
|
||||
intent = kb_router.analyze_intent(query)
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"query": query,
|
||||
"user_role": user.get("role"),
|
||||
"user_department": user.get("department", ""),
|
||||
@@ -655,10 +636,10 @@ def deprecate_document(kb_name: str, filename: str) -> Tuple[Any, int]:
|
||||
reason,
|
||||
deprecated_by=user.get('user_id', 'unknown')
|
||||
)
|
||||
return success_response(data=result)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/restore', methods=['POST'])
|
||||
@@ -687,10 +668,10 @@ def restore_document(kb_name: str, filename: str) -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
result = kb_manager.restore_document(kb_name, filename)
|
||||
return success_response(data=result)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/versions', methods=['GET'])
|
||||
@@ -738,7 +719,8 @@ def get_document_versions(kb_name: str, filename: str) -> Tuple[Any, int]:
|
||||
versions = version_query.get_document_history(kb_name, filename, limit)
|
||||
versions_data = [v.to_dict() for v in versions]
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"document_id": filename,
|
||||
"collection": kb_name,
|
||||
"versions": versions_data,
|
||||
@@ -746,7 +728,7 @@ def get_document_versions(kb_name: str, filename: str) -> Tuple[Any, int]:
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@kb_bp.route('/collections/<kb_name>/update-image-descriptions', methods=['POST'])
|
||||
@@ -778,10 +760,10 @@ def update_image_descriptions(kb_name: str) -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
result = kb_manager.update_image_descriptions(kb_name)
|
||||
return success_response(data=result)
|
||||
return jsonify(result)
|
||||
except Exception as e:
|
||||
logger.error(f"操作异常: {e}")
|
||||
return error_response("OPERATION_FAILED", INTERNAL_ERROR, "操作失败,请稍后重试", http_status=500)
|
||||
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
|
||||
|
||||
|
||||
@kb_bp.route('/collections/sync-vlm-cache', methods=['POST'])
|
||||
@@ -822,12 +804,12 @@ def sync_vlm_cache() -> Tuple[Any, int]:
|
||||
images_dir = Path(".data/images")
|
||||
|
||||
if not vlm_cache_dir.exists():
|
||||
return error_response("VLM_CACHE_NOT_FOUND", BAD_REQUEST, "VLM 缓存目录不存在", http_status=400)
|
||||
return jsonify({"success": False, "error": "VLM 缓存目录不存在"}), 400
|
||||
|
||||
# 获取所有 VLM 缓存文件
|
||||
cache_files = list(vlm_cache_dir.glob("*.txt"))
|
||||
if not cache_files:
|
||||
return success_response(data={"total_cache_files": 0, "synced_count": 0}, message="无 VLM 缓存文件")
|
||||
return jsonify({"success": True, "total_cache_files": 0, "synced_count": 0, "message": "无 VLM 缓存文件"})
|
||||
|
||||
# 构建图片 MD5 → 文件名 的映射
|
||||
image_hash_map = {}
|
||||
@@ -897,7 +879,8 @@ def sync_vlm_cache() -> Tuple[Any, int]:
|
||||
skipped_count += 1
|
||||
details.append({"cache": cache_file.name, "image": image_filename, "status": "skipped", "reason": "向量库中未找到对应切片"})
|
||||
|
||||
return success_response(data={
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"total_cache_files": len(cache_files),
|
||||
"synced_count": synced_count,
|
||||
"skipped_count": skipped_count,
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from auth.gateway import require_gateway_auth
|
||||
from core.status_codes import SUCCESS, BAD_REQUEST, FORBIDDEN, NOT_FOUND, INTERNAL_ERROR, SERVICE_UNAVAILABLE, DELETE_SUCCESS
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
session_bp = Blueprint('session', __name__)
|
||||
|
||||
@@ -36,7 +34,7 @@ def get_sessions():
|
||||
"""
|
||||
session_manager = current_app.config['SESSION_MANAGER']
|
||||
if session_manager is None:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "会话服务不可用", http_status=503)
|
||||
return jsonify({"error": "会话服务不可用"}), 503
|
||||
user_id = request.current_user["user_id"]
|
||||
|
||||
sessions = session_manager.get_user_sessions(user_id, limit=20)
|
||||
@@ -49,7 +47,7 @@ def get_sessions():
|
||||
else:
|
||||
s["preview"] = "空会话"
|
||||
|
||||
return success_response(data={"sessions": sessions})
|
||||
return jsonify({"sessions": sessions})
|
||||
|
||||
|
||||
@session_bp.route('/history/<session_id>', methods=['GET'])
|
||||
@@ -67,7 +65,7 @@ def get_history(session_id):
|
||||
"""
|
||||
session_manager = current_app.config['SESSION_MANAGER']
|
||||
if session_manager is None:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "会话服务不可用", http_status=503)
|
||||
return jsonify({"error": "会话服务不可用"}), 503
|
||||
user_id = request.current_user["user_id"]
|
||||
|
||||
# 验证会话归属
|
||||
@@ -75,11 +73,11 @@ def get_history(session_id):
|
||||
session_ids = [s["session_id"] for s in sessions]
|
||||
|
||||
if session_id not in session_ids:
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "无权访问此会话", http_status=403)
|
||||
return jsonify({"error": "无权访问此会话"}), 403
|
||||
|
||||
history = session_manager.get_history(session_id, limit=100)
|
||||
|
||||
return success_response(data={"history": history})
|
||||
return jsonify({"history": history})
|
||||
|
||||
|
||||
@session_bp.route('/session/<session_id>', methods=['DELETE'])
|
||||
@@ -88,7 +86,7 @@ def delete_session(session_id):
|
||||
"""删除会话"""
|
||||
session_manager = current_app.config['SESSION_MANAGER']
|
||||
if session_manager is None:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "会话服务不可用", http_status=503)
|
||||
return jsonify({"error": "会话服务不可用"}), 503
|
||||
user_id = request.current_user["user_id"]
|
||||
|
||||
# 验证会话归属
|
||||
@@ -96,11 +94,11 @@ def delete_session(session_id):
|
||||
session_ids = [s["session_id"] for s in sessions]
|
||||
|
||||
if session_id not in session_ids:
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "无权访问此会话", http_status=403)
|
||||
return jsonify({"error": "无权删除此会话"}), 403
|
||||
|
||||
session_manager.delete_session(session_id)
|
||||
|
||||
return success_response(status_code=DELETE_SUCCESS, message="会话已删除")
|
||||
return jsonify({"success": True, "message": "会话已删除"})
|
||||
|
||||
|
||||
@session_bp.route('/clear/<session_id>', methods=['POST'])
|
||||
@@ -109,7 +107,7 @@ def clear_history(session_id):
|
||||
"""清空会话历史(保留会话)"""
|
||||
session_manager = current_app.config['SESSION_MANAGER']
|
||||
if session_manager is None:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "会话服务不可用", http_status=503)
|
||||
return jsonify({"error": "会话服务不可用"}), 503
|
||||
user_id = request.current_user["user_id"]
|
||||
|
||||
# 验证会话归属
|
||||
@@ -117,8 +115,8 @@ def clear_history(session_id):
|
||||
session_ids = [s["session_id"] for s in sessions]
|
||||
|
||||
if session_id not in session_ids:
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "无权访问此会话", http_status=403)
|
||||
return jsonify({"error": "无权操作此会话"}), 403
|
||||
|
||||
session_manager.clear_history(session_id)
|
||||
|
||||
return success_response(message="历史已清空")
|
||||
return jsonify({"success": True, "message": "历史已清空"})
|
||||
|
||||
@@ -31,12 +31,12 @@ Example:
|
||||
"""
|
||||
|
||||
from typing import Optional, Tuple, Any
|
||||
from flask import Blueprint, request, current_app
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from auth.gateway import require_gateway_auth
|
||||
from core.status_codes import SUCCESS, SYNC_SUCCESS, SYNC_ERROR, INTERNAL_ERROR, SERVICE_UNAVAILABLE
|
||||
from core.status_codes import SYNC_SUCCESS, SYNC_ERROR, INTERNAL_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
sync_bp = Blueprint('sync', __name__)
|
||||
@@ -68,7 +68,9 @@ def _require_sync_service() -> Tuple[Optional[Any], Optional[Tuple]]:
|
||||
service = _get_sync_service()
|
||||
if not service:
|
||||
return None, error_response(
|
||||
"SERVICE_UNAVAILABLE", INTERNAL_ERROR, "同步服务未启用",
|
||||
error="SERVICE_UNAVAILABLE",
|
||||
error_code=INTERNAL_ERROR,
|
||||
message="同步服务未启用",
|
||||
http_status=503
|
||||
)
|
||||
return service, None
|
||||
@@ -80,10 +82,9 @@ def _require_sync_service() -> Tuple[Optional[Any], Optional[Tuple]]:
|
||||
@require_gateway_auth
|
||||
def trigger_sync() -> Tuple[Any, int]:
|
||||
"""
|
||||
手动触发知识库同步(异步任务)
|
||||
手动触发知识库同步
|
||||
|
||||
立即返回 task_id,后台线程执行同步。
|
||||
客户端通过 GET /tasks/<task_id> 轮询进度。
|
||||
扫描文档目录,检测变更并执行向量化处理。
|
||||
|
||||
请求体 (可选):
|
||||
{
|
||||
@@ -92,7 +93,7 @@ def trigger_sync() -> Tuple[Any, int]:
|
||||
}
|
||||
|
||||
Returns:
|
||||
成功: {"success": true, "data": {"task_id": "xxx", "message": "同步任务已启动"}}
|
||||
成功: {"success": true, "data": {"result": {...}}}
|
||||
失败: {"error": "...", "error_code": "..."}
|
||||
|
||||
Example:
|
||||
@@ -103,64 +104,21 @@ def trigger_sync() -> Tuple[Any, int]:
|
||||
if err:
|
||||
return err
|
||||
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
# 检查是否有正在运行的同步任务
|
||||
running = registry.list_tasks(status='running', task_type='sync', limit=1)
|
||||
if running:
|
||||
return error_response(
|
||||
"TASK_RUNNING", SYNC_ERROR,
|
||||
f"同步任务正在执行中 (task_id: {running[0].id}),请等待完成",
|
||||
http_status=409
|
||||
try:
|
||||
result = service.sync_now()
|
||||
return success_response(
|
||||
data={"result": result.to_dict() if hasattr(result, 'to_dict') else result},
|
||||
status_code=SYNC_SUCCESS,
|
||||
message="同步完成"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
return error_response(
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
# 创建异步任务
|
||||
task = registry.create_task('sync', '文档同步')
|
||||
|
||||
def _do_sync(task, sync_service):
|
||||
"""后台执行同步"""
|
||||
# 注册进度回调
|
||||
processed = [0]
|
||||
|
||||
def on_change(change):
|
||||
processed[0] += 1
|
||||
registry.update_progress(
|
||||
task.id,
|
||||
current=processed[0],
|
||||
stage='处理文件',
|
||||
message=f"已处理: {change.document_name if hasattr(change, 'document_name') else change.document_id}"
|
||||
)
|
||||
|
||||
old_callback = sync_service.on_change_callback
|
||||
sync_service.on_change_callback = on_change
|
||||
|
||||
try:
|
||||
registry.update_progress(task.id, stage='扫描文档', message='正在检测变更...')
|
||||
result = sync_service.sync_now()
|
||||
|
||||
result_dict = result.to_dict() if hasattr(result, 'to_dict') else {
|
||||
'documents_processed': result.documents_processed,
|
||||
'documents_added': result.documents_added,
|
||||
'documents_modified': result.documents_modified,
|
||||
'documents_deleted': result.documents_deleted,
|
||||
'errors': result.errors,
|
||||
}
|
||||
return result_dict
|
||||
|
||||
finally:
|
||||
sync_service.on_change_callback = old_callback
|
||||
|
||||
registry.start_task(task.id, _do_sync, service)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'task_id': task.id,
|
||||
'message': '同步任务已启动,通过 GET /tasks/' + task.id + ' 查询进度'
|
||||
},
|
||||
status_code=SYNC_SUCCESS,
|
||||
message="同步任务已启动"
|
||||
)
|
||||
|
||||
|
||||
@sync_bp.route('/sync/status', methods=['GET'])
|
||||
@@ -185,8 +143,12 @@ def get_sync_status() -> Tuple[Any, int]:
|
||||
"""
|
||||
service, err = _require_sync_service()
|
||||
if err:
|
||||
return error_response("SERVICE_UNAVAILABLE", SERVICE_UNAVAILABLE, "同步服务未启用", http_status=503,
|
||||
enabled=False)
|
||||
return jsonify({
|
||||
"status": "failed",
|
||||
"status_code": INTERNAL_ERROR,
|
||||
"enabled": False,
|
||||
"message": "同步服务未启用"
|
||||
})
|
||||
|
||||
try:
|
||||
# 获取状态信息
|
||||
@@ -201,11 +163,15 @@ def get_sync_status() -> Tuple[Any, int]:
|
||||
if hasattr(service, 'get_status'):
|
||||
status.update(service.get_status())
|
||||
|
||||
return success_response(data=status)
|
||||
return jsonify(status)
|
||||
except Exception as e:
|
||||
logger.error(f"状态查询异常: {e}")
|
||||
return error_response("INTERNAL_ERROR", INTERNAL_ERROR, "操作失败", http_status=500,
|
||||
enabled=True)
|
||||
return jsonify({
|
||||
"status": "failed",
|
||||
"status_code": INTERNAL_ERROR,
|
||||
"enabled": True,
|
||||
"error": "操作失败"
|
||||
})
|
||||
|
||||
|
||||
@sync_bp.route('/sync/history', methods=['GET'])
|
||||
@@ -230,11 +196,13 @@ def get_sync_history() -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
history = service.get_sync_history(limit=limit) if hasattr(service, 'get_sync_history') else []
|
||||
return success_response(data={"history": history})
|
||||
return jsonify({"history": history})
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
return error_response(
|
||||
"SYNC_ERROR", SYNC_ERROR, "操作失败",
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
@@ -263,11 +231,13 @@ def get_change_logs() -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
changes = service.get_change_logs(limit=limit, collection=collection) if hasattr(service, 'get_change_logs') else []
|
||||
return success_response(data={"changes": changes})
|
||||
return jsonify({"changes": changes})
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
return error_response(
|
||||
"SYNC_ERROR", SYNC_ERROR, "操作失败",
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
@@ -293,23 +263,27 @@ def start_sync_monitor() -> Tuple[Any, int]:
|
||||
|
||||
try:
|
||||
if hasattr(service, 'is_running') and service.is_running():
|
||||
return success_response(status_code=SYNC_SUCCESS, message="文件监控已在运行")
|
||||
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已在运行"})
|
||||
|
||||
if hasattr(service, 'start'):
|
||||
success = service.start()
|
||||
if success:
|
||||
return success_response(status_code=SYNC_SUCCESS, message="文件监控已启动")
|
||||
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已启动"})
|
||||
else:
|
||||
return error_response(
|
||||
"SYNC_ERROR", SYNC_ERROR, "启动文件监控失败",
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="启动文件监控失败",
|
||||
http_status=500
|
||||
)
|
||||
else:
|
||||
return success_response(message="文件监控功能不可用")
|
||||
return jsonify({"status": "success", "message": "文件监控功能不可用"})
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
return error_response(
|
||||
"SYNC_ERROR", SYNC_ERROR, "操作失败",
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
@@ -332,10 +306,12 @@ def stop_sync_monitor() -> Tuple[Any, int]:
|
||||
try:
|
||||
if hasattr(service, 'stop'):
|
||||
service.stop()
|
||||
return success_response(status_code=SYNC_SUCCESS, message="文件监控已停止")
|
||||
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已停止"})
|
||||
except Exception as e:
|
||||
logger.error(f"同步操作异常: {e}")
|
||||
return error_response(
|
||||
"SYNC_ERROR", SYNC_ERROR, "操作失败",
|
||||
error="SYNC_ERROR",
|
||||
error_code=SYNC_ERROR,
|
||||
message="操作失败",
|
||||
http_status=500
|
||||
)
|
||||
|
||||
@@ -1,191 +0,0 @@
|
||||
"""
|
||||
异步任务查询 API
|
||||
|
||||
提供任务状态的 JSON 轮询和 SSE 流式两种查询方式。
|
||||
|
||||
路由列表:
|
||||
GET /tasks : 任务列表
|
||||
GET /tasks/<id> : 任务状态(JSON)
|
||||
GET /tasks/<id>/progress: 任务进度(SSE 流式)
|
||||
GET /tasks/stats : 任务统计
|
||||
|
||||
后端组调用方式:
|
||||
1. POST /sync → 返回 {"task_id": "xxx", ...}
|
||||
2. GET /tasks/xxx → 轮询状态,直到 status 为 completed 或 failed
|
||||
|
||||
dev-ui 调用方式:
|
||||
1. POST /sync → 返回 task_id
|
||||
2. GET /tasks/xxx/progress → SSE 流式接收进度事件
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import Tuple, Any
|
||||
from flask import Blueprint, request, Response, stream_with_context
|
||||
from auth.gateway import require_gateway_auth
|
||||
from api.response_utils import success_response, error_response
|
||||
from core.status_codes import SUCCESS, NOT_FOUND
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
task_bp = Blueprint('tasks', __name__)
|
||||
|
||||
|
||||
def _get_registry():
|
||||
"""获取任务注册表"""
|
||||
from core.task_registry import get_registry
|
||||
return get_registry()
|
||||
|
||||
|
||||
# ==================== 任务查询 API ====================
|
||||
|
||||
@task_bp.route('/tasks', methods=['GET'])
|
||||
@require_gateway_auth
|
||||
def list_tasks() -> Tuple[Any, int]:
|
||||
"""
|
||||
获取任务列表
|
||||
|
||||
查询参数:
|
||||
status: 过滤状态(running / completed / failed / pending)
|
||||
type: 过滤类型(sync / reindex / upload / batch_upload / exam_generate / exam_grade)
|
||||
limit: 返回数量限制(默认 50)
|
||||
|
||||
Returns:
|
||||
{"success": true, "data": {"tasks": [...], "total": N}}
|
||||
"""
|
||||
registry = _get_registry()
|
||||
registry.maybe_cleanup()
|
||||
|
||||
status = request.args.get('status')
|
||||
task_type = request.args.get('type')
|
||||
limit = request.args.get('limit', 50, type=int)
|
||||
|
||||
tasks = registry.list_tasks(status=status, task_type=task_type, limit=limit)
|
||||
return success_response(
|
||||
data={
|
||||
'tasks': [t.to_dict() for t in tasks],
|
||||
'total': len(tasks)
|
||||
},
|
||||
status_code=SUCCESS,
|
||||
message="查询成功"
|
||||
)
|
||||
|
||||
|
||||
@task_bp.route('/tasks/stats', methods=['GET'])
|
||||
@require_gateway_auth
|
||||
def task_stats() -> Tuple[Any, int]:
|
||||
"""
|
||||
获取任务统计信息
|
||||
|
||||
Returns:
|
||||
{"success": true, "data": {"total": N, "by_status": {...}, "by_type": {...}}}
|
||||
"""
|
||||
registry = _get_registry()
|
||||
return success_response(
|
||||
data=registry.get_stats(),
|
||||
status_code=SUCCESS,
|
||||
message="查询成功"
|
||||
)
|
||||
|
||||
|
||||
@task_bp.route('/tasks/<task_id>', methods=['GET'])
|
||||
@require_gateway_auth
|
||||
def get_task(task_id: str) -> Tuple[Any, int]:
|
||||
"""
|
||||
获取单个任务状态(JSON 轮询接口)
|
||||
|
||||
后端组推荐使用此接口轮询任务进度。
|
||||
|
||||
轮询建议:
|
||||
- 间隔 1-2 秒
|
||||
- 当 status 为 completed 或 failed 时停止轮询
|
||||
|
||||
Returns:
|
||||
成功: {"success": true, "data": {task详情}}
|
||||
未找到: {"error": "任务不存在"}
|
||||
"""
|
||||
registry = _get_registry()
|
||||
task = registry.get_task(task_id)
|
||||
|
||||
if not task:
|
||||
return error_response(
|
||||
"TASK_NOT_FOUND", NOT_FOUND,
|
||||
f"任务不存在: {task_id}",
|
||||
http_status=404
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data=task.to_dict(),
|
||||
status_code=SUCCESS,
|
||||
message="查询成功"
|
||||
)
|
||||
|
||||
|
||||
@task_bp.route('/tasks/<task_id>/progress', methods=['GET'])
|
||||
@require_gateway_auth
|
||||
def task_progress_stream(task_id: str):
|
||||
"""
|
||||
SSE 流式任务进度推送
|
||||
|
||||
dev-ui 前端推荐使用此接口,实时接收任务进度事件。
|
||||
|
||||
SSE 事件类型:
|
||||
- start: 任务开始
|
||||
- progress: 进度更新(含 progress/current/total/stage/message)
|
||||
- complete: 任务完成(含完整结果)
|
||||
- error: 任务失败(含错误信息)
|
||||
- heartbeat: 每 15 秒发送一次保活
|
||||
|
||||
Returns:
|
||||
text/event-stream
|
||||
"""
|
||||
registry = _get_registry()
|
||||
task = registry.get_task(task_id)
|
||||
|
||||
if not task:
|
||||
return error_response(
|
||||
"TASK_NOT_FOUND", NOT_FOUND,
|
||||
f"任务不存在: {task_id}",
|
||||
http_status=404
|
||||
)
|
||||
|
||||
def generate_sse():
|
||||
"""SSE 生成器"""
|
||||
sent_complete = False
|
||||
|
||||
while True:
|
||||
# 取出缓冲区事件
|
||||
events = task.drain_events()
|
||||
|
||||
for event in events:
|
||||
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
|
||||
if event.get('type') in ('complete', 'error'):
|
||||
sent_complete = True
|
||||
|
||||
# 如果任务已结束且事件已发送完毕,退出
|
||||
if sent_complete:
|
||||
break
|
||||
|
||||
# 如果任务已完成但没有事件,发送最终状态后退出
|
||||
if task.status in ('completed', 'failed') and not events:
|
||||
final_event = {
|
||||
'type': 'complete' if task.status == 'completed' else 'error',
|
||||
'data': task.to_dict()
|
||||
}
|
||||
yield f"data: {json.dumps(final_event, ensure_ascii=False)}\n\n"
|
||||
break
|
||||
|
||||
# 心跳保活
|
||||
yield f": heartbeat\n\n"
|
||||
time.sleep(1)
|
||||
|
||||
return Response(
|
||||
stream_with_context(generate_sse()),
|
||||
mimetype='text/event-stream',
|
||||
headers={
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
)
|
||||
@@ -106,11 +106,6 @@ RECALL_MULTIPLIER = 3 # 候选池最小倍数 = top_k * 此值
|
||||
|
||||
# ----- 重排序 -----
|
||||
USE_RERANK = True
|
||||
RERANK_BACKEND = os.getenv("RERANK_BACKEND", "cloud") # cloud / local / fallback
|
||||
RERANK_CLOUD_MODEL = os.getenv("RERANK_CLOUD_MODEL", "xop3qwen8breranker")
|
||||
RERANK_CLOUD_API_KEY = os.getenv("RERANK_CLOUD_API_KEY", "")
|
||||
RERANK_CLOUD_BASE_URL = os.getenv("RERANK_CLOUD_BASE_URL", "https://maas-api.cn-huabei-1.xf-yun.com/v2/rerank")
|
||||
RERANK_CLOUD_TIMEOUT = int(os.getenv("RERANK_CLOUD_TIMEOUT", "15"))
|
||||
RERANK_CANDIDATES = 20 # 送入重排序的候选数
|
||||
RERANK_TOP_K = 15 # 重排序后保留数
|
||||
RERANK_USE_ONNX = os.getenv("RERANK_USE_ONNX", "true").lower() == "true"
|
||||
|
||||
288
config.py
288
config.py
@@ -1,288 +0,0 @@
|
||||
# RAG 知识库服务配置
|
||||
# ================================
|
||||
# 敏感信息通过环境变量注入,默认值为空字符串
|
||||
|
||||
import os
|
||||
|
||||
# 加载 .env 文件(敏感信息不写入代码,通过 .env 注入)
|
||||
try:
|
||||
from dotenv import load_dotenv
|
||||
load_dotenv(os.path.join(os.path.dirname(os.path.abspath(__file__)), ".env"))
|
||||
except ImportError:
|
||||
pass # python-dotenv 未安装时静默跳过
|
||||
|
||||
# ==============================================================================
|
||||
# 一、API 密钥与模型
|
||||
# ==============================================================================
|
||||
|
||||
# 通义千问 LLM 服务
|
||||
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
|
||||
DASHSCOPE_BASE_URL = os.getenv("DASHSCOPE_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1")
|
||||
DASHSCOPE_MODEL = os.getenv("DASHSCOPE_MODEL", "mimo-v2.5") # 文本生成模型
|
||||
RAG_CHAT_MODEL = os.getenv("RAG_CHAT_MODEL", "mimo-v2.5") # RAG 对话模型
|
||||
INTENT_MODEL = os.getenv("INTENT_MODEL", "mimo-v2.5") # 意图分析模型
|
||||
VLM_MODEL = os.getenv("VLM_MODEL", "mimo-v2.5") # 视觉语言模型(图片描述)
|
||||
|
||||
# 兼容旧变量名(逐步迁移到 DASHSCOPE_* 命名)
|
||||
API_KEY = DASHSCOPE_API_KEY
|
||||
BASE_URL = DASHSCOPE_BASE_URL
|
||||
MODEL = DASHSCOPE_MODEL
|
||||
|
||||
# ==============================================================================
|
||||
# 二、环境与功能开关
|
||||
# ==============================================================================
|
||||
|
||||
APP_ENV = os.getenv("APP_ENV", "dev") # dev / prod
|
||||
IS_DEV = APP_ENV == "dev"
|
||||
IS_PROD = APP_ENV == "prod"
|
||||
|
||||
# 开发模式开关(控制 mock token 登录、模拟用户等开发功能)
|
||||
# 默认开启,生产环境需在 .env 中设置 DEV_MODE=false
|
||||
DEV_MODE = os.getenv("DEV_MODE", "true").lower() != "false"
|
||||
|
||||
# 开发/生产环境自动切换
|
||||
ENABLE_SESSION = IS_DEV # 会话存储(仅开发环境)
|
||||
ENABLE_FEEDBACK = True # 反馈系统
|
||||
|
||||
# 扩展功能(手动开启)
|
||||
ENABLE_WEB_SEARCH = False # 网络搜索(需 SERPER_API_KEY)
|
||||
|
||||
# ==============================================================================
|
||||
# 三、路径配置
|
||||
# ==============================================================================
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__))
|
||||
MODELS_DIR = os.path.join(PROJECT_ROOT, "models")
|
||||
EMBEDDING_MODEL_PATH = os.path.join(MODELS_DIR, "bge-base-zh-v1.5")
|
||||
RERANK_MODEL_PATH = os.path.join(MODELS_DIR, "bge-reranker-base")
|
||||
_vector_store_path = os.path.join(PROJECT_ROOT, "knowledge", "vector_store")
|
||||
CHROMA_DB_PATH = os.path.join(_vector_store_path, "chroma")
|
||||
DOCUMENTS_PATH = os.path.join(PROJECT_ROOT, "documents")
|
||||
BM25_INDEXES_PATH = os.path.join(_vector_store_path, "bm25")
|
||||
|
||||
# 文件存储类型: local / smb / s3 / http
|
||||
STORAGE_TYPE = os.getenv("STORAGE_TYPE", "local")
|
||||
|
||||
# SMB/CIFS 配置
|
||||
STORAGE_SMB_HOST = os.getenv("STORAGE_SMB_HOST", "")
|
||||
STORAGE_SMB_SHARE = os.getenv("STORAGE_SMB_SHARE", "")
|
||||
STORAGE_SMB_USERNAME = os.getenv("STORAGE_SMB_USERNAME", "")
|
||||
STORAGE_SMB_PASSWORD = os.getenv("STORAGE_SMB_PASSWORD", "")
|
||||
STORAGE_SMB_DOMAIN = os.getenv("STORAGE_SMB_DOMAIN", "")
|
||||
STORAGE_SMB_BASE_PATH = os.getenv("STORAGE_SMB_BASE_PATH", "")
|
||||
|
||||
# S3 配置
|
||||
STORAGE_S3_ENDPOINT = os.getenv("STORAGE_S3_ENDPOINT", "")
|
||||
STORAGE_S3_BUCKET = os.getenv("STORAGE_S3_BUCKET", "")
|
||||
STORAGE_S3_ACCESS_KEY = os.getenv("STORAGE_S3_ACCESS_KEY", "")
|
||||
STORAGE_S3_SECRET_KEY = os.getenv("STORAGE_S3_SECRET_KEY", "")
|
||||
STORAGE_S3_REGION = os.getenv("STORAGE_S3_REGION", "us-east-1")
|
||||
|
||||
# HTTP 文件服务配置
|
||||
STORAGE_HTTP_BASE_URL = os.getenv("STORAGE_HTTP_BASE_URL", "")
|
||||
STORAGE_HTTP_TOKEN = os.getenv("STORAGE_HTTP_TOKEN", "")
|
||||
STORAGE_HTTP_TIMEOUT = int(os.getenv("STORAGE_HTTP_TIMEOUT", "60"))
|
||||
|
||||
# ==============================================================================
|
||||
# 四、设备配置(GPU / CPU)
|
||||
# ==============================================================================
|
||||
|
||||
EMBEDDING_DEVICE = os.getenv("EMBEDDING_DEVICE", os.getenv("DEVICE", "auto"))
|
||||
RERANK_DEVICE = os.getenv("RERANK_DEVICE", os.getenv("DEVICE", "auto"))
|
||||
|
||||
# ==============================================================================
|
||||
# 五、LLM 参数
|
||||
# ==============================================================================
|
||||
|
||||
# ----- 通用问答 -----
|
||||
LLM_TEMPERATURE = 0.7 # 生成温度(0=确定性,1=随机性)
|
||||
LLM_MAX_TOKENS = 3000 # 最大输出 token 数
|
||||
|
||||
# ----- 意图分析(轻量、确定性高)-----
|
||||
# INTENT_MODEL 在顶部「一、API 密钥与模型」中统一配置
|
||||
INTENT_TEMPERATURE = 0.1
|
||||
INTENT_MAX_TOKENS = 4096 # 推理模型思维链消耗大量 token,2048 偶发截断导致意图分析失败
|
||||
INTENT_HISTORY_WINDOW = 6 # 分析时取最近几条历史消息
|
||||
|
||||
# ==============================================================================
|
||||
# 六、检索参数
|
||||
# ==============================================================================
|
||||
|
||||
# ----- 混合检索 -----
|
||||
USE_MULTI_KB = True # 多向量库模式
|
||||
USE_HYBRID_SEARCH = True # 向量 + BM25 混合检索
|
||||
VECTOR_WEIGHT = 0.5 # 向量检索权重(仅在 USE_MULTI_KB=False 时生效;动态 RRF 启用时被覆盖)
|
||||
BM25_WEIGHT = 0.5 # BM25 检索权重(同上)
|
||||
RAG_SEARCH_TOP_K = 30 # 最终返回结果数(不小于 MMR_TOP_K,避免截断 MMR 输出)
|
||||
RAG_SEARCH_CANDIDATES = 100 # [死代码] 候选池大小 — 实际由 RERANK_CANDIDATES 控制,此值未传递给 engine
|
||||
RECALL_MULTIPLIER = 3 # 候选池最小倍数 = top_k * 此值
|
||||
|
||||
# ----- 重排序 -----
|
||||
USE_RERANK = True # 是否启用重排序
|
||||
RERANK_CANDIDATES = 20 # 送入重排序的候选数
|
||||
RERANK_TOP_K = 15 # 重排序后保留数
|
||||
RERANK_USE_ONNX = os.getenv("RERANK_USE_ONNX", "true").lower() == "true"
|
||||
RERANK_CONTEXT_MIN_SCORE = 0.05 # Phase 1:Rerank 分数低于此值的切片不送入 LLM
|
||||
|
||||
# ----- 云端 Reranker(DashScope API)-----
|
||||
# RERANK_BACKEND: "local"=本地模型(CPU/GPU),"cloud"=云端API,"fallback"=优先云端、失败回退本地
|
||||
RERANK_BACKEND = os.getenv("RERANK_BACKEND", "local")
|
||||
RERANK_CLOUD_MODEL = os.getenv("RERANK_CLOUD_MODEL", "xop3qwen8breranker")
|
||||
RERANK_CLOUD_API_KEY = os.getenv("RERANK_CLOUD_API_KEY", DASHSCOPE_API_KEY)
|
||||
RERANK_CLOUD_BASE_URL = os.getenv("RERANK_CLOUD_BASE_URL", "https://maas-api.cn-huabei-1.xf-yun.com/v1/rerank")
|
||||
RERANK_CLOUD_TIMEOUT = int(os.getenv("RERANK_CLOUD_TIMEOUT", "15")) # 云端请求超时(秒)
|
||||
|
||||
# ----- RRF 融合 -----
|
||||
RRF_K = 60 # RRF 常数(越大越平滑)
|
||||
DYNAMIC_RRF_ENABLED = True # 根据查询类型动态调整向量/BM25 权重
|
||||
|
||||
# ----- MMR 多样性去重 -----
|
||||
MMR_ENABLED = True
|
||||
MMR_USE_EMBEDDING = os.getenv("MMR_USE_EMBEDDING", "false").lower() == "true" # True=语义向量(慢),False=文本相似度(快,使用 jieba 词级 Jaccard)
|
||||
MMR_TOP_K = 30 # MMR 处理后保留数
|
||||
MMR_LAMBDA = 0.5 # 相关性 vs 多样性权衡(0=纯多样,1=纯相关)
|
||||
|
||||
# ----- 查询扩展 -----
|
||||
QUERY_EXPANSION_ENABLED = True
|
||||
QUERY_EXPANSION_THRESHOLD = 0.8 # 扩展词相似度阈值
|
||||
|
||||
# ----- 章节过滤 -----
|
||||
SECTION_FILTER_ENABLED = True # 查询提到章节时优先匹配对应切片
|
||||
|
||||
# ==============================================================================
|
||||
# 七、上下文构建
|
||||
# ==============================================================================
|
||||
|
||||
MAX_CONTEXT_CHUNKS = 20 # 送给 LLM 的最大文本切片数
|
||||
CONTEXT_MAX_CHARS = 8000 # Phase 2:上下文最大字符数(约 4000 token)
|
||||
CONTEXT_SOFT_LIMIT = 6000 # Phase 2:软限制,超过后只接受高分切片组
|
||||
MAX_SOURCES_RETURNED = 10 # 返回给前端的最大来源数
|
||||
MAX_HISTORY_ROUNDS = 10 # 对话历史最大轮数
|
||||
IMAGE_CONTEXT_HISTORY = 4 # 图片上下文取最近几轮历史
|
||||
DIRECT_CONTEXT_MAX_CHARS = 2000 # 直接回答模式上下文截断字符数
|
||||
|
||||
# ==============================================================================
|
||||
# 八、FAQ 与黑名单
|
||||
# ==============================================================================
|
||||
|
||||
# FAQ 召回与权重
|
||||
FAQ_RECALL_TOP_K = 3 # FAQ 集合单独召回数
|
||||
FAQ_BOOST_AMOUNT = 0.1 # FAQ 命中时距离减少量(提升排名)
|
||||
|
||||
# FAQ 时间衰减(防止过期 FAQ 长期霸榜)
|
||||
FAQ_DECAY_MONTHS = 6 # 超过此月数开始衰减
|
||||
FAQ_DECAY_RATE = 0.01 # 每超一个月的距离惩罚
|
||||
FAQ_DECAY_MAX = 0.1 # 最大衰减惩罚
|
||||
|
||||
# 黑名单(负反馈过滤)
|
||||
BLACKLIST_MIN_DISLIKES = 3 # 差评达到此数量进入黑名单
|
||||
BLACKLIST_CACHE_TTL = 300 # 黑名单缓存刷新间隔(秒)
|
||||
|
||||
# ==============================================================================
|
||||
# 九、缓存配置
|
||||
# ==============================================================================
|
||||
|
||||
# 查询结果缓存
|
||||
QUERY_CACHE_ENABLED = True
|
||||
QUERY_CACHE_SIZE = 500
|
||||
QUERY_CACHE_TTL = 3600 # 秒
|
||||
|
||||
# Embedding 缓存
|
||||
EMBEDDING_CACHE_ENABLED = True
|
||||
EMBEDDING_CACHE_SIZE = 2000
|
||||
EMBEDDING_CACHE_TTL = 86400
|
||||
|
||||
# Rerank 缓存
|
||||
RERANK_CACHE_ENABLED = True
|
||||
RERANK_CACHE_SIZE = 1000
|
||||
RERANK_CACHE_TTL = 3600
|
||||
|
||||
# 语义缓存(相似查询复用结果)
|
||||
SEMANTIC_CACHE_ENABLED = True
|
||||
SEMANTIC_CACHE_THRESHOLD = 0.92 # 相似度阈值
|
||||
|
||||
# 缓存写入最低置信度
|
||||
# 注意:ChromaDB cosine distance 范围 [0,2],score = 1 - dist
|
||||
# 当前 embedding 模型的 cosine similarity 普遍在 0.03-0.06 之间
|
||||
# 搜索管线已通过 rerank 过滤低质量结果,此处不再额外限制
|
||||
CACHE_MIN_SCORE = 0.0
|
||||
|
||||
# LLM 调用预算(当前 llm_budget 模块未集成到主流程,以下配置暂不生效)
|
||||
MAX_LLM_CALLS_PER_QUERY = 2
|
||||
MAX_QUERY_REWRITES = 1
|
||||
|
||||
# ==============================================================================
|
||||
# 十、文档解析
|
||||
# ==============================================================================
|
||||
|
||||
# MinerU 解析器
|
||||
MINERU_DEVICE_MODE = os.getenv("MINERU_DEVICE_MODE", "cpu") # cpu / cuda
|
||||
|
||||
# MinerU 在线 API(优先使用,解析效果更好)
|
||||
MINERU_API_TOKEN = os.getenv("MINERU_API_TOKEN", "") # 在 https://mineru.net/apiManage/token 申请
|
||||
MINERU_API_URL = os.getenv("MINERU_API_URL", "https://mineru.net/api/v4/extract/task")
|
||||
MINERU_PREFER_ONLINE = os.getenv("MINERU_PREFER_ONLINE", "true").lower() == "false" # 优先使用在线 API
|
||||
|
||||
# MinerU 解析模式(本地 + 云端统一配置)
|
||||
MINERU_MODEL_VERSION = os.getenv("MINERU_MODEL_VERSION", "pipeline") # 云端解析模型: pipeline(快速推荐) / vlm(高精度慢) / MinerU-HTML
|
||||
MINERU_LOCAL_BACKEND = os.getenv("MINERU_LOCAL_BACKEND", "vlm-auto-engine") # 本地解析后端: pipeline(快速推荐) / vlm-auto-engine / hybrid-auto-engine
|
||||
MINERU_ONLINE_TIMEOUT = int(os.getenv("MINERU_ONLINE_TIMEOUT", "300")) # 云端解析轮询超时(秒),大文档/VLM 模式建议 600+
|
||||
|
||||
# 分块参数
|
||||
CHUNK_SIZE = 1000
|
||||
CHUNK_OVERLAP = 100
|
||||
MIN_CHUNK_SIZE = 200
|
||||
MAX_CHUNK_SIZE = 1200
|
||||
|
||||
# 自适应 TopK(根据置信度动态调整返回数)
|
||||
ADAPTIVE_TOPK_ENABLED = True
|
||||
ADAPTIVE_LOW_CONFIDENCE = 0.5
|
||||
ADAPTIVE_HIGH_CONFIDENCE = 0.8
|
||||
ADAPTIVE_EXPAND_RATIO = 2.0
|
||||
ADAPTIVE_SHRINK_RATIO = 0.5
|
||||
ADAPTIVE_MIN_TOPK = 15
|
||||
ADAPTIVE_MAX_TOPK = 20
|
||||
|
||||
# 连续切片完整性保护(枚举/条款/清单类问题)
|
||||
CONTEXT_EXPANSION_ENABLED = True
|
||||
CONTEXT_EXPANSION_BEFORE = 1
|
||||
CONTEXT_EXPANSION_AFTER = 8
|
||||
CONTEXT_EXPANSION_MAX_CHUNKS = 50
|
||||
EXPANSION_SCORE_THRESHOLD = 0.3 # Phase 3:Rerank 分数低于此值的切片不扩展邻居
|
||||
MAX_EXPANDED_NEIGHBORS = 8 # Phase 3:每个种子切片最多扩展的邻居数
|
||||
CONFIDENCE_WARN_THRESHOLD = 0.15 # Phase 4:top-3 均分低于此值时,提示 LLM 谨慎回答
|
||||
CONFIDENCE_CAUTION_THRESHOLD = 0.30 # Phase 4:top-3 均分低于此值时,提示 LLM 优先引用原文
|
||||
ENUM_QUERY_DISABLE_TOPK_SHRINK = True
|
||||
ENUM_QUERY_MMR_LAMBDA = 0.85
|
||||
|
||||
# ----- 章节聚类救援(Section-Cluster Rescue)-----
|
||||
# 当同一 section 下多个切片(text+table)同时出现在候选集中,
|
||||
# 即使单个切片 CrossEncoder 分数很低,也视为强信号进行提升/救援。
|
||||
SECTION_CLUSTER_BOOST_ENABLED = True # 引擎层:聚类提升(rerank 后、扩展前)
|
||||
SECTION_CLUSTER_RESCUE_ENABLED = True # 路由层:聚类救援(min_score 过滤前)
|
||||
BM25_DIVERGENCE_RESCUE_ENABLED = True # 路由层:BM25-CrossEncoder 分歧检测救援
|
||||
BM25_DIVERGENCE_MAX_RANK = 3 # 仅救援 BM25 rank <= 此值的切片(top-3 是强信号)
|
||||
CLUSTER_MIN_MEMBERS = 3 # 触发聚类的最小切片数
|
||||
CLUSTER_MIN_TYPES = 2 # 触发聚类的最小类型多样性(text+table=2)
|
||||
CLUSTER_SEED_FLOOR = 0.35 # 引擎层聚类提升后的最低分数(略高于 EXPANSION_SCORE_THRESHOLD=0.3)
|
||||
CLUSTER_RESCUE_FLOOR = 0.06 # 路由层救援保底分数(略高于 RERANK_CONTEXT_MIN_SCORE=0.05)
|
||||
CLUSTER_MAX_BOOST_PER_SECTION = 8 # 引擎层:每个 section 最大提升切片数
|
||||
CLUSTER_MAX_SECTIONS = 3 # 全局最大提升/救援 section 数
|
||||
CLUSTER_MAX_RESCUE_PER_SECTION = 6 # 路由层:每个 section 最大救援切片数
|
||||
CLUSTER_SECTION_PREFIX_LEVELS = 1 # section_path 归一化保留的层级数(按章节顶层分组)
|
||||
|
||||
# ==============================================================================
|
||||
# 十一、可选功能配置
|
||||
# ==============================================================================
|
||||
|
||||
# 网络搜索(需 Serper API)
|
||||
SERPER_API_KEY = os.getenv("SERPER_API_KEY", "")
|
||||
|
||||
# ==============================================================================
|
||||
# 工具函数
|
||||
# ==============================================================================
|
||||
|
||||
def get_llm_client():
|
||||
"""获取 LLM 客户端实例"""
|
||||
from openai import OpenAI
|
||||
return OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL)
|
||||
318
core/engine.py
318
core/engine.py
@@ -77,10 +77,6 @@ try:
|
||||
CONTEXT_EXPANSION_MAX_CHUNKS, ENUM_QUERY_DISABLE_TOPK_SHRINK, ENUM_QUERY_MMR_LAMBDA,
|
||||
# Phase 3 扩展精细化
|
||||
EXPANSION_SCORE_THRESHOLD, MAX_EXPANDED_NEIGHBORS,
|
||||
# 章节聚类救援
|
||||
SECTION_CLUSTER_BOOST_ENABLED, CLUSTER_MIN_MEMBERS, CLUSTER_MIN_TYPES,
|
||||
CLUSTER_SEED_FLOOR, CLUSTER_MAX_BOOST_PER_SECTION, CLUSTER_MAX_SECTIONS,
|
||||
CLUSTER_SECTION_PREFIX_LEVELS,
|
||||
# 上下文与生成
|
||||
LLM_TEMPERATURE, LLM_MAX_TOKENS, RECALL_MULTIPLIER,
|
||||
# FAQ 与黑名单
|
||||
@@ -106,28 +102,20 @@ except ImportError:
|
||||
MMR_TOP_K = 30
|
||||
CONTEXT_EXPANSION_ENABLED = True
|
||||
CONTEXT_EXPANSION_BEFORE = 1
|
||||
CONTEXT_EXPANSION_AFTER = 8
|
||||
CONTEXT_EXPANSION_AFTER = 5
|
||||
CONTEXT_EXPANSION_MAX_CHUNKS = 24
|
||||
EXPANSION_SCORE_THRESHOLD = 0.3
|
||||
MAX_EXPANDED_NEIGHBORS = 8
|
||||
# 章节聚类救援默认值
|
||||
SECTION_CLUSTER_BOOST_ENABLED = True
|
||||
CLUSTER_MIN_MEMBERS = 3
|
||||
CLUSTER_MIN_TYPES = 2
|
||||
CLUSTER_SEED_FLOOR = 0.35
|
||||
CLUSTER_MAX_BOOST_PER_SECTION = 8
|
||||
CLUSTER_MAX_SECTIONS = 3
|
||||
CLUSTER_SECTION_PREFIX_LEVELS = 1
|
||||
MAX_EXPANDED_NEIGHBORS = 4
|
||||
ENUM_QUERY_DISABLE_TOPK_SHRINK = True
|
||||
ENUM_QUERY_MMR_LAMBDA = 0.85
|
||||
DYNAMIC_RRF_ENABLED = True
|
||||
EMBEDDING_DEVICE = "auto"
|
||||
RERANK_DEVICE = "auto"
|
||||
RERANK_USE_ONNX = False
|
||||
RERANK_BACKEND = "cloud"
|
||||
RERANK_BACKEND = "local"
|
||||
RERANK_CLOUD_MODEL = "xop3qwen8breranker"
|
||||
RERANK_CLOUD_API_KEY = ""
|
||||
RERANK_CLOUD_BASE_URL = "https://maas-api.cn-huabei-1.xf-yun.com/v2/rerank"
|
||||
RERANK_CLOUD_BASE_URL = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks"
|
||||
RERANK_CLOUD_TIMEOUT = 15
|
||||
|
||||
|
||||
@@ -669,7 +657,6 @@ class RAGEngine:
|
||||
|
||||
results_list = [vector_results]
|
||||
weights = [VECTOR_WEIGHT]
|
||||
bm25_results = None # 初始化,防止 NameError
|
||||
|
||||
if USE_HYBRID_SEARCH and self.bm25_index.bm25:
|
||||
bm25_results = self.bm25_index.search(query, top_k=recall_k)
|
||||
@@ -684,22 +671,6 @@ class RAGEngine:
|
||||
vector_w, bm25_w = self._get_dynamic_rrf_weights(query)
|
||||
weights = [vector_w, bm25_w]
|
||||
|
||||
# ========== 保留 BM25 原始 top-3 完整信息,用于下游分歧检测救援 ==========
|
||||
_bm25_raw_top3 = []
|
||||
if USE_HYBRID_SEARCH and bm25_results and bm25_results.get('ids') and bm25_results['ids'][0]:
|
||||
_bm25_ids = bm25_results['ids'][0][:3]
|
||||
_bm25_docs = bm25_results['documents'][0][:3]
|
||||
_bm25_metas = bm25_results['metadatas'][0][:3]
|
||||
_bm25_dists = (bm25_results.get('distances', [[]])[0] or [0]*3)[:3]
|
||||
for i in range(len(_bm25_ids)):
|
||||
_bm25_raw_top3.append({
|
||||
'id': _bm25_ids[i],
|
||||
'doc': _bm25_docs[i],
|
||||
'meta': _bm25_metas[i],
|
||||
'bm25_score': _bm25_dists[i],
|
||||
'rank': i + 1
|
||||
})
|
||||
|
||||
if len(results_list) > 1:
|
||||
fused_results = self.reciprocal_rank_fusion(results_list, weights)
|
||||
_debug['steps'].append({'name': 'rrf_fusion', 'count': len(fused_results['ids'][0]) if fused_results.get('ids') else 0, 'weights': [round(w, 2) for w in weights]})
|
||||
@@ -715,8 +686,6 @@ class RAGEngine:
|
||||
|
||||
is_enum_query = self._is_enumeration_query(query)
|
||||
fused_results['_enum_query'] = is_enum_query
|
||||
# 传递 BM25 原始 top-3 到路由层,用于分歧检测救援
|
||||
fused_results['_bm25_top3'] = _bm25_raw_top3
|
||||
|
||||
# 章节过滤(如果查询中提到了章节)
|
||||
fused_results = self._filter_by_section(fused_results, query)
|
||||
@@ -763,19 +732,11 @@ class RAGEngine:
|
||||
# 时间衰减(Time Decay)
|
||||
fused_results = self._apply_time_decay(fused_results)
|
||||
|
||||
# 提前附加 _debug,使聚类提升能写入调试步骤
|
||||
fused_results['_debug'] = _debug
|
||||
|
||||
# ========== 章节聚类提升:在扩展前将低分但聚类的切片提升至种子阈值 ==========
|
||||
if SECTION_CLUSTER_BOOST_ENABLED:
|
||||
fused_results = self._section_cluster_boost(fused_results, query)
|
||||
|
||||
# ========== 上下文扩展:补充强命中切片周围的连续文本(rerank 之后,防止被截断)==========
|
||||
# Phase 3:仅对高分种子扩展邻居
|
||||
before_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||
fused_results = self._expand_contiguous_chunks(fused_results, top_k=top_k,
|
||||
min_score=EXPANSION_SCORE_THRESHOLD,
|
||||
query=query)
|
||||
min_score=EXPANSION_SCORE_THRESHOLD)
|
||||
after_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||
_debug['steps'].append({'name': 'context_expansion', 'before': before_exp, 'after': after_exp})
|
||||
|
||||
@@ -787,15 +748,7 @@ class RAGEngine:
|
||||
and not (is_enum_query and ENUM_QUERY_DISABLE_TOPK_SHRINK)
|
||||
and fused_results.get('_score_source') != 'rrf'
|
||||
):
|
||||
# 根据分数来源计算相似度分数(越高越好)
|
||||
score_source = fused_results.get('_score_source')
|
||||
top_dist = fused_results['distances'][0][0]
|
||||
if score_source == 'rerank':
|
||||
# Rerank 后 distances 是相关性分数,越大越好,直接使用
|
||||
top_score = top_dist
|
||||
else:
|
||||
# 向量距离,越小越好,转为相似度
|
||||
top_score = 1.0 - top_dist
|
||||
top_score = 1.0 - fused_results['distances'][0][0] # 距离转相似度
|
||||
adjusted_k, should_retrieve, reason = self._adaptive_topk.adjust(top_score, top_k)
|
||||
if "high_confidence" in reason:
|
||||
# 高置信度时截断结果
|
||||
@@ -1134,7 +1087,7 @@ class RAGEngine:
|
||||
'metadatas': [f_metas],
|
||||
'distances': [f_scores]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -1157,7 +1110,7 @@ class RAGEngine:
|
||||
'metadatas': [f_metas],
|
||||
'distances': [f_scores]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -1172,7 +1125,7 @@ class RAGEngine:
|
||||
'metadatas': [results['metadatas'][0][:top_k]],
|
||||
'distances': [results['distances'][0][:top_k]]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
if key in results:
|
||||
truncated[key] = results[key]
|
||||
return truncated
|
||||
@@ -1207,139 +1160,14 @@ class RAGEngine:
|
||||
return None
|
||||
return self.collection
|
||||
|
||||
@staticmethod
|
||||
def _normalize_section_path(section_path: str, levels: int = None) -> str:
|
||||
"""归一化 section_path:取前 N 级路径,容忍 MinerU 标题检测误差。
|
||||
|
||||
例如: "第三章 吸烟场所的功能设置 > 第三条 文明吸烟..." → "第三章 吸烟场所的功能设置"
|
||||
"""
|
||||
if not section_path:
|
||||
return ''
|
||||
if levels is None:
|
||||
levels = CLUSTER_SECTION_PREFIX_LEVELS
|
||||
parts = [p.strip() for p in section_path.split('>')]
|
||||
return ' > '.join(parts[:levels])
|
||||
|
||||
def _section_cluster_boost(self, results: dict, query: str = '') -> dict:
|
||||
"""章节聚类提升:当同一 section 下多个切片(text+table)同时出现在候选集中,
|
||||
即使单个切片 CrossEncoder 分数很低,也将整组提升到种子阈值。
|
||||
|
||||
核心洞察:单个低分切片不可信,但同一 section 多个切片同时出现是强信号。
|
||||
提升后的切片可以作为 _expand_contiguous_chunks 的种子,触发邻居扩展。
|
||||
|
||||
Args:
|
||||
results: rerank 后的检索结果
|
||||
query: 用户查询(用于后续扩展)
|
||||
|
||||
Returns:
|
||||
修改后的 results(distances 被调整,meta 中标记 _cluster_boosted)
|
||||
"""
|
||||
if not results.get('ids') or not results['ids'][0]:
|
||||
return results
|
||||
|
||||
ids = results['ids'][0]
|
||||
metas = results.get('metadatas', [[]])[0]
|
||||
distances = results.get('distances', [[]])[0] if results.get('distances') else None
|
||||
|
||||
if not distances:
|
||||
return results
|
||||
|
||||
# 1. 按 (source, normalized_section) 分组
|
||||
from collections import defaultdict
|
||||
section_groups = defaultdict(list) # key → [(index, meta, dist)]
|
||||
|
||||
for i, (meta, dist) in enumerate(zip(metas, distances)):
|
||||
source = meta.get('source', '')
|
||||
section_path = meta.get('section', '') or meta.get('section_path', '')
|
||||
norm_section = self._normalize_section_path(section_path)
|
||||
if not source or not norm_section:
|
||||
continue
|
||||
key = (source, norm_section)
|
||||
section_groups[key].append((i, meta, dist))
|
||||
|
||||
# 2. 检测聚类信号并提升
|
||||
boost_target_dist = 1.0 - CLUSTER_SEED_FLOOR # score=0.35 → dist=0.65
|
||||
boosted_sections = []
|
||||
total_boosted = 0
|
||||
|
||||
# 按组成员数降序排列,优先处理最大聚类
|
||||
sorted_groups = sorted(section_groups.items(), key=lambda x: len(x[1]), reverse=True)
|
||||
|
||||
for (source, norm_section), members in sorted_groups:
|
||||
if len(boosted_sections) >= CLUSTER_MAX_SECTIONS:
|
||||
break
|
||||
|
||||
# 聚类信号检测:成员数 >= 阈值 且 类型多样性 >= 阈值
|
||||
chunk_types = set(m[1].get('chunk_type', 'text') for m in members)
|
||||
if len(members) < CLUSTER_MIN_MEMBERS or len(chunk_types) < CLUSTER_MIN_TYPES:
|
||||
continue
|
||||
|
||||
# 提升组内切片分数(仅提升低于阈值的)
|
||||
boost_count = 0
|
||||
for idx, meta, dist in members:
|
||||
if boost_count >= CLUSTER_MAX_BOOST_PER_SECTION:
|
||||
break
|
||||
# 只提升分数低于种子阈值的切片(高分切片不需要)
|
||||
if dist > boost_target_dist:
|
||||
distances[idx] = boost_target_dist
|
||||
meta['_cluster_boosted'] = True
|
||||
boost_count += 1
|
||||
total_boosted += 1
|
||||
|
||||
if boost_count > 0:
|
||||
boosted_sections.append({
|
||||
'source': source,
|
||||
'section': norm_section,
|
||||
'members': len(members),
|
||||
'types': list(chunk_types),
|
||||
'boosted': boost_count
|
||||
})
|
||||
|
||||
# 3. 写 debug 信息
|
||||
if boosted_sections:
|
||||
debug_info = results.get('_debug', {})
|
||||
if 'steps' not in debug_info:
|
||||
debug_info['steps'] = []
|
||||
debug_info['steps'].append({
|
||||
'name': 'section_cluster_boost',
|
||||
'sections': boosted_sections,
|
||||
'total_boosted': total_boosted
|
||||
})
|
||||
results['_debug'] = debug_info
|
||||
logger.info(f"[章节聚类提升] 提升 {total_boosted} 个切片,"
|
||||
f"涉及 {len(boosted_sections)} 个 section: "
|
||||
f"{[s['section'][:30] for s in boosted_sections]}")
|
||||
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _chunk_lexical_score(chunk_text: str, query: str) -> float:
|
||||
"""计算切片文本与查询的词法重叠度(bigram 命中率),用于辅助种子资格判定。"""
|
||||
if not chunk_text or not query:
|
||||
return 0.0
|
||||
import re
|
||||
clean_q = re.sub(r'[??!!。,,、;;::"""\'\s*#`]+', ' ', query).strip()
|
||||
if len(clean_q) < 2:
|
||||
return 0.0
|
||||
bigrams = set()
|
||||
for i in range(len(clean_q) - 1):
|
||||
w = clean_q[i:i+2].strip()
|
||||
if len(w) == 2:
|
||||
bigrams.add(w)
|
||||
if not bigrams:
|
||||
return 0.0
|
||||
matched = sum(1 for w in bigrams if w in chunk_text)
|
||||
return matched / len(bigrams)
|
||||
|
||||
def _expand_contiguous_chunks(self, results: dict, top_k: int = None,
|
||||
min_score: float = 0.0, query: str = '') -> dict:
|
||||
min_score: float = 0.0) -> dict:
|
||||
"""Add same-source same-section neighbor text chunks around strong hits.
|
||||
|
||||
Args:
|
||||
results: 检索结果
|
||||
top_k: 最大切片数
|
||||
min_score: Phase 3 最低分数阈值,仅对 Rerank 分数高于此值的种子扩展
|
||||
query: 查询文本,用于词法匹配辅助种子资格判定
|
||||
"""
|
||||
if not CONTEXT_EXPANSION_ENABLED:
|
||||
return results
|
||||
@@ -1369,7 +1197,7 @@ class RAGEngine:
|
||||
seeds = [
|
||||
(doc_id, doc, meta, dist)
|
||||
for doc_id, doc, meta, dist in items[:base_limit]
|
||||
if (meta.get('chunk_type', 'text') == 'text' or meta.get('_cluster_boosted'))
|
||||
if meta.get('chunk_type', 'text') == 'text'
|
||||
and meta.get('source')
|
||||
and self._to_int(meta.get('chunk_index')) is not None
|
||||
]
|
||||
@@ -1380,12 +1208,8 @@ class RAGEngine:
|
||||
break
|
||||
|
||||
# Phase 3:跳过分数低于阈值的种子(仅当 min_score > 0 时生效)
|
||||
# 词法匹配豁免:CrossEncoder 低分但关键词重叠度高时仍允许作为种子
|
||||
if min_score > 0 and seed_dist < min_score:
|
||||
if query and self._chunk_lexical_score(_seed_doc, query) > 0.3:
|
||||
pass # 词法匹配度高,允许作为种子
|
||||
else:
|
||||
continue
|
||||
continue
|
||||
|
||||
source = seed_meta.get('source')
|
||||
section = seed_meta.get('section', '') or seed_meta.get('section_path', '')
|
||||
@@ -1487,7 +1311,7 @@ class RAGEngine:
|
||||
'distances': [[item[3] for item in items]],
|
||||
'_expanded_context': {'added': added}
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_bm25_top3'):
|
||||
for key in ('_debug', '_score_source', '_enum_query'):
|
||||
if key in results:
|
||||
expanded[key] = results[key]
|
||||
return expanded
|
||||
@@ -1514,7 +1338,6 @@ class RAGEngine:
|
||||
sub_top_k = max(top_k, 5)
|
||||
|
||||
all_results = []
|
||||
_all_bm25_top3 = [] # 收集各子查询的 BM25 top3
|
||||
for sub_q in sub_queries:
|
||||
try:
|
||||
sub_result = self.search_knowledge(
|
||||
@@ -1525,9 +1348,6 @@ class RAGEngine:
|
||||
)
|
||||
if sub_result and sub_result.get('ids') and sub_result['ids'][0]:
|
||||
all_results.append(sub_result)
|
||||
# 收集子查询的 BM25 top3
|
||||
if sub_result.get('_bm25_top3'):
|
||||
_all_bm25_top3.extend(sub_result['_bm25_top3'])
|
||||
except Exception as e:
|
||||
logger.warning(f"子查询检索失败: '{sub_q}' - {e}")
|
||||
|
||||
@@ -1536,19 +1356,9 @@ class RAGEngine:
|
||||
|
||||
# 合并去重
|
||||
if len(all_results) == 1:
|
||||
merged = all_results[0]
|
||||
else:
|
||||
merged = self._merge_and_deduplicate(all_results, top_k)
|
||||
return all_results[0]
|
||||
|
||||
# 将收集的 BM25 top3 传递到合并结果中
|
||||
if _all_bm25_top3:
|
||||
_all_bm25_top3.sort(key=lambda x: x.get('bm25_score', 0), reverse=True)
|
||||
_all_bm25_top3 = _all_bm25_top3[:3]
|
||||
for rank, item in enumerate(_all_bm25_top3):
|
||||
item['rank'] = rank + 1
|
||||
merged['_bm25_top3'] = _all_bm25_top3
|
||||
|
||||
return merged
|
||||
return self._merge_and_deduplicate(all_results, top_k)
|
||||
|
||||
def _search_with_decomposition(
|
||||
self, query, decomposer, top_k=5, allowed_levels=None,
|
||||
@@ -1580,7 +1390,6 @@ class RAGEngine:
|
||||
|
||||
# 并行检索各子查询
|
||||
all_results = []
|
||||
_all_bm25_top3 = [] # 收集各子查询的 BM25 top3
|
||||
for sub_q in sub_queries:
|
||||
try:
|
||||
sub_result = self.search_knowledge(
|
||||
@@ -1591,9 +1400,6 @@ class RAGEngine:
|
||||
)
|
||||
if sub_result and sub_result.get('ids') and sub_result['ids'][0]:
|
||||
all_results.append(sub_result)
|
||||
# 收集子查询的 BM25 top3
|
||||
if sub_result.get('_bm25_top3'):
|
||||
_all_bm25_top3.extend(sub_result['_bm25_top3'])
|
||||
except Exception as e:
|
||||
logger.warning(f"子查询检索失败: '{sub_q}' - {e}")
|
||||
|
||||
@@ -1606,14 +1412,6 @@ class RAGEngine:
|
||||
else:
|
||||
merged = self._merge_and_deduplicate(all_results, top_k)
|
||||
|
||||
# 将收集的 BM25 top3 传递到合并结果中
|
||||
if _all_bm25_top3:
|
||||
_all_bm25_top3.sort(key=lambda x: x.get('bm25_score', 0), reverse=True)
|
||||
_all_bm25_top3 = _all_bm25_top3[:3]
|
||||
for rank, item in enumerate(_all_bm25_top3):
|
||||
item['rank'] = rank + 1
|
||||
merged['_bm25_top3'] = _all_bm25_top3
|
||||
|
||||
return merged
|
||||
|
||||
def _merge_and_deduplicate(self, results_list, top_k):
|
||||
@@ -1698,13 +1496,12 @@ class RAGEngine:
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
|
||||
def _query_single_collection(coll_name):
|
||||
"""查询单个向量库(向量 + BM25),返回 (coll_results, bm25_raw_items)"""
|
||||
"""查询单个向量库(向量 + BM25)"""
|
||||
coll_results = []
|
||||
bm25_raw_items = [] # 该 collection 的 BM25 原始结果
|
||||
try:
|
||||
coll = self.kb_manager.get_collection(coll_name)
|
||||
if not coll:
|
||||
return coll_results, bm25_raw_items
|
||||
return coll_results
|
||||
|
||||
query_kwargs = {
|
||||
"query_embeddings": [query_vector],
|
||||
@@ -1722,61 +1519,25 @@ class RAGEngine:
|
||||
if USE_HYBRID_SEARCH:
|
||||
try:
|
||||
bm25 = self.kb_manager.get_bm25_index(coll_name)
|
||||
if bm25 and bm25.bm25:
|
||||
if bm25.bm25:
|
||||
bm25_res = bm25.search(query, top_k=recall_k)
|
||||
# 兼容两种 BM25Index:core.bm25_index 返回 dict,knowledge.base 返回 tuple
|
||||
if isinstance(bm25_res, tuple):
|
||||
_ids, _docs, _metas, _dists = bm25_res
|
||||
bm25_res = {
|
||||
'ids': [_ids],
|
||||
'documents': [_docs],
|
||||
'metadatas': [_metas],
|
||||
'distances': [_dists]
|
||||
}
|
||||
if source_filter and bm25_res['metadatas'][0]:
|
||||
if source_filter and bm25_res['metadatas'] and bm25_res['metadatas'][0]:
|
||||
bm25_res = self._filter_results(bm25_res, lambda meta: meta.get('source') == source_filter)
|
||||
if bm25_res['metadatas'] and bm25_res['metadatas'][0]:
|
||||
for meta in bm25_res['metadatas'][0]:
|
||||
meta['_collection'] = coll_name
|
||||
coll_results.append(bm25_res)
|
||||
# 提取 BM25 原始 top-3(在此处直接捕获,避免与向量结果混淆)
|
||||
_bm25_ids = bm25_res['ids'][0][:3]
|
||||
_bm25_docs = bm25_res['documents'][0][:3]
|
||||
_bm25_metas = bm25_res['metadatas'][0][:3]
|
||||
_bm25_dists = (bm25_res.get('distances', [[]])[0] or [0]*3)[:3]
|
||||
for i in range(len(_bm25_ids)):
|
||||
# 确保 meta 包含 _collection(用于路由层注入时下游处理)
|
||||
bm25_meta = _bm25_metas[i]
|
||||
if '_collection' not in bm25_meta:
|
||||
bm25_meta = {**bm25_meta, '_collection': coll_name}
|
||||
bm25_raw_items.append({
|
||||
'id': _bm25_ids[i],
|
||||
'doc': _bm25_docs[i],
|
||||
'meta': bm25_meta,
|
||||
'bm25_score': _bm25_dists[i],
|
||||
})
|
||||
logger.debug(f"[BM25] {coll_name}: captured {len(bm25_raw_items)} raw items")
|
||||
except Exception as e:
|
||||
logger.debug(f"向量库 {coll_name} BM25检索失败: {e}")
|
||||
logger.debug(f"向量库 {coll_name} 检索失败: {e}")
|
||||
except Exception as e:
|
||||
logger.debug(f"多向量库检索失败: {e}")
|
||||
return coll_results, bm25_raw_items
|
||||
return coll_results
|
||||
|
||||
all_results = []
|
||||
_bm25_raw_top3 = []
|
||||
with ThreadPoolExecutor(max_workers=len(target_collections)) as executor:
|
||||
futures = {executor.submit(_query_single_collection, name): name for name in target_collections}
|
||||
for future in as_completed(futures):
|
||||
coll_results, bm25_raw_items = future.result()
|
||||
all_results.extend(coll_results)
|
||||
_bm25_raw_top3.extend(bm25_raw_items)
|
||||
|
||||
# 按 bm25_score 降序取全局 top-3
|
||||
if _bm25_raw_top3:
|
||||
_bm25_raw_top3.sort(key=lambda x: x['bm25_score'], reverse=True)
|
||||
_bm25_raw_top3 = _bm25_raw_top3[:3]
|
||||
for rank, item in enumerate(_bm25_raw_top3):
|
||||
item['rank'] = rank + 1
|
||||
all_results.extend(future.result())
|
||||
|
||||
# ========== FAQ 检索 ==========
|
||||
faq_results = self._search_faq_collection(query_vector, top_k=FAQ_RECALL_TOP_K)
|
||||
@@ -1824,8 +1585,6 @@ class RAGEngine:
|
||||
|
||||
is_enum_query = self._is_enumeration_query(query)
|
||||
fused_results['_enum_query'] = is_enum_query
|
||||
# 传递 BM25 原始 top-3 到路由层,用于分歧检测救援
|
||||
fused_results['_bm25_top3'] = _bm25_raw_top3
|
||||
|
||||
# 章节过滤(如果查询中提到了章节)
|
||||
fused_results = self._filter_by_section(fused_results, query)
|
||||
@@ -1868,19 +1627,11 @@ class RAGEngine:
|
||||
# 时间衰减
|
||||
fused_results = self._apply_time_decay(fused_results)
|
||||
|
||||
# 提前附加 _debug,使聚类提升能写入调试步骤
|
||||
fused_results['_debug'] = _debug
|
||||
|
||||
# ========== 章节聚类提升:在扩展前将低分但聚类的切片提升至种子阈值 ==========
|
||||
if SECTION_CLUSTER_BOOST_ENABLED:
|
||||
fused_results = self._section_cluster_boost(fused_results, query)
|
||||
|
||||
# ========== 上下文扩展:补充强命中切片周围的连续文本(rerank 之后,防止被截断)==========
|
||||
# Phase 3:仅对高分种子扩展邻居
|
||||
before_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||
fused_results = self._expand_contiguous_chunks(fused_results, top_k=top_k,
|
||||
min_score=EXPANSION_SCORE_THRESHOLD,
|
||||
query=query)
|
||||
min_score=EXPANSION_SCORE_THRESHOLD)
|
||||
after_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||
if _debug is not None:
|
||||
_debug['steps'].append({'name': 'context_expansion', 'before': before_exp, 'after': after_exp})
|
||||
@@ -1893,15 +1644,7 @@ class RAGEngine:
|
||||
and not (is_enum_query and ENUM_QUERY_DISABLE_TOPK_SHRINK)
|
||||
and fused_results.get('_score_source') != 'rrf'
|
||||
):
|
||||
# 根据分数来源计算相似度分数(越高越好)
|
||||
score_source = fused_results.get('_score_source')
|
||||
top_dist = fused_results['distances'][0][0]
|
||||
if score_source == 'rerank':
|
||||
# Rerank 后 distances 是相关性分数,越大越好,直接使用
|
||||
top_score = top_dist
|
||||
else:
|
||||
# 向量距离,越小越好,转为相似度
|
||||
top_score = 1.0 - top_dist
|
||||
top_score = 1.0 - fused_results['distances'][0][0] # 距离转相似度
|
||||
adjusted_k, should_retrieve, reason = self._adaptive_topk.adjust(top_score, top_k)
|
||||
if "high_confidence" in reason:
|
||||
# 高置信度时截断结果
|
||||
@@ -1951,7 +1694,7 @@ class RAGEngine:
|
||||
'metadatas': [filtered_metas],
|
||||
'distances': [filtered_distances]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -2024,7 +1767,7 @@ class RAGEngine:
|
||||
'metadatas': [filtered_metas],
|
||||
'distances': [filtered_distances]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -2140,7 +1883,7 @@ class RAGEngine:
|
||||
'metadatas': [[c['metadata'] for c in selected]],
|
||||
'distances': [[id_to_dist.get(doc_id, 0) for doc_id in selected_ids]]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -2180,7 +1923,7 @@ class RAGEngine:
|
||||
'metadatas': [[c['metadata'] for c in selected]],
|
||||
'distances': [[id_to_dist.get(c['id'], 0) for c in selected]]
|
||||
}
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
return filtered
|
||||
@@ -2289,12 +2032,9 @@ class RAGEngine:
|
||||
'_rerank_cached': cache_hit
|
||||
}
|
||||
# 保留原有标记字段
|
||||
for key in ('_debug', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context'):
|
||||
if key in results:
|
||||
reranked[key] = results[key]
|
||||
# Rerank 后 distances 语义变为 CrossEncoder 分数,更新 _score_source
|
||||
# 使自适应 TopK 能正确应用(之前 _score_source='rrf' 会导致自适应 TopK 被跳过)
|
||||
reranked['_score_source'] = 'rerank'
|
||||
return reranked
|
||||
|
||||
# ---------------- 流式生成 ----------------
|
||||
|
||||
@@ -2,10 +2,6 @@
|
||||
"""
|
||||
LLM 调用预算控制器
|
||||
|
||||
注意:此模块当前未集成到主流程(engine.py / chat_routes.py 均未调用)。
|
||||
相关配置项 MAX_LLM_CALLS_PER_QUERY / MAX_QUERY_REWRITES 暂不生效。
|
||||
未来如需启用 LLM 调用预算控制,在 chat_routes.py 的 generate_stream 中集成即可。
|
||||
|
||||
控制每次查询的 LLM 调用次数,防止过度消耗
|
||||
|
||||
功能:
|
||||
|
||||
81
core/mmr.py
81
core/mmr.py
@@ -108,44 +108,22 @@ def mmr_rerank(
|
||||
return selected
|
||||
|
||||
|
||||
def _tokenize_words(text: str) -> set:
|
||||
"""
|
||||
使用 jieba 分词并过滤噪声,返回有意义的词集合。
|
||||
|
||||
过滤规则:
|
||||
- 去除单字符词(如 "的", "了", "在")—— 这些是停用词,对区分文档无意义
|
||||
- 去除纯数字 / 纯标点
|
||||
- 保留 2 字及以上的实词
|
||||
"""
|
||||
import jieba
|
||||
words = set()
|
||||
for w in jieba.cut(text):
|
||||
w = w.strip()
|
||||
if len(w) >= 2 and not w.isdigit():
|
||||
words.add(w)
|
||||
return words
|
||||
|
||||
|
||||
def mmr_filter_by_content(
|
||||
candidates: List[Dict],
|
||||
top_k: int = 30,
|
||||
similarity_threshold: float = 0.85
|
||||
similarity_threshold: float = 0.9
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
基于 jieba 词级 Jaccard 相似度的去重(不需要 embedding)
|
||||
|
||||
与旧版字符级 set(text) 的区别:
|
||||
- 旧版:set("安全生产管理制度") → {'安','全','生','产',...},中文文档间字符集合高度重叠
|
||||
- 新版:jieba 分词 → {"安全生产", "管理制度", ...},词级集合区分度高
|
||||
基于内容相似度的去重(简化版,不需要 embedding)
|
||||
|
||||
适用于:
|
||||
- MMR_USE_EMBEDDING=False 时的快速去重
|
||||
- 避免 CPU 编码 100+ 文档的 50 秒开销
|
||||
- 没有 embedding 的情况
|
||||
- 快速去重场景
|
||||
|
||||
Args:
|
||||
candidates: 候选文档列表
|
||||
top_k: 返回数量
|
||||
similarity_threshold: 相似度阈值,超过则视为重复(默认 0.85)
|
||||
similarity_threshold: 相似度阈值,超过则视为重复
|
||||
|
||||
Returns:
|
||||
去重后的候选文档列表
|
||||
@@ -156,42 +134,35 @@ def mmr_filter_by_content(
|
||||
if len(candidates) <= top_k:
|
||||
return candidates
|
||||
|
||||
# 预分词:对所有候选文档一次性分词,避免重复调用 jieba.cut
|
||||
word_sets = []
|
||||
for c in candidates:
|
||||
content = c.get('content', c.get('document', ''))[:500]
|
||||
word_sets.append(_tokenize_words(content))
|
||||
selected = []
|
||||
remaining = candidates.copy()
|
||||
|
||||
selected_indices = []
|
||||
|
||||
for i in range(len(candidates)):
|
||||
if len(selected_indices) >= top_k:
|
||||
break
|
||||
|
||||
current_words = word_sets[i]
|
||||
if not current_words:
|
||||
# 空内容直接保留
|
||||
selected_indices.append(i)
|
||||
continue
|
||||
while len(selected) < top_k and remaining:
|
||||
current = remaining.pop(0)
|
||||
|
||||
# 检查是否与已选内容重复
|
||||
is_duplicate = False
|
||||
for j in selected_indices:
|
||||
selected_words = word_sets[j]
|
||||
if not selected_words:
|
||||
continue
|
||||
current_content = current.get('content', current.get('document', ''))[:200]
|
||||
|
||||
intersection = len(current_words & selected_words)
|
||||
union = len(current_words | selected_words)
|
||||
similarity = intersection / union if union > 0 else 0
|
||||
for s in selected:
|
||||
s_content = s.get('content', s.get('document', ''))[:200]
|
||||
|
||||
if similarity > similarity_threshold:
|
||||
is_duplicate = True
|
||||
break
|
||||
# 简单的 Jaccard 相似度
|
||||
words1 = set(current_content)
|
||||
words2 = set(s_content)
|
||||
if words1 and words2:
|
||||
intersection = len(words1 & words2)
|
||||
union = len(words1 | words2)
|
||||
similarity = intersection / union if union > 0 else 0
|
||||
|
||||
if similarity > similarity_threshold:
|
||||
is_duplicate = True
|
||||
break
|
||||
|
||||
if not is_duplicate:
|
||||
selected_indices.append(i)
|
||||
selected.append(current)
|
||||
|
||||
return [candidates[i] for i in selected_indices]
|
||||
return selected
|
||||
|
||||
|
||||
# ==================== 测试 ====================
|
||||
|
||||
@@ -49,8 +49,6 @@ _STATUS_MESSAGES: Dict[int, str] = {
|
||||
4011: "向量库不存在",
|
||||
4012: "文件内容为空",
|
||||
4013: "权限不足",
|
||||
4014: "任务不存在",
|
||||
4015: "任务冲突",
|
||||
|
||||
# 服务端错误 (50xx)
|
||||
5000: "服务器内部错误",
|
||||
@@ -61,7 +59,6 @@ _STATUS_MESSAGES: Dict[int, str] = {
|
||||
5020: "出题失败",
|
||||
5021: "批阅失败",
|
||||
5030: "图片描述更新失败",
|
||||
5040: "重建索引失败",
|
||||
}
|
||||
|
||||
|
||||
@@ -116,8 +113,6 @@ FILE_NOT_FOUND = 4010
|
||||
COLLECTION_NOT_FOUND = 4011
|
||||
NO_CONTENT = 4012
|
||||
PERMISSION_DENIED = 4013
|
||||
TASK_NOT_FOUND = 4014
|
||||
TASK_CONFLICT = 4015
|
||||
|
||||
# 服务端错误 (50xx)
|
||||
INTERNAL_ERROR = 5000
|
||||
@@ -128,4 +123,3 @@ SYNC_ERROR = 5010
|
||||
EXAM_ERROR = 5020
|
||||
GRADE_ERROR = 5021
|
||||
IMAGE_DESC_ERROR = 5030
|
||||
REINDEX_ERROR = 5040
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
"""
|
||||
异步任务注册表
|
||||
|
||||
为长时间运行的操作(同步、重建索引、上传向量化、出题、批阅)提供统一的
|
||||
任务状态跟踪机制。支持 JSON 轮询和 SSE 流式两种进度查询方式。
|
||||
|
||||
核心设计:
|
||||
- 进程内字典存储,线程安全
|
||||
- 后台线程执行长操作,立即返回 task_id
|
||||
- 任务完成后保留 1 小时自动清理
|
||||
- 单 Worker 环境下足够可靠
|
||||
|
||||
使用示例:
|
||||
from core.task_registry import get_registry
|
||||
|
||||
registry = get_registry()
|
||||
task_id = registry.start_task('sync', '文档同步', total=10)
|
||||
|
||||
# 在后台线程中更新进度
|
||||
registry.update_progress(task_id, current=5, message='处理中...')
|
||||
registry.complete_task(task_id, result={...})
|
||||
"""
|
||||
|
||||
import uuid
|
||||
import time
|
||||
import threading
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ==================== 数据模型 ====================
|
||||
|
||||
@dataclass
|
||||
class TaskInfo:
|
||||
"""任务信息"""
|
||||
id: str # 任务唯一标识
|
||||
type: str # 任务类型: sync / reindex / upload / batch_upload / exam_generate / exam_grade
|
||||
description: str # 人类可读描述
|
||||
status: str = 'pending' # pending / running / completed / failed
|
||||
progress: float = 0.0 # 0-100 百分比
|
||||
current: int = 0 # 当前处理项数
|
||||
total: int = 0 # 总项数
|
||||
stage: str = '' # 当前阶段描述
|
||||
message: str = '' # 当前步骤消息
|
||||
result: Any = None # 完成后的结果数据
|
||||
error: Optional[str] = None # 失败时的错误信息
|
||||
created_at: float = 0.0 # 创建时间戳
|
||||
started_at: Optional[float] = None
|
||||
completed_at: Optional[float] = None
|
||||
_events: List[dict] = field(default_factory=list, repr=False) # SSE 事件缓冲
|
||||
_lock: threading.Lock = field(default_factory=threading.Lock, repr=False)
|
||||
|
||||
def add_event(self, event: dict):
|
||||
"""添加 SSE 事件到缓冲区"""
|
||||
with self._lock:
|
||||
self._events.append(event)
|
||||
|
||||
def drain_events(self) -> List[dict]:
|
||||
"""取出并清空事件缓冲区"""
|
||||
with self._lock:
|
||||
events = list(self._events)
|
||||
self._events.clear()
|
||||
return events
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转为可序列化的字典"""
|
||||
d = {
|
||||
'task_id': self.id,
|
||||
'type': self.type,
|
||||
'description': self.description,
|
||||
'status': self.status,
|
||||
'progress': round(self.progress, 1),
|
||||
'current': self.current,
|
||||
'total': self.total,
|
||||
'stage': self.stage,
|
||||
'message': self.message,
|
||||
'created_at': datetime.fromtimestamp(self.created_at).isoformat(),
|
||||
}
|
||||
if self.started_at:
|
||||
d['started_at'] = datetime.fromtimestamp(self.started_at).isoformat()
|
||||
if self.completed_at:
|
||||
d['completed_at'] = datetime.fromtimestamp(self.completed_at).isoformat()
|
||||
d['duration_ms'] = int((self.completed_at - self.started_at) * 1000)
|
||||
if self.result is not None:
|
||||
d['result'] = self.result
|
||||
if self.error is not None:
|
||||
d['error'] = self.error
|
||||
return d
|
||||
|
||||
|
||||
# ==================== 任务注册表 ====================
|
||||
|
||||
class TaskRegistry:
|
||||
"""
|
||||
全局任务注册表(单例)
|
||||
|
||||
管理所有异步任务的创建、执行、状态更新和查询。
|
||||
"""
|
||||
|
||||
def __init__(self, max_workers: int = 4, task_ttl: int = 3600):
|
||||
self._tasks: Dict[str, TaskInfo] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._executor = ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='task')
|
||||
self._task_ttl = task_ttl # 已完成任务的保留时间(秒)
|
||||
self._cleanup_interval = 300 # 清理间隔(秒)
|
||||
self._last_cleanup = time.time()
|
||||
|
||||
def create_task(self, task_type: str, description: str, total: int = 0) -> TaskInfo:
|
||||
"""
|
||||
创建新任务
|
||||
|
||||
Args:
|
||||
task_type: 任务类型标识
|
||||
description: 人类可读描述
|
||||
total: 预计处理的总项数(用于计算百分比)
|
||||
|
||||
Returns:
|
||||
TaskInfo 实例
|
||||
"""
|
||||
task_id = uuid.uuid4().hex[:12]
|
||||
task = TaskInfo(
|
||||
id=task_id,
|
||||
type=task_type,
|
||||
description=description,
|
||||
status='pending',
|
||||
total=total,
|
||||
created_at=time.time(),
|
||||
)
|
||||
with self._lock:
|
||||
self._tasks[task_id] = task
|
||||
logger.info(f"[任务] 创建 {task_id}: {description}")
|
||||
return task
|
||||
|
||||
def start_task(self, task_id: str, fn: Callable, *args, **kwargs) -> str:
|
||||
"""
|
||||
在后台线程中执行任务
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
fn: 要执行的函数,签名为 fn(task, *args, **kwargs)
|
||||
*args, **kwargs: 传给 fn 的参数
|
||||
|
||||
Returns:
|
||||
task_id
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
raise ValueError(f"任务不存在: {task_id}")
|
||||
|
||||
self._submit(fn, task, args, kwargs)
|
||||
return task_id
|
||||
|
||||
def _submit(self, fn, task, args, kwargs):
|
||||
"""提交任务到线程池"""
|
||||
def _wrapper():
|
||||
task.status = 'running'
|
||||
task.started_at = time.time()
|
||||
task.add_event({'type': 'start', 'data': {'stage': task.stage or '初始化'}})
|
||||
try:
|
||||
result = fn(task, *args, **kwargs)
|
||||
task.status = 'completed'
|
||||
task.progress = 100.0
|
||||
task.completed_at = time.time()
|
||||
task.result = result
|
||||
task.add_event({'type': 'complete', 'data': task.to_dict()})
|
||||
logger.info(f"[任务] 完成 {task.id}: {task.description}")
|
||||
except Exception as e:
|
||||
task.status = 'failed'
|
||||
task.error = str(e)
|
||||
task.completed_at = time.time()
|
||||
task.add_event({'type': 'error', 'data': {'message': str(e)}})
|
||||
logger.error(f"[任务] 失败 {task.id}: {e}", exc_info=True)
|
||||
|
||||
self._executor.submit(_wrapper)
|
||||
|
||||
def get_task(self, task_id: str) -> Optional[TaskInfo]:
|
||||
"""获取任务信息"""
|
||||
with self._lock:
|
||||
return self._tasks.get(task_id)
|
||||
|
||||
def list_tasks(self, status: Optional[str] = None, task_type: Optional[str] = None,
|
||||
limit: int = 50) -> List[TaskInfo]:
|
||||
"""列出任务(按创建时间倒序)"""
|
||||
with self._lock:
|
||||
tasks = list(self._tasks.values())
|
||||
|
||||
if status:
|
||||
tasks = [t for t in tasks if t.status == status]
|
||||
if task_type:
|
||||
tasks = [t for t in tasks if t.type == task_type]
|
||||
|
||||
tasks.sort(key=lambda t: t.created_at, reverse=True)
|
||||
return tasks[:limit]
|
||||
|
||||
def update_progress(self, task_id: str, *, current: Optional[int] = None,
|
||||
total: Optional[int] = None, stage: Optional[str] = None,
|
||||
message: Optional[str] = None):
|
||||
"""
|
||||
更新任务进度(线程安全)
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
current: 当前处理项数
|
||||
total: 更新总项数
|
||||
stage: 当前阶段
|
||||
message: 当前步骤描述
|
||||
"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
|
||||
with task._lock:
|
||||
if current is not None:
|
||||
task.current = current
|
||||
if total is not None:
|
||||
task.total = total
|
||||
if stage is not None:
|
||||
task.stage = stage
|
||||
if message is not None:
|
||||
task.message = message
|
||||
|
||||
# 计算百分比
|
||||
if task.total > 0:
|
||||
task.progress = min(99.0, (task.current / task.total) * 100)
|
||||
|
||||
# 推送进度事件
|
||||
task.add_event({
|
||||
'type': 'progress',
|
||||
'data': {
|
||||
'progress': round(task.progress, 1),
|
||||
'current': task.current,
|
||||
'total': task.total,
|
||||
'stage': task.stage,
|
||||
'message': task.message,
|
||||
}
|
||||
})
|
||||
|
||||
def complete_task(self, task_id: str, result: Any = None):
|
||||
"""手动标记任务完成"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
task.status = 'completed'
|
||||
task.progress = 100.0
|
||||
task.completed_at = time.time()
|
||||
if result is not None:
|
||||
task.result = result
|
||||
task.add_event({'type': 'complete', 'data': task.to_dict()})
|
||||
|
||||
def fail_task(self, task_id: str, error: str):
|
||||
"""手动标记任务失败"""
|
||||
task = self._tasks.get(task_id)
|
||||
if not task:
|
||||
return
|
||||
task.status = 'failed'
|
||||
task.error = error
|
||||
task.completed_at = time.time()
|
||||
task.add_event({'type': 'error', 'data': {'message': error}})
|
||||
|
||||
def cleanup(self):
|
||||
"""清理过期的已完成任务"""
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
expired = [
|
||||
tid for tid, t in self._tasks.items()
|
||||
if t.status in ('completed', 'failed')
|
||||
and t.completed_at
|
||||
and (now - t.completed_at) > self._task_ttl
|
||||
]
|
||||
for tid in expired:
|
||||
del self._tasks[tid]
|
||||
|
||||
if expired:
|
||||
logger.debug(f"[任务] 清理 {len(expired)} 个过期任务")
|
||||
|
||||
def maybe_cleanup(self):
|
||||
"""定期清理(避免频繁操作)"""
|
||||
if time.time() - self._last_cleanup > self._cleanup_interval:
|
||||
self.cleanup()
|
||||
self._last_cleanup = time.time()
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
"""获取任务统计"""
|
||||
with self._lock:
|
||||
tasks = list(self._tasks.values())
|
||||
|
||||
stats = {'total': len(tasks), 'by_status': {}, 'by_type': {}}
|
||||
for t in tasks:
|
||||
stats['by_status'][t.status] = stats['by_status'].get(t.status, 0) + 1
|
||||
stats['by_type'][t.type] = stats['by_type'].get(t.type, 0) + 1
|
||||
return stats
|
||||
|
||||
|
||||
# ==================== 全局单例 ====================
|
||||
|
||||
_registry: Optional[TaskRegistry] = None
|
||||
_registry_lock = threading.Lock()
|
||||
|
||||
|
||||
def get_registry() -> TaskRegistry:
|
||||
"""获取全局任务注册表单例"""
|
||||
global _registry
|
||||
if _registry is None:
|
||||
with _registry_lock:
|
||||
if _registry is None:
|
||||
_registry = TaskRegistry()
|
||||
return _registry
|
||||
@@ -1,774 +0,0 @@
|
||||
# RAG 检索流程逻辑
|
||||
|
||||
本文档描述 RAG 知识库服务从文档解析入库到 LLM 回答的完整数据流,供开发排查和系统优化参考。
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
1. [整体架构](#一整体架构)
|
||||
2. [文档解析与入库流程](#二文档解析与入库流程)
|
||||
3. [ChromaDB 存储字段详解](#三chromadb-存储字段详解)
|
||||
4. [BM25 索引两种实现](#四bm25-索引两种实现)
|
||||
5. [检索管线完整数据流](#五检索管线完整数据流)
|
||||
6. [distances / scores 语义变化](#六distances--scores-语义变化)
|
||||
7. [路由层处理流程](#七路由层处理流程)
|
||||
8. [三重救援机制详解](#八三重救援机制详解)
|
||||
9. [图片召回与选择](#九图片召回与选择)
|
||||
10. [返回格式与溯源信息](#十返回格式与溯源信息)
|
||||
11. [配置项完整列表](#十一配置项完整列表)
|
||||
12. [单/多知识库路径说明](#十二单多知识库路径说明)
|
||||
|
||||
---
|
||||
|
||||
## 一、整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 文档入库流程 │
|
||||
│ │
|
||||
│ 文件上传 → MinerU 解析 → MinerUChunk → 语义增强 → ChromaDB 存储 │
|
||||
│ ↓ │
|
||||
│ BM25 索引构建 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ 检索问答流程 │
|
||||
│ │
|
||||
│ 用户提问 │
|
||||
│ ↓ │
|
||||
│ 意图分析(改写/子查询/意图分类) │
|
||||
│ ↓ │
|
||||
│ 语义缓存检查 ──命中──→ 直接返回缓存答案 │
|
||||
│ ↓ 未命中 │
|
||||
│ 混合检索(向量 + BM25 + 图片 + FAQ) │
|
||||
│ ↓ │
|
||||
│ RRF 融合 → 过滤链 → MMR → Rerank → 后处理 → 扩展 → 自适应 TopK │
|
||||
│ ↓ │
|
||||
│ search_hybrid(distances → scores 转换) │
|
||||
│ ↓ │
|
||||
│ 路由层:contexts 构建 → 三重救援 → 排序 → 预算截断 │
|
||||
│ ↓ │
|
||||
│ Prompt 构建 → LLM 流式生成 → 后处理 → 返回 │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、文档解析与入库流程
|
||||
|
||||
### 2.1 入口方法
|
||||
|
||||
```python
|
||||
# knowledge/manager.py
|
||||
def add_file_to_kb(self, kb_name, filepath, embedding_model=None,
|
||||
extra_metadata=None, enable_table_summary=True,
|
||||
enable_image_description=False, file_content=None) -> int
|
||||
```
|
||||
|
||||
**完整流程**:
|
||||
|
||||
```
|
||||
1. 调用 parsers.parse_document(filepath) 进行 MinerU 解析
|
||||
2. 合并跨页表格(_merge_cross_page_tables)
|
||||
3. 清理同名旧切片(查询 source == filename 的旧切片并删除)
|
||||
4. 逐切片处理:
|
||||
a. 获取 chunk_type、page、section_path
|
||||
b. 构建语义增强内容(semantic_content)
|
||||
c. 构建 metadata 字典
|
||||
d. 生成 embedding 向量
|
||||
e. 可选:LLM 生成表格摘要 / VLM 生成图片描述
|
||||
5. 批量写入 ChromaDB(collection.add)
|
||||
6. 更新 BM25 索引(bm25.add_documents + save)
|
||||
```
|
||||
|
||||
### 2.2 MinerU 解析流程
|
||||
|
||||
**三个解析入口**:
|
||||
|
||||
| 函数 | 用途 | 调用方式 |
|
||||
|------|------|---------|
|
||||
| `parse_with_mineru()` | 本地 GPU 解析 | 由 `parse_with_mineru_persistent` 在在线失败时调用 |
|
||||
| `parse_with_mineru_online()` | 在线 API 解析 | 由 `parse_with_mineru_persistent` 优先调用 |
|
||||
| `parse_with_mineru_persistent()` | 持久化解析(主入口) | 由 `parsers.parse_document()` 调用 |
|
||||
|
||||
**实际主入口是 `parse_with_mineru_persistent()`**,内部根据 `config.MINERU_PREFER_ONLINE` 自动选择在线或本地,在线失败自动回退本地。
|
||||
|
||||
**解析核心流程**(以在线为例):
|
||||
|
||||
1. 文件校验(存在性、大小 ≤ 100MB、格式校验)
|
||||
2. 计算文件 MD5 hash(前12位),用于隔离输出目录
|
||||
3. 申请上传链接 → PUT 上传文件 → 轮询查询结果(5秒间隔)→ 下载 zip 包
|
||||
4. 解析 content_list(优先 v2 格式 `_content_list_v2.json`)
|
||||
5. 逐项解析 content_list 构建 `MinerUChunk`
|
||||
|
||||
**`MinerUChunk` 数据结构**:
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MinerUChunk:
|
||||
content: str # 文本内容
|
||||
chunk_type: str # text / heading / table / image / chart / equation
|
||||
page_start: int = 1 # 起始页码(1-based)
|
||||
page_end: int = 1 # 结束页码
|
||||
text_level: int = 0 # 标题级别 (0=正文, 1=h1, 2=h2, 3=h3)
|
||||
title: str = "" # 标题文本
|
||||
section_path: str = "" # 章节路径," > " 连接
|
||||
bbox: Optional[List[float]] = None # 边界框 [x0, y0, x1, y1]
|
||||
source_file: str = "" # 源文件名
|
||||
table_html: Optional[str] = None # 表格 HTML(仅 table)
|
||||
image_path: Optional[str] = None # 图片路径(独立图片)
|
||||
images: Optional[List[Dict]] = None # 关联图片: [{"id":"abc.jpg","order":1}]
|
||||
context_before: str = "" # 图片前文本上下文
|
||||
context_after: str = "" # 图片后文本上下文
|
||||
```
|
||||
|
||||
### 2.3 section_path 生成逻辑
|
||||
|
||||
维护 `section_stack: [(level, title), ...]`,遇到新标题时:
|
||||
- 弹出栈中 `level >= 当前 level` 的项
|
||||
- 压入 `(level, title)`
|
||||
- `section_path = " > ".join([栈中所有项的 title])`
|
||||
|
||||
### 2.4 后处理三阶段
|
||||
|
||||
1. **过滤空切片**:移除 content 为空的 chunk
|
||||
2. **合并碎片**:标题+正文合并、短文本合并(阈值 `MIN_CHUNK_SIZE//2`),表格/图片/公式不参与
|
||||
3. **拆分超长**:超过 `MAX_CHUNK_SIZE` 的文本用 `split_text_with_limit` 拆分
|
||||
|
||||
### 2.5 图片处理
|
||||
|
||||
**图片来源三类**:
|
||||
1. 独立图片/图表(content_list 中 type=image/chart)→ `chunk.image_path`
|
||||
2. 表格的图片形式(type=table 有 img_path)→ `chunk.image_path`
|
||||
3. 嵌入表格 HTML 的图片(`<img src="...">`)→ `chunk.images` 列表
|
||||
|
||||
**图片路径重映射**(`parse_with_mineru_persistent` 中):
|
||||
1. 从 MinerU 输出找到图片源文件
|
||||
2. 按文件内容 MD5 重命名,移动到 `.data/images/` 目录
|
||||
3. 更新 chunk.image_path 和 chunk.images 中的路径
|
||||
|
||||
### 2.6 语义内容构建
|
||||
|
||||
**文本类型** `_build_semantic_content_for_text()`:
|
||||
```
|
||||
{标题}
|
||||
主题:{章节路径(截断到3级)}
|
||||
{正文内容}
|
||||
```
|
||||
|
||||
**表格类型** `_build_semantic_content_for_table()`:
|
||||
```
|
||||
主题:{章节路径}
|
||||
表格:{标题}(非"表格"时)
|
||||
字段:{表头列表}
|
||||
描述:该表包含N行数据,记录各字段信息
|
||||
示例:字段1=值1, 字段2=值2
|
||||
|
||||
表格内容:
|
||||
| ... | ... |
|
||||
```
|
||||
|
||||
**图片类型** `generate_lightweight_image_description()`:
|
||||
```
|
||||
图片:图2.1,系统架构图,位于「第一章 > 1.2 概述」,第5页
|
||||
前文:系统由三个模块组成...
|
||||
后文:如图所示,各模块之间...
|
||||
```
|
||||
|
||||
> **设计意图**:语义增强内容存入 ChromaDB 的 `documents` 字段,用于向量检索。增强后的内容比原始文本包含更多上下文信息,提升检索召回率。
|
||||
|
||||
---
|
||||
|
||||
## 三、ChromaDB 存储字段详解
|
||||
|
||||
### 3.1 存储结构
|
||||
|
||||
ChromaDB 使用 `collection.add(ids, documents, metadatas, embeddings)` 批量写入。
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `ids` | `List[str]` | 切片唯一标识,格式 `{filename}_{index}`,如 `"1.docx_14"` |
|
||||
| `documents` | `List[str]` | 语义增强后的内容(非原始 content) |
|
||||
| `metadatas` | `List[dict]` | 切片元数据(见下表) |
|
||||
| `embeddings` | `List[List[float]]` | 768维向量,由 `embedding_model.encode(semantic_content)` 生成 |
|
||||
|
||||
### 3.2 metadatas 字段详解
|
||||
|
||||
| 字段 | 类型 | 来源 | 作用 | 示例 |
|
||||
|------|------|------|------|------|
|
||||
| `chunk_id` | str | `{filename}_{index}` | 切片唯一标识(与 ids 相同) | `"1.docx_14"` |
|
||||
| `chunk_index` | int | 入库时赋值 `i` | 切片在文件中的序号,用于邻居扩展和救援 | `14` |
|
||||
| `chunk_type` | str | MinerU 解析 | 切片类型,驱动检索和展示逻辑 | `"text"` |
|
||||
| `source` | str | 文件名 | 源文件名,用于黑名单过滤和来源展示 | `"1.docx"` |
|
||||
| `collection` | str | 入库参数 | 所属向量库名,确保跨库可追溯 | `"public_kb"` |
|
||||
| `doc_type` | str | 文件扩展名映射 | 文档类型,驱动前端差异化溯源展示 | `"word"` |
|
||||
| `page` | int | MinerU 解析 | 起始页码(仅 PDF 可靠) | `5` |
|
||||
| `page_end` | int | MinerU 解析 | 结束页码 | `5` |
|
||||
| `section` | str | MinerU `section_path` | 章节路径,用于章节过滤、聚类救援、prompt 标题注入 | `"第一章 > 1.1 概述"` |
|
||||
| `status` | str | 默认 `"active"` | 切片状态,`deprecated` 时被引擎过滤 | `"active"` |
|
||||
| `version` | str | 默认 `"v1"` | 版本号,用于缓存失效 | `"v1"` |
|
||||
| `images_json` | str | `json.dumps(chunk.images)` | 关联图片列表的 JSON 序列化,图片选择时反序列化 | `'[{"id":"abc.jpg","order":1}]'` |
|
||||
| `image_path` | str | MinerU 解析 | 图片文件名(不含目录),用于图片 URL 生成 | `"0569dd285537.jpg"` |
|
||||
|
||||
> **注意**:`chunk_id` 与 `ids` 重复存储。`ids` 是 ChromaDB 的主键,`chunk_id` 存在 metadata 中便于路由层按 metadata 查找。两者值相同但用途不同。
|
||||
|
||||
> **命名不一致**:MinerU 中字段名 `section_path`,入库时映射为 metadata 的 `section`。路由层代码中两种名称混用(`meta.get('section')` 和 `meta.get('section_path')`),通常用 `or` 兼容:`meta.get('section', '') or meta.get('section_path', '')`。
|
||||
|
||||
### 3.3 chunk_type 取值
|
||||
|
||||
| chunk_type | 说明 | 检索行为 | 展示行为 |
|
||||
|-----------|------|---------|---------|
|
||||
| `text` | 普通文本 | 正常参与向量检索 | 正常展示 |
|
||||
| `heading` | 标题 | 正常参与向量检索 | 正常展示 |
|
||||
| `table` | 表格 | 走图片独立检索通道;有表格保护 | 受 `max_chunks` 豁免;可被表格救援找回 |
|
||||
| `image` | 图片 | 走图片独立检索通道 | 用 `full_description` 替换 doc |
|
||||
| `chart` | 图表 | 走图片独立检索通道 | 同 image |
|
||||
| `equation` | 公式 | 正常参与向量检索 | 正常展示 |
|
||||
| `faq` | FAQ | FAQ 独立检索通道 | 享受分数加权 |
|
||||
|
||||
---
|
||||
|
||||
## 四、BM25 索引两种实现
|
||||
|
||||
项目中存在两个 `BM25Index` 类,返回格式有重要差异。
|
||||
|
||||
### 4.1 对比表
|
||||
|
||||
| 维度 | `core/bm25_index.py` | `knowledge/base.py` |
|
||||
|------|---------------------|---------------------|
|
||||
| **使用场景** | 单知识库路径(当前不执行) | 多知识库路径(当前活跃) |
|
||||
| **实例管理** | 全局单例 `RAGEngine.bm25_index` | 每个向量库独立实例 |
|
||||
| **持久化** | 单一 `bm25_index.pkl` | `{kb_name}.pkl` 每库独立 |
|
||||
| **search() 返回** | `Dict`(ChromaDB 兼容格式) | `Tuple[List, List, List, List]` |
|
||||
| **返回值解构** | `result['ids'][0]` | `ids, docs, metas, scores = bm25.search()` |
|
||||
| **列表嵌套** | `[['id1', 'id2']]`(嵌套列表) | `['id1', 'id2']`(扁平列表) |
|
||||
| **分数字段名** | `distances` | 第4个返回值(匿名) |
|
||||
| **add_documents 模式** | 替换(覆盖) | 追加 + 去重 |
|
||||
|
||||
### 4.2 兼容处理
|
||||
|
||||
多知识库路径中,`_search_multi_kb` 方法对 `knowledge/base.py` 的 tuple 返回值做了兼容转换:
|
||||
|
||||
```python
|
||||
# core/engine.py _search_multi_kb 中
|
||||
if isinstance(bm25_res, tuple):
|
||||
_ids, _docs, _metas, _dists = bm25_res
|
||||
bm25_res = {
|
||||
'ids': [_ids], 'documents': [_docs],
|
||||
'metadatas': [_metas], 'distances': [_dists]
|
||||
}
|
||||
```
|
||||
|
||||
> **设计建议**:两个 BM25Index 实现有重叠功能,未来可考虑统一为 dict 返回格式,消除兼容转换代码。
|
||||
|
||||
---
|
||||
|
||||
## 五、检索管线完整数据流
|
||||
|
||||
### 5.1 入口方法
|
||||
|
||||
```python
|
||||
# api/chat_routes.py
|
||||
search_result = search_hybrid(retrieval_query, top_k, candidates, allowed_collections, sub_queries)
|
||||
|
||||
# search_hybrid 内部调用
|
||||
result = engine.search_knowledge(query, top_k, allowed_levels, collections, sub_queries)
|
||||
# 然后添加 scores 字段
|
||||
```
|
||||
|
||||
### 5.2 search_knowledge 完整流程
|
||||
|
||||
```
|
||||
query (str)
|
||||
│
|
||||
├─ [1] 缓存检查 ──命中──→ 直接返回
|
||||
│
|
||||
├─ [2] 外部子查询(IntentAnalyzer 生成的 sub_queries)
|
||||
│ → _search_with_sub_queries() → 合并 → 返回
|
||||
│
|
||||
├─ [3] 查询拆分(QueryDecomposer 判断)
|
||||
│ → _search_with_decomposition() → 合并 → 返回
|
||||
│
|
||||
├─ [4] 查询扩展(当前仅构建 expanded_queries,未用于多查询检索)
|
||||
│
|
||||
├─ [5] 分支:USE_MULTI_KB=True → _search_multi_kb() → 返回
|
||||
│ USE_MULTI_KB=False → 单知识库路径(当前不执行)
|
||||
│
|
||||
└─ 单知识库路径(以下步骤在 _search_multi_kb 中对称存在):
|
||||
│
|
||||
├─ [6] where 过滤构建(security_level / source)
|
||||
│
|
||||
├─ [7] 向量编码 → query_vector (768维)
|
||||
│
|
||||
├─ [8] 向量检索 → vector_results
|
||||
│ {ids:[[]], documents:[[]], metadatas:[[]], distances:[[]]}
|
||||
│
|
||||
├─ [9] 图片独立检索 → image_results → 合并
|
||||
│
|
||||
├─ [10] FAQ 独立检索 → faq_results → 合并
|
||||
│
|
||||
├─ [11] BM25 检索 → bm25_results
|
||||
│ + 捕获 BM25 原始 top-3 → _bm25_raw_top3
|
||||
│
|
||||
├─ [12] 动态 RRF 权重 → (vector_w, bm25_w)
|
||||
│
|
||||
├─ [13] RRF 融合 → fused_results
|
||||
│ {distances:[[rrf_score]], '_score_source':'rrf'}
|
||||
│ ids 带 collection 前缀:"public_kb/filename_3"
|
||||
│
|
||||
├─ [14] 废止过滤 → 移除 status != "active" 的切片
|
||||
│
|
||||
├─ [15] 枚举标记 → fused_results['_enum_query'] = bool
|
||||
│
|
||||
├─ [16] BM25 top3 传递 → fused_results['_bm25_top3']
|
||||
│
|
||||
├─ [17] 章节过滤 → 按查询中的章节关键词过滤
|
||||
│
|
||||
├─ [18] 上下文扩展 1(MMR前,min_score=0)→ 补齐邻居
|
||||
│
|
||||
├─ [19] MMR 去重 → 缩减到 MMR_TOP_K=30
|
||||
│
|
||||
├─ [20] Rerank 重排 → distances 变为 rerank 分数 [0,1]
|
||||
│ '_reranked'=True, '_score_source' 仍为 'rrf'
|
||||
│
|
||||
├─ [21] FAQ 加权 → FAQ 切片 distances -= 0.1
|
||||
│
|
||||
├─ [22] 黑名单过滤 → 移除黑名单 source
|
||||
│
|
||||
├─ [23] 时间衰减 → 老 FAQ distances += decay
|
||||
│
|
||||
├─ [24] 章节聚类提升 → 低分但聚类的切片 distances = 0.65
|
||||
│
|
||||
├─ [25] 上下文扩展 2(Rerank后,min_score=0.3)→ 高分种子邻居
|
||||
│
|
||||
├─ [26] 自适应 TopK → 可能截断结果
|
||||
│ ⚠️ 当前 bug: _score_source='rrf' 导致此步被跳过
|
||||
│
|
||||
└─ [27] 缓存写入 + 返回
|
||||
```
|
||||
|
||||
### 5.3 特殊键在管线中的传递
|
||||
|
||||
以下键在过滤/截断方法中需要手动复制(否则会丢失):
|
||||
|
||||
```python
|
||||
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
|
||||
if key in results:
|
||||
filtered[key] = results[key]
|
||||
```
|
||||
|
||||
| 键 | 写入位置 | 读取位置 | 作用 |
|
||||
|----|---------|---------|------|
|
||||
| `_debug` | 每步追加 step | 返回给前端(dev 模式) | 检索调试信息 |
|
||||
| `_score_source` | RRF 融合写入 `'rrf'` | 自适应 TopK 判断 | 区分 distances 语义 |
|
||||
| `_enum_query` | 枚举检测后写入 | MMR lambda 选择、自适应 TopK | 枚举查询保留更多上下文 |
|
||||
| `_expanded_context` | 扩展时写入 | 返回给前端 | 扩展统计 |
|
||||
| `_bm25_top3` | BM25 检索后写入 | 路由层分歧检测救援 | BM25 原始 top-3 切片信息 |
|
||||
| `_reranked` | Rerank 后写入 | `search_hybrid` 中判断 | 区分 distances 是距离还是分数 |
|
||||
| `_cluster_boosted` | 聚类提升写入 meta | 上下文扩展时作为种子资格 | 标记被引擎层提升的切片 |
|
||||
|
||||
### 5.4 多知识库并发检索
|
||||
|
||||
`_search_multi_kb` 使用 `ThreadPoolExecutor` 并行查询各向量库:
|
||||
|
||||
```python
|
||||
with ThreadPoolExecutor(max_workers=len(target_collections)) as executor:
|
||||
futures = {executor.submit(_query_single_collection, name): name for name in target_collections}
|
||||
for future in as_completed(futures):
|
||||
coll_results, bm25_raw_items = future.result()
|
||||
all_results.extend(coll_results)
|
||||
_bm25_raw_top3.extend(bm25_raw_items)
|
||||
```
|
||||
|
||||
每个向量库独立查询:向量检索 + BM25 检索,结果标记 `_collection = coll_name`。
|
||||
|
||||
### 5.5 子查询路径的 BM25 top3 传递
|
||||
|
||||
`_search_with_sub_queries` 和 `_search_with_decomposition` 合并结果时,会收集各子查询的 `_bm25_top3`,按 `bm25_score` 降序取全局 top-3,确保路由层分歧检测在子查询路径下也能工作。
|
||||
|
||||
---
|
||||
|
||||
## 六、distances / scores 语义变化
|
||||
|
||||
`distances` 字段在管线各阶段含义不同,是理解检索逻辑的关键。
|
||||
|
||||
### 6.1 语义变化表
|
||||
|
||||
| 阶段 | distances 含义 | 方向 | 范围 | 标记 |
|
||||
|------|---------------|------|------|------|
|
||||
| ChromaDB 向量检索 | cosine distance | 越小越好 | [0, 2] | - |
|
||||
| BM25 检索 | BM25 原始分数 | 越大越好 | [0, ∞) | - |
|
||||
| RRF 融合后 | RRF 分数 | 越大越好 | [0, ~0.05] | `_score_source='rrf'` |
|
||||
| Rerank 后 | CrossEncoder 相关性分数 | 越大越好 | [0, 1] | `_reranked=True` |
|
||||
| FAQ 加权后 | rerank 分数 - 0.1 | 越大越好 | [-0.1, 1] | - |
|
||||
| 时间衰减后 | 距离 + decay | 越大越差 | - | - |
|
||||
| 聚类提升后 | 被设为 `1.0 - CLUSTER_SEED_FLOOR` = 0.65 | 越大越好 | - | `_cluster_boosted=True` |
|
||||
|
||||
### 6.2 search_hybrid 中的 distances → scores 转换
|
||||
|
||||
```python
|
||||
# api/chat_routes.py search_hybrid()
|
||||
if result.get('_reranked'):
|
||||
# Rerank 后:distances 就是相关性分数,直接使用
|
||||
scores = [float(d) for d in distances]
|
||||
else:
|
||||
# 未 Rerank:将向量距离转为相似度分数
|
||||
scores = [1.0 - d if d <= 1.0 else 1.0 / (1.0 + d) for d in distances]
|
||||
result['scores'] = [scores]
|
||||
```
|
||||
|
||||
### 6.3 自适应 TopK 中的距离→相似度转换
|
||||
|
||||
```python
|
||||
# engine.py 第 790 行
|
||||
top_score = 1.0 - fused_results['distances'][0][0] # 距离转相似度
|
||||
```
|
||||
|
||||
> **⚠️ 已知问题**:RRF+Rerank 后,`_score_source` 仍为 `'rrf'`,导致自适应 TopK 被跳过(第 788 行条件 `fused_results.get('_score_source') != 'rrf'` 不满足)。但此时 distances 已是有效的 rerank 分数,自适应 TopK 本应可以应用。此 bug 导致高置信度查询无法收缩结果,低置信度查询无法扩展。
|
||||
|
||||
---
|
||||
|
||||
## 七、路由层处理流程
|
||||
|
||||
### 7.1 generate_stream 完整流程
|
||||
|
||||
```
|
||||
用户提问 (message)
|
||||
│
|
||||
├─ [0] 意图分析 → IntentAnalysis(rewritten_query, need_retrieval, use_context, sub_queries, intent)
|
||||
│ need_retrieval=False + use_context=True → 直接用历史上下文回答 → return
|
||||
│
|
||||
├─ [0.5] retrieval_query = intent.rewritten_query
|
||||
│
|
||||
├─ [1] 语义缓存检查 ──命中──→ 流式返回缓存答案 → return
|
||||
│
|
||||
├─ [2] 混合检索 → search_hybrid() → search_result
|
||||
│
|
||||
├─ [3] 构建 contexts 列表
|
||||
│ 从 search_result 的 documents[0] / metadatas[0] / scores[0] 三数组构建
|
||||
│ contexts = [{'doc': display_doc, 'meta': meta, 'score': score}, ...]
|
||||
│ 图片/图表切片:doc 替换为 meta.full_description(如果存在)
|
||||
│
|
||||
├─ [3.5] 补充检索:从文本切片提取图号/表号引用,补充检索缺失图片
|
||||
│
|
||||
├─ [4] 懒加载增强(当前禁用)
|
||||
│
|
||||
├─ [5] 图片选择 → select_images()
|
||||
│
|
||||
├─ [6] 三重救援
|
||||
│ ├─ _rescue_bm25_divergence(BM25 分歧检测救援)
|
||||
│ ├─ _rescue_lexical_match(词法匹配救援)
|
||||
│ └─ _rescue_section_cluster(章节聚类救援)
|
||||
│
|
||||
├─ [7] 排序 + 预算截断
|
||||
│ _order_text_contexts_for_prompt() → min_score 过滤 → max_chunks 截断
|
||||
│
|
||||
├─ [8] 表格救援 → _rescue_table_chunks()
|
||||
│
|
||||
├─ [9] Prompt 构建
|
||||
│ ├─ 置信度分数(top-3 平均 rerank 分数)
|
||||
│ ├─ 图片描述注入
|
||||
│ ├─ 意图驱动指令注入(对比/推理/操作/枚举)
|
||||
│ └─ 置信度指令注入
|
||||
│
|
||||
├─ [10] LLM 流式生成 → engine.generate_answer_stream()
|
||||
│
|
||||
└─ [11] 后处理
|
||||
├─ 答案对齐过滤(提取图号/表号,反向筛选图片)
|
||||
├─ 去引用标记(移除 [1][2])
|
||||
├─ 附加引用标注([ref:chunk_id])
|
||||
├─ 敏感信息过滤
|
||||
├─ 保存会话
|
||||
└─ 写入语义缓存
|
||||
```
|
||||
|
||||
### 7.2 contexts 构建关键转换
|
||||
|
||||
```python
|
||||
# search_result 格式(来自 search_hybrid)
|
||||
search_result = {
|
||||
'documents': [[doc1, doc2, ...]],
|
||||
'metadatas': [[meta1, meta2, ...]],
|
||||
'scores': [[score1, score2, ...]], # search_hybrid 添加
|
||||
'ids': [['public_kb/filename_3', ...]], # 多知识库模式带前缀
|
||||
'distances': [[rerank_score, ...]], # 原始 distances 仍保留
|
||||
'_bm25_top3': [{id, doc, meta, bm25_score, rank}, ...],
|
||||
'_debug': {...},
|
||||
}
|
||||
|
||||
# contexts 列表格式(路由层使用)
|
||||
contexts = [
|
||||
{'doc': display_doc, 'meta': meta, 'score': score},
|
||||
...
|
||||
]
|
||||
```
|
||||
|
||||
> **注意**:contexts 中**没有 `id` 字段**。切片 ID 只能通过 `meta.chunk_id` 获取(格式如 `1.docx_14`),而 engine 的 `ids` 可能带 collection 前缀(如 `public_kb/1.docx_14`)。
|
||||
|
||||
### 7.3 min_score 过滤
|
||||
|
||||
`RERANK_CONTEXT_MIN_SCORE = 0.05`
|
||||
|
||||
在 `_order_text_contexts_for_prompt` 中:
|
||||
- `score >= min_score` 的切片直接通过
|
||||
- **表格保护**:同 section 有切片通过阈值时,同 section 的 table 切片保留下限为 `min_score * 0.3` = 0.015
|
||||
|
||||
---
|
||||
|
||||
## 八、三重救援机制详解
|
||||
|
||||
三重救援是双层保护架构:**引擎层**在 rerank 后提升低分但可信的切片(分数较高),**路由层**在 min_score 过滤前做最终安全网(分数较低)。
|
||||
|
||||
### 8.1 BM25 分歧检测救援 `_rescue_bm25_divergence`
|
||||
|
||||
**触发条件**:
|
||||
- `BM25_DIVERGENCE_RESCUE_ENABLED = True`
|
||||
- `search_result._bm25_top3` 非空
|
||||
- BM25 项的 `rank <= BM25_DIVERGENCE_MAX_RANK`(= 3)
|
||||
|
||||
**保底分数**:`CLUSTER_RESCUE_FLOOR = 0.06`
|
||||
|
||||
**两种情况**:
|
||||
|
||||
| 情况 | 条件 | 处理 | 示例 |
|
||||
|------|------|------|------|
|
||||
| **A — 分数压制** | 切片在 contexts 中但 `score < min_score` | 提升 score 至 `CLUSTER_RESCUE_FLOOR` | q003: 0.0034 → 0.06 |
|
||||
| **B — 截断丢失** | 切片不在 contexts 中(被 rerank top_k 截断) | 从 `_bm25_top3` 备份注入新 context | q014: 不在 → 注入 score=0.06 |
|
||||
|
||||
**保护机制**:
|
||||
- 仅救援 BM25 rank ≤ 3 的切片(top-3 是精确关键词匹配的强信号)
|
||||
- 救援分数固定为 `CLUSTER_RESCUE_FLOOR`(0.06),不会高于 rerank 正常通过的切片
|
||||
- 匹配使用 `meta.chunk_id`(不带 collection 前缀),与 BM25 top3 的 `id` 字段匹配
|
||||
|
||||
**数据来源**:`_bm25_top3` 由 `engine.search_knowledge` 在 BM25 搜索后保存,格式为:
|
||||
```python
|
||||
[{'id': '1.docx_14', 'doc': '...', 'meta': {...}, 'bm25_score': 12.4, 'rank': 1}, ...]
|
||||
```
|
||||
|
||||
### 8.2 词法匹配救援 `_rescue_lexical_match`
|
||||
|
||||
**触发条件**:
|
||||
- contexts 非空且 retrieval_query 非空
|
||||
- 清理后查询长度 ≥ 2
|
||||
- 能提取 bigram
|
||||
|
||||
**保底分数**:`CLUSTER_RESCUE_FLOOR = 0.06`
|
||||
|
||||
**Phase 1 — 词法匹配救援**:
|
||||
1. 清理查询(去除 markdown 格式和标点)
|
||||
2. 提取查询 bigram 集合(连续两字组)
|
||||
3. 对每个 `score < min_score` 的切片,计算 bigram 命中率
|
||||
4. 命中率 > 0.35 → 提升 score 至 `max(原score, CLUSTER_RESCUE_FLOOR)`
|
||||
5. 记录被救援的 `(source, chunk_index)` 作为种子
|
||||
|
||||
**Phase 2 — 邻居救援**:
|
||||
- 对每个词法匹配救援的种子,同时救援同 source 下 `chunk_index` 后续 8 个相邻切片
|
||||
- 目的:枚举类问题的 header 切片被救援后,其后续子条目也应被保留
|
||||
|
||||
### 8.3 章节聚类救援 `_rescue_section_cluster`
|
||||
|
||||
**触发条件**:
|
||||
- `SECTION_CLUSTER_RESCUE_ENABLED = True`
|
||||
- 存在"全灭 section":某 section 的成员数 ≥ `CLUSTER_MIN_MEMBERS`(= 3),类型多样性 ≥ `CLUSTER_MIN_TYPES`(= 2),且所有成员 score < min_score
|
||||
|
||||
**保底分数**:`CLUSTER_RESCUE_FLOOR = 0.06`
|
||||
|
||||
**处理逻辑**:
|
||||
1. 按 `(source, normalized_section)` 分组
|
||||
2. 检测"全灭 section":所有成员 score < min_score
|
||||
3. 计算聚类强度 = 成员数 × 类型多样性 × (1 + 查询匹配度)
|
||||
4. 按强度降序,救援 top `CLUSTER_MAX_SECTIONS`(= 3)个 section
|
||||
5. 每个 section 最多救援 `CLUSTER_MAX_RESCUE_PER_SECTION`(= 6)个切片
|
||||
|
||||
### 8.4 引擎层 vs 路由层的双层保护
|
||||
|
||||
| 维度 | 引擎层 `_section_cluster_boost` | 路由层 `_rescue_section_cluster` |
|
||||
|------|-------------------------------|-------------------------------|
|
||||
| 执行时机 | Rerank 后、扩展前 | min_score 过滤前 |
|
||||
| 提升方式 | 修改 distances | 修改 contexts score |
|
||||
| 保底分数 | `CLUSTER_SEED_FLOOR = 0.35`(dist=0.65) | `CLUSTER_RESCUE_FLOOR = 0.06` |
|
||||
| 覆盖范围 | 聚类切片在 rerank 之前就能被后续步骤看到 | 最终安全网,确保不遗漏 |
|
||||
|
||||
> **设计意图**:引擎层提升分数较高(0.35 对应 dist=0.65),使得这些切片能在后续的扩展步骤中被当作种子。路由层是最终安全网,分数较低(0.06)仅确保通过 min_score 过滤。
|
||||
|
||||
---
|
||||
|
||||
## 九、图片召回与选择
|
||||
|
||||
### 9.1 独立图片检索(P0 通道)
|
||||
|
||||
图片/图表切片走独立检索通道,不被文本切片挤占名额:
|
||||
|
||||
```python
|
||||
image_recall_k = max(5, top_k // 2) # 图片独立召回数量
|
||||
image_results = _search_image_chunks(query_vector, image_recall_k, where_filter)
|
||||
```
|
||||
|
||||
- 仅检索 `chunk_type` 为 `image`、`chart`、`table` 的切片
|
||||
- 多知识库模式下对每个 collection 分别调用
|
||||
- 图片结果与文本结果通过 `_merge_results()` 合并
|
||||
|
||||
### 9.2 图片相关性提升(Boost)
|
||||
|
||||
在路由层对图片/图表切片做相关性评估:
|
||||
- **编号匹配**:查询提到"图2.1"且图片 caption 匹配 → boost_factor = 2.0
|
||||
- **语义重叠**:caption 与查询有足够字符重叠 → boost_factor = 1.5
|
||||
- Boost 以 `_image_boost` 标记记录在 metadata 中,不改变排序
|
||||
|
||||
### 9.3 图片选择(`select_images`)
|
||||
|
||||
从召回结果中筛选最终展示给 LLM 的图片:
|
||||
1. 动态预算:精确查图 2 张,有图片数据 5 张,有引用 3 张,默认 2 张
|
||||
2. 对有 `image_path` 的切片用 `score_image_relevance` 打分
|
||||
3. VLM 相关性筛选 + 章节关联检测 + 图号/表号匹配加分
|
||||
4. 按分数降序取 top `MAX_IMAGES`
|
||||
|
||||
### 9.4 图片后置过滤
|
||||
|
||||
LLM 生成回答后,用回答内容反向过滤图片:
|
||||
- 提取回答关键词
|
||||
- 检查每张图片描述与回答关键词的重叠度
|
||||
- 超过阈值的保留;兜底:若全部被过滤则保留最高分 1 张
|
||||
|
||||
---
|
||||
|
||||
## 十、返回格式与溯源信息
|
||||
|
||||
### 10.1 SSE 事件序列
|
||||
|
||||
| 事件类型 | 说明 |
|
||||
|---------|------|
|
||||
| `intent_result` | 意图分析结果(仅 dev) |
|
||||
| `retrieval_debug` | 检索管线调试信息(仅 dev) |
|
||||
| `start` | 开始生成 |
|
||||
| `sources` | 检索来源列表 |
|
||||
| `chunks_retrieved` | 召回切片详情(仅 dev) |
|
||||
| `section_cluster_rescue` | 章节聚类救援(仅 dev) |
|
||||
| `chunk` | 每个 token(流式) |
|
||||
| `finish` | 完成事件(含完整回答和元数据) |
|
||||
|
||||
### 10.2 来源信息(sources)
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "文件名.docx",
|
||||
"page": 12,
|
||||
"page_end": 14,
|
||||
"page_range": "12-14",
|
||||
"section": "第三章 > 第二节 > 小节名",
|
||||
"chunk_type": "text",
|
||||
"doc_type": "word",
|
||||
"section_chunk_id": 5,
|
||||
"score": 0.892
|
||||
}
|
||||
```
|
||||
|
||||
### 10.3 引用格式(citations)
|
||||
|
||||
`_attach_citations()` 自动插入 `[ref:chunk_id]` 标记:
|
||||
|
||||
```
|
||||
根据相关规定,安全检查应包括以下几个方面[ref:3.docx_154]...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、配置项完整列表
|
||||
|
||||
### 检索管线配置
|
||||
|
||||
| 配置项 | 默认值 | 作用 | 状态 |
|
||||
|-------|--------|------|------|
|
||||
| `USE_MULTI_KB` | `True` | 多向量库模式 | ✅ 活跃 |
|
||||
| `USE_HYBRID_SEARCH` | `True` | 向量+BM25混合检索 | ✅ 活跃 |
|
||||
| `USE_RERANK` | `True` | Rerank 重排 | ✅ 活跃 |
|
||||
| `RERANK_CANDIDATES` | `20` | Rerank 候选数 | ✅ 活跃 |
|
||||
| `RERANK_CONTEXT_MIN_SCORE` | `0.05` | 路由层最低分数阈值 | ✅ 活跃 |
|
||||
| `RERANK_BACKEND` | `"local"` | local/cloud/fallback | ✅ 活跃 |
|
||||
| `RERANK_USE_ONNX` | env("true") | ONNX 加速 | ✅ 活跃 |
|
||||
| `DYNAMIC_RRF_ENABLED` | `True` | 动态 RRF 权重 | ✅ 活跃 |
|
||||
| `RRF_K` | `60` | RRF 常数 | ✅ 活跃 |
|
||||
| `MMR_ENABLED` | `True` | MMR 去重 | ✅ 活跃 |
|
||||
| `MMR_USE_EMBEDDING` | env("false") | 高精度/轻量版 | ✅ 活跃 |
|
||||
| `MMR_TOP_K` | `30` | MMR 保留数量 | ✅ 活跃 |
|
||||
| `MMR_LAMBDA` | `0.5` | 相关性vs多样性 | ✅ 活跃 |
|
||||
| `ENUM_QUERY_MMR_LAMBDA` | `0.85` | 枚举查询 lambda | ✅ 活跃 |
|
||||
| `QUERY_EXPANSION_ENABLED` | `True` | 查询扩展 | ⚠️ 构建但未用于多查询 |
|
||||
| `SECTION_FILTER_ENABLED` | `True` | 章节过滤 | ✅ 活跃 |
|
||||
| `ADAPTIVE_TOPK_ENABLED` | `True` | 自适应 TopK | ⚠️ RRF+Rerank 后被跳过(bug) |
|
||||
| `CONTEXT_EXPANSION_ENABLED` | `True` | 上下文扩展 | ✅ 活跃 |
|
||||
| `CONTEXT_EXPANSION_BEFORE` | `1` | 向前扩展数 | ✅ 活跃 |
|
||||
| `CONTEXT_EXPANSION_AFTER` | `8` | 向后扩展数 | ✅ 活跃 |
|
||||
| `CONTEXT_EXPANSION_MAX_CHUNKS` | `50` | 最大扩展总数 | ✅ 活跃 |
|
||||
| `SECTION_CLUSTER_BOOST_ENABLED` | `True` | 引擎层聚类提升 | ✅ 活跃 |
|
||||
| `SECTION_CLUSTER_RESCUE_ENABLED` | `True` | 路由层聚类救援 | ✅ 活跃 |
|
||||
| `BM25_DIVERGENCE_RESCUE_ENABLED` | `True` | BM25 分歧救援 | ✅ 活跃 |
|
||||
| `BM25_DIVERGENCE_MAX_RANK` | `3` | 救援 BM25 排名阈值 | ✅ 活跃 |
|
||||
| `RAG_SEARCH_TOP_K` | `30` | 传给 search_hybrid 的 top_k | ✅ 活跃 |
|
||||
| `RAG_SEARCH_CANDIDATES` | `100` | 传给 search_hybrid 的 candidates | ❌ 不传递给 engine |
|
||||
| `ENABLE_WEB_SEARCH` | `False` | 网络搜索 | ❌ 预留接口,未实现 |
|
||||
| `VECTOR_WEIGHT` / `BM25_WEIGHT` | `0.5` | 静态 RRF 权重 | ⚠️ 动态 RRF 启用时被覆盖 |
|
||||
|
||||
### 缓存配置
|
||||
|
||||
| 配置项 | 默认值 | 作用 | 状态 |
|
||||
|-------|--------|------|------|
|
||||
| `QUERY_CACHE_ENABLED` | `True` | 查询缓存 | ✅ 活跃 |
|
||||
| `EMBEDDING_CACHE_ENABLED` | `True` | Embedding 缓存 | ✅ 活跃 |
|
||||
| `RERANK_CACHE_ENABLED` | `True` | Rerank 分数缓存 | ✅ 活跃 |
|
||||
| `SEMANTIC_CACHE_ENABLED` | `True` | 语义缓存 | ✅ 活跃 |
|
||||
|
||||
### 聚类/救援配置
|
||||
|
||||
| 配置项 | 默认值 | 作用 | 状态 |
|
||||
|-------|--------|------|------|
|
||||
| `CLUSTER_MIN_MEMBERS` | `3` | 触发聚类最小成员数 | ✅ 活跃 |
|
||||
| `CLUSTER_MIN_TYPES` | `2` | 触发聚类最小类型数 | ✅ 活跃 |
|
||||
| `CLUSTER_SEED_FLOOR` | `0.35` | 引擎层聚类提升阈值 | ✅ 活跃 |
|
||||
| `CLUSTER_RESCUE_FLOOR` | `0.06` | 路由层救援保底分数 | ✅ 活跃 |
|
||||
| `CLUSTER_MAX_BOOST_PER_SECTION` | `8` | 引擎层每 section 最大提升数 | ✅ 活跃 |
|
||||
| `CLUSTER_MAX_SECTIONS` | `3` | 全局最大提升 section 数 | ✅ 活跃 |
|
||||
| `CLUSTER_MAX_RESCUE_PER_SECTION` | `6` | 路由层每 section 最大救援数 | ✅ 活跃 |
|
||||
| `CLUSTER_SECTION_PREFIX_LEVELS` | `1` | section 归一化层级 | ✅ 活跃 |
|
||||
|
||||
### LLM 预算配置
|
||||
|
||||
| 配置项 | 默认值 | 作用 | 状态 |
|
||||
|-------|--------|------|------|
|
||||
| `MAX_LLM_CALLS_PER_QUERY` | `2` | 每查询 LLM 调用上限 | ⚠️ 仅 llm_budget.py 使用(模块未集成到主流程) |
|
||||
| `MAX_QUERY_REWRITES` | `1` | 查询改写上限 | ⚠️ 同上 |
|
||||
|
||||
---
|
||||
|
||||
## 十二、单/多知识库路径说明
|
||||
|
||||
### 12.1 当前配置
|
||||
|
||||
`USE_MULTI_KB = True`(硬编码在 `config.py`)
|
||||
|
||||
**效果**:`search_knowledge` 在第 612 行直接分支到 `_search_multi_kb()`,跳过第 628-811 行的单知识库路径。
|
||||
|
||||
### 12.2 两条路径对比
|
||||
|
||||
| 维度 | 单知识库路径 | 多知识库路径 |
|
||||
|------|------------|------------|
|
||||
| 向量库 | `self.collection` | `self.kb_manager.get_collection(coll_name)` |
|
||||
| BM25 | `self.bm25_index`(core/bm25_index.py) | `kb_manager.get_bm25_index(coll_name)`(knowledge/base.py) |
|
||||
| 并发 | 无 | `ThreadPoolExecutor` 并行查各库 |
|
||||
| RRF 权重 | `[VECTOR_WEIGHT, BM25_WEIGHT]` | `[vector_w, bm25_w, ...]` 交替 |
|
||||
| ID 前缀 | 无 | 带 collection 前缀 |
|
||||
| 安全过滤 | ChromaDB where 过滤 | source 过滤 |
|
||||
|
||||
### 12.3 单知识库路径保留原因
|
||||
|
||||
- 逻辑与多知识库路径对称,作为回退方案
|
||||
- 小规模部署可能不需要多知识库
|
||||
- `self.bm25_index`(core/bm25_index.py)仅在此路径使用
|
||||
|
||||
### 12.4 独立检索路径
|
||||
|
||||
`knowledge/search.py` 的 `SearchMixin` 和 `SearchResult` 是独立于 engine 的检索路径:
|
||||
- 被 `knowledge/router.py` 使用(知识库路由推荐功能)
|
||||
- **不走 engine 主流程**
|
||||
- 返回 `SearchResult` dataclass(扁平列表格式),与 engine 的 ChromaDB 格式不兼容
|
||||
391
docs/curl测试手册.md
391
docs/curl测试手册.md
@@ -2,9 +2,9 @@
|
||||
|
||||
> 基于 `后端对接规范.md`,所有 curl 命令均在生产模式 (`APP_ENV=prod`) 下验证通过。
|
||||
>
|
||||
> 测试日期:2026-06-10(最近更新)| 生产服务器:`47.116.16.222` | 服务地址:`http://127.0.0.1:5001`
|
||||
> 测试日期:2026-06-04 | 生产服务器:`47.116.16.222` | 服务地址:`http://127.0.0.1:5001`
|
||||
>
|
||||
> 当前部署模型:`qwen-turbo`(DashScope)| 嵌入模型:`bge-base-zh-v1.5`(本地 CPU)| Rerank:`qwen3-rerank`(DashScope 云端 API)
|
||||
> 当前部署模型:`qwen-plus`(DashScope)| 嵌入模型:`bge-base-zh-v1.5`(本地 CPU)| Rerank:`qwen3-rerank`(DashScope 云端 API)
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -22,9 +22,6 @@
|
||||
- [12. 图片服务](#12-图片服务)
|
||||
- [13. 报告服务](#13-报告服务)
|
||||
- [14. 知识库路由](#14-知识库路由)
|
||||
- [15. 异步任务查询](#15-异步任务查询)
|
||||
- [16. 认证系统](#16-认证系统)
|
||||
- [17. 其他端点](#17-其他端点)
|
||||
- [附录. 已知问题](#附录-已知问题)
|
||||
|
||||
---
|
||||
@@ -283,19 +280,11 @@ curl -s http://localhost:5001/collections
|
||||
"department": "",
|
||||
"description": "",
|
||||
"display_name": "dept_1_kb",
|
||||
"document_count": 500,
|
||||
"document_count": 800,
|
||||
"name": "dept_1_kb"
|
||||
},
|
||||
{
|
||||
"created_at": "2026-06-10T03:04:20.619279",
|
||||
"department": "",
|
||||
"description": "",
|
||||
"display_name": "resources",
|
||||
"document_count": 0,
|
||||
"name": "resources"
|
||||
}
|
||||
],
|
||||
"total": 3
|
||||
"total": 9
|
||||
}
|
||||
```
|
||||
|
||||
@@ -392,15 +381,15 @@ curl -s "http://localhost:5001/collections/public_kb/documents"
|
||||
"collection": "public_kb",
|
||||
"documents": [
|
||||
{
|
||||
"chunks": 69,
|
||||
"source": "3.txt"
|
||||
"chunks": 105,
|
||||
"source": "1.docx"
|
||||
},
|
||||
{
|
||||
"chunks": 12,
|
||||
"source": "1.txt"
|
||||
"chunks": 39,
|
||||
"source": "三峡公报_1-15页.pdf"
|
||||
}
|
||||
],
|
||||
"total": 10
|
||||
"total": 9
|
||||
}
|
||||
```
|
||||
|
||||
@@ -590,25 +579,24 @@ curl -s -X POST http://localhost:5001/documents/upload \
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2002,
|
||||
"message": "文件上传成功,已保存并添加到向量库",
|
||||
"data": {
|
||||
"file": {
|
||||
"filename": "test.txt",
|
||||
"collection": "public_kb",
|
||||
"filename": "test.txt",
|
||||
"path": "public_kb/test.txt",
|
||||
"size": 18
|
||||
"size": 18,
|
||||
"replaced": false
|
||||
},
|
||||
"sync_status": "已保存并添加到向量库"
|
||||
}
|
||||
},
|
||||
"message": "文件上传成功,已保存并添加到向量库",
|
||||
"status": "success",
|
||||
"status_code": 2002,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
> **说明**:文件保存和向量化均为同步操作,响应时文件已处理完成。不包含 `task_id` 字段(异步任务系统仅 main 分支可用)。
|
||||
>
|
||||
> **同名文件处理**:上传同名文件时,旧版本的切片会被自动清理后覆盖,不会生成时间戳后缀文件。
|
||||
> **同名文件处理**:上传同名文件时,旧版本的切片会被自动清理后覆盖(`replaced: true`),不会生成时间戳后缀文件。
|
||||
|
||||
**验证结果**:✅ 通过
|
||||
|
||||
@@ -630,24 +618,22 @@ curl -s -X POST http://localhost:5001/documents/batch-upload \
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"results": [
|
||||
{"filename": "file1.txt", "path": "public_kb/file1.txt", "status": "success", "replaced": false},
|
||||
{"filename": "file2.txt", "path": "public_kb/file2.txt", "status": "success", "replaced": false}
|
||||
],
|
||||
"success_count": 2,
|
||||
"total": 2
|
||||
},
|
||||
"message": "批量上传完成,成功 2/2 个文件",
|
||||
"status": "success",
|
||||
"status_code": 2003,
|
||||
"message": "批量上传完成,成功 2/2 个文件",
|
||||
"data": {
|
||||
"total": 2,
|
||||
"success_count": 2,
|
||||
"results": [
|
||||
{"filename": "file1.txt", "path": "public_kb/file1.txt", "status": "success"},
|
||||
{"filename": "file2.txt", "path": "public_kb/file2.txt", "status": "success"}
|
||||
]
|
||||
}
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
> **说明**:批量上传为同步操作,响应时所有文件已处理完成。不包含 `task_id` 字段(异步任务系统仅 main 分支可用)。
|
||||
>
|
||||
> **同名文件处理**:与单文件上传相同,批量上传中遇到同名文件也会自动覆盖旧版本。
|
||||
> **同名文件处理**:与单文件上传相同,批量上传中遇到同名文件也会自动覆盖旧版本(`replaced: true`)。
|
||||
|
||||
**验证结果**:✅ 通过
|
||||
|
||||
@@ -896,7 +882,7 @@ curl -s -X DELETE "http://localhost:5001/chunks/人员名册.txt_text_0?collecti
|
||||
|
||||
### POST /sync
|
||||
|
||||
触发文档同步(异步任务)。
|
||||
触发文档同步。
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:5001/sync \
|
||||
@@ -906,29 +892,25 @@ curl -s -X POST http://localhost:5001/sync \
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2010,
|
||||
"message": "同步完成",
|
||||
"data": {
|
||||
"result": {
|
||||
"documents_processed": 20,
|
||||
"documents_added": 3,
|
||||
"documents_modified": 2,
|
||||
"documents_deleted": 0,
|
||||
"errors": []
|
||||
"documents_added": 1,
|
||||
"documents_deleted": 1,
|
||||
"documents_modified": 0,
|
||||
"documents_processed": 2,
|
||||
"end_time": "2026-05-04T01:45:42",
|
||||
"errors": [],
|
||||
"start_time": "2026-05-04T01:45:41",
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"message": "同步完成",
|
||||
"status": "success",
|
||||
"status_code": 2010,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
> **说明**:此接口为**同步操作**,请求会阻塞直到同步完成(可能需要较长时间)。响应中 `data.result` 包含完整的同步结果。不包含 `task_id` 字段(异步任务系统仅 main 分支可用)。
|
||||
>
|
||||
> **冲突检测**:如果已有同步任务正在运行,返回 HTTP 409:
|
||||
> ```json
|
||||
> {"error": "TASK_RUNNING", "message": "同步任务正在执行中,请等待完成"}
|
||||
> ```
|
||||
|
||||
**验证结果**:✅ 通过
|
||||
|
||||
---
|
||||
@@ -1380,22 +1362,6 @@ curl -s -X POST "http://localhost:5001/faq/suggestions/6/reject" \
|
||||
|
||||
---
|
||||
|
||||
### POST /faq/\<id\>/approve
|
||||
|
||||
直接批准 FAQ(非从建议列表)。
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:5001/faq/1/approve" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{}'
|
||||
```
|
||||
|
||||
> **注意**:此端点与 `POST /faq/suggestions/<id>/approve` 不同,后者用于审批用户提交的 FAQ 建议。
|
||||
|
||||
**验证结果**:✅ 通过(路由存在)
|
||||
|
||||
---
|
||||
|
||||
## 11. 出题系统
|
||||
|
||||
### 出题架构说明(v2)
|
||||
@@ -1489,10 +1455,6 @@ curl -s -X POST http://localhost:5001/exam/generate \
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2020,
|
||||
"message": "出题成功",
|
||||
"data": {
|
||||
"questions": [
|
||||
{
|
||||
@@ -1524,13 +1486,15 @@ curl -s -X POST http://localhost:5001/exam/generate \
|
||||
"requested_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
|
||||
"actual_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
|
||||
"warnings": []
|
||||
}
|
||||
},
|
||||
"message": "出题成功",
|
||||
"status": "success",
|
||||
"status_code": 2020,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
> **说明**:此接口为**同步操作**,请求会阻塞直到出题完成(通常需要 30-60 秒)。响应中 `data` 直接包含完整出题结果。不包含 `task_id` 字段(异步任务系统仅 main 分支可用)。
|
||||
>
|
||||
> **注意**:`warnings` 字段在某题型实际生成数量少于请求数量时返回提示信息。
|
||||
> **说明**:`warnings` 字段在某题型实际生成数量少于请求数量时返回提示信息。
|
||||
|
||||
**验证结果**:✅ 通过(2026-06-05)
|
||||
|
||||
@@ -1562,10 +1526,6 @@ curl -s -X POST http://localhost:5001/exam/generate-smart \
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2020,
|
||||
"message": "AI 智能出题成功",
|
||||
"data": {
|
||||
"ai_analysis": {
|
||||
"total_knowledge_points": 21,
|
||||
@@ -1584,12 +1544,14 @@ curl -s -X POST http://localhost:5001/exam/generate-smart \
|
||||
"source_chunks_used": 15,
|
||||
"success": true,
|
||||
"total": 16
|
||||
}
|
||||
},
|
||||
"message": "AI 智能出题成功",
|
||||
"status": "success",
|
||||
"status_code": 2020,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
> **说明**:此接口为**同步操作**,请求会阻塞直到出题完成。响应中 `data` 直接包含完整出题结果(含 `ai_analysis` 字段)。不包含 `task_id` 字段(异步任务系统仅 main 分支可用)。
|
||||
|
||||
**注意事项**:
|
||||
- 实际出题数量 ≤ min(文档知识点数, AI 推荐数量)
|
||||
- 如果文档知识点较少,生成的题目数量会相应减少
|
||||
@@ -1638,10 +1600,6 @@ curl -s -X POST http://localhost:5001/exam/grade \
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2021,
|
||||
"message": "批阅完成",
|
||||
"data": {
|
||||
"request_id": null,
|
||||
"results": [
|
||||
@@ -1662,12 +1620,14 @@ curl -s -X POST http://localhost:5001/exam/grade \
|
||||
"success": true,
|
||||
"total_max_score": 2,
|
||||
"total_score": 2
|
||||
}
|
||||
},
|
||||
"message": "批阅完成",
|
||||
"status": "success",
|
||||
"status_code": 2021,
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
> **说明**:此接口为**同步操作**,响应中 `data` 直接包含完整批阅结果。不包含 `task_id` 字段(异步任务系统仅 main 分支可用)。
|
||||
|
||||
**`grading_status` 取值说明**:
|
||||
- `success`:评分成功
|
||||
- `failed`:评分失败(主观题 LLM 解析失败或超时),此时 `details` 包含 `error` 字段
|
||||
@@ -1846,16 +1806,16 @@ curl -s -X POST http://localhost:5001/kb/route \
|
||||
```json
|
||||
{
|
||||
"intent": {
|
||||
"confidence": 1.0,
|
||||
"confidence": 0.95,
|
||||
"department": null,
|
||||
"is_general": true,
|
||||
"keywords": [],
|
||||
"reason": "LLM 意图分析"
|
||||
},
|
||||
"query": "test",
|
||||
"query": "三峡工程",
|
||||
"target_collections": ["public_kb"],
|
||||
"user_department": "开发部",
|
||||
"user_role": "admin"
|
||||
"user_department": "",
|
||||
"user_role": "user"
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1863,156 +1823,6 @@ curl -s -X POST http://localhost:5001/kb/route \
|
||||
|
||||
---
|
||||
|
||||
## 15. 异步任务查询
|
||||
|
||||
> **⚠️ 生产服务器(server-release)不支持**:异步任务系统(`/tasks` 系列端点)仅存在于 main 分支。当前生产服务器的上传、同步、出题、批阅接口均为**同步操作**,直接返回结果,无需轮询。
|
||||
>
|
||||
> 如果后续升级到 main 分支版本,以下接口将可用:
|
||||
>
|
||||
> | 端点 | 方法 | 说明 |
|
||||
> |------|------|------|
|
||||
> | `/tasks` | GET | 获取任务列表(支持 status/type/limit 过滤) |
|
||||
> | `/tasks/<task_id>` | GET | 获取单个任务状态(JSON 轮询) |
|
||||
> | `/tasks/<task_id>/progress` | GET | SSE 流式任务进度推送 |
|
||||
> | `/tasks/stats` | GET | 获取任务统计信息 |
|
||||
>
|
||||
> **任务状态**:`pending` → `running` → `completed` / `failed`
|
||||
|
||||
**验证结果**:❌ 路由不存在(404)— 符合 server-release 预期行为
|
||||
|
||||
---
|
||||
|
||||
## 16. 认证系统
|
||||
|
||||
### POST /auth/login
|
||||
|
||||
用户登录。
|
||||
|
||||
```bash
|
||||
echo '{"username":"admin","password":"xxx"}' > /tmp/login.json
|
||||
curl -s -X POST http://localhost:5001/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/login.json
|
||||
```
|
||||
|
||||
**响应示例**(失败):
|
||||
```json
|
||||
{"error": "用户名或密码错误"}
|
||||
```
|
||||
|
||||
**验证结果**:✅ 通过(参数校验正常)
|
||||
|
||||
---
|
||||
|
||||
### GET /auth/me
|
||||
|
||||
获取当前用户信息。
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5001/auth/me
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /auth/users
|
||||
|
||||
获取用户列表。
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5001/auth/users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PUT /auth/users/\<user_id\>
|
||||
|
||||
修改用户信息。
|
||||
|
||||
```bash
|
||||
echo '{"role":"user"}' > /tmp/update_user.json
|
||||
curl -s -X PUT "http://localhost:5001/auth/users/test-user" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/update_user.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /auth/change-password
|
||||
|
||||
修改密码。
|
||||
|
||||
```bash
|
||||
echo '{"old_password":"old","new_password":"new"}' > /tmp/changepw.json
|
||||
curl -s -X POST http://localhost:5001/auth/change-password \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/changepw.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 17. 其他端点
|
||||
|
||||
### GET /documents/\<path\>/raw
|
||||
|
||||
下载文档原始文件。
|
||||
|
||||
```bash
|
||||
curl -s -o output.docx "http://localhost:5001/documents/public_kb%2F1.docx/raw"
|
||||
```
|
||||
|
||||
**响应**:返回原始文件(HTTP 200)
|
||||
|
||||
**验证结果**:✅ 通过
|
||||
|
||||
---
|
||||
|
||||
### DELETE /chunks/batch
|
||||
|
||||
批量删除切片。
|
||||
|
||||
```bash
|
||||
echo '{"chunk_ids":["file.txt_text_0","file.txt_text_1"],"collection":"public_kb"}' > /tmp/batch_del.json
|
||||
curl -s -X DELETE http://localhost:5001/chunks/batch \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @/tmp/batch_del.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /stats
|
||||
|
||||
获取系统统计信息。
|
||||
|
||||
```bash
|
||||
curl -s http://localhost:5001/stats
|
||||
```
|
||||
|
||||
**验证结果**:❌ HTTP 500(`KeyError: 'SESSION_MANAGER'`,见已知问题 #2)
|
||||
|
||||
---
|
||||
|
||||
### POST /collections/sync-vlm-cache
|
||||
|
||||
同步 VLM 缓存。
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:5001/collections/sync-vlm-cache
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### POST /collections/\<kb_name\>/reindex
|
||||
|
||||
重构向量库索引(异步任务)。
|
||||
|
||||
```bash
|
||||
curl -s -X POST "http://localhost:5001/collections/public_kb/reindex"
|
||||
```
|
||||
|
||||
> **说明**:清除哈希记录并触发全量同步。
|
||||
|
||||
---
|
||||
|
||||
## 附录. 已知问题
|
||||
|
||||
### 1. /rag 接口 collections 参数
|
||||
@@ -2029,63 +1839,33 @@ curl -s -X POST "http://localhost:5001/collections/public_kb/reindex"
|
||||
{"message": "问题", "collections": ["dept_tech"], "chat_history": []}
|
||||
```
|
||||
|
||||
### 2. /stats 接口 500 错误
|
||||
|
||||
**问题**:`GET /stats` 返回 HTTP 500,日志显示 `KeyError: 'SESSION_MANAGER'`。
|
||||
|
||||
**原因**:该端点依赖 `SESSION_MANAGER` 配置项,但 server-release 分支未注册会话管理器。
|
||||
|
||||
**状态**:已知缺陷,不影响其他接口正常使用。
|
||||
|
||||
### 3. /tasks 系列端点不存在
|
||||
|
||||
**问题**:`GET /tasks`、`GET /tasks/<id>`、`GET /tasks/<id>/progress`、`GET /tasks/stats` 均返回 HTTP 404。
|
||||
|
||||
**原因**:异步任务系统仅存在于 main 分支,server-release 保持同步操作模式。上传、同步、出题、批阅接口直接返回结果,无需轮询。
|
||||
|
||||
**影响**:无 — 这是预期行为,所有操作均为同步完成。
|
||||
|
||||
### 4. /documents/sync 与 /sync 的区别
|
||||
|
||||
**问题**:服务器上存在两个同步触发端点,行为略有不同。
|
||||
|
||||
| 端点 | 说明 |
|
||||
|------|------|
|
||||
| `POST /sync` | 主同步接口,扫描并同步所有知识库 |
|
||||
| `POST /documents/sync` | 文档级同步接口,需要额外参数 |
|
||||
|
||||
**建议**:使用 `POST /sync` 触发手动同步。
|
||||
|
||||
---
|
||||
|
||||
## 测试覆盖统计
|
||||
|
||||
> 2026-06-10 生产服务器测试结果(47.116.16.222,云端 Reranker + MMR 文本相似度)
|
||||
> 2026-06-04 生产服务器测试结果(47.116.16.222,云端 Reranker + MMR 文本相似度)
|
||||
|
||||
| 分类 | 总数 | 通过 | 异常 | 备注 |
|
||||
|------|------|------|------|------|
|
||||
| 分类 | 总数 | 通过 | 超时/异常 | 备注 |
|
||||
|------|------|------|-----------|------|
|
||||
| 健康检查 | 1 | 1 | 0 | ✅ 0.002s |
|
||||
| 问答接口 | 2 | 2 | 0 | ✅ /rag 流式,/chat 正常 |
|
||||
| 问答接口 | 2 | 2 | 0 | ✅ /rag 流式 12-14s |
|
||||
| 检索接口 | 1 | 1 | 0 | ✅ /search 0.58-1.53s |
|
||||
| 向量库管理 | 3 | 3 | 0 | ✅ collections + documents + chunks |
|
||||
| 文档管理 | 4 | 4 | 0 | ✅ list/status/preview/raw |
|
||||
| 同步服务 | 3 | 3 | 0 | ✅ status/history/changes |
|
||||
| 反馈系统 | 5 | 5 | 0 | ✅ feedback/list/stats/bad-cases/blacklist |
|
||||
| FAQ 管理 | 2 | 2 | 0 | ✅ faq + suggestions |
|
||||
| 出题系统 | 2 | 2 | 0 | ✅ exam/health + generate(参数校验) |
|
||||
| 图片服务 | 4 | 4 | 0 | ✅ list/stats/info + 图片下载 |
|
||||
| 报告服务 | 2 | 2 | 0 | ✅ weekly + monthly |
|
||||
| 知识库路由 | 1 | 1 | 0 | ✅ /kb/route |
|
||||
| 认证系统 | 1 | 1 | 0 | ✅ /auth/login 参数校验正常 |
|
||||
| 异步任务 | 1 | 0 | 1 | ⚠️ /tasks 404 — 仅 main 分支可用 |
|
||||
| 其他 | 1 | 0 | 1 | ⚠️ /stats 500 — SESSION_MANAGER 未注册 |
|
||||
| **总计** | **33** | **31** | **2** | **2 个异常均为已知缺陷,不影响核心功能** |
|
||||
| 向量库管理 | 3 | 3 | 0 | ✅ |
|
||||
| 文档管理 | 1 | 1 | 0 | ✅ |
|
||||
| 同步服务 | 3 | 3 | 0 | ✅ |
|
||||
| 反馈系统 | 4 | 4 | 0 | ✅ |
|
||||
| FAQ 管理 | 2 | 2 | 0 | ✅ |
|
||||
| 出题系统 | 1 | 1 | 0 | ✅ |
|
||||
| 图片服务 | 2 | 2 | 0 | ✅ |
|
||||
| 报告服务 | 2 | 2 | 0 | ✅ |
|
||||
| 知识库路由 | 1 | 1 | 0 | ✅ |
|
||||
| **总计** | **23** | **23** | **0** | **全部通过** |
|
||||
|
||||
---
|
||||
|
||||
## 性能基准测试
|
||||
|
||||
> 2026-06-10 生产服务器实测(47.116.16.222,public_kb ~824 切片)
|
||||
> 2026-06-04 生产服务器实测(47.116.16.222,public_kb 824 切片)
|
||||
|
||||
### 检索性能
|
||||
|
||||
@@ -2126,7 +1906,7 @@ curl -s -X POST "http://localhost:5001/collections/public_kb/reindex"
|
||||
└── 流式 token 生成 ~11s
|
||||
```
|
||||
|
||||
> **注**:LLM 生成阶段耗时取决于 qwen-turbo API 响应速度,非本地可优化。当前已使用 qwen-turbo(最快模型),如需进一步压缩可考虑减少检索切片数量或缩短 prompt。
|
||||
> **注**:LLM 生成阶段耗时取决于 qwen-plus API 响应速度,非本地可优化。如需进一步压缩至 10s 以内,可考虑换用更快的模型(如 qwen-turbo)。
|
||||
|
||||
---
|
||||
|
||||
@@ -2138,11 +1918,16 @@ curl -s -X POST "http://localhost:5001/collections/public_kb/reindex"
|
||||
|
||||
当前生产服务器知识库列表:
|
||||
|
||||
| 知识库 | 文档数 | 说明 |
|
||||
| 知识库 | 切片数 | 说明 |
|
||||
|--------|--------|------|
|
||||
| public_kb | ~824 | 公开知识库,所有用户可访问 |
|
||||
| dept_1_kb | ~500 | 部门知识库 1 |
|
||||
| resources | 0 | 资源库(新建,暂无文档) |
|
||||
| dept_1_kb | ~800 | 部门知识库 1 |
|
||||
| dept_2_kb | ~118 | 部门知识库 2 |
|
||||
| dept_3_kb | ~132 | 部门知识库 3 |
|
||||
| dept_4_kb | ~4 | 部门知识库 4 |
|
||||
| dept_6_kb | ~29 | 部门知识库 6 |
|
||||
| test1 | ~637 | 测试知识库 |
|
||||
| faq_kb / test3 | 0 | 空(预留) |
|
||||
|
||||
### 切片元数据字段
|
||||
|
||||
@@ -2170,7 +1955,7 @@ curl -s -X POST "http://localhost:5001/collections/public_kb/reindex"
|
||||
curl -s -X POST http://127.0.0.1:5001/collections/<kb_name>/reindex
|
||||
```
|
||||
|
||||
> **⚠️ 注意**:reindex 为同步操作,会阻塞直到重建完成。耗时取决于知识库文档数量,大知识库可能需要数分钟。执行期间搜索和问答接口不受影响(多 worker 架构)。
|
||||
> **⚠️ 注意**:reindex 会调用 `sync_now()` 全局同步,期间 gunicorn worker 被阻塞,搜索和问答接口将暂时无响应。建议在低峰期执行。
|
||||
|
||||
### Reranker 配置
|
||||
|
||||
|
||||
@@ -1,390 +0,0 @@
|
||||
# main 分支独有功能与端点说明
|
||||
|
||||
> 本文档记录 `main` 分支(本地最新版本)相对于 `server-release`(生产服务器版本)的**独有功能和端点差异**。
|
||||
>
|
||||
> 更新日期:2026-06-10 | 对比基准:`origin/server-release` (commit `15c0aec`) vs `main` (commit `edaef7a`)
|
||||
|
||||
---
|
||||
|
||||
## 一、新增端点(仅 main 可用)
|
||||
|
||||
### 1. 异步任务查询系统
|
||||
|
||||
main 分支引入了完整的异步任务注册表(`core/task_registry.py`),将上传、同步、出题等长耗时操作改为后台线程执行,接口立即返回 `task_id`。
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/tasks` | GET | 获取任务列表(支持 status/type/limit 过滤) |
|
||||
| `/tasks/<task_id>` | GET | 获取单个任务状态(JSON 轮询,后端组推荐) |
|
||||
| `/tasks/<task_id>/progress` | GET | SSE 流式任务进度推送(dev-ui 前端推荐) |
|
||||
| `/tasks/stats` | GET | 获取任务统计信息 |
|
||||
|
||||
**GET /tasks 请求参数**:
|
||||
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `status` | string | ❌ | 过滤状态:`pending` / `running` / `completed` / `failed` |
|
||||
| `type` | string | ❌ | 过滤类型:`sync` / `reindex` / `upload` / `batch_upload` / `exam_generate` / `exam_grade` |
|
||||
| `limit` | int | ❌ | 返回数量限制(默认 50) |
|
||||
|
||||
**GET /tasks 响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2000,
|
||||
"message": "查询成功",
|
||||
"data": {
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "a1b2c3d4e5f6",
|
||||
"type": "sync",
|
||||
"description": "文档同步",
|
||||
"status": "running",
|
||||
"progress": 45.0,
|
||||
"current": 9,
|
||||
"total": 20,
|
||||
"stage": "处理文件",
|
||||
"message": "已处理: 产品手册.pdf",
|
||||
"created_at": "2026-06-05T10:30:00",
|
||||
"started_at": "2026-06-05T10:30:01"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GET /tasks/\<task_id\>/progress SSE 事件序列**:
|
||||
```
|
||||
data: {"type": "start", "data": {"stage": "扫描文档"}}
|
||||
data: {"type": "progress", "data": {"progress": 10.0, "current": 2, "total": 20, "stage": "处理文件", "message": "已处理: file1.pdf"}}
|
||||
data: {"type": "progress", "data": {"progress": 25.0, "current": 5, "total": 20, "stage": "处理文件", "message": "已处理: file2.docx"}}
|
||||
data: {"type": "complete", "data": {"task_id": "a1b2c3d4e5f6", "status": "completed", "result": {...}}}
|
||||
```
|
||||
|
||||
> 心跳保活:每 1 秒发送 `: heartbeat`,防止连接超时。
|
||||
|
||||
**任务状态流转**:
|
||||
```
|
||||
pending → running → completed
|
||||
→ failed
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. 缓存管理接口
|
||||
|
||||
用于调试和监控 LRU 缓存与语义缓存的运行状态。
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/cache/stats` | GET | 获取所有缓存的命中统计 |
|
||||
| `/cache/clear` | POST | 清空所有缓存 |
|
||||
|
||||
**GET /cache/stats 响应示例**:
|
||||
```json
|
||||
{
|
||||
"embedding_cache": {
|
||||
"total_entries": 128,
|
||||
"hits": 45,
|
||||
"misses": 83,
|
||||
"hit_rate": "35.16%",
|
||||
"evictions": 0
|
||||
},
|
||||
"semantic_cache": {
|
||||
"total_entries": 50,
|
||||
"hits": 12,
|
||||
"misses": 38,
|
||||
"hit_rate": "24.00%"
|
||||
},
|
||||
"semantic_cache_intent": {
|
||||
"total_entries": 30,
|
||||
"hits": 8,
|
||||
"misses": 22,
|
||||
"hit_rate": "26.67%"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**POST /cache/clear 响应示例**:
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"cleared": {
|
||||
"lru_cache": "cleared",
|
||||
"semantic_cache": "cleared"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. 会话管理接口
|
||||
|
||||
需要 `ENABLE_SESSION=true` 配置项启用,使用 SQLite 存储会话历史。
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/sessions` | GET | 获取当前用户的会话列表 |
|
||||
| `/history/<session_id>` | GET | 获取指定会话的聊天历史 |
|
||||
| `/session/<session_id>` | DELETE | 删除指定会话 |
|
||||
| `/clear/<session_id>` | POST | 清空指定会话的历史(保留会话) |
|
||||
|
||||
> **注意**:这些端点在 `ENABLE_SESSION=false`(生产模式默认值)时不注册,返回 404。
|
||||
|
||||
**GET /sessions 响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2000,
|
||||
"data": {
|
||||
"sessions": [
|
||||
{
|
||||
"session_id": "abc-123-def",
|
||||
"created_at": "2026-06-05T10:30:00",
|
||||
"last_active": "2026-06-05T11:00:00",
|
||||
"preview": "用户最后一条消息的前50字..."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GET /history/\<session_id\> 响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2000,
|
||||
"data": {
|
||||
"history": [
|
||||
{"role": "user", "content": "你好", "created_at": "2026-06-05T10:30:00"},
|
||||
{"role": "assistant", "content": "你好!有什么可以帮你的?", "created_at": "2026-06-05T10:30:01"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 审计日志接口
|
||||
|
||||
需要 `ENABLE_SESSION=true` 配置项启用,查询用户操作审计记录。
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/audit/logs` | GET | 查询审计日志(管理员) |
|
||||
|
||||
**GET /audit/logs 请求参数**:
|
||||
|
||||
| 参数 | 类型 | 必需 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `limit` | int | ❌ | 返回条数(默认 50) |
|
||||
| `days` | int | ❌ | 查询天数范围(默认 7) |
|
||||
| `action` | string | ❌ | 按操作类型过滤 |
|
||||
|
||||
**响应示例**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2000,
|
||||
"data": {
|
||||
"logs": [
|
||||
{
|
||||
"id": 1,
|
||||
"user_id": "admin001",
|
||||
"username": "admin",
|
||||
"action": "rag_query",
|
||||
"query": "三峡工程",
|
||||
"result_summary": "...",
|
||||
"role": "admin",
|
||||
"department": "管理部",
|
||||
"ip_address": "127.0.0.1",
|
||||
"duration_ms": 1234,
|
||||
"timestamp": "2026-06-05T12:00:00"
|
||||
}
|
||||
],
|
||||
"total": 100
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. 系统统计接口
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/stats` | GET | 获取系统综合统计 |
|
||||
|
||||
> **server-release 上此端点返回 500**(`KeyError: 'SESSION_MANAGER'`),因为会话管理器未初始化。main 分支在 `ENABLE_SESSION=true` 时正常工作。
|
||||
|
||||
---
|
||||
|
||||
## 二、已有端点的行为与内部逻辑差异
|
||||
|
||||
以下端点在 main 和 server-release 上 URL 相同,但**行为或内部逻辑有显著差异**。
|
||||
|
||||
### 1. 全面异步化
|
||||
|
||||
server-release 上为同步阻塞的操作,在 main 分支改为异步执行并返回 `task_id`:
|
||||
|
||||
| 端点 | server-release 行为 | main 行为 |
|
||||
|------|---------------------|-----------|
|
||||
| `POST /documents/upload` | 同步保存+向量化,直接返回结果 | 同步保存,异步向量化,返回 `task_id` |
|
||||
| `POST /documents/batch-upload` | 同步保存+向量化,直接返回结果 | 同步保存,异步向量化,返回 `task_id` |
|
||||
| `POST /sync` | 同步阻塞直到完成 | 异步执行,立即返回 `task_id` |
|
||||
| `POST /collections/<kb>/reindex` | 同步阻塞直到完成 | 异步执行,立即返回 `task_id` |
|
||||
| `POST /exam/generate` | 同步阻塞(30-60s) | 异步执行,立即返回 `task_id` |
|
||||
| `POST /exam/generate-smart` | 同步阻塞(30-60s) | 异步执行,立即返回 `task_id` |
|
||||
| `POST /exam/grade` | 同步阻塞 | 异步执行,立即返回 `task_id` |
|
||||
|
||||
**main 分支上传响应示例**(对比 server-release):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2002,
|
||||
"message": "文件上传成功,已保存,向量化任务已启动",
|
||||
"data": {
|
||||
"file": {
|
||||
"filename": "test.txt",
|
||||
"collection": "public_kb",
|
||||
"path": "public_kb/test.txt",
|
||||
"size": 18,
|
||||
"replaced": false
|
||||
},
|
||||
"sync_status": "已保存,向量化任务已启动",
|
||||
"task_id": "a1b2c3d4e5f6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **新增字段**:`task_id`(用于轮询进度)、`replaced`(是否为覆盖旧文件)。
|
||||
> server-release 上这两个字段不存在。
|
||||
|
||||
**后端调用流程变更**:
|
||||
```
|
||||
server-release: POST /upload → 等待 → 200 OK(文件已处理)
|
||||
main: POST /upload → 立即 200 OK(task_id)→ GET /tasks/<id> 轮询直到 completed
|
||||
```
|
||||
|
||||
### 2. RAG 问答增强(不涉及端口变化)
|
||||
|
||||
`POST /rag` 端点在两个分支上 URL 和响应格式一致,但**内部管道逻辑**有差异。
|
||||
|
||||
**两个分支共有的能力(engine.py 层,已同步)**:
|
||||
|
||||
| 能力 | 说明 |
|
||||
|------|------|
|
||||
| Embedding 缓存 | `_encode_cached()` LRU 缓存,减少重复编码 |
|
||||
| 表格邻居上下文扩展 | 表格切片自动扩展相邻文本上下文 |
|
||||
| MMR 去重 | 文本相似度去重,减少冗余切片 |
|
||||
| 子查询分解 | 复杂查询拆分为子查询提升召回 |
|
||||
| 图片 P0 独立召回 | 图片切片独立通道检索 |
|
||||
| 引用去重 | chunk_id 保序去重 |
|
||||
|
||||
**main 独有的增强(chat_routes.py 层)**:
|
||||
|
||||
| 能力 | 说明 | 涉及函数 |
|
||||
|------|------|----------|
|
||||
| 语义缓存闭环 | 相似问题命中缓存直接返回,跳过检索和生成 | 集成在 RAG 管道主流程中 |
|
||||
| 表格救援 | 被预算截断的表格切片补回上下文 | `_rescue_table_chunks()` |
|
||||
| 语义前缀精简 | 精简表格切片冗余语义前缀,保留章节标识 | `_strip_semantic_prefix()` |
|
||||
| 层级章节相似度 | 数值精确匹配 + Jaccard 层级系数,用于图片/表格章节过滤 | `_section_similarity()` |
|
||||
| 图片后置过滤 | 基于 LLM 回答关键词反向筛选图片 | `_filter_images_by_answer()` |
|
||||
|
||||
**server-release 独有的增强(agentic*.py 模块化层)**:
|
||||
|
||||
| 能力 | 说明 | 所在模块 |
|
||||
|------|------|----------|
|
||||
| 查询改写 | 口语→专业术语映射、实体补全、LLM 深度重写、图片指代识别 | `agentic_query.py` QueryRewriteMixin |
|
||||
| 上下文压缩 | rerank 过滤、token 截断、去重 | `agentic_context.py` ContextMixin |
|
||||
| 质量评估 | 置信度门控、答案反思 | `agentic_quality.py` QualityMixin |
|
||||
| 受限文档检查 | 权限级别感知的文档过滤 | `engine.py` check_restricted_documents() |
|
||||
|
||||
> **总结**:两个分支在引擎层(engine.py)的检索能力基本一致。差异在于上层管道编排——main 在 chat_routes.py 中增加了表格救援、语义缓存等管道函数;server-release 则通过 AgenticRAG 模块化系统实现了查询改写、上下文压缩等能力。这些差异不影响 API 端口定义。
|
||||
|
||||
### 3. 认证增强
|
||||
|
||||
| 端点 | 变更说明 |
|
||||
|------|----------|
|
||||
| `POST /auth/login` | 新增 IP 速率限制(频繁登录返回 HTTP 429) |
|
||||
| `POST /auth/change-password` | 新增旧密码验证(server-release 不验证旧密码) |
|
||||
|
||||
---
|
||||
|
||||
## 三、全局响应格式变更
|
||||
|
||||
main 分支将所有端点的响应统一为 `success_response()` / `error_response()` 封装格式。
|
||||
|
||||
**server-release 响应格式**(部分端点使用原始 jsonify):
|
||||
```json
|
||||
{
|
||||
"contexts": ["..."],
|
||||
"metadatas": [...],
|
||||
"scores": [0.99]
|
||||
}
|
||||
```
|
||||
|
||||
**main 分支响应格式**(统一封装):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2000,
|
||||
"message": "查询成功",
|
||||
"data": {
|
||||
"contexts": ["..."],
|
||||
"metadatas": [...],
|
||||
"scores": [0.99]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **⚠️ Breaking Change**:如果后端直接读取响应顶层字段(如 `response.contexts`),迁移到 main 后需要改为 `response.data.contexts`。建议后端统一使用 `response.data` 访问实际数据。
|
||||
|
||||
---
|
||||
|
||||
## 四、新增状态码
|
||||
|
||||
| 状态码 | 常量名 | 说明 |
|
||||
|--------|--------|------|
|
||||
| 4014 | `TASK_NOT_FOUND` | 任务不存在 |
|
||||
| 4015 | `TASK_CONFLICT` | 任务冲突(如重复触发同步) |
|
||||
| 5040 | `REINDEX_ERROR` | 重建索引失败 |
|
||||
|
||||
---
|
||||
|
||||
## 五、架构差异总结
|
||||
|
||||
| 维度 | server-release | main |
|
||||
|------|----------------|------|
|
||||
| 长操作模式 | 同步阻塞 | 异步任务 + task_id 轮询 |
|
||||
| 响应格式 | 混合(jsonify + success_response) | 统一 success_response 封装 |
|
||||
| RAG 引擎层 | 相同(engine.py 已同步) | 相同 |
|
||||
| RAG 管道层 | AgenticRAG 模块化编排(查询改写/上下文压缩/质量评估) | chat_routes.py 单体管道(语义缓存/表格救援/图片过滤) |
|
||||
| 会话管理 | 无状态 | SQLite 会话存储(可选) |
|
||||
| 审计日志 | 无 | 操作审计记录 |
|
||||
| 缓存系统 | LRU(语义缓存已初始化但未接入流程) | LRU + 语义缓存(完整闭环) |
|
||||
| 认证安全 | 基础 | IP 速率限制 + 旧密码验证 |
|
||||
| 核心架构 | AgenticRAG 多模块 + engine.py | 统一 engine.py + chat_routes.py 管道 |
|
||||
|
||||
---
|
||||
|
||||
## 六、迁移注意事项
|
||||
|
||||
如果后续需要将 main 分支部署到服务器,需要注意:
|
||||
|
||||
1. **后端适配**:后端组需要修改调用方式,从同步等待结果改为轮询 `GET /tasks/<task_id>`。建议轮询间隔 1-2 秒。
|
||||
|
||||
2. **响应格式**:所有接口响应统一为 `{success, status, status_code, message, data}` 格式,前端/后端需要从 `data` 字段读取实际数据。
|
||||
|
||||
3. **配置项**:新增 `ENABLE_SESSION`、`ENABLE_FEEDBACK` 等开关,需要确认生产环境配置。
|
||||
|
||||
4. **RAG 管道差异**:main 和 server-release 的 RAG 增强方向不同(main 侧重语义缓存/表格救援,server-release 侧重模块化查询改写/上下文压缩),但不影响 API 端口兼容性。迁移时两者的检索效果可能略有差异,需要做效果对比测试。
|
||||
|
||||
5. **兼容性**:如果暂不迁移后端,可以只在 main 分支上保持同步模式(通过环境变量开关),避免 breaking change。
|
||||
180
docs/代码审查报告_2026-06-05.md
Normal file
180
docs/代码审查报告_2026-06-05.md
Normal file
@@ -0,0 +1,180 @@
|
||||
## RAG-Agent 代码审查报告(精简版)
|
||||
|
||||
审查日期:2026-06-05
|
||||
|
||||
排除说明:storage 模块尚未启用(暂不纳入);用户认证/权限由后端服务负责(生产环境 RAG 服务为无状态接口,不做鉴权)。
|
||||
|
||||
---
|
||||
|
||||
### 一、高危问题(6 项)
|
||||
|
||||
**H1. SSE 错误事件泄露完整堆栈信息**
|
||||
- 文件:`api/chat_routes.py:1955`
|
||||
- `/rag` 接口 SSE 生成器在异常时将 `traceback.format_exc()` 完整堆栈直接发给客户端,暴露调用栈、文件路径、代码行号、内部变量。
|
||||
- 修复:移除 traceback 字段,仅在服务端日志记录,客户端返回通用错误消息。
|
||||
|
||||
**H2. 文档更新/删除接口存在路径遍历风险**
|
||||
- 文件:`api/document_routes.py:652,714`
|
||||
- `update_document` 和 `delete_document` 直接将 URL 中的 `doc_path` 拼接到文件路径,未做安全校验。可构造 `../../` 路径遍历载荷。
|
||||
- 修复:使用 `os.path.realpath()` 解析最终路径,验证是否在 DOCUMENTS_PATH 目录下。
|
||||
|
||||
**H3. `serve_document_file` 路径遍历风险**
|
||||
- 文件:`api/document_routes.py:139`
|
||||
- 文件服务接口同样存在路径遍历风险。虽然有 DEV_MODE 开关,但默认值为 `'true'`。
|
||||
- 修复:添加 realpath 校验。
|
||||
|
||||
**H4. 文档更新接口缺少文件类型和大小校验**
|
||||
- 文件:`api/document_routes.py:621`
|
||||
- `update_document` (PUT) 未验证文件类型和大小,直接 `file.save(filepath)`。与之对比,`upload_document` 有完整校验。
|
||||
- 修复:添加与 upload 一致的 ALLOWED_EXTENSIONS 和 MAX_FILE_SIZE 校验。
|
||||
|
||||
**H5. 批量上传接口缺少文件大小校验**
|
||||
- 文件:`api/document_routes.py:350`
|
||||
- `batch_upload_documents` 对每个文件只检查了扩展名,未检查文件大小。可批量上传超大文件导致磁盘耗尽。
|
||||
- 修复:在循环内添加 MAX_FILE_SIZE 校验。
|
||||
|
||||
**H6. `main.py` debug 模式默认开启 + 监听 0.0.0.0**
|
||||
- 文件:`main.py:29-31`
|
||||
- `--debug` 默认 `True`,`--host` 默认 `0.0.0.0`。Flask 调试模式启用 Werkzeug 交互式 debugger,可通过触发异常执行任意代码。
|
||||
- 修复:`--debug` 默认值改为 `False`。
|
||||
|
||||
---
|
||||
|
||||
### 二、中危问题(13 项)
|
||||
|
||||
**M1. 多处异常响应直接暴露内部错误信息**
|
||||
- 文件:`document_routes.py:332,467,733`;`kb_routes.py:523,636`;`feedback_routes.py:98,116`;`sync_routes.py:119`;`audit_routes.py:100` 等。
|
||||
- 大量 `except` 块直接 `str(e)` 返回给客户端,可能包含数据库路径、SQL 片段、文件系统结构。
|
||||
- 修复:统一使用通用错误消息,原始异常仅记录到服务端日志。
|
||||
|
||||
**M2. `/search` 接口缺少输入安全验证**
|
||||
- 文件:`api/chat_routes.py:1974`
|
||||
- 未调用 `validate_query()` 做注入检测和长度限制,与 `/chat`、`/rag` 不一致。
|
||||
- 修复:添加 `validate_query(query)` 调用。
|
||||
|
||||
**M3. `/search` 的 `top_k` 参数未校验范围**
|
||||
- 文件:`api/chat_routes.py:1989`
|
||||
- 可传 `top_k=999999` 导致内存溢出。
|
||||
- 修复:`top_k = max(1, min(int(top_k), 50))`。
|
||||
|
||||
**M4. `context_count` 参数未校验范围**
|
||||
- 文件:`api/document_routes.py:821`
|
||||
- 未限制范围且非整数字符串会 ValueError 导致 500。
|
||||
- 修复:try/except + `max(0, min(n, 10))`。
|
||||
|
||||
**M5. CORS 配置允许所有来源**
|
||||
- 文件:`api/__init__.py:70`
|
||||
- `CORS(app)` 默认允许 `*` 跨域。生产环境应限制为已知前端域名。
|
||||
- 修复:根据 APP_ENV 条件配置 origins。
|
||||
|
||||
**M6. LIKE 通配符注入风险**
|
||||
- 文件:`api/kb_routes.py:503`
|
||||
- `kb_name` 含 `%` 或 `_` 时会导致非预期的 LIKE 匹配行为。
|
||||
- 修复:对 LIKE 特殊字符转义后再拼入模式。
|
||||
|
||||
**M7. SESSION_MANAGER 为 None 时未处理**
|
||||
- 文件:`api/session_routes.py:35,64,83,102`
|
||||
- 初始化失败时 SESSION_MANAGER 为 None,调用方法会触发 AttributeError 导致 500。
|
||||
- 修复:使用前检查 None,返回 503。
|
||||
|
||||
**M8. LLM 调用缺少统一的超时和重试机制**
|
||||
- 文件:`core/llm_utils.py`
|
||||
- 部分 LLM 调用无超时控制,长时间阻塞会耗尽 worker。`@retry` 装饰器只在部分方法上使用。
|
||||
- 修复:在 `_call_llm` 层面统一超时和重试。
|
||||
|
||||
**M9. LLM 输出 JSON 解析不够健壮**
|
||||
- 文件:`core/agentic.py`、`core/agentic_answer.py`、`core/agentic_quality.py` 等
|
||||
- 多处 LLM 返回的 JSON 解析缺少多策略提取和重试,仅靠 prompt 约束。exam_pkg 已修复但 core 模块尚未统一。
|
||||
- 修复:提取 exam_pkg 的 `_extract_json` 为公共工具,core 模块统一使用。
|
||||
|
||||
**M10. Prompt 注入风险**
|
||||
- 文件:`core/engine.py:2017`、`core/agentic_answer.py:83`
|
||||
- 用户输入直接拼入 prompt,未做净化。恶意输入可操控 LLM 输出。
|
||||
- 修复:对用户输入做基本的 prompt 注入检测(如检测 "ignore previous instructions" 等模式)。
|
||||
|
||||
**M11. `subprocess.run` 命令参数注入风险**
|
||||
- 文件:`parsers/mineru_parser.py:632`
|
||||
- file_path 中特殊字符(如以 `-` 开头的文件名)可能被命令行工具解释为选项。
|
||||
- 修复:在文件路径前插入 `--` 分隔符;对 backend、lang 参数做白名单校验。
|
||||
|
||||
**M12. Excel/文本解析器无文件大小限制**
|
||||
- 文件:`parsers/excel_parser.py:81`、`parsers/txt_parser.py:15`
|
||||
- 一次性加载全文件到内存,超大文件导致 OOM。
|
||||
- 修复:解析前检查文件大小,设定上限(如 50MB)。
|
||||
|
||||
**M13. 全局变量缓存竞态条件**
|
||||
- 文件:`api/document_routes.py:100`、`api/kb_routes.py:46`
|
||||
- 模块级全局变量 `_kb_manager` 等在多线程 gunicorn 下存在竞态。
|
||||
- 修复:使用 `threading.Lock` 保护或改用 `flask.current_app.config`。
|
||||
|
||||
---
|
||||
|
||||
### 三、低危问题(12 项)
|
||||
|
||||
**L1.** `config.py` 硬编码第三方 API 端点 `xiaomimimo.com` 作为默认值(第 20 行)— 改为空字符串,要求环境变量显式配置。
|
||||
|
||||
**L2.** `python-dotenv` 未安装时静默跳过,服务可能 fail-open 启动(`config.py:11`)— 生产环境缺失时抛异常。
|
||||
|
||||
**L3.** `assert` 校验可被 `python -O` 跳过(`api/__init__.py:236`)— 改为 `raise ValueError`。
|
||||
|
||||
**L4.** `/chat` 的 `history` 未限长度(`chat_routes.py:1247`)— 可消耗大量 token。
|
||||
|
||||
**L5.** `history` 元素结构未验证(`chat_routes.py:1117`)— 缺少字段时 KeyError 导致 500。
|
||||
|
||||
**L6.** `safe_filename` 运算符优先级不明确(`document_routes.py:94`)— 加括号明确。
|
||||
|
||||
**L7.** DocStore glob 模式未转义特殊字符(`document_routes.py:267`)— 用 `glob.escape()`。
|
||||
|
||||
**L8.** 相对路径 `.data/images` 因工作目录不同可能解析错误(`chat_routes.py:59`)— 改用 PROJECT_ROOT 绝对路径。
|
||||
|
||||
**L9.** `asyncio.run()` 在 Flask 请求上下文中兼容性问题(`chat_routes.py:1744`)。
|
||||
|
||||
**L10.** Excel 同一文件被重复读取多次(`excel_parser.py:81,89`)— 应复用 ExcelFile 对象。
|
||||
|
||||
**L11.** PDF 图片提取 `doc` 对象异常时未关闭(`image_extractor.py:78`)— 改用 `with` 语句。
|
||||
|
||||
**L12.** TXT 解析器异常用 `print` 而非 `logger`(`txt_parser.py:26`)。
|
||||
|
||||
---
|
||||
|
||||
### 四、修复优先级
|
||||
|
||||
按修复成本从低到高排序:
|
||||
|
||||
**第一批:快速修复(半天,改几行代码)**
|
||||
|
||||
| 编号 | 问题 | 改动量 |
|
||||
|:---:|---|:---:|
|
||||
| H6 | main.py debug 默认开启 | 1 行 |
|
||||
| M3 | /search top_k 范围校验 | 2 行 |
|
||||
| M2 | /search 加 validate_query | 2 行 |
|
||||
| M4 | context_count 范围校验 | 3 行 |
|
||||
| L3 | assert 改 raise | 3 行 |
|
||||
| H1 | SSE 移除 traceback 字段 | 5 行 |
|
||||
|
||||
**第二批:安全加固(1-2 天)**
|
||||
|
||||
| 编号 | 问题 | 改动量 |
|
||||
|:---:|---|:---:|
|
||||
| H2+H3 | 文档接口路径遍历 realpath 校验 | ~30 行 |
|
||||
| H4+H5 | 文档更新/批量上传加文件校验 | ~30 行 |
|
||||
| M6 | LIKE 通配符转义 | ~10 行 |
|
||||
| M1 | 异常信息统一脱敏 | 多文件,每处 2-3 行 |
|
||||
| M5 | CORS 生产环境限制来源 | ~5 行 |
|
||||
| M7 | SESSION_MANAGER None 保护 | ~10 行 |
|
||||
| M11 | subprocess 参数注入防护 | ~5 行 |
|
||||
|
||||
**第三批:架构改进(1-2 周)**
|
||||
|
||||
| 编号 | 问题 | 说明 |
|
||||
|:---:|---|---|
|
||||
| M8+M9 | LLM 调用统一超时/重试/解析 | 提取 exam_pkg 经验为公共工具 |
|
||||
| M10 | Prompt 注入防御 | 需设计检测规则 |
|
||||
| M13 | 全局变量竞态修复 | threading.Lock |
|
||||
| M12 | 解析器文件大小限制 | 统一加前置校验 |
|
||||
|
||||
---
|
||||
|
||||
### 五、做得好的方面
|
||||
|
||||
SQL 查询全部使用参数化查询,无注入风险;`validate_query()` 对聊天输入做了注入检测和违禁词过滤;`safe_filename` 对上传文件做了基本防护;`filter_response()` 能过滤 API 密钥等敏感信息;exam_pkg 的输入校验体系完整(已在本轮开发中加固);`.gitignore` 正确排除了 `.env` 等敏感文件。
|
||||
298
docs/出题系统逻辑.md
298
docs/出题系统逻辑.md
@@ -1,298 +0,0 @@
|
||||
# 出题系统生成逻辑
|
||||
|
||||
> 源码:`exam_pkg/`
|
||||
> 主入口:`manager.py → generate_questions_from_file()`
|
||||
> 核心生成器:`generator.py → generate_questions_structured_v2()`
|
||||
|
||||
---
|
||||
|
||||
## 1. 整体架构
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ API 入口 (api.py) │
|
||||
│ /exam/generate │
|
||||
│ /exam/generate-smart│
|
||||
└──────────┬──────────┘
|
||||
│ 异步任务 (task_id)
|
||||
┌──────────▼──────────┐
|
||||
│ manager.py │
|
||||
│ generate_questions │
|
||||
│ _from_file() │
|
||||
└──────────┬──────────┘
|
||||
│
|
||||
┌────────────────────▼────────────────────┐
|
||||
│ retrieve_file_chunks() │
|
||||
│ 构建语义 query → 向量检索 → 文件切片列表 │
|
||||
└────────────────────┬────────────────────┘
|
||||
│
|
||||
┌────────────────────▼────────────────────┐
|
||||
│ generator.py: generate_questions_ │
|
||||
│ structured_v2() │
|
||||
│ │
|
||||
│ Phase 1 文档结构分析(按章节分组) │
|
||||
│ Phase 2 知识点规划(LLM 提取 + 去重) │
|
||||
│ Phase 3 精准出题(AI分配 + 逐点检索) │
|
||||
│ Phase 4 质量校验(去重 + 平衡 + 补题) │
|
||||
└────────────────────┬────────────────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ 返回题目列表 + 溯源 │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## 2. API 入口
|
||||
|
||||
### 2.1 标准出题 `/exam/generate`
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"file_path": "public_kb/产品手册.pdf",
|
||||
"collection": "public_kb",
|
||||
"question_types": {
|
||||
"single_choice": 3,
|
||||
"multiple_choice": 2,
|
||||
"true_false": 2,
|
||||
"fill_blank": 2,
|
||||
"subjective": 1
|
||||
},
|
||||
"difficulty": 3,
|
||||
"exclude_stems": ["已有题干1", "已有题干2"],
|
||||
"options": { "max_source_chunks": 50 }
|
||||
}
|
||||
```
|
||||
|
||||
返回 `task_id`,通过 `GET /tasks/{task_id}` 轮询结果。
|
||||
|
||||
### 2.2 智能出题 `/exam/generate-smart`
|
||||
|
||||
不传 `question_types`,先调用 `analyze_document_for_exam()` 让 LLM 分析文档后自动推荐题型和数量,再走标准生成流程。
|
||||
|
||||
### 2.3 约束
|
||||
|
||||
| 约束项 | 值 |
|
||||
|---|---|
|
||||
| 总题数上限 | 20 道 |
|
||||
| 难度范围 | 1-5 |
|
||||
| `exclude_stems` 上限 | 100 条 |
|
||||
| 合法题型 | `single_choice`, `multiple_choice`, `true_false`, `fill_blank`, `subjective` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 切片检索(Phase 0)
|
||||
|
||||
`retrieve_file_chunks()` 在出题前先检索文件的相关切片:
|
||||
|
||||
1. **构建语义 query**:根据 `question_types` 自动拼装检索词。例如需要填空题会追加"术语 公式 数值",需要主观题追加"流程 步骤 原则"。
|
||||
2. **向量检索**:调用 `engine.search_knowledge()`,按文件名过滤(`source_filter`),支持文件名和完整路径两种格式,支持多 collection 按优先级检索。
|
||||
3. **动态 top_k**:`min(50, 总题数 × 3)`,确保切片数量足够覆盖所有题目。
|
||||
|
||||
---
|
||||
|
||||
## 4. 四阶段生成流水线
|
||||
|
||||
### Phase 1:文档结构分析
|
||||
|
||||
`group_chunks_by_section(chunks)` — 将所有切片按 `section` 字段分组为 `Dict[章节名, List[切片]]`。
|
||||
|
||||
清理章节名中的 `**` 等标记,空章节归入"未分类"。
|
||||
|
||||
### Phase 2:知识点规划
|
||||
|
||||
对每个章节调用 `_extract_knowledge_points(section, chunks, max_points=3)`:
|
||||
|
||||
- **长内容(≥100 字)**:调用 LLM 提取 3 个关键知识点(短短语,5-15 字),prompt 要求"适合出考试题、不重复不重叠"。
|
||||
- **短内容(<100 字)**:直接清理后作为知识点名称,不调 LLM。
|
||||
|
||||
全局去重:所有知识点按 `name` 去重(`seen_kp_names` 集合),确保跨章节不重复。
|
||||
|
||||
每个知识点标记来源章节(`kp['section']`),供后续精准检索使用。
|
||||
|
||||
**降级路径**:如果所有章节都提取不出知识点,走 `_generate_questions_fallback()` — 把全部 chunks 拼成一个大 prompt 直接让 LLM 出题。
|
||||
|
||||
### Phase 3:精准出题
|
||||
|
||||
#### 3a. AI 分配题型
|
||||
|
||||
`_ai_assign_question_types(knowledge_points, question_types)` — 按章节轮询分配"哪个知识点出什么题型":
|
||||
|
||||
- 每种题型独立分配,确保题型覆盖。
|
||||
- 轮询章节,优先从不同章节选知识点。
|
||||
- 每个知识点最多出 1 道同题型题目。
|
||||
|
||||
输出 assignments 列表:`[{"knowledge_point": "请假流程", "question_type": "single_choice", "section": "第三章"}, ...]`
|
||||
|
||||
#### 3b. 逐知识点出题
|
||||
|
||||
对每个 assignment:
|
||||
|
||||
1. **精准检索**:`_retrieve_kp_chunks_v2(kp_name, section_chunks, top_k=5)` — 用知识点名称做关键词匹配,在该章节的切片中评分排序,取 top 5 最相关的切片。评分规则:知识点全文匹配 +100 分,关键词匹配 +10 分,内容长度适中加分。
|
||||
2. **构建上下文**:`build_source_context(kp_chunks)` — 拼接切片内容,每个切片带 `[chunk_id:xxx | 第N页 章节]` 溯源标记。
|
||||
3. **构造 Prompt**:`_build_prompt_for_kp()` — 指定核心知识点、难度、题型数量,要求"必须围绕该知识点出题、每道题不同角度、严禁非 JSON 内容"。附带 5 种题型的 JSON 格式示例。
|
||||
4. **调用 LLM**:`_generate_with_retry()` — 最多重试 2 次。每次调用后 `safe_parse_questions()` 解析 JSON(支持直接解析、提取代码块、提取数组三种方式),`validate_questions_schema()` 校验(必须有 type/stem/answer,选择题必须有 options)。
|
||||
5. **补充溯源**:`_enrich_with_source_trace()` — 给每道题附加 `source_trace`(文档名、chunk_id 列表、来源信息)。
|
||||
|
||||
### Phase 4:质量校验
|
||||
|
||||
#### 4a. 去重
|
||||
|
||||
`_deduplicate_questions(questions, exclude_stems)` — 三层去重:
|
||||
|
||||
1. **题干前缀去重**:题干前 80 字相同 → 去掉。
|
||||
2. **知识点+题型去重**:题干前 30 字 + 题型相同 → 去掉。
|
||||
3. **跨调用去重**:`exclude_stems` 中已有题目的题干前 80 字预填入去重集合,新生成的题目如果与之冲突也会被过滤。
|
||||
|
||||
#### 4b. 题型平衡
|
||||
|
||||
`_balance_question_types(questions, target_types)` — 按题型分组,每种题型按目标数量截取(多了截断)。
|
||||
|
||||
#### 4c. 补题(仅 v1 结构化路径)
|
||||
|
||||
v1 的 `generate_questions_structured()` 有补题机制 `_makeup_questions()`:如果某题型数量不足,用前 5 个 chunks 重新出一轮补充。v2 路径依赖分配阶段的精确控制,不额外补题。
|
||||
|
||||
---
|
||||
|
||||
## 5. 各题型的 JSON 结构
|
||||
|
||||
### 单选题
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "single_choice",
|
||||
"content": {
|
||||
"stem": "题干内容",
|
||||
"data": { "options": [{"key": "A", "content": "..."}, ...] },
|
||||
"answer": "B",
|
||||
"explanation": "解析..."
|
||||
},
|
||||
"referenced_chunk_ids": ["chunk_001"]
|
||||
}
|
||||
```
|
||||
|
||||
### 多选题
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "multiple_choice",
|
||||
"content": {
|
||||
"stem": "题干",
|
||||
"data": { "options": [...] },
|
||||
"answer": ["A", "C"],
|
||||
"explanation": "解析..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 判断题
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "true_false",
|
||||
"content": {
|
||||
"stem": "判断:某陈述",
|
||||
"data": {},
|
||||
"answer": "对",
|
||||
"explanation": "解析..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 填空题
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "fill_blank",
|
||||
"content": {
|
||||
"stem": "RAG的全称是___,核心在于___。",
|
||||
"data": { "blank_count": 2 },
|
||||
"answer": [["检索增强生成"], ["外部知识库", "检索"]],
|
||||
"explanation": "解析..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`answer` 是二维数组:每个空一个数组,数组内元素为该空的可接受答案(第一个为标准答案,其余为同义词)。
|
||||
|
||||
### 主观题
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "subjective",
|
||||
"content": {
|
||||
"stem": "请简述...",
|
||||
"data": {
|
||||
"scoring_points": [
|
||||
{ "point": "要点1", "weight": 0.4 },
|
||||
{ "point": "要点2", "weight": 0.3 },
|
||||
{ "point": "要点3", "weight": 0.3 }
|
||||
]
|
||||
},
|
||||
"answer": "参考范文...",
|
||||
"explanation": "解析..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 批题逻辑
|
||||
|
||||
批题入口:`POST /exam/grade`,同样是异步任务。
|
||||
|
||||
`grader.py → grade_answers()` 按题型分流:
|
||||
|
||||
| 题型 | 批阅方式 | 说明 |
|
||||
|---|---|---|
|
||||
| `single_choice` / `true_false` | **本地判分** | 直接比对答案,不调 LLM |
|
||||
| `multiple_choice` | **本地判分** | `set(student) == set(correct)`,顺序无关 |
|
||||
| `fill_blank` | **模糊匹配** | 逐空比对,支持同义词(answer 数组中的备选项),忽略空格和标点差异 |
|
||||
| `subjective` | **LLM 评分** | 将题目 stem + scoring_points + 参考答案 + 学生答案一起送给 LLM,按要点权重评分 |
|
||||
|
||||
并发控制:最多 3 路并发批阅(`threading.Semaphore(3)`),带 2 次重试。
|
||||
|
||||
---
|
||||
|
||||
## 7. 关键函数索引
|
||||
|
||||
| 函数 | 文件 | 作用 |
|
||||
|---|---|---|
|
||||
| `generate_questions_from_file` | manager.py | 出题总入口 |
|
||||
| `analyze_file_for_exam` | manager.py | AI 智能分析(推荐题型) |
|
||||
| `retrieve_file_chunks` | manager.py | 切片检索 |
|
||||
| `generate_questions_structured_v2` | generator.py | v2 四阶段生成主流程 |
|
||||
| `generate_questions_structured` | generator.py | v1 生成主流程(含补题) |
|
||||
| `group_chunks_by_section` | generator.py | 按章节分组 |
|
||||
| `_extract_knowledge_points` | generator.py | LLM 知识点提取 |
|
||||
| `_ai_assign_question_types` | generator.py | AI 题型分配 |
|
||||
| `_retrieve_kp_chunks_v2` | generator.py | 知识点精准检索 |
|
||||
| `_build_prompt_for_kp` | generator.py | 构造出题 Prompt |
|
||||
| `_generate_with_retry` | generator.py | 带重试的 LLM 调用 |
|
||||
| `safe_parse_questions` | generator.py | JSON 安全解析 |
|
||||
| `validate_questions_schema` | generator.py | 题目 Schema 校验 |
|
||||
| `_enrich_with_source_trace` | generator.py | 补充溯源信息 |
|
||||
| `_deduplicate_questions` | generator.py | 三层去重 |
|
||||
| `_balance_question_types` | generator.py | 题型数量平衡 |
|
||||
| `_generate_questions_fallback` | generator.py | 降级路径(无知识点时) |
|
||||
| `_makeup_questions` | generator.py | v1 补题机制 |
|
||||
| `grade_answers` | grader.py | 批题总入口 |
|
||||
| `grade_objective` | grader.py | 客观题本地批阅 |
|
||||
| `grade_fill_blank` | grader.py | 填空题模糊匹配 |
|
||||
|
||||
---
|
||||
|
||||
## 8. LLM 调用统计
|
||||
|
||||
一次标准出题(10 题、5 章节)的 LLM 调用次数估算:
|
||||
|
||||
| 阶段 | 调用次数 | 说明 |
|
||||
|---|---|---|
|
||||
| 知识点提取 | ~5 次 | 每章节 1 次 |
|
||||
| 题型分配 | 0 次 | 本地算法分配 |
|
||||
| 出题 | ~10 次 | 每知识点 1 次(含重试) |
|
||||
| Schema 校验 | 0 次 | 本地逻辑 |
|
||||
| 去重 / 平衡 | 0 次 | 本地逻辑 |
|
||||
| **合计** | **~15 次** | |
|
||||
|
||||
智能出题额外增加 1 次 LLM 调用(`analyze_document_for_exam`)。
|
||||
255
docs/后端对接规范.md
255
docs/后端对接规范.md
@@ -4,33 +4,9 @@
|
||||
|
||||
## 📋 变更记录(2026-06-05 更新)
|
||||
|
||||
> **本次更新内容**:新增 AI 智能出题端口、更新生产环境测试结果、**长操作改为异步任务**
|
||||
> **本次更新内容**:新增 AI 智能出题端口、更新生产环境测试结果
|
||||
>
|
||||
> **2026-06-05 更新**:出题批卷接口格式优化与输入校验增强
|
||||
>
|
||||
> **2026-06-05 异步任务变更**:同步、上传向量化、出题、批阅等长耗时操作改为异步任务模式,立即返回 `task_id`,通过 `GET /tasks/<task_id>` 轮询结果
|
||||
|
||||
### 异步任务变更(⚠️ 重要,2026-06-05)
|
||||
|
||||
| 端点 | 变更说明 |
|
||||
|------|----------|
|
||||
| `POST /sync` | 改为异步任务,返回 `{"task_id": "xxx"}` 而非同步结果 |
|
||||
| `POST /documents/sync` | 改为异步任务,返回 `{"task_id": "xxx"}` |
|
||||
| `POST /collections/<kb>/reindex` | 改为异步任务,返回 `{"task_id": "xxx"}` |
|
||||
| `POST /documents/upload` | 新增 `task_id` 字段(向量化后台执行) |
|
||||
| `POST /documents/batch-upload` | 新增 `task_id` 字段(批量向量化后台执行) |
|
||||
| `POST /exam/generate` | 改为异步任务,返回 `{"task_id": "xxx"}` |
|
||||
| `POST /exam/generate-smart` | 改为异步任务,返回 `{"task_id": "xxx"}` |
|
||||
| `POST /exam/grade` | 改为异步任务,返回 `{"task_id": "xxx"}` |
|
||||
|
||||
**新增任务查询接口**:
|
||||
|
||||
| 端点 | 方法 | 功能 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `/tasks` | GET | 任务列表 | 支持按 status/type 过滤 |
|
||||
| `/tasks/<task_id>` | GET | 任务状态(JSON) | 后端组推荐轮询接口,建议 1-2 秒间隔 |
|
||||
| `/tasks/<task_id>/progress` | GET | 任务进度(SSE) | dev-ui 前端推荐使用 |
|
||||
| `/tasks/stats` | GET | 任务统计 | 按状态和类型分组统计 |
|
||||
|
||||
### 新增端口
|
||||
|
||||
@@ -147,22 +123,10 @@ RAG服务负责:
|
||||
|
||||
### 2.4 出题系统(可选)
|
||||
|
||||
| 端点 | 方法 | 功能 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| `/exam/generate` | POST | 生成题目 | 异步任务,返回 task_id |
|
||||
| `/exam/generate-smart` | POST | AI 智能出题 | 异步任务,返回 task_id |
|
||||
| `/exam/grade` | POST | 批阅答案 | 异步任务,返回 task_id |
|
||||
|
||||
### 2.5 异步任务查询
|
||||
|
||||
> 所有异步操作(同步、重建索引、上传向量化、出题、批阅)返回的 `task_id` 均可通过以下接口查询进度。
|
||||
|
||||
| 端点 | 方法 | 功能 | 说明 |
|
||||
|-----|------|------|------|
|
||||
| `/tasks` | GET | 任务列表 | 支持按 status/type 过滤 |
|
||||
| `/tasks/<task_id>` | GET | 任务状态(JSON) | **后端组推荐轮询接口**,建议 1-2 秒间隔 |
|
||||
| `/tasks/<task_id>/progress` | GET | 任务进度(SSE) | dev-ui 前端推荐使用 |
|
||||
| `/tasks/stats` | GET | 任务统计 | 按状态和类型分组统计 |
|
||||
| 端点 | 方法 | 功能 |
|
||||
|-----|------|------|
|
||||
| `/exam/generate` | POST | 生成题目 |
|
||||
| `/exam/grade` | POST | 批阅答案 |
|
||||
|
||||
---
|
||||
|
||||
@@ -283,7 +247,7 @@ ENABLE_DIFY_WORKFLOW=false
|
||||
|
||||
---
|
||||
|
||||
**最后更新**: 2026-06-05
|
||||
**最后更新**: 2026-04-29
|
||||
|
||||
> 本文档供后端开发人员参考,用于对接 RAG 知识库服务。
|
||||
|
||||
@@ -1125,24 +1089,17 @@ Content-Type: multipart/form-data
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2002,
|
||||
"message": "文件上传成功,已保存,向量化任务已启动",
|
||||
"data": {
|
||||
"file": {
|
||||
"filename": "document.pdf",
|
||||
"collection": "public_kb",
|
||||
"path": "public_kb/document.pdf",
|
||||
"size": 1024000,
|
||||
"replaced": false
|
||||
},
|
||||
"sync_status": "已保存,向量化任务已启动",
|
||||
"task_id": "a1b2c3d4e5f6"
|
||||
"message": "文件上传成功,已保存并添加到向量库",
|
||||
"file": {
|
||||
"filename": "document.pdf",
|
||||
"collection": "public_kb",
|
||||
"path": "public_kb/document.pdf",
|
||||
"size": 1024000,
|
||||
"replaced": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **异步说明**:文件保存为同步操作,向量化在后台线程异步执行。响应中的 `task_id` 可用于轮询向量化进度(`GET /tasks/<task_id>`)。当同步服务不可用时,`task_id` 为 `null`,`sync_status` 为 `"已保存,等待手动同步"`。
|
||||
|
||||
**同名文件处理**:上传同名文件时,旧版本的切片会被自动清理后覆盖(`replaced: true`),不会生成时间戳后缀文件。这确保了向量库中不会出现同一文档的新旧切片共存的情况。
|
||||
|
||||
### 5.2 批量上传
|
||||
@@ -1300,51 +1257,26 @@ POST /sync
|
||||
|
||||
**请求体:** 无需传递参数(同步所有知识库)
|
||||
|
||||
**响应(异步任务):**
|
||||
**响应:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2010,
|
||||
"message": "同步任务已启动",
|
||||
"message": "同步完成",
|
||||
"data": {
|
||||
"task_id": "c3d4e5f6a1b2",
|
||||
"message": "同步任务已启动,通过 GET /tasks/c3d4e5f6a1b2 查询进度"
|
||||
"result": {
|
||||
"documents_added": 1,
|
||||
"documents_deleted": 1,
|
||||
"documents_modified": 0,
|
||||
"documents_processed": 2,
|
||||
"errors": [],
|
||||
"status": "completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **⚠️ 异步变更**:此接口已从同步改为异步。不再直接返回同步结果,而是返回 `task_id`。后端需通过 `GET /tasks/<task_id>` 轮询任务状态,直到 `status` 为 `completed` 或 `failed`。任务完成后,`result` 字段包含完整的同步结果(含 `documents_processed`、`documents_added` 等)。
|
||||
>
|
||||
> **冲突检测**:如果已有同步任务正在运行,返回 HTTP 409:`{"error": "TASK_RUNNING", "message": "同步任务正在执行中 (task_id: xxx),请等待完成"}`
|
||||
|
||||
**后端轮询示例**:
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
def trigger_sync_and_wait():
|
||||
"""触发同步并等待完成"""
|
||||
# 1. 触发同步任务
|
||||
resp = requests.post('http://rag-service:5001/sync')
|
||||
task_id = resp.json()['data']['task_id']
|
||||
|
||||
# 2. 轮询任务状态(每 2 秒)
|
||||
while True:
|
||||
time.sleep(2)
|
||||
status_resp = requests.get(f'http://rag-service:5001/tasks/{task_id}')
|
||||
task_data = status_resp.json()['data']
|
||||
|
||||
if task_data['status'] == 'completed':
|
||||
print(f"同步完成: {task_data['result']}")
|
||||
return task_data['result']
|
||||
elif task_data['status'] == 'failed':
|
||||
raise Exception(f"同步失败: {task_data['error']}")
|
||||
else:
|
||||
print(f"同步中: {task_data['progress']}% - {task_data['message']}")
|
||||
```
|
||||
```
|
||||
|
||||
### 6.2 同步状态
|
||||
|
||||
```
|
||||
@@ -1427,7 +1359,7 @@ POST /sync/stop
|
||||
```json
|
||||
{
|
||||
"status": "success",
|
||||
"status_code": 2010,
|
||||
"status_code": 3001,
|
||||
"message": "文件监控已启动"
|
||||
}
|
||||
```
|
||||
@@ -1485,24 +1417,26 @@ POST /exam/generate
|
||||
| difficulty | 必须为 1-5 的整数 | HTTP 400 INVALID_PARAMS |
|
||||
| **总题数上限** | **所有题型数量之和不能超过 20** | HTTP 400 INVALID_PARAMS |
|
||||
|
||||
**响应(异步任务):**
|
||||
**响应:**
|
||||
|
||||
**完整响应格式**(包含外层包装):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2020,
|
||||
"message": "出题任务已启动",
|
||||
"status": "success",
|
||||
"status_code": 2011,
|
||||
"message": "出题完成",
|
||||
"data": {
|
||||
"task_id": "d4e5f6a1b2c3",
|
||||
"message": "出题任务已启动 (10题),通过 GET /tasks/d4e5f6a1b2c3 查询结果"
|
||||
"success": true,
|
||||
"request_id": "xxx",
|
||||
"total": 10,
|
||||
"source_chunks_used": 15,
|
||||
"questions": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **⚠️ 异步变更**:此接口已从同步改为异步。响应仅返回 `task_id`,后端需通过 `GET /tasks/<task_id>` 轮询任务状态。任务完成后,`result` 字段包含完整出题结果(格式见下方说明)。
|
||||
|
||||
**轮询结果(GET /tasks/\<task_id\> 完成后的 result 字段)**:
|
||||
|
||||
**data 内部结构**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
@@ -1701,22 +1635,25 @@ POST /exam/grade
|
||||
|
||||
#### 响应
|
||||
|
||||
**响应(异步任务):**
|
||||
**完整响应格式**(包含外层包装):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status": "success",
|
||||
"status_code": 2021,
|
||||
"message": "批阅任务已启动",
|
||||
"message": "批阅完成",
|
||||
"data": {
|
||||
"task_id": "f6a1b2c3d4e5",
|
||||
"message": "批阅任务已启动 (5题),通过 GET /tasks/f6a1b2c3d4e5 查询结果"
|
||||
"request_id": "可选,原样返回",
|
||||
"success": true,
|
||||
"total_score": 12.5,
|
||||
"total_max_score": 22.0,
|
||||
"score_rate": 56.8,
|
||||
"results": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **⚠️ 异步变更**:此接口已从同步改为异步。响应仅返回 `task_id`,后端需通过 `GET /tasks/<task_id>` 轮询任务状态。任务完成后,`result` 字段包含完整批阅结果(格式见下方说明)。
|
||||
|
||||
**轮询结果(GET /tasks/\<task_id\> 完成后的 result 字段)**:
|
||||
**data 内部结构**:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1880,30 +1817,16 @@ def build_grade_request(answer_list, questions_map):
|
||||
return {"answers": grade_answers}
|
||||
```
|
||||
|
||||
**Step 4: 调用 RAG 批卷接口(异步任务)**
|
||||
**Step 4: 调用 RAG 批卷接口**
|
||||
|
||||
```python
|
||||
import time
|
||||
|
||||
def call_rag_grade(grade_request):
|
||||
"""调用 RAG 批卷接口并轮询等待结果"""
|
||||
# 1. 提交批阅任务
|
||||
"""调用 RAG 批卷接口"""
|
||||
response = requests.post(
|
||||
'http://rag-service:5001/exam/grade',
|
||||
json=grade_request
|
||||
)
|
||||
task_id = response.json()['data']['task_id']
|
||||
|
||||
# 2. 轮询任务状态(每 2 秒)
|
||||
while True:
|
||||
time.sleep(2)
|
||||
status_resp = requests.get(f'http://rag-service:5001/tasks/{task_id}')
|
||||
task_data = status_resp.json()['data']
|
||||
|
||||
if task_data['status'] == 'completed':
|
||||
return task_data['result']
|
||||
elif task_data['status'] == 'failed':
|
||||
raise Exception(f"批阅失败: {task_data['error']}")
|
||||
return response.json()
|
||||
```
|
||||
|
||||
**Step 5: 更新学生成绩**
|
||||
@@ -2507,22 +2430,19 @@ ChromaDB 集合名称限制:
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/sync` | POST | 触发同步(**异步任务,返回 task_id**) |
|
||||
| `/sync` | POST | 触发同步 |
|
||||
| `/sync/status` | GET | 同步状态 |
|
||||
| `/sync/history` | GET | 同步历史 |
|
||||
| `/sync/changes` | GET | 变更日志 |
|
||||
| `/sync/start` | POST | 启动文件监控 |
|
||||
| `/sync/stop` | POST | 停止文件监控 |
|
||||
| `/documents/sync` | POST | 触发文档同步(**异步任务,返回 task_id**) |
|
||||
| `/collections/<kb_name>/reindex` | POST | 重建索引(**异步任务,返回 task_id**) |
|
||||
|
||||
### 13.7 出题系统
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/exam/generate` | POST | 生成题目(**异步任务,返回 task_id**) |
|
||||
| `/exam/generate-smart` | POST | AI 智能出题(**异步任务,返回 task_id**) |
|
||||
| `/exam/grade` | POST | 批改答案(**异步任务,返回 task_id**) |
|
||||
| `/exam/generate` | POST | 生成题目 |
|
||||
| `/exam/grade` | POST | 批改答案 |
|
||||
| `/exam/health` | GET | 出题服务健康检查 |
|
||||
|
||||
### 13.8 反馈与 FAQ 管理
|
||||
@@ -2577,77 +2497,6 @@ ChromaDB 集合名称限制:
|
||||
|------|------|------|
|
||||
| `/documents/<path>/preview` | GET | 文档预览,按 `chunk_index` 跳转到具体切片(dev-ui 引用溯源用) |
|
||||
|
||||
### 13.13 异步任务查询
|
||||
|
||||
> 所有异步操作(同步、重建索引、上传向量化、出题、批阅)返回的 `task_id` 均可通过以下接口查询进度。
|
||||
|
||||
| 端点 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| `/tasks` | GET | 任务列表(支持 status/type 过滤) |
|
||||
| `/tasks/<task_id>` | GET | 任务状态(JSON 轮询,**后端组推荐使用**) |
|
||||
| `/tasks/<task_id>/progress` | GET | 任务进度(SSE 流式,dev-ui 前端使用) |
|
||||
| `/tasks/stats` | GET | 任务统计 |
|
||||
|
||||
**任务状态字段说明**:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `task_id` | string | 任务唯一标识 |
|
||||
| `type` | string | 类型:sync / reindex / upload / batch_upload / exam_generate / exam_grade |
|
||||
| `status` | string | 状态:pending / running / completed / failed |
|
||||
| `progress` | float | 进度百分比(0-100) |
|
||||
| `current` | int | 当前处理项数 |
|
||||
| `total` | int | 总项数 |
|
||||
| `stage` | string | 当前阶段 |
|
||||
| `message` | string | 当前步骤描述 |
|
||||
| `result` | any | 完成后的结果数据(仅 status=completed 时存在) |
|
||||
| `error` | string | 失败错误信息(仅 status=failed 时存在) |
|
||||
| `duration_ms` | int | 执行耗时毫秒(仅已完成时存在) |
|
||||
|
||||
**后端对接轮询模式**:
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
def async_task_poll(task_id, base_url='http://rag-service:5001', interval=2, timeout=300):
|
||||
"""
|
||||
通用异步任务轮询函数
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID
|
||||
base_url: RAG 服务地址
|
||||
interval: 轮询间隔(秒)
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
任务结果(result 字段)
|
||||
|
||||
Raises:
|
||||
TimeoutError: 超时
|
||||
Exception: 任务失败
|
||||
"""
|
||||
elapsed = 0
|
||||
while elapsed < timeout:
|
||||
time.sleep(interval)
|
||||
elapsed += interval
|
||||
|
||||
resp = requests.get(f'{base_url}/tasks/{task_id}')
|
||||
if resp.status_code == 404:
|
||||
raise Exception(f"任务不存在: {task_id}")
|
||||
|
||||
task = resp.json()['data']
|
||||
|
||||
if task['status'] == 'completed':
|
||||
return task.get('result')
|
||||
elif task['status'] == 'failed':
|
||||
raise Exception(f"任务失败: {task.get('error', '未知错误')}")
|
||||
# 可选:记录进度日志
|
||||
# logger.info(f"任务 {task_id}: {task['progress']}% - {task['message']}")
|
||||
|
||||
raise TimeoutError(f"任务超时: {task_id} (已等待 {timeout}s)")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十四、文件管理服务(后端负责)
|
||||
|
||||
@@ -1,554 +0,0 @@
|
||||
# 异步任务接口变更说明
|
||||
|
||||
> **变更日期**:2026-06-05
|
||||
>
|
||||
> **变更原因**:同步、向量化、出题、批阅等操作耗时较长(数秒到数十秒),改为异步任务模式后接口立即返回 `task_id`,避免后端调用超时。
|
||||
>
|
||||
> **影响范围**:8 个已有端口的响应格式变更 + 4 个新增任务查询接口
|
||||
|
||||
---
|
||||
|
||||
## 一、核心变更
|
||||
|
||||
所有受影响的接口从 **同步阻塞返回结果** 改为 **异步任务立即返回 `task_id`**。
|
||||
|
||||
**调用流程变更**:
|
||||
|
||||
```
|
||||
旧流程:
|
||||
POST /sync → 等待 10-30s → 返回完整结果
|
||||
|
||||
新流程:
|
||||
POST /sync → 立即返回 {"task_id": "xxx"}
|
||||
GET /tasks/xxx → 轮询(1-2s 间隔)→ status=completed 时取 result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、受影响的端口(8 个)
|
||||
|
||||
### 1. POST /sync — 触发同步
|
||||
|
||||
**旧响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2010,
|
||||
"message": "同步完成",
|
||||
"data": {
|
||||
"result": {
|
||||
"documents_processed": 20,
|
||||
"documents_added": 3,
|
||||
"documents_modified": 2,
|
||||
"documents_deleted": 0,
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2010,
|
||||
"message": "同步任务已启动",
|
||||
"data": {
|
||||
"task_id": "c3d4e5f6a1b2",
|
||||
"message": "同步任务已启动,通过 GET /tasks/c3d4e5f6a1b2 查询进度"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**冲突检测**:已有同步任务运行时返回 HTTP 409:
|
||||
```json
|
||||
{"error": "TASK_RUNNING", "message": "同步任务正在执行中 (task_id: xxx),请等待完成"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. POST /documents/sync — 触发文档同步
|
||||
|
||||
**旧响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"results": [{"collection": "public_kb", "status": "success"}],
|
||||
"synced_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
**新响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"task_id": "xxx",
|
||||
"message": "同步任务已启动,通过 GET /tasks/xxx 查询进度"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. POST /collections/\<kb_name\>/reindex — 重建索引
|
||||
|
||||
**旧响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"message": "重新索引完成: 处理 20 个文档",
|
||||
"documents_processed": 20,
|
||||
"documents_added": 3
|
||||
}
|
||||
```
|
||||
|
||||
**新响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"task_id": "xxx",
|
||||
"message": "重建索引任务已启动: kb_name,通过 GET /tasks/xxx 查询进度"
|
||||
}
|
||||
```
|
||||
|
||||
**冲突检测**:已有重建任务运行时返回 HTTP 409。
|
||||
|
||||
---
|
||||
|
||||
### 4. POST /documents/upload — 上传单个文件
|
||||
|
||||
**旧响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2002,
|
||||
"message": "文件上传成功,已保存并添加到向量库",
|
||||
"data": {
|
||||
"file": {
|
||||
"filename": "test.txt",
|
||||
"collection": "public_kb",
|
||||
"path": "public_kb/test.txt",
|
||||
"size": 1024,
|
||||
"replaced": false
|
||||
},
|
||||
"sync_status": "已保存并添加到向量库"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新响应**(新增 `task_id` 字段):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2002,
|
||||
"message": "文件上传成功,已保存,向量化任务已启动",
|
||||
"data": {
|
||||
"file": {
|
||||
"filename": "test.txt",
|
||||
"collection": "public_kb",
|
||||
"path": "public_kb/test.txt",
|
||||
"size": 1024,
|
||||
"replaced": false
|
||||
},
|
||||
"sync_status": "已保存,向量化任务已启动",
|
||||
"task_id": "a1b2c3d4e5f6"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:文件保存仍为同步操作(毫秒级),仅向量化部分异步执行。`task_id` 为 `null` 表示同步服务不可用,需手动同步。
|
||||
|
||||
---
|
||||
|
||||
### 5. POST /documents/batch-upload — 批量上传
|
||||
|
||||
**旧响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2003,
|
||||
"data": {
|
||||
"results": [...],
|
||||
"success_count": 2,
|
||||
"total": 2
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新响应**(新增 `task_id` 字段):
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2003,
|
||||
"data": {
|
||||
"results": [...],
|
||||
"success_count": 2,
|
||||
"total": 2,
|
||||
"task_id": "b2c3d4e5f6a1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:批量上传成功后自动触发后台向量化任务。无成功上传时不返回 `task_id`。
|
||||
|
||||
---
|
||||
|
||||
### 6. POST /exam/generate — 生成题目
|
||||
|
||||
**旧响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2020,
|
||||
"message": "出题完成",
|
||||
"data": {
|
||||
"success": true,
|
||||
"total": 10,
|
||||
"questions": [...],
|
||||
"warnings": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2020,
|
||||
"message": "出题任务已启动",
|
||||
"data": {
|
||||
"task_id": "d4e5f6a1b2c3",
|
||||
"message": "出题任务已启动 (10题),通过 GET /tasks/d4e5f6a1b2c3 查询结果"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**轮询完成后的 `result` 字段**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total": 10,
|
||||
"source_chunks_used": 15,
|
||||
"requested_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
|
||||
"actual_types": {"single_choice": 5, "true_false": 3, "fill_blank": 2},
|
||||
"warnings": [],
|
||||
"questions": [...]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. POST /exam/generate-smart — AI 智能出题
|
||||
|
||||
**旧响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2020,
|
||||
"message": "AI 智能出题成功",
|
||||
"data": {
|
||||
"ai_analysis": {...},
|
||||
"questions": [...],
|
||||
"total": 16
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2020,
|
||||
"message": "AI 智能出题任务已启动",
|
||||
"data": {
|
||||
"task_id": "e5f6a1b2c3d4",
|
||||
"message": "AI 智能出题任务已启动,通过 GET /tasks/e5f6a1b2c3d4 查询结果"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**轮询完成后的 `result` 字段**:与旧 `data` 格式相同(含 `ai_analysis`、`questions`、`total`)。
|
||||
|
||||
---
|
||||
|
||||
### 8. POST /exam/grade — 批阅答案
|
||||
|
||||
**旧响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2021,
|
||||
"message": "批阅完成",
|
||||
"data": {
|
||||
"success": true,
|
||||
"total_score": 12.5,
|
||||
"total_max_score": 22.0,
|
||||
"score_rate": 56.8,
|
||||
"results": [...]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**新响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2021,
|
||||
"message": "批阅任务已启动",
|
||||
"data": {
|
||||
"task_id": "f6a1b2c3d4e5",
|
||||
"message": "批阅任务已启动 (5题),通过 GET /tasks/f6a1b2c3d4e5 查询结果"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**轮询完成后的 `result` 字段**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"total_score": 12.5,
|
||||
"total_max_score": 22.0,
|
||||
"score_rate": 56.8,
|
||||
"results": [
|
||||
{
|
||||
"question_id": "uuid-001",
|
||||
"score": 0,
|
||||
"max_score": 2.0,
|
||||
"grading_status": "success",
|
||||
"details": {
|
||||
"correct": false,
|
||||
"student_answer": "A",
|
||||
"correct_answer": "B",
|
||||
"feedback": "正确答案: B"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、新增接口(4 个)
|
||||
|
||||
### GET /tasks
|
||||
|
||||
获取任务列表。
|
||||
|
||||
**查询参数**:`status`(过滤状态)、`type`(过滤类型)、`limit`(返回数量,默认 50)
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2000,
|
||||
"data": {
|
||||
"tasks": [
|
||||
{
|
||||
"task_id": "a1b2c3d4e5f6",
|
||||
"type": "sync",
|
||||
"description": "文档同步",
|
||||
"status": "running",
|
||||
"progress": 45.0,
|
||||
"current": 9,
|
||||
"total": 20,
|
||||
"stage": "处理文件",
|
||||
"message": "已处理: 产品手册.pdf",
|
||||
"created_at": "2026-06-05T10:30:00"
|
||||
}
|
||||
],
|
||||
"total": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### GET /tasks/\<task_id\>
|
||||
|
||||
获取单个任务状态(**后端组推荐使用的轮询接口**)。
|
||||
|
||||
**建议轮询间隔**:1-2 秒,当 `status` 为 `completed` 或 `failed` 时停止。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2000,
|
||||
"data": {
|
||||
"task_id": "a1b2c3d4e5f6",
|
||||
"type": "sync",
|
||||
"description": "文档同步",
|
||||
"status": "completed",
|
||||
"progress": 100.0,
|
||||
"current": 20,
|
||||
"total": 20,
|
||||
"stage": "完成",
|
||||
"message": "同步完成",
|
||||
"created_at": "2026-06-05T10:30:00",
|
||||
"started_at": "2026-06-05T10:30:01",
|
||||
"completed_at": "2026-06-05T10:30:15",
|
||||
"duration_ms": 14000,
|
||||
"result": {
|
||||
"documents_processed": 20,
|
||||
"documents_added": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**任务状态**:
|
||||
|
||||
| 状态 | 说明 |
|
||||
|------|------|
|
||||
| `pending` | 已创建,等待执行 |
|
||||
| `running` | 正在执行 |
|
||||
| `completed` | 执行完成,`result` 字段包含完整结果 |
|
||||
| `failed` | 执行失败,`error` 字段包含错误信息 |
|
||||
|
||||
**任务类型(type)**:`sync` / `reindex` / `upload` / `batch_upload` / `exam_generate` / `exam_grade`
|
||||
|
||||
---
|
||||
|
||||
### GET /tasks/\<task_id\>/progress
|
||||
|
||||
SSE 流式任务进度推送(dev-ui 前端推荐使用,后端组通常不需要此接口)。
|
||||
|
||||
**Content-Type**:`text/event-stream`
|
||||
|
||||
---
|
||||
|
||||
### GET /tasks/stats
|
||||
|
||||
获取任务统计信息。
|
||||
|
||||
**响应**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"status_code": 2000,
|
||||
"data": {
|
||||
"total": 5,
|
||||
"by_status": {"running": 1, "completed": 3, "failed": 1},
|
||||
"by_type": {"sync": 2, "exam_generate": 2, "upload": 1}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、后端对接代码参考
|
||||
|
||||
### 通用轮询函数
|
||||
|
||||
```python
|
||||
import time
|
||||
import requests
|
||||
|
||||
def async_task_poll(task_id, base_url='http://rag-service:5001', interval=2, timeout=300):
|
||||
"""
|
||||
通用异步任务轮询函数
|
||||
|
||||
Args:
|
||||
task_id: 任务 ID(从 POST 响应中获取)
|
||||
base_url: RAG 服务地址
|
||||
interval: 轮询间隔(秒),建议 1-2 秒
|
||||
timeout: 超时时间(秒)
|
||||
|
||||
Returns:
|
||||
任务结果(result 字段内容)
|
||||
|
||||
Raises:
|
||||
TimeoutError: 超时
|
||||
Exception: 任务失败
|
||||
"""
|
||||
elapsed = 0
|
||||
while elapsed < timeout:
|
||||
time.sleep(interval)
|
||||
elapsed += interval
|
||||
|
||||
resp = requests.get(f'{base_url}/tasks/{task_id}')
|
||||
if resp.status_code == 404:
|
||||
raise Exception(f"任务不存在: {task_id}")
|
||||
|
||||
task = resp.json()['data']
|
||||
|
||||
if task['status'] == 'completed':
|
||||
return task.get('result')
|
||||
elif task['status'] == 'failed':
|
||||
raise Exception(f"任务失败: {task.get('error', '未知错误')}")
|
||||
|
||||
raise TimeoutError(f"任务超时: {task_id} (已等待 {timeout}s)")
|
||||
```
|
||||
|
||||
### 出题流程示例
|
||||
|
||||
```python
|
||||
def generate_exam(file_path, collection, question_types, difficulty=3):
|
||||
"""异步出题流程"""
|
||||
# 1. 提交出题任务
|
||||
resp = requests.post('http://rag-service:5001/exam/generate', json={
|
||||
'file_path': file_path,
|
||||
'collection': collection,
|
||||
'question_types': question_types,
|
||||
'difficulty': difficulty
|
||||
})
|
||||
task_id = resp.json()['data']['task_id']
|
||||
|
||||
# 2. 轮询等待结果
|
||||
result = async_task_poll(task_id)
|
||||
|
||||
# 3. result 包含完整的出题结果
|
||||
questions = result['questions']
|
||||
return questions
|
||||
```
|
||||
|
||||
### 批阅流程示例
|
||||
|
||||
```python
|
||||
def grade_exam(answers):
|
||||
"""异步批阅流程"""
|
||||
# 1. 提交批阅任务
|
||||
resp = requests.post('http://rag-service:5001/exam/grade', json={
|
||||
'answers': answers
|
||||
})
|
||||
task_id = resp.json()['data']['task_id']
|
||||
|
||||
# 2. 轮询等待结果
|
||||
result = async_task_poll(task_id)
|
||||
|
||||
# 3. result 包含完整的批阅结果
|
||||
return result
|
||||
```
|
||||
|
||||
### 同步流程示例
|
||||
|
||||
```python
|
||||
def trigger_sync():
|
||||
"""异步同步流程"""
|
||||
# 1. 提交同步任务
|
||||
resp = requests.post('http://rag-service:5001/sync')
|
||||
|
||||
if resp.status_code == 409:
|
||||
print("已有同步任务在运行,跳过")
|
||||
return
|
||||
|
||||
task_id = resp.json()['data']['task_id']
|
||||
|
||||
# 2. 轮询等待结果
|
||||
result = async_task_poll(task_id)
|
||||
print(f"同步完成: 处理 {result['documents_processed']} 个文档")
|
||||
return result
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、注意事项
|
||||
|
||||
1. **超时时间**:异步任务完成后保留 1 小时,超时后自动清理。请在任务完成后及时获取结果。
|
||||
|
||||
2. **并发限制**:同一类型的任务(如 `sync`)同一时间只能有一个运行中,重复提交返回 HTTP 409。
|
||||
|
||||
3. **向后兼容**:如果 RAG 服务未升级(旧版本),POST 接口仍返回旧的同步结果格式。后端可通过检查响应中是否包含 `task_id` 字段来判断版本。
|
||||
|
||||
4. **轮询频率**:建议 1-2 秒间隔,过于频繁的轮询会增加服务器负担。
|
||||
|
||||
5. **错误处理**:任务可能因 LLM 超时、文件解析失败等原因失败,`status` 变为 `failed`,`error` 字段包含错误描述。
|
||||
166
exam_pkg/api.py
166
exam_pkg/api.py
@@ -2,8 +2,8 @@
|
||||
出题与批题系统 API 蓝图
|
||||
|
||||
提供 REST API 接口:
|
||||
- 出题:生成题目(异步任务,返回 task_id)
|
||||
- 批题:批阅答案(异步任务,返回 task_id)
|
||||
- 出题:生成题目(返回 JSON 给后端)
|
||||
- 批题:批阅答案(返回结果给后端)
|
||||
|
||||
职责边界:
|
||||
- RAG 服务负责:生成题目 + 批阅答案
|
||||
@@ -12,11 +12,6 @@
|
||||
使用方式:
|
||||
from exam_pkg.api import exam_bp
|
||||
app.register_blueprint(exam_bp, url_prefix='/exam')
|
||||
|
||||
异步任务流程:
|
||||
1. POST /exam/generate → 返回 {"task_id": "xxx", ...}
|
||||
2. GET /tasks/xxx → 轮询状态,直到 completed
|
||||
3. result 字段包含完整出题/批阅结果
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
@@ -34,7 +29,7 @@ from auth.gateway import (
|
||||
)
|
||||
|
||||
# 导入统一响应格式
|
||||
from core.status_codes import EXAM_SUCCESS, GRADE_SUCCESS, EXAM_ERROR, GRADE_ERROR, BAD_REQUEST, UNAUTHORIZED, FORBIDDEN, NO_CONTENT, LLM_ERROR
|
||||
from core.status_codes import EXAM_SUCCESS, GRADE_SUCCESS, EXAM_ERROR, GRADE_ERROR, BAD_REQUEST, NO_CONTENT, LLM_ERROR
|
||||
from api.response_utils import success_response, error_response
|
||||
|
||||
# 合法题型
|
||||
@@ -174,7 +169,7 @@ def api_generate_questions():
|
||||
# 获取当前用户
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return error_response("UNAUTHORIZED", UNAUTHORIZED, "未认证", http_status=401)
|
||||
return error_response("UNAUTHORIZED", BAD_REQUEST, "未认证", http_status=401)
|
||||
|
||||
# 检查向量库访问权限
|
||||
if not check_collection_permission(
|
||||
@@ -183,51 +178,20 @@ def api_generate_questions():
|
||||
collection_name=collection,
|
||||
operation="read"
|
||||
):
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "权限不足", http_status=403)
|
||||
return error_response("FORBIDDEN", BAD_REQUEST, "权限不足", http_status=403)
|
||||
|
||||
# 调用新版出题接口(异步任务)
|
||||
from core.task_registry import get_registry
|
||||
import logging as _logging
|
||||
_logger = _logging.getLogger(__name__)
|
||||
|
||||
registry = get_registry()
|
||||
total_questions = sum(question_types.values())
|
||||
task = registry.create_task(
|
||||
'exam_generate',
|
||||
f"出题: {os.path.basename(file_path)} ({total_questions}题)",
|
||||
total=total_questions
|
||||
# 调用新版出题接口
|
||||
result = generate_questions_from_file(
|
||||
file_path=file_path,
|
||||
collection=collection,
|
||||
question_types=question_types,
|
||||
difficulty=data.get('difficulty', 3),
|
||||
options=data.get('options', {}),
|
||||
request_id=data.get('request_id'),
|
||||
exclude_stems=data.get('exclude_stems')
|
||||
)
|
||||
|
||||
def _do_generate(task, fp, coll, q_types, diff, opts, req_id, excl):
|
||||
"""后台执行出题"""
|
||||
registry.update_progress(task.id, stage='检索知识', message='正在检索相关文档切片...')
|
||||
result = generate_questions_from_file(
|
||||
file_path=fp,
|
||||
collection=coll,
|
||||
question_types=q_types,
|
||||
difficulty=diff,
|
||||
options=opts,
|
||||
request_id=req_id,
|
||||
exclude_stems=excl
|
||||
)
|
||||
registry.update_progress(task.id, stage='完成', message=f"生成 {result.get('total', 0)} 道题")
|
||||
return result
|
||||
|
||||
registry.start_task(
|
||||
task.id, _do_generate,
|
||||
file_path, collection, question_types,
|
||||
data.get('difficulty', 3), data.get('options', {}),
|
||||
data.get('request_id'), data.get('exclude_stems')
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'task_id': task.id,
|
||||
'message': f'出题任务已启动 ({total_questions}题),通过 GET /tasks/{task.id} 查询结果'
|
||||
},
|
||||
status_code=EXAM_SUCCESS,
|
||||
message="出题任务已启动"
|
||||
)
|
||||
return success_response(data=result, status_code=EXAM_SUCCESS, message="出题成功")
|
||||
|
||||
except Exception as e:
|
||||
return error_response("EXAM_ERROR", EXAM_ERROR, str(e), http_status=500)
|
||||
@@ -276,7 +240,7 @@ def api_generate_smart():
|
||||
# 获取当前用户
|
||||
user = get_current_user()
|
||||
if not user:
|
||||
return error_response("UNAUTHORIZED", UNAUTHORIZED, "未认证", http_status=401)
|
||||
return error_response("UNAUTHORIZED", BAD_REQUEST, "未认证", http_status=401)
|
||||
|
||||
# 检查向量库访问权限
|
||||
if not check_collection_permission(
|
||||
@@ -285,57 +249,34 @@ def api_generate_smart():
|
||||
collection_name=collection,
|
||||
operation="read"
|
||||
):
|
||||
return error_response("FORBIDDEN", FORBIDDEN, "权限不足", http_status=403)
|
||||
return error_response("FORBIDDEN", BAD_REQUEST, "权限不足", http_status=403)
|
||||
|
||||
# AI 智能出题(异步任务)
|
||||
from core.task_registry import get_registry
|
||||
import logging as _logging
|
||||
_logger = _logging.getLogger(__name__)
|
||||
|
||||
registry = get_registry()
|
||||
task = registry.create_task(
|
||||
'exam_generate',
|
||||
f"AI智能出题: {os.path.basename(file_path)}"
|
||||
# 1. 调用 AI 分析文件,获取推荐的题型和数量
|
||||
from exam_pkg.manager import analyze_file_for_exam
|
||||
ai_analysis = analyze_file_for_exam(
|
||||
file_path=file_path,
|
||||
collection=collection
|
||||
)
|
||||
|
||||
def _do_smart_generate(task, fp, coll, diff, opts, req_id, excl):
|
||||
"""后台执行 AI 智能出题"""
|
||||
registry.update_progress(task.id, stage='AI分析', message='正在分析文档内容...')
|
||||
from exam_pkg.manager import analyze_file_for_exam
|
||||
ai_analysis = analyze_file_for_exam(file_path=fp, collection=coll)
|
||||
question_types = ai_analysis.get('question_types', {})
|
||||
if not question_types or sum(question_types.values()) == 0:
|
||||
return error_response("EXAM_ERROR", EXAM_ERROR, "AI 分析后未生成有效题型配置", http_status=500)
|
||||
|
||||
q_types = ai_analysis.get('question_types', {})
|
||||
if not q_types or sum(q_types.values()) == 0:
|
||||
raise ValueError("AI 分析后未生成有效题型配置")
|
||||
|
||||
total = sum(q_types.values())
|
||||
registry.update_progress(task.id, total=total, stage='生成题目',
|
||||
message=f"AI 推荐 {total} 道题,正在生成...")
|
||||
|
||||
result = generate_questions_from_file(
|
||||
file_path=fp, collection=coll,
|
||||
question_types=q_types, difficulty=diff,
|
||||
options=opts, request_id=req_id, exclude_stems=excl
|
||||
)
|
||||
result['ai_analysis'] = ai_analysis
|
||||
registry.update_progress(task.id, stage='完成', message=f"生成 {result.get('total', 0)} 道题")
|
||||
return result
|
||||
|
||||
registry.start_task(
|
||||
task.id, _do_smart_generate,
|
||||
file_path, collection,
|
||||
data.get('difficulty', 3), data.get('options', {}),
|
||||
data.get('request_id'), data.get('exclude_stems')
|
||||
# 2. 使用 AI 推荐的题型调用出题接口
|
||||
result = generate_questions_from_file(
|
||||
file_path=file_path,
|
||||
collection=collection,
|
||||
question_types=question_types,
|
||||
difficulty=data.get('difficulty', 3),
|
||||
options=data.get('options', {}),
|
||||
request_id=data.get('request_id'),
|
||||
exclude_stems=data.get('exclude_stems')
|
||||
)
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'task_id': task.id,
|
||||
'message': 'AI 智能出题任务已启动,通过 GET /tasks/' + task.id + ' 查询结果'
|
||||
},
|
||||
status_code=EXAM_SUCCESS,
|
||||
message="AI 智能出题任务已启动"
|
||||
)
|
||||
# 3. 在返回结果中添加 AI 分析信息
|
||||
result['ai_analysis'] = ai_analysis
|
||||
|
||||
return success_response(data=result, status_code=EXAM_SUCCESS, message="AI 智能出题成功")
|
||||
|
||||
except Exception as e:
|
||||
return error_response("EXAM_ERROR", EXAM_ERROR, str(e), http_status=500)
|
||||
@@ -426,34 +367,13 @@ def api_grade_answers():
|
||||
f"第 {i+1} 题的 question_type 无效: {q_type},合法值: {', '.join(sorted(VALID_QUESTION_TYPES))}",
|
||||
http_status=400)
|
||||
|
||||
# 调用批题接口(异步任务)
|
||||
from core.task_registry import get_registry
|
||||
registry = get_registry()
|
||||
|
||||
task = registry.create_task(
|
||||
'exam_grade',
|
||||
f"批阅: {len(answers)} 道题",
|
||||
total=len(answers)
|
||||
# 调用新版批题接口
|
||||
result = grade_answers(
|
||||
answers=answers,
|
||||
request_id=data.get('request_id')
|
||||
)
|
||||
|
||||
def _do_grade(task, ans_list, req_id):
|
||||
"""后台执行批阅"""
|
||||
registry.update_progress(task.id, stage='批阅中', message='正在逐题评分...')
|
||||
result = grade_answers(answers=ans_list, request_id=req_id)
|
||||
registry.update_progress(task.id, stage='完成',
|
||||
message=f"批阅完成,得分率 {result.get('score_rate', 0):.1f}%")
|
||||
return result
|
||||
|
||||
registry.start_task(task.id, _do_grade, answers, data.get('request_id'))
|
||||
|
||||
return success_response(
|
||||
data={
|
||||
'task_id': task.id,
|
||||
'message': f'批阅任务已启动 ({len(answers)}题),通过 GET /tasks/{task.id} 查询结果'
|
||||
},
|
||||
status_code=GRADE_SUCCESS,
|
||||
message="批阅任务已启动"
|
||||
)
|
||||
return success_response(data=result, status_code=GRADE_SUCCESS, message="批阅完成")
|
||||
|
||||
except Exception as e:
|
||||
return error_response("GRADE_ERROR", GRADE_ERROR, str(e), http_status=500)
|
||||
|
||||
@@ -382,34 +382,18 @@ class BM25Index:
|
||||
|
||||
def add_documents(self, ids: List[str], documents: List[str], metadatas: List[dict]) -> None:
|
||||
"""
|
||||
添加文档到索引(追加模式,自动去重)
|
||||
|
||||
如果 ID 已存在则更新对应文档,否则追加新文档。
|
||||
添加后自动重建 BM25 索引。
|
||||
添加文档到索引(会覆盖原有索引)
|
||||
|
||||
Args:
|
||||
ids: 文档 ID 列表
|
||||
documents: 文档内容列表
|
||||
metadatas: 文档元数据列表
|
||||
"""
|
||||
# 建立已有 ID -> 索引位置 的映射,用于去重
|
||||
existing_map = {doc_id: idx for idx, doc_id in enumerate(self.ids)}
|
||||
|
||||
for i, doc_id in enumerate(ids):
|
||||
if doc_id in existing_map:
|
||||
# 更新已有文档
|
||||
pos = existing_map[doc_id]
|
||||
self.documents[pos] = documents[i]
|
||||
self.metadatas[pos] = metadatas[i]
|
||||
else:
|
||||
# 追加新文档
|
||||
existing_map[doc_id] = len(self.ids)
|
||||
self.ids.append(doc_id)
|
||||
self.documents.append(documents[i])
|
||||
self.metadatas.append(metadatas[i])
|
||||
|
||||
if self.documents:
|
||||
tokenized = [self.tokenize(doc) for doc in self.documents]
|
||||
self.ids = ids
|
||||
self.documents = documents
|
||||
self.metadatas = metadatas
|
||||
if documents:
|
||||
tokenized = [self.tokenize(doc) for doc in documents]
|
||||
self.bm25 = BM25Okapi(tokenized)
|
||||
|
||||
def search(self, query: str, top_k: int = 10) -> Tuple[List[str], List[str], List[dict], List[float]]:
|
||||
|
||||
@@ -134,9 +134,6 @@ class CollectionMixin:
|
||||
"""
|
||||
from .base import BM25Index
|
||||
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
|
||||
if not kb_name or not kb_name.replace('_', '').isalnum():
|
||||
return False, "向量库名称只能包含字母、数字和下划线"
|
||||
|
||||
@@ -193,9 +190,6 @@ class CollectionMixin:
|
||||
Returns:
|
||||
更新成功返回 True,向量库不存在返回 False
|
||||
"""
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
|
||||
collections = self._metadata.get("collections", {})
|
||||
if kb_name not in collections:
|
||||
return False
|
||||
@@ -234,9 +228,6 @@ class CollectionMixin:
|
||||
"""
|
||||
import shutil
|
||||
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
|
||||
if kb_name == PUBLIC_KB_NAME:
|
||||
return False, "公开知识库不能删除"
|
||||
|
||||
@@ -357,8 +348,6 @@ class CollectionMixin:
|
||||
- department: 所属部门
|
||||
- description: 描述
|
||||
"""
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
result = []
|
||||
|
||||
# 扫描 base_path 下的所有子目录作为向量库
|
||||
@@ -393,31 +382,16 @@ class CollectionMixin:
|
||||
|
||||
self._save_metadata()
|
||||
|
||||
stale_collections = []
|
||||
|
||||
for name, info in self._metadata.get("collections", {}).items():
|
||||
try:
|
||||
collection = self.get_collection(name)
|
||||
result.append(CollectionInfo(
|
||||
name=name,
|
||||
display_name=info.get("display_name", name),
|
||||
document_count=collection.count() if collection else 0,
|
||||
created_at=info.get("created_at", ""),
|
||||
department=info.get("department", ""),
|
||||
description=info.get("description", "")
|
||||
))
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
f"跳过异常向量库 '{name}': {e},可能是 ChromaDB 数据目录已丢失"
|
||||
)
|
||||
stale_collections.append(name)
|
||||
|
||||
# 清理元数据中指向已失效集合的条目
|
||||
if stale_collections:
|
||||
for name in stale_collections:
|
||||
self._metadata.get("collections", {}).pop(name, None)
|
||||
logger.info(f"清理失效向量库元数据: {name}")
|
||||
self._save_metadata()
|
||||
collection = self.get_collection(name)
|
||||
result.append(CollectionInfo(
|
||||
name=name,
|
||||
display_name=info.get("display_name", name),
|
||||
document_count=collection.count() if collection else 0,
|
||||
created_at=info.get("created_at", ""),
|
||||
department=info.get("department", ""),
|
||||
description=info.get("description", "")
|
||||
))
|
||||
|
||||
return result
|
||||
|
||||
@@ -431,6 +405,4 @@ class CollectionMixin:
|
||||
Returns:
|
||||
存在返回 True,不存在返回 False
|
||||
"""
|
||||
# 从磁盘重新加载元数据,确保多 worker 进程间状态一致
|
||||
self._metadata = self._load_metadata()
|
||||
return kb_name in self._metadata.get("collections", {})
|
||||
|
||||
@@ -29,11 +29,6 @@
|
||||
import os
|
||||
import json
|
||||
import threading
|
||||
try:
|
||||
import fcntl
|
||||
_HAS_FCNTL = True
|
||||
except ImportError:
|
||||
_HAS_FCNTL = False # Windows 环境无 fcntl
|
||||
from typing import List, Dict, Optional, Tuple
|
||||
from pathlib import Path
|
||||
import logging
|
||||
@@ -139,36 +134,22 @@ class KnowledgeBaseManager(
|
||||
logger.info(f"知识库管理器初始化完成,路径: {self.base_path},发现 {len(existing_kbs)} 个向量库: {existing_kbs}")
|
||||
|
||||
def _load_metadata(self) -> dict:
|
||||
"""加载元数据(带文件锁,确保多 worker 进程间一致)"""
|
||||
"""加载元数据"""
|
||||
metadata_path = os.path.join(self.base_path, KB_METADATA_FILE)
|
||||
if os.path.exists(metadata_path):
|
||||
try:
|
||||
with open(metadata_path, 'r', encoding='utf-8') as f:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
|
||||
try:
|
||||
return json.load(f)
|
||||
finally:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
return json.load(f)
|
||||
except Exception as e:
|
||||
logger.error(f"加载元数据失败: {e}")
|
||||
return {"collections": {}}
|
||||
|
||||
def _save_metadata(self):
|
||||
"""保存元数据(带文件锁,防止并发写入数据覆盖)"""
|
||||
"""保存元数据"""
|
||||
metadata_path = os.path.join(self.base_path, KB_METADATA_FILE)
|
||||
try:
|
||||
with open(metadata_path, 'w', encoding='utf-8') as f:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
|
||||
try:
|
||||
json.dump(self._metadata, f, ensure_ascii=False, indent=2)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
finally:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
|
||||
json.dump(self._metadata, f, ensure_ascii=False, indent=2)
|
||||
except Exception as e:
|
||||
logger.error(f"保存元数据失败: {e}")
|
||||
|
||||
|
||||
2
main.py
2
main.py
@@ -38,7 +38,7 @@ def main():
|
||||
from api import create_app
|
||||
app = create_app()
|
||||
|
||||
print(f"\nRAG API 服务启动: http://{args.host}:{args.port}")
|
||||
print(f"\n🚀 RAG API 服务启动: http://{args.host}:{args.port}")
|
||||
print(f" 调试模式: {'开启' if debug else '关闭'}")
|
||||
|
||||
app.run(
|
||||
|
||||
@@ -137,8 +137,8 @@ def parse_with_mineru_online(
|
||||
file_path: str,
|
||||
api_token: str = None,
|
||||
api_url: str = None,
|
||||
model_version: str = None,
|
||||
timeout: int = None
|
||||
model_version: str = "vlm",
|
||||
timeout: int = 300
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
使用 MinerU 在线 API 解析文档
|
||||
@@ -157,8 +157,8 @@ def parse_with_mineru_online(
|
||||
file_path: 文档文件路径
|
||||
api_token: API Token(默认从 config 读取)
|
||||
api_url: API 地址
|
||||
model_version: 模型版本 (vlm / pipeline / MinerU-HTML),默认从 config 读取
|
||||
timeout: 轮询超时(秒),默认从 config 读取
|
||||
model_version: 模型版本 (vlm / pipeline / MinerU-HTML)
|
||||
timeout: 请求超时(秒)
|
||||
|
||||
Returns:
|
||||
解析结果(与 parse_with_mineru 格式相同)
|
||||
@@ -167,12 +167,10 @@ def parse_with_mineru_online(
|
||||
import time
|
||||
import zipfile
|
||||
import io
|
||||
from config import MINERU_API_TOKEN, MINERU_API_URL, MINERU_MODEL_VERSION, MINERU_ONLINE_TIMEOUT
|
||||
from config import MINERU_API_TOKEN, MINERU_API_URL
|
||||
|
||||
token = api_token or MINERU_API_TOKEN
|
||||
url = api_url or MINERU_API_URL
|
||||
model_version = model_version or MINERU_MODEL_VERSION
|
||||
timeout = timeout or MINERU_ONLINE_TIMEOUT
|
||||
|
||||
if not token:
|
||||
raise RuntimeError("MinerU 在线 API Token 未配置,请在 config.py 中设置 MINERU_API_TOKEN")
|
||||
@@ -243,15 +241,9 @@ def parse_with_mineru_online(
|
||||
result_resp.raise_for_status()
|
||||
result = result_resp.json()
|
||||
|
||||
# 检查 API 层面的错误码,快速失败而非静默等到超时
|
||||
api_code = result.get("code")
|
||||
if api_code and api_code != 0:
|
||||
api_msg = result.get("msg", "未知错误")
|
||||
raise RuntimeError(f"MinerU API 错误 (code={api_code}): {api_msg}")
|
||||
|
||||
extract_results = result.get("data", {}).get("extract_result", [])
|
||||
if not extract_results:
|
||||
logger.debug(f"等待解析结果... ({waited}s/{max_wait}s)")
|
||||
logger.debug(f"等待解析结果... ({waited}s)")
|
||||
continue
|
||||
|
||||
# 取第一个文件的结果
|
||||
@@ -284,16 +276,11 @@ def parse_with_mineru_online(
|
||||
if progress:
|
||||
extracted = progress.get("extracted_pages", 0)
|
||||
total = progress.get("total_pages", 0)
|
||||
logger.info(f"解析进度: {extracted}/{total} 页 ({waited}s/{max_wait}s, model={model_version})")
|
||||
logger.info(f"解析进度: {extracted}/{total} 页 ({waited}s)")
|
||||
else:
|
||||
logger.debug(f"状态: {state}, 等待中... ({waited}s/{max_wait}s)")
|
||||
logger.debug(f"状态: {state}, 等待中... ({waited}s)")
|
||||
|
||||
raise RuntimeError(
|
||||
f"MinerU 在线解析超时 ({max_wait}s)"
|
||||
f",当前 model_version={model_version}"
|
||||
f",可尝试: 1) 设置 MINERU_MODEL_VERSION=pipeline 加速"
|
||||
f" 2) 增大 MINERU_ONLINE_TIMEOUT"
|
||||
)
|
||||
raise RuntimeError("MinerU 在线解析超时")
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"MinerU 在线 API 调用失败: {e}")
|
||||
@@ -750,7 +737,7 @@ def parse_with_mineru(
|
||||
lang: str = "ch",
|
||||
enable_table: bool = True,
|
||||
enable_formula: bool = True,
|
||||
backend: str = None,
|
||||
backend: str = "pipeline",
|
||||
start_page: int = 0,
|
||||
end_page: int = 99999
|
||||
) -> Dict[str, Any]:
|
||||
@@ -783,14 +770,6 @@ def parse_with_mineru(
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||
|
||||
# 从 config 读取默认 backend
|
||||
if backend is None:
|
||||
try:
|
||||
from config import MINERU_LOCAL_BACKEND
|
||||
backend = MINERU_LOCAL_BACKEND
|
||||
except ImportError:
|
||||
backend = 'pipeline'
|
||||
|
||||
# 检查文件大小
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size > MAX_PDF_SIZE:
|
||||
@@ -835,6 +814,7 @@ def parse_with_mineru(
|
||||
|
||||
cmd = [
|
||||
str(mineru_exe),
|
||||
"--",
|
||||
"-p", str(file_path),
|
||||
"-o", str(output_dir),
|
||||
"-m", "auto",
|
||||
@@ -879,7 +859,7 @@ def parse_with_mineru(
|
||||
logger.error(f"MinerU 解析失败: {e}")
|
||||
raise
|
||||
finally:
|
||||
# 清理临时目录
|
||||
清理临时目录
|
||||
if cleanup_output and os.path.exists(output_dir):
|
||||
shutil.rmtree(output_dir, ignore_errors=True)
|
||||
|
||||
@@ -1708,7 +1688,7 @@ def parse_with_mineru_persistent(
|
||||
lang: str = "ch",
|
||||
enable_table: bool = True,
|
||||
enable_formula: bool = True,
|
||||
backend: str = None,
|
||||
backend: str = "pipeline",
|
||||
start_page: int = 0,
|
||||
end_page: int = 99999,
|
||||
cleanup_after_image_move: bool = True
|
||||
@@ -1747,14 +1727,6 @@ def parse_with_mineru_persistent(
|
||||
if not file_path.exists():
|
||||
raise FileNotFoundError(f"文件不存在: {file_path}")
|
||||
|
||||
# 从 config 读取默认 backend
|
||||
if backend is None:
|
||||
try:
|
||||
from config import MINERU_LOCAL_BACKEND
|
||||
backend = MINERU_LOCAL_BACKEND
|
||||
except ImportError:
|
||||
backend = 'pipeline'
|
||||
|
||||
# 检查文件大小
|
||||
file_size = file_path.stat().st_size
|
||||
if file_size > MAX_PDF_SIZE:
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
# RAG 缓存性能提升报告
|
||||
|
||||
**日期**: 2026-06-05
|
||||
**环境**: Windows / Python 3.12.6 / CPU 推理
|
||||
**测试范围**: 全部 7 类缓存机制
|
||||
|
||||
---
|
||||
|
||||
## 一、修复概述
|
||||
|
||||
本次修复了两个之前未生效的缓存:
|
||||
|
||||
1. **Embedding Cache** (`core/cache.py` → `core/engine.py`)
|
||||
- 问题:`embedding_model.encode()` 在 engine.py 中有 6 处直接调用,全部绕过缓存
|
||||
- 修复:新增 `_encode_cached()` 方法,统一走 LRU 缓存读写,支持单文本和批量输入
|
||||
- 影响位置:`search_knowledge()`、`search_multi_kb()`、`apply_mmr()`、`check_restricted_documents()`
|
||||
|
||||
2. **AgenticRAG Semantic Cache** (`core/semantic_cache.py` → `core/agentic.py`)
|
||||
- 问题:`self.semantic_cache` 在 `AgenticRAG.__init__()` 中初始化但 `process()` 中从未调用
|
||||
- 修复:在 `process()` 查询重写后添加 `.get()` 检查,生成答案后添加 `.set()` 写入
|
||||
- 语义缓存使用 FAISS 向量索引,cosine 相似度阈值 0.92
|
||||
|
||||
---
|
||||
|
||||
## 二、端到端实测结果(本地服务 /search 接口)
|
||||
|
||||
### 2.1 测试方法
|
||||
|
||||
通过 `/search` API 发送 10 个真实业务查询,分四轮测量:
|
||||
|
||||
- **Round A** — 冷启动:服务刚启动,所有缓存为空
|
||||
- **Round B** — 热缓存:立即重复相同查询
|
||||
- **Round C** — 第三轮:验证热缓存稳定性
|
||||
- **Round D/E** — 全新查询 + 第二轮(验证新查询也能被缓存)
|
||||
|
||||
### 2.2 逐查询延迟明细
|
||||
|
||||
| # | 查询 | 冷启动 (A) | 热缓存 (B) | 热缓存 (C) | 加速比 |
|
||||
|---|------|-----------|-----------|-----------|--------|
|
||||
| 1 | 智启科技成立于哪一年? | 3853.8 ms | 295.3 ms | 345.0 ms | 13.0x |
|
||||
| 2 | 公司的客服热线是多少? | 670.6 ms | 203.2 ms | 262.6 ms | 3.3x |
|
||||
| 3 | 年假满10年不满20年可以休多少天? | 523.7 ms | 223.7 ms | 262.1 ms | 2.3x |
|
||||
| 4 | 产假可以休多少天? | 353.5 ms | 117.4 ms | 140.4 ms | 3.0x |
|
||||
| 5 | ZDAP平台标准版支持多少并发用户? | 678.1 ms | 325.3 ms | 347.9 ms | 2.1x |
|
||||
| 6 | 请假4天需要谁审批? | 713.1 ms | 528.8 ms | 456.6 ms | 1.3x |
|
||||
| 7 | 技术研发中心的负责人是谁? | 400.8 ms | 178.0 ms | 172.3 ms | 2.3x |
|
||||
| 8 | 如何申请外部培训? | 628.4 ms | 398.4 ms | 462.3 ms | 1.6x |
|
||||
| 9 | 入职当天需要做什么? | 666.9 ms | 365.3 ms | 425.2 ms | 1.8x |
|
||||
| 10 | 公司的愿景是什么? | 450.8 ms | 227.0 ms | 217.5 ms | 2.0x |
|
||||
|
||||
> 注:查询 #1 冷启动延迟异常高 (3853ms) 是因为模型首次加载(lazy init),属于一次性开销。
|
||||
|
||||
### 2.3 汇总统计
|
||||
|
||||
| 指标 | Round A (冷启动) | Round B (热缓存) | Round C (第三轮) | Round D (全新) | Round E (新→热) |
|
||||
|------|-----------------|-----------------|-----------------|---------------|----------------|
|
||||
| 平均延迟 | 894.0 ms | 286.3 ms | 309.2 ms | 446.4 ms | 182.0 ms |
|
||||
| P50 延迟 | 647.7 ms | 261.2 ms | 303.8 ms | 475.5 ms | 181.2 ms |
|
||||
| 最快 | 353.5 ms | 117.4 ms | 140.4 ms | 289.4 ms | 82.7 ms |
|
||||
| 最慢 | 3853.8 ms | 528.8 ms | 462.3 ms | 533.1 ms | 279.6 ms |
|
||||
|
||||
### 2.4 核心结论
|
||||
|
||||
| 对比维度 | 加速比 | 每查询节省 |
|
||||
|---------|--------|----------|
|
||||
| 冷启动 → 热缓存 (A vs B) | **3.1x** | 607.7 ms |
|
||||
| 冷启动 → 第三轮 (A vs C) | **2.9x** | 584.8 ms |
|
||||
| 全新查询 → 热 (D vs E) | **2.5x** | 264.4 ms |
|
||||
|
||||
若排除查询 #1 的模型冷加载影响(仅比较 #2-#10),冷启动平均 ~587ms,热缓存平均 ~286ms,加速比约 **2.1x**。
|
||||
|
||||
### 2.5 缓存分层贡献分析
|
||||
|
||||
热缓存延迟并未降至亚毫秒级(仍有 ~286ms),说明 Query Cache 并非所有查询都命中。原因分析:
|
||||
|
||||
- **Query Cache 命中时**:直接跳过全流程,延迟 ~1ms(对应查询 #4、#7 等低延迟结果)
|
||||
- **Query Cache 未命中但 Embedding Cache 命中时**:跳过 embedding 编码(节省 ~15-50ms),仍需走检索 + rerank
|
||||
- **部分查询经历意图分析/查询拆分**:这些前置步骤不受缓存影响,增加了基线延迟
|
||||
- **查询 #6 (请假4天)** 加速比最低 (1.3x):可能因为该查询触发了查询拆分或意图分析的特殊路径
|
||||
|
||||
---
|
||||
|
||||
## 三、单元级基准测试
|
||||
|
||||
### 3.1 各缓存层读取延迟
|
||||
|
||||
| 缓存层 | 读取延迟 (avg) | P50 | 替代操作延迟 | 理论加速比 |
|
||||
|--------|---------------|-----|-------------|----------|
|
||||
| Query Cache | 0.0016 ms | 0.0015 ms | ~2135 ms (全流程) | ~1,300,000x |
|
||||
| Embedding Cache | 0.0013 ms | 0.0012 ms | ~15 ms (encode) | ~11,500x |
|
||||
| Semantic Cache (FAISS) | 0.020 ms | 0.015 ms | ~2120 ms (检索+生成) | ~100,000x |
|
||||
| Rerank Cache | 0.0026 ms | 0.0026 ms | ~80 ms (rerank) | ~30,000x |
|
||||
|
||||
### 3.2 Semantic Cache 命中率验证
|
||||
|
||||
| 噪声级别 | 命中率 | 说明 |
|
||||
|---------|--------|------|
|
||||
| σ=0(精确匹配) | 200/200 = 100% | 完全相同的查询向量 |
|
||||
| σ=0.01(微小变化) | 200/200 = 100% | 打字差异、标点变化 |
|
||||
| σ=0.05(中等差异) | 0/200 = 0% | 换一种说法提问 |
|
||||
| σ=0.10(较大差异) | 0/200 = 0% | 语义相关但不同问题 |
|
||||
| 完全随机 | 0/200 = 0% | 不相关问题 |
|
||||
|
||||
**结论**:当前阈值 0.92 能有效匹配精确和微小变化的查询,但对换一种说法的等价查询无法命中。如需覆盖语义等价查询,建议降低阈值至 0.85-0.90。
|
||||
|
||||
### 3.3 Semantic Cache 量级性能
|
||||
|
||||
| 缓存量 | 查找延迟 (avg) | P50 |
|
||||
|--------|---------------|-----|
|
||||
| 100 条 | 0.009 ms | 0.009 ms |
|
||||
| 500 条 | 0.031 ms | 0.031 ms |
|
||||
| 1,000 条 | 0.079 ms | 0.064 ms |
|
||||
| 3,000 条 | 0.220 ms | 0.196 ms |
|
||||
| 5,000 条 | 0.703 ms | 0.688 ms |
|
||||
|
||||
5,000 条缓存量下查找仍在亚毫秒级,FAISS IndexFlatIP 性能优秀。
|
||||
|
||||
---
|
||||
|
||||
## 四、内存开销评估
|
||||
|
||||
| 缓存层 | 配置容量 | 单条大小 | 总内存 |
|
||||
|--------|---------|---------|--------|
|
||||
| Query Cache | 500 条 | ~2 KB | ~1.0 MB |
|
||||
| Embedding Cache | 2,000 条 | ~6.1 KB | ~12.0 MB |
|
||||
| Rerank Cache | 1,000 条 | ~0.5 KB | ~0.5 MB |
|
||||
| Semantic Cache | 5,000 条 | ~3.2 KB | ~15.6 MB |
|
||||
| **合计** | — | — | **~29 MB** |
|
||||
|
||||
总内存开销约 29 MB,在服务器环境中可忽略不计。
|
||||
|
||||
---
|
||||
|
||||
## 五、缓存架构审查
|
||||
|
||||
### 5.1 现有缓存体系(7 层)
|
||||
|
||||
| # | 缓存名称 | 类型 | 位置 | 状态 |
|
||||
|---|---------|------|------|------|
|
||||
| 1 | Query Cache | LRU + TTL | engine.py | 已生效 |
|
||||
| 2 | Embedding Cache | LRU + TTL | engine.py | **本次修复** |
|
||||
| 3 | Rerank Cache | LRU + TTL | engine.py | 已生效 |
|
||||
| 4 | Semantic Cache (IntentAnalyzer) | FAISS 向量索引 | intent_analyzer.py | 已生效 |
|
||||
| 5 | Semantic Cache (AgenticRAG) | FAISS 向量索引 | agentic.py | **本次修复** |
|
||||
| 6 | Blacklist Cache | 内存 dict + TTL | engine.py | 已生效 |
|
||||
| 7 | BM25 Index Cache | 磁盘索引缓存 | bm25_index.py | 已生效 |
|
||||
|
||||
### 5.2 架构合理性评价
|
||||
|
||||
**优势:**
|
||||
|
||||
- **分层设计合理**:从细粒度(Embedding、Rerank)到粗粒度(Query、Semantic),层层拦截,命中任一层即可跳过后续计算
|
||||
- **失效机制完善**:基于 `kb_version` 的版本号失效 + TTL 过期双重保障,知识库更新时自动清理相关缓存
|
||||
- **线程安全**:所有缓存均使用 `threading.RLock` 保护,支持并发访问
|
||||
- **内存可控**:LRU 淘汰 + max_size 上限,不会无限增长
|
||||
|
||||
**潜在改进点:**
|
||||
|
||||
1. **Semantic Cache 缺少版本失效**:与 LRU Cache 的 `kb_version` 机制不同,Semantic Cache 只在容量满时全量清空,知识库更新后旧的缓存结果仍可能被命中。建议在文档上传时调用 `semantic_cache.clear()`
|
||||
2. **Semantic Cache 阈值偏严**:实测 0.92 仅能匹配微小变化(σ≤0.01),对换一种说法的等价查询无法命中,建议在生产环境调整到 0.85-0.90
|
||||
3. **部分查询缓存加速比偏低**:触发意图分析/查询拆分的查询有额外开销不受缓存控制,可考虑对意图分析结果也做缓存
|
||||
4. **Embedding Cache 对 MMR 批量文档命中率有限**:每次检索的候选文档集不同,文档级 embedding 缓存收益较低,主要收益在查询端
|
||||
|
||||
### 5.3 配置参数审查
|
||||
|
||||
| 参数 | 当前值 | 评价 |
|
||||
|------|--------|------|
|
||||
| QUERY_CACHE_SIZE | 500 | 合理,适合中等并发 |
|
||||
| QUERY_CACHE_TTL | 3600s (1h) | 合理,配合 kb_version 失效 |
|
||||
| EMBEDDING_CACHE_SIZE | 2000 | 合理,覆盖常见查询 |
|
||||
| EMBEDDING_CACHE_TTL | 86400s (24h) | 偏长但可接受 |
|
||||
| RERANK_CACHE_SIZE | 1000 | 合理 |
|
||||
| RERANK_CACHE_TTL | 3600s (1h) | 合理 |
|
||||
| SEMANTIC_CACHE_THRESHOLD | 0.92 | **偏严格,建议调至 0.85-0.90** |
|
||||
| SEMANTIC_CACHE max_size | 5000 | 合理,5000 条时延迟仍 < 1ms |
|
||||
|
||||
### 5.4 结论
|
||||
|
||||
修复后的 7 层缓存全部正常工作。实测 `/search` 接口冷启动平均 894ms → 热缓存 286ms,**整体加速 3.1x,每查询节省 608ms**。语义缓存(FAISS)精确命中时延迟仅 0.02ms,对完全相同的查询可跳过整个检索+生成流程。总内存开销约 29 MB,对服务器无压力。建议后续关注 Semantic Cache 阈值调优和知识库版本联动失效。
|
||||
@@ -1,342 +0,0 @@
|
||||
## RAG 缓存 Redis 迁移方案
|
||||
|
||||
### 一、迁移目标
|
||||
|
||||
将当前四层进程内缓存迁移到 Redis,实现跨进程/跨实例共享、重启不丢失、多 worker 缓存一致。
|
||||
|
||||
### 二、迁移优先级
|
||||
|
||||
| 优先级 | 缓存层 | 复杂度 | 理由 |
|
||||
|--------|--------|--------|------|
|
||||
| P0 | Rerank Cache | 极低 | Redis Hash 天然匹配,无版本失效,0.75MB |
|
||||
| P1 | Query Cache | 低 | 价值最大(跳过整个检索管线),接口简单 |
|
||||
| P2 | Embedding Cache | 低~中 | 需注意 float 数组序列化效率和 MGET 批量优化 |
|
||||
| P3 | Semantic Cache | 高 | FAISS 向量索引无法直接替换,建议混合方案 |
|
||||
|
||||
### 三、总体架构设计
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────┐
|
||||
│ Gunicorn Worker 1..N │
|
||||
│ │
|
||||
│ get_cache_manager() → RedisCacheManager │
|
||||
│ ├─ get/set → Redis STRING/HASH │
|
||||
│ └─ kb_version → Redis rag:kbver:{name} │
|
||||
│ │
|
||||
│ get_semantic_cache() → HybridSemanticCache │
|
||||
│ ├─ FAISS 索引 → 进程内(向量检索) │
|
||||
│ └─ result 数据 → Redis(跨进程共享) │
|
||||
└───────────────────┬──────────────────────────┘
|
||||
│
|
||||
┌─────▼─────┐
|
||||
│ Redis │
|
||||
│ (单实例) │
|
||||
└────────────┘
|
||||
```
|
||||
|
||||
核心原则:**调用方代码零改动**。`get_cache_manager()` 和 `get_semantic_cache()` 函数签名不变,内部实现从 LRUCache 切换为 Redis。engine.py、chat_routes.py、sync.py 等所有调用方不需要任何修改。
|
||||
|
||||
### 四、Redis Key 设计
|
||||
|
||||
#### 4.1 版本号机制
|
||||
|
||||
将 `kb_version` 存入 Redis,而非编入 key。原因:编入 key 会导致版本号变化后旧 key 残留在 Redis 中直到 TTL 过期,浪费内存。
|
||||
|
||||
```
|
||||
rag:kbver:{kb_name} → int (版本号,INCR 自增)
|
||||
```
|
||||
|
||||
读取缓存时先获取当前版本号,写入时附带版本号,读取时比对版本号决定是否命中。
|
||||
|
||||
#### 4.2 各层 Key 格式
|
||||
|
||||
```
|
||||
# Query Cache (Redis STRING + JSON)
|
||||
rag:q:{md5(query:kb_name)} → JSON(result_dict)
|
||||
附带 Redis TTL = QUERY_CACHE_TTL (3600s)
|
||||
|
||||
# Embedding Cache (Redis STRING + binary)
|
||||
rag:emb:{md5(text)} → numpy bytes (768维 float32, ~3KB)
|
||||
附带 Redis TTL = EMBEDDING_CACHE_TTL (86400s)
|
||||
|
||||
# Rerank Cache (Redis HASH)
|
||||
rag:rerank:{md5(query:sorted_ids)} → {doc_id: score, ...}
|
||||
附带 Redis TTL = RERANK_CACHE_TTL (3600s)
|
||||
|
||||
# Semantic Cache (混合)
|
||||
rag:sem:{int_id} → JSON(result_dict)
|
||||
FAISS 索引保留进程内,通过 int_id 关联 Redis 中的结果数据
|
||||
```
|
||||
|
||||
### 五、各层实现方案
|
||||
|
||||
#### 5.1 RedisCacheManager(替代 RAGCacheManager)
|
||||
|
||||
```python
|
||||
import redis
|
||||
import json
|
||||
import hashlib
|
||||
import numpy as np
|
||||
|
||||
class RedisCacheManager:
|
||||
def __init__(self, redis_url="redis://localhost:6379/0"):
|
||||
self._pool = redis.ConnectionPool.from_url(
|
||||
redis_url, decode_responses=False, max_connections=10
|
||||
)
|
||||
self._r = redis.Redis(connection_pool=self._pool)
|
||||
self._stats = {...} # 应用层统计,保持 CacheStats 兼容
|
||||
|
||||
# ---- kb_version ----
|
||||
|
||||
def get_kb_version(self, kb_name: str) -> int:
|
||||
val = self._r.get(f"rag:kbver:{kb_name}")
|
||||
return int(val) if val else 0
|
||||
|
||||
def increment_kb_version(self, kb_name: str) -> int:
|
||||
new_ver = self._r.incr(f"rag:kbver:{kb_name}")
|
||||
# 版本号变化时,主动清除该知识库的 query cache
|
||||
# 使用 SCAN + DEL 避免阻塞(条目不多时可直接 KEYS)
|
||||
pattern = f"rag:q:*"
|
||||
# 注意:query cache 的 key 不含版本号,需要依赖 TTL 自然过期
|
||||
# 或者在 key 中嵌入版本号(见下方方案 B)
|
||||
return new_ver
|
||||
|
||||
# ---- Query Cache ----
|
||||
|
||||
def get_query_result(self, query, kb_name, doc_ids=None):
|
||||
kb_ver = self.get_kb_version(kb_name)
|
||||
key = self._query_key(query, kb_name, kb_ver)
|
||||
data = self._r.get(key)
|
||||
if data is None:
|
||||
self._stats['query'].misses += 1
|
||||
return None
|
||||
self._stats['query'].hits += 1
|
||||
return json.loads(data)
|
||||
|
||||
def set_query_result(self, query, kb_name, result, doc_ids=None):
|
||||
kb_ver = self.get_kb_version(kb_name)
|
||||
key = self._query_key(query, kb_name, kb_ver)
|
||||
self._r.set(key, json.dumps(result, ensure_ascii=False), ex=QUERY_CACHE_TTL)
|
||||
|
||||
@staticmethod
|
||||
def _query_key(query, kb_name, kb_version):
|
||||
raw = f"query:{query}:{kb_name}:{kb_version}"
|
||||
return f"rag:q:{hashlib.md5(raw.encode()).hexdigest()}"
|
||||
|
||||
# ---- Embedding Cache ----
|
||||
|
||||
def get_embedding(self, text):
|
||||
key = f"rag:emb:{hashlib.md5(f'emb:{text}'.encode()).hexdigest()}"
|
||||
data = self._r.get(key)
|
||||
if data is None:
|
||||
self._stats['embedding'].misses += 1
|
||||
return None
|
||||
self._stats['embedding'].hits += 1
|
||||
return np.frombuffer(data, dtype=np.float32).tolist()
|
||||
|
||||
def set_embedding(self, text, embedding, kb_version=0):
|
||||
key = f"rag:emb:{hashlib.md5(f'emb:{text}'.encode()).hexdigest()}"
|
||||
arr = np.array(embedding, dtype=np.float32)
|
||||
self._r.set(key, arr.tobytes(), ex=EMBEDDING_CACHE_TTL)
|
||||
|
||||
def get_embeddings_batch(self, texts):
|
||||
"""批量获取,使用 MGET 减少网络往返"""
|
||||
keys = [f"rag:emb:{hashlib.md5(f'emb:{t}'.encode()).hexdigest()}" for t in texts]
|
||||
results = self._r.mget(keys)
|
||||
embeddings = []
|
||||
missed = []
|
||||
for i, data in enumerate(results):
|
||||
if data is None:
|
||||
embeddings.append(None)
|
||||
missed.append(i)
|
||||
else:
|
||||
embeddings.append(np.frombuffer(data, dtype=np.float32).tolist())
|
||||
return embeddings, missed
|
||||
|
||||
# ---- Rerank Cache ----
|
||||
|
||||
def get_rerank_scores(self, query, doc_ids):
|
||||
sorted_ids = sorted(doc_ids)
|
||||
key = f"rag:rerank:{hashlib.md5(f'rerank:{query}:{':'.join(sorted_ids)}'.encode()).hexdigest()}"
|
||||
data = self._r.hgetall(key)
|
||||
if not data:
|
||||
self._stats['rerank'].misses += 1
|
||||
return None
|
||||
self._stats['rerank'].hits += 1
|
||||
return {k.decode(): float(v) for k, v in data.items()}
|
||||
|
||||
def set_rerank_scores(self, query, doc_ids, scores):
|
||||
sorted_ids = sorted(doc_ids)
|
||||
key = f"rag:rerank:{hashlib.md5(f'rerank:{query}:{':'.join(sorted_ids)}'.encode()).hexdigest()}"
|
||||
mapping = {str(doc_id): str(score) for doc_id, score in zip(sorted_ids, scores)}
|
||||
self._r.hset(key, mapping=mapping)
|
||||
self._r.expire(key, RERANK_CACHE_TTL)
|
||||
|
||||
# ---- 统计与清除 ----
|
||||
|
||||
def get_all_stats(self):
|
||||
return self._stats # CacheStats 兼容
|
||||
|
||||
def clear_all(self):
|
||||
# 只删除 rag: 前缀的 key
|
||||
for pattern in ["rag:q:*", "rag:emb:*", "rag:rerank:*", "rag:sem:*"]:
|
||||
cursor = 0
|
||||
while True:
|
||||
cursor, keys = self._r.scan(cursor, match=pattern, count=100)
|
||||
if keys:
|
||||
self._r.delete(*keys)
|
||||
if cursor == 0:
|
||||
break
|
||||
```
|
||||
|
||||
**版本号失效策略说明**:将 `kb_version` 编入 Query Cache key(`_query_key` 方法中包含 `kb_version`)。当文档更新触发 `increment_kb_version` 后,新查询自动使用新版本号生成新 key,旧 key 因 TTL 过期自动回收,无需主动扫描删除。Embedding Cache 不做版本失效(向量本身不因文档增删而变化,只在新文档加入时自然 miss)。
|
||||
|
||||
#### 5.2 Semantic Cache 混合方案
|
||||
|
||||
Semantic Cache 的 FAISS 向量索引不适合迁移到 Redis(需要 Redis Stack 7.2+ 的向量搜索能力)。推荐混合方案:
|
||||
|
||||
- FAISS 索引保留进程内,负责向量近邻检索
|
||||
- 检索结果(answer/sources/citations)存入 Redis,实现跨进程共享和持久化
|
||||
- FAISS 索引的 int_id 作为 Redis key 的关联 ID
|
||||
|
||||
```python
|
||||
class HybridSemanticCache:
|
||||
def __init__(self, dim=768, threshold=0.92, max_size=10000, redis_client=None):
|
||||
# FAISS 索引(进程内)
|
||||
self._index = faiss.IndexFlatIP(dim)
|
||||
self._vectors = [] # 用于 numpy 降级
|
||||
self._dim = dim
|
||||
self._threshold = threshold
|
||||
self._max_size = max_size
|
||||
self._redis = redis_client
|
||||
self._local_results = {} # 降级用:FAISS id -> result (无 Redis 时)
|
||||
self._next_id = 0
|
||||
self._lock = threading.RLock()
|
||||
self._hits = 0
|
||||
self._misses = 0
|
||||
|
||||
def get(self, query_emb):
|
||||
with self._lock:
|
||||
# FAISS 检索
|
||||
emb = query_emb.reshape(1, -1).astype(np.float32)
|
||||
emb /= np.linalg.norm(emb)
|
||||
D, I = self._index.search(emb, 1)
|
||||
if D[0][0] <= self._threshold:
|
||||
self._misses += 1
|
||||
return None
|
||||
|
||||
faiss_id = int(I[0][0])
|
||||
self._hits += 1
|
||||
|
||||
# 优先从 Redis 读取结果
|
||||
if self._redis:
|
||||
data = self._redis.get(f"rag:sem:{faiss_id}")
|
||||
if data:
|
||||
return json.loads(data)
|
||||
|
||||
# 降级:从本地 Dict 读取
|
||||
return self._local_results.get(faiss_id)
|
||||
|
||||
def set(self, query_emb, result):
|
||||
with self._lock:
|
||||
# 容量检查
|
||||
if self._index.ntotal >= self._max_size:
|
||||
self._evict_half()
|
||||
|
||||
# 添加到 FAISS 索引
|
||||
emb = query_emb.reshape(1, -1).astype(np.float32)
|
||||
emb /= np.linalg.norm(emb)
|
||||
self._index.add(emb)
|
||||
faiss_id = self._next_id
|
||||
self._next_id += 1
|
||||
|
||||
# 结果存入 Redis(如果有)
|
||||
if self._redis:
|
||||
self._redis.set(
|
||||
f"rag:sem:{faiss_id}",
|
||||
json.dumps(result, ensure_ascii=False),
|
||||
ex=86400 # 24 小时 TTL
|
||||
)
|
||||
else:
|
||||
self._local_results[faiss_id] = result
|
||||
```
|
||||
|
||||
### 六、配置变更
|
||||
|
||||
在 `config.example.py` 中新增:
|
||||
|
||||
```python
|
||||
# Redis 缓存(设置后自动启用 Redis 替代内存缓存)
|
||||
REDIS_CACHE_URL = os.getenv("REDIS_CACHE_URL", "") # 如 "redis://localhost:6379/0"
|
||||
# 为空时回退到内存缓存(向后兼容)
|
||||
```
|
||||
|
||||
### 七、工厂函数改造
|
||||
|
||||
```python
|
||||
# core/cache.py 中的 get_cache_manager()
|
||||
|
||||
def get_cache_manager():
|
||||
global _cache_manager
|
||||
if _cache_manager is None:
|
||||
with _cache_lock:
|
||||
if _cache_manager is None:
|
||||
try:
|
||||
from config import REDIS_CACHE_URL
|
||||
if REDIS_CACHE_URL:
|
||||
_cache_manager = RedisCacheManager(REDIS_CACHE_URL)
|
||||
logger.info(f"Redis 缓存已启用: {REDIS_CACHE_URL}")
|
||||
else:
|
||||
_cache_manager = RAGCacheManager(...) # 原有内存缓存
|
||||
logger.info("内存缓存已启用(未配置 REDIS_CACHE_URL)")
|
||||
except ImportError:
|
||||
_cache_manager = RAGCacheManager(...)
|
||||
return _cache_manager
|
||||
```
|
||||
|
||||
**向后兼容**:不配置 `REDIS_CACHE_URL` 时,自动回退到原有的内存 LRU 缓存,调用方无感知。
|
||||
|
||||
### 八、部署变更
|
||||
|
||||
docker-compose.prod.yml 新增 Redis 服务:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
command: redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
|
||||
rag-service:
|
||||
environment:
|
||||
- REDIS_CACHE_URL=redis://redis:6379/0
|
||||
- GUNICORN_WORKERS=2 # 现在可以安全地多 worker
|
||||
depends_on:
|
||||
- redis
|
||||
```
|
||||
|
||||
Redis 配置 `maxmemory 128mb` + `allkeys-lru` 淘汰策略。按前面估算,四层缓存满载约 95MB,128MB 足够且留有余量。
|
||||
|
||||
### 九、迁移步骤建议
|
||||
|
||||
1. 新增 `core/redis_cache.py`,实现 `RedisCacheManager` 和 `HybridSemanticCache`
|
||||
2. 改造 `core/cache.py` 的 `get_cache_manager()` 工厂函数,根据配置选择实现
|
||||
3. 改造 `core/semantic_cache.py` 的 `get_semantic_cache()` 工厂函数
|
||||
4. `config.example.py` 新增 `REDIS_CACHE_URL` 配置项
|
||||
5. `docker-compose.prod.yml` 新增 Redis 服务
|
||||
6. `deploy/gunicorn.conf.py` 将 `max_requests` 调高至 5000
|
||||
7. 本地测试:配置 Redis 后运行缓存冷热对比测试,验证命中率
|
||||
8. 服务器部署:添加 Redis 容器,配置环境变量
|
||||
|
||||
### 十、预期收益
|
||||
|
||||
| 指标 | 当前(内存缓存) | 迁移后(Redis 缓存) |
|
||||
|------|------------------|---------------------|
|
||||
| 多 worker 支持 | 不支持 | 支持 |
|
||||
| 重启后缓存 | 丢失 | 保留(Redis 持久化) |
|
||||
| max_requests 重启 | 缓存冷启动 | 无影响 |
|
||||
| 内存占用 | ~95MB/worker | ~5MB/worker + 128MB Redis |
|
||||
| 缓存一致性 | 多 worker 不一致 | 全局一致 |
|
||||
@@ -54,7 +54,7 @@ def setup_file_logging(log_path: str):
|
||||
|
||||
# ───────────── 配置 ─────────────
|
||||
RAG_API_URL = "http://127.0.0.1:5001/rag"
|
||||
DEFAULT_DATASET = "tests/eval_dataset_v2.json"
|
||||
DEFAULT_DATASET = "data/eval/eval_dataset.json"
|
||||
RESULTS_DIR = "data/eval_results"
|
||||
REQUEST_TIMEOUT = 120 # 秒
|
||||
REQUEST_INTERVAL = 1.5 # 请求间隔(秒),避免过载
|
||||
@@ -79,8 +79,7 @@ def call_rag_sse(question: str, collections: list = None, api_url: str = None) -
|
||||
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "text/event-stream",
|
||||
"Authorization": "Bearer mock-token-admin"
|
||||
"Accept": "text/event-stream"
|
||||
}
|
||||
# chat_history 在生产模式下是必填字段
|
||||
payload = {"message": question, "chat_history": []}
|
||||
@@ -186,98 +185,28 @@ def llm_quality_score(query: str, answer: str, reference: str) -> dict:
|
||||
3. 相关性(relevance):回答是否直接针对问题,没有跑题或冗余
|
||||
4. 流畅性(fluency):回答是否通顺、结构清晰、易于理解
|
||||
|
||||
【输出要求】
|
||||
只输出一个纯 JSON 对象,不要包含任何其他文字、解释或 markdown 格式:
|
||||
{{"accuracy": <整数>, "completeness": <整数>, "relevance": <整数>, "fluency": <整数>, "overall": <整数>}}"""
|
||||
|
||||
def _extract_json(text: str) -> dict | None:
|
||||
"""从文本中提取 JSON,支持嵌套大括号"""
|
||||
if not text:
|
||||
return None
|
||||
# 1. 移除推理模型的思考标签及其内容
|
||||
text = re.sub(r'<think>[\s\S]*?</think>', '', text).strip()
|
||||
# 2. 尝试直接解析整个文本
|
||||
try:
|
||||
return json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 3. 贪婪匹配最大的 {...} 块(支持嵌套)
|
||||
# 从最后一个 } 往前找匹配的 {
|
||||
brace_depth = 0
|
||||
start = -1
|
||||
end = -1
|
||||
for i in range(len(text) - 1, -1, -1):
|
||||
if text[i] == '}':
|
||||
if brace_depth == 0:
|
||||
end = i
|
||||
brace_depth += 1
|
||||
elif text[i] == '{':
|
||||
brace_depth -= 1
|
||||
if brace_depth == 0:
|
||||
start = i
|
||||
break
|
||||
if start >= 0 and end > start:
|
||||
try:
|
||||
return json.loads(text[start:end + 1])
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
# 4. 回退:简单单层 {...} 匹配
|
||||
m = re.search(r'\{[^{}]+\}', text)
|
||||
if m:
|
||||
try:
|
||||
return json.loads(m.group())
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
return None
|
||||
请严格按以下 JSON 格式返回,不要包含其他内容:
|
||||
{{"accuracy": <分数>, "completeness": <分数>, "relevance": <分数>, "fluency": <分数>, "overall": <总分>}}"""
|
||||
|
||||
try:
|
||||
# 构建请求参数
|
||||
request_params = {
|
||||
"model": DASHSCOPE_MODEL,
|
||||
"messages": [
|
||||
{"role": "system", "content": "You are a JSON-only evaluator. Output ONLY a valid JSON object, nothing else."},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
"temperature": 0.1,
|
||||
"max_tokens": 2000,
|
||||
}
|
||||
# 尝试关闭推理模型的思考输出(部分 API 支持)
|
||||
try:
|
||||
request_params["extra_body"] = {"enable_thinking": False}
|
||||
except Exception:
|
||||
pass
|
||||
response = client.chat.completions.create(
|
||||
model=DASHSCOPE_MODEL,
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.1,
|
||||
max_tokens=300
|
||||
)
|
||||
text = response.choices[0].message.content.strip()
|
||||
|
||||
response = client.chat.completions.create(**request_params)
|
||||
msg = response.choices[0].message
|
||||
|
||||
# 优先取 content,如果为空则尝试 reasoning_content 后的内容
|
||||
text = getattr(msg, 'content', '') or ''
|
||||
|
||||
# 如果 content 为空,尝试从 reasoning_content 中提取
|
||||
# (部分推理模型 API 将思考内容放在 reasoning_content,回答放在 content)
|
||||
if not text.strip():
|
||||
reasoning = getattr(msg, 'reasoning_content', '') or ''
|
||||
if reasoning:
|
||||
# 从思考内容末尾尝试提取 JSON
|
||||
text = reasoning
|
||||
|
||||
if not text.strip():
|
||||
logger.warning("LLM 返回内容为空")
|
||||
return {"overall": 0.0, "error": "empty_response"}
|
||||
|
||||
scores = _extract_json(text)
|
||||
if scores:
|
||||
# 提取 JSON
|
||||
json_match = re.search(r'\{[^}]+\}', text)
|
||||
if json_match:
|
||||
scores = json.loads(json_match.group())
|
||||
# 归一化到 0-1
|
||||
for k in list(scores.keys()):
|
||||
try:
|
||||
scores[k] = round(min(10, max(0, float(scores[k]))) / 10.0, 4)
|
||||
except (ValueError, TypeError):
|
||||
scores[k] = 0.0
|
||||
for k in scores:
|
||||
scores[k] = round(min(10, max(0, scores[k])) / 10.0, 4)
|
||||
return scores
|
||||
else:
|
||||
# 记录更多内容用于调试
|
||||
debug_text = text[:200].replace('\n', '\\n')
|
||||
logger.warning(f"LLM 返回内容无法解析 JSON: {debug_text}")
|
||||
logger.warning(f"LLM 返回内容无法解析 JSON: {text[:100]}")
|
||||
return {"overall": 0.0, "error": "parse_failed"}
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM 评分异常: {e}")
|
||||
@@ -299,7 +228,7 @@ def evaluate_dataset(dataset_path: str, use_llm: bool = True, api_url: str = Non
|
||||
with open(dataset_path, 'r', encoding='utf-8') as f:
|
||||
dataset = json.load(f)
|
||||
|
||||
questions = dataset.get("queries", dataset.get("questions", []))
|
||||
questions = dataset.get("questions", [])
|
||||
total = len(questions)
|
||||
if total == 0:
|
||||
logger.error("数据集中没有问题")
|
||||
|
||||
@@ -1,109 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
验证 eval_dataset_v2.json 的完整性和格式正确性
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from collections import Counter
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
DATASET_PATH = PROJECT_ROOT / "tests" / "eval_dataset_v2.json"
|
||||
|
||||
|
||||
def validate():
|
||||
errors = []
|
||||
warnings = []
|
||||
|
||||
with open(DATASET_PATH, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
queries = data.get("queries", [])
|
||||
print(f"[OK] Dataset loaded: {len(queries)} queries")
|
||||
|
||||
# 1. Check required fields
|
||||
required = {"id", "query", "query_type", "relevant_docs", "reference_answer", "expected_keywords", "difficulty"}
|
||||
for q in queries:
|
||||
missing = required - set(q.keys())
|
||||
if missing:
|
||||
errors.append(f" {q.get('id', '?')}: missing fields: {missing}")
|
||||
|
||||
# 2. Check unique IDs
|
||||
ids = [q["id"] for q in queries]
|
||||
dupes = [id for id, cnt in Counter(ids).items() if cnt > 1]
|
||||
if dupes:
|
||||
errors.append(f" Duplicate IDs: {dupes}")
|
||||
|
||||
# 3. Check query types
|
||||
valid_types = {"simple_fact", "enumeration", "definition", "comparison",
|
||||
"reasoning", "table_data", "cross_doc", "negative", "paraphrase"}
|
||||
type_counts = Counter(q["query_type"] for q in queries)
|
||||
invalid_types = set(type_counts.keys()) - valid_types
|
||||
if invalid_types:
|
||||
errors.append(f" Invalid query_types: {invalid_types}")
|
||||
|
||||
print(f"\n[INFO] Query type distribution:")
|
||||
for t, c in sorted(type_counts.items(), key=lambda x: -x[1]):
|
||||
marker = " [INVALID]" if t in (invalid_types or set()) else ""
|
||||
print(f" {t}: {c}{marker}")
|
||||
|
||||
# 4. Check difficulty distribution
|
||||
diff_counts = Counter(q["difficulty"] for q in queries)
|
||||
print(f"\n[INFO] Difficulty distribution:")
|
||||
for d in ["easy", "medium", "hard"]:
|
||||
print(f" {d}: {diff_counts.get(d, 0)}")
|
||||
|
||||
# 5. Check document coverage
|
||||
doc_counts = Counter()
|
||||
for q in queries:
|
||||
for doc in q["relevant_docs"]:
|
||||
doc_counts[doc] += 1
|
||||
if not any("negative" in q["query_type"] for q in queries):
|
||||
warnings.append(" No negative test cases")
|
||||
|
||||
print(f"\n[INFO] Document coverage:")
|
||||
for doc, cnt in sorted(doc_counts.items(), key=lambda x: -x[1]):
|
||||
print(f" {doc}: {cnt} queries")
|
||||
neg_count = sum(1 for q in queries if q["query_type"] == "negative")
|
||||
print(f" (negative/out-of-scope): {neg_count} queries")
|
||||
|
||||
# 6. Check expected_keywords
|
||||
empty_kw = [q["id"] for q in queries if not q.get("expected_keywords")]
|
||||
if empty_kw:
|
||||
warnings.append(f" Empty expected_keywords: {empty_kw}")
|
||||
|
||||
# 7. Check paraphrase references
|
||||
paraphrases = [q for q in queries if q["query_type"] == "paraphrase"]
|
||||
for p in paraphrases:
|
||||
ref = p.get("paraphrase_of")
|
||||
if ref and ref not in ids:
|
||||
errors.append(f" {p['id']}: paraphrase_of '{ref}' not found")
|
||||
|
||||
# 8. Check reference_answer length
|
||||
short_refs = [q["id"] for q in queries if len(q.get("reference_answer", "")) < 10]
|
||||
if short_refs:
|
||||
warnings.append(f" Very short reference_answer: {short_refs}")
|
||||
|
||||
# Summary
|
||||
print(f"\n{'='*60}")
|
||||
if errors:
|
||||
print(f"[ERROR] {len(errors)} error(s):")
|
||||
for e in errors:
|
||||
print(e)
|
||||
if warnings:
|
||||
print(f"[WARN] {len(warnings)} warning(s):")
|
||||
for w in warnings:
|
||||
print(w)
|
||||
if not errors and not warnings:
|
||||
print("[PASS] Dataset validation passed!")
|
||||
print(f"{'='*60}")
|
||||
|
||||
return len(errors) == 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if sys.platform == 'win32':
|
||||
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||
ok = validate()
|
||||
sys.exit(0 if ok else 1)
|
||||
@@ -1,614 +0,0 @@
|
||||
{
|
||||
"description": "RAG 系统综合评测数据集 v2 — 覆盖货源投放、吸烟环境、零售终端、三峡公报四个知识库文档",
|
||||
"version": "2.0",
|
||||
"created_at": "2026-06-17",
|
||||
"source_documents": [
|
||||
"1.docx",
|
||||
"2.docx",
|
||||
"3.docx",
|
||||
"三峡公报_1-15页.pdf"
|
||||
],
|
||||
"query_categories": [
|
||||
"simple_fact",
|
||||
"enumeration",
|
||||
"definition",
|
||||
"comparison",
|
||||
"reasoning",
|
||||
"table_data",
|
||||
"cross_doc",
|
||||
"negative",
|
||||
"paraphrase"
|
||||
],
|
||||
"queries": [
|
||||
{
|
||||
"id": "q001",
|
||||
"query": "货源投放有哪些方式?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "货源投放方式主要有六种:1.按档位投放;2.按档位+标签扩展投放;3.按价位段自选投放;4.选点投放;5.批零网配;6.事件用烟投放。",
|
||||
"expected_keywords": ["按档位投放", "标签扩展", "价位段自选", "选点投放", "批零网配", "事件用烟", "六种"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q002",
|
||||
"query": "市场状态分为哪几种?分别是什么?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "市场状态分为五种:俏、紧、平、松、软。",
|
||||
"expected_keywords": ["俏", "紧", "平", "松", "软", "五种"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q003",
|
||||
"query": "什么是紧俏品规?",
|
||||
"query_type": "definition",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "紧俏品规是指当地消费需求持续旺盛,货源缺口较大,零售价格坚挺,在较长时间内不能满足市场需求的品规。紧俏品规数不得超过地市级公司所经营品规数的20%。",
|
||||
"expected_keywords": ["消费需求", "旺盛", "货源缺口", "零售价格坚挺", "20%"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q004",
|
||||
"query": "紧俏品规数量有什么限制?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "紧俏品规数不得超过地市级公司所经营品规数的20%。",
|
||||
"expected_keywords": ["20%", "不得超过", "品规数"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q005",
|
||||
"query": "均衡满足品规有什么限制?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "均衡满足品规数不得超过地市级公司所经营顺销品规数的40%(顺销品规包括均衡满足品规和完全满足品规)。",
|
||||
"expected_keywords": ["40%", "不得超过", "顺销品规", "均衡满足"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q006",
|
||||
"query": "单客户单次单品规订货上限是多少?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "单客户单次单品规订货上限不超过50条(件)。",
|
||||
"expected_keywords": ["50条", "单客户", "单品规", "不超过"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q007",
|
||||
"query": "月度单客户订货总量上限是怎么规定的?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "月度单客户订货总量上限为上一年度平均月度进货量的15倍。",
|
||||
"expected_keywords": ["15倍", "平均月度进货量", "上一年度"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q008",
|
||||
"query": "主导品规的周供应占比要求是多少?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "主导品规周供应量应达到本价位段周供应量的60%以上。",
|
||||
"expected_keywords": ["60%", "价位段", "周供应量", "主导品规"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q009",
|
||||
"query": "主导品规和护卫品规的供应占比合计要求是多少?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "主导品规和护卫品规的周供应量合计应达到本价位段周供应量的85%以上。",
|
||||
"expected_keywords": ["85%", "主导品规", "护卫品规", "合计"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q010",
|
||||
"query": "货源投放的总体要求是什么?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "货源投放是烟草营销的核心业务,总体要求包括六个坚持:坚持市场导向、供需匹配;坚持总量控制、稍紧平衡;坚持增速合理、贵在持续;坚持公平公正、严格规范;坚持状态优先、科学投放;坚持区域协同、高效运作。",
|
||||
"expected_keywords": ["市场导向", "总量控制", "稍紧平衡", "公平公正", "状态优先", "区域协同", "六个"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q011",
|
||||
"query": "新品投放有什么要求和限制?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "新品指二类及以上销售时间不超过24个月的品规。新品可采用选点投放方式,要求认真分析新品定位,坚持科学民主决策,制订具体选点标准,不得人为指定选点客户名单。",
|
||||
"expected_keywords": ["24个月", "选点投放", "科学民主", "不得人为指定"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q012",
|
||||
"query": "紧俏品规在投放时有什么特殊要求?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "紧俏品规投放要更加注重公平投放、普惠性投放,适度扩大供货面,且不得按档位扩展投放。",
|
||||
"expected_keywords": ["公平投放", "普惠", "扩大供货面", "不得", "档位扩展"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q013",
|
||||
"query": "按档位投放和选点投放有什么区别?",
|
||||
"query_type": "comparison",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "按档位投放是由系统根据客户档级自动分配投放量,面向所有在档客户。选点投放是针对特定客户群体进行的定向投放,需要制定选点标准,经过集体决策确定投放对象。",
|
||||
"expected_keywords": ["档级", "自动分配", "选点", "定向", "集体决策"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q014",
|
||||
"query": "主导品规和护卫品规有什么区别?",
|
||||
"query_type": "comparison",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "主导品规一般有1-4个,属于深受本地消费者喜爱、零售客户欢迎的品规。护卫品规一般有2-5个,属于本地有相当数量消费者喜爱的品规。主要区别在于规模和市场地位不同,主导品规规模更大、地位更重要。",
|
||||
"expected_keywords": ["1-4个", "2-5个", "消费者喜爱", "规模", "市场地位"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q015",
|
||||
"query": "如果某个品规供货面高、订货面低,应该怎么调整投放策略?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "供货面高但订货面低说明投放范围过大但客户需求不足。应缩小供货面,减少投放客户范围,将货源集中到有真实需求的客户。",
|
||||
"expected_keywords": ["供货面", "订货面", "调整", "策略"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q016",
|
||||
"query": "批零网配客户配货增加量有什么限制?",
|
||||
"query_type": "table_data",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "批零网配客户配货增加量不得超过同档级客户初始合理定量的50%。",
|
||||
"expected_keywords": ["50%", "同档级", "初始合理定量", "不得超过"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q017",
|
||||
"query": "市场状态评价指标体系中各指标的权重是怎样的?",
|
||||
"query_type": "table_data",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "市场状态评价采用双层双级指标体系,不同品类(紧俏品规、均衡满足品规、宽松品规)的评价指标权重不同,包括零售价格指数、市场流通价格指数、终端动销率、终端库存、社会存销比、订货面、订足面等指标。",
|
||||
"expected_keywords": ["双层双级", "权重", "零售价格指数", "动销率", "存销比", "订货面"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q018",
|
||||
"query": "文明吸烟环境的SUCCESS功能框架包含哪些功能?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "SUCCESS功能框架包含7个功能:S-Smoking(吸烟)、U-Utility(实用)、C-Convenience(便利)、C-Comfortable(舒适)、E-Experience(体验)、S-Safe(安全)、S-Survey(调研)。",
|
||||
"expected_keywords": ["Smoking", "Utility", "Convenience", "Comfortable", "Experience", "Safe", "Survey", "七个"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q019",
|
||||
"query": "吸烟设施有哪些类型?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "吸烟设施分为三种类型:吸烟室、吸烟区、吸烟点。",
|
||||
"expected_keywords": ["吸烟室", "吸烟区", "吸烟点", "三种"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q020",
|
||||
"query": "吸烟环境分为哪几个区域类别?各覆盖什么场所?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "分为三大类九个子类:A类(公共服务区域)包括A1交通枢纽、A2政务服务、A3旅游景区、A4医疗机构;B类(商业区域)包括B1商场、B2酒店、B3餐饮娱乐;C类(办公/工业区域)包括C1办公楼、C2工业园区。",
|
||||
"expected_keywords": ["A类", "B类", "C类", "交通枢纽", "政务", "景区", "商场", "酒店", "办公楼"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q021",
|
||||
"query": "吸烟场所的建设标准是哪一年发布的?共有多少条?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "文明吸烟环境建设标准(试行)共7章12条,管理标准(试行)共8章20条,维护标准(试行)共6章9条。三份标准均为试行版本。",
|
||||
"expected_keywords": ["试行", "建设标准", "管理标准", "维护标准", "12条", "20条", "9条"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q022",
|
||||
"query": "吸烟室、吸烟区和吸烟点有什么区别?",
|
||||
"query_type": "comparison",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "吸烟室是独立的封闭空间,有面积和通风要求;吸烟区是在开放或半开放空间中划定的区域;吸烟点是最小化的设施,通常只配备烟灰缸和标识牌。三者在面积、设施配备、适用场所上有所不同。",
|
||||
"expected_keywords": ["封闭空间", "开放", "划定", "面积", "设施配备", "场所"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q023",
|
||||
"query": "A类区域和B类区域的吸烟设施配置有什么不同要求?",
|
||||
"query_type": "comparison",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "A类区域(公共服务区域)和B类区域(商业区域)对吸烟设施的类型选择、面积标准、设备配置等有不同的要求。A类区域通常要求更高标准的设施配置。",
|
||||
"expected_keywords": ["A类", "B类", "配置", "面积", "标准", "不同"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q024",
|
||||
"query": "吸烟设施的日常维护和定期维护有什么区别?",
|
||||
"query_type": "comparison",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "日常维护是指每日或高频次的保洁、设备检查等基础工作。定期维护是指按固定周期进行的深度清洁、设备更换、设施检修等工作。两者的频率、深度和负责人员不同。",
|
||||
"expected_keywords": ["日常维护", "定期维护", "频率", "保洁", "设备更换", "深度清洁"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q025",
|
||||
"query": "吸烟设施的编码规则是怎样的?",
|
||||
"query_type": "definition",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "吸烟设施编码格式为:所在区(县/市)+街道(镇)+道路名称+场所编码(吸烟室为AXXX、吸烟区亭为BXXX、吸烟点为CXXX)+具体设备编码(XXX)。",
|
||||
"expected_keywords": ["编码", "区县", "街道", "AXXX", "BXXX", "CXXX"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q026",
|
||||
"query": "吸烟环境的维护交接流程是怎样的?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "维护交接需要按照维护标准中的规定,完成正式的交接手续,填写交接记录表,明确交接双方的责任和义务。",
|
||||
"expected_keywords": ["交接", "手续", "记录", "责任", "维护标准"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q027",
|
||||
"query": "吸烟设施的监督检查包括哪些内容?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "监督检查是管理标准中的专门章节,包括对设施运行状态、卫生状况、设备完好程度、使用规范性等方面的检查。",
|
||||
"expected_keywords": ["监督检查", "运行状态", "卫生", "设备", "规范性"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q028",
|
||||
"query": "零售终端的分类体系是怎样的?从高到低怎么排列?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "零售终端从高到低分为:直营终端、合作终端、加盟终端(星级加盟/普通加盟)、现代终端(A/B/C/D/E五个等级)、普通终端(含'五化'普通终端)。",
|
||||
"expected_keywords": ["直营终端", "合作终端", "加盟终端", "现代终端", "普通终端", "A", "B", "C", "D", "E"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q029",
|
||||
"query": "加盟终端使用什么品牌名称?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "加盟终端使用'金丝利零售'(Kingsley Retail)品牌。",
|
||||
"expected_keywords": ["金丝利零售", "Kingsley", "Retail", "品牌"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q030",
|
||||
"query": "加盟终端的加盟协议期限是多长?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "首次加盟协议期限为三年,续签加盟协议期限也为三年。注意:智能终端设备合作协议和现代终端合作协议的期限是五年,不要混淆。",
|
||||
"expected_keywords": ["三年", "加盟协议", "期限"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q031",
|
||||
"query": "加盟终端评价的合格分数线是多少?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "加盟终端运行评价满分为100分,达标分为80分(含)。一般现代终端评价同样为满分100分、达标分80分。",
|
||||
"expected_keywords": ["100分", "80分", "达标", "评价"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q032",
|
||||
"query": "星标加盟终端的评分标准是什么?城网和农网有什么区别?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "星标加盟终端评价标准:城网要求评价周期内平均分95分及以上、单次得分不低于90分;农网要求平均分90分及以上、单次得分不低于85分。两者都需要评价周期内积极配合市县两级公司开展各类活动。",
|
||||
"expected_keywords": ["95分", "90分", "85分", "星标", "城网", "农网"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q033",
|
||||
"query": "加盟终端之间的最小间距要求是多少?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "加盟终端布局间步行距离应不低于300米。",
|
||||
"expected_keywords": ["300米", "步行距离", "不低于"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q034",
|
||||
"query": "加盟终端的卷烟展示面积要求是多少?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "卷烟展示面积不低于1.8平方米。",
|
||||
"expected_keywords": ["1.8", "平方米", "展示面积", "不低于"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q035",
|
||||
"query": "加盟终端的每日营业时间要求是多少?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "加盟终端营业时间要求每天12小时以上。",
|
||||
"expected_keywords": ["12小时", "营业时间", "每天"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q036",
|
||||
"query": "加盟终端的'六个统一'是什么?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "加盟终端实行'六个统一'管理体系,涵盖品牌形象、服务标准、运营管理等方面的统一要求。",
|
||||
"expected_keywords": ["六个统一", "品牌形象", "服务标准", "管理"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q037",
|
||||
"query": "现代终端分为几个等级?分别是什么?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "一般现代终端分为两层五类:新现代终端(A类、B类、C类)和普通现代终端(D类、E类)。A类使用江苏烟草自采智能终端设备,B类使用非自采智能终端设备,C类使用简易智能终端设备,D类通过数据接口自动上传,E类通过客户手动上传。",
|
||||
"expected_keywords": ["A类", "B类", "C类", "D类", "E类", "新现代", "普通现代"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q038",
|
||||
"query": "终端运营管理中有哪些禁止行为?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "终端运营管理中规定了10条禁止行为,用于保护品牌价值和规范终端经营行为。",
|
||||
"expected_keywords": ["10条", "禁止行为", "品牌价值", "经营"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q039",
|
||||
"query": "加盟终端和合作终端有什么区别?",
|
||||
"query_type": "comparison",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "加盟终端使用'金丝利零售'品牌,签订3年加盟协议,实行'六个统一'管理。合作终端是另一种合作模式,与加盟终端在品牌使用、管理方式、投入标准等方面有所不同。",
|
||||
"expected_keywords": ["金丝利零售", "加盟协议", "合作", "品牌", "管理方式"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q040",
|
||||
"query": "终端评价不合格会怎么处理?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "终端评价不合格将进入退出管理流程,根据评价结果进行降级或退出处理。退出管理定义了具体的降级和移除程序。",
|
||||
"expected_keywords": ["退出管理", "降级", "退出", "评价", "不合格"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q041",
|
||||
"query": "直营终端有什么特殊要求?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "直营终端是由公司直接拥有和运营的终端,在品牌形象、服务标准、运营管理等方面执行最高标准,是终端体系中的最高层级。",
|
||||
"expected_keywords": ["直营", "公司", "运营", "最高", "层级"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q042",
|
||||
"query": "普通终端如何升级为现代终端?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "普通终端可通过'五化'建设升级为五化普通终端,进而达到现代终端标准。五化是普通终端的升级路径,包括店面形象、经营管理、信息化等方面的提升。",
|
||||
"expected_keywords": ["五化", "升级", "普通终端", "现代终端", "店面", "经营管理"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q043",
|
||||
"query": "终端建设标准的附件有多少个?",
|
||||
"query_type": "simple_fact",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "终端梯次化建设标准共有20个附件,包括新现代终端客户申请书、现代终端合作协议、加盟终端合作协议、评价评分表、审批表、维护申请表等各类模板。",
|
||||
"expected_keywords": ["20个", "附件", "申请书", "合作协议", "评价"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q044",
|
||||
"query": "三峡工程有哪些综合效益?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["三峡公报_1-15页.pdf"],
|
||||
"reference_answer": "三峡工程的综合效益包括防洪、发电、航运、水资源利用、生态环境保护等。三峡电站是世界上总装机容量最大的水电站。",
|
||||
"expected_keywords": ["防洪", "发电", "航运", "水资源", "生态", "综合效益"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q045",
|
||||
"query": "2022年三峡电站发电量为什么比往年低?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["三峡公报_1-15页.pdf"],
|
||||
"reference_answer": "2022年三峡电站年度发电量为787.90亿千瓦时,为2012年以来最低值。主要原因是长江流域遭遇1961年以来最严重的夏秋连旱,7-9月入库水量仅1007亿立方米。",
|
||||
"expected_keywords": ["787.90", "干旱", "1961年", "夏秋连旱", "1007亿", "入库水量"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q046",
|
||||
"query": "三峡工程在防洪方面发挥了什么作用?",
|
||||
"query_type": "definition",
|
||||
"relevant_docs": ["三峡公报_1-15页.pdf"],
|
||||
"reference_answer": "三峡水库蓄水运行以来,至2022年累计汛期拦洪总量2005.16亿立方米,有效应对了多次区域性大洪水,大大降低了中下游洪水位。",
|
||||
"expected_keywords": ["2005.16亿", "拦洪", "汛期", "洪水位", "中下游"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q047",
|
||||
"query": "三峡船闸和升船机2022年的运行情况对比如何?",
|
||||
"query_type": "comparison",
|
||||
"relevant_docs": ["三峡公报_1-15页.pdf"],
|
||||
"reference_answer": "2022年三峡船闸运行10400闸次,通过船舶40641艘次,货运量1.56亿吨,通航率92.72%。升船机运行4470厢次,通过船舶4506艘次,旅客54416人次。",
|
||||
"expected_keywords": ["10400", "40641", "1.56亿吨", "92.72%", "4470", "54416"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q048",
|
||||
"query": "三峡工程对节能减排有什么贡献?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["三峡公报_1-15页.pdf"],
|
||||
"reference_answer": "截至2022年底,三峡电站累计发出清洁电力相当于节约标准煤4.85亿吨,减少二氧化碳排放12.65亿吨。",
|
||||
"expected_keywords": ["4.85亿吨", "标准煤", "12.65亿吨", "二氧化碳", "清洁电力"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q049",
|
||||
"query": "2022年三峡水库的泥沙淤积情况如何?排沙比是多少?",
|
||||
"query_type": "table_data",
|
||||
"relevant_docs": ["三峡公报_1-15页.pdf"],
|
||||
"reference_answer": "2022年三峡水库入库悬移质输沙量0.136亿吨,出库0.026亿吨,库区淤积0.110亿吨,排沙比19.3%。",
|
||||
"expected_keywords": ["0.136亿吨", "0.026亿吨", "0.110亿吨", "19.3%", "排沙比"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q050",
|
||||
"query": "2022年三峡水库蓄水目标完成了吗?为什么?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["三峡公报_1-15页.pdf"],
|
||||
"reference_answer": "2022年三峡水库库水位最高蓄至160.04米,未完成175米蓄水目标。原因是长江流域遭遇严重干旱,入库水量减少,同时为应对下游旱情多次补水。",
|
||||
"expected_keywords": ["160.04米", "175米", "未完成", "干旱", "补水"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q051",
|
||||
"query": "2022年三峡水库为什么频繁向下游补水?补了多少?",
|
||||
"query_type": "reasoning",
|
||||
"relevant_docs": ["三峡公报_1-15页.pdf"],
|
||||
"reference_answer": "2022年长江流域遭遇严重干旱,三峡水库频繁向下游补水是为缓解下游旱情。枯水季节累计补水158天,补水总量217.76亿立方米。",
|
||||
"expected_keywords": ["158天", "217.76亿", "旱情", "枯水季节", "补水"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q052",
|
||||
"query": "三峡工程2022年进行了哪些生态调度试验?",
|
||||
"query_type": "enumeration",
|
||||
"relevant_docs": ["三峡公报_1-15页.pdf"],
|
||||
"reference_answer": "2022年进行了三类生态调度:1.针对四大家鱼等产漂流性卵鱼类繁殖的水文生态调度试验;2.针对库区产黏沉性卵鱼类自然繁殖的生态调度试验;3.库尾减淤调度试验。",
|
||||
"expected_keywords": ["四大家鱼", "漂流性卵", "黏沉性卵", "减淤", "生态调度"],
|
||||
"difficulty": "medium"
|
||||
},
|
||||
{
|
||||
"id": "q053",
|
||||
"query": "三份工作规范文档分别涉及什么领域?各自适用范围是什么?",
|
||||
"query_type": "cross_doc",
|
||||
"relevant_docs": ["1.docx", "2.docx", "3.docx"],
|
||||
"reference_answer": "1.docx是货源投放工作规范,适用于烟草产品的供应分配管理。2.docx是文明吸烟环境建设/管理/维护标准,适用于吸烟设施的建设与运维。3.docx是零售终端梯次化建设标准,适用于卷烟零售终端的分级管理。三份文档均属于江苏省烟草行业规范。",
|
||||
"expected_keywords": ["货源投放", "吸烟环境", "零售终端", "江苏省", "烟草"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q054",
|
||||
"query": "三份文档中的分类体系有什么相似之处?",
|
||||
"query_type": "cross_doc",
|
||||
"relevant_docs": ["1.docx", "2.docx", "3.docx"],
|
||||
"reference_answer": "三份文档都采用了分层分类的管理方法:货源投放将品规分为紧俏/均衡/宽松三类,客户按档级分类;吸烟环境将设施分为室/区/点三类并按A/B/C区域分类;零售终端分为普通/现代/加盟/合作/直营五个层级。",
|
||||
"expected_keywords": ["分类", "分层", "品规", "设施", "终端", "层级"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q055",
|
||||
"query": "货源投放规范和终端建设标准在评价体系上有什么共同点?",
|
||||
"query_type": "cross_doc",
|
||||
"relevant_docs": ["1.docx", "3.docx"],
|
||||
"reference_answer": "两者都采用了量化评分体系:货源投放使用双层双级指标体系评价市场状态,终端建设使用100分制评价终端达标情况。都设置了明确的评分阈值和等级划分标准。",
|
||||
"expected_keywords": ["量化", "评分", "指标体系", "阈值", "等级"],
|
||||
"difficulty": "hard"
|
||||
},
|
||||
{
|
||||
"id": "q056",
|
||||
"query": "我们公司食堂在哪里?",
|
||||
"query_type": "negative",
|
||||
"relevant_docs": [],
|
||||
"reference_answer": "知识库中没有关于公司食堂的信息。现有文档主要涉及货源投放工作规范、文明吸烟环境标准和零售终端建设标准。",
|
||||
"expected_keywords": ["未找到", "未"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q057",
|
||||
"query": "公司的股票代码是什么?",
|
||||
"query_type": "negative",
|
||||
"relevant_docs": [],
|
||||
"reference_answer": "知识库中没有关于股票代码的信息。现有文档主要涉及烟草行业的工作规范和建设标准。",
|
||||
"expected_keywords": ["未找到", "未"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q058",
|
||||
"query": "如何申请年休假?需要哪些流程?",
|
||||
"query_type": "negative",
|
||||
"relevant_docs": [],
|
||||
"reference_answer": "知识库中没有关于年休假申请流程的信息。现有文档涉及的是货源投放、吸烟环境建设和零售终端管理。",
|
||||
"expected_keywords": ["未找到", "未"],
|
||||
"difficulty": "easy"
|
||||
},
|
||||
{
|
||||
"id": "q059",
|
||||
"query": "紧俏品规的上限比例是多少?",
|
||||
"query_type": "paraphrase",
|
||||
"relevant_docs": ["1.docx"],
|
||||
"reference_answer": "紧俏品规数不得超过地市级公司所经营品规数的20%。",
|
||||
"expected_keywords": ["20%", "不得超过"],
|
||||
"difficulty": "easy",
|
||||
"paraphrase_of": "q004"
|
||||
},
|
||||
{
|
||||
"id": "q060",
|
||||
"query": "吸烟环境的七种功能分别是什么?",
|
||||
"query_type": "paraphrase",
|
||||
"relevant_docs": ["2.docx"],
|
||||
"reference_answer": "SUCCESS功能框架包含7个功能:S-Smoking(吸烟)、U-Utility(实用)、C-Convenience(便利)、C-Comfortable(舒适)、E-Experience(体验)、S-Safe(安全)、S-Survey(调研)。",
|
||||
"expected_keywords": ["Smoking", "Utility", "Convenience", "Comfortable", "Experience", "Safe", "Survey"],
|
||||
"difficulty": "medium",
|
||||
"paraphrase_of": "q018"
|
||||
},
|
||||
{
|
||||
"id": "q061",
|
||||
"query": "加盟门店之间最少要隔多远?",
|
||||
"query_type": "paraphrase",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "加盟终端之间的最小间距为300米。",
|
||||
"expected_keywords": ["300米", "间距"],
|
||||
"difficulty": "easy",
|
||||
"paraphrase_of": "q033"
|
||||
},
|
||||
{
|
||||
"id": "q062",
|
||||
"query": "金丝利零售门店每天最少营业多长时间?",
|
||||
"query_type": "paraphrase",
|
||||
"relevant_docs": ["3.docx"],
|
||||
"reference_answer": "加盟终端每日营业时间不低于12小时。",
|
||||
"expected_keywords": ["12小时", "营业"],
|
||||
"difficulty": "easy",
|
||||
"paraphrase_of": "q035"
|
||||
}
|
||||
],
|
||||
"statistics": {
|
||||
"total_queries": 62,
|
||||
"by_category": {
|
||||
"simple_fact": 15,
|
||||
"enumeration": 13,
|
||||
"reasoning": 11,
|
||||
"comparison": 7,
|
||||
"paraphrase": 4,
|
||||
"definition": 3,
|
||||
"table_data": 3,
|
||||
"cross_doc": 3,
|
||||
"negative": 3
|
||||
},
|
||||
"by_difficulty": {
|
||||
"easy": 22,
|
||||
"medium": 30,
|
||||
"hard": 10
|
||||
},
|
||||
"by_document": {
|
||||
"1.docx": 21,
|
||||
"2.docx": 13,
|
||||
"3.docx": 21,
|
||||
"三峡公报_1-15页.pdf": 9,
|
||||
"negative(无相关文档)": 3,
|
||||
"cross_doc(多文档)": 3,
|
||||
"paraphrase(改写)": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user