diff --git a/api/chat_routes.py b/api/chat_routes.py index 37a6c6f..9fb587f 100644 --- a/api/chat_routes.py +++ b/api/chat_routes.py @@ -188,6 +188,84 @@ def _get_full_table_from_docstore(chunk_id: str) -> Optional[str]: 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]: """ @@ -248,8 +326,31 @@ def _order_text_contexts_for_prompt(contexts: List[Dict], query: str, max_chunks }) # Phase 1:按 Rerank 分数过滤低分切片 + # 保护策略:同一 section 内如有切片通过阈值,则同 section 的 table 切片也保留 + # 原因:表格切片的 rerank 分数往往偏低(尤其是元问题如"有表格吗?"), + # 但它们与同 section 的 text 切片属于同一语义单元,不应割裂 + # 安全下限:被保护的 table 切片自身 score 不得低于 min_score * 0.3, + # 防止 section 粒度较粗时完全不相关的表格被无条件保护 if min_score > 0: - text_contexts = [c for c in text_contexts if c.get('score', 0) >= min_score] + _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] # 合并:文本切片优先,图表切片补充 @@ -258,7 +359,11 @@ def _order_text_contexts_for_prompt(contexts: List[Dict], query: str, max_chunks combined_contexts = text_contexts + chart_contexts[:max_chart_contexts] if not _is_enum_query(query): - return combined_contexts[:max_chunks] + # 表格不受 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', {}) @@ -312,6 +417,153 @@ def _order_text_contexts_for_prompt(contexts: List[Dict], query: str, max_chunks 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_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:按字符预算构建上下文文本。 @@ -361,22 +613,46 @@ def _build_context_with_budget(contexts: List[Dict], max_chars: int, soft_limit: total_chars = 0 for key in group_order: group = groups[key] - group_text = "\n\n".join(ctx.get('doc', '') for ctx in group) + source, section = key - # 超过软限制后,只接受高分组 - if total_chars > soft_limit and group_max_score(key) < 0.1: + # 判断是否包含表格切片(表格是结构化关键内容,不受预算截断) + 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 = ctx.get('doc', '') + 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" @@ -477,6 +753,17 @@ def _attach_citations(answer: str, contexts: List[Dict]) -> Dict[str, Any]: 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: @@ -492,17 +779,20 @@ def _attach_citations(answer: str, contexts: List[Dict]) -> Dict[str, Any]: 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 使用原始值(不含 collection 前缀) - raw_id = ctx.get('_raw_chunk_id') - if raw_id: - citation['chunk_id'] = raw_id + citation['chunk_id'] = raw_id citations.append(citation) return { @@ -735,8 +1025,8 @@ def score_image_relevance(query: str, meta: Dict, doc: str = '') -> float: # 3. 整体文本相似度(字符级别) if search_text: - # 检查查询的核心词是否在描述中 - query_core = re.sub(r'[图表图片如图所示]', '', query) # 去掉泛词 + # 复用已过滤停用词的 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) @@ -763,6 +1053,77 @@ def score_image_relevance(query: str, meta: Dict, doc: str = '') -> float: return score +def _filter_images_by_answer(selected_images: List[Dict], answer: str) -> List[Dict]: + """ + 后置图片过滤:根据 LLM 生成的回答内容反向筛选图片。 + + 只有图片描述与回答内容有足够关键词重叠时才保留, + 确保展示的图片与回答内容一致,避免不相关图片干扰用户。 + + 过滤规则: + 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]: """ 选择要展示的图片(打分排序 + 预算控制) @@ -792,26 +1153,39 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]: """ import re - # 动态预算:列举型查询允许更多图片 - list_keywords = ["哪些", "有什么", "包含", "列出", "所有", "全部"] - is_list_query = any(kw in query for kw in list_keywords) + # 动态预算:数据驱动,不依赖硬编码关键词列表 + # 核心策略:宽松预选 + 后置过滤(_filter_images_by_answer)精准裁剪 - # 检测查询中的图片编号(如 "图2.1") + # 精确查图:用户指定了具体图号(如 "图2.3")—— 结构化模式匹配,非硬编码 figure_pattern = r'图\s*(\d+\.?\d*)' figure_matches = re.findall(figure_pattern, query) has_figure_query = bool(figure_matches) - # 新增:从检索文本中提取图表引用(见表2.2、见图2.5 等) - # 同时记录引用所在的文件来源 + # 数据驱动的图片意图检测:检查检索结果中是否包含图片/图表类型切片 + # 原理:如果向量检索返回了 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]: # 只从前5个最相关的文本块提取引用 + for ctx in contexts[:5]: doc_text = ctx.get('doc', '') source = ctx.get('meta', {}).get('source', '') - # 提取 "见图X.X"、"如图X.X" 或单独的 "图X.X" fig_refs = re.findall(r'(?:[见如])?图\s*(\d+\.?\d*)', doc_text) for fig_num in fig_refs: if fig_num not in referenced_figures: @@ -819,7 +1193,6 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]: if source: referenced_figures[fig_num].add(source) - # 提取 "见表X.X"、"如表X.X" 或单独的 "表X.X" table_refs = re.findall(r'(?:[见如])?表\s*(\d+\.?\d*)', doc_text) for table_num in table_refs: if table_num not in referenced_tables: @@ -831,63 +1204,84 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]: # 获取检索结果中涉及的主要文件来源 primary_sources = set() - for ctx in contexts[:5]: # 只看前5个最相关的 + for ctx in contexts[:5]: source = ctx.get('meta', {}).get('source', '') if source: primary_sources.add(source) - # 图片意图检测:区分精确查图和泛指 - strong_image_keywords = ["示意图", "流程图", "结构图", "过程线", "曲线图", "分布图", "图示", "看图", "显示图"] - weak_image_keywords = ["图片", "图表", "如图", "图", "统计"] - - has_strong_image_intent = any(kw in query for kw in strong_image_keywords) - has_weak_image_intent = any(kw in query for kw in weak_image_keywords) - - # 精确查图:用户指定了具体图号(如 "图2.3"),只返回最匹配的 1-2 张 + # 动态预算:根据检索结果数据驱动设置 + # 后置过滤 _filter_images_by_answer 会根据回答内容精准裁剪 if has_figure_query: + # 精确查图:用户指定了具体图号 MAX_IMAGES = 2 - MIN_SCORE = 5.0 # 精确匹配应该高分 - # 强图片意图:明确要看某种图 - elif has_strong_image_intent: - MAX_IMAGES = 3 - MIN_SCORE = 5.0 # 提高阈值,避免不相关图片通过 - # 列举型查询 - elif is_list_query: + MIN_SCORE = 5.0 + elif has_image_data: + # 检索结果中有图片数据 → 宽松预算,给后置过滤留足候选空间 MAX_IMAGES = 5 - MIN_SCORE = 3.0 - # 有图表引用:检索文本中提到了图表 + MIN_SCORE = 2.0 elif has_referenced_figures: + # 检索文本中引用了图表编号 MAX_IMAGES = 3 - MIN_SCORE = 2.0 # 降低阈值,让引用的图表能通过 - # 弱图片意图:只是提到"图"字,可能是泛指(如 "发电量图") - elif has_weak_image_intent: - MAX_IMAGES = 1 # 只返回最相关的一张 - MIN_SCORE = 2.0 # 降低阈值,让语义相关的图片能通过 - # 普通查询 + MIN_SCORE = 2.0 else: + # 无图片数据 → 保守默认值 MAX_IMAGES = 2 MIN_SCORE = 3.0 - # 获取检索结果中涉及的主要章节(只看前 3 个最相关的文本块) - primary_sections = set() + # 动态调整:当表格嵌入大量图片时,提升上限以展示完整内容 + 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: - # 提取章节编号 - # 优先匹配 X.X 格式(如 "2.3发电"),再匹配 第X章 格式 - section_num = re.search(r'(\d+\.\d+)', section) - if not section_num: - # 尝试匹配 "第X章" 格式 - chapter_match = re.search(r'第\s*(\d+)\s*章', section) - if chapter_match: - section_num = chapter_match - if section_num: - primary_sections.add(section_num.group(1)) + 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'): @@ -917,16 +1311,19 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]: # 图片章节 img_section = meta.get('section', '') or meta.get('section_path', '') - # 只匹配 X.X 格式的章节号,避免匹配年份 - img_section_num = re.search(r'(\d+\.\d+)', img_section) - img_section_id = img_section_num.group(1) if img_section_num else None - # ========== 核心修复:图片必须与主要文本切片章节关联 ========== - # 如果有主要章节,且图片章节不在其中,大幅降低分数 + # ========== 章节关联检测:基于层级相似度,无需硬编码格式假设 ========== + # 计算图片章节与主要检索结果的最高相似度 section_penalty = 0.0 - if primary_sections and img_section_id and img_section_id not in primary_sections: + 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 # 大幅降低分数 + section_penalty = -5.0 # 除非图片被文本切片明确引用 is_referenced = False for fig_num in referenced_figures: @@ -949,10 +1346,10 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]: 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 = img_section_id and img_section_id in primary_sections + # 使用层级相似度判断章节关联性 + section_match = max_section_sim >= 0.3 - # P2:只有章节匹配才加分,移除"s >= 5.0"漏洞 + # 章节匹配时才加分 if section_match: # 图号匹配加分 s += 8.0 @@ -967,10 +1364,10 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]: for table_num, sources in referenced_tables.items(): # 只检查 doc 字段 if f"表{table_num}" in doc or f"表 {table_num}" in doc: - # 检查图片章节是否与主要章节匹配 - section_match = img_section_id and img_section_id in primary_sections + # 使用层级相似度判断章节关联性 + section_match = max_section_sim >= 0.3 - # P2:只有章节匹配才加分,移除"s >= 5.0"漏洞 + # 章节匹配时才加分 if section_match: # 表号匹配加分 s += 8.0 @@ -998,6 +1395,47 @@ def select_images(contexts: List[Dict], query: str) -> List[Dict]: # ========== 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: @@ -1354,6 +1792,18 @@ def rag(): 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 @@ -1453,6 +1903,9 @@ def rag(): 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: @@ -1461,9 +1914,13 @@ def rag(): _sc = get_semantic_cache() _eng = _get_eng() if _sc and _eng and hasattr(_eng, 'embedding_model'): - _semantic_cache_emb = _eng.embedding_model.encode(message) + # 缓存 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) - if cached is not None: + # 防御性校验:必须是 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", "") # 流式返回缓存的答案 @@ -1504,7 +1961,7 @@ def rag(): sub_queries = intent.sub_queries search_result = search_hybrid( - message, + retrieval_query, top_k=RAG_SEARCH_TOP_K, candidates=RAG_SEARCH_CANDIDATES, allowed_collections=collections, @@ -1525,18 +1982,19 @@ def rag(): 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, message) + figure_matches = re.findall(figure_pattern, retrieval_query) has_figure_query = bool(figure_matches) - # Bug 4 修复:缩小 strong_image_keywords,移除歧义词 - # "过程线" 是水文术语不是图片意图,"图片"/"图表"/"如图" 太泛 - strong_image_keywords = ["示意图", "流程图", "结构图", "曲线图", "分布图", "图示", "看图", "显示图", "给我看"] - has_image_intent = has_figure_query or any(kw in message for kw in strong_image_keywords) + # 数据驱动:检查检索结果中是否有图片/图表类型切片 + 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 - # Bug 2 修复:不重排 contexts,只在 meta 里打标记给后续的 select_images 用 + # 给图片/图表切片打 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'): @@ -1658,18 +2116,12 @@ def rag(): missing_figures = referenced_figures - existing_figure_images missing_tables = referenced_tables - existing_table_images - # 计算主要章节(用于补充检索过滤) - primary_sections_for_supplement = set() + # 计算主要章节路径(用于补充检索过滤) + 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: - section_num = re.search(r'(\d+\.\d+)', section) - if not section_num: - chapter_match = re.search(r'第\s*(\d+)\s*章', section) - if chapter_match: - section_num = chapter_match - if section_num: - primary_sections_for_supplement.add(section_num.group(1)) + primary_section_paths_for_supp.add(section) if missing_figures or missing_tables: # 补充检索 @@ -1726,14 +2178,18 @@ def rag(): if is_match: # 额外检查:图片章节是否与主要章节匹配 - # 避免补充检索到不相关的图片 + # 使用层级相似度判断,无需硬编码格式假设 supp_section = supp_meta.get('section', '') or supp_meta.get('section_path', '') - supp_section_num = re.search(r'(\d+\.\d+)', supp_section) - supp_section_id = supp_section_num.group(1) if supp_section_num else None - # 如果图片章节不在主要章节中,跳过 - if primary_sections_for_supplement and supp_section_id and supp_section_id not in primary_sections_for_supplement: - # 不是主要章节的图片,跳过 + 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 替换 @@ -1772,12 +2228,12 @@ def rag(): 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, message, kb_name)) + 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, message) + selected_images = select_images(contexts, retrieval_query) # 调试事件:图片选择详情 if IS_DEV: @@ -1785,16 +2241,32 @@ def rag(): # 4. 构建 prompt(Phase 6:LLM 图片感知) # Bug 1 修复:文本切片用于 top 5 名额竞争,图片描述不参与竞争 - text_contexts = _order_text_contexts_for_prompt(contexts, message, MAX_CONTEXT_CHUNKS, + 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(message) or _is_comparison: + if _is_enum_query(retrieval_query) or _is_comparison: # 列举类 / 对比类查询:保持原始顺序,不做预算截断 - context_text = "\n\n".join([ctx.get('doc', '') for ctx in text_contexts]) + # 仍然注入 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 @@ -1802,6 +2274,17 @@ def rag(): # 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 @@ -1814,6 +2297,16 @@ def rag(): 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【回答要求】参考资料中包含表格及其嵌入图片。" + "请以**表格形式**呈现数据(保持原始表格结构)," + "并在对应单元格中使用 `![图片](图片URL)` 格式嵌入图片。" + "不要将表格内容转为纯文本描述,不要把图片与表格分开展示。" + ) + else: # 添加指令让 LLM 介绍图片 context_text += "\n\n【回答要求】回答时请简要介绍每张图片的内容和用途。" @@ -1847,7 +2340,7 @@ def rag(): ) enhanced_context = instruction_instruction + "\n\n" + enhanced_context - if _is_enum_query(message): + if _is_enum_query(retrieval_query): enum_instruction = ( "\n\n【回答要求】如果参考资料中包含编号列表、禁止情形、要求或条款," "请按资料中的原始顺序完整列出;不要合并相邻条目,不要跳项," @@ -1914,6 +2407,9 @@ def rag(): # 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 添加的数字引用标记,避免与后端引用重复 @@ -1941,6 +2437,8 @@ def rag(): 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'): @@ -1990,9 +2488,12 @@ def rag(): from core.engine import get_engine as _get_eng _eng = _get_eng() if _eng and hasattr(_eng, 'embedding_model'): - _semantic_cache_emb = _eng.embedding_model.encode(message) + _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", []), diff --git a/core/engine.py b/core/engine.py index 7eb43aa..304e1a3 100644 --- a/core/engine.py +++ b/core/engine.py @@ -29,6 +29,7 @@ RAG 核心引擎 import os import gc +import re import time import logging import threading @@ -112,7 +113,7 @@ except ImportError: RERANK_DEVICE = "auto" RERANK_USE_ONNX = False RERANK_BACKEND = "local" - RERANK_CLOUD_MODEL = "qwen3-rerank" + RERANK_CLOUD_MODEL = "xop3qwen8breranker" RERANK_CLOUD_API_KEY = "" RERANK_CLOUD_BASE_URL = "https://dashscope.aliyuncs.com/compatible-api/v1/reranks" RERANK_CLOUD_TIMEOUT = 15 @@ -272,8 +273,7 @@ class CloudReranker: body = { "model": self.model, "query": query, - "documents": documents, - "top_n": len(documents) + "documents": documents } session = self._get_session() @@ -1231,6 +1231,7 @@ class RAGEngine: logger.warning(f"扩展连续切片失败: {e}") return {'ids': [], 'documents': [], 'metadatas': []} + # 扩展同 section 的 text 邻居 where_filter = {"$and": [{"source": source}, {"chunk_type": "text"}]} if section: where_filter["$and"].append({"section": section}) @@ -1241,6 +1242,20 @@ class RAGEngine: if not neighbors.get('ids') or len(neighbors.get('ids', [])) <= 1: neighbors = _get_neighbors({"$and": [{"source": source}, {"chunk_type": "text"}]}) + # 同时扩展同 section 的 table 邻居(table 切片的 rerank 分数往往偏低, + # 但与同 section 的 text 切片属于同一语义单元,不应割裂) + table_where = {"$and": [{"source": source}, {"chunk_type": "table"}]} + if section: + table_where["$and"].append({"section": section}) + table_neighbors = _get_neighbors(table_where) + + # 当 section 为空时,table 查询只有 source 条件,可能拉入大量无关表格, + # 缩小 chunk_index 窗口至 ±1 以降低噪音;有 section 时使用正常窗口 + if section: + _t_before, _t_after = CONTEXT_EXPANSION_BEFORE, CONTEXT_EXPANSION_AFTER + else: + _t_before, _t_after = 1, 1 + neighbor_rows = [] for n_id, n_doc, n_meta in zip( neighbors.get('ids', []), @@ -1253,6 +1268,18 @@ class RAGEngine: if seed_index - CONTEXT_EXPANSION_BEFORE <= n_index <= seed_index + CONTEXT_EXPANSION_AFTER: neighbor_rows.append((n_index, n_id, n_doc, n_meta)) + # 同 section 的 table 邻居也加入扩展范围 + for n_id, n_doc, n_meta in zip( + table_neighbors.get('ids', []), + table_neighbors.get('documents', []), + table_neighbors.get('metadatas', []) + ): + n_index = self._to_int(n_meta.get('chunk_index')) + if n_index is None: + continue + if seed_index - _t_before <= n_index <= seed_index + _t_after: + neighbor_rows.append((n_index, n_id, n_doc, n_meta)) + seed_neighbors_added = 0 for n_index, n_id, n_doc, n_meta in sorted(neighbor_rows, key=lambda row: row[0]): if len(items) >= max_chunks: @@ -2041,21 +2068,33 @@ class RAGEngine: "content": ( "你是一个严谨的知识库问答助手。" "你必须且只能根据用户提供的【参考资料】回答问题。" + "参考资料中每段内容前标有章节路径(━格式),请注意区分不同章节的内容," + "特别当不同章节标题相似或包含相同关键词时,务必根据章节路径准确定位,不要混淆。" "如果参考资料中有答案,必须引用对应内容回答,并在回答末尾标注引用编号(如[1]、[2])。" "如果参考资料中确实没有相关信息,简短说明即可,不要编造或补充资料外的内容。" "禁止使用参考资料以外的知识进行补充或推测。" + "【重要-表格处理规则】当用户询问表格、要求展示表格内容时,你必须将参考资料中的 Markdown 表格原样输出(保留 | 分隔符和表格结构)," + "不要仅用文字描述表格存在或仅列出章节名称。如果参考资料中多个章节都有表格," + "优先展示与用户问题最相关的表格完整内容。" ) }) # 添加当前问题(带上下文)- 强化指令 if context: + # 检测用户问题是否涉及表格,加入针对性指令 + _table_hint = "" + # 检测上下文中是否包含 Markdown 表格(数据驱动,无需硬编码关键词) + _has_table_in_context = bool(re.search(r'\|.+\|', context)) if context else False + if _has_table_in_context: + _table_hint = "\n注意:参考资料中包含 Markdown 格式的表格数据,请务必将相关表格以原始 Markdown 表格格式完整展示在回答中,不要仅用文字描述。" + user_message = f"""【参考资料】 {context} 【用户问题】 {query} -请仔细阅读以上全部参考资料后回答。如果参考资料中包含相关内容,必须引用回答并标注编号。如果资料中没有相关信息,请明确说明。""" +请仔细阅读以上全部参考资料后回答。注意参考资料中标有章节路径,请根据章节路径准确定位相关内容。如果参考资料中包含相关内容,必须引用回答并标注编号。如果资料中没有相关信息,请明确说明。{_table_hint}""" else: user_message = query diff --git a/core/intent_analyzer.py b/core/intent_analyzer.py index 5f2dea2..eabba0c 100644 --- a/core/intent_analyzer.py +++ b/core/intent_analyzer.py @@ -83,9 +83,16 @@ class IntentAnalyzer: 根据对话历史和当前用户消息,输出一个 JSON 对象,包含以下字段: 1. **rewritten_query**: 改写后的完整问题 - - 如果问题包含指代(如"这两张图片"、"继续说"),将其改写为完整、独立的问题 - - 例如:"分析一下这两张图片" → "分析一下对话历史中提到的图片" - - 如果问题本身已经完整,直接返回原文 + - **指代消解**:如果问题包含指代(如"这两张图片"、"继续说"),将其改写为完整、独立的问题 + - 例如:"分析一下这两张图片" → "分析一下对话历史中提到的图片" + - **追问补全**:如果问题是省略式追问(省略了上一轮讨论的主题实体),必须补全为完整问题 + - 判断方法:当前问题缺少主语/宾语,且对话历史中可以推断出省略的实体 + - 补全方法:从上一轮用户问题中提取主题实体,与追问组合成完整问题 + - 例如: + - 上一轮问"吸烟点C1类是什么区?",追问"有完整表格吗?" → "吸烟点C1类有完整表格吗?" + - 上一轮问"三峡工程的投资情况",追问"建设地点在哪?" → "三峡工程的建设地点在哪?" + - 上一轮问"货源投放有哪些原则?",追问"具体内容是什么?" → "货源投放原则的具体内容是什么?" + - 如果问题本身已经完整且独立,直接返回原文 2. **use_context**: 布尔值 - true: 问题依赖历史对话中的信息,答案已经在历史回答中 @@ -105,7 +112,9 @@ class IntentAnalyzer: - 推理类(intent="reasoning"):生成最多2个子查询 * 原问题的检索查询 * 一个补充角度的检索查询(如原因、背景、影响等),帮助获取更全面的上下文 - - 其他类(factual/instruction/other):严格只生成1个子查询(原问题) + - 其他类(factual/instruction/other):严格只生成1个子查询 + * 子查询应基于 rewritten_query(改写后的完整问题),而非用户原始输入 + * 例如:追问"有完整表格吗?"改写为"吸烟点C1类有完整表格吗?"后,子查询应为"吸烟点C1类的完整表格内容" - 不要为同一实体生成语义重叠的查询 - 子查询应保持原问题的关键词,长度20-60字符为宜 @@ -307,7 +316,8 @@ class IntentAnalyzer: if cache_emb is not None: cached = cache.get(cache_emb) - if cached: + # 确保缓存条目是意图分析结果(非 RAG 回答缓存) + if cached and cached.get("cache_type") != "rag_answer": logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}") return IntentAnalysis.from_dict(cached) else: @@ -377,9 +387,11 @@ class IntentAnalyzer: intent=intent_type ) - # 存入语义缓存 + # 存入语义缓存(标记类型,避免与 RAG 回答缓存混淆) if cache and cache_emb is not None: - cache.set(cache_emb, analysis.to_dict()) + cache_data = analysis.to_dict() + cache_data["cache_type"] = "intent_analysis" + cache.set(cache_emb, cache_data) # 存入精确匹配缓存 if len(self._exact_cache) < self._exact_cache_max: @@ -437,6 +449,7 @@ class IntentAnalyzer: if not history: return "(无历史对话)" + import re parts = [] # 提取最近 3 轮对话 @@ -445,6 +458,7 @@ class IntentAnalyzer: for msg in recent_history: role = "用户" if msg.get("role") == "user" else "助手" content = msg.get("content", "") + original_content = content # 保留原始内容用于结构化提取 # 截断过长的内容 if len(content) > 500: @@ -452,9 +466,10 @@ class IntentAnalyzer: parts.append(f"【{role}】{content}") - # 提取图片信息 + # 提取结构化信息(从 assistant 消息中提取章节、表格、来源等) metadata = msg.get("metadata", {}) if isinstance(metadata, dict): + # 已有的图片提取 images = metadata.get("images", []) if images: for img in images[:3]: @@ -463,6 +478,43 @@ class IntentAnalyzer: img_type = img.get("type", "图片") parts.append(f" └─ {img_type}: {desc}") + # 来源文件提取 + sources = metadata.get("sources", []) + if sources: + source_names = [] + for s in sources[:3]: + if isinstance(s, dict): + name = s.get("source", "") or s.get("name", "") + if name: + source_names.append(name) + elif isinstance(s, str): + source_names.append(s) + if source_names: + parts.append(f" └─ 来源文件: {', '.join(source_names)}") + + # collections(检索知识库)提取 + colls = metadata.get("collections", []) + if colls: + parts.append(f" └─ 检索知识库: {', '.join(colls)}") + + # 从 assistant 原始内容中提取章节路径和表格结构 + if role == "助手" and original_content: + # 提取章节路径:━ xxx ━ 格式 + sections = re.findall(r'━\s*(.+?)\s*━', original_content) + if sections: + unique_sections = list(dict.fromkeys(sections)) # 去重保序 + parts.append(f" └─ 涉及章节: {'; '.join(unique_sections[:3])}") + + # 提取表格列名:| A | B | C | 格式的表头行 + table_headers = re.findall(r'^\|\s*(.+?)\s*\|', original_content, re.MULTILINE) + if table_headers: + # 取第一个表格的列名 + first_header = table_headers[0] + cols = [c.strip() for c in first_header.split('|') if c.strip()] + # 排除分隔符行(--- 格式) + if cols and not all(re.match(r'^[-:]+$', c) for c in cols): + parts.append(f" └─ 含表格,列名: {', '.join(cols[:6])}") + # 添加图片上下文 if context_images: parts.append("\n【上下文中的图片】") diff --git a/knowledge/manager.py b/knowledge/manager.py index 31a8e86..a373cf5 100644 --- a/knowledge/manager.py +++ b/knowledge/manager.py @@ -393,6 +393,11 @@ class KnowledgeBaseManager( if len(chunks) < 2: return chunks + # 检测页码是否可靠:若所有 chunk 的 page_start 相同(如 Word 文档 page_idx 全为 0), + # 则页码信息不可用,需要启用降级合并规则 + page_values = set(getattr(c, 'page_start', 0) for c in chunks) + pages_unavailable = len(page_values) <= 1 + merged_chunks = [] i = 0 merge_count = 0 @@ -405,6 +410,7 @@ class KnowledgeBaseManager( # 查找下一个表格(跳过中间的"续表"文本) next_table_idx = None next_chunk = None + intermediate_texts = [] # 收集中间文本用于降级判断 for j in range(i + 1, min(i + 4, len(chunks))): # 最多向前看3个切片 candidate = chunks[j] @@ -419,7 +425,11 @@ class KnowledgeBaseManager( elif candidate_type == 'text' and ('续表' in candidate_title or '续表' in candidate_content): # 遇到"续表"文本,继续查找下一个表格 continue - elif candidate_type not in ('text',): + elif candidate_type == 'text': + # 非"续表"文本,收集后停止查找 + intermediate_texts.append(candidate) + break + else: # 遇到非文本类型,停止查找 break @@ -439,24 +449,59 @@ class KnowledgeBaseManager( # 获取内容(用于检测"续表") next_content = getattr(next_chunk, 'content', '') + # 通用/无意义标题集合,这些标题不能用于"标题相似"判定 + _GENERIC_TITLES = {'表格', 'table', '表格', ''} + # 判断是否为跨页表格 is_cross_page = False # 规则1: 页码连续(如果页码有效) page_valid = curr_page_end > 0 and next_page_start > 0 if page_valid and curr_page_end + 1 == next_page_start: - is_cross_page = True + # 页码连续时,还需标题匹配或为通用标题才合并 + # 避免把不同页面上不相关的表格错误合并 + if curr_title == next_title or curr_title in _GENERIC_TITLES and next_title in _GENERIC_TITLES: + is_cross_page = True + elif curr_title and next_title: + clean_next_r1 = next_title.replace('续表', '').strip() + if curr_title in clean_next_r1 or clean_next_r1 in curr_title: + is_cross_page = True # 规则2: 第二个表格标题或内容包含"续表" elif '续表' in next_title or '续表' in next_content: is_cross_page = True # 规则3: 标题相似(去掉"续表"后比较) - elif curr_title and next_title: + # 排除通用标题(如"表格"),防止把所有标题为"表格"的相邻表格都误合并 + elif (curr_title and next_title + and curr_title not in _GENERIC_TITLES + and next_title not in _GENERIC_TITLES): clean_next = next_title.replace('续表', '').strip() - if curr_title in clean_next or clean_next in curr_title: + if clean_next and (curr_title in clean_next or clean_next in curr_title): is_cross_page = True + # 规则4(降级): 页码不可用(如 Word 文档 page_idx 全为 0) + # 仅当页码信息缺失时才启用此规则,避免 PDF 正常页码时被误合并 + if (not is_cross_page + and pages_unavailable + and curr_title in _GENERIC_TITLES + and next_title in _GENERIC_TITLES): + # 检查中间文本是否暗示跨页延续(空、短文本、续表标记等) + has_separating_content = False + for text_chunk in intermediate_texts: + tc = (getattr(text_chunk, 'content', '') or '').strip() + tt = (getattr(text_chunk, 'title', '') or '').strip() + if not tc: + continue # 空文本不算分隔 + if '续表' in tc or '续表' in tt: + continue # 续表标记,说明是跨页 + # 有实质性中间内容(如分类标题"A3类:xxx"),不合并 + has_separating_content = True + break + if not has_separating_content: + is_cross_page = True + logger.debug(f"降级合并(页码不可用): '{curr_title}' + '{next_title}'") + if is_cross_page: # 执行合并 merge_count += 1 @@ -469,14 +514,27 @@ class KnowledgeBaseManager( # 合并两个表格的 HTML current.table_html = curr_html + '\n' + next_html - # 合并 image_path 到 images + # 合并 image_path 和嵌入图片到 images curr_img = getattr(current, 'image_path', None) next_img = getattr(next_chunk, 'image_path', None) - merged_images = [] - if curr_img: + curr_images = getattr(current, 'images', None) or [] + next_images = getattr(next_chunk, 'images', None) or [] + + # 合并两个表格的所有图片(image_path + 嵌入图片) + merged_images = list(curr_images) # 保留当前表格的嵌入图片 + # 添加 image_path 图片(如果不在列表中) + existing_ids = {img.get('id', '') for img in merged_images if isinstance(img, dict)} + if curr_img and curr_img not in existing_ids: merged_images.append({'id': curr_img, 'page': curr_page_end}) - if next_img: + existing_ids.add(curr_img) + for img in next_images: # 添加下一个表格的嵌入图片 + img_id = img.get('id', '') if isinstance(img, dict) else '' + if img_id and img_id not in existing_ids: + merged_images.append(img) + existing_ids.add(img_id) + if next_img and next_img not in existing_ids: merged_images.append({'id': next_img, 'page': next_page_start}) + if merged_images: current.images = merged_images # 保留第一个图片作为主 image_path @@ -525,7 +583,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=100) + summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=512) return summary.strip() if summary else "" except Exception as e: logger.warning(f"生成表格摘要失败: {e}") @@ -584,7 +642,7 @@ class KnowledgeBaseManager( ] } ], - max_tokens=200 + max_tokens=512 ) description = response.choices[0].message.content