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
This commit is contained in:
lacerate551
2026-06-20 19:26:40 +08:00
parent ee5295cc97
commit 5ba0d782e2
8 changed files with 3402 additions and 3028 deletions

View File

@@ -1352,23 +1352,46 @@ def score_image_relevance(query: str, meta: Dict, doc: str = '') -> float:
return score
def _filter_images_by_answer(selected_images: List[Dict], answer: str) -> List[Dict]:
"""
后置图片过滤:根据 LLM 生成的回答内容反向筛选图片。
# 图片意图关键词:用于判断查询/回答是否需要图片
# 集中管理,便于后续新增文档类型时扩展
只有图片描述与回答内容有足够关键词重叠时才保留,
确保展示的图片与回答内容一致,避免不相关图片干扰用户。
# 查询侧关键词(宽松):用户查询中出现这些词表示想看图片
_FIGURE_QUERY_KEYWORDS = frozenset([
'', '', '照片', '图表', '如图', '见表', '示意图',
'展示', '示意', '外观', '实物', '结构', '流程图', '架构图',
])
# 回答侧关键词严格LLM 回答中出现这些词表示引用了图片
# 不含单字"图""表""力图""企图""表达"等领域词误触发),改用正则图号检测兜底
# 不含"图片"(定义类查询的 LLM 回答也会泛化提到"图片",导致负面用例误放行)
_FIGURE_ANSWER_KEYWORDS = frozenset([
'照片', '图表', '如图', '见图', '见表', '示意图',
'流程图', '架构图', '结构图', '实物图', '外观图',
])
def _filter_images_by_answer(selected_images: List[Dict], answer: str, query: str = "",
primary_sections: List[str] = None) -> List[Dict]:
"""
后置图片过滤:查询主题一致性校验 + 无图意图早退 + 子章节级过滤。
确保展示的图片与查询/回答主题一致,过滤 section_cluster_boost 过度召回的
不相关章节图片。
过滤规则:
1. 从回答中提取 2 字及以上的中文关键词jieba 分词
2. 对每张图片检查其描述full_description / description中匹配了多少关键词
3. 匹配数 >= 阈值则保留,否则丢弃
4. 如果过滤后图片为 0保留分数最高的 1 张(兜底)
5. 用户明确指定图号(如图 2.1)时不过滤
1. 候选 <= 1 张时直接返回(无需过滤
2. 无图意图早退:回答和查询都不含图片引用词 → 返回空
3. 图号豁免:图片描述包含回答引用的具体图号/表号 → 无条件保留
4. 主题一致性:合并查询+回答关键词,图片描述重叠 >= 阈值才保留
- 子章节惩罚:图片子章节与主要检索章节不一致时,阈值 +2
5. 兜底:过滤后为空且有图片意图 → 保留分数最高的 1 张
Args:
selected_images: select_images 返回的候选图片列表
answer: LLM 生成的回答文本
query: 用户查询(拼接 retrieval_query + message覆盖改写丢失的关键词
primary_sections: 主要检索结果的 section_path 列表(来自 top 文本切片),
用于子章节级过滤。为空时跳过子章节惩罚,行为与原版一致。
Returns:
过滤后的图片列表
@@ -1376,28 +1399,81 @@ def _filter_images_by_answer(selected_images: List[Dict], answer: str) -> List[D
if not selected_images or len(selected_images) <= 1:
return selected_images
# 如果回答中提到了具体图号,说明 LLM 认为这些图是相关的,不过滤
import re
if re.search(r'\s*\d+\.?\d*', answer):
return selected_images
# 回答中提取关键词
# 提取回答中引用的图号/表号(用于精确豁免,不再一刀切绕过)
_mentioned_refs = set()
_mentioned_refs.update(re.findall(r'(?:[见如])?图\s*(\d+[\.\-]?\d*)', answer))
_mentioned_refs.update(re.findall(r'(?:[见如])?表\s*(\d+[\.\-]?\d*)', answer))
# 前置无图意图判断:回答和查询都不需要图片时,直接返回空
# 解决定义/原则类查询case 18/19领域关键词导致图片描述高重叠的问题
_has_figure_in_answer = any(kw in answer for kw in _FIGURE_ANSWER_KEYWORDS)
if not _has_figure_in_answer:
_has_figure_in_answer = bool(re.search(r'[图表]\s*\d+', answer))
_has_figure_in_query = any(kw in query for kw in _FIGURE_QUERY_KEYWORDS)
if not _has_figure_in_query:
_has_figure_in_query = bool(re.search(r'[图表]\s*\d+', query))
if not _has_figure_in_answer and not _has_figure_in_query:
logger.info(f"[图片后置过滤] 无图意图前置检查:回答和查询均不含图片意图,返回空 "
f"(候选 {len(selected_images)} 张)")
return []
# 合并查询+回答关键词:查询提供主题焦点,回答提供细节补充
# 解决纯回答关键词在长回答500+字)场景下区分度不足的问题
try:
import jieba
answer_keywords = set(
w for w in jieba.lcut(answer)
_combined_text = f"{query} {answer}" if query else answer
_keywords = set(
w for w in jieba.lcut(_combined_text)
if len(w) >= 2 and re.search(r'[\u4e00-\u9fff]', w)
)
except ImportError:
# jieba 不可用时回退到 bigram
chars = re.findall(r'[\u4e00-\u9fff]', answer)
answer_keywords = set(chars[i] + chars[i + 1] for i in range(len(chars) - 1))
_chars = re.findall(r'[\u4e00-\u9fff]', f"{query} {answer}")
_keywords = set(_chars[i] + _chars[i + 1] for i in range(len(_chars) - 1))
if not answer_keywords:
if not _keywords:
return selected_images
# 动态阈值:回答关键词越多,阈值越高(至少匹配 15% 或 2 个关键词,取较小值)
threshold = max(2, min(3, round(len(answer_keywords) * 0.15)))
threshold = max(2, min(4, round(len(_keywords) * 0.10)))
# === 子章节级过滤:从主要检索结果提取主题章节,用于区分同父章节不同子主题的图片 ===
def _leaf_section_name(section_path: str) -> str:
"""提取 section_path 的叶子节点名称(去编号前缀)"""
if not section_path:
return ''
parts = [p.strip() for p in section_path.split('>') if p.strip()]
leaf = parts[-1] if parts else ''
# 去掉编号前缀:(2)、、2.3、第1章、一、 等
return re.sub(
r'^(?:\(\d+\)\s*|[一二三四五六七八九十]+\s*|\d+[\.\d]*\s*|'
r'\s*\d+\s*章\s*|[一二三四五六七八九十]+、\s*)', '', leaf
).strip()
def _section_leaf_match(img_leaf: str, primary_name: str) -> bool:
"""子章节名称匹配:长名称用子串包含,短名称(<3字只做精确匹配"""
if not img_leaf or not primary_name:
return False
if img_leaf == primary_name:
return True
if len(img_leaf) >= 3 and len(primary_name) >= 3:
return primary_name in img_leaf or img_leaf in primary_name
return False
# 构建主要子章节名称集合(从 top 文本切片的 section_path 提取)
_primary_leaf_names = set()
if primary_sections:
for ps in primary_sections:
name = _leaf_section_name(ps)
if name and len(name) >= 2:
_primary_leaf_names.add(name)
# 检索发散检测:主要子章节超过 3 个说明 section_cluster_boost 范围过宽
# 子章节惩罚退化为无效(几乎所有图片都能匹配某个 primary section
# 此时对所有图片统一加严阈值,避免不相关图片全部通过
_scattered_bonus = 1 if len(_primary_leaf_names) > 3 else 0
filtered = []
for img in selected_images:
@@ -1407,18 +1483,45 @@ def _filter_images_by_answer(selected_images: List[Dict], answer: str) -> List[D
filtered.append(img)
continue
# 计算描述与回答的关键词重叠数
overlap = sum(1 for kw in answer_keywords if kw in desc)
if overlap >= threshold:
# 图号精确豁免:图片描述包含回答引用的具体图号/表号,无条件保留
# 防御性逻辑:与 P0 答案对齐互补P0 用 description 100字截断此处用 full_description
if _mentioned_refs:
_ref_matched = False
for ref in _mentioned_refs:
_ref_norm = ref.replace('-', '.')
if (f"{_ref_norm}" in desc or f"{_ref_norm}" in desc or
f"{_ref_norm}" in desc or f"{_ref_norm}" in desc):
_ref_matched = True
break
if _ref_matched:
filtered.append(img)
continue
# 主题一致性过滤:图片描述与查询+回答关键词重叠 >= 阈值才保留
overlap = sum(1 for kw in _keywords if kw in desc)
# 阈值计算:基础阈值 + 检索发散加严 + 子章节惩罚
effective_threshold = threshold + _scattered_bonus
if _primary_leaf_names:
img_section_path = img.get('section_path', '')
img_leaf = _leaf_section_name(img_section_path)
if img_leaf and not any(_section_leaf_match(img_leaf, pn) for pn in _primary_leaf_names):
effective_threshold += 2
if overlap >= effective_threshold:
filtered.append(img)
# 兜底:如果过滤后为空,保留分数最高的 1 张
# 兜底:过滤后为空且有图片意图时,保留分数最高的 1 张
# 注意:无图意图场景已在上方早退(返回 []),此处兜底仅在有图意图时生效
if not filtered and selected_images:
filtered = [max(selected_images, key=lambda x: x.get('score', 0))]
if len(filtered) < len(selected_images):
_sub_info = f", 子章节 {len(_primary_leaf_names)}" if _primary_leaf_names else ""
_scattered_info = f", 发散加严+{_scattered_bonus}" if _scattered_bonus else ""
logger.info(f"[图片后置过滤] {len(selected_images)}{len(filtered)}"
f"(回答关键词 {len(answer_keywords)} 个, 阈值 {threshold})")
f"(关键词 {len(_keywords)} 个, 阈值 {threshold}{_sub_info}{_scattered_info}, "
f"图号豁免 {len(_mentioned_refs)} 个)")
return filtered
@@ -1588,6 +1691,9 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
# 1. lazy_enhance 对 image/chart 更新 ctx['doc']
# 2. lazy_enhance 对 table 更新 ctx['image_description'](而非 doc
doc = ctx.get('image_description', '') or meta.get('vlm_desc', '') or ctx.get('doc', '')
# 注入 rerank 分数到 meta供 score_image_relevance 使用
# rerank 分数存储在 ctx 顶层而非 meta 中)
meta['score'] = ctx.get('score', 0)
s = score_image_relevance(query, meta, doc)
# ========== VLM 相关性筛选(方案 C==========
@@ -1683,10 +1789,12 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
scored_images.append({
'score': s,
'id': os.path.basename(meta['image_path']),
'chunk_id': meta.get('chunk_id', ''),
'url': f"/images/{os.path.basename(meta['image_path'])}",
'type': meta['chunk_type'],
'source': meta.get('source'),
'page': meta.get('page'),
'section_path': meta.get('section', '') or meta.get('section_path', ''),
'description': doc[:100], # 短描述用于 UI 展示
'full_description': doc # Bug 6b 修复:完整描述用于 LLM 上下文
})
@@ -1697,6 +1805,7 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
# 如果 P1 未执行(表格无 image_path需要独立计算分数
if s is None:
doc = ctx.get('image_description', '') or meta.get('vlm_desc', '') or ctx.get('doc', '')
meta['score'] = ctx.get('score', 0)
s = score_image_relevance(query, meta, doc)
# 章节相关性过滤:使用层级相似度,无需硬编码章节格式假设
@@ -1754,10 +1863,12 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
scored_images.append({
'score': assoc_score,
'id': img_id,
'chunk_id': meta.get('chunk_id', ''),
'url': f"/images/{img_id}",
'type': 'table_image',
'source': meta.get('source'),
'page': img_page,
'section_path': meta.get('section', '') or meta.get('section_path', ''),
'description': doc[:100],
'full_description': doc
})
@@ -1799,16 +1910,67 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
scored_images.append({
'score': 8.0, # 基础分
'id': img_id,
'chunk_id': img_meta.get('chunk_id', ''),
'url': f"/images/{img_id}",
'type': img_meta.get('chunk_type'),
'source': img_meta.get('source'),
'page': img_meta.get('page'),
'section_path': img_meta.get('section', '') or img_meta.get('section_path', ''),
'description': img_doc[:100], # 短描述用于 UI 展示
'full_description': img_doc # Bug 6b 修复:完整描述用于 LLM 上下文
})
existing_image_ids.add(img_id)
break
# ========== P3: CrossEncoder 语义精排 ==========
# 用 reranker 对 (query, image_description) 对做语义评分
# 策略CE < 0 直接剔除语义不相关CE 0~2 保留不加分弱相关CE > 2 加分
if scored_images:
try:
from core.engine import get_engine
engine = get_engine()
if engine.reranker:
# 为每张图片选取最佳描述文本VLM > full_description > description
# 截断到 512 字符bge-reranker-base token 上限)
pairs = []
for img in scored_images:
desc = img.get('full_description', '') or img.get('description', '') or ''
if not desc:
desc = img.get('id', '') # fallback: 图片文件名
if len(desc) > 512:
desc = desc[:512]
pairs.append((query, desc))
ce_scores = engine.reranker.predict(pairs)
# 分阶段处理:先标记 CE 分数,再过滤
kept_images = []
for img, ce_raw in zip(scored_images, ce_scores):
ce_score = float(ce_raw)
img['_ce_score'] = round(ce_score, 3)
if ce_score < 0:
# CE 负分:语义不相关,直接剔除
img['_ce_adj'] = 'removed'
logger.debug(f"CE 剔除: {img.get('id','')} (ce={ce_score:.2f})")
elif ce_score < 2:
# CE 0~2弱相关保留但不加分交给后置过滤判断
img['_ce_adj'] = 0.0
kept_images.append(img)
else:
# CE > 2强相关加分
adjustment = min((ce_score - 2) / 3.0, 1.0) * 5.0
img['score'] = img['score'] + adjustment
img['_ce_adj'] = round(adjustment, 2)
kept_images.append(img)
removed = len(scored_images) - len(kept_images)
if removed > 0:
logger.debug(f"CrossEncoder 图片精排: 剔除 {removed} 张负分图片")
scored_images = kept_images
except Exception as e:
logger.debug(f"CrossEncoder 图片精排跳过: {e}")
scored_images.sort(key=lambda x: x['score'], reverse=True)
return scored_images[:MAX_IMAGES]
@@ -2479,16 +2641,51 @@ def rag():
if meta.get('chunk_type') in ('image', 'chart'):
logging.info(f" - 图片: {meta.get('caption', '')[:50]}, path: {meta.get('image_path', '')}")
# 2.5. 懒加载增强Phase 4
# 暂时禁用VLM 调用耗时过长,可能导致请求超时
# TODO: 后续可改为异步后台任务
# 2.5. 懒加载增强Phase 4— 异步后台生成
# 当前请求直接使用已有的 VLM 描述meta/cache不阻塞响应
# 后台线程异步调用 VLM/LLM 生成缺失描述,写入缓存和 ChromaDB
# 下次查询时缓存命中,响应不受影响
try:
import asyncio
import threading
from knowledge.lazy_enhance import enhance_retrieved_chunks
kb_name = collections[0] if collections else 'public_kb'
asyncio.run(enhance_retrieved_chunks(contexts, retrieval_query, kb_name))
# 提取后台增强所需字段(替代 deepcopy避免 100-500ms 递归拷贝)
_bg_contexts = []
for _ctx in contexts:
_meta = _ctx.get('meta', {})
_bg_contexts.append({
'meta': {
'chunk_id': _meta.get('chunk_id', ''),
'image_path': _meta.get('image_path', ''),
'chunk_type': _meta.get('chunk_type', 'text'),
'has_vlm_desc': _meta.get('has_vlm_desc', False),
'has_summary': _meta.get('has_summary', False),
'section': _meta.get('section', ''),
'section_path': _meta.get('section_path', ''),
'page': _meta.get('page'),
'caption': _meta.get('caption', ''),
'source': _meta.get('source', ''),
},
'doc': _ctx.get('doc', ''), # 不截断,字符串浅引用无额外开销
'score': _ctx.get('score', 0),
'image_description': _ctx.get('image_description', ''),
})
def _background_enhance():
try:
import asyncio as _asyncio
_asyncio.run(_asyncio.wait_for(
enhance_retrieved_chunks(_bg_contexts, retrieval_query, kb_name, defer_chromadb=True),
timeout=60.0
))
except Exception as e:
logger.warning(f"懒加载增强失败: {e}")
logger.debug(f"后台 VLM 增强失败: {e}")
_t = threading.Thread(target=_background_enhance, daemon=True)
_t.start()
except Exception as e:
logger.debug(f"懒加载增强启动失败: {e}")
# 3. 选择要展示的图片Phase 5
selected_images = select_images(contexts, retrieval_query)
@@ -2695,7 +2892,18 @@ def rag():
# else: LLM 没有提图号保留原选择不再截断到1张
# 后置图片过滤:用回答内容反向筛选图片,确保图片与回答一致
selected_images = _filter_images_by_answer(selected_images, full_answer_text)
# query 拼接 retrieval_query + message防止意图改写丢失图片意图关键词
# 提取主要检索章节路径top-10 文本切片),用于子章节级图片过滤
_primary_sections = list(set(
ctx.get('meta', {}).get('section', '') or ctx.get('meta', {}).get('section_path', '')
for ctx in text_contexts[:10]
if ctx.get('meta', {}).get('section', '') or ctx.get('meta', {}).get('section_path', '')
))
selected_images = _filter_images_by_answer(
selected_images, full_answer_text,
query=f"{retrieval_query} {message}",
primary_sections=_primary_sections
)
rich_media = {'images': selected_images, 'tables': [], 'sections': []}
@@ -2857,7 +3065,8 @@ def search():
return success_response(data={
'contexts': results['documents'][0],
'metadatas': results['metadatas'][0],
'scores': results['scores'][0]
'scores': results['scores'][0],
'ids': results['ids'][0] if 'ids' in results and results['ids'] else []
})

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_MODEL = os.getenv("DASHSCOPE_MODEL", "mimo-v2.5") # 文本生成模型
RAG_CHAT_MODEL = os.getenv("RAG_CHAT_MODEL", "mimo-v2.5") # RAG 对话模型
INTENT_MODEL = os.getenv("INTENT_MODEL", "mimo-v2.5") # 意图分析模型
INTENT_MODEL = os.getenv("INTENT_MODEL", "deepseek-v4-flash") # 意图分析模型(百炼快速模型)
VLM_MODEL = os.getenv("VLM_MODEL", "mimo-v2.5") # 视觉语言模型(图片描述)
# 百炼 API阿里云 DashScope用于意图分析等轻量任务
BAILIAN_API_KEY = os.getenv("BAILIAN_API_KEY", "")
BAILIAN_BASE_URL = os.getenv("BAILIAN_BASE_URL", "https://dashscope.aliyuncs.com/compatible-mode/v1")
# 兼容旧变量名(逐步迁移到 DASHSCOPE_* 命名)
API_KEY = DASHSCOPE_API_KEY
BASE_URL = DASHSCOPE_BASE_URL
@@ -96,12 +100,12 @@ RERANK_DEVICE = os.getenv("RERANK_DEVICE", os.getenv("DEVICE", "auto"))
# ----- 通用问答 -----
LLM_TEMPERATURE = 0.7 # 生成温度0=确定性1=随机性)
LLM_MAX_TOKENS = 3000 # 最大输出 token 数
LLM_MAX_TOKENS = 3000 # 最大输出 token 数推理模型思考链占用部分预算3000 平衡速度与质量)
# ----- 意图分析(轻量、确定性高)-----
# INTENT_MODEL 在顶部「一、API 密钥与模型」中统一配置
INTENT_TEMPERATURE = 0.1
INTENT_MAX_TOKENS = 4096 # 推理模型思维链消耗大量 token2048 偶发截断导致意图分析失败
INTENT_MAX_TOKENS = 1024 # 推理模型无需思考链预算JSON 输出 1024 足够
INTENT_HISTORY_WINDOW = 6 # 分析时取最近几条历史消息
# ==============================================================================
@@ -286,3 +290,16 @@ def get_llm_client():
"""获取 LLM 客户端实例"""
from openai import OpenAI
return OpenAI(api_key=DASHSCOPE_API_KEY, base_url=DASHSCOPE_BASE_URL)
_intent_client = None
def get_intent_client():
"""获取意图分析专用 LLM 客户端(百炼快速模型)"""
global _intent_client
if _intent_client is None:
if not BAILIAN_API_KEY:
raise ValueError("BAILIAN_API_KEY 未配置,请在 .env 中设置")
from openai import OpenAI
_intent_client = OpenAI(api_key=BAILIAN_API_KEY, base_url=BAILIAN_BASE_URL)
return _intent_client

View File

@@ -233,10 +233,10 @@ class IntentAnalyzer:
self._exact_cache_max = 500
def _get_client(self):
"""获取 LLM 客户端"""
"""获取 LLM 客户端(百炼快速模型)"""
if self._client is None:
from config import get_llm_client
self._client = get_llm_client()
from config import get_intent_client
self._client = get_intent_client()
return self._client
def _get_cache(self):

View File

@@ -72,16 +72,35 @@ def call_llm(
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():
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
if reasoning and reasoning.strip():
# 从思维链中提取 JSON 块作为内容
json_match = re.search(r'\{[\s\S]*\}', reasoning)
# 先去掉 <think>...</think> 标签
cleaned = re.sub(r'', '', reasoning, flags=re.DOTALL).strip()
if cleaned:
logger.info("LLM: content为空从reasoning_content提取内容")
# 尝试提取 JSON 对象(兼容结构化响应场景)
json_match = re.search(r'\{[\s\S]*\}', cleaned)
if json_match:
logger.info("LLM: content为空从reasoning_content提取JSON")
try:
json.loads(json_match.group())
return json_match.group().strip()
logger.warning("LLM 返回空 content可能需要增大 max_tokens")
except (json.JSONDecodeError, ValueError):
pass
# 尝试提取 JSON 数组
bracket_match = re.search(r'\[[\s\S]*\]', cleaned)
if bracket_match:
try:
json.loads(bracket_match.group())
return bracket_match.group().strip()
except (json.JSONDecodeError, ValueError):
pass
# 纯文本响应:直接返回清理后的内容
return cleaned
logger.warning("LLM 返回空 content 且 reasoning_content 也无法提取(可能需要增大 max_tokens")
return None
return content.strip()
@@ -95,7 +114,7 @@ def call_llm_stream(
prompt: str,
model: str,
temperature: float = 0.3,
max_tokens: int = 1000,
max_tokens: int = 3000,
messages: List[dict] = None,
error_prefix: str = "[错误]",
**kwargs
@@ -104,13 +123,14 @@ def call_llm_stream(
流式 LLM 调用(生成器封装)
自动处理流式响应,逐块 yield 文本内容。
兼容推理模型mimo-v2.5 等):当 content 为空时回退到 reasoning_content。
Args:
client: OpenAI 客户端实例
prompt: 用户提示
model: 模型名称
temperature: 温度参数
max_tokens: 最大 token 数
max_tokens: 最大 token 数(推理模型需留足思考链预算)
messages: 完整消息列表
error_prefix: 错误时的前缀
**kwargs: 其他参数
@@ -135,9 +155,33 @@ def call_llm_stream(
**kwargs
)
content_yielded = False
reasoning_buffer = []
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
if not chunk.choices:
continue
delta = chunk.choices[0].delta
# 正常 content 输出
if hasattr(delta, 'content') and delta.content:
content_yielded = True
yield delta.content
continue
# 推理模型reasoning_content思考链
rc = getattr(delta, 'reasoning_content', None)
if rc:
reasoning_buffer.append(rc)
# 回退content 为空但 reasoning_content 有内容(推理模型 token 不足时)
if not content_yielded and reasoning_buffer:
reasoning_text = ''.join(reasoning_buffer)
# 去掉 <think>...</think> 标签
cleaned = re.sub(r'', '', reasoning_text, flags=re.DOTALL).strip()
if cleaned:
logger.info("流式 LLM: content为空从reasoning_content提取内容")
yield cleaned
except Exception as e:
logger.error(f"LLM 流式调用失败: {e}")

View File

@@ -331,6 +331,21 @@ class CollectionMixin:
except Exception as e:
logger.warning(f"清理版本记录失败: {e}")
# 清理不再被引用的图片和 VLM 缓存文件
# 注意:此时 ChromaDB collection 已删除cleanup_image_orphans 会扫描
# 所有剩余 collection仅该 collection 引用的图片会被识别为孤儿
try:
from knowledge.image_cleanup import cleanup_image_orphans
cleanup_result = cleanup_image_orphans(self)
if cleanup_result['deleted_images'] or cleanup_result['deleted_caches']:
logger.info(
f"清理孤儿文件: {cleanup_result['deleted_images']} 图片 + "
f"{cleanup_result['deleted_caches']} VLM缓存, "
f"释放 {cleanup_result['freed_bytes']/1024:.1f} KB"
)
except Exception as e:
logger.warning(f"清理孤儿文件失败: {e}")
if kb_name in self._metadata.get("collections", {}):
del self._metadata["collections"][kb_name]
self._save_metadata()

View File

@@ -72,6 +72,18 @@ class DocumentMixin:
except Exception as e:
logger.warning(f"清理版本记录失败: {e}")
# 清理不再被引用的图片和 VLM 缓存文件
try:
from knowledge.image_cleanup import cleanup_image_orphans
cleanup_result = cleanup_image_orphans(self, collections=[kb_name])
if cleanup_result['deleted_images'] or cleanup_result['deleted_caches']:
logger.info(
f"清理孤儿文件: {cleanup_result['deleted_images']} 图片 + "
f"{cleanup_result['deleted_caches']} VLM缓存"
)
except Exception as e:
logger.warning(f"清理孤儿文件失败: {e}")
logger.info(f"{kb_name} 删除文档: {filename}, 片段数: {deleted}")
return deleted

View File

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

View File

@@ -622,7 +622,7 @@ class KnowledgeBaseManager(
try:
from config import get_llm_client, DASHSCOPE_MODEL
client = get_llm_client()
summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=512)
summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=2048)
return summary.strip() if summary else ""
except Exception as e:
logger.warning(f"生成表格摘要失败: {e}")
@@ -681,13 +681,31 @@ class KnowledgeBaseManager(
]
}
],
max_tokens=512
max_tokens=2048 # mimo-v2.5 推理模型思考链消耗 ~1000 token需留足输出空间
)
description = response.choices[0].message.content
# 推理模型兼容content 为空时从 reasoning_content 提取
if not description or not description.strip():
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
if reasoning and reasoning.strip():
import re
# 尝试从思考链中提取有用文本(去掉 <think> 标签后的内容)
cleaned = re.sub(r'', '', reasoning, flags=re.DOTALL).strip()
if cleaned:
logger.info(f"VLM content为空从reasoning_content提取描述: {image_path}")
description = cleaned
else:
description = reasoning.strip()
if not description:
logger.warning(f"VLM 返回空描述: {image_path}")
return ""
# 缓存结果
import hashlib
import re as _re
img_hash = hashlib.md5(img_path.read_bytes()).hexdigest()
cache_dir = Path('.data/cache/vlm')
cache_dir.mkdir(parents=True, exist_ok=True)