feat(server-release): 从 main 同步子章节级图片过滤 + VLM/LLM 推理模型兼容

- chat_routes: 子章节级图片过滤(section_path 传递、叶子节点匹配、发散检测)
- chat_routes: _FIGURE_ANSWER_KEYWORDS 移除单字"图""表",正则图号兜底
- lazy_enhance: defer_chromadb 参数避免后台线程 SQLite 写锁竞争
- lazy_enhance: 缓存内容校验(>=5字)和空结果保护
- llm_utils: reasoning_content 兜底(剥离 <think> 标签)+ 流式 reasoning 支持
- intent_analyzer: SYSTEM_PROMPT 增加追问补全逻辑
- manager: VLM 描述/摘要 max_tokens 提升至 2048 + Windows fcntl 兼容

不改变端口、API 接口和响应数据字段。
This commit is contained in:
lacerate551
2026-06-20 20:27:01 +08:00
parent 87f3fae1aa
commit 8f75c51c59
5 changed files with 375 additions and 128 deletions

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