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:
@@ -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 []
|
||||
})
|
||||
|
||||
|
||||
|
||||
23
config.py
23
config.py
@@ -20,9 +20,13 @@ DASHSCOPE_API_KEY = os.getenv("DASHSCOPE_API_KEY", "")
|
||||
DASHSCOPE_BASE_URL = os.getenv("DASHSCOPE_BASE_URL", "https://token-plan-cn.xiaomimimo.com/v1")
|
||||
DASHSCOPE_MODEL = os.getenv("DASHSCOPE_MODEL", "mimo-v2.5") # 文本生成模型
|
||||
RAG_CHAT_MODEL = os.getenv("RAG_CHAT_MODEL", "mimo-v2.5") # RAG 对话模型
|
||||
INTENT_MODEL = os.getenv("INTENT_MODEL", "mimo-v2.5") # 意图分析模型
|
||||
INTENT_MODEL = os.getenv("INTENT_MODEL", "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 # 推理模型思维链消耗大量 token,2048 偶发截断导致意图分析失败
|
||||
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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -72,16 +72,35 @@ def call_llm(
|
||||
|
||||
content = response.choices[0].message.content
|
||||
|
||||
# 推理模型兼容:content 为空时尝试从 reasoning_content 提取
|
||||
# 推理模型兼容(mimo-v2.5 等):
|
||||
# 推理模型思考链消耗大量 token(~1000),max_tokens 不足时 content 为空,
|
||||
# 全部输出进入 reasoning_content。此处从思考链中提取有效内容。
|
||||
if not content or not content.strip():
|
||||
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
|
||||
if reasoning and reasoning.strip():
|
||||
# 从思维链中提取 JSON 块作为内容
|
||||
json_match = re.search(r'\{[\s\S]*\}', reasoning)
|
||||
# 先去掉 <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}")
|
||||
|
||||
@@ -331,6 +331,21 @@ class CollectionMixin:
|
||||
except Exception as e:
|
||||
logger.warning(f"清理版本记录失败: {e}")
|
||||
|
||||
# 清理不再被引用的图片和 VLM 缓存文件
|
||||
# 注意:此时 ChromaDB collection 已删除,cleanup_image_orphans 会扫描
|
||||
# 所有剩余 collection,仅该 collection 引用的图片会被识别为孤儿
|
||||
try:
|
||||
from knowledge.image_cleanup import cleanup_image_orphans
|
||||
cleanup_result = cleanup_image_orphans(self)
|
||||
if cleanup_result['deleted_images'] or cleanup_result['deleted_caches']:
|
||||
logger.info(
|
||||
f"清理孤儿文件: {cleanup_result['deleted_images']} 图片 + "
|
||||
f"{cleanup_result['deleted_caches']} VLM缓存, "
|
||||
f"释放 {cleanup_result['freed_bytes']/1024:.1f} KB"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"清理孤儿文件失败: {e}")
|
||||
|
||||
if kb_name in self._metadata.get("collections", {}):
|
||||
del self._metadata["collections"][kb_name]
|
||||
self._save_metadata()
|
||||
|
||||
@@ -72,6 +72,18 @@ class DocumentMixin:
|
||||
except Exception as e:
|
||||
logger.warning(f"清理版本记录失败: {e}")
|
||||
|
||||
# 清理不再被引用的图片和 VLM 缓存文件
|
||||
try:
|
||||
from knowledge.image_cleanup import cleanup_image_orphans
|
||||
cleanup_result = cleanup_image_orphans(self, collections=[kb_name])
|
||||
if cleanup_result['deleted_images'] or cleanup_result['deleted_caches']:
|
||||
logger.info(
|
||||
f"清理孤儿文件: {cleanup_result['deleted_images']} 图片 + "
|
||||
f"{cleanup_result['deleted_caches']} VLM缓存"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"清理孤儿文件失败: {e}")
|
||||
|
||||
logger.info(f"从 {kb_name} 删除文档: {filename}, 片段数: {deleted}")
|
||||
return deleted
|
||||
|
||||
|
||||
@@ -25,7 +25,20 @@ def compute_file_hash(file_path: str) -> str:
|
||||
return hashlib.md5(file_path.encode()).hexdigest()
|
||||
|
||||
|
||||
async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, metadata: dict = None) -> str:
|
||||
def _get_embedding_model():
|
||||
"""从 RAGEngine 获取 embedding 模型(KnowledgeBaseManager 上没有此属性)"""
|
||||
try:
|
||||
from core.engine import get_engine
|
||||
engine = get_engine()
|
||||
if not engine._initialized:
|
||||
engine.initialize()
|
||||
return engine.embedding_model
|
||||
except Exception as e:
|
||||
logger.warning(f"获取 embedding 模型失败: {e}")
|
||||
return None
|
||||
|
||||
|
||||
async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, metadata: dict = None, defer_chromadb: bool = False) -> str:
|
||||
"""
|
||||
懒加载 VLM 描述
|
||||
|
||||
@@ -36,6 +49,7 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
image_path: 图片路径(相对路径或绝对路径)
|
||||
kb_name: 知识库名称
|
||||
metadata: 图片元数据(包含 section、page、caption、上下文等)
|
||||
defer_chromadb: 为 True 时跳过 ChromaDB 更新(仅写文件缓存),避免后台线程写锁竞争
|
||||
|
||||
Returns:
|
||||
VLM 生成的图片描述
|
||||
@@ -49,23 +63,45 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
||||
else:
|
||||
full_image_path = image_path
|
||||
|
||||
# 1. 检查缓存
|
||||
# 1. 检查缓存(空缓存视为无效,需重新生成)
|
||||
img_hash = compute_file_hash(full_image_path)
|
||||
cache_file = VLM_CACHE_DIR / f"{img_hash}.txt"
|
||||
if cache_file.exists():
|
||||
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}")
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user