10 Commits

Author SHA1 Message Date
lacerate551
a31ee4bba0 feat(exam_pkg): 优化出题系统稳定性与推理模型适配
- core/llm_utils: MiMo模型自动注入thinking=disabled参数,全局生效
- config: 新增LLM_DISABLE_THINKING配置项(默认true)
- generator: 推理模型自适应max_tokens(1.5x)、429限流重试+指数退避、v2管线补题机制
- generator: analyze_document_for_exam新增max_total参数控制AI出题上限
- generator: validate_questions_schema兼容type和question_type字段
- grader: 主观题max_tokens从1000提升至2000、fuzzy_match增加编辑距离容错
- manager: results变量初始化防NameError、透传max_total参数
- api: /exam/generate-smart支持max_total请求参数
2026-06-22 18:51:11 +08:00
lacerate551
b966be5417 fix: 修正 RAG性能分析报告中的错误和遗漏
- INTENT_MAX_TOKENS 2048 不再标注为'浪费',推理模型思考链需 ~1000 token
- 删除 P2 建议'INTENT_MAX_TOKENS 降低 2048→512'(推理模型不可降)
- P0 优化建议标注 deepseek-v4-flash 额度已尽,需寻找替代轻量模型
- Rerank 配置补充云端 xop3qwen8breranker(讯飞云)信息
- §6.4 休眠模块:AgenticRAG 类已在 v4.0 删除,更新描述
- 阶段 7 补充两管线断裂问题:chart_contexts 降门槛、P0 安全网、答案后过滤鸡生蛋问题
- 阶段 9 补充推理模型 content 为空问题和 reasoning_content 回退机制
- 阶段 10 图片后过滤补充 _filter_images_by_answer 函数名
2026-06-22 13:08:14 +08:00
lacerate551
789610d01f fix: INTENT_MAX_TOKENS 从 1024 提升至 2048,适配推理模型思考链预算
mimo-v2.5 推理模型思考链消耗 ~800-1000 tokens,原 1024 不够导致
content 为空、全部输出进入 reasoning_content,引发 JSON 数组格式
解析错误。2048 可确保思考链 + JSON 输出均有足够空间。
2026-06-21 23:30:31 +08:00
lacerate551
e761af6111 fix: 兼容 mimo-v2.5 返回 JSON 数组而非对象的格式
- intent_analyzer._parse_json: 解析结果为 list 时取第一个 dict 元素
- llm_utils.parse_json_from_response: 同样处理 list 返回值
- 修复日志错误: 'list' object has no attribute 'get'
2026-06-21 23:20:57 +08:00
lacerate551
8268071fdc docs: 模型切换至 mimo-v2.5 + 文档全面更新
- config.py: INTENT_MODEL 从 deepseek-v4-flash 切换至 mimo-v2.5
- config.py: get_intent_client() 从百炼 API 切换至 mimo API
- RAG系统完整指南.md: v4.0→v4.1,新增图片检索子系统、P0安全网、
  救援管线、五层缓存架构文档;替换所有旧模型名和API地址
- RAG数据流程.md: 完全重写,匹配实际代码逻辑
- curl测试手册.md: 更新模型名和Reranker配置
- MinerU模型部署指南.md: VLM_MODEL 更新为 mimo-v2.5
- 开发与系统模块说明.md: API地址和模型名更新
- 测试指南.md: API地址和模型名更新
2026-06-21 23:11:46 +08:00
lacerate551
4753a53487 fix(rag): 图片描述注入安全网与 chart_contexts 宽松阈值
问题:图片/图表切片的 CrossEncoder 评分系统性偏低(0.002-0.08 vs 文本 0.3-0.9),
导致 chart_contexts 被 min_score 过滤,图片描述无法进入 LLM context。
典型案例:雷达图查询(ci=73)rerank score=0.039 < min_score=0.05,
虽然 select_images 正确选中了雷达图,但描述未出现在 LLM context 中。

修复:
1. chart_contexts 使用宽松阈值 min_score*0.5(与 table 保护逻辑一致)
   - 图片描述的 CE 分数天然偏低,不应与文本使用相同阈值
   - 实际内容相关性由 select_images 独立评分保证
2. P0 安全网:在现有图片注入代码后检查 selected_images 完整性
   - 若【相关图片信息】未生成,补注入所有选中图片描述
   - 若已有但遗漏部分图片,追加遗漏的描述
3. images_selected 调试事件增加 chunk_id/actual_score/id/desc_preview
4. P0 完整性日志:DEV 模式下检查图片描述是否完整注入 context
2026-06-21 22:41:30 +08:00
lacerate551
63a769540a fix(rag): 图片占位符正则扩展匹配 LLM 输出的 placeholder URL
LLM 有时输出 ![图片](https://via.placeholder.com/150) 而非
![图片](图片URL),正则尾部增加 https?:// 匹配覆盖。
2026-06-21 00:44:00 +08:00
lacerate551
f0e5426b4d fix(rag): 修复表格图片替换和跨页合并三个bug
1. chat_routes._replace_table_image_placeholders:
   - 处理 ![图片](图片URL) LLM 格式(去掉多余!和假URL)
   - 支持 [N张图片] 多张占位符(html_table_to_markdown 生成)
   - 修复 !! 双感叹号导致 markdown 图片语法失效

2. manager._merge_cross_page_tables:
   - 表头跳过逻辑改为对比第一行而非 find_all('th')
   - 修复合并后表格中间出现重复表头行的问题
2026-06-21 00:40:12 +08:00
lacerate551
eb177e11e5 fix(rag): 替换答案中 [图片] 占位符为 markdown 图片语法
LLM 上下文中的表格包含 [图片] 占位符(MinerU 解析产物),LLM 原样
输出后前端 markdown-it 只渲染为纯文本。新增 _replace_table_image_placeholders
函数,在 finish 事件发送前按顺序将 [图片] 替换为 ![filename](/images/xxx.png),
前端可直接渲染为 <img> 标签。
2026-06-20 23:20:40 +08:00
lacerate551
5ba0d782e2 feat(rag): 子章节级图片过滤 + VLM 后台增强 + 意图分析优化
- 图片选择新增 section_path 字段,支持子章节级过滤
- _filter_images_by_answer 增加 primary_sections 参数和叶子节点匹配
- 检索发散检测:primary_leaf_names > 3 时全局阈值+1
- _FIGURE_ANSWER_KEYWORDS 移除单字"图""表",正则图号兜底防误触发
- lazy_enhance 后台增强流程优化
- 意图分析与 LLM 工具层改进

评测:图片选择 F1 从 52.4% 提升至 66.3%(+13.9pp),Precision +16.5pp
2026-06-20 19:26:40 +08:00
21 changed files with 4960 additions and 3397 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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_BASE_URL = os.getenv("DASHSCOPE_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1")
DASHSCOPE_MODEL = os.getenv("DASHSCOPE_MODEL", "mimo-v2.5") # 文本生成模型 DASHSCOPE_MODEL = os.getenv("DASHSCOPE_MODEL", "mimo-v2.5") # 文本生成模型
RAG_CHAT_MODEL = os.getenv("RAG_CHAT_MODEL", "mimo-v2.5") # RAG 对话模型 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") # 视觉语言模型(图片描述) 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_* 命名) # 兼容旧变量名(逐步迁移到 DASHSCOPE_* 命名)
API_KEY = DASHSCOPE_API_KEY API_KEY = DASHSCOPE_API_KEY
BASE_URL = DASHSCOPE_BASE_URL 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_TEMPERATURE = 0.7 # 生成温度0=确定性1=随机性)
LLM_MAX_TOKENS = 3000 # 最大输出 token 数 LLM_MAX_TOKENS = 3000 # 最大输出 token 数
LLM_DISABLE_THINKING = os.getenv("LLM_DISABLE_THINKING", "true").lower() != "false" # 关闭推理模型的思考模式(提速 + 让 temperature 生效)
# ----- 意图分析(轻量、确定性高)----- # ----- 意图分析(轻量、确定性高)-----
# INTENT_MODEL 在顶部「一、API 密钥与模型」中统一配置 # INTENT_MODEL 在顶部「一、API 密钥与模型」中统一配置
INTENT_TEMPERATURE = 0.1 INTENT_TEMPERATURE = 0.1
INTENT_MAX_TOKENS = 4096 # 推理模型思维链消耗大量 token2048 偶发截断导致意图分析失败 INTENT_MAX_TOKENS = 2048 # 推理模型需思考链预算(~1000 tokensJSON 输出 ~200 tokens
INTENT_HISTORY_WINDOW = 6 # 分析时取最近几条历史消息 INTENT_HISTORY_WINDOW = 6 # 分析时取最近几条历史消息
# ============================================================================== # ==============================================================================
@@ -286,3 +291,17 @@ def get_llm_client():
"""获取 LLM 客户端实例""" """获取 LLM 客户端实例"""
from openai import OpenAI from openai import OpenAI
return OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL) 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

View File

@@ -233,10 +233,10 @@ class IntentAnalyzer:
self._exact_cache_max = 500 self._exact_cache_max = 500
def _get_client(self): def _get_client(self):
"""获取 LLM 客户端""" """获取 LLM 客户端(百炼快速模型)"""
if self._client is None: if self._client is None:
from config import get_llm_client from config import get_intent_client
self._client = get_llm_client() self._client = get_intent_client()
return self._client return self._client
def _get_cache(self): def _get_cache(self):
@@ -529,7 +529,16 @@ class IntentAnalyzer:
"""解析 JSON 响应""" """解析 JSON 响应"""
# 尝试直接解析 # 尝试直接解析
try: 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: except json.JSONDecodeError:
pass pass

View File

@@ -11,6 +11,24 @@ from typing import List, Optional, Union, Iterator, Callable
logger = logging.getLogger(__name__) 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( def call_llm(
client, client,
@@ -57,6 +75,13 @@ def call_llm(
if messages is None: if messages is None:
messages = [{"role": "user", "content": prompt}] 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: try:
response = client.chat.completions.create( response = client.chat.completions.create(
model=model, model=model,
@@ -71,19 +96,38 @@ def call_llm(
return response return response
content = response.choices[0].message.content content = response.choices[0].message.content
# 推理模型兼容content 为空时尝试从 reasoning_content 提取 # 推理模型兼容mimo-v2.5 等):
# 推理模型思考链消耗大量 token~1000max_tokens 不足时 content 为空,
# 全部输出进入 reasoning_content。此处从思考链中提取有效内容。
if not content or not content.strip(): if not content or not content.strip():
reasoning = getattr(response.choices[0].message, 'reasoning_content', None) reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
if reasoning and reasoning.strip(): if reasoning and reasoning.strip():
# 从思维链中提取 JSON 块作为内容 # 先去掉 <think>...</think> 标签
json_match = re.search(r'\{[\s\S]*\}', reasoning) cleaned = re.sub(r'', '', reasoning, flags=re.DOTALL).strip()
if json_match: if cleaned:
logger.info("LLM: content为空从reasoning_content提取JSON") logger.info("LLM: content为空从reasoning_content提取内容")
return json_match.group().strip() # 尝试提取 JSON 对象(兼容结构化响应场景)
logger.warning("LLM 返回空 content可能需要增大 max_tokens") 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 None
return content.strip() return content.strip()
except Exception as e: except Exception as e:
logger.warning(f"LLM 调用失败: {e}") logger.warning(f"LLM 调用失败: {e}")
@@ -95,7 +139,7 @@ def call_llm_stream(
prompt: str, prompt: str,
model: str, model: str,
temperature: float = 0.3, temperature: float = 0.3,
max_tokens: int = 1000, max_tokens: int = 3000,
messages: List[dict] = None, messages: List[dict] = None,
error_prefix: str = "[错误]", error_prefix: str = "[错误]",
**kwargs **kwargs
@@ -104,13 +148,14 @@ def call_llm_stream(
流式 LLM 调用(生成器封装) 流式 LLM 调用(生成器封装)
自动处理流式响应,逐块 yield 文本内容。 自动处理流式响应,逐块 yield 文本内容。
兼容推理模型mimo-v2.5 等):当 content 为空时回退到 reasoning_content。
Args: Args:
client: OpenAI 客户端实例 client: OpenAI 客户端实例
prompt: 用户提示 prompt: 用户提示
model: 模型名称 model: 模型名称
temperature: 温度参数 temperature: 温度参数
max_tokens: 最大 token 数 max_tokens: 最大 token 数(推理模型需留足思考链预算)
messages: 完整消息列表 messages: 完整消息列表
error_prefix: 错误时的前缀 error_prefix: 错误时的前缀
**kwargs: 其他参数 **kwargs: 其他参数
@@ -125,6 +170,13 @@ def call_llm_stream(
if messages is None: if messages is None:
messages = [{"role": "user", "content": prompt}] 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: try:
stream = client.chat.completions.create( stream = client.chat.completions.create(
model=model, model=model,
@@ -135,9 +187,33 @@ def call_llm_stream(
**kwargs **kwargs
) )
content_yielded = False
reasoning_buffer = []
for chunk in stream: for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content: if not chunk.choices:
yield chunk.choices[0].delta.content 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: except Exception as e:
logger.error(f"LLM 流式调用失败: {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 json_str = json_match.group(1) if json_match else content
try: 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): except (json.JSONDecodeError, TypeError, ValueError):
return None return None

View File

@@ -364,7 +364,7 @@ DASHSCOPE_BASE_URL=<your-base-url>
DASHSCOPE_MODEL=mimo-v2.5 DASHSCOPE_MODEL=mimo-v2.5
RAG_CHAT_MODEL=mimo-v2.5 RAG_CHAT_MODEL=mimo-v2.5
INTENT_MODEL=mimo-v2.5 INTENT_MODEL=mimo-v2.5
VLM_MODEL=qwen-vl-plus VLM_MODEL=mimo-v2.5
# MinerU 在线 API可选设置后无需本地模型 # MinerU 在线 API可选设置后无需本地模型
MINERU_API_TOKEN=<your-mineru-api-token> MINERU_API_TOKEN=<your-mineru-api-token>

View 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-flash3500-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 tokenJSON 输出 ~200 token2048 是必要预算(此前 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。
### 阶段 4Rerank
**位置**`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-800msCPU ONNX 推理)
**瓶颈分析**
1. **CPU 推理**CrossEncoder 是最重的 CPU 计算任务。每个 (query, doc) pair 需要一次 forward pass。20 个候选 × ~30ms/pair ≈ 600ms。
2. **max_length=512**:长文档截断到 512 token避免 OOM 但可能丢失信息。
3. **缓存效果好**:同一查询+同一文档集合直接命中缓存。但首次查询必经此阶段。
### 阶段 5MMR 去重
**位置**`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对于需要对比多个文档的查询可能不够。
### 阶段 9LLM 流式生成 — 最大瓶颈
**位置**`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-flash5000-15000ms含流式输出。mimo-v2.5 推理模型预计 **8000-20000ms**
**瓶颈分析**
1. **模型延迟不可控**LLM API 的 TTFT首 token 时间)和 TPStoken/秒)取决于服务端负载和网络。这是整个管线中唯一无法通过代码优化的瓶颈。
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 token3000 平衡速度与质量。
### 阶段 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 是普通 DictPython GIL 保护下基本安全。
### 6.4 休眠模块
以下模块当前未接入生产流程,属于**独立休眠模块**(原属 AgenticRAG 类,该类已在 v4.0 删除):
- `confidence_gate.py` — 置信度门控
- `quality_assessor.py` — 质量评估器
- `reasoning_reflector.py` — 推理反思器
- `loop_guard.py` — 循环守卫
这些模块增加了代码库的维护负担但不影响运行时性能。可按需启用。

View File

@@ -1,7 +1,7 @@
# RAG 数据流程 # RAG 数据流程
> 本文档基于 v7.0.0 架构,详细梳理 RAG 系统从用户查询到回答生成的完整数据流。 > 本文档基于当前代码实际逻辑梳理,详细记录 RAG 系统从用户查询到回答生成的完整数据流。
> 涵盖意图分析、混合检索、云端重排序、MMR 去重、上下文构建、LLM 生成和引用溯源等核心环节。 > 涵盖意图分析、混合检索、重排序、救援管道、上下文构建、图片选择与注入、LLM 生成和引用溯源等核心环节。
--- ---
@@ -16,7 +16,7 @@
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
│ 1. 意图分析 (intent_analyzer) │ │ 1. 意图分析 (intent_analyzer) │
│ 问题改写 · 指代消解 · 是否需要检索 · 重复提问检测 │ │ 问题改写 · 指代消解 · 是否需要检索 · 重复提问检测 │
│ 模型:qwen-turbo │ 模型:mimo-v2.5
└─────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────┘
@@ -29,31 +29,37 @@
│ └────────┬──────────┘ │ │ └────────┬──────────┘ │
│ ▼ │ │ ▼ │
│ RRF 融合 (Reciprocal Rank Fusion) │ │ RRF 融合 (Reciprocal Rank Fusion) │
│ │ │
│ 查询缓存检查 (命中则跳过 3-7 步) │
└─────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
│ 3. 云端 Rerank (DashScope API) │ 3. 云端 Rerank
│ 模型qwen3-rerank │ 模型:xop3qwen8breranker (讯飞云)
│ 结果缓存rerank_cache (TTL=1h, 版本失效自动清除) │
└─────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
│ 4. MMR 去重 (文本 Jaccard 模式) │ 4. 救援管道 (chat_routes.py)
MMR_USE_EMBEDDING=false BM25 分歧救援 · 词法匹配救援 · 章节聚类救援
│ 目的:挽救被 CrossEncoder 低估但实际相关的切片 │
└─────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
│ 5. 上下文扩展 + 图片选择 │ │ 5. 上下文构建 + 图片选择 │
上下文增强 · 图文关联补充 · 懒加载 VLM 描述 _order_text_contexts_for_prompt · _build_context_with_budget
│ select_images · VLM 相关性筛选 · CrossEncoder 精排 │
│ 懒加载 VLM 描述 · 图片描述注入 · P0 安全网 │
└─────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────┐
│ 6. LLM 生成 (AgenticRAG 引擎) │ 6. LLM 生成
│ 模型:qwen3.6-flash主 LLM/ qwen-vl-plusVLM │ 模型:mimo-v2.5
│ 流式输出 · 引用标注 │ 流式输出 · 置信度指令 · 引用标注 · 图片后置过滤
└─────────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────────┘
@@ -65,19 +71,26 @@
### 1.2 模型配置一览 ### 1.2 模型配置一览
| 用途 | 模型 | 说明 | | 用途 | 模型 | API 端点 | 说明 |
|------|------|------| |------|------|----------|------|
| 主 LLM | qwen3.6-flash | 回答生成、上下文理解 | | 主 LLM | mimo-v2.5 | xiaomimimo.com | 回答生成、上下文理解 |
| 意图分析 | qwen-turbo | 轻量快速,用于问题改写与意图判断 | | 意图分析 | mimo-v2.5 | xiaomimimo.com | 问题改写与意图判断 |
| VLM | qwen-vl-plus | 图片理解与描述生成 | | VLM | mimo-v2.5 | xiaomimimo.com | 图片理解与描述生成 |
| 云端重排序 | qwen3-rerank | DashScope API 调用,替代本地 BGE-reranker | | 云端重排序 | 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 APIqwen3-rerank ### 1.3 关键配置参数
- **MMR 去重**:使用文本相似度模式(`MMR_USE_EMBEDDING=false`),基于 Jaccard 系数
- **Agentic 引擎**拆分为多个子模块agentic_search / agentic_answer / agentic_citation 等) | 参数 | 默认值 | 说明 |
- **Graph RAG**:模块已清空,不再使用 |------|--------|------|
| `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/chat_routes.py`
用户通过 API 发送查询请求,由 `generate()` 函数统一调度:
``` ```
POST /rag 或 POST /chat POST /chat — 普通聊天(不检索知识库,直接 LLM 回答)
Body: { "query": "用户问题", "kb_name": "知识库名称" } POST /rag — 知识库问答(检索 + LLM 生成)
POST /search — 纯检索(返回切片,不生成回答)
``` ```
`generate()` 的职责: `/rag` 的核心函数 `rag()` 的职责:
1. 调用意图分析模块,获取改写后的查询与检索决策 1. 调用意图分析模块,获取改写后的查询与检索决策
2. 若需要检索,调用混合检索管线 2. 若需要检索,调用混合检索管线`search_hybrid()`
3. 执行图片选择与上下文构建 3. 执行救援管道BM25 分歧 / 词法匹配 / 章节聚类)
4. 调用 LLM 生成回答(流式输出 4. 执行图片选择(`select_images()`
5. 组装最终响应(回答 + 引用来源 + 图片) 5. 构建 LLM 上下文(排序 + 预算截断 + 图片注入
6. 调用 LLM 生成回答(流式输出)
7. 后置图片过滤(`_filter_images_by_answer()`
8. 组装最终响应(回答 + 引用来源 + 图片)
### 2.2 请求数据结构 ### 2.2 请求数据结构
```python ```python
# 请求 # 请求
{ {
"query": "蓄水以来逐年发电量", "message": "用户问题",
"kb_name": "public_kb",
"history": [...] # 可选:对话历史 "history": [...] # 可选:对话历史
} }
# 响应 # SSE 响应事件流
{ data: {"type": "intent_result", "data": {...}}
"type": "finish", data: {"type": "chunks_retrieved", "data": {...}}
"answer": "完整回答文本", data: {"type": "images_selected", "data": {...}}
"sources": [...], # 引用来源列表 data: {"type": "context_built", "data": {...}}
"images": [...] # 精选图片列表 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` **入口文件**`core/intent_analyzer.py`
**使用模型**qwen-turbo轻量快速 **使用模型**mimo-v2.5
### 3.1 核心功能 ### 3.1 核心功能
@@ -138,6 +154,8 @@ class IntentAnalysis:
rewritten_query: str # 改写后的查询(指代消解、省略补全) rewritten_query: str # 改写后的查询(指代消解、省略补全)
use_context: bool # 是否使用历史上下文 use_context: bool # 是否使用历史上下文
need_retrieval: bool # 是否需要检索知识库 need_retrieval: bool # 是否需要检索知识库
intent: str # 意图类型factual/comparison/reasoning/instruction
sub_queries: list # 子查询列表(对比/复杂查询拆分)
``` ```
### 3.2 处理逻辑 ### 3.2 处理逻辑
@@ -147,7 +165,9 @@ class IntentAnalysis:
| 问题改写 | 指代消解("它" → 具体实体)、省略补全(补全缺失主语) | | 问题改写 | 指代消解("它" → 具体实体)、省略补全(补全缺失主语) |
| 上下文判断 | 根据对话历史决定是否需要前文信息 | | 上下文判断 | 根据对话历史决定是否需要前文信息 |
| 检索决策 | 判断是否需要查询知识库(闲聊类问题可跳过) | | 检索决策 | 判断是否需要查询知识库(闲聊类问题可跳过) |
| 重复提问检测 | 用户重复提问相同问题时,强制设置 `need_retrieval=true` | | 意图分类 | 识别查询意图(事实查询/对比分析/推理/操作指导) |
| 子查询拆分 | 对比类查询拆分为多个子查询并行检索 |
| 语义缓存 | 相似度 ≥ 0.92 时复用历史意图分析结果 |
### 3.3 重复提问规则 ### 3.3 重复提问规则
@@ -170,9 +190,16 @@ if cached:
return 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 向量检索 ### 4.2 向量检索
使用 embedding 模型将查询编码为向量,在 ChromaDB 中执行 ANN 检索: 使用本地 bge-base-zh-v1.5 模型将查询编码为向量,在 ChromaDB 中执行 ANN 检索:
```python ```python
query_vector = embedding_model.encode(query) query_vector = embedding_model.encode(query)
@@ -211,13 +238,9 @@ fused_results = reciprocal_rank_fusion(
) )
``` ```
**融合参数** ### 4.5 子查询并行检索
| 参数 | 默认值 | 说明 | 意图分析器拆分的子查询(如对比类查询)会并行检索,结果合并后再 rerank。
|------|--------|------|
| `recall_k` | 100 | 各通道召回候选数量 |
| `VECTOR_WEIGHT` | 0.6 | 向量检索权重 |
| `BM25_WEIGHT` | 0.4 | BM25 检索权重 |
--- ---
@@ -225,204 +248,233 @@ fused_results = reciprocal_rank_fusion(
### 5.1 云端 Rerank ### 5.1 云端 Rerank
**v7.0.0 变更**:从本地 BGE-reranker 切换为云端 DashScope API **调用模型**xop3qwen8breranker讯飞云 Maas API
**调用模型**qwen3-rerank
RRF 融合后的候选结果通过云端 Reranker 进行精排,基于查询与文档的语义相关性重新打分排序: RRF 融合后的候选结果通过云端 Reranker 进行精排,基于查询与文档的语义相关性重新打分排序:
```python ```python
reranked = rerank_results( reranked = rerank_results(query, fused_results, top_k=15)
query,
fused_results,
model="qwen3-rerank", # DashScope API
top_k=15
)
``` ```
### 5.2 与旧版的区别 **重要特征**CrossEncoder 对不同类型切片的评分分布差异显著:
| 对比项 | 旧版(本地 BGE-reranker | v7.0.0(云端 qwen3-rerank | | 切片类型 | 典型分数范围 | 说明 |
|--------|--------------------------|---------------------------| |----------|------------|------|
| 部署方式 | 本地模型加载 | DashScope API 远程调用 | | 文本切片 | 0.3 ~ 0.99 | 正常分布 |
| 资源占用 | 需要 GPU 显存 | 无本地资源消耗 | | 表格切片 | 0.1 ~ 0.5 | 偏低,有专用救援逻辑 |
| 模型能力 | BGE-reranker较小 | qwen3-rerank更强 | | 图片/图表切片 | 0.002 ~ 0.08 | 系统性偏低,需要宽松阈值 |
| 延迟 | 低(本地推理) | 中等(网络往返) |
### 5.2 Rerank 缓存
- 每个 (query, doc_id) 对的 rerank 分数独立缓存
- TTL = 1 小时
- 知识库版本号变更时自动失效
--- ---
## 六、MMR 去重 ## 六、救援管道
### 6.1 配置 **入口文件**`api/chat_routes.py`
**目的**:挽救被 CrossEncoder 低估但实际相关的切片
v7.0.0 采用文本相似度模式进行 MMRMaximal Marginal Relevance去重 `_order_text_contexts_for_prompt` 之前执行,三个救援管道按顺序运行
``` ### 6.1 BM25 分歧救援 `_rescue_bm25_divergence`
MMR_USE_EMBEDDING = false
```
### 6.2 工作原理 **触发条件**BM25 排名 top-3 但 CrossEngineer 评分低于 min_score 的切片
使用文本 Jaccard 相似度(而非 embedding 向量余弦相似度)来衡量候选文档之间的重复程度: **逻辑**BM25 是关键词精确匹配的强信号,如果 BM25 认为相关但 CE 误判,提升分数至保底值。
```python ```python
def _apply_mmr(query, candidates, top_k=30, lambda_param=0.7): # 配置
""" BM25_DIVERGENCE_RESCUE_ENABLED = True
MMR 去重:在相关性和多样性之间取得平衡 BM25_DIVERGENCE_MAX_RANK = 3 # 仅救援 BM25 rank <= 3 的切片
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]
``` ```
### 6.3 选择文本模式的原因 ### 6.2 词法匹配救援 `_rescue_lexical_match`
- **速度更快**:无需计算 embedding 向量之间的余弦相似度 **触发条件**切片文本精确包含查询关键词bigram 匹配率 > 35%)但 CE 评分低
- **效果直观**Jaccard 系数直接反映文本内容的重叠程度
- **避免向量偏差**embedding 模型可能对格式化内容(如图片描述)产生不准确的相似度 **逻辑**当切片文本直接包含查询中的关键词组合时说明语义相关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 ### 7.2 预算构建 `_build_context_with_budget`
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 懒加载增强 按字符预算构建上下文文本:
对没有 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 图片选择 ### 7.3 图片选择 `select_images()`
**核心函数**`select_images()`
从检索结果中筛选与查询最相关的图片: 从检索结果中筛选与查询最相关的图片:
**步骤一:意图检测** **步骤一:动态预算**
| 查询类型 | 参数调整 | | 查询类型 | MAX_IMAGES | MIN_SCORE | 检测方式 |
|----------|----------| |----------|------------|-----------|----------|
| 精确图号查询("图2.3" | `MAX_IMAGES=2, MIN_SCORE=5.0` | | 精确图号查询("图2.3" | 2 | 5.0 | 正则匹配 `图\s*(\d+\.?\d*)` |
| 图片意图("发电量图" | `MAX_IMAGES=1` | | 图片数据(检索含 image/chart | 5 | 2.0 | 检查检索结果 chunk_type |
| 普通查询 | `MAX_IMAGES=2` | | 文本引用图表 | 3 | 2.0 | 提取 top-5 文本中的图号/表号 |
| 普通查询 | 2 | 3.0 | 默认 |
**步骤二:提取图表引用** **步骤二:提取图表引用**
从 top 5 文本块中提取 "见图2.3"、"如表2.2" 等引用,建立图号与来源文件的映射 从 top 5 文本块中提取 "见图2.3"、"如表2.2" 等引用,建立图号与来源文件的映射
```python **步骤三:图片相关性打分 `score_image_relevance()`**
referenced_figures = {'2.3': {'source_file': 'xxx.pdf'}, ...}
```
**步骤三:图片相关性打分**
`score_image_relevance()` 打分规则:
| 匹配项 | 加分 | | 匹配项 | 加分 |
|--------|------| |--------|------|
| 图号精确匹配(查询中有"图2.3" | +10 分 | | 图号精确匹配(查询中有"图2.3" | +10 分 |
| 表号精确匹配 | +10 分 | | 表号精确匹配 | +10 分 |
| 关键词匹配("发电量"等 | +2 分/个 | | 关键词匹配(jieba 分词 | +2 分/个,上限 8 分 |
| 字符重叠 | +0.2 分/字符 | | 字符重叠 | +0.2 分/字符,上限 3 分 |
| 章节匹配 | +1.5 分 | | 章节匹配 | +1.5 分/关键词 |
| 图片类型chart > image | +2 / +1 分 | | 图片类型chart | +2 分 |
| 图片类型image | +1 分 |
| 向量相似度 | +2 分(最高) | | 向量相似度 | +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 ```python
scored_images.sort(key=lambda x: x['score'], reverse=True) scored_images.sort(key=lambda x: x['score'], reverse=True)
return scored_images[:MAX_IMAGES] return scored_images[:MAX_IMAGES]
``` ```
### 7.4 构建 LLM Prompt ### 7.4 图片描述注入
```python **核心逻辑**:将 `select_images` 选中的图片的 `full_description` 注入到 LLM context 中。
# 文本上下文
context_text = "\n\n".join([ctx['doc'] for ctx in contexts[:5]])
# 图片信息
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 生成 ## 八、LLM 生成
### 8.1 AgenticRAG 引擎 ### 8.1 流式生成
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 流式生成
使用 SSEServer-Sent Events实现流式输出 使用 SSEServer-Sent Events实现流式输出
```python ```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 推送给前端 yield token # 逐 token 推送给前端
``` ```
**LLM 模型**qwen3.6-flash **LLM 模型**mimo-v2.5
**VLM 模型**qwen-vl-plus处理图片理解任务
### 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 ```python
{ {
@@ -443,7 +495,8 @@ for token in engine.generate_answer_stream(query, enhanced_context):
"type": "chart", "type": "chart",
"source": "三峡公报_2022.pdf", "source": "三峡公报_2022.pdf",
"page": 12, "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 引用标注机制 ### 9.1 引用标注机制
`agentic_citation.py` 负责在回答中插入引用标记,将回答内容与知识库来源关联: `_attach_citations()` 负责在回答中插入引用标记,将回答内容与知识库来源关联:
``` ```
根据统计数据[1]2022年三峡电站年度发电量为787.90亿千瓦时[2]。 根据统计数据[ref:三峡公报_text_24]2022年三峡电站年度发电量为787.90亿千瓦时[ref:三峡公报_table_5]。
[1] 来源三峡公报_2022.pdf第12页综述 > 2.3 发电
[2] 来源三峡公报_2022.pdf第15页表2.1
``` ```
### 9.2 引用数据来源 ### 9.2 引用数据来源
@@ -476,10 +526,6 @@ for token in engine.generate_answer_stream(query, enhanced_context):
| `chunk_id` | 切片唯一标识 | | `chunk_id` | 切片唯一标识 |
| `chunk_type` | 类型text / table / image / chart | | `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 | | `image` | 图片 | content(=caption), image_path, context_before/after |
| `chart` | 图表 | content(=caption), image_path, context_before/after | | `chart` | 图表 | content(=caption), image_path, context_before/after |
### 10.2 MinerUChunk 数据结构 ### 10.2 切片入库
```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 切片入库
**入口文件**`knowledge/manager.py` **入口文件**`knowledge/manager.py`
**核心函数**`add_file_to_kb()` **核心函数**`add_file_to_kb()`
@@ -547,63 +572,95 @@ MinerUChunk 列表
``` ```
**图片描述策略** **图片描述策略**
1. 优先使用 VLM 缓存描述(语义更丰富,由 qwen-vl-plus 生成 1. 优先使用 VLM 缓存描述(由 mimo-v2.5 生成,语义更丰富)
2. 若无缓存,生成轻量描述(基于文件名 + 章节路径 + 上下文) 2. 若无缓存,生成轻量描述(基于文件名 + 章节路径 + 上下文)
3. VLM 描述异步懒加载:首次检索命中时后台生成,下次查询即可使用
轻量描述示例: ### 10.3 ChromaDB 存储结构
```
图表图2.3,位于「综述 > 2.3发电」第12页
前文受长江流域性严重枯水影响2022年三峡电站年度发电量为787.90亿千瓦时...
后文2.4航运 三峡船闸和葛洲坝船闸实行统一调度...
```
VLM 描述示例(更精准):
```
图2.3 柱状图 主要内容描述该柱状图展示了2003年至2022年每年的发电量单位亿千瓦时
发电量在2003年为86.07亿千瓦时随后逐年波动上升至2020年达到峰值1118.02亿千瓦时...
```
### 10.4 ChromaDB 存储结构
| 字段 | 类型 | 说明 | | 字段 | 类型 | 说明 |
|------|------|------| |------|------|------|
| `ids` | str | 切片唯一标识,如 `doc.pdf_text_24` | | `ids` | str | 切片唯一标识,如 `doc.pdf_text_24` |
| `embeddings` | List[float] | 向量表示 | | `embeddings` | List[float] | 向量表示 |
| `documents` | str | 切片内容(文本/描述/摘要) | | `documents` | str | 切片内容(文本/VLM 描述/摘要) |
| `metadatas` | dict | 元数据(见下表) | | `metadatas` | dict | 元数据(见下表) |
**图片切片 metadata 示例** **图片切片 metadata 关键字段**
```python ```python
{ {
'source': '三峡公报_2022.pdf', 'source': '三峡公报_2022.pdf', # 源文件名
'page': 12, 'page': 12, # 页码
'chunk_type': 'chart', 'chunk_type': 'chart', # 切片类型
'section': '综述 > 2.3发电', 'section': '综述 > 2.3发电', # 章节路径
'figure_number': '2.3', 'image_path': 'ab77281e7913.jpg', # 图片文件名
'image_path': 'ab77281e7913.jpg', 'has_vlm_desc': True, # 是否有 VLM 描述
'has_vlm_desc': True, 'vlm_desc': '图2.3 柱状图...', # VLM 描述内容
'preview': '图2.3 柱状图 主要内容...' '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/intent_analyzer.py` | 意图分析 | `analyze()`, `IntentAnalysis` |
| `core/engine.py` | 检索引擎核心 | `search_knowledge()`, `reciprocal_rank_fusion()`, `rerank_results()` | | `core/bm25_index.py` | BM25 关键词检索 | `BM25Index.search()` |
| `core/agentic_search.py` | 检索调度 | 多轮检索、查询分解 | | `core/mmr.py` | MMR 去重 | `mmr_rerank()` |
| `core/agentic_answer.py` | 回答生成 | 基于上下文生成最终回答 | | `core/cache.py` | 缓存管理 | `RAGCacheManager`, `get_cache_manager()` |
| `core/agentic_citation.py` | 引用标注 | 来源引用标记插入 | | `core/semantic_cache.py` | 语义缓存 | `SemanticCache` |
| `core/agentic_context.py` | 上下文管理 | 上下文窗口控制、截断 | | `core/chunker.py` | 语义分块器 | `SemanticChunker` |
| `core/agentic_query.py` | 查询处理 | 查询改写、多查询生成 | | `core/llm_utils.py` | LLM 调用工具 | `call_llm()`, `call_llm_stream()` |
| `core/agentic_media.py` | 多媒体处理 | 图片理解、表格解析 |
| `core/agentic_quality.py` | 质量控制 | 回答质量评估、幻觉检测 |
| `core/agentic_meta.py` | 元数据管理 | 知识库信息、检索统计 |
| `parsers/mineru_parser.py` | 文档解析 | `parse_with_mineru()`, `MinerUChunk` | | `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()` | | `knowledge/lazy_enhance.py` | 懒加载增强 | `lazy_vlm_description()`, `enhance_retrieved_chunks()` |
| `config.py` | 配置集中管理 | 模型/参数/开关 |

View File

@@ -1,10 +1,10 @@
# RAG 系统完整指南 # RAG 系统完整指南
> **版本**: v4.0统一编排 + 四层缓存修复) > **版本**: v4.1模型切换 + 图片检索修复)
> **生产入口**: `api/chat_routes.py::rag()` → `generate()` → `core/engine.py` > **生产入口**: `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.5xiaomimimo.com APIReranker 切换至 xop3qwen8breranker讯飞云 API新增 chart_contexts 降门槛、P0 安全网、答案后过滤等图片检索修复逻辑
## 一、功能概述 ## 一、功能概述
@@ -14,12 +14,13 @@
|------|------|----------| |------|------|----------|
| **意图分析** | LLM 驱动的双层判断(是否需要检索)+ 查询改写 | `core/intent_analyzer.py` | | **意图分析** | LLM 驱动的双层判断(是否需要检索)+ 查询改写 | `core/intent_analyzer.py` |
| **混合检索** | 向量检索 + BM25 + RRF 融合 + Rerank 重排 | `core/engine.py` | | **混合检索** | 向量检索 + BM25 + RRF 融合 + Rerank 重排 | `core/engine.py` |
| **层缓存** | Query + Embedding + RerankLRU+ 语义缓存FAISS | `core/cache.py` + `core/semantic_cache.py` | | **层缓存** | Query + Embedding + RerankLRU+ 语义缓存FAISS+ ChromaDB 元数据缓存 | `core/cache.py` + `core/semantic_cache.py` |
| **流式生成** | SSE 流式答案输出,逐 token 推送 | `core/engine.py::generate_answer_stream()` | | **流式生成** | SSE 流式答案输出,逐 token 推送 | `core/engine.py::generate_answer_stream()` |
| **引用标注** | 自动标注信息来源和引用编号 | `api/chat_routes.py::_attach_citations()` | | **引用标注** | 自动标注信息来源和引用编号 | `api/chat_routes.py::_attach_citations()` |
| **富媒体** | 图片/表格的智能提取与展示 | `api/chat_routes.py` | | **富媒体** | 图片/表格的智能提取与展示 + P0 安全网 + 答案后过滤 | `api/chat_routes.py` |
| **查询理解** | 查询分解、扩展、MMR 去重、自适应 TopK | `core/` 各独立模块 | | **查询理解** | 查询分解、扩展、MMR 去重、自适应 TopK | `core/` 各独立模块 |
| **安全护栏** | 敏感信息过滤、Prompt 安全守卫 | `api/response_utils.py``core/prompt_guard.py` | | **安全护栏** | 敏感信息过滤、Prompt 安全守卫 | `api/response_utils.py``core/prompt_guard.py` |
| **救援管线** | BM25 散度救援、词法匹配救援、章节聚类救援、表格救援 | `core/engine.py` |
--- ---
@@ -78,9 +79,12 @@
│ │ FAQ 加权 │→ │ 黑名单过滤 │→ │ 时间衰减 │ │ │ │ FAQ 加权 │→ │ 黑名单过滤 │→ │ 时间衰减 │ │
│ └───────────┘ └──────────────┘ └──────┬───────┘ │ │ └───────────┘ └──────────────┘ └──────┬───────┘ │
│ ↓ │ │ ↓ │
│ ┌───────────────┐ ┌──────────────┐ │ ┌───────────────┐ ┌──────────────┐ ┌──────────────┐
│ │ 上下文扩展 │→ │ 自适应 TopK │ │ │ 上下文扩展 │→ │ 自适应 TopK │→ │ 救援管线 │
│ └───────────────┘ └──────────────┘ │ └───────────────┘ └──────────────┘ │ (BM25散度/ │
│ │ 词法/章节/ │ │
│ │ 表格救援) │ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────┐
@@ -105,7 +109,7 @@
--- ---
## 三、层缓存架构 ## 三、层缓存架构
### 3.1 缓存层次概览 ### 3.1 缓存层次概览
@@ -115,6 +119,7 @@
| L2 | Embedding Cache | LRU (OrderedDict) | 2000 条 | 24 小时 | 缓存向量化结果,避免重复调用 embedding 模型 | | L2 | Embedding Cache | LRU (OrderedDict) | 2000 条 | 24 小时 | 缓存向量化结果,避免重复调用 embedding 模型 |
| L3 | Rerank Cache | LRU (OrderedDict) | 1000 条 | 1 小时 | 缓存 Rerank 分数,避免重复调用 Reranker | | L3 | Rerank Cache | LRU (OrderedDict) | 1000 条 | 1 小时 | 缓存 Rerank 分数,避免重复调用 Reranker |
| L4 | Semantic Cache | FAISS IndexFlatIP | 10000 条 | 无过期 | 语义级缓存,相似查询也能命中 | | L4 | Semantic Cache | FAISS IndexFlatIP | 10000 条 | 无过期 | 语义级缓存,相似查询也能命中 |
| L5 | ChromaDB 元数据缓存 | 进程内 dict | 无限制 | 无过期 | 缓存 ChromaDB Collection 元数据kb_version 等),避免频繁查询 ChromaDB |
### 3.2 Query Cache ### 3.2 Query Cache
@@ -288,9 +293,9 @@ search_knowledge(query, top_k=30)
├─ 7. 章节过滤(查询提到章节时优先匹配) ├─ 7. 章节过滤(查询提到章节时优先匹配)
├─ 8. ★ Rerank 重排 ★(云端 DashScope qwen3-rerank 或本地 BGE ├─ 8. ★ Rerank 重排 ★(云端讯飞 xop3qwen8breranker 或本地 BGE
│ └─ rerank_results(query, results, top_k) │ └─ rerank_results(query, results, top_k)
│ └─ 由 RERANK_BACKEND 控制local/cloud/fallback │ └─ 由 RERANK_BACKEND 控制local/cloud/fallback
├─ 9. MMR 去重 ├─ 9. MMR 去重
│ ├─ 语义向量版MMR_USE_EMBEDDING=True │ ├─ 语义向量版MMR_USE_EMBEDDING=True
@@ -306,7 +311,9 @@ search_knowledge(query, top_k=30)
├─ 14. 自适应 TopK根据置信度调整返回数量 ├─ 14. 自适应 TopK根据置信度调整返回数量
─ 15. 缓存写入 → 返回结果 ─ 15. 救援管线BM25散度/词法匹配/章节聚类/表格救援)
└─ 16. 缓存写入 → 返回结果
``` ```
### 5.2 混合检索代码示例 ### 5.2 混合检索代码示例
@@ -330,7 +337,7 @@ image_results = collection.query(
# RRF 融合(动态权重) # RRF 融合(动态权重)
fused = reciprocal_rank_fusion([vector_results, bm25_results], weights=[vector_w, bm25_w]) fused = reciprocal_rank_fusion([vector_results, bm25_results], weights=[vector_w, bm25_w])
# Rerank 重排(云端 DashScope qwen3-rerank 或本地 BGE # Rerank 重排(云端讯飞 xop3qwen8breranker 或本地 BGE
reranked = rerank_results(query, fused, top_k=15) reranked = rerank_results(query, fused, top_k=15)
# MMR 去重 # MMR 去重
@@ -359,7 +366,7 @@ RRF分数 = Σ (权重 / (k + 排名位置))
| RERANK_BACKEND | 说明 | | RERANK_BACKEND | 说明 |
|----------------|------| |----------------|------|
| `"cloud"` | 仅使用云端 DashScope `qwen3-rerank` API | | `"cloud"` | 仅使用云端讯飞 `xop3qwen8breranker` API |
| `"local"` | 仅使用本地 `BAAI/bge-reranker-base`CrossEncoder / ONNX | | `"local"` | 仅使用本地 `BAAI/bge-reranker-base`CrossEncoder / ONNX |
| `"fallback"` | 优先云端,失败时自动回退本地(推荐生产环境) | | `"fallback"` | 优先云端,失败时自动回退本地(推荐生产环境) |
@@ -368,13 +375,13 @@ RRF分数 = Σ (权重 / (k + 排名位置))
```python ```python
# config.py # config.py
RERANK_BACKEND = os.getenv("RERANK_BACKEND", "local") 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_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 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备选** **本地 Reranker备选**
@@ -424,12 +431,16 @@ POST /rag (SSE 流式)
├─ 5. 图片补充检索 + 图片打分选择 (select_images) ├─ 5. 图片补充检索 + 图片打分选择 (select_images)
│ └─[DEV] 发 SSE: images_selected │ └─[DEV] 发 SSE: images_selected
├─ 6. 构建上下文 (_order_texts_for_prompt) ├─ 6. 构建上下文 (_order_texts_for_prompt)
│ └─ chart_contexts 使用 min_score * 0.5 降门槛
│ └─[DEV] 发 SSE: context_built │ └─[DEV] 发 SSE: context_built
├─ 6.5. P0 安全网 — 确保 selected_images 描述完整进入 LLM 上下文
├─ 7. 流式答案生成 engine.generate_answer_stream() ├─ 7. 流式答案生成 engine.generate_answer_stream()
│ └─ 逐 token 发 SSE: chunk │ └─ 逐 token 发 SSE: chunk
├─ 8. 答案图号对齐过滤 ├─ 8. 答案图号对齐过滤
├─ 8.5. 答案后过滤 (_filter_images_by_answer) — 根据 LLM 答案过滤不相关图片
├─ 9. 引用标注 _attach_citations() ├─ 9. 引用标注 _attach_citations()
├─ 10. 敏感信息过滤 filter_response() ├─ 10. 敏感信息过滤 filter_response()
├─ 11. 语义缓存写入 SemanticCache.set() # 写入缓存供后续命中 ├─ 11. 语义缓存写入 SemanticCache.set() # 写入缓存供后续命中
@@ -454,6 +465,76 @@ POST /rag (SSE 流式)
> 标注 [DEV] 的事件仅在 `IS_DEV=True` 时发送。 > 标注 [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 调用方式 ## 七、API 调用方式
@@ -530,14 +611,24 @@ curl http://localhost:5001/cache/stats \
```python ```python
# config.py # config.py
DASHSCOPE_API_KEY = "your-api-key" # 通义千问 API DASHSCOPE_API_KEY = "your-api-key" # mimo API 密钥
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" DASHSCOPE_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"
DASHSCOPE_MODEL = "qwen3.6-flash" # 文本生成模型(主力 LLM DASHSCOPE_MODEL = "mimo-v2.5" # 文本生成模型(主力 LLM
INTENT_MODEL = "qwen-turbo" # 意图分析模型(轻量快速) INTENT_MODEL = "mimo-v2.5" # 意图分析模型(轻量快速)
VLM_MODEL = "qwen-vl-plus" # 视觉语言模型(图片描述) VLM_MODEL = "mimo-v2.5" # 视觉语言模型(图片描述)
RAG_CHAT_MODEL = "qwen3.6-flash" # RAG 对话模型 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"
``` ```
> **注意**:百炼 APIDashScope额度用尽后所有 LLM 调用已统一切换至 mimo APIxiaomimimo.com。`get_intent_client()` 也使用 `DASHSCOPE_API_KEY` / `DASHSCOPE_BASE_URL`(即 mimo API不再使用百炼 API。
### 8.2 检索参数 ### 8.2 检索参数
```python ```python
@@ -553,7 +644,7 @@ RECALL_MULTIPLIER = 3 # 候选池最小倍数
# 重排序 # 重排序
USE_RERANK = True # 启用重排序 USE_RERANK = True # 启用重排序
RERANK_BACKEND = "local" # "local"=本地模型, "cloud"=云端API, "fallback"=优先云端失败回退本地 RERANK_BACKEND = "local" # "local"=本地模型, "cloud"=云端API, "fallback"=优先云端失败回退本地
RERANK_CLOUD_MODEL = "qwen3-rerank" RERANK_CLOUD_MODEL = "xop3qwen8breranker"
RERANK_CANDIDATES = 20 # 送入重排序的候选数 RERANK_CANDIDATES = 20 # 送入重排序的候选数
RERANK_TOP_K = 15 # 重排序后保留数 RERANK_TOP_K = 15 # 重排序后保留数
RERANK_USE_ONNX = True # ONNX 加速(仅本地模式,环境变量控制) RERANK_USE_ONNX = True # ONNX 加速(仅本地模式,环境变量控制)
@@ -701,10 +792,10 @@ config/ # 运行时配置
| 检索方式 | 单一向量检索 | 向量 + BM25 + FAQ + 图片独立召回 | | 检索方式 | 单一向量检索 | 向量 + BM25 + FAQ + 图片独立召回 |
| 融合算法 | 无 | RRF 动态权重融合 | | 融合算法 | 无 | RRF 动态权重融合 |
| 去重 | 无 | MMR 语义去重 | | 去重 | 无 | MMR 语义去重 |
| 重排序 | 无 | 云端 qwen3-rerank API支持本地 BGE 回退) | | 重排序 | 无 | 云端 xop3qwen8breranker API讯飞云,支持本地 BGE 回退) |
| 问题分解 | 无 | 自动拆分对比/推理类查询 | | 问题分解 | 无 | 自动拆分对比/推理类查询 |
| 闲聊处理 | 无 | 意图分析自动判断 | | 闲聊处理 | 无 | 意图分析自动判断 |
| 缓存体系 | 无 | 层缓存Query + Embedding + Rerank + 语义缓存) | | 缓存体系 | 无 | 层缓存Query + Embedding + Rerank + 语义缓存 + 元数据缓存 |
| 语义缓存 | 无 | FAISS 向量索引相似查询复用92x 加速) | | 语义缓存 | 无 | FAISS 向量索引相似查询复用92x 加速) |
| 自适应 TopK | 固定 top_k | 根据置信度动态调整 | | 自适应 TopK | 固定 top_k | 根据置信度动态调整 |
| 上下文理解 | 无 | 多轮对话 + 历史上下文 | | 上下文理解 | 无 | 多轮对话 + 历史上下文 |
@@ -736,9 +827,9 @@ Rerank 在生产流程中有 **一个调用路径**
|--------|--------|------| |--------|--------|------|
| `USE_RERANK` | `True` | 总开关 | | `USE_RERANK` | `True` | 总开关 |
| `RERANK_BACKEND` | `"local"` | 后端选择 | | `RERANK_BACKEND` | `"local"` | 后端选择 |
| `RERANK_CLOUD_MODEL` | `"qwen3-rerank"` | 云端模型名称 | | `RERANK_CLOUD_MODEL` | `"xop3qwen8breranker"` | 云端模型名称 |
| `RERANK_CLOUD_API_KEY` | 同 `DASHSCOPE_API_KEY` | 云端 API 密钥 | | `RERANK_CLOUD_API_KEY` | 同 `DASHSCOPE_API_KEY` | 云端 API 密钥 |
| `RERANK_CLOUD_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-api/v1/reranks` | 云端 API 地址 | | `RERANK_CLOUD_BASE_URL` | `https://maas-api.cn-huabei-1.xf-yun.com/v1/rerank` | 云端 API 地址(讯飞云) |
| `RERANK_CLOUD_TIMEOUT` | `15` | 云端请求超时(秒) | | `RERANK_CLOUD_TIMEOUT` | `15` | 云端请求超时(秒) |
| `RERANK_MODEL_PATH` | `models/bge-reranker-base` | 本地模型路径 | | `RERANK_MODEL_PATH` | `models/bge-reranker-base` | 本地模型路径 |
| `RERANK_CANDIDATES` | `20` | 送入 Rerank 的候选数 | | `RERANK_CANDIDATES` | `20` | 送入 Rerank 的候选数 |
@@ -807,6 +898,26 @@ print({"hits": sc.hits, "misses": sc.misses, "total": sc.total_entries})
## 十三、演进记录 ## 十三、演进记录
### v4.12026-06-21— 模型切换 + 图片检索修复
**模型统一切换**:百炼 APIDashScope额度用尽后所有 LLM 调用统一切换至 mimo APIxiaomimimo.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.02026-06-05— 统一编排 + 四层缓存修复 ### v4.02026-06-05— 统一编排 + 四层缓存修复
**删除未使用的备用编排路径**:移除了 `core/agentic.py` 及 8 个 Mixin 文件(共 10 个文件 ~2050 行)。这些文件实现了完整的决策循环编排(含置信度门控、质量评估、推理反思等),但从未接入任何 HTTP 路由。 **删除未使用的备用编排路径**:移除了 `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/管线更新 ### 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 缓存外部化 ### Redis 缓存外部化
当前层缓存均为进程内内存存储,在多 Worker / 多实例部署时无法共享。已规划 Redis 迁移方案(详见 `reports/redis_migration_plan.md`),核心设计: 当前层缓存均为进程内内存存储L5 ChromaDB 元数据缓存除外),在多 Worker / 多实例部署时无法共享。已规划 Redis 迁移方案(详见 `reports/redis_migration_plan.md`),核心设计:
- `RedisCacheManager` 提供与 `RAGCacheManager` 相同的接口 - `RedisCacheManager` 提供与 `RAGCacheManager` 相同的接口
- 通过 `REDIS_CACHE_URL` 环境变量启用,向后兼容 - 通过 `REDIS_CACHE_URL` 环境变量启用,向后兼容

View File

@@ -4,7 +4,7 @@
> >
> 测试日期2026-06-10最近更新| 生产服务器:`47.116.16.222` | 服务地址:`http://127.0.0.1:5001` > 测试日期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 | | MMR 文本相似度 | `MMR_USE_EMBEDDING=false` | MMR 阶段从 ~36s 降至 ~0s |
### 耗时拆解(优化后 /rag 问答) ### 耗时拆解(优化后 /rag 问答)
@@ -2126,7 +2126,7 @@ curl -s -X POST "http://localhost:5001/collections/public_kb/reindex"
└── 流式 token 生成 ~11s └── 流式 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 配置 ### Reranker 配置
生产环境使用 DashScope 云端 Reranker 替代本地 CPU 推理,显著提升检索速度: 生产环境使用讯飞云 Reranker 替代本地 CPU 推理,显著提升检索速度:
| 配置项 | 值 | 说明 | | 配置项 | 值 | 说明 |
|--------|-----|------| |--------|-----|------|
| `RERANK_BACKEND` | `cloud` | 使用云端 API可选 `local` / `cloud` / `fallback` | | `RERANK_BACKEND` | `cloud` | 使用云端 API可选 `local` / `cloud` / `fallback` |
| `RERANK_CLOUD_MODEL` | `qwen3-rerank` | DashScope 云端排序模型 | | `RERANK_CLOUD_MODEL` | `xop3qwen8breranker` | 讯飞云排序模型 |
| `RERANK_CLOUD_API_KEY` | `sk-*` | DashScope 标准 API Key | | `RERANK_CLOUD_API_KEY` | `sk-*` | API Key |
| `RERANK_CLOUD_BASE_URL` | `https://dashscope.aliyuncs.com/compatible-api/v1/reranks` | OpenAI 兼容端点 | | `RERANK_CLOUD_BASE_URL` | `https://maas-api.cn-huabei-1.xf-yun.com/v1/rerank` | 讯飞云 Rerank 端点 |
| `MMR_USE_EMBEDDING` | `false` | MMR 使用文本相似度(零额外计算) | | `MMR_USE_EMBEDDING` | `false` | MMR 使用文本相似度(零额外计算) |
> **fallback 模式**`RERANK_BACKEND=fallback` 优先使用云端 API如果云端不可用则自动回退到本地模型适合生产环境高可用场景。 > **fallback 模式**`RERANK_BACKEND=fallback` 优先使用云端 API如果云端不可用则自动回退到本地模型适合生产环境高可用场景。

View 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+ tokenscontent 为空
**当前状态**: 关闭推理模式后本次测试通过,但**开启推理模式会再次失败**
**建议**: 基础 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 | 题目多样性控制 | 中等 | 出题质量 |

View File

@@ -138,19 +138,22 @@
#### 4a. 去重 #### 4a. 去重
`_deduplicate_questions(questions, exclude_stems)`层去重: `_deduplicate_questions(questions, exclude_stems)`层去重:
1. **题干前缀去重**:题干前 80 字相同 → 去掉。 1. **题干前缀去重**:题干前 80 字相同 → 去掉。
2. **知识点+题型去重**:题干前 30 字 + 题型相同 → 去掉 2. **跨调用排除**`_matches_exclude``exclude_stems` 中已有题目的前缀≤30 字),新题干前 30 字如果以此为前缀 → 去掉。用 `startswith()` 匹配,解决排除题干短于 30 字时的长度不匹配问题
3. **跨调用去重**`exclude_stems` 中已有题目的题干前 80 字预填入去重集合,新生成的题目如果与之冲突也会被过滤 3. **知识点+题型去重**:题干前 30 字 + 题型相同 → 去掉
4. **跨调用题干去重**`exclude_stems` 中已有题目的题干前 80 字预填入去重集合,精确匹配过滤。
#### 4b. 题型平衡 #### 4b. 题型平衡
`_balance_question_types(questions, target_types)` — 按题型分组,每种题型按目标数量截取(多了截断)。 `_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 安全解析 | | `safe_parse_questions` | generator.py | JSON 安全解析 |
| `validate_questions_schema` | generator.py | 题目 Schema 校验 | | `validate_questions_schema` | generator.py | 题目 Schema 校验 |
| `_enrich_with_source_trace` | generator.py | 补充溯源信息 | | `_enrich_with_source_trace` | generator.py | 补充溯源信息 |
| `_deduplicate_questions` | generator.py | 层去重 | | `_deduplicate_questions` | generator.py | 层去重 |
| `_balance_question_types` | generator.py | 题型数量平衡 | | `_balance_question_types` | generator.py | 题型数量平衡 |
| `_generate_questions_fallback` | generator.py | 降级路径(无知识点时) | | `_generate_questions_fallback` | generator.py | 降级路径(无知识点时) |
| `_makeup_questions` | generator.py | v1 补题机制 | | `_makeup_questions` | generator.py | v1 补题机制 |

View File

@@ -312,11 +312,11 @@ pip install -r requirements.txt
```python ```python
# config.py - 必需配置 # config.py - 必需配置
# 通义千问 API必需 # LLM API必需
DASHSCOPE_API_KEY = "your-api-key" DASHSCOPE_API_KEY = "your-api-key"
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" DASHSCOPE_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"
DASHSCOPE_MODEL = "qwen-flash" # 文本模型 DASHSCOPE_MODEL = "mimo-v2.5" # 文本模型
DASHSCOPE_VL_MODEL = "qwen-vl-plus" # 视觉模型(图片描述) VLM_MODEL = "mimo-v2.5" # 视觉模型(图片描述)
# 兼容变量 # 兼容变量
API_KEY = DASHSCOPE_API_KEY API_KEY = DASHSCOPE_API_KEY

View File

@@ -84,9 +84,9 @@ ls models/bge-base-zh-v1.5/
```python ```python
# API配置 # API配置
DASHSCOPE_API_KEY = "your-api-key" DASHSCOPE_API_KEY = "your-api-key"
DASHSCOPE_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1" DASHSCOPE_BASE_URL = "https://token-plan-cn.xiaomimimo.com/v1"
DASHSCOPE_MODEL = "qwen3.6-flash" # 主 LLM文本生成 / RAG 对话) DASHSCOPE_MODEL = "mimo-v2.5" # 主 LLM文本生成 / RAG 对话)
INTENT_MODEL = "qwen-turbo" # 意图分析模型(轻量、确定性高) INTENT_MODEL = "mimo-v2.5" # 意图分析模型
``` ```
> **注意**: Graph RAGNeo4j功能已废弃相关配置NEO4J_URI、USE_GRAPH_RAG 等)已移除。 > **注意**: Graph RAGNeo4j功能已废弃相关配置NEO4J_URI、USE_GRAPH_RAG 等)已移除。

View File

@@ -246,6 +246,7 @@ def api_generate_smart():
"file_path": "public/产品手册.pdf", "file_path": "public/产品手册.pdf",
"collection": "public_kb", "collection": "public_kb",
"difficulty": 3, // 可选,默认 3 "difficulty": 3, // 可选,默认 3
"max_total": 20, // 可选AI出题总数上限。不传则不限制
"options": {} // 可选 "options": {} // 可选
} }
@@ -267,6 +268,16 @@ def api_generate_smart():
if diff_error: if diff_error:
return error_response("INVALID_PARAMS", BAD_REQUEST, diff_error, http_status=400) 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') exclude_stems = data.get('exclude_stems')
stems_error = validate_exclude_stems(exclude_stems) stems_error = validate_exclude_stems(exclude_stems)
@@ -298,11 +309,11 @@ def api_generate_smart():
f"AI智能出题: {os.path.basename(file_path)}" 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 智能出题""" """后台执行 AI 智能出题"""
registry.update_progress(task.id, stage='AI分析', message='正在分析文档内容...') registry.update_progress(task.id, stage='AI分析', message='正在分析文档内容...')
from exam_pkg.manager import analyze_file_for_exam 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', {}) q_types = ai_analysis.get('question_types', {})
if not q_types or sum(q_types.values()) == 0: if not q_types or sum(q_types.values()) == 0:
@@ -325,7 +336,8 @@ def api_generate_smart():
task.id, _do_smart_generate, task.id, _do_smart_generate,
file_path, collection, file_path, collection,
data.get('difficulty', 3), data.get('options', {}), 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( return success_response(

View File

@@ -26,19 +26,38 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# 导入 LLM 工具函数 # 导入 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 配置 # 导入 LLM 配置
try: 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 LLM_AVAILABLE = True
except ImportError: except ImportError:
API_KEY = None API_KEY = None
BASE_URL = None BASE_URL = None
MODEL = None MODEL = None
LLM_TEMPERATURE = 0.7
LLM_MAX_TOKENS = 4000
LLM_AVAILABLE = False 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]]: 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 = [] validated = []
for q in questions: for q in questions:
# 必须有 type # 必须有 type 或 question_type
if q.get('type') not in VALID_TYPES: q_type = q.get('question_type') or q.get('type')
if q_type not in VALID_TYPES:
continue continue
# 必须有 content # 必须有 content
@@ -145,7 +165,7 @@ def validate_questions_schema(questions: List[Dict]) -> List[Dict]:
continue continue
# 选项题必须有 options # 选项题必须有 options
if q['type'] in ['single_choice', 'multiple_choice']: if q_type in ['single_choice', 'multiple_choice']:
if not content.get('data', {}).get('options'): if not content.get('data', {}).get('options'):
continue continue
@@ -359,8 +379,8 @@ class QuestionGenerator:
client=self.client, client=self.client,
prompt=prompt, prompt=prompt,
model=self.model, model=self.model,
temperature=0.7, temperature=LLM_TEMPERATURE,
max_tokens=4000, max_tokens=_get_effective_max_tokens(4000, self.model),
messages=messages messages=messages
) )
if not content: if not content:
@@ -493,25 +513,37 @@ class QuestionGenerator:
return type_names.get(q_type, q_type) return type_names.get(q_type, q_type)
def _call_llm(self, prompt: str) -> str: def _call_llm(self, prompt: str) -> str:
"""调用本地 LLMOpenAI 兼容接口)""" """调用本地 LLMOpenAI 兼容接口),支持 429 限流重试"""
if not self.client: if not self.client:
raise ValueError("LLM 客户端未初始化,请检查 config.py 中的 API_KEY 配置") raise ValueError("LLM 客户端未初始化,请检查 config.py 中的 API_KEY 配置")
import time
messages = [ messages = [
{"role": "system", "content": "你是一个专业的出题专家擅长根据文档内容生成各类考试题目。你必须严格按照JSON格式输出不要有任何其他内容。"}, {"role": "system", "content": "你是一个专业的出题专家擅长根据文档内容生成各类考试题目。你必须严格按照JSON格式输出不要有任何其他内容。"},
{"role": "user", "content": prompt} {"role": "user", "content": prompt}
] ]
result = call_llm(
client=self.client, # 重试机制429 限流时指数退避(最多重试 3 次)
prompt=prompt, for attempt in range(4):
model=self.model, result = call_llm(
temperature=0.7, client=self.client,
max_tokens=4000, prompt=prompt,
messages=messages model=self.model,
) temperature=LLM_TEMPERATURE,
if result is None: max_tokens=_get_effective_max_tokens(4000, self.model),
raise Exception("LLM 调用失败") messages=messages
return result )
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: def _get_format_examples(self) -> str:
"""返回各题型格式示例(覆盖全部 5 种题型)""" """返回各题型格式示例(覆盖全部 5 种题型)"""
@@ -610,7 +642,6 @@ class QuestionGenerator:
) )
# 清理纯标点符号 # 清理纯标点符号
import re
all_content = re.sub(r'^[\s\*\-\d\.。、,::;]+$', '', all_content, flags=re.MULTILINE) all_content = re.sub(r'^[\s\*\-\d\.。、,::;]+$', '', all_content, flags=re.MULTILINE)
all_content = all_content.strip() all_content = all_content.strip()
@@ -645,27 +676,24 @@ class QuestionGenerator:
请直接输出 JSON 数组:""" 请直接输出 JSON 数组:"""
try: for _attempt in range(2):
response = self._call_llm(prompt) try:
response = self._call_llm(prompt)
if not response or not response.strip():
continue
# 清理响应(移除可能的 markdown 标记 # 使用多策略 JSON 提取(支持 markdown 代码块、正则回退
response = response.strip() result = extract_json_list(response)
if response.startswith('```'): if isinstance(result, list) and result:
lines = response.split('\n') return [
response = '\n'.join(lines[1:-1] if lines[-1] == '```' else lines[1:]) {"name": kp, "section": section}
for kp in result[:max_points]
# 解析 JSON if isinstance(kp, str) and 3 <= len(kp) <= 30
result = json.loads(response) ]
if isinstance(result, list): # 解析结果无效,重试一次
return [ logger.warning(f" 知识点提取返回无效结果(尝试 {_attempt+1}/2),重试中...")
{"name": kp, "section": section} except Exception as e:
for kp in result[:max_points] logger.error(f" 知识点提取失败(尝试 {_attempt+1}/2): {e}")
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}")
return [] return []
@@ -918,7 +946,7 @@ def generate_questions_from_content(
return generator.generate_questions_structured(chunks, document_name, question_types, difficulty) 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 智能分析文档内容,决定适合的题型和数量 AI 智能分析文档内容,决定适合的题型和数量
@@ -1026,14 +1054,14 @@ def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
try: try:
response = generator._call_llm(prompt) response = generator._call_llm(prompt)
response = response.strip() if not response or not response.strip():
return _generate_default_question_types(total_knowledge_points)
# 清理 markdown 代码块 # 使用多策略 JSON 提取(支持 markdown 代码块、正则回退)
if response.startswith('```'): result = extract_json_object(response)
lines = response.split('\n') if not isinstance(result, dict):
response = '\n'.join(lines[1:-1] if lines[-1] == '```' else lines[1:]) logger.error(f"AI 分析返回非对象类型: {type(result)}")
return _generate_default_question_types(total_knowledge_points)
result = json.loads(response)
# 验证和清理结果 # 验证和清理结果
valid_types = ['single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective'] valid_types = ['single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective']
@@ -1046,6 +1074,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] 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 { return {
"total_knowledge_points": result.get('total_knowledge_points', total_knowledge_points), "total_knowledge_points": result.get('total_knowledge_points', total_knowledge_points),
"suitable_types": suitable_types, "suitable_types": suitable_types,
@@ -1053,10 +1099,6 @@ def analyze_document_for_exam(chunks: List[Dict]) -> Dict[str, Any]:
"reason": result.get('reason', 'AI 分析完成') "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: except Exception as e:
logger.error(f"AI 分析失败: {e}") logger.error(f"AI 分析失败: {e}")
return _generate_default_question_types(total_knowledge_points) return _generate_default_question_types(total_knowledge_points)
@@ -1156,6 +1198,10 @@ def generate_questions_structured_v2(
# 提取知识点 # 提取知识点
kps = generator._extract_knowledge_points(section, section_chunks, max_points=3) kps = generator._extract_knowledge_points(section, section_chunks, max_points=3)
# API 限流保护:连续 LLM 调用间隔 1 秒
import time
time.sleep(1)
# 全局去重 # 全局去重
for kp in kps: for kp in kps:
kp_name = kp['name'] kp_name = kp['name']
@@ -1219,6 +1265,10 @@ def generate_questions_structured_v2(
prompt, {q_type: 1}, kp_chunks, document_name, max_retries=2 prompt, {q_type: 1}, kp_chunks, document_name, max_retries=2
) )
# API 限流保护:连续 LLM 调用间隔 1 秒
import time
time.sleep(1)
if success and questions: if success and questions:
all_questions.extend(questions) all_questions.extend(questions)
else: else:
@@ -1241,6 +1291,28 @@ def generate_questions_structured_v2(
type_counts[q.get('question_type')] += 1 type_counts[q.get('question_type')] += 1
logger.info(f" 题型分布: {dict(type_counts)}") 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)}")
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=exclude_stems)
final.extend(extra_deduped[:shortage])
# 补题后重新统计
type_counts = defaultdict(int)
for q in final:
type_counts[q.get('question_type')] += 1
logger.info(f" 补题后题型分布: {dict(type_counts)}")
return final return final
@@ -1404,9 +1476,27 @@ def _deduplicate_questions(questions: List[Dict], exclude_stems: List[str] = Non
# 预填已有题目的题干前缀,使新生成的题目与已有题目冲突时被过滤 # 预填已有题目的题干前缀,使新生成的题目与已有题目冲突时被过滤
if exclude_stems: if exclude_stems:
_valid_types = ('single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'subjective')
for stem in exclude_stems: for stem in exclude_stems:
seen_stems.add(stem[:80]) 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 = [] deduped = []
@@ -1419,6 +1509,10 @@ def _deduplicate_questions(questions: List[Dict], exclude_stems: List[str] = Non
if stem_key in seen_stems: if stem_key in seen_stems:
continue continue
# 跨调用排除:题干前缀匹配到 exclude_stems 则跳过
if _matches_exclude(stem):
continue
# 知识点 + 题型去重 # 知识点 + 题型去重
kp_type_key = f"{stem[:30]}_{q.get('question_type')}" kp_type_key = f"{stem[:30]}_{q.get('question_type')}"
if kp_type_key in seen_kp_type: if kp_type_key in seen_kp_type:

View File

@@ -37,6 +37,21 @@ except ImportError:
MODEL = None MODEL = None
LLM_AVAILABLE = False 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
# ==================== 装饰器 ==================== # ==================== 装饰器 ====================
@@ -169,19 +184,56 @@ def grade_fill_blank(answer: Dict) -> Dict:
def fuzzy_match(student_answer: str, correct_answer: str) -> bool: def fuzzy_match(student_answer: str, correct_answer: str) -> bool:
""" """
模糊匹配(支持同义词) 模糊匹配(支持同义词和小编辑距离容错
当前实现:精确匹配(忽略前后空格、大小写) 策略:
TODO: 可以扩展为语义相似度匹配 1. 精确匹配(去空格、转小写、统一标点)
2. 编辑距离容错≥4字答案允许≤2字符差异
""" """
if not student_answer or not correct_answer: if not student_answer or not correct_answer:
return False return False
# 标准化:去空格、转小写 # 标准化:去空格、转小写、统一标点
s = student_answer.strip().lower() def _normalize(text: str) -> str:
c = correct_answer.strip().lower() 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 类 ==================== # ==================== AnswerGrader 类 ====================
@@ -237,7 +289,21 @@ class AnswerGrader:
# 🔥 P1 改进:并发调用 LLM 批阅主观题 # 🔥 P1 改进:并发调用 LLM 批阅主观题
if llm_questions: 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 改进:按原始顺序重组结果 # 🔥 P1 改进:按原始顺序重组结果
results = [results_map.get(ans.get('question_id')) for ans in answers] results = [results_map.get(ans.get('question_id')) for ans in answers]
@@ -369,7 +435,7 @@ class AnswerGrader:
prompt=prompt, prompt=prompt,
model=self.model, model=self.model,
temperature=0.3, temperature=0.3,
max_tokens=1000, max_tokens=_get_effective_max_tokens(2000, self.model),
messages=messages messages=messages
) )
if result is None: if result is None:

View File

@@ -154,7 +154,8 @@ def generate_questions_from_file(
def analyze_file_for_exam( def analyze_file_for_exam(
file_path: str, file_path: str,
collection: str, collection: str,
top_k: int = 50 top_k: int = 50,
max_total: int = None
) -> Dict[str, Any]: ) -> Dict[str, Any]:
""" """
分析文件内容,返回 AI 推荐的题型和数量 分析文件内容,返回 AI 推荐的题型和数量
@@ -193,7 +194,7 @@ def analyze_file_for_exam(
} }
# 2. 调用 AI 分析 # 2. 调用 AI 分析
return analyze_document_for_exam(chunks) return analyze_document_for_exam(chunks, max_total=max_total)
def retrieve_file_chunks_for_analysis( def retrieve_file_chunks_for_analysis(
@@ -311,6 +312,7 @@ def retrieve_file_chunks(
engine = get_engine() engine = get_engine()
# 按优先级遍历 collections找到文件即停止 # 按优先级遍历 collections找到文件即停止
results = None
for coll in collections: for coll in collections:
# 尝试两种格式:文件名和完整路径 # 尝试两种格式:文件名和完整路径
for source_filter in [filename, file_path]: for source_filter in [filename, file_path]:
@@ -330,7 +332,7 @@ def retrieve_file_chunks(
break # 外层循环跳出 break # 外层循环跳出
chunks = [] 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( for i, (doc, meta, score) in enumerate(zip(
results['documents'][0], results['documents'][0],
results['metadatas'][0], results['metadatas'][0],

View File

@@ -331,6 +331,21 @@ class CollectionMixin:
except Exception as e: except Exception as e:
logger.warning(f"清理版本记录失败: {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", {}): if kb_name in self._metadata.get("collections", {}):
del self._metadata["collections"][kb_name] del self._metadata["collections"][kb_name]
self._save_metadata() self._save_metadata()

View File

@@ -72,6 +72,18 @@ class DocumentMixin:
except Exception as e: except Exception as e:
logger.warning(f"清理版本记录失败: {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}") logger.info(f"{kb_name} 删除文档: {filename}, 片段数: {deleted}")
return deleted return deleted

View File

@@ -25,7 +25,20 @@ def compute_file_hash(file_path: str) -> str:
return hashlib.md5(file_path.encode()).hexdigest() 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 描述 懒加载 VLM 描述
@@ -36,6 +49,7 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
image_path: 图片路径(相对路径或绝对路径) image_path: 图片路径(相对路径或绝对路径)
kb_name: 知识库名称 kb_name: 知识库名称
metadata: 图片元数据(包含 section、page、caption、上下文等 metadata: 图片元数据(包含 section、page、caption、上下文等
defer_chromadb: 为 True 时跳过 ChromaDB 更新(仅写文件缓存),避免后台线程写锁竞争
Returns: Returns:
VLM 生成的图片描述 VLM 生成的图片描述
@@ -49,23 +63,45 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
else: else:
full_image_path = image_path full_image_path = image_path
# 1. 检查缓存 # 1. 检查缓存(空缓存视为无效,需重新生成)
img_hash = compute_file_hash(full_image_path) img_hash = compute_file_hash(full_image_path)
cache_file = VLM_CACHE_DIR / f"{img_hash}.txt" cache_file = VLM_CACHE_DIR / f"{img_hash}.txt"
if cache_file.exists(): if cache_file.exists():
logger.info(f"VLM 缓存命中: {image_path}") cached = cache_file.read_text(encoding='utf-8')
return 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传入元数据 # 2. 调用 VLM传入元数据
logger.info(f"VLM 懒加载: {image_path}") logger.info(f"VLM 懒加载: {image_path}")
kb_manager = get_kb_manager() kb_manager = get_kb_manager()
description = kb_manager._generate_image_description(full_image_path, metadata=metadata) 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) VLM_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file.write_text(description, encoding='utf-8') 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: try:
collection = kb_manager.get_collection(kb_name) collection = kb_manager.get_collection(kb_name)
result = collection.get(ids=[chunk_id], include=['metadatas']) 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 描述重新计算向量) # 更新 embedding使用 VLM 描述重新计算向量)
# 这样 VLM 描述中的关键词(如"发电量")才能参与相似度检索 # 这样 VLM 描述中的关键词(如"发电量")才能参与相似度检索
embedding_model = kb_manager.embedding_model embedding_model = _get_embedding_model()
if embedding_model: if embedding_model:
new_vector = embedding_model.encode(description).tolist() new_vector = embedding_model.encode(description).tolist()
if isinstance(new_vector[0], list): 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], embeddings=[new_vector],
documents=[description] # 同时更新 document 字段 documents=[description] # 同时更新 document 字段
) )
logger.info(f"已更新向量库 embedding: {chunk_id}") logger.info(f"已更新向量库(embedding+metadata): {chunk_id}")
else: else:
# 无 embedding 模型时只更新 metadata # 无 embedding 模型时只更新 metadata
collection.update( collection.update(
ids=[chunk_id], ids=[chunk_id],
metadatas=[new_metadata] metadatas=[new_metadata]
) )
logger.info(f"已更新向量库(仅metadata,无embedding模型): {chunk_id}")
except Exception as e: except Exception as e:
logger.warning(f"更新向量库失败: {e}") logger.warning(f"更新向量库失败: {e}")
return description 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 chunk_id: 切片 ID
table_md: 表格 Markdown 内容 table_md: 表格 Markdown 内容
kb_name: 知识库名称 kb_name: 知识库名称
defer_chromadb: 为 True 时跳过 ChromaDB 更新(仅写文件缓存),避免后台线程写锁竞争
Returns: Returns:
LLM 生成的表格摘要 LLM 生成的表格摘要
""" """
from knowledge.manager import get_kb_manager from knowledge.manager import get_kb_manager
# 1. 检查缓存 # 1. 检查缓存(空缓存视为无效)
table_hash = hashlib.md5(table_md.encode()).hexdigest() table_hash = hashlib.md5(table_md.encode()).hexdigest()
cache_file = LLM_CACHE_DIR / f"{table_hash}.txt" cache_file = LLM_CACHE_DIR / f"{table_hash}.txt"
if cache_file.exists(): if cache_file.exists():
logger.info(f"LLM 缓存命中: {chunk_id}") cached = cache_file.read_text(encoding='utf-8')
return 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 # 2. 调用 LLM
logger.info(f"LLM 懒加载: {chunk_id}") logger.info(f"LLM 懒加载: {chunk_id}")
kb_manager = get_kb_manager() kb_manager = get_kb_manager()
summary = kb_manager._generate_table_summary(table_md, None) 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. 写入缓存 # 3. 写入缓存
LLM_CACHE_DIR.mkdir(parents=True, exist_ok=True) LLM_CACHE_DIR.mkdir(parents=True, exist_ok=True)
cache_file.write_text(summary, encoding='utf-8') 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: try:
collection = kb_manager.get_collection(kb_name) collection = kb_manager.get_collection(kb_name)
result = collection.get(ids=[chunk_id], include=['metadatas']) result = collection.get(ids=[chunk_id], include=['metadatas'])
if result['metadatas']: if result['metadatas']:
# 新增摘要切片 # 新增摘要切片(需要 embedding 模型)
embedding_model = kb_manager.embedding_model embedding_model = _get_embedding_model()
vector = embedding_model.encode(summary).tolist() if embedding_model:
if isinstance(vector[0], list): vector = embedding_model.encode(summary).tolist()
vector = vector[0] if isinstance(vector[0], list):
vector = vector[0]
collection.add( collection.add(
ids=[f"{chunk_id}_summary"], ids=[f"{chunk_id}_summary"],
embeddings=[vector], embeddings=[vector],
documents=[summary], documents=[summary],
metadatas=[{ metadatas=[{
**result['metadatas'][0], **result['metadatas'][0],
'is_summary': True, 'is_summary': True,
'original_doc_id': chunk_id 'original_doc_id': chunk_id
}] }]
) )
# 更新原切片标记 logger.info(f"已新增摘要切片(embedding): {chunk_id}_summary")
else:
logger.info(f"跳过摘要切片(无embedding模型): {chunk_id}")
# 更新原切片标记(不依赖 embedding 模型)
collection.update( collection.update(
ids=[chunk_id], ids=[chunk_id],
metadatas=[{**result['metadatas'][0], 'has_summary': True}] 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 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 检索后增强:按需调用 LLM/VLM
@@ -176,23 +239,24 @@ async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str):
contexts: 检索上下文列表 contexts: 检索上下文列表
query: 用户查询 query: 用户查询
kb_name: 知识库名称 kb_name: 知识库名称
defer_chromadb: 为 True 时后台线程只写文件缓存,不更新 ChromaDB避免写锁竞争
""" """
for ctx in contexts: import re
meta = ctx.get('meta', {})
chunk_type = meta.get('chunk_type', 'text')
image_path = meta.get('image_path', '')
# 图片切片:懒加载 VLM 描述 for ctx in contexts:
if chunk_type in ('image', 'chart') and not meta.get('has_vlm_desc'): try:
if image_path: meta = ctx.get('meta', {})
try: 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 字段中提取图号(上下文可能包含"见图2.5"等)
doc_text = ctx.get('doc', '') doc_text = ctx.get('doc', '')
import re
# 提取图号(从前文/后文中) # 提取图号(从前文/后文中)
figure_number = "" figure_number = ""
# 匹配 "见图2.5"、"图2.5"、"见图 2.5" 等
fig_match = re.search(r'[见如]?图\s*(\d+\.?\d*)', doc_text) fig_match = re.search(r'[见如]?图\s*(\d+\.?\d*)', doc_text)
if fig_match: if fig_match:
figure_number = fig_match.group(1) 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'), 'page': meta.get('page'),
'caption': meta.get('caption', ''), 'caption': meta.get('caption', ''),
'source': meta.get('source', ''), 'source': meta.get('source', ''),
'figure_number': figure_number, # 添加提取的图号 'figure_number': figure_number,
'doc_text': doc_text # 添加完整文档文本 'doc_text': doc_text
} }
vlm_desc = await lazy_vlm_description( vlm_desc = await lazy_vlm_description(
meta.get('id', ''), meta.get('chunk_id', ''),
image_path, image_path,
kb_name, kb_name,
metadata=image_metadata metadata=image_metadata,
defer_chromadb=defer_chromadb
) )
ctx['doc'] = vlm_desc if vlm_desc:
ctx['vlm_enhanced'] = True ctx['doc'] = vlm_desc
except Exception as e: ctx['vlm_enhanced'] = True
logger.warning(f"VLM 懒加载失败: {e}")
# 表格切片:同时处理摘要和关联图片的 VLM 描述 # 表格切片:同时处理摘要和关联图片的 VLM 描述
elif chunk_type == 'table': elif chunk_type == 'table':
doc_text = ctx.get('doc', '') doc_text = ctx.get('doc', '')
# 1. 懒加载表格摘要(高分切片) # 1. 懒加载表格摘要(高分切片)
if not meta.get('has_summary'): if not meta.get('has_summary'):
score = meta.get('score', 0) score = ctx.get('score', 0)
if score > 0.7: # 只对高相关表格生成摘要 if score > 0.7:
try:
summary = await lazy_table_summary( summary = await lazy_table_summary(
meta.get('id', ''), meta.get('chunk_id', ''),
doc_text, doc_text,
kb_name kb_name,
defer_chromadb=defer_chromadb
) )
# 摘要作为补充信息 if summary:
ctx['summary'] = summary ctx['summary'] = summary
ctx['llm_enhanced'] = True 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
# 2. 表格有关联图片时,懒加载 VLM 描述
if image_path and not meta.get('has_vlm_desc'):
# 提取表号(如 "表2.2"、"见表2.1" # 提取表号(如 "表2.2"、"见表2.1"
table_number = "" table_number = ""
# 匹配 "表2.2"、"见表2.2"、"见表 2.2" 等
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', doc_text) table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', doc_text)
if table_match: if table_match:
table_number = table_match.group(1) table_number = table_match.group(1)
# 如果 doc 中没有,尝试从 section 中提取
section = meta.get('section') or meta.get('section_path', '') section = meta.get('section') or meta.get('section_path', '')
if not table_number and section: if not table_number and section:
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', section) table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', section)
if table_match: if table_match:
table_number = table_match.group(1) table_number = table_match.group(1)
# 构建表格图片元数据
table_image_metadata = { table_image_metadata = {
'section': section, 'section': section,
'page': meta.get('page'), 'page': meta.get('page'),
'caption': meta.get('caption', ''), 'caption': meta.get('caption', ''),
'source': meta.get('source', ''), 'source': meta.get('source', ''),
'table_number': table_number, # 表号 'table_number': table_number,
'figure_number': table_number, # 兼容字段 'figure_number': table_number,
'doc_text': doc_text, 'doc_text': doc_text,
'is_table': True # 标记为表格图片 'is_table': True
} }
vlm_desc = await lazy_vlm_description( vlm_desc = await lazy_vlm_description(
meta.get('id', ''), meta.get('chunk_id', ''),
image_path, image_path,
kb_name, kb_name,
metadata=table_image_metadata metadata=table_image_metadata,
defer_chromadb=defer_chromadb
) )
# 表格图片描述作为补充信息 if vlm_desc:
ctx['image_description'] = vlm_desc ctx['image_description'] = vlm_desc
ctx['vlm_enhanced'] = True ctx['vlm_enhanced'] = True
except Exception as e:
logger.warning(f"表格图片 VLM 懒加载失败: {e}") except Exception as e:
chunk_id = ctx.get('meta', {}).get('chunk_id', '?')
logger.warning(f"增强切片失败(chunk_id={chunk_id}): {e}")

View File

@@ -39,6 +39,7 @@ from pathlib import Path
import logging import logging
import chromadb import chromadb
from bs4 import BeautifulSoup
# 从 base.py 导入基础类和常量 # 从 base.py 导入基础类和常量
from .base import ( from .base import (
@@ -550,8 +551,39 @@ class KnowledgeBaseManager(
curr_html = getattr(current, 'table_html', '') or '' curr_html = getattr(current, 'table_html', '') or ''
next_html = getattr(next_chunk, 'table_html', '') or '' next_html = getattr(next_chunk, 'table_html', '') or ''
if curr_html and next_html: if curr_html and next_html:
# 合并两个表格的 HTML # 正确合并两个表格的 HTML
current.table_html = curr_html + '\n' + next_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')
# 跳过与第一个表格表头重复的行
# 对比第一行而非所有 thfind_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 # 合并 image_path 和嵌入图片到 images
curr_img = getattr(current, 'image_path', None) curr_img = getattr(current, 'image_path', None)
@@ -622,7 +654,7 @@ class KnowledgeBaseManager(
try: try:
from config import get_llm_client, DASHSCOPE_MODEL from config import get_llm_client, DASHSCOPE_MODEL
client = get_llm_client() client = get_llm_client()
summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=512) summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=2048)
return summary.strip() if summary else "" return summary.strip() if summary else ""
except Exception as e: except Exception as e:
logger.warning(f"生成表格摘要失败: {e}") logger.warning(f"生成表格摘要失败: {e}")
@@ -681,13 +713,31 @@ class KnowledgeBaseManager(
] ]
} }
], ],
max_tokens=512 max_tokens=2048 # mimo-v2.5 推理模型思考链消耗 ~1000 token需留足输出空间
) )
description = response.choices[0].message.content 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 hashlib
import re as _re
img_hash = hashlib.md5(img_path.read_bytes()).hexdigest() img_hash = hashlib.md5(img_path.read_bytes()).hexdigest()
cache_dir = Path('.data/cache/vlm') cache_dir = Path('.data/cache/vlm')
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)