Compare commits
17 Commits
858ee40e5b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f951bc6598 | ||
|
|
e40989eeab | ||
|
|
2f7ee02b48 | ||
|
|
a3aa6cbcf3 | ||
|
|
a61eeb3e3f | ||
|
|
a31ee4bba0 | ||
|
|
b966be5417 | ||
|
|
789610d01f | ||
|
|
e761af6111 | ||
|
|
8268071fdc | ||
|
|
4753a53487 | ||
|
|
63a769540a | ||
|
|
f0e5426b4d | ||
|
|
eb177e11e5 | ||
|
|
5ba0d782e2 | ||
|
|
ee5295cc97 | ||
|
|
e205eda6a8 |
26
.gitignore
vendored
26
.gitignore
vendored
@@ -59,8 +59,8 @@ CLAUDE.md
|
||||
# 会话数据库(含用户数据)
|
||||
sessions.db
|
||||
|
||||
# 测试文件
|
||||
tests/
|
||||
# pytest / 测试运行缓存(测试源码需要纳入版本库)
|
||||
.pytest_cache/
|
||||
|
||||
# 文档源文件(可能含敏感内容)
|
||||
documents/
|
||||
@@ -81,13 +81,20 @@ test_output/
|
||||
# 非必要文档
|
||||
docs/superpowers/
|
||||
|
||||
# 以下为v5.0.0新增忽略:多向量库存储及SQLite数据
|
||||
data/
|
||||
# 多向量库存储及 SQLite 运行数据
|
||||
# data/db.py 与 data/__init__.py 是应用源码,必须纳入版本库;仅忽略数据库文件。
|
||||
data/prod/
|
||||
data/dev/
|
||||
data/sqlite/
|
||||
data/**/*.db
|
||||
data/**/*.db-shm
|
||||
data/**/*.db-wal
|
||||
vector_store/
|
||||
knowledge/vector_store/
|
||||
|
||||
# 缓存和临时数据目录
|
||||
.data/
|
||||
.recovery/
|
||||
|
||||
# 测试JSON文件
|
||||
exam_questions.json
|
||||
@@ -114,14 +121,13 @@ chat-ui/
|
||||
# 生产环境配置(含 API 密钥)
|
||||
deploy/.env.production
|
||||
|
||||
# 根目录临时测试脚本
|
||||
test_*.py
|
||||
test_*.json
|
||||
# 根目录临时测试脚本(tests/ 下的正式回归测试不忽略)
|
||||
/test_*.py
|
||||
/test_*.json
|
||||
rag_response.json
|
||||
nul
|
||||
|
||||
# 调试脚本和临时计划(仅本地使用)
|
||||
scripts/
|
||||
# 临时计划(正式 scripts/ 源码需要纳入版本库)
|
||||
plans/
|
||||
|
||||
# Qoder 工具目录
|
||||
@@ -143,6 +149,8 @@ docs/环境分离说明.md
|
||||
# 临时脚本
|
||||
scripts/evaluate_enum_context.py
|
||||
scripts/migrate_split_databases.py
|
||||
/scripts/test_cache*.py
|
||||
/scripts/test_intent*.py
|
||||
|
||||
# 上传清单(仅本地使用)
|
||||
UPLOAD_CHECKLIST.md
|
||||
|
||||
6174
api/chat_routes.py
6174
api/chat_routes.py
File diff suppressed because it is too large
Load Diff
@@ -9,11 +9,13 @@ import os
|
||||
# 一、API 密钥与模型
|
||||
# ==============================================================================
|
||||
|
||||
# 通义千问 LLM 服务
|
||||
# OpenAI 兼容 LLM 服务(当前默认使用小米 MiMo)
|
||||
DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
|
||||
DASHSCOPE_BASE_URL = os.getenv("DASHSCOPE_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
DASHSCOPE_MODEL = os.getenv("DASHSCOPE_MODEL", "qwen-flash") # 文本生成模型
|
||||
RAG_CHAT_MODEL = os.getenv("RAG_CHAT_MODEL", "qwen-flash") # RAG 对话模型
|
||||
DASHSCOPE_BASE_URL = os.getenv("DASHSCOPE_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1")
|
||||
DASHSCOPE_MODEL = os.getenv("DASHSCOPE_MODEL", "mimo-v2.5") # 文本生成模型
|
||||
RAG_CHAT_MODEL = os.getenv("RAG_CHAT_MODEL", "mimo-v2.5") # RAG 对话模型
|
||||
INTENT_MODEL = os.getenv("INTENT_MODEL", "mimo-v2.5") # 意图分析模型
|
||||
VLM_MODEL = os.getenv("VLM_MODEL", "mimo-v2.5") # 图片理解模型
|
||||
|
||||
# 兼容旧变量名(逐步迁移到 DASHSCOPE_* 命名)
|
||||
API_KEY = DASHSCOPE_API_KEY
|
||||
@@ -27,6 +29,7 @@ MODEL = DASHSCOPE_MODEL
|
||||
APP_ENV = os.getenv("APP_ENV", "dev") # dev / prod
|
||||
IS_DEV = APP_ENV == "dev"
|
||||
IS_PROD = APP_ENV == "prod"
|
||||
DEV_MODE = os.getenv("DEV_MODE", "true").lower() != "false"
|
||||
|
||||
# 开发/生产环境自动切换
|
||||
ENABLE_SESSION = IS_DEV # 会话存储(仅开发环境)
|
||||
@@ -85,10 +88,11 @@ RERANK_DEVICE = os.getenv("RERANK_DEVICE", os.getenv("DEVICE", "auto"))
|
||||
# ----- 通用问答 -----
|
||||
LLM_TEMPERATURE = 0.7 # 生成温度(0=确定性,1=随机性)
|
||||
LLM_MAX_TOKENS = 3000 # 最大输出 token 数
|
||||
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() != "false"
|
||||
|
||||
# ----- 意图分析(轻量、确定性高)-----
|
||||
INTENT_TEMPERATURE = 0.1
|
||||
INTENT_MAX_TOKENS = 300
|
||||
INTENT_MAX_TOKENS = 2048
|
||||
INTENT_HISTORY_WINDOW = 6 # 分析时取最近几条历史消息
|
||||
|
||||
# ==============================================================================
|
||||
@@ -201,6 +205,9 @@ MAX_QUERY_REWRITES = 1
|
||||
|
||||
# MinerU 解析器
|
||||
MINERU_DEVICE_MODE = os.getenv("MINERU_DEVICE_MODE", "cpu") # cpu / cuda
|
||||
MINERU_MODEL_VERSION = os.getenv("MINERU_MODEL_VERSION", "vlm")
|
||||
MINERU_LOCAL_BACKEND = os.getenv("MINERU_LOCAL_BACKEND", "pipeline")
|
||||
MINERU_ONLINE_TIMEOUT = int(os.getenv("MINERU_ONLINE_TIMEOUT", "300"))
|
||||
|
||||
# MinerU 在线 API(作为本地解析失败时的备选)
|
||||
MINERU_API_TOKEN = os.getenv("MINERU_API_TOKEN", "") # 在 https://mineru.net/apiManage/token 申请
|
||||
@@ -246,6 +253,20 @@ CONFIDENCE_CAUTION_THRESHOLD = 0.30 # top-3 均分低于此值时,提示 LLM
|
||||
ENUM_QUERY_DISABLE_TOPK_SHRINK = True
|
||||
ENUM_QUERY_MMR_LAMBDA = 0.85
|
||||
|
||||
# 章节聚类提升与 BM25/CrossEncoder 分歧救援
|
||||
SECTION_CLUSTER_BOOST_ENABLED = True
|
||||
SECTION_CLUSTER_RESCUE_ENABLED = True
|
||||
BM25_DIVERGENCE_RESCUE_ENABLED = True
|
||||
BM25_DIVERGENCE_MAX_RANK = 3
|
||||
CLUSTER_MIN_MEMBERS = 3
|
||||
CLUSTER_MIN_TYPES = 2
|
||||
CLUSTER_SEED_FLOOR = 0.35
|
||||
CLUSTER_RESCUE_FLOOR = 0.06
|
||||
CLUSTER_MAX_BOOST_PER_SECTION = 8
|
||||
CLUSTER_MAX_SECTIONS = 3
|
||||
CLUSTER_MAX_RESCUE_PER_SECTION = 6
|
||||
CLUSTER_SECTION_PREFIX_LEVELS = 2
|
||||
|
||||
# ==============================================================================
|
||||
# 十一、可选功能配置
|
||||
# ==============================================================================
|
||||
@@ -261,3 +282,20 @@ def get_llm_client():
|
||||
"""获取 LLM 客户端实例"""
|
||||
from openai import OpenAI
|
||||
return OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL)
|
||||
|
||||
|
||||
_intent_client = None
|
||||
|
||||
|
||||
def get_intent_client():
|
||||
"""获取意图分析专用 LLM 客户端(进程内复用连接池)。"""
|
||||
global _intent_client
|
||||
if _intent_client is None:
|
||||
if not DASHSCOPE_API_KEY:
|
||||
raise ValueError("DASHSCOPE_API_KEY 未配置,请在环境变量中设置")
|
||||
from openai import OpenAI
|
||||
_intent_client = OpenAI(
|
||||
api_key=DASHSCOPE_API_KEY,
|
||||
base_url=DASHSCOPE_BASE_URL,
|
||||
)
|
||||
return _intent_client
|
||||
|
||||
31
config.py
31
config.py
@@ -20,9 +20,13 @@ DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
|
||||
DASHSCOPE_BASE_URL = os.getenv("DASHSCOPE_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1")
|
||||
DASHSCOPE_MODEL = os.getenv("DASHSCOPE_MODEL", "mimo-v2.5") # 文本生成模型
|
||||
RAG_CHAT_MODEL = os.getenv("RAG_CHAT_MODEL", "mimo-v2.5") # RAG 对话模型
|
||||
INTENT_MODEL = os.getenv("INTENT_MODEL", "mimo-v2.5") # 意图分析模型
|
||||
INTENT_MODEL = os.getenv("INTENT_MODEL", "mimo-v2.5") # 意图分析模型(百炼额度用尽,切回 mimo)
|
||||
VLM_MODEL = os.getenv("VLM_MODEL", "mimo-v2.5") # 视觉语言模型(图片描述)
|
||||
|
||||
# 百炼 API(阿里云 DashScope,用于意图分析等轻量任务)
|
||||
BAILIAN_API_KEY = os.getenv("BAILIAN_API_KEY", "")
|
||||
BAILIAN_BASE_URL = os.getenv("BAILIAN_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
|
||||
|
||||
# 兼容旧变量名(逐步迁移到 DASHSCOPE_* 命名)
|
||||
API_KEY = DASHSCOPE_API_KEY
|
||||
BASE_URL = DASHSCOPE_BASE_URL
|
||||
@@ -97,11 +101,12 @@ RERANK_DEVICE = os.getenv("RERANK_DEVICE", os.getenv("DEVICE", "auto"))
|
||||
# ----- 通用问答 -----
|
||||
LLM_TEMPERATURE = 0.7 # 生成温度(0=确定性,1=随机性)
|
||||
LLM_MAX_TOKENS = 3000 # 最大输出 token 数
|
||||
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() != "false" # 关闭推理模型的思考模式(提速 + 让 temperature 生效)
|
||||
|
||||
# ----- 意图分析(轻量、确定性高)-----
|
||||
# INTENT_MODEL 在顶部「一、API 密钥与模型」中统一配置
|
||||
INTENT_TEMPERATURE = 0.1
|
||||
INTENT_MAX_TOKENS = 4096 # 推理模型思维链消耗大量 token,2048 偶发截断导致意图分析失败
|
||||
INTENT_MAX_TOKENS = 2048 # 推理模型需思考链预算(~1000 tokens),JSON 输出 ~200 tokens
|
||||
INTENT_HISTORY_WINDOW = 6 # 分析时取最近几条历史消息
|
||||
|
||||
# ==============================================================================
|
||||
@@ -221,11 +226,11 @@ MINERU_DEVICE_MODE = os.getenv("MINERU_DEVICE_MODE", "cpu") # cpu / cuda
|
||||
# MinerU 在线 API(优先使用,解析效果更好)
|
||||
MINERU_API_TOKEN = os.getenv("MINERU_API_TOKEN", "") # 在 https://mineru.net/apiManage/token 申请
|
||||
MINERU_API_URL = os.getenv("MINERU_API_URL", "https://mineru.net/api/v4/extract/task")
|
||||
MINERU_PREFER_ONLINE = os.getenv("MINERU_PREFER_ONLINE", "true").lower() == "false" # 优先使用在线 API
|
||||
MINERU_PREFER_ONLINE = os.getenv("MINERU_PREFER_ONLINE", "true").lower() == "true" # 优先使用在线 API
|
||||
|
||||
# MinerU 解析模式(本地 + 云端统一配置)
|
||||
MINERU_MODEL_VERSION = os.getenv("MINERU_MODEL_VERSION", "pipeline") # 云端解析模型: pipeline(快速推荐) / vlm(高精度慢) / MinerU-HTML
|
||||
MINERU_LOCAL_BACKEND = os.getenv("MINERU_LOCAL_BACKEND", "vlm-auto-engine") # 本地解析后端: pipeline(快速推荐) / vlm-auto-engine / hybrid-auto-engine
|
||||
MINERU_MODEL_VERSION = os.getenv("MINERU_MODEL_VERSION", "vlm") # 云端解析模型: pipeline(快速推荐) / vlm(高精度慢) / MinerU-HTML
|
||||
MINERU_LOCAL_BACKEND = os.getenv("MINERU_LOCAL_BACKEND", "pipeline") # 本地解析后端: pipeline(快速备选) / vlm-auto-engine / hybrid-auto-engine
|
||||
MINERU_ONLINE_TIMEOUT = int(os.getenv("MINERU_ONLINE_TIMEOUT", "300")) # 云端解析轮询超时(秒),大文档/VLM 模式建议 600+
|
||||
|
||||
# 分块参数
|
||||
@@ -269,7 +274,7 @@ CLUSTER_RESCUE_FLOOR = 0.06 # 路由层救援保底分数(略高于
|
||||
CLUSTER_MAX_BOOST_PER_SECTION = 8 # 引擎层:每个 section 最大提升切片数
|
||||
CLUSTER_MAX_SECTIONS = 3 # 全局最大提升/救援 section 数
|
||||
CLUSTER_MAX_RESCUE_PER_SECTION = 6 # 路由层:每个 section 最大救援切片数
|
||||
CLUSTER_SECTION_PREFIX_LEVELS = 1 # section_path 归一化保留的层级数(按章节顶层分组)
|
||||
CLUSTER_SECTION_PREFIX_LEVELS = 2 # section_path 归一化保留的层级数(按章节前两级分组,提升聚类精确度)
|
||||
|
||||
# ==============================================================================
|
||||
# 十一、可选功能配置
|
||||
@@ -286,3 +291,17 @@ def get_llm_client():
|
||||
"""获取 LLM 客户端实例"""
|
||||
from openai import OpenAI
|
||||
return OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL)
|
||||
|
||||
|
||||
_intent_client = None
|
||||
|
||||
def get_intent_client():
|
||||
"""获取意图分析专用 LLM 客户端"""
|
||||
global _intent_client
|
||||
if _intent_client is None:
|
||||
# 百炼额度用尽,意图分析也使用 mimo API
|
||||
if not DASHSCOPE_API_KEY:
|
||||
raise ValueError("DASHSCOPE_API_KEY 未配置,请在 .env 中设置")
|
||||
from openai import OpenAI
|
||||
_intent_client = OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL)
|
||||
return _intent_client
|
||||
|
||||
@@ -189,6 +189,9 @@ class RAGCacheManager:
|
||||
# 失效旧版本缓存
|
||||
self.query_cache.invalidate_by_version(old_version)
|
||||
self.embedding_cache.invalidate_by_version(old_version)
|
||||
# Rerank 缓存未携带知识库版本;文档变更后必须全量清空,
|
||||
# 否则相同 doc_id 可能继续复用旧文档内容对应的分数。
|
||||
self.rerank_cache.clear()
|
||||
|
||||
logger.info(f"知识库 {kb_name} 版本更新: {old_version} -> {new_version}")
|
||||
return new_version
|
||||
|
||||
@@ -233,10 +233,10 @@ class IntentAnalyzer:
|
||||
self._exact_cache_max = 500
|
||||
|
||||
def _get_client(self):
|
||||
"""获取 LLM 客户端"""
|
||||
"""获取 LLM 客户端(百炼快速模型)"""
|
||||
if self._client is None:
|
||||
from config import get_llm_client
|
||||
self._client = get_llm_client()
|
||||
from config import get_intent_client
|
||||
self._client = get_intent_client()
|
||||
return self._client
|
||||
|
||||
def _get_cache(self):
|
||||
@@ -316,8 +316,8 @@ class IntentAnalyzer:
|
||||
|
||||
if cache_emb is not None:
|
||||
cached = cache.get(cache_emb)
|
||||
# 确保缓存条目是意图分析结果(非 RAG 回答缓存)
|
||||
if cached and cached.get("cache_type") != "rag_answer":
|
||||
# 仅接受明确标记的意图缓存,避免未来新增缓存类型时交叉命中。
|
||||
if cached and cached.get("cache_type") == "intent_analysis":
|
||||
logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}")
|
||||
return IntentAnalysis.from_dict(cached)
|
||||
else:
|
||||
@@ -529,7 +529,16 @@ class IntentAnalyzer:
|
||||
"""解析 JSON 响应"""
|
||||
# 尝试直接解析
|
||||
try:
|
||||
return json.loads(content)
|
||||
parsed = json.loads(content)
|
||||
# mimo 等模型可能返回 JSON 数组而非对象,取第一个元素
|
||||
if isinstance(parsed, list) and len(parsed) > 0:
|
||||
first = parsed[0]
|
||||
if isinstance(first, dict):
|
||||
return first
|
||||
return None
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
|
||||
@@ -11,6 +11,24 @@ from typing import List, Optional, Union, Iterator, Callable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# MiMo 推理模型关键词(用于识别需要 thinking 参数的模型)
|
||||
_MIMO_MODEL_KEYWORDS = ('mimo',)
|
||||
|
||||
def _is_mimo_model(model_name: str) -> bool:
|
||||
"""判断是否为小米 MiMo 模型(支持 thinking 参数)"""
|
||||
if not model_name:
|
||||
return False
|
||||
return any(kw in model_name.lower() for kw in _MIMO_MODEL_KEYWORDS)
|
||||
|
||||
def _inject_mimo_thinking(kwargs: dict, model: str, disable_thinking: bool = True) -> dict:
|
||||
"""为 MiMo 模型注入 thinking 参数。返回更新后的 kwargs。"""
|
||||
if not _is_mimo_model(model):
|
||||
return kwargs
|
||||
extra = dict(kwargs.get('extra_body') or {})
|
||||
extra['thinking'] = {'type': 'disabled' if disable_thinking else 'enabled'}
|
||||
kwargs['extra_body'] = extra
|
||||
return kwargs
|
||||
|
||||
|
||||
def call_llm(
|
||||
client,
|
||||
@@ -57,6 +75,13 @@ def call_llm(
|
||||
if messages is None:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
# MiMo 模型自动注入 thinking 参数
|
||||
try:
|
||||
from config import LLM_DISABLE_THINKING
|
||||
except ImportError:
|
||||
LLM_DISABLE_THINKING = True
|
||||
kwargs = _inject_mimo_thinking(kwargs, model, disable_thinking=LLM_DISABLE_THINKING)
|
||||
|
||||
try:
|
||||
response = client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -71,19 +96,38 @@ def call_llm(
|
||||
return response
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
# 推理模型兼容:content 为空时尝试从 reasoning_content 提取
|
||||
|
||||
# 推理模型兼容(mimo-v2.5 等):
|
||||
# 推理模型思考链消耗大量 token(~1000),max_tokens 不足时 content 为空,
|
||||
# 全部输出进入 reasoning_content。此处从思考链中提取有效内容。
|
||||
if not content or not content.strip():
|
||||
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
|
||||
if reasoning and reasoning.strip():
|
||||
# 从思维链中提取 JSON 块作为内容
|
||||
json_match = re.search(r'\{[\s\S]*\}', reasoning)
|
||||
if json_match:
|
||||
logger.info("LLM: content为空,从reasoning_content提取JSON")
|
||||
return json_match.group().strip()
|
||||
logger.warning("LLM 返回空 content(可能需要增大 max_tokens)")
|
||||
# 先去掉 <think>...</think> 标签
|
||||
cleaned = re.sub(r'', '', reasoning, flags=re.DOTALL).strip()
|
||||
if cleaned:
|
||||
logger.info("LLM: content为空,从reasoning_content提取内容")
|
||||
# 尝试提取 JSON 对象(兼容结构化响应场景)
|
||||
json_match = re.search(r'\{[\s\S]*\}', cleaned)
|
||||
if json_match:
|
||||
try:
|
||||
json.loads(json_match.group())
|
||||
return json_match.group().strip()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# 尝试提取 JSON 数组
|
||||
bracket_match = re.search(r'\[[\s\S]*\]', cleaned)
|
||||
if bracket_match:
|
||||
try:
|
||||
json.loads(bracket_match.group())
|
||||
return bracket_match.group().strip()
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
pass
|
||||
# 纯文本响应:直接返回清理后的内容
|
||||
return cleaned
|
||||
logger.warning("LLM 返回空 content 且 reasoning_content 也无法提取(可能需要增大 max_tokens)")
|
||||
return None
|
||||
|
||||
|
||||
return content.strip()
|
||||
except Exception as e:
|
||||
logger.warning(f"LLM 调用失败: {e}")
|
||||
@@ -95,7 +139,7 @@ def call_llm_stream(
|
||||
prompt: str,
|
||||
model: str,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 1000,
|
||||
max_tokens: int = 3000,
|
||||
messages: List[dict] = None,
|
||||
error_prefix: str = "[错误]",
|
||||
**kwargs
|
||||
@@ -104,13 +148,14 @@ def call_llm_stream(
|
||||
流式 LLM 调用(生成器封装)
|
||||
|
||||
自动处理流式响应,逐块 yield 文本内容。
|
||||
兼容推理模型(mimo-v2.5 等):当 content 为空时回退到 reasoning_content。
|
||||
|
||||
Args:
|
||||
client: OpenAI 客户端实例
|
||||
prompt: 用户提示
|
||||
model: 模型名称
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大 token 数
|
||||
max_tokens: 最大 token 数(推理模型需留足思考链预算)
|
||||
messages: 完整消息列表
|
||||
error_prefix: 错误时的前缀
|
||||
**kwargs: 其他参数
|
||||
@@ -125,6 +170,13 @@ def call_llm_stream(
|
||||
if messages is None:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
|
||||
# MiMo 模型自动注入 thinking 参数
|
||||
try:
|
||||
from config import LLM_DISABLE_THINKING
|
||||
except ImportError:
|
||||
LLM_DISABLE_THINKING = True
|
||||
kwargs = _inject_mimo_thinking(kwargs, model, disable_thinking=LLM_DISABLE_THINKING)
|
||||
|
||||
try:
|
||||
stream = client.chat.completions.create(
|
||||
model=model,
|
||||
@@ -135,9 +187,33 @@ def call_llm_stream(
|
||||
**kwargs
|
||||
)
|
||||
|
||||
content_yielded = False
|
||||
reasoning_buffer = []
|
||||
|
||||
for chunk in stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
if not chunk.choices:
|
||||
continue
|
||||
delta = chunk.choices[0].delta
|
||||
|
||||
# 正常 content 输出
|
||||
if hasattr(delta, 'content') and delta.content:
|
||||
content_yielded = True
|
||||
yield delta.content
|
||||
continue
|
||||
|
||||
# 推理模型:reasoning_content(思考链)
|
||||
rc = getattr(delta, 'reasoning_content', None)
|
||||
if rc:
|
||||
reasoning_buffer.append(rc)
|
||||
|
||||
# 回退:content 为空但 reasoning_content 有内容(推理模型 token 不足时)
|
||||
if not content_yielded and reasoning_buffer:
|
||||
reasoning_text = ''.join(reasoning_buffer)
|
||||
# 去掉 <think>...</think> 标签
|
||||
cleaned = re.sub(r'', '', reasoning_text, flags=re.DOTALL).strip()
|
||||
if cleaned:
|
||||
logger.info("流式 LLM: content为空,从reasoning_content提取内容")
|
||||
yield cleaned
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM 流式调用失败: {e}")
|
||||
@@ -201,7 +277,13 @@ def parse_json_from_response(content: str) -> Optional[dict]:
|
||||
json_str = json_match.group(1) if json_match else content
|
||||
|
||||
try:
|
||||
return json.loads(json_str.strip())
|
||||
parsed = json.loads(json_str.strip())
|
||||
# mimo 等模型可能返回 JSON 数组而非对象,取第一个元素
|
||||
if isinstance(parsed, list) and len(parsed) > 0 and isinstance(parsed[0], dict):
|
||||
return parsed[0]
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
return None
|
||||
except (json.JSONDecodeError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
46
data/__init__.py
Normal file
46
data/__init__.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
数据目录
|
||||
|
||||
统一数据库结构(按业务域和运行环境划分):
|
||||
- data/prod/feedback.db: 反馈、FAQ 与质量报告
|
||||
- data/prod/knowledge.db: 同步、纲要与文档版本
|
||||
- data/dev/session.db: 本地开发会话与审计日志
|
||||
- data/dev/exam.db: 本地出题、试卷与批阅数据
|
||||
|
||||
使用方式:
|
||||
from data.db import get_connection, init_databases
|
||||
|
||||
# 首次运行时初始化
|
||||
init_databases()
|
||||
|
||||
# 使用连接
|
||||
with get_connection("session") as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM sessions")
|
||||
"""
|
||||
|
||||
from data.db import (
|
||||
get_connection,
|
||||
get_raw_connection,
|
||||
init_databases,
|
||||
get_db_stats,
|
||||
row_to_dict,
|
||||
rows_to_list,
|
||||
json_parse,
|
||||
json_stringify,
|
||||
DB_PATHS,
|
||||
DATA_DIR,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"get_connection",
|
||||
"get_raw_connection",
|
||||
"init_databases",
|
||||
"get_db_stats",
|
||||
"row_to_dict",
|
||||
"rows_to_list",
|
||||
"json_parse",
|
||||
"json_stringify",
|
||||
"DB_PATHS",
|
||||
"DATA_DIR",
|
||||
]
|
||||
821
data/db.py
Normal file
821
data/db.py
Normal file
@@ -0,0 +1,821 @@
|
||||
"""
|
||||
统一数据访问层 - 集中管理所有数据库连接
|
||||
|
||||
功能:
|
||||
1. 统一数据库路径配置(按环境分离:prod/dev)
|
||||
2. 连接池管理(上下文管理器)
|
||||
3. WAL 模式 + 外键约束
|
||||
4. 自动事务管理(commit/rollback)
|
||||
5. 初始化所有表结构
|
||||
|
||||
数据库分离:
|
||||
- feedback.db: 反馈系统(生产模式也使用)
|
||||
- knowledge.db: 知识管理(生产模式也使用)
|
||||
- session.db: 会话管理(仅开发模式)
|
||||
- exam.db: 出题系统(仅开发模式)
|
||||
|
||||
使用方式:
|
||||
from data.db import get_connection, init_databases
|
||||
|
||||
# 初始化数据库(首次运行时调用)
|
||||
init_databases()
|
||||
|
||||
# 使用连接
|
||||
with get_connection("feedback") as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM feedbacks WHERE user_id = ?", (user_id,))
|
||||
rows = cursor.fetchall()
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from contextlib import contextmanager
|
||||
from typing import Generator, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ==================== 路径配置 ====================
|
||||
|
||||
DATA_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
DB_PATHS = {
|
||||
# 生产模式数据库(开发模式也使用)
|
||||
"feedback": os.path.join(DATA_DIR, "prod", "feedback.db"),
|
||||
"knowledge": os.path.join(DATA_DIR, "prod", "knowledge.db"),
|
||||
|
||||
# 仅开发模式数据库
|
||||
"session": os.path.join(DATA_DIR, "dev", "session.db"),
|
||||
"exam": os.path.join(DATA_DIR, "dev", "exam.db"),
|
||||
}
|
||||
|
||||
# 数据库名称映射(用于日志)
|
||||
DB_NAMES = {
|
||||
"feedback": "反馈系统数据库(反馈、FAQ、质量报告)",
|
||||
"knowledge": "知识管理数据库(同步、大纲、版本)",
|
||||
"session": "会话管理数据库(会话、消息、审计日志)",
|
||||
"exam": "出题系统数据库(题库、试卷、批卷)",
|
||||
}
|
||||
|
||||
_init_lock = threading.Lock()
|
||||
_initialized = False
|
||||
|
||||
|
||||
# ==================== 连接管理 ====================
|
||||
|
||||
@contextmanager
|
||||
def get_connection(db_name: str, row_factory: bool = True) -> Generator[sqlite3.Connection, None, None]:
|
||||
"""
|
||||
获取数据库连接(上下文管理器)
|
||||
|
||||
Args:
|
||||
db_name: 数据库名称 ("feedback", "knowledge", "session", "exam")
|
||||
row_factory: 是否启用行工厂(返回字典格式)
|
||||
|
||||
Yields:
|
||||
sqlite3.Connection: 数据库连接
|
||||
|
||||
Example:
|
||||
with get_connection("feedback") as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("SELECT * FROM feedbacks")
|
||||
rows = cursor.fetchall()
|
||||
"""
|
||||
if db_name not in DB_PATHS:
|
||||
raise ValueError(f"未知的数据库: {db_name},可用: {list(DB_PATHS.keys())}")
|
||||
|
||||
db_path = DB_PATHS[db_name]
|
||||
|
||||
# fresh clone 中 prod/dev 目录尚不存在;连接前统一创建父目录。
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
|
||||
# 关键:check_same_thread=False 支持多worker
|
||||
conn = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
|
||||
|
||||
# 启用 WAL 模式(提升并发性能)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
|
||||
# 设置繁忙超时(5秒)
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
|
||||
# 启用外键约束
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
# 设置行工厂(返回字典格式)
|
||||
if row_factory:
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception:
|
||||
conn.rollback()
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_raw_connection(db_name: str) -> sqlite3.Connection:
|
||||
"""
|
||||
获取原始数据库连接(不使用上下文管理器)
|
||||
|
||||
注意:调用者需要手动关闭连接
|
||||
|
||||
Args:
|
||||
db_name: 数据库名称
|
||||
|
||||
Returns:
|
||||
sqlite3.Connection: 数据库连接
|
||||
"""
|
||||
if db_name not in DB_PATHS:
|
||||
raise ValueError(f"未知的数据库: {db_name}")
|
||||
|
||||
db_path = DB_PATHS[db_name]
|
||||
os.makedirs(os.path.dirname(db_path), exist_ok=True)
|
||||
conn = sqlite3.connect(db_path, timeout=30, check_same_thread=False)
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute("PRAGMA busy_timeout=5000")
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ==================== 数据库初始化 ====================
|
||||
|
||||
def init_databases():
|
||||
"""
|
||||
初始化所有数据库表结构
|
||||
|
||||
按环境分离为:
|
||||
- 生产模式数据库(开发模式也使用):feedback.db, knowledge.db
|
||||
- 仅开发模式数据库:session.db, exam.db
|
||||
"""
|
||||
global _initialized
|
||||
if _initialized:
|
||||
return
|
||||
|
||||
from config import IS_DEV
|
||||
|
||||
with _init_lock:
|
||||
if _initialized:
|
||||
return
|
||||
|
||||
# 始终初始化生产模式数据库
|
||||
_init_feedback_db()
|
||||
_init_knowledge_db()
|
||||
|
||||
# 仅开发模式初始化会话和出题数据库
|
||||
if IS_DEV:
|
||||
_init_session_db()
|
||||
_init_exam_db()
|
||||
logger.info("所有数据库初始化完成(开发模式)")
|
||||
else:
|
||||
logger.info("生产模式数据库初始化完成")
|
||||
|
||||
_initialized = True
|
||||
|
||||
|
||||
def _init_feedback_db():
|
||||
"""初始化 feedback.db(反馈系统数据库 - 生产模式也使用)"""
|
||||
with get_connection("feedback", row_factory=False) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# ========== 反馈表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS feedbacks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
query TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
sources TEXT,
|
||||
rating INTEGER NOT NULL,
|
||||
reason TEXT,
|
||||
user_id TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_session
|
||||
ON feedbacks(session_id)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_rating
|
||||
ON feedbacks(rating)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_feedback_created
|
||||
ON feedbacks(created_at)
|
||||
''')
|
||||
|
||||
# ========== FAQ 表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS faqs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question TEXT NOT NULL,
|
||||
answer TEXT NOT NULL,
|
||||
source_documents TEXT,
|
||||
frequency INTEGER DEFAULT 1,
|
||||
avg_rating REAL DEFAULT 0,
|
||||
status TEXT DEFAULT 'draft',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_faq_status
|
||||
ON faqs(status)
|
||||
''')
|
||||
|
||||
# ========== FAQ 问题变体表(Multi-Query Indexing)==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS faq_variants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
faq_id INTEGER NOT NULL,
|
||||
variant_question TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (faq_id) REFERENCES faqs(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_faq_variant_faq
|
||||
ON faq_variants(faq_id)
|
||||
''')
|
||||
|
||||
# ========== 质量报告表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS quality_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_type TEXT NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE NOT NULL,
|
||||
total_queries INTEGER DEFAULT 0,
|
||||
total_feedback INTEGER DEFAULT 0,
|
||||
positive_count INTEGER DEFAULT 0,
|
||||
negative_count INTEGER DEFAULT 0,
|
||||
avg_rating REAL DEFAULT 0,
|
||||
satisfaction_rate REAL DEFAULT 0,
|
||||
high_freq_queries TEXT,
|
||||
low_rating_queries TEXT,
|
||||
improvement_suggestions TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== FAQ 建议表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS faq_suggestions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
query TEXT NOT NULL,
|
||||
answer TEXT,
|
||||
frequency INTEGER DEFAULT 1,
|
||||
avg_rating REAL DEFAULT 0,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_faq_suggestion_status
|
||||
ON faq_suggestions(status)
|
||||
''')
|
||||
|
||||
logger.info(f"初始化数据库: {DB_PATHS['feedback']} (feedback.db)")
|
||||
|
||||
|
||||
def _init_session_db():
|
||||
"""初始化 session.db(会话管理数据库 - 仅开发模式使用)"""
|
||||
with get_connection("session", row_factory=False) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# ========== 会话表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_active TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
metadata TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user
|
||||
ON sessions(user_id)
|
||||
''')
|
||||
|
||||
# ========== 消息表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session
|
||||
ON messages(session_id, created_at)
|
||||
''')
|
||||
|
||||
# ========== 审计日志表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
username TEXT DEFAULT '',
|
||||
action TEXT NOT NULL,
|
||||
query TEXT,
|
||||
result_summary TEXT,
|
||||
sources TEXT,
|
||||
role TEXT,
|
||||
department TEXT,
|
||||
ip_address TEXT,
|
||||
duration_ms INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user
|
||||
ON audit_logs(user_id, created_at)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action
|
||||
ON audit_logs(action, created_at)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created
|
||||
ON audit_logs(created_at)
|
||||
''')
|
||||
|
||||
logger.info(f"初始化数据库: {DB_PATHS['session']} (session.db)")
|
||||
|
||||
|
||||
def _init_knowledge_db():
|
||||
"""初始化 knowledge.db(知识管理数据库)"""
|
||||
with get_connection("knowledge", row_factory=False) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# ========== 文档哈希表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS document_hashes (
|
||||
document_id TEXT PRIMARY KEY,
|
||||
document_name TEXT,
|
||||
content_hash TEXT,
|
||||
file_size INTEGER,
|
||||
last_modified TIMESTAMP,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== 变更日志表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS change_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT,
|
||||
document_name TEXT,
|
||||
change_type TEXT,
|
||||
old_hash TEXT,
|
||||
new_hash TEXT,
|
||||
change_time TIMESTAMP,
|
||||
processed INTEGER DEFAULT 0,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_change_logs_time
|
||||
ON change_logs(change_time)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_change_logs_processed
|
||||
ON change_logs(processed)
|
||||
''')
|
||||
|
||||
# ========== 同步状态表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS sync_status (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
sync_type TEXT,
|
||||
status TEXT,
|
||||
start_time TIMESTAMP,
|
||||
end_time TIMESTAMP,
|
||||
documents_processed INTEGER,
|
||||
documents_added INTEGER,
|
||||
documents_modified INTEGER,
|
||||
documents_deleted INTEGER,
|
||||
error_message TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== 纲要缓存表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS outline_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL UNIQUE,
|
||||
document_name TEXT,
|
||||
total_pages INTEGER DEFAULT 0,
|
||||
content_hash TEXT NOT NULL,
|
||||
outline_json TEXT NOT NULL,
|
||||
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_outline_doc
|
||||
ON outline_cache(document_id)
|
||||
''')
|
||||
|
||||
# ========== 文档向量缓存表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS document_vectors (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL UNIQUE,
|
||||
document_name TEXT,
|
||||
vector_hash TEXT,
|
||||
vector_json TEXT NOT NULL,
|
||||
tags_json TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_vector_doc
|
||||
ON document_vectors(document_id)
|
||||
''')
|
||||
|
||||
# ========== 推荐缓存表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS recommendation_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
recommendations_json TEXT NOT NULL,
|
||||
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== 文档版本表(统一结构) ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS document_versions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
collection TEXT,
|
||||
version TEXT NOT NULL DEFAULT 'v1',
|
||||
content_hash TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
effective_date DATE,
|
||||
expiry_date DATE,
|
||||
deprecated_date DATETIME,
|
||||
deprecated_reason TEXT,
|
||||
deprecated_by TEXT,
|
||||
change_summary TEXT,
|
||||
changed_sections TEXT,
|
||||
supersedes TEXT,
|
||||
chunk_count INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT,
|
||||
UNIQUE(document_id, collection, version)
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== 版本变更日志表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS version_change_logs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
collection TEXT,
|
||||
old_version TEXT,
|
||||
new_version TEXT,
|
||||
old_status TEXT,
|
||||
new_status TEXT,
|
||||
change_type TEXT NOT NULL,
|
||||
reason TEXT,
|
||||
changed_by TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# 添加性能优化索引
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_document_versions_status
|
||||
ON document_versions(document_id, collection, status)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_version_change_logs_document
|
||||
ON version_change_logs(document_id, collection, created_at DESC)
|
||||
''')
|
||||
|
||||
logger.info(f"初始化数据库: {DB_PATHS['knowledge']} (knowledge.db)")
|
||||
|
||||
|
||||
def _init_exam_db():
|
||||
"""初始化 exam.db(出题系统数据库)"""
|
||||
with get_connection("exam", row_factory=False) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# ========== 题目表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS questions (
|
||||
id TEXT PRIMARY KEY,
|
||||
question_type TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
options TEXT,
|
||||
correct_answer TEXT NOT NULL,
|
||||
analysis TEXT,
|
||||
knowledge_points TEXT,
|
||||
difficulty INTEGER DEFAULT 3,
|
||||
score INTEGER NOT NULL,
|
||||
source_file TEXT NOT NULL,
|
||||
source_collection TEXT NOT NULL,
|
||||
source_snippet TEXT,
|
||||
source_hash TEXT,
|
||||
status TEXT DEFAULT 'approved',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_questions_source
|
||||
ON questions(source_file)
|
||||
''')
|
||||
|
||||
# ========== 试卷表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS exams (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
total_score INTEGER NOT NULL,
|
||||
total_count INTEGER NOT NULL,
|
||||
duration INTEGER DEFAULT 60,
|
||||
status TEXT DEFAULT 'published',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by TEXT
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== 试卷题目关联表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS exam_questions (
|
||||
exam_id TEXT NOT NULL,
|
||||
question_id TEXT NOT NULL,
|
||||
question_order INTEGER NOT NULL,
|
||||
PRIMARY KEY (exam_id, question_id),
|
||||
FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== 学生答卷表(只存 student_id) ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS student_answers (
|
||||
id TEXT PRIMARY KEY,
|
||||
exam_id TEXT NOT NULL,
|
||||
student_id TEXT NOT NULL,
|
||||
question_id TEXT NOT NULL,
|
||||
question_type TEXT NOT NULL,
|
||||
student_answer TEXT NOT NULL,
|
||||
score REAL DEFAULT 0,
|
||||
max_score INTEGER NOT NULL,
|
||||
feedback TEXT,
|
||||
score_details TEXT,
|
||||
submitted_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
graded_at TIMESTAMP,
|
||||
FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_student_answers_exam
|
||||
ON student_answers(exam_id, student_id)
|
||||
''')
|
||||
|
||||
# ========== 批阅报告表(只存 student_id) ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS grade_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
exam_id TEXT NOT NULL,
|
||||
student_id TEXT NOT NULL,
|
||||
total_score REAL NOT NULL,
|
||||
max_score REAL NOT NULL,
|
||||
score_rate REAL,
|
||||
analysis TEXT,
|
||||
graded_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== 题目-制度关联表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS question_document_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question_id TEXT NOT NULL,
|
||||
question_type TEXT NOT NULL,
|
||||
exam_id TEXT NOT NULL,
|
||||
document_id TEXT NOT NULL,
|
||||
document_name TEXT,
|
||||
chapter TEXT,
|
||||
key_points TEXT,
|
||||
relevance_score REAL DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_qdl_question
|
||||
ON question_document_links(question_id)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_qdl_document
|
||||
ON question_document_links(document_id)
|
||||
''')
|
||||
|
||||
# ========== 知识点表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS knowledge_points (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
category TEXT,
|
||||
description TEXT,
|
||||
parent_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (parent_id) REFERENCES knowledge_points(id)
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== 题目-知识点关联表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS question_knowledge_links (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question_id TEXT NOT NULL,
|
||||
question_type TEXT NOT NULL,
|
||||
exam_id TEXT NOT NULL,
|
||||
knowledge_point_id INTEGER NOT NULL,
|
||||
knowledge_point_name TEXT,
|
||||
weight REAL DEFAULT 1.0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (knowledge_point_id) REFERENCES knowledge_points(id)
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_qkl_question
|
||||
ON question_knowledge_links(question_id)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_qkl_knowledge
|
||||
ON question_knowledge_links(knowledge_point_id)
|
||||
''')
|
||||
|
||||
# ========== 题目状态表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS question_status (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
question_id TEXT NOT NULL UNIQUE,
|
||||
question_type TEXT NOT NULL,
|
||||
exam_id TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'approved',
|
||||
affected_by TEXT,
|
||||
affect_reason TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
cursor.execute('''
|
||||
CREATE INDEX IF NOT EXISTS idx_qs_status
|
||||
ON question_status(status)
|
||||
''')
|
||||
|
||||
# ========== 整卷分析报告表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS exam_analysis_reports (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
report_id TEXT NOT NULL UNIQUE,
|
||||
exam_id TEXT,
|
||||
exam_name TEXT,
|
||||
student_id TEXT,
|
||||
total_score REAL,
|
||||
max_score REAL,
|
||||
score_rate REAL,
|
||||
type_scores TEXT,
|
||||
knowledge_analysis TEXT,
|
||||
weak_points TEXT,
|
||||
strong_points TEXT,
|
||||
ai_comment TEXT,
|
||||
study_suggestions TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
# ========== 新题建议表 ==========
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS question_suggestions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
document_id TEXT NOT NULL,
|
||||
suggestion TEXT,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
logger.info(f"初始化数据库: {DB_PATHS['exam']} (exam.db)")
|
||||
|
||||
|
||||
# ==================== 工具函数 ====================
|
||||
|
||||
def row_to_dict(row: sqlite3.Row) -> Dict:
|
||||
"""将 sqlite3.Row 转换为字典"""
|
||||
if row is None:
|
||||
return {}
|
||||
return dict(row)
|
||||
|
||||
|
||||
def rows_to_list(rows: List[sqlite3.Row]) -> List[Dict]:
|
||||
"""将 sqlite3.Row 列表转换为字典列表"""
|
||||
return [row_to_dict(row) for row in rows]
|
||||
|
||||
|
||||
def json_parse(value: str, default=None):
|
||||
"""安全解析 JSON 字符串"""
|
||||
if not value:
|
||||
return default
|
||||
try:
|
||||
return json.loads(value)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def json_stringify(value, ensure_ascii: bool = False) -> str:
|
||||
"""将对象转换为 JSON 字符串"""
|
||||
if value is None:
|
||||
return None
|
||||
return json.dumps(value, ensure_ascii=ensure_ascii)
|
||||
|
||||
|
||||
def get_db_stats() -> Dict:
|
||||
"""获取所有数据库的统计信息"""
|
||||
stats = {}
|
||||
|
||||
for db_name, db_path in DB_PATHS.items():
|
||||
if os.path.exists(db_path):
|
||||
file_size = os.path.getsize(db_path)
|
||||
|
||||
with get_connection(db_name, row_factory=False) as conn:
|
||||
cursor = conn.cursor()
|
||||
|
||||
# 获取所有表名
|
||||
cursor.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
tables = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
# 统计每个表的行数
|
||||
table_counts = {}
|
||||
for table in tables:
|
||||
cursor.execute(f"SELECT COUNT(*) FROM {table}")
|
||||
table_counts[table] = cursor.fetchone()[0]
|
||||
|
||||
stats[db_name] = {
|
||||
"path": db_path,
|
||||
"name": DB_NAMES.get(db_name, db_name),
|
||||
"file_size": file_size,
|
||||
"tables": table_counts,
|
||||
"total_rows": sum(table_counts.values())
|
||||
}
|
||||
else:
|
||||
stats[db_name] = {
|
||||
"path": db_path,
|
||||
"name": DB_NAMES.get(db_name, db_name),
|
||||
"exists": False
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
|
||||
# ==================== 自动初始化 ====================
|
||||
|
||||
# 模块加载时不自动初始化,由调用方决定何时初始化
|
||||
# 这样可以避免在 import 时创建数据库文件
|
||||
@@ -364,7 +364,7 @@ DASHSCOPE_BASE_URL=<your-base-url>
|
||||
DASHSCOPE_MODEL=mimo-v2.5
|
||||
RAG_CHAT_MODEL=mimo-v2.5
|
||||
INTENT_MODEL=mimo-v2.5
|
||||
VLM_MODEL=qwen-vl-plus
|
||||
VLM_MODEL=mimo-v2.5
|
||||
|
||||
# MinerU 在线 API(可选,设置后无需本地模型)
|
||||
MINERU_API_TOKEN=<your-mineru-api-token>
|
||||
|
||||
303
docs/RAG引用跳转-优化计划.md
Normal file
303
docs/RAG引用跳转-优化计划.md
Normal file
@@ -0,0 +1,303 @@
|
||||
## RAG 引用溯源跳转 — 优化计划
|
||||
|
||||
本文档基于 dev-ui 前端(Vue3 + NaiveUI)和 chat_routes.py 后端的现有实现,分析引用跳转功能的优化方向。供讨论后实施。
|
||||
|
||||
---
|
||||
|
||||
### 一、现有实现现状
|
||||
|
||||
#### 1.1 数据流
|
||||
|
||||
```
|
||||
SSE finish 事件 → citations[] 数组(_build_citation 构建)
|
||||
↓
|
||||
Chat.vue extractCitations() → 解析 [ref:chunk_id] → 渲染为 <sup>[N]</sup>
|
||||
↓
|
||||
用户点击引用项 → jumpToChunk(citation)
|
||||
↓
|
||||
打开 1200px 分屏弹窗:
|
||||
├─ 左侧:PdfViewer / DocxViewer / TextViewer(按 doc_type 选择)
|
||||
└─ 右侧:切片上下文列表(/documents/preview API 返回)
|
||||
↓
|
||||
弹窗内切换切片 → navigateToChunk(chunk) → 更新 viewer 响应式 prop
|
||||
```
|
||||
|
||||
#### 1.2 各组件当前能力
|
||||
|
||||
| 组件 | 文件 | 当前能力 | 局限 |
|
||||
|------|------|---------|------|
|
||||
| **PdfViewer** | `viewers/PdfViewer.vue` | pdfjs-dist 渲染,`initialPage` prop 页码跳转,watch 响应外部页码变化 | 仅页级跳转,无页内 bbox 高亮;单页渲染(非连续滚动) |
|
||||
| **DocxViewer** | `viewers/DocxViewer.vue` | mammoth 转 HTML,2-gram Jaccard 段落匹配,`<mark>` 高亮 + scrollIntoView | 无法精确定位到文本范围(仅段落级);大文档性能未测试 |
|
||||
| **Chat.vue** | `views/Chat.vue` | jumpToChunk 调用 preview API 获取完整 chunk text 替代 300 字截断 | preview API 失败时回退到截断文本,匹配精度下降 |
|
||||
| **_build_citation** | `api/chat_routes.py:1147` | 按 doc_type 构建定位信息,PDF 含 bbox/page,Word 含 section_chunk_id | bbox 已返回但前端未使用;content 截断 300 字 |
|
||||
|
||||
#### 1.3 后端已提供但前端未消费的字段
|
||||
|
||||
| 字段 | 后端来源 | 当前状态 |
|
||||
|------|---------|---------|
|
||||
| `bbox` | ChromaDB metadata → `_build_citation` 解析 JSON → 返回数组 | PdfViewer 未接收,未渲染高亮 |
|
||||
| `sub_type` | ChromaDB metadata(MinerU V2 解析) | `_build_citation` 未返回此字段 |
|
||||
| `table_type` | ChromaDB metadata(MinerU V2 解析) | `_build_citation` 未返回此字段 |
|
||||
| `vlm_description` | MinerUChunk(VLM 视觉描述) | 未进入 citation 数据 |
|
||||
| `chart_markdown` | MinerUChunk(图表数据表) | 未进入 citation 数据 |
|
||||
|
||||
---
|
||||
|
||||
### 二、优化项清单
|
||||
|
||||
#### Phase 1:PDF 页内 bbox 高亮(核心优化)
|
||||
|
||||
**目标**:在 PDF 页码跳转的基础上,叠加半透明矩形标注引用区域。
|
||||
|
||||
**涉及文件**:
|
||||
- `dev-ui/src/components/viewers/PdfViewer.vue`(主要改动)
|
||||
- `dev-ui/src/views/Chat.vue`(传递 bbox prop)
|
||||
- `api/chat_routes.py`(无需改动,bbox 已返回)
|
||||
|
||||
**PdfViewer.vue 改动方案**:
|
||||
|
||||
1. 新增 `highlightBbox` prop:
|
||||
```js
|
||||
const props = defineProps({
|
||||
url: { type: String, required: true },
|
||||
initialPage: { type: Number, default: 1 },
|
||||
highlightBbox: { type: Array, default: null } // [x0, y0, x1, y1] 或 null
|
||||
})
|
||||
```
|
||||
|
||||
2. 在 `renderPage()` 的 `page.render()` 完成后叠加绘制高亮矩形:
|
||||
```js
|
||||
async function renderPage() {
|
||||
// ... 现有的 canvas 渲染逻辑 ...
|
||||
await page.render({ canvasContext: ctx, viewport }).promise
|
||||
|
||||
// bbox 高亮叠加
|
||||
if (props.highlightBbox && currentPage.value === props.initialPage) {
|
||||
drawBboxHighlight(ctx, viewport, props.highlightBbox)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. 新增 `drawBboxHighlight()` 函数:
|
||||
```js
|
||||
function drawBboxHighlight(ctx, viewport, bbox) {
|
||||
// MinerU bbox 坐标系:PDF 原始坐标,原点左下角
|
||||
// PDF.js viewport:原点左上角,已通过 getViewport 转换
|
||||
const [x0, y0, x1, y1] = bbox
|
||||
// 方式1:直接传 viewport 转换(如果 bbox 是 PDF 用户空间坐标)
|
||||
const topLeft = viewport.convertToViewportPoint(x0, y1) // 注意 y 翻转
|
||||
const bottomRight = viewport.convertToViewportPoint(x1, y0)
|
||||
|
||||
const w = bottomRight[0] - topLeft[0]
|
||||
const h = bottomRight[1] - topLeft[1]
|
||||
|
||||
// 半透明填充
|
||||
ctx.fillStyle = 'rgba(99, 102, 241, 0.2)'
|
||||
ctx.fillRect(topLeft[0], topLeft[1], w, h)
|
||||
// 边框
|
||||
ctx.strokeStyle = 'rgba(99, 102, 241, 0.6)'
|
||||
ctx.lineWidth = 2
|
||||
ctx.strokeRect(topLeft[0], topLeft[1], w, h)
|
||||
}
|
||||
```
|
||||
|
||||
4. watch `highlightBbox` 变化时重新渲染(切换切片时):
|
||||
```js
|
||||
watch(() => props.highlightBbox, () => {
|
||||
if (pdfDoc) renderPage()
|
||||
})
|
||||
```
|
||||
|
||||
**Chat.vue 改动方案**:
|
||||
|
||||
1. 新增响应式变量 `citationBbox`
|
||||
2. `jumpToChunk()` 中从 citation 取 bbox:`citationBbox.value = citation.bbox || null`
|
||||
3. `navigateToChunk()` 中从 chunk metadata 取 bbox:`citationBbox.value = chunk.metadata?.bbox ? JSON.parse(chunk.metadata.bbox) : null`
|
||||
4. 模板中传递给 PdfViewer:`<PdfViewer :highlight-bbox="citationBbox" ...>`
|
||||
|
||||
**风险点**:
|
||||
- MinerU 的 bbox 坐标系需确认。PDF 标准坐标系原点左下角、y 向上;PDF.js viewport 默认原点左上角、y 向下。`viewport.convertToViewportPoint` 应能处理转换,但需实际测试坐标是否正确。
|
||||
- 部分切片可能没有 bbox(DOCX 来源、或 MinerU 解析遗漏),需做空值保护。
|
||||
- bbox 可能是页面坐标系(0-1 归一化)或绝对像素坐标,需确认 MinerU 输出格式。
|
||||
|
||||
---
|
||||
|
||||
#### Phase 2:后端 citation 数据增强
|
||||
|
||||
**目标**:让前端获得更多结构化信息,支持差异化渲染和更精准的定位。
|
||||
|
||||
**涉及文件**:
|
||||
- `api/chat_routes.py` — `_build_citation()` 函数
|
||||
|
||||
**改动方案**:
|
||||
|
||||
在 `_build_citation()` 的通用字段部分新增:
|
||||
|
||||
```python
|
||||
citation = {
|
||||
# ... 现有字段 ...
|
||||
# 新增:MinerU 结构化元数据
|
||||
"sub_type": meta.get('sub_type', ''), # 图片/图表子类型
|
||||
"table_type": meta.get('table_type', ''), # 表格类型
|
||||
"chunk_type": meta.get('chunk_type', 'text'), # 已有,但确保传递
|
||||
}
|
||||
```
|
||||
|
||||
在 PDF 分支中新增 bbox 的 page 关联信息(当前已有 bbox 和 page,无需额外改动)。
|
||||
|
||||
在 Word 分支中新增更长的 content 截断:
|
||||
|
||||
```python
|
||||
elif doc_type == 'word':
|
||||
citation.update({
|
||||
"section_chunk_id": meta.get('section_chunk_id'),
|
||||
"content": (full_content or meta.get('preview', ''))[:500], # 300→500,提升 DOCX 匹配精度
|
||||
})
|
||||
```
|
||||
|
||||
**收益**:
|
||||
- `sub_type` 可让前端在引用列表中区分图表类型(bar/line/natural_image)
|
||||
- `table_type` 可让前端判断是否用表格样式展示
|
||||
- Word content 截断从 300→500 字,减少 preview API 依赖,提升 fallback 场景下的匹配精度
|
||||
|
||||
---
|
||||
|
||||
#### Phase 3:引用列表差异化展示
|
||||
|
||||
**目标**:在消息底部的引用列表中,根据 chunk_type 和 sub_type 显示类型图标/标签,提升可读性。
|
||||
|
||||
**涉及文件**:
|
||||
- `dev-ui/src/views/Chat.vue`(模板和 CSS)
|
||||
|
||||
**改动方案**:
|
||||
|
||||
在引用列表项(`.citation-item`)中增加类型标签:
|
||||
|
||||
```html
|
||||
<div v-for="c in group.items" :key="c.displayIndex" class="citation-item clickable" @click="jumpToChunk(c)">
|
||||
<span class="citation-num">[{{ c.displayIndex }}]</span>
|
||||
<!-- 新增:类型标签 -->
|
||||
<n-tag v-if="c.chunk_type === 'table'" size="tiny" :bordered="false" type="warning">表格</n-tag>
|
||||
<n-tag v-else-if="c.chunk_type === 'image' || c.chunk_type === 'chart'" size="tiny" :bordered="false" type="info">
|
||||
{{ c.sub_type || '图片' }}
|
||||
</n-tag>
|
||||
<span v-if="c.page" class="citation-location">第{{ c.page }}页</span>
|
||||
<span v-if="c.section" class="citation-section">{{ c.section }}</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
**效果**:用户可以一眼看出引用的是文本、表格还是图表,点击前有预期。
|
||||
|
||||
---
|
||||
|
||||
#### Phase 4:弹窗内图表/表格的增强展示
|
||||
|
||||
**目标**:在引用预览弹窗的右侧切片面板中,对表格和图片/图表类型的切片提供差异化展示。
|
||||
|
||||
**涉及文件**:
|
||||
- `dev-ui/src/views/Chat.vue`(切片面板模板)
|
||||
- 可选:`dev-ui/src/components/viewers/` 下新增或复用 viewer
|
||||
|
||||
**改动方案**:
|
||||
|
||||
1. 表格切片(`chunk_type === 'table'`):如果切片 metadata 中有 `table_html`,渲染为 HTML 表格而非纯文本
|
||||
2. 图片/图表切片(`chunk_type === 'image' || 'chart'`):显示 `image_path` 对应的图片缩略图
|
||||
3. 图表数据预览:如果 `chart_markdown` 可用,在图表切片下方展示 Markdown 数据表
|
||||
|
||||
```html
|
||||
<div class="citation-chunk" :class="{ target: chunk.is_target }">
|
||||
<div class="chunk-header">
|
||||
<!-- 现有 header 内容 -->
|
||||
<n-tag v-if="chunk.metadata?.chunk_type === 'table'" size="tiny" type="warning">表格</n-tag>
|
||||
<n-tag v-if="chunk.metadata?.sub_type" size="tiny" type="info">{{ chunk.metadata.sub_type }}</n-tag>
|
||||
</div>
|
||||
<!-- 表格:尝试渲染 HTML -->
|
||||
<div v-if="chunk.metadata?.chunk_type === 'table' && chunk.metadata?.table_html"
|
||||
class="chunk-table" v-html="chunk.metadata.table_html" />
|
||||
<!-- 图片/图表:显示缩略图 -->
|
||||
<div v-else-if="['image','chart'].includes(chunk.metadata?.chunk_type) && chunk.metadata?.image_path"
|
||||
class="chunk-image">
|
||||
<img :src="`/api/documents/${citationPreviewSource}/image/${chunk.metadata.image_path}`" />
|
||||
</div>
|
||||
<!-- 默认:纯文本 -->
|
||||
<div v-else class="chunk-text">{{ chunk.document || chunk.content }}</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**注意**:preview API 返回的 chunk metadata 中是否包含 `table_html`、`image_path` 等字段,需确认后端 `/documents/<path>/preview` 接口是否透传了这些字段。如果未透传,需要后端配合补充。
|
||||
|
||||
---
|
||||
|
||||
#### Phase 5:DOCX 多段落高亮(可选)
|
||||
|
||||
**目标**:当一个切片内容跨多个段落时,高亮所有匹配段落而非仅最佳匹配的一个。
|
||||
|
||||
**涉及文件**:
|
||||
- `dev-ui/src/components/viewers/DocxViewer.vue`
|
||||
|
||||
**改动方案**:
|
||||
|
||||
修改 `highlightAndScroll()` 中的选择逻辑,从"选最佳单个"改为"选所有超过阈值的":
|
||||
|
||||
```js
|
||||
// 当前:只保留 bestEl
|
||||
if (score > bestScore) { bestScore = score; bestEl = block }
|
||||
|
||||
// 优化:收集所有超过阈值的匹配
|
||||
const SCORE_THRESHOLD = 30
|
||||
const matches = []
|
||||
// ... 遍历后 ...
|
||||
for (const block of blocks) {
|
||||
// ... 计算 score ...
|
||||
if (score >= SCORE_THRESHOLD) {
|
||||
matches.push({ el: block, score })
|
||||
}
|
||||
}
|
||||
// 按 score 降序,高亮所有匹配
|
||||
matches.sort((a, b) => b.score - a.score)
|
||||
matches.forEach((m, i) => {
|
||||
m.el.classList.add('citation-highlight-block')
|
||||
if (i === 0) {
|
||||
setTimeout(() => m.el.scrollIntoView({ behavior: 'smooth', block: 'center' }), 100)
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**风险**:可能高亮过多不相关段落(特别是短文本切片匹配到多处相似内容时)。需配合阈值调优。
|
||||
|
||||
---
|
||||
|
||||
### 三、实施优先级建议
|
||||
|
||||
| 优先级 | 优化项 | 改动范围 | 预估工作量 | 收益 |
|
||||
|--------|--------|---------|-----------|------|
|
||||
| **P0** | Phase 1:PDF bbox 高亮 | PdfViewer.vue + Chat.vue | 中(2-3小时) | 核心功能补齐,PDF 引用精确定位 |
|
||||
| **P1** | Phase 2:后端 citation 增强 | chat_routes.py | 小(30分钟) | 为后续前端优化提供数据基础 |
|
||||
| **P2** | Phase 3:引用列表差异化展示 | Chat.vue 模板 | 小(30分钟) | 提升可读性,改动低风险 |
|
||||
| **P3** | Phase 4:弹窗内图表/表格增强 | Chat.vue 模板 + 后端 preview | 中(1-2小时) | 丰富引用预览信息 |
|
||||
| **P4** | Phase 5:DOCX 多段落高亮 | DocxViewer.vue | 小(1小时) | 可选优化,当前单段落高亮已够用 |
|
||||
|
||||
---
|
||||
|
||||
### 四、需确认的技术细节
|
||||
|
||||
1. **MinerU bbox 坐标系**:需确认是 PDF 用户空间坐标(72 DPI,原点左下角)还是归一化坐标(0-1)还是像素坐标。这决定了 `viewport.convertToViewportPoint` 的调用方式。建议用一个已知 PDF 的 bbox 值做实验。
|
||||
|
||||
2. **preview API 的 metadata 透传范围**:`document_routes.py` 的 preview 接口返回 chunk metadata 时,是否包含 `table_html`、`image_path`、`bbox` 等字段?如果 ChromaDB metadata 中已存储,理论上会自动包含,但需验证。
|
||||
|
||||
3. **PdfViewer 连续滚动 vs 单页模式**:当前是单页渲染(一次只显示一页),切换页码时重新渲染。如果要做跨页 bbox 高亮(切片跨两页),需要支持连续滚动模式或双页渲染。当前单页模式已能覆盖大多数场景。
|
||||
|
||||
4. **mammoth 对复杂排版的还原度**:DOCX 转 HTML 时,mammoth 对表格嵌套、图片嵌入、特殊字体的还原度有限。如果文档包含复杂排版,高亮匹配可能偏离实际位置。这是 mammoth 库的固有限制。
|
||||
|
||||
5. **SSE citations 数据量控制**:当前 content 截断 300 字。如果增加到 500 字(Phase 2),每次 RAG 回答的 SSE 数据量会增加。按平均 5-8 个引用计算,增量约 1-2KB,可接受。
|
||||
|
||||
---
|
||||
|
||||
### 五、不在本次范围
|
||||
|
||||
以下为更远期优化方向,不纳入本轮实施:
|
||||
|
||||
- **全文档连续滚动预览**:PdfViewer 改为渲染所有页面(类似浏览器内置 PDF 查看器),支持滚动到任意位置。需要 pdfjs-dist 的多页渲染支持,性能开销大。
|
||||
- **DOCX 原文编辑**:在预览弹窗中支持对 DOCX 内容的标注/批注。需要 DOCX 编辑器集成,超出引用溯源范畴。
|
||||
- **引用覆盖率统计**:回答中每个段落的引用覆盖情况可视化。需要前端渲染层深度改造。
|
||||
- **跨文档对比视图**:多个引用来自不同文档时,左右分屏对比。当前单文档预览已满足需求。
|
||||
460
docs/RAG性能分析报告.md
Normal file
460
docs/RAG性能分析报告.md
Normal file
@@ -0,0 +1,460 @@
|
||||
# RAG 知识库问答系统 — 性能分析报告
|
||||
|
||||
> 基于 15 题基准测试 + 代码静态分析 | 2026-06-22
|
||||
>
|
||||
> 基准测试环境:本地 deepseek-v4-flash 模型,服务器使用 mimo-v2.5 时延迟特征可能不同
|
||||
|
||||
---
|
||||
|
||||
## 一、完整请求管线总览
|
||||
|
||||
一次 `/rag/stream` 请求经历 **10 个串行阶段**,每个阶段必须等待前一阶段完成后才能开始。SSE 流式响应在 LLM 生成阶段才开始产出用户可见内容,前 8 个阶段用户感知为"等待中"。
|
||||
|
||||
```
|
||||
用户提问
|
||||
│
|
||||
├─ 0. 输入校验 & 安全过滤 ~1ms
|
||||
├─ 1. 意图分析(LLM 调用) ~3500-5000ms ◄── 瓶颈 #1
|
||||
├─ 2. 语义缓存查找 ~50-200ms
|
||||
├─ 3. 混合检索(向量+BM25+图片) ~200-600ms
|
||||
├─ 4. RRF 融合 + Rerank ~300-800ms
|
||||
├─ 5. MMR 去重 ~5-20ms
|
||||
├─ 6. 救援管线(3层) ~5-30ms
|
||||
├─ 7. 图片选择 + VLM 增强 ~10-50ms
|
||||
├─ 8. 上下文构建 ~5-15ms
|
||||
├─ 9. LLM 流式生成 ~5000-15000ms ◄── 瓶颈 #2
|
||||
└─ 10. 后处理(对齐/引用/会话存储) ~50-200ms
|
||||
```
|
||||
|
||||
**总耗时**:典型请求 **10-20 秒**(含流式输出时间),其中 LLM 调用占 **80%以上**。
|
||||
|
||||
---
|
||||
|
||||
## 二、各阶段详细分析
|
||||
|
||||
### 阶段 1:意图分析 — 最大瓶颈之一
|
||||
|
||||
**位置**:`core/intent_analyzer.py` → `analyze()` 方法
|
||||
|
||||
**功能**:一次非流式 LLM 调用,同时完成问题改写、指代消解、意图分类、子查询生成。
|
||||
|
||||
**执行流程**:
|
||||
|
||||
```
|
||||
精确缓存查找(Dict, O(1))
|
||||
↓ 未命中
|
||||
语义缓存查找(FAISS, cosine ≥ 0.92)
|
||||
↓ 未命中
|
||||
LLM 调用(非流式,JSON 输出) ◄── 主要耗时
|
||||
↓
|
||||
写入双层缓存 + 返回 IntentAnalysis
|
||||
```
|
||||
|
||||
**关键参数**:
|
||||
|
||||
| 参数 | 服务器值 | 说明 |
|
||||
|------|----------|------|
|
||||
| INTENT_MODEL | mimo-v2.5 | 与主模型共用,无法使用轻量模型 |
|
||||
| INTENT_TEMPERATURE | 0.1 | 低温度保证确定性输出 |
|
||||
| INTENT_MAX_TOKENS | 2048 | 推理模型思考链 ~1000 token + JSON 输出 ~200 token,配置合理 |
|
||||
| INTENT_HISTORY_WINDOW | 6 | 取最近 6 条历史消息 |
|
||||
| 精确缓存上限 | 500 条 | 内存 Dict,重启清零 |
|
||||
| 语义缓存阈值 | 0.92 | 余弦相似度,过于严格则命中率低 |
|
||||
|
||||
**实测耗时**(deepseek-v4-flash):3500-5000ms。mimo-v2.5 因模型更大,预期 **4000-7000ms**。
|
||||
|
||||
**瓶颈分析**:
|
||||
|
||||
1. **非流式调用**:必须等待完整 JSON 响应才能解析,无法提前返回。这是所有阶段中唯一必须完整等待的 LLM 调用。
|
||||
2. **模型过重**:意图分析本质是分类+改写任务,不需要 mimo-v2.5 这样的推理大模型。换用轻量非推理模型可减少 30-40% 延迟,但百炼 deepseek-v4-flash 额度已尽,需寻找其他可用轻量模型。
|
||||
3. **MAX_TOKENS 配置合理**:mimo-v2.5 是推理模型,思考链消耗 ~1000 token,JSON 输出 ~200 token,2048 是必要预算(此前 1024 导致 content 为空、全部输出进入 reasoning_content 引发解析错误)。
|
||||
4. **缓存命中率低**:精确缓存 key 包含历史上下文,同一用户连续问不同问题时不会命中;语义缓存阈值 0.92 过于严格。
|
||||
|
||||
### 阶段 2:语义缓存查找
|
||||
|
||||
**位置**:`core/semantic_cache.py`
|
||||
|
||||
**功能**:FAISS 向量索引,用 embedding 余弦相似度匹配历史问答,命中则跳过整个检索+生成流程。
|
||||
|
||||
**查找逻辑**:
|
||||
1. 将 `"{query}|{collections}"` 编码为向量
|
||||
2. FAISS IndexFlatIP 搜索 top-1(向量已归一化,内积=余弦)
|
||||
3. 阈值 ≥ 0.92 → 命中
|
||||
4. 验证 `cache_type == "rag_answer"`(区分意图缓存和问答缓存)
|
||||
|
||||
**性能**:50-200ms(主要是 embedding 编码耗时,FAISS 搜索本身 <1ms)
|
||||
|
||||
**瓶颈分析**:此阶段本身不慢,但受限于 0.92 的高阈值,实际命中率较低。同一个问题的不同表述方式(如"智启平台支持哪些数据源" vs "智启能对接什么数据库")余弦相似度可能在 0.85-0.91 之间,无法命中。降低阈值会引入误命中风险。
|
||||
|
||||
### 阶段 3:混合检索
|
||||
|
||||
**位置**:`core/engine.py` → `search_knowledge()`
|
||||
|
||||
**功能**:三路并行召回 + RRF 融合
|
||||
|
||||
**三路召回**:
|
||||
|
||||
| 召回路径 | 数量 | 后端 | 耗时 |
|
||||
|----------|------|------|------|
|
||||
| 向量检索 | recall_k = max(20, 30×3) = 90 | ChromaDB + bge-base-zh-v1.5 (CPU) | 100-300ms |
|
||||
| BM25 关键词 | top_k=30 | rank_bm25 (jieba 分词) | 10-30ms |
|
||||
| 图片独立召回 | n_results=5 | ChromaDB,过滤 chunk_type | 20-50ms |
|
||||
|
||||
**RRF 融合**(Reciprocal Rank Fusion):
|
||||
|
||||
```
|
||||
score = weight / (k + rank + 1),k = 60
|
||||
|
||||
动态权重(按查询长度自适应):
|
||||
alpha = clamp((len(query) - 15) / 35, 0, 1)
|
||||
vector_weight = 0.3 + 0.4 × alpha # 短查询 0.3,长查询 0.7
|
||||
bm25_weight = 1.0 - vector_weight
|
||||
```
|
||||
|
||||
**性能**:总计 200-600ms。Embedding 编码是主要耗时(CPU 推理),ChromaDB 查询本身很快。
|
||||
|
||||
**瓶颈分析**:
|
||||
|
||||
1. **Embedding 在 CPU 上运行**:bge-base-zh-v1.5 编码 90 个候选文档需要 100-200ms。GPU 可降至 10-30ms,但当前服务器未配置 GPU。
|
||||
2. **召回数量偏大**:`recall_k=90` 意味着 embedding 编码量大。实际 Rerank 只取 20 个,多召回的 70 个是浪费。可降低 `RECALL_MULTIPLIER` 从 3 到 2。
|
||||
3. **BM25 无瓶颈**:纯内存计算,jieba 分词+BM25 打分共 10-30ms。
|
||||
|
||||
### 阶段 4:Rerank
|
||||
|
||||
**位置**:`core/engine.py` → `rerank_results()`
|
||||
|
||||
**功能**:CrossEncoder 精排,对融合后的候选重打分
|
||||
|
||||
**服务器配置**:`RERANK_BACKEND = "local"`,使用 ONNX 格式的 bge-reranker-base。云端备选为 `xop3qwen8breranker`(讯飞云 API),`RERANK_BACKEND = "cloud"` 或 `"fallback"` 时启用。
|
||||
|
||||
**流程**:
|
||||
1. 检查 Rerank 缓存(MD5 of query + sorted_doc_ids)
|
||||
2. 缓存未命中 → 调用 ONNX 推理,predict(query, doc) × N
|
||||
3. 按分数排序,截取 `RERANK_TOP_K=15`
|
||||
|
||||
**性能**:
|
||||
- 缓存命中:<1ms
|
||||
- 缓存未命中(20 个候选):300-800ms(CPU ONNX 推理)
|
||||
|
||||
**瓶颈分析**:
|
||||
|
||||
1. **CPU 推理**:CrossEncoder 是最重的 CPU 计算任务。每个 (query, doc) pair 需要一次 forward pass。20 个候选 × ~30ms/pair ≈ 600ms。
|
||||
2. **max_length=512**:长文档截断到 512 token,避免 OOM 但可能丢失信息。
|
||||
3. **缓存效果好**:同一查询+同一文档集合直接命中缓存。但首次查询必经此阶段。
|
||||
|
||||
### 阶段 5:MMR 去重
|
||||
|
||||
**位置**:`core/mmr.py`
|
||||
|
||||
**当前配置**:`MMR_USE_EMBEDDING = False`(文本模式),使用 jieba 词级 Jaccard 相似度
|
||||
|
||||
**算法**:贪心选择,每次取与已选集合最不相似且与查询最相关的候选
|
||||
|
||||
**性能**:5-20ms(纯文本计算,无 embedding 开销)
|
||||
|
||||
**瓶颈分析**:**无瓶颈**。文本模式 Jaccard 非常轻量。
|
||||
|
||||
### 阶段 6:救援管线(3 层)
|
||||
|
||||
**位置**:`api/chat_routes.py` 内部函数 + `core/engine.py`
|
||||
|
||||
**第一层 — BM25 发散救援**:
|
||||
- 当 BM25 top-3 中的文档被 Rerank 打到极低分时,恢复其分数到 `CLUSTER_RESCUE_FLOOR=0.06`
|
||||
- 防止关键词完全匹配但语义分低的文档被丢弃
|
||||
|
||||
**第二层 — 词法匹配救援**:
|
||||
- 提取查询 bigram,对每个低分文档计算 bigram 匹配率
|
||||
- 匹配率 > 0.35 → 提升到 floor
|
||||
- 附带邻居救援:同文档 ±8 个 chunk_index 的邻居也被提升
|
||||
|
||||
**第三层 — 章节聚类救援**(engine.py `_section_cluster_boost`):
|
||||
- 按 `(source, normalized_section)` 分组
|
||||
- 检测"全灭章节":所有成员分数 < min_score
|
||||
- 要求 ≥ 3 成员 + ≥ 2 种 chunk_type
|
||||
- 提升最强聚类的 top-3 章节,每节最多 8 个 chunk
|
||||
|
||||
**性能**:5-30ms(纯规则计算)
|
||||
|
||||
**瓶颈分析**:**无瓶颈**。但在复杂表格/对比查询中,救援管线的效果直接影响回答质量。
|
||||
|
||||
### 阶段 7:图片选择
|
||||
|
||||
**位置**:`api/chat_routes.py` → `select_images()`
|
||||
|
||||
**评分体系**(多层叠加):
|
||||
|
||||
| 评分因子 | 分值 | 条件 |
|
||||
|----------|------|------|
|
||||
| 图号精确匹配 | +10.0 | 查询提到"图2.3"且匹配 |
|
||||
| 表号精确匹配 | +10.0 | 查询提到"表1"且匹配 |
|
||||
| 关键词匹配 | +2.0/词 | jieba 分词后匹配,上限 +8.0 |
|
||||
| 字符重叠 | +0.2/字 | 上限 +3.0 |
|
||||
| 章节匹配 | +1.5/关键词 | 图片章节与查询关键词重叠 |
|
||||
| 图表类型 | +2.0 (chart) / +1.0 (image) | chunk_type 加分 |
|
||||
| 引用+章节匹配 | +8.0 + 5.0 | 被文本引用且章节/来源匹配 |
|
||||
| VLM 相关性 < 0.3 | -3.0 | VLM 描述与查询不相关 |
|
||||
| VLM 相关性 ≥ 0.5 | +2.0 | VLM 描述与查询相关 |
|
||||
| 章节距离过远 | -5.0 | section_similarity < 0.3 且非引用 |
|
||||
|
||||
**动态预算**:
|
||||
|
||||
| 场景 | MAX_IMAGES | MIN_SCORE |
|
||||
|------|------------|-----------|
|
||||
| 精确图号查询 | 2 | 5.0 |
|
||||
| 检索结果含图片数据 | 5 | 2.0 |
|
||||
| 文本中有图号引用 | 3 | 2.0 |
|
||||
| 默认 | 2 | 3.0 |
|
||||
|
||||
**性能**:10-50ms(规则计算 + VLM 相关性检查)
|
||||
|
||||
**瓶颈分析**:图片选择本身不慢。瓶颈在 **VLM 描述生成**(`knowledge/lazy_enhance.py`),需要调用 VLM 模型为每张图片生成文字描述。当前设计为懒加载(首次查询时生成并缓存),首次查询可能有 60s 超时。
|
||||
|
||||
**两管线断裂问题**:`select_images()` 独立于文本上下文管线选择图片,但图片描述可能因 `min_score` 过滤未进入 LLM 上下文。具体问题链:
|
||||
|
||||
1. **chart_contexts 降门槛**:CrossEncoder 对图片/图表打分系统性偏低(0.002-0.08 vs 文本 0.3-0.9),原 `min_score=0.05` 会过滤掉几乎所有图表。已修复为 `min_score * 0.5 = 0.025`。
|
||||
2. **P0 安全网**:即使降门槛后,图片描述仍可能被 `_build_context_with_budget()` 截断。安全网在 LLM 生成前检查:若 `selected_images` 的描述不在 `context_text` 中,强制追加缺失描述。日志标记为 `[P0] 图片描述未完整注入 context`。
|
||||
3. **答案后过滤鸡生蛋问题**:`_filter_images_by_answer()` 根据 LLM 答案内容过滤图片。若 LLM 因上下文缺失而回答"未找到某图表",正确图片会被误过滤。P0 安全网可缓解此问题。
|
||||
|
||||
### 阶段 8:上下文构建
|
||||
|
||||
**位置**:`api/chat_routes.py` → `_build_context_with_budget()` / `_order_text_contexts_for_prompt()`
|
||||
|
||||
**预算控制**:
|
||||
|
||||
```
|
||||
CONTEXT_MAX_CHARS = 8000 # 硬上限(约 4000 token)
|
||||
CONTEXT_SOFT_LIMIT = 6000 # 软限制(超过后只接受高分 chunk)
|
||||
MAX_CONTEXT_CHUNKS = 20 # 最大切片数
|
||||
```
|
||||
|
||||
**排序策略**:
|
||||
1. 枚举/对比查询:保持原文顺序,注入 `━ section ━` 分隔符
|
||||
2. 普通查询:按 (source, section) 分组 → 组内按 chunk_index 排序 → 组间按 max_rerank_score 排序
|
||||
3. 表格保护:即使分数低于 min_score,只要同章节有高分文本 chunk,表格 chunk 也保留(floor = min_score × 0.3)
|
||||
|
||||
**性能**:5-15ms
|
||||
|
||||
**瓶颈分析**:**无瓶颈**。但上下文质量直接影响 LLM 生成质量。8000 字符 ≈ 4000 token,对于需要对比多个文档的查询可能不够。
|
||||
|
||||
### 阶段 9:LLM 流式生成 — 最大瓶颈
|
||||
|
||||
**位置**:`core/engine.py` → `generate_answer_stream()`
|
||||
|
||||
**功能**:将上下文 + 历史 + 查询组装成 prompt,调用 LLM 流式输出
|
||||
|
||||
**Prompt 结构**:
|
||||
```
|
||||
[System] 你是一个专业的知识库问答助手...(规则指令)
|
||||
[System] 参考资料:(context, 最多 8000 字符)
|
||||
[User×N] 历史对话(最多 10 轮)
|
||||
[User] 当前问题
|
||||
```
|
||||
|
||||
**关键参数**:
|
||||
|
||||
| 参数 | 服务器值 | 说明 |
|
||||
|------|----------|------|
|
||||
| MODEL | mimo-v2.5 | 主生成模型 |
|
||||
| LLM_TEMPERATURE | 0.7 | 较高温度,生成更发散 |
|
||||
| LLM_MAX_TOKENS | 3000 | 最大输出 token |
|
||||
| LLM_TOP_P | (未设置) | 默认 1.0 |
|
||||
|
||||
**实测耗时**(deepseek-v4-flash):5000-15000ms(含流式输出)。mimo-v2.5 推理模型预计 **8000-20000ms**。
|
||||
|
||||
**瓶颈分析**:
|
||||
|
||||
1. **模型延迟不可控**:LLM API 的 TTFT(首 token 时间)和 TPS(token/秒)取决于服务端负载和网络。这是整个管线中唯一无法通过代码优化的瓶颈。
|
||||
2. **推理模型 content 为空问题**:mimo-v2.5 是推理模型,`max_tokens` 不足时思考链消耗全部预算,`content` 为空需从 `reasoning_content` 提取内容。当前 `LLM_MAX_TOKENS=3000` 足够,但若降低此值需注意思考链预算。
|
||||
3. **Prompt 长度影响 TTFT**:8000 字符上下文 + 10 轮历史 ≈ 6000-8000 input token。输入越长,TTFT 越高。
|
||||
4. **流式缓解**:用户看到第一个 token 的等待时间 = 意图分析 + 检索 + TTFT ≈ 6-12 秒。流式输出减少了感知等待,但总耗时不变。
|
||||
5. **MAX_TOKENS=3000 配置合理**:推理模型思考链占用部分预算,实际回答通常 500-1500 token,3000 平衡速度与质量。
|
||||
|
||||
### 阶段 10:后处理
|
||||
|
||||
**功能**:答案对齐、图片过滤、引用附加、会话存储、语义缓存写入
|
||||
|
||||
**子步骤**:
|
||||
|
||||
| 步骤 | 耗时 | 说明 |
|
||||
|------|------|------|
|
||||
| 图号引用提取 | ~5ms | 正则匹配答案中的图/表引用 |
|
||||
| 图片后过滤 | ~5ms | `_filter_images_by_answer()`,根据 LLM 答案内容裁剪不相关图片 |
|
||||
| 引用清理 | ~2ms | 去除 LLM 添加的 [N] 标记 |
|
||||
| 引用附加 | ~10ms | 添加结构化 [ref:chunk_id] |
|
||||
| 敏感内容过滤 | ~5ms | prompt_guard 检查 |
|
||||
| 会话存储 | ~20-100ms | SQLite 写入 |
|
||||
| 语义缓存写入 | ~10-50ms | FAISS 索引添加 |
|
||||
|
||||
**总计**:50-200ms。**无瓶颈**。
|
||||
|
||||
---
|
||||
|
||||
## 三、缓存体系分析
|
||||
|
||||
### 五层缓存架构
|
||||
|
||||
```
|
||||
精确缓存层
|
||||
├─ Query Cache LRU 500, TTL 1h 最终结果缓存(含 rerank 分数)
|
||||
├─ Embedding Cache LRU 2000, TTL 24h 文档向量缓存
|
||||
├─ Rerank Cache LRU 1000, TTL 1h CrossEncoder 分数缓存
|
||||
└─ Intent Cache Dict 500, 无 TTL 意图分析结果缓存
|
||||
|
||||
语义缓存层
|
||||
└─ FAISS Cache max 10000, 阈值 0.92 向量化相似问答缓存
|
||||
```
|
||||
|
||||
### 缓存命中场景
|
||||
|
||||
| 场景 | 命中层 | 节省时间 |
|
||||
|------|--------|----------|
|
||||
| 完全相同的问题 + 相同知识库 | Query Cache | 跳过整个检索(节省 ~1s) |
|
||||
| 相似问题(余弦 ≥ 0.92) | Semantic Cache | 跳过检索 + 生成(节省 ~10s) |
|
||||
| 相同文档集合被 rerank | Rerank Cache | 跳过 CrossEncoder(节省 ~600ms) |
|
||||
| 相同文档需要 embedding | Embedding Cache | 跳过编码(节省 ~100ms) |
|
||||
| 相同问题(意图层面) | Intent Cache | 跳过意图分析 LLM(节省 ~4s) |
|
||||
|
||||
### 缓存问题
|
||||
|
||||
1. **内存存储,重启清零**:所有缓存都在进程内存中,服务重启后第一次请求全部冷启动。
|
||||
2. **语义缓存阈值过高**:0.92 的余弦阈值导致换一种说法就命中不了。
|
||||
3. **Rerank 缓存不参与 kb_version 失效**:文档更新后 rerank 缓存可能返回旧分数。
|
||||
4. **语义缓存淘汰策略粗暴**:达到 10000 上限时全量清空(`clear()`),不是 LRU。
|
||||
|
||||
---
|
||||
|
||||
## 四、瓶颈总结与优化优先级
|
||||
|
||||
### 耗时分布(典型请求,基于 deepseek-v4-flash 实测)
|
||||
|
||||
```
|
||||
意图分析 ████████████████████ 25-35% ~4000ms
|
||||
LLM 生成 ████████████████████████████████ 45-60% ~8000ms
|
||||
检索+Rerank ████████ 10-15% ~1200ms
|
||||
其他阶段 ██ 3-5% ~400ms
|
||||
```
|
||||
|
||||
### 优化机会排序
|
||||
|
||||
| 优先级 | 方向 | 预期收益 | 难度 | 风险 |
|
||||
|--------|------|----------|------|------|
|
||||
| **P0** | 意图分析换轻量模型 | 节省 2-3s/请求 | 低 | 低(需寻找可用轻量模型,百炼 deepseek-v4-flash 额度已尽) |
|
||||
| **P0** | 主模型 API 优化(换供应商/批处理) | 节省 3-5s/请求 | 中 | 中(需要评估质量) |
|
||||
| **P1** | 意图分析结果缓存优化 | 重复问题节省 4s | 低 | 低 |
|
||||
| **P1** | 语义缓存阈值调优(0.92→0.88) | 提高命中率,节省 10s | 低 | 中(可能误命中) |
|
||||
| **P2** | RECALL_MULTIPLIER 降低(3→2) | 减少 embedding 编码量 ~30ms | 低 | 低 |
|
||||
| **P2** | LLM_TEMPERATURE 降低(0.7→0.3) | 减少发散,回答更精准 | 低 | 中(可能影响创造性) |
|
||||
| **P3** | Rerank 换 GPU 推理 | 节省 ~500ms | 高(需硬件) | 低 |
|
||||
| **P3** | Embedding 换 GPU 推理 | 节省 ~150ms | 高(需硬件) | 低 |
|
||||
| **P3** | 语义缓存改 LRU 淘汰 | 避免全量清空抖动 | 中 | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 五、配置参数速查表
|
||||
|
||||
### 模型与服务
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| DASHSCOPE_MODEL | mimo-v2.5 | 主 LLM |
|
||||
| INTENT_MODEL | mimo-v2.5 | 意图分析模型 |
|
||||
| VLM_MODEL | mimo-v2.5 | 图片描述模型 |
|
||||
| EMBEDDING_MODEL_PATH | bge-base-zh-v1.5 | 本地 embedding |
|
||||
| RERANK_BACKEND | local | 本地 ONNX rerank(可选 cloud/fallback) |
|
||||
| RERANK_CLOUD_MODEL | xop3qwen8breranker | 云端 rerank(讯飞云 API) |
|
||||
|
||||
### 检索参数
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| RAG_SEARCH_TOP_K | 30 | 最终返回数 |
|
||||
| RECALL_MULTIPLIER | 3 | 向量召回倍数 |
|
||||
| RERANK_CANDIDATES | 20 | 送入 Rerank 的候选数 |
|
||||
| RERANK_TOP_K | 15 | Rerank 后保留数 |
|
||||
| RERANK_CONTEXT_MIN_SCORE | 0.05 | 最低 rerank 分数阈值 |
|
||||
| MMR_TOP_K | 30 | MMR 处理后保留数 |
|
||||
| MMR_LAMBDA | 0.5 | 相关性/多样性平衡 |
|
||||
| MMR_USE_EMBEDDING | False | 使用 jieba Jaccard |
|
||||
| RRF_K | 60 | RRF 平滑参数 |
|
||||
| DYNAMIC_RRF_ENABLED | True | 按查询长度自适应权重 |
|
||||
|
||||
### 上下文构建
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| CONTEXT_MAX_CHARS | 8000 | 上下文硬上限(字符) |
|
||||
| CONTEXT_SOFT_LIMIT | 6000 | 软限制(超此只接受高分) |
|
||||
| MAX_CONTEXT_CHUNKS | 20 | 最大切片数 |
|
||||
| MAX_HISTORY_ROUNDS | 10 | 对话历史上限 |
|
||||
|
||||
### LLM 生成
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| LLM_TEMPERATURE | 0.7 | 生成温度 |
|
||||
| LLM_MAX_TOKENS | 3000 | 最大输出 token |
|
||||
| LLM_TOP_P | 未设置 | 默认 1.0(不限制) |
|
||||
|
||||
### 缓存
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| QUERY_CACHE_SIZE | 500 | 查询缓存容量 |
|
||||
| QUERY_CACHE_TTL | 3600s | 查询缓存有效期 |
|
||||
| EMBEDDING_CACHE_SIZE | 2000 | 向量缓存容量 |
|
||||
| EMBEDDING_CACHE_TTL | 86400s | 向量缓存有效期 |
|
||||
| RERANK_CACHE_SIZE | 1000 | Rerank 缓存容量 |
|
||||
| RERANK_CACHE_TTL | 3600s | Rerank 缓存有效期 |
|
||||
| SEMANTIC_CACHE_THRESHOLD | 0.92 | 语义缓存余弦阈值 |
|
||||
|
||||
### 救援管线
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| CLUSTER_MIN_MEMBERS | 3 | 聚类最少成员数 |
|
||||
| CLUSTER_MIN_TYPES | 2 | 聚类最少类型数 |
|
||||
| CLUSTER_SEED_FLOOR | 0.35 | 种子分数下限 |
|
||||
| CLUSTER_RESCUE_FLOOR | 0.06 | 救援后分数 |
|
||||
| CLUSTER_MAX_SECTIONS | 3 | 最多救援章节数 |
|
||||
| BM25_DIVERGENCE_RESCUE_ENABLED | True | BM25 发散救援开关 |
|
||||
| BM25_DIVERGENCE_MAX_RANK | 3 | BM25 top-N 参与救援 |
|
||||
|
||||
### 置信度
|
||||
|
||||
| 参数 | 当前值 | 说明 |
|
||||
|------|--------|------|
|
||||
| CONFIDENCE_WARN_THRESHOLD | 0.15 | 低于此值:谨慎回答 |
|
||||
| CONFIDENCE_CAUTION_THRESHOLD | 0.30 | 低于此值:引用原文 |
|
||||
|
||||
---
|
||||
|
||||
## 六、架构层面的潜在风险
|
||||
|
||||
### 6.1 单点故障
|
||||
|
||||
- **LLM API 单点**:所有 LLM 调用(意图分析 + 生成)都走同一个 DASHSCOPE_BASE_URL。API 限流或故障时整个服务不可用。意图分析有降级兜底(默认 need_retrieval=True),但生成阶段无降级。
|
||||
- **ChromaDB 单点**:向量库文件损坏时所有检索失败。已有 ChromaDB corruption 风险分析文档。
|
||||
|
||||
### 6.2 内存压力
|
||||
|
||||
- **FAISS 语义缓存**:10000 条 × 768 维 float32 = ~30MB 向量数据 + 等量元数据。达到上限全量清空时可能有瞬间抖动。
|
||||
- **BM25 全量加载**:所有文档的全文 + jieba 分词结果都在内存中。知识库增长时内存线性增长。
|
||||
- **三层 LRU 缓存**:Query(500) + Embedding(2000) + Rerank(1000) = 最多 3500 条缓存。Embedding 缓存存 768 维向量,2000 条 ≈ 6MB。
|
||||
|
||||
### 6.3 并发安全
|
||||
|
||||
- gunicorn gthread 模式 2 线程 + ChromaDB 文件锁。并发写入 ChromaDB 时可能冲突。
|
||||
- FAISS 语义缓存使用 RLock,线程安全。
|
||||
- 精确缓存使用 RLock,线程安全。
|
||||
- IntentAnalyzer._exact_cache 是普通 Dict,Python GIL 保护下基本安全。
|
||||
|
||||
### 6.4 休眠模块
|
||||
|
||||
以下模块当前未接入生产流程,属于**独立休眠模块**(原属 AgenticRAG 类,该类已在 v4.0 删除):
|
||||
|
||||
- `confidence_gate.py` — 置信度门控
|
||||
- `quality_assessor.py` — 质量评估器
|
||||
- `reasoning_reflector.py` — 推理反思器
|
||||
- `loop_guard.py` — 循环守卫
|
||||
|
||||
这些模块增加了代码库的维护负担但不影响运行时性能。可按需启用。
|
||||
549
docs/RAG数据流程.md
549
docs/RAG数据流程.md
@@ -1,7 +1,7 @@
|
||||
# RAG 数据流程
|
||||
|
||||
> 本文档基于 v7.0.0 架构,详细梳理 RAG 系统从用户查询到回答生成的完整数据流。
|
||||
> 涵盖意图分析、混合检索、云端重排序、MMR 去重、上下文构建、LLM 生成和引用溯源等核心环节。
|
||||
> 本文档基于当前代码实际逻辑梳理,详细记录 RAG 系统从用户查询到回答生成的完整数据流。
|
||||
> 涵盖意图分析、混合检索、重排序、救援管道、上下文构建、图片选择与注入、LLM 生成和引用溯源等核心环节。
|
||||
|
||||
---
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 1. 意图分析 (intent_analyzer) │
|
||||
│ 问题改写 · 指代消解 · 是否需要检索 · 重复提问检测 │
|
||||
│ 模型:qwen-turbo │
|
||||
│ 模型:mimo-v2.5 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
@@ -29,31 +29,37 @@
|
||||
│ └────────┬──────────┘ │
|
||||
│ ▼ │
|
||||
│ RRF 融合 (Reciprocal Rank Fusion) │
|
||||
│ │ │
|
||||
│ 查询缓存检查 (命中则跳过 3-7 步) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 3. 云端 Rerank (DashScope API) │
|
||||
│ 模型:qwen3-rerank │
|
||||
│ 3. 云端 Rerank │
|
||||
│ 模型:xop3qwen8breranker (讯飞云) │
|
||||
│ 结果缓存:rerank_cache (TTL=1h, 版本失效自动清除) │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 4. MMR 去重 (文本 Jaccard 模式) │
|
||||
│ MMR_USE_EMBEDDING=false │
|
||||
│ 4. 救援管道 (chat_routes.py) │
|
||||
│ BM25 分歧救援 · 词法匹配救援 · 章节聚类救援 │
|
||||
│ 目的:挽救被 CrossEncoder 低估但实际相关的切片 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 5. 上下文扩展 + 图片选择 │
|
||||
│ 上下文增强 · 图文关联补充 · 懒加载 VLM 描述 │
|
||||
│ 5. 上下文构建 + 图片选择 │
|
||||
│ _order_text_contexts_for_prompt · _build_context_with_budget │
|
||||
│ select_images · VLM 相关性筛选 · CrossEncoder 精排 │
|
||||
│ 懒加载 VLM 描述 · 图片描述注入 · P0 安全网 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 6. LLM 生成 (AgenticRAG 引擎) │
|
||||
│ 模型:qwen3.6-flash(主 LLM)/ qwen-vl-plus(VLM) │
|
||||
│ 流式输出 · 引用标注 │
|
||||
│ 6. LLM 生成 │
|
||||
│ 模型:mimo-v2.5 │
|
||||
│ 流式输出 · 置信度指令 · 引用标注 · 图片后置过滤 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
@@ -65,19 +71,26 @@
|
||||
|
||||
### 1.2 模型配置一览
|
||||
|
||||
| 用途 | 模型 | 说明 |
|
||||
|------|------|------|
|
||||
| 主 LLM | qwen3.6-flash | 回答生成、上下文理解 |
|
||||
| 意图分析 | qwen-turbo | 轻量快速,用于问题改写与意图判断 |
|
||||
| VLM | qwen-vl-plus | 图片理解与描述生成 |
|
||||
| 云端重排序 | qwen3-rerank | DashScope API 调用,替代本地 BGE-reranker |
|
||||
| 用途 | 模型 | API 端点 | 说明 |
|
||||
|------|------|----------|------|
|
||||
| 主 LLM | mimo-v2.5 | xiaomimimo.com | 回答生成、上下文理解 |
|
||||
| 意图分析 | mimo-v2.5 | xiaomimimo.com | 问题改写与意图判断 |
|
||||
| VLM | mimo-v2.5 | xiaomimimo.com | 图片理解与描述生成 |
|
||||
| 云端重排序 | xop3qwen8breranker | 讯飞云 Maas API | 替代本地 BGE-reranker |
|
||||
| 向量编码 | bge-base-zh-v1.5 | 本地模型 | 查询与文档 embedding |
|
||||
|
||||
### 1.3 v7.0.0 架构变更要点
|
||||
> 模型可通过环境变量覆盖:`DASHSCOPE_MODEL`、`INTENT_MODEL`、`VLM_MODEL`、`RERANK_CLOUD_MODEL`
|
||||
|
||||
- **Reranker**:从本地 BGE-reranker 切换为云端 DashScope API(qwen3-rerank)
|
||||
- **MMR 去重**:使用文本相似度模式(`MMR_USE_EMBEDDING=false`),基于 Jaccard 系数
|
||||
- **Agentic 引擎**:拆分为多个子模块(agentic_search / agentic_answer / agentic_citation 等)
|
||||
- **Graph RAG**:模块已清空,不再使用
|
||||
### 1.3 关键配置参数
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `RERANK_CONTEXT_MIN_SCORE` | 0.05 | Rerank 分数低于此值的切片不送入 LLM |
|
||||
| `MAX_CONTEXT_CHUNKS` | 20 | 送给 LLM 的最大文本切片数 |
|
||||
| `CONTEXT_MAX_CHARS` | 8000 | 上下文最大字符数(约 4000 token) |
|
||||
| `CONTEXT_SOFT_LIMIT` | 6000 | 软限制,超过后只接受高分切片组 |
|
||||
| `CONFIDENCE_WARN_THRESHOLD` | 0.15 | top-3 均分低于此值时,提示 LLM 谨慎回答 |
|
||||
| `CONFIDENCE_CAUTION_THRESHOLD` | 0.30 | top-3 均分低于此值时,提示 LLM 优先引用原文 |
|
||||
|
||||
---
|
||||
|
||||
@@ -87,38 +100,41 @@
|
||||
|
||||
**入口文件**:`api/chat_routes.py`
|
||||
|
||||
用户通过 API 发送查询请求,由 `generate()` 函数统一调度:
|
||||
|
||||
```
|
||||
POST /rag 或 POST /chat
|
||||
Body: { "query": "用户问题", "kb_name": "知识库名称" }
|
||||
POST /chat — 普通聊天(不检索知识库,直接 LLM 回答)
|
||||
POST /rag — 知识库问答(检索 + LLM 生成)
|
||||
POST /search — 纯检索(返回切片,不生成回答)
|
||||
```
|
||||
|
||||
`generate()` 的职责:
|
||||
`/rag` 的核心函数 `rag()` 的职责:
|
||||
|
||||
1. 调用意图分析模块,获取改写后的查询与检索决策
|
||||
2. 若需要检索,调用混合检索管线
|
||||
3. 执行图片选择与上下文构建
|
||||
4. 调用 LLM 生成回答(流式输出)
|
||||
5. 组装最终响应(回答 + 引用来源 + 图片)
|
||||
2. 若需要检索,调用混合检索管线(`search_hybrid()`)
|
||||
3. 执行救援管道(BM25 分歧 / 词法匹配 / 章节聚类)
|
||||
4. 执行图片选择(`select_images()`)
|
||||
5. 构建 LLM 上下文(排序 + 预算截断 + 图片注入)
|
||||
6. 调用 LLM 生成回答(流式输出)
|
||||
7. 后置图片过滤(`_filter_images_by_answer()`)
|
||||
8. 组装最终响应(回答 + 引用来源 + 图片)
|
||||
|
||||
### 2.2 请求数据结构
|
||||
|
||||
```python
|
||||
# 请求
|
||||
{
|
||||
"query": "蓄水以来逐年发电量",
|
||||
"kb_name": "public_kb",
|
||||
"message": "用户问题",
|
||||
"history": [...] # 可选:对话历史
|
||||
}
|
||||
|
||||
# 响应
|
||||
{
|
||||
"type": "finish",
|
||||
"answer": "完整回答文本",
|
||||
"sources": [...], # 引用来源列表
|
||||
"images": [...] # 精选图片列表
|
||||
}
|
||||
# SSE 响应事件流
|
||||
data: {"type": "intent_result", "data": {...}}
|
||||
data: {"type": "chunks_retrieved", "data": {...}}
|
||||
data: {"type": "images_selected", "data": {...}}
|
||||
data: {"type": "context_built", "data": {...}}
|
||||
data: {"type": "chunk", "content": "部分回答"}
|
||||
data: {"type": "chunk", "content": "部分回答"}
|
||||
...
|
||||
data: {"type": "finish", "answer": "完整回答", "sources": [...], "images": [...]}
|
||||
```
|
||||
|
||||
---
|
||||
@@ -126,7 +142,7 @@ Body: { "query": "用户问题", "kb_name": "知识库名称" }
|
||||
## 三、意图分析
|
||||
|
||||
**入口文件**:`core/intent_analyzer.py`
|
||||
**使用模型**:qwen-turbo(轻量快速)
|
||||
**使用模型**:mimo-v2.5
|
||||
|
||||
### 3.1 核心功能
|
||||
|
||||
@@ -138,6 +154,8 @@ class IntentAnalysis:
|
||||
rewritten_query: str # 改写后的查询(指代消解、省略补全)
|
||||
use_context: bool # 是否使用历史上下文
|
||||
need_retrieval: bool # 是否需要检索知识库
|
||||
intent: str # 意图类型:factual/comparison/reasoning/instruction
|
||||
sub_queries: list # 子查询列表(对比/复杂查询拆分)
|
||||
```
|
||||
|
||||
### 3.2 处理逻辑
|
||||
@@ -147,7 +165,9 @@ class IntentAnalysis:
|
||||
| 问题改写 | 指代消解("它" → 具体实体)、省略补全(补全缺失主语) |
|
||||
| 上下文判断 | 根据对话历史决定是否需要前文信息 |
|
||||
| 检索决策 | 判断是否需要查询知识库(闲聊类问题可跳过) |
|
||||
| 重复提问检测 | 用户重复提问相同问题时,强制设置 `need_retrieval=true` |
|
||||
| 意图分类 | 识别查询意图(事实查询/对比分析/推理/操作指导) |
|
||||
| 子查询拆分 | 对比类查询拆分为多个子查询并行检索 |
|
||||
| 语义缓存 | 相似度 ≥ 0.92 时复用历史意图分析结果 |
|
||||
|
||||
### 3.3 重复提问规则
|
||||
|
||||
@@ -170,9 +190,16 @@ if cached:
|
||||
return cached
|
||||
```
|
||||
|
||||
缓存层级(5 层):
|
||||
1. `query_cache` — 精确查询结果缓存
|
||||
2. `embedding_cache` — embedding 向量缓存
|
||||
3. `rerank_cache` — rerank 分数缓存(TTL=1h,知识库版本变更自动失效)
|
||||
4. `semantic_cache` — 语义相似查询缓存(阈值 0.92)
|
||||
5. `intent_exact_cache` — 意图分析精确缓存
|
||||
|
||||
### 4.2 向量检索
|
||||
|
||||
使用 embedding 模型将查询编码为向量,在 ChromaDB 中执行 ANN 检索:
|
||||
使用本地 bge-base-zh-v1.5 模型将查询编码为向量,在 ChromaDB 中执行 ANN 检索:
|
||||
|
||||
```python
|
||||
query_vector = embedding_model.encode(query)
|
||||
@@ -211,13 +238,9 @@ fused_results = reciprocal_rank_fusion(
|
||||
)
|
||||
```
|
||||
|
||||
**融合参数**:
|
||||
### 4.5 子查询并行检索
|
||||
|
||||
| 参数 | 默认值 | 说明 |
|
||||
|------|--------|------|
|
||||
| `recall_k` | 100 | 各通道召回候选数量 |
|
||||
| `VECTOR_WEIGHT` | 0.6 | 向量检索权重 |
|
||||
| `BM25_WEIGHT` | 0.4 | BM25 检索权重 |
|
||||
意图分析器拆分的子查询(如对比类查询)会并行检索,结果合并后再 rerank。
|
||||
|
||||
---
|
||||
|
||||
@@ -225,204 +248,233 @@ fused_results = reciprocal_rank_fusion(
|
||||
|
||||
### 5.1 云端 Rerank
|
||||
|
||||
**v7.0.0 变更**:从本地 BGE-reranker 切换为云端 DashScope API。
|
||||
|
||||
**调用模型**:qwen3-rerank
|
||||
**调用模型**:xop3qwen8breranker(讯飞云 Maas API)
|
||||
|
||||
RRF 融合后的候选结果通过云端 Reranker 进行精排,基于查询与文档的语义相关性重新打分排序:
|
||||
|
||||
```python
|
||||
reranked = rerank_results(
|
||||
query,
|
||||
fused_results,
|
||||
model="qwen3-rerank", # DashScope API
|
||||
top_k=15
|
||||
)
|
||||
reranked = rerank_results(query, fused_results, top_k=15)
|
||||
```
|
||||
|
||||
### 5.2 与旧版的区别
|
||||
**重要特征**:CrossEncoder 对不同类型切片的评分分布差异显著:
|
||||
|
||||
| 对比项 | 旧版(本地 BGE-reranker) | v7.0.0(云端 qwen3-rerank) |
|
||||
|--------|--------------------------|---------------------------|
|
||||
| 部署方式 | 本地模型加载 | DashScope API 远程调用 |
|
||||
| 资源占用 | 需要 GPU 显存 | 无本地资源消耗 |
|
||||
| 模型能力 | BGE-reranker(较小) | qwen3-rerank(更强) |
|
||||
| 延迟 | 低(本地推理) | 中等(网络往返) |
|
||||
| 切片类型 | 典型分数范围 | 说明 |
|
||||
|----------|------------|------|
|
||||
| 文本切片 | 0.3 ~ 0.99 | 正常分布 |
|
||||
| 表格切片 | 0.1 ~ 0.5 | 偏低,有专用救援逻辑 |
|
||||
| 图片/图表切片 | 0.002 ~ 0.08 | 系统性偏低,需要宽松阈值 |
|
||||
|
||||
### 5.2 Rerank 缓存
|
||||
|
||||
- 每个 (query, doc_id) 对的 rerank 分数独立缓存
|
||||
- TTL = 1 小时
|
||||
- 知识库版本号变更时自动失效
|
||||
|
||||
---
|
||||
|
||||
## 六、MMR 去重
|
||||
## 六、救援管道
|
||||
|
||||
### 6.1 配置
|
||||
**入口文件**:`api/chat_routes.py`
|
||||
**目的**:挽救被 CrossEncoder 低估但实际相关的切片
|
||||
|
||||
v7.0.0 采用文本相似度模式进行 MMR(Maximal Marginal Relevance)去重:
|
||||
在 `_order_text_contexts_for_prompt` 之前执行,三个救援管道按顺序运行:
|
||||
|
||||
```
|
||||
MMR_USE_EMBEDDING = false
|
||||
```
|
||||
### 6.1 BM25 分歧救援 `_rescue_bm25_divergence`
|
||||
|
||||
### 6.2 工作原理
|
||||
**触发条件**:BM25 排名 top-3 但 CrossEngineer 评分低于 min_score 的切片
|
||||
|
||||
使用文本 Jaccard 相似度(而非 embedding 向量余弦相似度)来衡量候选文档之间的重复程度:
|
||||
**逻辑**:BM25 是关键词精确匹配的强信号,如果 BM25 认为相关但 CE 误判,提升分数至保底值。
|
||||
|
||||
```python
|
||||
def _apply_mmr(query, candidates, top_k=30, lambda_param=0.7):
|
||||
"""
|
||||
MMR 去重:在相关性和多样性之间取得平衡
|
||||
|
||||
lambda_param=0.7: 70% 权重给相关性,30% 权重给多样性
|
||||
相似度度量:文本 Jaccard 系数(基于词集合交集/并集)
|
||||
"""
|
||||
selected = []
|
||||
for candidate in candidates:
|
||||
if not selected:
|
||||
selected.append(candidate)
|
||||
continue
|
||||
|
||||
# Jaccard 相似度(文本模式)
|
||||
max_sim = max(
|
||||
jaccard_similarity(candidate.tokens, s.tokens)
|
||||
for s in selected
|
||||
)
|
||||
|
||||
mmr_score = lambda_param * relevance - (1 - lambda_param) * max_sim
|
||||
if mmr_score > threshold:
|
||||
selected.append(candidate)
|
||||
|
||||
return selected[:top_k]
|
||||
# 配置
|
||||
BM25_DIVERGENCE_RESCUE_ENABLED = True
|
||||
BM25_DIVERGENCE_MAX_RANK = 3 # 仅救援 BM25 rank <= 3 的切片
|
||||
```
|
||||
|
||||
### 6.3 选择文本模式的原因
|
||||
### 6.2 词法匹配救援 `_rescue_lexical_match`
|
||||
|
||||
- **速度更快**:无需计算 embedding 向量之间的余弦相似度
|
||||
- **效果直观**:Jaccard 系数直接反映文本内容的重叠程度
|
||||
- **避免向量偏差**:embedding 模型可能对格式化内容(如图片描述)产生不准确的相似度
|
||||
**触发条件**:切片文本精确包含查询关键词(bigram 匹配率 > 35%)但 CE 评分低
|
||||
|
||||
**逻辑**:当切片文本直接包含查询中的关键词组合时,说明语义相关,CE 可能因为表述差异低估。
|
||||
|
||||
### 6.3 章节聚类救援 `_rescue_section_cluster`
|
||||
|
||||
**触发条件**:同一 section 内所有切片都被 min_score 过滤("全灭 section")
|
||||
|
||||
**逻辑**:如果同一章节的切片全部被过滤,但其他章节有切片通过,说明 CE 对该章节整体低估。为全灭 section 的切片分配保底分数(0.06)。
|
||||
|
||||
```python
|
||||
# 配置
|
||||
SECTION_CLUSTER_RESCUE_ENABLED = True
|
||||
CLUSTER_RESCUE_FLOOR = 0.06 # 略高于 RERANK_CONTEXT_MIN_SCORE=0.05
|
||||
```
|
||||
|
||||
### 6.4 表格救援 `_rescue_table_chunks`
|
||||
|
||||
**触发条件**:查询涉及表格但上下文中没有表格数据(表格被预算截断)
|
||||
|
||||
**逻辑**:从被截断的切片中补回表格数据。
|
||||
|
||||
---
|
||||
|
||||
## 七、上下文构建
|
||||
|
||||
### 7.1 检索结果处理
|
||||
### 7.1 切片排序与过滤 `_order_text_contexts_for_prompt`
|
||||
|
||||
**入口文件**:`api/chat_routes.py`
|
||||
**核心逻辑**:
|
||||
|
||||
将检索结果转换为 LLM 可用的上下文列表:
|
||||
1. **文本切片 + 表格切片** → 进入 `text_contexts`
|
||||
2. **图片/图表切片** → 进入 `chart_contexts`(独立处理)
|
||||
3. **min_score 过滤**:
|
||||
- `text_contexts`:使用 `RERANK_CONTEXT_MIN_SCORE`(0.05)
|
||||
- 表格保护:同 section 内如有切片通过阈值,table 切片保底分数为 `min_score * 0.3`
|
||||
- `chart_contexts`:使用宽松阈值 `min_score * 0.5`(0.025)
|
||||
- 原因:CrossEncoder 对图片描述评分系统性偏低
|
||||
- 实际内容相关性由 `select_images` 独立评分保证
|
||||
4. **合并**:`text_contexts + chart_contexts[:3]`
|
||||
|
||||
```python
|
||||
contexts = []
|
||||
for result in search_results:
|
||||
meta = result['metadata']
|
||||
if meta['chunk_type'] in ('image', 'chart'):
|
||||
# 图片切片:使用完整描述(而非轻量描述)
|
||||
doc = meta.get('full_description', result['document'])
|
||||
else:
|
||||
doc = result['document']
|
||||
contexts.append({'doc': doc, 'meta': meta})
|
||||
```
|
||||
### 7.2 预算构建 `_build_context_with_budget`
|
||||
|
||||
### 7.2 懒加载增强
|
||||
按字符预算构建上下文文本:
|
||||
|
||||
对没有 VLM 描述的图片切片,按需调用 VLM 生成更精准的语义描述:
|
||||
1. 按 (source, section) 分组
|
||||
2. 组内按 chunk_index 排序(保持原文连续性)
|
||||
3. 组间按组内最高 Rerank 分数降序
|
||||
4. 逐组加入直到达到 `CONTEXT_MAX_CHARS`(8000)
|
||||
5. 超过 `CONTEXT_SOFT_LIMIT`(6000)后收紧准入
|
||||
|
||||
```python
|
||||
enhance_retrieved_chunks(contexts, query, kb_name)
|
||||
# 对缺少 VLM 描述的图片 → 调用 qwen-vl-plus 生成描述
|
||||
```
|
||||
**特殊处理**:表格切片不受预算截断(结构化关键内容)
|
||||
|
||||
### 7.3 图片选择
|
||||
|
||||
**核心函数**:`select_images()`
|
||||
### 7.3 图片选择 `select_images()`
|
||||
|
||||
从检索结果中筛选与查询最相关的图片:
|
||||
|
||||
**步骤一:意图检测**
|
||||
**步骤一:动态预算**
|
||||
|
||||
| 查询类型 | 参数调整 |
|
||||
|----------|----------|
|
||||
| 精确图号查询("图2.3") | `MAX_IMAGES=2, MIN_SCORE=5.0` |
|
||||
| 弱图片意图("发电量图") | `MAX_IMAGES=1` |
|
||||
| 普通查询 | `MAX_IMAGES=2` |
|
||||
| 查询类型 | MAX_IMAGES | MIN_SCORE | 检测方式 |
|
||||
|----------|------------|-----------|----------|
|
||||
| 精确图号查询("图2.3") | 2 | 5.0 | 正则匹配 `图\s*(\d+\.?\d*)` |
|
||||
| 有图片数据(检索含 image/chart) | 5 | 2.0 | 检查检索结果 chunk_type |
|
||||
| 文本引用图表 | 3 | 2.0 | 提取 top-5 文本中的图号/表号 |
|
||||
| 普通查询 | 2 | 3.0 | 默认 |
|
||||
|
||||
**步骤二:提取图表引用**
|
||||
|
||||
从 top 5 文本块中提取 "见图2.3"、"如表2.2" 等引用,建立图号与来源文件的映射:
|
||||
从 top 5 文本块中提取 "见图2.3"、"如表2.2" 等引用,建立图号与来源文件的映射。
|
||||
|
||||
```python
|
||||
referenced_figures = {'2.3': {'source_file': 'xxx.pdf'}, ...}
|
||||
```
|
||||
|
||||
**步骤三:图片相关性打分**
|
||||
|
||||
`score_image_relevance()` 打分规则:
|
||||
**步骤三:图片相关性打分 `score_image_relevance()`**
|
||||
|
||||
| 匹配项 | 加分 |
|
||||
|--------|------|
|
||||
| 图号精确匹配(查询中有"图2.3") | +10 分 |
|
||||
| 表号精确匹配 | +10 分 |
|
||||
| 关键词匹配("发电量"等) | +2 分/个 |
|
||||
| 字符重叠 | +0.2 分/字符 |
|
||||
| 章节匹配 | +1.5 分 |
|
||||
| 图片类型(chart > image) | +2 / +1 分 |
|
||||
| 关键词匹配(jieba 分词) | +2 分/个,上限 8 分 |
|
||||
| 字符重叠 | +0.2 分/字符,上限 3 分 |
|
||||
| 章节匹配 | +1.5 分/关键词 |
|
||||
| 图片类型(chart) | +2 分 |
|
||||
| 图片类型(image) | +1 分 |
|
||||
| 向量相似度 | +2 分(最高) |
|
||||
| 引用匹配(需章节相关) | +8 分 |
|
||||
| 引用匹配 + 章节相关 | +8 分 + 5 分 |
|
||||
| VLM 相关性 < 0.3 | -3 分(VLM 描述与查询不相关) |
|
||||
| VLM 相关性 ≥ 0.5 | +2 分 |
|
||||
| 章节不相关 | -5 分(除非被文本引用) |
|
||||
|
||||
**步骤四:图文关联补充**
|
||||
**步骤四:VLM 相关性筛选**
|
||||
|
||||
遍历 top 5 文本块中引用的图表编号,查找对应的图片切片并补充到结果中。
|
||||
对有 VLM 描述的图片,使用关键词重叠率判断描述与查询的相关性:
|
||||
- 重叠率 < 0.3:降分 -3(描述与查询不相关)
|
||||
- 重叠率 ≥ 0.5:加分 +2
|
||||
|
||||
**步骤五:返回 top N 图片**
|
||||
**步骤五:CrossEncoder 精排**
|
||||
|
||||
对 `select_images` 候选用 CE 做语义精排:
|
||||
- CE < 0:直接剔除(语义不相关)
|
||||
- CE 0~2:保留但不加分(弱相关)
|
||||
- CE > 2:加分 `min((ce - 2) / 3, 1) * 5`
|
||||
|
||||
**步骤六:表格嵌入图片补充**
|
||||
|
||||
处理 `images_json` 字段的表格切片,补充表格中嵌入的图片。
|
||||
|
||||
**步骤七:文本引用图片补充**
|
||||
|
||||
从 top 5 文本块中提取 `referenced_images` 字段,补充未选中的关联图片。
|
||||
|
||||
**步骤八:返回 top N**
|
||||
|
||||
```python
|
||||
scored_images.sort(key=lambda x: x['score'], reverse=True)
|
||||
return scored_images[:MAX_IMAGES]
|
||||
```
|
||||
|
||||
### 7.4 构建 LLM Prompt
|
||||
### 7.4 图片描述注入
|
||||
|
||||
```python
|
||||
# 文本上下文
|
||||
context_text = "\n\n".join([ctx['doc'] for ctx in contexts[:5]])
|
||||
**核心逻辑**:将 `select_images` 选中的图片的 `full_description` 注入到 LLM context 中。
|
||||
|
||||
# 图片信息
|
||||
if selected_images:
|
||||
image_info = "【可用图片】\n" + 图片描述列表
|
||||
|
||||
# 最终上下文
|
||||
enhanced_context = context_text + image_info
|
||||
```
|
||||
context_text(文本切片)
|
||||
+
|
||||
【相关图片信息】
|
||||
【图片1】VLM 完整描述(来源:xxx 第N页)
|
||||
【图片2】VLM 完整描述(来源:xxx 第N页)
|
||||
+
|
||||
【回答要求】回答时请简要介绍每张图片的内容和用途。
|
||||
```
|
||||
|
||||
**P0 安全网**(防止图片描述被过滤遗漏):
|
||||
|
||||
1. 若 `selected_images` 非空但 `【相关图片信息】` 未生成 → 补注入所有选中图片描述
|
||||
2. 若已有 `【相关图片信息】` 但遗漏部分图片 → 追加遗漏的描述
|
||||
3. DEV 模式日志:检查所有选中图片的 ID 是否出现在 context 中
|
||||
|
||||
### 7.5 置信度指令
|
||||
|
||||
根据 top-3 切片的平均 Rerank 分数注入不同的回答指令:
|
||||
|
||||
| 置信度范围 | 指令 |
|
||||
|-----------|------|
|
||||
| < 0.15 | "参考资料与问题的相关性较低,仅基于明确信息回答,不足则说明" |
|
||||
| 0.15 ~ 0.30 | "参考资料的相关性一般,优先引用资料原文,避免推测" |
|
||||
| ≥ 0.30 | 无额外指令 |
|
||||
|
||||
---
|
||||
|
||||
## 八、LLM 生成
|
||||
|
||||
### 8.1 AgenticRAG 引擎
|
||||
|
||||
v7.0.0 将 Agentic 引擎拆分为独立的子模块,各司其职:
|
||||
|
||||
| 子模块 | 职责 |
|
||||
|--------|------|
|
||||
| `agentic_search.py` | 检索调度:管理多轮检索、查询分解 |
|
||||
| `agentic_answer.py` | 回答生成:基于上下文生成最终回答 |
|
||||
| `agentic_citation.py` | 引用标注:在回答中插入来源引用标记 |
|
||||
| `agentic_context.py` | 上下文管理:上下文窗口控制、截断策略 |
|
||||
| `agentic_query.py` | 查询处理:查询改写、多查询生成 |
|
||||
| `agentic_media.py` | 多媒体处理:图片理解、表格解析 |
|
||||
| `agentic_quality.py` | 质量控制:回答质量评估、幻觉检测 |
|
||||
| `agentic_meta.py` | 元数据管理:知识库信息、检索统计 |
|
||||
|
||||
### 8.2 流式生成
|
||||
### 8.1 流式生成
|
||||
|
||||
使用 SSE(Server-Sent Events)实现流式输出:
|
||||
|
||||
```python
|
||||
for token in engine.generate_answer_stream(query, enhanced_context):
|
||||
for token in engine.generate_answer_stream(query, enhanced_context, history):
|
||||
yield token # 逐 token 推送给前端
|
||||
```
|
||||
|
||||
**主 LLM 模型**:qwen3.6-flash
|
||||
**VLM 模型**:qwen-vl-plus(处理图片理解任务)
|
||||
**LLM 模型**:mimo-v2.5
|
||||
|
||||
### 8.3 回答结构
|
||||
### 8.2 System Prompt
|
||||
|
||||
```
|
||||
你是一个严谨的知识库问答助手。
|
||||
你必须且只能根据用户提供的【参考资料】回答问题。
|
||||
如果参考资料中有答案,必须引用对应内容回答,并在回答末尾标注引用编号。
|
||||
如果参考资料中确实没有相关信息,简短说明即可,不要编造或补充资料外的内容。
|
||||
禁止使用参考资料以外的知识进行补充或推测。
|
||||
【重要-表格处理规则】当用户询问表格时,必须将 Markdown 表格原样输出...
|
||||
```
|
||||
|
||||
### 8.3 图片后置过滤 `_filter_images_by_answer`
|
||||
|
||||
LLM 生成回答后,根据回答内容对图片做最终过滤:
|
||||
|
||||
1. **候选 ≤ 1 张**:直接返回
|
||||
2. **无图意图早退**:回答和查询都不含图片引用词 → 返回空
|
||||
3. **图号精确豁免**:图片描述包含回答引用的具体图号/表号 → 无条件保留
|
||||
4. **主题一致性**:合并查询+回答关键词,图片描述重叠 ≥ 阈值才保留
|
||||
- 子章节惩罚:图片子章节与主要检索章节不一致时,阈值 +2
|
||||
5. **兜底**:过滤后为空且有图片意图 → 保留分数最高的 1 张
|
||||
|
||||
### 8.4 回答结构
|
||||
|
||||
```python
|
||||
{
|
||||
@@ -443,7 +495,8 @@ for token in engine.generate_answer_stream(query, enhanced_context):
|
||||
"type": "chart",
|
||||
"source": "三峡公报_2022.pdf",
|
||||
"page": 12,
|
||||
"description": "图2.3 柱状图:2003-2022年逐年发电量"
|
||||
"description": "图2.3 柱状图:2003-2022年逐年发电量",
|
||||
"full_description": "...完整 VLM 描述..."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -455,13 +508,10 @@ for token in engine.generate_answer_stream(query, enhanced_context):
|
||||
|
||||
### 9.1 引用标注机制
|
||||
|
||||
`agentic_citation.py` 负责在回答中插入引用标记,将回答内容与知识库来源关联:
|
||||
`_attach_citations()` 负责在回答中插入引用标记,将回答内容与知识库来源关联:
|
||||
|
||||
```
|
||||
根据统计数据[1],2022年三峡电站年度发电量为787.90亿千瓦时[2]。
|
||||
|
||||
[1] 来源:三峡公报_2022.pdf,第12页,综述 > 2.3 发电
|
||||
[2] 来源:三峡公报_2022.pdf,第15页,表2.1
|
||||
根据统计数据[ref:三峡公报_text_24],2022年三峡电站年度发电量为787.90亿千瓦时[ref:三峡公报_table_5]。
|
||||
```
|
||||
|
||||
### 9.2 引用数据来源
|
||||
@@ -476,10 +526,6 @@ for token in engine.generate_answer_stream(query, enhanced_context):
|
||||
| `chunk_id` | 切片唯一标识 |
|
||||
| `chunk_type` | 类型(text / table / image / chart) |
|
||||
|
||||
### 9.3 前端引用跳转
|
||||
|
||||
前端解析回答中的引用标记(如 `[1]`),渲染为可点击的链接,点击后跳转到对应的来源文件或页面。
|
||||
|
||||
---
|
||||
|
||||
## 十、文档入库流程(补充参考)
|
||||
@@ -510,28 +556,7 @@ MinerU 解析 PDF/Word/Excel 文件,输出结构化内容:
|
||||
| `image` | 图片 | content(=caption), image_path, context_before/after |
|
||||
| `chart` | 图表 | content(=caption), image_path, context_before/after |
|
||||
|
||||
### 10.2 MinerUChunk 数据结构
|
||||
|
||||
```python
|
||||
@dataclass
|
||||
class MinerUChunk:
|
||||
content: str # 文本内容
|
||||
chunk_type: str # 类型: text, table, image, chart
|
||||
page_start: int = 1 # 起始页码
|
||||
page_end: int = 1 # 结束页码
|
||||
text_level: int = 0 # 标题级别 (0=正文, 1=h1, 2=h2...)
|
||||
title: str = "" # 标题文本
|
||||
section_path: str = "" # 章节路径
|
||||
bbox: Optional[List[float]] = None # 边界框 [x0, y0, x1, y1]
|
||||
source_file: str = "" # 源文件名
|
||||
table_html: Optional[str] = None # 表格 HTML
|
||||
image_path: Optional[str] = None # 图片路径
|
||||
images: Optional[List[Dict]] = None # 关联图片列表
|
||||
context_before: str = "" # 图片前的文本上下文
|
||||
context_after: str = "" # 图片后的文本上下文
|
||||
```
|
||||
|
||||
### 10.3 切片入库
|
||||
### 10.2 切片入库
|
||||
|
||||
**入口文件**:`knowledge/manager.py`
|
||||
**核心函数**:`add_file_to_kb()`
|
||||
@@ -547,63 +572,95 @@ MinerUChunk 列表
|
||||
```
|
||||
|
||||
**图片描述策略**:
|
||||
1. 优先使用 VLM 缓存描述(语义更丰富,由 qwen-vl-plus 生成)
|
||||
1. 优先使用 VLM 缓存描述(由 mimo-v2.5 生成,语义更丰富)
|
||||
2. 若无缓存,生成轻量描述(基于文件名 + 章节路径 + 上下文)
|
||||
3. VLM 描述异步懒加载:首次检索命中时后台生成,下次查询即可使用
|
||||
|
||||
轻量描述示例:
|
||||
```
|
||||
图表:图2.3,位于「综述 > 2.3发电」,第12页
|
||||
前文:受长江流域性严重枯水影响,2022年三峡电站年度发电量为787.90亿千瓦时...
|
||||
后文:2.4航运 三峡船闸和葛洲坝船闸实行统一调度...
|
||||
```
|
||||
|
||||
VLM 描述示例(更精准):
|
||||
```
|
||||
图2.3 柱状图 主要内容描述:该柱状图展示了2003年至2022年每年的发电量(单位:亿千瓦时)。
|
||||
发电量在2003年为86.07亿千瓦时,随后逐年波动上升,至2020年达到峰值1118.02亿千瓦时...
|
||||
```
|
||||
|
||||
### 10.4 ChromaDB 存储结构
|
||||
### 10.3 ChromaDB 存储结构
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `ids` | str | 切片唯一标识,如 `doc.pdf_text_24` |
|
||||
| `embeddings` | List[float] | 向量表示 |
|
||||
| `documents` | str | 切片内容(文本/描述/摘要) |
|
||||
| `documents` | str | 切片内容(文本/VLM 描述/摘要) |
|
||||
| `metadatas` | dict | 元数据(见下表) |
|
||||
|
||||
**图片切片 metadata 示例**:
|
||||
**图片切片 metadata 关键字段**:
|
||||
|
||||
```python
|
||||
{
|
||||
'source': '三峡公报_2022.pdf',
|
||||
'page': 12,
|
||||
'chunk_type': 'chart',
|
||||
'section': '综述 > 2.3发电',
|
||||
'figure_number': '2.3',
|
||||
'image_path': 'ab77281e7913.jpg',
|
||||
'has_vlm_desc': True,
|
||||
'preview': '图2.3 柱状图 主要内容...'
|
||||
'source': '三峡公报_2022.pdf', # 源文件名
|
||||
'page': 12, # 页码
|
||||
'chunk_type': 'chart', # 切片类型
|
||||
'section': '综述 > 2.3发电', # 章节路径
|
||||
'image_path': 'ab77281e7913.jpg', # 图片文件名
|
||||
'has_vlm_desc': True, # 是否有 VLM 描述
|
||||
'vlm_desc': '图2.3 柱状图...', # VLM 描述内容
|
||||
'chunk_id': '三峡公报_chart_5', # 切片唯一标识
|
||||
'version': 'v10', # 知识库版本号
|
||||
'status': 'active', # 状态
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:ChromaDB metadata 中**没有** `full_description` 字段。图片的完整描述存储在 `vlm_desc` 字段或 `documents` 字段中。`full_description` 仅在 `select_images()` 运行时动态计算。
|
||||
|
||||
### 10.4 懒加载增强
|
||||
|
||||
**入口文件**:`knowledge/lazy_enhance.py`
|
||||
|
||||
对检索命中但没有 VLM 描述的图片切片,在后台线程异步调用 VLM 生成描述:
|
||||
|
||||
```python
|
||||
# 检索命中 → 后台线程调用 VLM → 写入文件缓存 + ChromaDB
|
||||
enhance_retrieved_chunks(contexts, query, kb_name, defer_chromadb=True)
|
||||
```
|
||||
|
||||
- **image/chart 切片**:更新 `ctx['doc']` 字段
|
||||
- **table 切片**:更新 `ctx['image_description']` 字段
|
||||
- **缓存目录**:`.data/cache/vlm/`、`.data/cache/llm/`
|
||||
|
||||
---
|
||||
|
||||
## 十一、缓存系统
|
||||
|
||||
### 11.1 缓存层级
|
||||
|
||||
| 层级 | 类 | 用途 | 容量 | TTL |
|
||||
|------|-----|------|------|-----|
|
||||
| query_cache | LRUCache | 精确查询结果缓存 | 500 | 无 |
|
||||
| embedding_cache | LRUCache | 查询 embedding 向量 | 500 | 无 |
|
||||
| rerank_cache | TTLCache | Rerank 分数缓存 | 500 | 1h |
|
||||
| semantic_cache | SemanticCache | 语义相似查询缓存 | 200 | 无 |
|
||||
| intent_exact_cache | LRUCache | 意图分析精确缓存 | 200 | 无 |
|
||||
|
||||
### 11.2 缓存失效
|
||||
|
||||
- **版本失效**:知识库版本号变更时,query_cache 和 rerank_cache 自动失效
|
||||
- **手动清除**:`POST /cache/clear`(DEV 模式)
|
||||
|
||||
### 11.3 缓存 API
|
||||
|
||||
```
|
||||
GET /cache/stats — 查看缓存统计
|
||||
POST /cache/clear — 清除所有缓存
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十一、关键文件索引
|
||||
## 十二、关键文件索引
|
||||
|
||||
| 文件 | 职责 | 关键函数/类 |
|
||||
|------|------|-------------|
|
||||
| `api/chat_routes.py` | API 路由与请求调度 | `generate()`, `select_images()`, `score_image_relevance()` |
|
||||
| `api/chat_routes.py` | API 路由与请求调度 | `rag()`, `select_images()`, `score_image_relevance()`, `_filter_images_by_answer()`, `_order_text_contexts_for_prompt()`, `_build_context_with_budget()` |
|
||||
| `core/engine.py` | 检索引擎核心 | `search_knowledge()`, `rerank_results()`, `generate_answer_stream()` |
|
||||
| `core/intent_analyzer.py` | 意图分析 | `analyze()`, `IntentAnalysis` |
|
||||
| `core/engine.py` | 检索引擎核心 | `search_knowledge()`, `reciprocal_rank_fusion()`, `rerank_results()` |
|
||||
| `core/agentic_search.py` | 检索调度 | 多轮检索、查询分解 |
|
||||
| `core/agentic_answer.py` | 回答生成 | 基于上下文生成最终回答 |
|
||||
| `core/agentic_citation.py` | 引用标注 | 来源引用标记插入 |
|
||||
| `core/agentic_context.py` | 上下文管理 | 上下文窗口控制、截断 |
|
||||
| `core/agentic_query.py` | 查询处理 | 查询改写、多查询生成 |
|
||||
| `core/agentic_media.py` | 多媒体处理 | 图片理解、表格解析 |
|
||||
| `core/agentic_quality.py` | 质量控制 | 回答质量评估、幻觉检测 |
|
||||
| `core/agentic_meta.py` | 元数据管理 | 知识库信息、检索统计 |
|
||||
| `core/bm25_index.py` | BM25 关键词检索 | `BM25Index.search()` |
|
||||
| `core/mmr.py` | MMR 去重 | `mmr_rerank()` |
|
||||
| `core/cache.py` | 缓存管理 | `RAGCacheManager`, `get_cache_manager()` |
|
||||
| `core/semantic_cache.py` | 语义缓存 | `SemanticCache` |
|
||||
| `core/chunker.py` | 语义分块器 | `SemanticChunker` |
|
||||
| `core/llm_utils.py` | LLM 调用工具 | `call_llm()`, `call_llm_stream()` |
|
||||
| `parsers/mineru_parser.py` | 文档解析 | `parse_with_mineru()`, `MinerUChunk` |
|
||||
| `knowledge/manager.py` | 知识库管理 | `add_file_to_kb()`, `generate_lightweight_image_description()` |
|
||||
| `knowledge/manager.py` | 知识库管理 | `add_file_to_kb()`, `KBManager` |
|
||||
| `knowledge/lazy_enhance.py` | 懒加载增强 | `lazy_vlm_description()`, `enhance_retrieved_chunks()` |
|
||||
| `config.py` | 配置集中管理 | 模型/参数/开关 |
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
10. [返回格式与溯源信息](#十返回格式与溯源信息)
|
||||
11. [配置项完整列表](#十一配置项完整列表)
|
||||
12. [单/多知识库路径说明](#十二单多知识库路径说明)
|
||||
13. [切片策略与检索策略兼容性分析](#十三切片策略与检索策略兼容性分析)
|
||||
14. [MinerU 解析策略](#十四mineru-解析策略)
|
||||
|
||||
---
|
||||
|
||||
@@ -121,6 +123,13 @@ class MinerUChunk:
|
||||
images: Optional[List[Dict]] = None # 关联图片: [{"id":"abc.jpg","order":1}]
|
||||
context_before: str = "" # 图片前文本上下文
|
||||
context_after: str = "" # 图片后文本上下文
|
||||
# VLM 增强信息(云端 MinerU V4 VLM 后端提供)
|
||||
vlm_description: str = "" # VLM 视觉描述(图片/图表)
|
||||
chart_markdown: str = "" # VLM 提取的图表数据表(Markdown 格式)
|
||||
# MinerU 结构化元数据
|
||||
table_type: str = "" # 表格类型(cflow/table/text,来自 V2 _v2_table_type)
|
||||
table_nest_level: str = "" # 表格嵌套层级(来自 V2 _v2_table_nest_level)
|
||||
sub_type: str = "" # 图片/图表子类型(natural_image/table_image/bar/line 等)
|
||||
```
|
||||
|
||||
### 2.3 section_path 生成逻辑
|
||||
@@ -210,6 +219,11 @@ ChromaDB 使用 `collection.add(ids, documents, metadatas, embeddings)` 批量
|
||||
| `version` | str | 默认 `"v1"` | 版本号,用于缓存失效 | `"v1"` |
|
||||
| `images_json` | str | `json.dumps(chunk.images)` | 关联图片列表的 JSON 序列化,图片选择时反序列化 | `'[{"id":"abc.jpg","order":1}]'` |
|
||||
| `image_path` | str | MinerU 解析 | 图片文件名(不含目录),用于图片 URL 生成 | `"0569dd285537.jpg"` |
|
||||
| `text_level` | int | MinerU 解析 | 标题级别(0=正文, 1=H1, 2=H2, 3=H3),用于层次感知检索 | `2` |
|
||||
| `bbox` | str | MinerU 解析(PDF) | 边界框 JSON 序列化 `[x0,y0,x1,y1]`,用于前端页内精准定位。仅 PDF 有值,DOCX 为 None 不存储 | `"[100,200,500,600]"` |
|
||||
| `table_type` | str | MinerU V2 解析 | 表格类型(cflow/table/text),用于区分表格渲染方式 | `"table"` |
|
||||
| `table_nest_level` | str | MinerU V2 解析 | 表格嵌套层级,标识嵌套表格的深度 | `"2"` |
|
||||
| `sub_type` | str | MinerU V2 解析 | 图片/图表子类型(natural_image/table_image/bar/line/bar_line 等),用于前端差异化展示 | `"bar"` |
|
||||
|
||||
> **注意**:`chunk_id` 与 `ids` 重复存储。`ids` 是 ChromaDB 的主键,`chunk_id` 存在 metadata 中便于路由层按 metadata 查找。两者值相同但用途不同。
|
||||
|
||||
@@ -730,7 +744,7 @@ LLM 生成回答后,用回答内容反向过滤图片:
|
||||
| `CLUSTER_MAX_BOOST_PER_SECTION` | `8` | 引擎层每 section 最大提升数 | ✅ 活跃 |
|
||||
| `CLUSTER_MAX_SECTIONS` | `3` | 全局最大提升 section 数 | ✅ 活跃 |
|
||||
| `CLUSTER_MAX_RESCUE_PER_SECTION` | `6` | 路由层每 section 最大救援数 | ✅ 活跃 |
|
||||
| `CLUSTER_SECTION_PREFIX_LEVELS` | `1` | section 归一化层级 | ✅ 活跃 |
|
||||
| `CLUSTER_SECTION_PREFIX_LEVELS` | `2` | section 归一化层级(按章节前两级分组) | ✅ 活跃 |
|
||||
|
||||
### LLM 预算配置
|
||||
|
||||
@@ -772,3 +786,287 @@ LLM 生成回答后,用回答内容反向过滤图片:
|
||||
- 被 `knowledge/router.py` 使用(知识库路由推荐功能)
|
||||
- **不走 engine 主流程**
|
||||
- 返回 `SearchResult` dataclass(扁平列表格式),与 engine 的 ChromaDB 格式不兼容
|
||||
|
||||
---
|
||||
|
||||
## 十三、切片策略与检索策略兼容性分析
|
||||
|
||||
本节基于 `public_kb` 三份文档(1.docx / 2.docx / 3.docx)的实际切片数据,分析切片策略与检索管线各环节的搭配情况,识别已确认的失配点并提出优化方向。
|
||||
|
||||
### 13.1 public_kb 切片统计
|
||||
|
||||
| 指标 | 1.docx | 2.docx | 3.docx |
|
||||
|------|--------|--------|--------|
|
||||
| 总切片数 | 65 | 193 | 411 |
|
||||
| 文本切片 | 60 | 144 | 385 |
|
||||
| 表格切片 | 3 | 49 | 21 |
|
||||
| 图片切片 | 2 | 0 | 5 |
|
||||
| 纯标题切片(<20字) | 16 (27%) | 61 (42%) | 97 (25%) |
|
||||
| 正文碎片(<100字) | 2 | 4 | 5 |
|
||||
| 文本中位长度 | 122字 | 24字 | 66字 |
|
||||
| 文本最大长度 | 725字 | 798字 | 757字 |
|
||||
| 连续标题链(≥2个) | 3条 | 22条 | 34条 |
|
||||
|
||||
**关键发现**:2.docx 的纯标题切片占比高达 42%,3.docx 有 34 条连续标题链(其中一条包含 21 个连续标题——附件列表)。这些标题切片不包含实质内容,但在向量库中占据存储空间,并参与检索管线的所有阶段。
|
||||
|
||||
### 13.2 切片策略概述
|
||||
|
||||
当前切片策略以**标题层级**为核心拆分依据:
|
||||
|
||||
```
|
||||
MinerU 解析 → content_list → 逐项构建 MinerUChunk
|
||||
→ heading_rules 引擎识别标题级别(text_level)
|
||||
→ 每个标题开始新的 section_path
|
||||
→ _post_process_chunks 三阶段后处理:
|
||||
Phase 1: 过滤空切片
|
||||
Phase 2: 合并碎片(标题+正文、短文本 < min_merge_size=100)
|
||||
Phase 3: 拆分超长(> max_chunk_size=1000)
|
||||
```
|
||||
|
||||
**合并规则**:
|
||||
- 标题 chunk(`text_level > 0`)刷新缓冲并开始新合并组
|
||||
- 正文 chunk 若 `< min_merge_size`(100字)则并入缓冲
|
||||
- 正文 chunk 若 `≥ min_merge_size` 且缓冲已满则直接输出
|
||||
- 合并上限 `max_merged_size = 800` 字
|
||||
|
||||
**后果**:每个标题级别(H1/H2/H3)的段落都会产生独立的 chunk。当标题下方没有正文(或正文在子标题下)时,产生"纯标题切片"。
|
||||
|
||||
### 13.3 已确认的失配点
|
||||
|
||||
#### 失配 1:纯标题切片的语义内容冗余(严重)
|
||||
|
||||
**问题**:纯标题切片存入 ChromaDB 的 `documents` 字段是同一文本的三次重复:
|
||||
|
||||
```
|
||||
三、做好货源投放前的基础工作 ← title(text_level > 0 时追加)
|
||||
主题:三、做好货源投放前的基础工作 ← section_path(最多3级)
|
||||
三、做好货源投放前的基础工作 ← content(原始文本)
|
||||
```
|
||||
|
||||
生成代码见 `knowledge/base.py:231-254`(`_build_semantic_content_for_text`)。
|
||||
|
||||
**影响**:
|
||||
- Embedding 向量几乎无区分度——所有标题的向量高度相似
|
||||
- 向量检索几乎不会命中这些切片(内容查询与重复标题的相似度极低)
|
||||
- 浪费向量库存储空间(2.docx 有 61 个此类切片)
|
||||
|
||||
#### 失配 2:上下文扩展的 section 精确匹配(严重)
|
||||
|
||||
**问题**:`_expand_contiguous_chunks`(`engine.py:1334`)在 section 过滤时使用**精确匹配**:
|
||||
|
||||
```python
|
||||
where_filter = {"$and": [{"source": source}, {"section": section}]}
|
||||
```
|
||||
|
||||
当种子是父级标题(如 `section = "三、做好货源投放前的基础工作"`)时:
|
||||
- 子标题下的正文切片 `section = "三 > (一)货源投放要求 > ..."` **不匹配**
|
||||
- 回退到 source-only 模式,从整个文档中拉取相邻 `chunk_index` 的切片
|
||||
- 对大文档,回退模式可能拉取到不相关的切片
|
||||
|
||||
**影响**:父级标题被检索到时,无法通过 section 过滤拉取其子节内容,降低了上下文完整性。
|
||||
|
||||
#### 失配 3:~~`text_level` 未传入检索管线~~ ✅ 已修复
|
||||
|
||||
**原问题**:`text_level`(标题级别 0/1/2/3)在切片阶段被精确计算,但未存入 ChromaDB metadata,检索管线无法感知标题层级。
|
||||
|
||||
**修复**:`knowledge/manager.py` 的 metadata 构建处已新增 `text_level` 字段:
|
||||
|
||||
```python
|
||||
metadata = {
|
||||
...
|
||||
'text_level': getattr(chunk, 'text_level', 0), # 已新增
|
||||
}
|
||||
```
|
||||
|
||||
**当前状态**:`text_level` 已存入 ChromaDB metadata,为后续层次感知检索提供了数据基础。引擎层和路由层尚未利用此字段做特殊处理(如标题切片降权、导航锚点扩展),但数据层已就绪。
|
||||
|
||||
#### 失配 4:~~章节聚类归一化粒度过粗~~ ✅ 已调整
|
||||
|
||||
**原问题**:`CLUSTER_SECTION_PREFIX_LEVELS = 1` 将 section_path 归一化到第一级,不同二级章节的切片被归入同一聚类组,可能触发误提升。
|
||||
|
||||
**修复**:`config.py` 中已将 `CLUSTER_SECTION_PREFIX_LEVELS` 从 `1` 调整为 `2`:
|
||||
|
||||
```python
|
||||
CLUSTER_SECTION_PREFIX_LEVELS = 2 # section_path 归一化保留的层级数(按章节前两级分组,提升聚类精确度)
|
||||
```
|
||||
|
||||
**当前状态**:归一化到前两级(如 `(二)货源投放要求 > 7.关于主导品规投放`),聚类信号精确度提升。对深层嵌套文档(3 级及以上 section)的影响已通过 `CLUSTER_MAX_SECTIONS=3` 和 `CLUSTER_MIN_TYPES=2` 约束。
|
||||
|
||||
#### 失配 5:预算构建器中标题切片的开销(轻微)
|
||||
|
||||
**问题**:`_build_context_with_budget`(`chat_routes.py:866`)按 `(source, section)` 分组后,每个组添加 `━ {section} ━` 分隔行。纯标题切片形成单例组:
|
||||
|
||||
```
|
||||
━ 三、做好货源投放前的基础工作 ━ ← ~25字符开销
|
||||
三、做好货源投放前的基础工作 ← ~14字符内容(信息量≈0)
|
||||
```
|
||||
|
||||
**影响**:约 40 字符的预算被浪费在无信息量的组上。在 `CONTEXT_MAX_CHARS = 8000` 的预算下,少量标题切片影响可控,但 2.docx 的 61 个标题切片如果被拉入就会累积显著开销。
|
||||
|
||||
#### 失配 6:MMR 去重对标题切片的过度消除(模式相关)
|
||||
|
||||
**问题**:Embedding-based MMR(`MMR_USE_EMBEDDING=True`)模式下,标题切片因 Embedding 高度相似而相互惩罚。Jaccard 模式(`MMR_USE_EMBEDDING=False`,当前设置)因词级分词有一定区分度,问题较轻。
|
||||
|
||||
**影响**:当标题切片恰好是某子章节的唯一入口时,被 MMR 消除后该子章节在检索结果中完全丢失。当前使用 Jaccard 模式,此问题暂未触发。
|
||||
|
||||
#### 失配 7:~~3.docx 的 section_path 污染~~ ✅ 已修复
|
||||
|
||||
**原问题**:3.docx 中存在一个完整的合同条款段落被误识别为 H1 标题,导致 section_path 极长且无语义意义,该 section 下 59 个切片分组失真。
|
||||
|
||||
**修复**:`parsers/heading_rules.py` 中新增了两层防护:
|
||||
|
||||
1. **`_validate_level` 超长文本降级**:H1 > 40字、H2 > 60字、H3 > 50字时自动降为正文(level=0)。覆盖所有返回路径(v1 常规匹配、v2 style 匹配、bold_short_text 兜底)。
|
||||
2. **规则级 `max_length` + `exclude_pattern`**:部分 H1 规则(如 `numeric_level1`)设置 `max_length=50` 和排除句末标点(`;;。,、::`)的 `exclude_pattern`,在匹配阶段即过滤段落文本。
|
||||
|
||||
**当前状态**:长段落不再被误判为标题,section_path 污染问题已消除。
|
||||
|
||||
### 13.4 切片-检索兼容性总结
|
||||
|
||||
| 失配点 | 严重度 | 影响范围 | 现状 |
|
||||
|--------|--------|---------|------|
|
||||
| 标题语义内容冗余 | 🔴 严重 | 全部文档 | 670 个向量库切片中约 174 个是纯标题 |
|
||||
| section 精确匹配 | 🔴 严重 | 父级标题检索 | 回退到 source-only,可能拉取不相关内容 |
|
||||
| ~~text_level 未传入~~ | ~~🟠 显著~~ | ~~全局~~ | ✅ 已修复:text_level 已存入 ChromaDB metadata |
|
||||
| ~~聚类归一化过粗~~ | ~~🟡 中等~~ | ~~聚类提升/救援~~ | ✅ 已修复:CLUSTER_SECTION_PREFIX_LEVELS 调整为 2 |
|
||||
| 预算构建开销 | 🟢 轻微 | 上下文构建 | 标题单例组浪费字符预算 |
|
||||
| MMR 过度消除 | 🟢 模式相关 | MMR 去重 | 当前 Jaccard 模式暂未触发 |
|
||||
| ~~section_path 污染~~ | ~~🟡 中等~~ | ~~3.docx~~ | ✅ 已修复:heading_rules 超长文本降级防护 |
|
||||
|
||||
### 13.5 优化建议
|
||||
|
||||
#### 短期(改动小,收益明确)
|
||||
|
||||
**1. ~~后处理合并连续标题链~~ ✅ 已实施**
|
||||
|
||||
已在 `_post_process_chunks` Phase 2 中实现连续标题链合并:
|
||||
|
||||
- **H1 级标题是章节边界**,强制断开,不参与链合并
|
||||
- **非 H1 连续标题**(H2/H3)合并为单个 chunk,合并后取更高层级(数值更小),保留第一个标题的 section_path
|
||||
- 合并上限仍为 `max_merged_size = 800` 字
|
||||
|
||||
**2. ~~存储 `text_level` 到 ChromaDB metadata~~ ✅ 已实施**
|
||||
|
||||
`knowledge/manager.py` 中已新增 `text_level` 字段存储,为后续层次感知检索提供数据基础。
|
||||
|
||||
**3. 后处理合并标题与首个子节正文**
|
||||
|
||||
当一个标题切片的下一个切片是子标题(更高 `text_level` 数值)下的正文时,将标题文本前置到子节切片中:
|
||||
|
||||
```
|
||||
当前:#7 [H2] "四、相关的指标与分类"(10字) → #8 [H2] "(一)货源属性分类\n1.紧俏品规..."(349字)
|
||||
优化:#7 [H2] "四、相关的指标与分类\n(一)货源属性分类\n1.紧俏品规..."(359字)
|
||||
```
|
||||
|
||||
预期收益:消除父级标题的孤立切片,同时为子节切片提供上层上下文。
|
||||
|
||||
#### 中期(需要评测验证)
|
||||
|
||||
**4. 上下文扩展支持 section 前缀匹配**
|
||||
|
||||
将 `_expand_contiguous_chunks` 的 section 过滤从精确匹配改为前缀匹配:
|
||||
|
||||
```python
|
||||
# 当前:精确匹配
|
||||
{"section": section}
|
||||
# 优化:前缀匹配(拉取所有子节切片)
|
||||
{"section": {"$regex": f"^{re.escape(section)}"}}
|
||||
```
|
||||
|
||||
需评估:前缀匹配可能拉取过多切片,需要配合 `CONTEXT_EXPANSION_MAX_CHUNKS` 上限控制。
|
||||
|
||||
**5. ~~调整 `CLUSTER_SECTION_PREFIX_LEVELS`~~ ✅ 已实施**
|
||||
|
||||
已从 `1` 调整为 `2`,聚类信号精确度提升。
|
||||
|
||||
#### 长期(架构级改进)
|
||||
|
||||
**6. 层次感知检索**
|
||||
|
||||
利用已存入 metadata 的 `text_level` 实现结构感知的检索策略:
|
||||
- 标题切片作为"导航锚点",被检索到时自动拉取其 section_path 前缀下的所有子切片
|
||||
- MMR 去重时对标题切片设置保护阈值,确保每个主要章节至少保留一个入口
|
||||
- 预算构建器对标题单例组做特殊处理(合并到子节组或跳过)
|
||||
|
||||
**7. ~~修复 section_path 污染~~ ✅ 已实施**
|
||||
|
||||
`parsers/heading_rules.py` 中 `_validate_level` 方法已实现超长文本降级防护(H1>40字、H2>60字、H3>50字降为正文)。
|
||||
|
||||
**8. bbox 空间感知检索**(新增)
|
||||
|
||||
利用已存入 metadata 的 `bbox` 坐标实现空间感知的检索策略:
|
||||
- 同一页面相邻区域的切片在检索时可做空间聚类
|
||||
- 图片/图表切片的 bbox 可用于判断其在文档中的物理位置关系
|
||||
- 仅 PDF 切片有 bbox,DOCX 切片无此信息
|
||||
|
||||
**9. VLM 描述增强图片检索**(新增)
|
||||
|
||||
利用已存入 MinerUChunk 的 `vlm_description` 和 `chart_markdown` 字段:
|
||||
- 将 VLM 视觉描述注入图片切片的 semantic_content,提升向量检索的语义匹配度
|
||||
- chart_markdown(图表数据表)可增强图表切片的 BM25 关键词匹配
|
||||
- 仅云端 MinerU V4 VLM 后端提供,本地 pipeline 后端无此信息
|
||||
|
||||
---
|
||||
|
||||
## 十四、MinerU 解析策略
|
||||
|
||||
### 14.1 云端优先 + 本地备选
|
||||
|
||||
当前解析策略为**云端 MinerU V4 API 优先,本地 MinerU 备选**:
|
||||
|
||||
| 维度 | 云端 MinerU V4 | 本地 MinerU |
|
||||
|------|---------------|------------|
|
||||
| **入口** | `parse_with_mineru_online()` | `parse_with_mineru()` |
|
||||
| **后端** | VLM(视觉语言模型) | pipeline(传统 OCR) |
|
||||
| **解析速度** | ~5 秒(含网络) | 取决于 GPU/CPU |
|
||||
| **V2 数据丰富度** | 丰富:bbox、title_content、VLM 描述、list_items、sub_type | 基础:无 bbox、无 title type、无 VLM 描述 |
|
||||
| **配置项** | `MINERU_PREFER_ONLINE=True` | `MINERU_LOCAL_BACKEND="pipeline"` |
|
||||
| **回退逻辑** | 失败自动回退本地 | 最终回退方案 |
|
||||
|
||||
**调度流程**:
|
||||
```
|
||||
parse_with_mineru_persistent()
|
||||
→ config.MINERU_PREFER_ONLINE=True
|
||||
→ parse_with_mineru_online() ← 优先
|
||||
→ 成功 → 返回
|
||||
→ 失败 → parse_with_mineru() ← 本地回退(pipeline 后端)
|
||||
```
|
||||
|
||||
### 14.2 V2 content_list 解析
|
||||
|
||||
`_parse_v2_content_list()` 负责将 MinerU V2 格式的 content_list 转换为 MinerUChunk 列表。V2 是 MinerU 的结构化输出格式,比 V1 包含更丰富的元数据。
|
||||
|
||||
**V2 与 V1 的关键差异**:
|
||||
|
||||
| 维度 | V2 格式 | V1 格式 |
|
||||
|------|---------|---------|
|
||||
| 标题文本 | `title_content` 字段 | `text` 字段 |
|
||||
| 图片路径 | `image_source.path` | `img_path` |
|
||||
| VLM 描述 | `content.content` | 无 |
|
||||
| 列表 | `list_items` 数组 | 无(直接文本) |
|
||||
| 表格标题 | `table_caption`(列表格式) | `table_caption`(字符串) |
|
||||
| bbox | 所有元素都有 | 仅部分元素 |
|
||||
|
||||
**V2 解析已处理的类型**:
|
||||
- `title`:读取 `title_content`(PDF),回退 `paragraph_content`(DOCX)
|
||||
- `paragraph`:读取 `paragraph_content`
|
||||
- `table`:提取 `table_caption`(列表格式解析)、`table_footnote`、`image_source.path`、`table_nest_level`
|
||||
- `image` / `chart`:提取 `image_source.path`(回退 `img_path`)、VLM 描述、chart_markdown、`sub_type`、caption(列表格式)
|
||||
- `equation`:读取 `text`
|
||||
- `list`:拼接 `list_items` 为段落文本
|
||||
|
||||
### 14.3 DOCX vs PDF 后端差异
|
||||
|
||||
云端 MinerU 对 DOCX 和 PDF 使用不同的解析后端,导致 V2 输出有显著差异:
|
||||
|
||||
| 特征 | DOCX(office 后端) | PDF(hybrid/VLM 后端) |
|
||||
|------|---------------------|----------------------|
|
||||
| **bbox** | ❌ 无 | ✅ 所有元素都有 |
|
||||
| **title type/level** | ❌ 无(仅 bold style) | ✅ 有(H1/H2/H3) |
|
||||
| **VLM 描述** | ❌ 无 | ✅ 图片/图表有视觉描述 |
|
||||
| **chart_markdown** | ❌ 无 | ✅ 图表有数据表 Markdown |
|
||||
| **list_items** | ❌ 无 | ✅ 结构化列表 |
|
||||
| **sub_type** | ❌ 无 | ✅ natural_image/bar/line 等 |
|
||||
| **title 字段名** | `paragraph_content` | `title_content` |
|
||||
| **图片路径** | `image_source.path` | `image_source.path` |
|
||||
|
||||
**策略**:DOCX 的标题层级由本地 `heading_rules.py` 引擎基于文本模式补充推断;PDF 的标题层级直接使用 MinerU 提供的 `text_level`。
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# RAG 系统完整指南
|
||||
|
||||
> **版本**: v4.0(统一编排 + 四层缓存修复)
|
||||
> **版本**: v4.1(模型切换 + 图片检索修复)
|
||||
> **生产入口**: `api/chat_routes.py::rag()` → `generate()` → `core/engine.py`
|
||||
> **最后更新**: 2026-06-05
|
||||
> **最后更新**: 2026-06-21
|
||||
>
|
||||
> 本次更新:删除未使用的 AgenticRAG 备用编排路径(10 个文件 ~2050 行),修复 Query Cache 键不匹配与阈值问题,将语义缓存集成至生产 `/rag` 端点。
|
||||
> 本次更新:LLM/意图/VLM 模型统一切换至 mimo-v2.5(xiaomimimo.com API),Reranker 切换至 xop3qwen8breranker(讯飞云 API),新增 chart_contexts 降门槛、P0 安全网、答案后过滤等图片检索修复逻辑。
|
||||
|
||||
## 一、功能概述
|
||||
|
||||
@@ -14,12 +14,13 @@
|
||||
|------|------|----------|
|
||||
| **意图分析** | LLM 驱动的双层判断(是否需要检索)+ 查询改写 | `core/intent_analyzer.py` |
|
||||
| **混合检索** | 向量检索 + BM25 + RRF 融合 + Rerank 重排 | `core/engine.py` |
|
||||
| **四层缓存** | Query + Embedding + Rerank(LRU)+ 语义缓存(FAISS) | `core/cache.py` + `core/semantic_cache.py` |
|
||||
| **五层缓存** | Query + Embedding + Rerank(LRU)+ 语义缓存(FAISS)+ ChromaDB 元数据缓存 | `core/cache.py` + `core/semantic_cache.py` |
|
||||
| **流式生成** | SSE 流式答案输出,逐 token 推送 | `core/engine.py::generate_answer_stream()` |
|
||||
| **引用标注** | 自动标注信息来源和引用编号 | `api/chat_routes.py::_attach_citations()` |
|
||||
| **富媒体** | 图片/表格的智能提取与展示 | `api/chat_routes.py` |
|
||||
| **富媒体** | 图片/表格的智能提取与展示 + P0 安全网 + 答案后过滤 | `api/chat_routes.py` |
|
||||
| **查询理解** | 查询分解、扩展、MMR 去重、自适应 TopK | `core/` 各独立模块 |
|
||||
| **安全护栏** | 敏感信息过滤、Prompt 安全守卫 | `api/response_utils.py`、`core/prompt_guard.py` |
|
||||
| **救援管线** | BM25 散度救援、词法匹配救援、章节聚类救援、表格救援 | `core/engine.py` |
|
||||
|
||||
---
|
||||
|
||||
@@ -78,9 +79,12 @@
|
||||
│ │ FAQ 加权 │→ │ 黑名单过滤 │→ │ 时间衰减 │ │
|
||||
│ └───────────┘ └──────────────┘ └──────┬───────┘ │
|
||||
│ ↓ │
|
||||
│ ┌───────────────┐ ┌──────────────┐ │
|
||||
│ │ 上下文扩展 │→ │ 自适应 TopK │ │
|
||||
│ └───────────────┘ └──────────────┘ │
|
||||
│ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ 上下文扩展 │→ │ 自适应 TopK │→ │ 救援管线 │ │
|
||||
│ └───────────────┘ └──────────────┘ │ (BM25散度/ │ │
|
||||
│ │ 词法/章节/ │ │
|
||||
│ │ 表格救援) │ │
|
||||
│ └──────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
@@ -105,7 +109,7 @@
|
||||
|
||||
---
|
||||
|
||||
## 三、四层缓存架构
|
||||
## 三、五层缓存架构
|
||||
|
||||
### 3.1 缓存层次概览
|
||||
|
||||
@@ -115,6 +119,7 @@
|
||||
| L2 | Embedding Cache | LRU (OrderedDict) | 2000 条 | 24 小时 | 缓存向量化结果,避免重复调用 embedding 模型 |
|
||||
| L3 | Rerank Cache | LRU (OrderedDict) | 1000 条 | 1 小时 | 缓存 Rerank 分数,避免重复调用 Reranker |
|
||||
| L4 | Semantic Cache | FAISS IndexFlatIP | 10000 条 | 无过期 | 语义级缓存,相似查询也能命中 |
|
||||
| L5 | ChromaDB 元数据缓存 | 进程内 dict | 无限制 | 无过期 | 缓存 ChromaDB Collection 元数据(kb_version 等),避免频繁查询 ChromaDB |
|
||||
|
||||
### 3.2 Query Cache
|
||||
|
||||
@@ -288,9 +293,9 @@ search_knowledge(query, top_k=30)
|
||||
│
|
||||
├─ 7. 章节过滤(查询提到章节时优先匹配)
|
||||
│
|
||||
├─ 8. ★ Rerank 重排 ★(云端 DashScope qwen3-rerank 或本地 BGE)
|
||||
│ └─ rerank_results(query, results, top_k)
|
||||
│ └─ 由 RERANK_BACKEND 控制(local/cloud/fallback)
|
||||
├─ 8. ★ Rerank 重排 ★(云端讯飞 xop3qwen8breranker 或本地 BGE)
|
||||
│ └─ rerank_results(query, results, top_k)
|
||||
│ └─ 由 RERANK_BACKEND 控制(local/cloud/fallback)
|
||||
│
|
||||
├─ 9. MMR 去重
|
||||
│ ├─ 语义向量版(MMR_USE_EMBEDDING=True)
|
||||
@@ -306,7 +311,9 @@ search_knowledge(query, top_k=30)
|
||||
│
|
||||
├─ 14. 自适应 TopK(根据置信度调整返回数量)
|
||||
│
|
||||
└─ 15. 缓存写入 → 返回结果
|
||||
├─ 15. 救援管线(BM25散度/词法匹配/章节聚类/表格救援)
|
||||
│
|
||||
└─ 16. 缓存写入 → 返回结果
|
||||
```
|
||||
|
||||
### 5.2 混合检索代码示例
|
||||
@@ -330,7 +337,7 @@ image_results = collection.query(
|
||||
# RRF 融合(动态权重)
|
||||
fused = reciprocal_rank_fusion([vector_results, bm25_results], weights=[vector_w, bm25_w])
|
||||
|
||||
# Rerank 重排(云端 DashScope qwen3-rerank 或本地 BGE)
|
||||
# Rerank 重排(云端讯飞 xop3qwen8breranker 或本地 BGE)
|
||||
reranked = rerank_results(query, fused, top_k=15)
|
||||
|
||||
# MMR 去重
|
||||
@@ -359,7 +366,7 @@ RRF分数 = Σ (权重 / (k + 排名位置))
|
||||
|
||||
| RERANK_BACKEND | 说明 |
|
||||
|----------------|------|
|
||||
| `"cloud"` | 仅使用云端 DashScope `qwen3-rerank` API |
|
||||
| `"cloud"` | 仅使用云端讯飞 `xop3qwen8breranker` API |
|
||||
| `"local"` | 仅使用本地 `BAAI/bge-reranker-base`(CrossEncoder / ONNX) |
|
||||
| `"fallback"` | 优先云端,失败时自动回退本地(推荐生产环境) |
|
||||
|
||||
@@ -368,13 +375,13 @@ RRF分数 = Σ (权重 / (k + 排名位置))
|
||||
```python
|
||||
# config.py
|
||||
RERANK_BACKEND = os.getenv("RERANK_BACKEND", "local")
|
||||
RERANK_CLOUD_MODEL = "qwen3-rerank"
|
||||
RERANK_CLOUD_MODEL = "xop3qwen8breranker"
|
||||
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://maas-api.cn-huabei-1.xf-yun.com/v1/rerank"
|
||||
RERANK_CLOUD_TIMEOUT = 15
|
||||
```
|
||||
|
||||
`CloudReranker` 类(`core/engine.py`)封装 DashScope 的 `/compatible-api/v1/reranks` 接口,提供与本地 `CrossEncoder.predict()` / `ONNXReranker.predict()` 一致的调用接口。
|
||||
`CloudReranker` 类(`core/engine.py`)封装讯飞云的 `/v1/rerank` 接口,提供与本地 `CrossEncoder.predict()` / `ONNXReranker.predict()` 一致的调用接口。
|
||||
|
||||
**本地 Reranker(备选)**:
|
||||
|
||||
@@ -424,12 +431,16 @@ POST /rag (SSE 流式)
|
||||
├─ 5. 图片补充检索 + 图片打分选择 (select_images)
|
||||
│ └─[DEV] 发 SSE: images_selected
|
||||
├─ 6. 构建上下文 (_order_texts_for_prompt)
|
||||
│ └─ chart_contexts 使用 min_score * 0.5 降门槛
|
||||
│ └─[DEV] 发 SSE: context_built
|
||||
│
|
||||
├─ 6.5. P0 安全网 — 确保 selected_images 描述完整进入 LLM 上下文
|
||||
│
|
||||
├─ 7. 流式答案生成 engine.generate_answer_stream()
|
||||
│ └─ 逐 token 发 SSE: chunk
|
||||
│
|
||||
├─ 8. 答案图号对齐过滤
|
||||
├─ 8.5. 答案后过滤 (_filter_images_by_answer) — 根据 LLM 答案过滤不相关图片
|
||||
├─ 9. 引用标注 _attach_citations()
|
||||
├─ 10. 敏感信息过滤 filter_response()
|
||||
├─ 11. 语义缓存写入 SemanticCache.set() # 写入缓存供后续命中
|
||||
@@ -454,6 +465,76 @@ POST /rag (SSE 流式)
|
||||
|
||||
> 标注 [DEV] 的事件仅在 `IS_DEV=True` 时发送。
|
||||
|
||||
### 6.1 图片检索子系统
|
||||
|
||||
图片检索是独立于文本上下文管线的子系统,存在特有的"两管线断裂"问题,已通过多重修复保障完整性。
|
||||
|
||||
**图片检索流程**:
|
||||
|
||||
```
|
||||
search_knowledge() 返回混合检索结果
|
||||
↓
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ _order_text_contexts_for_prompt() — 上下文提取与构建 │
|
||||
│ │
|
||||
│ 1. 分离 text_contexts 和 chart_contexts │
|
||||
│ 2. chart_contexts 使用 min_score * 0.5 降门槛过滤 │
|
||||
│ (CrossEncoder 对图片打分系统性偏低:0.002-0.08) │
|
||||
│ 3. chart_contexts 的描述注入 context_text 【相关图片信息】 │
|
||||
│ 4. text_contexts 正常注入 context_text │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
↓ ↓
|
||||
┌──────────────────┐ ┌──────────────────────────────────────┐
|
||||
│ select_images() │ │ context_text 送入 LLM │
|
||||
│ 独立图片选择 │ │ (可能不包含所有选中图片的描述) │
|
||||
│ P1: BM25/向量 │ └──────────────────────────────────────┘
|
||||
│ P2: VLM 检查 │
|
||||
│ P3: CE 排名 │
|
||||
│ P4: 多样性去重 │
|
||||
└────────┬─────────┘
|
||||
↓
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ P0 安全网 — 保证 selected_images 描述完整进入 LLM 上下文│
|
||||
│ │
|
||||
│ 检查 context_text 中的 【相关图片信息】 部分, │
|
||||
│ 若 selected_images 的描述不在 context_text 中, │
|
||||
│ 强制追加缺失的描述。 │
|
||||
│ (解决两管线断裂:图片被选中但描述被 min_score 过滤掉) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
↓
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ _filter_images_by_answer() — 答案后过滤 │
|
||||
│ │
|
||||
│ LLM 生成答案后,根据答案内容过滤不相关图片。 │
|
||||
│ ⚠️ 鸡生蛋问题:若 LLM 因上下文缺失而回答"未找到", │
|
||||
│ 会导致正确图片被误过滤。P0 安全网可缓解此问题。 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**select_images 四阶段选择**:
|
||||
|
||||
| 阶段 | 方法 | 说明 |
|
||||
|------|------|------|
|
||||
| P1 | BM25/向量召回 | 从检索结果中筛选 chunk_type 为 image/chart 的候选 |
|
||||
| P2 | VLM 相关性检查 | `_check_vlm_relevance()` 判断图片与查询的相关性,不相关者 -3.0 惩罚 |
|
||||
| P3 | CrossEncoder 排名 | CE<0 移除,CE 0~2 保留无加成,CE>2 加分 |
|
||||
| P4 | 多样性去重 | 同文档多图片按分数保留 TopN,避免单一文档图片垄断 |
|
||||
|
||||
**CrossEncoder 图片评分特征**:CrossEncoder 对图片/图表的打分系统性低于文本(0.002-0.08 vs 0.3-0.9),因此 `chart_contexts` 使用 `min_score * 0.5` 的降门槛策略。
|
||||
|
||||
**VLM 相关性检查**:`_check_vlm_relevance()` 使用 VLM 模型判断图片内容与查询的相关性,阈值 0.3,不相关图片会被施加 -3.0 的分数惩罚。
|
||||
|
||||
### 6.2 救援管线
|
||||
|
||||
当常规检索召回不足时,以下救援机制依次尝试补充结果:
|
||||
|
||||
| 救援类型 | 触发条件 | 策略 |
|
||||
|----------|----------|------|
|
||||
| BM25 散度救援 | 向量与 BM25 结果差异大 | 补充 BM25 独有但向量遗漏的高分结果 |
|
||||
| 词法匹配救援 | 专有名词/术语精确匹配 | 对查询中的关键术语做精确匹配补充 |
|
||||
| 章节聚类救援 | 同章节切片被部分召回 | 补充同章节内相邻切片(rerank 后、扩展前) |
|
||||
| 表格救援 | 表格数据被碎片化 | 对 table 类型切片做整表补充 |
|
||||
|
||||
---
|
||||
|
||||
## 七、API 调用方式
|
||||
@@ -530,14 +611,24 @@ curl http://localhost:5001/cache/stats \
|
||||
|
||||
```python
|
||||
# config.py
|
||||
DASHSCOPE_API_KEY = "your-api-key" # 通义千问 API
|
||||
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
DASHSCOPE_MODEL = "qwen3.6-flash" # 文本生成模型(主力 LLM)
|
||||
INTENT_MODEL = "qwen-turbo" # 意图分析模型(轻量快速)
|
||||
VLM_MODEL = "qwen-vl-plus" # 视觉语言模型(图片描述)
|
||||
RAG_CHAT_MODEL = "qwen3.6-flash" # RAG 对话模型
|
||||
DASHSCOPE_API_KEY = "your-api-key" # mimo API 密钥
|
||||
DASHSCOPE_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"
|
||||
DASHSCOPE_MODEL = "mimo-v2.5" # 文本生成模型(主力 LLM)
|
||||
INTENT_MODEL = "mimo-v2.5" # 意图分析模型(轻量快速)
|
||||
VLM_MODEL = "mimo-v2.5" # 视觉语言模型(图片描述)
|
||||
RAG_CHAT_MODEL = "mimo-v2.5" # RAG 对话模型
|
||||
|
||||
# Embedding 模型(本地)
|
||||
EMBEDDING_MODEL_PATH = "models/bge-base-zh-v1.5"
|
||||
|
||||
# Reranker 模型
|
||||
RERANK_MODEL_PATH = "models/bge-reranker-base" # 本地备选
|
||||
RERANK_CLOUD_MODEL = "xop3qwen8breranker" # 云端(讯飞云)
|
||||
RERANK_CLOUD_BASE_URL = "https://maas-api.cn-huabei-1.xf-yun.com/v1/rerank"
|
||||
```
|
||||
|
||||
> **注意**:百炼 API(DashScope)额度用尽后,所有 LLM 调用已统一切换至 mimo API(xiaomimimo.com)。`get_intent_client()` 也使用 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL`(即 mimo API),不再使用百炼 API。
|
||||
|
||||
### 8.2 检索参数
|
||||
|
||||
```python
|
||||
@@ -553,7 +644,7 @@ RECALL_MULTIPLIER = 3 # 候选池最小倍数
|
||||
# 重排序
|
||||
USE_RERANK = True # 启用重排序
|
||||
RERANK_BACKEND = "local" # "local"=本地模型, "cloud"=云端API, "fallback"=优先云端失败回退本地
|
||||
RERANK_CLOUD_MODEL = "qwen3-rerank"
|
||||
RERANK_CLOUD_MODEL = "xop3qwen8breranker"
|
||||
RERANK_CANDIDATES = 20 # 送入重排序的候选数
|
||||
RERANK_TOP_K = 15 # 重排序后保留数
|
||||
RERANK_USE_ONNX = True # ONNX 加速(仅本地模式,环境变量控制)
|
||||
@@ -701,10 +792,10 @@ config/ # 运行时配置
|
||||
| 检索方式 | 单一向量检索 | 向量 + BM25 + FAQ + 图片独立召回 |
|
||||
| 融合算法 | 无 | RRF 动态权重融合 |
|
||||
| 去重 | 无 | MMR 语义去重 |
|
||||
| 重排序 | 无 | 云端 qwen3-rerank API(支持本地 BGE 回退) |
|
||||
| 重排序 | 无 | 云端 xop3qwen8breranker API(讯飞云,支持本地 BGE 回退) |
|
||||
| 问题分解 | 无 | 自动拆分对比/推理类查询 |
|
||||
| 闲聊处理 | 无 | 意图分析自动判断 |
|
||||
| 缓存体系 | 无 | 四层缓存(Query + Embedding + Rerank + 语义缓存) |
|
||||
| 缓存体系 | 无 | 五层缓存(Query + Embedding + Rerank + 语义缓存 + 元数据缓存) |
|
||||
| 语义缓存 | 无 | FAISS 向量索引,相似查询复用(92x 加速) |
|
||||
| 自适应 TopK | 固定 top_k | 根据置信度动态调整 |
|
||||
| 上下文理解 | 无 | 多轮对话 + 历史上下文 |
|
||||
@@ -736,9 +827,9 @@ Rerank 在生产流程中有 **一个调用路径**:
|
||||
|--------|--------|------|
|
||||
| `USE_RERANK` | `True` | 总开关 |
|
||||
| `RERANK_BACKEND` | `"local"` | 后端选择 |
|
||||
| `RERANK_CLOUD_MODEL` | `"qwen3-rerank"` | 云端模型名称 |
|
||||
| `RERANK_CLOUD_MODEL` | `"xop3qwen8breranker"` | 云端模型名称 |
|
||||
| `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://maas-api.cn-huabei-1.xf-yun.com/v1/rerank` | 云端 API 地址(讯飞云) |
|
||||
| `RERANK_CLOUD_TIMEOUT` | `15` | 云端请求超时(秒) |
|
||||
| `RERANK_MODEL_PATH` | `models/bge-reranker-base` | 本地模型路径 |
|
||||
| `RERANK_CANDIDATES` | `20` | 送入 Rerank 的候选数 |
|
||||
@@ -807,6 +898,26 @@ print({"hits": sc.hits, "misses": sc.misses, "total": sc.total_entries})
|
||||
|
||||
## 十三、演进记录
|
||||
|
||||
### v4.1(2026-06-21)— 模型切换 + 图片检索修复
|
||||
|
||||
**模型统一切换**:百炼 API(DashScope)额度用尽后,所有 LLM 调用统一切换至 mimo API(xiaomimimo.com):
|
||||
- `DASHSCOPE_MODEL` / `RAG_CHAT_MODEL` / `INTENT_MODEL` / `VLM_MODEL`:`qwen3.6-flash` / `qwen-turbo` / `qwen-vl-plus` → `mimo-v2.5`
|
||||
- `DASHSCOPE_BASE_URL`:`dashscope.aliyuncs.com` → `token-plan-cn.xiaomimimo.com/v1`
|
||||
- `get_intent_client()`:从百炼 API 切换至 mimo API
|
||||
|
||||
**Reranker 切换**:
|
||||
- `RERANK_CLOUD_MODEL`:`qwen3-rerank` → `xop3qwen8breranker`(讯飞云)
|
||||
- `RERANK_CLOUD_BASE_URL`:`dashscope.aliyuncs.com` → `maas-api.cn-huabei-1.xf-yun.com/v1/rerank`
|
||||
|
||||
**图片检索修复**(P0 级):
|
||||
1. **chart_contexts 降门槛**:CrossEncoder 对图片/图表打分系统性偏低(0.002-0.08 vs 文本 0.3-0.9),原 `min_score=0.05` 过滤掉几乎所有图表。修复为 `min_score * 0.5 = 0.025`。
|
||||
2. **P0 安全网**:`select_images` 独立于文本上下文管线选择图片,但图片描述可能因 min_score 过滤未进入 LLM 上下文。新增安全网检查:若 `selected_images` 的描述未出现在 `context_text` 中,强制注入。
|
||||
3. **答案后过滤**(`_filter_images_by_answer`):LLM 生成答案后,根据答案内容过滤不相关图片。注意:若 LLM 因上下文缺失而回答"未找到",会导致正确图片被误过滤(鸡生蛋问题)。
|
||||
|
||||
**救援管线**:新增 BM25 散度救援、词法匹配救援、章节聚类救援、表格救援等机制,确保低召回场景下的结果覆盖。
|
||||
|
||||
**缓存层扩展**:从四层缓存扩展为五层,新增 ChromaDB 元数据缓存(L5),避免频繁查询 ChromaDB 获取 kb_version 等元数据。
|
||||
|
||||
### v4.0(2026-06-05)— 统一编排 + 四层缓存修复
|
||||
|
||||
**删除未使用的备用编排路径**:移除了 `core/agentic.py` 及 8 个 Mixin 文件(共 10 个文件 ~2050 行)。这些文件实现了完整的决策循环编排(含置信度门控、质量评估、推理反思等),但从未接入任何 HTTP 路由。
|
||||
@@ -820,7 +931,7 @@ print({"hits": sc.hits, "misses": sc.misses, "total": sc.total_entries})
|
||||
|
||||
### v3.2 — 模型/Reranker/管线更新
|
||||
|
||||
引入云端 qwen3-rerank、ONNX 加速、动态 RRF 权重等。
|
||||
引入云端 Reranker(现 xop3qwen8breranker/讯飞云,原 qwen3-rerank/DashScope)、ONNX 加速、动态 RRF 权重等。
|
||||
|
||||
---
|
||||
|
||||
@@ -828,7 +939,7 @@ print({"hits": sc.hits, "misses": sc.misses, "total": sc.total_entries})
|
||||
|
||||
### Redis 缓存外部化
|
||||
|
||||
当前四层缓存均为进程内内存存储,在多 Worker / 多实例部署时无法共享。已规划 Redis 迁移方案(详见 `reports/redis_migration_plan.md`),核心设计:
|
||||
当前五层缓存均为进程内内存存储(L5 ChromaDB 元数据缓存除外),在多 Worker / 多实例部署时无法共享。已规划 Redis 迁移方案(详见 `reports/redis_migration_plan.md`),核心设计:
|
||||
|
||||
- `RedisCacheManager` 提供与 `RAGCacheManager` 相同的接口
|
||||
- 通过 `REDIS_CACHE_URL` 环境变量启用,向后兼容
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
>
|
||||
> 测试日期:2026-06-10(最近更新)| 生产服务器:`47.116.16.222` | 服务地址:`http://127.0.0.1:5001`
|
||||
>
|
||||
> 当前部署模型:`qwen-turbo`(DashScope)| 嵌入模型:`bge-base-zh-v1.5`(本地 CPU)| Rerank:`qwen3-rerank`(DashScope 云端 API)
|
||||
> 当前部署模型:`mimo-v2.5`(xiaomimimo.com API)| 嵌入模型:`bge-base-zh-v1.5`(本地 CPU)| Rerank:`xop3qwen8breranker`(讯飞云 API)
|
||||
|
||||
## 目录
|
||||
|
||||
@@ -2106,7 +2106,7 @@ curl -s -X POST "http://localhost:5001/collections/public_kb/reindex"
|
||||
|
||||
| 优化项 | 配置 | 效果 |
|
||||
|--------|------|------|
|
||||
| Reranker 云端化 | `RERANK_BACKEND=cloud`(qwen3-rerank) | 搜索阶段从 33-48s 降至 ~0.3s |
|
||||
| Reranker 云端化 | `RERANK_BACKEND=cloud`(xop3qwen8breranker/讯飞云) | 搜索阶段从 33-48s 降至 ~0.3s |
|
||||
| MMR 文本相似度 | `MMR_USE_EMBEDDING=false` | MMR 阶段从 ~36s 降至 ~0s |
|
||||
|
||||
### 耗时拆解(优化后 /rag 问答)
|
||||
@@ -2126,7 +2126,7 @@ curl -s -X POST "http://localhost:5001/collections/public_kb/reindex"
|
||||
└── 流式 token 生成 ~11s
|
||||
```
|
||||
|
||||
> **注**:LLM 生成阶段耗时取决于 qwen-turbo API 响应速度,非本地可优化。当前已使用 qwen-turbo(最快模型),如需进一步压缩可考虑减少检索切片数量或缩短 prompt。
|
||||
> **注**:LLM 生成阶段耗时取决于 mimo-v2.5 API 响应速度,非本地可优化。如需进一步压缩可考虑减少检索切片数量或缩短 prompt。
|
||||
|
||||
---
|
||||
|
||||
@@ -2174,14 +2174,14 @@ curl -s -X POST http://127.0.0.1:5001/collections/<kb_name>/reindex
|
||||
|
||||
### Reranker 配置
|
||||
|
||||
生产环境使用 DashScope 云端 Reranker 替代本地 CPU 推理,显著提升检索速度:
|
||||
生产环境使用讯飞云 Reranker 替代本地 CPU 推理,显著提升检索速度:
|
||||
|
||||
| 配置项 | 值 | 说明 |
|
||||
|--------|-----|------|
|
||||
| `RERANK_BACKEND` | `cloud` | 使用云端 API(可选 `local` / `cloud` / `fallback`) |
|
||||
| `RERANK_CLOUD_MODEL` | `qwen3-rerank` | DashScope 云端排序模型 |
|
||||
| `RERANK_CLOUD_API_KEY` | `sk-*` | DashScope 标准 API Key |
|
||||
| `RERANK_CLOUD_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-api/v1/reranks` | OpenAI 兼容端点 |
|
||||
| `RERANK_CLOUD_MODEL` | `xop3qwen8breranker` | 讯飞云排序模型 |
|
||||
| `RERANK_CLOUD_API_KEY` | `sk-*` | API Key |
|
||||
| `RERANK_CLOUD_BASE_URL` | `https://maas-api.cn-huabei-1.xf-yun.com/v1/rerank` | 讯飞云 Rerank 端点 |
|
||||
| `MMR_USE_EMBEDDING` | `false` | MMR 使用文本相似度(零额外计算) |
|
||||
|
||||
> **fallback 模式**:`RERANK_BACKEND=fallback` 优先使用云端 API,如果云端不可用则自动回退到本地模型,适合生产环境高可用场景。
|
||||
|
||||
196
docs/云端MinerU输出分析与优化方案.md
Normal file
196
docs/云端MinerU输出分析与优化方案.md
Normal file
@@ -0,0 +1,196 @@
|
||||
## 云端 MinerU V4 输出分析 & 信息利用优化方案
|
||||
|
||||
### 一、测试环境
|
||||
|
||||
- 云端 API: MinerU V4 (`https://mineru.net/api/v4`)
|
||||
- 模型: `vlm`(高精度视觉语言模型)
|
||||
- DOCX 测试: `1.docx`(472KB)→ 5秒完成
|
||||
- PDF 测试: `三峡公报_1-15页.pdf`(2.7MB)→ 5秒完成
|
||||
|
||||
---
|
||||
|
||||
### 二、云端输出 Schema 对比
|
||||
|
||||
#### 2.1 DOCX(office 后端)
|
||||
|
||||
| 字段 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | paragraph/table/image | 无独立 title 类型 |
|
||||
| `content.paragraph_content[].content` | 文本内容 | |
|
||||
| `content.paragraph_content[].style` | 仅 `bold` | 无 italic/underline/font_size |
|
||||
| `bbox` | **无** | DOCX 不做 OCR |
|
||||
| `anchor` | 偶有 | Word 书签(如 `_Toc423623536`) |
|
||||
| `table_type`/`table_nest_level` | 有 | 表格类型和嵌套层级 |
|
||||
| `image_source.path` | 有 | 图片路径 |
|
||||
|
||||
DOCX 的 V2 输出非常精简,与本地 `pipeline` 模式基本一致。
|
||||
|
||||
#### 2.2 PDF(hybrid 后端,VLM 模型)
|
||||
|
||||
| 字段 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `type` | paragraph/title/list/image/chart/table/page_header/footer/number | **丰富的类型区分** |
|
||||
| `bbox` | **全部有** | 96/96 项都有坐标 |
|
||||
| `title_content` (title 项) | 有 level 字段 | 标题层级 |
|
||||
| `paragraph_content` (paragraph 项) | 文本内容 | 无 style/font_size |
|
||||
| `sub_type` (image) | `natural_image` 等 | 图片子类型 |
|
||||
| `content.content` (chart) | **Markdown 表格** | VLM 从图表中提取的数据表 |
|
||||
| `content.content` (image) | **VLM 视觉描述** | 如 "Aerial view of a large dam..." |
|
||||
| `list_items` (list) | 结构化列表 | 目录条目等 |
|
||||
| `table_footnote` | 表格脚注 | |
|
||||
| `chart_caption`/`chart_footnote` | 图表元数据 | |
|
||||
| `image_source.path` | 图片路径 | 新格式(旧代码用 `img_path`) |
|
||||
|
||||
#### 2.3 V1 vs V2 格式差异
|
||||
|
||||
| 特性 | V1 (`content_list.json`) | V2 (`content_list_v2.json`) |
|
||||
|------|--------------------------|------------------------------|
|
||||
| 结构 | 扁平列表 | 按页嵌套 `[[page0], [page1]...]` |
|
||||
| title 文本 | `text` 字段 | `title_content` 字段 |
|
||||
| title 级别 | `text_level` 字段 | `level` 字段 |
|
||||
| bbox | 有 | 有 |
|
||||
| bold style | Markdown `**text**` | `style: ["bold"]` |
|
||||
| list 类型 | 有 `list_items` | 有 `list_items` |
|
||||
| chart 数据 | 无 markdown 表格 | `content.content` 含 markdown |
|
||||
| VLM 描述 | 无 | `content.content`(image 项) |
|
||||
|
||||
---
|
||||
|
||||
### 三、发现的严重 Bug(全部已修复 2026-06-19)
|
||||
|
||||
#### Bug #1: V2 title 文本全部丢失(严重)✅ 已修复
|
||||
|
||||
当前 `_parse_v2_content_list()` 处理 title 类型时:
|
||||
|
||||
```python
|
||||
elif v2_type == 'title':
|
||||
level = content.get('level', 0)
|
||||
text_parts = []
|
||||
para_content = content.get('paragraph_content', []) # ← 错误!
|
||||
for part in para_content:
|
||||
...
|
||||
```
|
||||
|
||||
但 PDF 的 title 项使用的是 `title_content` 字段,不是 `paragraph_content`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "title",
|
||||
"content": {
|
||||
"title_content": [{"type": "text", "content": "三峡工程公报"}],
|
||||
"level": 1
|
||||
},
|
||||
"bbox": [203, 138, 794, 211]
|
||||
}
|
||||
```
|
||||
|
||||
**影响**: 所有 PDF 标题的文本丢失 → section_path 为空 → 章节层级结构完全破坏。
|
||||
|
||||
#### Bug #2: list 类型未处理 ✅ 已修复
|
||||
|
||||
V1/V2 都有 `list` 类型(如目录条目),但当前 `_parse_v2_content_list()` 没有处理分支,直接被静默丢弃。
|
||||
|
||||
#### Bug #3: chart markdown 数据未提取 ✅ 已修复
|
||||
|
||||
V2 的 chart 类型在 `content.content` 中包含 VLM 提取的 Markdown 表格,当前代码只提取 `img_path` 和 `caption`,丢掉了结构化数据。
|
||||
|
||||
#### Bug #4: VLM 图片描述未利用 ✅ 已修复
|
||||
|
||||
V2 的 image 项的 `content.content` 包含 VLM 生成的视觉描述文本(如 "Aerial view of a large hydroelectric dam..."),可用于增强图片检索,当前被丢弃。
|
||||
|
||||
#### Bug #5: image_source 路径格式不兼容 ✅ 已修复
|
||||
|
||||
V2 图片路径从 `img_path` 改为 `image_source.path`,当前转换代码未适配。
|
||||
|
||||
---
|
||||
|
||||
### 四、优化方案
|
||||
|
||||
#### Phase 1: 修复 V2 解析 Bug(P0,已完成 2026-06-19)
|
||||
|
||||
**目标**: 修复 PDF 标题丢失等严重问题。
|
||||
|
||||
| 改动 | 文件 | 状态 |
|
||||
|------|------|------|
|
||||
| title 项读 `title_content` | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
|
||||
| 添加 list 类型处理 | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
|
||||
| image_source 路径兼容 | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
|
||||
| chart markdown 提取 | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
|
||||
| VLM 图片描述提取 | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
|
||||
| MinerUChunk 新增 vlm_description/chart_markdown | `mineru_parser.py` | ✅ 已完成 |
|
||||
| 在线/本地路径同步更新 | `mineru_parser.py` | ✅ 已完成 |
|
||||
|
||||
#### Phase 1.5: 审计修复(已完成 2026-06-19)
|
||||
|
||||
**目标**: 修复审计发现的"提取但未用"字段和 bbox 存储断裂。
|
||||
|
||||
| 改动 | 文件 | 状态 |
|
||||
|------|------|------|
|
||||
| bbox 存入 ChromaDB metadata(JSON 序列化,DOCX None 保护) | `knowledge/manager.py` | ✅ 已完成 |
|
||||
| table_type/table_nest_level → MinerUChunk → ChromaDB | `mineru_parser.py` + `manager.py` | ✅ 已完成 |
|
||||
| sub_type → MinerUChunk → ChromaDB | `mineru_parser.py` + `manager.py` | ✅ 已完成 |
|
||||
| MINERU_LOCAL_BACKEND 改为 pipeline | `config.py` | ✅ 已完成 |
|
||||
|
||||
#### Phase 2: 增强信息利用(P1,待实施)
|
||||
|
||||
**目标**: 在检索管线中利用已存储的新元数据。
|
||||
|
||||
| 改动 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| ~~VLM 图片描述存入 chunk~~ | ~~mineru_parser.py~~ | ✅ Phase 1 已完成 |
|
||||
| ~~bbox 存入 ChromaDB~~ | ~~manager.py~~ | ✅ Phase 1.5 已完成 |
|
||||
| ~~sub_type 存入 ChromaDB~~ | ~~manager.py~~ | ✅ Phase 1.5 已完成 |
|
||||
| bbox 空间感知检索 | `core/engine.py` | 同页内容优先聚合 |
|
||||
| VLM 描述增强图片检索 | `core/engine.py` | 图片 chunk 用描述文本做语义匹配 |
|
||||
| table_type 检索路由 | `core/engine.py` | 按表格类型区别处理 |
|
||||
|
||||
#### Phase 3: 检索管线利用(P2,中期)
|
||||
|
||||
**目标**: 在检索阶段利用新增的元数据。
|
||||
|
||||
| 改动 | 文件 | 说明 |
|
||||
|------|------|------|
|
||||
| bbox 空间感知检索 | `core/engine.py` | 同页内容优先聚合 |
|
||||
| VLM 描述增强图片检索 | `core/engine.py` | 图片 chunk 用描述文本做语义匹配 |
|
||||
| chart markdown 表格检索 | `core/engine.py` | 图表数据表按表格类型路由 |
|
||||
| table_footnote 上下文补充 | `core/engine.py` | 表格命中时附带脚注 |
|
||||
|
||||
#### Phase 4: 架构调整
|
||||
|
||||
| 改动 | 说明 |
|
||||
|------|------|
|
||||
| 云端优先 + 本地 pipeline 备选 | `MINERU_PREFER_ONLINE=True`, 失败回退 `pipeline` |
|
||||
| V2 默认启用 | `MINERU_PREFER_V2=True`(保持现状) |
|
||||
| 本地 backend 改为 `pipeline` | `MINERU_LOCAL_BACKEND="pipeline"`(快速备选) |
|
||||
|
||||
---
|
||||
|
||||
### 五、字段利用状态汇总(更新于 2026-06-19)
|
||||
|
||||
| 字段 | 来源 | 状态 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `title_content` | PDF V2 | ✅ 已利用 | Phase 1 修复,标题文本正确提取 |
|
||||
| `list_items` | PDF V1/V2 | ✅ 已利用 | Phase 1 拼接为段落文本 |
|
||||
| `content.content` (chart) | PDF V2 | ✅ 已利用 | Phase 1 存入 chart_markdown → ChromaDB |
|
||||
| `content.content` (image) | PDF V2 | ✅ 已利用 | Phase 1 存入 vlm_description → 嵌入 chunk content |
|
||||
| `bbox` | PDF V1/V2 | ✅ 已利用 | Phase 1.5 存入 ChromaDB(JSON 序列化) |
|
||||
| `table_type` | PDF V2 | ✅ 已利用 | Phase 1.5 存入 ChromaDB metadata |
|
||||
| `table_nest_level` | PDF V2 | ✅ 已利用 | Phase 1.5 存入 ChromaDB metadata |
|
||||
| `sub_type` | PDF V1/V2 | ✅ 已利用 | Phase 1.5 存入 ChromaDB metadata |
|
||||
| `table_footnote` | PDF V1/V2 | ✅ 已利用 | Phase 1 附加到表格 chunk content |
|
||||
| `anchor` | DOCX V2 | 暂不利用 | Word 书签/交叉引用,RAG 场景无需求 |
|
||||
| `chart_caption`/`chart_footnote` | PDF V2 | ✅ 已利用 | chart caption 已提取 |
|
||||
| `font_size`/`font_name` | 均无 | N/A | 云端 V4 不输出这些字段 |
|
||||
| `layout.json` | 两者 | 暂不利用 | 精细布局分析,当前无需求 |
|
||||
|
||||
---
|
||||
|
||||
### 六、DOCX vs PDF 策略差异
|
||||
|
||||
| 策略 | DOCX (office) | PDF (hybrid/VLM) |
|
||||
|------|---------------|-------------------|
|
||||
| 标题识别 | 依赖 heading_rules(正则+bold) | VLM 已识别 title+level,heading_rules 做补充校正 |
|
||||
| 图片检索 | 仅靠 caption | VLM 描述 + caption 双通道 |
|
||||
| 图表处理 | 无 | chart 类型含 markdown 数据表 |
|
||||
| 列表处理 | 无独立 list 类型 | list 类型保留结构化列表 |
|
||||
| 表格增强 | table_type/nest_level | table_type + footnote + 嵌入图片 |
|
||||
1276
docs/出题批卷系统设计.md
1276
docs/出题批卷系统设计.md
File diff suppressed because it is too large
Load Diff
1076
docs/出题批题后端对接指南.md
1076
docs/出题批题后端对接指南.md
File diff suppressed because it is too large
Load Diff
190
docs/出题系统测试报告.md
Normal file
190
docs/出题系统测试报告.md
Normal file
@@ -0,0 +1,190 @@
|
||||
# 出题系统测试报告
|
||||
|
||||
**测试日期**: 2026-06-22
|
||||
**测试环境**: mimo-v2.5(关闭推理模式)、2.docx(文明吸烟环境建设标准)
|
||||
**测试方式**: curl 接口测试 + 代码审查
|
||||
|
||||
---
|
||||
|
||||
## 一、测试总览
|
||||
|
||||
| # | 测试项 | 接口 | 结果 | 耗时 |
|
||||
|---|--------|------|------|------|
|
||||
| 1 | 参数出题 | POST /exam/generate | ✅ 成功 | ~87s (4题) |
|
||||
| 2 | AI一键出题 | POST /exam/generate-smart | ⚠️ 成功但有上限失控 | ~244s (50题) |
|
||||
| 3 | 偏门题型(纯主观) | POST /exam/generate | ✅ 成功 | ~87s (5题) |
|
||||
| 4 | 偏门题型(多选+填空) | POST /exam/generate | ✅ 成功 | ~60s (6题) |
|
||||
| 5 | 跨调用去重 | POST /exam/generate + exclude_stems | ✅ 去重有效 | ~60s (5题) |
|
||||
| 6 | 批题(4题型混合) | POST /exam/grade | ✅ 全部成功 | ~30s |
|
||||
|
||||
---
|
||||
|
||||
## 二、测试详情
|
||||
|
||||
### Test 1: 参数出题 (single_choice:2, fill_blank:1, true_false:1)
|
||||
- **结果**: 4题全部生成成功,题型匹配
|
||||
- **问题**:
|
||||
- ❌ 题目多样性不足:4题中3题考同一个知识点("标准的解释机构")
|
||||
- ❌ source_trace 数据缺失(chunk_id=?, page=?)
|
||||
|
||||
### Test 2: AI一键出题 (generate-smart)
|
||||
- **AI分析**: 检测到54个知识点,推荐50道题(20单选+15判断+10多选+5填空)
|
||||
- **结果**: 50题全部生成,12个不同章节,0题干重复
|
||||
- **问题**:
|
||||
- ❌ **P0 上限失控**: prompt 要求"不超过20",但AI返回50,代码没有 enforce 上限校验
|
||||
- ⚠️ 50题意味着20-30次LLM调用,极易触发429限流
|
||||
|
||||
### Test 3: 纯主观题 (subjective:5)
|
||||
- **结果**: 5题全部生成,覆盖不同章节,scoring_points 完整
|
||||
- **耗时**: 87秒(关闭推理模式后正常)
|
||||
|
||||
### Test 4: 多选+填空 (multiple_choice:3, fill_blank:3)
|
||||
- **结果**: 6题全部生成
|
||||
- **问题**:
|
||||
- ❌ **填空题答案格式不一致**: `content.answer` 是 `[["消费水平较高"]]` (list of list),但没有 `data.reference_answer` 字段,只有 `data.blank_count`
|
||||
- ⚠️ 与 grader 期望的格式可能不匹配
|
||||
|
||||
### Test 5: 跨调用去重 (exclude_stems)
|
||||
- **结果**: 3个排除题干全部未出现,去重有效 ✅
|
||||
|
||||
### Test 6: 批题 (grade)
|
||||
- **单选题答错**: score=0, correct=false ✅
|
||||
- **判断题答对**: score=2, correct=true ✅
|
||||
- **填空题部分对**: score=4.0 (满分4), blank_scores=[4.0] ✅
|
||||
- **主观题(低质量回答)**: score=2.0/10, 4个scoring_point逐项评分 ✅
|
||||
- **总评**: 得分率44.4%,评分合理
|
||||
|
||||
---
|
||||
|
||||
## 三、发现的问题(按严重度排序)
|
||||
|
||||
### P0 - 严重问题
|
||||
|
||||
#### 1. 🚨 AI一键出题上限失控 (generator.py:1053-1068)
|
||||
**位置**: `analyze_document_for_exam()` 函数
|
||||
**现象**: prompt 写了"所有数量之和不要超过 min(total_knowledge_points * 2, 20)",但 LLM 返回50题,代码直接采纳
|
||||
**根因**: 代码只做了"题型合法性"校验,没有对总数做上限 enforce
|
||||
```python
|
||||
# 当前代码 (generator.py:1053-1059) - 只校验单题型合法性,无总数限制
|
||||
valid_types = ['single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective']
|
||||
question_types = {}
|
||||
for q_type in valid_types:
|
||||
count = result.get('question_types', {}).get(q_type, 0)
|
||||
if isinstance(count, int) and count >= 0:
|
||||
question_types[q_type] = count # 直接采纳,无上限
|
||||
```
|
||||
**建议**: 增加总数上限校验,超过 `max_questions`(建议20)时按比例缩减
|
||||
```python
|
||||
total = sum(question_types.values())
|
||||
max_questions = 20
|
||||
if total > max_questions:
|
||||
ratio = max_questions / total
|
||||
question_types = {k: max(0, round(v * ratio)) for k, v in question_types.items()}
|
||||
```
|
||||
|
||||
#### 2. 🚨 LLM 调用无限流机制,429 风暴 (generator.py:290-303, 540-543)
|
||||
**位置**: `generate_questions_structured()` 主循环 + `_generate_with_retry()` 重试
|
||||
**现象**: 并发出题时大量429错误,重试退避太短(1s/2s/4s),3次全败后放弃该知识点
|
||||
**根因**:
|
||||
- 主循环 for 逐知识点调用 LLM,无请求间隔(补题函数有 sleep(1),主循环没有)
|
||||
- 429 重试退避 `2^attempt` 秒 (1/2/4s) 对 API 限流不够
|
||||
- 无全局限流器(令牌桶/漏桶)
|
||||
**建议**:
|
||||
1. 主循环每次 LLM 调用后加 `time.sleep(1.5)` 最小间隔
|
||||
2. 429 退避改为指数+抖动: `min(30, 2 ** attempt + random.uniform(0, 2))`
|
||||
3. 长期: 引入 `tenacity` 或自实现令牌桶限流器
|
||||
|
||||
#### 3. 🚨 validate_questions_schema 只认 `type` 不认 `question_type` (generator.py:154)
|
||||
**位置**: `validate_questions_schema()` 函数
|
||||
**现象**: LLM 返回的题目可能用 `question_type` 字段,但校验只检查 `q.get('type')`,导致有效题目被丢弃
|
||||
**对比**: `_validate_question_types()` (第448行) 做了兼容: `q_type = q.get('question_type') or q.get('type')`
|
||||
```python
|
||||
# 当前代码 (generator.py:154) - 不兼容
|
||||
if q.get('type') not in VALID_TYPES:
|
||||
continue # question_type 字段的题目被丢弃!
|
||||
|
||||
# 应改为
|
||||
q_type = q.get('question_type') or q.get('type')
|
||||
if q_type not in VALID_TYPES:
|
||||
continue
|
||||
```
|
||||
|
||||
### P1 - 中等问题
|
||||
|
||||
#### 4. ⚠️ 填空题答案格式不一致
|
||||
**现象**: 出题返回 `content.answer = [["答案1"], ["答案2"]]` (list of list),但无 `data.reference_answer`
|
||||
**影响**: 前端/本地数据库可能期望 `reference_answer` 字段
|
||||
**建议**: 统一在 `data` 中增加 `reference_answer` 字段,与 `content.answer` 保持一致
|
||||
|
||||
#### 5. ⚠️ grader max_tokens 对推理模型不足 (grader.py:438)
|
||||
**位置**: `_grade_subjective()` 方法
|
||||
**现象**: `max_tokens=_get_effective_max_tokens(1000, self.model)`
|
||||
- 推理模型关闭推理时: 1000 tokens 勉强够
|
||||
- 推理模型开启推理时: 思考链消耗 800+ tokens,content 为空
|
||||
**当前状态**: 关闭推理模式后本次测试通过,但**开启推理模式会再次失败**
|
||||
**建议**: 基础 max_tokens 从 1000 提升至 2000
|
||||
|
||||
#### 6. ⚠️ local_db._detect_question_type 使用旧格式 (local_db.py:378-383)
|
||||
**位置**: `_detect_question_type()` 方法
|
||||
**现象**: 检测 `options` 和 `reference_answer` 字段来判断题型,但新格式用 `content.data.options` 和 `content.answer`
|
||||
```python
|
||||
# 当前代码 - 检查顶层 options
|
||||
if 'options' in question and question['options']:
|
||||
return 'choice'
|
||||
elif 'reference_answer' in question:
|
||||
return 'short_answer'
|
||||
|
||||
# 应改为检查 content 内部结构
|
||||
content = question.get('content', {})
|
||||
data = content.get('data', {})
|
||||
if data.get('options'):
|
||||
return 'choice'
|
||||
elif question.get('question_type') in ('fill_blank', 'subjective', ...):
|
||||
return question['question_type']
|
||||
```
|
||||
|
||||
#### 7. ⚠️ 题目多样性不足
|
||||
**现象**: Test 1 中 4 题有 3 题考同一知识点("标准的解释机构")
|
||||
**根因**: `_assign_questions_to_kps()` 可能将多个题型分配给同一知识点
|
||||
**建议**: 增加"同一知识点最多出 N 道题"的限制(建议 N=2)
|
||||
|
||||
### P2 - 低优先级
|
||||
|
||||
#### 8. 💡 source_trace 数据偶尔缺失
|
||||
**现象**: Test 1 中部分题目的 chunk_id 和 page 显示为 `?`
|
||||
**可能原因**: `find_referenced_chunks` 匹配失败时的 fallback 显示
|
||||
|
||||
#### 9. 💡 多选题答案格式
|
||||
**现象**: 多选题 answer 为 `['A', 'B']` 或 `['A', 'B', 'D']` (list),前端需处理
|
||||
**建议**: 在 API 文档中明确多选题 answer 格式为 list
|
||||
|
||||
#### 10. 💡 补题机制未与已有题目交叉去重
|
||||
**现象**: 补题时只检查本次生成的题干,不检查已有题目
|
||||
**建议**: 补题函数接受 `exclude_stems` 参数
|
||||
|
||||
---
|
||||
|
||||
## 四、测试通过项 ✅
|
||||
|
||||
1. **基础出题功能**: 5种题型均可正常生成
|
||||
2. **AI智能分析**: 文档分析→题型推荐→出题流程完整
|
||||
3. **补题机制**: 知识点出题失败后自动补题
|
||||
4. **跨调用去重**: exclude_stems 功能正常,排除的题干不再出现
|
||||
5. **批题功能**: 客观题精确匹配,填空题部分给分,主观题LLM逐项评分
|
||||
6. **source_trace**: 大部分题目有完整的溯源信息(chunk_id, section, snippet)
|
||||
7. **JSON解析**: mimo-v2.5 返回的 JSON 格式(含 markdown 包裹)可正常解析
|
||||
8. **题目格式**: 单选题 options 为 list of dict `[{key, content}]`,格式规范
|
||||
|
||||
---
|
||||
|
||||
## 五、建议修复优先级
|
||||
|
||||
| 优先级 | 问题 | 修复难度 | 影响范围 |
|
||||
|--------|------|----------|----------|
|
||||
| P0 | AI出题上限失控 | 简单 | smart 出题 |
|
||||
| P0 | LLM 429 限流 | 中等 | 全部出题 |
|
||||
| P0 | validate_questions_schema type 字段 | 简单 | 全部出题 |
|
||||
| P1 | 填空题答案格式统一 | 简单 | 填空题+批题 |
|
||||
| P1 | grader max_tokens | 简单 | 主观题批题 |
|
||||
| P1 | local_db 旧格式 | 中等 | 本地存储 |
|
||||
| P1 | 题目多样性控制 | 中等 | 出题质量 |
|
||||
@@ -138,19 +138,22 @@
|
||||
|
||||
#### 4a. 去重
|
||||
|
||||
`_deduplicate_questions(questions, exclude_stems)` — 三层去重:
|
||||
`_deduplicate_questions(questions, exclude_stems)` — 四层去重:
|
||||
|
||||
1. **题干前缀去重**:题干前 80 字相同 → 去掉。
|
||||
2. **知识点+题型去重**:题干前 30 字 + 题型相同 → 去掉。
|
||||
3. **跨调用去重**:`exclude_stems` 中已有题目的题干前 80 字预填入去重集合,新生成的题目如果与之冲突也会被过滤。
|
||||
2. **跨调用排除**(`_matches_exclude`):`exclude_stems` 中已有题目的前缀(≤30 字),新题干前 30 字如果以此为前缀 → 去掉。用 `startswith()` 匹配,解决排除题干短于 30 字时的长度不匹配问题。
|
||||
3. **知识点+题型去重**:题干前 30 字 + 题型相同 → 去掉。
|
||||
4. **跨调用题干去重**:`exclude_stems` 中已有题目的题干前 80 字预填入去重集合,精确匹配过滤。
|
||||
|
||||
#### 4b. 题型平衡
|
||||
|
||||
`_balance_question_types(questions, target_types)` — 按题型分组,每种题型按目标数量截取(多了截断)。
|
||||
|
||||
#### 4c. 补题(仅 v1 结构化路径)
|
||||
#### 4c. 补题
|
||||
|
||||
v1 的 `generate_questions_structured()` 有补题机制 `_makeup_questions()`:如果某题型数量不足,用前 5 个 chunks 重新出一轮补充。v2 路径依赖分配阶段的精确控制,不额外补题。
|
||||
v1 的 `generate_questions_structured()` 有补题机制 `_makeup_questions()`:如果某题型数量不足,用前 5 个 chunks 重新出一轮补充。
|
||||
|
||||
v2 路径(`generate_questions_structured_v2`)同样支持补题:Phase 4.4 检查各题型是否达到目标数量,不足的题型调用 `_makeup_questions()` 补充,补题结果也经过去重处理。
|
||||
|
||||
---
|
||||
|
||||
@@ -272,7 +275,7 @@ v1 的 `generate_questions_structured()` 有补题机制 `_makeup_questions()`
|
||||
| `safe_parse_questions` | generator.py | JSON 安全解析 |
|
||||
| `validate_questions_schema` | generator.py | 题目 Schema 校验 |
|
||||
| `_enrich_with_source_trace` | generator.py | 补充溯源信息 |
|
||||
| `_deduplicate_questions` | generator.py | 三层去重 |
|
||||
| `_deduplicate_questions` | generator.py | 四层去重 |
|
||||
| `_balance_question_types` | generator.py | 题型数量平衡 |
|
||||
| `_generate_questions_fallback` | generator.py | 降级路径(无知识点时) |
|
||||
| `_makeup_questions` | generator.py | v1 补题机制 |
|
||||
|
||||
@@ -312,11 +312,11 @@ pip install -r requirements.txt
|
||||
```python
|
||||
# config.py - 必需配置
|
||||
|
||||
# 通义千问 API(必需)
|
||||
# LLM API(必需)
|
||||
DASHSCOPE_API_KEY = "your-api-key"
|
||||
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
DASHSCOPE_MODEL = "qwen-flash" # 文本模型
|
||||
DASHSCOPE_VL_MODEL = "qwen-vl-plus" # 视觉模型(图片描述)
|
||||
DASHSCOPE_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"
|
||||
DASHSCOPE_MODEL = "mimo-v2.5" # 文本模型
|
||||
VLM_MODEL = "mimo-v2.5" # 视觉模型(图片描述)
|
||||
|
||||
# 兼容变量
|
||||
API_KEY = DASHSCOPE_API_KEY
|
||||
|
||||
@@ -84,9 +84,9 @@ ls models/bge-base-zh-v1.5/
|
||||
```python
|
||||
# API配置
|
||||
DASHSCOPE_API_KEY = "your-api-key"
|
||||
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
DASHSCOPE_MODEL = "qwen3.6-flash" # 主 LLM(文本生成 / RAG 对话)
|
||||
INTENT_MODEL = "qwen-turbo" # 意图分析模型(轻量、确定性高)
|
||||
DASHSCOPE_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"
|
||||
DASHSCOPE_MODEL = "mimo-v2.5" # 主 LLM(文本生成 / RAG 对话)
|
||||
INTENT_MODEL = "mimo-v2.5" # 意图分析模型
|
||||
```
|
||||
|
||||
> **注意**: Graph RAG(Neo4j)功能已废弃,相关配置(NEO4J_URI、USE_GRAPH_RAG 等)已移除。
|
||||
|
||||
@@ -246,6 +246,7 @@ def api_generate_smart():
|
||||
"file_path": "public/产品手册.pdf",
|
||||
"collection": "public_kb",
|
||||
"difficulty": 3, // 可选,默认 3
|
||||
"max_total": 20, // 可选,AI出题总数上限。不传则不限制
|
||||
"options": {} // 可选
|
||||
}
|
||||
|
||||
@@ -267,6 +268,16 @@ def api_generate_smart():
|
||||
if diff_error:
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400)
|
||||
|
||||
# 可选:AI出题总数上限(不传则不限制)
|
||||
max_total = data.get('max_total')
|
||||
if max_total is not None:
|
||||
try:
|
||||
max_total = int(max_total)
|
||||
if max_total <= 0:
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, "max_total 必须为正整数", http_status=400)
|
||||
except (ValueError, TypeError):
|
||||
return error_response("INVALID_PARAMS", BAD_REQUEST, "max_total 必须为整数", http_status=400)
|
||||
|
||||
# 校验排除题干列表(可选)
|
||||
exclude_stems = data.get('exclude_stems')
|
||||
stems_error = validate_exclude_stems(exclude_stems)
|
||||
@@ -298,11 +309,11 @@ def api_generate_smart():
|
||||
f"AI智能出题: {os.path.basename(file_path)}"
|
||||
)
|
||||
|
||||
def _do_smart_generate(task, fp, coll, diff, opts, req_id, excl):
|
||||
def _do_smart_generate(task, fp, coll, diff, opts, req_id, excl, max_t):
|
||||
"""后台执行 AI 智能出题"""
|
||||
registry.update_progress(task.id, stage='AI分析', message='正在分析文档内容...')
|
||||
from exam_pkg.manager import analyze_file_for_exam
|
||||
ai_analysis = analyze_file_for_exam(file_path=fp, collection=coll)
|
||||
ai_analysis = analyze_file_for_exam(file_path=fp, collection=coll, max_total=max_t)
|
||||
|
||||
q_types = ai_analysis.get('question_types', {})
|
||||
if not q_types or sum(q_types.values()) == 0:
|
||||
@@ -325,7 +336,8 @@ def api_generate_smart():
|
||||
task.id, _do_smart_generate,
|
||||
file_path, collection,
|
||||
data.get('difficulty', 3), data.get('options', {}),
|
||||
data.get('request_id'), data.get('exclude_stems')
|
||||
data.get('request_id'), data.get('exclude_stems'),
|
||||
max_total
|
||||
)
|
||||
|
||||
return success_response(
|
||||
|
||||
@@ -26,19 +26,38 @@ import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 导入 LLM 工具函数
|
||||
from core.llm_utils import call_llm, parse_json_list_from_response
|
||||
from core.llm_utils import call_llm, parse_json_list_from_response, extract_json_list, extract_json_object
|
||||
|
||||
# 导入 LLM 配置
|
||||
try:
|
||||
from config import API_KEY, BASE_URL, MODEL
|
||||
from config import API_KEY, BASE_URL, MODEL, LLM_TEMPERATURE, LLM_MAX_TOKENS
|
||||
LLM_AVAILABLE = True
|
||||
except ImportError:
|
||||
API_KEY = None
|
||||
BASE_URL = None
|
||||
MODEL = None
|
||||
LLM_TEMPERATURE = 0.7
|
||||
LLM_MAX_TOKENS = 4000
|
||||
LLM_AVAILABLE = False
|
||||
|
||||
|
||||
# 推理模型识别:这些模型会消耗额外 token 用于思考链,需要更大的 max_tokens 预算
|
||||
_REASONING_MODEL_KEYWORDS = ('mimo', 'qwq', 'deepseek-r1', 'deepseek-reasoner', 'o1', 'o3')
|
||||
|
||||
def _is_reasoning_model(model_name: str) -> bool:
|
||||
"""判断是否为推理模型(需要额外思考链 token 预算)"""
|
||||
if not model_name:
|
||||
return False
|
||||
name_lower = model_name.lower()
|
||||
return any(kw in name_lower for kw in _REASONING_MODEL_KEYWORDS)
|
||||
|
||||
def _get_effective_max_tokens(base_max: int, model_name: str) -> int:
|
||||
"""根据模型类型计算实际 max_tokens。推理模型需要 1.5x 预算给思考链"""
|
||||
if _is_reasoning_model(model_name):
|
||||
return max(base_max, int(base_max * 1.5))
|
||||
return base_max
|
||||
|
||||
|
||||
# ==================== 辅助函数 ====================
|
||||
|
||||
def group_chunks_by_section(chunks: List[Dict]) -> Dict[str, List[Dict]]:
|
||||
@@ -131,8 +150,9 @@ def validate_questions_schema(questions: List[Dict]) -> List[Dict]:
|
||||
validated = []
|
||||
|
||||
for q in questions:
|
||||
# 必须有 type
|
||||
if q.get('type') not in VALID_TYPES:
|
||||
# 必须有 type 或 question_type
|
||||
q_type = q.get('question_type') or q.get('type')
|
||||
if q_type not in VALID_TYPES:
|
||||
continue
|
||||
|
||||
# 必须有 content
|
||||
@@ -145,10 +165,18 @@ def validate_questions_schema(questions: List[Dict]) -> List[Dict]:
|
||||
continue
|
||||
|
||||
# 选项题必须有 options
|
||||
if q['type'] in ['single_choice', 'multiple_choice']:
|
||||
if q_type in ['single_choice', 'multiple_choice']:
|
||||
if not content.get('data', {}).get('options'):
|
||||
continue
|
||||
|
||||
# 填空题答案格式归一化:扁平数组 → 二维数组
|
||||
if q_type == 'fill_blank':
|
||||
ans = content.get('answer')
|
||||
if isinstance(ans, list) and ans and all(isinstance(item, str) for item in ans):
|
||||
# 扁平数组 ["答案1", "答案2"] → [["答案1"], ["答案2"]]
|
||||
content['answer'] = [[item] for item in ans]
|
||||
logger.warning(f"填空题答案格式修正: 扁平数组 → 二维数组 ({len(ans)} 空)")
|
||||
|
||||
validated.append(q)
|
||||
|
||||
return validated
|
||||
@@ -359,8 +387,8 @@ class QuestionGenerator:
|
||||
client=self.client,
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=0.7,
|
||||
max_tokens=4000,
|
||||
temperature=LLM_TEMPERATURE,
|
||||
max_tokens=_get_effective_max_tokens(4000, self.model),
|
||||
messages=messages
|
||||
)
|
||||
if not content:
|
||||
@@ -493,25 +521,37 @@ class QuestionGenerator:
|
||||
return type_names.get(q_type, q_type)
|
||||
|
||||
def _call_llm(self, prompt: str) -> str:
|
||||
"""调用本地 LLM(OpenAI 兼容接口)"""
|
||||
"""调用本地 LLM(OpenAI 兼容接口),支持 429 限流重试"""
|
||||
if not self.client:
|
||||
raise ValueError("LLM 客户端未初始化,请检查 config.py 中的 API_KEY 配置")
|
||||
|
||||
import time
|
||||
|
||||
messages = [
|
||||
{"role": "system", "content": "你是一个专业的出题专家,擅长根据文档内容生成各类考试题目。你必须严格按照JSON格式输出,不要有任何其他内容。"},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
result = call_llm(
|
||||
client=self.client,
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=0.7,
|
||||
max_tokens=4000,
|
||||
messages=messages
|
||||
)
|
||||
if result is None:
|
||||
raise Exception("LLM 调用失败")
|
||||
return result
|
||||
|
||||
# 重试机制:429 限流时指数退避(最多重试 3 次)
|
||||
for attempt in range(4):
|
||||
result = call_llm(
|
||||
client=self.client,
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=LLM_TEMPERATURE,
|
||||
max_tokens=_get_effective_max_tokens(4000, self.model),
|
||||
messages=messages
|
||||
)
|
||||
if result is not None:
|
||||
return result
|
||||
|
||||
# 可能是 429 限流,等待后重试
|
||||
if attempt < 3:
|
||||
wait_time = 2 ** attempt # 1s, 2s, 4s
|
||||
logger.warning(f" LLM 调用失败,{wait_time}s 后重试 ({attempt+1}/3)...")
|
||||
time.sleep(wait_time)
|
||||
|
||||
raise Exception("LLM 调用失败(已重试 3 次)")
|
||||
|
||||
def _get_format_examples(self) -> str:
|
||||
"""返回各题型格式示例(覆盖全部 5 种题型)"""
|
||||
@@ -610,7 +650,6 @@ class QuestionGenerator:
|
||||
)
|
||||
|
||||
# 清理纯标点符号
|
||||
import re
|
||||
all_content = re.sub(r'^[\s\*\-\d\.。、,::;;]+$', '', all_content, flags=re.MULTILINE)
|
||||
all_content = all_content.strip()
|
||||
|
||||
@@ -645,27 +684,24 @@ class QuestionGenerator:
|
||||
|
||||
请直接输出 JSON 数组:"""
|
||||
|
||||
try:
|
||||
response = self._call_llm(prompt)
|
||||
for _attempt in range(2):
|
||||
try:
|
||||
response = self._call_llm(prompt)
|
||||
if not response or not response.strip():
|
||||
continue
|
||||
|
||||
# 清理响应(移除可能的 markdown 标记)
|
||||
response = response.strip()
|
||||
if response.startswith('```'):
|
||||
lines = response.split('\n')
|
||||
response = '\n'.join(lines[1:-1] if lines[-1] == '```' else lines[1:])
|
||||
|
||||
# 解析 JSON
|
||||
result = json.loads(response)
|
||||
if isinstance(result, list):
|
||||
return [
|
||||
{"name": kp, "section": section}
|
||||
for kp in result[:max_points]
|
||||
if isinstance(kp, str) and 3 <= len(kp) <= 30
|
||||
]
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f" 知识点 JSON 解析失败: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f" 知识点提取失败: {e}")
|
||||
# 使用多策略 JSON 提取(支持 markdown 代码块、正则回退)
|
||||
result = extract_json_list(response)
|
||||
if isinstance(result, list) and result:
|
||||
return [
|
||||
{"name": kp, "section": section}
|
||||
for kp in result[:max_points]
|
||||
if isinstance(kp, str) and 3 <= len(kp) <= 30
|
||||
]
|
||||
# 解析结果无效,重试一次
|
||||
logger.warning(f" 知识点提取返回无效结果(尝试 {_attempt+1}/2),重试中...")
|
||||
except Exception as e:
|
||||
logger.error(f" 知识点提取失败(尝试 {_attempt+1}/2): {e}")
|
||||
|
||||
return []
|
||||
|
||||
@@ -918,7 +954,7 @@ def generate_questions_from_content(
|
||||
return generator.generate_questions_structured(chunks, document_name, question_types, difficulty)
|
||||
|
||||
|
||||
def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
def analyze_document_for_exam(chunks: List[Dict], max_total: int = None) -> Dict[str, Any]:
|
||||
"""
|
||||
AI 智能分析文档内容,决定适合的题型和数量
|
||||
|
||||
@@ -996,6 +1032,7 @@ def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
)
|
||||
prompt += f"\n### {section_name[:30]}\n{content_preview[:300]}\n"
|
||||
|
||||
question_limit = min(total_knowledge_points * 2, max_total if max_total else 20)
|
||||
prompt += """
|
||||
## 要求
|
||||
根据文档内容特点,决定:
|
||||
@@ -1019,21 +1056,21 @@ def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
|
||||
注意:
|
||||
- 不适合的题型数量设为 0
|
||||
- 所有数量之和不要超过 {min(total_knowledge_points * 2, 20)}
|
||||
- 所有数量之和不要超过 %d
|
||||
- 必须返回合法 JSON,不要有其他内容
|
||||
|
||||
请直接输出 JSON:"""
|
||||
请直接输出 JSON:""" % question_limit
|
||||
|
||||
try:
|
||||
response = generator._call_llm(prompt)
|
||||
response = response.strip()
|
||||
if not response or not response.strip():
|
||||
return _generate_default_question_types(total_knowledge_points)
|
||||
|
||||
# 清理 markdown 代码块
|
||||
if response.startswith('```'):
|
||||
lines = response.split('\n')
|
||||
response = '\n'.join(lines[1:-1] if lines[-1] == '```' else lines[1:])
|
||||
|
||||
result = json.loads(response)
|
||||
# 使用多策略 JSON 提取(支持 markdown 代码块、正则回退)
|
||||
result = extract_json_object(response)
|
||||
if not isinstance(result, dict):
|
||||
logger.error(f"AI 分析返回非对象类型: {type(result)}")
|
||||
return _generate_default_question_types(total_knowledge_points)
|
||||
|
||||
# 验证和清理结果
|
||||
valid_types = ['single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective']
|
||||
@@ -1046,6 +1083,24 @@ def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
# 过滤不适合的题型
|
||||
suitable_types = [t for t, c in question_types.items() if c > 0]
|
||||
|
||||
# 总数上限校验:如果指定了 max_total,超过时按比例缩减
|
||||
if max_total and max_total > 0:
|
||||
total = sum(question_types.values())
|
||||
if total > max_total:
|
||||
ratio = max_total / total
|
||||
question_types = {k: max(0, round(v * ratio)) for k, v in question_types.items()}
|
||||
# 修正四舍五入误差
|
||||
diff = max_total - sum(question_types.values())
|
||||
if diff > 0:
|
||||
# 把差额分配给最大的题型
|
||||
for k in sorted(question_types, key=question_types.get, reverse=True):
|
||||
question_types[k] += 1
|
||||
diff -= 1
|
||||
if diff <= 0:
|
||||
break
|
||||
logger.info(f" AI 推荐 {total} 题,按上限 {max_total} 缩减为 {sum(question_types.values())} 题")
|
||||
suitable_types = [t for t, c in question_types.items() if c > 0]
|
||||
|
||||
return {
|
||||
"total_knowledge_points": result.get('total_knowledge_points', total_knowledge_points),
|
||||
"suitable_types": suitable_types,
|
||||
@@ -1053,10 +1108,6 @@ def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
|
||||
"reason": result.get('reason', 'AI 分析完成')
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"AI 分析结果 JSON 解析失败: {e}")
|
||||
# 降级:根据知识点数量生成默认配置
|
||||
return _generate_default_question_types(total_knowledge_points)
|
||||
except Exception as e:
|
||||
logger.error(f"AI 分析失败: {e}")
|
||||
return _generate_default_question_types(total_knowledge_points)
|
||||
@@ -1156,6 +1207,10 @@ def generate_questions_structured_v2(
|
||||
# 提取知识点
|
||||
kps = generator._extract_knowledge_points(section, section_chunks, max_points=3)
|
||||
|
||||
# API 限流保护:连续 LLM 调用间隔 1 秒
|
||||
import time
|
||||
time.sleep(1)
|
||||
|
||||
# 全局去重
|
||||
for kp in kps:
|
||||
kp_name = kp['name']
|
||||
@@ -1219,6 +1274,10 @@ def generate_questions_structured_v2(
|
||||
prompt, {q_type: 1}, kp_chunks, document_name, max_retries=2
|
||||
)
|
||||
|
||||
# API 限流保护:连续 LLM 调用间隔 1 秒
|
||||
import time
|
||||
time.sleep(1)
|
||||
|
||||
if success and questions:
|
||||
all_questions.extend(questions)
|
||||
else:
|
||||
@@ -1241,6 +1300,37 @@ def generate_questions_structured_v2(
|
||||
type_counts[q.get('question_type')] += 1
|
||||
logger.info(f" 题型分布: {dict(type_counts)}")
|
||||
|
||||
# 4.4 补题:如果某题型数量不足,使用 chunks 补充
|
||||
shortage_types = {}
|
||||
for q_type, target_count in question_types.items():
|
||||
actual_count = type_counts.get(q_type, 0)
|
||||
if actual_count < target_count:
|
||||
shortage_types[q_type] = target_count - actual_count
|
||||
|
||||
if shortage_types:
|
||||
logger.info(f" [v2] 补题: 缺少题型 {dict(shortage_types)}")
|
||||
# 收集 final 中已有题干,补题时一并排除
|
||||
existing_stems = [q.get('content', {}).get('stem', '') for q in final if q.get('content', {}).get('stem')]
|
||||
combined_exclude = list(exclude_stems or []) + existing_stems
|
||||
|
||||
for q_type, shortage in shortage_types.items():
|
||||
logger.info(f" 补充 {q_type} {shortage} 道...")
|
||||
extra = generator._makeup_questions(chunks, q_type, shortage, difficulty, document_name)
|
||||
# 补题需与已有题目 + 跨调用排除列表一起去重
|
||||
extra_deduped = _deduplicate_questions(extra, exclude_stems=combined_exclude)
|
||||
final.extend(extra_deduped[:shortage])
|
||||
# 将新补的题干也加入排除列表,防止后续题型补出重复
|
||||
for q in extra_deduped:
|
||||
stem = q.get('content', {}).get('stem', '')
|
||||
if stem:
|
||||
combined_exclude.append(stem)
|
||||
|
||||
# 补题后重新统计
|
||||
type_counts = defaultdict(int)
|
||||
for q in final:
|
||||
type_counts[q.get('question_type')] += 1
|
||||
logger.info(f" 补题后题型分布: {dict(type_counts)}")
|
||||
|
||||
return final
|
||||
|
||||
|
||||
@@ -1404,9 +1494,27 @@ def _deduplicate_questions(questions: List[Dict], exclude_stems: List[str] = Non
|
||||
|
||||
# 预填已有题目的题干前缀,使新生成的题目与已有题目冲突时被过滤
|
||||
if exclude_stems:
|
||||
_valid_types = ('single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective')
|
||||
for stem in exclude_stems:
|
||||
seen_stems.add(stem[:80])
|
||||
seen_kp_type.add(f"{stem[:30]}_") # 通配题型匹配
|
||||
# 用 exclude_stem 自身长度做前缀键(排除题干通常短于30字)
|
||||
# 新题目的 stem[:30] 如果以此前缀开头,[:len(prefix)] 后就能匹配
|
||||
_prefix = stem[:30]
|
||||
for _qt in _valid_types:
|
||||
seen_kp_type.add(f"{_prefix}_{_qt}")
|
||||
|
||||
# 辅助函数:检查新题干是否匹配任何 exclude 前缀
|
||||
_exclude_prefixes = []
|
||||
if exclude_stems:
|
||||
_exclude_prefixes = [s[:30] for s in exclude_stems]
|
||||
|
||||
def _matches_exclude(stem_text: str) -> bool:
|
||||
"""检查题干前30字是否以某个 exclude 前缀开头"""
|
||||
_stem30 = stem_text[:30]
|
||||
for _ep in _exclude_prefixes:
|
||||
if _stem30.startswith(_ep):
|
||||
return True
|
||||
return False
|
||||
|
||||
deduped = []
|
||||
|
||||
@@ -1419,6 +1527,10 @@ def _deduplicate_questions(questions: List[Dict], exclude_stems: List[str] = Non
|
||||
if stem_key in seen_stems:
|
||||
continue
|
||||
|
||||
# 跨调用排除:题干前缀匹配到 exclude_stems 则跳过
|
||||
if _matches_exclude(stem):
|
||||
continue
|
||||
|
||||
# 知识点 + 题型去重
|
||||
kp_type_key = f"{stem[:30]}_{q.get('question_type')}"
|
||||
if kp_type_key in seen_kp_type:
|
||||
|
||||
@@ -37,6 +37,21 @@ except ImportError:
|
||||
MODEL = None
|
||||
LLM_AVAILABLE = False
|
||||
|
||||
# 推理模型识别(与 generator.py 共享同一套关键词)
|
||||
_REASONING_MODEL_KEYWORDS = ('mimo', 'qwq', 'deepseek-r1', 'deepseek-reasoner', 'o1', 'o3')
|
||||
|
||||
def _is_reasoning_model(model_name: str) -> bool:
|
||||
if not model_name:
|
||||
return False
|
||||
name_lower = model_name.lower()
|
||||
return any(kw in name_lower for kw in _REASONING_MODEL_KEYWORDS)
|
||||
|
||||
def _get_effective_max_tokens(base_max: int, model_name: str) -> int:
|
||||
"""推理模型需要 1.5x token 预算给思考链"""
|
||||
if _is_reasoning_model(model_name):
|
||||
return max(base_max, int(base_max * 1.5))
|
||||
return base_max
|
||||
|
||||
|
||||
# ==================== 装饰器 ====================
|
||||
|
||||
@@ -73,11 +88,33 @@ grading_semaphore = threading.Semaphore(MAX_CONCURRENT_GRADING)
|
||||
|
||||
# ==================== 本地批阅函数 ====================
|
||||
|
||||
def _normalize_true_false(value) -> bool:
|
||||
"""
|
||||
将判断题的各种表示形式统一转为 bool。
|
||||
|
||||
支持: "对"/"错", "正确"/"错误", "true"/"false", "yes"/"no",
|
||||
True/False, 1/0, "T"/"F", "1"/"0"
|
||||
"""
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
v = value.strip().lower()
|
||||
if v in ('对', '正确', 'true', 'yes', 't', '1'):
|
||||
return True
|
||||
if v in ('错', '错误', 'false', 'no', 'f', '0'):
|
||||
return False
|
||||
# 无法识别时返回 None,让比较逻辑走原始字符串匹配
|
||||
return None
|
||||
|
||||
|
||||
def grade_objective(answer: Dict) -> Dict:
|
||||
"""
|
||||
批阅客观题(选择/判断)
|
||||
|
||||
🔥 本地直接判断,无 LLM 调用
|
||||
判断题返回的 student_answer / correct_answer 统一为 bool (true/false)
|
||||
"""
|
||||
q_type = answer['question_type']
|
||||
question_content = answer.get('content', {})
|
||||
@@ -85,17 +122,39 @@ def grade_objective(answer: Dict) -> Dict:
|
||||
student_answer = answer.get('student_answer')
|
||||
max_score = answer.get('max_score', 2.0)
|
||||
|
||||
# 判断正确性
|
||||
if q_type == 'single_choice':
|
||||
# 判断题:归一化为 bool 比较 + bool 输出
|
||||
if q_type == 'true_false':
|
||||
norm_correct = _normalize_true_false(correct_answer)
|
||||
norm_student = _normalize_true_false(student_answer)
|
||||
if norm_correct is not None and norm_student is not None:
|
||||
correct = norm_correct == norm_student
|
||||
correct_answer = norm_correct
|
||||
student_answer = norm_student
|
||||
else:
|
||||
# 兜底:无法归一化时用原始字符串比较
|
||||
correct = student_answer == correct_answer
|
||||
# 仍尝试转为 bool 输出,转不了则保留原值
|
||||
if norm_correct is not None:
|
||||
correct_answer = norm_correct
|
||||
if norm_student is not None:
|
||||
student_answer = norm_student
|
||||
elif q_type == 'single_choice':
|
||||
correct = student_answer == correct_answer
|
||||
elif q_type == 'multiple_choice':
|
||||
# 多选题:答案顺序无关
|
||||
correct = set(student_answer) == set(correct_answer) if isinstance(student_answer, list) else False
|
||||
elif q_type == 'true_false':
|
||||
correct = student_answer == correct_answer
|
||||
else:
|
||||
correct = False
|
||||
|
||||
# 构造 feedback:判断题用 true/false,其他题型用原值
|
||||
if not correct:
|
||||
if q_type == 'true_false' and isinstance(correct_answer, bool):
|
||||
feedback = f"正确答案: {'true' if correct_answer else 'false'}"
|
||||
else:
|
||||
feedback = f"正确答案: {correct_answer}"
|
||||
else:
|
||||
feedback = "正确!"
|
||||
|
||||
return {
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": max_score if correct else 0,
|
||||
@@ -105,11 +164,49 @@ def grade_objective(answer: Dict) -> Dict:
|
||||
"correct": correct,
|
||||
"student_answer": student_answer,
|
||||
"correct_answer": correct_answer,
|
||||
"feedback": f"正确答案: {correct_answer}" if not correct else "正确!"
|
||||
"feedback": feedback
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def _normalize_fill_blank_answer(correct_answers: list, blank_count: int = 0) -> list:
|
||||
"""
|
||||
归一化填空题答案为二维数组 [["答案1", "同义词"], ["答案2"], ...]
|
||||
|
||||
修复 LLM 生成扁平数组 ["答案1", "答案2"] 的格式错误:
|
||||
- 扁平数组会被误认为"1个空、多个可选答案",导致匹配一个就给满分
|
||||
- 归一化后每个元素独立为空,各自占分
|
||||
|
||||
Args:
|
||||
correct_answers: 原始答案(可能是 1D 或 2D)
|
||||
blank_count: 题目声明的空数(来自 content.data.blank_count),用于辅助判断
|
||||
"""
|
||||
if not correct_answers:
|
||||
return correct_answers
|
||||
|
||||
# 已经是标准二维格式:每个元素都是 list
|
||||
if all(isinstance(item, list) for item in correct_answers):
|
||||
return correct_answers
|
||||
|
||||
# 扁平数组:元素全是字符串 → 每个字符串是独立的空
|
||||
if all(isinstance(item, str) for item in correct_answers):
|
||||
expected_blanks = blank_count if blank_count > 0 else len(correct_answers)
|
||||
logger.warning(
|
||||
f"填空题答案格式修正: 扁平数组 {correct_answers!r} → 二维数组 "
|
||||
f"(检测到 {len(correct_answers)} 个元素, blank_count={blank_count})"
|
||||
)
|
||||
return [[item] for item in correct_answers]
|
||||
|
||||
# 混合类型(不太可能发生),尝试兜底
|
||||
result = []
|
||||
for item in correct_answers:
|
||||
if isinstance(item, list):
|
||||
result.append(item)
|
||||
else:
|
||||
result.append([item])
|
||||
return result
|
||||
|
||||
|
||||
def grade_fill_blank(answer: Dict) -> Dict:
|
||||
"""
|
||||
批阅填空题 - 支持同义词匹配
|
||||
@@ -122,6 +219,45 @@ def grade_fill_blank(answer: Dict) -> Dict:
|
||||
student_answers = answer.get('student_answer', [])
|
||||
max_score = answer.get('max_score', 4.0)
|
||||
|
||||
if not isinstance(student_answers, list):
|
||||
logger.warning(
|
||||
"填空题学生答案格式错误: 期望列表,实际为 %s",
|
||||
type(student_answers).__name__,
|
||||
)
|
||||
return {
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": 0,
|
||||
"max_score": max_score,
|
||||
"grading_status": "failed",
|
||||
"details": {
|
||||
"error": f"学生答案格式错误,期望列表,实际为 {type(student_answers).__name__}"
|
||||
},
|
||||
}
|
||||
|
||||
for index, student_answer in enumerate(student_answers):
|
||||
if not isinstance(student_answer, str):
|
||||
logger.warning(
|
||||
"填空题学生答案第 %s 项格式错误: 期望字符串,实际为 %s",
|
||||
index + 1,
|
||||
type(student_answer).__name__,
|
||||
)
|
||||
return {
|
||||
"question_id": answer.get('question_id'),
|
||||
"score": 0,
|
||||
"max_score": max_score,
|
||||
"grading_status": "failed",
|
||||
"details": {
|
||||
"error": (
|
||||
f"填空题学生答案第 {index + 1} 项格式错误,期望字符串,"
|
||||
f"实际为 {type(student_answer).__name__}"
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
# 归一化答案格式(修复 LLM 生成的扁平数组问题)
|
||||
blank_count = question_content.get('data', {}).get('blank_count', 0)
|
||||
correct_answers = _normalize_fill_blank_answer(correct_answers, blank_count)
|
||||
|
||||
if not correct_answers or not student_answers:
|
||||
return {
|
||||
"question_id": answer.get('question_id'),
|
||||
@@ -169,19 +305,56 @@ def grade_fill_blank(answer: Dict) -> Dict:
|
||||
|
||||
def fuzzy_match(student_answer: str, correct_answer: str) -> bool:
|
||||
"""
|
||||
模糊匹配(支持同义词)
|
||||
模糊匹配(支持同义词和小编辑距离容错)
|
||||
|
||||
当前实现:精确匹配(忽略前后空格、大小写)
|
||||
TODO: 可以扩展为语义相似度匹配
|
||||
策略:
|
||||
1. 精确匹配(去空格、转小写、统一标点)
|
||||
2. 编辑距离容错(≥4字答案允许≤2字符差异)
|
||||
"""
|
||||
if not student_answer or not correct_answer:
|
||||
return False
|
||||
|
||||
# 标准化:去空格、转小写
|
||||
s = student_answer.strip().lower()
|
||||
c = correct_answer.strip().lower()
|
||||
# 标准化:去空格、转小写、统一标点
|
||||
def _normalize(text: str) -> str:
|
||||
t = text.strip().lower()
|
||||
# 统一常见中文标点变体
|
||||
t = t.replace('(', '(').replace(')', ')').replace(',', ',')
|
||||
t = t.replace(';', ';').replace(':', ':').replace('"', '"').replace('"', '"')
|
||||
return t
|
||||
|
||||
return s == c
|
||||
s = _normalize(student_answer)
|
||||
c = _normalize(correct_answer)
|
||||
|
||||
if s == c:
|
||||
return True
|
||||
|
||||
# 编辑距离容错:答案≥4字时允许≤2字符差异
|
||||
if len(s) >= 4 and len(c) >= 4:
|
||||
dist = _edit_distance(s, c)
|
||||
if dist <= 2:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _edit_distance(s1: str, s2: str) -> int:
|
||||
"""计算两个字符串的编辑距离(Levenshtein)"""
|
||||
if len(s1) < len(s2):
|
||||
return _edit_distance(s2, s1)
|
||||
if len(s2) == 0:
|
||||
return len(s1)
|
||||
|
||||
prev_row = list(range(len(s2) + 1))
|
||||
for i, c1 in enumerate(s1):
|
||||
curr_row = [i + 1]
|
||||
for j, c2 in enumerate(s2):
|
||||
# 插入、删除、替换
|
||||
insertions = prev_row[j + 1] + 1
|
||||
deletions = curr_row[j] + 1
|
||||
substitutions = prev_row[j] + (c1 != c2)
|
||||
curr_row.append(min(insertions, deletions, substitutions))
|
||||
prev_row = curr_row
|
||||
return prev_row[-1]
|
||||
|
||||
|
||||
# ==================== AnswerGrader 类 ====================
|
||||
@@ -237,7 +410,21 @@ class AnswerGrader:
|
||||
|
||||
# 🔥 P1 改进:并发调用 LLM 批阅主观题
|
||||
if llm_questions:
|
||||
self._grade_subjective_concurrently(llm_questions, results_map)
|
||||
try:
|
||||
self._grade_subjective_concurrently(llm_questions, results_map)
|
||||
except Exception as e:
|
||||
logger.error(f"主观题并发批阅整体异常: {e}")
|
||||
# 兜底:为所有未完成的主观题设置失败状态
|
||||
for ans in llm_questions:
|
||||
qid = ans.get('question_id')
|
||||
if qid not in results_map:
|
||||
results_map[qid] = {
|
||||
"question_id": qid,
|
||||
"score": 0,
|
||||
"max_score": ans.get('max_score', 10),
|
||||
"grading_status": "failed",
|
||||
"details": {"error": f"批阅系统异常: {str(e)}"}
|
||||
}
|
||||
|
||||
# 🔥 P1 改进:按原始顺序重组结果
|
||||
results = [results_map.get(ans.get('question_id')) for ans in answers]
|
||||
@@ -369,7 +556,7 @@ class AnswerGrader:
|
||||
prompt=prompt,
|
||||
model=self.model,
|
||||
temperature=0.3,
|
||||
max_tokens=1000,
|
||||
max_tokens=_get_effective_max_tokens(2000, self.model),
|
||||
messages=messages
|
||||
)
|
||||
if result is None:
|
||||
|
||||
@@ -170,8 +170,9 @@ class ExamLocalDB:
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
''', (exam_id, name, description, total_score, len(questions), duration, 'published', created_by))
|
||||
|
||||
# 关联题目
|
||||
for order, qid in enumerate(question_ids):
|
||||
# 仅关联实际存在的题目,避免无效 ID 触发外键约束。
|
||||
valid_question_ids = [q['id'] for q in questions]
|
||||
for order, qid in enumerate(valid_question_ids):
|
||||
cursor.execute('''
|
||||
INSERT INTO exam_questions (exam_id, question_id, question_order)
|
||||
VALUES (?, ?, ?)
|
||||
@@ -183,7 +184,7 @@ class ExamLocalDB:
|
||||
'total_score': total_score,
|
||||
'total_count': len(questions),
|
||||
'duration': duration,
|
||||
'question_ids': question_ids
|
||||
'question_ids': valid_question_ids
|
||||
}
|
||||
|
||||
def get_exam(self, exam_id: str) -> Optional[Dict]:
|
||||
|
||||
@@ -154,7 +154,8 @@ def generate_questions_from_file(
|
||||
def analyze_file_for_exam(
|
||||
file_path: str,
|
||||
collection: str,
|
||||
top_k: int = 50
|
||||
top_k: int = 50,
|
||||
max_total: int = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
分析文件内容,返回 AI 推荐的题型和数量
|
||||
@@ -193,7 +194,7 @@ def analyze_file_for_exam(
|
||||
}
|
||||
|
||||
# 2. 调用 AI 分析
|
||||
return analyze_document_for_exam(chunks)
|
||||
return analyze_document_for_exam(chunks, max_total=max_total)
|
||||
|
||||
|
||||
def retrieve_file_chunks_for_analysis(
|
||||
@@ -311,6 +312,7 @@ def retrieve_file_chunks(
|
||||
engine = get_engine()
|
||||
|
||||
# 按优先级遍历 collections,找到文件即停止
|
||||
results = None
|
||||
for coll in collections:
|
||||
# 尝试两种格式:文件名和完整路径
|
||||
for source_filter in [filename, file_path]:
|
||||
@@ -330,7 +332,7 @@ def retrieve_file_chunks(
|
||||
break # 外层循环跳出
|
||||
|
||||
chunks = []
|
||||
if results.get('documents') and results['documents'][0]:
|
||||
if results and results.get('documents') and results['documents'][0]:
|
||||
for i, (doc, meta, score) in enumerate(zip(
|
||||
results['documents'][0],
|
||||
results['metadatas'][0],
|
||||
|
||||
@@ -331,6 +331,21 @@ class CollectionMixin:
|
||||
except Exception as e:
|
||||
logger.warning(f"清理版本记录失败: {e}")
|
||||
|
||||
# 清理不再被引用的图片和 VLM 缓存文件
|
||||
# 注意:此时 ChromaDB collection 已删除,cleanup_image_orphans 会扫描
|
||||
# 所有剩余 collection,仅该 collection 引用的图片会被识别为孤儿
|
||||
try:
|
||||
from knowledge.image_cleanup import cleanup_image_orphans
|
||||
cleanup_result = cleanup_image_orphans(self)
|
||||
if cleanup_result['deleted_images'] or cleanup_result['deleted_caches']:
|
||||
logger.info(
|
||||
f"清理孤儿文件: {cleanup_result['deleted_images']} 图片 + "
|
||||
f"{cleanup_result['deleted_caches']} VLM缓存, "
|
||||
f"释放 {cleanup_result['freed_bytes']/1024:.1f} KB"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"清理孤儿文件失败: {e}")
|
||||
|
||||
if kb_name in self._metadata.get("collections", {}):
|
||||
del self._metadata["collections"][kb_name]
|
||||
self._save_metadata()
|
||||
|
||||
@@ -72,6 +72,18 @@ class DocumentMixin:
|
||||
except Exception as e:
|
||||
logger.warning(f"清理版本记录失败: {e}")
|
||||
|
||||
# 清理不再被引用的图片和 VLM 缓存文件
|
||||
try:
|
||||
from knowledge.image_cleanup import cleanup_image_orphans
|
||||
cleanup_result = cleanup_image_orphans(self, collections=[kb_name])
|
||||
if cleanup_result['deleted_images'] or cleanup_result['deleted_caches']:
|
||||
logger.info(
|
||||
f"清理孤儿文件: {cleanup_result['deleted_images']} 图片 + "
|
||||
f"{cleanup_result['deleted_caches']} VLM缓存"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"清理孤儿文件失败: {e}")
|
||||
|
||||
logger.info(f"从 {kb_name} 删除文档: {filename}, 片段数: {deleted}")
|
||||
return deleted
|
||||
|
||||
|
||||
@@ -25,7 +25,20 @@ def compute_file_hash(file_path: str) -> str:
|
||||
return hashlib.md5(file_path.encode()).hexdigest()
|
||||
|
||||
|
||||
async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, metadata: dict = None) -> str:
|
||||
def _get_embedding_model():
|
||||
"""从 RAGEngine 获取 embedding 模型(KnowledgeBaseManager 上没有此属性)"""
|
||||
try:
|
||||
from core.engine import get_engine
|
||||
engine = get_engine()
|
||||
if not engine._initialized:
|
||||
engine.initialize()
|
||||
return engine.embedding_model
|
||||
except Exception as e:
|
||||
logger.warning(f"获取 embedding 模型失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, metadata: dict = None, defer_chromadb: bool = False) -> str:
|
||||
"""
|
||||
懒加载 VLM 描述
|
||||
|
||||
@@ -36,6 +49,7 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
image_path: 图片路径(相对路径或绝对路径)
|
||||
kb_name: 知识库名称
|
||||
metadata: 图片元数据(包含 section、page、caption、上下文等)
|
||||
defer_chromadb: 为 True 时跳过 ChromaDB 更新(仅写文件缓存),避免后台线程写锁竞争
|
||||
|
||||
Returns:
|
||||
VLM 生成的图片描述
|
||||
@@ -49,23 +63,45 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
else:
|
||||
full_image_path = image_path
|
||||
|
||||
# 1. 检查缓存
|
||||
# 1. 检查缓存(空缓存视为无效,需重新生成)
|
||||
img_hash = compute_file_hash(full_image_path)
|
||||
cache_file = VLM_CACHE_DIR / f"{img_hash}.txt"
|
||||
if cache_file.exists():
|
||||
logger.info(f"VLM 缓存命中: {image_path}")
|
||||
return cache_file.read_text(encoding='utf-8')
|
||||
cached = cache_file.read_text(encoding='utf-8')
|
||||
if len(cached.strip()) >= 5:
|
||||
logger.info(f"VLM 缓存命中: {image_path}")
|
||||
return cached
|
||||
else:
|
||||
logger.warning(f"VLM 缓存内容过短({len(cached.strip())}字符),删除并重新生成: {image_path}")
|
||||
try:
|
||||
cache_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. 调用 VLM(传入元数据)
|
||||
logger.info(f"VLM 懒加载: {image_path}")
|
||||
kb_manager = get_kb_manager()
|
||||
description = kb_manager._generate_image_description(full_image_path, metadata=metadata)
|
||||
|
||||
# 3. 写入缓存
|
||||
# 3. 空描述保护:VLM 返回内容过短时不写入缓存和向量库
|
||||
if not description or len(description.strip()) < 5:
|
||||
logger.warning(f"VLM 返回描述过短({len(description.strip()) if description else 0}字符),跳过缓存和向量库更新: {image_path}")
|
||||
return description or ''
|
||||
|
||||
# 4. 写入缓存
|
||||
VLM_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(description, encoding='utf-8')
|
||||
|
||||
# 4. 更新向量库(metadata + embedding)
|
||||
# 5. 更新向量库(metadata + embedding),需校验 chunk_id 非空
|
||||
# defer_chromadb=True 时跳过(后台线程只写缓存,避免 SQLite 写锁竞争)
|
||||
if defer_chromadb:
|
||||
logger.info(f"延迟 ChromaDB 更新(仅写缓存): {chunk_id}")
|
||||
return description
|
||||
|
||||
if not chunk_id:
|
||||
logger.warning("chunk_id 为空,跳过向量库更新")
|
||||
return description
|
||||
|
||||
try:
|
||||
collection = kb_manager.get_collection(kb_name)
|
||||
result = collection.get(ids=[chunk_id], include=['metadatas'])
|
||||
@@ -79,7 +115,7 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
|
||||
# 更新 embedding(使用 VLM 描述重新计算向量)
|
||||
# 这样 VLM 描述中的关键词(如"发电量")才能参与相似度检索
|
||||
embedding_model = kb_manager.embedding_model
|
||||
embedding_model = _get_embedding_model()
|
||||
if embedding_model:
|
||||
new_vector = embedding_model.encode(description).tolist()
|
||||
if isinstance(new_vector[0], list):
|
||||
@@ -91,20 +127,21 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
embeddings=[new_vector],
|
||||
documents=[description] # 同时更新 document 字段
|
||||
)
|
||||
logger.info(f"已更新向量库 embedding: {chunk_id}")
|
||||
logger.info(f"已更新向量库(embedding+metadata): {chunk_id}")
|
||||
else:
|
||||
# 无 embedding 模型时只更新 metadata
|
||||
collection.update(
|
||||
ids=[chunk_id],
|
||||
metadatas=[new_metadata]
|
||||
)
|
||||
logger.info(f"已更新向量库(仅metadata,无embedding模型): {chunk_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"更新向量库失败: {e}")
|
||||
|
||||
return description
|
||||
|
||||
|
||||
async def lazy_table_summary(chunk_id: str, table_md: str, kb_name: str) -> str:
|
||||
async def lazy_table_summary(chunk_id: str, table_md: str, kb_name: str, defer_chromadb: bool = False) -> str:
|
||||
"""
|
||||
懒加载表格摘要
|
||||
|
||||
@@ -114,50 +151,76 @@ async def lazy_table_summary(chunk_id: str, table_md: str, kb_name: str) -> str:
|
||||
chunk_id: 切片 ID
|
||||
table_md: 表格 Markdown 内容
|
||||
kb_name: 知识库名称
|
||||
defer_chromadb: 为 True 时跳过 ChromaDB 更新(仅写文件缓存),避免后台线程写锁竞争
|
||||
|
||||
Returns:
|
||||
LLM 生成的表格摘要
|
||||
"""
|
||||
from knowledge.manager import get_kb_manager
|
||||
|
||||
# 1. 检查缓存
|
||||
# 1. 检查缓存(空缓存视为无效)
|
||||
table_hash = hashlib.md5(table_md.encode()).hexdigest()
|
||||
cache_file = LLM_CACHE_DIR / f"{table_hash}.txt"
|
||||
if cache_file.exists():
|
||||
logger.info(f"LLM 缓存命中: {chunk_id}")
|
||||
return cache_file.read_text(encoding='utf-8')
|
||||
cached = cache_file.read_text(encoding='utf-8')
|
||||
if len(cached.strip()) >= 5:
|
||||
logger.info(f"LLM 缓存命中: {chunk_id}")
|
||||
return cached
|
||||
else:
|
||||
logger.warning(f"LLM 缓存内容过短({len(cached.strip())}字符),删除并重新生成: {chunk_id}")
|
||||
try:
|
||||
cache_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
# 2. 调用 LLM
|
||||
logger.info(f"LLM 懒加载: {chunk_id}")
|
||||
kb_manager = get_kb_manager()
|
||||
summary = kb_manager._generate_table_summary(table_md, None)
|
||||
|
||||
# 空摘要保护
|
||||
if not summary or len(summary.strip()) < 5:
|
||||
logger.warning(f"LLM 返回摘要过短,跳过缓存和向量库更新: {chunk_id}")
|
||||
return summary or ''
|
||||
|
||||
# 3. 写入缓存
|
||||
LLM_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||
cache_file.write_text(summary, encoding='utf-8')
|
||||
|
||||
# 4. 更新向量库(可选)
|
||||
# 4. 更新向量库,需校验 chunk_id 非空
|
||||
# defer_chromadb=True 时跳过(后台线程只写缓存,避免 SQLite 写锁竞争)
|
||||
if defer_chromadb:
|
||||
logger.info(f"延迟 ChromaDB 更新(仅写缓存): {chunk_id}")
|
||||
return summary
|
||||
|
||||
if not chunk_id:
|
||||
logger.warning("chunk_id 为空,跳过表格向量库更新")
|
||||
return summary
|
||||
try:
|
||||
collection = kb_manager.get_collection(kb_name)
|
||||
result = collection.get(ids=[chunk_id], include=['metadatas'])
|
||||
if result['metadatas']:
|
||||
# 新增摘要切片
|
||||
embedding_model = kb_manager.embedding_model
|
||||
vector = embedding_model.encode(summary).tolist()
|
||||
if isinstance(vector[0], list):
|
||||
vector = vector[0]
|
||||
# 新增摘要切片(需要 embedding 模型)
|
||||
embedding_model = _get_embedding_model()
|
||||
if embedding_model:
|
||||
vector = embedding_model.encode(summary).tolist()
|
||||
if isinstance(vector[0], list):
|
||||
vector = vector[0]
|
||||
|
||||
collection.add(
|
||||
ids=[f"{chunk_id}_summary"],
|
||||
embeddings=[vector],
|
||||
documents=[summary],
|
||||
metadatas=[{
|
||||
**result['metadatas'][0],
|
||||
'is_summary': True,
|
||||
'original_doc_id': chunk_id
|
||||
}]
|
||||
)
|
||||
# 更新原切片标记
|
||||
collection.add(
|
||||
ids=[f"{chunk_id}_summary"],
|
||||
embeddings=[vector],
|
||||
documents=[summary],
|
||||
metadatas=[{
|
||||
**result['metadatas'][0],
|
||||
'is_summary': True,
|
||||
'original_doc_id': chunk_id
|
||||
}]
|
||||
)
|
||||
logger.info(f"已新增摘要切片(embedding): {chunk_id}_summary")
|
||||
else:
|
||||
logger.info(f"跳过摘要切片(无embedding模型): {chunk_id}")
|
||||
# 更新原切片标记(不依赖 embedding 模型)
|
||||
collection.update(
|
||||
ids=[chunk_id],
|
||||
metadatas=[{**result['metadatas'][0], 'has_summary': True}]
|
||||
@@ -168,7 +231,7 @@ async def lazy_table_summary(chunk_id: str, table_md: str, kb_name: str) -> str:
|
||||
return summary
|
||||
|
||||
|
||||
async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str):
|
||||
async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str, defer_chromadb: bool = False):
|
||||
"""
|
||||
检索后增强:按需调用 LLM/VLM
|
||||
|
||||
@@ -176,23 +239,24 @@ async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str):
|
||||
contexts: 检索上下文列表
|
||||
query: 用户查询
|
||||
kb_name: 知识库名称
|
||||
defer_chromadb: 为 True 时后台线程只写文件缓存,不更新 ChromaDB(避免写锁竞争)
|
||||
"""
|
||||
for ctx in contexts:
|
||||
meta = ctx.get('meta', {})
|
||||
chunk_type = meta.get('chunk_type', 'text')
|
||||
image_path = meta.get('image_path', '')
|
||||
import re
|
||||
|
||||
# 图片切片:懒加载 VLM 描述
|
||||
if chunk_type in ('image', 'chart') and not meta.get('has_vlm_desc'):
|
||||
if image_path:
|
||||
try:
|
||||
for ctx in contexts:
|
||||
try:
|
||||
meta = ctx.get('meta', {})
|
||||
chunk_type = meta.get('chunk_type', 'text')
|
||||
image_path = meta.get('image_path', '')
|
||||
|
||||
# 图片切片:懒加载 VLM 描述
|
||||
if chunk_type in ('image', 'chart') and not meta.get('has_vlm_desc'):
|
||||
if image_path:
|
||||
# 从 doc 字段中提取图号(上下文可能包含"见图2.5"等)
|
||||
doc_text = ctx.get('doc', '')
|
||||
import re
|
||||
|
||||
# 提取图号(从前文/后文中)
|
||||
figure_number = ""
|
||||
# 匹配 "见图2.5"、"图2.5"、"见图 2.5" 等
|
||||
fig_match = re.search(r'[见如]?图\s*(\d+\.?\d*)', doc_text)
|
||||
if fig_match:
|
||||
figure_number = fig_match.group(1)
|
||||
@@ -210,78 +274,73 @@ async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str):
|
||||
'page': meta.get('page'),
|
||||
'caption': meta.get('caption', ''),
|
||||
'source': meta.get('source', ''),
|
||||
'figure_number': figure_number, # 添加提取的图号
|
||||
'doc_text': doc_text # 添加完整文档文本
|
||||
'figure_number': figure_number,
|
||||
'doc_text': doc_text
|
||||
}
|
||||
vlm_desc = await lazy_vlm_description(
|
||||
meta.get('id', ''),
|
||||
meta.get('chunk_id', ''),
|
||||
image_path,
|
||||
kb_name,
|
||||
metadata=image_metadata
|
||||
metadata=image_metadata,
|
||||
defer_chromadb=defer_chromadb
|
||||
)
|
||||
ctx['doc'] = vlm_desc
|
||||
ctx['vlm_enhanced'] = True
|
||||
except Exception as e:
|
||||
logger.warning(f"VLM 懒加载失败: {e}")
|
||||
if vlm_desc:
|
||||
ctx['doc'] = vlm_desc
|
||||
ctx['vlm_enhanced'] = True
|
||||
|
||||
# 表格切片:同时处理摘要和关联图片的 VLM 描述
|
||||
elif chunk_type == 'table':
|
||||
doc_text = ctx.get('doc', '')
|
||||
# 表格切片:同时处理摘要和关联图片的 VLM 描述
|
||||
elif chunk_type == 'table':
|
||||
doc_text = ctx.get('doc', '')
|
||||
|
||||
# 1. 懒加载表格摘要(高分切片)
|
||||
if not meta.get('has_summary'):
|
||||
score = meta.get('score', 0)
|
||||
if score > 0.7: # 只对高相关表格生成摘要
|
||||
try:
|
||||
# 1. 懒加载表格摘要(高分切片)
|
||||
if not meta.get('has_summary'):
|
||||
score = ctx.get('score', 0)
|
||||
if score > 0.7:
|
||||
summary = await lazy_table_summary(
|
||||
meta.get('id', ''),
|
||||
meta.get('chunk_id', ''),
|
||||
doc_text,
|
||||
kb_name
|
||||
kb_name,
|
||||
defer_chromadb=defer_chromadb
|
||||
)
|
||||
# 摘要作为补充信息
|
||||
ctx['summary'] = summary
|
||||
ctx['llm_enhanced'] = True
|
||||
except Exception as e:
|
||||
logger.warning(f"表格摘要懒加载失败: {e}")
|
||||
|
||||
# 2. 表格有关联图片时,懒加载 VLM 描述
|
||||
if image_path and not meta.get('has_vlm_desc'):
|
||||
try:
|
||||
import re
|
||||
if summary:
|
||||
ctx['summary'] = summary
|
||||
ctx['llm_enhanced'] = True
|
||||
|
||||
# 2. 表格有关联图片时,懒加载 VLM 描述
|
||||
if image_path and not meta.get('has_vlm_desc'):
|
||||
# 提取表号(如 "表2.2"、"见表2.1")
|
||||
table_number = ""
|
||||
# 匹配 "表2.2"、"见表2.2"、"见表 2.2" 等
|
||||
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', doc_text)
|
||||
if table_match:
|
||||
table_number = table_match.group(1)
|
||||
|
||||
# 如果 doc 中没有,尝试从 section 中提取
|
||||
section = meta.get('section') or meta.get('section_path', '')
|
||||
if not table_number and section:
|
||||
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', section)
|
||||
if table_match:
|
||||
table_number = table_match.group(1)
|
||||
|
||||
# 构建表格图片元数据
|
||||
table_image_metadata = {
|
||||
'section': section,
|
||||
'page': meta.get('page'),
|
||||
'caption': meta.get('caption', ''),
|
||||
'source': meta.get('source', ''),
|
||||
'table_number': table_number, # 表号
|
||||
'figure_number': table_number, # 兼容字段
|
||||
'table_number': table_number,
|
||||
'figure_number': table_number,
|
||||
'doc_text': doc_text,
|
||||
'is_table': True # 标记为表格图片
|
||||
'is_table': True
|
||||
}
|
||||
vlm_desc = await lazy_vlm_description(
|
||||
meta.get('id', ''),
|
||||
meta.get('chunk_id', ''),
|
||||
image_path,
|
||||
kb_name,
|
||||
metadata=table_image_metadata
|
||||
metadata=table_image_metadata,
|
||||
defer_chromadb=defer_chromadb
|
||||
)
|
||||
# 表格图片描述作为补充信息
|
||||
ctx['image_description'] = vlm_desc
|
||||
ctx['vlm_enhanced'] = True
|
||||
except Exception as e:
|
||||
logger.warning(f"表格图片 VLM 懒加载失败: {e}")
|
||||
if vlm_desc:
|
||||
ctx['image_description'] = vlm_desc
|
||||
ctx['vlm_enhanced'] = True
|
||||
|
||||
except Exception as e:
|
||||
chunk_id = ctx.get('meta', {}).get('chunk_id', '?')
|
||||
logger.warning(f"增强切片失败(chunk_id={chunk_id}): {e}")
|
||||
|
||||
@@ -39,6 +39,7 @@ from pathlib import Path
|
||||
import logging
|
||||
|
||||
import chromadb
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
# 从 base.py 导入基础类和常量
|
||||
from .base import (
|
||||
@@ -335,6 +336,7 @@ class KnowledgeBaseManager(
|
||||
"section": section_path,
|
||||
"status": "active",
|
||||
"version": "v1",
|
||||
"text_level": getattr(chunk, 'text_level', 0), # 标题级别(0=正文,1=h1,2=h2,3=h3),供检索管线层次感知
|
||||
}
|
||||
|
||||
if extra_metadata:
|
||||
@@ -347,6 +349,25 @@ class KnowledgeBaseManager(
|
||||
if hasattr(chunk, 'image_path') and chunk.image_path:
|
||||
metadata['image_path'] = chunk.image_path
|
||||
|
||||
# bbox 坐标(PDF 有,DOCX 无,需 None 保护)
|
||||
# _build_citation() 从 metadata 读取 bbox 做引用定位
|
||||
chunk_bbox = getattr(chunk, 'bbox', None)
|
||||
if chunk_bbox:
|
||||
metadata['bbox'] = json.dumps(chunk_bbox)
|
||||
|
||||
# MinerU 结构化元数据(表格类型、嵌套层级、图片子类型)
|
||||
chunk_table_type = getattr(chunk, 'table_type', '')
|
||||
if chunk_table_type:
|
||||
metadata['table_type'] = chunk_table_type
|
||||
|
||||
chunk_nest_level = getattr(chunk, 'table_nest_level', '')
|
||||
if chunk_nest_level:
|
||||
metadata['table_nest_level'] = str(chunk_nest_level)
|
||||
|
||||
chunk_sub_type = getattr(chunk, 'sub_type', '')
|
||||
if chunk_sub_type:
|
||||
metadata['sub_type'] = chunk_sub_type
|
||||
|
||||
# 生成向量
|
||||
try:
|
||||
embedding = embedding_model.encode(semantic_content).tolist()
|
||||
@@ -530,8 +551,39 @@ class KnowledgeBaseManager(
|
||||
curr_html = getattr(current, 'table_html', '') or ''
|
||||
next_html = getattr(next_chunk, 'table_html', '') or ''
|
||||
if curr_html and next_html:
|
||||
# 合并两个表格的 HTML
|
||||
current.table_html = curr_html + '\n' + next_html
|
||||
# 正确合并两个表格的 HTML:
|
||||
# 将第二个表格的 <tr> 行追加到第一个表格中
|
||||
# (而非简单拼接两个 <table>,否则 html_table_to_markdown
|
||||
# 的 soup.find('table') 只能找到第一个表格)
|
||||
try:
|
||||
soup1 = BeautifulSoup(curr_html, 'html.parser')
|
||||
soup2 = BeautifulSoup(next_html, 'html.parser')
|
||||
table1 = soup1.find('table')
|
||||
table2 = soup2.find('table')
|
||||
if table1 and table2:
|
||||
# 从第二个表格提取数据行
|
||||
next_rows = table2.find_all('tr')
|
||||
# 跳过与第一个表格表头重复的行
|
||||
# 对比第一行而非所有 th(find_all('th') 会匹配
|
||||
# 整个表格的 th,无法与单行做列表比较)
|
||||
first_row_t1 = table1.find('tr')
|
||||
if first_row_t1 and next_rows:
|
||||
row1_texts = [c.get_text(strip=True) for c in first_row_t1.find_all(['th', 'td'])]
|
||||
row2_texts = [c.get_text(strip=True) for c in next_rows[0].find_all(['th', 'td'])]
|
||||
if row1_texts and row2_texts and row1_texts == row2_texts:
|
||||
next_rows = next_rows[1:]
|
||||
logger.debug("跨页表格合并: 跳过了重复的表头行")
|
||||
for row in next_rows:
|
||||
table1.append(row)
|
||||
current.table_html = str(soup1)
|
||||
logger.debug(f"跨页表格 HTML 合并成功: 追加了 {len(next_rows)} 行")
|
||||
else:
|
||||
current.table_html = curr_html + '\n' + next_html
|
||||
except Exception as e:
|
||||
logger.warning(f"跨页表格 HTML 合并异常: {e},回退到简单拼接")
|
||||
current.table_html = curr_html + '\n' + next_html
|
||||
elif not curr_html and next_html:
|
||||
current.table_html = next_html
|
||||
|
||||
# 合并 image_path 和嵌入图片到 images
|
||||
curr_img = getattr(current, 'image_path', None)
|
||||
@@ -602,7 +654,7 @@ class KnowledgeBaseManager(
|
||||
try:
|
||||
from config import get_llm_client, DASHSCOPE_MODEL
|
||||
client = get_llm_client()
|
||||
summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=512)
|
||||
summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=2048)
|
||||
return summary.strip() if summary else ""
|
||||
except Exception as e:
|
||||
logger.warning(f"生成表格摘要失败: {e}")
|
||||
@@ -661,13 +713,31 @@ class KnowledgeBaseManager(
|
||||
]
|
||||
}
|
||||
],
|
||||
max_tokens=512
|
||||
max_tokens=2048 # mimo-v2.5 推理模型思考链消耗 ~1000 token,需留足输出空间
|
||||
)
|
||||
|
||||
description = response.choices[0].message.content
|
||||
|
||||
# 推理模型兼容:content 为空时从 reasoning_content 提取
|
||||
if not description or not description.strip():
|
||||
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
|
||||
if reasoning and reasoning.strip():
|
||||
import re
|
||||
# 尝试从思考链中提取有用文本(去掉 <think> 标签后的内容)
|
||||
cleaned = re.sub(r'', '', reasoning, flags=re.DOTALL).strip()
|
||||
if cleaned:
|
||||
logger.info(f"VLM content为空,从reasoning_content提取描述: {image_path}")
|
||||
description = cleaned
|
||||
else:
|
||||
description = reasoning.strip()
|
||||
|
||||
if not description:
|
||||
logger.warning(f"VLM 返回空描述: {image_path}")
|
||||
return ""
|
||||
|
||||
# 缓存结果
|
||||
import hashlib
|
||||
import re as _re
|
||||
img_hash = hashlib.md5(img_path.read_bytes()).hexdigest()
|
||||
cache_dir = Path('.data/cache/vlm')
|
||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -664,6 +664,17 @@ class KnowledgeSyncService:
|
||||
except Exception as e:
|
||||
logger.warning(f"递增缓存版本号失败: {e}")
|
||||
|
||||
# 语义缓存没有知识库版本字段,文档增删改后必须清空;否则回答缓存
|
||||
# 可能继续返回已过时的来源、引用或图片路径。
|
||||
try:
|
||||
from core.semantic_cache import get_semantic_cache
|
||||
semantic_cache = get_semantic_cache()
|
||||
if semantic_cache:
|
||||
semantic_cache.clear()
|
||||
logger.debug(f"已清空语义缓存(文档变更触发): {kb_name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清空语义缓存失败: {e}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
|
||||
@@ -106,11 +106,12 @@ DEFAULT_HEADING_RULES: List[HeadingRule] = [
|
||||
name="chinese_chapter",
|
||||
),
|
||||
# 2. 中文条款编号 -> h2
|
||||
# 匹配:第一条、第三款 等
|
||||
# 匹配:第一条、第三款 等(仅短标题,长正文段落不算标题)
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[条款]'),
|
||||
level=2,
|
||||
name="chinese_article",
|
||||
max_length=30,
|
||||
),
|
||||
# 3. 数字三级标题 -> h3(必须在二级之前匹配)
|
||||
# 匹配:1.1.1 背景、2.3.4 方案 等
|
||||
@@ -121,20 +122,23 @@ DEFAULT_HEADING_RULES: List[HeadingRule] = [
|
||||
max_length=100,
|
||||
),
|
||||
# 4. 数字二级标题 -> h2(必须在一级之前匹配)
|
||||
# 匹配:1.1 背景、2.3 方案 等
|
||||
# 匹配:1.1 背景、2.3 方案、2.1运行调度(无空格) 等
|
||||
# 使用负向前瞻排除三级标题(由 numeric_level3 处理)
|
||||
HeadingRule(
|
||||
pattern=re.compile(r'^\d+\.\d+[\.、\s]'),
|
||||
pattern=re.compile(r'^\d+\.\d+(?!\.\d)'),
|
||||
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,
|
||||
exclude_pattern=re.compile(r'[;;。,、::]$'),
|
||||
),
|
||||
# 6. 英文章节标题 -> h1
|
||||
# 匹配:Chapter 1、Section 2、Part 3 等
|
||||
@@ -201,6 +205,22 @@ class HeadingRuleEngine:
|
||||
import copy
|
||||
self.rules = copy.deepcopy(DEFAULT_HEADING_RULES)
|
||||
|
||||
def _validate_level(self, level: int, text: str, rule_name=None):
|
||||
"""各级别标题长度防护:超长文本不应作为标题,降为正文。
|
||||
H1 > 40字, H2 > 60字, H3 > 50字 → 降为正文。
|
||||
统一覆盖 v1 常规匹配、v2 style 匹配、bold_short_text 兜底所有返回路径。"""
|
||||
text_len = len(text)
|
||||
if level == 1 and text_len > 40:
|
||||
logger.debug(f"标题识别: '{text[:30]}...' H1 但超长({text_len}字),降为正文")
|
||||
return 0, None
|
||||
if level == 2 and text_len > 60:
|
||||
logger.debug(f"标题识别: '{text[:30]}...' H2 但超长({text_len}字),降为正文")
|
||||
return 0, None
|
||||
if level == 3 and text_len > 50:
|
||||
logger.debug(f"标题识别: '{text[:30]}...' H3 但超长({text_len}字),降为正文")
|
||||
return 0, None
|
||||
return level, rule_name
|
||||
|
||||
def detect(self, text: str, style: Optional[List[str]] = None) -> Tuple[int, Optional[str]]:
|
||||
"""
|
||||
检测文本的标题级别
|
||||
@@ -227,7 +247,7 @@ class HeadingRuleEngine:
|
||||
level = rule.match(text)
|
||||
if level > 0:
|
||||
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
|
||||
return level, rule.name
|
||||
return self._validate_level(level, text, rule.name)
|
||||
|
||||
# 再检查是否匹配中文章节/条款等高优先级规则
|
||||
for rule in self.rules:
|
||||
@@ -236,9 +256,16 @@ class HeadingRuleEngine:
|
||||
level = rule.match(text)
|
||||
if level > 0:
|
||||
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
|
||||
return level, rule.name
|
||||
return self._validate_level(level, text, rule.name)
|
||||
|
||||
# 最后兜底:加粗短文本 → h2
|
||||
# 但需先检查所有规则的 exclude_pattern,防止编号列表项被误判为标题
|
||||
# 例如 "3.完全满足品规。指..." 虽有 bold 样式,但属于列表项而非标题
|
||||
for rule in self.rules:
|
||||
if rule.enabled and rule.exclude_pattern and rule.exclude_pattern.search(text):
|
||||
logger.debug(f"标题识别(v2 style): '{text[:30]}' 被 {rule.name} 的 exclude_pattern 排除")
|
||||
return 0, None
|
||||
|
||||
# 否则作为加粗短文本 → h2(与 bold_short_text 规则对齐,但不依赖 **...** 标记)
|
||||
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h2 (规则: bold_short_text_via_style)")
|
||||
return 2, 'bold_short_text'
|
||||
|
||||
@@ -247,7 +274,7 @@ class HeadingRuleEngine:
|
||||
level = rule.match(text)
|
||||
if level > 0:
|
||||
logger.debug(f"标题识别: '{text[:30]}' -> h{level} (规则: {rule.name})")
|
||||
return level, rule.name
|
||||
return self._validate_level(level, text, rule.name)
|
||||
|
||||
return 0, None
|
||||
|
||||
|
||||
@@ -131,6 +131,13 @@ class MinerUChunk:
|
||||
# 图片上下文(用于语义检索)
|
||||
context_before: str = "" # 图片前的文本上下文
|
||||
context_after: str = "" # 图片后的文本上下文
|
||||
# VLM 增强信息
|
||||
vlm_description: str = "" # VLM 视觉描述(图片/图表)
|
||||
chart_markdown: str = "" # VLM 提取的图表数据表(Markdown 格式)
|
||||
# MinerU 结构化元数据
|
||||
table_type: str = "" # 表格类型(cflow/table/text,来自 _v2_table_type)
|
||||
table_nest_level: str = "" # 表格嵌套层级(来自 _v2_table_nest_level)
|
||||
sub_type: str = "" # 图片/图表子类型(natural_image/table_image 等)
|
||||
|
||||
|
||||
def parse_with_mineru_online(
|
||||
@@ -308,15 +315,26 @@ def _parse_v2_content_list(v2_data: list) -> list:
|
||||
|
||||
转换规则:
|
||||
- paragraph → text(拼接 paragraph_content,提取 style 信息)
|
||||
- table → table(保留 table_body/html)
|
||||
- title → text(提取 level 信息)
|
||||
- title → text(从 title_content 提取文本,level 信息)
|
||||
- table → table(保留 html,提取 table_caption/table_footnote 列表格式)
|
||||
- image → image(提取 image_source.path、VLM 视觉描述 content.content)
|
||||
- chart → chart(提取 image_source.path、VLM 数据表 content.content Markdown)
|
||||
- list → text(将 list_items 拼接为段落,保留 list_type)
|
||||
- equation → equation
|
||||
- page_header / page_footer / page_number → 过滤掉
|
||||
|
||||
支持的额外字段(_v2_ 前缀):
|
||||
- _v2_styles: 样式列表(如 ['bold'])
|
||||
- _v2_table_type / _v2_table_nest_level / _v2_table_footnote: 表格元数据
|
||||
- _v2_vlm_description: VLM 生成的图片视觉描述
|
||||
- _v2_chart_markdown: VLM 从图表中提取的 Markdown 数据表
|
||||
- _v2_list_type: 列表类型(text_list 等)
|
||||
|
||||
Args:
|
||||
v2_data: v2 格式的嵌套列表
|
||||
|
||||
Returns:
|
||||
v1 兼容的扁平列表,额外包含 _v2_styles / _v2_table_type 字段
|
||||
v1 兼容的扁平列表
|
||||
"""
|
||||
flat_list: List[Dict] = []
|
||||
|
||||
@@ -330,8 +348,8 @@ def _parse_v2_content_list(v2_data: list) -> list:
|
||||
v2_type: str = item.get('type', '')
|
||||
content = item.get('content', {})
|
||||
|
||||
# 过滤噪音类型(页眉、页脚、页码)
|
||||
if v2_type in ('page_header', 'page_footer', 'page_number'):
|
||||
# 过滤噪音类型(页眉、页脚、页码、目录索引)
|
||||
if v2_type in ('page_header', 'page_footer', 'page_number', 'index'):
|
||||
continue
|
||||
|
||||
if v2_type == 'paragraph':
|
||||
@@ -363,28 +381,68 @@ def _parse_v2_content_list(v2_data: list) -> list:
|
||||
flat_list.append(flat_item)
|
||||
|
||||
elif v2_type == 'table':
|
||||
# table_caption 在 V2 中是列表格式: [{"type": "text", "content": "..."}]
|
||||
table_caption_list = content.get('table_caption', []) if isinstance(content, dict) else []
|
||||
table_caption = ''.join(
|
||||
p.get('content', '') for p in table_caption_list if isinstance(p, dict)
|
||||
).strip() if isinstance(table_caption_list, list) else str(table_caption_list)
|
||||
# 兜底: 如果 caption 列表为空,尝试旧的 caption 字符串字段
|
||||
if not table_caption:
|
||||
table_caption = content.get('caption', '') if isinstance(content, dict) else ''
|
||||
# table_footnote
|
||||
table_footnote_list = content.get('table_footnote', []) if isinstance(content, dict) else []
|
||||
table_footnote = ''.join(
|
||||
p.get('content', '') for p in table_footnote_list if isinstance(p, dict)
|
||||
).strip() if isinstance(table_footnote_list, list) else ''
|
||||
# image_source (V2 新格式) 兜底 img_path
|
||||
img_src = content.get('image_source', {}) if isinstance(content, dict) else {}
|
||||
img_path = img_src.get('path', '') if isinstance(img_src, dict) else ''
|
||||
if not img_path:
|
||||
img_path = content.get('img_path', '') if isinstance(content, dict) else ''
|
||||
|
||||
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 '',
|
||||
'caption': table_caption,
|
||||
'img_path': img_path,
|
||||
'_v2_table_type': content.get('table_type', '') if isinstance(content, dict) else '',
|
||||
'_v2_table_nest_level': content.get('table_nest_level', '') if isinstance(content, dict) else '',
|
||||
'_v2_table_footnote': table_footnote,
|
||||
}
|
||||
flat_list.append(flat_item)
|
||||
|
||||
elif v2_type == 'title':
|
||||
# v2 title 项有 level 信息
|
||||
level = content.get('level', 0) if isinstance(content, dict) else 0
|
||||
# v2 title 项: level 在 content.level,文本在 title_content(非 paragraph_content)
|
||||
vlm_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:
|
||||
# PDF V2 使用 title_content,DOCX 理论上不应出现 title 类型
|
||||
title_content = content.get('title_content', []) if isinstance(content, dict) else []
|
||||
# 兜底: 如果 title_content 为空,尝试 paragraph_content
|
||||
if not title_content:
|
||||
title_content = content.get('paragraph_content', []) if isinstance(content, dict) else []
|
||||
for part in title_content:
|
||||
if isinstance(part, dict):
|
||||
text_parts.append(part.get('content', ''))
|
||||
full_text = ''.join(text_parts).strip()
|
||||
|
||||
if full_text:
|
||||
# PDF V2: heading_rules 优先(模式匹配对编号标题可靠)
|
||||
# VLM level 仅作兜底(VLM 常给所有标题 level=1,不可靠)
|
||||
level = 0
|
||||
try:
|
||||
from parsers.heading_rules import get_heading_engine
|
||||
engine = get_heading_engine()
|
||||
detected_level, rule_name = engine.detect(full_text, style=['bold'])
|
||||
if detected_level > 0:
|
||||
level = detected_level
|
||||
except Exception:
|
||||
pass
|
||||
if level == 0 and vlm_level > 0:
|
||||
level = vlm_level
|
||||
|
||||
flat_item = {
|
||||
'type': 'text',
|
||||
'text': full_text,
|
||||
@@ -397,16 +455,97 @@ def _parse_v2_content_list(v2_data: list) -> list:
|
||||
flat_list.append(flat_item)
|
||||
|
||||
elif v2_type in ('image', 'chart'):
|
||||
# image_source (V2 新格式) 兜底 img_path
|
||||
img_src = content.get('image_source', {}) if isinstance(content, dict) else {}
|
||||
img_path = img_src.get('path', '') if isinstance(img_src, dict) else ''
|
||||
if not img_path:
|
||||
img_path = content.get('img_path', '') if isinstance(content, dict) else ''
|
||||
|
||||
# caption 在 V2 中可能是列表格式
|
||||
caption_raw = content.get('image_caption', content.get('caption', '')) if isinstance(content, dict) else ''
|
||||
if isinstance(caption_raw, list):
|
||||
caption = ''.join(p.get('content', '') for p in caption_raw if isinstance(p, dict)).strip()
|
||||
else:
|
||||
caption = str(caption_raw)
|
||||
|
||||
# VLM 视觉描述 (image 和 chart 项的 content.content 字段)
|
||||
vlm_description = ''
|
||||
if isinstance(content, dict):
|
||||
desc = content.get('content', '')
|
||||
if isinstance(desc, str) and desc and len(desc) > 10:
|
||||
# image 直接使用;chart 需排除 markdown 表格(表格走 chart_markdown)
|
||||
if v2_type == 'image':
|
||||
vlm_description = desc
|
||||
elif v2_type == 'chart' and '|' not in desc:
|
||||
vlm_description = desc
|
||||
|
||||
# chart 的 VLM 数据表 (content.content 字段,Markdown 表格)
|
||||
chart_markdown = ''
|
||||
if v2_type == 'chart' and isinstance(content, dict):
|
||||
md = content.get('content', '')
|
||||
if isinstance(md, str) and md:
|
||||
if '|' in md:
|
||||
chart_markdown = md
|
||||
# 即使没有 '|',如果有结构化数据特征也保留
|
||||
elif len(md) > 50 and any(kw in md for kw in ('数据', '合计', '总计', '年份', '单位')):
|
||||
chart_markdown = md
|
||||
# chart caption
|
||||
chart_caption_list = content.get('chart_caption', [])
|
||||
if isinstance(chart_caption_list, list) and chart_caption_list:
|
||||
caption = ''.join(
|
||||
p.get('content', '') for p in chart_caption_list if isinstance(p, dict)
|
||||
).strip() or caption
|
||||
|
||||
# sub_type (natural_image / table_image 等)
|
||||
sub_type = item.get('sub_type', '')
|
||||
|
||||
# 封面 logo 过滤:第一页无 caption 无 VLM 描述的图片通常是封面装饰
|
||||
if (v2_type == 'image' and page_idx == 0
|
||||
and not caption and not vlm_description and not img_path):
|
||||
continue
|
||||
|
||||
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 '',
|
||||
'img_path': img_path,
|
||||
'image_path': img_path,
|
||||
'caption': caption,
|
||||
'sub_type': sub_type,
|
||||
'_v2_vlm_description': vlm_description,
|
||||
'_v2_chart_markdown': chart_markdown,
|
||||
}
|
||||
flat_list.append(flat_item)
|
||||
|
||||
elif v2_type == 'list':
|
||||
# 结构化列表:将列表项拼接为段落文本
|
||||
list_items = content.get('list_items', []) if isinstance(content, dict) else []
|
||||
item_texts = []
|
||||
for li in list_items:
|
||||
if not isinstance(li, dict):
|
||||
continue
|
||||
item_content = li.get('item_content', [])
|
||||
text = ''.join(
|
||||
p.get('content', '') for p in item_content if isinstance(p, dict)
|
||||
).strip()
|
||||
if text:
|
||||
item_texts.append(text)
|
||||
|
||||
if item_texts:
|
||||
list_type = content.get('list_type', 'text_list') if isinstance(content, dict) else 'text_list'
|
||||
full_text = '\n'.join(item_texts)
|
||||
flat_item = {
|
||||
'type': 'text',
|
||||
'text': full_text,
|
||||
'content': full_text,
|
||||
'page_idx': page_idx,
|
||||
'bbox': item.get('bbox', []),
|
||||
'text_level': 0,
|
||||
'_v2_styles': [],
|
||||
'_v2_list_type': list_type,
|
||||
}
|
||||
flat_list.append(flat_item)
|
||||
|
||||
elif v2_type == 'equation':
|
||||
flat_item = {
|
||||
'type': 'equation',
|
||||
@@ -419,6 +558,100 @@ def _parse_v2_content_list(v2_data: list) -> list:
|
||||
}
|
||||
flat_list.append(flat_item)
|
||||
|
||||
# ── TOC 残留过滤 ──
|
||||
# 目录条目可能以 list / title / paragraph 类型混入,用行尾页码模式检测
|
||||
# 模式1: 连续点号/省略号+页码(如 "1 综述..........1"、"2 三峡工程…4")
|
||||
# 模式2: 短行+空格+页码数字(如 "2.2 防洪 8"、"6.2 水位 28")
|
||||
import re
|
||||
_toc_dots = re.compile(r'(\.{2,}|…+|⋯+)\s*\d+\s*$')
|
||||
_toc_space_num = re.compile(r'\s{2,}\d{1,3}\s*$') # 2+空格+1-3位数字
|
||||
_toc_filtered = 0
|
||||
filtered_list = []
|
||||
for fi in flat_list:
|
||||
text = fi.get('text', '') or fi.get('content', '')
|
||||
# 只检查 text 类型(title/list/paragraph 产出),table/image/chart 不动
|
||||
if fi.get('type') == 'text' and text:
|
||||
lines = [l.strip() for l in text.split('\n') if l.strip()]
|
||||
if lines:
|
||||
toc_hits = 0
|
||||
for l in lines:
|
||||
if _toc_dots.search(l):
|
||||
toc_hits += 1
|
||||
elif _toc_space_num.search(l) and len(l) < 40:
|
||||
# 短行+空格+页码:典型的目录格式
|
||||
toc_hits += 1
|
||||
# TOC 判定逻辑:
|
||||
# 1) 匹配率 > 50% → 确定是目录
|
||||
# 2) 匹配率 > 25% 且平均行长 < 25字 → 目录(短行+页码是强信号)
|
||||
avg_line_len = sum(len(l) for l in lines) / len(lines) if lines else 0
|
||||
match_ratio = toc_hits / len(lines) if lines else 0
|
||||
is_toc = (match_ratio > 0.5) or (match_ratio > 0.25 and avg_line_len < 25)
|
||||
if is_toc:
|
||||
_toc_filtered += 1
|
||||
logger.debug(f"TOC 过滤命中({toc_hits}/{len(lines)}, avg={avg_line_len:.0f}): {text[:60]}...")
|
||||
continue
|
||||
elif toc_hits > 0:
|
||||
logger.debug(f"TOC 部分匹配({toc_hits}/{len(lines)}): {text[:60]}...")
|
||||
filtered_list.append(fi)
|
||||
if _toc_filtered > 0:
|
||||
logger.info(f"TOC 过滤第一轮: 移除 {_toc_filtered} 个目录块")
|
||||
flat_list = filtered_list
|
||||
|
||||
# 第二轮:清理孤立的 TOC 标题(子条目被过滤后残留的父标题)
|
||||
_toc_orphan = 0
|
||||
final_list = []
|
||||
for fi in flat_list:
|
||||
text = (fi.get('text', '') or fi.get('content', '')).strip()
|
||||
page = fi.get('page_idx', 0)
|
||||
if fi.get('type') == 'text' and text and page <= 5:
|
||||
# 孤立 "目录" 标题
|
||||
if text == '目录':
|
||||
_toc_orphan += 1
|
||||
continue
|
||||
# 短标题 + 尾部页码数字(如 "6 长江中下游河道状况 25")
|
||||
if (len(text) < 40 and not text.endswith('。') and not text.endswith(';')
|
||||
and re.search(r'\s+\d{1,3}\s*$', text)):
|
||||
_toc_orphan += 1
|
||||
continue
|
||||
final_list.append(fi)
|
||||
if _toc_orphan > 0:
|
||||
logger.info(f"TOC 过滤第二轮: 移除 {_toc_orphan} 个孤立目录标题")
|
||||
flat_list = final_list
|
||||
|
||||
# ── 单字符标题残留过滤 ──
|
||||
# VLM 可能部分识别目录标题(如 "目录" → "录"),单字符标题几乎不会是有效章节
|
||||
_single_char = 0
|
||||
_sc_list = []
|
||||
for fi in flat_list:
|
||||
text = (fi.get('text', '') or fi.get('content', '')).strip()
|
||||
if fi.get('type') == 'text' and text and len(text) <= 1 and fi.get('text_level', 0) > 0:
|
||||
_single_char += 1
|
||||
logger.debug(f"单字符标题过滤: '{text}' pg={fi.get('page_idx', 0)}")
|
||||
continue
|
||||
_sc_list.append(fi)
|
||||
if _single_char > 0:
|
||||
logger.info(f"单字符标题过滤: 移除 {_single_char} 个残留")
|
||||
flat_list = _sc_list
|
||||
|
||||
# ── 封面重复标题去重 ──
|
||||
# pg=0(封面)和 pg=1(扉页)常有完全相同的标题(如 "三峡工程公报"、"2022"),保留较后的
|
||||
_cover_seen = {} # text -> page_idx
|
||||
_cover_dup = 0
|
||||
dedup_list = []
|
||||
for fi in flat_list:
|
||||
text = (fi.get('text', '') or fi.get('content', '')).strip()
|
||||
page = fi.get('page_idx', 0)
|
||||
if fi.get('type') == 'text' and text and page <= 1 and fi.get('text_level', 0) > 0:
|
||||
if text in _cover_seen:
|
||||
_cover_dup += 1
|
||||
logger.debug(f"封面重复标题过滤: '{text}' pg={page} (首次 pg={_cover_seen[text]})")
|
||||
continue
|
||||
_cover_seen[text] = page
|
||||
dedup_list.append(fi)
|
||||
if _cover_dup > 0:
|
||||
logger.info(f"封面去重: 移除 {_cover_dup} 个重复封面标题")
|
||||
flat_list = dedup_list
|
||||
|
||||
logger.info(f"v2 格式转换: {len(v2_data)} 页 → {len(flat_list)} 项(已过滤噪音类型)")
|
||||
return flat_list
|
||||
|
||||
@@ -628,6 +861,7 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
|
||||
elif item_type == "table":
|
||||
table_body = item.get("html", "") or item.get("table_body", "")
|
||||
table_caption = item.get("caption", "") or item.get("table_caption", "")
|
||||
table_footnote = item.get("_v2_table_footnote", "")
|
||||
img_path = item.get("img_path", "") or item.get("image_path", "")
|
||||
|
||||
section_path = " > ".join([s[1] for s in section_stack])
|
||||
@@ -642,8 +876,13 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
|
||||
# 从原始 HTML 提取嵌入图片(md_table 经 get_text 转换后已丢失 <img> 标签)
|
||||
table_images = extract_images_from_markdown(table_body) if table_body else []
|
||||
|
||||
# 表格内容增强:caption + footnote
|
||||
table_content = table_caption or "表格"
|
||||
if table_footnote:
|
||||
table_content = f"{table_content}\n[脚注] {table_footnote}"
|
||||
|
||||
chunk = MinerUChunk(
|
||||
content=table_caption or "表格",
|
||||
content=table_content,
|
||||
chunk_type="table",
|
||||
page_start=page_idx + 1,
|
||||
page_end=page_idx + 1,
|
||||
@@ -653,7 +892,9 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
|
||||
source_file=file_path.name,
|
||||
table_html=table_body,
|
||||
image_path=img_path,
|
||||
images=table_images if table_images else None
|
||||
images=table_images if table_images else None,
|
||||
table_type=item.get("_v2_table_type", ""),
|
||||
table_nest_level=item.get("_v2_table_nest_level", ""),
|
||||
)
|
||||
chunks.append(chunk)
|
||||
if table_body:
|
||||
@@ -664,16 +905,29 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
|
||||
elif item_type in ("image", "chart"):
|
||||
img_path = item.get("img_path", "") or item.get("image_path", "")
|
||||
caption = item.get("caption", "")
|
||||
vlm_desc = item.get("_v2_vlm_description", "")
|
||||
chart_md = item.get("_v2_chart_markdown", "")
|
||||
sub_type = item.get("sub_type", "")
|
||||
|
||||
section_path = " > ".join([s[1] for s in section_stack])
|
||||
|
||||
markdown_parts.append(f"\n")
|
||||
# chart 的 VLM 数据表也写入 markdown 输出
|
||||
if chart_md:
|
||||
markdown_parts.append(chart_md)
|
||||
|
||||
chunk_type = "chart" if item_type == "chart" else "image"
|
||||
context_before, context_after = get_context_for_image(idx, page_idx)
|
||||
|
||||
# 图片内容增强:caption + VLM 描述
|
||||
content_text = caption or ("图表" if item_type == "chart" else "图片")
|
||||
if vlm_desc:
|
||||
content_text = f"{content_text}\n[视觉描述] {vlm_desc}"
|
||||
if chart_md:
|
||||
content_text = f"{content_text}\n[数据表]\n{chart_md}"
|
||||
|
||||
chunk = MinerUChunk(
|
||||
content=caption or ("图表" if item_type == "chart" else "图片"),
|
||||
content=content_text,
|
||||
chunk_type=chunk_type,
|
||||
page_start=page_idx + 1,
|
||||
page_end=page_idx + 1,
|
||||
@@ -683,11 +937,18 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
|
||||
source_file=file_path.name,
|
||||
image_path=img_path,
|
||||
context_before=context_before,
|
||||
context_after=context_after
|
||||
context_after=context_after,
|
||||
vlm_description=vlm_desc,
|
||||
chart_markdown=chart_md,
|
||||
table_html=chart_md if chart_md else None, # chart 数据表作为表格存储
|
||||
sub_type=sub_type,
|
||||
)
|
||||
chunks.append(chunk)
|
||||
if img_path:
|
||||
images.append(img_path)
|
||||
# chart 数据表也加入 tables 列表,便于表格检索
|
||||
if chart_md:
|
||||
tables.append(chart_md)
|
||||
|
||||
elif item_type == "equation":
|
||||
# 处理公式类型
|
||||
@@ -1148,6 +1409,7 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
|
||||
elif item_type == "table":
|
||||
table_body = item.get("table_body", "")
|
||||
table_caption = item.get("table_caption", "")
|
||||
table_footnote = item.get("_v2_table_footnote", "")
|
||||
# 表格也可能有图片形式(img_path)
|
||||
img_path = item.get("img_path", "")
|
||||
|
||||
@@ -1163,8 +1425,13 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
|
||||
# 从原始 HTML 提取嵌入图片(md_table 经 get_text 转换后已丢失 <img> 标签)
|
||||
table_images = extract_images_from_markdown(table_body) if table_body else []
|
||||
|
||||
# 表格内容增强:caption + footnote
|
||||
table_content = table_caption or "表格"
|
||||
if table_footnote:
|
||||
table_content = f"{table_content}\n[脚注] {table_footnote}"
|
||||
|
||||
chunk = MinerUChunk(
|
||||
content=table_caption or "表格",
|
||||
content=table_content,
|
||||
chunk_type="table",
|
||||
page_start=page_idx + 1,
|
||||
page_end=page_idx + 1,
|
||||
@@ -1174,7 +1441,9 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
|
||||
source_file=file_path.name,
|
||||
table_html=table_body,
|
||||
image_path=img_path, # 表格的独立图片形式
|
||||
images=table_images if table_images else None # 嵌入图片列表
|
||||
images=table_images if table_images else None, # 嵌入图片列表
|
||||
table_type=item.get("_v2_table_type", ""),
|
||||
table_nest_level=item.get("_v2_table_nest_level", ""),
|
||||
)
|
||||
chunks.append(chunk)
|
||||
if table_body:
|
||||
@@ -1187,10 +1456,15 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
|
||||
# 处理图片和图表类型(MinerU 将图表识别为 chart 类型)
|
||||
img_path = item.get("img_path", "")
|
||||
caption = item.get("caption", "")
|
||||
vlm_desc = item.get("_v2_vlm_description", "")
|
||||
chart_md = item.get("_v2_chart_markdown", "")
|
||||
sub_type = item.get("sub_type", "")
|
||||
|
||||
section_path = " > ".join([s[1] for s in section_stack])
|
||||
|
||||
markdown_parts.append(f"\n")
|
||||
if chart_md:
|
||||
markdown_parts.append(chart_md)
|
||||
|
||||
# 图表类型标记为 chart,便于后续区分处理
|
||||
chunk_type = "chart" if item_type == "chart" else "image"
|
||||
@@ -1198,8 +1472,15 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
|
||||
# 获取图片上下文
|
||||
context_before, context_after = get_context_for_image(idx, page_idx)
|
||||
|
||||
# 图片内容增强:caption + VLM 描述
|
||||
content_text = caption or ("图表" if item_type == "chart" else "图片")
|
||||
if vlm_desc:
|
||||
content_text = f"{content_text}\n[视觉描述] {vlm_desc}"
|
||||
if chart_md:
|
||||
content_text = f"{content_text}\n[数据表]\n{chart_md}"
|
||||
|
||||
chunk = MinerUChunk(
|
||||
content=caption or ("图表" if item_type == "chart" else "图片"),
|
||||
content=content_text,
|
||||
chunk_type=chunk_type,
|
||||
page_start=page_idx + 1,
|
||||
page_end=page_idx + 1,
|
||||
@@ -1209,11 +1490,17 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
|
||||
source_file=file_path.name,
|
||||
image_path=img_path,
|
||||
context_before=context_before,
|
||||
context_after=context_after
|
||||
context_after=context_after,
|
||||
vlm_description=vlm_desc,
|
||||
chart_markdown=chart_md,
|
||||
table_html=chart_md if chart_md else None,
|
||||
sub_type=sub_type,
|
||||
)
|
||||
chunks.append(chunk)
|
||||
if img_path:
|
||||
images.append(img_path)
|
||||
if chart_md:
|
||||
tables.append(chart_md)
|
||||
|
||||
elif item_type == "equation":
|
||||
# 处理公式类型
|
||||
@@ -1322,6 +1609,7 @@ def _post_process_chunks(
|
||||
# Phase 2: 合并碎片
|
||||
merged = []
|
||||
buffer = None # 当前合并缓冲
|
||||
_buffer_has_body = False # 缓冲是否已包含正文(防止标题继续合并)
|
||||
|
||||
for chunk in chunks:
|
||||
# 表格、图片和图表不参与合并,直接输出
|
||||
@@ -1329,13 +1617,34 @@ def _post_process_chunks(
|
||||
if buffer:
|
||||
merged.append(buffer)
|
||||
buffer = None
|
||||
_buffer_has_body = False
|
||||
merged.append(chunk)
|
||||
continue
|
||||
|
||||
# 标题 chunk(text_level > 0),开始新的合并组
|
||||
# 标题 chunk(text_level > 0)
|
||||
if chunk.text_level > 0:
|
||||
if buffer:
|
||||
merged.append(buffer)
|
||||
# H1 级标题是章节边界,强制断开,不参与连续标题链合并
|
||||
if chunk.text_level == 1:
|
||||
merged.append(buffer)
|
||||
_buffer_has_body = False
|
||||
# 连续标题链合并:缓冲也是纯标题(无正文)时,合并而非刷新(仅非 H1)
|
||||
elif buffer.text_level > 0 and not _buffer_has_body:
|
||||
combined = buffer.content.rstrip() + '\n' + chunk.content
|
||||
if len(combined) <= max_merged_size:
|
||||
buffer.content = combined
|
||||
buffer.page_end = chunk.page_end
|
||||
# 取更高层级(数值更小)
|
||||
buffer.text_level = min(buffer.text_level, chunk.text_level)
|
||||
# section_path 保留第一个(更高级别)的
|
||||
continue
|
||||
# 合并后超限,刷新缓冲
|
||||
merged.append(buffer)
|
||||
_buffer_has_body = False
|
||||
else:
|
||||
# 缓冲是正文,正常刷新
|
||||
merged.append(buffer)
|
||||
_buffer_has_body = False
|
||||
# 标题作为新缓冲的起点
|
||||
buffer = MinerUChunk(
|
||||
content=chunk.content,
|
||||
@@ -1348,6 +1657,7 @@ def _post_process_chunks(
|
||||
bbox=chunk.bbox,
|
||||
source_file=chunk.source_file,
|
||||
)
|
||||
_buffer_has_body = False
|
||||
continue
|
||||
|
||||
# 正文 chunk
|
||||
@@ -1367,6 +1677,7 @@ def _post_process_chunks(
|
||||
bbox=chunk.bbox,
|
||||
source_file=chunk.source_file,
|
||||
)
|
||||
_buffer_has_body = True # 正文 chunk 创建的缓冲已含正文
|
||||
else:
|
||||
# 足够长,直接输出
|
||||
merged.append(chunk)
|
||||
@@ -1377,6 +1688,9 @@ def _post_process_chunks(
|
||||
# 合并
|
||||
buffer.content = buffer.content.rstrip() + '\n' + chunk.content
|
||||
buffer.page_end = chunk.page_end
|
||||
# 正文并入标题缓冲后,标记已含正文,防止后续标题继续合并
|
||||
# 保留 text_level 不置零,使标题层级信息传递到向量库
|
||||
_buffer_has_body = True
|
||||
else:
|
||||
# 超过上限,输出缓冲,当前 chunk 开始新缓冲或直接输出
|
||||
merged.append(buffer)
|
||||
@@ -1392,8 +1706,10 @@ def _post_process_chunks(
|
||||
bbox=chunk.bbox,
|
||||
source_file=chunk.source_file,
|
||||
)
|
||||
_buffer_has_body = True # 正文 chunk 创建的缓冲已含正文
|
||||
else:
|
||||
buffer = None
|
||||
_buffer_has_body = False
|
||||
merged.append(chunk)
|
||||
|
||||
# 刷新最后的缓冲
|
||||
|
||||
273
scripts/analyze_rag_performance.py
Normal file
273
scripts/analyze_rag_performance.py
Normal file
@@ -0,0 +1,273 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
RAG 性能分析脚本
|
||||
|
||||
分析 RAG 流程各阶段耗时,帮助定位性能瓶颈
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from typing import Dict
|
||||
|
||||
# 添加项目根目录到路径
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
def call_rag_stream_api(
|
||||
question: str,
|
||||
kb_name: str = "public_kb",
|
||||
base_url: str = "http://localhost:5001",
|
||||
) -> Dict:
|
||||
"""
|
||||
调用 RAG 流式 API 并收集各阶段事件
|
||||
|
||||
Returns:
|
||||
包含各阶段耗时信息的字典
|
||||
"""
|
||||
import requests
|
||||
|
||||
url = f"{base_url.rstrip('/')}/rag"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": "Bearer mock-token-admin"
|
||||
}
|
||||
data = {
|
||||
"message": question,
|
||||
"collections": [kb_name] if kb_name else []
|
||||
}
|
||||
|
||||
events = []
|
||||
timing = {
|
||||
'total_duration_ms': 0,
|
||||
'search_time_ms': 0,
|
||||
'rerank_time_ms': 0,
|
||||
'llm_time_ms': 0,
|
||||
'stages': []
|
||||
}
|
||||
|
||||
try:
|
||||
start_time = time.time()
|
||||
response = requests.post(url, json=data, headers=headers, stream=True, timeout=300)
|
||||
response.raise_for_status()
|
||||
|
||||
for line in response.iter_lines():
|
||||
if line:
|
||||
line_str = line.decode('utf-8')
|
||||
if line_str.startswith('data: '):
|
||||
try:
|
||||
event = json.loads(line_str[6:])
|
||||
event['_received_at'] = time.time()
|
||||
events.append(event)
|
||||
|
||||
# 分析 finish 事件中的耗时信息
|
||||
if event.get('type') == 'finish':
|
||||
timing['total_duration_ms'] = event.get('duration_ms', 0)
|
||||
if 'timing' in event:
|
||||
timing['search_time_ms'] = event['timing'].get('total_search_ms', 0)
|
||||
timing['rerank_time_ms'] = event['timing'].get('rerank_ms', 0)
|
||||
timing['rerank_cached'] = event['timing'].get('rerank_cached', False)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
end_time = time.time()
|
||||
timing['actual_elapsed_ms'] = int((end_time - start_time) * 1000)
|
||||
|
||||
# 计算 LLM 生成时间
|
||||
if timing['total_duration_ms'] > 0 and timing['search_time_ms'] > 0:
|
||||
timing['llm_time_ms'] = timing['total_duration_ms'] - timing['search_time_ms']
|
||||
|
||||
finish_event = next((event for event in events if event.get('type') == 'finish'), None)
|
||||
error_event = next((event for event in events if event.get('type') == 'error'), None)
|
||||
return {
|
||||
'success': finish_event is not None and error_event is None,
|
||||
'error': error_event.get('message', '') if error_event else (
|
||||
'' if finish_event else 'SSE 流结束但未收到 finish 事件'
|
||||
),
|
||||
'events': events,
|
||||
'timing': timing,
|
||||
'answer': finish_event.get('answer', '') if finish_event else '',
|
||||
}
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'events': events,
|
||||
'timing': timing
|
||||
}
|
||||
|
||||
|
||||
def analyze_performance(result: Dict, question: str) -> None:
|
||||
"""分析并打印性能数据"""
|
||||
print("\n" + "=" * 80)
|
||||
print(f"问题: {question}")
|
||||
print("=" * 80)
|
||||
|
||||
if not result['success']:
|
||||
print(f"[X] 请求失败: {result.get('error', '未知错误')}")
|
||||
return
|
||||
|
||||
timing = result['timing']
|
||||
events = result['events']
|
||||
|
||||
# 打印各阶段事件时间线
|
||||
print("\n[事件时间线]")
|
||||
print("-" * 80)
|
||||
first_event_time = None
|
||||
for event in events:
|
||||
event_type = event.get('type', 'unknown')
|
||||
received_at = event.get('_received_at', 0)
|
||||
|
||||
if first_event_time is None:
|
||||
first_event_time = received_at
|
||||
relative_time = 0
|
||||
else:
|
||||
relative_time = (received_at - first_event_time) * 1000
|
||||
|
||||
if event_type == 'finish':
|
||||
print(f" {relative_time:>8.0f}ms | {event_type:20s} | 总耗时: {event.get('duration_ms', 0)}ms")
|
||||
elif event_type == 'sources':
|
||||
sources = event.get('sources', [])
|
||||
print(f" {relative_time:>8.0f}ms | {event_type:20s} | 找到 {len(sources)} 个来源")
|
||||
elif event_type == 'chunks_retrieved':
|
||||
chunks = event.get('data', {}).get('chunks', [])
|
||||
print(f" {relative_time:>8.0f}ms | {event_type:20s} | 召回 {len(chunks)} 个切片")
|
||||
elif event_type == 'chunk':
|
||||
# 流式输出,只显示第一个
|
||||
if not hasattr(analyze_performance, '_chunk_printed'):
|
||||
print(f" {relative_time:>8.0f}ms | {event_type:20s} | 开始流式输出...")
|
||||
analyze_performance._chunk_printed = True
|
||||
else:
|
||||
print(f" {relative_time:>8.0f}ms | {event_type:20s}")
|
||||
|
||||
if hasattr(analyze_performance, '_chunk_printed'):
|
||||
delattr(analyze_performance, '_chunk_printed')
|
||||
|
||||
# 打印耗时统计
|
||||
print("\n[耗时统计]")
|
||||
print("-" * 80)
|
||||
total = timing['total_duration_ms']
|
||||
search = timing['search_time_ms']
|
||||
rerank = timing['rerank_time_ms']
|
||||
llm = timing['llm_time_ms']
|
||||
actual = timing.get('actual_elapsed_ms', total)
|
||||
|
||||
if total > 0:
|
||||
print(f" 总耗时 (API报告): {total:>8}ms ({total/1000:.2f}s)")
|
||||
print(f" 实际耗时 (本地测量): {actual:>8}ms ({actual/1000:.2f}s)")
|
||||
print()
|
||||
print(f" 检索阶段: {search:>8}ms ({search/total*100:.1f}%)")
|
||||
if rerank > 0:
|
||||
cached_flag = " [缓存]" if timing.get('rerank_cached') else ""
|
||||
print(f" 重排序阶段: {rerank:>8}ms ({rerank/total*100:.1f}%){cached_flag}")
|
||||
print(f" LLM生成阶段: {llm:>8}ms ({llm/total*100:.1f}%)")
|
||||
|
||||
# 性能诊断
|
||||
print("\n[性能诊断]")
|
||||
print("-" * 80)
|
||||
if total > 10000:
|
||||
print(" [!] 总耗时超过 10 秒,需要优化")
|
||||
|
||||
if search > 3000:
|
||||
print(f" [!] 检索耗时过长 ({search}ms),可能原因:")
|
||||
print(" - 向量库数据量过大")
|
||||
print(" - 未命中查询缓存")
|
||||
print(" - BM25 索引加载慢")
|
||||
|
||||
if rerank > 2000 and not timing.get('rerank_cached'):
|
||||
print(f" [!] 重排序耗时过长 ({rerank}ms),可能原因:")
|
||||
print(" - Rerank 模型计算量大")
|
||||
print(" - 候选切片数量过多")
|
||||
|
||||
if llm > 5000:
|
||||
print(f" [!] LLM生成耗时过长 ({llm}ms),可能原因:")
|
||||
print(" - 模型生成速度慢")
|
||||
print(" - 输出 token 数量多")
|
||||
print(" - 网络延迟")
|
||||
print(" 建议: 检查 MiMo thinking 是否关闭、上下文长度及 max_tokens")
|
||||
|
||||
if total < 5000:
|
||||
print(" [OK] 性能良好")
|
||||
else:
|
||||
print(" [!] 未能获取耗时数据")
|
||||
|
||||
|
||||
def run_performance_tests(
|
||||
base_url: str = "http://localhost:5001",
|
||||
kb_name: str = "public_kb",
|
||||
question: str = None,
|
||||
):
|
||||
"""执行性能测试"""
|
||||
# 测试问题(不同类型)
|
||||
test_questions = [
|
||||
{"question": "智启科技成立于哪一年?", "type": "精确匹配"},
|
||||
{"question": "公司的愿景是什么?", "type": "语义理解"},
|
||||
{"question": "公司有哪些分公司,分别在哪些城市?", "type": "跨文档关联"},
|
||||
{"question": "一个入职3年的员工,累计病假1个月,能拿到多少病假工资?", "type": "复杂推理"},
|
||||
{"question": "P4级工程师的年薪范围是多少?", "type": "表格数据"},
|
||||
]
|
||||
if question:
|
||||
test_questions = [{"question": question, "type": "自定义问题"}]
|
||||
|
||||
print("=" * 80)
|
||||
print("RAG Performance Analysis")
|
||||
print(f"Time: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
print("=" * 80)
|
||||
|
||||
results = []
|
||||
for i, test in enumerate(test_questions):
|
||||
print(f"\n\n[{i+1}/{len(test_questions)}] 类型: {test['type']}")
|
||||
result = call_rag_stream_api(test['question'], kb_name=kb_name, base_url=base_url)
|
||||
analyze_performance(result, test['question'])
|
||||
results.append({
|
||||
'question': test['question'],
|
||||
'type': test['type'],
|
||||
'success': result['success'],
|
||||
'timing': result['timing']
|
||||
})
|
||||
|
||||
# 汇总统计
|
||||
print("\n\n" + "=" * 80)
|
||||
print("[Performance Summary]")
|
||||
print("=" * 80)
|
||||
|
||||
successful = [r for r in results if r['success']]
|
||||
if successful:
|
||||
total_times = [r['timing']['total_duration_ms'] for r in successful]
|
||||
search_times = [r['timing']['search_time_ms'] for r in successful]
|
||||
llm_times = [r['timing']['llm_time_ms'] for r in successful]
|
||||
|
||||
print(f"\n成功请求数: {len(successful)}/{len(results)}")
|
||||
print(f"\n平均总耗时: {sum(total_times)/len(total_times):.0f}ms")
|
||||
print(f"平均检索耗时: {sum(search_times)/len(search_times):.0f}ms")
|
||||
print(f"平均LLM耗时: {sum(llm_times)/len(llm_times):.0f}ms")
|
||||
print(f"\n最快总耗时: {min(total_times)}ms")
|
||||
print(f"最慢总耗时: {max(total_times)}ms")
|
||||
|
||||
# 保存详细报告
|
||||
report_dir = os.path.join(PROJECT_ROOT, ".data", "performance")
|
||||
os.makedirs(report_dir, exist_ok=True)
|
||||
report_file = os.path.join(
|
||||
report_dir,
|
||||
f"performance_report_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json",
|
||||
)
|
||||
with open(report_file, 'w', encoding='utf-8') as f:
|
||||
json.dump({
|
||||
'test_time': datetime.now().isoformat(),
|
||||
'results': results
|
||||
}, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n详细报告已保存: {report_file}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description="RAG SSE 链路耗时分析")
|
||||
parser.add_argument("--base-url", default="http://localhost:5001")
|
||||
parser.add_argument("--kb", default="public_kb", help="目标知识库名称")
|
||||
parser.add_argument("--question", help="只测试一个自定义问题")
|
||||
args = parser.parse_args()
|
||||
run_performance_tests(args.base_url, args.kb, args.question)
|
||||
82
scripts/download_models.py
Normal file
82
scripts/download_models.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
下载 BGE embedding 和 reranker 模型到本地 models/ 目录
|
||||
|
||||
首次配置时运行:
|
||||
python scripts/download_models.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
MODELS_DIR = os.path.join(PROJECT_ROOT, "models")
|
||||
EMBEDDING_MODEL_PATH = os.path.join(MODELS_DIR, "bge-base-zh-v1.5")
|
||||
RERANK_MODEL_PATH = os.path.join(MODELS_DIR, "bge-reranker-base")
|
||||
|
||||
os.makedirs(MODELS_DIR, exist_ok=True)
|
||||
|
||||
|
||||
def download_embedding_model():
|
||||
"""下载 BGE 中文向量模型"""
|
||||
if os.path.exists(os.path.join(EMBEDDING_MODEL_PATH, "config.json")):
|
||||
print(f"[跳过] Embedding 模型已存在: {EMBEDDING_MODEL_PATH}")
|
||||
return True
|
||||
|
||||
print(f"[下载] BGE 中文向量模型 (bge-base-zh-v1.5)...")
|
||||
print(f" 目标路径: {EMBEDDING_MODEL_PATH}")
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer
|
||||
model = SentenceTransformer("BAAI/bge-base-zh-v1.5")
|
||||
model.save(EMBEDDING_MODEL_PATH)
|
||||
print(f"[完成] Embedding 模型下载成功!")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[失败] Embedding 模型下载失败: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def download_reranker_model():
|
||||
"""下载 BGE reranker 模型"""
|
||||
if os.path.exists(os.path.join(RERANK_MODEL_PATH, "config.json")):
|
||||
print(f"[跳过] Reranker 模型已存在: {RERANK_MODEL_PATH}")
|
||||
return True
|
||||
|
||||
print(f"[下载] BGE Reranker 模型 (bge-reranker-base)...")
|
||||
print(f" 目标路径: {RERANK_MODEL_PATH}")
|
||||
try:
|
||||
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
||||
os.makedirs(RERANK_MODEL_PATH, exist_ok=True)
|
||||
model = AutoModelForSequenceClassification.from_pretrained("BAAI/bge-reranker-base")
|
||||
tokenizer = AutoTokenizer.from_pretrained("BAAI/bge-reranker-base")
|
||||
model.save_pretrained(RERANK_MODEL_PATH)
|
||||
tokenizer.save_pretrained(RERANK_MODEL_PATH)
|
||||
print(f"[完成] Reranker 模型下载成功!")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"[失败] Reranker 模型下载失败: {e}")
|
||||
print(f" (项目启动时会自动尝试下载,或可稍后手动重试)")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=" * 60)
|
||||
print("RAG 项目模型下载脚本")
|
||||
print("=" * 60)
|
||||
|
||||
ok1 = download_embedding_model()
|
||||
print()
|
||||
ok2 = download_reranker_model()
|
||||
|
||||
print()
|
||||
print("=" * 60)
|
||||
if ok1:
|
||||
print("Embedding 模型: OK (项目可以启动)")
|
||||
else:
|
||||
print("Embedding 模型: FAILED (项目无法启动,请检查网络)")
|
||||
|
||||
if ok2:
|
||||
print("Reranker 模型: OK")
|
||||
else:
|
||||
print("Reranker 模型: FAILED (不影响启动,首次使用 rerank 时自动下载)")
|
||||
|
||||
print("=" * 60)
|
||||
101
tests/test_data_db.py
Normal file
101
tests/test_data_db.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""data.db fresh-clone and transaction regression tests."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import unittest
|
||||
import uuid
|
||||
|
||||
import config
|
||||
import data.db as db
|
||||
from exam_pkg.local_db import ExamLocalDB
|
||||
|
||||
|
||||
class DataDbTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
test_root = os.path.join(project_root, ".data", "test-runs")
|
||||
os.makedirs(test_root, exist_ok=True)
|
||||
self.temp_dir = os.path.join(test_root, f"run-{uuid.uuid4().hex}")
|
||||
os.makedirs(self.temp_dir)
|
||||
self.original_paths = db.DB_PATHS
|
||||
self.original_is_dev = config.IS_DEV
|
||||
db.DB_PATHS = {
|
||||
"feedback": os.path.join(self.temp_dir, "prod", "feedback.db"),
|
||||
"knowledge": os.path.join(self.temp_dir, "prod", "knowledge.db"),
|
||||
"session": os.path.join(self.temp_dir, "dev", "session.db"),
|
||||
"exam": os.path.join(self.temp_dir, "dev", "exam.db"),
|
||||
}
|
||||
config.IS_DEV = True
|
||||
db._initialized = False
|
||||
|
||||
def tearDown(self):
|
||||
db._initialized = False
|
||||
db.DB_PATHS = self.original_paths
|
||||
config.IS_DEV = self.original_is_dev
|
||||
test_root = os.path.realpath(os.path.join(os.path.dirname(self.temp_dir)))
|
||||
target = os.path.realpath(self.temp_dir)
|
||||
if os.path.commonpath([test_root, target]) != test_root:
|
||||
raise RuntimeError(f"拒绝清理测试目录之外的路径: {target}")
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
def _table_names(self, db_name):
|
||||
with db.get_connection(db_name) as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
).fetchall()
|
||||
return {row[0] for row in rows}
|
||||
|
||||
def test_fresh_clone_initializes_all_dev_databases(self):
|
||||
db.init_databases()
|
||||
|
||||
for path in db.DB_PATHS.values():
|
||||
self.assertTrue(os.path.isfile(path), path)
|
||||
|
||||
self.assertTrue({"feedbacks", "faqs", "faq_suggestions"} <= self._table_names("feedback"))
|
||||
self.assertTrue({"document_hashes", "document_versions", "sync_status"} <= self._table_names("knowledge"))
|
||||
self.assertTrue({"sessions", "messages", "audit_logs"} <= self._table_names("session"))
|
||||
self.assertTrue({"questions", "exams", "exam_questions"} <= self._table_names("exam"))
|
||||
|
||||
with db.get_connection("knowledge") as conn:
|
||||
columns = {
|
||||
row[1] for row in conn.execute("PRAGMA table_info(document_versions)").fetchall()
|
||||
}
|
||||
self.assertTrue(
|
||||
{"content_hash", "expiry_date", "deprecated_date", "deprecated_by", "changed_sections"}
|
||||
<= columns
|
||||
)
|
||||
|
||||
# Initialization must remain safe when several services call it.
|
||||
db.init_databases()
|
||||
|
||||
def test_connection_commits_and_rolls_back(self):
|
||||
with db.get_connection("session") as conn:
|
||||
conn.execute("CREATE TABLE tx_test (value TEXT)")
|
||||
conn.execute("INSERT INTO tx_test VALUES ('committed')")
|
||||
|
||||
with self.assertRaisesRegex(RuntimeError, "rollback"):
|
||||
with db.get_connection("session") as conn:
|
||||
conn.execute("INSERT INTO tx_test VALUES ('rolled-back')")
|
||||
raise RuntimeError("rollback")
|
||||
|
||||
with db.get_connection("session") as conn:
|
||||
values = [row[0] for row in conn.execute("SELECT value FROM tx_test").fetchall()]
|
||||
self.assertEqual(["committed"], values)
|
||||
|
||||
def test_exam_creation_ignores_missing_question_ids(self):
|
||||
exam_db = ExamLocalDB()
|
||||
question_id = exam_db.add_question(
|
||||
{"content": "示例题", "answer": "答案", "score": 2},
|
||||
source_file="example.txt",
|
||||
source_collection="public",
|
||||
)
|
||||
|
||||
exam = exam_db.create_exam("回归测试", [question_id, "missing-question"])
|
||||
|
||||
self.assertEqual(1, exam["total_count"])
|
||||
self.assertEqual([question_id], exam["question_ids"])
|
||||
self.assertEqual(1, len(exam_db.get_exam(exam["id"])["questions"]))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
197
tests/test_shared_regressions.py
Normal file
197
tests/test_shared_regressions.py
Normal file
@@ -0,0 +1,197 @@
|
||||
"""Regression tests for branch-shared RAG behavior."""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.cache import RAGCacheManager
|
||||
from core.intent_analyzer import IntentAnalyzer
|
||||
from exam_pkg.generator import QuestionGenerator, analyze_document_for_exam
|
||||
from exam_pkg.grader import grade_fill_blank
|
||||
from knowledge.sync import ChangeType, DocumentChange, KnowledgeSyncService
|
||||
|
||||
|
||||
class CacheInvalidationTests(unittest.TestCase):
|
||||
def test_kb_version_change_clears_rerank_scores(self):
|
||||
cache = RAGCacheManager()
|
||||
cache.set_rerank_scores("问题", ["doc-1"], [0.91])
|
||||
self.assertEqual({"doc-1": 0.91}, cache.get_rerank_scores("问题", ["doc-1"]))
|
||||
|
||||
cache.increment_kb_version("public")
|
||||
|
||||
self.assertIsNone(cache.get_rerank_scores("问题", ["doc-1"]))
|
||||
|
||||
|
||||
class IntentCacheTests(unittest.TestCase):
|
||||
def test_unknown_semantic_cache_type_is_not_used_as_intent(self):
|
||||
class FakeEmbeddingModel:
|
||||
def encode(self, _text):
|
||||
return [1.0, 0.0]
|
||||
|
||||
class FakeCache:
|
||||
def __init__(self):
|
||||
self.saved = None
|
||||
|
||||
def get(self, _embedding):
|
||||
return {
|
||||
"cache_type": "future_cache_type",
|
||||
"rewritten_query": "错误的缓存结果",
|
||||
}
|
||||
|
||||
def set(self, _embedding, value):
|
||||
self.saved = value
|
||||
|
||||
def get_stats(self):
|
||||
return {}
|
||||
|
||||
analyzer = IntentAnalyzer(model="mimo-v2.5")
|
||||
analyzer._embedding_model = FakeEmbeddingModel()
|
||||
analyzer._cache = FakeCache()
|
||||
analyzer._client = object()
|
||||
|
||||
llm_result = json.dumps(
|
||||
{
|
||||
"rewritten_query": "来自 LLM 的新结果",
|
||||
"use_context": False,
|
||||
"need_retrieval": True,
|
||||
"reason": "新问题",
|
||||
"sub_queries": ["来自 LLM 的新结果"],
|
||||
"intent": "factual",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
with patch("core.llm_utils.call_llm", return_value=llm_result):
|
||||
result = analyzer.analyze("当前问题", [])
|
||||
|
||||
self.assertEqual("来自 LLM 的新结果", result.rewritten_query)
|
||||
self.assertEqual("intent_analysis", analyzer._cache.saved["cache_type"])
|
||||
|
||||
|
||||
class ExamRegressionTests(unittest.TestCase):
|
||||
def test_analysis_prompt_respects_requested_max_total(self):
|
||||
captured = {}
|
||||
|
||||
def fake_call(_self, prompt):
|
||||
captured["prompt"] = prompt
|
||||
return json.dumps(
|
||||
{
|
||||
"total_knowledge_points": 2,
|
||||
"suitable_types": ["single_choice"],
|
||||
"question_types": {
|
||||
"single_choice": 3,
|
||||
"multiple_choice": 0,
|
||||
"true_false": 0,
|
||||
"fill_blank": 0,
|
||||
"subjective": 0,
|
||||
},
|
||||
"reason": "测试",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
|
||||
chunks = [{"content": "知识点" * 80, "section": "第一章"}]
|
||||
with patch.object(QuestionGenerator, "_call_llm", fake_call):
|
||||
result = analyze_document_for_exam(chunks, max_total=3)
|
||||
|
||||
self.assertIn("所有数量之和不要超过 3", captured["prompt"])
|
||||
self.assertLessEqual(sum(result["question_types"].values()), 3)
|
||||
|
||||
def test_fill_blank_rejects_non_list_student_answer(self):
|
||||
result = grade_fill_blank(
|
||||
{
|
||||
"question_id": "q1",
|
||||
"content": {"answer": [["正确答案"]], "data": {"blank_count": 1}},
|
||||
"student_answer": "正确答案",
|
||||
"max_score": 2,
|
||||
}
|
||||
)
|
||||
self.assertEqual("failed", result["grading_status"])
|
||||
self.assertEqual(0, result["score"])
|
||||
|
||||
def test_fill_blank_rejects_non_string_items(self):
|
||||
result = grade_fill_blank(
|
||||
{
|
||||
"question_id": "q1",
|
||||
"content": {"answer": [["正确答案"]], "data": {"blank_count": 1}},
|
||||
"student_answer": [{"text": "正确答案"}],
|
||||
"max_score": 2,
|
||||
}
|
||||
)
|
||||
self.assertEqual("failed", result["grading_status"])
|
||||
self.assertIn("第 1 项", result["details"]["error"])
|
||||
|
||||
|
||||
class SyncInvalidationTests(unittest.TestCase):
|
||||
def test_document_delete_clears_semantic_cache(self):
|
||||
class FakeDb:
|
||||
def delete_document_hash(self, _document_id):
|
||||
return None
|
||||
|
||||
class FakeKbManager:
|
||||
def delete_document(self, _kb_name, _document_name):
|
||||
return 1
|
||||
|
||||
class FakeSemanticCache:
|
||||
def __init__(self):
|
||||
self.cleared = False
|
||||
|
||||
def clear(self):
|
||||
self.cleared = True
|
||||
|
||||
service = object.__new__(KnowledgeSyncService)
|
||||
service.documents_path = "unused"
|
||||
service.db = FakeDb()
|
||||
semantic_cache = FakeSemanticCache()
|
||||
change = DocumentChange(
|
||||
document_id="public/example.pdf",
|
||||
document_name="example.pdf",
|
||||
change_type=ChangeType.DELETED,
|
||||
old_hash="old",
|
||||
new_hash=None,
|
||||
change_time=datetime.now(),
|
||||
)
|
||||
|
||||
with (
|
||||
patch("knowledge.manager.get_kb_manager", return_value=FakeKbManager()),
|
||||
patch("knowledge.sync.CACHE_AVAILABLE", False),
|
||||
patch("core.semantic_cache.get_semantic_cache", return_value=semantic_cache),
|
||||
):
|
||||
self.assertTrue(service.process_change(change))
|
||||
|
||||
self.assertTrue(semantic_cache.cleared)
|
||||
|
||||
|
||||
class ConfigTemplateTests(unittest.TestCase):
|
||||
def test_config_example_defines_all_direct_imports(self):
|
||||
root = pathlib.Path(__file__).resolve().parents[1]
|
||||
template_tree = ast.parse((root / "config.example.py").read_text(encoding="utf-8"))
|
||||
defined = {
|
||||
node.name
|
||||
for node in template_tree.body
|
||||
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
}
|
||||
for node in template_tree.body:
|
||||
if isinstance(node, ast.Assign):
|
||||
defined.update(target.id for target in node.targets if isinstance(target, ast.Name))
|
||||
elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
|
||||
defined.add(node.target.id)
|
||||
|
||||
imported = set()
|
||||
excluded = {"venv", ".git", ".data", ".recovery", "__pycache__"}
|
||||
for path in root.rglob("*.py"):
|
||||
if any(part in excluded for part in path.parts):
|
||||
continue
|
||||
tree = ast.parse(path.read_text(encoding="utf-8-sig"))
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom) and node.module == "config":
|
||||
imported.update(alias.name for alias in node.names)
|
||||
|
||||
self.assertEqual([], sorted(imported - defined))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -9,12 +9,30 @@ Phase 3 验证测试:重复上传旧切片残留修复
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
# 添加项目根目录到 Python 路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
TEST_TEMP_ROOT = os.path.join(PROJECT_ROOT, ".data", "test-runs")
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
|
||||
def _make_test_dir():
|
||||
"""在工作区内创建测试目录,兼容 Codex 的 Windows 文件权限。"""
|
||||
os.makedirs(TEST_TEMP_ROOT, exist_ok=True)
|
||||
path = os.path.join(TEST_TEMP_ROOT, f"upload-{uuid.uuid4().hex}")
|
||||
os.makedirs(path)
|
||||
return path
|
||||
|
||||
|
||||
def _cleanup_test_dir(path):
|
||||
root = os.path.realpath(TEST_TEMP_ROOT)
|
||||
target = os.path.realpath(path)
|
||||
if os.path.commonpath([root, target]) != root:
|
||||
raise RuntimeError(f"拒绝清理测试目录之外的路径: {target}")
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
|
||||
def test_add_file_to_kb_dedup():
|
||||
@@ -143,7 +161,7 @@ def test_upload_document_overwrite():
|
||||
print("\n=== 测试 2:upload_document 同名文件覆盖 ===")
|
||||
|
||||
# 创建临时目录
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_dir = _make_test_dir()
|
||||
try:
|
||||
target_dir = os.path.join(temp_dir, 'public_kb')
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
@@ -184,14 +202,14 @@ def test_upload_document_overwrite():
|
||||
print(f" [PASS] replaced={replaced}")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
_cleanup_test_dir(temp_dir)
|
||||
|
||||
|
||||
def test_batch_upload_overwrite():
|
||||
"""测试批量上传中的同名文件覆盖逻辑"""
|
||||
print("\n=== 测试 3:批量上传同名文件覆盖 ===")
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_dir = _make_test_dir()
|
||||
try:
|
||||
target_dir = os.path.join(temp_dir, 'public_kb')
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
@@ -240,14 +258,14 @@ def test_batch_upload_overwrite():
|
||||
print(f" [PASS] 文件内容已更新")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
_cleanup_test_dir(temp_dir)
|
||||
|
||||
|
||||
def test_docstore_cleanup():
|
||||
"""测试 DocStore 文件清理逻辑"""
|
||||
print("\n=== 测试 4:DocStore 文件清理 ===")
|
||||
|
||||
temp_dir = tempfile.mkdtemp()
|
||||
temp_dir = _make_test_dir()
|
||||
try:
|
||||
docstore_dir = Path(temp_dir) / 'docstore'
|
||||
docstore_dir.mkdir()
|
||||
@@ -289,7 +307,7 @@ def test_docstore_cleanup():
|
||||
print(f" [PASS] 剩余 {len(remaining)} 个无关文件未被清理")
|
||||
|
||||
finally:
|
||||
shutil.rmtree(temp_dir)
|
||||
_cleanup_test_dir(temp_dir)
|
||||
|
||||
|
||||
def test_sync_hash_cleanup():
|
||||
|
||||
Reference in New Issue
Block a user