Compare commits
2 Commits
fc11b11dda
...
8f75c51c59
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f75c51c59 | ||
|
|
87f3fae1aa |
@@ -292,6 +292,198 @@ def _process_table_doc(doc: str, meta: Dict) -> str:
|
|||||||
return doc
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
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 _order_text_contexts_for_prompt(contexts: List[Dict], query: str, max_chunks: int,
|
def _order_text_contexts_for_prompt(contexts: List[Dict], query: str, max_chunks: int,
|
||||||
min_score: float = 0.0) -> List[Dict]:
|
min_score: float = 0.0) -> List[Dict]:
|
||||||
"""
|
"""
|
||||||
@@ -1034,23 +1226,46 @@ def score_image_relevance(query: str, meta: Dict, doc: str = '') -> float:
|
|||||||
return score
|
return score
|
||||||
|
|
||||||
|
|
||||||
def _filter_images_by_answer(selected_images: List[Dict], answer: str) -> List[Dict]:
|
# 图片意图关键词:用于判断查询/回答是否需要图片
|
||||||
"""
|
# 集中管理,便于后续新增文档类型时扩展
|
||||||
后置图片过滤:根据 LLM 生成的回答内容反向筛选图片。
|
|
||||||
|
|
||||||
只有图片描述与回答内容有足够关键词重叠时才保留,
|
# 查询侧关键词(宽松):用户查询中出现这些词表示想看图片
|
||||||
确保展示的图片与回答内容一致,避免不相关图片干扰用户。
|
_FIGURE_QUERY_KEYWORDS = frozenset([
|
||||||
|
'图', '表', '照片', '图表', '如图', '见表', '示意图',
|
||||||
|
'展示', '示意', '外观', '实物', '结构', '流程图', '架构图',
|
||||||
|
])
|
||||||
|
|
||||||
|
# 回答侧关键词(严格):LLM 回答中出现这些词表示引用了图片
|
||||||
|
# 不含单字"图""表"("力图""企图""表达"等领域词误触发),改用正则图号检测兜底
|
||||||
|
# 不含"图片"(定义类查询的 LLM 回答也会泛化提到"图片",导致负面用例误放行)
|
||||||
|
_FIGURE_ANSWER_KEYWORDS = frozenset([
|
||||||
|
'照片', '图表', '如图', '见图', '见表', '示意图',
|
||||||
|
'流程图', '架构图', '结构图', '实物图', '外观图',
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def _filter_images_by_answer(selected_images: List[Dict], answer: str, query: str = "",
|
||||||
|
primary_sections: List[str] = None) -> List[Dict]:
|
||||||
|
"""
|
||||||
|
后置图片过滤:查询主题一致性校验 + 无图意图早退 + 子章节级过滤。
|
||||||
|
|
||||||
|
确保展示的图片与查询/回答主题一致,过滤 section_cluster_boost 过度召回的
|
||||||
|
不相关章节图片。
|
||||||
|
|
||||||
过滤规则:
|
过滤规则:
|
||||||
1. 从回答中提取 2 字及以上的中文关键词(jieba 分词)
|
1. 候选 <= 1 张时直接返回(无需过滤)
|
||||||
2. 对每张图片,检查其描述(full_description / description)中匹配了多少关键词
|
2. 无图意图早退:回答和查询都不含图片引用词 → 返回空
|
||||||
3. 匹配数 >= 阈值则保留,否则丢弃
|
3. 图号豁免:图片描述包含回答引用的具体图号/表号 → 无条件保留
|
||||||
4. 如果过滤后图片为 0,保留分数最高的 1 张(兜底)
|
4. 主题一致性:合并查询+回答关键词,图片描述重叠 >= 阈值才保留
|
||||||
5. 用户明确指定图号(如图 2.1)时不过滤
|
- 子章节惩罚:图片子章节与主要检索章节不一致时,阈值 +2
|
||||||
|
5. 兜底:过滤后为空且有图片意图 → 保留分数最高的 1 张
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
selected_images: select_images 返回的候选图片列表
|
selected_images: select_images 返回的候选图片列表
|
||||||
answer: LLM 生成的回答文本
|
answer: LLM 生成的回答文本
|
||||||
|
query: 用户查询(拼接 retrieval_query + message,覆盖改写丢失的关键词)
|
||||||
|
primary_sections: 主要检索结果的 section_path 列表(来自 top 文本切片),
|
||||||
|
用于子章节级过滤。为空时跳过子章节惩罚,行为与原版一致。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
过滤后的图片列表
|
过滤后的图片列表
|
||||||
@@ -1058,28 +1273,80 @@ def _filter_images_by_answer(selected_images: List[Dict], answer: str) -> List[D
|
|||||||
if not selected_images or len(selected_images) <= 1:
|
if not selected_images or len(selected_images) <= 1:
|
||||||
return selected_images
|
return selected_images
|
||||||
|
|
||||||
# 如果回答中提到了具体图号,说明 LLM 认为这些图是相关的,不过滤
|
|
||||||
import re
|
import re
|
||||||
if re.search(r'图\s*\d+\.?\d*', answer):
|
|
||||||
return selected_images
|
|
||||||
|
|
||||||
# 从回答中提取关键词
|
# 提取回答中引用的图号/表号(用于精确豁免,不再一刀切绕过)
|
||||||
|
_mentioned_refs = set()
|
||||||
|
_mentioned_refs.update(re.findall(r'(?:[见如])?图\s*(\d+[\.\-]?\d*)', answer))
|
||||||
|
_mentioned_refs.update(re.findall(r'(?:[见如])?表\s*(\d+[\.\-]?\d*)', answer))
|
||||||
|
|
||||||
|
# 前置无图意图判断:回答和查询都不需要图片时,直接返回空
|
||||||
|
# 解决定义/原则类查询(case 18/19)领域关键词导致图片描述高重叠的问题
|
||||||
|
_has_figure_in_answer = any(kw in answer for kw in _FIGURE_ANSWER_KEYWORDS)
|
||||||
|
if not _has_figure_in_answer:
|
||||||
|
_has_figure_in_answer = bool(re.search(r'[图表]\s*\d+', answer))
|
||||||
|
_has_figure_in_query = any(kw in query for kw in _FIGURE_QUERY_KEYWORDS)
|
||||||
|
if not _has_figure_in_query:
|
||||||
|
_has_figure_in_query = bool(re.search(r'[图表]\s*\d+', query))
|
||||||
|
|
||||||
|
if not _has_figure_in_answer and not _has_figure_in_query:
|
||||||
|
logger.info(f"[图片后置过滤] 无图意图前置检查:回答和查询均不含图片意图,返回空 "
|
||||||
|
f"(候选 {len(selected_images)} 张)")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# 合并查询+回答关键词:查询提供主题焦点,回答提供细节补充
|
||||||
|
# 解决纯回答关键词在长回答(500+字)场景下区分度不足的问题
|
||||||
try:
|
try:
|
||||||
import jieba
|
import jieba
|
||||||
answer_keywords = set(
|
_combined_text = f"{query} {answer}" if query else answer
|
||||||
w for w in jieba.lcut(answer)
|
_keywords = set(
|
||||||
|
w for w in jieba.lcut(_combined_text)
|
||||||
if len(w) >= 2 and re.search(r'[\u4e00-\u9fff]', w)
|
if len(w) >= 2 and re.search(r'[\u4e00-\u9fff]', w)
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
# jieba 不可用时回退到 bigram
|
chars = re.findall(r'[\u4e00-\u9fff]', f"{query} {answer}" if query else answer)
|
||||||
chars = re.findall(r'[\u4e00-\u9fff]', answer)
|
_keywords = set(chars[i] + chars[i + 1] for i in range(len(chars) - 1))
|
||||||
answer_keywords = set(chars[i] + chars[i + 1] for i in range(len(chars) - 1))
|
|
||||||
|
|
||||||
if not answer_keywords:
|
if not _keywords:
|
||||||
return selected_images
|
return selected_images
|
||||||
|
|
||||||
# 动态阈值:回答关键词越多,阈值越高(至少匹配 15% 或 2 个关键词,取较小值)
|
# 动态阈值:关键词越多阈值越高,上限 4(比原版略宽松,因子章节惩罚承担更多过滤)
|
||||||
threshold = max(2, min(3, round(len(answer_keywords) * 0.15)))
|
threshold = max(2, min(4, round(len(_keywords) * 0.10)))
|
||||||
|
|
||||||
|
def _leaf_section_name(section_path: str) -> str:
|
||||||
|
"""提取 section_path 的叶子节点名称(去编号前缀)"""
|
||||||
|
if not section_path:
|
||||||
|
return ''
|
||||||
|
parts = [p.strip() for p in section_path.split('>') if p.strip()]
|
||||||
|
leaf = parts[-1] if parts else ''
|
||||||
|
# 去掉编号前缀:(2)、(一)、2.3、第1章、一、 等
|
||||||
|
return re.sub(
|
||||||
|
r'^(?:\(\d+\)\s*|([一二三四五六七八九十]+)\s*|\d+[\.\d]*\s*|'
|
||||||
|
r'第\s*\d+\s*章\s*|[一二三四五六七八九十]+、\s*)', '', leaf
|
||||||
|
).strip()
|
||||||
|
|
||||||
|
def _section_leaf_match(img_leaf: str, primary_name: str) -> bool:
|
||||||
|
"""子章节名称匹配:长名称用子串包含,短名称(<3字)只做精确匹配"""
|
||||||
|
if not img_leaf or not primary_name:
|
||||||
|
return False
|
||||||
|
if img_leaf == primary_name:
|
||||||
|
return True
|
||||||
|
if len(img_leaf) >= 3 and len(primary_name) >= 3:
|
||||||
|
return primary_name in img_leaf or img_leaf in primary_name
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 构建主要子章节名称集合(从 top 文本切片的 section_path 提取)
|
||||||
|
_primary_leaf_names = set()
|
||||||
|
if primary_sections:
|
||||||
|
for ps in primary_sections:
|
||||||
|
name = _leaf_section_name(ps)
|
||||||
|
if name and len(name) >= 2:
|
||||||
|
_primary_leaf_names.add(name)
|
||||||
|
|
||||||
|
# 检索发散检测:主要子章节超过 3 个说明 section_cluster_boost 范围过宽
|
||||||
|
# 子章节惩罚退化为无效(几乎所有图片都能匹配某个 primary section)
|
||||||
|
# 此时对所有图片统一加严阈值,避免不相关图片全部通过
|
||||||
|
_scattered_bonus = 1 if len(_primary_leaf_names) > 3 else 0
|
||||||
|
|
||||||
filtered = []
|
filtered = []
|
||||||
for img in selected_images:
|
for img in selected_images:
|
||||||
@@ -1089,18 +1356,45 @@ def _filter_images_by_answer(selected_images: List[Dict], answer: str) -> List[D
|
|||||||
filtered.append(img)
|
filtered.append(img)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 计算描述与回答的关键词重叠数
|
# 图号精确豁免:图片描述包含回答引用的具体图号/表号,无条件保留
|
||||||
overlap = sum(1 for kw in answer_keywords if kw in desc)
|
# 防御性逻辑:与 P0 答案对齐互补(P0 用 description 100字截断,此处用 full_description)
|
||||||
if overlap >= threshold:
|
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)
|
filtered.append(img)
|
||||||
|
|
||||||
# 兜底:如果过滤后为空,保留分数最高的 1 张
|
# 兜底:过滤后为空且有图片意图时,保留分数最高的 1 张
|
||||||
|
# 注意:无图意图场景已在上方早退(返回 []),此处兜底仅在有图意图时生效
|
||||||
if not filtered and selected_images:
|
if not filtered and selected_images:
|
||||||
filtered = [max(selected_images, key=lambda x: x.get('score', 0))]
|
filtered = [max(selected_images, key=lambda x: x.get('score', 0))]
|
||||||
|
|
||||||
if len(filtered) < len(selected_images):
|
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)} 张 "
|
logger.info(f"[图片后置过滤] {len(selected_images)} → {len(filtered)} 张 "
|
||||||
f"(回答关键词 {len(answer_keywords)} 个, 阈值 {threshold})")
|
f"(关键词 {len(_keywords)} 个, 阈值 {threshold}{_sub_info}{_scattered_info}, "
|
||||||
|
f"图号豁免 {len(_mentioned_refs)} 个)")
|
||||||
|
|
||||||
return filtered
|
return filtered
|
||||||
|
|
||||||
@@ -1329,10 +1623,12 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
|
|||||||
scored_images.append({
|
scored_images.append({
|
||||||
'score': s,
|
'score': s,
|
||||||
'id': os.path.basename(meta['image_path']),
|
'id': os.path.basename(meta['image_path']),
|
||||||
|
'chunk_id': meta.get('chunk_id', ''),
|
||||||
'url': f"/images/{os.path.basename(meta['image_path'])}",
|
'url': f"/images/{os.path.basename(meta['image_path'])}",
|
||||||
'type': meta['chunk_type'],
|
'type': meta['chunk_type'],
|
||||||
'source': meta.get('source'),
|
'source': meta.get('source'),
|
||||||
'page': meta.get('page'),
|
'page': meta.get('page'),
|
||||||
|
'section_path': meta.get('section', '') or meta.get('section_path', ''),
|
||||||
'description': doc[:100], # 短描述用于 UI 展示
|
'description': doc[:100], # 短描述用于 UI 展示
|
||||||
'full_description': doc # Bug 6b 修复:完整描述用于 LLM 上下文
|
'full_description': doc # Bug 6b 修复:完整描述用于 LLM 上下文
|
||||||
})
|
})
|
||||||
@@ -1363,6 +1659,7 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
|
|||||||
'type': 'table_image',
|
'type': 'table_image',
|
||||||
'source': meta.get('source'),
|
'source': meta.get('source'),
|
||||||
'page': img_page,
|
'page': img_page,
|
||||||
|
'section_path': meta.get('section', '') or meta.get('section_path', ''),
|
||||||
'description': doc[:100],
|
'description': doc[:100],
|
||||||
'full_description': doc
|
'full_description': doc
|
||||||
})
|
})
|
||||||
@@ -1408,6 +1705,7 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]:
|
|||||||
'type': img_meta.get('chunk_type'),
|
'type': img_meta.get('chunk_type'),
|
||||||
'source': img_meta.get('source'),
|
'source': img_meta.get('source'),
|
||||||
'page': img_meta.get('page'),
|
'page': img_meta.get('page'),
|
||||||
|
'section_path': img_meta.get('section', '') or img_meta.get('section_path', ''),
|
||||||
'description': img_doc[:100], # 短描述用于 UI 展示
|
'description': img_doc[:100], # 短描述用于 UI 展示
|
||||||
'full_description': img_doc # Bug 6b 修复:完整描述用于 LLM 上下文
|
'full_description': img_doc # Bug 6b 修复:完整描述用于 LLM 上下文
|
||||||
})
|
})
|
||||||
@@ -1635,7 +1933,8 @@ def rag():
|
|||||||
DIRECT_CONTEXT_MAX_CHARS,
|
DIRECT_CONTEXT_MAX_CHARS,
|
||||||
RERANK_CONTEXT_MIN_SCORE,
|
RERANK_CONTEXT_MIN_SCORE,
|
||||||
CONTEXT_MAX_CHARS, CONTEXT_SOFT_LIMIT,
|
CONTEXT_MAX_CHARS, CONTEXT_SOFT_LIMIT,
|
||||||
CONFIDENCE_WARN_THRESHOLD, CONFIDENCE_CAUTION_THRESHOLD
|
CONFIDENCE_WARN_THRESHOLD, CONFIDENCE_CAUTION_THRESHOLD,
|
||||||
|
SECTION_CLUSTER_RESCUE_ENABLED
|
||||||
)
|
)
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
@@ -2140,6 +2439,16 @@ def rag():
|
|||||||
|
|
||||||
# 4. 构建 prompt(Phase 6:LLM 图片感知)
|
# 4. 构建 prompt(Phase 6:LLM 图片感知)
|
||||||
# Bug 1 修复:文本切片用于 top 5 名额竞争,图片描述不参与竞争
|
# Bug 1 修复:文本切片用于 top 5 名额竞争,图片描述不参与竞争
|
||||||
|
|
||||||
|
# 词法匹配救援:当切片文本精确包含查询关键词但 CrossEncoder 评分低时,
|
||||||
|
# 提升分数使其通过 min_score 过滤(适用于独立切片无法触发聚类救援的场景)
|
||||||
|
contexts = _rescue_lexical_match(contexts, retrieval_query, RERANK_CONTEXT_MIN_SCORE)
|
||||||
|
|
||||||
|
# 章节聚类救援(路由层安全网):在 min_score 过滤前,
|
||||||
|
# 检测"全灭 section"并分配保底分数
|
||||||
|
if SECTION_CLUSTER_RESCUE_ENABLED:
|
||||||
|
contexts = _rescue_section_cluster(contexts, retrieval_query, RERANK_CONTEXT_MIN_SCORE)
|
||||||
|
|
||||||
text_contexts = _order_text_contexts_for_prompt(contexts, message, MAX_CONTEXT_CHUNKS,
|
text_contexts = _order_text_contexts_for_prompt(contexts, message, MAX_CONTEXT_CHUNKS,
|
||||||
min_score=RERANK_CONTEXT_MIN_SCORE)
|
min_score=RERANK_CONTEXT_MIN_SCORE)
|
||||||
# Phase 2:按字符预算构建上下文
|
# Phase 2:按字符预算构建上下文
|
||||||
@@ -2286,7 +2595,18 @@ def rag():
|
|||||||
# else: LLM 没有提图号,保留原选择(不再截断到1张)
|
# else: LLM 没有提图号,保留原选择(不再截断到1张)
|
||||||
|
|
||||||
# 后置图片过滤:用回答内容反向筛选图片,确保图片与回答一致
|
# 后置图片过滤:用回答内容反向筛选图片,确保图片与回答一致
|
||||||
selected_images = _filter_images_by_answer(selected_images, full_answer_text)
|
# query 拼接 retrieval_query + message,防止意图改写丢失图片意图关键词
|
||||||
|
# 提取主要检索章节路径(top-10 文本切片),用于子章节级图片过滤
|
||||||
|
_primary_sections = list(set(
|
||||||
|
ctx.get('meta', {}).get('section', '') or ctx.get('meta', {}).get('section_path', '')
|
||||||
|
for ctx in text_contexts[:10]
|
||||||
|
if ctx.get('meta', {}).get('section', '') or ctx.get('meta', {}).get('section_path', '')
|
||||||
|
))
|
||||||
|
selected_images = _filter_images_by_answer(
|
||||||
|
selected_images, full_answer_text,
|
||||||
|
query=f"{retrieval_query} {message}",
|
||||||
|
primary_sections=_primary_sections
|
||||||
|
)
|
||||||
|
|
||||||
rich_media = {'images': selected_images, 'tables': [], 'sections': []}
|
rich_media = {'images': selected_images, 'tables': [], 'sections': []}
|
||||||
|
|
||||||
|
|||||||
171
core/engine.py
171
core/engine.py
@@ -77,6 +77,10 @@ try:
|
|||||||
CONTEXT_EXPANSION_MAX_CHUNKS, ENUM_QUERY_DISABLE_TOPK_SHRINK, ENUM_QUERY_MMR_LAMBDA,
|
CONTEXT_EXPANSION_MAX_CHUNKS, ENUM_QUERY_DISABLE_TOPK_SHRINK, ENUM_QUERY_MMR_LAMBDA,
|
||||||
# Phase 3 扩展精细化
|
# Phase 3 扩展精细化
|
||||||
EXPANSION_SCORE_THRESHOLD, MAX_EXPANDED_NEIGHBORS,
|
EXPANSION_SCORE_THRESHOLD, MAX_EXPANDED_NEIGHBORS,
|
||||||
|
# 章节聚类救援
|
||||||
|
SECTION_CLUSTER_BOOST_ENABLED, CLUSTER_MIN_MEMBERS, CLUSTER_MIN_TYPES,
|
||||||
|
CLUSTER_SEED_FLOOR, CLUSTER_MAX_BOOST_PER_SECTION, CLUSTER_MAX_SECTIONS,
|
||||||
|
CLUSTER_SECTION_PREFIX_LEVELS,
|
||||||
# 上下文与生成
|
# 上下文与生成
|
||||||
LLM_TEMPERATURE, LLM_MAX_TOKENS, RECALL_MULTIPLIER,
|
LLM_TEMPERATURE, LLM_MAX_TOKENS, RECALL_MULTIPLIER,
|
||||||
# FAQ 与黑名单
|
# FAQ 与黑名单
|
||||||
@@ -102,10 +106,18 @@ except ImportError:
|
|||||||
MMR_TOP_K = 30
|
MMR_TOP_K = 30
|
||||||
CONTEXT_EXPANSION_ENABLED = True
|
CONTEXT_EXPANSION_ENABLED = True
|
||||||
CONTEXT_EXPANSION_BEFORE = 1
|
CONTEXT_EXPANSION_BEFORE = 1
|
||||||
CONTEXT_EXPANSION_AFTER = 5
|
CONTEXT_EXPANSION_AFTER = 8
|
||||||
CONTEXT_EXPANSION_MAX_CHUNKS = 24
|
CONTEXT_EXPANSION_MAX_CHUNKS = 24
|
||||||
EXPANSION_SCORE_THRESHOLD = 0.3
|
EXPANSION_SCORE_THRESHOLD = 0.3
|
||||||
MAX_EXPANDED_NEIGHBORS = 4
|
MAX_EXPANDED_NEIGHBORS = 8
|
||||||
|
# 章节聚类救援默认值
|
||||||
|
SECTION_CLUSTER_BOOST_ENABLED = True
|
||||||
|
CLUSTER_MIN_MEMBERS = 3
|
||||||
|
CLUSTER_MIN_TYPES = 2
|
||||||
|
CLUSTER_SEED_FLOOR = 0.35
|
||||||
|
CLUSTER_MAX_BOOST_PER_SECTION = 8
|
||||||
|
CLUSTER_MAX_SECTIONS = 3
|
||||||
|
CLUSTER_SECTION_PREFIX_LEVELS = 1
|
||||||
ENUM_QUERY_DISABLE_TOPK_SHRINK = True
|
ENUM_QUERY_DISABLE_TOPK_SHRINK = True
|
||||||
ENUM_QUERY_MMR_LAMBDA = 0.85
|
ENUM_QUERY_MMR_LAMBDA = 0.85
|
||||||
DYNAMIC_RRF_ENABLED = True
|
DYNAMIC_RRF_ENABLED = True
|
||||||
@@ -733,11 +745,19 @@ class RAGEngine:
|
|||||||
# 时间衰减(Time Decay)
|
# 时间衰减(Time Decay)
|
||||||
fused_results = self._apply_time_decay(fused_results)
|
fused_results = self._apply_time_decay(fused_results)
|
||||||
|
|
||||||
|
# 提前附加 _debug,使聚类提升能写入调试步骤
|
||||||
|
fused_results['_debug'] = _debug
|
||||||
|
|
||||||
|
# ========== 章节聚类提升:在扩展前将低分但聚类的切片提升至种子阈值 ==========
|
||||||
|
if SECTION_CLUSTER_BOOST_ENABLED:
|
||||||
|
fused_results = self._section_cluster_boost(fused_results, query)
|
||||||
|
|
||||||
# ========== 上下文扩展:补充强命中切片周围的连续文本(rerank 之后,防止被截断)==========
|
# ========== 上下文扩展:补充强命中切片周围的连续文本(rerank 之后,防止被截断)==========
|
||||||
# Phase 3:仅对高分种子扩展邻居
|
# Phase 3:仅对高分种子扩展邻居
|
||||||
before_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
before_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||||
fused_results = self._expand_contiguous_chunks(fused_results, top_k=top_k,
|
fused_results = self._expand_contiguous_chunks(fused_results, top_k=top_k,
|
||||||
min_score=EXPANSION_SCORE_THRESHOLD)
|
min_score=EXPANSION_SCORE_THRESHOLD,
|
||||||
|
query=query)
|
||||||
after_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
after_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||||
_debug['steps'].append({'name': 'context_expansion', 'before': before_exp, 'after': after_exp})
|
_debug['steps'].append({'name': 'context_expansion', 'before': before_exp, 'after': after_exp})
|
||||||
|
|
||||||
@@ -1162,13 +1182,14 @@ class RAGEngine:
|
|||||||
return self.collection
|
return self.collection
|
||||||
|
|
||||||
def _expand_contiguous_chunks(self, results: dict, top_k: int = None,
|
def _expand_contiguous_chunks(self, results: dict, top_k: int = None,
|
||||||
min_score: float = 0.0) -> dict:
|
min_score: float = 0.0, query: str = '') -> dict:
|
||||||
"""Add same-source same-section neighbor text chunks around strong hits.
|
"""Add same-source same-section neighbor text chunks around strong hits.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
results: 检索结果
|
results: 检索结果
|
||||||
top_k: 最大切片数
|
top_k: 最大切片数
|
||||||
min_score: Phase 3 最低分数阈值,仅对 Rerank 分数高于此值的种子扩展
|
min_score: Phase 3 最低分数阈值,仅对 Rerank 分数高于此值的种子扩展
|
||||||
|
query: 查询文本,用于词法匹配辅助种子资格判定
|
||||||
"""
|
"""
|
||||||
if not CONTEXT_EXPANSION_ENABLED:
|
if not CONTEXT_EXPANSION_ENABLED:
|
||||||
return results
|
return results
|
||||||
@@ -1198,7 +1219,7 @@ class RAGEngine:
|
|||||||
seeds = [
|
seeds = [
|
||||||
(doc_id, doc, meta, dist)
|
(doc_id, doc, meta, dist)
|
||||||
for doc_id, doc, meta, dist in items[:base_limit]
|
for doc_id, doc, meta, dist in items[:base_limit]
|
||||||
if meta.get('chunk_type', 'text') == 'text'
|
if (meta.get('chunk_type', 'text') == 'text' or meta.get('_cluster_boosted'))
|
||||||
and meta.get('source')
|
and meta.get('source')
|
||||||
and self._to_int(meta.get('chunk_index')) is not None
|
and self._to_int(meta.get('chunk_index')) is not None
|
||||||
]
|
]
|
||||||
@@ -1210,7 +1231,11 @@ class RAGEngine:
|
|||||||
|
|
||||||
# Phase 3:跳过分数低于阈值的种子(仅当 min_score > 0 时生效)
|
# Phase 3:跳过分数低于阈值的种子(仅当 min_score > 0 时生效)
|
||||||
if min_score > 0 and seed_dist < min_score:
|
if min_score > 0 and seed_dist < min_score:
|
||||||
continue
|
# 词法匹配豁免:CrossEncoder 低分但关键词重叠度高时仍允许作为种子
|
||||||
|
if query and self._chunk_lexical_score(_seed_doc, query) > 0.3:
|
||||||
|
pass # 词法匹配度高,允许作为种子
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
source = seed_meta.get('source')
|
source = seed_meta.get('source')
|
||||||
section = seed_meta.get('section', '') or seed_meta.get('section_path', '')
|
section = seed_meta.get('section', '') or seed_meta.get('section_path', '')
|
||||||
@@ -1316,6 +1341,130 @@ class RAGEngine:
|
|||||||
expanded[key] = results[key]
|
expanded[key] = results[key]
|
||||||
return expanded
|
return expanded
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_section_path(section_path: str, levels: int = None) -> str:
|
||||||
|
"""归一化 section_path:取前 N 级路径,容忍 MinerU 标题检测误差。
|
||||||
|
|
||||||
|
例如: "第三章 吸烟场所的功能设置 > 第三条 文明吸烟..." → "第三章 吸烟场所的功能设置"
|
||||||
|
"""
|
||||||
|
if not section_path:
|
||||||
|
return ''
|
||||||
|
if levels is None:
|
||||||
|
levels = CLUSTER_SECTION_PREFIX_LEVELS
|
||||||
|
parts = [p.strip() for p in section_path.split('>')]
|
||||||
|
return ' > '.join(parts[:levels])
|
||||||
|
|
||||||
|
def _section_cluster_boost(self, results: dict, query: str = '') -> dict:
|
||||||
|
"""章节聚类提升:当同一 section 下多个切片(text+table)同时出现在候选集中,
|
||||||
|
即使单个切片 CrossEncoder 分数很低,也将整组提升到种子阈值。
|
||||||
|
|
||||||
|
核心洞察:单个低分切片不可信,但同一 section 多个切片同时出现是强信号。
|
||||||
|
提升后的切片可以作为 _expand_contiguous_chunks 的种子,触发邻居扩展。
|
||||||
|
|
||||||
|
Args:
|
||||||
|
results: rerank 后的检索结果
|
||||||
|
query: 用户查询(用于后续扩展)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
修改后的 results(distances 被调整,meta 中标记 _cluster_boosted)
|
||||||
|
"""
|
||||||
|
if not results.get('ids') or not results['ids'][0]:
|
||||||
|
return results
|
||||||
|
|
||||||
|
ids = results['ids'][0]
|
||||||
|
metas = results.get('metadatas', [[]])[0]
|
||||||
|
distances = results.get('distances', [[]])[0] if results.get('distances') else None
|
||||||
|
|
||||||
|
if not distances:
|
||||||
|
return results
|
||||||
|
|
||||||
|
# 1. 按 (source, normalized_section) 分组
|
||||||
|
from collections import defaultdict
|
||||||
|
section_groups = defaultdict(list) # key → [(index, meta, dist)]
|
||||||
|
|
||||||
|
for i, (meta, dist) in enumerate(zip(metas, distances)):
|
||||||
|
source = meta.get('source', '')
|
||||||
|
section_path = meta.get('section', '') or meta.get('section_path', '')
|
||||||
|
norm_section = self._normalize_section_path(section_path)
|
||||||
|
if not source or not norm_section:
|
||||||
|
continue
|
||||||
|
key = (source, norm_section)
|
||||||
|
section_groups[key].append((i, meta, dist))
|
||||||
|
|
||||||
|
# 2. 检测聚类信号并提升
|
||||||
|
boost_target_dist = 1.0 - CLUSTER_SEED_FLOOR # score=0.35 → dist=0.65
|
||||||
|
boosted_sections = []
|
||||||
|
total_boosted = 0
|
||||||
|
|
||||||
|
# 按组成员数降序排列,优先处理最大聚类
|
||||||
|
sorted_groups = sorted(section_groups.items(), key=lambda x: len(x[1]), reverse=True)
|
||||||
|
|
||||||
|
for (source, norm_section), members in sorted_groups:
|
||||||
|
if len(boosted_sections) >= CLUSTER_MAX_SECTIONS:
|
||||||
|
break
|
||||||
|
|
||||||
|
# 聚类信号检测:成员数 >= 阈值 且 类型多样性 >= 阈值
|
||||||
|
chunk_types = set(m[1].get('chunk_type', 'text') for m in members)
|
||||||
|
if len(members) < CLUSTER_MIN_MEMBERS or len(chunk_types) < CLUSTER_MIN_TYPES:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 提升组内切片分数(仅提升低于阈值的)
|
||||||
|
boost_count = 0
|
||||||
|
for idx, meta, dist in members:
|
||||||
|
if boost_count >= CLUSTER_MAX_BOOST_PER_SECTION:
|
||||||
|
break
|
||||||
|
# 只提升分数低于种子阈值的切片(高分切片不需要)
|
||||||
|
if dist > boost_target_dist:
|
||||||
|
distances[idx] = boost_target_dist
|
||||||
|
meta['_cluster_boosted'] = True
|
||||||
|
boost_count += 1
|
||||||
|
total_boosted += 1
|
||||||
|
|
||||||
|
if boost_count > 0:
|
||||||
|
boosted_sections.append({
|
||||||
|
'source': source,
|
||||||
|
'section': norm_section,
|
||||||
|
'members': len(members),
|
||||||
|
'types': list(chunk_types),
|
||||||
|
'boosted': boost_count
|
||||||
|
})
|
||||||
|
|
||||||
|
# 3. 写 debug 信息
|
||||||
|
if boosted_sections:
|
||||||
|
debug_info = results.get('_debug', {})
|
||||||
|
if 'steps' not in debug_info:
|
||||||
|
debug_info['steps'] = []
|
||||||
|
debug_info['steps'].append({
|
||||||
|
'name': 'section_cluster_boost',
|
||||||
|
'sections': boosted_sections,
|
||||||
|
'total_boosted': total_boosted
|
||||||
|
})
|
||||||
|
results['_debug'] = debug_info
|
||||||
|
logger.info(f"[章节聚类提升] 提升 {total_boosted} 个切片,"
|
||||||
|
f"涉及 {len(boosted_sections)} 个 section: "
|
||||||
|
f"{[s['section'][:30] for s in boosted_sections]}")
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _chunk_lexical_score(chunk_text: str, query: str) -> float:
|
||||||
|
"""计算切片文本与查询的词法重叠度(bigram 命中率),用于辅助种子资格判定。"""
|
||||||
|
if not chunk_text or not query:
|
||||||
|
return 0.0
|
||||||
|
import re
|
||||||
|
clean_q = re.sub(r'[??!!。,,、;;::"""\'\s*#`]+', ' ', query).strip()
|
||||||
|
if len(clean_q) < 2:
|
||||||
|
return 0.0
|
||||||
|
bigrams = set()
|
||||||
|
for i in range(len(clean_q) - 1):
|
||||||
|
w = clean_q[i:i+2].strip()
|
||||||
|
if len(w) == 2:
|
||||||
|
bigrams.add(w)
|
||||||
|
if not bigrams:
|
||||||
|
return 0.0
|
||||||
|
matched = sum(1 for w in bigrams if w in chunk_text)
|
||||||
|
return matched / len(bigrams)
|
||||||
|
|
||||||
def _search_with_sub_queries(
|
def _search_with_sub_queries(
|
||||||
self, original_query, sub_queries, top_k=5, allowed_levels=None,
|
self, original_query, sub_queries, top_k=5, allowed_levels=None,
|
||||||
role=None, department=None, collections=None, source_filter=None
|
role=None, department=None, collections=None, source_filter=None
|
||||||
@@ -1627,11 +1776,19 @@ class RAGEngine:
|
|||||||
# 时间衰减
|
# 时间衰减
|
||||||
fused_results = self._apply_time_decay(fused_results)
|
fused_results = self._apply_time_decay(fused_results)
|
||||||
|
|
||||||
|
# 提前附加 _debug,使聚类提升能写入调试步骤
|
||||||
|
fused_results['_debug'] = _debug
|
||||||
|
|
||||||
|
# ========== 章节聚类提升:在扩展前将低分但聚类的切片提升至种子阈值 ==========
|
||||||
|
if SECTION_CLUSTER_BOOST_ENABLED:
|
||||||
|
fused_results = self._section_cluster_boost(fused_results, query)
|
||||||
|
|
||||||
# ========== 上下文扩展:补充强命中切片周围的连续文本(rerank 之后,防止被截断)==========
|
# ========== 上下文扩展:补充强命中切片周围的连续文本(rerank 之后,防止被截断)==========
|
||||||
# Phase 3:仅对高分种子扩展邻居
|
# Phase 3:仅对高分种子扩展邻居
|
||||||
before_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
before_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||||
fused_results = self._expand_contiguous_chunks(fused_results, top_k=top_k,
|
fused_results = self._expand_contiguous_chunks(fused_results, top_k=top_k,
|
||||||
min_score=EXPANSION_SCORE_THRESHOLD)
|
min_score=EXPANSION_SCORE_THRESHOLD,
|
||||||
|
query=query)
|
||||||
after_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
after_exp = len(fused_results['ids'][0]) if fused_results.get('ids') else 0
|
||||||
if _debug is not None:
|
if _debug is not None:
|
||||||
_debug['steps'].append({'name': 'context_expansion', 'before': before_exp, 'after': after_exp})
|
_debug['steps'].append({'name': 'context_expansion', 'before': before_exp, 'after': after_exp})
|
||||||
|
|||||||
@@ -83,9 +83,16 @@ class IntentAnalyzer:
|
|||||||
根据对话历史和当前用户消息,输出一个 JSON 对象,包含以下字段:
|
根据对话历史和当前用户消息,输出一个 JSON 对象,包含以下字段:
|
||||||
|
|
||||||
1. **rewritten_query**: 改写后的完整问题
|
1. **rewritten_query**: 改写后的完整问题
|
||||||
- 如果问题包含指代(如"这两张图片"、"继续说"),将其改写为完整、独立的问题
|
- **指代消解**:如果问题包含指代(如"这两张图片"、"继续说"),将其改写为完整、独立的问题
|
||||||
- 例如:"分析一下这两张图片" → "分析一下对话历史中提到的图片"
|
- 例如:"分析一下这两张图片" → "分析一下对话历史中提到的图片"
|
||||||
- 如果问题本身已经完整,直接返回原文
|
- **追问补全**:如果问题是省略式追问(省略了上一轮讨论的主题实体),必须补全为完整问题
|
||||||
|
- 判断方法:当前问题缺少主语/宾语,且对话历史中可以推断出省略的实体
|
||||||
|
- 补全方法:从上一轮用户问题中提取主题实体,与追问组合成完整问题
|
||||||
|
- 例如:
|
||||||
|
- 上一轮问"吸烟点C1类是什么区?",追问"有完整表格吗?" → "吸烟点C1类有完整表格吗?"
|
||||||
|
- 上一轮问"三峡工程的投资情况",追问"建设地点在哪?" → "三峡工程的建设地点在哪?"
|
||||||
|
- 上一轮问"货源投放有哪些原则?",追问"具体内容是什么?" → "货源投放原则的具体内容是什么?"
|
||||||
|
- 如果问题本身已经完整且独立,直接返回原文
|
||||||
|
|
||||||
2. **use_context**: 布尔值
|
2. **use_context**: 布尔值
|
||||||
- true: 问题依赖历史对话中的信息,答案已经在历史回答中
|
- true: 问题依赖历史对话中的信息,答案已经在历史回答中
|
||||||
@@ -105,7 +112,9 @@ class IntentAnalyzer:
|
|||||||
- 推理类(intent="reasoning"):生成最多2个子查询
|
- 推理类(intent="reasoning"):生成最多2个子查询
|
||||||
* 原问题的检索查询
|
* 原问题的检索查询
|
||||||
* 一个补充角度的检索查询(如原因、背景、影响等),帮助获取更全面的上下文
|
* 一个补充角度的检索查询(如原因、背景、影响等),帮助获取更全面的上下文
|
||||||
- 其他类(factual/instruction/other):严格只生成1个子查询(原问题)
|
- 其他类(factual/instruction/other):严格只生成1个子查询
|
||||||
|
* 子查询应基于 rewritten_query(改写后的完整问题),而非用户原始输入
|
||||||
|
* 例如:追问"有完整表格吗?"改写为"吸烟点C1类有完整表格吗?"后,子查询应为"吸烟点C1类的完整表格内容"
|
||||||
- 不要为同一实体生成语义重叠的查询
|
- 不要为同一实体生成语义重叠的查询
|
||||||
- 子查询应保持原问题的关键词,长度20-60字符为宜
|
- 子查询应保持原问题的关键词,长度20-60字符为宜
|
||||||
|
|
||||||
|
|||||||
@@ -72,16 +72,35 @@ def call_llm(
|
|||||||
|
|
||||||
content = response.choices[0].message.content
|
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():
|
if not content or not content.strip():
|
||||||
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
|
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
|
||||||
if reasoning and reasoning.strip():
|
if reasoning and reasoning.strip():
|
||||||
# 从思维链中提取 JSON 块作为内容
|
# 先去掉 <think>...</think> 标签
|
||||||
json_match = re.search(r'\{[\s\S]*\}', reasoning)
|
cleaned = re.sub(r'', '', reasoning, flags=re.DOTALL).strip()
|
||||||
if json_match:
|
if cleaned:
|
||||||
logger.info("LLM: content为空,从reasoning_content提取JSON")
|
logger.info("LLM: content为空,从reasoning_content提取内容")
|
||||||
return json_match.group().strip()
|
# 尝试提取 JSON 对象(兼容结构化响应场景)
|
||||||
logger.warning("LLM 返回空 content(可能需要增大 max_tokens)")
|
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 None
|
||||||
|
|
||||||
return content.strip()
|
return content.strip()
|
||||||
@@ -95,7 +114,7 @@ def call_llm_stream(
|
|||||||
prompt: str,
|
prompt: str,
|
||||||
model: str,
|
model: str,
|
||||||
temperature: float = 0.3,
|
temperature: float = 0.3,
|
||||||
max_tokens: int = 1000,
|
max_tokens: int = 3000,
|
||||||
messages: List[dict] = None,
|
messages: List[dict] = None,
|
||||||
error_prefix: str = "[错误]",
|
error_prefix: str = "[错误]",
|
||||||
**kwargs
|
**kwargs
|
||||||
@@ -104,13 +123,14 @@ def call_llm_stream(
|
|||||||
流式 LLM 调用(生成器封装)
|
流式 LLM 调用(生成器封装)
|
||||||
|
|
||||||
自动处理流式响应,逐块 yield 文本内容。
|
自动处理流式响应,逐块 yield 文本内容。
|
||||||
|
兼容推理模型(mimo-v2.5 等):当 content 为空时回退到 reasoning_content。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
client: OpenAI 客户端实例
|
client: OpenAI 客户端实例
|
||||||
prompt: 用户提示
|
prompt: 用户提示
|
||||||
model: 模型名称
|
model: 模型名称
|
||||||
temperature: 温度参数
|
temperature: 温度参数
|
||||||
max_tokens: 最大 token 数
|
max_tokens: 最大 token 数(推理模型需留足思考链预算)
|
||||||
messages: 完整消息列表
|
messages: 完整消息列表
|
||||||
error_prefix: 错误时的前缀
|
error_prefix: 错误时的前缀
|
||||||
**kwargs: 其他参数
|
**kwargs: 其他参数
|
||||||
@@ -135,9 +155,33 @@ def call_llm_stream(
|
|||||||
**kwargs
|
**kwargs
|
||||||
)
|
)
|
||||||
|
|
||||||
|
content_yielded = False
|
||||||
|
reasoning_buffer = []
|
||||||
|
|
||||||
for chunk in stream:
|
for chunk in stream:
|
||||||
if chunk.choices and chunk.choices[0].delta.content:
|
if not chunk.choices:
|
||||||
yield chunk.choices[0].delta.content
|
continue
|
||||||
|
delta = chunk.choices[0].delta
|
||||||
|
|
||||||
|
# 正常 content 输出
|
||||||
|
if hasattr(delta, 'content') and delta.content:
|
||||||
|
content_yielded = True
|
||||||
|
yield delta.content
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 推理模型:reasoning_content(思考链)
|
||||||
|
rc = getattr(delta, 'reasoning_content', None)
|
||||||
|
if rc:
|
||||||
|
reasoning_buffer.append(rc)
|
||||||
|
|
||||||
|
# 回退:content 为空但 reasoning_content 有内容(推理模型 token 不足时)
|
||||||
|
if not content_yielded and reasoning_buffer:
|
||||||
|
reasoning_text = ''.join(reasoning_buffer)
|
||||||
|
# 去掉 <think>...</think> 标签
|
||||||
|
cleaned = re.sub(r'', '', reasoning_text, flags=re.DOTALL).strip()
|
||||||
|
if cleaned:
|
||||||
|
logger.info("流式 LLM: content为空,从reasoning_content提取内容")
|
||||||
|
yield cleaned
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LLM 流式调用失败: {e}")
|
logger.error(f"LLM 流式调用失败: {e}")
|
||||||
@@ -352,7 +396,7 @@ def quick_yes_no(
|
|||||||
if keywords is None:
|
if keywords is None:
|
||||||
keywords = ["是", "需要", "yes", "true"]
|
keywords = ["是", "需要", "yes", "true"]
|
||||||
|
|
||||||
result = call_llm(client, prompt, model, temperature=0, max_tokens=10)
|
result = call_llm(client, prompt, model, temperature=0, max_tokens=128)
|
||||||
if result is None:
|
if result is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|||||||
@@ -382,18 +382,34 @@ class BM25Index:
|
|||||||
|
|
||||||
def add_documents(self, ids: List[str], documents: List[str], metadatas: List[dict]) -> None:
|
def add_documents(self, ids: List[str], documents: List[str], metadatas: List[dict]) -> None:
|
||||||
"""
|
"""
|
||||||
添加文档到索引(会覆盖原有索引)
|
添加文档到索引(追加模式,自动去重)
|
||||||
|
|
||||||
|
如果 ID 已存在则更新对应文档,否则追加新文档。
|
||||||
|
添加后自动重建 BM25 索引。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
ids: 文档 ID 列表
|
ids: 文档 ID 列表
|
||||||
documents: 文档内容列表
|
documents: 文档内容列表
|
||||||
metadatas: 文档元数据列表
|
metadatas: 文档元数据列表
|
||||||
"""
|
"""
|
||||||
self.ids = ids
|
# 建立已有 ID -> 索引位置 的映射,用于去重
|
||||||
self.documents = documents
|
existing_map = {doc_id: idx for idx, doc_id in enumerate(self.ids)}
|
||||||
self.metadatas = metadatas
|
|
||||||
if documents:
|
for i, doc_id in enumerate(ids):
|
||||||
tokenized = [self.tokenize(doc) for doc in documents]
|
if doc_id in existing_map:
|
||||||
|
# 更新已有文档
|
||||||
|
pos = existing_map[doc_id]
|
||||||
|
self.documents[pos] = documents[i]
|
||||||
|
self.metadatas[pos] = metadatas[i]
|
||||||
|
else:
|
||||||
|
# 追加新文档
|
||||||
|
existing_map[doc_id] = len(self.ids)
|
||||||
|
self.ids.append(doc_id)
|
||||||
|
self.documents.append(documents[i])
|
||||||
|
self.metadatas.append(metadatas[i])
|
||||||
|
|
||||||
|
if self.documents:
|
||||||
|
tokenized = [self.tokenize(doc) for doc in self.documents]
|
||||||
self.bm25 = BM25Okapi(tokenized)
|
self.bm25 = BM25Okapi(tokenized)
|
||||||
|
|
||||||
def search(self, query: str, top_k: int = 10) -> Tuple[List[str], List[str], List[dict], List[float]]:
|
def search(self, query: str, top_k: int = 10) -> Tuple[List[str], List[str], List[dict], List[float]]:
|
||||||
|
|||||||
@@ -25,7 +25,20 @@ def compute_file_hash(file_path: str) -> str:
|
|||||||
return hashlib.md5(file_path.encode()).hexdigest()
|
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 描述
|
懒加载 VLM 描述
|
||||||
|
|
||||||
@@ -36,6 +49,7 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
|||||||
image_path: 图片路径(相对路径或绝对路径)
|
image_path: 图片路径(相对路径或绝对路径)
|
||||||
kb_name: 知识库名称
|
kb_name: 知识库名称
|
||||||
metadata: 图片元数据(包含 section、page、caption、上下文等)
|
metadata: 图片元数据(包含 section、page、caption、上下文等)
|
||||||
|
defer_chromadb: 为 True 时跳过 ChromaDB 更新(仅写文件缓存),避免后台线程写锁竞争
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
VLM 生成的图片描述
|
VLM 生成的图片描述
|
||||||
@@ -49,23 +63,45 @@ async def lazy_vlm_description(chunk_id: str, image_path: str, kb_name: str, met
|
|||||||
else:
|
else:
|
||||||
full_image_path = image_path
|
full_image_path = image_path
|
||||||
|
|
||||||
# 1. 检查缓存
|
# 1. 检查缓存(空缓存视为无效,需重新生成)
|
||||||
img_hash = compute_file_hash(full_image_path)
|
img_hash = compute_file_hash(full_image_path)
|
||||||
cache_file = VLM_CACHE_DIR / f"{img_hash}.txt"
|
cache_file = VLM_CACHE_DIR / f"{img_hash}.txt"
|
||||||
if cache_file.exists():
|
if cache_file.exists():
|
||||||
logger.info(f"VLM 缓存命中: {image_path}")
|
cached = cache_file.read_text(encoding='utf-8')
|
||||||
return 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(传入元数据)
|
# 2. 调用 VLM(传入元数据)
|
||||||
logger.info(f"VLM 懒加载: {image_path}")
|
logger.info(f"VLM 懒加载: {image_path}")
|
||||||
kb_manager = get_kb_manager()
|
kb_manager = get_kb_manager()
|
||||||
description = kb_manager._generate_image_description(full_image_path, metadata=metadata)
|
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)
|
VLM_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
cache_file.write_text(description, encoding='utf-8')
|
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:
|
try:
|
||||||
collection = kb_manager.get_collection(kb_name)
|
collection = kb_manager.get_collection(kb_name)
|
||||||
result = collection.get(ids=[chunk_id], include=['metadatas'])
|
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 描述重新计算向量)
|
# 更新 embedding(使用 VLM 描述重新计算向量)
|
||||||
# 这样 VLM 描述中的关键词(如"发电量")才能参与相似度检索
|
# 这样 VLM 描述中的关键词(如"发电量")才能参与相似度检索
|
||||||
embedding_model = kb_manager.embedding_model
|
embedding_model = _get_embedding_model()
|
||||||
if embedding_model:
|
if embedding_model:
|
||||||
new_vector = embedding_model.encode(description).tolist()
|
new_vector = embedding_model.encode(description).tolist()
|
||||||
if isinstance(new_vector[0], list):
|
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],
|
embeddings=[new_vector],
|
||||||
documents=[description] # 同时更新 document 字段
|
documents=[description] # 同时更新 document 字段
|
||||||
)
|
)
|
||||||
logger.info(f"已更新向量库 embedding: {chunk_id}")
|
logger.info(f"已更新向量库(embedding+metadata): {chunk_id}")
|
||||||
else:
|
else:
|
||||||
# 无 embedding 模型时只更新 metadata
|
# 无 embedding 模型时只更新 metadata
|
||||||
collection.update(
|
collection.update(
|
||||||
ids=[chunk_id],
|
ids=[chunk_id],
|
||||||
metadatas=[new_metadata]
|
metadatas=[new_metadata]
|
||||||
)
|
)
|
||||||
|
logger.info(f"已更新向量库(仅metadata,无embedding模型): {chunk_id}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"更新向量库失败: {e}")
|
logger.warning(f"更新向量库失败: {e}")
|
||||||
|
|
||||||
return description
|
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
|
chunk_id: 切片 ID
|
||||||
table_md: 表格 Markdown 内容
|
table_md: 表格 Markdown 内容
|
||||||
kb_name: 知识库名称
|
kb_name: 知识库名称
|
||||||
|
defer_chromadb: 为 True 时跳过 ChromaDB 更新(仅写文件缓存),避免后台线程写锁竞争
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
LLM 生成的表格摘要
|
LLM 生成的表格摘要
|
||||||
"""
|
"""
|
||||||
from knowledge.manager import get_kb_manager
|
from knowledge.manager import get_kb_manager
|
||||||
|
|
||||||
# 1. 检查缓存
|
# 1. 检查缓存(空缓存视为无效)
|
||||||
table_hash = hashlib.md5(table_md.encode()).hexdigest()
|
table_hash = hashlib.md5(table_md.encode()).hexdigest()
|
||||||
cache_file = LLM_CACHE_DIR / f"{table_hash}.txt"
|
cache_file = LLM_CACHE_DIR / f"{table_hash}.txt"
|
||||||
if cache_file.exists():
|
if cache_file.exists():
|
||||||
logger.info(f"LLM 缓存命中: {chunk_id}")
|
cached = cache_file.read_text(encoding='utf-8')
|
||||||
return 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
|
# 2. 调用 LLM
|
||||||
logger.info(f"LLM 懒加载: {chunk_id}")
|
logger.info(f"LLM 懒加载: {chunk_id}")
|
||||||
kb_manager = get_kb_manager()
|
kb_manager = get_kb_manager()
|
||||||
summary = kb_manager._generate_table_summary(table_md, None)
|
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. 写入缓存
|
# 3. 写入缓存
|
||||||
LLM_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
LLM_CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
cache_file.write_text(summary, encoding='utf-8')
|
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:
|
try:
|
||||||
collection = kb_manager.get_collection(kb_name)
|
collection = kb_manager.get_collection(kb_name)
|
||||||
result = collection.get(ids=[chunk_id], include=['metadatas'])
|
result = collection.get(ids=[chunk_id], include=['metadatas'])
|
||||||
if result['metadatas']:
|
if result['metadatas']:
|
||||||
# 新增摘要切片
|
# 新增摘要切片(需要 embedding 模型)
|
||||||
embedding_model = kb_manager.embedding_model
|
embedding_model = _get_embedding_model()
|
||||||
vector = embedding_model.encode(summary).tolist()
|
if embedding_model:
|
||||||
if isinstance(vector[0], list):
|
vector = embedding_model.encode(summary).tolist()
|
||||||
vector = vector[0]
|
if isinstance(vector[0], list):
|
||||||
|
vector = vector[0]
|
||||||
|
|
||||||
collection.add(
|
collection.add(
|
||||||
ids=[f"{chunk_id}_summary"],
|
ids=[f"{chunk_id}_summary"],
|
||||||
embeddings=[vector],
|
embeddings=[vector],
|
||||||
documents=[summary],
|
documents=[summary],
|
||||||
metadatas=[{
|
metadatas=[{
|
||||||
**result['metadatas'][0],
|
**result['metadatas'][0],
|
||||||
'is_summary': True,
|
'is_summary': True,
|
||||||
'original_doc_id': chunk_id
|
'original_doc_id': chunk_id
|
||||||
}]
|
}]
|
||||||
)
|
)
|
||||||
# 更新原切片标记
|
logger.info(f"已新增摘要切片(embedding): {chunk_id}_summary")
|
||||||
|
else:
|
||||||
|
logger.info(f"跳过摘要切片(无embedding模型): {chunk_id}")
|
||||||
|
# 更新原切片标记(不依赖 embedding 模型)
|
||||||
collection.update(
|
collection.update(
|
||||||
ids=[chunk_id],
|
ids=[chunk_id],
|
||||||
metadatas=[{**result['metadatas'][0], 'has_summary': True}]
|
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
|
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
|
检索后增强:按需调用 LLM/VLM
|
||||||
|
|
||||||
@@ -176,23 +239,24 @@ async def enhance_retrieved_chunks(contexts: list, query: str, kb_name: str):
|
|||||||
contexts: 检索上下文列表
|
contexts: 检索上下文列表
|
||||||
query: 用户查询
|
query: 用户查询
|
||||||
kb_name: 知识库名称
|
kb_name: 知识库名称
|
||||||
|
defer_chromadb: 为 True 时后台线程只写文件缓存,不更新 ChromaDB(避免写锁竞争)
|
||||||
"""
|
"""
|
||||||
for ctx in contexts:
|
import re
|
||||||
meta = ctx.get('meta', {})
|
|
||||||
chunk_type = meta.get('chunk_type', 'text')
|
|
||||||
image_path = meta.get('image_path', '')
|
|
||||||
|
|
||||||
# 图片切片:懒加载 VLM 描述
|
for ctx in contexts:
|
||||||
if chunk_type in ('image', 'chart') and not meta.get('has_vlm_desc'):
|
try:
|
||||||
if image_path:
|
meta = ctx.get('meta', {})
|
||||||
try:
|
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 字段中提取图号(上下文可能包含"见图2.5"等)
|
||||||
doc_text = ctx.get('doc', '')
|
doc_text = ctx.get('doc', '')
|
||||||
import re
|
|
||||||
|
|
||||||
# 提取图号(从前文/后文中)
|
# 提取图号(从前文/后文中)
|
||||||
figure_number = ""
|
figure_number = ""
|
||||||
# 匹配 "见图2.5"、"图2.5"、"见图 2.5" 等
|
|
||||||
fig_match = re.search(r'[见如]?图\s*(\d+\.?\d*)', doc_text)
|
fig_match = re.search(r'[见如]?图\s*(\d+\.?\d*)', doc_text)
|
||||||
if fig_match:
|
if fig_match:
|
||||||
figure_number = fig_match.group(1)
|
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'),
|
'page': meta.get('page'),
|
||||||
'caption': meta.get('caption', ''),
|
'caption': meta.get('caption', ''),
|
||||||
'source': meta.get('source', ''),
|
'source': meta.get('source', ''),
|
||||||
'figure_number': figure_number, # 添加提取的图号
|
'figure_number': figure_number,
|
||||||
'doc_text': doc_text # 添加完整文档文本
|
'doc_text': doc_text
|
||||||
}
|
}
|
||||||
vlm_desc = await lazy_vlm_description(
|
vlm_desc = await lazy_vlm_description(
|
||||||
meta.get('id', ''),
|
meta.get('chunk_id', ''),
|
||||||
image_path,
|
image_path,
|
||||||
kb_name,
|
kb_name,
|
||||||
metadata=image_metadata
|
metadata=image_metadata,
|
||||||
|
defer_chromadb=defer_chromadb
|
||||||
)
|
)
|
||||||
ctx['doc'] = vlm_desc
|
if vlm_desc:
|
||||||
ctx['vlm_enhanced'] = True
|
ctx['doc'] = vlm_desc
|
||||||
except Exception as e:
|
ctx['vlm_enhanced'] = True
|
||||||
logger.warning(f"VLM 懒加载失败: {e}")
|
|
||||||
|
|
||||||
# 表格切片:同时处理摘要和关联图片的 VLM 描述
|
# 表格切片:同时处理摘要和关联图片的 VLM 描述
|
||||||
elif chunk_type == 'table':
|
elif chunk_type == 'table':
|
||||||
doc_text = ctx.get('doc', '')
|
doc_text = ctx.get('doc', '')
|
||||||
|
|
||||||
# 1. 懒加载表格摘要(高分切片)
|
# 1. 懒加载表格摘要(高分切片)
|
||||||
if not meta.get('has_summary'):
|
if not meta.get('has_summary'):
|
||||||
score = meta.get('score', 0)
|
score = ctx.get('score', 0)
|
||||||
if score > 0.7: # 只对高相关表格生成摘要
|
if score > 0.7:
|
||||||
try:
|
|
||||||
summary = await lazy_table_summary(
|
summary = await lazy_table_summary(
|
||||||
meta.get('id', ''),
|
meta.get('chunk_id', ''),
|
||||||
doc_text,
|
doc_text,
|
||||||
kb_name
|
kb_name,
|
||||||
|
defer_chromadb=defer_chromadb
|
||||||
)
|
)
|
||||||
# 摘要作为补充信息
|
if summary:
|
||||||
ctx['summary'] = summary
|
ctx['summary'] = summary
|
||||||
ctx['llm_enhanced'] = True
|
ctx['llm_enhanced'] = True
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"表格摘要懒加载失败: {e}")
|
|
||||||
|
|
||||||
# 2. 表格有关联图片时,懒加载 VLM 描述
|
|
||||||
if image_path and not meta.get('has_vlm_desc'):
|
|
||||||
try:
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
# 2. 表格有关联图片时,懒加载 VLM 描述
|
||||||
|
if image_path and not meta.get('has_vlm_desc'):
|
||||||
# 提取表号(如 "表2.2"、"见表2.1")
|
# 提取表号(如 "表2.2"、"见表2.1")
|
||||||
table_number = ""
|
table_number = ""
|
||||||
# 匹配 "表2.2"、"见表2.2"、"见表 2.2" 等
|
|
||||||
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', doc_text)
|
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', doc_text)
|
||||||
if table_match:
|
if table_match:
|
||||||
table_number = table_match.group(1)
|
table_number = table_match.group(1)
|
||||||
|
|
||||||
# 如果 doc 中没有,尝试从 section 中提取
|
|
||||||
section = meta.get('section') or meta.get('section_path', '')
|
section = meta.get('section') or meta.get('section_path', '')
|
||||||
if not table_number and section:
|
if not table_number and section:
|
||||||
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', section)
|
table_match = re.search(r'[见如]?表\s*(\d+\.?\d*)', section)
|
||||||
if table_match:
|
if table_match:
|
||||||
table_number = table_match.group(1)
|
table_number = table_match.group(1)
|
||||||
|
|
||||||
# 构建表格图片元数据
|
|
||||||
table_image_metadata = {
|
table_image_metadata = {
|
||||||
'section': section,
|
'section': section,
|
||||||
'page': meta.get('page'),
|
'page': meta.get('page'),
|
||||||
'caption': meta.get('caption', ''),
|
'caption': meta.get('caption', ''),
|
||||||
'source': meta.get('source', ''),
|
'source': meta.get('source', ''),
|
||||||
'table_number': table_number, # 表号
|
'table_number': table_number,
|
||||||
'figure_number': table_number, # 兼容字段
|
'figure_number': table_number,
|
||||||
'doc_text': doc_text,
|
'doc_text': doc_text,
|
||||||
'is_table': True # 标记为表格图片
|
'is_table': True
|
||||||
}
|
}
|
||||||
vlm_desc = await lazy_vlm_description(
|
vlm_desc = await lazy_vlm_description(
|
||||||
meta.get('id', ''),
|
meta.get('chunk_id', ''),
|
||||||
image_path,
|
image_path,
|
||||||
kb_name,
|
kb_name,
|
||||||
metadata=table_image_metadata
|
metadata=table_image_metadata,
|
||||||
|
defer_chromadb=defer_chromadb
|
||||||
)
|
)
|
||||||
# 表格图片描述作为补充信息
|
if vlm_desc:
|
||||||
ctx['image_description'] = vlm_desc
|
ctx['image_description'] = vlm_desc
|
||||||
ctx['vlm_enhanced'] = True
|
ctx['vlm_enhanced'] = True
|
||||||
except Exception as e:
|
|
||||||
logger.warning(f"表格图片 VLM 懒加载失败: {e}")
|
except Exception as e:
|
||||||
|
chunk_id = ctx.get('meta', {}).get('chunk_id', '?')
|
||||||
|
logger.warning(f"增强切片失败(chunk_id={chunk_id}): {e}")
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ try:
|
|||||||
import fcntl
|
import fcntl
|
||||||
_HAS_FCNTL = True
|
_HAS_FCNTL = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
_HAS_FCNTL = False
|
_HAS_FCNTL = False # Windows 环境无 fcntl
|
||||||
from typing import List, Dict, Optional, Tuple
|
from typing import List, Dict, Optional, Tuple
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import logging
|
import logging
|
||||||
@@ -602,7 +602,7 @@ class KnowledgeBaseManager(
|
|||||||
try:
|
try:
|
||||||
from config import get_llm_client, DASHSCOPE_MODEL
|
from config import get_llm_client, DASHSCOPE_MODEL
|
||||||
client = get_llm_client()
|
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 ""
|
return summary.strip() if summary else ""
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"生成表格摘要失败: {e}")
|
logger.warning(f"生成表格摘要失败: {e}")
|
||||||
@@ -661,13 +661,31 @@ class KnowledgeBaseManager(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
max_tokens=512
|
max_tokens=2048 # mimo-v2.5 推理模型思考链消耗 ~1000 token,需留足输出空间
|
||||||
)
|
)
|
||||||
|
|
||||||
description = response.choices[0].message.content
|
description = response.choices[0].message.content
|
||||||
|
|
||||||
|
# 推理模型兼容:content 为空时从 reasoning_content 提取
|
||||||
|
if not description or not description.strip():
|
||||||
|
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
|
||||||
|
if reasoning and reasoning.strip():
|
||||||
|
import re
|
||||||
|
# 尝试从思考链中提取有用文本(去掉 <think> 标签后的内容)
|
||||||
|
cleaned = re.sub(r'', '', reasoning, flags=re.DOTALL).strip()
|
||||||
|
if cleaned:
|
||||||
|
logger.info(f"VLM content为空,从reasoning_content提取描述: {image_path}")
|
||||||
|
description = cleaned
|
||||||
|
else:
|
||||||
|
description = reasoning.strip()
|
||||||
|
|
||||||
|
if not description:
|
||||||
|
logger.warning(f"VLM 返回空描述: {image_path}")
|
||||||
|
return ""
|
||||||
|
|
||||||
# 缓存结果
|
# 缓存结果
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import re as _re
|
||||||
img_hash = hashlib.md5(img_path.read_bytes()).hexdigest()
|
img_hash = hashlib.md5(img_path.read_bytes()).hexdigest()
|
||||||
cache_dir = Path('.data/cache/vlm')
|
cache_dir = Path('.data/cache/vlm')
|
||||||
cache_dir.mkdir(parents=True, exist_ok=True)
|
cache_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|||||||
Reference in New Issue
Block a user