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:
@@ -1226,23 +1226,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:
|
||||
过滤后的图片列表
|
||||
@@ -1250,28 +1273,80 @@ 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}" if query else 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)))
|
||||
# 动态阈值:关键词越多阈值越高,上限 4(比原版略宽松,因子章节惩罚承担更多过滤)
|
||||
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:
|
||||
@@ -1281,18 +1356,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
|
||||
|
||||
@@ -1521,10 +1623,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 上下文
|
||||
})
|
||||
@@ -1555,6 +1659,7 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
|
||||
'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
|
||||
})
|
||||
@@ -1600,6 +1705,7 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
|
||||
'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 上下文
|
||||
})
|
||||
@@ -2489,7 +2595,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': []}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user