diff --git a/api/chat_routes.py b/api/chat_routes.py
index fb7a91b..32a67fb 100644
--- a/api/chat_routes.py
+++ b/api/chat_routes.py
@@ -1,2926 +1,3135 @@
-"""
-核心聊天与检索 API
-
-本模块提供 RAG 系统的核心问答接口,包括:
-- 普通聊天模式(/chat)
-- 知识库问答模式(/rag,支持 SSE 流式返回)
-- 混合检索接口(/search,供外部系统调用)
-
-路由列表:
- POST /chat : 普通聊天模式(JSON 响应)
- POST /rag : 知识库问答模式(SSE 流式返回)
- POST /search : 混合检索接口(供 Dify 调用)
-
-架构说明:
- - 会话管理由后端服务负责,RAG 服务不存储对话历史
- - 权限验证由后端网关完成(通过 request.current_user 获取用户信息)
- - /rag 接口已升级为 SSE 流式返回,支持实时输出
-
-Example:
- curl -X POST http://localhost:5001/rag \\
- -H "Content-Type: application/json" \\
- -H "Authorization: Bearer mock-token-admin" \\
- -d '{"query": "公司报销制度是什么?"}'
-"""
-
-import json
-import os
-import queue
-import threading
-import time as _time
-from typing import List, Dict, Any, Optional, Tuple
-from pathlib import Path
-
-import numpy as np
-from flask import Blueprint, request, jsonify, Response, current_app
-import logging
-
-logger = logging.getLogger(__name__)
-from auth.gateway import require_gateway_auth
-from core.status_codes import SUCCESS, BAD_REQUEST, NOT_FOUND, INTERNAL_ERROR
-from api.response_utils import success_response, error_response
-
-from auth.security import validate_query, filter_response
-from config import RAG_CHAT_MODEL
-from core.llm_utils import call_llm, call_llm_stream
-from core.prompt_guard import detect_injection, sanitize_user_input
-
-
-def _get_vlm_cache(image_path: str) -> Optional[str]:
- """
- 从缓存获取图片的 VLM 描述
-
- Args:
- image_path: 图片文件名(不含路径),如 '0cd5e156ff0a.png'
-
- Returns:
- VLM 描述文本,无缓存返回 None
- """
- import hashlib
-
- try:
- images_dir = Path(".data/images")
- full_path = images_dir / image_path
-
- if not full_path.exists():
- return None
-
- img_hash = hashlib.md5(full_path.read_bytes()).hexdigest()
- cache_file = Path(f".data/cache/vlm/{img_hash}.txt")
-
- if cache_file.exists():
- return cache_file.read_text(encoding='utf-8')
- except Exception as e:
- logger.debug(f"读取 VLM 缓存失败: {e}")
-
- return None
-
-
-def _check_vlm_relevance(query: str, vlm_desc: str) -> float:
- """
- 使用 LLM 评估图片描述与查询的相关性
-
- Args:
- query: 用户查询
- vlm_desc: 图片的 VLM 描述
-
- Returns:
- 相关性分数 (0-1),0=不相关,1=高度相关
- """
- if not vlm_desc or len(vlm_desc) < 20:
- return 0.5 # 无有效描述,中性评分
-
- # 使用 jieba 分词提取关键词
- import jieba
- stop_words = {'图', '表', '图片', '图表', '如图', '所示', '的', '是', '在', '有', '了', '和', '与', '或', '及', '等', '中', '对'}
- query_keywords = [w for w in jieba.lcut(query) if len(w) >= 2 and w not in stop_words]
- if not query_keywords:
- return 0.5
-
- # 检查关键词是否在 VLM 描述中出现
- matches = sum(1 for kw in query_keywords if kw in vlm_desc)
- if matches == 0:
- return 0.0 # 完全不相关
-
- # 按匹配比例评分
- ratio = matches / len(query_keywords)
- return min(ratio, 1.0)
-
-chat_bp = Blueprint('chat', __name__)
-
-
-def _safe_int(value: Any, default: int = 10**9) -> int:
- """
- 安全的整数转换
-
- Args:
- value: 待转换的值
- default: 转换失败时的默认值
-
- Returns:
- 转换后的整数值,失败返回默认值
- """
- try:
- if value is None or value == "":
- return default
- return int(value)
- except (TypeError, ValueError):
- return default
-
-
-def _is_enum_query(query: str) -> bool:
- """
- 判断是否为枚举型查询
-
- 枚举型查询通常包含"哪些"、"列出"等关键词,
- 需要特殊的上下文排序策略。
-
- Args:
- query: 用户查询文本
-
- Returns:
- 是枚举型查询返回 True,否则返回 False
- """
- try:
- from core.query_classifier import is_enumeration_query
- return is_enumeration_query(query)
- except Exception as e:
- logger.debug(f"枚举查询检测失败: {e}")
- markers = ("哪些", "有哪些", "列出", "严禁", "禁止", "不得", "包括", "要求", "情形", "场景")
- return bool(query) and any(marker in query for marker in markers)
-
-
-def _get_full_table_from_docstore(chunk_id: str) -> Optional[str]:
- """
- 从 DocStore 获取表格的完整 Markdown 内容
-
- Args:
- chunk_id: 切片 ID,如 '组织架构.xlsx_0' 或 'test_report.pdf_3'
-
- Returns:
- 完整表格 Markdown,未找到返回 None
- """
- docstore_dir = Path(".data/docstore")
- if not docstore_dir.exists():
- return None
-
- # chunk_id 格式: {filename}_{index}
- # DocStore 格式: {filename}_table_{index}.json 或 {filename}_{index}.json
- possible_paths = [
- docstore_dir / f"{chunk_id}.json", # 直接匹配
- docstore_dir / f"{chunk_id.replace('_', '_table_', 1)}.json", # xxx_0 -> xxx_table_0
- ]
-
- # 如果 chunk_id 是 {filename}_{num} 格式,尝试 {filename}_table_{num}
- parts = chunk_id.rsplit('_', 1)
- if len(parts) == 2:
- filename, idx = parts
- possible_paths.append(docstore_dir / f"{filename}_table_{idx}.json")
-
- for doc_path in possible_paths:
- if doc_path.exists():
- try:
- with open(doc_path, 'r', encoding='utf-8') as f:
- record = json.load(f)
- return record.get('markdown', '')
- except Exception as e:
- logger.debug(f"读取 DocStore 失败: {doc_path}, {e}")
- return None
-
-
-def _strip_semantic_prefix(doc: str, chunk_type: str) -> str:
- """
- 去除切片 doc 中的冗余语义前缀,保留关键标识信息
-
- 表格切片的 doc 由 _build_semantic_content_for_table 生成,格式为:
- 主题:section_path(保留,用于关联章节)
- 字段:A, B, C(去除,冗余)
- 描述:该表包含N行数据(去除,冗余)
- 示例:字段=值(去除,冗余)
-
- 表格内容:
- | A | B | C |
- |---|---|---|
- | 1 | 2 | 3 |
-
- 优化策略:
- - 保留"主题:"行(表格所属章节标识)
- - 去除"字段:"/"描述:"/"示例:"行(冗余信息)
- - 添加"【表格】"标记,让 LLM 明确识别表格类型
- - 保留 Markdown 表格内容(| 开头)和 HTML 表格内容(
List[Dict]:
- """
- 对文本上下文排序,优化提示词构建
-
- 对于列举型查询,保持同一文档章节的切片连续排列,
- 便于 LLM 理解完整的语义上下文。
-
- Args:
- contexts: 检索到的上下文列表
- query: 用户查询
- max_chunks: 最大切片数量
-
- Returns:
- 排序后的上下文列表
-
- Example:
- >>> ordered = _order_text_contexts_for_prompt(contexts, "有哪些禁止情形?", 10)
- """
- # 文本切片 + 表格切片(从 DocStore 获取完整内容)
- text_contexts = []
- for ctx in contexts:
- chunk_type = ctx.get('meta', {}).get('chunk_type', '')
- if chunk_type in ('image', 'chart'):
- continue # 图片/图表单独处理
-
- if chunk_type == 'table':
- # 表格:从 DocStore 获取完整 Markdown
- chunk_id = ctx.get('meta', {}).get('chunk_id', '')
- full_table = _get_full_table_from_docstore(chunk_id) if chunk_id else None
- if full_table:
- # 替换为完整内容,保留 score
- text_contexts.append({
- 'doc': full_table,
- 'meta': {**ctx.get('meta', {}), '_from_docstore': True},
- 'score': ctx.get('score', 0)
- })
- else:
- # DocStore 中没有,使用原始摘要
- text_contexts.append(ctx)
- else:
- # 普通文本切片
- text_contexts.append(ctx)
-
- # 图片/图表切片:提取其 doc 内容(包含前文/后文上下文)
- # 这些切片虽然 chunk_type 是 image/chart,但其 doc 字段包含有价值的上下文信息
- chart_contexts = []
- for ctx in contexts:
- chunk_type = ctx.get('meta', {}).get('chunk_type', '')
- if chunk_type in ('image', 'chart'):
- doc = ctx.get('doc', '')
- if doc and len(doc) > 20: # 有实际内容
- # 标记来源,避免重复
- chart_contexts.append({
- 'doc': doc,
- 'meta': {**ctx.get('meta', {}), '_is_chart_context': True},
- 'score': ctx.get('score', 0)
- })
-
- # Phase 1:按 Rerank 分数过滤低分切片
- # 保护策略:同一 section 内如有切片通过阈值,则同 section 的 table 切片也保留
- # 原因:表格切片的 rerank 分数往往偏低(尤其是元问题如"有表格吗?"),
- # 但它们与同 section 的 text 切片属于同一语义单元,不应割裂
- # 安全下限:被保护的 table 切片自身 score 不得低于 min_score * 0.3,
- # 防止 section 粒度较粗时完全不相关的表格被无条件保护
- if min_score > 0:
- _table_floor = min_score * 0.3 # table 保护最低分数下限
- # 先找出所有通过阈值的 section
- passing_sections = set()
- for c in text_contexts:
- if c.get('score', 0) >= min_score:
- meta = c.get('meta', {})
- section_key = (meta.get('source', ''), meta.get('section', '') or meta.get('section_path', ''))
- if section_key[1]: # 有 section 信息的才保护
- passing_sections.add(section_key)
-
- text_contexts = [
- c for c in text_contexts
- if c.get('score', 0) >= min_score
- or (
- c.get('meta', {}).get('chunk_type') == 'table'
- and c.get('score', 0) >= _table_floor
- and (c.get('meta', {}).get('source', ''), c.get('meta', {}).get('section', '') or c.get('meta', {}).get('section_path', '')) in passing_sections
- )
- ]
- chart_contexts = [c for c in chart_contexts if c.get('score', 0) >= min_score]
-
- # 合并:文本切片优先,图表切片补充
- # 限制图表切片数量,避免过多
- max_chart_contexts = 3
- combined_contexts = text_contexts + chart_contexts[:max_chart_contexts]
-
- if not _is_enum_query(query):
- # 表格不受 max_chunks 限制(CrossEncoder 对表格评分偏低,
- # 但表格是结构化关键内容,不应因分数低而被截断)
- table_ctx = [c for c in combined_contexts if c.get('meta', {}).get('chunk_type') == 'table']
- non_table_ctx = [c for c in combined_contexts if c.get('meta', {}).get('chunk_type') != 'table']
- return non_table_ctx[:max_chunks] + table_ctx
-
- def sort_key(ctx):
- meta = ctx.get('meta', {})
- source = meta.get('source', '')
- section = meta.get('section', '') or meta.get('section_path', '')
- rank = _safe_int(meta.get('_retrieval_rank'), 10**6)
- return (
- source != primary_source,
- section != primary_section,
- source,
- section,
- _safe_int(meta.get('chunk_index')),
- _safe_int(meta.get('section_chunk_id')),
- rank,
- )
-
- primary = text_contexts[0].get('meta', {}) if text_contexts else {}
- primary_source = primary.get('source', '')
- primary_section = primary.get('section', '') or primary.get('section_path', '')
- primary_index = _safe_int(primary.get('chunk_index'), None)
-
- try:
- from config import CONTEXT_EXPANSION_BEFORE, CONTEXT_EXPANSION_AFTER
- except Exception as e:
- logger.debug(f"读取上下文扩展配置失败: {e}")
- CONTEXT_EXPANSION_BEFORE, CONTEXT_EXPANSION_AFTER = 1, 5
-
- if primary_source and primary_index is not None:
- window_start = primary_index - CONTEXT_EXPANSION_BEFORE
- window_end = primary_index + CONTEXT_EXPANSION_AFTER + 1
- window = []
- for ctx in text_contexts:
- meta = ctx.get('meta', {})
- idx = _safe_int(meta.get('chunk_index'), None)
- if meta.get('source') == primary_source and idx is not None and window_start <= idx <= window_end:
- window.append(ctx)
- window_ids = {
- ctx.get('meta', {}).get('chunk_id') or (ctx.get('meta', {}).get('source'), ctx.get('meta', {}).get('chunk_index'))
- for ctx in window
- }
- window = sorted(window, key=lambda ctx: _safe_int(ctx.get('meta', {}).get('chunk_index')))
- remainder = [
- ctx for ctx in text_contexts
- if (ctx.get('meta', {}).get('chunk_id') or (ctx.get('meta', {}).get('source'), ctx.get('meta', {}).get('chunk_index'))) not in window_ids
- ]
- # 枚举查询不截断 max_chunks:需要跨 section 信息,
- # window 优先保证主命中文档连续,remainder 按相关性补充
- return window + sorted(remainder, key=sort_key)
-
- ordered = sorted(text_contexts, key=sort_key)
- return ordered
-
-
-def _process_table_doc(doc: str, meta: Dict) -> str:
- """
- 处理单个切片的 doc:精简表格语义前缀 + 注入嵌入图片 URL
-
- 集中处理两处共用逻辑(正常路径和预算截断路径),避免重复代码。
-
- Args:
- doc: 切片原始 doc
- meta: 切片 metadata
-
- Returns:
- 处理后的 doc
- """
- doc = _strip_semantic_prefix(doc, meta.get('chunk_type', ''))
- if meta.get('chunk_type') == 'table' and meta.get('images_json'):
- try:
- img_list = json.loads(meta['images_json'])
- if img_list:
- img_urls = [
- f"/images/{img.get('id', '')}"
- for img in img_list
- if isinstance(img, dict) and img.get('id')
- ]
- if img_urls:
- doc += "\n\n[该表格包含以下图片,可在回答中引用]: " + ", ".join(img_urls)
- except (json.JSONDecodeError, TypeError):
- pass
- return doc
-
-
-def _section_similarity(section_a: str, section_b: str) -> float:
- """
- 计算两个章节路径的层级相似度(数据驱动,无需硬编码格式假设)。
-
- 策略:
- 1. 如果两个路径都有数值编号(如 "2.3"),优先用精确数值匹配
- 2. 否则按 " > " 层级拆分,计算 Jaccard 相似度
- 3. 无数值编号时优雅降级,适用于 "第七章 附则" 或无结构文档
-
- Returns: 0.0 ~ 1.0
- """
- import re as _re
-
- if not section_a or not section_b:
- return 0.0
-
- # 快速路径:完全相同
- if section_a == section_b:
- return 1.0
-
- # 优先精确匹配:数值编号(如 "2.3")
- num_a = _re.search(r'(\d+\.\d+)', section_a)
- num_b = _re.search(r'(\d+\.\d+)', section_b)
- if num_a and num_b:
- return 1.0 if num_a.group(1) == num_b.group(1) else 0.0
-
- # 层级文本匹配:拆分路径为各级标题,计算 Jaccard 系数
- def _split_levels(path):
- parts = [p.strip() for p in path.split('>') if p.strip()]
- # 去掉常见序号前缀(如 "1.1 "、"第1章 "),保留语义部分
- cleaned = set()
- for p in parts:
- cleaned.add(_re.sub(r'^(?:\d+[\.\d]*\s*|第\s*\d+\s*章\s*|[一二三四五六七八九十]+、\s*)', '', p).strip())
- return cleaned
-
- levels_a = _split_levels(section_a)
- levels_b = _split_levels(section_b)
-
- if not levels_a or not levels_b:
- return 0.0
-
- overlap = len(levels_a & levels_b)
- union = len(levels_a | levels_b)
- return overlap / union if union > 0 else 0.0
-
-
-def _rescue_bm25_divergence(contexts: List[Dict], search_result: dict,
- min_score: float) -> List[Dict]:
- """
- BM25-CrossEncoder 分歧检测救援:当 BM25 排名靠前(top-3)的切片
- 被 CrossEncoder rerank 压制(分数低于 min_score 或被截断)时,
- 将其分数提升至保底值,使其通过后续的 min_score 过滤。
-
- 适用场景:
- - BM25 top-1/top-2 的切片(精确关键词匹配强信号)被 rerank 评分极低
- - 正确切片在 rerank 后被截断,根本不在 contexts 中
-
- Args:
- contexts: 全部上下文切片
- search_result: engine.search_hybrid() 返回的原始结果(含 _bm25_top3)
- min_score: 最低分数阈值
-
- Returns:
- 修改后的 contexts
- """
- if not contexts:
- return contexts
-
- from config import (
- BM25_DIVERGENCE_RESCUE_ENABLED, BM25_DIVERGENCE_MAX_RANK,
- CLUSTER_RESCUE_FLOOR,
- )
-
- if not BM25_DIVERGENCE_RESCUE_ENABLED:
- return contexts
-
- bm25_top3 = search_result.get('_bm25_top3', [])
- if not bm25_top3:
- return contexts
-
- # 构建 contexts 中已有切片的 ID 索引,用于快速查找
- existing_ids = {}
- for i, ctx in enumerate(contexts):
- chunk_id = ctx.get('meta', {}).get('chunk_id') or ctx.get('id')
- if chunk_id:
- existing_ids[chunk_id] = i
-
- # 计算 contexts 中的多数 source(用于情况 B 注入校验)
- # 防止跨文档注入无关切片(如 q013 场景:2.docx 的吸烟场所切片被注入到 1.docx 的查询中)
- source_counter: Dict[str, int] = {}
- for ctx in contexts:
- src = ctx.get('meta', {}).get('source', '')
- if src:
- source_counter[src] = source_counter.get(src, 0) + 1
- majority_source = max(source_counter, key=source_counter.get) if source_counter else None
-
- rescued_count = 0
-
- for bm25_item in bm25_top3:
- rank = bm25_item.get('rank', 99)
- if rank > BM25_DIVERGENCE_MAX_RANK:
- continue
-
- bm25_id = bm25_item.get('id')
- bm25_meta = bm25_item.get('meta', {})
- bm25_doc = bm25_item.get('doc', '')
-
- # 情况 A:切片在 contexts 中但 score < min_score
- if bm25_id and bm25_id in existing_ids:
- ctx = contexts[existing_ids[bm25_id]]
- if ctx.get('score', 0) < min_score:
- ctx['score'] = CLUSTER_RESCUE_FLOOR
- rescued_count += 1
- logger.debug(
- f"BM25 分歧救援 (情况A): rank={rank}, "
- f"source={bm25_meta.get('source', '')}, "
- f"section={bm25_meta.get('section', '')}, "
- f"原score→{CLUSTER_RESCUE_FLOOR}"
- )
- continue
-
- # 情况 B:切片不在 contexts 中(被 rerank 截断或已被过滤)
- # 从 _bm25_top3 备份中注入,但需校验 source 一致性
- if bm25_doc and bm25_meta:
- bm25_source = bm25_meta.get('source', '')
- # source 一致性校验:只注入与 contexts 多数 source 一致的切片
- # 避免跨文档注入无关内容(如 2.docx 的吸烟场所切片混入 1.docx 的投放查询)
- if majority_source and bm25_source and bm25_source != majority_source:
- logger.debug(
- f"BM25 分歧救援 (情况B-跳过): rank={rank}, "
- f"source={bm25_source} != majority={majority_source}, "
- f"section={bm25_meta.get('section', '')}"
- )
- continue
- injected_ctx = {
- 'doc': bm25_doc,
- 'meta': bm25_meta,
- 'score': CLUSTER_RESCUE_FLOOR,
- }
- contexts.append(injected_ctx)
- rescued_count += 1
- logger.debug(
- f"BM25 分歧救援 (情况B-注入): rank={rank}, "
- f"source={bm25_meta.get('source', '')}, "
- f"section={bm25_meta.get('section', '')}"
- )
-
- if rescued_count > 0:
- logger.info(f"BM25 分歧救援: 共救援 {rescued_count} 个切片")
-
- return contexts
-
-
-def _rescue_lexical_match(contexts: List[Dict], retrieval_query: str,
- min_score: float) -> List[Dict]:
- """
- 词法匹配救援:当切片文本精确包含查询的核心关键词但 CrossEncoder 评分很低时,
- 将其分数提升至保底值,并同时救援同 source 下 chunk_index 相邻的切片。
-
- 适用场景:
- 1. 某个 section 只有一个正确切片(无法触发聚类救援)
- 2. 枚举类问题的 header 切片被词法匹配救援后,其后续子条目也应被一并保留
-
- Args:
- contexts: 全部上下文切片
- retrieval_query: 检索查询
- min_score: 最低分数阈值
-
- Returns:
- 修改后的 contexts
- """
- if not contexts or not retrieval_query:
- return contexts
-
- import re
- from config import CLUSTER_RESCUE_FLOOR
-
- # 清理查询:去除 markdown 格式和标点
- clean_query = re.sub(r'\*+|#+|`', '', retrieval_query)
- clean_query = re.sub(r'[??!!。,,、;;::"""\'\s]+', ' ', clean_query).strip()
-
- if len(clean_query) < 2:
- return contexts
-
- # 提取查询中的有意义 bigram(连续两字组)
- query_bigrams = set()
- for i in range(len(clean_query) - 1):
- w = clean_query[i:i+2].strip()
- if len(w) == 2:
- query_bigrams.add(w)
-
- if not query_bigrams:
- return contexts
-
- # Phase 1: 词法匹配救援——找到高分匹配的切片
- rescued_seeds = [] # [(source, chunk_index)]
- for ctx in contexts:
- if ctx.get('score', 0) >= min_score:
- continue
-
- doc = ctx.get('doc', '') or ''
- meta = ctx.get('meta', {})
- section = meta.get('section', '') or meta.get('section_path', '')
- combined = doc + ' ' + section
-
- matched = sum(1 for w in query_bigrams if w in combined)
- match_ratio = matched / len(query_bigrams)
-
- if match_ratio > 0.35:
- ctx['score'] = max(ctx.get('score', 0), CLUSTER_RESCUE_FLOOR)
- source = meta.get('source', '')
- chunk_index = meta.get('chunk_index')
- if source and chunk_index is not None:
- try:
- rescued_seeds.append((source, int(chunk_index)))
- except (ValueError, TypeError):
- pass
-
- # Phase 2: 邻居救援——对每个被词法匹配救援的种子,
- # 同时救援同 source 下 chunk_index 后续相邻的切片(枚举子条目)
- if rescued_seeds:
- for source, seed_idx in rescued_seeds:
- for ctx in contexts:
- if ctx.get('score', 0) >= min_score:
- continue
- meta = ctx.get('meta', {})
- if meta.get('source') != source:
- continue
- try:
- n_idx = int(meta.get('chunk_index', -1))
- except (ValueError, TypeError):
- continue
- # 救援种子后续 8 个相邻切片(覆盖大多数枚举/条款模式)
- if seed_idx < n_idx <= seed_idx + 8:
- ctx['score'] = max(ctx.get('score', 0), CLUSTER_RESCUE_FLOOR)
-
- return contexts
-
-
-def _normalize_section_for_rescue(section_path: str, levels: int = 2) -> str:
- """归一化 section_path:取前 N 级路径用于分组"""
- if not section_path:
- return ''
- parts = [p.strip() for p in section_path.split('>')]
- return ' > '.join(parts[:levels])
-
-
-def _rescue_section_cluster(contexts: List[Dict], retrieval_query: str,
- min_score: float) -> List[Dict]:
- """
- 路由层章节聚类救援:当某个 section 有多个候选切片但全部低于 min_score 时,
- 给该 section 的切片分配保底分数,使其通过后续的 min_score 过滤。
-
- 作为引擎层 _section_cluster_boost 的二次安全网,防止极端情况下所有正确切片被过滤。
-
- Args:
- contexts: engine 返回的全部上下文切片(每个含 doc, meta, score)
- retrieval_query: 改写后的检索查询
- min_score: 当前的最低分数阈值
-
- Returns:
- 修改后的 contexts(部分切片 score 被提升至保底分数)
- """
- if not contexts:
- return contexts
-
- from config import (
- CLUSTER_MIN_MEMBERS, CLUSTER_MIN_TYPES, CLUSTER_RESCUE_FLOOR,
- CLUSTER_MAX_SECTIONS, CLUSTER_MAX_RESCUE_PER_SECTION,
- CLUSTER_SECTION_PREFIX_LEVELS,
- )
-
- # 1. 按 (source, normalized_section) 分组
- from collections import defaultdict
- section_groups = defaultdict(list) # key → [index_in_contexts]
-
- for i, ctx in enumerate(contexts):
- meta = ctx.get('meta', {})
- source = meta.get('source', '')
- section_path = meta.get('section', '') or meta.get('section_path', '')
- norm_section = _normalize_section_for_rescue(section_path, CLUSTER_SECTION_PREFIX_LEVELS)
- if not source or not norm_section:
- continue
- key = (source, norm_section)
- section_groups[key].append(i)
-
- # 2. 检测"全灭 section"并计算聚类强度
- rescue_candidates = [] # (strength, key, member_indices)
-
- for key, indices in section_groups.items():
- if len(indices) < CLUSTER_MIN_MEMBERS:
- continue
-
- # 检查是否所有成员都低于 min_score("全灭")
- scores = [contexts[i].get('score', 0) for i in indices]
- if any(s >= min_score for s in scores):
- continue # 已有成员通过阈值,无需救援
-
- # 计算聚类强度
- chunk_types = set(contexts[i].get('meta', {}).get('chunk_type', 'text') for i in indices)
- type_diversity = len(chunk_types)
- if type_diversity < CLUSTER_MIN_TYPES:
- continue # 类型不够多样,可能是噪音
-
- # 查询与 section_path 的字符重叠率
- source, norm_section = key
- query_chars = set(retrieval_query)
- section_chars = set(norm_section)
- overlap = len(query_chars & section_chars) / max(len(query_chars), 1)
-
- # 聚类强度 = 成员数 × 类型多样性 × (1 + 查询匹配度)
- strength = len(indices) * type_diversity * (1.0 + overlap)
- rescue_candidates.append((strength, key, indices))
-
- # 3. 按强度降序,救援 top-N section
- rescue_candidates.sort(key=lambda x: x[0], reverse=True)
-
- rescued_sections = []
- total_rescued = 0
-
- for strength, key, indices in rescue_candidates[:CLUSTER_MAX_SECTIONS]:
- # 对组内切片分配保底分数(仅低于 min_score 的)
- rescue_count = 0
- for idx in indices:
- if rescue_count >= CLUSTER_MAX_RESCUE_PER_SECTION:
- break
- ctx = contexts[idx]
- if ctx.get('score', 0) < min_score:
- ctx['score'] = CLUSTER_RESCUE_FLOOR
- rescue_count += 1
- total_rescued += 1
-
- if rescue_count > 0:
- source, norm_section = key
- rescued_sections.append({
- 'source': source,
- 'section': norm_section,
- 'members': len(indices),
- 'rescued': rescue_count,
- 'strength': round(strength, 2)
- })
-
- return contexts
-
-
-def _rescue_table_chunks(contexts: List[Dict], context_text: str,
- retrieval_query: str, max_rescue_chars: int = 3000) -> str:
- """
- 表格救援:当查询涉及表格但上下文预算截断了表格内容时,将最相关的表格补回。
-
- 根因:CrossEncoder 对 Markdown 表格格式的评分普遍偏低,
- 导致 _build_context_with_budget 按分数排序时表格被排到最后并被截断。
- 此函数作为安全网,确保与查询最相关的表格始终出现在上下文中。
-
- Args:
- contexts: 经过 _order_text_contexts_for_prompt 处理的全部文本切片
- context_text: _build_context_with_budget 的输出
- retrieval_query: 改写后的检索查询
- max_rescue_chars: 救援表格的最大字符数
-
- Returns:
- 可能追加了表格内容的上下文文本
- """
- # 1. 数据驱动检测:检索结果中是否有表格类型切片(无需硬编码关键词)
- has_table_in_contexts = any(
- ctx.get('meta', {}).get('chunk_type') == 'table' for ctx in contexts
- )
- if not has_table_in_contexts:
- return context_text
-
- # 2. 检测现有上下文是否已包含表格数据(Markdown 表格至少 2 列)
- if '|' in context_text and context_text.count('|') > 4:
- return context_text
-
- # 3. 从 contexts 中找被截断的表格切片
- # 使用 _in_budget_context 标记精确判断(由 _build_context_with_budget 设置)
- table_candidates = []
- for ctx in contexts:
- if ctx.get('meta', {}).get('chunk_type') != 'table':
- continue
- # 精确判断:如果 _build_context_with_budget 已标记该 chunk 为已纳入,跳过
- if ctx.get('_in_budget_context'):
- continue
- table_candidates.append(ctx)
-
- if not table_candidates:
- return context_text
-
- # 4. 按分数降序取最佳表格,追加到上下文
- table_candidates.sort(key=lambda c: c.get('score', 0), reverse=True)
-
- rescue_parts = []
- rescue_chars = 0
- for ctx in table_candidates[:3]: # 最多救援 3 个表格
- meta = ctx.get('meta', {})
- doc = _process_table_doc(ctx.get('doc', ''), meta)
- section = meta.get('section', '') or meta.get('section_path', '')
-
- part_text = ""
- if section:
- part_text += f"━ {section} ━\n"
- part_text += doc
-
- if rescue_chars + len(part_text) > max_rescue_chars:
- break
-
- rescue_parts.append(part_text)
- rescue_chars += len(part_text)
-
- if rescue_parts:
- separator = "\n\n--- 以下为与查询最相关的表格(CrossEncoder 评分偏低,自动补入)---\n\n"
- context_text += separator + "\n\n".join(rescue_parts)
-
- return context_text
-
-
-def _build_context_with_budget(contexts: List[Dict], max_chars: int, soft_limit: int) -> str:
- """
- Phase 2:按字符预算构建上下文文本。
-
- 1. 按 (source, section) 分组,组内按 chunk_index 保持连续
- 2. 组间按组内最高 Rerank 分数降序排列
- 3. 逐组加入直到达到 max_chars 预算
- 4. 超过 soft_limit 后仅接受组内最高分 >= soft_min_score 的组
-
- 对于列举类查询(_is_enum_query),保持原始顺序直接拼接,
- 因为 _order_text_contexts_for_prompt 已经优化了排序。
-
- Args:
- contexts: 已排序的上下文列表
- max_chars: 硬性字符上限
- soft_limit: 软限制,超过后收紧准入
-
- Returns:
- 拼接好的上下文文本
- """
- if not contexts:
- return ""
-
- # 按 (source, section) 分组
- groups = {}
- group_order = []
- for ctx in contexts:
- meta = ctx.get('meta', {})
- key = (meta.get('source', ''), meta.get('section', '') or meta.get('section_path', ''))
- if key not in groups:
- groups[key] = []
- group_order.append(key)
- groups[key].append(ctx)
-
- # 组内按 chunk_index 排序
- for key in groups:
- groups[key].sort(key=lambda c: _safe_int(c.get('meta', {}).get('chunk_index')))
-
- # 组间按组内最高 score 降序
- def group_max_score(key):
- return max((c.get('score', 0) for c in groups[key]), default=0)
-
- group_order.sort(key=group_max_score, reverse=True)
-
- # 贪心加入直到预算满
- parts = []
- total_chars = 0
- for key in group_order:
- group = groups[key]
- source, section = key
-
- # 判断是否包含表格切片(表格是结构化关键内容,不受预算截断)
- has_table = any(c.get('meta', {}).get('chunk_type') == 'table' for c in group)
-
- # 构建组文本,表格切片附加图片 URL 供 LLM 引用
- doc_parts = []
- for ctx in group:
- doc = _process_table_doc(ctx.get('doc', ''), ctx.get('meta', {}))
- doc_parts.append(doc)
- group_text = "\n\n".join(doc_parts)
-
- # 在组首插入章节路径标题行,帮助 LLM 区分不同章节
- section_header = ''
- if section:
- section_header = f"━ {section} ━"
- group_text = section_header + "\n" + group_text
-
- # 超过软限制后,只接受高分组(但表格组始终保留,不因分数低被跳过)
- if total_chars > soft_limit and group_max_score(key) < 0.1 and not has_table:
- continue
-
- if total_chars + len(group_text) > max_chars:
- # 尝试逐条加入该组,直到预算满
- # 先加入章节标题(如果有)
- if section_header and total_chars + len(section_header) + 2 <= max_chars:
- parts.append(section_header)
- total_chars += len(section_header) + 2
- for ctx in group:
- doc = _process_table_doc(ctx.get('doc', ''), ctx.get('meta', {}))
- if total_chars + len(doc) + 2 > max_chars: # +2 for "\n\n"
- break
- ctx['_in_budget_context'] = True # 标记已纳入上下文
- parts.append(doc)
- total_chars += len(doc) + 2
- # 超预算后一律 break,被截断的表格由 _rescue_table_chunks 补回
- break
-
- for ctx in group:
- ctx['_in_budget_context'] = True # 标记已纳入上下文
- parts.append(group_text)
- total_chars += len(group_text) + 2 # +2 for "\n\n"
-
- return "\n\n".join(parts)
-
-
-def _attach_citations(answer: str, contexts: List[Dict]) -> Dict[str, Any]:
- """
- 自动为回答添加引用标记(按段落级别匹配,jieba 分词精准匹配)
-
- 流程:
- 1. 将 answer 按自然段落分割(\n\n)
- 2. 对每个段落用 jieba 分词后计算词级重叠度
- 3. Phase 6:动态阈值(短段落 0.55 / 长段落 0.45),每段最多 2 个引用
- 4. 前端负责对引用进行重新编号
-
- Args:
- answer: LLM 生成的回答
- contexts: 检索到的上下文列表
-
- Returns:
- {
- "answer_with_refs": "回答文本(含 [ref:chunk_id] 标记)",
- "citations": [引用列表]
- }
- """
- import re
-
- if not contexts:
- return {"answer_with_refs": answer, "citations": []}
-
- # 按 (collection, chunk_id) 复合键组织 contexts,防止跨库同名文件覆盖
- ctx_by_chunk = {}
- for ctx in contexts:
- meta = ctx.get('meta', {})
- chunk_id = meta.get('chunk_id') or f"{meta.get('source')}_{meta.get('chunk_index', 0)}"
- coll = meta.get('_collection') or meta.get('collection') or ''
- composite_key = f"{coll}/{chunk_id}" if coll else chunk_id
- # 保存原始 chunk_id,用于对外输出(ref tag / citation)
- ctx['_raw_chunk_id'] = chunk_id
- ctx_by_chunk[composite_key] = ctx
-
- # jieba 分词函数(fallback 到字符级)
- try:
- import jieba
- def _tokenize(text: str) -> set:
- return set(w for w in jieba.lcut(text) if len(w) >= 2)
- except ImportError:
- def _tokenize(text: str) -> set:
- return set(text)
-
- # 按自然段落分割(\n\n 为分隔符,保留分隔符用于重组)
- parts = re.split(r'(\n\n+)', answer)
-
- cited_chunks_ordered: List[str] = [] # 保序去重的 chunk_id 列表
- cited_set: set = set()
- result_parts = []
-
- for i in range(0, len(parts), 2):
- para = parts[i]
- sep = parts[i + 1] if i + 1 < len(parts) else ''
-
- stripped = para.strip()
- # 跳过过短段落或 markdown 表格/标题行
- is_table = stripped.startswith('|') or '|---' in stripped
- is_short = len(stripped) < 15
-
- if is_table or is_short:
- result_parts.append(para + sep)
- continue
-
- # 词级重叠匹配:找最相关的 chunk
- para_words = _tokenize(stripped[:300])
-
- # Phase 6:动态阈值 — 短段落用更高阈值避免误匹配
- overlap_threshold = 0.55 if len(stripped) < 50 else 0.45
-
- # 收集所有超过阈值的候选 chunk,按分数降序
- candidates = []
- for chunk_id, ctx in ctx_by_chunk.items():
- ctx_doc = ctx.get('doc', '')
- if not ctx_doc:
- continue
- ctx_words = _tokenize(ctx_doc[:400])
- if not para_words:
- continue
- overlap = len(para_words & ctx_words)
- score = overlap / len(para_words)
- if score >= overlap_threshold:
- candidates.append((chunk_id, score))
-
- candidates.sort(key=lambda x: x[1], reverse=True)
-
- # Phase 6:允许最多 2 个引用(分数差距 < 0.1 时附加第二引用)
- selected_ids = []
- if candidates:
- selected_ids.append(candidates[0][0])
- if len(candidates) > 1 and (candidates[0][1] - candidates[1][1]) < 0.1:
- selected_ids.append(candidates[1][0])
-
- # 按 _raw_chunk_id 去重:不同 composite_key 可能指向同一个底层 chunk
- # 避免同一 chunk 产生重复引用标记(如 [3][3])
- seen_raw_ids = set()
- deduped_ids = []
- for cid in selected_ids:
- raw_id = ctx_by_chunk[cid].get('_raw_chunk_id', cid)
- if raw_id not in seen_raw_ids:
- seen_raw_ids.add(raw_id)
- deduped_ids.append(cid)
- selected_ids = deduped_ids
-
- if selected_ids:
- for cid in selected_ids:
- if cid not in cited_set:
- cited_set.add(cid)
- cited_chunks_ordered.append(cid)
- # 在段落末尾插入引用标记(使用原始 chunk_id,不暴露复合键)
- ref_tags = "".join(
- f"[ref:{ctx_by_chunk[cid].get('_raw_chunk_id', cid)}]"
- for cid in selected_ids
- )
- result_parts.append(f"{para}{ref_tags}{sep}")
- else:
- result_parts.append(para + sep)
-
- # 构建引用列表(按出现顺序),使用原始 chunk_id 构建 citation
- # 按 _raw_chunk_id 去重,避免同一 chunk 产生重复引用条目
- citations = []
- seen_citation_raw_ids = set()
- for composite_key in cited_chunks_ordered:
- ctx = ctx_by_chunk.get(composite_key)
- if ctx:
- raw_id = ctx.get('_raw_chunk_id') or composite_key
- if raw_id in seen_citation_raw_ids:
- continue # 同一 chunk 已在引用列表中,跳过
- seen_citation_raw_ids.add(raw_id)
- meta = ctx.get('meta', {})
- full_content = ctx.get('doc', '')
- citation = _build_citation(meta, full_content)
- citation['chunk_id'] = raw_id
- citations.append(citation)
-
- return {
- "answer_with_refs": "".join(result_parts),
- "citations": citations
- }
-
-
-def _clean_section(raw: str) -> str:
- """清洗 section 字段:过滤掉像正文内容而非章节路径的值"""
- if not raw:
- return ''
- import re as _re
-
- def _is_heading(part: str) -> bool:
- """判断一个字符串是否像章节标题(而非正文内容)"""
- p = part.strip()
- if not p:
- return False
- # 以句号/问号/感叹号结尾 → 正文句子
- if p.endswith(('。', '!', '?', '.', '!', '?')):
- return False
- # 含冒号且偏长 → 更像是内容摘要而非标题
- if ':' in p and len(p) > 25:
- return False
- # 长度超过 30 字 → 不像是标题
- if len(p) > 30:
- return False
- return True
-
- # 去掉 Markdown 加粗标记
- cleaned = raw.replace('**', '').strip()
-
- # 如果含 ' > ' 分隔符,验证每一级都是合法标题
- if ' > ' in cleaned:
- parts = [p.strip() for p in cleaned.split('>') if p.strip()]
- valid = [p for p in parts if _is_heading(p)]
- if valid:
- return ' > '.join(valid[:3])
- # 所有部分都不像标题 → 清空
- return ''
-
- # 匹配 【xxx】 或 第X篇/章/节 格式
- if _re.match(r'^(【[^】]+】|第[一二三四五六七八九十\d]+[篇章节部])', cleaned):
- return cleaned[:40]
- # 短字符串(< 30字)且不像句子(无句号/逗号),保留
- if len(cleaned) < 30 and not any(c in cleaned for c in '。,;:'):
- return cleaned
- # 其余视为正文内容,清空
- return ''
-
-
-def _build_citation(meta: Dict, full_content: str = '') -> Dict[str, Any]:
- """
- 根据文档类型构建定位信息
-
- 不同文档类型使用不同的定位策略:
- - PDF: 坐标定位(page + bbox)
- - Word: 语义定位(section + section_chunk_id + preview)
- - Excel: 表格定位(sheet + preview)
-
- Args:
- meta: 切片元数据
- full_content: 完整切片内容(可选)
-
- Returns:
- 引用信息字典,包含定位信息
-
- Example:
- >>> citation = _build_citation(meta, "完整内容...")
- >>> print(citation["page"]) # PDF: 页码
- >>> print(citation["section"]) # Word: 章节
- """
- # 从 chunk_id 中提取全局切片序号(格式: "filename_N")
- chunk_id_raw = meta.get('chunk_id', '')
- chunk_index = None
- if chunk_id_raw and '_' in str(chunk_id_raw):
- try:
- chunk_index = int(str(chunk_id_raw).rsplit('_', 1)[-1])
- except (ValueError, IndexError):
- chunk_index = meta.get('chunk_index')
- else:
- chunk_index = meta.get('chunk_index')
-
- citation = {
- "chunk_id": chunk_id_raw,
- "chunk_index": chunk_index, # 全局切片序号,用于精准定位文档位置
- "source": meta.get('source', ''),
- "collection": meta.get('_collection') or meta.get('collection', ''), # 所属向量库,用于前端文档预览跳转
- "doc_type": meta.get('doc_type', 'other'),
- "section": _clean_section(meta.get('section', '')),
- "preview": meta.get('preview', ''),
- "content": (full_content or meta.get('preview', ''))[:300], # 截断至 300 字避免冒返大量数据
- "chunk_type": meta.get('chunk_type', 'text'),
- }
-
- doc_type = meta.get('doc_type', 'other')
-
- if doc_type == 'pdf':
- # PDF: 坐标定位
- bbox_raw = meta.get('bbox')
- bbox = None
- if bbox_raw:
- try:
- bbox = json.loads(bbox_raw) if isinstance(bbox_raw, str) else bbox_raw
- except (json.JSONDecodeError, TypeError):
- bbox = bbox_raw
-
- citation.update({
- "page": meta.get('page'),
- "page_end": meta.get('page_end'),
- "bbox": bbox,
- "bbox_mode": meta.get('bbox_mode'),
- })
- elif doc_type == 'word':
- # Word: 语义定位
- citation.update({
- "section_chunk_id": meta.get('section_chunk_id'), # 章节内段落序号
- })
- elif doc_type == 'excel':
- # Excel: 表格定位
- citation.update({
- "page": meta.get('page'), # 工作表序号
- })
- else:
- # 其他类型:返回所有可用信息
- bbox_raw = meta.get('bbox')
- bbox = None
- if bbox_raw:
- try:
- bbox = json.loads(bbox_raw) if isinstance(bbox_raw, str) else bbox_raw
- except (json.JSONDecodeError, TypeError):
- bbox = bbox_raw
-
- citation.update({
- "page": meta.get('page'),
- "page_end": meta.get('page_end'),
- "bbox": bbox,
- "bbox_mode": meta.get('bbox_mode'),
- })
-
- return citation
-
-
-def score_image_relevance(query: str, meta: Dict, doc: str = '') -> float:
- """
- 图片相关性打分(语义增强版)
-
- 通过多维度特征评估图片与查询的相关性:
- 1. 图片编号精确匹配(如 "图2.1")
- 2. 关键词匹配(年份、数值+单位、中文词组)
- 3. 整体文本相似度
- 4. 章节匹配
- 5. 图片类型加分
-
- Args:
- query: 用户查询
- meta: 切片元数据
- doc: document 字段(包含图片描述和上下文)
-
- Returns:
- 相关性分数(>= 3.0 推荐展示)
-
- Example:
- >>> score = score_image_relevance("图2.1是什么?", meta, doc)
- >>> if score >= 3.0:
- ... # 推荐展示该图片
- """
- import re
- score = 0.0
-
- # 优先使用 doc 字段(包含完整描述和上下文)
- search_text = doc or meta.get('caption', '')
- section = meta.get('section', '') or meta.get('section_path', '')
- source = meta.get('source', '')
-
- # 1. 图片编号精确匹配(最高优先级)
- figure_pattern = r'图\s*(\d+\.?\d*)'
- figure_matches = re.findall(figure_pattern, query)
-
- if figure_matches:
- for fig_num in figure_matches:
- # 在所有文本中查找图号
- all_text = f"{search_text} {section} {source}"
- if f"图{fig_num}" in all_text or f"图 {fig_num}" in all_text or f"见图{fig_num}" in all_text:
- score += 10.0 # 精确匹配,直接返回
- return score
-
- # 1.5. 表格编号精确匹配(新增:支持表格图片)
- table_pattern = r'表\s*(\d+\.?\d*)'
- table_matches = re.findall(table_pattern, query)
-
- if table_matches:
- for table_num in table_matches:
- # 在所有文本中查找表号
- all_text = f"{search_text} {section} {source}"
- if f"表{table_num}" in all_text or f"表 {table_num}" in all_text or f"见表{table_num}" in all_text:
- score += 10.0 # 精确匹配,直接返回
- return score
-
- # 2. 查询词匹配(通用方式,不硬编码关键词)
- # 从查询中提取有意义的词:中文词组、数字+单位、年份等
- # 使用 jieba 分词(如果可用)或简单的正则提取
- query_keywords = []
-
- # 提取年份(如 "2003年")
- year_matches = re.findall(r'(\d{4})\s*年', query)
- query_keywords.extend(year_matches)
-
- # 提取数值+单位(如 "100亿"、"50万千瓦时")
- num_unit_matches = re.findall(r'(\d+\.?\d*\s*[亿万万千百吨米秒])', query)
- query_keywords.extend(num_unit_matches)
-
- # 使用 jieba 分词提取中文关键词
- import jieba
- jieba_words = [w for w in jieba.lcut(query) if len(w) >= 2]
- query_keywords.extend(jieba_words)
-
- # 过滤掉泛词(图、表、图片等)
- stop_words = {'图', '表', '图片', '图表', '如图', '所示', '如下', '如下表', '如下图', '的', '是', '在', '有', '了', '和', '与', '或', '及', '等', '中', '对'}
- query_keywords = [kw for kw in query_keywords if kw not in stop_words]
-
- # 在图片描述中匹配关键词
- keyword_match_score = 0.0
- for kw in query_keywords:
- if kw in search_text or kw in section:
- keyword_match_score += 2.0
-
- score += min(keyword_match_score, 8.0) # 最多加 8 分
-
- # 3. 整体文本相似度(字符级别)
- if search_text:
- # 复用已过滤停用词的 query_keywords,避免字符级误删(如"表现"→"现")
- query_core = "".join(query_keywords)
- if query_core:
- overlap = len(set(query_core) & set(search_text))
- score += min(overlap * 0.2, 3.0)
-
- # 4. 章节匹配
- if section:
- # 从查询中提取章节关键词(复用 jieba 分词)
- section_keywords = query_keywords
- for kw in section_keywords:
- if kw in section:
- score += 1.5
-
- # 5. 图片类型加分
- if meta.get('chunk_type') == 'chart':
- score += 2.0
- elif meta.get('chunk_type') == 'image':
- score += 1.0
-
- # 6. 检索相似度(如果有)
- retrieval_score = meta.get('score', 0)
- if retrieval_score > 0:
- score += min(retrieval_score * 2, 2.0)
-
- return score
-
-
-def _filter_images_by_answer(selected_images: List[Dict], answer: str) -> List[Dict]:
- """
- 后置图片过滤:根据 LLM 生成的回答内容反向筛选图片。
-
- 只有图片描述与回答内容有足够关键词重叠时才保留,
- 确保展示的图片与回答内容一致,避免不相关图片干扰用户。
-
- 过滤规则:
- 1. 从回答中提取 2 字及以上的中文关键词(jieba 分词)
- 2. 对每张图片,检查其描述(full_description / description)中匹配了多少关键词
- 3. 匹配数 >= 阈值则保留,否则丢弃
- 4. 如果过滤后图片为 0,保留分数最高的 1 张(兜底)
- 5. 用户明确指定图号(如图 2.1)时不过滤
-
- Args:
- selected_images: select_images 返回的候选图片列表
- answer: LLM 生成的回答文本
-
- Returns:
- 过滤后的图片列表
- """
- 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
-
- # 从回答中提取关键词
- try:
- import jieba
- answer_keywords = set(
- w for w in jieba.lcut(answer)
- 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))
-
- if not answer_keywords:
- return selected_images
-
- # 动态阈值:回答关键词越多,阈值越高(至少匹配 15% 或 2 个关键词,取较小值)
- threshold = max(2, min(3, round(len(answer_keywords) * 0.15)))
-
- filtered = []
- for img in selected_images:
- desc = img.get('full_description', '') or img.get('description', '') or ''
- if not desc:
- # 没有描述的图片,保留(无法判断)
- filtered.append(img)
- continue
-
- # 计算描述与回答的关键词重叠数
- overlap = sum(1 for kw in answer_keywords if kw in desc)
- if overlap >= threshold:
- filtered.append(img)
-
- # 兜底:如果过滤后为空,保留分数最高的 1 张
- if not filtered and selected_images:
- filtered = [max(selected_images, key=lambda x: x.get('score', 0))]
-
- if len(filtered) < len(selected_images):
- logger.info(f"[图片后置过滤] {len(selected_images)} → {len(filtered)} 张 "
- f"(回答关键词 {len(answer_keywords)} 个, 阈值 {threshold})")
-
- return filtered
-
-
-def select_images(contexts: List[Dict], query: str) -> List[Dict]:
- """
- 选择要展示的图片(打分排序 + 预算控制)
-
- 根据查询意图动态调整图片数量上限:
- - 精确查图(指定图号): 最多 2 张
- - 强图片意图(示意图、流程图等): 最多 3 张
- - 列举型查询: 最多 5 张
- - 普通查询: 最多 2 张
-
- 核心逻辑:
- 1. 检测查询中的图号引用
- 2. 从检索文本中提取图表引用
- 3. 对图片打分并过滤低分图片
- 4. 通过章节关联排除不相关图片
-
- Args:
- contexts: 检索上下文列表
- query: 用户查询
-
- Returns:
- 精选图片列表(每项含 score, id, url, type, source 等字段)
-
- Example:
- >>> images = select_images(contexts, "图2.1展示了什么?")
- >>> print(len(images)) # <= 2
- """
- import re
-
- # 动态预算:数据驱动,不依赖硬编码关键词列表
- # 核心策略:宽松预选 + 后置过滤(_filter_images_by_answer)精准裁剪
-
- # 精确查图:用户指定了具体图号(如 "图2.3")—— 结构化模式匹配,非硬编码
- figure_pattern = r'图\s*(\d+\.?\d*)'
- figure_matches = re.findall(figure_pattern, query)
- has_figure_query = bool(figure_matches)
-
- # 数据驱动的图片意图检测:检查检索结果中是否包含图片/图表类型切片
- # 原理:如果向量检索返回了 image/chart 类型 chunk 或含 images_json 的 table chunk,
- # 说明知识库中存在与查询语义相关的图片内容,应给予展示机会
- has_image_data = False
- _image_chunk_count = 0
- _table_image_count = 0
- for ctx in contexts:
- meta = ctx.get('meta', {})
- ct = meta.get('chunk_type', '')
- if ct in ('image', 'chart'):
- _image_chunk_count += 1
- has_image_data = True
- if ct == 'table' and meta.get('images_json'):
- _table_image_count += 1
- has_image_data = True
-
- # 从检索文本中提取图表引用(见表2.2、见图2.5 等)
- # 重要:只从语义相关的 top 5 文本块提取,避免不相关引用干扰
- referenced_figures = {} # {图号: set(文件来源)}
- referenced_tables = {} # {表号: set(文件来源)}
-
- for ctx in contexts[:5]:
- doc_text = ctx.get('doc', '')
- source = ctx.get('meta', {}).get('source', '')
-
- fig_refs = re.findall(r'(?:[见如])?图\s*(\d+\.?\d*)', doc_text)
- for fig_num in fig_refs:
- if fig_num not in referenced_figures:
- referenced_figures[fig_num] = set()
- if source:
- referenced_figures[fig_num].add(source)
-
- table_refs = re.findall(r'(?:[见如])?表\s*(\d+\.?\d*)', doc_text)
- for table_num in table_refs:
- if table_num not in referenced_tables:
- referenced_tables[table_num] = set()
- if source:
- referenced_tables[table_num].add(source)
-
- has_referenced_figures = bool(referenced_figures or referenced_tables)
-
- # 获取检索结果中涉及的主要文件来源
- primary_sources = set()
- for ctx in contexts[:5]:
- source = ctx.get('meta', {}).get('source', '')
- if source:
- primary_sources.add(source)
-
- # 动态预算:根据检索结果数据驱动设置
- # 后置过滤 _filter_images_by_answer 会根据回答内容精准裁剪
- if has_figure_query:
- # 精确查图:用户指定了具体图号
- MAX_IMAGES = 2
- MIN_SCORE = 5.0
- elif has_image_data:
- # 检索结果中有图片数据 → 宽松预算,给后置过滤留足候选空间
- MAX_IMAGES = 5
- MIN_SCORE = 2.0
- elif has_referenced_figures:
- # 检索文本中引用了图表编号
- MAX_IMAGES = 3
- MIN_SCORE = 2.0
- else:
- # 无图片数据 → 保守默认值
- MAX_IMAGES = 2
- MIN_SCORE = 3.0
-
- # 动态调整:当表格嵌入大量图片时,提升上限以展示完整内容
- if has_image_data and _table_image_count > 0:
- total_table_images = 0
- for ctx in contexts:
- meta = ctx.get('meta', {})
- if meta.get('chunk_type') == 'table' and meta.get('images_json'):
- try:
- total_table_images += len(json.loads(meta['images_json']))
- except (json.JSONDecodeError, TypeError):
- pass
- if total_table_images > MAX_IMAGES:
- MAX_IMAGES = min(total_table_images, 15) # 上限 15,防止图片过多
-
- # 获取检索结果中涉及的主要章节路径(只看前 3 个最相关的文本块)
- primary_section_paths = set()
- for ctx in contexts[:3]:
- section = ctx.get('meta', {}).get('section', '') or ctx.get('meta', {}).get('section_path', '')
- if section:
- primary_section_paths.add(section)
-
- # ========== P1.5 预计算:表格主题相关性评分 ==========
- # 从查询中提取关键词片段(使用 jieba 分词 + 2字及以上的词,自动过滤停用词)
- try:
- import jieba
- _query_kw_segments = [w for w in jieba.lcut(query)
- if len(w) >= 2 and re.search(r'[\u4e00-\u9fff]', w)]
- except ImportError:
- # jieba 不可用时回退到 bigram
- _chars = re.findall(r'[\u4e00-\u9fff]', query)
- _query_kw_segments = [_chars[i] + _chars[i+1] for i in range(len(_chars) - 1)]
-
- # 对所有含 images_json 的表格切片计算主题匹配分
- _table_topic_scores = {}
- for _tc in contexts:
- _tm = _tc.get('meta', {})
- if _tm.get('chunk_type') == 'table' and _tm.get('images_json'):
- _t_section = (_tm.get('section', '') or _tm.get('section_path', ''))
- _t_title = _tm.get('title', '') or ''
- _t_combined = _t_title + _t_section
- _score = sum(1 for kw in _query_kw_segments if kw in _t_combined)
- _table_topic_scores[id(_tc)] = _score
-
- # 自适应阈值:要求至少匹配 70% 的最佳表格得分(至少 2 分)
- # 例如查询 "设施设备的参考样式" → 4 个关键词 → 最佳匹配 4 → 阈值 max(2, 2) = 2
- # "形象识别标识" 只匹配 "参考"+"样式" = 2 → 但阈值=3(70%of4)时被过滤
- _best_table_score = max(_table_topic_scores.values()) if _table_topic_scores else 0
- _table_topic_threshold = max(2, round(_best_table_score * 0.7)) if _best_table_score >= 2 else 0
-
- scored_images = []
- for ctx in contexts:
- meta = ctx.get('meta', {})
- chunk_type = meta.get('chunk_type', 'text')
- s = None # P1 评分,用于 P1.5 继承
- doc = ctx.get('doc', '') # 默认文档内容
-
- # 处理图片类型和有关联图片的表格类型
- if meta.get('image_path') and chunk_type in ('image', 'chart', 'table'):
- # 优先使用 VLM 详细描述
- # 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', '')
- s = score_image_relevance(query, meta, doc)
-
- # ========== VLM 相关性筛选(方案 C)==========
- # 用 VLM 描述判断图片内容是否与查询相关
- # 优先级:meta.vlm_desc(已同步) > .data/cache/vlm/(懒加载缓存)
- image_path = meta.get('image_path', '')
- vlm_desc = meta.get('vlm_desc', '') or _get_vlm_cache(image_path)
- if vlm_desc:
- vlm_relevance = _check_vlm_relevance(query, vlm_desc)
- if vlm_relevance < 0.3:
- # VLM 描述与查询不相关,适度降分
- s -= 3.0
- logger.debug(f"图片 {image_path} VLM 不相关,降分: {vlm_relevance:.2f}")
- elif vlm_relevance >= 0.5:
- # 相关,小幅加分
- s += 2.0
-
- # 图片来源
- img_source = meta.get('source', '')
-
- # 图片章节
- img_section = meta.get('section', '') or meta.get('section_path', '')
-
- # ========== 章节关联检测:基于层级相似度,无需硬编码格式假设 ==========
- # 计算图片章节与主要检索结果的最高相似度
- section_penalty = 0.0
- max_section_sim = 0.0
- if primary_section_paths:
- for ps in primary_section_paths:
- sim = _section_similarity(img_section, ps)
- max_section_sim = max(max_section_sim, sim)
- # 当相似度低于阈值且有足够的章节信息时,判定为不相关
- if primary_section_paths and img_section and max_section_sim < 0.3:
- # 图片章节与主要检索结果不匹配,惩罚
- section_penalty = -5.0
- # 除非图片被文本切片明确引用
- is_referenced = False
- for fig_num in referenced_figures:
- if f"图{fig_num}" in doc or f"图 {fig_num}" in doc:
- is_referenced = True
- break
- if not is_referenced:
- for table_num in referenced_tables:
- if f"表{table_num}" in doc or f"表 {table_num}" in doc:
- is_referenced = True
- break
- if is_referenced:
- section_penalty = 0.0 # 被引用则不惩罚
-
- s += section_penalty
-
- # 新增:如果图片描述中包含检索文本引用的图号,大幅加分
- # 前提:图片章节与主要检索结果的章节相关
- if referenced_figures:
- for fig_num, sources in referenced_figures.items():
- # 只检查 doc 字段,不检查 meta(避免 section 中的误匹配)
- if f"图{fig_num}" in doc or f"图 {fig_num}" in doc:
- # 使用层级相似度判断章节关联性
- section_match = max_section_sim >= 0.3
-
- # 章节匹配时才加分
- if section_match:
- # 图号匹配加分
- s += 8.0
- # 如果图片来源与引用来源一致,额外加分
- if img_source in sources:
- s += 5.0 # 文件匹配额外加分
- break
-
- # 新增:如果表格描述中包含检索文本引用的表号,大幅加分
- # 同样要求章节相关性
- if referenced_tables:
- for table_num, sources in referenced_tables.items():
- # 只检查 doc 字段
- if f"表{table_num}" in doc or f"表 {table_num}" in doc:
- # 使用层级相似度判断章节关联性
- section_match = max_section_sim >= 0.3
-
- # 章节匹配时才加分
- if section_match:
- # 表号匹配加分
- s += 8.0
- # 如果图片来源与引用来源一致,额外加分
- if img_source in sources:
- s += 5.0 # 文件匹配额外加分
- break
-
- # 新增:如果图片来源在主要检索结果中,加分
- if img_source in primary_sources:
- s += 2.0
-
- if s >= MIN_SCORE:
- scored_images.append({
- 'score': s,
- 'id': os.path.basename(meta['image_path']),
- 'url': f"/images/{os.path.basename(meta['image_path'])}",
- 'type': meta['chunk_type'],
- 'source': meta.get('source'),
- 'page': meta.get('page'),
- 'description': doc[:100], # 短描述用于 UI 展示
- 'full_description': doc # Bug 6b 修复:完整描述用于 LLM 上下文
- })
-
- # ========== P1.5:处理表格切片的 images_json(跨页表格多图)==========
- # 当表格切片有 images_json 字段时,添加所有关联图片
- if chunk_type == 'table' and meta.get('images_json'):
- # 如果 P1 未执行(表格无 image_path),需要独立计算分数
- if s is None:
- doc = ctx.get('image_description', '') or meta.get('vlm_desc', '') or ctx.get('doc', '')
- s = score_image_relevance(query, meta, doc)
-
- # 章节相关性过滤:使用层级相似度,无需硬编码章节格式假设
- table_section = meta.get('section', '') or meta.get('section_path', '')
-
- section_relevant = True
- if primary_section_paths and table_section:
- # 计算表格章节与所有主要章节的最高相似度
- max_sim = max(
- (_section_similarity(table_section, ps) for ps in primary_section_paths),
- default=0.0
- )
- if max_sim < 0.3:
- section_relevant = False
- elif primary_section_paths and not table_section:
- # 表格无章节信息时优雅降级:不做章节过滤,仅依赖主题分数
- pass
-
- if not section_relevant:
- # 例外:如果表格标题/内容被查询直接提及,仍视为相关
- table_title = meta.get('title', '') or ''
- table_doc = ctx.get('doc', '') or ''
- if table_title and table_title in query:
- section_relevant = True
- elif table_doc and any(kw in table_doc for kw in query.split() if len(kw) >= 2):
- section_relevant = True
-
- if not section_relevant:
- logger.debug(f"P1.5 跳过无关表格图片: path={table_section}, title={meta.get('title', '')}")
- continue # 跳过此表格的所有嵌入图片
-
- # 标题/主题相关性过滤:基于预计算的关键词匹配评分
- # 如果最佳表格得分 >= 2,则过滤掉得分为 0 的表格
- _this_topic_score = _table_topic_scores.get(id(ctx), 0)
- if _this_topic_score < _table_topic_threshold:
- logger.debug(f"P1.5 跳过主题不匹配表格: topic_score={_this_topic_score}, threshold={_table_topic_threshold}, title={meta.get('title', '')}")
- continue
-
- try:
- images_list = json.loads(meta['images_json'])
- for img_info in images_list:
- if isinstance(img_info, dict):
- img_id = img_info.get('id') or img_info.get('path', '')
- img_page = img_info.get('page', meta.get('page'))
- else:
- img_id = str(img_info)
- img_page = meta.get('page')
-
- # 跳过已添加的图片(避免重复)
- existing_ids = {img['id'] for img in scored_images}
- if img_id and img_id not in existing_ids:
- # 为关联图片计算分数(继承主表格分数,略低)
- assoc_score = s - 1.0 if s >= MIN_SCORE else MIN_SCORE - 1.0
- if assoc_score >= MIN_SCORE:
- scored_images.append({
- 'score': assoc_score,
- 'id': img_id,
- 'url': f"/images/{img_id}",
- 'type': 'table_image',
- 'source': meta.get('source'),
- 'page': img_page,
- 'description': doc[:100],
- 'full_description': doc
- })
- except (json.JSONDecodeError, TypeError):
- pass
-
- # ========== P2:通过文本切片的 referenced_images 补充图片 ==========
- # 检查 top 5 文本切片的 referenced_images,补充未选中的关联图片
- existing_image_ids = {img['id'] for img in scored_images}
-
- for ctx in contexts[:5]:
- meta = ctx.get('meta', {})
- if meta.get('chunk_type') != 'text':
- continue
-
- referenced = meta.get('referenced_images', [])
- if not referenced:
- continue
-
- # 查找对应的图片切片
- for fig_num in referenced:
- # 在所有 contexts 中查找匹配的图片
- for img_ctx in contexts:
- img_meta = img_ctx.get('meta', {})
- if img_meta.get('chunk_type') not in ('image', 'chart', 'table'):
- continue
-
- img_path = img_meta.get('image_path', '')
- img_id = os.path.basename(img_path)
-
- # 检查是否已存在
- if img_id in existing_image_ids:
- continue
-
- # 检查图号/表号是否匹配
- img_doc = img_ctx.get('doc', '')
- if f"图{fig_num}" in img_doc or f"表{fig_num}" in img_doc:
- # 添加到结果中
- scored_images.append({
- 'score': 8.0, # 基础分
- 'id': img_id,
- 'url': f"/images/{img_id}",
- 'type': img_meta.get('chunk_type'),
- 'source': img_meta.get('source'),
- 'page': img_meta.get('page'),
- 'description': img_doc[:100], # 短描述用于 UI 展示
- 'full_description': img_doc # Bug 6b 修复:完整描述用于 LLM 上下文
- })
- existing_image_ids.add(img_id)
- break
-
- scored_images.sort(key=lambda x: x['score'], reverse=True)
- return scored_images[:MAX_IMAGES]
-
-def chat_with_llm(message: str, history: List[Dict] = None, enable_web_search: bool = True) -> Dict[str, Any]:
- """
- 普通聊天 - 使用 LLM 直接回复
-
- 当查询不需要知识库检索时,直接调用 LLM 进行回复。
- 可选启用网络搜索增强。
-
- Args:
- message: 用户消息
- history: 对话历史(由后端传入)
- enable_web_search: 是否启用网络搜索
-
- Returns:
- {
- "answer": str,
- "sources": list,
- "web_searched": bool
- }
-
- Example:
- >>> result = chat_with_llm("你好", enable_web_search=False)
- >>> print(result["answer"])
- """
- from config import get_llm_client, LLM_MAX_TOKENS
-
- client = get_llm_client()
-
- # 构建消息
- messages = []
-
- # 添加历史
- if history:
- for h in history[-MAX_HISTORY_ROUNDS:]:
- messages.append({"role": h["role"], "content": h["content"]})
-
- messages.append({"role": "user", "content": message})
-
- # 调用 LLM
- answer = call_llm(client, prompt="", model=RAG_CHAT_MODEL, messages=messages, max_tokens=LLM_MAX_TOKENS)
-
- return {
- "answer": answer or "",
- "sources": [],
- "web_searched": False
- }
-
-
-def search_hybrid(query: str, top_k: int = 5,
- allowed_levels: list = None, allowed_collections: list = None,
- sub_queries: list = None):
- """
- 混合检索:直接调用生产环境引擎,确保测试效果与生产一致
-
- Args:
- query: 查询文本
- top_k: 返回数量
- allowed_levels: 允许的安全级别
- allowed_collections: 允许的向量库列表
- sub_queries: 意图分析器生成的子查询列表(对比类查询用)
-
- Returns:
- 融合后的检索结果
- """
- from core.engine import get_engine
-
- engine = get_engine()
-
- # 直接调用生产环境的检索方法
- result = engine.search_knowledge(
- query=query,
- top_k=top_k,
- allowed_levels=allowed_levels,
- collections=allowed_collections,
- sub_queries=sub_queries
- )
-
- # 添加 scores 字段(用于前端显示和上下文过滤)
- if result and result.get('ids') and result['ids'][0]:
- distances = result.get('distances', [[]])[0]
- if result.get('_reranked'):
- # Rerank 后 distances 中存储的是相关性分数(越高越好),直接使用
- scores = [float(d) for d in distances]
- else:
- # 未 Rerank:将向量距离转换为相似度分数(距离越小,分数越高)
- scores = [1.0 - d if d <= 1.0 else 1.0 / (1.0 + d) for d in distances]
- result['scores'] = [scores]
- else:
- result = {
- 'ids': [[]],
- 'documents': [[]],
- 'metadatas': [[]],
- 'distances': [[]],
- 'scores': [[]]
- }
-
- return result
-
-
-# ==================== 路由 ====================
-
-@chat_bp.route('/chat', methods=['POST'])
-@require_gateway_auth
-def chat():
- """
- 普通聊天模式 - 直接使用LLM回复
-
- 请求体:
- {
- "message": "消息内容",
- "history": [{"role": "user/assistant", "content": "..."}] // 可选
- }
- """
- data = request.json or {}
- message = data.get('message')
- history = data.get('history', [])
-
- if not message:
- return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 message", http_status=400)
-
- # 输入安全验证
- is_valid, reason = validate_query(message)
- if not is_valid:
- return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
-
- # 智能聊天
- result = chat_with_llm(message, history)
-
- # 过滤敏感信息
- answer = filter_response(result["answer"])
-
- return success_response(data={
- "answer": answer,
- "mode": "chat",
- "sources": result.get("sources", []),
- "web_searched": result.get("web_searched", False)
- })
-
-
-@chat_bp.route('/rag', methods=['POST'])
-@require_gateway_auth
-def rag():
- """
- 知识库问答模式 - SSE 流式返回
-
- 请求体:
- {
- "message": "消息内容",
- "history": [{"role": "user/assistant", "content": "..."}], // 可选(开发环境)
- "chat_history": [{"role": "user/assistant", "content": "..."}], // 可选(生产环境)
- "collections": ["public_kb"], // 可选,知识库列表
- "session_id": "xxx" // 可选,会话ID
- }
-
- SSE 事件序列:
- 1. start: 开始处理
- 2. sources: 检索到的来源
- 3. chunk: 每个 token
- 4. finish: 完成响应(包含完整 answer 和 sources)
- 5. error: 错误事件
- """
- import re
- from config import (
- IS_PROD, IS_DEV, ENABLE_SESSION,
- RAG_SEARCH_TOP_K,
- MAX_CONTEXT_CHUNKS, MAX_SOURCES_RETURNED,
- LLM_TEMPERATURE, LLM_MAX_TOKENS,
- MAX_HISTORY_ROUNDS, IMAGE_CONTEXT_HISTORY,
- DIRECT_CONTEXT_MAX_CHARS,
- RERANK_CONTEXT_MIN_SCORE,
- CONTEXT_MAX_CHARS, CONTEXT_SOFT_LIMIT,
- CONFIDENCE_WARN_THRESHOLD, CONFIDENCE_CAUTION_THRESHOLD,
- SECTION_CLUSTER_RESCUE_ENABLED, CLUSTER_MIN_MEMBERS, CLUSTER_MIN_TYPES,
- CLUSTER_RESCUE_FLOOR, CLUSTER_MAX_SECTIONS, CLUSTER_MAX_RESCUE_PER_SECTION,
- CLUSTER_SECTION_PREFIX_LEVELS
- )
-
- data = request.json or {}
-
- message = data.get('message')
- # 兼容两种参数名:history(旧)和 chat_history(新)
- # 用 is not None 判断,避免 [] 被 or 吞掉
- if 'chat_history' in data:
- history = data['chat_history']
- elif 'history' in data:
- history = data['history']
- else:
- history = None
- collections = data.get('collections')
- session_id = data.get('session_id')
-
- if not message:
- return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 message", http_status=400)
-
- # 生产环境强制校验 chat_history
- if IS_PROD and history is None:
- return error_response("MISSING_HISTORY", BAD_REQUEST, "chat_history is required in production", http_status=400)
-
- # 输入安全验证
- is_valid, reason = validate_query(message)
- if not is_valid:
- return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
-
- # 如果没有指定 collections,使用默认的公开库
- if not collections:
- collections = ['public_kb']
-
- # ==================== 会话历史加载 ====================
- # 优先使用传入的 history,否则从 session_repo 加载
- user_id = request.current_user.get("user_id")
-
- if history is not None:
- # 使用传入的历史(生产环境必须传入)
- pass
- elif session_id and ENABLE_SESSION:
- # 开发环境:从本地数据库加载
- try:
- session_repo = current_app.session_repo
- history = session_repo.get_history(session_id)
- # 限制历史长度
- history = history[-MAX_HISTORY_ROUNDS:] if len(history) > MAX_HISTORY_ROUNDS else history
- except Exception as e:
- logger.debug(f"解析历史记录失败: {e}")
- history = []
- else:
- history = []
-
- # 如果没有 session_id,创建新会话(仅开发环境)
- if not session_id and ENABLE_SESSION:
- try:
- session_repo = current_app.session_repo
- session_id = session_repo.create_session(user_id)
- except Exception as e:
- logger.debug(f"创建会话失败: {e}")
-
- # ==================== collections 历史推断(开发环境) ====================
- # 当 collections 未显式指定(或仅为默认 public_kb)且有会话历史时,
- # 从历史消息中推断上次使用的 KB,自动恢复以避免用户忘切 KB
- if (not collections or collections == ['public_kb']) and history:
- for _msg in reversed(history):
- if _msg.get("role") == "assistant":
- _meta = _msg.get("metadata", {})
- if isinstance(_meta, dict) and _meta.get("collections"):
- collections = _meta["collections"]
- logger.info(f"[KB推断] 从历史推断 collections: {collections}")
- break
-
- # 提前获取 session_repo 引用,避免在生成器内部访问 current_app
- # (生成器执行时应用上下文可能已结束)
- session_repo_ref = None
- if ENABLE_SESSION:
- try:
- session_repo_ref = current_app.session_repo
- except Exception as e:
- logger.debug(f"获取会话仓库失败: {e}")
-
- def generate():
- """生成 SSE 流"""
- import re
- start_time = _time.time()
- full_answer = []
-
- try:
- # 0. 意图分析(改写 + 双层判断)
- context_images = []
- if history:
- # 从历史中提取图片信息
- for msg in reversed(history[-IMAGE_CONTEXT_HISTORY:]):
- metadata = msg.get("metadata", {})
- if isinstance(metadata, dict):
- images = metadata.get("images", [])
- if images:
- context_images.extend(images[:3])
-
- intent = None
- try:
- from core.intent_analyzer import analyze_intent
- intent = analyze_intent(message, history or [], context_images)
-
- logger.info(f"[意图分析] use_context={intent.use_context}, need_retrieval={intent.need_retrieval}, intent={intent.intent}, sub_queries={intent.sub_queries}")
-
- # 调试事件:意图分析结果
- if IS_DEV:
- yield f"data: {json.dumps({'type': 'intent_result', 'data': {'intent': intent.intent, 'confidence': round(intent.confidence, 2), 'rewritten_query': intent.rewritten_query, 'sub_queries': intent.sub_queries, 'need_retrieval': intent.need_retrieval, 'reason': intent.reason}}, ensure_ascii=False)}\n\n"
-
- # 如果不需要检索,直接使用上下文回答
- if not intent.need_retrieval and intent.use_context:
- yield f"data: {json.dumps({'type': 'start', 'message': '正在分析...'}, ensure_ascii=False)}\n\n"
-
- # 构建上下文
- context_text = ""
- if history:
- # 提取最近的助手回答
- for msg in reversed(history):
- if msg.get("role") == "assistant":
- context_text = msg.get("content", "")
- break
-
- # 构建图片上下文
- image_context = ""
- if context_images:
- image_context = "\n\n【上下文中的图片】\n"
- for img in context_images[:5]:
- if isinstance(img, dict):
- desc = img.get("description", "")
- img_type = img.get("type", "图片")
- image_context += f"- {img_type}: {desc}\n"
-
- # 直接调用 LLM
- from config import get_llm_client, DASHSCOPE_MODEL
- client = get_llm_client()
-
- system_prompt = f"""你是一个专业的知识库问答助手。请根据对话历史和上下文回答用户问题。
-
-如果用户问题是关于图片的,请根据上下文中的图片描述进行分析。
-
-{image_context}"""
-
- user_prompt = f"""对话历史:
-{context_text[:DIRECT_CONTEXT_MAX_CHARS] if context_text else '(无历史上下文)'}
-
-用户问题:{intent.rewritten_query}
-
-请直接回答用户问题。"""
-
- # 流式生成回答
- for content in call_llm_stream(
- client,
- prompt=user_prompt,
- model=DASHSCOPE_MODEL,
- messages=[
- {"role": "system", "content": system_prompt},
- {"role": "user", "content": user_prompt}
- ],
- temperature=LLM_TEMPERATURE
- ):
- full_answer.append(content)
- yield f"data: {json.dumps({'type': 'chunk', 'content': content}, ensure_ascii=False)}\n\n"
-
- # 发送完成事件
- yield f"data: {json.dumps({'type': 'finish', 'answer': ''.join(full_answer), 'sources': []}, ensure_ascii=False)}\n\n"
- return # 直接返回,不执行后续检索
-
- except Exception as e:
- logger.warning(f"意图分析失败: {e},继续执行检索流程")
-
- # 构建检索查询:使用改写后的完整问题(解决追问偏离问题)
- retrieval_query = intent.rewritten_query if (intent and intent.rewritten_query) else message
-
- # 1.5 语义缓存检查(跳过检索+生成全流程)
- _semantic_cache_emb = None
- try:
- from core.semantic_cache import get_semantic_cache
- from core.engine import get_engine as _get_eng
- _sc = get_semantic_cache()
- _eng = _get_eng()
- if _sc and _eng and hasattr(_eng, 'embedding_model'):
- # 缓存 key 使用 retrieval_query + collections,避免不同上下文的追问命中错误缓存
- _cache_collections = ','.join(sorted(collections)) if collections else ''
- _cache_key_text = f"{retrieval_query}|{_cache_collections}"
- _semantic_cache_emb = _eng.embedding_model.encode(_cache_key_text)
- cached = _sc.get(_semantic_cache_emb)
- # 防御性校验:必须是 RAG 回答缓存(非 intent_analyzer 缓存),且回答非空
- if cached is not None and cached.get("cache_type") == "rag_answer" and cached.get("answer"):
- logger.info(f"[语义缓存] 命中: {message[:50]}...")
- cached_answer = cached.get("answer", "")
- # 流式返回缓存的答案
- yield f"data: {json.dumps({'type': 'start', 'message': '正在检索知识库...'}, ensure_ascii=False)}\n\n"
- # 分块发送缓存答案
- chunk_size = 20
- for i in range(0, len(cached_answer), chunk_size):
- chunk = cached_answer[i:i+chunk_size]
- full_answer.append(chunk)
- yield f"data: {json.dumps({'type': 'chunk', 'content': chunk}, ensure_ascii=False)}\n\n"
- finish_event = {
- "type": "finish",
- "answer": cached_answer,
- "mode": "rag",
- "session_id": session_id,
- "sources": cached.get("sources", []),
- "citations": cached.get("citations", []),
- "images": cached.get("images", []),
- "tables": cached.get("tables", []),
- "sections": [],
- "duration_ms": int((_time.time() - start_time) * 1000),
- "confidence_score": 1.0,
- "semantic_cache_hit": True
- }
- yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n"
- return
- except Exception as e:
- logger.warning(f"[语义缓存] 检查失败: {e}")
- _semantic_cache_emb = None
-
- # 1. 发送开始事件
- yield f"data: {json.dumps({'type': 'start', 'message': '正在检索知识库...'}, ensure_ascii=False)}\n\n"
-
- # 2. 执行混合检索(扩大召回数量,确保图片切片有机会被召回)
- # 如果意图分析生成了子查询(对比类),传给搜索引擎并行检索
- sub_queries = None
- if intent and intent.sub_queries and len(intent.sub_queries) > 1:
- sub_queries = intent.sub_queries
-
- search_result = search_hybrid(
- retrieval_query,
- top_k=RAG_SEARCH_TOP_K,
- allowed_collections=collections,
- sub_queries=sub_queries
- )
-
- # 调试事件:检索管线详情
- if IS_DEV:
- debug_info = search_result.get('_debug', {})
- yield f"data: {json.dumps({'type': 'retrieval_debug', 'data': {'steps': debug_info.get('steps', []), 'collections_searched': collections, 'total_candidates': len(search_result.get('ids', [[]])[0])}}, ensure_ascii=False)}\n\n"
-
- # 提取章节聚类提升事件(引擎层)单独发送
- for step in debug_info.get('steps', []):
- if step.get('name') == 'section_cluster_boost':
- yield f"data: {json.dumps({'type': 'section_cluster_boost', 'data': step}, ensure_ascii=False)}\n\n"
-
- # 提取上下文
- contexts = []
- sources = []
-
- if search_result.get('documents') and search_result['documents'][0]:
- docs = search_result['documents'][0]
- metas = search_result.get('metadatas', [[]])[0]
- scores = search_result.get('scores', [[]])[0]
-
- # 图片相关性提升:数据驱动检测(无硬编码关键词)
- import re
- figure_pattern = r'图\s*(\d+\.?\d*)'
- figure_matches = re.findall(figure_pattern, retrieval_query)
- has_figure_query = bool(figure_matches)
-
- # 数据驱动:检查检索结果中是否有图片/图表类型切片
- has_image_data = any(
- m.get('chunk_type') in ('image', 'chart') for m in metas
- )
- has_image_intent = has_figure_query or has_image_data
-
- # 给图片/图表切片打 boost 标记,供后续 select_images 使用
- if has_image_intent:
- for i, (doc, meta, score) in enumerate(zip(docs, metas, scores)):
- if meta.get('chunk_type') in ('image', 'chart'):
- # 检查 caption 是否与查询相关
- caption = meta.get('caption', '') or ''
- should_boost = False
- boost_factor = 1.0
-
- # 如果查询包含图片编号,检查 caption 是否匹配
- if has_figure_query:
- for fig_num in figure_matches:
- if f"图{fig_num}" in caption or f"图 {fig_num}" in caption:
- should_boost = True
- boost_factor = 2.0 # 强提升
- break
-
- # 或者 caption 与查询有足够重叠
- if not should_boost:
- overlap = len(set(message) & set(caption))
- if overlap >= 3 or any(word in caption for word in message if len(word) >= 3):
- should_boost = True
- boost_factor = 1.5 # 提升 50%
-
- if should_boost:
- meta['_image_boost'] = boost_factor # 打标记,不重排
- # 不做 sort!保持检索引擎的原始排序
-
- # 按 source 去重,保留最高分
- seen_sources = {}
- for rank, (doc, meta, score) in enumerate(zip(docs, metas, scores)):
- meta['_retrieval_rank'] = rank
- # 确保 _collection 字段存在(单知识库路径下 ChromaDB 原生不返回此字段)
- if not meta.get('_collection'):
- # 优先使用入库时写入的 collection 字段
- meta['_collection'] = meta.get('collection') or (collections[0] if collections else 'public_kb')
- source_name = meta.get('source', '未知')
- if source_name not in seen_sources or score > seen_sources[source_name]['score']:
- doc_type = meta.get('doc_type', 'other')
- page = meta.get('page', 0)
- page_end = meta.get('page_end')
- # 仅 PDF 的页码是真实可靠的;Word 等的页码是 MinerU 合成的,无意义,不外露
- if doc_type == 'pdf' and page:
- page_range = f"{page}-{page_end}" if (page_end and page_end > page) else str(page)
- else:
- page = None
- page_end = None
- page_range = ''
-
- seen_sources[source_name] = {
- 'source': source_name,
- 'page': page,
- 'page_end': page_end,
- 'page_range': page_range,
- 'section': meta.get('section', '') or meta.get('section_path', ''),
- 'chunk_type': meta.get('chunk_type', 'text'),
- 'doc_type': doc_type, # 文档类型
- 'section_chunk_id': meta.get('section_chunk_id'), # 章节内序号
- 'score': round(score, 3) if isinstance(score, float) else score
- }
- # ========== P1:图片使用 full_description ==========
- # 图片切片使用完整描述(用于 LLM 上下文),而非短摘要
- display_doc = doc
- if meta.get('chunk_type') in ('image', 'chart', 'table'):
- full_desc = meta.get('full_description', '')
- if full_desc:
- display_doc = full_desc
-
- # contexts 仍然保留所有结果用于生成答案
- contexts.append({'doc': display_doc, 'meta': meta, 'score': score})
-
- sources = list(seen_sources.values())
-
- # 调试事件:召回切片详情
- if IS_DEV:
- chunks_debug = []
- for i, ctx in enumerate(contexts[:20]):
- m = ctx.get('meta', {})
- chunks_debug.append({
- 'rank': i + 1,
- 'source': m.get('source', ''),
- 'page': m.get('page', 0),
- 'chunk_type': m.get('chunk_type', 'text'),
- 'section': m.get('section', ''),
- 'score': round(ctx.get('score', 0), 4) if ctx.get('score') else None,
- 'content': (ctx.get('doc', '') or '')[:300]
- })
- yield f"data: {json.dumps({'type': 'chunks_retrieved', 'data': {'count': len(contexts), 'chunks': chunks_debug}}, ensure_ascii=False)}\n\n"
-
- # 补充检索:从文本切片中提取图号/表号引用,补充检索对应的图片
- # 重要:只从最相关的 top 5 文本切片提取引用,避免不相关引用干扰
- import re
- referenced_figures = set()
- referenced_tables = set()
-
- # 只检查 top 5 文本切片(与 select_images 逻辑一致)
- text_contexts = [ctx for ctx in contexts if ctx.get('meta', {}).get('chunk_type') == 'text'][:5]
- for ctx in text_contexts:
- doc_text = ctx.get('doc', '')
- fig_refs = re.findall(r'(?:[见如及和与])?图\s*(\d+\.?\d*)', doc_text)
- referenced_figures.update(fig_refs)
- table_refs = re.findall(r'(?:[见如及和与])?表\s*(\d+\.?\d*)', doc_text)
- referenced_tables.update(table_refs)
-
- # 检查哪些图号/表号对应的图片不在 contexts 中
- existing_figure_images = set()
- existing_table_images = set()
- for ctx in contexts:
- doc = ctx.get('doc', '')
- meta = ctx.get('meta', {})
- if meta.get('chunk_type') in ('image', 'chart'):
- for fig_num in referenced_figures:
- if f"图{fig_num}" in doc:
- existing_figure_images.add(fig_num)
- for table_num in referenced_tables:
- if f"表{table_num}" in doc:
- existing_table_images.add(table_num)
-
- # 需要补充检索的图号/表号
- missing_figures = referenced_figures - existing_figure_images
- missing_tables = referenced_tables - existing_table_images
-
- # 计算主要章节路径(用于补充检索过滤)
- primary_section_paths_for_supp = set()
- for ctx in text_contexts[:3]:
- section = ctx.get('meta', {}).get('section', '') or ctx.get('meta', {}).get('section_path', '')
- if section:
- primary_section_paths_for_supp.add(section)
-
- if missing_figures or missing_tables:
- # 补充检索
- from knowledge.manager import get_kb_manager
- kb_manager = get_kb_manager()
- kb_name = collections[0] if collections else 'public_kb'
- collection = kb_manager.get_collection(kb_name)
-
- if collection:
- # 构建补充查询
- supplement_queries = []
- for fig_num in missing_figures:
- supplement_queries.append(f"图{fig_num}")
- for table_num in missing_tables:
- supplement_queries.append(f"表{table_num}")
-
- supplement_query = " ".join(supplement_queries)
-
- # 使用 embedding 检索
- # P4:复用 engine 的 embedding 模型,避免重复加载
- try:
- from core.engine import get_engine
- engine = get_engine()
- query_vector = engine.embedding_model.encode(supplement_query).tolist()
- if isinstance(query_vector[0], list):
- query_vector = query_vector[0]
-
- supplement_result = collection.query(
- query_embeddings=[query_vector],
- n_results=10,
- include=['documents', 'metadatas', 'distances']
- )
-
- # 添加匹配的图片切片
- for supp_doc, supp_meta, supp_dist in zip(
- supplement_result['documents'][0],
- supplement_result['metadatas'][0],
- supplement_result['distances'][0]
- ):
- chunk_type = supp_meta.get('chunk_type', '')
- if chunk_type in ('image', 'chart'):
- # 检查是否匹配缺失的图号/表号
- is_match = False
- matched_fig = None
- for fig_num in missing_figures:
- if f"图{fig_num}" in supp_doc:
- is_match = True
- matched_fig = fig_num
- break
- for table_num in missing_tables:
- if f"表{table_num}" in supp_doc:
- is_match = True
- break
-
- if is_match:
- # 额外检查:图片章节是否与主要章节匹配
- # 使用层级相似度判断,无需硬编码格式假设
- supp_section = supp_meta.get('section', '') or supp_meta.get('section_path', '')
-
- if primary_section_paths_for_supp and supp_section:
- supp_max_sim = max(
- (_section_similarity(supp_section, ps) for ps in primary_section_paths_for_supp),
- default=0.0
- )
- if supp_max_sim < 0.3:
- continue
- elif primary_section_paths_for_supp and not supp_section:
- # 补充检索的图片无章节信息时优雅降级:跳过
- continue
-
- # Bug 6a 修复:补充检索的图片也要做 full_description 替换
- # 与正常检索保持一致
- display_doc = supp_doc
- if supp_meta.get('chunk_type') in ('image', 'chart', 'table'):
- full_desc = supp_meta.get('full_description', '')
- if full_desc:
- display_doc = full_desc
-
- contexts.append({
- 'doc': display_doc,
- 'meta': {**supp_meta, '_collection': supp_meta.get('_collection') or (collections[0] if collections else 'public_kb')},
- 'score': 1.0 - supp_dist
- })
- logger.info(f"[补充检索] 添加图片: {supp_meta.get('image_path', '')}")
- except Exception as e:
- logger.warning(f"补充检索失败: {e}")
-
- # 发送来源事件
- yield f"data: {json.dumps({'type': 'sources', 'sources': sources[:MAX_SOURCES_RETURNED]}, ensure_ascii=False)}\n\n"
-
- # 调试:检查 contexts 中是否有图片切片
- image_count = sum(1 for ctx in contexts if ctx.get('meta', {}).get('chunk_type') in ('image', 'chart'))
- if image_count > 0:
- logger.info(f"[图片检索] contexts 中包含 {image_count} 个图片/图表切片")
- for ctx in contexts:
- meta = ctx.get('meta', {})
- 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: 后续可改为异步后台任务
- try:
- import asyncio
- 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))
- except Exception as e:
- logger.warning(f"懒加载增强失败: {e}")
-
- # 3. 选择要展示的图片(Phase 5)
- selected_images = select_images(contexts, retrieval_query)
-
- # 调试事件:图片选择详情
- if IS_DEV:
- yield f"data: {json.dumps({'type': 'images_selected', 'data': {'total_scored': len([c for c in contexts if c.get('meta',{}).get('chunk_type') in ('image','chart','table')]), 'selected_count': len(selected_images), 'images': [{'source': img.get('source',''), 'page': img.get('page',0), 'score': round(img.get('_image_boost', 1.0), 2)} for img in selected_images]}}, ensure_ascii=False)}\n\n"
-
- # 4. 构建 prompt(Phase 6:LLM 图片感知)
- # Bug 1 修复:文本切片用于 top 5 名额竞争,图片描述不参与竞争
-
- # BM25 分歧检测救援:BM25 top-3 但 rerank 压制的切片
- contexts = _rescue_bm25_divergence(contexts, search_result, RERANK_CONTEXT_MIN_SCORE)
-
- # 词法匹配救援:当切片文本精确包含查询关键词但 CrossEncoder 评分低时,
- # 提升分数使其通过 min_score 过滤(适用于独立切片无法触发聚类救援的场景)
- contexts = _rescue_lexical_match(contexts, retrieval_query, RERANK_CONTEXT_MIN_SCORE)
-
- # 章节聚类救援(路由层安全网):在 min_score 过滤前,
- # 检测"全灭 section"并分配保底分数
- _rescue_debug = None
- if SECTION_CLUSTER_RESCUE_ENABLED:
- _before_scores = {id(ctx): ctx.get('score', 0) for ctx in contexts}
- contexts = _rescue_section_cluster(contexts, retrieval_query, RERANK_CONTEXT_MIN_SCORE)
- # 统计救援结果
- _rescued = [ctx for ctx in contexts if id(ctx) in _before_scores
- and ctx.get('score', 0) > _before_scores[id(ctx)]]
- if _rescued and IS_DEV:
- _rescue_sections = set()
- for ctx in _rescued:
- meta = ctx.get('meta', {})
- sp = meta.get('section', '') or meta.get('section_path', '')
- _rescue_sections.add(_normalize_section_for_rescue(sp, CLUSTER_SECTION_PREFIX_LEVELS))
- _rescue_debug = {
- 'rescued_count': len(_rescued),
- 'rescued_sections': list(_rescue_sections)
- }
- yield f"data: {json.dumps({'type': 'section_cluster_rescue', 'data': _rescue_debug}, ensure_ascii=False)}\n\n"
-
- text_contexts = _order_text_contexts_for_prompt(contexts, retrieval_query, MAX_CONTEXT_CHUNKS,
- min_score=RERANK_CONTEXT_MIN_SCORE)
- # Phase 2:按字符预算构建上下文
- _is_comparison = intent and intent.intent == "comparison"
- if _is_enum_query(retrieval_query) or _is_comparison:
- # 列举类 / 对比类查询:保持原始顺序,不做预算截断
- # 仍然注入 section_path 标题,帮助 LLM 区分不同章节
- enum_parts = []
- prev_section = None
- for ctx in text_contexts:
- meta = ctx.get('meta', {})
- section = meta.get('section', '') or meta.get('section_path', '')
- if section and section != prev_section:
- enum_parts.append(f"━ {section} ━")
- prev_section = section
- # 精简表格切片:去除冗余的语义增强前缀
- doc = _strip_semantic_prefix(ctx.get('doc', ''), meta.get('chunk_type', ''))
- enum_parts.append(doc)
- context_text = "\n\n".join(enum_parts)
- else:
- context_text = _build_context_with_budget(text_contexts, CONTEXT_MAX_CHARS, CONTEXT_SOFT_LIMIT)
-
- # 表格救援:CrossEncoder 对表格评分偏低,导致表格被预算截断
- # 当查询涉及表格但上下文中没有表格数据时,从被截断的切片中补回
- context_text = _rescue_table_chunks(text_contexts, context_text, retrieval_query)
-
- # Phase 4:计算置信度分数(top-3 平均 Rerank 分数)
- _top_scores = [ctx.get('score', 0) for ctx in text_contexts[:3]]
- _confidence_score = round(sum(_top_scores) / len(_top_scores), 4) if _top_scores else 0.0
-
- # Bug 6b 优化:直接使用 selected_images 中的 full_description
- # 这样 LLM 既能看到文本切片,也能知道图片内容
- if selected_images:
- # 区分表格嵌入图片和独立图片
- has_table_embedded_images = any(
- img.get('type') == 'table_image' for img in selected_images
- )
- # 检查上下文中是否有表格含嵌入图片
- has_table_with_images = any(
- ctx.get('meta', {}).get('chunk_type') == 'table'
- and ctx.get('meta', {}).get('images_json')
- for ctx in text_contexts
- )
-
- image_descriptions = []
- for i, img in enumerate(selected_images, 1):
- # 直接使用 select_images 时带上的 full_description
- full_desc = img.get('full_description', '') or img.get('description', '')
- if full_desc:
- # 添加图片来源信息
- img_source = img.get('source', '')
- img_page = img.get('page', '')
- source_info = f"(来源:{img_source} 第{img_page}页)" if img_source and img_page else ""
- image_descriptions.append(f"【图片{i}】{full_desc}{source_info}")
- if image_descriptions:
- context_text += "\n\n【相关图片信息】\n" + "\n\n".join(image_descriptions)
-
- # 根据图片类型给出不同的回答指令
- if has_table_embedded_images and has_table_with_images:
- context_text += (
- "\n\n【回答要求】参考资料中包含表格及其嵌入图片。"
- "请以**表格形式**呈现数据(保持原始表格结构),"
- "并在对应单元格中使用 `` 格式嵌入图片。"
- "不要将表格内容转为纯文本描述,不要把图片与表格分开展示。"
- )
- else:
- # 添加指令让 LLM 介绍图片
- context_text += "\n\n【回答要求】回答时请简要介绍每张图片的内容和用途。"
-
- enhanced_context = context_text
-
- # 对比类查询:添加结构化对比指令
- if intent and intent.intent == "comparison" and intent.sub_queries:
- comparison_instruction = (
- "\n\n【回答要求】这是一个对比类问题。"
- "请根据参考资料,从多个角度对比分析,"
- "使用表格或分点形式清晰呈现差异和共同点。"
- "如果参考资料中缺少某一方面的信息,请如实说明。"
- )
- enhanced_context = comparison_instruction + "\n\n" + enhanced_context
-
- # 推理类查询:添加因果分析指令
- elif intent and intent.intent == "reasoning":
- reasoning_instruction = (
- "\n\n【回答要求】这是一个需要分析原因或推理的问题。"
- "请根据参考资料,先梳理相关事实和数据,再给出逻辑清晰的分析。"
- "如果涉及因果关系,请明确标注原因和结果;如果资料不足以支撑推理,请如实说明。"
- )
- enhanced_context = reasoning_instruction + "\n\n" + enhanced_context
-
- # 操作指导类查询:添加步骤化指令
- elif intent and intent.intent == "instruction":
- instruction_instruction = (
- "\n\n【回答要求】这是一个操作指导类问题。"
- "请根据参考资料,以清晰的步骤或流程形式组织回答。"
- "如有前置条件或注意事项,请在步骤前说明。"
- )
- enhanced_context = instruction_instruction + "\n\n" + enhanced_context
-
- if _is_enum_query(retrieval_query):
- enum_instruction = (
- "\n\n【回答要求】如果参考资料中包含编号列表、禁止情形、要求或条款,"
- "请按资料中的原始顺序完整列出;不要合并相邻条目,不要跳项,"
- "资料不足时明确说明缺少哪部分依据。\n\n"
- )
- enhanced_context = enum_instruction + enhanced_context
-
- # Phase 4:根据置信度注入谨慎回答指令
- if _confidence_score < CONFIDENCE_WARN_THRESHOLD:
- enhanced_context += (
- "\n\n【重要提示】参考资料与问题的相关性较低。"
- "请仅基于参考资料中明确包含的信息回答,"
- '如果资料不足以回答问题,请直接说明"知识库中未找到直接相关的信息"。'
- )
- elif _confidence_score < CONFIDENCE_CAUTION_THRESHOLD:
- enhanced_context += (
- "\n\n【提示】参考资料的相关性一般,请优先引用资料中的原文,避免推测。"
- )
-
- # 调试事件:最终上下文
- if IS_DEV:
- text_used = text_contexts
- _scores = [round(ctx.get('score', 0), 4) for ctx in text_used]
- yield f"data: {json.dumps({'type': 'context_built', 'data': {'chunk_count': len(text_used), 'context_length': len(enhanced_context), 'budget_max_chars': CONTEXT_MAX_CHARS, 'min_score_filter': RERANK_CONTEXT_MIN_SCORE, 'confidence_top3': _confidence_score, 'score_stats': {'max': max(_scores) if _scores else 0, 'min': min(_scores) if _scores else 0, 'avg': round(sum(_scores)/len(_scores), 4) if _scores else 0}, 'context_preview': enhanced_context[:500], 'chunks_used': [{'source': ctx.get('meta',{}).get('source',''), 'page': ctx.get('meta',{}).get('page',0), 'score': ctx.get('score',0), 'preview': (ctx.get('doc','') or '')[:100]} for ctx in text_used]}}, ensure_ascii=False)}\n\n"
-
- # 5. 流式生成回答
- from core.engine import get_engine
- engine = get_engine()
-
- for token in engine.generate_answer_stream(message, enhanced_context, history):
- full_answer.append(token)
- yield f"data: {json.dumps({'type': 'chunk', 'content': token}, ensure_ascii=False)}\n\n"
-
- # 6. P0:答案对齐过滤器
- # 从 LLM 回答中提取图号引用,过滤图片选择结果
- full_answer_text = "".join(full_answer)
-
- # 提取回答中引用的图号/表号
- mentioned = set()
- # 中文图号:图2.1、图 2-1、见图2.1 等
- mentioned.update(re.findall(r'(?:[见如])?图\s*(\d+[\.\-]?\d*)', full_answer_text))
- # 中文表号:表2.1、表 2-1、见表2.1 等
- mentioned.update(re.findall(r'(?:[见如])?表\s*(\d+[\.\-]?\d*)', full_answer_text))
- # 英文图号:Figure 2.1、Fig.2.1 等
- mentioned.update(re.findall(r'(?:Fig(?:ure)?\.?\s*)(\d+[\.\-]?\d*)', full_answer_text, re.I))
-
- # 根据回答中的引用过滤图片
- if mentioned:
- aligned_images = []
- for img in selected_images:
- desc = img.get('description', '')
- # 标准化图号格式(将连字符转为点)
- for ref in mentioned:
- ref_normalized = ref.replace('-', '.')
- if (f"图{ref_normalized}" in desc or
- f"表{ref_normalized}" in desc or
- f"图 {ref_normalized}" in desc or
- f"表 {ref_normalized}" in desc):
- aligned_images.append(img)
- break
- # 如果有匹配的图片,使用对齐后的结果
- if aligned_images:
- selected_images = aligned_images
- # else: 没有匹配到,保留原选择(不再截断到1张)
- # else: LLM 没有提图号,保留原选择(不再截断到1张)
-
- # 后置图片过滤:用回答内容反向筛选图片,确保图片与回答一致
- selected_images = _filter_images_by_answer(selected_images, full_answer_text)
-
- rich_media = {'images': selected_images, 'tables': [], 'sections': []}
-
- # 7. 去掉 LLM 添加的数字引用标记,避免与后端引用重复
- clean_answer = re.sub(r'\[\d+\]', '', full_answer_text)
-
- # 8. 添加引用标注(自动插入 [ref:chunk_id])
- citation_result = _attach_citations(clean_answer, contexts)
-
- # 9. 过滤敏感信息(违禁词等)
- filtered_answer = filter_response(citation_result.get("answer_with_refs", clean_answer))
-
- # 9. 保存消息到会话(仅开发环境)
- if session_id and session_repo_ref:
- try:
- # 保存用户消息
- session_repo_ref.add_message(session_id, 'user', message)
- # 保存 AI 回答(包含完整 metadata:图片、来源、引用等)
- assistant_metadata = {
- 'is_rag': True,
- 'mode': 'rag',
- }
- if rich_media.get('images'):
- assistant_metadata['images'] = rich_media['images']
- if sources:
- assistant_metadata['sources'] = sources
- if citation_result.get('citations'):
- assistant_metadata['citations'] = citation_result['citations']
- # 记录本次检索使用的向量库(用于后续追问时自动恢复 KB 选择)
- assistant_metadata['collections'] = collections
- session_repo_ref.add_message(session_id, 'assistant', filtered_answer, assistant_metadata)
- # 更新会话最后活跃时间
- if hasattr(session_repo_ref, 'update_last_active'):
- session_repo_ref.update_last_active(session_id)
- except Exception as e:
- logger.warning(f"保存会话消息失败: {e}")
-
- # 10. 发送完成事件
- duration_ms = int((_time.time() - start_time) * 1000)
- finish_event = {
- "type": "finish",
- "answer": filtered_answer,
- "mode": "rag",
- "session_id": session_id,
- "sources": sources,
- "citations": citation_result.get("citations", []), # 结构化引用列表
- "images": rich_media["images"],
- "tables": rich_media["tables"],
- "sections": rich_media["sections"],
- "duration_ms": duration_ms,
- "confidence_score": _confidence_score # Phase 4:top-3 平均 Rerank 分数
- }
-
- # 添加分阶段耗时信息(仅开发环境)
- if IS_DEV and hasattr(search_result, 'get'):
- debug_info = search_result.get('_debug', {})
- timing_info = debug_info.get('timing', {})
- # 从 _debug steps 中提取 Rerank 耗时
- rerank_time = 0
- rerank_cached = False
- for step in debug_info.get('steps', []):
- if step.get('name') == 'rerank' and step.get('applied'):
- rerank_time = step.get('time_ms', 0)
- rerank_cached = step.get('cached', False)
- finish_event["timing"] = {
- "total_search_ms": timing_info.get('total_ms', 0),
- "rerank_ms": rerank_time,
- "rerank_cached": rerank_cached,
- "total_ms": duration_ms
- }
- # 10.5 写入语义缓存
- try:
- from core.semantic_cache import get_semantic_cache
- _sc = get_semantic_cache()
- if _sc and filtered_answer:
- if _semantic_cache_emb is None:
- from core.engine import get_engine as _get_eng
- _eng = _get_eng()
- if _eng and hasattr(_eng, 'embedding_model'):
- _cache_collections = ','.join(sorted(collections)) if collections else ''
- _cache_key_text = f"{retrieval_query}|{_cache_collections}"
- _semantic_cache_emb = _eng.embedding_model.encode(_cache_key_text)
- if _semantic_cache_emb is not None:
- _sc.set(_semantic_cache_emb, {
- "cache_type": "rag_answer",
- "answer": filtered_answer,
- "sources": sources,
- "citations": citation_result.get("citations", []),
- "images": rich_media.get("images", []),
- "tables": rich_media.get("tables", []),
- })
- logger.debug(f"[语义缓存] 写入成功: {message[:50]}...")
- except Exception as e:
- logger.info(f"[语义缓存] 写入失败: {e}")
-
- yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n"
-
- except Exception as e:
- import logging as _logging
- _logging.getLogger(__name__).error(f"[SSE] RAG 流异常: {e}", exc_info=True)
- error_event = {
- "type": "error",
- "message": "服务内部错误,请稍后重试"
- }
- yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
-
- return Response(
- generate(),
- mimetype='text/event-stream',
- headers={
- 'Cache-Control': 'no-cache',
- 'X-Accel-Buffering': 'no'
- }
- )
-
-
-@chat_bp.route('/search', methods=['POST'])
-@require_gateway_auth
-def search():
- """
- 混合检索接口 - 供 Dify 工作流调用
-
- 请求体:
- {
- "query": "查询文本",
- "top_k": 5,
- "collections": ["public_kb"] // 可选
- }
- """
- data = request.json or {}
- query = data.get('query', '')
- query = sanitize_user_input(query)
- injection_matches = detect_injection(query)
- if injection_matches:
- logger.warning(f"[Chat] 检测到可疑注入: {injection_matches}")
- top_k = data.get('top_k', 5)
- collections = data.get('collections') # 后端传入的知识库列表
-
- if not query:
- return error_response("MISSING_PARAMS", BAD_REQUEST, "query is required", http_status=400)
-
- # 输入安全校验(注入检测、违禁词、长度限制)
- is_valid, reason = validate_query(query)
- if not is_valid:
- return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
-
- # top_k 范围校验
- try:
- top_k = max(1, min(int(top_k), 50))
- except (ValueError, TypeError):
- top_k = 5
-
- # 如果没有指定 collections,使用默认的公开库
- if not collections:
- collections = ['public_kb']
-
- results = search_hybrid(query, top_k=top_k, allowed_collections=collections)
-
- return success_response(data={
- 'contexts': results['documents'][0],
- 'metadatas': results['metadatas'][0],
- 'scores': results['scores'][0]
- })
-
-
-# ==================== 缓存调试接口(临时) ====================
-
-@chat_bp.route('/cache/stats', methods=['GET'])
-def cache_stats():
- """缓存统计接口(调试用)"""
- stats = {}
- try:
- from core.cache import get_cache_manager
- cache = get_cache_manager()
- all_stats = cache.get_all_stats()
- for name, s in all_stats.items():
- stats[name] = {
- 'total_entries': s.total_entries,
- 'hits': s.hits,
- 'misses': s.misses,
- 'hit_rate': f"{s.hit_rate:.2%}",
- 'evictions': s.evictions,
- }
- except Exception as e:
- stats['_error'] = str(e)
-
- # 语义缓存统计(全局单例 + 意图分析器实例)
- try:
- from core.semantic_cache import get_semantic_cache
- sc = get_semantic_cache()
- if sc:
- stats['semantic_cache'] = sc.get_stats()
- except Exception:
- pass
-
- try:
- from core.intent_analyzer import IntentAnalyzer
- if hasattr(IntentAnalyzer, '_instance'):
- ia = IntentAnalyzer._instance
- if hasattr(ia, 'semantic_cache') and ia.semantic_cache:
- stats['semantic_cache_intent'] = ia.semantic_cache.get_stats()
- except Exception:
- pass
-
- return jsonify(stats)
-
-
-@chat_bp.route('/cache/clear', methods=['POST'])
-def cache_clear():
- """缓存清空接口(调试用)"""
- result = {}
- try:
- from core.cache import get_cache_manager
- cache = get_cache_manager()
- cache.clear_all()
- result['lru_cache'] = 'cleared'
- except Exception as e:
- result['lru_cache'] = f'error: {e}'
-
- try:
- from core.semantic_cache import get_semantic_cache
- sc = get_semantic_cache()
- sc.clear()
- result['semantic_cache'] = 'cleared'
- except Exception:
- result['semantic_cache'] = 'not_available'
-
- return jsonify({'status': 'ok', 'cleared': result})
+"""
+核心聊天与检索 API
+
+本模块提供 RAG 系统的核心问答接口,包括:
+- 普通聊天模式(/chat)
+- 知识库问答模式(/rag,支持 SSE 流式返回)
+- 混合检索接口(/search,供外部系统调用)
+
+路由列表:
+ POST /chat : 普通聊天模式(JSON 响应)
+ POST /rag : 知识库问答模式(SSE 流式返回)
+ POST /search : 混合检索接口(供 Dify 调用)
+
+架构说明:
+ - 会话管理由后端服务负责,RAG 服务不存储对话历史
+ - 权限验证由后端网关完成(通过 request.current_user 获取用户信息)
+ - /rag 接口已升级为 SSE 流式返回,支持实时输出
+
+Example:
+ curl -X POST http://localhost:5001/rag \\
+ -H "Content-Type: application/json" \\
+ -H "Authorization: Bearer mock-token-admin" \\
+ -d '{"query": "公司报销制度是什么?"}'
+"""
+
+import json
+import os
+import queue
+import threading
+import time as _time
+from typing import List, Dict, Any, Optional, Tuple
+from pathlib import Path
+
+import numpy as np
+from flask import Blueprint, request, jsonify, Response, current_app
+import logging
+
+logger = logging.getLogger(__name__)
+from auth.gateway import require_gateway_auth
+from core.status_codes import SUCCESS, BAD_REQUEST, NOT_FOUND, INTERNAL_ERROR
+from api.response_utils import success_response, error_response
+
+from auth.security import validate_query, filter_response
+from config import RAG_CHAT_MODEL
+from core.llm_utils import call_llm, call_llm_stream
+from core.prompt_guard import detect_injection, sanitize_user_input
+
+
+def _get_vlm_cache(image_path: str) -> Optional[str]:
+ """
+ 从缓存获取图片的 VLM 描述
+
+ Args:
+ image_path: 图片文件名(不含路径),如 '0cd5e156ff0a.png'
+
+ Returns:
+ VLM 描述文本,无缓存返回 None
+ """
+ import hashlib
+
+ try:
+ images_dir = Path(".data/images")
+ full_path = images_dir / image_path
+
+ if not full_path.exists():
+ return None
+
+ img_hash = hashlib.md5(full_path.read_bytes()).hexdigest()
+ cache_file = Path(f".data/cache/vlm/{img_hash}.txt")
+
+ if cache_file.exists():
+ return cache_file.read_text(encoding='utf-8')
+ except Exception as e:
+ logger.debug(f"读取 VLM 缓存失败: {e}")
+
+ return None
+
+
+def _check_vlm_relevance(query: str, vlm_desc: str) -> float:
+ """
+ 使用 LLM 评估图片描述与查询的相关性
+
+ Args:
+ query: 用户查询
+ vlm_desc: 图片的 VLM 描述
+
+ Returns:
+ 相关性分数 (0-1),0=不相关,1=高度相关
+ """
+ if not vlm_desc or len(vlm_desc) < 20:
+ return 0.5 # 无有效描述,中性评分
+
+ # 使用 jieba 分词提取关键词
+ import jieba
+ stop_words = {'图', '表', '图片', '图表', '如图', '所示', '的', '是', '在', '有', '了', '和', '与', '或', '及', '等', '中', '对'}
+ query_keywords = [w for w in jieba.lcut(query) if len(w) >= 2 and w not in stop_words]
+ if not query_keywords:
+ return 0.5
+
+ # 检查关键词是否在 VLM 描述中出现
+ matches = sum(1 for kw in query_keywords if kw in vlm_desc)
+ if matches == 0:
+ return 0.0 # 完全不相关
+
+ # 按匹配比例评分
+ ratio = matches / len(query_keywords)
+ return min(ratio, 1.0)
+
+chat_bp = Blueprint('chat', __name__)
+
+
+def _safe_int(value: Any, default: int = 10**9) -> int:
+ """
+ 安全的整数转换
+
+ Args:
+ value: 待转换的值
+ default: 转换失败时的默认值
+
+ Returns:
+ 转换后的整数值,失败返回默认值
+ """
+ try:
+ if value is None or value == "":
+ return default
+ return int(value)
+ except (TypeError, ValueError):
+ return default
+
+
+def _is_enum_query(query: str) -> bool:
+ """
+ 判断是否为枚举型查询
+
+ 枚举型查询通常包含"哪些"、"列出"等关键词,
+ 需要特殊的上下文排序策略。
+
+ Args:
+ query: 用户查询文本
+
+ Returns:
+ 是枚举型查询返回 True,否则返回 False
+ """
+ try:
+ from core.query_classifier import is_enumeration_query
+ return is_enumeration_query(query)
+ except Exception as e:
+ logger.debug(f"枚举查询检测失败: {e}")
+ markers = ("哪些", "有哪些", "列出", "严禁", "禁止", "不得", "包括", "要求", "情形", "场景")
+ return bool(query) and any(marker in query for marker in markers)
+
+
+def _get_full_table_from_docstore(chunk_id: str) -> Optional[str]:
+ """
+ 从 DocStore 获取表格的完整 Markdown 内容
+
+ Args:
+ chunk_id: 切片 ID,如 '组织架构.xlsx_0' 或 'test_report.pdf_3'
+
+ Returns:
+ 完整表格 Markdown,未找到返回 None
+ """
+ docstore_dir = Path(".data/docstore")
+ if not docstore_dir.exists():
+ return None
+
+ # chunk_id 格式: {filename}_{index}
+ # DocStore 格式: {filename}_table_{index}.json 或 {filename}_{index}.json
+ possible_paths = [
+ docstore_dir / f"{chunk_id}.json", # 直接匹配
+ docstore_dir / f"{chunk_id.replace('_', '_table_', 1)}.json", # xxx_0 -> xxx_table_0
+ ]
+
+ # 如果 chunk_id 是 {filename}_{num} 格式,尝试 {filename}_table_{num}
+ parts = chunk_id.rsplit('_', 1)
+ if len(parts) == 2:
+ filename, idx = parts
+ possible_paths.append(docstore_dir / f"{filename}_table_{idx}.json")
+
+ for doc_path in possible_paths:
+ if doc_path.exists():
+ try:
+ with open(doc_path, 'r', encoding='utf-8') as f:
+ record = json.load(f)
+ return record.get('markdown', '')
+ except Exception as e:
+ logger.debug(f"读取 DocStore 失败: {doc_path}, {e}")
+ return None
+
+
+def _strip_semantic_prefix(doc: str, chunk_type: str) -> str:
+ """
+ 去除切片 doc 中的冗余语义前缀,保留关键标识信息
+
+ 表格切片的 doc 由 _build_semantic_content_for_table 生成,格式为:
+ 主题:section_path(保留,用于关联章节)
+ 字段:A, B, C(去除,冗余)
+ 描述:该表包含N行数据(去除,冗余)
+ 示例:字段=值(去除,冗余)
+
+ 表格内容:
+ | A | B | C |
+ |---|---|---|
+ | 1 | 2 | 3 |
+
+ 优化策略:
+ - 保留"主题:"行(表格所属章节标识)
+ - 去除"字段:"/"描述:"/"示例:"行(冗余信息)
+ - 添加"【表格】"标记,让 LLM 明确识别表格类型
+ - 保留 Markdown 表格内容(| 开头)和 HTML 表格内容( List[Dict]:
+ """
+ 对文本上下文排序,优化提示词构建
+
+ 对于列举型查询,保持同一文档章节的切片连续排列,
+ 便于 LLM 理解完整的语义上下文。
+
+ Args:
+ contexts: 检索到的上下文列表
+ query: 用户查询
+ max_chunks: 最大切片数量
+
+ Returns:
+ 排序后的上下文列表
+
+ Example:
+ >>> ordered = _order_text_contexts_for_prompt(contexts, "有哪些禁止情形?", 10)
+ """
+ # 文本切片 + 表格切片(从 DocStore 获取完整内容)
+ text_contexts = []
+ for ctx in contexts:
+ chunk_type = ctx.get('meta', {}).get('chunk_type', '')
+ if chunk_type in ('image', 'chart'):
+ continue # 图片/图表单独处理
+
+ if chunk_type == 'table':
+ # 表格:从 DocStore 获取完整 Markdown
+ chunk_id = ctx.get('meta', {}).get('chunk_id', '')
+ full_table = _get_full_table_from_docstore(chunk_id) if chunk_id else None
+ if full_table:
+ # 替换为完整内容,保留 score
+ text_contexts.append({
+ 'doc': full_table,
+ 'meta': {**ctx.get('meta', {}), '_from_docstore': True},
+ 'score': ctx.get('score', 0)
+ })
+ else:
+ # DocStore 中没有,使用原始摘要
+ text_contexts.append(ctx)
+ else:
+ # 普通文本切片
+ text_contexts.append(ctx)
+
+ # 图片/图表切片:提取其 doc 内容(包含前文/后文上下文)
+ # 这些切片虽然 chunk_type 是 image/chart,但其 doc 字段包含有价值的上下文信息
+ chart_contexts = []
+ for ctx in contexts:
+ chunk_type = ctx.get('meta', {}).get('chunk_type', '')
+ if chunk_type in ('image', 'chart'):
+ doc = ctx.get('doc', '')
+ if doc and len(doc) > 20: # 有实际内容
+ # 标记来源,避免重复
+ chart_contexts.append({
+ 'doc': doc,
+ 'meta': {**ctx.get('meta', {}), '_is_chart_context': True},
+ 'score': ctx.get('score', 0)
+ })
+
+ # Phase 1:按 Rerank 分数过滤低分切片
+ # 保护策略:同一 section 内如有切片通过阈值,则同 section 的 table 切片也保留
+ # 原因:表格切片的 rerank 分数往往偏低(尤其是元问题如"有表格吗?"),
+ # 但它们与同 section 的 text 切片属于同一语义单元,不应割裂
+ # 安全下限:被保护的 table 切片自身 score 不得低于 min_score * 0.3,
+ # 防止 section 粒度较粗时完全不相关的表格被无条件保护
+ if min_score > 0:
+ _table_floor = min_score * 0.3 # table 保护最低分数下限
+ # 先找出所有通过阈值的 section
+ passing_sections = set()
+ for c in text_contexts:
+ if c.get('score', 0) >= min_score:
+ meta = c.get('meta', {})
+ section_key = (meta.get('source', ''), meta.get('section', '') or meta.get('section_path', ''))
+ if section_key[1]: # 有 section 信息的才保护
+ passing_sections.add(section_key)
+
+ text_contexts = [
+ c for c in text_contexts
+ if c.get('score', 0) >= min_score
+ or (
+ c.get('meta', {}).get('chunk_type') == 'table'
+ and c.get('score', 0) >= _table_floor
+ and (c.get('meta', {}).get('source', ''), c.get('meta', {}).get('section', '') or c.get('meta', {}).get('section_path', '')) in passing_sections
+ )
+ ]
+ chart_contexts = [c for c in chart_contexts if c.get('score', 0) >= min_score]
+
+ # 合并:文本切片优先,图表切片补充
+ # 限制图表切片数量,避免过多
+ max_chart_contexts = 3
+ combined_contexts = text_contexts + chart_contexts[:max_chart_contexts]
+
+ if not _is_enum_query(query):
+ # 表格不受 max_chunks 限制(CrossEncoder 对表格评分偏低,
+ # 但表格是结构化关键内容,不应因分数低而被截断)
+ table_ctx = [c for c in combined_contexts if c.get('meta', {}).get('chunk_type') == 'table']
+ non_table_ctx = [c for c in combined_contexts if c.get('meta', {}).get('chunk_type') != 'table']
+ return non_table_ctx[:max_chunks] + table_ctx
+
+ def sort_key(ctx):
+ meta = ctx.get('meta', {})
+ source = meta.get('source', '')
+ section = meta.get('section', '') or meta.get('section_path', '')
+ rank = _safe_int(meta.get('_retrieval_rank'), 10**6)
+ return (
+ source != primary_source,
+ section != primary_section,
+ source,
+ section,
+ _safe_int(meta.get('chunk_index')),
+ _safe_int(meta.get('section_chunk_id')),
+ rank,
+ )
+
+ primary = text_contexts[0].get('meta', {}) if text_contexts else {}
+ primary_source = primary.get('source', '')
+ primary_section = primary.get('section', '') or primary.get('section_path', '')
+ primary_index = _safe_int(primary.get('chunk_index'), None)
+
+ try:
+ from config import CONTEXT_EXPANSION_BEFORE, CONTEXT_EXPANSION_AFTER
+ except Exception as e:
+ logger.debug(f"读取上下文扩展配置失败: {e}")
+ CONTEXT_EXPANSION_BEFORE, CONTEXT_EXPANSION_AFTER = 1, 5
+
+ if primary_source and primary_index is not None:
+ window_start = primary_index - CONTEXT_EXPANSION_BEFORE
+ window_end = primary_index + CONTEXT_EXPANSION_AFTER + 1
+ window = []
+ for ctx in text_contexts:
+ meta = ctx.get('meta', {})
+ idx = _safe_int(meta.get('chunk_index'), None)
+ if meta.get('source') == primary_source and idx is not None and window_start <= idx <= window_end:
+ window.append(ctx)
+ window_ids = {
+ ctx.get('meta', {}).get('chunk_id') or (ctx.get('meta', {}).get('source'), ctx.get('meta', {}).get('chunk_index'))
+ for ctx in window
+ }
+ window = sorted(window, key=lambda ctx: _safe_int(ctx.get('meta', {}).get('chunk_index')))
+ remainder = [
+ ctx for ctx in text_contexts
+ if (ctx.get('meta', {}).get('chunk_id') or (ctx.get('meta', {}).get('source'), ctx.get('meta', {}).get('chunk_index'))) not in window_ids
+ ]
+ # 枚举查询不截断 max_chunks:需要跨 section 信息,
+ # window 优先保证主命中文档连续,remainder 按相关性补充
+ return window + sorted(remainder, key=sort_key)
+
+ ordered = sorted(text_contexts, key=sort_key)
+ return ordered
+
+
+def _process_table_doc(doc: str, meta: Dict) -> str:
+ """
+ 处理单个切片的 doc:精简表格语义前缀 + 注入嵌入图片 URL
+
+ 集中处理两处共用逻辑(正常路径和预算截断路径),避免重复代码。
+
+ Args:
+ doc: 切片原始 doc
+ meta: 切片 metadata
+
+ Returns:
+ 处理后的 doc
+ """
+ doc = _strip_semantic_prefix(doc, meta.get('chunk_type', ''))
+ if meta.get('chunk_type') == 'table' and meta.get('images_json'):
+ try:
+ img_list = json.loads(meta['images_json'])
+ if img_list:
+ img_urls = [
+ f"/images/{img.get('id', '')}"
+ for img in img_list
+ if isinstance(img, dict) and img.get('id')
+ ]
+ if img_urls:
+ doc += "\n\n[该表格包含以下图片,可在回答中引用]: " + ", ".join(img_urls)
+ except (json.JSONDecodeError, TypeError):
+ pass
+ return doc
+
+
+def _section_similarity(section_a: str, section_b: str) -> float:
+ """
+ 计算两个章节路径的层级相似度(数据驱动,无需硬编码格式假设)。
+
+ 策略:
+ 1. 如果两个路径都有数值编号(如 "2.3"),优先用精确数值匹配
+ 2. 否则按 " > " 层级拆分,计算 Jaccard 相似度
+ 3. 无数值编号时优雅降级,适用于 "第七章 附则" 或无结构文档
+
+ Returns: 0.0 ~ 1.0
+ """
+ import re as _re
+
+ if not section_a or not section_b:
+ return 0.0
+
+ # 快速路径:完全相同
+ if section_a == section_b:
+ return 1.0
+
+ # 优先精确匹配:数值编号(如 "2.3")
+ num_a = _re.search(r'(\d+\.\d+)', section_a)
+ num_b = _re.search(r'(\d+\.\d+)', section_b)
+ if num_a and num_b:
+ return 1.0 if num_a.group(1) == num_b.group(1) else 0.0
+
+ # 层级文本匹配:拆分路径为各级标题,计算 Jaccard 系数
+ def _split_levels(path):
+ parts = [p.strip() for p in path.split('>') if p.strip()]
+ # 去掉常见序号前缀(如 "1.1 "、"第1章 "),保留语义部分
+ cleaned = set()
+ for p in parts:
+ cleaned.add(_re.sub(r'^(?:\d+[\.\d]*\s*|第\s*\d+\s*章\s*|[一二三四五六七八九十]+、\s*)', '', p).strip())
+ return cleaned
+
+ levels_a = _split_levels(section_a)
+ levels_b = _split_levels(section_b)
+
+ if not levels_a or not levels_b:
+ return 0.0
+
+ overlap = len(levels_a & levels_b)
+ union = len(levels_a | levels_b)
+ return overlap / union if union > 0 else 0.0
+
+
+def _rescue_bm25_divergence(contexts: List[Dict], search_result: dict,
+ min_score: float) -> List[Dict]:
+ """
+ BM25-CrossEncoder 分歧检测救援:当 BM25 排名靠前(top-3)的切片
+ 被 CrossEncoder rerank 压制(分数低于 min_score 或被截断)时,
+ 将其分数提升至保底值,使其通过后续的 min_score 过滤。
+
+ 适用场景:
+ - BM25 top-1/top-2 的切片(精确关键词匹配强信号)被 rerank 评分极低
+ - 正确切片在 rerank 后被截断,根本不在 contexts 中
+
+ Args:
+ contexts: 全部上下文切片
+ search_result: engine.search_hybrid() 返回的原始结果(含 _bm25_top3)
+ min_score: 最低分数阈值
+
+ Returns:
+ 修改后的 contexts
+ """
+ if not contexts:
+ return contexts
+
+ from config import (
+ BM25_DIVERGENCE_RESCUE_ENABLED, BM25_DIVERGENCE_MAX_RANK,
+ CLUSTER_RESCUE_FLOOR,
+ )
+
+ if not BM25_DIVERGENCE_RESCUE_ENABLED:
+ return contexts
+
+ bm25_top3 = search_result.get('_bm25_top3', [])
+ if not bm25_top3:
+ return contexts
+
+ # 构建 contexts 中已有切片的 ID 索引,用于快速查找
+ existing_ids = {}
+ for i, ctx in enumerate(contexts):
+ chunk_id = ctx.get('meta', {}).get('chunk_id') or ctx.get('id')
+ if chunk_id:
+ existing_ids[chunk_id] = i
+
+ # 计算 contexts 中的多数 source(用于情况 B 注入校验)
+ # 防止跨文档注入无关切片(如 q013 场景:2.docx 的吸烟场所切片被注入到 1.docx 的查询中)
+ source_counter: Dict[str, int] = {}
+ for ctx in contexts:
+ src = ctx.get('meta', {}).get('source', '')
+ if src:
+ source_counter[src] = source_counter.get(src, 0) + 1
+ majority_source = max(source_counter, key=source_counter.get) if source_counter else None
+
+ rescued_count = 0
+
+ for bm25_item in bm25_top3:
+ rank = bm25_item.get('rank', 99)
+ if rank > BM25_DIVERGENCE_MAX_RANK:
+ continue
+
+ bm25_id = bm25_item.get('id')
+ bm25_meta = bm25_item.get('meta', {})
+ bm25_doc = bm25_item.get('doc', '')
+
+ # 情况 A:切片在 contexts 中但 score < min_score
+ if bm25_id and bm25_id in existing_ids:
+ ctx = contexts[existing_ids[bm25_id]]
+ if ctx.get('score', 0) < min_score:
+ ctx['score'] = CLUSTER_RESCUE_FLOOR
+ rescued_count += 1
+ logger.debug(
+ f"BM25 分歧救援 (情况A): rank={rank}, "
+ f"source={bm25_meta.get('source', '')}, "
+ f"section={bm25_meta.get('section', '')}, "
+ f"原score→{CLUSTER_RESCUE_FLOOR}"
+ )
+ continue
+
+ # 情况 B:切片不在 contexts 中(被 rerank 截断或已被过滤)
+ # 从 _bm25_top3 备份中注入,但需校验 source 一致性
+ if bm25_doc and bm25_meta:
+ bm25_source = bm25_meta.get('source', '')
+ # source 一致性校验:只注入与 contexts 多数 source 一致的切片
+ # 避免跨文档注入无关内容(如 2.docx 的吸烟场所切片混入 1.docx 的投放查询)
+ if majority_source and bm25_source and bm25_source != majority_source:
+ logger.debug(
+ f"BM25 分歧救援 (情况B-跳过): rank={rank}, "
+ f"source={bm25_source} != majority={majority_source}, "
+ f"section={bm25_meta.get('section', '')}"
+ )
+ continue
+ injected_ctx = {
+ 'doc': bm25_doc,
+ 'meta': bm25_meta,
+ 'score': CLUSTER_RESCUE_FLOOR,
+ }
+ contexts.append(injected_ctx)
+ rescued_count += 1
+ logger.debug(
+ f"BM25 分歧救援 (情况B-注入): rank={rank}, "
+ f"source={bm25_meta.get('source', '')}, "
+ f"section={bm25_meta.get('section', '')}"
+ )
+
+ if rescued_count > 0:
+ logger.info(f"BM25 分歧救援: 共救援 {rescued_count} 个切片")
+
+ return contexts
+
+
+def _rescue_lexical_match(contexts: List[Dict], retrieval_query: str,
+ min_score: float) -> List[Dict]:
+ """
+ 词法匹配救援:当切片文本精确包含查询的核心关键词但 CrossEncoder 评分很低时,
+ 将其分数提升至保底值,并同时救援同 source 下 chunk_index 相邻的切片。
+
+ 适用场景:
+ 1. 某个 section 只有一个正确切片(无法触发聚类救援)
+ 2. 枚举类问题的 header 切片被词法匹配救援后,其后续子条目也应被一并保留
+
+ Args:
+ contexts: 全部上下文切片
+ retrieval_query: 检索查询
+ min_score: 最低分数阈值
+
+ Returns:
+ 修改后的 contexts
+ """
+ if not contexts or not retrieval_query:
+ return contexts
+
+ import re
+ from config import CLUSTER_RESCUE_FLOOR
+
+ # 清理查询:去除 markdown 格式和标点
+ clean_query = re.sub(r'\*+|#+|`', '', retrieval_query)
+ clean_query = re.sub(r'[??!!。,,、;;::"""\'\s]+', ' ', clean_query).strip()
+
+ if len(clean_query) < 2:
+ return contexts
+
+ # 提取查询中的有意义 bigram(连续两字组)
+ query_bigrams = set()
+ for i in range(len(clean_query) - 1):
+ w = clean_query[i:i+2].strip()
+ if len(w) == 2:
+ query_bigrams.add(w)
+
+ if not query_bigrams:
+ return contexts
+
+ # Phase 1: 词法匹配救援——找到高分匹配的切片
+ rescued_seeds = [] # [(source, chunk_index)]
+ for ctx in contexts:
+ if ctx.get('score', 0) >= min_score:
+ continue
+
+ doc = ctx.get('doc', '') or ''
+ meta = ctx.get('meta', {})
+ section = meta.get('section', '') or meta.get('section_path', '')
+ combined = doc + ' ' + section
+
+ matched = sum(1 for w in query_bigrams if w in combined)
+ match_ratio = matched / len(query_bigrams)
+
+ if match_ratio > 0.35:
+ ctx['score'] = max(ctx.get('score', 0), CLUSTER_RESCUE_FLOOR)
+ source = meta.get('source', '')
+ chunk_index = meta.get('chunk_index')
+ if source and chunk_index is not None:
+ try:
+ rescued_seeds.append((source, int(chunk_index)))
+ except (ValueError, TypeError):
+ pass
+
+ # Phase 2: 邻居救援——对每个被词法匹配救援的种子,
+ # 同时救援同 source 下 chunk_index 后续相邻的切片(枚举子条目)
+ if rescued_seeds:
+ for source, seed_idx in rescued_seeds:
+ for ctx in contexts:
+ if ctx.get('score', 0) >= min_score:
+ continue
+ meta = ctx.get('meta', {})
+ if meta.get('source') != source:
+ continue
+ try:
+ n_idx = int(meta.get('chunk_index', -1))
+ except (ValueError, TypeError):
+ continue
+ # 救援种子后续 8 个相邻切片(覆盖大多数枚举/条款模式)
+ if seed_idx < n_idx <= seed_idx + 8:
+ ctx['score'] = max(ctx.get('score', 0), CLUSTER_RESCUE_FLOOR)
+
+ return contexts
+
+
+def _normalize_section_for_rescue(section_path: str, levels: int = 2) -> str:
+ """归一化 section_path:取前 N 级路径用于分组"""
+ if not section_path:
+ return ''
+ parts = [p.strip() for p in section_path.split('>')]
+ return ' > '.join(parts[:levels])
+
+
+def _rescue_section_cluster(contexts: List[Dict], retrieval_query: str,
+ min_score: float) -> List[Dict]:
+ """
+ 路由层章节聚类救援:当某个 section 有多个候选切片但全部低于 min_score 时,
+ 给该 section 的切片分配保底分数,使其通过后续的 min_score 过滤。
+
+ 作为引擎层 _section_cluster_boost 的二次安全网,防止极端情况下所有正确切片被过滤。
+
+ Args:
+ contexts: engine 返回的全部上下文切片(每个含 doc, meta, score)
+ retrieval_query: 改写后的检索查询
+ min_score: 当前的最低分数阈值
+
+ Returns:
+ 修改后的 contexts(部分切片 score 被提升至保底分数)
+ """
+ if not contexts:
+ return contexts
+
+ from config import (
+ CLUSTER_MIN_MEMBERS, CLUSTER_MIN_TYPES, CLUSTER_RESCUE_FLOOR,
+ CLUSTER_MAX_SECTIONS, CLUSTER_MAX_RESCUE_PER_SECTION,
+ CLUSTER_SECTION_PREFIX_LEVELS,
+ )
+
+ # 1. 按 (source, normalized_section) 分组
+ from collections import defaultdict
+ section_groups = defaultdict(list) # key → [index_in_contexts]
+
+ for i, ctx in enumerate(contexts):
+ meta = ctx.get('meta', {})
+ source = meta.get('source', '')
+ section_path = meta.get('section', '') or meta.get('section_path', '')
+ norm_section = _normalize_section_for_rescue(section_path, CLUSTER_SECTION_PREFIX_LEVELS)
+ if not source or not norm_section:
+ continue
+ key = (source, norm_section)
+ section_groups[key].append(i)
+
+ # 2. 检测"全灭 section"并计算聚类强度
+ rescue_candidates = [] # (strength, key, member_indices)
+
+ for key, indices in section_groups.items():
+ if len(indices) < CLUSTER_MIN_MEMBERS:
+ continue
+
+ # 检查是否所有成员都低于 min_score("全灭")
+ scores = [contexts[i].get('score', 0) for i in indices]
+ if any(s >= min_score for s in scores):
+ continue # 已有成员通过阈值,无需救援
+
+ # 计算聚类强度
+ chunk_types = set(contexts[i].get('meta', {}).get('chunk_type', 'text') for i in indices)
+ type_diversity = len(chunk_types)
+ if type_diversity < CLUSTER_MIN_TYPES:
+ continue # 类型不够多样,可能是噪音
+
+ # 查询与 section_path 的字符重叠率
+ source, norm_section = key
+ query_chars = set(retrieval_query)
+ section_chars = set(norm_section)
+ overlap = len(query_chars & section_chars) / max(len(query_chars), 1)
+
+ # 聚类强度 = 成员数 × 类型多样性 × (1 + 查询匹配度)
+ strength = len(indices) * type_diversity * (1.0 + overlap)
+ rescue_candidates.append((strength, key, indices))
+
+ # 3. 按强度降序,救援 top-N section
+ rescue_candidates.sort(key=lambda x: x[0], reverse=True)
+
+ rescued_sections = []
+ total_rescued = 0
+
+ for strength, key, indices in rescue_candidates[:CLUSTER_MAX_SECTIONS]:
+ # 对组内切片分配保底分数(仅低于 min_score 的)
+ rescue_count = 0
+ for idx in indices:
+ if rescue_count >= CLUSTER_MAX_RESCUE_PER_SECTION:
+ break
+ ctx = contexts[idx]
+ if ctx.get('score', 0) < min_score:
+ ctx['score'] = CLUSTER_RESCUE_FLOOR
+ rescue_count += 1
+ total_rescued += 1
+
+ if rescue_count > 0:
+ source, norm_section = key
+ rescued_sections.append({
+ 'source': source,
+ 'section': norm_section,
+ 'members': len(indices),
+ 'rescued': rescue_count,
+ 'strength': round(strength, 2)
+ })
+
+ return contexts
+
+
+def _rescue_table_chunks(contexts: List[Dict], context_text: str,
+ retrieval_query: str, max_rescue_chars: int = 3000) -> str:
+ """
+ 表格救援:当查询涉及表格但上下文预算截断了表格内容时,将最相关的表格补回。
+
+ 根因:CrossEncoder 对 Markdown 表格格式的评分普遍偏低,
+ 导致 _build_context_with_budget 按分数排序时表格被排到最后并被截断。
+ 此函数作为安全网,确保与查询最相关的表格始终出现在上下文中。
+
+ Args:
+ contexts: 经过 _order_text_contexts_for_prompt 处理的全部文本切片
+ context_text: _build_context_with_budget 的输出
+ retrieval_query: 改写后的检索查询
+ max_rescue_chars: 救援表格的最大字符数
+
+ Returns:
+ 可能追加了表格内容的上下文文本
+ """
+ # 1. 数据驱动检测:检索结果中是否有表格类型切片(无需硬编码关键词)
+ has_table_in_contexts = any(
+ ctx.get('meta', {}).get('chunk_type') == 'table' for ctx in contexts
+ )
+ if not has_table_in_contexts:
+ return context_text
+
+ # 2. 检测现有上下文是否已包含表格数据(Markdown 表格至少 2 列)
+ if '|' in context_text and context_text.count('|') > 4:
+ return context_text
+
+ # 3. 从 contexts 中找被截断的表格切片
+ # 使用 _in_budget_context 标记精确判断(由 _build_context_with_budget 设置)
+ table_candidates = []
+ for ctx in contexts:
+ if ctx.get('meta', {}).get('chunk_type') != 'table':
+ continue
+ # 精确判断:如果 _build_context_with_budget 已标记该 chunk 为已纳入,跳过
+ if ctx.get('_in_budget_context'):
+ continue
+ table_candidates.append(ctx)
+
+ if not table_candidates:
+ return context_text
+
+ # 4. 按分数降序取最佳表格,追加到上下文
+ table_candidates.sort(key=lambda c: c.get('score', 0), reverse=True)
+
+ rescue_parts = []
+ rescue_chars = 0
+ for ctx in table_candidates[:3]: # 最多救援 3 个表格
+ meta = ctx.get('meta', {})
+ doc = _process_table_doc(ctx.get('doc', ''), meta)
+ section = meta.get('section', '') or meta.get('section_path', '')
+
+ part_text = ""
+ if section:
+ part_text += f"━ {section} ━\n"
+ part_text += doc
+
+ if rescue_chars + len(part_text) > max_rescue_chars:
+ break
+
+ rescue_parts.append(part_text)
+ rescue_chars += len(part_text)
+
+ if rescue_parts:
+ separator = "\n\n--- 以下为与查询最相关的表格(CrossEncoder 评分偏低,自动补入)---\n\n"
+ context_text += separator + "\n\n".join(rescue_parts)
+
+ return context_text
+
+
+def _build_context_with_budget(contexts: List[Dict], max_chars: int, soft_limit: int) -> str:
+ """
+ Phase 2:按字符预算构建上下文文本。
+
+ 1. 按 (source, section) 分组,组内按 chunk_index 保持连续
+ 2. 组间按组内最高 Rerank 分数降序排列
+ 3. 逐组加入直到达到 max_chars 预算
+ 4. 超过 soft_limit 后仅接受组内最高分 >= soft_min_score 的组
+
+ 对于列举类查询(_is_enum_query),保持原始顺序直接拼接,
+ 因为 _order_text_contexts_for_prompt 已经优化了排序。
+
+ Args:
+ contexts: 已排序的上下文列表
+ max_chars: 硬性字符上限
+ soft_limit: 软限制,超过后收紧准入
+
+ Returns:
+ 拼接好的上下文文本
+ """
+ if not contexts:
+ return ""
+
+ # 按 (source, section) 分组
+ groups = {}
+ group_order = []
+ for ctx in contexts:
+ meta = ctx.get('meta', {})
+ key = (meta.get('source', ''), meta.get('section', '') or meta.get('section_path', ''))
+ if key not in groups:
+ groups[key] = []
+ group_order.append(key)
+ groups[key].append(ctx)
+
+ # 组内按 chunk_index 排序
+ for key in groups:
+ groups[key].sort(key=lambda c: _safe_int(c.get('meta', {}).get('chunk_index')))
+
+ # 组间按组内最高 score 降序
+ def group_max_score(key):
+ return max((c.get('score', 0) for c in groups[key]), default=0)
+
+ group_order.sort(key=group_max_score, reverse=True)
+
+ # 贪心加入直到预算满
+ parts = []
+ total_chars = 0
+ for key in group_order:
+ group = groups[key]
+ source, section = key
+
+ # 判断是否包含表格切片(表格是结构化关键内容,不受预算截断)
+ has_table = any(c.get('meta', {}).get('chunk_type') == 'table' for c in group)
+
+ # 构建组文本,表格切片附加图片 URL 供 LLM 引用
+ doc_parts = []
+ for ctx in group:
+ doc = _process_table_doc(ctx.get('doc', ''), ctx.get('meta', {}))
+ doc_parts.append(doc)
+ group_text = "\n\n".join(doc_parts)
+
+ # 在组首插入章节路径标题行,帮助 LLM 区分不同章节
+ section_header = ''
+ if section:
+ section_header = f"━ {section} ━"
+ group_text = section_header + "\n" + group_text
+
+ # 超过软限制后,只接受高分组(但表格组始终保留,不因分数低被跳过)
+ if total_chars > soft_limit and group_max_score(key) < 0.1 and not has_table:
+ continue
+
+ if total_chars + len(group_text) > max_chars:
+ # 尝试逐条加入该组,直到预算满
+ # 先加入章节标题(如果有)
+ if section_header and total_chars + len(section_header) + 2 <= max_chars:
+ parts.append(section_header)
+ total_chars += len(section_header) + 2
+ for ctx in group:
+ doc = _process_table_doc(ctx.get('doc', ''), ctx.get('meta', {}))
+ if total_chars + len(doc) + 2 > max_chars: # +2 for "\n\n"
+ break
+ ctx['_in_budget_context'] = True # 标记已纳入上下文
+ parts.append(doc)
+ total_chars += len(doc) + 2
+ # 超预算后一律 break,被截断的表格由 _rescue_table_chunks 补回
+ break
+
+ for ctx in group:
+ ctx['_in_budget_context'] = True # 标记已纳入上下文
+ parts.append(group_text)
+ total_chars += len(group_text) + 2 # +2 for "\n\n"
+
+ return "\n\n".join(parts)
+
+
+def _attach_citations(answer: str, contexts: List[Dict]) -> Dict[str, Any]:
+ """
+ 自动为回答添加引用标记(按段落级别匹配,jieba 分词精准匹配)
+
+ 流程:
+ 1. 将 answer 按自然段落分割(\n\n)
+ 2. 对每个段落用 jieba 分词后计算词级重叠度
+ 3. Phase 6:动态阈值(短段落 0.55 / 长段落 0.45),每段最多 2 个引用
+ 4. 前端负责对引用进行重新编号
+
+ Args:
+ answer: LLM 生成的回答
+ contexts: 检索到的上下文列表
+
+ Returns:
+ {
+ "answer_with_refs": "回答文本(含 [ref:chunk_id] 标记)",
+ "citations": [引用列表]
+ }
+ """
+ import re
+
+ if not contexts:
+ return {"answer_with_refs": answer, "citations": []}
+
+ # 按 (collection, chunk_id) 复合键组织 contexts,防止跨库同名文件覆盖
+ ctx_by_chunk = {}
+ for ctx in contexts:
+ meta = ctx.get('meta', {})
+ chunk_id = meta.get('chunk_id') or f"{meta.get('source')}_{meta.get('chunk_index', 0)}"
+ coll = meta.get('_collection') or meta.get('collection') or ''
+ composite_key = f"{coll}/{chunk_id}" if coll else chunk_id
+ # 保存原始 chunk_id,用于对外输出(ref tag / citation)
+ ctx['_raw_chunk_id'] = chunk_id
+ ctx_by_chunk[composite_key] = ctx
+
+ # jieba 分词函数(fallback 到字符级)
+ try:
+ import jieba
+ def _tokenize(text: str) -> set:
+ return set(w for w in jieba.lcut(text) if len(w) >= 2)
+ except ImportError:
+ def _tokenize(text: str) -> set:
+ return set(text)
+
+ # 按自然段落分割(\n\n 为分隔符,保留分隔符用于重组)
+ parts = re.split(r'(\n\n+)', answer)
+
+ cited_chunks_ordered: List[str] = [] # 保序去重的 chunk_id 列表
+ cited_set: set = set()
+ result_parts = []
+
+ for i in range(0, len(parts), 2):
+ para = parts[i]
+ sep = parts[i + 1] if i + 1 < len(parts) else ''
+
+ stripped = para.strip()
+ # 跳过过短段落或 markdown 表格/标题行
+ is_table = stripped.startswith('|') or '|---' in stripped
+ is_short = len(stripped) < 15
+
+ if is_table or is_short:
+ result_parts.append(para + sep)
+ continue
+
+ # 词级重叠匹配:找最相关的 chunk
+ para_words = _tokenize(stripped[:300])
+
+ # Phase 6:动态阈值 — 短段落用更高阈值避免误匹配
+ overlap_threshold = 0.55 if len(stripped) < 50 else 0.45
+
+ # 收集所有超过阈值的候选 chunk,按分数降序
+ candidates = []
+ for chunk_id, ctx in ctx_by_chunk.items():
+ ctx_doc = ctx.get('doc', '')
+ if not ctx_doc:
+ continue
+ ctx_words = _tokenize(ctx_doc[:400])
+ if not para_words:
+ continue
+ overlap = len(para_words & ctx_words)
+ score = overlap / len(para_words)
+ if score >= overlap_threshold:
+ candidates.append((chunk_id, score))
+
+ candidates.sort(key=lambda x: x[1], reverse=True)
+
+ # Phase 6:允许最多 2 个引用(分数差距 < 0.1 时附加第二引用)
+ selected_ids = []
+ if candidates:
+ selected_ids.append(candidates[0][0])
+ if len(candidates) > 1 and (candidates[0][1] - candidates[1][1]) < 0.1:
+ selected_ids.append(candidates[1][0])
+
+ # 按 _raw_chunk_id 去重:不同 composite_key 可能指向同一个底层 chunk
+ # 避免同一 chunk 产生重复引用标记(如 [3][3])
+ seen_raw_ids = set()
+ deduped_ids = []
+ for cid in selected_ids:
+ raw_id = ctx_by_chunk[cid].get('_raw_chunk_id', cid)
+ if raw_id not in seen_raw_ids:
+ seen_raw_ids.add(raw_id)
+ deduped_ids.append(cid)
+ selected_ids = deduped_ids
+
+ if selected_ids:
+ for cid in selected_ids:
+ if cid not in cited_set:
+ cited_set.add(cid)
+ cited_chunks_ordered.append(cid)
+ # 在段落末尾插入引用标记(使用原始 chunk_id,不暴露复合键)
+ ref_tags = "".join(
+ f"[ref:{ctx_by_chunk[cid].get('_raw_chunk_id', cid)}]"
+ for cid in selected_ids
+ )
+ result_parts.append(f"{para}{ref_tags}{sep}")
+ else:
+ result_parts.append(para + sep)
+
+ # 构建引用列表(按出现顺序),使用原始 chunk_id 构建 citation
+ # 按 _raw_chunk_id 去重,避免同一 chunk 产生重复引用条目
+ citations = []
+ seen_citation_raw_ids = set()
+ for composite_key in cited_chunks_ordered:
+ ctx = ctx_by_chunk.get(composite_key)
+ if ctx:
+ raw_id = ctx.get('_raw_chunk_id') or composite_key
+ if raw_id in seen_citation_raw_ids:
+ continue # 同一 chunk 已在引用列表中,跳过
+ seen_citation_raw_ids.add(raw_id)
+ meta = ctx.get('meta', {})
+ full_content = ctx.get('doc', '')
+ citation = _build_citation(meta, full_content)
+ citation['chunk_id'] = raw_id
+ citations.append(citation)
+
+ return {
+ "answer_with_refs": "".join(result_parts),
+ "citations": citations
+ }
+
+
+def _clean_section(raw: str) -> str:
+ """清洗 section 字段:过滤掉像正文内容而非章节路径的值"""
+ if not raw:
+ return ''
+ import re as _re
+
+ def _is_heading(part: str) -> bool:
+ """判断一个字符串是否像章节标题(而非正文内容)"""
+ p = part.strip()
+ if not p:
+ return False
+ # 以句号/问号/感叹号结尾 → 正文句子
+ if p.endswith(('。', '!', '?', '.', '!', '?')):
+ return False
+ # 含冒号且偏长 → 更像是内容摘要而非标题
+ if ':' in p and len(p) > 25:
+ return False
+ # 长度超过 30 字 → 不像是标题
+ if len(p) > 30:
+ return False
+ return True
+
+ # 去掉 Markdown 加粗标记
+ cleaned = raw.replace('**', '').strip()
+
+ # 如果含 ' > ' 分隔符,验证每一级都是合法标题
+ if ' > ' in cleaned:
+ parts = [p.strip() for p in cleaned.split('>') if p.strip()]
+ valid = [p for p in parts if _is_heading(p)]
+ if valid:
+ return ' > '.join(valid[:3])
+ # 所有部分都不像标题 → 清空
+ return ''
+
+ # 匹配 【xxx】 或 第X篇/章/节 格式
+ if _re.match(r'^(【[^】]+】|第[一二三四五六七八九十\d]+[篇章节部])', cleaned):
+ return cleaned[:40]
+ # 短字符串(< 30字)且不像句子(无句号/逗号),保留
+ if len(cleaned) < 30 and not any(c in cleaned for c in '。,;:'):
+ return cleaned
+ # 其余视为正文内容,清空
+ return ''
+
+
+def _build_citation(meta: Dict, full_content: str = '') -> Dict[str, Any]:
+ """
+ 根据文档类型构建定位信息
+
+ 不同文档类型使用不同的定位策略:
+ - PDF: 坐标定位(page + bbox)
+ - Word: 语义定位(section + section_chunk_id + preview)
+ - Excel: 表格定位(sheet + preview)
+
+ Args:
+ meta: 切片元数据
+ full_content: 完整切片内容(可选)
+
+ Returns:
+ 引用信息字典,包含定位信息
+
+ Example:
+ >>> citation = _build_citation(meta, "完整内容...")
+ >>> print(citation["page"]) # PDF: 页码
+ >>> print(citation["section"]) # Word: 章节
+ """
+ # 从 chunk_id 中提取全局切片序号(格式: "filename_N")
+ chunk_id_raw = meta.get('chunk_id', '')
+ chunk_index = None
+ if chunk_id_raw and '_' in str(chunk_id_raw):
+ try:
+ chunk_index = int(str(chunk_id_raw).rsplit('_', 1)[-1])
+ except (ValueError, IndexError):
+ chunk_index = meta.get('chunk_index')
+ else:
+ chunk_index = meta.get('chunk_index')
+
+ citation = {
+ "chunk_id": chunk_id_raw,
+ "chunk_index": chunk_index, # 全局切片序号,用于精准定位文档位置
+ "source": meta.get('source', ''),
+ "collection": meta.get('_collection') or meta.get('collection', ''), # 所属向量库,用于前端文档预览跳转
+ "doc_type": meta.get('doc_type', 'other'),
+ "section": _clean_section(meta.get('section', '')),
+ "preview": meta.get('preview', ''),
+ "content": (full_content or meta.get('preview', ''))[:300], # 截断至 300 字避免冒返大量数据
+ "chunk_type": meta.get('chunk_type', 'text'),
+ }
+
+ doc_type = meta.get('doc_type', 'other')
+
+ if doc_type == 'pdf':
+ # PDF: 坐标定位
+ bbox_raw = meta.get('bbox')
+ bbox = None
+ if bbox_raw:
+ try:
+ bbox = json.loads(bbox_raw) if isinstance(bbox_raw, str) else bbox_raw
+ except (json.JSONDecodeError, TypeError):
+ bbox = bbox_raw
+
+ citation.update({
+ "page": meta.get('page'),
+ "page_end": meta.get('page_end'),
+ "bbox": bbox,
+ "bbox_mode": meta.get('bbox_mode'),
+ })
+ elif doc_type == 'word':
+ # Word: 语义定位
+ citation.update({
+ "section_chunk_id": meta.get('section_chunk_id'), # 章节内段落序号
+ })
+ elif doc_type == 'excel':
+ # Excel: 表格定位
+ citation.update({
+ "page": meta.get('page'), # 工作表序号
+ })
+ else:
+ # 其他类型:返回所有可用信息
+ bbox_raw = meta.get('bbox')
+ bbox = None
+ if bbox_raw:
+ try:
+ bbox = json.loads(bbox_raw) if isinstance(bbox_raw, str) else bbox_raw
+ except (json.JSONDecodeError, TypeError):
+ bbox = bbox_raw
+
+ citation.update({
+ "page": meta.get('page'),
+ "page_end": meta.get('page_end'),
+ "bbox": bbox,
+ "bbox_mode": meta.get('bbox_mode'),
+ })
+
+ return citation
+
+
+def score_image_relevance(query: str, meta: Dict, doc: str = '') -> float:
+ """
+ 图片相关性打分(语义增强版)
+
+ 通过多维度特征评估图片与查询的相关性:
+ 1. 图片编号精确匹配(如 "图2.1")
+ 2. 关键词匹配(年份、数值+单位、中文词组)
+ 3. 整体文本相似度
+ 4. 章节匹配
+ 5. 图片类型加分
+
+ Args:
+ query: 用户查询
+ meta: 切片元数据
+ doc: document 字段(包含图片描述和上下文)
+
+ Returns:
+ 相关性分数(>= 3.0 推荐展示)
+
+ Example:
+ >>> score = score_image_relevance("图2.1是什么?", meta, doc)
+ >>> if score >= 3.0:
+ ... # 推荐展示该图片
+ """
+ import re
+ score = 0.0
+
+ # 优先使用 doc 字段(包含完整描述和上下文)
+ search_text = doc or meta.get('caption', '')
+ section = meta.get('section', '') or meta.get('section_path', '')
+ source = meta.get('source', '')
+
+ # 1. 图片编号精确匹配(最高优先级)
+ figure_pattern = r'图\s*(\d+\.?\d*)'
+ figure_matches = re.findall(figure_pattern, query)
+
+ if figure_matches:
+ for fig_num in figure_matches:
+ # 在所有文本中查找图号
+ all_text = f"{search_text} {section} {source}"
+ if f"图{fig_num}" in all_text or f"图 {fig_num}" in all_text or f"见图{fig_num}" in all_text:
+ score += 10.0 # 精确匹配,直接返回
+ return score
+
+ # 1.5. 表格编号精确匹配(新增:支持表格图片)
+ table_pattern = r'表\s*(\d+\.?\d*)'
+ table_matches = re.findall(table_pattern, query)
+
+ if table_matches:
+ for table_num in table_matches:
+ # 在所有文本中查找表号
+ all_text = f"{search_text} {section} {source}"
+ if f"表{table_num}" in all_text or f"表 {table_num}" in all_text or f"见表{table_num}" in all_text:
+ score += 10.0 # 精确匹配,直接返回
+ return score
+
+ # 2. 查询词匹配(通用方式,不硬编码关键词)
+ # 从查询中提取有意义的词:中文词组、数字+单位、年份等
+ # 使用 jieba 分词(如果可用)或简单的正则提取
+ query_keywords = []
+
+ # 提取年份(如 "2003年")
+ year_matches = re.findall(r'(\d{4})\s*年', query)
+ query_keywords.extend(year_matches)
+
+ # 提取数值+单位(如 "100亿"、"50万千瓦时")
+ num_unit_matches = re.findall(r'(\d+\.?\d*\s*[亿万万千百吨米秒])', query)
+ query_keywords.extend(num_unit_matches)
+
+ # 使用 jieba 分词提取中文关键词
+ import jieba
+ jieba_words = [w for w in jieba.lcut(query) if len(w) >= 2]
+ query_keywords.extend(jieba_words)
+
+ # 过滤掉泛词(图、表、图片等)
+ stop_words = {'图', '表', '图片', '图表', '如图', '所示', '如下', '如下表', '如下图', '的', '是', '在', '有', '了', '和', '与', '或', '及', '等', '中', '对'}
+ query_keywords = [kw for kw in query_keywords if kw not in stop_words]
+
+ # 在图片描述中匹配关键词
+ keyword_match_score = 0.0
+ for kw in query_keywords:
+ if kw in search_text or kw in section:
+ keyword_match_score += 2.0
+
+ score += min(keyword_match_score, 8.0) # 最多加 8 分
+
+ # 3. 整体文本相似度(字符级别)
+ if search_text:
+ # 复用已过滤停用词的 query_keywords,避免字符级误删(如"表现"→"现")
+ query_core = "".join(query_keywords)
+ if query_core:
+ overlap = len(set(query_core) & set(search_text))
+ score += min(overlap * 0.2, 3.0)
+
+ # 4. 章节匹配
+ if section:
+ # 从查询中提取章节关键词(复用 jieba 分词)
+ section_keywords = query_keywords
+ for kw in section_keywords:
+ if kw in section:
+ score += 1.5
+
+ # 5. 图片类型加分
+ if meta.get('chunk_type') == 'chart':
+ score += 2.0
+ elif meta.get('chunk_type') == 'image':
+ score += 1.0
+
+ # 6. 检索相似度(如果有)
+ retrieval_score = meta.get('score', 0)
+ if retrieval_score > 0:
+ score += min(retrieval_score * 2, 2.0)
+
+ return score
+
+
+# 图片意图关键词:用于判断查询/回答是否需要图片
+# 集中管理,便于后续新增文档类型时扩展
+
+# 查询侧关键词(宽松):用户查询中出现这些词表示想看图片
+_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. 候选 <= 1 张时直接返回(无需过滤)
+ 2. 无图意图早退:回答和查询都不含图片引用词 → 返回空
+ 3. 图号豁免:图片描述包含回答引用的具体图号/表号 → 无条件保留
+ 4. 主题一致性:合并查询+回答关键词,图片描述重叠 >= 阈值才保留
+ - 子章节惩罚:图片子章节与主要检索章节不一致时,阈值 +2
+ 5. 兜底:过滤后为空且有图片意图 → 保留分数最高的 1 张
+
+ Args:
+ selected_images: select_images 返回的候选图片列表
+ answer: LLM 生成的回答文本
+ query: 用户查询(拼接 retrieval_query + message,覆盖改写丢失的关键词)
+ primary_sections: 主要检索结果的 section_path 列表(来自 top 文本切片),
+ 用于子章节级过滤。为空时跳过子章节惩罚,行为与原版一致。
+
+ Returns:
+ 过滤后的图片列表
+ """
+ if not selected_images or len(selected_images) <= 1:
+ return selected_images
+
+ import re
+
+ # 提取回答中引用的图号/表号(用于精确豁免,不再一刀切绕过)
+ _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
+ _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:
+ _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 _keywords:
+ return selected_images
+
+ # 动态阈值:回答关键词越多,阈值越高(至少匹配 15% 或 2 个关键词,取较小值)
+ 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:
+ desc = img.get('full_description', '') or img.get('description', '') or ''
+ if not desc:
+ # 没有描述的图片,保留(无法判断)
+ filtered.append(img)
+ continue
+
+ # 图号精确豁免:图片描述包含回答引用的具体图号/表号,无条件保留
+ # 防御性逻辑:与 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 张
+ # 注意:无图意图场景已在上方早退(返回 []),此处兜底仅在有图意图时生效
+ 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(_keywords)} 个, 阈值 {threshold}{_sub_info}{_scattered_info}, "
+ f"图号豁免 {len(_mentioned_refs)} 个)")
+
+ return filtered
+
+
+def select_images(contexts: List[Dict], query: str) -> List[Dict]:
+ """
+ 选择要展示的图片(打分排序 + 预算控制)
+
+ 根据查询意图动态调整图片数量上限:
+ - 精确查图(指定图号): 最多 2 张
+ - 强图片意图(示意图、流程图等): 最多 3 张
+ - 列举型查询: 最多 5 张
+ - 普通查询: 最多 2 张
+
+ 核心逻辑:
+ 1. 检测查询中的图号引用
+ 2. 从检索文本中提取图表引用
+ 3. 对图片打分并过滤低分图片
+ 4. 通过章节关联排除不相关图片
+
+ Args:
+ contexts: 检索上下文列表
+ query: 用户查询
+
+ Returns:
+ 精选图片列表(每项含 score, id, url, type, source 等字段)
+
+ Example:
+ >>> images = select_images(contexts, "图2.1展示了什么?")
+ >>> print(len(images)) # <= 2
+ """
+ import re
+
+ # 动态预算:数据驱动,不依赖硬编码关键词列表
+ # 核心策略:宽松预选 + 后置过滤(_filter_images_by_answer)精准裁剪
+
+ # 精确查图:用户指定了具体图号(如 "图2.3")—— 结构化模式匹配,非硬编码
+ figure_pattern = r'图\s*(\d+\.?\d*)'
+ figure_matches = re.findall(figure_pattern, query)
+ has_figure_query = bool(figure_matches)
+
+ # 数据驱动的图片意图检测:检查检索结果中是否包含图片/图表类型切片
+ # 原理:如果向量检索返回了 image/chart 类型 chunk 或含 images_json 的 table chunk,
+ # 说明知识库中存在与查询语义相关的图片内容,应给予展示机会
+ has_image_data = False
+ _image_chunk_count = 0
+ _table_image_count = 0
+ for ctx in contexts:
+ meta = ctx.get('meta', {})
+ ct = meta.get('chunk_type', '')
+ if ct in ('image', 'chart'):
+ _image_chunk_count += 1
+ has_image_data = True
+ if ct == 'table' and meta.get('images_json'):
+ _table_image_count += 1
+ has_image_data = True
+
+ # 从检索文本中提取图表引用(见表2.2、见图2.5 等)
+ # 重要:只从语义相关的 top 5 文本块提取,避免不相关引用干扰
+ referenced_figures = {} # {图号: set(文件来源)}
+ referenced_tables = {} # {表号: set(文件来源)}
+
+ for ctx in contexts[:5]:
+ doc_text = ctx.get('doc', '')
+ source = ctx.get('meta', {}).get('source', '')
+
+ fig_refs = re.findall(r'(?:[见如])?图\s*(\d+\.?\d*)', doc_text)
+ for fig_num in fig_refs:
+ if fig_num not in referenced_figures:
+ referenced_figures[fig_num] = set()
+ if source:
+ referenced_figures[fig_num].add(source)
+
+ table_refs = re.findall(r'(?:[见如])?表\s*(\d+\.?\d*)', doc_text)
+ for table_num in table_refs:
+ if table_num not in referenced_tables:
+ referenced_tables[table_num] = set()
+ if source:
+ referenced_tables[table_num].add(source)
+
+ has_referenced_figures = bool(referenced_figures or referenced_tables)
+
+ # 获取检索结果中涉及的主要文件来源
+ primary_sources = set()
+ for ctx in contexts[:5]:
+ source = ctx.get('meta', {}).get('source', '')
+ if source:
+ primary_sources.add(source)
+
+ # 动态预算:根据检索结果数据驱动设置
+ # 后置过滤 _filter_images_by_answer 会根据回答内容精准裁剪
+ if has_figure_query:
+ # 精确查图:用户指定了具体图号
+ MAX_IMAGES = 2
+ MIN_SCORE = 5.0
+ elif has_image_data:
+ # 检索结果中有图片数据 → 宽松预算,给后置过滤留足候选空间
+ MAX_IMAGES = 5
+ MIN_SCORE = 2.0
+ elif has_referenced_figures:
+ # 检索文本中引用了图表编号
+ MAX_IMAGES = 3
+ MIN_SCORE = 2.0
+ else:
+ # 无图片数据 → 保守默认值
+ MAX_IMAGES = 2
+ MIN_SCORE = 3.0
+
+ # 动态调整:当表格嵌入大量图片时,提升上限以展示完整内容
+ if has_image_data and _table_image_count > 0:
+ total_table_images = 0
+ for ctx in contexts:
+ meta = ctx.get('meta', {})
+ if meta.get('chunk_type') == 'table' and meta.get('images_json'):
+ try:
+ total_table_images += len(json.loads(meta['images_json']))
+ except (json.JSONDecodeError, TypeError):
+ pass
+ if total_table_images > MAX_IMAGES:
+ MAX_IMAGES = min(total_table_images, 15) # 上限 15,防止图片过多
+
+ # 获取检索结果中涉及的主要章节路径(只看前 3 个最相关的文本块)
+ primary_section_paths = set()
+ for ctx in contexts[:3]:
+ section = ctx.get('meta', {}).get('section', '') or ctx.get('meta', {}).get('section_path', '')
+ if section:
+ primary_section_paths.add(section)
+
+ # ========== P1.5 预计算:表格主题相关性评分 ==========
+ # 从查询中提取关键词片段(使用 jieba 分词 + 2字及以上的词,自动过滤停用词)
+ try:
+ import jieba
+ _query_kw_segments = [w for w in jieba.lcut(query)
+ if len(w) >= 2 and re.search(r'[\u4e00-\u9fff]', w)]
+ except ImportError:
+ # jieba 不可用时回退到 bigram
+ _chars = re.findall(r'[\u4e00-\u9fff]', query)
+ _query_kw_segments = [_chars[i] + _chars[i+1] for i in range(len(_chars) - 1)]
+
+ # 对所有含 images_json 的表格切片计算主题匹配分
+ _table_topic_scores = {}
+ for _tc in contexts:
+ _tm = _tc.get('meta', {})
+ if _tm.get('chunk_type') == 'table' and _tm.get('images_json'):
+ _t_section = (_tm.get('section', '') or _tm.get('section_path', ''))
+ _t_title = _tm.get('title', '') or ''
+ _t_combined = _t_title + _t_section
+ _score = sum(1 for kw in _query_kw_segments if kw in _t_combined)
+ _table_topic_scores[id(_tc)] = _score
+
+ # 自适应阈值:要求至少匹配 70% 的最佳表格得分(至少 2 分)
+ # 例如查询 "设施设备的参考样式" → 4 个关键词 → 最佳匹配 4 → 阈值 max(2, 2) = 2
+ # "形象识别标识" 只匹配 "参考"+"样式" = 2 → 但阈值=3(70%of4)时被过滤
+ _best_table_score = max(_table_topic_scores.values()) if _table_topic_scores else 0
+ _table_topic_threshold = max(2, round(_best_table_score * 0.7)) if _best_table_score >= 2 else 0
+
+ scored_images = []
+ for ctx in contexts:
+ meta = ctx.get('meta', {})
+ chunk_type = meta.get('chunk_type', 'text')
+ s = None # P1 评分,用于 P1.5 继承
+ doc = ctx.get('doc', '') # 默认文档内容
+
+ # 处理图片类型和有关联图片的表格类型
+ if meta.get('image_path') and chunk_type in ('image', 'chart', 'table'):
+ # 优先使用 VLM 详细描述
+ # 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)==========
+ # 用 VLM 描述判断图片内容是否与查询相关
+ # 优先级:meta.vlm_desc(已同步) > .data/cache/vlm/(懒加载缓存)
+ image_path = meta.get('image_path', '')
+ vlm_desc = meta.get('vlm_desc', '') or _get_vlm_cache(image_path)
+ if vlm_desc:
+ vlm_relevance = _check_vlm_relevance(query, vlm_desc)
+ if vlm_relevance < 0.3:
+ # VLM 描述与查询不相关,适度降分
+ s -= 3.0
+ logger.debug(f"图片 {image_path} VLM 不相关,降分: {vlm_relevance:.2f}")
+ elif vlm_relevance >= 0.5:
+ # 相关,小幅加分
+ s += 2.0
+
+ # 图片来源
+ img_source = meta.get('source', '')
+
+ # 图片章节
+ img_section = meta.get('section', '') or meta.get('section_path', '')
+
+ # ========== 章节关联检测:基于层级相似度,无需硬编码格式假设 ==========
+ # 计算图片章节与主要检索结果的最高相似度
+ section_penalty = 0.0
+ max_section_sim = 0.0
+ if primary_section_paths:
+ for ps in primary_section_paths:
+ sim = _section_similarity(img_section, ps)
+ max_section_sim = max(max_section_sim, sim)
+ # 当相似度低于阈值且有足够的章节信息时,判定为不相关
+ if primary_section_paths and img_section and max_section_sim < 0.3:
+ # 图片章节与主要检索结果不匹配,惩罚
+ section_penalty = -5.0
+ # 除非图片被文本切片明确引用
+ is_referenced = False
+ for fig_num in referenced_figures:
+ if f"图{fig_num}" in doc or f"图 {fig_num}" in doc:
+ is_referenced = True
+ break
+ if not is_referenced:
+ for table_num in referenced_tables:
+ if f"表{table_num}" in doc or f"表 {table_num}" in doc:
+ is_referenced = True
+ break
+ if is_referenced:
+ section_penalty = 0.0 # 被引用则不惩罚
+
+ s += section_penalty
+
+ # 新增:如果图片描述中包含检索文本引用的图号,大幅加分
+ # 前提:图片章节与主要检索结果的章节相关
+ if referenced_figures:
+ for fig_num, sources in referenced_figures.items():
+ # 只检查 doc 字段,不检查 meta(避免 section 中的误匹配)
+ if f"图{fig_num}" in doc or f"图 {fig_num}" in doc:
+ # 使用层级相似度判断章节关联性
+ section_match = max_section_sim >= 0.3
+
+ # 章节匹配时才加分
+ if section_match:
+ # 图号匹配加分
+ s += 8.0
+ # 如果图片来源与引用来源一致,额外加分
+ if img_source in sources:
+ s += 5.0 # 文件匹配额外加分
+ break
+
+ # 新增:如果表格描述中包含检索文本引用的表号,大幅加分
+ # 同样要求章节相关性
+ if referenced_tables:
+ for table_num, sources in referenced_tables.items():
+ # 只检查 doc 字段
+ if f"表{table_num}" in doc or f"表 {table_num}" in doc:
+ # 使用层级相似度判断章节关联性
+ section_match = max_section_sim >= 0.3
+
+ # 章节匹配时才加分
+ if section_match:
+ # 表号匹配加分
+ s += 8.0
+ # 如果图片来源与引用来源一致,额外加分
+ if img_source in sources:
+ s += 5.0 # 文件匹配额外加分
+ break
+
+ # 新增:如果图片来源在主要检索结果中,加分
+ if img_source in primary_sources:
+ s += 2.0
+
+ if s >= MIN_SCORE:
+ 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 上下文
+ })
+
+ # ========== P1.5:处理表格切片的 images_json(跨页表格多图)==========
+ # 当表格切片有 images_json 字段时,添加所有关联图片
+ if chunk_type == 'table' and meta.get('images_json'):
+ # 如果 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)
+
+ # 章节相关性过滤:使用层级相似度,无需硬编码章节格式假设
+ table_section = meta.get('section', '') or meta.get('section_path', '')
+
+ section_relevant = True
+ if primary_section_paths and table_section:
+ # 计算表格章节与所有主要章节的最高相似度
+ max_sim = max(
+ (_section_similarity(table_section, ps) for ps in primary_section_paths),
+ default=0.0
+ )
+ if max_sim < 0.3:
+ section_relevant = False
+ elif primary_section_paths and not table_section:
+ # 表格无章节信息时优雅降级:不做章节过滤,仅依赖主题分数
+ pass
+
+ if not section_relevant:
+ # 例外:如果表格标题/内容被查询直接提及,仍视为相关
+ table_title = meta.get('title', '') or ''
+ table_doc = ctx.get('doc', '') or ''
+ if table_title and table_title in query:
+ section_relevant = True
+ elif table_doc and any(kw in table_doc for kw in query.split() if len(kw) >= 2):
+ section_relevant = True
+
+ if not section_relevant:
+ logger.debug(f"P1.5 跳过无关表格图片: path={table_section}, title={meta.get('title', '')}")
+ continue # 跳过此表格的所有嵌入图片
+
+ # 标题/主题相关性过滤:基于预计算的关键词匹配评分
+ # 如果最佳表格得分 >= 2,则过滤掉得分为 0 的表格
+ _this_topic_score = _table_topic_scores.get(id(ctx), 0)
+ if _this_topic_score < _table_topic_threshold:
+ logger.debug(f"P1.5 跳过主题不匹配表格: topic_score={_this_topic_score}, threshold={_table_topic_threshold}, title={meta.get('title', '')}")
+ continue
+
+ try:
+ images_list = json.loads(meta['images_json'])
+ for img_info in images_list:
+ if isinstance(img_info, dict):
+ img_id = img_info.get('id') or img_info.get('path', '')
+ img_page = img_info.get('page', meta.get('page'))
+ else:
+ img_id = str(img_info)
+ img_page = meta.get('page')
+
+ # 跳过已添加的图片(避免重复)
+ existing_ids = {img['id'] for img in scored_images}
+ if img_id and img_id not in existing_ids:
+ # 为关联图片计算分数(继承主表格分数,略低)
+ assoc_score = s - 1.0 if s >= MIN_SCORE else MIN_SCORE - 1.0
+ if assoc_score >= MIN_SCORE:
+ 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
+ })
+ except (json.JSONDecodeError, TypeError):
+ pass
+
+ # ========== P2:通过文本切片的 referenced_images 补充图片 ==========
+ # 检查 top 5 文本切片的 referenced_images,补充未选中的关联图片
+ existing_image_ids = {img['id'] for img in scored_images}
+
+ for ctx in contexts[:5]:
+ meta = ctx.get('meta', {})
+ if meta.get('chunk_type') != 'text':
+ continue
+
+ referenced = meta.get('referenced_images', [])
+ if not referenced:
+ continue
+
+ # 查找对应的图片切片
+ for fig_num in referenced:
+ # 在所有 contexts 中查找匹配的图片
+ for img_ctx in contexts:
+ img_meta = img_ctx.get('meta', {})
+ if img_meta.get('chunk_type') not in ('image', 'chart', 'table'):
+ continue
+
+ img_path = img_meta.get('image_path', '')
+ img_id = os.path.basename(img_path)
+
+ # 检查是否已存在
+ if img_id in existing_image_ids:
+ continue
+
+ # 检查图号/表号是否匹配
+ img_doc = img_ctx.get('doc', '')
+ if f"图{fig_num}" in img_doc or f"表{fig_num}" in img_doc:
+ # 添加到结果中
+ 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]
+
+def chat_with_llm(message: str, history: List[Dict] = None, enable_web_search: bool = True) -> Dict[str, Any]:
+ """
+ 普通聊天 - 使用 LLM 直接回复
+
+ 当查询不需要知识库检索时,直接调用 LLM 进行回复。
+ 可选启用网络搜索增强。
+
+ Args:
+ message: 用户消息
+ history: 对话历史(由后端传入)
+ enable_web_search: 是否启用网络搜索
+
+ Returns:
+ {
+ "answer": str,
+ "sources": list,
+ "web_searched": bool
+ }
+
+ Example:
+ >>> result = chat_with_llm("你好", enable_web_search=False)
+ >>> print(result["answer"])
+ """
+ from config import get_llm_client, LLM_MAX_TOKENS
+
+ client = get_llm_client()
+
+ # 构建消息
+ messages = []
+
+ # 添加历史
+ if history:
+ for h in history[-MAX_HISTORY_ROUNDS:]:
+ messages.append({"role": h["role"], "content": h["content"]})
+
+ messages.append({"role": "user", "content": message})
+
+ # 调用 LLM
+ answer = call_llm(client, prompt="", model=RAG_CHAT_MODEL, messages=messages, max_tokens=LLM_MAX_TOKENS)
+
+ return {
+ "answer": answer or "",
+ "sources": [],
+ "web_searched": False
+ }
+
+
+def search_hybrid(query: str, top_k: int = 5,
+ allowed_levels: list = None, allowed_collections: list = None,
+ sub_queries: list = None):
+ """
+ 混合检索:直接调用生产环境引擎,确保测试效果与生产一致
+
+ Args:
+ query: 查询文本
+ top_k: 返回数量
+ allowed_levels: 允许的安全级别
+ allowed_collections: 允许的向量库列表
+ sub_queries: 意图分析器生成的子查询列表(对比类查询用)
+
+ Returns:
+ 融合后的检索结果
+ """
+ from core.engine import get_engine
+
+ engine = get_engine()
+
+ # 直接调用生产环境的检索方法
+ result = engine.search_knowledge(
+ query=query,
+ top_k=top_k,
+ allowed_levels=allowed_levels,
+ collections=allowed_collections,
+ sub_queries=sub_queries
+ )
+
+ # 添加 scores 字段(用于前端显示和上下文过滤)
+ if result and result.get('ids') and result['ids'][0]:
+ distances = result.get('distances', [[]])[0]
+ if result.get('_reranked'):
+ # Rerank 后 distances 中存储的是相关性分数(越高越好),直接使用
+ scores = [float(d) for d in distances]
+ else:
+ # 未 Rerank:将向量距离转换为相似度分数(距离越小,分数越高)
+ scores = [1.0 - d if d <= 1.0 else 1.0 / (1.0 + d) for d in distances]
+ result['scores'] = [scores]
+ else:
+ result = {
+ 'ids': [[]],
+ 'documents': [[]],
+ 'metadatas': [[]],
+ 'distances': [[]],
+ 'scores': [[]]
+ }
+
+ return result
+
+
+# ==================== 路由 ====================
+
+@chat_bp.route('/chat', methods=['POST'])
+@require_gateway_auth
+def chat():
+ """
+ 普通聊天模式 - 直接使用LLM回复
+
+ 请求体:
+ {
+ "message": "消息内容",
+ "history": [{"role": "user/assistant", "content": "..."}] // 可选
+ }
+ """
+ data = request.json or {}
+ message = data.get('message')
+ history = data.get('history', [])
+
+ if not message:
+ return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 message", http_status=400)
+
+ # 输入安全验证
+ is_valid, reason = validate_query(message)
+ if not is_valid:
+ return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
+
+ # 智能聊天
+ result = chat_with_llm(message, history)
+
+ # 过滤敏感信息
+ answer = filter_response(result["answer"])
+
+ return success_response(data={
+ "answer": answer,
+ "mode": "chat",
+ "sources": result.get("sources", []),
+ "web_searched": result.get("web_searched", False)
+ })
+
+
+@chat_bp.route('/rag', methods=['POST'])
+@require_gateway_auth
+def rag():
+ """
+ 知识库问答模式 - SSE 流式返回
+
+ 请求体:
+ {
+ "message": "消息内容",
+ "history": [{"role": "user/assistant", "content": "..."}], // 可选(开发环境)
+ "chat_history": [{"role": "user/assistant", "content": "..."}], // 可选(生产环境)
+ "collections": ["public_kb"], // 可选,知识库列表
+ "session_id": "xxx" // 可选,会话ID
+ }
+
+ SSE 事件序列:
+ 1. start: 开始处理
+ 2. sources: 检索到的来源
+ 3. chunk: 每个 token
+ 4. finish: 完成响应(包含完整 answer 和 sources)
+ 5. error: 错误事件
+ """
+ import re
+ from config import (
+ IS_PROD, IS_DEV, ENABLE_SESSION,
+ RAG_SEARCH_TOP_K,
+ MAX_CONTEXT_CHUNKS, MAX_SOURCES_RETURNED,
+ LLM_TEMPERATURE, LLM_MAX_TOKENS,
+ MAX_HISTORY_ROUNDS, IMAGE_CONTEXT_HISTORY,
+ DIRECT_CONTEXT_MAX_CHARS,
+ RERANK_CONTEXT_MIN_SCORE,
+ CONTEXT_MAX_CHARS, CONTEXT_SOFT_LIMIT,
+ CONFIDENCE_WARN_THRESHOLD, CONFIDENCE_CAUTION_THRESHOLD,
+ SECTION_CLUSTER_RESCUE_ENABLED, CLUSTER_MIN_MEMBERS, CLUSTER_MIN_TYPES,
+ CLUSTER_RESCUE_FLOOR, CLUSTER_MAX_SECTIONS, CLUSTER_MAX_RESCUE_PER_SECTION,
+ CLUSTER_SECTION_PREFIX_LEVELS
+ )
+
+ data = request.json or {}
+
+ message = data.get('message')
+ # 兼容两种参数名:history(旧)和 chat_history(新)
+ # 用 is not None 判断,避免 [] 被 or 吞掉
+ if 'chat_history' in data:
+ history = data['chat_history']
+ elif 'history' in data:
+ history = data['history']
+ else:
+ history = None
+ collections = data.get('collections')
+ session_id = data.get('session_id')
+
+ if not message:
+ return error_response("MISSING_PARAMS", BAD_REQUEST, "缺少 message", http_status=400)
+
+ # 生产环境强制校验 chat_history
+ if IS_PROD and history is None:
+ return error_response("MISSING_HISTORY", BAD_REQUEST, "chat_history is required in production", http_status=400)
+
+ # 输入安全验证
+ is_valid, reason = validate_query(message)
+ if not is_valid:
+ return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
+
+ # 如果没有指定 collections,使用默认的公开库
+ if not collections:
+ collections = ['public_kb']
+
+ # ==================== 会话历史加载 ====================
+ # 优先使用传入的 history,否则从 session_repo 加载
+ user_id = request.current_user.get("user_id")
+
+ if history is not None:
+ # 使用传入的历史(生产环境必须传入)
+ pass
+ elif session_id and ENABLE_SESSION:
+ # 开发环境:从本地数据库加载
+ try:
+ session_repo = current_app.session_repo
+ history = session_repo.get_history(session_id)
+ # 限制历史长度
+ history = history[-MAX_HISTORY_ROUNDS:] if len(history) > MAX_HISTORY_ROUNDS else history
+ except Exception as e:
+ logger.debug(f"解析历史记录失败: {e}")
+ history = []
+ else:
+ history = []
+
+ # 如果没有 session_id,创建新会话(仅开发环境)
+ if not session_id and ENABLE_SESSION:
+ try:
+ session_repo = current_app.session_repo
+ session_id = session_repo.create_session(user_id)
+ except Exception as e:
+ logger.debug(f"创建会话失败: {e}")
+
+ # ==================== collections 历史推断(开发环境) ====================
+ # 当 collections 未显式指定(或仅为默认 public_kb)且有会话历史时,
+ # 从历史消息中推断上次使用的 KB,自动恢复以避免用户忘切 KB
+ if (not collections or collections == ['public_kb']) and history:
+ for _msg in reversed(history):
+ if _msg.get("role") == "assistant":
+ _meta = _msg.get("metadata", {})
+ if isinstance(_meta, dict) and _meta.get("collections"):
+ collections = _meta["collections"]
+ logger.info(f"[KB推断] 从历史推断 collections: {collections}")
+ break
+
+ # 提前获取 session_repo 引用,避免在生成器内部访问 current_app
+ # (生成器执行时应用上下文可能已结束)
+ session_repo_ref = None
+ if ENABLE_SESSION:
+ try:
+ session_repo_ref = current_app.session_repo
+ except Exception as e:
+ logger.debug(f"获取会话仓库失败: {e}")
+
+ def generate():
+ """生成 SSE 流"""
+ import re
+ start_time = _time.time()
+ full_answer = []
+
+ try:
+ # 0. 意图分析(改写 + 双层判断)
+ context_images = []
+ if history:
+ # 从历史中提取图片信息
+ for msg in reversed(history[-IMAGE_CONTEXT_HISTORY:]):
+ metadata = msg.get("metadata", {})
+ if isinstance(metadata, dict):
+ images = metadata.get("images", [])
+ if images:
+ context_images.extend(images[:3])
+
+ intent = None
+ try:
+ from core.intent_analyzer import analyze_intent
+ intent = analyze_intent(message, history or [], context_images)
+
+ logger.info(f"[意图分析] use_context={intent.use_context}, need_retrieval={intent.need_retrieval}, intent={intent.intent}, sub_queries={intent.sub_queries}")
+
+ # 调试事件:意图分析结果
+ if IS_DEV:
+ yield f"data: {json.dumps({'type': 'intent_result', 'data': {'intent': intent.intent, 'confidence': round(intent.confidence, 2), 'rewritten_query': intent.rewritten_query, 'sub_queries': intent.sub_queries, 'need_retrieval': intent.need_retrieval, 'reason': intent.reason}}, ensure_ascii=False)}\n\n"
+
+ # 如果不需要检索,直接使用上下文回答
+ if not intent.need_retrieval and intent.use_context:
+ yield f"data: {json.dumps({'type': 'start', 'message': '正在分析...'}, ensure_ascii=False)}\n\n"
+
+ # 构建上下文
+ context_text = ""
+ if history:
+ # 提取最近的助手回答
+ for msg in reversed(history):
+ if msg.get("role") == "assistant":
+ context_text = msg.get("content", "")
+ break
+
+ # 构建图片上下文
+ image_context = ""
+ if context_images:
+ image_context = "\n\n【上下文中的图片】\n"
+ for img in context_images[:5]:
+ if isinstance(img, dict):
+ desc = img.get("description", "")
+ img_type = img.get("type", "图片")
+ image_context += f"- {img_type}: {desc}\n"
+
+ # 直接调用 LLM
+ from config import get_llm_client, DASHSCOPE_MODEL
+ client = get_llm_client()
+
+ system_prompt = f"""你是一个专业的知识库问答助手。请根据对话历史和上下文回答用户问题。
+
+如果用户问题是关于图片的,请根据上下文中的图片描述进行分析。
+
+{image_context}"""
+
+ user_prompt = f"""对话历史:
+{context_text[:DIRECT_CONTEXT_MAX_CHARS] if context_text else '(无历史上下文)'}
+
+用户问题:{intent.rewritten_query}
+
+请直接回答用户问题。"""
+
+ # 流式生成回答
+ for content in call_llm_stream(
+ client,
+ prompt=user_prompt,
+ model=DASHSCOPE_MODEL,
+ messages=[
+ {"role": "system", "content": system_prompt},
+ {"role": "user", "content": user_prompt}
+ ],
+ temperature=LLM_TEMPERATURE
+ ):
+ full_answer.append(content)
+ yield f"data: {json.dumps({'type': 'chunk', 'content': content}, ensure_ascii=False)}\n\n"
+
+ # 发送完成事件
+ yield f"data: {json.dumps({'type': 'finish', 'answer': ''.join(full_answer), 'sources': []}, ensure_ascii=False)}\n\n"
+ return # 直接返回,不执行后续检索
+
+ except Exception as e:
+ logger.warning(f"意图分析失败: {e},继续执行检索流程")
+
+ # 构建检索查询:使用改写后的完整问题(解决追问偏离问题)
+ retrieval_query = intent.rewritten_query if (intent and intent.rewritten_query) else message
+
+ # 1.5 语义缓存检查(跳过检索+生成全流程)
+ _semantic_cache_emb = None
+ try:
+ from core.semantic_cache import get_semantic_cache
+ from core.engine import get_engine as _get_eng
+ _sc = get_semantic_cache()
+ _eng = _get_eng()
+ if _sc and _eng and hasattr(_eng, 'embedding_model'):
+ # 缓存 key 使用 retrieval_query + collections,避免不同上下文的追问命中错误缓存
+ _cache_collections = ','.join(sorted(collections)) if collections else ''
+ _cache_key_text = f"{retrieval_query}|{_cache_collections}"
+ _semantic_cache_emb = _eng.embedding_model.encode(_cache_key_text)
+ cached = _sc.get(_semantic_cache_emb)
+ # 防御性校验:必须是 RAG 回答缓存(非 intent_analyzer 缓存),且回答非空
+ if cached is not None and cached.get("cache_type") == "rag_answer" and cached.get("answer"):
+ logger.info(f"[语义缓存] 命中: {message[:50]}...")
+ cached_answer = cached.get("answer", "")
+ # 流式返回缓存的答案
+ yield f"data: {json.dumps({'type': 'start', 'message': '正在检索知识库...'}, ensure_ascii=False)}\n\n"
+ # 分块发送缓存答案
+ chunk_size = 20
+ for i in range(0, len(cached_answer), chunk_size):
+ chunk = cached_answer[i:i+chunk_size]
+ full_answer.append(chunk)
+ yield f"data: {json.dumps({'type': 'chunk', 'content': chunk}, ensure_ascii=False)}\n\n"
+ finish_event = {
+ "type": "finish",
+ "answer": cached_answer,
+ "mode": "rag",
+ "session_id": session_id,
+ "sources": cached.get("sources", []),
+ "citations": cached.get("citations", []),
+ "images": cached.get("images", []),
+ "tables": cached.get("tables", []),
+ "sections": [],
+ "duration_ms": int((_time.time() - start_time) * 1000),
+ "confidence_score": 1.0,
+ "semantic_cache_hit": True
+ }
+ yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n"
+ return
+ except Exception as e:
+ logger.warning(f"[语义缓存] 检查失败: {e}")
+ _semantic_cache_emb = None
+
+ # 1. 发送开始事件
+ yield f"data: {json.dumps({'type': 'start', 'message': '正在检索知识库...'}, ensure_ascii=False)}\n\n"
+
+ # 2. 执行混合检索(扩大召回数量,确保图片切片有机会被召回)
+ # 如果意图分析生成了子查询(对比类),传给搜索引擎并行检索
+ sub_queries = None
+ if intent and intent.sub_queries and len(intent.sub_queries) > 1:
+ sub_queries = intent.sub_queries
+
+ search_result = search_hybrid(
+ retrieval_query,
+ top_k=RAG_SEARCH_TOP_K,
+ allowed_collections=collections,
+ sub_queries=sub_queries
+ )
+
+ # 调试事件:检索管线详情
+ if IS_DEV:
+ debug_info = search_result.get('_debug', {})
+ yield f"data: {json.dumps({'type': 'retrieval_debug', 'data': {'steps': debug_info.get('steps', []), 'collections_searched': collections, 'total_candidates': len(search_result.get('ids', [[]])[0])}}, ensure_ascii=False)}\n\n"
+
+ # 提取章节聚类提升事件(引擎层)单独发送
+ for step in debug_info.get('steps', []):
+ if step.get('name') == 'section_cluster_boost':
+ yield f"data: {json.dumps({'type': 'section_cluster_boost', 'data': step}, ensure_ascii=False)}\n\n"
+
+ # 提取上下文
+ contexts = []
+ sources = []
+
+ if search_result.get('documents') and search_result['documents'][0]:
+ docs = search_result['documents'][0]
+ metas = search_result.get('metadatas', [[]])[0]
+ scores = search_result.get('scores', [[]])[0]
+
+ # 图片相关性提升:数据驱动检测(无硬编码关键词)
+ import re
+ figure_pattern = r'图\s*(\d+\.?\d*)'
+ figure_matches = re.findall(figure_pattern, retrieval_query)
+ has_figure_query = bool(figure_matches)
+
+ # 数据驱动:检查检索结果中是否有图片/图表类型切片
+ has_image_data = any(
+ m.get('chunk_type') in ('image', 'chart') for m in metas
+ )
+ has_image_intent = has_figure_query or has_image_data
+
+ # 给图片/图表切片打 boost 标记,供后续 select_images 使用
+ if has_image_intent:
+ for i, (doc, meta, score) in enumerate(zip(docs, metas, scores)):
+ if meta.get('chunk_type') in ('image', 'chart'):
+ # 检查 caption 是否与查询相关
+ caption = meta.get('caption', '') or ''
+ should_boost = False
+ boost_factor = 1.0
+
+ # 如果查询包含图片编号,检查 caption 是否匹配
+ if has_figure_query:
+ for fig_num in figure_matches:
+ if f"图{fig_num}" in caption or f"图 {fig_num}" in caption:
+ should_boost = True
+ boost_factor = 2.0 # 强提升
+ break
+
+ # 或者 caption 与查询有足够重叠
+ if not should_boost:
+ overlap = len(set(message) & set(caption))
+ if overlap >= 3 or any(word in caption for word in message if len(word) >= 3):
+ should_boost = True
+ boost_factor = 1.5 # 提升 50%
+
+ if should_boost:
+ meta['_image_boost'] = boost_factor # 打标记,不重排
+ # 不做 sort!保持检索引擎的原始排序
+
+ # 按 source 去重,保留最高分
+ seen_sources = {}
+ for rank, (doc, meta, score) in enumerate(zip(docs, metas, scores)):
+ meta['_retrieval_rank'] = rank
+ # 确保 _collection 字段存在(单知识库路径下 ChromaDB 原生不返回此字段)
+ if not meta.get('_collection'):
+ # 优先使用入库时写入的 collection 字段
+ meta['_collection'] = meta.get('collection') or (collections[0] if collections else 'public_kb')
+ source_name = meta.get('source', '未知')
+ if source_name not in seen_sources or score > seen_sources[source_name]['score']:
+ doc_type = meta.get('doc_type', 'other')
+ page = meta.get('page', 0)
+ page_end = meta.get('page_end')
+ # 仅 PDF 的页码是真实可靠的;Word 等的页码是 MinerU 合成的,无意义,不外露
+ if doc_type == 'pdf' and page:
+ page_range = f"{page}-{page_end}" if (page_end and page_end > page) else str(page)
+ else:
+ page = None
+ page_end = None
+ page_range = ''
+
+ seen_sources[source_name] = {
+ 'source': source_name,
+ 'page': page,
+ 'page_end': page_end,
+ 'page_range': page_range,
+ 'section': meta.get('section', '') or meta.get('section_path', ''),
+ 'chunk_type': meta.get('chunk_type', 'text'),
+ 'doc_type': doc_type, # 文档类型
+ 'section_chunk_id': meta.get('section_chunk_id'), # 章节内序号
+ 'score': round(score, 3) if isinstance(score, float) else score
+ }
+ # ========== P1:图片使用 full_description ==========
+ # 图片切片使用完整描述(用于 LLM 上下文),而非短摘要
+ display_doc = doc
+ if meta.get('chunk_type') in ('image', 'chart', 'table'):
+ full_desc = meta.get('full_description', '')
+ if full_desc:
+ display_doc = full_desc
+
+ # contexts 仍然保留所有结果用于生成答案
+ contexts.append({'doc': display_doc, 'meta': meta, 'score': score})
+
+ sources = list(seen_sources.values())
+
+ # 调试事件:召回切片详情
+ if IS_DEV:
+ chunks_debug = []
+ for i, ctx in enumerate(contexts[:20]):
+ m = ctx.get('meta', {})
+ chunks_debug.append({
+ 'rank': i + 1,
+ 'source': m.get('source', ''),
+ 'page': m.get('page', 0),
+ 'chunk_type': m.get('chunk_type', 'text'),
+ 'section': m.get('section', ''),
+ 'score': round(ctx.get('score', 0), 4) if ctx.get('score') else None,
+ 'content': (ctx.get('doc', '') or '')[:300]
+ })
+ yield f"data: {json.dumps({'type': 'chunks_retrieved', 'data': {'count': len(contexts), 'chunks': chunks_debug}}, ensure_ascii=False)}\n\n"
+
+ # 补充检索:从文本切片中提取图号/表号引用,补充检索对应的图片
+ # 重要:只从最相关的 top 5 文本切片提取引用,避免不相关引用干扰
+ import re
+ referenced_figures = set()
+ referenced_tables = set()
+
+ # 只检查 top 5 文本切片(与 select_images 逻辑一致)
+ text_contexts = [ctx for ctx in contexts if ctx.get('meta', {}).get('chunk_type') == 'text'][:5]
+ for ctx in text_contexts:
+ doc_text = ctx.get('doc', '')
+ fig_refs = re.findall(r'(?:[见如及和与])?图\s*(\d+\.?\d*)', doc_text)
+ referenced_figures.update(fig_refs)
+ table_refs = re.findall(r'(?:[见如及和与])?表\s*(\d+\.?\d*)', doc_text)
+ referenced_tables.update(table_refs)
+
+ # 检查哪些图号/表号对应的图片不在 contexts 中
+ existing_figure_images = set()
+ existing_table_images = set()
+ for ctx in contexts:
+ doc = ctx.get('doc', '')
+ meta = ctx.get('meta', {})
+ if meta.get('chunk_type') in ('image', 'chart'):
+ for fig_num in referenced_figures:
+ if f"图{fig_num}" in doc:
+ existing_figure_images.add(fig_num)
+ for table_num in referenced_tables:
+ if f"表{table_num}" in doc:
+ existing_table_images.add(table_num)
+
+ # 需要补充检索的图号/表号
+ missing_figures = referenced_figures - existing_figure_images
+ missing_tables = referenced_tables - existing_table_images
+
+ # 计算主要章节路径(用于补充检索过滤)
+ primary_section_paths_for_supp = set()
+ for ctx in text_contexts[:3]:
+ section = ctx.get('meta', {}).get('section', '') or ctx.get('meta', {}).get('section_path', '')
+ if section:
+ primary_section_paths_for_supp.add(section)
+
+ if missing_figures or missing_tables:
+ # 补充检索
+ from knowledge.manager import get_kb_manager
+ kb_manager = get_kb_manager()
+ kb_name = collections[0] if collections else 'public_kb'
+ collection = kb_manager.get_collection(kb_name)
+
+ if collection:
+ # 构建补充查询
+ supplement_queries = []
+ for fig_num in missing_figures:
+ supplement_queries.append(f"图{fig_num}")
+ for table_num in missing_tables:
+ supplement_queries.append(f"表{table_num}")
+
+ supplement_query = " ".join(supplement_queries)
+
+ # 使用 embedding 检索
+ # P4:复用 engine 的 embedding 模型,避免重复加载
+ try:
+ from core.engine import get_engine
+ engine = get_engine()
+ query_vector = engine.embedding_model.encode(supplement_query).tolist()
+ if isinstance(query_vector[0], list):
+ query_vector = query_vector[0]
+
+ supplement_result = collection.query(
+ query_embeddings=[query_vector],
+ n_results=10,
+ include=['documents', 'metadatas', 'distances']
+ )
+
+ # 添加匹配的图片切片
+ for supp_doc, supp_meta, supp_dist in zip(
+ supplement_result['documents'][0],
+ supplement_result['metadatas'][0],
+ supplement_result['distances'][0]
+ ):
+ chunk_type = supp_meta.get('chunk_type', '')
+ if chunk_type in ('image', 'chart'):
+ # 检查是否匹配缺失的图号/表号
+ is_match = False
+ matched_fig = None
+ for fig_num in missing_figures:
+ if f"图{fig_num}" in supp_doc:
+ is_match = True
+ matched_fig = fig_num
+ break
+ for table_num in missing_tables:
+ if f"表{table_num}" in supp_doc:
+ is_match = True
+ break
+
+ if is_match:
+ # 额外检查:图片章节是否与主要章节匹配
+ # 使用层级相似度判断,无需硬编码格式假设
+ supp_section = supp_meta.get('section', '') or supp_meta.get('section_path', '')
+
+ if primary_section_paths_for_supp and supp_section:
+ supp_max_sim = max(
+ (_section_similarity(supp_section, ps) for ps in primary_section_paths_for_supp),
+ default=0.0
+ )
+ if supp_max_sim < 0.3:
+ continue
+ elif primary_section_paths_for_supp and not supp_section:
+ # 补充检索的图片无章节信息时优雅降级:跳过
+ continue
+
+ # Bug 6a 修复:补充检索的图片也要做 full_description 替换
+ # 与正常检索保持一致
+ display_doc = supp_doc
+ if supp_meta.get('chunk_type') in ('image', 'chart', 'table'):
+ full_desc = supp_meta.get('full_description', '')
+ if full_desc:
+ display_doc = full_desc
+
+ contexts.append({
+ 'doc': display_doc,
+ 'meta': {**supp_meta, '_collection': supp_meta.get('_collection') or (collections[0] if collections else 'public_kb')},
+ 'score': 1.0 - supp_dist
+ })
+ logger.info(f"[补充检索] 添加图片: {supp_meta.get('image_path', '')}")
+ except Exception as e:
+ logger.warning(f"补充检索失败: {e}")
+
+ # 发送来源事件
+ yield f"data: {json.dumps({'type': 'sources', 'sources': sources[:MAX_SOURCES_RETURNED]}, ensure_ascii=False)}\n\n"
+
+ # 调试:检查 contexts 中是否有图片切片
+ image_count = sum(1 for ctx in contexts if ctx.get('meta', {}).get('chunk_type') in ('image', 'chart'))
+ if image_count > 0:
+ logger.info(f"[图片检索] contexts 中包含 {image_count} 个图片/图表切片")
+ for ctx in contexts:
+ meta = ctx.get('meta', {})
+ 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 描述(meta/cache),不阻塞响应
+ # 后台线程异步调用 VLM/LLM 生成缺失描述,写入缓存和 ChromaDB,
+ # 下次查询时缓存命中,响应不受影响
+ try:
+ import threading
+ from knowledge.lazy_enhance import enhance_retrieved_chunks
+ kb_name = collections[0] if collections else 'public_kb'
+
+ # 提取后台增强所需字段(替代 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.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)
+
+ # 调试事件:图片选择详情
+ if IS_DEV:
+ yield f"data: {json.dumps({'type': 'images_selected', 'data': {'total_scored': len([c for c in contexts if c.get('meta',{}).get('chunk_type') in ('image','chart','table')]), 'selected_count': len(selected_images), 'images': [{'source': img.get('source',''), 'page': img.get('page',0), 'score': round(img.get('_image_boost', 1.0), 2)} for img in selected_images]}}, ensure_ascii=False)}\n\n"
+
+ # 4. 构建 prompt(Phase 6:LLM 图片感知)
+ # Bug 1 修复:文本切片用于 top 5 名额竞争,图片描述不参与竞争
+
+ # BM25 分歧检测救援:BM25 top-3 但 rerank 压制的切片
+ contexts = _rescue_bm25_divergence(contexts, search_result, RERANK_CONTEXT_MIN_SCORE)
+
+ # 词法匹配救援:当切片文本精确包含查询关键词但 CrossEncoder 评分低时,
+ # 提升分数使其通过 min_score 过滤(适用于独立切片无法触发聚类救援的场景)
+ contexts = _rescue_lexical_match(contexts, retrieval_query, RERANK_CONTEXT_MIN_SCORE)
+
+ # 章节聚类救援(路由层安全网):在 min_score 过滤前,
+ # 检测"全灭 section"并分配保底分数
+ _rescue_debug = None
+ if SECTION_CLUSTER_RESCUE_ENABLED:
+ _before_scores = {id(ctx): ctx.get('score', 0) for ctx in contexts}
+ contexts = _rescue_section_cluster(contexts, retrieval_query, RERANK_CONTEXT_MIN_SCORE)
+ # 统计救援结果
+ _rescued = [ctx for ctx in contexts if id(ctx) in _before_scores
+ and ctx.get('score', 0) > _before_scores[id(ctx)]]
+ if _rescued and IS_DEV:
+ _rescue_sections = set()
+ for ctx in _rescued:
+ meta = ctx.get('meta', {})
+ sp = meta.get('section', '') or meta.get('section_path', '')
+ _rescue_sections.add(_normalize_section_for_rescue(sp, CLUSTER_SECTION_PREFIX_LEVELS))
+ _rescue_debug = {
+ 'rescued_count': len(_rescued),
+ 'rescued_sections': list(_rescue_sections)
+ }
+ yield f"data: {json.dumps({'type': 'section_cluster_rescue', 'data': _rescue_debug}, ensure_ascii=False)}\n\n"
+
+ text_contexts = _order_text_contexts_for_prompt(contexts, retrieval_query, MAX_CONTEXT_CHUNKS,
+ min_score=RERANK_CONTEXT_MIN_SCORE)
+ # Phase 2:按字符预算构建上下文
+ _is_comparison = intent and intent.intent == "comparison"
+ if _is_enum_query(retrieval_query) or _is_comparison:
+ # 列举类 / 对比类查询:保持原始顺序,不做预算截断
+ # 仍然注入 section_path 标题,帮助 LLM 区分不同章节
+ enum_parts = []
+ prev_section = None
+ for ctx in text_contexts:
+ meta = ctx.get('meta', {})
+ section = meta.get('section', '') or meta.get('section_path', '')
+ if section and section != prev_section:
+ enum_parts.append(f"━ {section} ━")
+ prev_section = section
+ # 精简表格切片:去除冗余的语义增强前缀
+ doc = _strip_semantic_prefix(ctx.get('doc', ''), meta.get('chunk_type', ''))
+ enum_parts.append(doc)
+ context_text = "\n\n".join(enum_parts)
+ else:
+ context_text = _build_context_with_budget(text_contexts, CONTEXT_MAX_CHARS, CONTEXT_SOFT_LIMIT)
+
+ # 表格救援:CrossEncoder 对表格评分偏低,导致表格被预算截断
+ # 当查询涉及表格但上下文中没有表格数据时,从被截断的切片中补回
+ context_text = _rescue_table_chunks(text_contexts, context_text, retrieval_query)
+
+ # Phase 4:计算置信度分数(top-3 平均 Rerank 分数)
+ _top_scores = [ctx.get('score', 0) for ctx in text_contexts[:3]]
+ _confidence_score = round(sum(_top_scores) / len(_top_scores), 4) if _top_scores else 0.0
+
+ # Bug 6b 优化:直接使用 selected_images 中的 full_description
+ # 这样 LLM 既能看到文本切片,也能知道图片内容
+ if selected_images:
+ # 区分表格嵌入图片和独立图片
+ has_table_embedded_images = any(
+ img.get('type') == 'table_image' for img in selected_images
+ )
+ # 检查上下文中是否有表格含嵌入图片
+ has_table_with_images = any(
+ ctx.get('meta', {}).get('chunk_type') == 'table'
+ and ctx.get('meta', {}).get('images_json')
+ for ctx in text_contexts
+ )
+
+ image_descriptions = []
+ for i, img in enumerate(selected_images, 1):
+ # 直接使用 select_images 时带上的 full_description
+ full_desc = img.get('full_description', '') or img.get('description', '')
+ if full_desc:
+ # 添加图片来源信息
+ img_source = img.get('source', '')
+ img_page = img.get('page', '')
+ source_info = f"(来源:{img_source} 第{img_page}页)" if img_source and img_page else ""
+ image_descriptions.append(f"【图片{i}】{full_desc}{source_info}")
+ if image_descriptions:
+ context_text += "\n\n【相关图片信息】\n" + "\n\n".join(image_descriptions)
+
+ # 根据图片类型给出不同的回答指令
+ if has_table_embedded_images and has_table_with_images:
+ context_text += (
+ "\n\n【回答要求】参考资料中包含表格及其嵌入图片。"
+ "请以**表格形式**呈现数据(保持原始表格结构),"
+ "并在对应单元格中使用 `` 格式嵌入图片。"
+ "不要将表格内容转为纯文本描述,不要把图片与表格分开展示。"
+ )
+ else:
+ # 添加指令让 LLM 介绍图片
+ context_text += "\n\n【回答要求】回答时请简要介绍每张图片的内容和用途。"
+
+ enhanced_context = context_text
+
+ # 对比类查询:添加结构化对比指令
+ if intent and intent.intent == "comparison" and intent.sub_queries:
+ comparison_instruction = (
+ "\n\n【回答要求】这是一个对比类问题。"
+ "请根据参考资料,从多个角度对比分析,"
+ "使用表格或分点形式清晰呈现差异和共同点。"
+ "如果参考资料中缺少某一方面的信息,请如实说明。"
+ )
+ enhanced_context = comparison_instruction + "\n\n" + enhanced_context
+
+ # 推理类查询:添加因果分析指令
+ elif intent and intent.intent == "reasoning":
+ reasoning_instruction = (
+ "\n\n【回答要求】这是一个需要分析原因或推理的问题。"
+ "请根据参考资料,先梳理相关事实和数据,再给出逻辑清晰的分析。"
+ "如果涉及因果关系,请明确标注原因和结果;如果资料不足以支撑推理,请如实说明。"
+ )
+ enhanced_context = reasoning_instruction + "\n\n" + enhanced_context
+
+ # 操作指导类查询:添加步骤化指令
+ elif intent and intent.intent == "instruction":
+ instruction_instruction = (
+ "\n\n【回答要求】这是一个操作指导类问题。"
+ "请根据参考资料,以清晰的步骤或流程形式组织回答。"
+ "如有前置条件或注意事项,请在步骤前说明。"
+ )
+ enhanced_context = instruction_instruction + "\n\n" + enhanced_context
+
+ if _is_enum_query(retrieval_query):
+ enum_instruction = (
+ "\n\n【回答要求】如果参考资料中包含编号列表、禁止情形、要求或条款,"
+ "请按资料中的原始顺序完整列出;不要合并相邻条目,不要跳项,"
+ "资料不足时明确说明缺少哪部分依据。\n\n"
+ )
+ enhanced_context = enum_instruction + enhanced_context
+
+ # Phase 4:根据置信度注入谨慎回答指令
+ if _confidence_score < CONFIDENCE_WARN_THRESHOLD:
+ enhanced_context += (
+ "\n\n【重要提示】参考资料与问题的相关性较低。"
+ "请仅基于参考资料中明确包含的信息回答,"
+ '如果资料不足以回答问题,请直接说明"知识库中未找到直接相关的信息"。'
+ )
+ elif _confidence_score < CONFIDENCE_CAUTION_THRESHOLD:
+ enhanced_context += (
+ "\n\n【提示】参考资料的相关性一般,请优先引用资料中的原文,避免推测。"
+ )
+
+ # 调试事件:最终上下文
+ if IS_DEV:
+ text_used = text_contexts
+ _scores = [round(ctx.get('score', 0), 4) for ctx in text_used]
+ yield f"data: {json.dumps({'type': 'context_built', 'data': {'chunk_count': len(text_used), 'context_length': len(enhanced_context), 'budget_max_chars': CONTEXT_MAX_CHARS, 'min_score_filter': RERANK_CONTEXT_MIN_SCORE, 'confidence_top3': _confidence_score, 'score_stats': {'max': max(_scores) if _scores else 0, 'min': min(_scores) if _scores else 0, 'avg': round(sum(_scores)/len(_scores), 4) if _scores else 0}, 'context_preview': enhanced_context[:500], 'chunks_used': [{'source': ctx.get('meta',{}).get('source',''), 'page': ctx.get('meta',{}).get('page',0), 'score': ctx.get('score',0), 'preview': (ctx.get('doc','') or '')[:100]} for ctx in text_used]}}, ensure_ascii=False)}\n\n"
+
+ # 5. 流式生成回答
+ from core.engine import get_engine
+ engine = get_engine()
+
+ for token in engine.generate_answer_stream(message, enhanced_context, history):
+ full_answer.append(token)
+ yield f"data: {json.dumps({'type': 'chunk', 'content': token}, ensure_ascii=False)}\n\n"
+
+ # 6. P0:答案对齐过滤器
+ # 从 LLM 回答中提取图号引用,过滤图片选择结果
+ full_answer_text = "".join(full_answer)
+
+ # 提取回答中引用的图号/表号
+ mentioned = set()
+ # 中文图号:图2.1、图 2-1、见图2.1 等
+ mentioned.update(re.findall(r'(?:[见如])?图\s*(\d+[\.\-]?\d*)', full_answer_text))
+ # 中文表号:表2.1、表 2-1、见表2.1 等
+ mentioned.update(re.findall(r'(?:[见如])?表\s*(\d+[\.\-]?\d*)', full_answer_text))
+ # 英文图号:Figure 2.1、Fig.2.1 等
+ mentioned.update(re.findall(r'(?:Fig(?:ure)?\.?\s*)(\d+[\.\-]?\d*)', full_answer_text, re.I))
+
+ # 根据回答中的引用过滤图片
+ if mentioned:
+ aligned_images = []
+ for img in selected_images:
+ desc = img.get('description', '')
+ # 标准化图号格式(将连字符转为点)
+ for ref in mentioned:
+ ref_normalized = ref.replace('-', '.')
+ if (f"图{ref_normalized}" in desc or
+ f"表{ref_normalized}" in desc or
+ f"图 {ref_normalized}" in desc or
+ f"表 {ref_normalized}" in desc):
+ aligned_images.append(img)
+ break
+ # 如果有匹配的图片,使用对齐后的结果
+ if aligned_images:
+ selected_images = aligned_images
+ # else: 没有匹配到,保留原选择(不再截断到1张)
+ # else: LLM 没有提图号,保留原选择(不再截断到1张)
+
+ # 后置图片过滤:用回答内容反向筛选图片,确保图片与回答一致
+ # 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': []}
+
+ # 7. 去掉 LLM 添加的数字引用标记,避免与后端引用重复
+ clean_answer = re.sub(r'\[\d+\]', '', full_answer_text)
+
+ # 8. 添加引用标注(自动插入 [ref:chunk_id])
+ citation_result = _attach_citations(clean_answer, contexts)
+
+ # 9. 过滤敏感信息(违禁词等)
+ filtered_answer = filter_response(citation_result.get("answer_with_refs", clean_answer))
+
+ # 9. 保存消息到会话(仅开发环境)
+ if session_id and session_repo_ref:
+ try:
+ # 保存用户消息
+ session_repo_ref.add_message(session_id, 'user', message)
+ # 保存 AI 回答(包含完整 metadata:图片、来源、引用等)
+ assistant_metadata = {
+ 'is_rag': True,
+ 'mode': 'rag',
+ }
+ if rich_media.get('images'):
+ assistant_metadata['images'] = rich_media['images']
+ if sources:
+ assistant_metadata['sources'] = sources
+ if citation_result.get('citations'):
+ assistant_metadata['citations'] = citation_result['citations']
+ # 记录本次检索使用的向量库(用于后续追问时自动恢复 KB 选择)
+ assistant_metadata['collections'] = collections
+ session_repo_ref.add_message(session_id, 'assistant', filtered_answer, assistant_metadata)
+ # 更新会话最后活跃时间
+ if hasattr(session_repo_ref, 'update_last_active'):
+ session_repo_ref.update_last_active(session_id)
+ except Exception as e:
+ logger.warning(f"保存会话消息失败: {e}")
+
+ # 10. 发送完成事件
+ duration_ms = int((_time.time() - start_time) * 1000)
+ finish_event = {
+ "type": "finish",
+ "answer": filtered_answer,
+ "mode": "rag",
+ "session_id": session_id,
+ "sources": sources,
+ "citations": citation_result.get("citations", []), # 结构化引用列表
+ "images": rich_media["images"],
+ "tables": rich_media["tables"],
+ "sections": rich_media["sections"],
+ "duration_ms": duration_ms,
+ "confidence_score": _confidence_score # Phase 4:top-3 平均 Rerank 分数
+ }
+
+ # 添加分阶段耗时信息(仅开发环境)
+ if IS_DEV and hasattr(search_result, 'get'):
+ debug_info = search_result.get('_debug', {})
+ timing_info = debug_info.get('timing', {})
+ # 从 _debug steps 中提取 Rerank 耗时
+ rerank_time = 0
+ rerank_cached = False
+ for step in debug_info.get('steps', []):
+ if step.get('name') == 'rerank' and step.get('applied'):
+ rerank_time = step.get('time_ms', 0)
+ rerank_cached = step.get('cached', False)
+ finish_event["timing"] = {
+ "total_search_ms": timing_info.get('total_ms', 0),
+ "rerank_ms": rerank_time,
+ "rerank_cached": rerank_cached,
+ "total_ms": duration_ms
+ }
+ # 10.5 写入语义缓存
+ try:
+ from core.semantic_cache import get_semantic_cache
+ _sc = get_semantic_cache()
+ if _sc and filtered_answer:
+ if _semantic_cache_emb is None:
+ from core.engine import get_engine as _get_eng
+ _eng = _get_eng()
+ if _eng and hasattr(_eng, 'embedding_model'):
+ _cache_collections = ','.join(sorted(collections)) if collections else ''
+ _cache_key_text = f"{retrieval_query}|{_cache_collections}"
+ _semantic_cache_emb = _eng.embedding_model.encode(_cache_key_text)
+ if _semantic_cache_emb is not None:
+ _sc.set(_semantic_cache_emb, {
+ "cache_type": "rag_answer",
+ "answer": filtered_answer,
+ "sources": sources,
+ "citations": citation_result.get("citations", []),
+ "images": rich_media.get("images", []),
+ "tables": rich_media.get("tables", []),
+ })
+ logger.debug(f"[语义缓存] 写入成功: {message[:50]}...")
+ except Exception as e:
+ logger.info(f"[语义缓存] 写入失败: {e}")
+
+ yield f"data: {json.dumps(finish_event, ensure_ascii=False)}\n\n"
+
+ except Exception as e:
+ import logging as _logging
+ _logging.getLogger(__name__).error(f"[SSE] RAG 流异常: {e}", exc_info=True)
+ error_event = {
+ "type": "error",
+ "message": "服务内部错误,请稍后重试"
+ }
+ yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
+
+ return Response(
+ generate(),
+ mimetype='text/event-stream',
+ headers={
+ 'Cache-Control': 'no-cache',
+ 'X-Accel-Buffering': 'no'
+ }
+ )
+
+
+@chat_bp.route('/search', methods=['POST'])
+@require_gateway_auth
+def search():
+ """
+ 混合检索接口 - 供 Dify 工作流调用
+
+ 请求体:
+ {
+ "query": "查询文本",
+ "top_k": 5,
+ "collections": ["public_kb"] // 可选
+ }
+ """
+ data = request.json or {}
+ query = data.get('query', '')
+ query = sanitize_user_input(query)
+ injection_matches = detect_injection(query)
+ if injection_matches:
+ logger.warning(f"[Chat] 检测到可疑注入: {injection_matches}")
+ top_k = data.get('top_k', 5)
+ collections = data.get('collections') # 后端传入的知识库列表
+
+ if not query:
+ return error_response("MISSING_PARAMS", BAD_REQUEST, "query is required", http_status=400)
+
+ # 输入安全校验(注入检测、违禁词、长度限制)
+ is_valid, reason = validate_query(query)
+ if not is_valid:
+ return error_response("INVALID_QUERY", BAD_REQUEST, reason, http_status=400)
+
+ # top_k 范围校验
+ try:
+ top_k = max(1, min(int(top_k), 50))
+ except (ValueError, TypeError):
+ top_k = 5
+
+ # 如果没有指定 collections,使用默认的公开库
+ if not collections:
+ collections = ['public_kb']
+
+ results = search_hybrid(query, top_k=top_k, allowed_collections=collections)
+
+ return success_response(data={
+ 'contexts': results['documents'][0],
+ 'metadatas': results['metadatas'][0],
+ 'scores': results['scores'][0],
+ 'ids': results['ids'][0] if 'ids' in results and results['ids'] else []
+ })
+
+
+# ==================== 缓存调试接口(临时) ====================
+
+@chat_bp.route('/cache/stats', methods=['GET'])
+def cache_stats():
+ """缓存统计接口(调试用)"""
+ stats = {}
+ try:
+ from core.cache import get_cache_manager
+ cache = get_cache_manager()
+ all_stats = cache.get_all_stats()
+ for name, s in all_stats.items():
+ stats[name] = {
+ 'total_entries': s.total_entries,
+ 'hits': s.hits,
+ 'misses': s.misses,
+ 'hit_rate': f"{s.hit_rate:.2%}",
+ 'evictions': s.evictions,
+ }
+ except Exception as e:
+ stats['_error'] = str(e)
+
+ # 语义缓存统计(全局单例 + 意图分析器实例)
+ try:
+ from core.semantic_cache import get_semantic_cache
+ sc = get_semantic_cache()
+ if sc:
+ stats['semantic_cache'] = sc.get_stats()
+ except Exception:
+ pass
+
+ try:
+ from core.intent_analyzer import IntentAnalyzer
+ if hasattr(IntentAnalyzer, '_instance'):
+ ia = IntentAnalyzer._instance
+ if hasattr(ia, 'semantic_cache') and ia.semantic_cache:
+ stats['semantic_cache_intent'] = ia.semantic_cache.get_stats()
+ except Exception:
+ pass
+
+ return jsonify(stats)
+
+
+@chat_bp.route('/cache/clear', methods=['POST'])
+def cache_clear():
+ """缓存清空接口(调试用)"""
+ result = {}
+ try:
+ from core.cache import get_cache_manager
+ cache = get_cache_manager()
+ cache.clear_all()
+ result['lru_cache'] = 'cleared'
+ except Exception as e:
+ result['lru_cache'] = f'error: {e}'
+
+ try:
+ from core.semantic_cache import get_semantic_cache
+ sc = get_semantic_cache()
+ sc.clear()
+ result['semantic_cache'] = 'cleared'
+ except Exception:
+ result['semantic_cache'] = 'not_available'
+
+ return jsonify({'status': 'ok', 'cleared': result})
diff --git a/config.py b/config.py
index 2144879..dc693fc 100644
--- a/config.py
+++ b/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
diff --git a/core/intent_analyzer.py b/core/intent_analyzer.py
index eabba0c..78edb0a 100644
--- a/core/intent_analyzer.py
+++ b/core/intent_analyzer.py
@@ -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):
diff --git a/core/llm_utils.py b/core/llm_utils.py
index 97a49d4..43b25e2 100644
--- a/core/llm_utils.py
+++ b/core/llm_utils.py
@@ -71,19 +71,38 @@ def call_llm(
return response
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)
- if json_match:
- logger.info("LLM: content为空,从reasoning_content提取JSON")
- return json_match.group().strip()
- logger.warning("LLM 返回空 content(可能需要增大 max_tokens)")
+ # 先去掉 ... 标签
+ 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:
+ 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 content.strip()
except Exception as e:
logger.warning(f"LLM 调用失败: {e}")
@@ -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)
+ # 去掉 ... 标签
+ 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}")
diff --git a/knowledge/collection.py b/knowledge/collection.py
index f450cf1..107363f 100644
--- a/knowledge/collection.py
+++ b/knowledge/collection.py
@@ -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()
diff --git a/knowledge/document.py b/knowledge/document.py
index bdeca24..57894fe 100644
--- a/knowledge/document.py
+++ b/knowledge/document.py
@@ -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
diff --git a/knowledge/lazy_enhance.py b/knowledge/lazy_enhance.py
index de19141..5f0ca0d 100644
--- a/knowledge/lazy_enhance.py
+++ b/knowledge/lazy_enhance.py
@@ -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}")
diff --git a/knowledge/manager.py b/knowledge/manager.py
index 150d301..66b2f2f 100644
--- a/knowledge/manager.py
+++ b/knowledge/manager.py
@@ -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
+ # 尝试从思考链中提取有用文本(去掉 标签后的内容)
+ 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)