feat(rag): 数据驱动的检索优化与表格/图片处理增强
- 移除硬编码关键词列表,改为数据驱动的图片意图检测 - 新增 _section_similarity() 层级章节相似度函数,替代正则匹配 - 新增 _rescue_table_chunks() 表格救援机制,基于 _in_budget_context 标记 - 修复 _build_context_with_budget 表格组超预算不 break 的问题 - 新增 _filter_images_by_answer() 后置图片过滤 - 表格切片 rerank 分数保护策略(同 section 表格不被误删) - 跨页表格合并逻辑改进(通用标题检测 + 页码不可用降级策略) - 意图分析增强追问补全 + 缓存类型隔离 - 语义缓存 key 加入 collections 防止跨上下文误命中 - 使用 retrieval_query 替代原始 message 进行检索 - engine.py: CloudReranker 模型更新 + 移除 top_n + table 邻居扩展 - LLM prompt 增加表格处理规则和章节路径提示 🤖 Generated with [Qoder][https://qoder.com]
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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【上下文中的图片】")
|
||||
|
||||
Reference in New Issue
Block a user