9 Commits

Author SHA1 Message Date
lacerate551
279e2bf47c chore: 配置集中化、LLM 参数调整与 gitignore 更新
- config.example: 新增 MINERU_PREFER_V2、标题规则引擎、表单二次校正等配置项
- document_routes: DEV_MODE 判断统一收归 config.py
- llm_utils: quick_yes_no max_tokens 10→128,避免截断过短回答
- knowledge/router: 路由 LLM 调用 max_tokens 100→512
- feedback: 反馈分析 LLM 调用 max_tokens 200→512
- .gitignore: 新增 scripts/ 和 plans/ 目录忽略规则

🤖 Generated with [Qoder][https://qoder.com]
2026-06-08 16:14:17 +08:00
lacerate551
acb84b804d fix(auth): 登录速率限制与用户会话隔离增强
- auth_routes: 新增内存级登录速率限制(5min/10次),防止暴力破解
- auth_routes: 统一 DEV_MODE 判断收归 config.py 集中管理
- auth_routes: update_user 增加用户存在性校验
- gateway: require_role 装饰器恢复实际角色校验(原为占位实现)
- session_routes: get_user_sessions 统一传入 limit=20 参数
- session: get_history SQL 增加 id DESC 排序,修复同秒消息顺序错乱

🤖 Generated with [Qoder][https://qoder.com]
2026-06-08 16:12:56 +08:00
lacerate551
0b2ef8c161 feat(rag): 数据驱动的检索优化与表格/图片处理增强
- 移除硬编码关键词列表,改为数据驱动的图片意图检测
- 新增 _section_similarity() 层级章节相似度函数,替代正则匹配
- 新增 _rescue_table_chunks() 表格救援机制,基于 _in_budget_context 标记
- 修复 _build_context_with_budget 表格组超预算不 break 的问题
- 新增 _filter_images_by_answer() 后置图片过滤
- 表格切片 rerank 分数保护策略(同 section 表格不被误删)
- 跨页表格合并逻辑改进(通用标题检测 + 页码不可用降级策略)
- 意图分析增强追问补全 + 缓存类型隔离
- 语义缓存 key 加入 collections 防止跨上下文误命中
- 使用 retrieval_query 替代原始 message 进行检索
- engine.py: CloudReranker 模型更新 + 移除 top_n + table 邻居扩展
- LLM prompt 增加表格处理规则和章节路径提示

🤖 Generated with [Qoder][https://qoder.com]
2026-06-08 16:08:32 +08:00
lacerate551
8e3e9832ff feat(parser): 新增标题识别规则引擎
- 新增 parsers/heading_rules.py,支持可配置的标题层级检测规则
- 支持短中文文本标题识别的独立开关控制

🤖 Generated with [Qoder][https://qoder.com]
2026-06-08 16:06:01 +08:00
lacerate551
43261e9aff refactor(parser): 重构 MinerU 文档解析器
- 重写 MinerU 解析模块,改进文档解析流程和结构化输出
- 优化表格识别、标题层级提取和多格式文档处理
- 支持 v2 格式偏好(MINERU_PREFER_V2)

🤖 Generated with [Qoder][https://qoder.com]
2026-06-08 16:05:54 +08:00
lacerate551
a340eaaeee docs: 重写 RAG 系统指南并重命名(移除 AgenticRAG 内容)
- 全面重写文档,反映统一编排路径和四层缓存架构
- 添加 Query Cache 修复说明和语义缓存集成文档
- 重命名 Agentic_RAG完整指南.md → RAG系统完整指南.md
- 同步更新开发与系统模块说明.md 中的引用链接
2026-06-08 16:05:43 +08:00
lacerate551
8af8d38c01 perf(cache): 修复缓存 key 不匹配 Bug + embedding 缓存 + 清理 AgenticRAG 死代码
- cache.py: get/set 统一使用粗粒度 key(基于 kb_version),修复缓存永远不命中的问题
- engine.py: 新增 _encode_cached() 方法,embedding 编码走 LRU 缓存
- 删除 10 个 core/agentic_*.py 死文件(~1950 行)
- 清理 api/__init__.py 和 chat_routes.py 中的 AgenticRAG 残留引用
2026-06-08 16:05:28 +08:00
lacerate551
6deba8fae2 docs: 添加代码审查报告 2026-06-05 2026-06-05 15:26:38 +08:00
lacerate551
90b915232a fix(security): 代码审查安全加固 — 三批次修复(6H/13M/12L)
第一批(快速修复):
- H6: main.py --debug 默认值 True→False,防止 Werkzeug RCE
- M2+M3: /search 增加 validate_query + top_k 范围限制(1-50)
- M4: context_count 范围限制(0-10) + 异常捕获
- L3: assert → raise RuntimeError(生产环境 API Key 检查)
- H1: SSE 错误事件移除 traceback 字段

第二批(安全加固):
- H2+H3: 文档接口路径遍历 realpath 校验 + 文件类型/大小限制
- H4+H5: 批量上传文件大小检查
- M6: LIKE 查询通配符转义
- M1: 37 处 str(e) 异常信息统一脱敏(6 文件)
- M5: CORS 生产环境限制来源
- M7: SESSION_MANAGER None 保护(503)
- M11: subprocess 参数注入防护(白名单 + -- 分隔符)

第三批(架构改进):
- M8+M9: 提取 JSON 解析共享工具(extract_json_object/list)
- M10: Prompt 注入检测防御(prompt_guard.py)
- M12: 解析器文件大小限制(Excel 50MB/TXT 20MB/PDF 100MB)
- M13: 全局单例竞态条件双重检查锁定(engine/bm25/intent_analyzer)
2026-06-05 15:26:32 +08:00
43 changed files with 4208 additions and 4375 deletions

5
.gitignore vendored
View File

@@ -120,8 +120,9 @@ test_*.json
rag_response.json rag_response.json
nul nul
# 临时调试脚本(下划线开头 # 调试脚本和临时计划(仅本地使用
scripts/_*.py scripts/
plans/
# Qoder 工具目录 # Qoder 工具目录
.qoder/ .qoder/

View File

@@ -3,7 +3,7 @@ API 路由层 — Flask 应用工厂
本模块实现 Flask 应用工厂模式,负责: 本模块实现 Flask 应用工厂模式,负责:
- 创建和配置 Flask 应用实例 - 创建和配置 Flask 应用实例
- 初始化核心服务(AgenticRAG、同步服务) - 初始化核心服务(同步服务)
- 注册所有 API Blueprint - 注册所有 API Blueprint
- 配置前端静态文件路由 - 配置前端静态文件路由
@@ -47,7 +47,7 @@ def create_app() -> 'Flask':
1. 创建 Flask 应用,配置 CORS 1. 创建 Flask 应用,配置 CORS
2. 初始化 Repository会话存储 2. 初始化 Repository会话存储
3. 初始化核心服务(AgenticRAG、同步服务) 3. 初始化核心服务(同步服务)
4. 注册所有 API Blueprint 4. 注册所有 API Blueprint
5. 配置前端静态文件路由 5. 配置前端静态文件路由
6. 执行生产环境配置校验 6. 执行生产环境配置校验
@@ -67,6 +67,16 @@ def create_app() -> 'Flask':
static_folder = os.path.join(PROJECT_ROOT, 'chat-ui') static_folder = os.path.join(PROJECT_ROOT, 'chat-ui')
app = Flask(__name__, static_folder=static_folder, static_url_path='') app = Flask(__name__, static_folder=static_folder, static_url_path='')
# CORS 配置:生产环境限制来源,开发环境允许全部
if IS_PROD:
cors_origins = os.environ.get('CORS_ORIGINS', '').split(',') if os.environ.get('CORS_ORIGINS') else []
if cors_origins:
CORS(app, origins=cors_origins)
else:
CORS(app) # 未配置时仍允许全部,但记录警告
logger.warning("生产环境未配置 CORS_ORIGINSCORS 允许所有来源")
else:
CORS(app) CORS(app)
# ==================== Repository 依赖注入 ==================== # ==================== Repository 依赖注入 ====================
@@ -83,19 +93,6 @@ def create_app() -> 'Flask':
# ==================== 核心服务初始化 ==================== # ==================== 核心服务初始化 ====================
# Agentic RAG 引擎
try:
from core.agentic import AgenticRAG
from config import ENABLE_WEB_SEARCH
agentic_rag = AgenticRAG(
enable_web_search=ENABLE_WEB_SEARCH,
)
app.config['AGENTIC_RAG'] = agentic_rag
logger.info(f"Agentic RAG 引擎已初始化(网络搜索={'启用' if ENABLE_WEB_SEARCH else '关闭'}")
except Exception as e:
app.config['AGENTIC_RAG'] = None
logger.warning(f"Agentic RAG 初始化失败: {e}")
# 同步服务 # 同步服务
try: try:
from knowledge.sync import KnowledgeSyncService from knowledge.sync import KnowledgeSyncService
@@ -227,14 +224,14 @@ def _validate_production_config() -> None:
- DASHSCOPE_API_KEY: 大模型调用必需 - DASHSCOPE_API_KEY: 大模型调用必需
Raises: Raises:
AssertionError: 缺少必需的配置项 RuntimeError: 缺少必需的配置项
""" """
import os import os
from config import DASHSCOPE_API_KEY from config import DASHSCOPE_API_KEY
# 检查环境变量或配置文件中的 API Key # 检查环境变量或配置文件中的 API Key
has_key = os.getenv("DASHSCOPE_API_KEY") or os.environ.get("DASHSCOPE_API_KEY") or DASHSCOPE_API_KEY has_key = os.getenv("DASHSCOPE_API_KEY") or os.environ.get("DASHSCOPE_API_KEY") or DASHSCOPE_API_KEY
assert has_key and has_key != "", \ if not has_key or has_key == "":
"Missing DASHSCOPE_API_KEY in production environment" raise RuntimeError("Missing DASHSCOPE_API_KEY in production environment")
logger.info("Configuration validated") logger.info("Configuration validated")

View File

@@ -97,7 +97,8 @@ def get_audit_logs():
return jsonify({"logs": logs, "total": total}) return jsonify({"logs": logs, "total": total})
except Exception as e: except Exception as e:
return jsonify({"error": str(e), "logs": [], "total": 0}), 500 logger.error(f"审计查询异常: {e}")
return jsonify({"error": "查询失败", "logs": [], "total": 0}), 500
def log_audit_event(user_id: str, username: str, action: str, def log_audit_event(user_id: str, username: str, action: str,

View File

@@ -11,6 +11,7 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from auth.gateway import require_gateway_auth, require_role, get_user_permissions, MOCK_USERS from auth.gateway import require_gateway_auth, require_role, get_user_permissions, MOCK_USERS
import os import os
import time
from pathlib import Path from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
@@ -21,6 +22,37 @@ load_dotenv(env_path)
auth_bp = Blueprint('auth', __name__) auth_bp = Blueprint('auth', __name__)
# ==================== 登录速率限制 ====================
# 简单的内存级速率限制:每个 IP 在时间窗口内最多允许 N 次登录尝试
_login_attempts = {} # {ip: [(timestamp, ...), ...]}
_RATE_LIMIT_WINDOW = 300 # 5 分钟窗口
_RATE_LIMIT_MAX = 10 # 窗口内最多 10 次尝试
def _check_rate_limit(client_ip: str) -> bool:
"""检查是否超出登录速率限制,返回 True 表示允许"""
now = time.time()
if client_ip not in _login_attempts:
_login_attempts[client_ip] = []
# 清理过期记录
_login_attempts[client_ip] = [
t for t in _login_attempts[client_ip]
if now - t < _RATE_LIMIT_WINDOW
]
if len(_login_attempts[client_ip]) >= _RATE_LIMIT_MAX:
return False
_login_attempts[client_ip].append(now)
return True
def _is_dev_mode() -> bool:
"""统一的开发模式判断"""
return os.environ.get('DEV_MODE', 'true').lower() != 'false'
@auth_bp.route('/auth/login', methods=['POST']) @auth_bp.route('/auth/login', methods=['POST'])
def mock_login(): def mock_login():
""" """
@@ -50,10 +82,14 @@ def mock_login():
- manager / manager123 (经理,财务部) - manager / manager123 (经理,财务部)
- user / test123 (普通用户,技术部) - user / test123 (普通用户,技术部)
""" """
# 默认开启开发模式(生产环境需设置 DEV_MODE=false if not _is_dev_mode():
if os.environ.get('DEV_MODE', 'true').lower() == 'false':
return jsonify({"error": "仅开发环境可用,请设置 DEV_MODE=true"}), 403 return jsonify({"error": "仅开发环境可用,请设置 DEV_MODE=true"}), 403
# 速率限制检查
client_ip = request.remote_addr or 'unknown'
if not _check_rate_limit(client_ip):
return jsonify({"error": f"登录尝试过于频繁,请 {_RATE_LIMIT_WINDOW // 60} 分钟后再试"}), 429
data = request.json or {} data = request.json or {}
username = data.get('username') username = data.get('username')
password = data.get('password') password = data.get('password')
@@ -79,7 +115,9 @@ def mock_login():
def get_stats(): def get_stats():
"""获取系统统计信息(仅管理员)""" """获取系统统计信息(仅管理员)"""
from flask import current_app from flask import current_app
session_manager = current_app.config['SESSION_MANAGER'] session_manager = current_app.config.get('SESSION_MANAGER')
if not session_manager:
return jsonify({"error": "会话管理器未启用"}), 503
return jsonify(session_manager.get_stats()) return jsonify(session_manager.get_stats())
@@ -131,8 +169,7 @@ def get_users():
] ]
} }
""" """
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false' if not _is_dev_mode():
if not dev_mode:
return jsonify({"error": "仅开发环境可用"}), 403 return jsonify({"error": "仅开发环境可用"}), 403
users = [] users = []
@@ -159,12 +196,26 @@ def update_user(user_id):
"is_active": false "is_active": false
} }
""" """
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false' if not _is_dev_mode():
if not dev_mode:
return jsonify({"error": "仅开发环境可用"}), 403 return jsonify({"error": "仅开发环境可用"}), 403
# 模拟用户不支持真正的状态切换,直接返回成功 # 验证目标用户是否存在
return jsonify({"message": "操作成功(模拟)", "user_id": user_id}) target_user = None
for username, info in MOCK_USERS.items():
if info['user_id'] == user_id:
target_user = info
break
if not target_user:
return jsonify({"error": f"用户 {user_id} 不存在"}), 404
data = request.json or {}
# 模拟操作记录请求但不实际执行mock 用户数据是静态的)
return jsonify({
"message": "操作成功(模拟)",
"user_id": user_id,
"applied_changes": data
})
@auth_bp.route('/auth/change-password', methods=['POST']) @auth_bp.route('/auth/change-password', methods=['POST'])
@@ -179,8 +230,7 @@ def change_password():
"new_password": "xxx" "new_password": "xxx"
} }
""" """
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false' if not _is_dev_mode():
if not dev_mode:
return jsonify({"error": "仅开发环境可用"}), 403 return jsonify({"error": "仅开发环境可用"}), 403
data = request.json or {} data = request.json or {}
@@ -193,5 +243,12 @@ def change_password():
if len(new_password) < 6: if len(new_password) < 6:
return jsonify({"error": "新密码至少6位"}), 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 jsonify({"error": "旧密码错误"}), 401
# 模拟环境返回成功不实际修改密码mock 数据是静态的)
return jsonify({"message": "密码修改成功(模拟)"}) return jsonify({"message": "密码修改成功(模拟)"})

File diff suppressed because it is too large Load Diff

View File

@@ -45,6 +45,7 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from auth.gateway import require_gateway_auth from auth.gateway import require_gateway_auth
from config import DEV_MODE
from core.status_codes import ( from core.status_codes import (
UPLOAD_SUCCESS, BATCH_UPLOAD_SUCCESS, BAD_REQUEST, UPLOAD_SUCCESS, BATCH_UPLOAD_SUCCESS, BAD_REQUEST,
NO_FILE, NO_FILE_SELECTED, NO_COLLECTION, NO_FILE, NO_FILE_SELECTED, NO_COLLECTION,
@@ -96,6 +97,24 @@ def safe_filename(filename: str) -> str:
return safe_name return safe_name
def _validate_doc_path(doc_path: str, documents_path: str) -> str:
"""
校验文档路径是否在允许目录内,防止路径遍历攻击。
Returns:
解析后的安全绝对路径
Raises:
ValueError: 路径不合法
"""
filepath = os.path.join(documents_path, doc_path)
real_path = os.path.realpath(filepath)
real_base = os.path.realpath(documents_path)
if not real_path.startswith(real_base + os.sep) and real_path != real_base:
raise ValueError("非法路径")
return real_path
# 延迟初始化缓存 # 延迟初始化缓存
_kb_manager = None _kb_manager = None
_kb_checked = False _kb_checked = False
@@ -151,15 +170,19 @@ def serve_document_file(doc_path: str) -> Tuple[Any, int]:
文件内容或错误响应 文件内容或错误响应
Note: Note:
仅在 DEV_MODE=true 时可用 仅在 DEV_MODE=true 时可用(需在 .env 中显式设置)
""" """
if os.environ.get('DEV_MODE', 'true').lower() == 'false': if not DEV_MODE:
return jsonify({"error": "仅开发环境可用"}), 403 return jsonify({"error": "仅开发环境可用"}), 403
from config import DOCUMENTS_PATH from config import DOCUMENTS_PATH
from flask import send_from_directory from flask import send_from_directory
filepath = os.path.join(DOCUMENTS_PATH, doc_path) filepath = os.path.join(DOCUMENTS_PATH, doc_path)
try:
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
except ValueError:
return jsonify({"error": "非法路径"}), 403
if not os.path.exists(filepath): if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404 return jsonify({"error": "文件不存在"}), 404
@@ -329,7 +352,8 @@ def upload_document() -> Tuple[Any, int]:
sync_service.process_change(change) sync_service.process_change(change)
sync_status = "已保存并添加到向量库" sync_status = "已保存并添加到向量库"
except Exception as e: except Exception as e:
sync_status = f"已保存,向量化失败: {str(e)}" logger.warning(f"向量化失败: {e}")
sync_status = "已保存,向量化失败"
return success_response( return success_response(
data={ data={
@@ -404,6 +428,18 @@ def batch_upload_documents() -> Tuple[Any, int]:
}) })
continue continue
# 文件大小校验
file.seek(0, 2)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
results.append({
"filename": file.filename,
"status": "error",
"message": f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB"
})
continue
try: try:
original_filename = file.filename original_filename = file.filename
ext = os.path.splitext(original_filename)[1].lower() ext = os.path.splitext(original_filename)[1].lower()
@@ -464,10 +500,11 @@ def batch_upload_documents() -> Tuple[Any, int]:
"replaced": replaced "replaced": replaced
}) })
except Exception as e: except Exception as e:
logger.error(f"上传处理异常: {e}")
results.append({ results.append({
"filename": file.filename, "filename": file.filename,
"status": "error", "status": "error",
"message": str(e) "message": "上传处理失败"
}) })
return success_response( return success_response(
@@ -644,12 +681,27 @@ def update_document(doc_path: str) -> Tuple[Any, int]:
if file.filename == '': if file.filename == '':
return jsonify({"error": "没有选择文件"}), 400 return jsonify({"error": "没有选择文件"}), 400
# 文件类型校验
ext = os.path.splitext(file.filename)[1].lower()
if ext not in ALLOWED_EXTENSIONS:
return jsonify({"error": f"不支持的文件类型: {ext}"}), 400
# 文件大小校验
file.seek(0, 2) # 跳到文件末尾获取大小
file_size = file.tell()
file.seek(0) # 回到文件开头
if file_size > MAX_FILE_SIZE:
return jsonify({"error": f"文件过大(最大 {MAX_FILE_SIZE // 1024 // 1024}MB"}), 400
# 解析路径 # 解析路径
parts = doc_path.split('/') parts = doc_path.split('/')
if len(parts) < 2: if len(parts) < 2:
return jsonify({"error": "无效的文档路径"}), 400 return jsonify({"error": "无效的文档路径"}), 400
filepath = os.path.join(DOCUMENTS_PATH, doc_path) try:
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
except ValueError:
return jsonify({"error": "非法路径"}), 403
if not os.path.exists(filepath): if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404 return jsonify({"error": "文件不存在"}), 404
@@ -711,7 +763,10 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
# 目录名即向量库名 # 目录名即向量库名
collection = subdir collection = subdir
filepath = os.path.join(DOCUMENTS_PATH, doc_path) try:
filepath = _validate_doc_path(doc_path, DOCUMENTS_PATH)
except ValueError:
return jsonify({"error": "非法路径"}), 403
if not os.path.exists(filepath): if not os.path.exists(filepath):
return jsonify({"error": "文件不存在"}), 404 return jsonify({"error": "文件不存在"}), 404
@@ -730,7 +785,8 @@ def delete_document(doc_path: str) -> Tuple[Any, int]:
}) })
except Exception as e: except Exception as e:
return jsonify({"error": f"删除失败: {str(e)}"}), 500 logger.error(f"删除文档异常: {e}")
return jsonify({"error": "删除失败,请稍后重试"}), 500
@document_bp.route('/documents/<path:doc_path>/chunks', methods=['GET']) @document_bp.route('/documents/<path:doc_path>/chunks', methods=['GET'])
@@ -817,7 +873,10 @@ def preview_document(doc_path: str) -> Tuple[Any, int]:
# 查询参数 # 查询参数
chunk_index_str = request.args.get('chunk_index') chunk_index_str = request.args.get('chunk_index')
context_count = int(request.args.get('context', 2)) try:
context_count = max(0, min(int(request.args.get('context', 2)), 10))
except (ValueError, TypeError):
context_count = 2
# 获取所有切片 # 获取所有切片
all_chunks = kb_manager.get_document_chunks(collection, filename) all_chunks = kb_manager.get_document_chunks(collection, filename)
@@ -1082,5 +1141,5 @@ def delete_chunks_by_source() -> Tuple[Any, int]:
"message": f"已删除 {deleted_count} 个切片" "message": f"已删除 {deleted_count} 个切片"
}) })
except Exception as e: except Exception as e:
logger.error(f"批量删除切片失败: {e}") logger.error(f"操作异常: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": "操作失败,请稍后重试"}), 500

View File

@@ -33,6 +33,9 @@
from flask import Blueprint, request, jsonify from flask import Blueprint, request, jsonify
from auth.gateway import require_gateway_auth, require_role from auth.gateway import require_gateway_auth, require_role
import logging
logger = logging.getLogger(__name__)
feedback_bp = Blueprint('feedback', __name__) feedback_bp = Blueprint('feedback', __name__)
@@ -95,7 +98,8 @@ def submit_feedback():
"suggestion_id": result.get('suggestion_id') "suggestion_id": result.get('suggestion_id')
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/feedback/stats', methods=['GET']) @feedback_bp.route('/feedback/stats', methods=['GET'])
@@ -113,7 +117,8 @@ def get_feedback_stats():
"stats": stats "stats": stats
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/feedback/list', methods=['GET']) @feedback_bp.route('/feedback/list', methods=['GET'])
@@ -141,7 +146,8 @@ def get_feedback_list():
"total": len(feedbacks) "total": len(feedbacks)
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/reports/weekly', methods=['GET']) @feedback_bp.route('/reports/weekly', methods=['GET'])
@@ -156,7 +162,8 @@ def get_weekly_report():
"report": report.to_dict() "report": report.to_dict()
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/reports/monthly', methods=['GET']) @feedback_bp.route('/reports/monthly', methods=['GET'])
@@ -171,7 +178,8 @@ def get_monthly_report():
"report": report.to_dict() "report": report.to_dict()
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq', methods=['GET']) @feedback_bp.route('/faq', methods=['GET'])
@@ -190,7 +198,8 @@ def get_faq_list():
"total": len(faqs) "total": len(faqs)
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq', methods=['POST']) @feedback_bp.route('/faq', methods=['POST'])
@@ -233,7 +242,8 @@ def create_faq():
"message": "FAQ已创建请通过 /faq/<id>/approve 接口确认后生效" "message": "FAQ已创建请通过 /faq/<id>/approve 接口确认后生效"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/<int:faq_id>/approve', methods=['POST']) @feedback_bp.route('/faq/<int:faq_id>/approve', methods=['POST'])
@@ -276,7 +286,8 @@ def approve_faq(faq_id):
"message": "FAQ已批准并同步到知识库" "message": "FAQ已批准并同步到知识库"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/<int:faq_id>', methods=['PUT']) @feedback_bp.route('/faq/<int:faq_id>', methods=['PUT'])
@@ -326,7 +337,8 @@ def update_faq(faq_id):
"sync_status": sync_status "sync_status": sync_status
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/<int:faq_id>', methods=['DELETE']) @feedback_bp.route('/faq/<int:faq_id>', methods=['DELETE'])
@@ -358,7 +370,8 @@ def delete_faq(faq_id):
"message": "FAQ删除成功" if deleted else "FAQ不存在" "message": "FAQ删除成功" if deleted else "FAQ不存在"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/suggestions', methods=['GET']) @feedback_bp.route('/faq/suggestions', methods=['GET'])
@@ -378,7 +391,8 @@ def get_faq_suggestions():
"total": len(suggestions) "total": len(suggestions)
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/approve', methods=['POST']) @feedback_bp.route('/faq/suggestions/<int:suggestion_id>/approve', methods=['POST'])
@@ -413,7 +427,8 @@ def approve_faq_suggestion(suggestion_id):
else: else:
return jsonify({"error": result.get('error', '批准失败')}), 400 return jsonify({"error": result.get('error', '批准失败')}), 400
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/faq/suggestions/<int:suggestion_id>/reject', methods=['POST']) @feedback_bp.route('/faq/suggestions/<int:suggestion_id>/reject', methods=['POST'])
@@ -429,7 +444,8 @@ def reject_faq_suggestion(suggestion_id):
"message": "FAQ建议已拒绝" if rejected else "建议不存在" "message": "FAQ建议已拒绝" if rejected else "建议不存在"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
# ==================== Bad Case 分析接口 ==================== # ==================== Bad Case 分析接口 ====================
@@ -472,7 +488,8 @@ def get_bad_cases():
] ]
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
@feedback_bp.route('/feedback/blacklist', methods=['GET']) @feedback_bp.route('/feedback/blacklist', methods=['GET'])
@@ -497,4 +514,5 @@ def get_chunk_blacklist():
"usage": "在检索时过滤这些来源以提升回答质量" "usage": "在检索时过滤这些来源以提升回答质量"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"反馈操作异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500

View File

@@ -9,8 +9,11 @@
""" """
import os import os
import logging
from flask import Blueprint, send_file, jsonify, current_app from flask import Blueprint, send_file, jsonify, current_app
logger = logging.getLogger(__name__)
image_bp = Blueprint('images', __name__) image_bp = Blueprint('images', __name__)
@@ -70,7 +73,8 @@ def get_image(image_id: str):
return send_file(image_path, mimetype=mimetype) return send_file(image_path, mimetype=mimetype)
except Exception as e: except Exception as e:
return jsonify({"error": f"读取图片失败: {str(e)}"}), 500 logger.error(f"读取图片异常: {e}")
return jsonify({"error": "读取图片失败"}), 500
return jsonify({"error": "图片不存在", "image_id": image_id}), 404 return jsonify({"error": "图片不存在", "image_id": image_id}), 404
@@ -122,7 +126,8 @@ def get_image_info(image_id: str):
"url": f"/images/{image_id}" "url": f"/images/{image_id}"
}) })
except Exception as e: except Exception as e:
return jsonify({"error": f"读取图片信息失败: {str(e)}"}), 500 logger.error(f"读取图片信息异常: {e}")
return jsonify({"error": "读取图片信息失败"}), 500
return jsonify({"error": "图片不存在", "image_id": image_id}), 404 return jsonify({"error": "图片不存在", "image_id": image_id}), 404
@@ -179,7 +184,8 @@ def list_images():
}) })
except Exception as e: except Exception as e:
return jsonify({"error": f"列出图片失败: {str(e)}"}), 500 logger.error(f"列出图片异常: {e}")
return jsonify({"error": "列出图片失败"}), 500
@image_bp.route('/images/stats', methods=['GET']) @image_bp.route('/images/stats', methods=['GET'])
@@ -222,4 +228,5 @@ def image_stats():
}) })
except Exception as e: except Exception as e:
return jsonify({"error": f"获取统计信息失败: {str(e)}"}), 500 logger.error(f"获取统计信息异常: {e}")
return jsonify({"error": "获取统计信息失败"}), 500

View File

@@ -391,10 +391,11 @@ def sync_documents() -> Tuple[Any, int]:
} }
}) })
except Exception as e: except Exception as e:
logger.error(f"知识库操作异常: {e}")
results.append({ results.append({
"collection": "all", "collection": "all",
"status": "error", "status": "error",
"message": str(e) "message": "操作失败"
}) })
else: else:
# 没有 sync_service返回提示 # 没有 sync_service返回提示
@@ -462,7 +463,8 @@ def debug_scan() -> Tuple[Any, int]:
result["scanned_count"] = len(scanned) result["scanned_count"] = len(scanned)
result["scanned_ids"] = list(scanned.keys())[:10] result["scanned_ids"] = list(scanned.keys())[:10]
except Exception as e: except Exception as e:
result["scan_error"] = str(e) logger.error(f"扫描异常: {e}")
result["scan_error"] = "扫描失败"
return jsonify(result) return jsonify(result)
@@ -499,9 +501,11 @@ def reindex_collection(kb_name: str) -> Tuple[Any, int]:
from data.db import get_connection from data.db import get_connection
with get_connection("knowledge") as conn: with get_connection("knowledge") as conn:
cursor = conn.cursor() cursor = conn.cursor()
# 转义 LIKE 通配符,防止 kb_name 中的 % 或 _ 导致非预期匹配
escaped_kb = kb_name.replace('%', '\\%').replace('_', '\\_')
# 删除以 "{kb_name}/" 或 "{kb_name}\" 开头的文档哈希(兼容 Windows 和 Linux # 删除以 "{kb_name}/" 或 "{kb_name}\" 开头的文档哈希(兼容 Windows 和 Linux
cursor.execute("DELETE FROM document_hashes WHERE document_id LIKE ? OR document_id LIKE ?", cursor.execute("DELETE FROM document_hashes WHERE document_id LIKE ? ESCAPE '\\' OR document_id LIKE ? ESCAPE '\\'",
(f"{kb_name}/%", f"{kb_name}\\%")) (f"{escaped_kb}/%", f"{escaped_kb}\\%"))
deleted = cursor.rowcount deleted = cursor.rowcount
logger.info(f"已清除 {deleted} 条哈希记录: {kb_name}") logger.info(f"已清除 {deleted} 条哈希记录: {kb_name}")
except Exception as e: except Exception as e:
@@ -520,7 +524,8 @@ def reindex_collection(kb_name: str) -> Tuple[Any, int]:
"errors": result.errors "errors": result.errors
}) })
except Exception as e: except Exception as e:
return jsonify({"error": str(e)}), 500 logger.error(f"reindex 异常: {e}")
return jsonify({"error": "操作失败,请稍后重试"}), 500
else: else:
return jsonify({"error": "同步服务不可用"}), 503 return jsonify({"error": "同步服务不可用"}), 503
@@ -633,7 +638,8 @@ def deprecate_document(kb_name: str, filename: str) -> Tuple[Any, int]:
) )
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 logger.error(f"操作异常: {e}")
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/restore', methods=['POST']) @kb_bp.route('/collections/<kb_name>/documents/<path:filename>/restore', methods=['POST'])
@@ -664,7 +670,8 @@ def restore_document(kb_name: str, filename: str) -> Tuple[Any, int]:
result = kb_manager.restore_document(kb_name, filename) result = kb_manager.restore_document(kb_name, filename)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 logger.error(f"操作异常: {e}")
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
@kb_bp.route('/collections/<kb_name>/documents/<path:filename>/versions', methods=['GET']) @kb_bp.route('/collections/<kb_name>/documents/<path:filename>/versions', methods=['GET'])
@@ -720,7 +727,8 @@ def get_document_versions(kb_name: str, filename: str) -> Tuple[Any, int]:
"total": len(versions_data) "total": len(versions_data)
}) })
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 logger.error(f"操作异常: {e}")
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
@kb_bp.route('/collections/<kb_name>/update-image-descriptions', methods=['POST']) @kb_bp.route('/collections/<kb_name>/update-image-descriptions', methods=['POST'])
@@ -754,7 +762,8 @@ def update_image_descriptions(kb_name: str) -> Tuple[Any, int]:
result = kb_manager.update_image_descriptions(kb_name) result = kb_manager.update_image_descriptions(kb_name)
return jsonify(result) return jsonify(result)
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 logger.error(f"操作异常: {e}")
return jsonify({"success": False, "error": "操作失败,请稍后重试"}), 500
@kb_bp.route('/collections/sync-vlm-cache', methods=['POST']) @kb_bp.route('/collections/sync-vlm-cache', methods=['POST'])

View File

@@ -33,6 +33,8 @@ def get_sessions():
} }
""" """
session_manager = current_app.config['SESSION_MANAGER'] session_manager = current_app.config['SESSION_MANAGER']
if session_manager is None:
return jsonify({"error": "会话服务不可用"}), 503
user_id = request.current_user["user_id"] user_id = request.current_user["user_id"]
sessions = session_manager.get_user_sessions(user_id, limit=20) sessions = session_manager.get_user_sessions(user_id, limit=20)
@@ -62,10 +64,12 @@ def get_history(session_id):
} }
""" """
session_manager = current_app.config['SESSION_MANAGER'] session_manager = current_app.config['SESSION_MANAGER']
if session_manager is None:
return jsonify({"error": "会话服务不可用"}), 503
user_id = request.current_user["user_id"] user_id = request.current_user["user_id"]
# 验证会话归属 # 验证会话归属
sessions = session_manager.get_user_sessions(user_id) sessions = session_manager.get_user_sessions(user_id, limit=20)
session_ids = [s["session_id"] for s in sessions] session_ids = [s["session_id"] for s in sessions]
if session_id not in session_ids: if session_id not in session_ids:
@@ -81,10 +85,12 @@ def get_history(session_id):
def delete_session(session_id): def delete_session(session_id):
"""删除会话""" """删除会话"""
session_manager = current_app.config['SESSION_MANAGER'] session_manager = current_app.config['SESSION_MANAGER']
if session_manager is None:
return jsonify({"error": "会话服务不可用"}), 503
user_id = request.current_user["user_id"] user_id = request.current_user["user_id"]
# 验证会话归属 # 验证会话归属
sessions = session_manager.get_user_sessions(user_id) sessions = session_manager.get_user_sessions(user_id, limit=20)
session_ids = [s["session_id"] for s in sessions] session_ids = [s["session_id"] for s in sessions]
if session_id not in session_ids: if session_id not in session_ids:
@@ -100,10 +106,12 @@ def delete_session(session_id):
def clear_history(session_id): def clear_history(session_id):
"""清空会话历史(保留会话)""" """清空会话历史(保留会话)"""
session_manager = current_app.config['SESSION_MANAGER'] session_manager = current_app.config['SESSION_MANAGER']
if session_manager is None:
return jsonify({"error": "会话服务不可用"}), 503
user_id = request.current_user["user_id"] user_id = request.current_user["user_id"]
# 验证会话归属 # 验证会话归属
sessions = session_manager.get_user_sessions(user_id) sessions = session_manager.get_user_sessions(user_id, limit=20)
session_ids = [s["session_id"] for s in sessions] session_ids = [s["session_id"] for s in sessions]
if session_id not in session_ids: if session_id not in session_ids:

View File

@@ -112,10 +112,11 @@ def trigger_sync() -> Tuple[Any, int]:
message="同步完成" message="同步完成"
) )
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )
@@ -164,11 +165,12 @@ def get_sync_status() -> Tuple[Any, int]:
return jsonify(status) return jsonify(status)
except Exception as e: except Exception as e:
logger.error(f"状态查询异常: {e}")
return jsonify({ return jsonify({
"status": "failed", "status": "failed",
"status_code": INTERNAL_ERROR, "status_code": INTERNAL_ERROR,
"enabled": True, "enabled": True,
"error": str(e) "error": "操作失败"
}) })
@@ -196,10 +198,11 @@ def get_sync_history() -> Tuple[Any, int]:
history = service.get_sync_history(limit=limit) if hasattr(service, 'get_sync_history') else [] history = service.get_sync_history(limit=limit) if hasattr(service, 'get_sync_history') else []
return jsonify({"history": history}) return jsonify({"history": history})
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )
@@ -230,10 +233,11 @@ def get_change_logs() -> Tuple[Any, int]:
changes = service.get_change_logs(limit=limit, collection=collection) if hasattr(service, 'get_change_logs') else [] changes = service.get_change_logs(limit=limit, collection=collection) if hasattr(service, 'get_change_logs') else []
return jsonify({"changes": changes}) return jsonify({"changes": changes})
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )
@@ -275,10 +279,11 @@ def start_sync_monitor() -> Tuple[Any, int]:
else: else:
return jsonify({"status": "success", "message": "文件监控功能不可用"}) return jsonify({"status": "success", "message": "文件监控功能不可用"})
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )
@@ -303,9 +308,10 @@ def stop_sync_monitor() -> Tuple[Any, int]:
service.stop() service.stop()
return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已停止"}) return jsonify({"status": "success", "status_code": SYNC_SUCCESS, "message": "文件监控已停止"})
except Exception as e: except Exception as e:
logger.error(f"同步操作异常: {e}")
return error_response( return error_response(
error="SYNC_ERROR", error="SYNC_ERROR",
error_code=SYNC_ERROR, error_code=SYNC_ERROR,
message=str(e), message="操作失败",
http_status=500 http_status=500
) )

View File

@@ -20,12 +20,12 @@
## 模式说明 ## 模式说明
开发模式 (DEV_MODE=true,默认): 开发模式 (DEV_MODE=true):
- 支持 mock token 模拟用户Authorization: Bearer mock-token-admin - 支持 mock token 模拟用户Authorization: Bearer mock-token-admin
- 无 Header 时自动使用开发测试用户 - 无 Header 时自动使用开发测试用户
- 适用于前端测试和开发调试 - 适用于前端测试和开发调试
生产模式 (DEV_MODE=false): 生产模式 (DEV_MODE=false,默认):
- 不需要 Header直接放行 - 不需要 Header直接放行
- 权限由后端完全控制,通过 collections 参数传入 - 权限由后端完全控制,通过 collections 参数传入
- RAG 服务完全无状态,只负责问答检索 - RAG 服务完全无状态,只负责问答检索
@@ -34,17 +34,11 @@
from functools import wraps from functools import wraps
from flask import request, jsonify from flask import request, jsonify
from typing import Dict, Optional from typing import Dict, Optional
import os from config import DEV_MODE
from pathlib import Path
from dotenv import load_dotenv
# 加载 .env 文件(从项目根目录)
env_path = Path(__file__).parent.parent / '.env'
load_dotenv(env_path)
# ==================== 模拟用户数据(开发环境)==================== # ==================== 模拟用户数据(开发环境)====================
# 用于前端模拟登录测试,仅 DEV_MODE=true 时生效 # 用于前端模拟登录测试,仅 DEV_MODE=true 时生效(需在 .env 中显式开启)
MOCK_USERS = { MOCK_USERS = {
'admin': { 'admin': {
'user_id': 'admin001', 'user_id': 'admin001',
@@ -83,19 +77,19 @@ def require_gateway_auth(f):
""" """
网关认证装饰器 - 从 Header 读取用户信息 网关认证装饰器 - 从 Header 读取用户信息
开发模式 (DEV_MODE=true,默认): 开发模式 (DEV_MODE=true):
- 支持 mock token: Authorization: Bearer mock-token-admin - 支持 mock token: Authorization: Bearer mock-token-admin
- 无 Header 时自动使用开发测试用户admin 角色) - 无 Header 时自动使用开发测试用户admin 角色)
生产模式 (DEV_MODE=false): 生产模式 (DEV_MODE=false,默认):
- 不需要 Header直接放行 - 不需要 Header直接放行
- 用户信息设为默认值 - 用户信息设为默认值
- 权限由后端通过 collections 参数控制 - 权限由后端通过 collections 参数控制
""" """
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
# 开发模式开关(默认开启,生产环境设置 DEV_MODE=false # 开发模式开关(统一由 config.py 管理
dev_mode = os.environ.get('DEV_MODE', 'true').lower() != 'false' dev_mode = DEV_MODE
# 开发模式:支持 mock token # 开发模式:支持 mock token
if dev_mode: if dev_mode:
@@ -202,11 +196,24 @@ def can_delete_collection(role: str) -> bool:
def require_role(*roles): def require_role(*roles):
""" """
兼容旧代码 - 权限由后端管理,此装饰器不再执行权限验证 角色验证装饰器(开发和生产环境均生效)
需搭配 @require_gateway_auth 使用(先设置 current_user再验证角色
开发模式: 检查 mock token 对应用户的角色
生产模式: 检查网关注入的 X-User-Role Header
""" """
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
if roles:
user = get_current_user()
if user is None or user.get('role') not in roles:
from flask import jsonify
return jsonify({
"error": "权限不足,需要角色: {}".format(', '.join(roles)),
"status": "FORBIDDEN"
}), 403
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
return decorator return decorator
@@ -214,11 +221,12 @@ def require_role(*roles):
def require_collection_permission(operation: str): def require_collection_permission(operation: str):
""" """
兼容旧代码 - 权限由后端管理,此装饰器不再执行权限验证 集合权限验证装饰器(开发模式下为占位实现,生产环境权限由网关控制)
""" """
def decorator(f): def decorator(f):
@wraps(f) @wraps(f)
def decorated(*args, **kwargs): def decorated(*args, **kwargs):
# 生产环境下权限由网关/后端统一管控,此处放行
return f(*args, **kwargs) return f(*args, **kwargs)
return decorated return decorated
return decorator return decorator

View File

@@ -181,7 +181,10 @@ SEMANTIC_CACHE_ENABLED = True
SEMANTIC_CACHE_THRESHOLD = 0.92 # 相似度阈值 SEMANTIC_CACHE_THRESHOLD = 0.92 # 相似度阈值
# 缓存写入最低置信度 # 缓存写入最低置信度
CACHE_MIN_SCORE = 0.3 # 注意ChromaDB cosine distance 范围 [0,2]score = 1 - dist
# 当前 embedding 模型的 cosine similarity 普遍在 0.03-0.06 之间
# 搜索管线已通过 rerank 过滤低质量结果,此处不再额外限制
CACHE_MIN_SCORE = 0.0
# LLM 调用预算 # LLM 调用预算
MAX_LLM_CALLS_PER_QUERY = 2 MAX_LLM_CALLS_PER_QUERY = 2
@@ -198,6 +201,18 @@ MINERU_DEVICE_MODE = os.getenv("MINERU_DEVICE_MODE", "cpu") # cpu / cuda
MINERU_API_TOKEN = os.getenv("MINERU_API_TOKEN", "") # 在 https://mineru.net/apiManage/token 申请 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_API_URL = os.getenv("MINERU_API_URL", "https://mineru.net/api/v4/extract/task")
MINERU_PREFER_ONLINE = os.getenv("MINERU_PREFER_ONLINE", "true").lower() == "true" MINERU_PREFER_ONLINE = os.getenv("MINERU_PREFER_ONLINE", "true").lower() == "true"
MINERU_PREFER_V2 = os.getenv("MINERU_PREFER_V2", "true").lower() == "true" # 优先使用 v2 格式(含 style 信息)
# 标题识别规则引擎
# 规则定义见 parsers/heading_rules.py支持通过 config.py 覆盖
HEADING_RULES_CONFIG = None # None=使用内置默认规则; dict 列表=自定义规则覆盖
HEADING_SHORT_TEXT_ENABLED = True # 是否启用短中文文本标题识别(最易误判的规则,可单独关闭)
HEADING_SHORT_TEXT_MAX_LENGTH = 20 # 短文本最大字符数阈值
# 表单类型二次校正
# MinerU 解析 Word 文档时,某些表单被标记为 text根据内容特征修正为 table
FORM_RECLASSIFY_ENABLED = True # 是否启用 text->table 表单检测
FORM_RECLASSIFY_MIN_INDICATORS = 2 # 最少命中几个表单特征指标才校正
# 分块参数 # 分块参数
CHUNK_SIZE = 1000 CHUNK_SIZE = 1000

View File

@@ -3,7 +3,6 @@ RAG 核心引擎模块
包含: 包含:
- engine: RAGEngine 单例类,管理模型和共享资源 - engine: RAGEngine 单例类,管理模型和共享资源
- agentic: AgenticRAG 智能问答
- bm25_index: BM25 关键词检索索引 - bm25_index: BM25 关键词检索索引
- chunker: 文本分块器 - chunker: 文本分块器
""" """

View File

@@ -1,390 +0,0 @@
"""
Agentic RAG - 知识库智能问答系统
核心能力:
1. 知识库检索 - 向量检索 + BM25 + Rerank
2. 网络搜索 - 当知识库不足时自动搜索需配置SERPER_API_KEY
3. 图谱检索 - 实体关系推理需配置Neo4j
4. 多源融合 - 智能处理知识库和网络内容
5. Agent决策 - 动态决定检索、改写、分解等操作
使用方式:
from core.agentic import AgenticRAG
rag = AgenticRAG()
result = rag.process("你的问题")
print(result["answer"])
"""
import json
import logging
from openai import OpenAI
# 配置日志
logger = logging.getLogger(__name__)
# 导入基础模块
from core.engine import get_engine
from core.llm_utils import call_llm, quick_yes_no, parse_json_from_response
# 导入基础常量和配置
from .agentic_base import (
API_KEY, BASE_URL, MODEL,
HAS_SERPER,
HAS_BUDGET, SEMANTIC_CACHE_ENABLED,
MAX_CONTEXT_TOKENS, MAX_CONTEXT_COUNT, RERANK_THRESHOLD,
SOURCE_KB, SOURCE_WEB,
)
# 尝试导入语义缓存
try:
from core.semantic_cache import SemanticCache
except ImportError:
SemanticCache = None
# 导入 Mixin 类
from .agentic_query import QueryRewriteMixin
from .agentic_search import SearchMixin
from .agentic_answer import AnswerMixin
from .agentic_citation import CitationMixin
from .agentic_media import RichMediaMixin
from .agentic_quality import QualityMixin
from .agentic_context import ContextMixin
from .agentic_meta import MetaQuestionMixin
class AgenticRAG(
QueryRewriteMixin,
SearchMixin,
AnswerMixin,
CitationMixin,
RichMediaMixin,
QualityMixin,
ContextMixin,
MetaQuestionMixin
):
"""
Agentic RAG - 知识库智能问答
通过 Mixin 模式组合功能:
- QueryRewriteMixin: 查询重写
- SearchMixin: 检索功能
- AnswerMixin: 答案生成
- CitationMixin: 引用处理
- RichMediaMixin: 富媒体处理
- QualityMixin: 质量评估
- ContextMixin: 上下文处理
- MetaQuestionMixin: 元问题处理
"""
def __init__(
self,
max_iterations: int = 3,
enable_web_search: bool = True,
**kwargs
):
"""初始化"""
self.max_iterations = max_iterations
self.enable_web_search = enable_web_search and HAS_SERPER
self.client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
# 信息来源标记
self.SOURCE_KB = SOURCE_KB
self.SOURCE_WEB = SOURCE_WEB
# 初始化置信度门控
try:
from core.confidence_gate import create_gate
self.confidence_gate = create_gate()
except ImportError:
self.confidence_gate = None
# 初始化多维质量评估器
try:
from core.quality_assessor import create_assessor
self.quality_assessor = create_assessor()
except ImportError:
self.quality_assessor = None
# 初始化推理反思器
try:
from core.reasoning_reflector import create_reflector
self.reasoning_reflector = create_reflector()
except ImportError:
self.reasoning_reflector = None
# 初始化循环防护器
try:
from core.loop_guard import create_guard
self.loop_guard = create_guard(max_iterations=max_iterations)
except ImportError:
self.loop_guard = None
# 初始化语义缓存
self.semantic_cache = None
self.embedding_model = None
if SEMANTIC_CACHE_ENABLED and SemanticCache:
try:
engine = get_engine()
if engine and hasattr(engine, 'embedding_model'):
self.embedding_model = engine.embedding_model
emb_dim = 768
# 优先使用新 API兼容旧版本
if hasattr(self.embedding_model, 'get_embedding_dimension'):
emb_dim = self.embedding_model.get_embedding_dimension()
elif hasattr(self.embedding_model, 'get_sentence_embedding_dimension'):
emb_dim = self.embedding_model.get_sentence_embedding_dimension()
self.semantic_cache = SemanticCache(
dim=emb_dim,
threshold=0.92,
max_size=5000
)
logger.info(f"语义缓存已启用,维度={emb_dim}")
except Exception as e:
logger.warning(f"语义缓存初始化失败: {e}")
self.semantic_cache = None
# Context Compression 配置
self.MAX_CONTEXT_TOKENS = MAX_CONTEXT_TOKENS
self.MAX_CONTEXT_COUNT = MAX_CONTEXT_COUNT
self.RERANK_THRESHOLD = RERANK_THRESHOLD
# Answer Grounding 配置
self.MAX_GROUNDING_RETRY = 1
self.grounding_retry_count = 0
def should_rewrite(self, query: str, history: list = None) -> bool:
"""判断是否需要重写查询"""
# 口语化表达模式
colloquial_patterns = [
"这个", "那个", "", "", "",
"上面", "下面", "刚才", "之前",
"能不能", "可以吗", "行不行",
"怎么办", "怎么弄", "咋整"
]
for pattern in colloquial_patterns:
if pattern in query:
return True
# 查询太短
if len(query) < 5:
return True
# 有对话历史时,可能需要实体补全
if history:
for msg in reversed(history[-3:]):
if msg.get("role") == "user":
prev_query = msg.get("content", "")
# 如果当前查询缺少主语,可能需要补全
if any(kw in query for kw in ["标准", "规定", "流程", "制度"]):
if not any(kw in query for kw in ["报销", "出差", "请假", "工资", "合同"]):
return True
return False
def process(self, query: str, verbose: bool = True, history: list = None,
allowed_levels: list = None, role: str = None, department: str = None,
emit_log=None) -> dict:
"""
主流程:智能问答
Args:
query: 用户问题
verbose: 是否打印详细过程
history: 对话历史
allowed_levels: 允许访问的安全级别
role: 用户角色
department: 用户部门
emit_log: 日志发射函数(流式输出)
Returns:
{
"answer": str,
"sources": list,
"images": list,
"tables": list,
"citations": list,
"log_trace": list
}
"""
log_trace = []
# 1. 检查元问题
if self._is_meta_question(query):
answer = self._answer_meta_question(query, allowed_levels, role, department)
return {
"answer": answer,
"sources": [],
"images": [],
"tables": [],
"citations": [],
"log_trace": [{"phase": "meta_question", "query": query}]
}
# 2. 查询重写
current_query = query
if self.should_rewrite(query, history):
current_query = self._rewrite_query(query, history)
log_trace.append({"phase": "rewrite", "original": query, "rewritten": current_query})
if emit_log:
emit_log(f"📝 查询重写: {query}{current_query}")
# 3. 知识库检索
contexts = []
try:
engine = get_engine()
if not engine._initialized:
engine.initialize()
# 获取用户可访问的向量库
from knowledge.manager import get_kb_manager
kb_mgr = get_kb_manager()
accessible = kb_mgr.get_accessible_collections(role or 'user', department or '', 'read')
# 统一使用 search_knowledge() — 生产路径的同一 API
# search_knowledge() 返回 dict: {ids, documents, metadatas, distances},每项为 list[list]
# top_k 与生产路径对齐30确保 Rerank 后仍有足够结果
results = engine.search_knowledge(
query=current_query,
top_k=30,
collections=accessible if accessible else None,
)
docs = results.get('documents', [[]])[0]
metas = results.get('metadatas', [[]])[0]
dists = results.get('distances', [[]])[0]
for doc, meta, score in zip(docs, metas, dists):
contexts.append({
'doc': doc,
'meta': meta,
'score': 1 - score if score <= 1 else 1 / (1 + score), # 距离→相似度
'source_type': self.SOURCE_KB,
'query': current_query
})
log_trace.append({"phase": "kb_search", "query": current_query, "results": len(contexts)})
if emit_log:
emit_log(f"🔍 知识库检索: {len(contexts)} 条结果")
except Exception as e:
logger.error(f"知识库检索失败: {e}")
log_trace.append({"phase": "kb_search", "error": str(e)})
# 3.5 图片独立检索 + 打分选择(与生产路径对齐)
selected_images = []
try:
from api.chat_routes import select_images
selected_images = select_images(contexts, current_query)
if selected_images and emit_log:
emit_log(f"🖼️ 图片选择: {len(selected_images)} 张相关图片")
log_trace.append({"phase": "image_selection", "count": len(selected_images)})
except Exception as e:
logger.debug(f"图片选择失败: {e}")
# 4. 上下文压缩
contexts = self._compress_contexts(current_query, contexts)
# 5. 网络搜索(如果需要)
web_contexts = []
if self.enable_web_search and (
not contexts or
not self._is_kb_result_sufficient(current_query, [c['doc'] for c in contexts]) or
self._should_web_search(current_query)
):
web_contexts = self._web_search_flow(current_query, log_trace, emit_log, verbose, allowed_levels)
contexts.extend(web_contexts)
# 7. 生成答案(注入图片描述到上下文)
if contexts:
# 将选中图片的描述注入上下文,让 LLM 能"看到"图片内容
if selected_images:
image_contexts = []
for i, img in enumerate(selected_images, 1):
full_desc = img.get('full_description', '') or img.get('description', '')
if full_desc:
img_source = img.get('source', '')
img_page = img.get('page', '')
source_info = f"(来源:{img_source}{img_page}页)" if img_source and img_page else ""
image_contexts.append({
'doc': f"【图片{i}{full_desc}{source_info}",
'meta': {'source': img_source, 'page': img_page, 'chunk_type': img.get('type', 'image')},
'score': img.get('score', 0),
'source_type': self.SOURCE_KB,
'query': current_query
})
# 图片上下文追加到知识库上下文前面(让 LLM 优先看到图片信息)
contexts = image_contexts + contexts
answer = self._generate_fused_answer(current_query, contexts, allowed_levels)
# 答案验证(防止幻觉)
if self.grounding_retry_count < self.MAX_GROUNDING_RETRY:
answer = self._verify_and_refine_answer(current_query, answer, contexts)
else:
answer = self._generate_no_context_answer(current_query, allowed_levels)
# 8. 构建引用
citations = self._attach_citations(answer, contexts)
# 9. 图片结果:优先使用 select_images 的结构化结果(含 URL + 打分)
# 回退到 _extract_rich_media从 metadata 提取)
if selected_images:
images_result = selected_images
else:
rich_media = self._extract_rich_media(contexts)
images_result = rich_media.get("images", [])
return {
"answer": answer,
"sources": citations.get("sources", []),
"images": images_result,
"tables": citations.get("tables", []) if isinstance(citations, dict) else [],
"citations": citations.get("citations", []),
"log_trace": log_trace
}
def chat_search(self, query: str, history: list = None, role: str = None,
department: str = None, allowed_levels: list = None) -> dict:
"""聊天式检索接口"""
return self.process(
query,
verbose=False,
history=history,
role=role,
department=department,
allowed_levels=allowed_levels
)
def chat(self):
"""命令行交互模式"""
print("🤖 Agentic RAG 已启动,输入 'quit' 退出")
print("-" * 50)
history = []
while True:
try:
query = input("\n👤 你: ").strip()
if not query:
continue
if query.lower() in ['quit', 'exit', 'q']:
print("👋 再见!")
break
result = self.process(query, verbose=True, history=history)
print(f"\n🤖 AI: {result['answer']}")
if result['sources']:
print("\n📚 来源:")
for src in result['sources'][:3]:
print(f" - {src['source']}")
history.append({"role": "user", "content": query})
history.append({"role": "assistant", "content": result['answer']})
except KeyboardInterrupt:
print("\n👋 再见!")
break
except Exception as e:
print(f"❌ 错误: {e}")

View File

@@ -1,214 +0,0 @@
"""
Agentic RAG - 答案生成 Mixin
包含答案生成、上下文构建、融合回答等方法
"""
import logging
from .agentic_base import logger, MODEL, SOURCE_KB, SOURCE_WEB
from core.llm_utils import call_llm
logger = logging.getLogger(__name__)
class AnswerMixin:
"""答案生成方法"""
def _generate_fused_answer(self, query: str, contexts: list, allowed_levels: list = None) -> str:
"""生成融合答案 - 智能处理多源信息"""
# 分离不同来源
kb_contexts = [c for c in contexts if c.get('source_type') == self.SOURCE_KB]
web_contexts = [c for c in contexts if c.get('source_type') == self.SOURCE_WEB]
# 如果没有任何上下文,检测是否因权限限制
if not contexts:
return self._generate_no_context_answer(query, allowed_levels)
# 正常生成答案
context_str = self._build_context_string(kb_contexts, web_contexts)
prompt = self._build_normal_answer_prompt(query, context_str, kb_contexts, web_contexts)
result = call_llm(
self.client, prompt, MODEL,
temperature=0.7,
max_tokens=2000
)
return result or f"生成答案失败"
def _build_context_string(self, kb_contexts, web_contexts):
"""构建上下文字符串 - FAQ 优先策略,按分数排序"""
# 分离 FAQ 和普通知识库内容
faq_contexts = [c for c in kb_contexts if c.get('meta', {}).get('chunk_type') == 'faq']
regular_contexts = [c for c in kb_contexts if c.get('meta', {}).get('chunk_type') != 'faq']
# 按分数降序排列,确保最相关的内容优先展示
regular_contexts.sort(key=lambda c: c.get('score', 0), reverse=True)
# FAQ 部分(优先展示)
faq_parts = []
for i, c in enumerate(faq_contexts[:3], 1):
meta = c['meta']
answer = meta.get('faq_answer', c['doc'])
faq_parts.append(f"[FAQ-{i}] 常见问题\n问题:{c['doc']}\n标准答案:{answer}")
# 普通知识库部分(用 12 条,提升覆盖率)
kb_parts = []
for i, c in enumerate(regular_contexts[:12], 1):
meta = c['meta']
source_str = meta.get('source', '未知')
section = meta.get('section', '')
source_info = f"{source_str}"
if section:
source_info += f" > {section[:60]}"
kb_parts.append(f"[知识库-{i}] {source_info}\n{c['doc']}")
web_parts = []
for i, c in enumerate(web_contexts[:5], 1):
meta = c['meta']
web_parts.append(f"[网络-{i}] {meta.get('title', '')}\n来源:{meta.get('source', '')}\n{c['doc']}")
return "\n\n".join(faq_parts + kb_parts + web_parts)
def _build_normal_answer_prompt(self, query, context_str, kb_contexts, web_contexts):
"""构建正常回答的提示词(与生产路径 generate_answer_stream 对齐)"""
# 检测是否有图片上下文
has_images = any(c.get('meta', {}).get('chunk_type') in ('image', 'chart', 'table')
for c in kb_contexts)
image_instruction = ""
if has_images:
image_instruction = "\n5. 如果参考资料中包含【图片N】信息请在回答中简要介绍每张图片的内容和用途"
return f"""你是一个严谨的知识库问答助手。你必须且只能根据用户提供的【参考资料】回答问题。
【参考资料】
{context_str}
【用户问题】
{query}
【回答要求】
1. 如果参考资料中有答案,必须引用对应内容回答,并在回答末尾标注引用编号(如[1]、[2]
2. 如果参考资料中确实没有相关信息,简短说明"参考资料中没有相关信息"即可,不要编造或补充资料外的内容
3. 禁止使用参考资料以外的知识进行补充或推测
4. 分点列举,条理清晰,语言简洁{image_instruction}
请仔细阅读以上全部参考资料后回答:"""
def _build_answer_prompt_with_permission(self, query, context_str, levels_str, sources_str, kb_contexts, web_contexts):
"""构建带权限提示的回答提示词"""
return f"""你是一个严谨的智能助手。
【用户问题】
{query}
【重要提示】
检测到与用户问题更相关的信息可能存在于「{levels_str}」级别的文档中,但用户当前的权限级别无法访问。
【可访问的信息来源】
{context_str}
【回答要求】
1. 首先明确告知用户:当前回答基于您有权限访问的文档,可能不完整
2. 基于现有信息如实回答
3. 建议用户如需完整信息,请联系管理员申请相应权限
请回答:"""
def _generate_no_context_answer(self, query: str, allowed_levels: list = None) -> str:
"""无上下文时的回答 — 诚实告知,不编造"""
return "参考资料中没有找到与该问题相关的信息,无法根据现有知识库内容回答您的问题。"
def _verify_and_refine_answer(self, query: str, answer: str, contexts: list) -> str:
"""验证并精炼答案 - 防止幻觉
返回值始终是干净的答案文本,不包含验证推理过程。
"""
prompt = f"""请检查以下回答是否存在"幻觉"(与参考信息不符的内容)。
【用户问题】
{query}
【参考信息】
{chr(10).join([f"[{i+1}] {c['doc'][:200]}" for i, c in enumerate(contexts[:8])])}
【AI回答】
{answer}
【检查规则】
1. 逐条核对回答中的事实是否能在参考信息中找到依据
2. 如果没有幻觉只回复一个英文单词PASS
3. 如果有幻觉,只输出修正后的完整回答(不要输出检查过程、不要加标题、不要加"检查结果"等前缀)
修正后的回答:"""
try:
result = call_llm(
self.client, prompt, MODEL,
temperature=0.1,
max_tokens=2000
)
if not result:
return answer
# 如果返回 PASS 或很短的确认,说明无幻觉
cleaned = result.strip()
if cleaned.upper() == "PASS" or len(cleaned) < 10:
return answer
# 有幻觉时,返回修正后的干净答案(去掉可能的前缀)
for prefix in ["修正后的回答:", "修正后回答:", "修正回答:", "修正后:"]:
if cleaned.startswith(prefix):
cleaned = cleaned[len(prefix):].strip()
return cleaned
except Exception as e:
logger.warning(f"答案验证失败: {e}")
return answer
def _generate_uncertain_answer(self, query: str, contexts: list) -> str:
"""生成不确定性回答"""
context_str = "\n".join([c['doc'][:200] for c in contexts[:3]])
prompt = f"""用户问题:{query}
找到的信息可能不够完整或相关性不高:
{context_str}
请基于这些信息给出一个谨慎的回答,明确说明哪些部分是有依据的,哪些部分可能需要更多验证。
回答:"""
try:
result = call_llm(
self.client, prompt, MODEL,
temperature=0.7,
max_tokens=1000
)
return result or "根据现有信息无法确定答案。"
except Exception as e:
logger.error(f"生成不确定性回答失败: {e}")
return "根据现有信息无法确定答案。"
def _direct_answer(self, query: str, history: list = None) -> str:
"""直接使用 LLM 回答(无知识库检索)"""
messages = [
{"role": "system", "content": "你是一个专业的助手,请用中文回答用户的问题。"}
]
if history:
for h in history[-4:]:
if h.get("role") in ["user", "assistant"]:
messages.append({"role": h["role"], "content": h.get("content", "")})
messages.append({"role": "user", "content": query})
try:
result = call_llm(
self.client, "", MODEL,
temperature=0.7,
max_tokens=1500,
messages=messages
)
return result or "抱歉,我无法回答这个问题。"
except Exception as e:
logger.error(f"直接回答失败: {e}")
return f"回答生成失败:{str(e)}"

View File

@@ -1,62 +0,0 @@
"""
Agentic RAG - 基础模块
包含常量、导入和共享配置
"""
import logging
# 配置日志
logger = logging.getLogger(__name__)
# 尝试导入搜索API配置
try:
from config import SERPER_API_KEY
HAS_SERPER = True
except ImportError:
HAS_SERPER = False
SERPER_API_KEY = None
# LLM 预算控制
try:
from core.llm_budget import get_budget_controller, should_use_agent, CallType
HAS_BUDGET = True
except ImportError:
HAS_BUDGET = False
CallType = None
# 语义缓存
try:
from config import SEMANTIC_CACHE_ENABLED, SEMANTIC_CACHE_THRESHOLD
HAS_SEMANTIC_CACHE_CONFIG = True
except ImportError:
SEMANTIC_CACHE_ENABLED = False
SEMANTIC_CACHE_THRESHOLD = 0.92
HAS_SEMANTIC_CACHE_CONFIG = False
try:
from core.semantic_cache import SemanticCache, get_semantic_cache
HAS_SEMANTIC_CACHE = True
except ImportError:
HAS_SEMANTIC_CACHE = False
SemanticCache = None
# LLM 配置
try:
from config import API_KEY, BASE_URL, MODEL
except ImportError:
API_KEY = None
BASE_URL = None
MODEL = None
# 来源标记
SOURCE_KB = "知识库"
SOURCE_WEB = "网络搜索"
# Context Compression 配置
MAX_CONTEXT_TOKENS = 8000 # 最大上下文 token 数(与生产路径对齐)
MAX_CONTEXT_COUNT = 20 # 最大上下文数量
RERANK_THRESHOLD = 0.3 # Rerank 过滤阈值
# Answer Grounding 配置
MAX_GROUNDING_RETRY = 1 # 幻觉修正最多重试次数

View File

@@ -1,237 +0,0 @@
"""
Agentic RAG - 引用处理 Mixin
包含来源提取、引用构建、引用附加等方法
"""
import json
import logging
from .agentic_base import logger, SOURCE_KB
logger = logging.getLogger(__name__)
class CitationMixin:
"""引用处理方法"""
def _extract_sources(self, contexts: list) -> list:
"""提取来源列表,返回结构化定位信息"""
source_map = {}
for c in contexts:
meta = c.get('meta', {})
source_type = c.get('source_type', '未知')
if source_type == self.SOURCE_KB:
source_key = meta.get('source', '未知')
page = meta.get('page')
page_end = meta.get('page_end', page)
section = meta.get('section', '')
doc_type = meta.get('doc_type', 'other')
preview = meta.get('preview', '')
section_chunk_id = meta.get('section_chunk_id')
else:
source_key = meta.get('title', meta.get('source', '未知'))
page = None
page_end = None
section = ''
doc_type = 'other'
preview = ''
section_chunk_id = None
if source_key not in source_map:
source_map[source_key] = {
"source": source_key,
"type": source_type,
"doc_type": doc_type,
"count": 0,
"pages": [],
"sections": set(),
"previews": [],
"section_chunk_ids": set()
}
source_map[source_key]["count"] += 1
if page:
page_range = (page, page_end if page_end else page)
if page_range not in source_map[source_key]["pages"]:
source_map[source_key]["pages"].append(page_range)
if section:
source_map[source_key]["sections"].add(section)
if preview and len(source_map[source_key]["previews"]) < 3:
if preview not in source_map[source_key]["previews"]:
source_map[source_key]["previews"].append(preview)
if section_chunk_id:
source_map[source_key]["section_chunk_ids"].add(section_chunk_id)
sources = []
for key, info in source_map.items():
source_str = info["source"]
doc_type = info.get("doc_type", "other")
location_parts = []
if doc_type == 'pdf':
if info["pages"]:
valid_pages = [(s, e) for s, e in info["pages"] if s > 1 or e > 1]
if valid_pages or not info["sections"]:
page_strs = []
for start, end in sorted(info["pages"], key=lambda x: x[0]):
if start == end:
page_strs.append(f"{start}")
else:
page_strs.append(f"{start}-{end}")
location_parts.append(", ".join(page_strs))
if info["sections"]:
sections_list = sorted(info["sections"])[:3]
sections_str = "".join(sections_list)
if len(info["sections"]) > 3:
sections_str += f"{len(info['sections'])}个章节"
location_parts.append(sections_str)
elif doc_type == 'word':
if info["sections"]:
sections_list = sorted(info["sections"])[:3]
sections_str = "".join(sections_list)
if len(info["sections"]) > 3:
sections_str += f"{len(info['sections'])}个章节"
location_parts.append(sections_str)
if info.get("section_chunk_ids"):
chunk_ids = sorted(info["section_chunk_ids"])[:5]
if chunk_ids:
chunk_str = f"{chunk_ids[0]}"
if len(chunk_ids) > 1:
chunk_str = f"{chunk_ids[0]}-{chunk_ids[-1]}"
location_parts.append(chunk_str)
elif doc_type == 'excel':
if info["sections"]:
sections_list = sorted(info["sections"])[:3]
sections_str = "".join(sections_list)
location_parts.append(sections_str)
else:
if info["pages"]:
valid_pages = [(s, e) for s, e in info["pages"] if s > 1 or e > 1]
if valid_pages or not info["sections"]:
page_strs = []
for start, end in sorted(info["pages"], key=lambda x: x[0]):
if start == end:
page_strs.append(f"{start}")
else:
page_strs.append(f"{start}-{end}")
location_parts.append(", ".join(page_strs))
if info["sections"]:
sections_list = sorted(info["sections"])[:3]
sections_str = "".join(sections_list)
if len(info["sections"]) > 3:
sections_str += f"{len(info['sections'])}个章节"
location_parts.append(sections_str)
if location_parts:
source_str = f"{source_str} ({' | '.join(location_parts)})"
sources.append({
"source": source_str,
"type": info["type"],
"count": info["count"],
"doc_type": doc_type,
"previews": info.get("previews", []),
"section_chunk_ids": sorted(info.get("section_chunk_ids", []))[:5]
})
return sources
def _build_citation(self, meta: dict) -> dict:
"""根据文档类型构建定位信息"""
# 从 chunk_id 中提取全局切片序号(格式: "filename_N"
chunk_id_raw = meta.get('chunk_id', '')
chunk_index = None
if chunk_id_raw and '_' in str(chunk_id_raw):
try:
chunk_index = int(str(chunk_id_raw).rsplit('_', 1)[-1])
except (ValueError, IndexError):
chunk_index = meta.get('chunk_index')
else:
chunk_index = meta.get('chunk_index')
citation = {
"chunk_id": chunk_id_raw,
"chunk_index": chunk_index, # 全局切片序号,用于前端文档预览跳转
"source": meta.get('source', ''),
"collection": meta.get('_collection', ''), # 所属向量库,用于前端文档预览跳转
"doc_type": meta.get('doc_type', 'other'),
"section": meta.get('section', ''),
"preview": meta.get('preview', ''),
"content": meta.get('preview', ''), # 初始用 preview_attach_citations 中会用完整内容覆盖
"chunk_type": meta.get('chunk_type', 'text'),
}
doc_type = meta.get('doc_type', 'other')
if doc_type == 'pdf':
bbox_raw = meta.get('bbox')
bbox = None
if bbox_raw:
try:
bbox = json.loads(bbox_raw) if isinstance(bbox_raw, str) else bbox_raw
except (json.JSONDecodeError, TypeError):
bbox = bbox_raw
citation.update({
"page": meta.get('page'),
"page_end": meta.get('page_end'),
"bbox": bbox,
"bbox_mode": meta.get('bbox_mode'),
})
elif doc_type == 'word':
citation.update({
"section_chunk_id": meta.get('section_chunk_id'),
})
elif doc_type == 'excel':
citation.update({
"page": meta.get('page'),
})
else:
bbox_raw = meta.get('bbox')
bbox = None
if bbox_raw:
try:
bbox = json.loads(bbox_raw) if isinstance(bbox_raw, str) else bbox_raw
except (json.JSONDecodeError, TypeError):
bbox = bbox_raw
citation.update({
"page": meta.get('page'),
"page_end": meta.get('page_end'),
"bbox": bbox,
"bbox_mode": meta.get('bbox_mode'),
})
return citation
def _attach_citations(self, answer: str, contexts: list) -> dict:
"""将引用信息附加到答案"""
citations = []
for c in contexts:
meta = c.get('meta', {})
full_content = c.get('doc', '')
citation = self._build_citation(meta)
# 用上下文中的完整文档内容覆盖 content 字段
if full_content:
citation['content'] = full_content[:300]
citations.append(citation)
return {
"answer": answer,
"citations": citations,
"sources": self._extract_sources(contexts)
}

View File

@@ -1,111 +0,0 @@
"""
Agentic RAG - 上下文处理 Mixin
包含上下文压缩、去重、Token 控制等方法
"""
import logging
from .agentic_base import logger, MAX_CONTEXT_TOKENS, RERANK_THRESHOLD
logger = logging.getLogger(__name__)
class ContextMixin:
"""上下文处理方法"""
def _compress_contexts(self, query: str, contexts: list) -> list:
"""上下文压缩三步走Rerank 过滤 → 去重 → Token 控制"""
if not contexts:
return contexts
# Step 1: Rerank 过滤
filtered = self._rerank_filter(contexts)
# Step 2: 去重
deduped = self._deduplicate_contexts(filtered)
# Step 3: Token 控制
result = self._truncate_to_tokens(deduped, self.MAX_CONTEXT_TOKENS)
return result
def _rerank_filter(self, contexts: list) -> list:
"""Rerank 过滤 - 保留相关性分数 >= 阈值的上下文"""
scored_contexts = [c for c in contexts if c.get('score') is not None]
if scored_contexts:
filtered = [c for c in contexts if c.get('score', 0) >= self.RERANK_THRESHOLD]
return filtered if filtered else contexts
return contexts
def _deduplicate_contexts(self, contexts: list, threshold: float = 0.9) -> list:
"""去重 - 基于内容相似度去重"""
if len(contexts) <= 1:
return contexts
result = []
seen_keys = set()
for c in contexts:
doc = c.get('doc', '')
key = doc[:100] if doc else ''
meta = c.get('meta', {})
source = meta.get('source', '')
page = meta.get('page', '')
composite_key = f"{source}|{page}|{key}"
if composite_key not in seen_keys:
seen_keys.add(composite_key)
result.append(c)
return result
def _truncate_to_tokens(self, contexts: list, max_tokens: int) -> list:
"""Token 控制 - 截断到最大 Token 数"""
result = []
total_tokens = 0
for c in contexts:
doc = c.get('doc', '')
# 简单估算1 token ≈ 1.5 中文字符
tokens = len(doc) // 1.5
if total_tokens + tokens <= max_tokens:
result.append(c)
total_tokens += tokens
else:
break
return result
def _merge_and_deduplicate(self, old_contexts: list, new_contexts: list) -> list:
"""合并并去重两个上下文列表"""
result = list(old_contexts)
seen_keys = set()
# 记录已有上下文的 key
for c in old_contexts:
doc = c.get('doc', '')
key = doc[:100] if doc else ''
meta = c.get('meta', {})
source = meta.get('source', '')
composite_key = f"{source}|{key}"
seen_keys.add(composite_key)
# 添加新上下文(去重)
for c in new_contexts:
doc = c.get('doc', '')
key = doc[:100] if doc else ''
meta = c.get('meta', {})
source = meta.get('source', '')
composite_key = f"{source}|{key}"
if composite_key not in seen_keys:
seen_keys.add(composite_key)
result.append(c)
return result

View File

@@ -1,200 +0,0 @@
"""
Agentic RAG - 富媒体处理 Mixin
包含图表查找、图片提取、富媒体附加等方法
"""
import re
import json
import logging
from .agentic_base import logger
logger = logging.getLogger(__name__)
class RichMediaMixin:
"""富媒体处理方法"""
def _find_figure(self, query: str, contexts: list, source: str = None) -> dict:
"""精确查找图表,带 fallback"""
patterns = [
r'\s*(\d+[\.\-]\d+)',
r'Fig\.?\s*(\d+[\.\-]\d+)',
r'Figure\s*(\d+[\.\-]\d+)',
]
target_figure = None
for pattern in patterns:
match = re.search(pattern, query, re.IGNORECASE)
if match:
target_figure = match.group(1).replace('-', '.')
break
if not target_figure:
return {"found": False}
# 从 contexts 中查找
for ctx in contexts:
meta = ctx.get('meta', {})
fig_num = meta.get('figure_number', '')
if fig_num == target_figure:
if not source or meta.get('source') == source:
return {
"found": True,
"chunk_id": meta.get('chunk_id'),
"source": meta.get('source'),
"page": meta.get('page'),
"caption": meta.get('caption'),
"image_path": meta.get('image_path'),
}
# Fallback: 直接查向量库
try:
from knowledge.manager import get_kb_manager
kb_mgr = get_kb_manager()
coll = kb_mgr.get_collection('public_kb')
if coll:
where_conditions = [{'chunk_type': {'$in': ['image', 'chart']}}]
if source:
where_conditions.append({'source': source})
result = coll.get(
where={'$and': where_conditions} if len(where_conditions) > 1 else where_conditions[0],
include=['metadatas', 'documents']
)
for meta, doc in zip(result.get('metadatas', []), result.get('documents', [])):
if meta.get('figure_number') == target_figure:
return {
"found": True,
"chunk_id": meta.get('chunk_id'),
"source": meta.get('source'),
"page": meta.get('page'),
"caption": meta.get('caption'),
"image_path": meta.get('image_path'),
}
caption = meta.get('caption', '') or (doc if doc else '')
if f"{target_figure}" in caption or f"{target_figure}" in caption:
return {
"found": True,
"chunk_id": meta.get('chunk_id'),
"source": meta.get('source'),
"page": meta.get('page'),
"caption": meta.get('caption'),
"image_path": meta.get('image_path'),
}
except Exception as e:
logger.warning(f"_find_figure fallback 查询失败: {e}")
return {"found": False}
def _get_images_for_source(self, source: str, collections: list = None) -> list:
"""直接从向量库获取指定文件的所有图片"""
try:
from knowledge.manager import get_kb_manager
kb_mgr = get_kb_manager()
except ImportError:
return []
images = []
seen_ids = set()
target_collections = collections or ['public_kb']
for kb_name in target_collections:
try:
coll = kb_mgr.get_collection(kb_name)
if not coll:
continue
result = coll.get(
where={'source': source},
include=['metadatas']
)
for meta in result.get('metadatas', []):
images_json = meta.get('images_json')
if images_json:
try:
imgs = json.loads(images_json)
for img in imgs:
img_id = img.get('id')
if img_id and img_id not in seen_ids:
seen_ids.add(img_id)
images.append({
"id": img_id,
"caption": img.get("caption", ""),
"url": f"/images/{img_id}",
"page": img.get("page") or meta.get("page"),
"source": source,
"width": img.get("width"),
"height": img.get("height")
})
except (json.JSONDecodeError, TypeError):
pass
except Exception as e:
logger.warning(f"{kb_name} 获取图片失败: {e}")
continue
return images
def _extract_rich_media(self, contexts: list, sources_filter: list = None, max_images: int = 10,
max_tables: int = 5) -> dict:
"""从检索结果中提取富媒体(图片、表格)"""
images = []
tables = []
seen_image_ids = set()
seen_table_ids = set()
for ctx in contexts:
meta = ctx.get('meta', {})
source = meta.get('source', '')
# 过滤来源
if sources_filter and source not in sources_filter:
continue
# 提取图片
images_json = meta.get('images_json')
if images_json:
try:
imgs = json.loads(images_json)
for img in imgs:
img_id = img.get('id')
if img_id and img_id not in seen_image_ids:
seen_image_ids.add(img_id)
images.append({
"id": img_id,
"caption": img.get("caption", ""),
"url": f"/images/{img_id}",
"page": img.get("page") or meta.get("page"),
"source": source,
"type": img.get("type", "image")
})
except (json.JSONDecodeError, TypeError):
pass
# 提取表格
table_json = meta.get('table_json')
if table_json:
try:
tbl = json.loads(table_json)
tbl_id = tbl.get('id') or meta.get('chunk_id')
if tbl_id and tbl_id not in seen_table_ids:
seen_table_ids.add(tbl_id)
tables.append({
"id": tbl_id,
"caption": tbl.get("caption", ""),
"markdown": tbl.get("markdown", ""),
"page": meta.get("page"),
"source": source
})
except (json.JSONDecodeError, TypeError):
pass
return {
"images": images[:max_images],
"tables": tables[:max_tables]
}

View File

@@ -1,133 +0,0 @@
"""
Agentic RAG - 元问题处理 Mixin
包含元问题判断和知识库元数据回答方法
"""
import logging
from .agentic_base import logger
logger = logging.getLogger(__name__)
class MetaQuestionMixin:
"""元问题处理方法"""
def _is_meta_question(self, query: str) -> bool:
"""判断是否为元问题(关于知识库本身的问题)"""
meta_patterns = [
"有哪些文件", "什么文件", "哪些文件", "文件列表", "文件目录",
"可以查看", "能查看", "有权限查看", "权限查看",
"能访问", "可以访问", "有权限访问",
"我的权限", "用户权限", "查看权限", "访问权限",
"权限能", "权限可以", "有什么权限", "有哪些权限",
"我能看", "我可以看", "我能查", "我可以查",
"能看到什么", "能查到什么", "可以看什么", "可以查什么",
"知识库有哪些", "库里有", "文档有哪些", "有哪些文档",
"有什么文档", "有什么文件", "包含什么", "包含哪些",
"你知道什么", "你都知道", "你能回答什么",
"系统里有什么", "库里有什么",
"public_kb", "dept_tech", "dept_hr", "dept_finance", "dept_operation",
"kb里", "向量库", "有哪些库", "库列表", "kb有哪些"
]
query_lower = query.lower()
return any(kw in query_lower for kw in meta_patterns)
def _answer_meta_question(self, query: str, allowed_levels: list = None,
role: str = None, department: str = None) -> str:
"""回答元问题(关于知识库本身的问题)"""
try:
source_map = {}
try:
from knowledge.manager import get_kb_manager
from auth.gateway import get_accessible_collections as _get_accessible
kb_mgr = get_kb_manager()
accessible = _get_accessible(role or 'user', department or '', 'read')
for kb_name in accessible:
coll = kb_mgr.get_collection(kb_name)
if not coll:
continue
try:
result = coll.get(include=['metadatas'])
except Exception as e:
logger.debug(f"获取{kb_name}元数据失败: {e}")
continue
for meta in result.get('metadatas', []):
source = meta.get('source', '未知')
level = meta.get('security_level', 'public')
page = meta.get('page')
if source not in source_map:
source_map[source] = {
'count': 0, 'levels': set(),
'pages': set(), 'collections': set()
}
source_map[source]['count'] += 1
source_map[source]['levels'].add(level)
source_map[source]['collections'].add(kb_name)
if page:
source_map[source]['pages'].add(page)
except ImportError:
from core.engine import get_engine
all_docs = get_engine().collection.get(include=['metadatas'])
for meta in all_docs.get('metadatas', []):
source = meta.get('source', '未知')
level = meta.get('security_level', 'public')
page = meta.get('page')
if source not in source_map:
source_map[source] = {
'count': 0, 'levels': set(),
'pages': set(), 'collections': set()
}
source_map[source]['count'] += 1
source_map[source]['levels'].add(level)
if page:
source_map[source]['pages'].add(page)
# 根据安全级别过滤
if allowed_levels:
allowed_set = set(allowed_levels)
filtered_sources = {}
for source, info in source_map.items():
if info['levels'] & allowed_set:
filtered_sources[source] = info
source_map = filtered_sources
if not source_map:
return "抱歉,您当前没有权限查看任何文档,或者知识库为空。"
sorted_sources = sorted(source_map.items(), key=lambda x: x[1]['count'], reverse=True)
answer_parts = [f"📚 **知识库文档列表**(共 {len(sorted_sources)} 个文档)\n"]
for i, (source, info) in enumerate(sorted_sources, 1):
colls = info.get('collections', set())
coll_str = f",所属: {', '.join(sorted(colls))}" if colls else ""
pages_str = ''
if info['pages']:
pages_list = sorted(info['pages'])
if len(pages_list) <= 5:
pages_str = f",页码: {', '.join(map(str, pages_list))}"
else:
pages_str = f",共 {len(info['pages'])}"
answer_parts.append(f"{i}. **{source}** ({info['count']} 条片段{coll_str}{pages_str})")
answer_parts.append(f"\n**总计**: {sum(s[1]['count'] for s in sorted_sources)} 条知识片段")
answer_parts.append(f"\n**您的权限级别**: {', '.join(allowed_levels) if allowed_levels else '全部'}")
answer_parts.append("\n\n💡 **提示**: 您可以直接提问关于这些文档内容的问题。")
return '\n'.join(answer_parts)
except Exception as e:
return f"获取文档列表时出错: {str(e)}\n\n您可以直接提问,我会尝试从知识库中检索相关信息。"

View File

@@ -1,137 +0,0 @@
"""
Agentic RAG - 质量评估 Mixin
包含置信度门控、质量评估、推理反思等方法
"""
import logging
from .agentic_base import logger
logger = logging.getLogger(__name__)
class QualityMixin:
"""质量评估方法"""
def _check_confidence_gate(self, query: str, docs: list, verbose: bool = True,
precomputed_scores: list = None):
"""检查置信度门控
Args:
query: 用户查询
docs: 文档列表
verbose: 是否详细输出
precomputed_scores: 预计算的 Rerank 分数(可选,避免重复推理)
"""
if not self.confidence_gate:
return {"passed": True, "reason": "no_gate"}
try:
result = self.confidence_gate.evaluate(query, docs,
precomputed_scores=precomputed_scores)
return result
except Exception as e:
logger.warning(f"置信度门控检查失败: {e}")
return {"passed": True, "reason": "error"}
def _assess_quality(self, query: str, docs: list, metas: list = None,
verbose: bool = True) -> dict:
"""多维质量评估"""
if not self.quality_assessor:
return {"overall_score": 0.5, "dimensions": {}}
try:
result = self.quality_assessor.assess(query, docs, metas)
return result
except Exception as e:
logger.warning(f"质量评估失败: {e}")
return {"overall_score": 0.5, "dimensions": {}}
def _reflect_on_answer(self, query: str, answer: str, contexts: list,
verbose: bool = True) -> dict:
"""推理反思"""
if not self.reasoning_reflector:
return {"needs_reflection": False, "issues": []}
try:
result = self.reasoning_reflector.reflect(query, answer, contexts)
return result
except Exception as e:
logger.warning(f"推理反思失败: {e}")
return {"needs_reflection": False, "issues": []}
def _think(self, original_query: str, current_query: str,
iteration: int, contexts: list, verbose: bool = True) -> dict:
"""
Agent 思考:决定下一步行动
Returns:
{
"action": "answer" | "rewrite" | "search_web" | "decompose",
"reason": "...",
"rewrite_query": "..." # 如果 action == "rewrite"
}
"""
from core.llm_utils import call_llm, parse_json_from_response
from .agentic_base import MODEL
# 构建思考提示
context_summary = ""
if contexts:
for i, ctx in enumerate(contexts[:3], 1):
meta = ctx.get('meta', {})
source = meta.get('source', '未知')
doc_preview = ctx.get('doc', '')[:100]
context_summary += f"{i}. [{source}] {doc_preview}...\n"
prompt = f"""你是一个 RAG 系统的决策 Agent需要判断下一步行动。
【原始问题】
{original_query}
【当前问题】
{current_query}
【迭代轮次】
{iteration} / {self.max_iterations}
【已检索到的上下文】
{context_summary if context_summary else "(无)"}
【可选行动】
1. answer - 已有足够信息,可以回答
2. rewrite - 查询不够清晰,需要重写
3. search_web - 知识库信息不足,需要网络搜索
4. decompose - 问题太复杂,需要分解
【决策要求】
- 如果上下文足够回答问题,选择 answer
- 如果上下文不足且迭代未超限,选择 search_web 或 rewrite
- 返回 JSON 格式
请决策:"""
try:
result = call_llm(
self.client, prompt, MODEL,
temperature=0.3,
max_tokens=200
)
decision = parse_json_from_response(result) if result else {}
# 默认决策
if not decision or "action" not in decision:
if contexts and len(contexts) >= 2:
decision = {"action": "answer", "reason": "有足够上下文"}
else:
decision = {"action": "rewrite", "reason": "上下文不足"}
return decision
except Exception as e:
logger.warning(f"Agent 思考失败: {e}")
if contexts:
return {"action": "answer", "reason": "默认回答"}
return {"action": "rewrite", "reason": "默认重写"}

View File

@@ -1,271 +0,0 @@
"""
Agentic RAG - 查询重写 Mixin
包含查询改写、实体补全、专业术语映射等方法
"""
import re
import logging
from .agentic_base import logger, MODEL
logger = logging.getLogger(__name__)
class QueryRewriteMixin:
"""查询重写方法"""
def _rewrite_query(self, query: str, history: list = None,
strategy: str = "professional") -> str:
"""
增强版查询重写:将口语化表达转为专业术语
Args:
query: 原始查询
history: 对话历史(用于实体补全)
strategy: 重写策略
- professional: 口语化→专业术语
- expand: 扩展关键词
- clarify: 消歧义
- entity: 实体补全
Returns:
str: 重写后的查询
"""
# 尝试多种策略组合
rewritten = query
# 策略1: 口语化→专业术语映射
if strategy in ["professional", "all"]:
rewritten = self._apply_professional_mapping(rewritten)
# 策略2: 实体补全(利用对话历史)
if strategy in ["entity", "all"] and history:
rewritten = self._complete_entities(rewritten, history)
# 策略3: LLM 深度重写(仅在需要时调用)
if strategy in ["professional", "all"]:
llm_rewritten = self._llm_rewrite(rewritten)
if llm_rewritten and len(llm_rewritten) > len(rewritten) * 0.5:
rewritten = llm_rewritten
return rewritten
def _apply_professional_mapping(self, query: str) -> str:
"""应用口语化→专业术语映射"""
TERM_MAPPING = {
"报销": "差旅报销 费用报销 报销审批",
"请假": "休假申请 请假审批 考勤管理",
"加班": "加班申请 工时管理 加班审批",
"工资": "薪酬管理 工资发放 薪资结构",
"合同": "合同管理 合同签署 合同审批",
"流程": "审批流程 业务流程 工作流",
"制度": "管理制度 规章制度 企业规范",
"规定": "管理规定 制度规定 政策要求",
"几天": "时限 审批时限 办理时限",
"多久": "处理时效 审批周期 办理周期",
"多少": "标准 额度 限额 标准",
"能不能": "是否允许 是否可以 权限",
"人事": "人力资源 HR 人力部门",
"财务": "财务部 财务部门 财务管理",
"技术": "技术部 研发部 IT部门",
}
result = query
for colloquial, professional in TERM_MAPPING.items():
if colloquial in query:
result = result.replace(colloquial, f"{colloquial} {professional.split()[0]}")
return result
def _complete_entities(self, query: str, history: list) -> str:
"""实体补全:利用对话历史补充缺失的实体"""
if not history:
return query
# 图片指代识别
image_reference = self._detect_image_reference(query, history)
if image_reference:
return image_reference
# 获取最近用户消息
last_user_msg = None
for msg in reversed(history):
if msg.get("role") == "user":
last_user_msg = msg.get("content", "")
break
if not last_user_msg:
return query
# 检查当前查询是否缺少主语
BUSINESS_KEYWORDS = ["报销", "出差", "请假", "工资", "合同", "审批", "流程",
"制度", "规定", "标准", "金额", "时间"]
has_subject = any(kw in query for kw in BUSINESS_KEYWORDS)
if not has_subject:
try:
import jieba
entities = []
for word in jieba.cut(last_user_msg):
word = word.strip()
if len(word) >= 2 and any(kw in word for kw in BUSINESS_KEYWORDS):
entities.append(word)
if entities:
return f"{entities[0]} {query}"
except ImportError:
pass
return query
def _detect_image_reference(self, query: str, history: list) -> str:
"""检测图片指代查询并重写"""
IMAGE_REFERENCE_PATTERNS = [
r'这[张些]图片', r'那[张些]图片', r'上面的图片', r'刚才的图片',
r'这[张些]图', r'那[张些]图', r'上面的图', r'刚才的图',
r'解释一下这[张些]图', r'说明一下这[张些]图',
r'这[张些]是什么图', r'图[里内]是什么', r'图片[里内]是什么',
]
is_image_reference = False
for pattern in IMAGE_REFERENCE_PATTERNS:
if re.search(pattern, query):
is_image_reference = True
break
if not is_image_reference:
return ""
last_images = []
for msg in reversed(history):
if msg.get("role") == "assistant":
metadata = msg.get("metadata", {})
if isinstance(metadata, dict):
images = metadata.get("images", [])
if images:
for img in images[:5]:
if isinstance(img, dict):
desc = img.get("description", "")
img_type = img.get("type", "图片")
if desc:
last_images.append(f"{img_type}{desc}")
elif isinstance(img, str):
last_images.append(f"图片:{img}")
if not last_images:
content = msg.get("content", "")
if "图片" in content or "图表" in content or "" in content:
sentences = content.split("")
for sentence in sentences:
if "图片" in sentence or "图表" in sentence:
last_images.append(sentence.strip())
if last_images:
break
if last_images:
image_context = " ".join(last_images[:3])
question_intent = re.sub(
r'这[张些]图片?|那[张些]图片?|上面的图片?|刚才的图片?|解释一下|说明一下',
'', query
).strip()
if question_intent:
return f"{image_context} {question_intent}"
else:
return f"详细解释:{image_context}"
return query
def _extract_image_context_from_history(self, history: list) -> str:
"""从对话历史中提取图片上下文"""
if not history:
return ""
for msg in reversed(history):
if msg.get("role") == "assistant":
metadata = msg.get("metadata", {})
images = metadata.get("images", [])
content = msg.get("content", "")
image_descriptions = []
if images:
for i, img in enumerate(images[:5], 1):
if isinstance(img, dict):
desc = img.get("description", "")
img_type = img.get("type", "图片")
source = img.get("source", "")
page = img.get("page", "")
img_info = f"图片{i}{img_type}"
if desc:
img_info += f",描述:{desc}"
if source:
img_info += f",来源:{source}"
if page:
img_info += f",第{page}"
image_descriptions.append(img_info)
if not image_descriptions:
if "图片" in content or "图表" in content:
sentences = content.split("")
for sentence in sentences:
if "图片" in sentence or "图表" in sentence:
image_descriptions.append(sentence.strip())
if len(image_descriptions) >= 3:
break
if image_descriptions:
return "\n".join(image_descriptions)
return ""
def _answer_image_reference(self, enhanced_query: str, history: list) -> str:
"""回答图片引用问题"""
from core.llm_utils import call_llm
messages = [
{"role": "system", "content": "你是一个专业的助手,请根据提供的图片信息回答用户的问题。"}
]
for h in history[-4:]:
if h.get("role") in ["user", "assistant"]:
messages.append({"role": h["role"], "content": h.get("content", "")})
messages.append({"role": "user", "content": enhanced_query})
try:
result = call_llm(
self.client, "", MODEL,
temperature=0.3,
max_tokens=1000,
messages=messages
)
return result or ""
except Exception as e:
logger.error(f"图片引用回答失败: {e}")
return f"抱歉,回答图片问题时出现错误:{str(e)}"
def _llm_rewrite(self, query: str) -> str:
"""LLM 深度重写查询"""
from core.llm_utils import call_llm
prompt = f"""请将以下用户问题改写为更专业、更清晰的表达,保持原意不变。
原问题:{query}
改写后的问题:"""
try:
rewritten = call_llm(
self.client, prompt, MODEL,
temperature=0.3,
max_tokens=100
)
return rewritten.strip() if rewritten else query
except Exception as e:
logger.warning(f"LLM 重写失败: {e}")
return query

View File

@@ -1,152 +0,0 @@
"""
Agentic RAG - 检索 Mixin
包含知识库检索、网络搜索等方法
"""
import json
import logging
import requests
from .agentic_base import (
logger, HAS_SERPER, SERPER_API_KEY,
SOURCE_KB, SOURCE_WEB
)
logger = logging.getLogger(__name__)
class SearchMixin:
"""检索功能方法"""
def _web_search(self, query: str, top_k: int = 5) -> list:
"""网络搜索使用Serper API"""
if not HAS_SERPER:
return []
try:
url = "https://google.serper.dev/search"
payload = json.dumps({
"q": query,
"gl": "cn",
"hl": "zh-cn",
"num": top_k
})
headers = {
'X-API-KEY': SERPER_API_KEY,
'Content-Type': 'application/json'
}
response = requests.post(url, headers=headers, data=payload, timeout=10)
response.raise_for_status()
data = response.json()
results = []
for item in data.get('organic', [])[:top_k]:
results.append({
'title': item.get('title', ''),
'link': item.get('link', ''),
'snippet': item.get('snippet', ''),
'date': item.get('date', '')
})
return results
except Exception as e:
logger.warning(f"网络搜索失败: {e}")
return []
def _should_web_search(self, query: str) -> bool:
"""判断是否需要网络搜索"""
realtime_keywords = [
"今天", "最新", "今日", "当前", "现在",
"天气", "新闻", "股价", "行情", "汇率",
"最近", "近期", "这周", "本月", "今年",
"实时", "动态", "热点", "发生"
]
query_lower = query.lower()
return any(kw in query_lower for kw in realtime_keywords)
def _web_search_flow(self, query: str, log_trace: list, emit_log, verbose: bool,
allowed_levels: list = None) -> list:
"""
网络搜索流程
Args:
query: 查询
log_trace: 日志追踪列表
emit_log: 日志发射函数
verbose: 是否详细输出
allowed_levels: 允许的安全级别
Returns:
网络搜索结果列表
"""
if not self.enable_web_search or not HAS_SERPER:
return []
if emit_log:
emit_log("🌐 触发网络搜索...")
web_results = self._web_search(query, top_k=5)
if not web_results:
if emit_log:
emit_log("⚠️ 网络搜索未返回结果")
return []
# 转换为统一上下文格式
web_contexts = []
for item in web_results:
web_contexts.append({
'doc': f"{item.get('title', '')}\n{item.get('snippet', '')}",
'meta': {
'source': self.SOURCE_WEB,
'link': item.get('link', ''),
'date': item.get('date', '')
},
'source_type': self.SOURCE_WEB,
'query': query
})
log_trace.append({
'phase': 'web_search',
'query': query,
'results_count': len(web_contexts)
})
if emit_log:
emit_log(f"✅ 网络搜索返回 {len(web_contexts)} 条结果")
return web_contexts
def _is_kb_result_sufficient(self, query: str, docs: list) -> bool:
"""判断知识库检索结果是否充分"""
if not docs:
return False
# 结果数量检查
if len(docs) >= 3:
# 至少3条结果检查相关性
high_rel_count = 0
for doc in docs:
score = doc.get('score', 0) or doc.get('distance', 1)
# cosine 距离转相似度
if isinstance(score, (int, float)):
sim = 1 - score if score <= 1 else score
if sim >= 0.6:
high_rel_count += 1
if high_rel_count >= 2:
return True
# 有高质量结果
for doc in docs[:2]:
score = doc.get('score', 0) or doc.get('distance', 1)
if isinstance(score, (int, float)):
sim = 1 - score if score <= 1 else score
if sim >= 0.8:
return True
return False

View File

@@ -14,6 +14,7 @@ BM25 关键词检索索引
import os import os
import pickle import pickle
import threading
import numpy as np import numpy as np
from rank_bm25 import BM25Okapi from rank_bm25 import BM25Okapi
import jieba import jieba
@@ -105,16 +106,19 @@ class BM25Index:
# ==================== 全局 BM25 索引管理器 ==================== # ==================== 全局 BM25 索引管理器 ====================
_bm25_indexer: BM25Index = None _bm25_indexer: BM25Index = None
_bm25_lock = threading.Lock() # 单例创建锁,防止多线程竞态条件
def get_bm25_indexer() -> BM25Index: def get_bm25_indexer() -> BM25Index:
""" """
获取全局 BM25 索引器实例 获取全局 BM25 索引器实例(双重检查锁定,线程安全)
Returns: Returns:
BM25Index 实例 BM25Index 实例
""" """
global _bm25_indexer global _bm25_indexer
if _bm25_indexer is None:
with _bm25_lock:
if _bm25_indexer is None: if _bm25_indexer is None:
_bm25_indexer = BM25Index() _bm25_indexer = BM25Index()
return _bm25_indexer return _bm25_indexer
@@ -122,7 +126,7 @@ def get_bm25_indexer() -> BM25Index:
def init_bm25_indexer(ids=None, documents=None, metadatas=None) -> BM25Index: def init_bm25_indexer(ids=None, documents=None, metadatas=None) -> BM25Index:
""" """
初始化 BM25 索引器并添加文档 初始化 BM25 索引器并添加文档(加锁,线程安全)
Args: Args:
ids: 文档 ID 列表 ids: 文档 ID 列表
@@ -133,6 +137,7 @@ def init_bm25_indexer(ids=None, documents=None, metadatas=None) -> BM25Index:
初始化后的 BM25Index 实例 初始化后的 BM25Index 实例
""" """
global _bm25_indexer global _bm25_indexer
with _bm25_lock:
_bm25_indexer = BM25Index() _bm25_indexer = BM25Index()
if ids and documents: if ids and documents:
_bm25_indexer.add_documents(ids, documents, metadatas or []) _bm25_indexer.add_documents(ids, documents, metadatas or [])

View File

@@ -224,39 +224,36 @@ class RAGCacheManager:
""" """
获取查询缓存结果 获取查询缓存结果
始终使用粗粒度 key基于 kb_version确保 GET/SET key 一致。
Args: Args:
query: 查询文本 query: 查询文本
kb_name: 知识库名称 kb_name: 知识库名称
doc_ids: 相关文档 ID 列表(用于细粒度缓存 key doc_ids: 保留参数以兼容调用方签名(当前未使用
""" """
kb_version = self.get_kb_version(kb_name) kb_version = self.get_kb_version(kb_name)
# 计算文档哈希(如果提供了 doc_ids # 使用粗粒度 key与 SET 保持一致
doc_hash = "" key = self._make_query_cache_key(query, kb_name, kb_version)
if doc_ids:
doc_hash = self._compute_doc_hash(kb_name, doc_ids)
key = self._make_query_cache_key(query, kb_name, kb_version, doc_hash)
return self.query_cache.get(key) return self.query_cache.get(key)
def set_query_result(self, query: str, kb_name: str, result: Dict, doc_ids: List[str] = None) -> None: def set_query_result(self, query: str, kb_name: str, result: Dict, doc_ids: List[str] = None) -> None:
""" """
设置查询缓存结果 设置查询缓存结果
始终使用粗粒度 key基于 kb_version确保 GET/SET key 一致。
kb_version 在文档变更时自增,触发整个知识库的缓存失效。
Args: Args:
query: 查询文本 query: 查询文本
kb_name: 知识库名称 kb_name: 知识库名称
result: 缓存结果 result: 缓存结果
doc_ids: 相关文档 ID 列表(用于细粒度失效 doc_ids: 保留参数以兼容调用方签名(当前未使用
""" """
kb_version = self.get_kb_version(kb_name) kb_version = self.get_kb_version(kb_name)
# 计算相关文档的版本哈希(细粒度失效) # 使用与 GET 相同的粗粒度 key确保缓存可命中
doc_hash = "" key = self._make_query_cache_key(query, kb_name, kb_version)
if doc_ids:
doc_hash = self._compute_doc_hash(kb_name, doc_ids)
key = self._make_query_cache_key(query, kb_name, kb_version, doc_hash)
self.query_cache.set(key, result, kb_version=kb_version) self.query_cache.set(key, result, kb_version=kb_version)
def _compute_doc_hash(self, kb_name: str, doc_ids: List[str]) -> str: def _compute_doc_hash(self, kb_name: str, doc_ids: List[str]) -> str:

View File

@@ -29,8 +29,10 @@ RAG 核心引擎
import os import os
import gc import gc
import re
import time import time
import logging import logging
import threading
import numpy as np import numpy as np
from typing import List, Tuple, Optional, Dict, Any from typing import List, Tuple, Optional, Dict, Any
@@ -50,6 +52,7 @@ except ImportError:
# 延迟导入,防止循环依赖 # 延迟导入,防止循环依赖
_engine_instance = None _engine_instance = None
_engine_lock = threading.Lock() # 单例创建锁,防止多线程竞态条件
try: try:
from config import ( from config import (
@@ -110,7 +113,7 @@ except ImportError:
RERANK_DEVICE = "auto" RERANK_DEVICE = "auto"
RERANK_USE_ONNX = False RERANK_USE_ONNX = False
RERANK_BACKEND = "local" RERANK_BACKEND = "local"
RERANK_CLOUD_MODEL = "qwen3-rerank" RERANK_CLOUD_MODEL = "xop3qwen8breranker"
RERANK_CLOUD_API_KEY = "" RERANK_CLOUD_API_KEY = ""
RERANK_CLOUD_BASE_URL = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks" RERANK_CLOUD_BASE_URL = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks"
RERANK_CLOUD_TIMEOUT = 15 RERANK_CLOUD_TIMEOUT = 15
@@ -270,8 +273,7 @@ class CloudReranker:
body = { body = {
"model": self.model, "model": self.model,
"query": query, "query": query,
"documents": documents, "documents": documents
"top_n": len(documents)
} }
session = self._get_session() session = self._get_session()
@@ -327,8 +329,10 @@ class RAGEngine:
@classmethod @classmethod
def get_instance(cls) -> 'RAGEngine': def get_instance(cls) -> 'RAGEngine':
"""获取引擎单例实例""" """获取引擎单例实例(双重检查锁定,线程安全)"""
global _engine_instance global _engine_instance
if _engine_instance is None:
with _engine_lock:
if _engine_instance is None: if _engine_instance is None:
_engine_instance = cls() _engine_instance = cls()
return _engine_instance return _engine_instance
@@ -621,7 +625,7 @@ class RAGEngine:
elif len(conditions) > 1: elif len(conditions) > 1:
where_filter = {"$and": conditions} where_filter = {"$and": conditions}
query_vector = self.embedding_model.encode(query).tolist() query_vector = self._encode_cached(query).tolist()
recall_k = RERANK_CANDIDATES if (USE_RERANK or USE_HYBRID_SEARCH) else top_k recall_k = RERANK_CANDIDATES if (USE_RERANK or USE_HYBRID_SEARCH) else top_k
recall_k = max(recall_k, top_k * RECALL_MULTIPLIER) recall_k = max(recall_k, top_k * RECALL_MULTIPLIER)
@@ -807,6 +811,76 @@ class RAGEngine:
logger.warning(f"FAQ 集合查询失败: {e}") logger.warning(f"FAQ 集合查询失败: {e}")
return get_empty_result() return get_empty_result()
def _encode_cached(self, text):
"""
带缓存的 embedding 编码
优先从 Embedding CacheLRU读取未命中再调用模型编码并写入缓存。
支持单文本和批量文本输入。
Args:
text: 单个文本字符串 或 文本列表
Returns:
numpy 数组(单文本为一维,批量为二维)
"""
import numpy as _np
# 检查 embedding 缓存是否启用(缓存配置查询结果,避免每次重复导入)
if not hasattr(self, '_emb_cache_enabled'):
self._emb_cache_enabled = True # 默认启用
if CACHE_AVAILABLE:
try:
from config import EMBEDDING_CACHE_ENABLED
self._emb_cache_enabled = EMBEDDING_CACHE_ENABLED
except ImportError:
pass
if not self._emb_cache_enabled:
return self.embedding_model.encode(text)
try:
_cache = get_cache_manager()
except Exception:
return self.embedding_model.encode(text)
# 批量输入
if isinstance(text, list):
try:
cached_embs, missed_indices = _cache.get_embeddings_batch(text)
if missed_indices:
missed_texts = [text[i] for i in missed_indices]
# encode(list) 始终返回 2D ndarray直接按行索引即可
new_embs = self.embedding_model.encode(missed_texts)
if len(missed_indices) == 1:
# 单条时 encode 可能返回 1D需统一处理
if new_embs.ndim == 1:
new_embs = new_embs.reshape(1, -1)
for idx, mi in enumerate(missed_indices):
emb_list = new_embs[idx].tolist()
cached_embs[mi] = emb_list
try:
_cache.set_embedding(text[mi], emb_list)
except Exception:
pass
return _np.array(cached_embs)
except Exception:
# 缓存故障时优雅降级为直接编码
return self.embedding_model.encode(text)
# 单文本输入
cached = _cache.get_embedding(text)
if cached is not None:
return _np.array(cached)
embedding = self.embedding_model.encode(text)
try:
emb_list = embedding.tolist() if hasattr(embedding, 'tolist') else list(embedding)
_cache.set_embedding(text, emb_list)
except Exception:
pass
return embedding
def _search_image_chunks(self, query_vector: list, top_k: int = 5, where_filter: dict = None) -> dict: def _search_image_chunks(self, query_vector: list, top_k: int = 5, where_filter: dict = None) -> dict:
""" """
独立检索图片切片P0图片独立召回通道 独立检索图片切片P0图片独立召回通道
@@ -1157,6 +1231,7 @@ class RAGEngine:
logger.warning(f"扩展连续切片失败: {e}") logger.warning(f"扩展连续切片失败: {e}")
return {'ids': [], 'documents': [], 'metadatas': []} return {'ids': [], 'documents': [], 'metadatas': []}
# 扩展同 section 的 text 邻居
where_filter = {"$and": [{"source": source}, {"chunk_type": "text"}]} where_filter = {"$and": [{"source": source}, {"chunk_type": "text"}]}
if section: if section:
where_filter["$and"].append({"section": section}) where_filter["$and"].append({"section": section})
@@ -1167,6 +1242,20 @@ class RAGEngine:
if not neighbors.get('ids') or len(neighbors.get('ids', [])) <= 1: if not neighbors.get('ids') or len(neighbors.get('ids', [])) <= 1:
neighbors = _get_neighbors({"$and": [{"source": source}, {"chunk_type": "text"}]}) neighbors = _get_neighbors({"$and": [{"source": source}, {"chunk_type": "text"}]})
# 同时扩展同 section 的 table 邻居table 切片的 rerank 分数往往偏低,
# 但与同 section 的 text 切片属于同一语义单元,不应割裂)
table_where = {"$and": [{"source": source}, {"chunk_type": "table"}]}
if section:
table_where["$and"].append({"section": section})
table_neighbors = _get_neighbors(table_where)
# 当 section 为空时table 查询只有 source 条件,可能拉入大量无关表格,
# 缩小 chunk_index 窗口至 ±1 以降低噪音;有 section 时使用正常窗口
if section:
_t_before, _t_after = CONTEXT_EXPANSION_BEFORE, CONTEXT_EXPANSION_AFTER
else:
_t_before, _t_after = 1, 1
neighbor_rows = [] neighbor_rows = []
for n_id, n_doc, n_meta in zip( for n_id, n_doc, n_meta in zip(
neighbors.get('ids', []), neighbors.get('ids', []),
@@ -1179,6 +1268,18 @@ class RAGEngine:
if seed_index - CONTEXT_EXPANSION_BEFORE <= n_index <= seed_index + CONTEXT_EXPANSION_AFTER: if seed_index - CONTEXT_EXPANSION_BEFORE <= n_index <= seed_index + CONTEXT_EXPANSION_AFTER:
neighbor_rows.append((n_index, n_id, n_doc, n_meta)) neighbor_rows.append((n_index, n_id, n_doc, n_meta))
# 同 section 的 table 邻居也加入扩展范围
for n_id, n_doc, n_meta in zip(
table_neighbors.get('ids', []),
table_neighbors.get('documents', []),
table_neighbors.get('metadatas', [])
):
n_index = self._to_int(n_meta.get('chunk_index'))
if n_index is None:
continue
if seed_index - _t_before <= n_index <= seed_index + _t_after:
neighbor_rows.append((n_index, n_id, n_doc, n_meta))
seed_neighbors_added = 0 seed_neighbors_added = 0
for n_index, n_id, n_doc, n_meta in sorted(neighbor_rows, key=lambda row: row[0]): for n_index, n_id, n_doc, n_meta in sorted(neighbor_rows, key=lambda row: row[0]):
if len(items) >= max_chunks: if len(items) >= max_chunks:
@@ -1383,7 +1484,7 @@ class RAGEngine:
if not target_collections: if not target_collections:
return get_empty_result() return get_empty_result()
query_vector = self.embedding_model.encode(query).tolist() query_vector = self._encode_cached(query).tolist()
# 扩大召回数量,以便过滤废止切片后仍有足够结果 # 扩大召回数量,以便过滤废止切片后仍有足够结果
recall_k = RERANK_CANDIDATES if (USE_RERANK or USE_HYBRID_SEARCH) else top_k recall_k = RERANK_CANDIDATES if (USE_RERANK or USE_HYBRID_SEARCH) else top_k
recall_k = max(recall_k, top_k * RECALL_MULTIPLIER) recall_k = max(recall_k, top_k * RECALL_MULTIPLIER)
@@ -1735,12 +1836,12 @@ class RAGEngine:
# === 高精度版:基于语义向量 === # === 高精度版:基于语义向量 ===
from core.mmr import mmr_rerank from core.mmr import mmr_rerank
# 获取查询向量 # 获取查询向量(使用 embedding 缓存)
query_emb = np.array(self.embedding_model.encode(query)) query_emb = np.array(self._encode_cached(query))
# 批量编码所有文档 # 批量编码所有文档(使用 embedding 缓存)
docs_list = results['documents'][0] docs_list = results['documents'][0]
all_embeddings = self.embedding_model.encode(docs_list) all_embeddings = self._encode_cached(docs_list)
# 构建候选列表 # 构建候选列表
candidates = [] candidates = []
@@ -1936,104 +2037,7 @@ class RAGEngine:
reranked[key] = results[key] reranked[key] = results[key]
return reranked return reranked
# ---------------- 安全与工具 ---------------- # ---------------- 流式生成 ----------------
def check_restricted_documents(self, query, allowed_levels, top_k=3, role=None, department=None):
if not self._initialized:
self.initialize()
if USE_MULTI_KB and self.kb_manager and role and department:
from auth.gateway import get_accessible_collections
all_colls = [c.name for c in self.kb_manager.list_collections()]
accessible = set(get_accessible_collections(role, department, 'read'))
restricted = set(all_colls) - accessible
if not restricted:
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": []}
query_vector = self.embedding_model.encode(query).tolist()
found_sources = set()
top_score = 0.0
for coll_name in restricted:
try:
coll = self.kb_manager.get_collection(coll_name)
if not coll: continue
res = coll.query(query_embeddings=[query_vector], n_results=top_k)
if res['metadatas'] and res['metadatas'][0]:
for meta in res['metadatas'][0]:
found_sources.add(meta.get('source', '未知'))
for dist in (res.get('distances', [[]])[0] or []):
if dist > top_score: top_score = dist
except Exception as e:
logger.debug(f"权限检查遍历失败: {e}")
return {
"has_restricted": len(found_sources) > 0,
"restricted_levels": [c.replace('dept_', '') for c in restricted if True][:3],
"restricted_sources": list(found_sources)[:3],
"top_restricted_score": top_score
}
if not allowed_levels:
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": [], "top_restricted_score": 0.0}
restricted_levels = {"public", "internal", "confidential", "secret"} - set(allowed_levels)
if not restricted_levels:
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": [], "top_restricted_score": 0.0}
query_vector = self.embedding_model.encode(query).tolist()
try:
res = self.collection.query(
query_embeddings=[query_vector],
n_results=top_k,
where={"security_level": {"$in": list(restricted_levels)}}
)
docs = res.get('documents', [[]])[0]
if not docs:
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": [], "top_restricted_score": 0.0}
metas = res.get('metadatas', [[]])[0]
dists = res.get('distances', [[]])[0]
found_levels, found_sources, top_score = set(), set(), 0.0
for meta, dist in zip(metas, dists):
found_levels.add(meta.get('security_level', 'public'))
found_sources.add(meta.get('source', '未知'))
if dist > top_score: top_score = dist
return {
"has_restricted": True,
"restricted_levels": list(found_levels),
"restricted_sources": list(found_sources)[:3],
"top_restricted_score": top_score
}
except Exception as e:
logger.warning(f"受限内容检查失败: {e}")
return {"has_restricted": False, "restricted_levels": [], "restricted_sources": [], "top_restricted_score": 0.0}
def generate_answer(self, query, context):
"""底层生成答复能力"""
prompt = f"""你是一个严谨的智能助手,请根据以下参考资料回答用户的问题。
...
参考资料:
{context}
用户问题:{query}
请回答:"""
try:
from core.llm_utils import call_llm
result = call_llm(
self.llm_client,
prompt,
MODEL,
temperature=LLM_TEMPERATURE,
max_tokens=LLM_MAX_TOKENS
)
return result or f"调用大模型失败: 返回结果为空"
except Exception as e:
return f"调用大模型失败: {str(e)}"
def generate_answer_stream(self, query, context, history=None): def generate_answer_stream(self, query, context, history=None):
""" """
@@ -2064,21 +2068,33 @@ class RAGEngine:
"content": ( "content": (
"你是一个严谨的知识库问答助手。" "你是一个严谨的知识库问答助手。"
"你必须且只能根据用户提供的【参考资料】回答问题。" "你必须且只能根据用户提供的【参考资料】回答问题。"
"参考资料中每段内容前标有章节路径(━格式),请注意区分不同章节的内容,"
"特别当不同章节标题相似或包含相同关键词时,务必根据章节路径准确定位,不要混淆。"
"如果参考资料中有答案,必须引用对应内容回答,并在回答末尾标注引用编号(如[1]、[2])。" "如果参考资料中有答案,必须引用对应内容回答,并在回答末尾标注引用编号(如[1]、[2])。"
"如果参考资料中确实没有相关信息,简短说明即可,不要编造或补充资料外的内容。" "如果参考资料中确实没有相关信息,简短说明即可,不要编造或补充资料外的内容。"
"禁止使用参考资料以外的知识进行补充或推测。" "禁止使用参考资料以外的知识进行补充或推测。"
"【重要-表格处理规则】当用户询问表格、要求展示表格内容时,你必须将参考资料中的 Markdown 表格原样输出(保留 | 分隔符和表格结构),"
"不要仅用文字描述表格存在或仅列出章节名称。如果参考资料中多个章节都有表格,"
"优先展示与用户问题最相关的表格完整内容。"
) )
}) })
# 添加当前问题(带上下文)- 强化指令 # 添加当前问题(带上下文)- 强化指令
if context: if context:
# 检测用户问题是否涉及表格,加入针对性指令
_table_hint = ""
# 检测上下文中是否包含 Markdown 表格(数据驱动,无需硬编码关键词)
_has_table_in_context = bool(re.search(r'\|.+\|', context)) if context else False
if _has_table_in_context:
_table_hint = "\n注意:参考资料中包含 Markdown 格式的表格数据,请务必将相关表格以原始 Markdown 表格格式完整展示在回答中,不要仅用文字描述。"
user_message = f"""【参考资料】 user_message = f"""【参考资料】
{context} {context}
【用户问题】 【用户问题】
{query} {query}
请仔细阅读以上全部参考资料后回答。如果参考资料中包含相关内容,必须引用回答并标注编号。如果资料中没有相关信息,请明确说明。""" 请仔细阅读以上全部参考资料后回答。注意参考资料中标有章节路径,请根据章节路径准确定位相关内容。如果参考资料中包含相关内容,必须引用回答并标注编号。如果资料中没有相关信息,请明确说明。{_table_hint}"""
else: else:
user_message = query user_message = query

View File

@@ -18,6 +18,7 @@
import json import json
import logging import logging
import hashlib import hashlib
import threading
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple from typing import List, Dict, Optional, Tuple
@@ -82,9 +83,16 @@ class IntentAnalyzer:
根据对话历史和当前用户消息,输出一个 JSON 对象,包含以下字段: 根据对话历史和当前用户消息,输出一个 JSON 对象,包含以下字段:
1. **rewritten_query**: 改写后的完整问题 1. **rewritten_query**: 改写后的完整问题
- 如果问题包含指代(如"这两张图片""继续说"),将其改写为完整、独立的问题 - **指代消解**如果问题包含指代(如"这两张图片""继续说"),将其改写为完整、独立的问题
- 例如:"分析一下这两张图片""分析一下对话历史中提到的图片" - 例如:"分析一下这两张图片""分析一下对话历史中提到的图片"
- 如果问题本身已经完整,直接返回原文 - **追问补全**:如果问题是省略式追问(省略了上一轮讨论的主题实体),必须补全为完整问题
- 判断方法:当前问题缺少主语/宾语,且对话历史中可以推断出省略的实体
- 补全方法:从上一轮用户问题中提取主题实体,与追问组合成完整问题
- 例如:
- 上一轮问"吸烟点C1类是什么区",追问"有完整表格吗?""吸烟点C1类有完整表格吗"
- 上一轮问"三峡工程的投资情况",追问"建设地点在哪?""三峡工程的建设地点在哪?"
- 上一轮问"货源投放有哪些原则?",追问"具体内容是什么?""货源投放原则的具体内容是什么?"
- 如果问题本身已经完整且独立,直接返回原文
2. **use_context**: 布尔值 2. **use_context**: 布尔值
- true: 问题依赖历史对话中的信息,答案已经在历史回答中 - true: 问题依赖历史对话中的信息,答案已经在历史回答中
@@ -104,7 +112,9 @@ class IntentAnalyzer:
- 推理类intent="reasoning"生成最多2个子查询 - 推理类intent="reasoning"生成最多2个子查询
* 原问题的检索查询 * 原问题的检索查询
* 一个补充角度的检索查询(如原因、背景、影响等),帮助获取更全面的上下文 * 一个补充角度的检索查询(如原因、背景、影响等),帮助获取更全面的上下文
- 其他类factual/instruction/other严格只生成1个子查询(原问题) - 其他类factual/instruction/other严格只生成1个子查询
* 子查询应基于 rewritten_query改写后的完整问题而非用户原始输入
* 例如:追问"有完整表格吗?"改写为"吸烟点C1类有完整表格吗"后,子查询应为"吸烟点C1类的完整表格内容"
- 不要为同一实体生成语义重叠的查询 - 不要为同一实体生成语义重叠的查询
- 子查询应保持原问题的关键词长度20-60字符为宜 - 子查询应保持原问题的关键词长度20-60字符为宜
@@ -306,7 +316,8 @@ class IntentAnalyzer:
if cache_emb is not None: if cache_emb is not None:
cached = cache.get(cache_emb) cached = cache.get(cache_emb)
if cached: # 确保缓存条目是意图分析结果(非 RAG 回答缓存)
if cached and cached.get("cache_type") != "rag_answer":
logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}") logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}")
return IntentAnalysis.from_dict(cached) return IntentAnalysis.from_dict(cached)
else: else:
@@ -376,9 +387,11 @@ class IntentAnalyzer:
intent=intent_type intent=intent_type
) )
# 存入语义缓存 # 存入语义缓存(标记类型,避免与 RAG 回答缓存混淆)
if cache and cache_emb is not None: if cache and cache_emb is not None:
cache.set(cache_emb, analysis.to_dict()) cache_data = analysis.to_dict()
cache_data["cache_type"] = "intent_analysis"
cache.set(cache_emb, cache_data)
# 存入精确匹配缓存 # 存入精确匹配缓存
if len(self._exact_cache) < self._exact_cache_max: if len(self._exact_cache) < self._exact_cache_max:
@@ -436,6 +449,7 @@ class IntentAnalyzer:
if not history: if not history:
return "(无历史对话)" return "(无历史对话)"
import re
parts = [] parts = []
# 提取最近 3 轮对话 # 提取最近 3 轮对话
@@ -444,6 +458,7 @@ class IntentAnalyzer:
for msg in recent_history: for msg in recent_history:
role = "用户" if msg.get("role") == "user" else "助手" role = "用户" if msg.get("role") == "user" else "助手"
content = msg.get("content", "") content = msg.get("content", "")
original_content = content # 保留原始内容用于结构化提取
# 截断过长的内容 # 截断过长的内容
if len(content) > 500: if len(content) > 500:
@@ -451,9 +466,10 @@ class IntentAnalyzer:
parts.append(f"{role}{content}") parts.append(f"{role}{content}")
# 提取图片信息 # 提取结构化信息(从 assistant 消息中提取章节、表格、来源等)
metadata = msg.get("metadata", {}) metadata = msg.get("metadata", {})
if isinstance(metadata, dict): if isinstance(metadata, dict):
# 已有的图片提取
images = metadata.get("images", []) images = metadata.get("images", [])
if images: if images:
for img in images[:3]: for img in images[:3]:
@@ -462,6 +478,43 @@ class IntentAnalyzer:
img_type = img.get("type", "图片") img_type = img.get("type", "图片")
parts.append(f" └─ {img_type}: {desc}") parts.append(f" └─ {img_type}: {desc}")
# 来源文件提取
sources = metadata.get("sources", [])
if sources:
source_names = []
for s in sources[:3]:
if isinstance(s, dict):
name = s.get("source", "") or s.get("name", "")
if name:
source_names.append(name)
elif isinstance(s, str):
source_names.append(s)
if source_names:
parts.append(f" └─ 来源文件: {', '.join(source_names)}")
# collections检索知识库提取
colls = metadata.get("collections", [])
if colls:
parts.append(f" └─ 检索知识库: {', '.join(colls)}")
# 从 assistant 原始内容中提取章节路径和表格结构
if role == "助手" and original_content:
# 提取章节路径:━ xxx ━ 格式
sections = re.findall(r'\s*(.+?)\s*━', original_content)
if sections:
unique_sections = list(dict.fromkeys(sections)) # 去重保序
parts.append(f" └─ 涉及章节: {'; '.join(unique_sections[:3])}")
# 提取表格列名:| A | B | C | 格式的表头行
table_headers = re.findall(r'^\|\s*(.+?)\s*\|', original_content, re.MULTILINE)
if table_headers:
# 取第一个表格的列名
first_header = table_headers[0]
cols = [c.strip() for c in first_header.split('|') if c.strip()]
# 排除分隔符行(--- 格式)
if cols and not all(re.match(r'^[-:]+$', c) for c in cols):
parts.append(f" └─ 含表格,列名: {', '.join(cols[:6])}")
# 添加图片上下文 # 添加图片上下文
if context_images: if context_images:
parts.append("\n【上下文中的图片】") parts.append("\n【上下文中的图片】")
@@ -503,10 +556,13 @@ class IntentAnalyzer:
# ==================== 便捷函数 ==================== # ==================== 便捷函数 ====================
_analyzer = None _analyzer = None
_analyzer_lock = threading.Lock() # 单例创建锁,防止多线程竞态条件
def get_intent_analyzer() -> IntentAnalyzer: def get_intent_analyzer() -> IntentAnalyzer:
"""获取意图分析器单例""" """获取意图分析器单例(双重检查锁定,线程安全)"""
global _analyzer global _analyzer
if _analyzer is None:
with _analyzer_lock:
if _analyzer is None: if _analyzer is None:
_analyzer = IntentAnalyzer() _analyzer = IntentAnalyzer()
return _analyzer return _analyzer

View File

@@ -237,6 +237,76 @@ def parse_json_list_from_response(content: str) -> Optional[List[dict]]:
return None return None
def extract_json_object(content: str) -> Optional[dict]:
"""
多策略从 LLM 响应中提取 JSON 对象(增强版)
在 parse_json_from_response 基础上增加 fallback 策略:
1. 先调用 parse_json_from_responsemarkdown 代码块 → 直接解析)
2. 失败后 fallback 到正则匹配最外层 {...} 块
Args:
content: LLM 返回的原始内容
Returns:
解析后的字典,全部策略失败返回 None
"""
if not content:
return None
# 策略1+2markdown 代码块提取 + 直接 json.loads
result = parse_json_from_response(content)
if result is not None and isinstance(result, dict):
return result
# 策略3fallback正则匹配最外层 JSON 对象 {...}
brace_match = re.search(r'\{[\s\S]*\}', content)
if brace_match:
try:
parsed = json.loads(brace_match.group(0))
if isinstance(parsed, dict):
return parsed
except (json.JSONDecodeError, TypeError, ValueError):
pass
return None
def extract_json_list(content: str) -> Optional[list]:
"""
多策略从 LLM 响应中提取 JSON 数组(增强版)
在 parse_json_list_from_response 基础上增加 fallback 策略:
1. 先调用 parse_json_list_from_responsemarkdown 代码块 → 直接解析 → 嵌套提取)
2. 失败后 fallback 到正则匹配最外层 [...] 块
Args:
content: LLM 返回的原始内容
Returns:
解析后的列表,全部策略失败返回 None
"""
if not content:
return None
# 策略1+2markdown 代码块提取 + 直接 json.loads + 嵌套 key 提取
result = parse_json_list_from_response(content)
if result is not None:
return result
# 策略3fallback正则匹配最外层 JSON 数组 [...]
bracket_match = re.search(r'\[[\s\S]*\]', content)
if bracket_match:
try:
parsed = json.loads(bracket_match.group(0))
if isinstance(parsed, list):
return parsed
except (json.JSONDecodeError, TypeError, ValueError):
pass
return None
# ==================== 便捷函数 ==================== # ==================== 便捷函数 ====================
def quick_ask( def quick_ask(
@@ -282,7 +352,7 @@ def quick_yes_no(
if keywords is None: if keywords is None:
keywords = ["", "需要", "yes", "true"] keywords = ["", "需要", "yes", "true"]
result = call_llm(client, prompt, model, temperature=0, max_tokens=10) result = call_llm(client, prompt, model, temperature=0, max_tokens=128)
if result is None: if result is None:
return False return False

104
core/prompt_guard.py Normal file
View File

@@ -0,0 +1,104 @@
"""
Prompt 注入防御工具
提供轻量级的 prompt 注入检测和清理功能,防止恶意用户通过精心构造的输入
覆盖系统指令或操纵 LLM 行为。
"""
import re
import logging
logger = logging.getLogger(__name__)
# 常见注入模式(中英文)
_INJECTION_PATTERNS = [
# 英文注入模式
r'ignore\s+(all\s+)?(previous|above|prior)\s+(instructions?|prompts?|rules?)',
r'you\s+are\s+now\s+(a|an)\s+',
r'forget\s+(everything|all|your)\s+',
r'disregard\s+(all\s+)?(previous|prior|above)',
r'override\s+(your|the|all)\s+(instructions?|rules?|system)',
r'reveal\s+(your|the)\s+(system\s+)?(prompt|instructions?)',
r'act\s+as\s+(if\s+)?(you\s+are\s+)?',
# 中文注入模式
r'忽略(之前|以上|前面|所有).*(指令|规则|提示|约束)',
r'你现在是(一个|新的)?',
r'忘记(之前|所有|你).*(规则|指令|设定)',
r'无视(系统|所有|之前).*(指令|规则|提示)',
r'不要遵守(任何|之前|系统).*(指令|规则)',
]
# 编译正则表达式(不区分大小写)
_COMPILED_PATTERNS = [
re.compile(p, re.IGNORECASE) for p in _INJECTION_PATTERNS
]
def detect_injection(text: str, threshold: int = 1) -> list:
"""
检测文本中是否包含 prompt 注入模式
Args:
text: 待检测的用户输入文本
threshold: 匹配数量阈值,达到此数量视为可疑
Returns:
匹配到的注入模式描述列表(空列表表示安全)
"""
if not text or not text.strip():
return []
matches = []
for pattern in _COMPILED_PATTERNS:
match = pattern.search(text)
if match:
matches.append(match.group()[:50])
if len(matches) >= threshold:
logger.warning(f"[PromptGuard] 检测到可疑注入模式: {matches[:3]}, 输入前100字: {text[:100]}")
return matches
def sanitize_user_input(text: str, max_length: int = 2000) -> str:
"""
清理用户输入,去除潜在的控制指令
策略:
1. 截断超长输入
2. 去除 null 字节和控制字符
3. 限制输入长度
Args:
text: 原始用户输入
max_length: 最大长度
Returns:
清理后的安全文本
"""
if not text:
return ""
# 去除 null 字节和控制字符(保留换行和空格)
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]', '', text)
# 截断超长输入
if len(text) > max_length:
text = text[:max_length]
logger.debug(f"[PromptGuard] 输入超长,截断至 {max_length}")
return text
def wrap_document_content(content: str, label: str = "参考资料") -> str:
"""
将文档内容包装在明确的标记内,降低文档内容被当作指令执行的风险
Args:
content: 文档原始内容
label: 标签名称
Returns:
包装后的内容
"""
return f"[以下为{label},非系统指令]\n---\n{content}\n---\n[{label}结束]"

View File

@@ -1,115 +1,30 @@
# Agentic RAG 完整指南 # RAG 系统完整指南
> **版本**: v3.2(模型/Reranker/管线更新 > **版本**: v4.0(统一编排 + 四层缓存修复
> **生产入口**: `api/chat_routes.py::rag()``core/engine.py`(轻量编排,当前启用) > **生产入口**: `api/chat_routes.py::rag()` `generate()` `core/engine.py`
> **备用编排**: `core/agentic.py::AgenticRAG.process()` + 8 个 Mixin完整决策循环未接线 > **最后更新**: 2026-06-05
> **最后更新**: 2026-06-04
> >
> ⚠️ 项目存在两套编排,生产 `/rag` 走的不是 `AgenticRAG`——详见下方「一·五、两套编排路径」 > 本次更新:删除未使用的 AgenticRAG 备用编排路径10 个文件 ~2050 行),修复 Query Cache 键不匹配与阈值问题,将语义缓存集成至生产 `/rag` 端点
## 一、功能概述 ## 一、功能概述
Agentic RAG 是一个智能问答系统,基于 Mixin 模式组合 8 个功能模块,具备以下核心能力: 本系统是一个检索增强生成RAG问答系统采用**单一统一编排路径**,由 `api/chat_routes.py``generate()` 函数直接编排全流程。核心能力包括
| 功能 | 说明 | Mixin 模块 | | 功能 | 说明 | 实现位置 |
|------|------|-----------| |------|------|----------|
| **意图分析** | LLM 驱动的查询改写 + 双层判断(是否需要检索) | `IntentAnalyzer`(独立模块) | | **意图分析** | LLM 驱动的双层判断(是否需要检索)+ 查询改写 | `core/intent_analyzer.py` |
| **查询重写** | 口语化→专业术语、实体补全、指代消解 | `QueryRewriteMixin` | | **混合检索** | 向量检索 + BM25 + RRF 融合 + Rerank 重排 | `core/engine.py` |
| **混合检索** | 向量检索 + BM25 + RRF 融合 + Rerank 重排 | `SearchMixin` `RAGEngine` | | **四层缓存** | Query + Embedding + RerankLRU+ 语义缓存FAISS | `core/cache.py` + `core/semantic_cache.py` |
| **多源融合** | 知识库 + 网络搜索,智能处理冲突 | `AnswerMixin` | | **流式生成** | SSE 流式答案输出,逐 token 推送 | `core/engine.py::generate_answer_stream()` |
| **幻觉验证** | 基于参考信息验证答案,防止 LLM 编造 | `AnswerMixin` | | **引用标注** | 自动标注信息来源和引用编号 | `api/chat_routes.py::_attach_citations()` |
| **引用标注** | 自动标注信息来源和引用编号 | `CitationMixin` | | **富媒体** | 图片/表格的智能提取与展示 | `api/chat_routes.py` |
| **富媒体提取** | 图片/表格的智能提取与展示 | `RichMediaMixin` | | **查询理解** | 查询分解、扩展、MMR 去重、自适应 TopK | `core/` 各独立模块 |
| **质量评估** | 多维度质量评估(相关性/完整性/准确性/覆盖面) | `QualityMixin` | | **安全护栏** | 敏感信息过滤、Prompt 安全守卫 | `api/response_utils.py``core/prompt_guard.py` |
| **上下文压缩** | Rerank 阈值过滤 + Token 预算控制 | `ContextMixin` |
| **元问题处理** | 文件列表、权限查询等非知识类问题 | `MetaQuestionMixin` |
| **置信度门控** | 基于 Reranker 分数判断检索质量,低分触发补救 | `ConfidenceGate`(独立模块) |
---
## ⚠️ 一·五、两套编排路径(务必先读)
> **关键认知**:本项目存在**两套并存的编排orchestration**。生产 HTTP 接口 `/rag` 走的是**轻量编排**,而 `AgenticRAG.process()` 那套**完整决策循环目前处于备用状态、未接入任何 HTTP 路由**。
> 阅读下方所有架构图前请先理解这一点——下面 2.1 的「整体架构图」描绘的是**备用路径AgenticRAG.process**,不是当前生产实际跑的流程。
### 路径对比
| 维度 | 🟢 生产路径(当前启用) | 💤 备用路径(未接线) |
|------|----------------------|---------------------|
| 入口 | `api/chat_routes.py``rag()``generate()` | `core/agentic.py``AgenticRAG.process()` |
| 编排者 | `chat_routes` 自己的流程代码 | `AgenticRAG`8 个 Mixin 组合) |
| 意图分析 | ✅ `intent_analyzer.analyze_intent()` | ✅ `IntentAnalyzer` / `QueryRewriteMixin` |
| 检索 | ✅ `search_hybrid()``engine.search_knowledge()` | ✅ `engine.search_knowledge()` |
| 查询分解/扩展/MMR/自适应TopK | ✅ 在 `engine` 内部执行 | ✅ 同左 |
| 答案生成 | ✅ `engine.generate_answer_stream()`(流式) | `AnswerMixin._generate_fused_answer()` |
| 引用标注 | ✅ `chat_routes._attach_citations()`(本地版) | `CitationMixin._attach_citations()` |
| 置信度门控 | ❌ 不调用 | `ConfidenceGate`(仅此路径用) |
| 多维质量评估 | ❌ 不调用 | `QualityMixin._assess_quality()` |
| 推理反思 | ❌ 不调用 | `QualityMixin._reflect_on_answer()` |
| 循环防护 | ❌ 不调用 | `LoopGuard`(仅此路径用) |
| 幻觉验证 | ❌ 不调用 | `AnswerMixin._verify_and_refine_answer()` |
### 重要结论
- **Agentic 的核心能力是活跃的**:意图分析+LLM改写、子查询拆分、查询扩展、自适应 TopK、MMR 去重、混合检索+Rerank——这些都在 `/rag` 中**真实运行**,只是由 `chat_routes` + `engine` 直接调用,而非通过 `AgenticRAG` 类。
- **休眠的只是「决策循环编排类」**`AgenticRAG.process()` 及其独有组件(置信度门控 / 质量评估 / 推理反思 / 循环防护 / 幻觉验证)未接入 `/rag`
- import 证据:`confidence_gate.py``quality_assessor.py``reasoning_reflector.py``loop_guard.py` 以及 8 个 `agentic_*` Mixin **只被 `core/agentic.py` import**;而 `AgenticRAG` 实例虽在 `api/__init__.py:90` 启动时创建,但其唯一读取入口 `_get_agentic_rag()` **零调用**
- **这不是死代码可删**`AgenticRAG` 在启动时被实例化(直接删会导致启动报错),且 `_extract_rich_media``scripts/test_rag_image_recall.py` 使用。它是「**一套更重、更完整、目前未启用的 Agentic 决策闭环**」,未来可选择接入。
### 🔬 如何验证「系统现在到底走哪套流程」
**方法 1看开发环境 SSE 调试事件(最直接)**
`/rag``IS_DEV=True` 时会发出一串**只有 `chat_routes` 编排才会发**的调试事件,收到它们即证明走的是生产路径:
```bash
# UTF-8 payload 避免 Windows shell 编码问题
curl -s -N -X POST http://localhost:5001/rag \
-H "Content-Type: application/json; charset=utf-8" \
-H "Authorization: Bearer mock-token-admin" \
--data-binary @payload.json
```
观察 SSE 事件序列,**生产路径**会依次出现这些 `type``AgenticRAG.process` 不发这些):
| SSE 事件 `type` | 来源代码 | 含义 |
|----------------|---------|------|
| `start` | `chat_routes.py:1232` | 请求开始处理 |
| `intent_result` | `chat_routes.py:1228` | 意图分析结果(来自 `intent_analyzer`[DEV] |
| `retrieval_debug` | `chat_routes.py:1311` | 检索管线各步骤(来自 `engine.search_knowledge``_debug`[DEV] |
| `chunks_retrieved` | `chat_routes.py:1416` | 召回切片详情 [DEV] |
| `sources` | `chat_routes.py:1547` | 检索到的来源列表 |
| `images_selected` | `chat_routes.py:1574` | 图片选择详情 [DEV] |
| `context_built` | `chat_routes.py:1622` | 最终上下文构建 [DEV] |
| `chunk` | `chat_routes.py:1630` | 流式答案的每个 token |
| `finish` | `chat_routes.py:1699` | 含 `timing``sources``citations` |
| `error` | `chat_routes.py:1733` | 处理异常时的错误信息 |
> 标注 [DEV] 的事件仅在 `IS_DEV=True` 时发送,其余事件在生产环境也会发送。
**方法 2看服务端日志**
- 启动时:出现一次 `Agentic RAG 引擎已初始化``api/__init__.py:95`,仅实例化,不代表被调用)。
- 每次 `/rag` 请求:出现 `[意图分析] use_context=... need_retrieval=...``chat_routes.py:1224`)。
- **不会**出现任何来自 `AgenticRAG.process()` 内部的日志(如查询重写 `📝 查询重写``🔍 知识库检索: N 条结果`)——若出现则说明走了备用路径。
**方法 3埋点验证最确定**
临时在 `core/agentic.py``AgenticRAG.process()` 第一行加 `logger.warning("AgenticRAG.process CALLED")`,重启后发 `/rag` 请求——**该日志不会触发**,即证明生产不走 `AgenticRAG`
**方法 4静态确认调用链**
```bash
grep -rn "_get_agentic_rag()" --include="*.py" . # 仅定义,无调用者 → AgenticRAG 实例未被请求使用
grep -rn "\.process(" --include="*.py" api/ # /rag、/chat 均无 .process() 调用
```
--- ---
## 二、系统架构 ## 二、系统架构
> ⚠️ 注意:下方 2.1「整体架构图」描绘的是**备用路径 `AgenticRAG.process()`** 的完整设计;当前生产 `/rag` 的实际流程见上方「一·五」及本节 2.3「生产 /rag 实际流程」。
### 2.1 整体架构图 ### 2.1 整体架构图
``` ```
@@ -118,6 +33,12 @@ grep -rn "\.process(" --include="*.py" api/ # /rag、/chat 均无 .proce
└────────────────────────────┬────────────────────────────────────────┘ └────────────────────────────┬────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ 语义缓存检查 (SemanticCache - FAISS) │
│ cosine ≥ 0.92 → 命中则直接返回缓存结果 │
│ 跳过检索 + 生成全流程(~100ms vs ~9s
└────────────────────────────┬────────────────────────────────────────┘
↓ 未命中
┌─────────────────────────────────────────────────────────────────────┐
│ 意图分析 (IntentAnalyzer) │ │ 意图分析 (IntentAnalyzer) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 改写查询 │ │ 双层判断 │ │ 子查询拆分 │ │ │ │ 改写查询 │ │ 双层判断 │ │ 子查询拆分 │ │
@@ -126,26 +47,24 @@ grep -rn "\.process(" --include="*.py" api/ # /rag、/chat 均无 .proce
└─────────┼──────────────────┼──────────────────┼─────────────────────┘ └─────────┼──────────────────┼──────────────────┼─────────────────────┘
↓ ↓ ↓ ↓ ↓ ↓
┌──────────┐ ┌──────────────────────────────────────────┐ ┌──────────┐ ┌──────────────────────────────────────────┐
│ 直接回答 │ │ AgenticRAG.process() │ 直接回答 │ │ 统一编排流程
│ (LLM) │ │ 1. 元问题检查 │ (LLM) │ │ 1. 混合检索 (engine.search_knowledge)
└──────────┘ │ 2. 查询重写 (QueryRewriteMixin) └──────────┘ │ 2. 上下文提取 + 来源去重
│ 3. 知识库检索 (RAGEngine.search_knowledge) │ 3. 图片补充检索 + 打分选择
│ 4. 上下文压缩 (ContextMixin) │ 4. 构建上下文
│ 5. 网络搜索 (SearchMixin, 可选) │ 5. 流式答案生成 (engine.generate_stream)
│ 6. 图谱检索已废弃graph/ 目录已清空) │ 6. 答案图号对齐 + 引用标注
│ 7. 融合答案生成 (AnswerMixin) │ 7. 敏感信息过滤
│ 8. 幻觉验证 (AnswerMixin) │ 8. 语义缓存写入 + SSE finish
│ 9. 富媒体提取 (RichMediaMixin) │
│ 10. 引用标注 (CitationMixin) │
└────────────────────┬─────────────────────┘ └────────────────────┬─────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ 检索层 (RAGEngine) │ │ 检索层 (RAGEngine) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 向量检索 │ │ BM25 检索 │ │ FAQ 独立召回 │ │ │ 查询缓存 │ │ 向量检索 │ │ BM25 检索 │ │
│ │ (语义匹配) │ │ (关键词匹配) │ │ (精准命中) │ │ │ (LRU 500) │ │ (语义匹配) │ │ (关键词匹配) │ │
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ 命中直接返回│ └──────┬───────┘ └──────┬───────┘ │
└─────────────────┼─────────────────┘ │ │ └──────────────┘ └─────────────────┘ │
│ ↓ │ │ ↓ │
│ ┌──────────────┐ │ │ ┌──────────────┐ │
│ │ RRF 融合 │ ← 动态权重(查询类型/长度驱动)│ │ │ RRF 融合 │ ← 动态权重(查询类型/长度驱动)│
@@ -167,78 +86,133 @@ grep -rn "\.process(" --include="*.py" api/ # /rag、/chat 均无 .proce
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
│ 答案生成 (LLM 流式) │ │ 答案生成 (LLM 流式) │
│ ┌────────────────────────────────────────────────────────────────┐ │ │ ┌────────────────────────────────────────────────────────────────┐ │
│ │ 整合多源信息 + 标注来源 + 处理冲突 + 引用编号 + SSE 流式输出 │ │ │ │ 整合多源信息 + 标注来源 + 引用编号 + SSE 流式输出 │ │
│ └────────────────────────────────────────────────────────────────┘ │ │ └────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
``` ```
### 2.2 Mixin 组合架构 ### 2.2 编排方式
```python 系统采用**函数式编排**而非类组合模式。整个 RAG 流程由 `api/chat_routes.py``generate()` 函数直接控制,按步骤调用各独立模块:
class AgenticRAG(
QueryRewriteMixin, # 查询重写:口语化→专业术语、实体补全
SearchMixin, # 检索功能:网络搜索
AnswerMixin, # 答案生成:融合回答、幻觉验证
CitationMixin, # 引用处理:来源标注、引用编号
RichMediaMixin, # 富媒体:图片/表格提取
QualityMixin, # 质量评估:多维评估
ContextMixin, # 上下文处理:压缩、过滤
MetaQuestionMixin # 元问题:文件列表、权限查询
):
...
```
> 注:以上 2.1 / 2.2 是 `AgenticRAG`(备用路径)的设计。**当前生产 `/rag` 不实例化走这条链**,实际流程见下方 2.3。 - **意图分析**`core/intent_analyzer.py``analyze_intent()`
- **检索管线**`core/engine.py``search_knowledge()`
- **流式生成**`core/engine.py``generate_answer_stream()`
- **引用标注**`api/chat_routes.py` 内的 `_attach_citations()`
- **缓存系统**`core/cache.py``RAGCacheManager` 单例 + `core/semantic_cache.py``SemanticCache` 单例
### 2.3 生产 /rag 实际流程(当前启用) 各模块通过 `get_engine()``get_cache_manager()``get_semantic_cache()` 等工厂函数获取全局单例实例。
入口 `api/chat_routes.py::rag() → generate()`**不经过 `AgenticRAG`**
```
POST /rag (SSE 流式)
[chat_routes.generate()] ← 轻量编排,不实例化 AgenticRAG
├─ 发 SSE: start
├─ 1. 意图分析 intent_analyzer.analyze_intent() # chat_routes:1222
│ ├─ need_retrieval=False → 直接 LLM 回答(流式发 SSE: chunk结束
│ │ └─ use_context=True 时带历史上下文use_context=False 时纯闲聊
│ └─ 否则继续sub_queries 传入检索
│ └─[DEV] 发 SSE: intent_result
├─ 2. 混合检索 search_hybrid() → engine.search_knowledge() # chat_routes:1300
│ (内部:向量+BM25+RRF+废止过滤+章节过滤
│ +云端Rerank+MMR去重+FAQ加权+黑名单+时间衰减
│ +上下文扩展+自适应TopK
│ └─[DEV] 发 SSE: retrieval_debug
├─ 3. 提取上下文/来源(按 source 去重doc_type 驱动溯源展示) # chat_routes:1362
│ └─[DEV] 发 SSE: chunks_retrieved
│ └─ 发 SSE: sources
├─ 4. 图片补充检索 + 图片打分选择 (select_images)
│ └─[DEV] 发 SSE: images_selected
├─ 5. 构建上下文 (_order_text_contexts_for_prompt)
│ └─[DEV] 发 SSE: context_built
├─ 6. 流式答案生成 engine.generate_answer_stream() # chat_routes:1628
│ └─ 逐 token 发 SSE: chunk
├─ 7. 答案图号对齐过滤
├─ 8. 引用标注 chat_routes._attach_citations()(本地版,非 CitationMixin # chat_routes:1668
├─ 9. 敏感信息过滤 filter_response()
├─ 10. 发 SSE: finishanswer + sources + citations + images + timing
└─[异常] 发 SSE: error
```
**与备用路径AgenticRAG.process的差异**:生产路径**没有**置信度门控、多维质量评估、推理反思、循环防护、幻觉验证这几步——它们只存在于 `AgenticRAG.process()`
--- ---
## 三、意图分析流程 ## 三、四层缓存架构
### 3.1 IntentAnalyzer 双层判断 ### 3.1 缓存层次概览
| 层次 | 缓存类型 | 存储结构 | 容量 | TTL | 作用 |
|------|----------|----------|------|-----|------|
| L1 | Query Cache | LRU (OrderedDict) | 500 条 | 1 小时 | 缓存完整问答结果,命中后跳过整个检索+生成 |
| L2 | Embedding Cache | LRU (OrderedDict) | 2000 条 | 24 小时 | 缓存向量化结果,避免重复调用 embedding 模型 |
| L3 | Rerank Cache | LRU (OrderedDict) | 1000 条 | 1 小时 | 缓存 Rerank 分数,避免重复调用 Reranker |
| L4 | Semantic Cache | FAISS IndexFlatIP | 10000 条 | 无过期 | 语义级缓存,相似查询也能命中 |
### 3.2 Query Cache
Query Cache 是最外层的完整问答结果缓存。命中后直接返回缓存的 `answer + sources + citations`,跳过检索和生成全流程。
**缓存键设计**`{query_hash}:{kb_name}:{kb_version}`
- 基于查询文本哈希 + 知识库名称 + 知识库版本号
- 知识库版本变更时(如文档更新/重新索引),相关缓存自动失效
**已修复的问题**
1. **键不匹配问题(已修复)**:此前 `set_query_result()` 在有 `doc_ids` 参数时使用 `doc_hash` 分支生成键,而 `get_query_result()` 始终使用 `kb_version` 分支——导致 GET 和 SET 的键永远不匹配,命中率始终为 0%。修复后两端统一使用 `kb_version` 分支。
2. **写入阈值问题(已修复)**`CACHE_MIN_SCORE` 原值为 `0.3`,但 ChromaDB 余弦距离经 `1 - dist` 计算后得分通常在 0.03-0.06 之间,远低于阈值,导致几乎不写入缓存。修复后设为 `0.0`
### 3.3 Embedding Cache
缓存文本向量化结果,由 `RAGEngine` 在调用 embedding 模型前后自动读写。键为查询文本哈希,避免对相同文本重复调用 embedding 模型(如 DashScope text-embedding-v3
### 3.4 Rerank Cache
缓存 Rerank 重排序的分数结果。键为 `query + sorted(doc_ids)` 的精确匹配。注意:由于 RRF 融合产出差异,命中率可能偏低。
### 3.5 语义缓存Semantic Cache
语义缓存基于 FAISS 向量索引实现语义级匹配——即使查询文字不完全相同只要语义足够相似cosine similarity ≥ 0.92),就能命中缓存。
**工作机制**
```
用户查询 → embedding 编码 → FAISS 向量检索
→ cosine ≥ 0.92 → 命中:返回缓存的 answer + sources + citations~100ms
→ cosine < 0.92 → 未命中:执行完整 RAG 流程后写入缓存
```
**集成位置**
- **读取**:在 `generate()` 函数的意图分析之后、混合检索之前(跳过整个检索+生成流程)
- **写入**:在 `generate()` 函数生成完整答案后、发送 `finish` 事件之前
- **缓存内容**answer、sources、citations、images、tables
**验证结果**:语义缓存命中率约 66.7%,平均响应从 ~9.2 秒降至 ~100 毫秒(约 92 倍加速)。
### 3.6 缓存失效机制
所有基于 LRU 的缓存L1-L3均支持基于知识库版本号`kb_version`)的自动失效:
- 每个缓存条目关联 `kb_version`
- 知识库文档变更时 `kb_version` 递增
- 读取时检查 `kb_version` 是否匹配,不匹配则视为过期
语义缓存L4当前无 TTL 过期机制,仅受 `max_size=10000` 容量限制。
### 3.7 缓存配置
```python
# config.py / config.example.py
# 查询结果缓存
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 # 24小时
# Rerank 缓存
RERANK_CACHE_ENABLED = True
RERANK_CACHE_SIZE = 1000
RERANK_CACHE_TTL = 3600
# 语义缓存
SEMANTIC_CACHE_ENABLED = True
SEMANTIC_CACHE_THRESHOLD = 0.92 # cosine 相似度阈值
# 缓存写入最低置信度
CACHE_MIN_SCORE = 0.0 # ChromaDB 余弦距离经 1-dist 后得分约 0.03-0.06,须设为 0
```
### 3.8 部署注意事项
当前所有缓存均为**进程内内存存储**LRU 使用 `OrderedDict`,语义缓存使用 FAISS 内存索引),有以下部署影响:
- **单 Worker**Gunicorn 默认 1 个 worker所有请求共享同一缓存实例缓存有效
- **多 Worker**:每个 worker 有独立缓存,不共享,缓存效率降低
- **冷启动**:进程重启后缓存全部丢失,需重新预热
- **`max_requests=1000`**Gunicorn worker 定期重启会导致缓存周期性清空
对于生产环境多实例部署场景,已规划 Redis 外部缓存迁移方案(见 `reports/redis_migration_plan.md`)。
---
## 四、意图分析流程
### 4.1 IntentAnalyzer 双层判断
意图分析由 `core/intent_analyzer.py``IntentAnalyzer` 类完成,采用 **LLM 驱动** 的双层判断: 意图分析由 `core/intent_analyzer.py``IntentAnalyzer` 类完成,采用 **LLM 驱动** 的双层判断:
@@ -269,7 +243,7 @@ POST /rag (SSE 流式)
- intent: factual/comparison/reasoning/instruction/other - intent: factual/comparison/reasoning/instruction/other
``` ```
### 3.2 QueryClassifier 规则分类 ### 4.2 QueryClassifier 规则分类
`core/query_classifier.py` 提供无 LLM 调用的快速规则分类: `core/query_classifier.py` 提供无 LLM 调用的快速规则分类:
@@ -286,9 +260,9 @@ POST /rag (SSE 流式)
--- ---
## 、检索管线详解 ## 、检索管线详解
### 4.1 完整检索流程 ### 5.1 完整检索流程
``` ```
search_knowledge(query, top_k=30) search_knowledge(query, top_k=30)
@@ -335,7 +309,7 @@ search_knowledge(query, top_k=30)
└─ 15. 缓存写入 → 返回结果 └─ 15. 缓存写入 → 返回结果
``` ```
### 4.2 混合检索代码示例 ### 5.2 混合检索代码示例
```python ```python
# 向量检索(语义相似) # 向量检索(语义相似)
@@ -348,7 +322,10 @@ bm25_results = bm25_index.search(query, top_k=recall_k)
faq_results = faq_collection.query(query_embeddings=[query_vector], n_results=3) faq_results = faq_collection.query(query_embeddings=[query_vector], n_results=3)
# 图片独立召回P0 通道) # 图片独立召回P0 通道)
image_results = collection.query(query_embeddings=[query_vector], n_results=5, where={"chunk_type": {"$in": ["image", "chart", "table"]}}) image_results = collection.query(
query_embeddings=[query_vector], n_results=5,
where={"chunk_type": {"$in": ["image", "chart", "table"]}}
)
# RRF 融合(动态权重) # RRF 融合(动态权重)
fused = reciprocal_rank_fusion([vector_results, bm25_results], weights=[vector_w, bm25_w]) fused = reciprocal_rank_fusion([vector_results, bm25_results], weights=[vector_w, bm25_w])
@@ -360,7 +337,7 @@ reranked = rerank_results(query, fused, top_k=15)
mmr_results = mmr_rerank(query_emb, reranked, top_k=30, lambda_param=0.5) mmr_results = mmr_rerank(query_emb, reranked, top_k=30, lambda_param=0.5)
``` ```
### 4.3 RRF 融合算法 ### 5.3 RRF 融合算法
``` ```
RRF分数 = Σ (权重 / (k + 排名位置)) RRF分数 = Σ (权重 / (k + 排名位置))
@@ -376,9 +353,9 @@ RRF分数 = Σ (权重 / (k + 排名位置))
- 查询类型驱动: FACT→BM25优先, PROCESS→向量优先 - 查询类型驱动: FACT→BM25优先, PROCESS→向量优先
``` ```
### 4.4 Rerank 重排 ### 5.4 Rerank 重排
**后端**: 支持三种模式,由 `RERANK_BACKEND` 环境变量控制 **后端**支持三种模式,由 `RERANK_BACKEND` 环境变量控制
| RERANK_BACKEND | 说明 | | RERANK_BACKEND | 说明 |
|----------------|------| |----------------|------|
@@ -386,20 +363,20 @@ RRF分数 = Σ (权重 / (k + 排名位置))
| `"local"` | 仅使用本地 `BAAI/bge-reranker-base`CrossEncoder / ONNX | | `"local"` | 仅使用本地 `BAAI/bge-reranker-base`CrossEncoder / ONNX |
| `"fallback"` | 优先云端,失败时自动回退本地(推荐生产环境) | | `"fallback"` | 优先云端,失败时自动回退本地(推荐生产环境) |
**云端 Reranker推荐**: **云端 Reranker推荐**
```python ```python
# config.py # config.py
RERANK_BACKEND = os.getenv("RERANK_BACKEND", "local") # local / cloud / fallback RERANK_BACKEND = os.getenv("RERANK_BACKEND", "local")
RERANK_CLOUD_MODEL = "qwen3-rerank" # DashScope 云端 Rerank 模型 RERANK_CLOUD_MODEL = "qwen3-rerank"
RERANK_CLOUD_API_KEY = os.getenv("RERANK_CLOUD_API_KEY", DASHSCOPE_API_KEY) RERANK_CLOUD_API_KEY = os.getenv("RERANK_CLOUD_API_KEY", DASHSCOPE_API_KEY)
RERANK_CLOUD_BASE_URL = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks" RERANK_CLOUD_BASE_URL = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks"
RERANK_CLOUD_TIMEOUT = 15 # 云端请求超时(秒) RERANK_CLOUD_TIMEOUT = 15
``` ```
`CloudReranker` 类(`core/engine.py`)封装 DashScope 的 `/compatible-api/v1/reranks` 接口,提供与本地 `CrossEncoder.predict()` / `ONNXReranker.predict()` 一致的调用接口。 `CloudReranker` 类(`core/engine.py`)封装 DashScope 的 `/compatible-api/v1/reranks` 接口,提供与本地 `CrossEncoder.predict()` / `ONNXReranker.predict()` 一致的调用接口。
**本地 Reranker备选**: **本地 Reranker备选**
```python ```python
def rerank_results(self, query, results, top_k=5): def rerank_results(self, query, results, top_k=5):
@@ -409,69 +386,73 @@ def rerank_results(self, query, results, top_k=5):
# 返回 top_k 个最高分结果 # 返回 top_k 个最高分结果
``` ```
**调用位置**: `core/engine.py``search_knowledge()``_search_multi_kb()`RRF 融合 + 废止/章节过滤之后、MMR 去重之前执行。 **调用位置**`core/engine.py``search_knowledge()``_search_multi_kb()`RRF 融合 + 废止/章节过滤之后、MMR 去重之前执行。
**引擎初始化顺序**: `RAGEngine.__init__()` 中按 `RERANK_BACKEND` 决定加载策略:
- `cloud` / `fallback`:先尝试创建 `CloudReranker`,需要 `RERANK_CLOUD_API_KEY`
- `local` / `fallback`(云端失败时):加载本地 `BAAI/bge-reranker-base`,支持 ONNX 加速
--- ---
## 五、置信度门控 ## 六、生产 /rag 完整流程
`core/confidence_gate.py` 基于 Reranker 分数判断检索结果质量 入口 `api/chat_routes.py::rag() → generate()`
``` ```
检索结果 → Reranker 计算置信度 → 阈值判断 → 决策 POST /rag (SSE 流式)
[chat_routes.generate()]
┌─────────────────┼─────────────────┐ ├─ 发 SSE: start
↓ ↓ ↓
PASS (≥0.4) REWRITE (0.2~0.4) WEB_SEARCH (<0.2) ├─ 1. 语义缓存检查 SemanticCache.get() # chat_routes
继续生成 查询重写 网络搜索补救 ├─ 命中 → 直接流式发 SSE: chunk + finish结束~100ms
│ └─ 未命中 → 继续;记录 embedding 供后续写入
├─ 2. 意图分析 intent_analyzer.analyze_intent()
│ ├─ need_retrieval=False → 直接 LLM 回答(流式发 SSE: chunk结束
│ │ └─ use_context=True 时带历史上下文use_context=False 时纯闲聊
│ └─ 否则继续sub_queries 传入检索
│ └─[DEV] 发 SSE: intent_result
├─ 3. 混合检索 search_hybrid() → engine.search_knowledge()
│ (内部:查询缓存检查 → 向量+BM25+RRF+废止过滤+章节过滤
│ +云端Rerank+MMR去重+FAQ加权+黑名单+时间衰减
│ +上下文扩展+自适应TopK
│ └─[DEV] 发 SSE: retrieval_debug
├─ 4. 提取上下文/来源(按 source 去重doc_type 驱动溯源展示)
│ └─[DEV] 发 SSE: chunks_retrieved
│ └─ 发 SSE: sources
├─ 5. 图片补充检索 + 图片打分选择 (select_images)
│ └─[DEV] 发 SSE: images_selected
├─ 6. 构建上下文 (_order_texts_for_prompt)
│ └─[DEV] 发 SSE: context_built
├─ 7. 流式答案生成 engine.generate_answer_stream()
│ └─ 逐 token 发 SSE: chunk
├─ 8. 答案图号对齐过滤
├─ 9. 引用标注 _attach_citations()
├─ 10. 敏感信息过滤 filter_response()
├─ 11. 语义缓存写入 SemanticCache.set() # 写入缓存供后续命中
├─ 12. 发 SSE: finishanswer + sources + citations + images + timing
└─[异常] 发 SSE: error
``` ```
**阈值配置**: ### SSE 事件序列
- `PASS_THRESHOLD = 0.2`: 通过阈值(低于此值需要补救)
- `GOOD_THRESHOLD = 0.4`: 良好阈值(高质量结果)
- `EXCELLENT_THRESHOLD = 0.7`: 优秀阈值
--- | SSE 事件 `type` | 含义 |
|----------------|------|
| `start` | 请求开始处理 |
| `intent_result` | 意图分析结果 [DEV] |
| `retrieval_debug` | 检索管线各步骤 [DEV] |
| `chunks_retrieved` | 召回切片详情 [DEV] |
| `sources` | 检索到的来源列表 |
| `images_selected` | 图片选择详情 [DEV] |
| `context_built` | 最终上下文构建 [DEV] |
| `chunk` | 流式答案的每个 token |
| `finish` | 含 `timing``sources``citations``images` |
| `error` | 处理异常时的错误信息 |
## 六、AgenticRAG 主流程 > 标注 [DEV] 的事件仅在 `IS_DEV=True` 时发送。
### 6.1 process() 方法
```python
def process(self, query, verbose=True, history=None,
allowed_levels=None, role=None, department=None,
emit_log=None) -> dict:
"""
返回:
{
"answer": str, # 最终答案
"sources": list, # 来源列表
"images": list, # 图片列表
"tables": list, # 表格列表
"citations": list, # 引用列表
"log_trace": list # 推理过程追踪
}
"""
```
### 6.2 流程步骤
| 步骤 | 方法 | 说明 |
|------|------|------|
| 1 | `_is_meta_question()` | 检查元问题(文件列表、权限等) |
| 2 | `should_rewrite()` + `_rewrite_query()` | 查询重写(口语化→专业术语、实体补全) |
| 3 | `engine.search_knowledge()` / `engine.search_multiple()` | 知识库检索(含向量+BM25+RRF+MMR+Rerank |
| 4 | `_compress_contexts()` | 上下文压缩Rerank 阈值过滤) |
| 5 | `_web_search_flow()` | 网络搜索(可选,需 SERPER_API_KEY |
| 6 | ~~`_graph_search()`~~ | ~~图谱检索已废弃graph/ 目录已清空)~~ |
| 7 | `_generate_fused_answer()` | 融合答案生成(多源信息+冲突处理) |
| 8 | `_verify_and_refine_answer()` | 幻觉验证(防止 LLM 编造) |
| 9 | `_extract_rich_media()` | 富媒体提取(图片/表格) |
| 10 | `_attach_citations()` | 引用标注 |
--- ---
@@ -489,7 +470,7 @@ curl -X POST http://localhost:5001/rag \
}' }'
``` ```
**响应格式**: SSEServer-Sent Events流式返回 **响应格式**SSEServer-Sent Events流式返回
``` ```
event: token event: token
@@ -501,7 +482,7 @@ data: {"text": "规定"}
... ...
event: finish event: finish
data: {"answer": "完整答案", "sources": [...], "citations": [...], "images": [...], "duration_ms": 3200} data: {"answer": "完整答案", "sources": [...], "citations": [...], "images": [...], "duration_ms": 200}
``` ```
### 7.2 代码调用 ### 7.2 代码调用
@@ -520,20 +501,27 @@ for token in engine.generate_answer_stream(query, context, history=history):
print(token, end="", flush=True) print(token, end="", flush=True)
``` ```
### 7.3 AgenticRAG 调用 ### 7.3 缓存统计查询
```python ```bash
from core.agentic import AgenticRAG # 查看各层缓存命中率和统计
curl http://localhost:5001/cache/stats \
rag = AgenticRAG(max_iterations=3, enable_web_search=True) -H "Authorization: Bearer mock-token-admin"
result = rag.process("出差补助标准是什么?")
print(f"答案: {result['answer']}")
print(f"来源: {result['sources']}")
print(f"图片: {result['images']}")
print(f"引用: {result['citations']}")
``` ```
返回示例:
```json
{
"query_cache": {"total_entries": 50, "hits": 10, "misses": 6, "hit_rate": 0.625},
"embedding_cache": {"total_entries": 200, "hits": 0, "misses": 0, "hit_rate": 0},
"rerank_cache": {"total_entries": 100, "hits": 0, "misses": 0, "hit_rate": 0},
"semantic_cache": {"total_entries": 15, "hits": 6, "misses": 3, "hit_rate": 0.667}
}
```
> 注:当 Query Cache 或 Semantic Cache 在外层拦截了重复查询时Embedding Cache 和 Rerank Cache 的命中率为 0 是正常现象——重复查询根本不会到达这些层。
--- ---
## 八、配置说明 ## 八、配置说明
@@ -565,10 +553,10 @@ RECALL_MULTIPLIER = 3 # 候选池最小倍数
# 重排序 # 重排序
USE_RERANK = True # 启用重排序 USE_RERANK = True # 启用重排序
RERANK_BACKEND = "local" # "local"=本地模型, "cloud"=云端API, "fallback"=优先云端失败回退本地 RERANK_BACKEND = "local" # "local"=本地模型, "cloud"=云端API, "fallback"=优先云端失败回退本地
RERANK_CLOUD_MODEL = "qwen3-rerank" # 云端 Rerank 模型DashScope API RERANK_CLOUD_MODEL = "qwen3-rerank"
RERANK_CANDIDATES = 20 # 送入重排序的候选数 RERANK_CANDIDATES = 20 # 送入重排序的候选数
RERANK_TOP_K = 15 # 重排序后保留数 RERANK_TOP_K = 15 # 重排序后保留数
RERANK_USE_ONNX = True # ONNX 加速(仅本地模式,环境变量控制,默认开启 RERANK_USE_ONNX = True # ONNX 加速(仅本地模式,环境变量控制)
# RRF 融合 # RRF 融合
RRF_K = 60 # RRF 常数 RRF_K = 60 # RRF 常数
@@ -581,30 +569,7 @@ MMR_TOP_K = 30 # MMR 保留数
MMR_LAMBDA = 0.5 # 相关性 vs 多样性权衡 MMR_LAMBDA = 0.5 # 相关性 vs 多样性权衡
``` ```
### 8.3 缓存配置 ### 8.3 设备配置
```python
# 查询结果缓存
QUERY_CACHE_ENABLED = True
QUERY_CACHE_SIZE = 500
QUERY_CACHE_TTL = 3600 # 1小时
# Embedding 缓存
EMBEDDING_CACHE_ENABLED = True
EMBEDDING_CACHE_SIZE = 2000
EMBEDDING_CACHE_TTL = 86400 # 24小时
# Rerank 缓存
RERANK_CACHE_ENABLED = True
RERANK_CACHE_SIZE = 1000
RERANK_CACHE_TTL = 3600 # 1小时
# 语义缓存
SEMANTIC_CACHE_ENABLED = True
SEMANTIC_CACHE_THRESHOLD = 0.92 # 相似度阈值
```
### 8.4 设备配置
```python ```python
DEVICE = "auto" # auto / cuda / cpu / cuda:0 DEVICE = "auto" # auto / cuda / cpu / cuda:0
@@ -618,17 +583,9 @@ RERANK_DEVICE = DEVICE # Rerank 模型设备
``` ```
core/ # RAG 核心引擎 core/ # RAG 核心引擎
├── engine.py # RAGEngine 单例检索主流程、Rerank、RRF ├── engine.py # RAGEngine 单例检索主流程、Rerank、RRF、流式生成
├── agentic.py # AgenticRAG 主类Mixin 组合 ├── cache.py # 三层 LRU 缓存管理器Query/Embedding/Rerank
├── agentic_base.py # 基础常量与条件导入 ├── semantic_cache.py # 语义缓存FAISS IndexFlatIP
├── agentic_query.py # QueryRewriteMixin查询重写
├── agentic_search.py # SearchMixin网络搜索
├── agentic_answer.py # AnswerMixin答案生成、幻觉验证
├── agentic_citation.py # CitationMixin引用标注
├── agentic_media.py # RichMediaMixin富媒体提取
├── agentic_quality.py # QualityMixin质量评估
├── agentic_context.py # ContextMixin上下文压缩
├── agentic_meta.py # MetaQuestionMixin元问题处理
├── bm25_index.py # BM25Index关键词检索 ├── bm25_index.py # BM25Index关键词检索
├── chunker.py # 文本分块器 ├── chunker.py # 文本分块器
├── mmr.py # MMR 去重(语义向量版 + 文本 Jaccard 版) ├── mmr.py # MMR 去重(语义向量版 + 文本 Jaccard 版)
@@ -637,14 +594,13 @@ core/ # RAG 核心引擎
├── query_decomposer.py # QueryDecomposer复杂查询拆分 ├── query_decomposer.py # QueryDecomposer复杂查询拆分
├── query_expansion.py # 查询扩展 ├── query_expansion.py # 查询扩展
├── adaptive_topk.py # AdaptiveTopK自适应 TopK ├── adaptive_topk.py # AdaptiveTopK自适应 TopK
├── confidence_gate.py # ConfidenceGate置信度门控 ├── confidence_gate.py # ConfidenceGate置信度门控,当前未接入生产流程
├── quality_assessor.py # 多维质量评估 ├── quality_assessor.py # 多维质量评估(当前未接入生产流程)
├── reasoning_reflector.py # 推理反思 ├── reasoning_reflector.py # 推理反思(当前未接入生产流程)
├── loop_guard.py # 循环防护 ├── loop_guard.py # 循环防护(当前未接入生产流程)
├── prompt_guard.py # Prompt 安全守卫
├── llm_budget.py # LLM 调用预算控制 ├── llm_budget.py # LLM 调用预算控制
├── llm_utils.py # LLM 调用工具函数 ├── llm_utils.py # LLM 调用工具函数
├── semantic_cache.py # 语义缓存
├── cache.py # 三层缓存管理器Query/Embedding/Rerank
├── status_codes.py # 状态码定义 ├── status_codes.py # 状态码定义
└── constants.py # 公共常量 └── constants.py # 公共常量
@@ -666,7 +622,7 @@ knowledge/ # 知识库管理
api/ # API 路由层 api/ # API 路由层
├── __init__.py # create_app() 工厂 ├── __init__.py # create_app() 工厂
├── chat_routes.py # /chat, /rag(SSE), /search ├── chat_routes.py # /chat, /rag(SSE), /search(核心编排入口)
├── kb_routes.py # /collections ├── kb_routes.py # /collections
├── document_routes.py # /documents/* ├── document_routes.py # /documents/*
├── sync_routes.py # /sync ├── sync_routes.py # /sync
@@ -726,6 +682,10 @@ deploy/ # 部署配置
├── gunicorn.conf.py # Gunicorn WSGI 配置 ├── gunicorn.conf.py # Gunicorn WSGI 配置
└── wsgi.py # WSGI 入口 └── wsgi.py # WSGI 入口
reports/ # 分析报告
├── cache_performance_report.md # 缓存性能验证报告
└── redis_migration_plan.md # Redis 缓存迁移方案(规划中)
config/ # 运行时配置 config/ # 运行时配置
└── banned_words.txt # 敏感词库 └── banned_words.txt # 敏感词库
``` ```
@@ -734,8 +694,8 @@ config/ # 运行时配置
## 十、与传统 RAG 对比 ## 十、与传统 RAG 对比
| 特性 | 传统 RAG | Agentic RAG (当前) | | 特性 | 传统 RAG | 本系统 |
|------|---------|-------------------| |------|---------|--------|
| 意图判断 | 无 | IntentAnalyzer LLM 双层判断 | | 意图判断 | 无 | IntentAnalyzer LLM 双层判断 |
| 查询改写 | 无 | 口语化→专业术语 + 实体补全 + 指代消解 | | 查询改写 | 无 | 口语化→专业术语 + 实体补全 + 指代消解 |
| 检索方式 | 单一向量检索 | 向量 + BM25 + FAQ + 图片独立召回 | | 检索方式 | 单一向量检索 | 向量 + BM25 + FAQ + 图片独立召回 |
@@ -744,14 +704,11 @@ config/ # 运行时配置
| 重排序 | 无 | 云端 qwen3-rerank API支持本地 BGE 回退) | | 重排序 | 无 | 云端 qwen3-rerank API支持本地 BGE 回退) |
| 问题分解 | 无 | 自动拆分对比/推理类查询 | | 问题分解 | 无 | 自动拆分对比/推理类查询 |
| 闲聊处理 | 无 | 意图分析自动判断 | | 闲聊处理 | 无 | 意图分析自动判断 |
| 网络搜索 | 无 | 可选支持Serper API | | 缓存体系 | 无 | 四层缓存Query + Embedding + Rerank + 语义缓存 |
| 知识图谱 | 无 | ~~可选支持Neo4j~~已废弃graph/ 目录已清空 | | 语义缓存 | 无 | FAISS 向量索引相似查询复用92x 加速 |
| 幻觉验证 | 无 | 基于参考信息的答案验证 |
| 置信度门控 | 无 | Reranker 分数驱动,低分触发补救 |
| 缓存 | 无 | 三层缓存 + 语义缓存 |
| 自适应 TopK | 固定 top_k | 根据置信度动态调整 | | 自适应 TopK | 固定 top_k | 根据置信度动态调整 |
| 上下文理解 | 无 | 多轮对话 + 历史上下文 | | 上下文理解 | 无 | 多轮对话 + 历史上下文 |
| 响应时间 | ~2秒 | ~3-8秒(取决于 Rerank + LLM | | 响应时间 | ~2秒 | 首次 ~3-8秒 / 缓存命中 ~100-200毫秒 |
--- ---
@@ -759,71 +716,71 @@ config/ # 运行时配置
### 11.1 Rerank 调用路径 ### 11.1 Rerank 调用路径
Rerank 在系统中有 **两个独立调用路径** Rerank 在生产流程中有 **一个调用路径**
| 路径 | 位置 | 说明 | | 路径 | 位置 | 说明 |
|------|------|------| |------|------|------|
| 主检索管线 | `engine.rerank_results()` | RRF 融合后、MMR 去重前执行,对候选重排取 top_k | | 主检索管线 | `engine.rerank_results()` | RRF 融合后、MMR 去重前执行,对候选重排取 top_k |
| 置信度门控 | `confidence_gate._compute_scores()` | 直接调用 `reranker.predict()`,可能重复推理 |
### 11.2 性能瓶颈 ### 11.2 性能特征
| 瓶颈 | 严重程度 | 说明 | | 项目 | 说明 |
|------|---------|------| |------|------|
| Rerank 缓存命中率偏低 | 🟡 中 | `rerank_results()` 已正确调用缓存读写,但缓存 key 基于 `query + sorted(doc_ids)` 精确匹配RRF 融合产出稍有不同就无法命中 | | Rerank 缓存命中率 | 偏低——键基于 `query + sorted(doc_ids)` 精确匹配RRF 融合产出稍有不同就无法命中 |
| 置信度门控重复推理 | 🟡 中 | 同一 query+documents 可能被 Rerank 两次(当前仅备用路径使用,暂未影响生产) | | 性能计时 | `rerank_results()` 返回 `_rerank_time_ms` 计时字段 |
| ~~无性能计时~~ | ~~🟡 中~~ | 已修复:`rerank_results()` 现返回 `_rerank_time_ms` 计时字段 | | Query Cache 拦截 | 重复查询被 Query Cache 在外层拦截,不会到达 Rerank 层(正确行为) |
| 查询分类器策略未生效 | 🟢 低 | `QueryClassifier` 定义的差异化 rerank 参数未传递到引擎 |
### 11.3 Rerank 配置参数 ### 11.3 Rerank 配置参数
| 配置项 | 默认值 | 说明 | | 配置项 | 默认值 | 说明 |
|--------|--------|------| |--------|--------|------|
| `USE_RERANK` | `True` | 总开关 | | `USE_RERANK` | `True` | 总开关 |
| `RERANK_BACKEND` | `"local"` | 后端选择`local`=本地模型, `cloud`=云端API, `fallback`=优先云端失败回退本地 | | `RERANK_BACKEND` | `"local"` | 后端选择 |
| `RERANK_CLOUD_MODEL` | `"qwen3-rerank"` | 云端 Rerank 模型名称DashScope API | | `RERANK_CLOUD_MODEL` | `"qwen3-rerank"` | 云端模型名称 |
| `RERANK_CLOUD_API_KEY` | 同 `DASHSCOPE_API_KEY` | 云端 API 密钥 | | `RERANK_CLOUD_API_KEY` | 同 `DASHSCOPE_API_KEY` | 云端 API 密钥 |
| `RERANK_CLOUD_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-api/v1/reranks` | 云端 API 地址 | | `RERANK_CLOUD_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-api/v1/reranks` | 云端 API 地址 |
| `RERANK_CLOUD_TIMEOUT` | `15` | 云端请求超时(秒) | | `RERANK_CLOUD_TIMEOUT` | `15` | 云端请求超时(秒) |
| `RERANK_MODEL_PATH` | `models/bge-reranker-base` | 本地模型路径(仅 local/fallback 模式) | | `RERANK_MODEL_PATH` | `models/bge-reranker-base` | 本地模型路径 |
| `RERANK_CANDIDATES` | `20` | 送入 Rerank 的候选数 | | `RERANK_CANDIDATES` | `20` | 送入 Rerank 的候选数 |
| `RERANK_TOP_K` | `15` | Rerank 后保留数 | | `RERANK_TOP_K` | `15` | Rerank 后保留数 |
| `RERANK_USE_ONNX` | `True`(环境变量默认) | ONNX 加速开关(仅本地模式) | | `RERANK_USE_ONNX` | `True` | ONNX 加速开关 |
| `RERANK_DEVICE` | 跟随 `DEVICE` | 设备选择(仅本地模式) | | `RERANK_DEVICE` | 跟随 `DEVICE` | 设备选择 |
| `RERANK_THRESHOLD` | `0.3` | 上下文过滤阈值 | | `RERANK_THRESHOLD` | `0.3` | 上下文过滤阈值 |
| `RERANK_CACHE_ENABLED` | `True` | 缓存开关(已在 `rerank_results()` 中使用) | | `RERANK_CACHE_ENABLED` | `True` | 缓存开关 |
--- ---
## 十二、最佳实践 ## 十二、最佳实践
### 12.1 何时使用 Agentic RAG ### 12.1 何时使用 /rag 接口
**推荐使用** **推荐使用**
- 复杂问题需要多轮检索
- 复杂问题需要检索知识库
- 用户表达模糊需要改写 - 用户表达模糊需要改写
- 需要区分闲聊和知识问答 - 需要区分闲聊和知识问答
- 需要多轮对话记忆 - 需要多轮对话记忆
- 需要引用来源和幻觉验 - 需要引用来源和证
**不推荐使用** **不推荐使用**(改用 `/search` 接口更快)
- 简单明确的问题(用 `/search` 接口更快)
- 对响应时间极度敏感的场景 - 简单明确的问题,只需返回原始检索结果
- 对响应时间极度敏感且不需要 LLM 生成答案的场景
### 12.2 性能优化 ### 12.2 性能优化
```python ```python
# 减少迭代次数
rag = AgenticRAG(max_iterations=2)
# 禁用网络搜索
rag = AgenticRAG(enable_web_search=False)
# ONNX 加速默认已开启;如有兼容性问题可关闭(环境变量) # ONNX 加速默认已开启;如有兼容性问题可关闭(环境变量)
# RERANK_USE_ONNX=false # RERANK_USE_ONNX=false
# 使用轻量 MMR文本相似度代替语义向量 # 使用轻量 MMR文本相似度代替语义向量
# config.py: MMR_USE_EMBEDDING = False # config.py: MMR_USE_EMBEDDING = False
# 调整语义缓存阈值(降低阈值可提高命中率,但可能降低准确性)
# config.py: SEMANTIC_CACHE_THRESHOLD = 0.90
# 调整缓存容量
# config.py: QUERY_CACHE_SIZE = 1000 # 增大查询缓存容量
``` ```
### 12.3 调试技巧 ### 12.3 调试技巧
@@ -834,73 +791,62 @@ result = engine.search_knowledge("问题", top_k=10)
debug = result.get('_debug', {}) debug = result.get('_debug', {})
for step in debug.get('steps', []): for step in debug.get('steps', []):
print(f"步骤: {step['name']}, 详情: {step}") print(f"步骤: {step['name']}, 详情: {step}")
# 查看缓存统计
from core.cache import get_cache_manager
cm = get_cache_manager()
print(cm.get_stats())
# 查看语义缓存统计
from core.semantic_cache import get_semantic_cache
sc = get_semantic_cache()
print({"hits": sc.hits, "misses": sc.misses, "total": sc.total_entries})
``` ```
--- ---
## 附加篇Agentic RAG 深入优化与工作机制 ## 十三、演进记录
### 一、Agentic RAG 的核心架构 ### v4.02026-06-05— 统一编排 + 四层缓存修复
Agentic RAG 构建了动态的决策闭环,核心组件包括: **删除未使用的备用编排路径**:移除了 `core/agentic.py` 及 8 个 Mixin 文件(共 10 个文件 ~2050 行)。这些文件实现了完整的决策循环编排(含置信度门控、质量评估、推理反思等),但从未接入任何 HTTP 路由。
- **意图分析器**LLM 驱动的双层判断,替代硬编码规则 **修复 Query Cache**
- **查询重写器**:口语化→专业术语、实体补全、指代消解
- **混合检索引擎**:向量 + BM25 + FAQ + 图片独立召回 + RRF 融合
- **MMR 去重**平衡相关性与多样性Rerank 后进一步精炼结果
- **Rerank 重排**:云端 qwen3-rerank API 精确排序,支持本地 BGE 回退
- **置信度门控**Reranker 分数驱动,低分触发补救流程
- **幻觉验证**:基于参考信息验证答案,防止 LLM 编造
### 二、分阶段优化策略 1. 修复 GET/SET 键不匹配——此前 SET 使用 `doc_hash` 分支GET 使用 `kb_version` 分支,两端永远不匹配,命中率始终为 0%
2. 修复 `CACHE_MIN_SCORE = 0.3` 阈值过高——ChromaDB 余弦距离经 `1-dist` 后得分约 0.03-0.06,远低于 0.3,导致几乎不写入缓存
#### 1. 检索前:优化查询质量 **集成语义缓存**:将 FAISS 语义缓存从已删除的备用路径移植到生产 `/rag` 端点,在意图分析后、混合检索前检查,命中时跳过整个检索+生成流程。验证结果:命中率 66.7%92 倍加速。
- **智能查询重写**:口语化表述 → 精准检索术语 ### v3.2 — 模型/Reranker/管线更新
- **复杂问题分解**:对比/推理类查询自动拆分为子查询
- **意图分析**LLM 双层判断,避免不必要的检索
#### 2. 检索中:提升召回精准度 引入云端 qwen3-rerank、ONNX 加速、动态 RRF 权重等。
- **多路召回与融合**:向量 + BM25 + FAQ + 图片独立召回 ---
- **动态 RRF 权重**:查询类型/长度驱动的权重调整
- **MMR 去重**Rerank 后进一步精炼平衡相关性与多样性召回100 → Rerank取15 → MMR精炼
- **Rerank 重排**:云端 qwen3-rerank 精排,置信度门控过滤低质量结果
#### 3. 检索后:质量评估与自我迭代 ## 十四、未来规划
- **多维质量评估**:相关性/完整性/准确性/覆盖面 ### Redis 缓存外部化
- **推理反思**:检查推理过程中未验证的假设
- **分层补救**:低置信度 → 查询重写 → 网络搜索
### 三、系统级优化 当前四层缓存均为进程内内存存储,在多 Worker / 多实例部署时无法共享。已规划 Redis 迁移方案(详见 `reports/redis_migration_plan.md`),核心设计:
#### 1. 避免"循环检索"陷阱 - `RedisCacheManager` 提供与 `RAGCacheManager` 相同的接口
- 通过 `REDIS_CACHE_URL` 环境变量启用,向后兼容
- 语义缓存采用混合方案FAISS 索引保持在进程内,缓存结果存储到 Redis
- Query Cache、Embedding Cache、Rerank Cache 全部迁移到 Redis
- 循环防护器(`loop_guard.py`):最多允许 N 次重写检索 ### 可选能力接入
- 置信度递增检查:连续两次无提升则终止
#### 2. 平衡智能性与效率 `core/` 目录下仍保留以下独立模块,当前未接入生产流程,可按需启用:
- 轻量级决策模型:意图分析使用低温度、少 token 的 LLM 调用 - `confidence_gate.py`:置信度门控,基于 Reranker 分数判断检索质量
- 三层缓存Query Cache + Embedding Cache + Rerank Cache - `quality_assessor.py`:多维质量评估(相关性/完整性/准确性/覆盖面)
- 语义缓存相似查询复用结果threshold=0.92 - `reasoning_reflector.py`:推理反思,检查未验证的假设
- LLM 预算控制:`MAX_LLM_CALLS_PER_QUERY = 2` - `loop_guard.py`:循环防护,防止重复检索
#### 3. 安全与可解释性 ---
- 证据溯源:引用标注 + 来源编号 ## 参考资料
- 思维链展示:`log_trace` 记录推理过程
- 安全护栏:输入验证 + 输出过滤 + 权限控制
### 四、学术前沿
1. **RAG-Gym**:三维度系统优化(提示工程 + 执行器调优 + 评判器训练)
2. **过程监督 vs 结果监督**:细粒度过程奖励显著提升训练效率
3. **Re2Search**推理反思机制F1 score 提升 10%+
### 参考资料
1. Xiong, G., et al. (2025). RAG-Gym: Systematic Optimization of Language Agents for Retrieval-Augmented Generation. arXiv:2502.13957 1. Xiong, G., et al. (2025). RAG-Gym: Systematic Optimization of Language Agents for Retrieval-Augmented Generation. arXiv:2502.13957
2. Zhang, W., et al. (2025). Process vs. Outcome Reward: Which is Better for Agentic RAG Reinforcement Learning. arXiv:2505.14069 2. Zhang, W., et al. (2025). Process vs. Outcome Reward: Which is Better for Agentic RAG Reinforcement Learning. arXiv:2505.14069
3. Agentic RAG 实战指南:从查询重写到多步重查全掌握。火山引擎 ADG 社区

View 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` 等敏感文件。

View File

@@ -444,7 +444,7 @@ Query Rewriting: "它" → "出差补助"
- [后端对接规范.md](./后端对接规范.md) - API 接口规范(主要) - [后端对接规范.md](./后端对接规范.md) - API 接口规范(主要)
- [数据库设计文档.md](./数据库设计文档.md) - 数据库结构 - [数据库设计文档.md](./数据库设计文档.md) - 数据库结构
- [Agentic_RAG完整指南.md](./Agentic_RAG完整指南.md) - Agentic RAG 详解 - [RAG系统完整指南.md](./RAG系统完整指南.md) - RAG 系统架构与缓存详解
--- ---

View File

@@ -16,7 +16,6 @@
import json import json
import logging import logging
import re
import threading import threading
import time import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
@@ -24,7 +23,7 @@ from functools import wraps
from typing import List, Dict, Any, Optional from typing import List, Dict, Any, Optional
# 导入 LLM 工具函数 # 导入 LLM 工具函数
from core.llm_utils import call_llm from core.llm_utils import call_llm, extract_json_object
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -378,47 +377,12 @@ class AnswerGrader:
return result return result
def _extract_json(self, response: str) -> dict: def _extract_json(self, response: str) -> dict:
""" """多策略从 LLM 响应中提取 JSON 对象(使用共享工具)"""
多策略从 LLM 响应中提取 JSON 对象 result = extract_json_object(response)
if result is not None:
策略优先级: return result
1. markdown 代码块提取 ```json ... ```
2. 直接 json.loads
3. 正则匹配第一个 {...} 块
"""
if not response: if not response:
raise ValueError("LLM 返回为空") raise ValueError("LLM 返回为空")
# 策略1提取 markdown 代码块
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', response)
if json_match:
json_str = json_match.group(1).strip()
try:
result = json.loads(json_str)
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass # 继续下一策略
# 策略2直接解析整个响应
try:
result = json.loads(response.strip())
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass # 继续下一策略
# 策略3正则匹配最外层 JSON 对象
brace_match = re.search(r'\{[\s\S]*\}', response)
if brace_match:
try:
result = json.loads(brace_match.group(0))
if isinstance(result, dict):
return result
except json.JSONDecodeError:
pass
# 全部策略失败
raise json.JSONDecodeError( raise json.JSONDecodeError(
f"无法从 LLM 响应中提取有效 JSON响应前300字: {response[:300]}", f"无法从 LLM 响应中提取有效 JSON响应前300字: {response[:300]}",
response, 0 response, 0

View File

@@ -393,6 +393,11 @@ class KnowledgeBaseManager(
if len(chunks) < 2: if len(chunks) < 2:
return chunks return chunks
# 检测页码是否可靠:若所有 chunk 的 page_start 相同(如 Word 文档 page_idx 全为 0
# 则页码信息不可用,需要启用降级合并规则
page_values = set(getattr(c, 'page_start', 0) for c in chunks)
pages_unavailable = len(page_values) <= 1
merged_chunks = [] merged_chunks = []
i = 0 i = 0
merge_count = 0 merge_count = 0
@@ -405,6 +410,7 @@ class KnowledgeBaseManager(
# 查找下一个表格(跳过中间的"续表"文本) # 查找下一个表格(跳过中间的"续表"文本)
next_table_idx = None next_table_idx = None
next_chunk = None next_chunk = None
intermediate_texts = [] # 收集中间文本用于降级判断
for j in range(i + 1, min(i + 4, len(chunks))): # 最多向前看3个切片 for j in range(i + 1, min(i + 4, len(chunks))): # 最多向前看3个切片
candidate = chunks[j] candidate = chunks[j]
@@ -419,7 +425,11 @@ class KnowledgeBaseManager(
elif candidate_type == 'text' and ('续表' in candidate_title or '续表' in candidate_content): elif candidate_type == 'text' and ('续表' in candidate_title or '续表' in candidate_content):
# 遇到"续表"文本,继续查找下一个表格 # 遇到"续表"文本,继续查找下一个表格
continue continue
elif candidate_type not in ('text',): elif candidate_type == 'text':
# 非"续表"文本,收集后停止查找
intermediate_texts.append(candidate)
break
else:
# 遇到非文本类型,停止查找 # 遇到非文本类型,停止查找
break break
@@ -439,12 +449,22 @@ class KnowledgeBaseManager(
# 获取内容(用于检测"续表" # 获取内容(用于检测"续表"
next_content = getattr(next_chunk, 'content', '') next_content = getattr(next_chunk, 'content', '')
# 通用/无意义标题集合,这些标题不能用于"标题相似"判定
_GENERIC_TITLES = {'表格', 'table', '表格', ''}
# 判断是否为跨页表格 # 判断是否为跨页表格
is_cross_page = False is_cross_page = False
# 规则1: 页码连续(如果页码有效) # 规则1: 页码连续(如果页码有效)
page_valid = curr_page_end > 0 and next_page_start > 0 page_valid = curr_page_end > 0 and next_page_start > 0
if page_valid and curr_page_end + 1 == next_page_start: if page_valid and curr_page_end + 1 == next_page_start:
# 页码连续时,还需标题匹配或为通用标题才合并
# 避免把不同页面上不相关的表格错误合并
if curr_title == next_title or curr_title in _GENERIC_TITLES and next_title in _GENERIC_TITLES:
is_cross_page = True
elif curr_title and next_title:
clean_next_r1 = next_title.replace('续表', '').strip()
if curr_title in clean_next_r1 or clean_next_r1 in curr_title:
is_cross_page = True is_cross_page = True
# 规则2: 第二个表格标题或内容包含"续表" # 规则2: 第二个表格标题或内容包含"续表"
@@ -452,11 +472,36 @@ class KnowledgeBaseManager(
is_cross_page = True is_cross_page = True
# 规则3: 标题相似(去掉"续表"后比较) # 规则3: 标题相似(去掉"续表"后比较)
elif curr_title and next_title: # 排除通用标题(如"表格"),防止把所有标题为"表格"的相邻表格都误合并
elif (curr_title and next_title
and curr_title not in _GENERIC_TITLES
and next_title not in _GENERIC_TITLES):
clean_next = next_title.replace('续表', '').strip() clean_next = next_title.replace('续表', '').strip()
if curr_title in clean_next or clean_next in curr_title: if clean_next and (curr_title in clean_next or clean_next in curr_title):
is_cross_page = True is_cross_page = True
# 规则4降级: 页码不可用(如 Word 文档 page_idx 全为 0
# 仅当页码信息缺失时才启用此规则,避免 PDF 正常页码时被误合并
if (not is_cross_page
and pages_unavailable
and curr_title in _GENERIC_TITLES
and next_title in _GENERIC_TITLES):
# 检查中间文本是否暗示跨页延续(空、短文本、续表标记等)
has_separating_content = False
for text_chunk in intermediate_texts:
tc = (getattr(text_chunk, 'content', '') or '').strip()
tt = (getattr(text_chunk, 'title', '') or '').strip()
if not tc:
continue # 空文本不算分隔
if '续表' in tc or '续表' in tt:
continue # 续表标记,说明是跨页
# 有实质性中间内容(如分类标题"A3类xxx"),不合并
has_separating_content = True
break
if not has_separating_content:
is_cross_page = True
logger.debug(f"降级合并(页码不可用): '{curr_title}' + '{next_title}'")
if is_cross_page: if is_cross_page:
# 执行合并 # 执行合并
merge_count += 1 merge_count += 1
@@ -469,14 +514,27 @@ class KnowledgeBaseManager(
# 合并两个表格的 HTML # 合并两个表格的 HTML
current.table_html = curr_html + '\n' + next_html current.table_html = curr_html + '\n' + next_html
# 合并 image_path 到 images # 合并 image_path 和嵌入图片到 images
curr_img = getattr(current, 'image_path', None) curr_img = getattr(current, 'image_path', None)
next_img = getattr(next_chunk, 'image_path', None) next_img = getattr(next_chunk, 'image_path', None)
merged_images = [] curr_images = getattr(current, 'images', None) or []
if curr_img: next_images = getattr(next_chunk, 'images', None) or []
# 合并两个表格的所有图片image_path + 嵌入图片)
merged_images = list(curr_images) # 保留当前表格的嵌入图片
# 添加 image_path 图片(如果不在列表中)
existing_ids = {img.get('id', '') for img in merged_images if isinstance(img, dict)}
if curr_img and curr_img not in existing_ids:
merged_images.append({'id': curr_img, 'page': curr_page_end}) merged_images.append({'id': curr_img, 'page': curr_page_end})
if next_img: existing_ids.add(curr_img)
for img in next_images: # 添加下一个表格的嵌入图片
img_id = img.get('id', '') if isinstance(img, dict) else ''
if img_id and img_id not in existing_ids:
merged_images.append(img)
existing_ids.add(img_id)
if next_img and next_img not in existing_ids:
merged_images.append({'id': next_img, 'page': next_page_start}) merged_images.append({'id': next_img, 'page': next_page_start})
if merged_images: if merged_images:
current.images = merged_images current.images = merged_images
# 保留第一个图片作为主 image_path # 保留第一个图片作为主 image_path
@@ -525,7 +583,7 @@ class KnowledgeBaseManager(
try: try:
from config import get_llm_client, DASHSCOPE_MODEL from config import get_llm_client, DASHSCOPE_MODEL
client = get_llm_client() client = get_llm_client()
summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=100) summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=512)
return summary.strip() if summary else "" return summary.strip() if summary else ""
except Exception as e: except Exception as e:
logger.warning(f"生成表格摘要失败: {e}") logger.warning(f"生成表格摘要失败: {e}")
@@ -584,7 +642,7 @@ class KnowledgeBaseManager(
] ]
} }
], ],
max_tokens=200 max_tokens=512
) )
description = response.choices[0].message.content description = response.choices[0].message.content

View File

@@ -283,7 +283,7 @@ class KnowledgeBaseRouter:
content = call_llm( content = call_llm(
self.llm_client, prompt, MODEL, self.llm_client, prompt, MODEL,
temperature=0.1, temperature=0.1,
max_tokens=100 max_tokens=512
) )
if content is None: if content is None:

View File

@@ -28,7 +28,7 @@ def main():
parser = argparse.ArgumentParser(description='RAG API 服务') parser = argparse.ArgumentParser(description='RAG API 服务')
parser.add_argument('--host', default='0.0.0.0', help='监听地址(默认 0.0.0.0') parser.add_argument('--host', default='0.0.0.0', help='监听地址(默认 0.0.0.0')
parser.add_argument('--port', type=int, default=5001, help='监听端口(默认 5001') parser.add_argument('--port', type=int, default=5001, help='监听端口(默认 5001')
parser.add_argument('--debug', action='store_true', default=True, help='调试模式') parser.add_argument('--debug', action='store_true', default=False, help='调试模式')
parser.add_argument('--no-debug', action='store_true', help='关闭调试模式') parser.add_argument('--no-debug', action='store_true', help='关闭调试模式')
args = parser.parse_args() args = parser.parse_args()

View File

@@ -24,6 +24,9 @@ logger = logging.getLogger(__name__)
# 大表切片阈值 # 大表切片阈值
MAX_ROWS_PER_CHUNK = 200 MAX_ROWS_PER_CHUNK = 200
# 文件大小限制
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50MB
@dataclass @dataclass
class UnifiedChunk: class UnifiedChunk:
@@ -67,6 +70,13 @@ def parse_excel(
} }
""" """
filepath = Path(filepath) filepath = Path(filepath)
# 检查文件大小
if filepath.exists():
file_size = filepath.stat().st_size
if file_size > MAX_FILE_SIZE:
raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB最大允许 {MAX_FILE_SIZE / 1024 / 1024:.0f}MB")
if not filepath.exists(): if not filepath.exists():
raise FileNotFoundError(f"文件不存在: {filepath}") raise FileNotFoundError(f"文件不存在: {filepath}")

347
parsers/heading_rules.py Normal file
View File

@@ -0,0 +1,347 @@
# -*- coding: utf-8 -*-
"""
标题识别规则引擎
将 _detect_heading_level 的硬编码正则提取为可配置的规则列表。
规则按优先级从高到低排序,第一个匹配即返回。
MinerU 解析 DOCX 等 Office 格式时通常不提供 text_level全部为 0
此时需要启发式识别标题层级。本模块提供可配置的规则引擎替代原来的
硬编码 if-elif 链。
设计要点:
- HeadingRule 数据类支持正向匹配pattern和反向排除exclude_pattern
- 长度约束min_length / max_length可精确控制匹配范围
- 规则可单独禁用enabled=False便于调试
- 全局单例通过 config.py 覆盖默认值
MinerU v2 格式备注:
content_list_v2.json 中的 paragraph_content 包含 style=["bold"] 信息,
layout.json 中的 spans 也有 style 信息。这些信息比正则匹配 **加粗** 更可靠,
但当前代码使用 v1 格式content_list.json暂不利用 v2 的 style。
HeadingRuleEngine.detect() 签名预留了 style 参数,未来切换到 v2 格式后
可直接利用 style 信息辅助判断。
"""
import re
import logging
from dataclasses import dataclass
from typing import Optional, List, Tuple
logger = logging.getLogger(__name__)
@dataclass
class HeadingRule:
"""
标题识别规则
每条规则定义一个文本模式到标题级别的映射。
规则引擎按列表顺序逐条匹配,第一个命中即返回。
Attributes:
pattern: 编译后的正则match 语义,从文本开头匹配)
level: 匹配时返回的标题级别 (1=h1, 2=h2, 3=h3)
name: 规则名称(用于日志和配置覆盖)
max_length: 文本最大长度0=不限
min_length: 文本最小长度0=不限
enabled: 是否启用
exclude_pattern: 匹配此模式则排除(反向过滤)
Example:
>>> rule = HeadingRule(
... pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'),
... level=1,
... name="chinese_chapter",
... )
>>> rule.match("第一章 总则")
1
>>> rule.match("这是正文")
0
"""
pattern: re.Pattern
level: int
name: str
max_length: int = 0
min_length: int = 0
enabled: bool = True
exclude_pattern: Optional[re.Pattern] = None
def match(self, text: str) -> int:
"""
检查文本是否匹配此规则
Args:
text: 待检测文本(调用前应已 strip
Returns:
标题级别0 表示不匹配
"""
if not self.enabled:
return 0
if self.min_length > 0 and len(text) < self.min_length:
return 0
if self.max_length > 0 and len(text) > self.max_length:
return 0
if self.exclude_pattern and self.exclude_pattern.search(text):
return 0
if self.pattern.match(text):
return self.level
return 0
# 默认规则列表(按优先级从高到低)
#
# 注意事项:
# - 数字三级标题 (1.1.1) 必须在二级 (1.1) 之前,因为 1.1.1 也匹配 ^\d+\.\d+
# - short_chinese_heading 是最宽泛的规则,放在最后作为兜底
# - 第 9 条规则相比原版增加了 exclude_pattern排除以句末标点结尾的短文本
DEFAULT_HEADING_RULES: List[HeadingRule] = [
# 1. 中文章节标题 -> h1
# 匹配:第一章、第二章、第十节、第三篇 等
HeadingRule(
pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'),
level=1,
name="chinese_chapter",
),
# 2. 中文条款编号 -> h2
# 匹配:第一条、第三款 等
HeadingRule(
pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[条款]'),
level=2,
name="chinese_article",
),
# 3. 数字三级标题 -> h3必须在二级之前匹配
# 匹配1.1.1 背景、2.3.4 方案 等
HeadingRule(
pattern=re.compile(r'^\d+\.\d+\.\d+[\.、\s]'),
level=3,
name="numeric_level3",
max_length=100,
),
# 4. 数字二级标题 -> h2必须在一级之前匹配
# 匹配1.1 背景、2.3 方案 等
HeadingRule(
pattern=re.compile(r'^\d+\.\d+[\.、\s]'),
level=2,
name="numeric_level2",
max_length=80,
),
# 5. 数字一级标题 -> h1
# 匹配1. 概述、2、背景 等
HeadingRule(
pattern=re.compile(r'^\d+[\.、\s]'),
level=1,
name="numeric_level1",
max_length=50,
),
# 6. 英文章节标题 -> h1
# 匹配Chapter 1、Section 2、Part 3 等
HeadingRule(
pattern=re.compile(r'^(Chapter|Section|Part|Chapter\s+\d+|Section\s+\d+)', re.IGNORECASE),
level=1,
name="english_chapter",
),
# 7. 分类标题 -> h3必须在 bold_short_text 之前,否则 **A2类** 会被加粗规则抢先匹配)
# 匹配A1类公园、**A2类**:各类卫生医疗机构、**B1类** 道路 等
HeadingRule(
pattern=re.compile(r'^\*{0,2}[A-Z]\d+[类類]\*{0,2}[:]'),
level=3,
name="category_heading",
),
# 8. 加粗短文本 -> h2
# 匹配:**重要通知**、**概述** 等Markdown 加粗标记)
# 注意:**A2类** 已被分类标题规则优先匹配,不会误判为 h2
HeadingRule(
pattern=re.compile(r'^\*\*.+\*\*$'),
level=2,
name="bold_short_text",
max_length=50,
),
# 9. 短中文文本 -> h2替代原"任何 <20 字符含中文"规则)
# 关键改进:排除以句末标点结尾的文本
# 原规则将 "这是一段正文。" 也识别为 h2导致大量误判
# 新规则:包含中文 + 长度 2-20 + 不以句末标点结尾 → h2
HeadingRule(
pattern=re.compile(r'[一-鿿]'),
level=2,
name="short_chinese_heading",
max_length=20,
min_length=2,
exclude_pattern=re.compile(r'[。!?;…]$'),
enabled=True,
),
]
class HeadingRuleEngine:
"""
标题识别规则引擎
按规则列表顺序逐条匹配,第一个命中即返回标题级别。
支持从 config.py 加载自定义规则或覆盖默认规则参数。
Example:
>>> engine = HeadingRuleEngine()
>>> engine.detect("第一章 总则")
(1, 'chinese_chapter')
>>> engine.detect("这是普通正文。")
(0, None)
"""
def __init__(self, rules: Optional[List[HeadingRule]] = None) -> None:
"""
Args:
rules: 规则列表None 则使用默认规则的深拷贝
"""
if rules is not None:
self.rules: List[HeadingRule] = rules
else:
import copy
self.rules = copy.deepcopy(DEFAULT_HEADING_RULES)
def detect(self, text: str, style: Optional[List[str]] = None) -> Tuple[int, Optional[str]]:
"""
检测文本的标题级别
Args:
text: 待检测文本
style: MinerU v2 格式中的 style 信息(如 ["bold"]
当文本标记为 bold 且较短时,可直接判定为标题,
无需依赖 Markdown **...** 标记。
Returns:
(level, rule_name): 标题级别和匹配的规则名
level=0 表示不是标题
"""
text = text.strip()
if not text:
return 0, None
# v2 style 信息:如果文本标记为 bold 且较短,优先尝试加粗规则
if style and 'bold' in style and 2 <= len(text) <= 50:
# 先检查是否匹配更高优先级的分类标题规则
for rule in self.rules:
if rule.name == 'category_heading' and rule.enabled:
level = rule.match(text)
if level > 0:
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
return level, rule.name
# 再检查是否匹配中文章节/条款等高优先级规则
for rule in self.rules:
if rule.name in ('chinese_chapter', 'chinese_article', 'numeric_level3',
'numeric_level2', 'numeric_level1', 'english_chapter') and rule.enabled:
level = rule.match(text)
if level > 0:
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
return level, rule.name
# 否则作为加粗短文本 → h2与 bold_short_text 规则对齐,但不依赖 **...** 标记)
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h2 (规则: bold_short_text_via_style)")
return 2, 'bold_short_text'
# 常规规则匹配v1 格式或无 style 信息时)
for rule in self.rules:
level = rule.match(text)
if level > 0:
logger.debug(f"标题识别: '{text[:30]}' -> h{level} (规则: {rule.name})")
return level, rule.name
return 0, None
# ==================== 全局单例 ====================
_engine: Optional[HeadingRuleEngine] = None
def get_heading_engine() -> HeadingRuleEngine:
"""获取全局标题识别引擎(延迟初始化,线程安全)"""
global _engine
if _engine is None:
_engine = _create_engine_from_config()
return _engine
def _create_engine_from_config() -> HeadingRuleEngine:
"""
从 config 创建引擎(支持配置覆盖)
优先级:
1. config.HEADING_RULES_CONFIG 不为 None → 使用自定义规则
2. config 细粒度参数覆盖默认规则(如 HEADING_SHORT_TEXT_ENABLED
3. 使用默认规则
"""
# 尝试加载完整自定义规则
try:
from config import HEADING_RULES_CONFIG
if HEADING_RULES_CONFIG is not None:
rules = _build_rules_from_config(HEADING_RULES_CONFIG)
logger.info(f"使用自定义标题规则: {len(rules)}")
return HeadingRuleEngine(rules)
except ImportError:
pass
# 使用默认规则,应用细粒度配置覆盖
rules = list(DEFAULT_HEADING_RULES)
try:
from config import HEADING_SHORT_TEXT_ENABLED
for rule in rules:
if rule.name == "short_chinese_heading":
rule.enabled = HEADING_SHORT_TEXT_ENABLED
logger.debug(f"配置覆盖: short_chinese_heading.enabled={HEADING_SHORT_TEXT_ENABLED}")
except ImportError:
pass
try:
from config import HEADING_SHORT_TEXT_MAX_LENGTH
for rule in rules:
if rule.name == "short_chinese_heading":
rule.max_length = HEADING_SHORT_TEXT_MAX_LENGTH
logger.debug(f"配置覆盖: short_chinese_heading.max_length={HEADING_SHORT_TEXT_MAX_LENGTH}")
except ImportError:
pass
return HeadingRuleEngine(rules)
def _build_rules_from_config(config: list) -> List[HeadingRule]:
"""
从配置字典列表构建规则列表
Args:
config: 规则配置列表,每项为 dict包含
- pattern (str): 正则表达式字符串
- level (int): 标题级别
- name (str): 规则名称
- max_length (int, 可选): 文本最大长度
- min_length (int, 可选): 文本最小长度
- enabled (bool, 可选): 是否启用
- exclude_pattern (str, 可选): 排除正则
Returns:
规则列表
"""
rules = []
for item in config:
exclude = None
if 'exclude_pattern' in item:
exclude = re.compile(item['exclude_pattern'])
rules.append(HeadingRule(
pattern=re.compile(item['pattern']),
level=item['level'],
name=item['name'],
max_length=item.get('max_length', 0),
min_length=item.get('min_length', 0),
enabled=item.get('enabled', True),
exclude_pattern=exclude,
))
return rules
def reset_heading_engine() -> None:
"""重置引擎(用于测试)"""
global _engine
_engine = None

View File

@@ -46,6 +46,9 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 文件大小限制
MAX_PDF_SIZE = 100 * 1024 * 1024 # 100MB
# 支持的文件格式 # 支持的文件格式
SUPPORTED_FORMATS = { SUPPORTED_FORMATS = {
'.pdf': 'PDF 文档', '.pdf': 'PDF 文档',
@@ -176,6 +179,11 @@ def parse_with_mineru_online(
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}") raise FileNotFoundError(f"文件不存在: {file_path}")
# 检查文件大小
file_size = file_path.stat().st_size
if file_size > MAX_PDF_SIZE:
raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB最大允许 {MAX_PDF_SIZE / 1024 / 1024:.0f}MB")
logger.info(f"使用 MinerU 在线 API 解析: {file_path.name}") logger.info(f"使用 MinerU 在线 API 解析: {file_path.name}")
# 读取文件内容 # 读取文件内容
@@ -278,14 +286,138 @@ def parse_with_mineru_online(
raise RuntimeError(f"MinerU 在线 API 调用失败: {e}") raise RuntimeError(f"MinerU 在线 API 调用失败: {e}")
def _parse_v2_content_list(v2_data: list) -> list:
"""
将 MinerU v2 嵌套格式转换为 v1 兼容的扁平 content_list
v2 格式: [[page0_items], [page1_items], ...]
v1 格式: [item, item, ...] 扁平列表
转换规则:
- paragraph → text拼接 paragraph_content提取 style 信息)
- table → table保留 table_body/html
- title → text提取 level 信息)
- page_header / page_footer / page_number → 过滤掉
Args:
v2_data: v2 格式的嵌套列表
Returns:
v1 兼容的扁平列表,额外包含 _v2_styles / _v2_table_type 字段
"""
flat_list: List[Dict] = []
for page_idx, page_items in enumerate(v2_data):
if not isinstance(page_items, list):
continue
for item in page_items:
if not isinstance(item, dict):
continue
v2_type: str = item.get('type', '')
content = item.get('content', {})
# 过滤噪音类型(页眉、页脚、页码)
if v2_type in ('page_header', 'page_footer', 'page_number'):
continue
if v2_type == 'paragraph':
# 拼接段落文本,收集样式信息
text_parts: List[str] = []
has_bold = False
para_content = content.get('paragraph_content', []) if isinstance(content, dict) else []
for part in para_content:
if not isinstance(part, dict):
continue
part_text = part.get('content', '')
text_parts.append(part_text)
if 'bold' in part.get('style', []):
has_bold = True
full_text = ''.join(text_parts).strip()
if not full_text:
continue
flat_item: Dict[str, Any] = {
'type': 'text',
'text': full_text,
'content': full_text,
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'text_level': 0,
'_v2_styles': ['bold'] if has_bold else [],
}
flat_list.append(flat_item)
elif v2_type == 'table':
flat_item = {
'type': 'table',
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'html': content.get('html', '') if isinstance(content, dict) else '',
'table_body': content.get('html', '') if isinstance(content, dict) else '',
'caption': content.get('caption', '') if isinstance(content, dict) else '',
'_v2_table_type': content.get('table_type', '') if isinstance(content, dict) else '',
}
flat_list.append(flat_item)
elif v2_type == 'title':
# v2 title 项有 level 信息
level = content.get('level', 0) if isinstance(content, dict) else 0
text_parts = []
para_content = content.get('paragraph_content', []) if isinstance(content, dict) else []
for part in para_content:
if isinstance(part, dict):
text_parts.append(part.get('content', ''))
full_text = ''.join(text_parts).strip()
if full_text:
flat_item = {
'type': 'text',
'text': full_text,
'content': full_text,
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'text_level': level,
'_v2_styles': ['bold'],
}
flat_list.append(flat_item)
elif v2_type in ('image', 'chart'):
flat_item = {
'type': v2_type,
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'img_path': content.get('img_path', '') if isinstance(content, dict) else '',
'image_path': content.get('img_path', '') if isinstance(content, dict) else '',
'caption': content.get('caption', '') if isinstance(content, dict) else '',
}
flat_list.append(flat_item)
elif v2_type == 'equation':
flat_item = {
'type': 'equation',
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'content': content.get('latex', '') if isinstance(content, dict) else '',
'text': content.get('latex', '') if isinstance(content, dict) else '',
'latex': content.get('latex', '') if isinstance(content, dict) else '',
'img_path': content.get('img_path', '') if isinstance(content, dict) else '',
}
flat_list.append(flat_item)
logger.info(f"v2 格式转换: {len(v2_data)} 页 → {len(flat_list)} 项(已过滤噪音类型)")
return flat_list
def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, Any]: def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, Any]:
""" """
解析 MinerU 在线 API 返回的 zip 包 解析 MinerU 在线 API 返回的 zip 包
zip 包结构 zip 包结构:
- full.md - Markdown 解析结果 - full.md - Markdown 解析结果
- *_content_list.json - 内容列表(扁平格式,优先使用 - *_content_list.json - 内容列表(v1 扁平格式)
- *_content_list_v2.json - 内容列表(嵌套格式) - *_content_list_v2.json - 内容列表(v2 嵌套格式,含 style 信息
- images/ - 图片目录 - images/ - 图片目录
Args: Args:
@@ -298,23 +430,44 @@ def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, A
import zipfile import zipfile
import io import io
# 读取格式偏好配置
try:
from config import MINERU_PREFER_V2
except ImportError:
MINERU_PREFER_V2 = True # 默认优先 v2
with zipfile.ZipFile(io.BytesIO(zip_content), 'r') as zf: with zipfile.ZipFile(io.BytesIO(zip_content), 'r') as zf:
# 列出所有文件 # 列出所有文件
file_list = zf.namelist() file_list = zf.namelist()
logger.debug(f"zip 包内容: {file_list}") logger.debug(f"zip 包内容: {file_list}")
# 查找 content_list.json优先使用扁平格式 # 查找 content_list 文件v2 含 style 信息,优先使用
content_list_path = None content_list_path = None
is_v2 = False
if MINERU_PREFER_V2:
# 优先使用 v2 格式(含 style 信息)
for f in file_list:
if f.endswith('_content_list_v2.json'):
content_list_path = f
is_v2 = True
break
if not content_list_path:
for f in file_list:
if f.endswith('_content_list.json') and not f.endswith('_v2.json'):
content_list_path = f
break
else:
# 优先使用 v1 格式
for f in file_list: for f in file_list:
# 优先使用 content_list.json扁平格式
if f.endswith('_content_list.json') and not f.endswith('_v2.json'): if f.endswith('_content_list.json') and not f.endswith('_v2.json'):
content_list_path = f content_list_path = f
break break
# 如果没有找到,再尝试 v2 格式
if not content_list_path: if not content_list_path:
for f in file_list: for f in file_list:
if f.endswith('_content_list_v2.json'): if f.endswith('_content_list_v2.json'):
content_list_path = f content_list_path = f
is_v2 = True
break break
# 查找 markdown 文件 # 查找 markdown 文件
@@ -329,7 +482,13 @@ def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, A
if content_list_path: if content_list_path:
with zf.open(content_list_path) as f: with zf.open(content_list_path) as f:
content_list = json.load(f) content_list = json.load(f)
logger.info(f"读取 content_list: {len(content_list)} 项, 来源: {content_list_path}")
# v2 格式需要转换为 v1 兼容的扁平列表
if is_v2 and isinstance(content_list, list) and content_list and isinstance(content_list[0], list):
content_list = _parse_v2_content_list(content_list)
logger.info(f"v2 格式已转换为扁平列表,共 {len(content_list)}")
logger.info(f"读取 content_list: {len(content_list)} 项, 格式={'v2' if is_v2 else 'v1'}, 来源: {content_list_path}")
# 读取 markdown # 读取 markdown
markdown_content = "" markdown_content = ""
@@ -416,10 +575,14 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
if item_type == "text": if item_type == "text":
text = item.get("content", "") or item.get("text", "") text = item.get("content", "") or item.get("text", "")
v2_styles = item.get("_v2_styles", [])
# 启发式标题识别 # 启发式标题识别
if text_level == 0: if text_level == 0:
text_level = _detect_heading_level(text) from parsers.heading_rules import get_heading_engine
engine = get_heading_engine()
detected_level, _ = engine.detect(text, style=v2_styles)
text_level = detected_level
title = "" title = ""
if text_level > 0: if text_level > 0:
@@ -463,7 +626,8 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
else: else:
md_table = "" md_table = ""
table_images = extract_images_from_markdown(md_table) if md_table else [] # 从原始 HTML 提取嵌入图片md_table 经 get_text 转换后已丢失 <img> 标签)
table_images = extract_images_from_markdown(table_body) if table_body else []
chunk = MinerUChunk( chunk = MinerUChunk(
content=table_caption or "表格", content=table_caption or "表格",
@@ -550,8 +714,14 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
min_merge = 100 min_merge = 100
max_size = 1200 max_size = 1200
# 表单类型二次校正(在 _post_process_chunks 之前,因为 table 不参与合并)
_reclassify_text_chunks(chunks)
chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size) chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size)
# 验证分类标题是否被规则引擎正确识别(安全网,仅告警不修改)
_validate_category_section_paths(chunks)
return { return {
'markdown': "\n".join(markdown_parts) if markdown_parts else markdown_content, 'markdown': "\n".join(markdown_parts) if markdown_parts else markdown_content,
'chunks': chunks, 'chunks': chunks,
@@ -600,6 +770,11 @@ def parse_with_mineru(
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}") raise FileNotFoundError(f"文件不存在: {file_path}")
# 检查文件大小
file_size = file_path.stat().st_size
if file_size > MAX_PDF_SIZE:
raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB最大允许 {MAX_PDF_SIZE / 1024 / 1024:.0f}MB")
# 检查文件格式 # 检查文件格式
suffix = file_path.suffix.lower() suffix = file_path.suffix.lower()
if suffix not in SUPPORTED_FORMATS: if suffix not in SUPPORTED_FORMATS:
@@ -629,8 +804,17 @@ def parse_with_mineru(
if not mineru_exe.exists(): if not mineru_exe.exists():
mineru_exe = "mineru" # 回退到系统 PATH mineru_exe = "mineru" # 回退到系统 PATH
# 参数白名单校验,防止注入非法参数
ALLOWED_BACKENDS = {'auto', 'pipeline', 'vlm', 'vlm-sglang', 'vlm-auto-engine', 'hybrid-auto-engine', 'ocr'}
ALLOWED_LANGS = {'ch', 'en', 'ch_lite', 'en_lite', 'formula', 'table'}
if backend not in ALLOWED_BACKENDS:
backend = 'pipeline'
if lang not in ALLOWED_LANGS:
lang = 'ch'
cmd = [ cmd = [
str(mineru_exe), str(mineru_exe),
"--",
"-p", str(file_path), "-p", str(file_path),
"-o", str(output_dir), "-o", str(output_dir),
"-m", "auto", "-m", "auto",
@@ -675,16 +859,18 @@ def parse_with_mineru(
logger.error(f"MinerU 解析失败: {e}") logger.error(f"MinerU 解析失败: {e}")
raise raise
finally: finally:
# 清理临时目录 清理临时目录
if cleanup_output and os.path.exists(output_dir): if cleanup_output and os.path.exists(output_dir):
shutil.rmtree(output_dir, ignore_errors=True) shutil.rmtree(output_dir, ignore_errors=True)
def _detect_heading_level(text: str) -> int: def _detect_heading_level(text: str) -> int:
""" """
启发式标题识别 启发式标题识别(规则引擎版)
用于 MinerU 解析 DOCX 等 Office 格式时不提供 text_level 的情况 MinerU 解析 DOCX 等 Office 格式时不提供 text_level 时使用
规则按优先级从高到低匹配,第一个命中即返回。
规则定义见 parsers/heading_rules.py可通过 config.py 覆盖。
Args: Args:
text: 文本内容 text: 文本内容
@@ -692,57 +878,91 @@ def _detect_heading_level(text: str) -> int:
Returns: Returns:
标题级别 (0=正文, 1=h1, 2=h2, 3=h3) 标题级别 (0=正文, 1=h1, 2=h2, 3=h3)
""" """
import re from parsers.heading_rules import get_heading_engine
engine = get_heading_engine()
level, _ = engine.detect(text)
return level
text = text.strip() def _reclassify_text_chunks(chunks: List[MinerUChunk]) -> None:
"""
二次校正:检测被标记为 text 但实际是表格/表单的 chunk
# 空文本 MinerU 解析 Word 文档时,某些带下划线填空项的表单
被标记为 text 类型,需要根据内容特征修正为 table。
就地修改 chunks 列表中的 chunk_type 字段。
检测依据(基于实测数据设计):
- 连续下划线 ___ (3个以上) —— Word 表单填空项
- 冒号后跟下划线 如 "日期____" —— 键值对式表单
需 >= min_indicators 个指标同时命中才校正,避免误判。
"""
try:
from config import FORM_RECLASSIFY_ENABLED
if not FORM_RECLASSIFY_ENABLED:
return
except ImportError:
pass
try:
from config import FORM_RECLASSIFY_MIN_INDICATORS
min_indicators = FORM_RECLASSIFY_MIN_INDICATORS
except ImportError:
min_indicators = 2
# 表单特征指标(编译一次,避免循环内重复编译)
# 注意MinerU 输出的下划线是 Markdown 转义格式 \_\_\_
# 需要同时匹配纯下划线 ___ 和转义下划线 \_\_\_
_FORM_INDICATORS = [
re.compile(r'(?:\\_|_){3,}'), # 连续下划线3个以上含转义格式
re.compile(r'[:]\s*(?:\\_|_){2,}'), # 冒号后跟下划线(含转义格式)
]
reclassified = 0
for chunk in chunks:
if chunk.chunk_type != 'text':
continue
text = (chunk.content or '').strip()
if not text: if not text:
return 0 continue
# 中文章节标题模式 indicator_count = sum(1 for p in _FORM_INDICATORS if p.search(text))
# 第一章、第二章、... -> h1 if indicator_count >= min_indicators:
if re.match(r'^第[一二三四五六七八九十百千万]+[章节篇部]', text): chunk.chunk_type = 'table'
return 1 reclassified += 1
logger.info(f"表单检测: text -> table, content='{text[:80]}'")
# 第一条、第二条、... -> h2 (条文编号) if reclassified > 0:
if re.match(r'^第[一二三四五六七八九十百千万]+[条款]', text): logger.info(f"表单类型二次校正: 共 {reclassified} 个 text -> table")
return 2
# 数字章节: 1. 2. 3. 或 1、2、3、
# 一级标题: 1. 2. 3. (单数字)
if re.match(r'^\d+[\.、\s]', text):
# 短文本可能是标题
if len(text) < 50:
return 1
# 二级标题: 1.1 1.2 2.1 等 def _validate_category_section_paths(chunks: List[MinerUChunk]) -> None:
if re.match(r'^\d+\.\d+[\.、\s]', text): """
if len(text) < 80: 验证分类标题是否被规则引擎正确识别(安全网)
return 2
# 三级标题: 1.1.1 1.1.2 等 当规则引擎正确识别分类标题后section_stack 自然会更新,
if re.match(r'^\d+\.\d+\.\d+[\.、\s]', text): 不再需要后处理修改 section_path。此函数仅做验证和告警
if len(text) < 100: 便于发现规则引擎的遗漏。
return 3
# 英文章节标题 支持的模式A1类、B2类、C1类含可选 ** 粗体标记)。
# Chapter 1, Section 2, etc. """
if re.match(r'^(Chapter|Section|Part|Chapter\s+\d+|Section\s+\d+)', text, re.IGNORECASE): cat_pattern = re.compile(r'^\*{0,2}[A-Z]\d+[类類]\*{0,2}[:]')
return 1
# 短文本 + 加粗标记 (**xxx**) 可能是标题 missed_count = 0
if re.match(r'^\*\*.+\*\*$', text) and len(text) < 50: for chunk in chunks:
return 2 if chunk.chunk_type == 'text':
text = (chunk.content or '').strip()
if cat_pattern.match(text) and chunk.text_level == 0:
missed_count += 1
logger.warning(
f"分类标题未被识别为标题: '{text[:50]}', "
f"section_path='{chunk.section_path}'"
)
# 非常短的文本 (< 20 字符) 可能是标题 if missed_count > 0:
# 但需要排除常见的非标题短文本 logger.warning(
if len(text) < 20 and not re.match(r'^[\d\s\.,;:!?,。;:!?、]+$', text): f"发现 {missed_count} 个分类标题未被规则引擎识别,"
# 排除纯数字、纯标点 f"请检查 heading_rules 配置"
if re.search(r'[\u4e00-\u9fff]', text): # 包含中文 )
return 2
return 0
def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]: def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
@@ -781,16 +1001,45 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
else: else:
raise RuntimeError(f"MinerU 输出目录不存在: {auto_dir}{office_dir}") raise RuntimeError(f"MinerU 输出目录不存在: {auto_dir}{office_dir}")
# 读取 content_list.json # 读取 content_listv2 含 style 信息,优先使用)
content_list_path = output_subdir / f"{doc_name}_content_list.json" try:
if not content_list_path.exists(): from config import MINERU_PREFER_V2
content_list_path = output_subdir / f"{doc_name}_content_list_v2.json" except ImportError:
MINERU_PREFER_V2 = True
v1_path = output_subdir / f"{doc_name}_content_list.json"
v2_path = output_subdir / f"{doc_name}_content_list_v2.json"
content_list_path = None
is_v2 = False
if MINERU_PREFER_V2:
# 优先使用 v2 格式
if v2_path.exists():
content_list_path = v2_path
is_v2 = True
elif v1_path.exists():
content_list_path = v1_path
else:
# 优先使用 v1 格式
if v1_path.exists():
content_list_path = v1_path
elif v2_path.exists():
content_list_path = v2_path
is_v2 = True
content_list = [] content_list = []
if content_list_path.exists(): if content_list_path and content_list_path.exists():
with open(content_list_path, 'r', encoding='utf-8') as f: with open(content_list_path, 'r', encoding='utf-8') as f:
content_list = json.load(f) content_list = json.load(f)
# v2 格式需要转换为 v1 兼容的扁平列表
if is_v2 and isinstance(content_list, list) and content_list and isinstance(content_list[0], list):
content_list = _parse_v2_content_list(content_list)
logger.info(f"v2 格式已转换为扁平列表,共 {len(content_list)}")
logger.info(f"读取 content_list: {len(content_list)} 项, 格式={'v2' if is_v2 else 'v1'}")
# 读取 Markdown # 读取 Markdown
md_path = output_subdir / f"{doc_name}.md" md_path = output_subdir / f"{doc_name}.md"
markdown_content = "" markdown_content = ""
@@ -835,10 +1084,14 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
if item_type == "text": if item_type == "text":
text = item.get("text", "") text = item.get("text", "")
v2_styles = item.get("_v2_styles", [])
# 启发式标题识别(当 text_level 为 0 时) # 启发式标题识别(当 text_level 为 0 时)
if text_level == 0: if text_level == 0:
text_level = _detect_heading_level(text) from parsers.heading_rules import get_heading_engine
engine = get_heading_engine()
detected_level, _ = engine.detect(text, style=v2_styles)
text_level = detected_level
# 处理标题 # 处理标题
title = "" title = ""
@@ -887,8 +1140,8 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
else: else:
md_table = "" md_table = ""
# 提取表格中的嵌入图片 # 从原始 HTML 提取嵌入图片md_table 经 get_text 转换后已丢失 <img> 标签)
table_images = extract_images_from_markdown(md_table) if md_table else [] table_images = extract_images_from_markdown(table_body) if table_body else []
chunk = MinerUChunk( chunk = MinerUChunk(
content=table_caption or "表格", content=table_caption or "表格",
@@ -981,8 +1234,14 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
min_merge = 100 min_merge = 100
max_size = 1200 max_size = 1200
# 表单类型二次校正(在 _post_process_chunks 之前,因为 table 不参与合并)
_reclassify_text_chunks(chunks)
chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size) chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size)
# 验证分类标题是否被规则引擎正确识别(安全网,仅告警不修改)
_validate_category_section_paths(chunks)
return { return {
'markdown': "\n".join(markdown_parts), 'markdown': "\n".join(markdown_parts),
'chunks': chunks, 'chunks': chunks,
@@ -1327,7 +1586,17 @@ def html_table_to_markdown(html_table: str) -> str:
del rowspan_tracker[col_idx] del rowspan_tracker[col_idx]
col_idx += 1 col_idx += 1
# 提取单元格内容 # 提取单元格内容(保留图片引用信息)
img_tags = cell.find_all('img')
if img_tags:
# 单元格包含图片,生成占位标记供 LLM 感知
text_part = cell.get_text(strip=True)
img_count = len(img_tags)
if text_part:
content = f"{text_part} [{'图片' if img_count == 1 else f'{img_count}张图片'}]"
else:
content = f"[{'图片' if img_count == 1 else f'{img_count}张图片'}]"
else:
content = cell.get_text(strip=True) content = cell.get_text(strip=True)
# 处理 rowspan # 处理 rowspan
@@ -1458,6 +1727,11 @@ def parse_with_mineru_persistent(
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(f"文件不存在: {file_path}") raise FileNotFoundError(f"文件不存在: {file_path}")
# 检查文件大小
file_size = file_path.stat().st_size
if file_size > MAX_PDF_SIZE:
raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB最大允许 {MAX_PDF_SIZE / 1024 / 1024:.0f}MB")
# 计算文件 hash用于隔离输出目录 # 计算文件 hash用于隔离输出目录
file_hash = compute_file_hash(str(file_path)) file_hash = compute_file_hash(str(file_path))
output_dir = Path(output_base) / file_hash output_dir = Path(output_base) / file_hash
@@ -1603,6 +1877,21 @@ def parse_with_mineru_persistent(
chunk.image_path = new_name chunk.image_path = new_name
break break
# 更新表格嵌入图片的路径映射images 字段)
if hasattr(chunk, 'images') and chunk.images:
for img_info in chunk.images:
if isinstance(img_info, dict) and 'id' in img_info:
old_id = img_info['id']
if old_id in image_path_map:
img_info['id'] = image_path_map[old_id]
else:
# 兼容:尝试用文件名匹配映射
old_basename = os.path.basename(old_id)
for old_path, new_name in image_path_map.items():
if old_basename == os.path.basename(old_path):
img_info['id'] = new_name
break
# 更新结果中的图片路径列表(供外部使用) # 更新结果中的图片路径列表(供外部使用)
result['images'] = list(image_path_map.values()) result['images'] = list(image_path_map.values())

View File

@@ -5,12 +5,25 @@ TXT 文本解析器
""" """
import logging import logging
import os
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 文件大小限制
MAX_TXT_SIZE = 20 * 1024 * 1024 # 20MB
def extract_text_from_txt(filepath): def extract_text_from_txt(filepath):
"""从TXT提取文本""" """从TXT提取文本"""
# 检查文件大小
try:
file_size = os.path.getsize(filepath)
if file_size > MAX_TXT_SIZE:
logger.error(f"TXT文件过大: {file_size / 1024 / 1024:.1f}MB")
return ""
except OSError:
return ""
try: try:
with open(filepath, 'r', encoding='utf-8') as f: with open(filepath, 'r', encoding='utf-8') as f:
return f.read() return f.read()

View File

@@ -616,7 +616,7 @@ class FeedbackService:
prompt=prompt, prompt=prompt,
model=self.model, model=self.model,
temperature=0.7, temperature=0.7,
max_tokens=200 max_tokens=512
) )
if not response: if not response:

View File

@@ -159,7 +159,7 @@ class SessionManager:
SELECT id, role, content, metadata, created_at SELECT id, role, content, metadata, created_at
FROM messages FROM messages
WHERE session_id = ? WHERE session_id = ?
ORDER BY created_at DESC ORDER BY created_at DESC, id DESC
LIMIT ? LIMIT ?
''', (session_id, limit)) ''', (session_id, limit))