init: RAG 知识库服务初始提交

- 后端 API(Flask + Gunicorn)
- RAG 引擎(混合检索 + 云端 Reranker + 引用溯源)
- 文档解析(MinerU + 多格式支持)
- Docker 生产部署配置
- 排除前端项目、敏感配置、模型文件
This commit is contained in:
lacerate551
2026-06-04 17:35:27 +08:00
commit 100d1a06eb
158 changed files with 64534 additions and 0 deletions

18
core/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
"""
RAG 核心引擎模块
包含:
- engine: RAGEngine 单例类,管理模型和共享资源
- agentic: AgenticRAG 智能问答
- bm25_index: BM25 关键词检索索引
- chunker: 文本分块器
"""
from .engine import RAGEngine, get_engine
from .bm25_index import BM25Index
from .chunker import split_text_with_limit, split_text
__all__ = [
'RAGEngine', 'get_engine', 'BM25Index',
'split_text_with_limit', 'split_text'
]

262
core/adaptive_topk.py Normal file
View File

@@ -0,0 +1,262 @@
"""
自适应 TopK 策略
根据检索置信度动态调整 top_k
- 低置信度:扩大检索范围
- 高置信度:缩小检索范围
- 中等置信度:保持原样
使用方式:
from core.adaptive_topk import AdaptiveTopK
strategy = AdaptiveTopK()
adjusted_k, should_retrieve = strategy.adjust(top_score, initial_k)
"""
from dataclasses import dataclass
from typing import Tuple, Optional
import logging
logger = logging.getLogger(__name__)
@dataclass
class AdaptiveConfig:
"""自适应配置"""
# 置信度阈值
low_confidence_threshold: float = 0.5 # 低于此值认为是低置信度
high_confidence_threshold: float = 0.8 # 高于此值认为是高置信度
# 扩展/收缩比例
expand_ratio: float = 2.0 # 低置信度时扩大倍数
shrink_ratio: float = 0.5 # 高置信度时缩小比例
# 限制
min_top_k: int = 3 # 最小 top_k
max_top_k: int = 20 # 最大 top_k
# 是否启用
enabled: bool = True
class AdaptiveTopK:
"""
自适应 TopK 策略
核心逻辑:
1. 第一次检索返回结果后,检查最高得分
2. 根据得分判断置信度
3. 决定是否需要调整 top_k 重新检索
"""
def __init__(self, config: AdaptiveConfig = None):
"""
初始化
Args:
config: 配置对象如果为None则使用默认配置
"""
self.config = config or AdaptiveConfig()
def adjust(
self,
top_score: float,
initial_k: int,
current_results_count: int = 0
) -> Tuple[int, bool, str]:
"""
根据置信度调整 top_k
Args:
top_score: 当前检索结果的最高得分 (0-1)
initial_k: 初始 top_k
current_results_count: 当前结果数量
Returns:
(adjusted_k, should_retrieve, reason)
- adjusted_k: 调整后的 top_k
- should_retrieve: 是否需要重新检索
- reason: 调整原因
"""
if not self.config.enabled:
return initial_k, False, "disabled"
# 低置信度:扩大检索范围
if top_score < self.config.low_confidence_threshold:
adjusted_k = min(
int(initial_k * self.config.expand_ratio),
self.config.max_top_k
)
if adjusted_k > initial_k:
return adjusted_k, True, f"low_confidence({top_score:.2f}<{self.config.low_confidence_threshold})"
# 高置信度:可以缩小范围(但不重新检索)
elif top_score > self.config.high_confidence_threshold:
adjusted_k = max(
int(initial_k * self.config.shrink_ratio),
self.config.min_top_k
)
# 高置信度时不需要重新检索,只是返回时可以截断
return adjusted_k, False, f"high_confidence({top_score:.2f}>{self.config.high_confidence_threshold})"
# 中等置信度:保持原样
return initial_k, False, f"medium_confidence({top_score:.2f})"
def get_final_results(
self,
results: list,
adjusted_k: int,
reason: str
) -> list:
"""
获取最终结果
Args:
results: 检索结果列表
adjusted_k: 调整后的 top_k
reason: 调整原因
Returns:
截断后的结果列表
"""
if "high_confidence" in reason:
# 高置信度时截断结果
return results[:adjusted_k]
return results
def get_config_dict(self) -> dict:
"""获取配置字典"""
return {
"enabled": self.config.enabled,
"low_confidence_threshold": self.config.low_confidence_threshold,
"high_confidence_threshold": self.config.high_confidence_threshold,
"expand_ratio": self.config.expand_ratio,
"shrink_ratio": self.config.shrink_ratio,
"min_top_k": self.config.min_top_k,
"max_top_k": self.config.max_top_k
}
class AdaptiveTopKWithStats(AdaptiveTopK):
"""
带统计的自适应 TopK 策略
记录每次调整的统计信息,用于分析和优化
"""
def __init__(self, config: AdaptiveConfig = None):
super().__init__(config)
self.stats = {
"total_queries": 0,
"low_confidence_count": 0,
"high_confidence_count": 0,
"medium_confidence_count": 0,
"re_retrieve_count": 0
}
def adjust(
self,
top_score: float,
initial_k: int,
current_results_count: int = 0
) -> Tuple[int, bool, str]:
"""带统计的调整"""
self.stats["total_queries"] += 1
adjusted_k, should_retrieve, reason = super().adjust(
top_score, initial_k, current_results_count
)
# 更新统计
if "low_confidence" in reason:
self.stats["low_confidence_count"] += 1
elif "high_confidence" in reason:
self.stats["high_confidence_count"] += 1
else:
self.stats["medium_confidence_count"] += 1
if should_retrieve:
self.stats["re_retrieve_count"] += 1
return adjusted_k, should_retrieve, reason
def get_stats(self) -> dict:
"""获取统计信息"""
return self.stats.copy()
def reset_stats(self):
"""重置统计"""
self.stats = {
"total_queries": 0,
"low_confidence_count": 0,
"high_confidence_count": 0,
"medium_confidence_count": 0,
"re_retrieve_count": 0
}
# ==================== 便捷函数 ====================
def create_adaptive_topk(
enabled: bool = True,
low_threshold: float = 0.5,
high_threshold: float = 0.8,
expand_ratio: float = 2.0,
shrink_ratio: float = 0.5
) -> AdaptiveTopK:
"""
创建自适应 TopK 策略
Args:
enabled: 是否启用
low_threshold: 低置信度阈值
high_threshold: 高置信度阈值
expand_ratio: 扩展比例
shrink_ratio: 收缩比例
Returns:
AdaptiveTopK 实例
"""
config = AdaptiveConfig(
enabled=enabled,
low_confidence_threshold=low_threshold,
high_confidence_threshold=high_threshold,
expand_ratio=expand_ratio,
shrink_ratio=shrink_ratio
)
return AdaptiveTopK(config)
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("自适应 TopK 策略测试")
print("=" * 60)
strategy = AdaptiveTopKWithStats()
test_cases = [
(0.3, 5, "低置信度 - 应该扩展"),
(0.6, 5, "中等置信度 - 保持"),
(0.9, 5, "高置信度 - 应该收缩"),
(0.45, 10, "边界低置信度"),
(0.8, 10, "边界高置信度"),
]
for top_score, initial_k, description in test_cases:
adjusted_k, should_retrieve, reason = strategy.adjust(top_score, initial_k)
print(f"\n{description}")
print(f" 输入: top_score={top_score}, initial_k={initial_k}")
print(f" 输出: adjusted_k={adjusted_k}, should_retrieve={should_retrieve}")
print(f" 原因: {reason}")
print("\n" + "=" * 60)
print("统计信息:")
stats = strategy.get_stats()
for key, value in stats.items():
print(f" {key}: {value}")

390
core/agentic.py Normal file
View File

@@ -0,0 +1,390 @@
"""
Agentic RAG - 知识库智能问答系统
核心能力:
1. 知识库检索 - 向量检索 + BM25 + Rerank
2. 网络搜索 - 当知识库不足时自动搜索需配置SERPER_API_KEY
3. 图谱检索 - 实体关系推理需配置Neo4j
4. 多源融合 - 智能处理知识库和网络内容
5. Agent决策 - 动态决定检索、改写、分解等操作
使用方式:
from core.agentic import AgenticRAG
rag = AgenticRAG()
result = rag.process("你的问题")
print(result["answer"])
"""
import json
import logging
from openai import OpenAI
# 配置日志
logger = logging.getLogger(__name__)
# 导入基础模块
from core.engine import get_engine
from core.llm_utils import call_llm, quick_yes_no, parse_json_from_response
# 导入基础常量和配置
from .agentic_base import (
API_KEY, BASE_URL, MODEL,
HAS_SERPER,
HAS_BUDGET, SEMANTIC_CACHE_ENABLED,
MAX_CONTEXT_TOKENS, MAX_CONTEXT_COUNT, RERANK_THRESHOLD,
SOURCE_KB, SOURCE_WEB,
)
# 尝试导入语义缓存
try:
from core.semantic_cache import SemanticCache
except ImportError:
SemanticCache = None
# 导入 Mixin 类
from .agentic_query import QueryRewriteMixin
from .agentic_search import SearchMixin
from .agentic_answer import AnswerMixin
from .agentic_citation import CitationMixin
from .agentic_media import RichMediaMixin
from .agentic_quality import QualityMixin
from .agentic_context import ContextMixin
from .agentic_meta import MetaQuestionMixin
class AgenticRAG(
QueryRewriteMixin,
SearchMixin,
AnswerMixin,
CitationMixin,
RichMediaMixin,
QualityMixin,
ContextMixin,
MetaQuestionMixin
):
"""
Agentic RAG - 知识库智能问答
通过 Mixin 模式组合功能:
- QueryRewriteMixin: 查询重写
- SearchMixin: 检索功能
- AnswerMixin: 答案生成
- CitationMixin: 引用处理
- RichMediaMixin: 富媒体处理
- QualityMixin: 质量评估
- ContextMixin: 上下文处理
- MetaQuestionMixin: 元问题处理
"""
def __init__(
self,
max_iterations: int = 3,
enable_web_search: bool = True,
**kwargs
):
"""初始化"""
self.max_iterations = max_iterations
self.enable_web_search = enable_web_search and HAS_SERPER
self.client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
# 信息来源标记
self.SOURCE_KB = SOURCE_KB
self.SOURCE_WEB = SOURCE_WEB
# 初始化置信度门控
try:
from core.confidence_gate import create_gate
self.confidence_gate = create_gate()
except ImportError:
self.confidence_gate = None
# 初始化多维质量评估器
try:
from core.quality_assessor import create_assessor
self.quality_assessor = create_assessor()
except ImportError:
self.quality_assessor = None
# 初始化推理反思器
try:
from core.reasoning_reflector import create_reflector
self.reasoning_reflector = create_reflector()
except ImportError:
self.reasoning_reflector = None
# 初始化循环防护器
try:
from core.loop_guard import create_guard
self.loop_guard = create_guard(max_iterations=max_iterations)
except ImportError:
self.loop_guard = None
# 初始化语义缓存
self.semantic_cache = None
self.embedding_model = None
if SEMANTIC_CACHE_ENABLED and SemanticCache:
try:
engine = get_engine()
if engine and hasattr(engine, 'embedding_model'):
self.embedding_model = engine.embedding_model
emb_dim = 768
# 优先使用新 API兼容旧版本
if hasattr(self.embedding_model, 'get_embedding_dimension'):
emb_dim = self.embedding_model.get_embedding_dimension()
elif hasattr(self.embedding_model, 'get_sentence_embedding_dimension'):
emb_dim = self.embedding_model.get_sentence_embedding_dimension()
self.semantic_cache = SemanticCache(
dim=emb_dim,
threshold=0.92,
max_size=5000
)
logger.info(f"语义缓存已启用,维度={emb_dim}")
except Exception as e:
logger.warning(f"语义缓存初始化失败: {e}")
self.semantic_cache = None
# Context Compression 配置
self.MAX_CONTEXT_TOKENS = MAX_CONTEXT_TOKENS
self.MAX_CONTEXT_COUNT = MAX_CONTEXT_COUNT
self.RERANK_THRESHOLD = RERANK_THRESHOLD
# Answer Grounding 配置
self.MAX_GROUNDING_RETRY = 1
self.grounding_retry_count = 0
def should_rewrite(self, query: str, history: list = None) -> bool:
"""判断是否需要重写查询"""
# 口语化表达模式
colloquial_patterns = [
"这个", "那个", "", "", "",
"上面", "下面", "刚才", "之前",
"能不能", "可以吗", "行不行",
"怎么办", "怎么弄", "咋整"
]
for pattern in colloquial_patterns:
if pattern in query:
return True
# 查询太短
if len(query) < 5:
return True
# 有对话历史时,可能需要实体补全
if history:
for msg in reversed(history[-3:]):
if msg.get("role") == "user":
prev_query = msg.get("content", "")
# 如果当前查询缺少主语,可能需要补全
if any(kw in query for kw in ["标准", "规定", "流程", "制度"]):
if not any(kw in query for kw in ["报销", "出差", "请假", "工资", "合同"]):
return True
return False
def process(self, query: str, verbose: bool = True, history: list = None,
allowed_levels: list = None, role: str = None, department: str = None,
emit_log=None) -> dict:
"""
主流程:智能问答
Args:
query: 用户问题
verbose: 是否打印详细过程
history: 对话历史
allowed_levels: 允许访问的安全级别
role: 用户角色
department: 用户部门
emit_log: 日志发射函数(流式输出)
Returns:
{
"answer": str,
"sources": list,
"images": list,
"tables": list,
"citations": list,
"log_trace": list
}
"""
log_trace = []
# 1. 检查元问题
if self._is_meta_question(query):
answer = self._answer_meta_question(query, allowed_levels, role, department)
return {
"answer": answer,
"sources": [],
"images": [],
"tables": [],
"citations": [],
"log_trace": [{"phase": "meta_question", "query": query}]
}
# 2. 查询重写
current_query = query
if self.should_rewrite(query, history):
current_query = self._rewrite_query(query, history)
log_trace.append({"phase": "rewrite", "original": query, "rewritten": current_query})
if emit_log:
emit_log(f"📝 查询重写: {query}{current_query}")
# 3. 知识库检索
contexts = []
try:
engine = get_engine()
if not engine._initialized:
engine.initialize()
# 获取用户可访问的向量库
from knowledge.manager import get_kb_manager
kb_mgr = get_kb_manager()
accessible = kb_mgr.get_accessible_collections(role or 'user', department or '', 'read')
# 统一使用 search_knowledge() — 生产路径的同一 API
# search_knowledge() 返回 dict: {ids, documents, metadatas, distances},每项为 list[list]
# top_k 与生产路径对齐30确保 Rerank 后仍有足够结果
results = engine.search_knowledge(
query=current_query,
top_k=30,
collections=accessible if accessible else None,
)
docs = results.get('documents', [[]])[0]
metas = results.get('metadatas', [[]])[0]
dists = results.get('distances', [[]])[0]
for doc, meta, score in zip(docs, metas, dists):
contexts.append({
'doc': doc,
'meta': meta,
'score': 1 - score if score <= 1 else 1 / (1 + score), # 距离→相似度
'source_type': self.SOURCE_KB,
'query': current_query
})
log_trace.append({"phase": "kb_search", "query": current_query, "results": len(contexts)})
if emit_log:
emit_log(f"🔍 知识库检索: {len(contexts)} 条结果")
except Exception as e:
logger.error(f"知识库检索失败: {e}")
log_trace.append({"phase": "kb_search", "error": str(e)})
# 3.5 图片独立检索 + 打分选择(与生产路径对齐)
selected_images = []
try:
from api.chat_routes import select_images
selected_images = select_images(contexts, current_query)
if selected_images and emit_log:
emit_log(f"🖼️ 图片选择: {len(selected_images)} 张相关图片")
log_trace.append({"phase": "image_selection", "count": len(selected_images)})
except Exception as e:
logger.debug(f"图片选择失败: {e}")
# 4. 上下文压缩
contexts = self._compress_contexts(current_query, contexts)
# 5. 网络搜索(如果需要)
web_contexts = []
if self.enable_web_search and (
not contexts or
not self._is_kb_result_sufficient(current_query, [c['doc'] for c in contexts]) or
self._should_web_search(current_query)
):
web_contexts = self._web_search_flow(current_query, log_trace, emit_log, verbose, allowed_levels)
contexts.extend(web_contexts)
# 7. 生成答案(注入图片描述到上下文)
if contexts:
# 将选中图片的描述注入上下文,让 LLM 能"看到"图片内容
if selected_images:
image_contexts = []
for i, img in enumerate(selected_images, 1):
full_desc = img.get('full_description', '') or img.get('description', '')
if full_desc:
img_source = img.get('source', '')
img_page = img.get('page', '')
source_info = f"(来源:{img_source}{img_page}页)" if img_source and img_page else ""
image_contexts.append({
'doc': f"【图片{i}{full_desc}{source_info}",
'meta': {'source': img_source, 'page': img_page, 'chunk_type': img.get('type', 'image')},
'score': img.get('score', 0),
'source_type': self.SOURCE_KB,
'query': current_query
})
# 图片上下文追加到知识库上下文前面(让 LLM 优先看到图片信息)
contexts = image_contexts + contexts
answer = self._generate_fused_answer(current_query, contexts, allowed_levels)
# 答案验证(防止幻觉)
if self.grounding_retry_count < self.MAX_GROUNDING_RETRY:
answer = self._verify_and_refine_answer(current_query, answer, contexts)
else:
answer = self._generate_no_context_answer(current_query, allowed_levels)
# 8. 构建引用
citations = self._attach_citations(answer, contexts)
# 9. 图片结果:优先使用 select_images 的结构化结果(含 URL + 打分)
# 回退到 _extract_rich_media从 metadata 提取)
if selected_images:
images_result = selected_images
else:
rich_media = self._extract_rich_media(contexts)
images_result = rich_media.get("images", [])
return {
"answer": answer,
"sources": citations.get("sources", []),
"images": images_result,
"tables": citations.get("tables", []) if isinstance(citations, dict) else [],
"citations": citations.get("citations", []),
"log_trace": log_trace
}
def chat_search(self, query: str, history: list = None, role: str = None,
department: str = None, allowed_levels: list = None) -> dict:
"""聊天式检索接口"""
return self.process(
query,
verbose=False,
history=history,
role=role,
department=department,
allowed_levels=allowed_levels
)
def chat(self):
"""命令行交互模式"""
print("🤖 Agentic RAG 已启动,输入 'quit' 退出")
print("-" * 50)
history = []
while True:
try:
query = input("\n👤 你: ").strip()
if not query:
continue
if query.lower() in ['quit', 'exit', 'q']:
print("👋 再见!")
break
result = self.process(query, verbose=True, history=history)
print(f"\n🤖 AI: {result['answer']}")
if result['sources']:
print("\n📚 来源:")
for src in result['sources'][:3]:
print(f" - {src['source']}")
history.append({"role": "user", "content": query})
history.append({"role": "assistant", "content": result['answer']})
except KeyboardInterrupt:
print("\n👋 再见!")
break
except Exception as e:
print(f"❌ 错误: {e}")

214
core/agentic_answer.py Normal file
View File

@@ -0,0 +1,214 @@
"""
Agentic RAG - 答案生成 Mixin
包含答案生成、上下文构建、融合回答等方法
"""
import logging
from .agentic_base import logger, MODEL, SOURCE_KB, SOURCE_WEB
from core.llm_utils import call_llm
logger = logging.getLogger(__name__)
class AnswerMixin:
"""答案生成方法"""
def _generate_fused_answer(self, query: str, contexts: list, allowed_levels: list = None) -> str:
"""生成融合答案 - 智能处理多源信息"""
# 分离不同来源
kb_contexts = [c for c in contexts if c.get('source_type') == self.SOURCE_KB]
web_contexts = [c for c in contexts if c.get('source_type') == self.SOURCE_WEB]
# 如果没有任何上下文,检测是否因权限限制
if not contexts:
return self._generate_no_context_answer(query, allowed_levels)
# 正常生成答案
context_str = self._build_context_string(kb_contexts, web_contexts)
prompt = self._build_normal_answer_prompt(query, context_str, kb_contexts, web_contexts)
result = call_llm(
self.client, prompt, MODEL,
temperature=0.7,
max_tokens=2000
)
return result or f"生成答案失败"
def _build_context_string(self, kb_contexts, web_contexts):
"""构建上下文字符串 - FAQ 优先策略,按分数排序"""
# 分离 FAQ 和普通知识库内容
faq_contexts = [c for c in kb_contexts if c.get('meta', {}).get('chunk_type') == 'faq']
regular_contexts = [c for c in kb_contexts if c.get('meta', {}).get('chunk_type') != 'faq']
# 按分数降序排列,确保最相关的内容优先展示
regular_contexts.sort(key=lambda c: c.get('score', 0), reverse=True)
# FAQ 部分(优先展示)
faq_parts = []
for i, c in enumerate(faq_contexts[:3], 1):
meta = c['meta']
answer = meta.get('faq_answer', c['doc'])
faq_parts.append(f"[FAQ-{i}] 常见问题\n问题:{c['doc']}\n标准答案:{answer}")
# 普通知识库部分(用 12 条,提升覆盖率)
kb_parts = []
for i, c in enumerate(regular_contexts[:12], 1):
meta = c['meta']
source_str = meta.get('source', '未知')
section = meta.get('section', '')
source_info = f"{source_str}"
if section:
source_info += f" > {section[:60]}"
kb_parts.append(f"[知识库-{i}] {source_info}\n{c['doc']}")
web_parts = []
for i, c in enumerate(web_contexts[:5], 1):
meta = c['meta']
web_parts.append(f"[网络-{i}] {meta.get('title', '')}\n来源:{meta.get('source', '')}\n{c['doc']}")
return "\n\n".join(faq_parts + kb_parts + web_parts)
def _build_normal_answer_prompt(self, query, context_str, kb_contexts, web_contexts):
"""构建正常回答的提示词(与生产路径 generate_answer_stream 对齐)"""
# 检测是否有图片上下文
has_images = any(c.get('meta', {}).get('chunk_type') in ('image', 'chart', 'table')
for c in kb_contexts)
image_instruction = ""
if has_images:
image_instruction = "\n5. 如果参考资料中包含【图片N】信息请在回答中简要介绍每张图片的内容和用途"
return f"""你是一个严谨的知识库问答助手。你必须且只能根据用户提供的【参考资料】回答问题。
【参考资料】
{context_str}
【用户问题】
{query}
【回答要求】
1. 如果参考资料中有答案,必须引用对应内容回答,并在回答末尾标注引用编号(如[1]、[2]
2. 如果参考资料中确实没有相关信息,简短说明"参考资料中没有相关信息"即可,不要编造或补充资料外的内容
3. 禁止使用参考资料以外的知识进行补充或推测
4. 分点列举,条理清晰,语言简洁{image_instruction}
请仔细阅读以上全部参考资料后回答:"""
def _build_answer_prompt_with_permission(self, query, context_str, levels_str, sources_str, kb_contexts, web_contexts):
"""构建带权限提示的回答提示词"""
return f"""你是一个严谨的智能助手。
【用户问题】
{query}
【重要提示】
检测到与用户问题更相关的信息可能存在于「{levels_str}」级别的文档中,但用户当前的权限级别无法访问。
【可访问的信息来源】
{context_str}
【回答要求】
1. 首先明确告知用户:当前回答基于您有权限访问的文档,可能不完整
2. 基于现有信息如实回答
3. 建议用户如需完整信息,请联系管理员申请相应权限
请回答:"""
def _generate_no_context_answer(self, query: str, allowed_levels: list = None) -> str:
"""无上下文时的回答 — 诚实告知,不编造"""
return "参考资料中没有找到与该问题相关的信息,无法根据现有知识库内容回答您的问题。"
def _verify_and_refine_answer(self, query: str, answer: str, contexts: list) -> str:
"""验证并精炼答案 - 防止幻觉
返回值始终是干净的答案文本,不包含验证推理过程。
"""
prompt = f"""请检查以下回答是否存在"幻觉"(与参考信息不符的内容)。
【用户问题】
{query}
【参考信息】
{chr(10).join([f"[{i+1}] {c['doc'][:200]}" for i, c in enumerate(contexts[:8])])}
【AI回答】
{answer}
【检查规则】
1. 逐条核对回答中的事实是否能在参考信息中找到依据
2. 如果没有幻觉只回复一个英文单词PASS
3. 如果有幻觉,只输出修正后的完整回答(不要输出检查过程、不要加标题、不要加"检查结果"等前缀)
修正后的回答:"""
try:
result = call_llm(
self.client, prompt, MODEL,
temperature=0.1,
max_tokens=2000
)
if not result:
return answer
# 如果返回 PASS 或很短的确认,说明无幻觉
cleaned = result.strip()
if cleaned.upper() == "PASS" or len(cleaned) < 10:
return answer
# 有幻觉时,返回修正后的干净答案(去掉可能的前缀)
for prefix in ["修正后的回答:", "修正后回答:", "修正回答:", "修正后:"]:
if cleaned.startswith(prefix):
cleaned = cleaned[len(prefix):].strip()
return cleaned
except Exception as e:
logger.warning(f"答案验证失败: {e}")
return answer
def _generate_uncertain_answer(self, query: str, contexts: list) -> str:
"""生成不确定性回答"""
context_str = "\n".join([c['doc'][:200] for c in contexts[:3]])
prompt = f"""用户问题:{query}
找到的信息可能不够完整或相关性不高:
{context_str}
请基于这些信息给出一个谨慎的回答,明确说明哪些部分是有依据的,哪些部分可能需要更多验证。
回答:"""
try:
result = call_llm(
self.client, prompt, MODEL,
temperature=0.7,
max_tokens=1000
)
return result or "根据现有信息无法确定答案。"
except Exception as e:
logger.error(f"生成不确定性回答失败: {e}")
return "根据现有信息无法确定答案。"
def _direct_answer(self, query: str, history: list = None) -> str:
"""直接使用 LLM 回答(无知识库检索)"""
messages = [
{"role": "system", "content": "你是一个专业的助手,请用中文回答用户的问题。"}
]
if history:
for h in history[-4:]:
if h.get("role") in ["user", "assistant"]:
messages.append({"role": h["role"], "content": h.get("content", "")})
messages.append({"role": "user", "content": query})
try:
result = call_llm(
self.client, "", MODEL,
temperature=0.7,
max_tokens=1500,
messages=messages
)
return result or "抱歉,我无法回答这个问题。"
except Exception as e:
logger.error(f"直接回答失败: {e}")
return f"回答生成失败:{str(e)}"

62
core/agentic_base.py Normal file
View File

@@ -0,0 +1,62 @@
"""
Agentic RAG - 基础模块
包含常量、导入和共享配置
"""
import logging
# 配置日志
logger = logging.getLogger(__name__)
# 尝试导入搜索API配置
try:
from config import SERPER_API_KEY
HAS_SERPER = True
except ImportError:
HAS_SERPER = False
SERPER_API_KEY = None
# LLM 预算控制
try:
from core.llm_budget import get_budget_controller, should_use_agent, CallType
HAS_BUDGET = True
except ImportError:
HAS_BUDGET = False
CallType = None
# 语义缓存
try:
from config import SEMANTIC_CACHE_ENABLED, SEMANTIC_CACHE_THRESHOLD
HAS_SEMANTIC_CACHE_CONFIG = True
except ImportError:
SEMANTIC_CACHE_ENABLED = False
SEMANTIC_CACHE_THRESHOLD = 0.92
HAS_SEMANTIC_CACHE_CONFIG = False
try:
from core.semantic_cache import SemanticCache, get_semantic_cache
HAS_SEMANTIC_CACHE = True
except ImportError:
HAS_SEMANTIC_CACHE = False
SemanticCache = None
# LLM 配置
try:
from config import API_KEY, BASE_URL, MODEL
except ImportError:
API_KEY = None
BASE_URL = None
MODEL = None
# 来源标记
SOURCE_KB = "知识库"
SOURCE_WEB = "网络搜索"
# Context Compression 配置
MAX_CONTEXT_TOKENS = 8000 # 最大上下文 token 数(与生产路径对齐)
MAX_CONTEXT_COUNT = 20 # 最大上下文数量
RERANK_THRESHOLD = 0.3 # Rerank 过滤阈值
# Answer Grounding 配置
MAX_GROUNDING_RETRY = 1 # 幻觉修正最多重试次数

237
core/agentic_citation.py Normal file
View File

@@ -0,0 +1,237 @@
"""
Agentic RAG - 引用处理 Mixin
包含来源提取、引用构建、引用附加等方法
"""
import json
import logging
from .agentic_base import logger, SOURCE_KB
logger = logging.getLogger(__name__)
class CitationMixin:
"""引用处理方法"""
def _extract_sources(self, contexts: list) -> list:
"""提取来源列表,返回结构化定位信息"""
source_map = {}
for c in contexts:
meta = c.get('meta', {})
source_type = c.get('source_type', '未知')
if source_type == self.SOURCE_KB:
source_key = meta.get('source', '未知')
page = meta.get('page')
page_end = meta.get('page_end', page)
section = meta.get('section', '')
doc_type = meta.get('doc_type', 'other')
preview = meta.get('preview', '')
section_chunk_id = meta.get('section_chunk_id')
else:
source_key = meta.get('title', meta.get('source', '未知'))
page = None
page_end = None
section = ''
doc_type = 'other'
preview = ''
section_chunk_id = None
if source_key not in source_map:
source_map[source_key] = {
"source": source_key,
"type": source_type,
"doc_type": doc_type,
"count": 0,
"pages": [],
"sections": set(),
"previews": [],
"section_chunk_ids": set()
}
source_map[source_key]["count"] += 1
if page:
page_range = (page, page_end if page_end else page)
if page_range not in source_map[source_key]["pages"]:
source_map[source_key]["pages"].append(page_range)
if section:
source_map[source_key]["sections"].add(section)
if preview and len(source_map[source_key]["previews"]) < 3:
if preview not in source_map[source_key]["previews"]:
source_map[source_key]["previews"].append(preview)
if section_chunk_id:
source_map[source_key]["section_chunk_ids"].add(section_chunk_id)
sources = []
for key, info in source_map.items():
source_str = info["source"]
doc_type = info.get("doc_type", "other")
location_parts = []
if doc_type == 'pdf':
if info["pages"]:
valid_pages = [(s, e) for s, e in info["pages"] if s > 1 or e > 1]
if valid_pages or not info["sections"]:
page_strs = []
for start, end in sorted(info["pages"], key=lambda x: x[0]):
if start == end:
page_strs.append(f"{start}")
else:
page_strs.append(f"{start}-{end}")
location_parts.append(", ".join(page_strs))
if info["sections"]:
sections_list = sorted(info["sections"])[:3]
sections_str = "".join(sections_list)
if len(info["sections"]) > 3:
sections_str += f"{len(info['sections'])}个章节"
location_parts.append(sections_str)
elif doc_type == 'word':
if info["sections"]:
sections_list = sorted(info["sections"])[:3]
sections_str = "".join(sections_list)
if len(info["sections"]) > 3:
sections_str += f"{len(info['sections'])}个章节"
location_parts.append(sections_str)
if info.get("section_chunk_ids"):
chunk_ids = sorted(info["section_chunk_ids"])[:5]
if chunk_ids:
chunk_str = f"{chunk_ids[0]}"
if len(chunk_ids) > 1:
chunk_str = f"{chunk_ids[0]}-{chunk_ids[-1]}"
location_parts.append(chunk_str)
elif doc_type == 'excel':
if info["sections"]:
sections_list = sorted(info["sections"])[:3]
sections_str = "".join(sections_list)
location_parts.append(sections_str)
else:
if info["pages"]:
valid_pages = [(s, e) for s, e in info["pages"] if s > 1 or e > 1]
if valid_pages or not info["sections"]:
page_strs = []
for start, end in sorted(info["pages"], key=lambda x: x[0]):
if start == end:
page_strs.append(f"{start}")
else:
page_strs.append(f"{start}-{end}")
location_parts.append(", ".join(page_strs))
if info["sections"]:
sections_list = sorted(info["sections"])[:3]
sections_str = "".join(sections_list)
if len(info["sections"]) > 3:
sections_str += f"{len(info['sections'])}个章节"
location_parts.append(sections_str)
if location_parts:
source_str = f"{source_str} ({' | '.join(location_parts)})"
sources.append({
"source": source_str,
"type": info["type"],
"count": info["count"],
"doc_type": doc_type,
"previews": info.get("previews", []),
"section_chunk_ids": sorted(info.get("section_chunk_ids", []))[:5]
})
return sources
def _build_citation(self, meta: dict) -> dict:
"""根据文档类型构建定位信息"""
# 从 chunk_id 中提取全局切片序号(格式: "filename_N"
chunk_id_raw = meta.get('chunk_id', '')
chunk_index = None
if chunk_id_raw and '_' in str(chunk_id_raw):
try:
chunk_index = int(str(chunk_id_raw).rsplit('_', 1)[-1])
except (ValueError, IndexError):
chunk_index = meta.get('chunk_index')
else:
chunk_index = meta.get('chunk_index')
citation = {
"chunk_id": chunk_id_raw,
"chunk_index": chunk_index, # 全局切片序号,用于前端文档预览跳转
"source": meta.get('source', ''),
"collection": meta.get('_collection', ''), # 所属向量库,用于前端文档预览跳转
"doc_type": meta.get('doc_type', 'other'),
"section": meta.get('section', ''),
"preview": meta.get('preview', ''),
"content": meta.get('preview', ''), # 初始用 preview_attach_citations 中会用完整内容覆盖
"chunk_type": meta.get('chunk_type', 'text'),
}
doc_type = meta.get('doc_type', 'other')
if doc_type == 'pdf':
bbox_raw = meta.get('bbox')
bbox = None
if bbox_raw:
try:
bbox = json.loads(bbox_raw) if isinstance(bbox_raw, str) else bbox_raw
except (json.JSONDecodeError, TypeError):
bbox = bbox_raw
citation.update({
"page": meta.get('page'),
"page_end": meta.get('page_end'),
"bbox": bbox,
"bbox_mode": meta.get('bbox_mode'),
})
elif doc_type == 'word':
citation.update({
"section_chunk_id": meta.get('section_chunk_id'),
})
elif doc_type == 'excel':
citation.update({
"page": meta.get('page'),
})
else:
bbox_raw = meta.get('bbox')
bbox = None
if bbox_raw:
try:
bbox = json.loads(bbox_raw) if isinstance(bbox_raw, str) else bbox_raw
except (json.JSONDecodeError, TypeError):
bbox = bbox_raw
citation.update({
"page": meta.get('page'),
"page_end": meta.get('page_end'),
"bbox": bbox,
"bbox_mode": meta.get('bbox_mode'),
})
return citation
def _attach_citations(self, answer: str, contexts: list) -> dict:
"""将引用信息附加到答案"""
citations = []
for c in contexts:
meta = c.get('meta', {})
full_content = c.get('doc', '')
citation = self._build_citation(meta)
# 用上下文中的完整文档内容覆盖 content 字段
if full_content:
citation['content'] = full_content[:300]
citations.append(citation)
return {
"answer": answer,
"citations": citations,
"sources": self._extract_sources(contexts)
}

111
core/agentic_context.py Normal file
View File

@@ -0,0 +1,111 @@
"""
Agentic RAG - 上下文处理 Mixin
包含上下文压缩、去重、Token 控制等方法
"""
import logging
from .agentic_base import logger, MAX_CONTEXT_TOKENS, RERANK_THRESHOLD
logger = logging.getLogger(__name__)
class ContextMixin:
"""上下文处理方法"""
def _compress_contexts(self, query: str, contexts: list) -> list:
"""上下文压缩三步走Rerank 过滤 → 去重 → Token 控制"""
if not contexts:
return contexts
# Step 1: Rerank 过滤
filtered = self._rerank_filter(contexts)
# Step 2: 去重
deduped = self._deduplicate_contexts(filtered)
# Step 3: Token 控制
result = self._truncate_to_tokens(deduped, self.MAX_CONTEXT_TOKENS)
return result
def _rerank_filter(self, contexts: list) -> list:
"""Rerank 过滤 - 保留相关性分数 >= 阈值的上下文"""
scored_contexts = [c for c in contexts if c.get('score') is not None]
if scored_contexts:
filtered = [c for c in contexts if c.get('score', 0) >= self.RERANK_THRESHOLD]
return filtered if filtered else contexts
return contexts
def _deduplicate_contexts(self, contexts: list, threshold: float = 0.9) -> list:
"""去重 - 基于内容相似度去重"""
if len(contexts) <= 1:
return contexts
result = []
seen_keys = set()
for c in contexts:
doc = c.get('doc', '')
key = doc[:100] if doc else ''
meta = c.get('meta', {})
source = meta.get('source', '')
page = meta.get('page', '')
composite_key = f"{source}|{page}|{key}"
if composite_key not in seen_keys:
seen_keys.add(composite_key)
result.append(c)
return result
def _truncate_to_tokens(self, contexts: list, max_tokens: int) -> list:
"""Token 控制 - 截断到最大 Token 数"""
result = []
total_tokens = 0
for c in contexts:
doc = c.get('doc', '')
# 简单估算1 token ≈ 1.5 中文字符
tokens = len(doc) // 1.5
if total_tokens + tokens <= max_tokens:
result.append(c)
total_tokens += tokens
else:
break
return result
def _merge_and_deduplicate(self, old_contexts: list, new_contexts: list) -> list:
"""合并并去重两个上下文列表"""
result = list(old_contexts)
seen_keys = set()
# 记录已有上下文的 key
for c in old_contexts:
doc = c.get('doc', '')
key = doc[:100] if doc else ''
meta = c.get('meta', {})
source = meta.get('source', '')
composite_key = f"{source}|{key}"
seen_keys.add(composite_key)
# 添加新上下文(去重)
for c in new_contexts:
doc = c.get('doc', '')
key = doc[:100] if doc else ''
meta = c.get('meta', {})
source = meta.get('source', '')
composite_key = f"{source}|{key}"
if composite_key not in seen_keys:
seen_keys.add(composite_key)
result.append(c)
return result

200
core/agentic_media.py Normal file
View File

@@ -0,0 +1,200 @@
"""
Agentic RAG - 富媒体处理 Mixin
包含图表查找、图片提取、富媒体附加等方法
"""
import re
import json
import logging
from .agentic_base import logger
logger = logging.getLogger(__name__)
class RichMediaMixin:
"""富媒体处理方法"""
def _find_figure(self, query: str, contexts: list, source: str = None) -> dict:
"""精确查找图表,带 fallback"""
patterns = [
r'\s*(\d+[\.\-]\d+)',
r'Fig\.?\s*(\d+[\.\-]\d+)',
r'Figure\s*(\d+[\.\-]\d+)',
]
target_figure = None
for pattern in patterns:
match = re.search(pattern, query, re.IGNORECASE)
if match:
target_figure = match.group(1).replace('-', '.')
break
if not target_figure:
return {"found": False}
# 从 contexts 中查找
for ctx in contexts:
meta = ctx.get('meta', {})
fig_num = meta.get('figure_number', '')
if fig_num == target_figure:
if not source or meta.get('source') == source:
return {
"found": True,
"chunk_id": meta.get('chunk_id'),
"source": meta.get('source'),
"page": meta.get('page'),
"caption": meta.get('caption'),
"image_path": meta.get('image_path'),
}
# Fallback: 直接查向量库
try:
from knowledge.manager import get_kb_manager
kb_mgr = get_kb_manager()
coll = kb_mgr.get_collection('public_kb')
if coll:
where_conditions = [{'chunk_type': {'$in': ['image', 'chart']}}]
if source:
where_conditions.append({'source': source})
result = coll.get(
where={'$and': where_conditions} if len(where_conditions) > 1 else where_conditions[0],
include=['metadatas', 'documents']
)
for meta, doc in zip(result.get('metadatas', []), result.get('documents', [])):
if meta.get('figure_number') == target_figure:
return {
"found": True,
"chunk_id": meta.get('chunk_id'),
"source": meta.get('source'),
"page": meta.get('page'),
"caption": meta.get('caption'),
"image_path": meta.get('image_path'),
}
caption = meta.get('caption', '') or (doc if doc else '')
if f"{target_figure}" in caption or f"{target_figure}" in caption:
return {
"found": True,
"chunk_id": meta.get('chunk_id'),
"source": meta.get('source'),
"page": meta.get('page'),
"caption": meta.get('caption'),
"image_path": meta.get('image_path'),
}
except Exception as e:
logger.warning(f"_find_figure fallback 查询失败: {e}")
return {"found": False}
def _get_images_for_source(self, source: str, collections: list = None) -> list:
"""直接从向量库获取指定文件的所有图片"""
try:
from knowledge.manager import get_kb_manager
kb_mgr = get_kb_manager()
except ImportError:
return []
images = []
seen_ids = set()
target_collections = collections or ['public_kb']
for kb_name in target_collections:
try:
coll = kb_mgr.get_collection(kb_name)
if not coll:
continue
result = coll.get(
where={'source': source},
include=['metadatas']
)
for meta in result.get('metadatas', []):
images_json = meta.get('images_json')
if images_json:
try:
imgs = json.loads(images_json)
for img in imgs:
img_id = img.get('id')
if img_id and img_id not in seen_ids:
seen_ids.add(img_id)
images.append({
"id": img_id,
"caption": img.get("caption", ""),
"url": f"/images/{img_id}",
"page": img.get("page") or meta.get("page"),
"source": source,
"width": img.get("width"),
"height": img.get("height")
})
except (json.JSONDecodeError, TypeError):
pass
except Exception as e:
logger.warning(f"{kb_name} 获取图片失败: {e}")
continue
return images
def _extract_rich_media(self, contexts: list, sources_filter: list = None, max_images: int = 10,
max_tables: int = 5) -> dict:
"""从检索结果中提取富媒体(图片、表格)"""
images = []
tables = []
seen_image_ids = set()
seen_table_ids = set()
for ctx in contexts:
meta = ctx.get('meta', {})
source = meta.get('source', '')
# 过滤来源
if sources_filter and source not in sources_filter:
continue
# 提取图片
images_json = meta.get('images_json')
if images_json:
try:
imgs = json.loads(images_json)
for img in imgs:
img_id = img.get('id')
if img_id and img_id not in seen_image_ids:
seen_image_ids.add(img_id)
images.append({
"id": img_id,
"caption": img.get("caption", ""),
"url": f"/images/{img_id}",
"page": img.get("page") or meta.get("page"),
"source": source,
"type": img.get("type", "image")
})
except (json.JSONDecodeError, TypeError):
pass
# 提取表格
table_json = meta.get('table_json')
if table_json:
try:
tbl = json.loads(table_json)
tbl_id = tbl.get('id') or meta.get('chunk_id')
if tbl_id and tbl_id not in seen_table_ids:
seen_table_ids.add(tbl_id)
tables.append({
"id": tbl_id,
"caption": tbl.get("caption", ""),
"markdown": tbl.get("markdown", ""),
"page": meta.get("page"),
"source": source
})
except (json.JSONDecodeError, TypeError):
pass
return {
"images": images[:max_images],
"tables": tables[:max_tables]
}

133
core/agentic_meta.py Normal file
View File

@@ -0,0 +1,133 @@
"""
Agentic RAG - 元问题处理 Mixin
包含元问题判断和知识库元数据回答方法
"""
import logging
from .agentic_base import logger
logger = logging.getLogger(__name__)
class MetaQuestionMixin:
"""元问题处理方法"""
def _is_meta_question(self, query: str) -> bool:
"""判断是否为元问题(关于知识库本身的问题)"""
meta_patterns = [
"有哪些文件", "什么文件", "哪些文件", "文件列表", "文件目录",
"可以查看", "能查看", "有权限查看", "权限查看",
"能访问", "可以访问", "有权限访问",
"我的权限", "用户权限", "查看权限", "访问权限",
"权限能", "权限可以", "有什么权限", "有哪些权限",
"我能看", "我可以看", "我能查", "我可以查",
"能看到什么", "能查到什么", "可以看什么", "可以查什么",
"知识库有哪些", "库里有", "文档有哪些", "有哪些文档",
"有什么文档", "有什么文件", "包含什么", "包含哪些",
"你知道什么", "你都知道", "你能回答什么",
"系统里有什么", "库里有什么",
"public_kb", "dept_tech", "dept_hr", "dept_finance", "dept_operation",
"kb里", "向量库", "有哪些库", "库列表", "kb有哪些"
]
query_lower = query.lower()
return any(kw in query_lower for kw in meta_patterns)
def _answer_meta_question(self, query: str, allowed_levels: list = None,
role: str = None, department: str = None) -> str:
"""回答元问题(关于知识库本身的问题)"""
try:
source_map = {}
try:
from knowledge.manager import get_kb_manager
from auth.gateway import get_accessible_collections as _get_accessible
kb_mgr = get_kb_manager()
accessible = _get_accessible(role or 'user', department or '', 'read')
for kb_name in accessible:
coll = kb_mgr.get_collection(kb_name)
if not coll:
continue
try:
result = coll.get(include=['metadatas'])
except Exception as e:
logger.debug(f"获取{kb_name}元数据失败: {e}")
continue
for meta in result.get('metadatas', []):
source = meta.get('source', '未知')
level = meta.get('security_level', 'public')
page = meta.get('page')
if source not in source_map:
source_map[source] = {
'count': 0, 'levels': set(),
'pages': set(), 'collections': set()
}
source_map[source]['count'] += 1
source_map[source]['levels'].add(level)
source_map[source]['collections'].add(kb_name)
if page:
source_map[source]['pages'].add(page)
except ImportError:
from core.engine import get_engine
all_docs = get_engine().collection.get(include=['metadatas'])
for meta in all_docs.get('metadatas', []):
source = meta.get('source', '未知')
level = meta.get('security_level', 'public')
page = meta.get('page')
if source not in source_map:
source_map[source] = {
'count': 0, 'levels': set(),
'pages': set(), 'collections': set()
}
source_map[source]['count'] += 1
source_map[source]['levels'].add(level)
if page:
source_map[source]['pages'].add(page)
# 根据安全级别过滤
if allowed_levels:
allowed_set = set(allowed_levels)
filtered_sources = {}
for source, info in source_map.items():
if info['levels'] & allowed_set:
filtered_sources[source] = info
source_map = filtered_sources
if not source_map:
return "抱歉,您当前没有权限查看任何文档,或者知识库为空。"
sorted_sources = sorted(source_map.items(), key=lambda x: x[1]['count'], reverse=True)
answer_parts = [f"📚 **知识库文档列表**(共 {len(sorted_sources)} 个文档)\n"]
for i, (source, info) in enumerate(sorted_sources, 1):
colls = info.get('collections', set())
coll_str = f",所属: {', '.join(sorted(colls))}" if colls else ""
pages_str = ''
if info['pages']:
pages_list = sorted(info['pages'])
if len(pages_list) <= 5:
pages_str = f",页码: {', '.join(map(str, pages_list))}"
else:
pages_str = f",共 {len(info['pages'])}"
answer_parts.append(f"{i}. **{source}** ({info['count']} 条片段{coll_str}{pages_str})")
answer_parts.append(f"\n**总计**: {sum(s[1]['count'] for s in sorted_sources)} 条知识片段")
answer_parts.append(f"\n**您的权限级别**: {', '.join(allowed_levels) if allowed_levels else '全部'}")
answer_parts.append("\n\n💡 **提示**: 您可以直接提问关于这些文档内容的问题。")
return '\n'.join(answer_parts)
except Exception as e:
return f"获取文档列表时出错: {str(e)}\n\n您可以直接提问,我会尝试从知识库中检索相关信息。"

137
core/agentic_quality.py Normal file
View File

@@ -0,0 +1,137 @@
"""
Agentic RAG - 质量评估 Mixin
包含置信度门控、质量评估、推理反思等方法
"""
import logging
from .agentic_base import logger
logger = logging.getLogger(__name__)
class QualityMixin:
"""质量评估方法"""
def _check_confidence_gate(self, query: str, docs: list, verbose: bool = True,
precomputed_scores: list = None):
"""检查置信度门控
Args:
query: 用户查询
docs: 文档列表
verbose: 是否详细输出
precomputed_scores: 预计算的 Rerank 分数(可选,避免重复推理)
"""
if not self.confidence_gate:
return {"passed": True, "reason": "no_gate"}
try:
result = self.confidence_gate.evaluate(query, docs,
precomputed_scores=precomputed_scores)
return result
except Exception as e:
logger.warning(f"置信度门控检查失败: {e}")
return {"passed": True, "reason": "error"}
def _assess_quality(self, query: str, docs: list, metas: list = None,
verbose: bool = True) -> dict:
"""多维质量评估"""
if not self.quality_assessor:
return {"overall_score": 0.5, "dimensions": {}}
try:
result = self.quality_assessor.assess(query, docs, metas)
return result
except Exception as e:
logger.warning(f"质量评估失败: {e}")
return {"overall_score": 0.5, "dimensions": {}}
def _reflect_on_answer(self, query: str, answer: str, contexts: list,
verbose: bool = True) -> dict:
"""推理反思"""
if not self.reasoning_reflector:
return {"needs_reflection": False, "issues": []}
try:
result = self.reasoning_reflector.reflect(query, answer, contexts)
return result
except Exception as e:
logger.warning(f"推理反思失败: {e}")
return {"needs_reflection": False, "issues": []}
def _think(self, original_query: str, current_query: str,
iteration: int, contexts: list, verbose: bool = True) -> dict:
"""
Agent 思考:决定下一步行动
Returns:
{
"action": "answer" | "rewrite" | "search_web" | "decompose",
"reason": "...",
"rewrite_query": "..." # 如果 action == "rewrite"
}
"""
from core.llm_utils import call_llm, parse_json_from_response
from .agentic_base import MODEL
# 构建思考提示
context_summary = ""
if contexts:
for i, ctx in enumerate(contexts[:3], 1):
meta = ctx.get('meta', {})
source = meta.get('source', '未知')
doc_preview = ctx.get('doc', '')[:100]
context_summary += f"{i}. [{source}] {doc_preview}...\n"
prompt = f"""你是一个 RAG 系统的决策 Agent需要判断下一步行动。
【原始问题】
{original_query}
【当前问题】
{current_query}
【迭代轮次】
{iteration} / {self.max_iterations}
【已检索到的上下文】
{context_summary if context_summary else "(无)"}
【可选行动】
1. answer - 已有足够信息,可以回答
2. rewrite - 查询不够清晰,需要重写
3. search_web - 知识库信息不足,需要网络搜索
4. decompose - 问题太复杂,需要分解
【决策要求】
- 如果上下文足够回答问题,选择 answer
- 如果上下文不足且迭代未超限,选择 search_web 或 rewrite
- 返回 JSON 格式
请决策:"""
try:
result = call_llm(
self.client, prompt, MODEL,
temperature=0.3,
max_tokens=200
)
decision = parse_json_from_response(result) if result else {}
# 默认决策
if not decision or "action" not in decision:
if contexts and len(contexts) >= 2:
decision = {"action": "answer", "reason": "有足够上下文"}
else:
decision = {"action": "rewrite", "reason": "上下文不足"}
return decision
except Exception as e:
logger.warning(f"Agent 思考失败: {e}")
if contexts:
return {"action": "answer", "reason": "默认回答"}
return {"action": "rewrite", "reason": "默认重写"}

271
core/agentic_query.py Normal file
View File

@@ -0,0 +1,271 @@
"""
Agentic RAG - 查询重写 Mixin
包含查询改写、实体补全、专业术语映射等方法
"""
import re
import logging
from .agentic_base import logger, MODEL
logger = logging.getLogger(__name__)
class QueryRewriteMixin:
"""查询重写方法"""
def _rewrite_query(self, query: str, history: list = None,
strategy: str = "professional") -> str:
"""
增强版查询重写:将口语化表达转为专业术语
Args:
query: 原始查询
history: 对话历史(用于实体补全)
strategy: 重写策略
- professional: 口语化→专业术语
- expand: 扩展关键词
- clarify: 消歧义
- entity: 实体补全
Returns:
str: 重写后的查询
"""
# 尝试多种策略组合
rewritten = query
# 策略1: 口语化→专业术语映射
if strategy in ["professional", "all"]:
rewritten = self._apply_professional_mapping(rewritten)
# 策略2: 实体补全(利用对话历史)
if strategy in ["entity", "all"] and history:
rewritten = self._complete_entities(rewritten, history)
# 策略3: LLM 深度重写(仅在需要时调用)
if strategy in ["professional", "all"]:
llm_rewritten = self._llm_rewrite(rewritten)
if llm_rewritten and len(llm_rewritten) > len(rewritten) * 0.5:
rewritten = llm_rewritten
return rewritten
def _apply_professional_mapping(self, query: str) -> str:
"""应用口语化→专业术语映射"""
TERM_MAPPING = {
"报销": "差旅报销 费用报销 报销审批",
"请假": "休假申请 请假审批 考勤管理",
"加班": "加班申请 工时管理 加班审批",
"工资": "薪酬管理 工资发放 薪资结构",
"合同": "合同管理 合同签署 合同审批",
"流程": "审批流程 业务流程 工作流",
"制度": "管理制度 规章制度 企业规范",
"规定": "管理规定 制度规定 政策要求",
"几天": "时限 审批时限 办理时限",
"多久": "处理时效 审批周期 办理周期",
"多少": "标准 额度 限额 标准",
"能不能": "是否允许 是否可以 权限",
"人事": "人力资源 HR 人力部门",
"财务": "财务部 财务部门 财务管理",
"技术": "技术部 研发部 IT部门",
}
result = query
for colloquial, professional in TERM_MAPPING.items():
if colloquial in query:
result = result.replace(colloquial, f"{colloquial} {professional.split()[0]}")
return result
def _complete_entities(self, query: str, history: list) -> str:
"""实体补全:利用对话历史补充缺失的实体"""
if not history:
return query
# 图片指代识别
image_reference = self._detect_image_reference(query, history)
if image_reference:
return image_reference
# 获取最近用户消息
last_user_msg = None
for msg in reversed(history):
if msg.get("role") == "user":
last_user_msg = msg.get("content", "")
break
if not last_user_msg:
return query
# 检查当前查询是否缺少主语
BUSINESS_KEYWORDS = ["报销", "出差", "请假", "工资", "合同", "审批", "流程",
"制度", "规定", "标准", "金额", "时间"]
has_subject = any(kw in query for kw in BUSINESS_KEYWORDS)
if not has_subject:
try:
import jieba
entities = []
for word in jieba.cut(last_user_msg):
word = word.strip()
if len(word) >= 2 and any(kw in word for kw in BUSINESS_KEYWORDS):
entities.append(word)
if entities:
return f"{entities[0]} {query}"
except ImportError:
pass
return query
def _detect_image_reference(self, query: str, history: list) -> str:
"""检测图片指代查询并重写"""
IMAGE_REFERENCE_PATTERNS = [
r'这[张些]图片', r'那[张些]图片', r'上面的图片', r'刚才的图片',
r'这[张些]图', r'那[张些]图', r'上面的图', r'刚才的图',
r'解释一下这[张些]图', r'说明一下这[张些]图',
r'这[张些]是什么图', r'图[里内]是什么', r'图片[里内]是什么',
]
is_image_reference = False
for pattern in IMAGE_REFERENCE_PATTERNS:
if re.search(pattern, query):
is_image_reference = True
break
if not is_image_reference:
return ""
last_images = []
for msg in reversed(history):
if msg.get("role") == "assistant":
metadata = msg.get("metadata", {})
if isinstance(metadata, dict):
images = metadata.get("images", [])
if images:
for img in images[:5]:
if isinstance(img, dict):
desc = img.get("description", "")
img_type = img.get("type", "图片")
if desc:
last_images.append(f"{img_type}{desc}")
elif isinstance(img, str):
last_images.append(f"图片:{img}")
if not last_images:
content = msg.get("content", "")
if "图片" in content or "图表" in content or "" in content:
sentences = content.split("")
for sentence in sentences:
if "图片" in sentence or "图表" in sentence:
last_images.append(sentence.strip())
if last_images:
break
if last_images:
image_context = " ".join(last_images[:3])
question_intent = re.sub(
r'这[张些]图片?|那[张些]图片?|上面的图片?|刚才的图片?|解释一下|说明一下',
'', query
).strip()
if question_intent:
return f"{image_context} {question_intent}"
else:
return f"详细解释:{image_context}"
return query
def _extract_image_context_from_history(self, history: list) -> str:
"""从对话历史中提取图片上下文"""
if not history:
return ""
for msg in reversed(history):
if msg.get("role") == "assistant":
metadata = msg.get("metadata", {})
images = metadata.get("images", [])
content = msg.get("content", "")
image_descriptions = []
if images:
for i, img in enumerate(images[:5], 1):
if isinstance(img, dict):
desc = img.get("description", "")
img_type = img.get("type", "图片")
source = img.get("source", "")
page = img.get("page", "")
img_info = f"图片{i}{img_type}"
if desc:
img_info += f",描述:{desc}"
if source:
img_info += f",来源:{source}"
if page:
img_info += f",第{page}"
image_descriptions.append(img_info)
if not image_descriptions:
if "图片" in content or "图表" in content:
sentences = content.split("")
for sentence in sentences:
if "图片" in sentence or "图表" in sentence:
image_descriptions.append(sentence.strip())
if len(image_descriptions) >= 3:
break
if image_descriptions:
return "\n".join(image_descriptions)
return ""
def _answer_image_reference(self, enhanced_query: str, history: list) -> str:
"""回答图片引用问题"""
from core.llm_utils import call_llm
messages = [
{"role": "system", "content": "你是一个专业的助手,请根据提供的图片信息回答用户的问题。"}
]
for h in history[-4:]:
if h.get("role") in ["user", "assistant"]:
messages.append({"role": h["role"], "content": h.get("content", "")})
messages.append({"role": "user", "content": enhanced_query})
try:
result = call_llm(
self.client, "", MODEL,
temperature=0.3,
max_tokens=1000,
messages=messages
)
return result or ""
except Exception as e:
logger.error(f"图片引用回答失败: {e}")
return f"抱歉,回答图片问题时出现错误:{str(e)}"
def _llm_rewrite(self, query: str) -> str:
"""LLM 深度重写查询"""
from core.llm_utils import call_llm
prompt = f"""请将以下用户问题改写为更专业、更清晰的表达,保持原意不变。
原问题:{query}
改写后的问题:"""
try:
rewritten = call_llm(
self.client, prompt, MODEL,
temperature=0.3,
max_tokens=100
)
return rewritten.strip() if rewritten else query
except Exception as e:
logger.warning(f"LLM 重写失败: {e}")
return query

152
core/agentic_search.py Normal file
View File

@@ -0,0 +1,152 @@
"""
Agentic RAG - 检索 Mixin
包含知识库检索、网络搜索等方法
"""
import json
import logging
import requests
from .agentic_base import (
logger, HAS_SERPER, SERPER_API_KEY,
SOURCE_KB, SOURCE_WEB
)
logger = logging.getLogger(__name__)
class SearchMixin:
"""检索功能方法"""
def _web_search(self, query: str, top_k: int = 5) -> list:
"""网络搜索使用Serper API"""
if not HAS_SERPER:
return []
try:
url = "https://google.serper.dev/search"
payload = json.dumps({
"q": query,
"gl": "cn",
"hl": "zh-cn",
"num": top_k
})
headers = {
'X-API-KEY': SERPER_API_KEY,
'Content-Type': 'application/json'
}
response = requests.post(url, headers=headers, data=payload, timeout=10)
response.raise_for_status()
data = response.json()
results = []
for item in data.get('organic', [])[:top_k]:
results.append({
'title': item.get('title', ''),
'link': item.get('link', ''),
'snippet': item.get('snippet', ''),
'date': item.get('date', '')
})
return results
except Exception as e:
logger.warning(f"网络搜索失败: {e}")
return []
def _should_web_search(self, query: str) -> bool:
"""判断是否需要网络搜索"""
realtime_keywords = [
"今天", "最新", "今日", "当前", "现在",
"天气", "新闻", "股价", "行情", "汇率",
"最近", "近期", "这周", "本月", "今年",
"实时", "动态", "热点", "发生"
]
query_lower = query.lower()
return any(kw in query_lower for kw in realtime_keywords)
def _web_search_flow(self, query: str, log_trace: list, emit_log, verbose: bool,
allowed_levels: list = None) -> list:
"""
网络搜索流程
Args:
query: 查询
log_trace: 日志追踪列表
emit_log: 日志发射函数
verbose: 是否详细输出
allowed_levels: 允许的安全级别
Returns:
网络搜索结果列表
"""
if not self.enable_web_search or not HAS_SERPER:
return []
if emit_log:
emit_log("🌐 触发网络搜索...")
web_results = self._web_search(query, top_k=5)
if not web_results:
if emit_log:
emit_log("⚠️ 网络搜索未返回结果")
return []
# 转换为统一上下文格式
web_contexts = []
for item in web_results:
web_contexts.append({
'doc': f"{item.get('title', '')}\n{item.get('snippet', '')}",
'meta': {
'source': self.SOURCE_WEB,
'link': item.get('link', ''),
'date': item.get('date', '')
},
'source_type': self.SOURCE_WEB,
'query': query
})
log_trace.append({
'phase': 'web_search',
'query': query,
'results_count': len(web_contexts)
})
if emit_log:
emit_log(f"✅ 网络搜索返回 {len(web_contexts)} 条结果")
return web_contexts
def _is_kb_result_sufficient(self, query: str, docs: list) -> bool:
"""判断知识库检索结果是否充分"""
if not docs:
return False
# 结果数量检查
if len(docs) >= 3:
# 至少3条结果检查相关性
high_rel_count = 0
for doc in docs:
score = doc.get('score', 0) or doc.get('distance', 1)
# cosine 距离转相似度
if isinstance(score, (int, float)):
sim = 1 - score if score <= 1 else score
if sim >= 0.6:
high_rel_count += 1
if high_rel_count >= 2:
return True
# 有高质量结果
for doc in docs[:2]:
score = doc.get('score', 0) or doc.get('distance', 1)
if isinstance(score, (int, float)):
sim = 1 - score if score <= 1 else score
if sim >= 0.8:
return True
return False

139
core/bm25_index.py Normal file
View File

@@ -0,0 +1,139 @@
"""
BM25 关键词检索索引
使用 rank_bm25 + jieba 分词实现中文关键词检索。
支持索引的序列化/反序列化。
使用方式:
from core.bm25_index import BM25Index
index = BM25Index()
index.add_documents(ids, documents, metadatas)
results = index.search("查询内容", top_k=5)
"""
import os
import pickle
import numpy as np
from rank_bm25 import BM25Okapi
import jieba
import logging
from core.constants import get_empty_result
logger = logging.getLogger(__name__)
class BM25Index:
"""BM25索引管理器用于关键词检索"""
def __init__(self):
self.bm25 = None
self.documents = [] # 原始文档
self.metadatas = [] # 元数据
self.ids = [] # 文档ID
def tokenize(self, text):
"""中文分词"""
return list(jieba.cut(text))
def add_documents(self, ids, documents, metadatas):
"""添加文档到索引"""
self.ids = ids
self.documents = documents
self.metadatas = metadatas
# 分词并构建BM25索引
tokenized_docs = [self.tokenize(doc) for doc in documents]
self.bm25 = BM25Okapi(tokenized_docs)
def search(self, query, top_k=10):
"""BM25检索"""
if not self.bm25:
return get_empty_result()
tokenized_query = self.tokenize(query)
scores = self.bm25.get_scores(tokenized_query)
# 获取top_k个结果
top_indices = np.argsort(scores)[::-1][:top_k]
return {
'ids': [[self.ids[i] for i in top_indices]],
'documents': [[self.documents[i] for i in top_indices]],
'metadatas': [[self.metadatas[i] for i in top_indices]],
'distances': [[float(scores[i]) for i in top_indices]]
}
def save(self, path):
"""保存索引到文件"""
data = {
'ids': self.ids,
'documents': self.documents,
'metadatas': self.metadatas
}
with open(path, 'wb') as f:
pickle.dump(data, f)
logger.info(f"BM25索引已保存: {path}")
def load(self, path):
"""从文件加载索引"""
if not os.path.exists(path):
return False
with open(path, 'rb') as f:
data = pickle.load(f)
self.ids = data['ids']
self.documents = data['documents']
self.metadatas = data['metadatas']
# 重建BM25索引
tokenized_docs = [self.tokenize(doc) for doc in self.documents]
self.bm25 = BM25Okapi(tokenized_docs)
logger.info(f"BM25索引已加载: {len(self.documents)} 个文档")
return True
def clear(self):
"""清空索引"""
self.bm25 = None
self.documents = []
self.metadatas = []
self.ids = []
# ==================== 全局 BM25 索引管理器 ====================
_bm25_indexer: BM25Index = None
def get_bm25_indexer() -> BM25Index:
"""
获取全局 BM25 索引器实例
Returns:
BM25Index 实例
"""
global _bm25_indexer
if _bm25_indexer is None:
_bm25_indexer = BM25Index()
return _bm25_indexer
def init_bm25_indexer(ids=None, documents=None, metadatas=None) -> BM25Index:
"""
初始化 BM25 索引器并添加文档
Args:
ids: 文档 ID 列表
documents: 文档内容列表
metadatas: 元数据列表
Returns:
初始化后的 BM25Index 实例
"""
global _bm25_indexer
_bm25_indexer = BM25Index()
if ids and documents:
_bm25_indexer.add_documents(ids, documents, metadatas or [])
return _bm25_indexer

443
core/cache.py Normal file
View File

@@ -0,0 +1,443 @@
# -*- coding: utf-8 -*-
"""
RAG 三层缓存模块
缓存层次:
1. Query Cache: 完整问答结果缓存
2. Embedding Cache: 向量化结果缓存
3. Rerank Cache: 重排序分数缓存
缓存失效基于知识库版本号kb_version的自动失效机制
"""
import hashlib
import time
import threading
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Any, Tuple
from collections import OrderedDict
import logging
logger = logging.getLogger(__name__)
@dataclass
class CacheEntry:
"""缓存条目"""
key: str
value: Any
created_at: float
ttl: float # 秒
hits: int = 0
kb_version: int = 0
def is_expired(self) -> bool:
return time.time() - self.created_at > self.ttl
@dataclass
class CacheStats:
"""缓存统计"""
total_entries: int = 0
hits: int = 0
misses: int = 0
evictions: int = 0
@property
def hit_rate(self) -> float:
total = self.hits + self.misses
return self.hits / total if total > 0 else 0.0
class LRUCache:
"""线程安全的 LRU 缓存实现"""
def __init__(self, max_size: int = 1000, default_ttl: float = 3600):
self.max_size = max_size
self.default_ttl = default_ttl
self._cache: OrderedDict[str, CacheEntry] = OrderedDict()
self._lock = threading.RLock()
self._stats = CacheStats()
def get(self, key: str) -> Optional[Any]:
"""获取缓存值"""
with self._lock:
if key not in self._cache:
self._stats.misses += 1
return None
entry = self._cache[key]
# 检查过期
if entry.is_expired():
del self._cache[key]
self._stats.misses += 1
self._stats.evictions += 1
return None
# LRU 更新
self._cache.move_to_end(key)
entry.hits += 1
self._stats.hits += 1
return entry.value
def set(self, key: str, value: Any, ttl: float = None,
kb_version: int = 0) -> None:
"""设置缓存值"""
with self._lock:
if key in self._cache:
del self._cache[key]
entry = CacheEntry(
key=key,
value=value,
created_at=time.time(),
ttl=ttl or self.default_ttl,
kb_version=kb_version
)
self._cache[key] = entry
# LRU 淘汰
while len(self._cache) > self.max_size:
oldest_key = next(iter(self._cache))
del self._cache[oldest_key]
self._stats.evictions += 1
self._stats.total_entries = len(self._cache)
def invalidate_by_version(self, kb_version: int) -> int:
"""失效指定版本的所有缓存"""
count = 0
with self._lock:
keys_to_delete = [
k for k, v in self._cache.items()
if v.kb_version == kb_version
]
for key in keys_to_delete:
del self._cache[key]
count += 1
self._stats.evictions += count
self._stats.total_entries = len(self._cache)
return count
def clear(self) -> None:
"""清空缓存"""
with self._lock:
self._cache.clear()
self._stats.total_entries = 0
def get_stats(self) -> CacheStats:
"""获取统计信息"""
with self._lock:
return self._stats
class RAGCacheManager:
"""RAG 三层缓存管理器"""
# 默认配置(可从 config 覆盖)
DEFAULT_QUERY_CACHE_SIZE = 500
DEFAULT_QUERY_CACHE_TTL = 3600 # 1小时
DEFAULT_EMBEDDING_CACHE_SIZE = 2000
DEFAULT_EMBEDDING_CACHE_TTL = 86400 # 24小时
DEFAULT_RERANK_CACHE_SIZE = 1000
DEFAULT_RERANK_CACHE_TTL = 3600 # 1小时
def __init__(
self,
query_cache_size: int = None,
query_cache_ttl: float = None,
embedding_cache_size: int = None,
embedding_cache_ttl: float = None,
rerank_cache_size: int = None,
rerank_cache_ttl: float = None,
kb_versions: Dict[str, int] = None
):
"""初始化缓存管理器"""
self.query_cache = LRUCache(
max_size=query_cache_size or self.DEFAULT_QUERY_CACHE_SIZE,
default_ttl=query_cache_ttl or self.DEFAULT_QUERY_CACHE_TTL
)
self.embedding_cache = LRUCache(
max_size=embedding_cache_size or self.DEFAULT_EMBEDDING_CACHE_SIZE,
default_ttl=embedding_cache_ttl or self.DEFAULT_EMBEDDING_CACHE_TTL
)
self.rerank_cache = LRUCache(
max_size=rerank_cache_size or self.DEFAULT_RERANK_CACHE_SIZE,
default_ttl=rerank_cache_ttl or self.DEFAULT_RERANK_CACHE_TTL
)
self._kb_versions: Dict[str, int] = kb_versions or {}
self._version_lock = threading.Lock()
def get_kb_version(self, kb_name: str) -> int:
"""获取知识库当前版本号"""
with self._version_lock:
return self._kb_versions.get(kb_name, 0)
def increment_kb_version(self, kb_name: str) -> int:
"""递增知识库版本号(文档更新时调用)"""
with self._version_lock:
old_version = self._kb_versions.get(kb_name, 0)
self._kb_versions[kb_name] = old_version + 1
new_version = self._kb_versions[kb_name]
# 失效旧版本缓存
self.query_cache.invalidate_by_version(old_version)
self.embedding_cache.invalidate_by_version(old_version)
logger.info(f"知识库 {kb_name} 版本更新: {old_version} -> {new_version}")
return new_version
# ==================== Query Cache 方法 ====================
@staticmethod
def _make_query_cache_key(query: str, kb_name: str, kb_version: int, doc_hash: str = "") -> str:
"""
生成查询缓存 key
Args:
query: 查询文本
kb_name: 知识库名称
kb_version: 知识库版本号
doc_hash: 相关文档版本哈希(细粒度失效)
Returns:
缓存 key
"""
if doc_hash:
# 细粒度:只失效相关文档的缓存
return hashlib.md5(
f"query:{query}:{kb_name}:{doc_hash}".encode()
).hexdigest()
else:
# 粗粒度:整个知识库版本变化时失效
return hashlib.md5(
f"query:{query}:{kb_name}:{kb_version}".encode()
).hexdigest()
def get_query_result(self, query: str, kb_name: str, doc_ids: List[str] = None) -> Optional[Dict]:
"""
获取查询缓存结果
Args:
query: 查询文本
kb_name: 知识库名称
doc_ids: 相关文档 ID 列表(用于细粒度缓存 key
"""
kb_version = self.get_kb_version(kb_name)
# 计算文档哈希(如果提供了 doc_ids
doc_hash = ""
if doc_ids:
doc_hash = self._compute_doc_hash(kb_name, doc_ids)
key = self._make_query_cache_key(query, kb_name, kb_version, doc_hash)
return self.query_cache.get(key)
def set_query_result(self, query: str, kb_name: str, result: Dict, doc_ids: List[str] = None) -> None:
"""
设置查询缓存结果
Args:
query: 查询文本
kb_name: 知识库名称
result: 缓存结果
doc_ids: 相关文档 ID 列表(用于细粒度失效)
"""
kb_version = self.get_kb_version(kb_name)
# 计算相关文档的版本哈希(细粒度失效)
doc_hash = ""
if doc_ids:
doc_hash = self._compute_doc_hash(kb_name, doc_ids)
key = self._make_query_cache_key(query, kb_name, kb_version, doc_hash)
self.query_cache.set(key, result, kb_version=kb_version)
def _compute_doc_hash(self, kb_name: str, doc_ids: List[str]) -> str:
"""
计算文档版本哈希
用于细粒度缓存失效:只失效相关文档变化时的缓存
"""
if not doc_ids:
return ""
# 从文档 ID 中提取 source文件名
sources = set()
for doc_id in doc_ids:
# doc_id 格式通常为 "filename_text_0" 或类似
parts = doc_id.split('_')
if parts:
sources.add(parts[0])
# 生成哈希
sources_str = ','.join(sorted(sources))
return hashlib.md5(f"docs:{sources_str}".encode()).hexdigest()
# ==================== Embedding Cache 方法 ====================
@staticmethod
def _make_embedding_key(text: str) -> str:
return hashlib.md5(f"emb:{text}".encode()).hexdigest()
def get_embedding(self, text: str) -> Optional[List[float]]:
"""获取文本的 Embedding 缓存"""
key = self._make_embedding_key(text)
return self.embedding_cache.get(key)
def set_embedding(self, text: str, embedding: List[float],
kb_version: int = 0) -> None:
"""设置 Embedding 缓存"""
key = self._make_embedding_key(text)
self.embedding_cache.set(key, embedding, kb_version=kb_version)
def get_embeddings_batch(self, texts: List[str]) -> Tuple[List[Optional[List[float]]], List[int]]:
"""
批量获取 Embedding
Returns:
(embeddings, missed_indices): 命中的 embedding 列表(未命中为 None和未命中的索引列表
"""
embeddings: List[Optional[List[float]]] = []
missed_indices: List[int] = []
for i, text in enumerate(texts):
emb = self.get_embedding(text)
if emb is not None:
embeddings.append(emb)
else:
embeddings.append(None)
missed_indices.append(i)
return embeddings, missed_indices
# ==================== Rerank Cache 方法 ====================
@staticmethod
def _make_rerank_key(query: str, doc_ids: List[str]) -> str:
sorted_ids = sorted(doc_ids)
return hashlib.md5(
f"rerank:{query}:{':'.join(sorted_ids)}".encode()
).hexdigest()
def get_rerank_scores(self, query: str, doc_ids: List[str]) -> Optional[Dict[str, float]]:
"""获取 Rerank 分数缓存
返回 {doc_id: score} 映射(而非位置列表),调用方按当前 doc_ids 顺序查表,
避免同一组文档以不同顺序返回时分数错位。
"""
key = self._make_rerank_key(query, doc_ids)
return self.rerank_cache.get(key)
def set_rerank_scores(self, query: str, doc_ids: List[str],
scores: List[float]) -> None:
"""设置 Rerank 分数缓存
{doc_id: score} 映射存储,保证顺序无关的正确性。
"""
key = self._make_rerank_key(query, doc_ids)
score_map = {doc_id: float(s) for doc_id, s in zip(doc_ids, scores)}
self.rerank_cache.set(key, score_map)
# ==================== 统计方法 ====================
def get_all_stats(self) -> Dict[str, CacheStats]:
"""获取所有缓存的统计信息"""
return {
"query_cache": self.query_cache.get_stats(),
"embedding_cache": self.embedding_cache.get_stats(),
"rerank_cache": self.rerank_cache.get_stats()
}
def clear_all(self) -> None:
"""清空所有缓存"""
self.query_cache.clear()
self.embedding_cache.clear()
self.rerank_cache.clear()
logger.info("所有缓存已清空")
# ==================== 全局缓存实例 ====================
_cache_manager: Optional[RAGCacheManager] = None
_cache_lock = threading.Lock()
def get_cache_manager() -> RAGCacheManager:
"""获取全局缓存管理器实例(单例模式)"""
global _cache_manager
if _cache_manager is None:
with _cache_lock:
if _cache_manager is None:
# 尝试从配置加载参数
try:
from config import (
QUERY_CACHE_SIZE, QUERY_CACHE_TTL,
EMBEDDING_CACHE_SIZE, EMBEDDING_CACHE_TTL,
RERANK_CACHE_SIZE, RERANK_CACHE_TTL
)
_cache_manager = RAGCacheManager(
query_cache_size=QUERY_CACHE_SIZE,
query_cache_ttl=QUERY_CACHE_TTL,
embedding_cache_size=EMBEDDING_CACHE_SIZE,
embedding_cache_ttl=EMBEDDING_CACHE_TTL,
rerank_cache_size=RERANK_CACHE_SIZE,
rerank_cache_ttl=RERANK_CACHE_TTL
)
except ImportError:
# 使用默认配置
_cache_manager = RAGCacheManager()
return _cache_manager
def reset_cache_manager() -> None:
"""重置全局缓存管理器(主要用于测试)"""
global _cache_manager
with _cache_lock:
if _cache_manager is not None:
_cache_manager.clear_all()
_cache_manager = None
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("缓存模块测试")
print("=" * 60)
cache = RAGCacheManager()
# 测试 Query Cache
print("\n1. Query Cache 测试")
cache.set_query_result("什么是Python?", "public_kb", {"answer": "Python是一种编程语言"})
result = cache.get_query_result("什么是Python?", "public_kb")
print(f" 缓存命中: {result}")
# 测试版本号失效
print("\n2. 版本号失效测试")
cache.increment_kb_version("public_kb")
result = cache.get_query_result("什么是Python?", "public_kb")
print(f" 版本更新后缓存失效: {result is None}")
# 测试 Embedding Cache
print("\n3. Embedding Cache 测试")
cache.set_embedding("测试文本", [0.1, 0.2, 0.3])
emb = cache.get_embedding("测试文本")
print(f" Embedding 缓存: {emb}")
# 测试统计
print("\n4. 缓存统计")
stats = cache.get_all_stats()
for name, stat in stats.items():
print(f" {name}: hits={stat.hits}, misses={stat.misses}, hit_rate={stat.hit_rate:.2%}")

258
core/chunker.py Normal file
View File

@@ -0,0 +1,258 @@
# -*- coding: utf-8 -*-
"""
文本分块模块
提供带硬性上限的分块函数,确保切片不会超过 max_length。
核心特性:
- 基于 LangChain RecursiveCharacterTextSplitter
- Markdown 结构感知分块
- 硬性上限保护max_length=1200
- 最小切片约束min_length=200
- 相邻切片合并(过短切片)
"""
from typing import List
import re
def split_text_with_limit(
text: str,
chunk_size: int = 1000,
overlap: int = 100,
max_length: int = 1200,
min_length: int = 200
) -> List[str]:
"""
带硬性上限和下限的分块函数
确保切片不会超过 max_length且不会低于 min_length尝试合并
Args:
text: 待分块文本
chunk_size: 目标分块大小
overlap: 分块重叠字符数
max_length: 硬性上限
min_length: 硬性下限(过短则尝试合并)
Returns:
分块列表
"""
if not text or not text.strip():
return []
try:
from langchain_text_splitters import RecursiveCharacterTextSplitter
except ImportError:
raise ImportError("请安装 langchain-text-splitters: pip install langchain-text-splitters")
# Markdown 分隔符优先级
separators = [
"\n#{1,6} ", # 标题
"\n```\n", # 代码块
"\n|", # 表格
"\n\n", # 段落
"\n", # 行
" ", # 词
"" # 字符
]
splitter = RecursiveCharacterTextSplitter(
separators=separators,
chunk_size=chunk_size,
chunk_overlap=overlap,
length_function=len,
keep_separator=True
)
chunks = splitter.split_text(text)
# 第一轮:硬性上限保护
result = []
for chunk in chunks:
if len(chunk) > max_length:
# 尝试在句子边界截断
last_boundary = max(
chunk.rfind('', 0, max_length),
chunk.rfind('', 0, max_length),
chunk.rfind('', 0, max_length),
chunk.rfind('.', 0, max_length),
chunk.rfind('\n', 0, max_length)
)
if last_boundary > max_length // 2:
result.append(chunk[:last_boundary + 1])
else:
result.append(chunk[:max_length])
else:
result.append(chunk)
# 第二轮:合并过短的切片
result = merge_short_chunks(result, min_length, max_length)
return result
def merge_short_chunks(chunks: List[str], min_length: int, max_length: int) -> List[str]:
"""
合并过短的相邻切片
Args:
chunks: 原始切片列表
min_length: 最小长度
max_length: 最大长度
Returns:
合并后的切片列表
"""
if not chunks or min_length <= 0:
return chunks
result = []
i = 0
while i < len(chunks):
current = chunks[i]
# 如果当前切片过短,尝试与下一个合并
while len(current) < min_length and i + 1 < len(chunks):
next_chunk = chunks[i + 1]
merged = current + "\n" + next_chunk
# 检查合并后是否超过上限
if len(merged) <= max_length:
current = merged
i += 1
else:
# 合并后超限,停止合并
break
result.append(current)
i += 1
return result
def filter_chunks_by_section(
chunks: List[dict],
query: str,
section_keywords: List[str] = None
) -> List[dict]:
"""
根据查询中的章节信息过滤切片
Args:
chunks: 切片列表,每个切片需包含 metadata
query: 用户查询
section_keywords: 章节关键词列表
Returns:
过滤后的切片列表
"""
if not section_keywords:
section_keywords = [
"第一章", "第二章", "第三章", "第四章", "第五章",
"第1章", "第2章", "第3章", "第4章", "第5章",
"一、", "二、", "三、", "四、", "五、",
"1.", "2.", "3.", "4.", "5."
]
# 从查询中提取章节关键词
mentioned_sections = []
for keyword in section_keywords:
if keyword in query:
mentioned_sections.append(keyword)
if not mentioned_sections:
return chunks
# 过滤切片
result = []
for chunk in chunks:
metadata = chunk.get('metadata', chunk)
section_path = metadata.get('section', metadata.get('section_path', ''))
# 检查是否匹配任一章节
for section in mentioned_sections:
if section in section_path or section in chunk.get('content', ''):
result.append(chunk)
break
# 如果过滤后结果为空,返回原始列表
return result if result else chunks
def extract_section_mention(query: str) -> str:
"""
从查询中提取章节提及
Args:
query: 用户查询
Returns:
提取的章节字符串,如 "第一章"
"""
patterns = [
r'第[一二三四五六七八九十\d]+章',
r'\s*\d+\s*章',
r'[一二三四五六七八九十]+、',
r'\d+\.',
]
for pattern in patterns:
match = re.search(pattern, query)
if match:
return match.group()
return ""
# 兼容性别名
split_text = split_text_with_limit
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
# 测试分块
test_text = """
一、适用范围
适用于全省地市公司货源投放工作所涉及的基础工作。
二、总体要求
货源投放是烟草营销的核心业务,总体要求是:
1.坚持市场导向、供需匹配;
2.坚持总量控制、稍紧平衡。
三、投放方法
主要有六种投放方法。
"""
print("=" * 60)
print("分块测试")
print("=" * 60)
chunks = split_text_with_limit(test_text, chunk_size=100, min_length=50)
for i, chunk in enumerate(chunks, 1):
print(f"\n[{i}] (len={len(chunk)})")
print(chunk.strip()[:100])
print("\n" + "=" * 60)
print("章节过滤测试")
print("=" * 60)
test_chunks = [
{"content": "内容1", "metadata": {"section": "一、适用范围"}},
{"content": "内容2", "metadata": {"section": "二、总体要求"}},
{"content": "内容3", "metadata": {"section": "三、投放方法"}},
]
filtered = filter_chunks_by_section(test_chunks, "适用范围是什么?")
print(f"查询: '适用范围是什么?'")
print(f"匹配结果: {[c['metadata']['section'] for c in filtered]}")

367
core/confidence_gate.py Normal file
View File

@@ -0,0 +1,367 @@
"""
置信度门控模块
基于 Reranker 分数判断检索结果质量,低于阈值则拦截并触发补救流程。
核心功能:
1. 使用 Reranker 计算检索结果的置信度分数
2. 根据阈值判断结果质量
3. 决定是继续生成还是触发补救流程
使用方式:
from core.confidence_gate import ConfidenceGate, create_gate
gate = create_gate()
result = gate.evaluate(query, documents)
if result.action == GateAction.REWRITE:
# 触发查询重写或网络搜索
...
"""
from dataclasses import dataclass
from typing import List, Optional
from enum import Enum
import logging
logger = logging.getLogger(__name__)
class GateAction(Enum):
"""门控动作"""
PASS = "pass" # 通过,继续生成
REWRITE = "rewrite" # 需要查询重写
WEB_SEARCH = "web_search" # 触发网络搜索
FALLBACK = "fallback" # 降级处理(无结果)
@dataclass
class GateResult:
"""门控结果"""
action: GateAction
confidence: float # 综合置信度
top_score: float # Top-1 分数
avg_score: float # Top-3 平均分数
reason: str # 决策原因
suggested_action: str # 建议的后续动作
scores: List[float] = None # 所有分数
class ConfidenceGate:
"""
置信度门控器
基于 Reranker 分数判断检索结果质量,决定是否继续生成或触发补救。
阈值设计(基于 Agentic RAG 优化报告):
- PASS_THRESHOLD = 0.35: 通过阈值,低于此值需要补救
- GOOD_THRESHOLD = 0.5: 良好阈值,高质量结果
- EXCELLENT_THRESHOLD = 0.7: 优秀阈值,可直接生成
"""
# 关键阈值
# 2026-04-15: PASS_THRESHOLD 从 0.35 降低到 0.2,减少误判导致补救流程
PASS_THRESHOLD = 0.2 # 通过阈值(降低以减少误判)
GOOD_THRESHOLD = 0.4 # 良好阈值(从 0.5 降低)
EXCELLENT_THRESHOLD = 0.7 # 优秀阈值
def __init__(self, reranker=None):
"""
初始化门控器
Args:
reranker: CrossEncoder 重排序模型
"""
self.reranker = reranker
def evaluate(self, query: str, documents: List[str],
metadatas: List[dict] = None,
precomputed_scores: List[float] = None) -> GateResult:
"""
评估检索结果质量
Args:
query: 用户查询
documents: 检索到的文档列表
metadatas: 文档元数据(可选,用于更精确评估)
precomputed_scores: 预计算的 Rerank 分数(可选,避免重复推理)
如果主检索管线已执行 Rerank可直接传入分数
Returns:
GateResult: 门控决策结果
"""
# 无结果情况
if not documents:
return GateResult(
action=GateAction.FALLBACK,
confidence=0.0,
top_score=0.0,
avg_score=0.0,
reason="无检索结果",
suggested_action="尝试网络搜索或告知用户无相关信息"
)
# 使用预计算分数或重新计算
if precomputed_scores is not None:
scores = precomputed_scores
logger.debug(f"置信度门控: 复用预计算 Rerank 分数 (跳过重复推理)")
else:
scores = self._compute_scores(query, documents)
top_score = max(scores) if scores else 0.0
avg_score = sum(scores[:3]) / min(3, len(scores)) if len(scores) >= 1 else 0.0
# 决策逻辑
if top_score >= self.GOOD_THRESHOLD:
# 高置信度,直接通过
return GateResult(
action=GateAction.PASS,
confidence=top_score,
top_score=top_score,
avg_score=avg_score,
reason=f"检索结果置信度高 ({top_score:.3f} >= {self.GOOD_THRESHOLD}),可直接生成回答",
suggested_action="继续生成答案",
scores=scores
)
elif top_score >= self.PASS_THRESHOLD:
# 中等置信度,可以通过但建议关注
return GateResult(
action=GateAction.PASS,
confidence=top_score,
top_score=top_score,
avg_score=avg_score,
reason=f"检索结果置信度中等 ({top_score:.3f}),可能需要补充信息",
suggested_action="继续生成答案,但需标注不确定性",
scores=scores
)
else:
# 低置信度,需要补救
# 判断是触发查询重写还是网络搜索
if avg_score < self.PASS_THRESHOLD:
# 平均分也很低,直接网络搜索
return GateResult(
action=GateAction.WEB_SEARCH,
confidence=top_score,
top_score=top_score,
avg_score=avg_score,
reason=f"Top-1 置信度 {top_score:.3f} 低于阈值 {self.PASS_THRESHOLD},平均置信度 {avg_score:.3f} 也很低",
suggested_action="触发网络搜索作为补充",
scores=scores
)
else:
# 尝试查询重写
return GateResult(
action=GateAction.REWRITE,
confidence=top_score,
top_score=top_score,
avg_score=avg_score,
reason=f"Top-1 置信度 {top_score:.3f} 低于阈值 {self.PASS_THRESHOLD},尝试查询重写",
suggested_action="触发查询重写或补充检索",
scores=scores
)
def _compute_scores(self, query: str, documents: List[str]) -> List[float]:
"""
计算 Reranker 分数
Args:
query: 用户查询
documents: 文档列表
Returns:
分数列表
"""
if not self.reranker:
# 无 Reranker使用向量相似度降级
return self._vector_similarity_fallback(query, documents)
try:
import numpy as np
pairs = [(query, doc) for doc in documents]
scores = self.reranker.predict(pairs)
# 确保返回 float 列表
return [float(s) for s in scores]
except Exception as e:
logger.warning(f"Reranker 计算失败: {e}")
return self._vector_similarity_fallback(query, documents)
def _vector_similarity_fallback(self, query: str, documents: List[str]) -> List[float]:
"""
向量相似度降级方案
当 Reranker 不可用时,使用向量相似度计算置信度。
比关键词匹配更可靠。
Args:
query: 用户查询
documents: 文档列表
Returns:
分数列表(归一化到 0-1 范围)
"""
try:
import numpy as np
from core.engine import get_engine
engine = get_engine()
if not engine or not engine.embedding_model:
return self._keyword_fallback(query, documents)
# 计算查询向量
query_vec = np.array(engine.embedding_model.encode(query))
query_norm = np.linalg.norm(query_vec)
if query_norm == 0:
return self._keyword_fallback(query, documents)
scores = []
for doc in documents:
# 计算文档向量
doc_vec = np.array(engine.embedding_model.encode(doc))
doc_norm = np.linalg.norm(doc_vec)
# 余弦相似度
if doc_norm > 0:
similarity = np.dot(query_vec, doc_vec) / (query_norm * doc_norm)
else:
similarity = 0.0
# 归一化到 0-1 范围(余弦相似度在 -1 到 1
normalized_score = (similarity + 1) / 2
scores.append(float(normalized_score))
return scores
except Exception as e:
logger.warning(f"向量相似度降级失败: {e}")
return self._keyword_fallback(query, documents)
def _keyword_fallback(self, query: str, documents: List[str]) -> List[float]:
"""
关键词匹配降级方案
当 Reranker 不可用时,使用关键词匹配作为降级方案。
返回归一化到 0-1 范围的分数。
"""
try:
import jieba
except ImportError:
# jieba 不可用,返回中等分数
return [0.5] * len(documents)
# 提取查询关键词
query_words = set()
for word in jieba.cut(query):
word = word.strip()
if len(word) >= 2:
query_words.add(word.lower())
if not query_words:
return [0.5] * len(documents)
scores = []
for doc in documents:
doc_lower = doc.lower()
matched = sum(1 for word in query_words if word in doc_lower)
# 归一化到 0-1
score = matched / len(query_words)
# 映射到类似 Reranker 的范围(关键词匹配通常分数较低,需要放大)
score = min(score * 0.8, 1.0)
scores.append(score)
return scores
def get_threshold_info(self) -> dict:
"""获取阈值信息"""
return {
"pass_threshold": self.PASS_THRESHOLD,
"good_threshold": self.GOOD_THRESHOLD,
"excellent_threshold": self.EXCELLENT_THRESHOLD,
"has_reranker": self.reranker is not None
}
def create_gate() -> ConfidenceGate:
"""
创建门控器实例
自动从 RAG Engine 获取 Reranker 模型。
Returns:
ConfidenceGate: 门控器实例
"""
try:
from core.engine import get_engine
engine = get_engine()
return ConfidenceGate(reranker=engine.reranker)
except Exception as e:
logger.warning(f"创建门控器失败,使用降级模式: {e}")
return ConfidenceGate(reranker=None)
# ==================== 便捷函数 ====================
def check_confidence(query: str, documents: List[str]) -> GateResult:
"""
便捷函数:检查检索结果置信度
Args:
query: 用户查询
documents: 检索到的文档列表
Returns:
GateResult: 门控决策结果
"""
gate = create_gate()
return gate.evaluate(query, documents)
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("置信度门控测试")
print("=" * 60)
# 测试用例
test_cases = [
# (query, documents, expected_action)
(
"公司报销制度是怎样的?",
["报销制度规定员工可以报销差旅费用,需提供发票...", "根据公司规定报销需在30天内提交..."],
GateAction.PASS # 应该高分通过
),
(
"宇宙的终极答案是什么?",
["文档中提到了一些技术细节...", "另一个不相关的内容..."],
GateAction.REWRITE # 低置信度,需要补救
),
(
"测试空结果",
[],
GateAction.FALLBACK # 无结果
),
]
gate = ConfidenceGate() # 不使用 Reranker 的测试
print(f"\n阈值配置: {gate.get_threshold_info()}")
print()
for i, (query, docs, expected) in enumerate(test_cases, 1):
result = gate.evaluate(query, docs)
status = "[OK]" if result.action == expected else "[WARN]"
print(f"测试 {i}: {status}")
print(f" 查询: {query}")
print(f" 文档数: {len(docs)}")
print(f" 动作: {result.action.value}")
print(f" 置信度: {result.confidence:.3f}")
print(f" Top分数: {result.top_score:.3f}")
print(f" 原因: {result.reason}")
print()

35
core/constants.py Normal file
View File

@@ -0,0 +1,35 @@
"""
公共常量定义
统一管理 RAG 系统中的常量值,避免重复定义。
"""
from copy import deepcopy
# ==================== 检索结果常量 ====================
# 空检索结果模板(不要直接返回,使用 get_empty_result()
_EMPTY_RESULT_TEMPLATE = {
'ids': [[]],
'documents': [[]],
'metadatas': [[]],
'distances': [[]]
}
# 空检索结果模板(无嵌套列表形式)
_EMPTY_RESULT_FLAT_TEMPLATE = {
'ids': [],
'documents': [],
'metadatas': [],
'distances': []
}
def get_empty_result() -> dict:
"""返回空检索结果的深拷贝,避免副作用"""
return deepcopy(_EMPTY_RESULT_TEMPLATE)
def get_empty_result_flat() -> dict:
"""返回扁平空检索结果的深拷贝"""
return deepcopy(_EMPTY_RESULT_FLAT_TEMPLATE)

2097
core/engine.py Normal file

File diff suppressed because it is too large Load Diff

584
core/intent_analyzer.py Normal file
View File

@@ -0,0 +1,584 @@
"""
意图分析器 - 改写 + 双层判断
核心功能:
1. 问题改写:指代消解、省略补全
2. 双层判断:
- 历史是否可答
- 是否需要外部知识
3. 输出决策use_context / need_retrieval
4. 语义缓存:相似问题复用结果
设计原则:
- 使用轻量 LLM 完成改写和判断
- 替代硬编码规则,更灵活可维护
- 语义缓存减少 LLM 调用
"""
import json
import logging
import hashlib
from dataclasses import dataclass, asdict
from typing import List, Dict, Optional, Tuple
from config import INTENT_TEMPERATURE, INTENT_MAX_TOKENS, INTENT_HISTORY_WINDOW, INTENT_MODEL
logger = logging.getLogger(__name__)
@dataclass
class IntentAnalysis:
"""意图分析结果"""
rewritten_query: str # 改写后的完整问题
use_context: bool # 是否使用上下文回答
need_retrieval: bool # 是否需要检索
confidence: float # 置信度
reason: str # 判断理由
context_images: List[dict] # 上下文中的图片信息
sub_queries: List[str] # 用于检索的子查询列表
intent: str # 意图类型: factual/comparison/reasoning/instruction/other
def to_dict(self) -> dict:
"""转换为字典(用于缓存)"""
return {
"rewritten_query": self.rewritten_query,
"use_context": self.use_context,
"need_retrieval": self.need_retrieval,
"confidence": self.confidence,
"reason": self.reason,
"context_images": self.context_images,
"sub_queries": self.sub_queries,
"intent": self.intent
}
@classmethod
def from_dict(cls, data: dict) -> "IntentAnalysis":
"""从字典创建(用于缓存读取)"""
return cls(
rewritten_query=data.get("rewritten_query", ""),
use_context=data.get("use_context", False),
need_retrieval=data.get("need_retrieval", True),
confidence=data.get("confidence", 0.5),
reason=data.get("reason", ""),
context_images=data.get("context_images", []),
sub_queries=data.get("sub_queries", []),
intent=data.get("intent", "factual")
)
class IntentAnalyzer:
"""
意图分析器
使用 LLM 一次调用完成:
1. 问题改写(指代消解)
2. 意图判断(是否需要检索)
"""
SYSTEM_PROMPT = """你是一个意图分析助手。你的任务是分析用户问题,判断它需要什么类型的回答。
## 任务说明
根据对话历史和当前用户消息,输出一个 JSON 对象,包含以下字段:
1. **rewritten_query**: 改写后的完整问题
- 如果问题包含指代(如"这两张图片""继续说"),将其改写为完整、独立的问题
- 例如:"分析一下这两张图片""分析一下对话历史中提到的图片"
- 如果问题本身已经完整,直接返回原文
2. **use_context**: 布尔值
- true: 问题依赖历史对话中的信息,答案已经在历史回答中
- false: 问题是新问题,需要新的回答
3. **need_retrieval**: 布尔值
- true: 需要从知识库检索信息才能回答
- false: 可以直接使用历史上下文或自己的知识回答
4. **reason**: 简短说明判断理由
5. **sub_queries**: 用于检索的子查询列表
- 对比类intent="comparison"生成最多3个子查询
* 关于A的一个最佳检索查询
* 关于B的一个最佳检索查询
* 直接对比A和B的检索查询
- 推理类intent="reasoning"生成最多2个子查询
* 原问题的检索查询
* 一个补充角度的检索查询(如原因、背景、影响等),帮助获取更全面的上下文
- 其他类factual/instruction/other严格只生成1个子查询原问题
- 不要为同一实体生成语义重叠的查询
- 子查询应保持原问题的关键词长度20-60字符为宜
6. **intent**: 意图分类5种类型请严格按以下定义判断
**"comparison"(对比查询)** —— 明确对比两个或多个事物的差异
- 关键特征:问题中出现两个以上被比较的对象,且问的是它们之间的差异、异同或优劣
- 关键词区别、不同、差异、对比、相比、各自区别、A和B有什么不同、哪个更好
- ⚠️ 注意:问"如何定义和区分"不是在对比,是在问定义标准 → factual
- ⚠️ 注意:"各自有什么"如果只是分别描述而非对比差异 → factual
**"reasoning"(推理查询)** —— 需要因果分析、原因推断或影响评估
- 关键特征:问题要求分析"为什么"、推导因果关系、或评估某事的影响/后果
- 关键词:为什么、原因、导致、影响、后果、为何、怎样导致、如何影响、分析原因
**"instruction"(操作指导)** —— 询问具体操作步骤、执行方法或实施流程
- 关键特征:问题要求的是"怎么做某事"——需要具体的步骤、方法或可执行的指导
- 关键词:怎么做、如何操作、如何实施、步骤是什么、操作流程、配置方法、如何申请、如何办理、升级路径、需要满足哪些条件
- ⚠️ 注意:问"某机制是怎样的"如果涉及评价标准和执行流程 → instruction
**"factual"(事实查询)** —— 查询事实、定义、标准、规定、数据、枚举项
- 关键特征:问题要求的是文档中明确记载的事实信息
- 关键词:是什么、有哪些、包括什么、多少、规定、标准、要求、内容、原则、办法、类型、种类、评分标准、占比限制、时限
- 这是最常见的类型——问"某东西包含哪些内容/原则/办法/标准"都属于事实查询
**"other"(其他)** —— 闲聊、问候、不属于以上类型的问题
## intent 判断流程(按优先级依次检查)
1. 问题是否在对比两个事物的差异?→ comparison
2. 问题是否在问"为什么"或分析原因/影响?→ reasoning
3. 问题是否在问具体怎么做、操作步骤、申请流程、升级路径?→ instruction
4. 以上都不是 → factual大多数知识库查询属于此类
### 常见易混淆情况:
- "如何定义和区分" → factual问的是定义和标准不是对比差异
- "有哪些原则/办法/标准" → factual枚举事实不是操作指导
- "如何优化/如何实施" → instruction问的是具体做法和步骤
- "评价标准和退出机制是怎样的" → instruction涉及评价流程和退出流程
- "升级路径是什么,需要满足哪些条件" → instruction涉及升级步骤和条件
- "A和B有什么区别/不同" → comparison明确对比两者差异
- "分别包含什么/各自含义" → factual分别描述不是对比差异
## 判断原则(两个判断是独立的!)
### use_context = true 的情况:
- 用户引用了历史对话中的内容("这两张图片""上面的数据""继续说"
- 问题是对历史回答的追问或深入询问
- 答案可以从历史回答中直接得出
- **重复提问**:用户再次问相同问题
### need_retrieval = true 的情况:
- 问题询问新的事实、数据、流程
- 问题提到了具体的图号、表号、章节
- 问题需要知识库中的文档来回答
- 用户明确要求查看知识库内容
## ⚠️ 重要规则:重复提问必须检索!
**当用户重复提问相同或相似问题时:**
- `use_context` = true因为确实依赖历史上下文
- `need_retrieval` = **true**(必须重新检索!)
**原因:**
1. 用户重复提问说明之前的回答不满意或不正确
2. 历史回答可能包含错误信息,不能直接复用
3. 必须重新检索确保回答准确性
**判断重复提问的方法:**
- 当前问题与历史中的用户问题语义相同或高度相似
- 例如:用户之前问"发电量",现在又问"发电量统计"
- 例如:用户之前问过某问题,现在换个方式再问
### 两者都可以为 false
- 简单的闲聊、问候
- 基于附件/图片的视觉分析请求(图片信息在上下文中)
- 常识性问题
## 输出格式
只输出一个 JSON 对象,不要其他内容:
```json
{
"rewritten_query": "改写后的问题",
"use_context": true/false,
"need_retrieval": true/false,
"reason": "判断理由",
"sub_queries": ["检索子查询1", "检索子查询2"],
"intent": "factual"
}
```
示例:
- 对比类:"新现代终端和普通现代终端有什么区别?" → sub_queries: ["新现代终端的定义和特点", "普通现代终端的定义和特点", "新现代终端与普通现代终端的区别"], intent: "comparison"
- 推理类:"为什么市场状态评估结果与实际投放不一致?" → sub_queries: ["市场状态评估结果与实际投放不一致的原因"], intent: "reasoning"
- 操作指导:"从普通终端升级到加盟终端需要怎么做?" → sub_queries: ["从普通终端升级到加盟终端的流程和条件"], intent: "instruction"
- 事实类(枚举):"货源投放工作有哪些原则?" → sub_queries: ["货源投放工作的原则"], intent: "factual"
- 事实类(定义):"市场状态有哪几种类型?" → sub_queries: ["市场状态的类型和评分标准"], intent: "factual"
"""
def __init__(self, model: str = None, use_cache: bool = True):
"""
初始化意图分析器
Args:
model: 使用的 LLM 模型(默认从配置读取)
use_cache: 是否启用语义缓存
"""
self.model = model
self.use_cache = use_cache
self._client = None
self._cache = None
self._embedding_model = None
# 精确匹配缓存(后备方案,不依赖 embedding
self._exact_cache: Dict[str, IntentAnalysis] = {}
self._exact_cache_max = 500
def _get_client(self):
"""获取 LLM 客户端"""
if self._client is None:
from config import get_llm_client
self._client = get_llm_client()
return self._client
def _get_cache(self):
"""获取语义缓存"""
if self._cache is None and self.use_cache:
try:
from core.semantic_cache import get_semantic_cache
self._cache = get_semantic_cache()
except Exception as e:
logger.warning(f"语义缓存初始化失败: {e}")
self._cache = None
return self._cache
def _get_embedding(self, text: str):
"""获取文本向量,优先复用 engine 已加载的模型"""
if self._embedding_model is None:
# 优先从 engine 复用已加载的 embedding 模型
try:
from core.engine import get_engine
engine = get_engine()
self._embedding_model = engine.embedding_model
if self._embedding_model is not None:
logger.info("意图分析缓存: 复用 engine 的 embedding 模型")
else:
logger.warning("意图分析缓存: engine.embedding_model 为 None")
except Exception as e:
# fallback: 单独加载
logger.warning(f"意图分析缓存: 无法获取 engine 的 embedding 模型: {e},尝试单独加载")
try:
from sentence_transformers import SentenceTransformer
self._embedding_model = SentenceTransformer('models/bge-base-zh-v1.5')
logger.info("意图分析缓存: 单独加载 embedding 模型成功")
except Exception as e2:
logger.warning(f"意图分析缓存: 嵌入模型初始化失败: {e2}")
return None
try:
import numpy as np
emb = self._embedding_model.encode(text)
return np.array(emb, dtype='float32')
except Exception as e:
logger.warning(f"向量化失败: {e}")
return None
def analyze(
self,
query: str,
history: List[dict],
context_images: List[dict] = None
) -> IntentAnalysis:
"""
分析用户意图
Args:
query: 用户当前问题
history: 对话历史 [{"role": "user/assistant", "content": "...", "metadata": {...}}]
context_images: 上下文中的图片信息
Returns:
IntentAnalysis: 分析结果
"""
# 构建历史摘要
history_summary = self._build_history_summary(history, context_images)
# 1. 精确匹配缓存(快速路径,不依赖 embedding
exact_key = self._build_cache_key(query, history)
if exact_key in self._exact_cache:
logger.info(f"意图分析精确缓存命中: {exact_key[:50]}")
return self._exact_cache[exact_key]
# 2. 尝试从语义缓存获取
cache = self._get_cache()
if cache:
# 使用 query + 历史关键信息作为缓存键
cache_key = self._build_cache_key(query, history)
cache_emb = self._get_embedding(cache_key)
if cache_emb is not None:
cached = cache.get(cache_emb)
if cached:
logger.info(f"意图分析缓存命中: {cached.get('reason', '')[:50]}")
return IntentAnalysis.from_dict(cached)
else:
logger.debug(f"意图分析缓存未命中,缓存状态: {cache.get_stats()}")
else:
logger.warning("意图分析缓存: _get_embedding 返回 None跳过缓存")
else:
logger.warning("意图分析缓存: _get_cache 返回 None缓存不可用")
# 构建 prompt
user_prompt = f"""## 对话历史
{history_summary}
## 当前用户消息
{query}
请分析用户的意图,输出 JSON 对象。"""
try:
client = self._get_client()
model = self.model or self._get_default_model()
from core.llm_utils import call_llm
content = call_llm(
client,
"",
model,
temperature=INTENT_TEMPERATURE,
max_tokens=INTENT_MAX_TOKENS,
messages=[
{"role": "system", "content": self.SYSTEM_PROMPT},
{"role": "user", "content": user_prompt}
]
)
# 解析 JSON
result = self._parse_json(content)
if result:
# 提取子查询,默认使用原始查询
sub_queries = result.get("sub_queries", [query])
if not sub_queries:
sub_queries = [query]
# 非对比类查询强制截断子查询LLM 可能不遵守规则)
intent_type = result.get("intent", "factual")
if intent_type == "comparison":
# 对比类:确保原始查询始终作为第一个子查询(避免拆分后丢失核心信息)
if query not in sub_queries:
sub_queries = [query] + sub_queries
sub_queries = sub_queries[:3] # 最多3个
elif intent_type == "reasoning":
sub_queries = sub_queries[:2] # 推理类最多2个子查询
elif len(sub_queries) > 1:
sub_queries = sub_queries[:1] # 其他类只保留1个
analysis = IntentAnalysis(
rewritten_query=result.get("rewritten_query", query),
use_context=result.get("use_context", False),
need_retrieval=result.get("need_retrieval", True),
confidence=0.9,
reason=result.get("reason", ""),
context_images=context_images or [],
sub_queries=sub_queries,
intent=intent_type
)
# 存入语义缓存
if cache and cache_emb is not None:
cache.set(cache_emb, analysis.to_dict())
# 存入精确匹配缓存
if len(self._exact_cache) < self._exact_cache_max:
self._exact_cache[exact_key] = analysis
return analysis
except Exception as e:
logger.warning(f"意图分析失败: {e},使用默认值")
# 默认值:需要检索
return IntentAnalysis(
rewritten_query=query,
use_context=False,
need_retrieval=True,
confidence=0.5,
reason="意图分析失败,默认走检索流程",
context_images=context_images or [],
sub_queries=[query],
intent="factual"
)
def _build_cache_key(self, query: str, history: List[dict]) -> str:
"""
构建缓存键
结合 query 和历史关键信息,确保相同上下文下的相同问题能命中缓存
"""
parts = [query]
# 添加最近历史的关键信息
if history:
for msg in history[-2:]: # 最近1轮
role = msg.get("role", "")
content = msg.get("content", "")[:100] # 截断
parts.append(f"{role[:1]}:{content}")
return " | ".join(parts)
def _build_history_summary(
self,
history: List[dict],
context_images: List[dict] = None
) -> str:
"""
构建历史摘要
Args:
history: 对话历史
context_images: 上下文中的图片
Returns:
历史摘要文本
"""
if not history:
return "(无历史对话)"
parts = []
# 提取最近 3 轮对话
recent_history = history[-INTENT_HISTORY_WINDOW:]
for msg in recent_history:
role = "用户" if msg.get("role") == "user" else "助手"
content = msg.get("content", "")
# 截断过长的内容
if len(content) > 500:
content = content[:500] + "..."
parts.append(f"{role}{content}")
# 提取图片信息
metadata = msg.get("metadata", {})
if isinstance(metadata, dict):
images = metadata.get("images", [])
if images:
for img in images[:3]:
if isinstance(img, dict):
desc = img.get("description", "")[:100]
img_type = img.get("type", "图片")
parts.append(f" └─ {img_type}: {desc}")
# 添加图片上下文
if context_images:
parts.append("\n【上下文中的图片】")
for img in context_images[:5]:
desc = img.get("description", "")[:100]
img_type = img.get("type", "图片")
parts.append(f" - {img_type}: {desc}")
return "\n".join(parts)
def _parse_json(self, content: str) -> Optional[dict]:
"""解析 JSON 响应"""
# 尝试直接解析
try:
return json.loads(content)
except json.JSONDecodeError:
pass
# 尝试提取 JSON 块
import re
json_match = re.search(r'\{[\s\S]*\}', content)
if json_match:
try:
return json.loads(json_match.group())
except json.JSONDecodeError:
pass
return None
def _get_default_model(self) -> str:
"""获取意图分析专用模型"""
try:
from config import INTENT_MODEL
return INTENT_MODEL
except (ImportError, NameError):
return INTENT_MODEL
# ==================== 便捷函数 ====================
_analyzer = None
def get_intent_analyzer() -> IntentAnalyzer:
"""获取意图分析器单例"""
global _analyzer
if _analyzer is None:
_analyzer = IntentAnalyzer()
return _analyzer
def analyze_intent(
query: str,
history: List[dict],
context_images: List[dict] = None
) -> IntentAnalysis:
"""
便捷函数:分析用户意图
Args:
query: 用户问题
history: 对话历史
context_images: 上下文中的图片
Returns:
IntentAnalysis: 分析结果
"""
analyzer = get_intent_analyzer()
return analyzer.analyze(query, history, context_images)
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
# 测试用例
test_cases = [
{
"query": "分析一下这两张图片",
"history": [
{"role": "user", "content": "三峡工程有什么图片"},
{"role": "assistant", "content": "我找到了三峡大坝的示意图...", "metadata": {"images": [{"type": "示意图", "description": "三峡大坝全景"}]}},
],
"expected": {"use_context": True, "need_retrieval": False}
},
{
"query": "三峡水库补水调度统计",
"history": [],
"expected": {"use_context": False, "need_retrieval": True}
},
{
"query": "继续说",
"history": [
{"role": "user", "content": "介绍一下三峡工程"},
{"role": "assistant", "content": "三峡工程是..."},
],
"expected": {"use_context": True, "need_retrieval": False}
},
]
print("=" * 60)
print("意图分析器测试")
print("=" * 60)
analyzer = IntentAnalyzer()
for i, case in enumerate(test_cases, 1):
print(f"\n--- 测试 {i} ---")
print(f"问题: {case['query']}")
result = analyzer.analyze(case["query"], case["history"])
print(f"改写后: {result.rewritten_query}")
print(f"use_context: {result.use_context}")
print(f"need_retrieval: {result.need_retrieval}")
print(f"理由: {result.reason}")
print(f"子查询: {result.sub_queries}")
print(f"意图: {result.intent}")

351
core/llm_budget.py Normal file
View File

@@ -0,0 +1,351 @@
# -*- coding: utf-8 -*-
"""
LLM 调用预算控制器
控制每次查询的 LLM 调用次数,防止过度消耗
功能:
- 每次查询最大调用次数限制
- 特定类型调用次数限制(如重写、反思)
- 调用统计与监控
"""
from dataclasses import dataclass, field
from typing import List, Optional, Dict
from enum import Enum
import time
import threading
import logging
logger = logging.getLogger(__name__)
class CallType(Enum):
"""LLM 调用类型"""
CLASSIFY = "classify" # 查询分类
REWRITE = "rewrite" # 查询重写
GENERATE = "generate" # 答案生成
REFLECT = "reflect" # 推理反思
DECOMPOSE = "decompose" # 查询分解
WEB_SEARCH = "web_search" # 网络搜索
@dataclass
class CallRecord:
"""调用记录"""
call_type: CallType
timestamp: float
tokens_used: int = 0
success: bool = True
description: str = ""
@dataclass
class BudgetConfig:
"""预算配置"""
max_calls_per_query: int = 2 # 每次查询最大调用次数
max_tokens_per_query: int = 8000 # 每次查询最大 token 数
max_rewrites: int = 1 # 最多重写次数
max_reflects: int = 1 # 最多反思次数
max_decomposes: int = 1 # 最多分解次数
class LLMBudgetController:
"""LLM 调用预算控制器"""
DEFAULT_CONFIG = BudgetConfig()
def __init__(self, config: BudgetConfig = None):
self.config = config or self.DEFAULT_CONFIG
self._current_query_calls: List[CallRecord] = []
self._lock = threading.Lock()
self._total_stats = {
"total_queries": 0,
"total_calls": 0,
"total_tokens": 0,
"budget_exceeded": 0
}
def start_query(self) -> None:
"""开始新查询(重置计数)"""
with self._lock:
self._current_query_calls.clear()
def can_call(self, call_type: CallType) -> bool:
"""
检查是否可以进行指定类型的调用
Args:
call_type: 调用类型
Returns:
是否允许调用
"""
with self._lock:
# 检查总调用次数
if len(self._current_query_calls) >= self.config.max_calls_per_query:
logger.debug(f"LLM 预算超限: 已调用 {len(self._current_query_calls)}")
return False
# 检查特定类型限制
type_count = sum(
1 for c in self._current_query_calls
if c.call_type == call_type
)
if call_type == CallType.REWRITE and type_count >= self.config.max_rewrites:
logger.debug(f"重写次数超限: 已重写 {type_count}")
return False
if call_type == CallType.REFLECT and type_count >= self.config.max_reflects:
logger.debug(f"反思次数超限: 已反思 {type_count}")
return False
if call_type == CallType.DECOMPOSE and type_count >= self.config.max_decomposes:
logger.debug(f"分解次数超限: 已分解 {type_count}")
return False
return True
def record_call(self, call_type: CallType, tokens_used: int = 0,
success: bool = True, description: str = "") -> CallRecord:
"""
记录一次调用
Args:
call_type: 调用类型
tokens_used: 使用的 token 数
success: 是否成功
description: 调用描述
Returns:
调用记录
"""
record = CallRecord(
call_type=call_type,
timestamp=time.time(),
tokens_used=tokens_used,
success=success,
description=description
)
with self._lock:
self._current_query_calls.append(record)
self._total_stats["total_calls"] += 1
self._total_stats["total_tokens"] += tokens_used
return record
def end_query(self) -> Dict:
"""
结束当前查询,返回统计信息
Returns:
本次查询的统计信息
"""
with self._lock:
stats = {
"calls": len(self._current_query_calls),
"tokens": sum(c.tokens_used for c in self._current_query_calls),
"call_types": {}
}
for call in self._current_query_calls:
type_name = call.call_type.value
stats["call_types"][type_name] = stats["call_types"].get(type_name, 0) + 1
if stats["calls"] >= self.config.max_calls_per_query:
self._total_stats["budget_exceeded"] += 1
self._total_stats["total_queries"] += 1
self._current_query_calls.clear()
return stats
def get_current_stats(self) -> Dict:
"""获取当前查询的调用统计"""
with self._lock:
return {
"total_calls": len(self._current_query_calls),
"max_calls": self.config.max_calls_per_query,
"remaining_calls": self.config.max_calls_per_query - len(self._current_query_calls),
"tokens_used": sum(c.tokens_used for c in self._current_query_calls),
"call_types": {
call.call_type.value: sum(1 for c in self._current_query_calls if c.call_type == call)
for call in CallType
}
}
def get_total_stats(self) -> Dict:
"""获取总体统计信息"""
with self._lock:
return {
**self._total_stats,
"avg_calls_per_query": (
self._total_stats["total_calls"] / self._total_stats["total_queries"]
if self._total_stats["total_queries"] > 0 else 0
),
"avg_tokens_per_query": (
self._total_stats["total_tokens"] / self._total_stats["total_queries"]
if self._total_stats["total_queries"] > 0 else 0
)
}
def reset_stats(self) -> None:
"""重置统计信息"""
with self._lock:
self._total_stats = {
"total_queries": 0,
"total_calls": 0,
"total_tokens": 0,
"budget_exceeded": 0
}
# ==================== 全局预算控制器 ====================
_budget_controller: Optional[LLMBudgetController] = None
_budget_lock = threading.Lock()
def get_budget_controller() -> LLMBudgetController:
"""获取全局预算控制器实例(单例模式)"""
global _budget_controller
if _budget_controller is None:
with _budget_lock:
if _budget_controller is None:
# 尝试从配置加载参数
try:
from config import MAX_LLM_CALLS_PER_QUERY, MAX_QUERY_REWRITES
config = BudgetConfig(
max_calls_per_query=MAX_LLM_CALLS_PER_QUERY,
max_rewrites=MAX_QUERY_REWRITES
)
_budget_controller = LLMBudgetController(config)
except ImportError:
_budget_controller = LLMBudgetController()
return _budget_controller
def reset_budget_controller() -> None:
"""重置全局预算控制器(主要用于测试)"""
global _budget_controller
with _budget_lock:
if _budget_controller is not None:
_budget_controller.reset_stats()
_budget_controller = None
# ==================== Agent 使用判断 ====================
def should_use_agent(query: str, query_type: str = None,
classified_result=None) -> bool:
"""
判断是否需要使用 Agent 流程
规则:
- META/SIMPLE/FILE_SPECIFIC: 不需要 Agent
- COMPARISON/PROCESS: 需要 Agent
- FACT: 根据复杂度判断
Args:
query: 用户查询
query_type: 查询类型字符串
classified_result: QueryClassifier.classify() 的返回结果
Returns:
是否需要 Agent 流程
"""
# 如果有分类结果,使用它
if classified_result is not None:
# 检查是否跳过 LLM
if hasattr(classified_result, 'skip_llm_decision') and classified_result.skip_llm_decision:
return False
# 获取查询类型
qt = classified_result.query_type.value if hasattr(classified_result.query_type, 'value') \
else str(classified_result.query_type)
# 不需要 Agent 的类型
if qt in ['META', 'SIMPLE', 'FILE_SPECIFIC', 'REALTIME']:
return False
# 需要 Agent 的类型
if qt in ['COMPARISON', 'PROCESS']:
return True
# FACT 类型:根据复杂度判断
if qt == 'FACT':
# 查询长度
if len(query) > 50:
return True
# 关键词数量
if hasattr(classified_result, 'keywords') and len(classified_result.keywords) > 4:
return True
return False
# 没有分类结果,使用简单规则
if query_type:
if query_type in ['META', 'SIMPLE', 'FILE_SPECIFIC']:
return False
if query_type in ['COMPARISON', 'PROCESS']:
return True
# 默认:简单查询(短查询)不走 Agent
return len(query) > 30
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("LLM 预算控制器测试")
print("=" * 60)
controller = LLMBudgetController(BudgetConfig(max_calls_per_query=3))
controller.start_query()
# 模拟调用
print("\n1. 检查调用限制")
print(f" can_call(REWRITE): {controller.can_call(CallType.REWRITE)}")
print(f" can_call(GENERATE): {controller.can_call(CallType.GENERATE)}")
# 记录调用
controller.record_call(CallType.REWRITE, tokens_used=500, description="查询重写")
controller.record_call(CallType.GENERATE, tokens_used=1500, description="答案生成")
print(f"\n2. 当前统计: {controller.get_current_stats()}")
# 再次检查
print(f"\n3. 再次检查已调用2次")
print(f" can_call(REWRITE): {controller.can_call(CallType.REWRITE)}") # 应该 False重写限制1次
print(f" can_call(GENERATE): {controller.can_call(CallType.GENERATE)}") # 应该 True
# 第三次调用后检查
controller.record_call(CallType.GENERATE, tokens_used=1000)
print(f"\n4. 调用达到上限后: {controller.can_call(CallType.GENERATE)}") # 应该 False
# 结束查询
stats = controller.end_query()
print(f"\n5. 本次查询统计: {stats}")
# Agent 判断测试
print("\n" + "=" * 60)
print("Agent 使用判断测试")
print("=" * 60)
test_cases = [
("什么是Python?", "SIMPLE"),
("比较 Python 和 Java 的区别", "COMPARISON"),
("请列出文档中的所有文件", "META"),
("如何部署这个服务?", "PROCESS"),
]
for query, qtype in test_cases:
result = should_use_agent(query, qtype)
print(f" '{query[:30]}...' [{qtype}] -> use_agent: {result}")

290
core/llm_utils.py Normal file
View File

@@ -0,0 +1,290 @@
"""
LLM 调用工具函数
统一封装 LLM 调用模式,减少代码重复。
"""
import json
import re
import logging
from typing import List, Optional, Union, Iterator, Callable
logger = logging.getLogger(__name__)
def call_llm(
client,
prompt: str,
model: str,
temperature: float = 0.3,
max_tokens: int = 1000,
messages: List[dict] = None,
stream: bool = False,
**kwargs
) -> Union[str, Iterator, None]:
"""
统一的 LLM 调用封装
Args:
client: OpenAI 客户端实例
prompt: 用户提示(当 messages 为 None 时使用)
model: 模型名称
temperature: 温度参数 (0-1)
max_tokens: 最大 token 数
messages: 完整消息列表(优先于 prompt
stream: 是否启用流式输出
**kwargs: 其他参数(如 response_format, tools 等)
Returns:
流式模式返回 stream 迭代器,否则返回响应内容字符串
调用失败返回 None
Example:
# 简单调用
response = call_llm(client, "你好", model, temperature=0.3)
# 使用消息列表
response = call_llm(client, "", model, messages=[
{"role": "system", "content": "你是助手"},
{"role": "user", "content": "你好"}
])
# 流式调用
for chunk in call_llm(client, prompt, model, stream=True):
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
"""
if messages is None:
messages = [{"role": "user", "content": prompt}]
try:
response = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
stream=stream,
**kwargs
)
if stream:
return response
content = response.choices[0].message.content
# 推理模型兼容content 为空时尝试从 reasoning_content 提取
if not content or not content.strip():
reasoning = getattr(response.choices[0].message, 'reasoning_content', None)
if reasoning and reasoning.strip():
# 从思维链中提取 JSON 块作为内容
json_match = re.search(r'\{[\s\S]*\}', reasoning)
if json_match:
logger.info("LLM: content为空从reasoning_content提取JSON")
return json_match.group().strip()
logger.warning("LLM 返回空 content可能需要增大 max_tokens")
return None
return content.strip()
except Exception as e:
logger.warning(f"LLM 调用失败: {e}")
return None
def call_llm_stream(
client,
prompt: str,
model: str,
temperature: float = 0.3,
max_tokens: int = 1000,
messages: List[dict] = None,
error_prefix: str = "[错误]",
**kwargs
) -> Iterator[str]:
"""
流式 LLM 调用(生成器封装)
自动处理流式响应,逐块 yield 文本内容。
Args:
client: OpenAI 客户端实例
prompt: 用户提示
model: 模型名称
temperature: 温度参数
max_tokens: 最大 token 数
messages: 完整消息列表
error_prefix: 错误时的前缀
**kwargs: 其他参数
Yields:
响应文本片段
Example:
for text in call_llm_stream(client, "讲个故事", model):
print(text, end="", flush=True)
"""
if messages is None:
messages = [{"role": "user", "content": prompt}]
try:
stream = client.chat.completions.create(
model=model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
stream=True,
**kwargs
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
yield chunk.choices[0].delta.content
except Exception as e:
logger.error(f"LLM 流式调用失败: {e}")
yield f"{error_prefix} 调用大模型失败: {str(e)}"
def call_llm_with_retry(
client,
prompt: str,
model: str,
max_retries: int = 3,
retry_delay: float = 1.0,
**kwargs
) -> Optional[str]:
"""
带重试的 LLM 调用
Args:
client: OpenAI 客户端实例
prompt: 用户提示
model: 模型名称
max_retries: 最大重试次数
retry_delay: 重试间隔(秒)
**kwargs: 传递给 call_llm 的其他参数
Returns:
响应内容,全部失败返回 None
"""
import time
for attempt in range(max_retries):
result = call_llm(client, prompt, model, **kwargs)
if result is not None:
return result
if attempt < max_retries - 1:
logger.debug(f"LLM 调用重试 {attempt + 1}/{max_retries}")
time.sleep(retry_delay)
logger.warning(f"LLM 调用失败,已重试 {max_retries}")
return None
def parse_json_from_response(content: str) -> Optional[dict]:
"""
从 LLM 响应中解析 JSON
自动处理 ```json 或 ``` 代码块格式
Args:
content: LLM 返回的原始内容
Returns:
解析后的字典,失败返回 None
"""
if not content:
return None
# 提取 JSON 代码块(支持 ```json 或 ```
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', content)
json_str = json_match.group(1) if json_match else content
try:
return json.loads(json_str.strip())
except (json.JSONDecodeError, TypeError, ValueError):
return None
def parse_json_list_from_response(content: str) -> Optional[List[dict]]:
"""
从 LLM 响应中解析 JSON 数组
Args:
content: LLM 返回的原始内容
Returns:
解析后的列表,失败返回 None
"""
if not content:
return None
# 提取 JSON 代码块
json_match = re.search(r'```(?:json)?\s*([\s\S]*?)\s*```', content)
json_str = json_match.group(1) if json_match else content
try:
result = json.loads(json_str.strip())
if isinstance(result, list):
return result
# 如果是 {"items": [...]} 格式,提取 items
if isinstance(result, dict):
for key in ['items', 'data', 'results', 'questions']:
if key in result and isinstance(result[key], list):
return result[key]
return None
except (json.JSONDecodeError, TypeError, ValueError):
return None
# ==================== 便捷函数 ====================
def quick_ask(
client,
prompt: str,
model: str,
temperature: float = 0.3
) -> str:
"""
快速提问(简化版)
Args:
client: OpenAI 客户端
prompt: 问题
model: 模型
temperature: 温度
Returns:
回答内容,失败返回空字符串
"""
result = call_llm(client, prompt, model, temperature=temperature)
return result or ""
def quick_yes_no(
client,
prompt: str,
model: str,
keywords: List[str] = None
) -> bool:
"""
快速是/否判断
Args:
client: OpenAI 客户端
prompt: 问题(需包含判断标准)
model: 模型
keywords: 判断为 True 的关键词(默认 ["", "需要", "yes", "true"]
Returns:
布尔判断结果
"""
if keywords is None:
keywords = ["", "需要", "yes", "true"]
result = call_llm(client, prompt, model, temperature=0, max_tokens=10)
if result is None:
return False
result_lower = result.lower()
return any(kw.lower() in result_lower for kw in keywords)

373
core/loop_guard.py Normal file
View File

@@ -0,0 +1,373 @@
"""
循环检索防护模块
防止 Agentic RAG 陷入无限检索循环,确保检索效率和质量。
核心机制:
1. 最大迭代数限制max_iterations=3
2. 置信度递增检查(每次迭代置信度必须提升)
3. 重复查询检测(避免重复检索相同内容)
4. 循环中断决策(判断是否应该停止迭代)
使用方式:
from core.loop_guard import LoopGuard, GuardDecision
guard = LoopGuard(max_iterations=3)
guard.record_iteration(query, confidence, results_count)
decision = guard.should_continue()
if decision == GuardDecision.STOP:
# 终止迭代
...
"""
from dataclasses import dataclass
from typing import List, Optional
from enum import Enum
import time
class GuardDecision(Enum):
"""循环防护决策"""
CONTINUE = "continue" # 继续迭代
STOP_MAX_ITER = "stop_max_iter" # 达到最大迭代数
STOP_NO_PROGRESS = "stop_no_progress" # 无进展(置信度未提升)
STOP_DUPLICATE = "stop_duplicate" # 重复查询
STOP_SUFFICIENT = "stop_sufficient" # 结果已足够
@dataclass
class IterationRecord:
"""迭代记录"""
iteration: int # 迭代次数
query: str # 查询内容
confidence: float # 置信度分数
results_count: int # 检索结果数量
timestamp: float # 时间戳
query_type: str # 查询类型(可选)
@dataclass
class GuardResult:
"""防护检查结果"""
decision: GuardDecision # 决策
reason: str # 原因
current_iteration: int # 当前迭代次数
confidence_trend: str # 置信度趋势("improving"/"stable"/"declining"
recommendation: str # 建议
class LoopGuard:
"""
循环检索防护器
监控迭代过程,防止无限循环和无效迭代。
"""
# 默认参数
DEFAULT_MAX_ITERATIONS = 3
MIN_CONFIDENCE_IMPROVEMENT = 0.05 # 最小置信度提升阈值
def __init__(self, max_iterations: int = None,
min_confidence_improvement: float = None):
"""
初始化防护器
Args:
max_iterations: 最大迭代次数
min_confidence_improvement: 最小置信度提升阈值
"""
self.max_iterations = max_iterations or self.DEFAULT_MAX_ITERATIONS
self.min_confidence_improvement = min_confidence_improvement or self.MIN_CONFIDENCE_IMPROVEMENT
# 迭代历史
self.iterations: List[IterationRecord] = []
self.query_history: List[str] = []
def record_iteration(self, query: str, confidence: float,
results_count: int, query_type: str = None) -> IterationRecord:
"""
记录一次迭代
Args:
query: 查询内容
confidence: 置信度分数
results_count: 检索结果数量
query_type: 查询类型
Returns:
IterationRecord: 迭代记录
"""
record = IterationRecord(
iteration=len(self.iterations) + 1,
query=query,
confidence=confidence,
results_count=results_count,
timestamp=time.time(),
query_type=query_type
)
self.iterations.append(record)
self.query_history.append(query.lower().strip())
return record
def should_continue(self, current_confidence: float = None) -> GuardResult:
"""
判断是否应该继续迭代
Args:
current_confidence: 当前置信度(可选,使用最近记录)
Returns:
GuardResult: 防护检查结果
"""
# 检查最大迭代数
if len(self.iterations) >= self.max_iterations:
return GuardResult(
decision=GuardDecision.STOP_MAX_ITER,
reason=f"已达到最大迭代次数 {self.max_iterations}",
current_iteration=len(self.iterations),
confidence_trend=self._get_confidence_trend(),
recommendation="使用当前结果生成答案"
)
# 检查是否有记录
if not self.iterations:
return GuardResult(
decision=GuardDecision.CONTINUE,
reason="首次迭代",
current_iteration=0,
confidence_trend="unknown",
recommendation="继续执行首次检索"
)
# 获取当前置信度
if current_confidence is None:
current_confidence = self.iterations[-1].confidence
# 检查置信度是否足够高
if current_confidence >= 0.7: # 高置信度阈值
return GuardResult(
decision=GuardDecision.STOP_SUFFICIENT,
reason=f"置信度已足够高 ({current_confidence:.3f})",
current_iteration=len(self.iterations),
confidence_trend="good",
recommendation="结果质量良好,可以生成答案"
)
# 检查置信度趋势
trend = self._get_confidence_trend()
if trend == "declining":
return GuardResult(
decision=GuardDecision.STOP_NO_PROGRESS,
reason="置信度下降,继续迭代无益",
current_iteration=len(self.iterations),
confidence_trend=trend,
recommendation="停止迭代,使用最佳历史结果"
)
if trend == "stable" and len(self.iterations) >= 2:
# 检查是否有显著提升
recent_improvement = self._get_recent_improvement()
if recent_improvement < self.min_confidence_improvement:
return GuardResult(
decision=GuardDecision.STOP_NO_PROGRESS,
reason=f"置信度提升不明显 ({recent_improvement:.3f} < {self.min_confidence_improvement})",
current_iteration=len(self.iterations),
confidence_trend=trend,
recommendation="检索结果趋于稳定,停止迭代"
)
return GuardResult(
decision=GuardDecision.CONTINUE,
reason="迭代正常进行中",
current_iteration=len(self.iterations),
confidence_trend=trend,
recommendation="继续执行下一次检索"
)
def is_duplicate_query(self, query: str, similarity_threshold: float = 0.9) -> bool:
"""
检测是否为重复查询
Args:
query: 待检测查询
similarity_threshold: 相似度阈值
Returns:
bool: 是否为重复查询
"""
query_lower = query.lower().strip()
# 完全匹配
if query_lower in self.query_history:
return True
# 简单相似度检查(基于共同词比例)
try:
import jieba
query_words = set(w for w in jieba.cut(query_lower) if len(w) >= 2)
for hist_query in self.query_history:
hist_words = set(w for w in jieba.cut(hist_query) if len(w) >= 2)
if not query_words or not hist_words:
continue
intersection = len(query_words & hist_words)
union = len(query_words | hist_words)
similarity = intersection / union if union > 0 else 0
if similarity >= similarity_threshold:
return True
except ImportError:
pass
return False
def get_best_iteration(self) -> Optional[IterationRecord]:
"""获取最佳迭代记录(置信度最高)"""
if not self.iterations:
return None
return max(self.iterations, key=lambda r: r.confidence)
def get_summary(self) -> dict:
"""获取迭代摘要"""
if not self.iterations:
return {
"total_iterations": 0,
"best_confidence": 0,
"avg_confidence": 0,
"total_results": 0
}
confidences = [r.confidence for r in self.iterations]
return {
"total_iterations": len(self.iterations),
"best_confidence": max(confidences),
"avg_confidence": sum(confidences) / len(confidences),
"total_results": sum(r.results_count for r in self.iterations),
"confidence_trend": self._get_confidence_trend(),
"iterations": [
{
"iteration": r.iteration,
"confidence": r.confidence,
"results_count": r.results_count
}
for r in self.iterations
]
}
def _get_confidence_trend(self) -> str:
"""获取置信度趋势"""
if len(self.iterations) < 2:
return "unknown"
confidences = [r.confidence for r in self.iterations]
# 计算趋势
improving_count = 0
declining_count = 0
for i in range(1, len(confidences)):
diff = confidences[i] - confidences[i-1]
if diff > 0.02:
improving_count += 1
elif diff < -0.02:
declining_count += 1
if declining_count > improving_count:
return "declining"
elif improving_count > declining_count:
return "improving"
else:
return "stable"
def _get_recent_improvement(self) -> float:
"""获取最近的置信度提升"""
if len(self.iterations) < 2:
return 0.0
return self.iterations[-1].confidence - self.iterations[-2].confidence
def reset(self):
"""重置防护器状态"""
self.iterations.clear()
self.query_history.clear()
def get_config(self) -> dict:
"""获取配置信息"""
return {
"max_iterations": self.max_iterations,
"min_confidence_improvement": self.min_confidence_improvement
}
def create_guard(max_iterations: int = 3) -> LoopGuard:
"""
创建循环防护器实例
Args:
max_iterations: 最大迭代次数
Returns:
LoopGuard: 防护器实例
"""
return LoopGuard(max_iterations=max_iterations)
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("循环检索防护测试")
print("=" * 60)
guard = LoopGuard(max_iterations=3)
print(f"\n配置: {guard.get_config()}")
# 模拟迭代
test_iterations = [
("公司报销制度", 0.3, 5),
("报销流程规定", 0.45, 8),
("报销审批标准", 0.42, 6), # 置信度下降
]
print("\n模拟迭代过程:")
for query, confidence, count in test_iterations:
# 检查重复
is_dup = guard.is_duplicate_query(query)
if is_dup:
print(f" ⚠️ 检测到重复查询: {query}")
# 记录迭代
guard.record_iteration(query, confidence, count)
# 检查是否继续
result = guard.should_continue()
print(f" 迭代 {result.current_iteration}: {query}")
print(f" 置信度: {confidence:.3f}, 趋势: {result.confidence_trend}")
print(f" 决策: {result.decision.value}")
print(f" 原因: {result.reason}")
if result.decision != GuardDecision.CONTINUE:
print(f" 🛑 停止迭代")
break
print(f"\n迭代摘要:")
summary = guard.get_summary()
print(f" 总迭代数: {summary['total_iterations']}")
print(f" 最佳置信度: {summary['best_confidence']:.3f}")
print(f" 平均置信度: {summary['avg_confidence']:.3f}")
print(f" 总结果数: {summary['total_results']}")
print("\n" + "=" * 60)
print("✅ 测试完成")
print("=" * 60)

223
core/mmr.py Normal file
View File

@@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-
"""
MMRMax Marginal Relevance去重模块
功能:
- 平衡相关性和多样性
- 避免重复内容占据 top_k 结果
- 前置到 rerank 之前,减少 rerank 输入量
使用场景:
召回 100 个 → MMR 去重取 30 个 → rerank 取 top_k
"""
import numpy as np
from typing import List, Dict, Tuple, Optional
import logging
logger = logging.getLogger(__name__)
def cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
"""计算两个向量的余弦相似度"""
norm1 = np.linalg.norm(vec1)
norm2 = np.linalg.norm(vec2)
if norm1 == 0 or norm2 == 0:
return 0.0
return float(np.dot(vec1, vec2) / (norm1 * norm2))
def mmr_rerank(
query_emb: np.ndarray,
candidates: List[Dict],
top_k: int = 30,
lambda_param: float = 0.5,
emb_key: str = 'embedding'
) -> List[Dict]:
"""
Max Marginal Relevance 去重
公式: MMR = λ * Relevance - (1-λ) * Max_Similarity
Args:
query_emb: 查询向量
candidates: 候选文档列表,每个文档需包含 embedding
top_k: 返回数量
lambda_param: 相关性/多样性权衡参数 (0-1)
- 1.0: 只考虑相关性
- 0.5: 平衡相关性和多样性
- 0.0: 只考虑多样性
emb_key: embedding 在候选文档中的 key
Returns:
去重后的候选文档列表
"""
if not candidates:
return []
if len(candidates) <= top_k:
return candidates
selected = []
remaining = candidates.copy()
while len(selected) < top_k and remaining:
mmr_scores = []
for i, cand in enumerate(remaining):
# 获取候选文档的 embedding
cand_emb = cand.get(emb_key)
if cand_emb is None:
# 没有 embedding跳过或使用默认分数
mmr_scores.append(-float('inf'))
continue
cand_emb = np.array(cand_emb)
# 1. 相关性:与查询的相似度
relevance = cosine_similarity(query_emb, cand_emb)
# 2. 冗余度:与已选文档的最大相似度
if selected:
# 过滤出有 embedding 的已选文档
selected_with_emb = [s for s in selected if s.get(emb_key) is not None]
if selected_with_emb:
max_sim = max(
cosine_similarity(cand_emb, np.array(s.get(emb_key)))
for s in selected_with_emb
)
else:
max_sim = 0.0
else:
max_sim = 0.0
# 3. MMR 分数 = λ * 相关性 - (1-λ) * 冗余度
mmr_score = lambda_param * relevance - (1 - lambda_param) * max_sim
mmr_scores.append(mmr_score)
# 选择 MMR 分数最高的
if mmr_scores:
best_idx = np.argmax(mmr_scores)
if mmr_scores[best_idx] > -float('inf'):
selected.append(remaining.pop(best_idx))
else:
# 所有候选都没有 embedding直接取前 top_k
selected.extend(remaining[:top_k - len(selected)])
break
return selected
def mmr_filter_by_content(
candidates: List[Dict],
top_k: int = 30,
similarity_threshold: float = 0.9
) -> List[Dict]:
"""
基于内容相似度的去重(简化版,不需要 embedding
适用于:
- 没有 embedding 的情况
- 快速去重场景
Args:
candidates: 候选文档列表
top_k: 返回数量
similarity_threshold: 相似度阈值,超过则视为重复
Returns:
去重后的候选文档列表
"""
if not candidates:
return []
if len(candidates) <= top_k:
return candidates
selected = []
remaining = candidates.copy()
while len(selected) < top_k and remaining:
current = remaining.pop(0)
# 检查是否与已选内容重复
is_duplicate = False
current_content = current.get('content', current.get('document', ''))[:200]
for s in selected:
s_content = s.get('content', s.get('document', ''))[:200]
# 简单的 Jaccard 相似度
words1 = set(current_content)
words2 = set(s_content)
if words1 and words2:
intersection = len(words1 & words2)
union = len(words1 | words2)
similarity = intersection / union if union > 0 else 0
if similarity > similarity_threshold:
is_duplicate = True
break
if not is_duplicate:
selected.append(current)
return selected
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("MMR 去重测试")
print("=" * 60)
# 模拟候选文档
np.random.seed(42)
def random_embedding():
emb = np.random.randn(768)
return emb / np.linalg.norm(emb)
query_emb = random_embedding()
# 创建 10 个候选,前 5 个相似
base_emb = random_embedding()
candidates = []
for i in range(10):
if i < 5:
# 前 5 个与 query 相似
emb = query_emb + np.random.randn(768) * 0.1
else:
# 后 5 个与 query 不太相似
emb = random_embedding()
candidates.append({
'id': f'doc_{i}',
'content': f'文档内容 {i}',
'embedding': emb / np.linalg.norm(emb)
})
print(f"\n候选数量: {len(candidates)}")
# MMR 去重
selected = mmr_rerank(query_emb, candidates, top_k=5, lambda_param=0.5)
print(f"MMR 选择数量: {len(selected)}")
print(f"选择的文档 ID: {[c['id'] for c in selected]}")
# 计算多样性
embs = [c['embedding'] for c in selected]
diversity_scores = []
for i in range(len(embs)):
for j in range(i + 1, len(embs)):
sim = cosine_similarity(embs[i], embs[j])
diversity_scores.append(sim)
avg_similarity = np.mean(diversity_scores) if diversity_scores else 0
print(f"平均相似度(越低多样性越高): {avg_similarity:.3f}")

576
core/quality_assessor.py Normal file
View File

@@ -0,0 +1,576 @@
"""
多维质量评估模块
对检索结果进行 4 维质量评估:
1. 相关性Relevance答案是否切题
2. 完整性Completeness信息是否充分
3. 准确性Accuracy是否有冲突信息
4. 覆盖率Coverage多角度覆盖
总阈值32/40 (80%)
使用方式:
from core.quality_assessor import QualityAssessor, assess_quality
assessor = QualityAssessor(llm_client)
result = assessor.assess(query, documents)
if result.total_score >= 32:
# 质量合格,继续生成
...
"""
from dataclasses import dataclass
from typing import List, Optional
from enum import Enum
from config import RAG_CHAT_MODEL
import logging
logger = logging.getLogger(__name__)
class QualityDimension(Enum):
"""质量评估维度"""
RELEVANCE = "relevance" # 相关性:答案是否切题
COMPLETENESS = "completeness" # 完整性:信息是否充分
ACCURACY = "accuracy" # 准确性:是否有冲突
COVERAGE = "coverage" # 覆盖率:多角度覆盖
@dataclass
class DimensionScore:
"""单个维度的评分"""
dimension: QualityDimension
score: int # 0-10 分
reason: str # 评分原因
issues: List[str] # 发现的问题
@dataclass
class QualityAssessment:
"""质量评估结果"""
relevance: DimensionScore
completeness: DimensionScore
accuracy: DimensionScore
coverage: DimensionScore
total_score: int # 总分0-40
is_sufficient: bool # 是否达标(>= 32
summary: str # 评估总结
recommendations: List[str] # 改进建议
class QualityAssessor:
"""
多维质量评估器
基于报告建议的 4 维评估框架,对检索结果进行全面质量检查。
"""
# 质量阈值(报告建议值)
QUALITY_THRESHOLD = 32 # 总分阈值40分制80%
DIMENSION_WEIGHTS = {
QualityDimension.RELEVANCE: 1.0,
QualityDimension.COMPLETENESS: 1.0,
QualityDimension.ACCURACY: 1.0,
QualityDimension.COVERAGE: 1.0,
}
def __init__(self, llm_client=None, model: str = None):
"""
初始化评估器
Args:
llm_client: LLM 客户端(用于语义评估)
model: 模型名称
"""
self.llm_client = llm_client
self.model = model or RAG_CHAT_MODEL
def assess(self, query: str, documents: List[str],
metadatas: List[dict] = None) -> QualityAssessment:
"""
评估检索结果质量
Args:
query: 用户查询
documents: 检索到的文档列表
metadatas: 文档元数据(可选)
Returns:
QualityAssessment: 质量评估结果
"""
if not documents:
return self._empty_assessment()
# 使用 LLM 进行语义评估
if self.llm_client:
return self._llm_assess(query, documents, metadatas)
else:
# 降级:基于规则的评估
return self._rule_based_assess(query, documents, metadatas)
def _llm_assess(self, query: str, documents: List[str],
metadatas: List[dict] = None) -> QualityAssessment:
"""
使用 LLM 进行语义质量评估
"""
# 构建文档摘要
doc_summary = self._summarize_documents(documents, metadatas)
prompt = f"""请对以下检索结果进行多维质量评估。
## 用户查询
{query}
## 检索到的文档摘要
{doc_summary}
## 评估要求
请从 4 个维度进行评分(每个维度 0-10 分):
1. **相关性Relevance**:文档内容是否直接回答用户问题?
- 10分完全相关直接回答
- 7-9分高度相关核心内容匹配
- 4-6分部分相关有侧面信息
- 0-3分几乎无关
2. **完整性Completeness**:信息是否充分完整?
- 10分信息完整可直接回答
- 7-9分基本完整缺少次要细节
- 4-6分部分完整缺少关键信息
- 0-3分信息严重不足
3. **准确性Accuracy**:文档之间是否有矛盾冲突?
- 10分信息一致无冲突
- 7-9分有轻微表述差异但不影响理解
- 4-6分有明显矛盾需要辨别
- 0-3分严重冲突信息不可靠
4. **覆盖率Coverage**:是否从多个角度/来源覆盖问题?
- 10分多来源、多角度全面覆盖
- 7-9分有多个相关来源
- 4-6分单一来源但有不同方面
- 0-3分覆盖角度单一
请以 JSON 格式返回评估结果:
```json
{{
"relevance": {{"score": 8, "reason": "原因", "issues": ["问题1"]}},
"completeness": {{"score": 7, "reason": "原因", "issues": []}},
"accuracy": {{"score": 9, "reason": "原因", "issues": []}},
"coverage": {{"score": 6, "reason": "原因", "issues": ["问题1"]}},
"summary": "整体评估总结",
"recommendations": ["建议1", "建议2"]
}}
```"""
try:
from core.llm_utils import call_llm
content = call_llm(
self.llm_client,
prompt,
self.model,
temperature=0.1,
max_tokens=800
)
if content is None:
return self._rule_based_assess(query, documents, metadatas)
return self._parse_llm_response(content)
except Exception as e:
logger.warning(f"LLM 质量评估失败: {e}")
return self._rule_based_assess(query, documents, metadatas)
def _parse_llm_response(self, content: str) -> QualityAssessment:
"""解析 LLM 返回的 JSON"""
import json
import re
# 提取 JSON 块
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', content)
if json_match:
json_str = json_match.group(1)
else:
# 尝试直接解析
json_str = content
try:
data = json.loads(json_str)
except json.JSONDecodeError:
# 解析失败,返回默认评估
return self._default_assessment()
# 构建维度评分
relevance = DimensionScore(
dimension=QualityDimension.RELEVANCE,
score=min(10, max(0, data.get("relevance", {}).get("score", 5))),
reason=data.get("relevance", {}).get("reason", ""),
issues=data.get("relevance", {}).get("issues", [])
)
completeness = DimensionScore(
dimension=QualityDimension.COMPLETENESS,
score=min(10, max(0, data.get("completeness", {}).get("score", 5))),
reason=data.get("completeness", {}).get("reason", ""),
issues=data.get("completeness", {}).get("issues", [])
)
accuracy = DimensionScore(
dimension=QualityDimension.ACCURACY,
score=min(10, max(0, data.get("accuracy", {}).get("score", 5))),
reason=data.get("accuracy", {}).get("reason", ""),
issues=data.get("accuracy", {}).get("issues", [])
)
coverage = DimensionScore(
dimension=QualityDimension.COVERAGE,
score=min(10, max(0, data.get("coverage", {}).get("score", 5))),
reason=data.get("coverage", {}).get("reason", ""),
issues=data.get("coverage", {}).get("issues", [])
)
total_score = relevance.score + completeness.score + accuracy.score + coverage.score
return QualityAssessment(
relevance=relevance,
completeness=completeness,
accuracy=accuracy,
coverage=coverage,
total_score=total_score,
is_sufficient=total_score >= self.QUALITY_THRESHOLD,
summary=data.get("summary", ""),
recommendations=data.get("recommendations", [])
)
def _rule_based_assess(self, query: str, documents: List[str],
metadatas: List[dict] = None) -> QualityAssessment:
"""
基于规则的质量评估(降级方案)
"""
# 相关性:基于关键词匹配
relevance_score = self._assess_relevance(query, documents)
# 完整性:基于文档长度和数量
completeness_score = self._assess_completeness(documents)
# 准确性:假设一致(降级方案无法检测冲突)
accuracy_score = 8 # 默认较高分数
# 覆盖率:基于来源多样性
coverage_score = self._assess_coverage(documents, metadatas)
total_score = relevance_score + completeness_score + accuracy_score + coverage_score
return QualityAssessment(
relevance=DimensionScore(
dimension=QualityDimension.RELEVANCE,
score=relevance_score,
reason=f"关键词匹配评估(规则降级)",
issues=[]
),
completeness=DimensionScore(
dimension=QualityDimension.COMPLETENESS,
score=completeness_score,
reason=f"基于文档长度和数量评估(规则降级)",
issues=[]
),
accuracy=DimensionScore(
dimension=QualityDimension.ACCURACY,
score=accuracy_score,
reason="降级方案:假设信息一致",
issues=[]
),
coverage=DimensionScore(
dimension=QualityDimension.COVERAGE,
score=coverage_score,
reason=f"基于来源多样性评估(规则降级)",
issues=[]
),
total_score=total_score,
is_sufficient=total_score >= self.QUALITY_THRESHOLD,
summary="基于规则的质量评估LLM 不可用)",
recommendations=["建议启用 LLM 进行更精确的语义评估"]
)
def _assess_relevance(self, query: str, documents: List[str]) -> int:
"""评估相关性(关键词匹配)"""
try:
import jieba
# 提取查询关键词
query_words = set()
for word in jieba.cut(query):
word = word.strip()
if len(word) >= 2:
query_words.add(word.lower())
if not query_words:
return 5
# 计算每个文档的关键词覆盖率
coverages = []
for doc in documents:
doc_lower = doc.lower()
matched = sum(1 for word in query_words if word in doc_lower)
coverages.append(matched / len(query_words))
avg_coverage = sum(coverages) / len(coverages) if coverages else 0
# 映射到 0-10 分
if avg_coverage >= 0.8:
return 9
elif avg_coverage >= 0.6:
return 7
elif avg_coverage >= 0.4:
return 5
elif avg_coverage >= 0.2:
return 3
else:
return 1
except ImportError:
return 5
def _assess_completeness(self, documents: List[str]) -> int:
"""评估完整性(基于文档长度和数量)"""
if not documents:
return 0
# 文档数量评估
doc_count_score = min(3, len(documents)) # 最多 3 分
# 文档长度评估
total_length = sum(len(doc) for doc in documents)
if total_length >= 2000:
length_score = 7
elif total_length >= 1000:
length_score = 5
elif total_length >= 500:
length_score = 3
else:
length_score = 1
return min(10, doc_count_score + length_score)
def _assess_coverage(self, documents: List[str],
metadatas: List[dict] = None) -> int:
"""评估覆盖率(来源多样性)"""
if not metadatas:
# 无元数据,基于文档内容差异度
if len(documents) >= 3:
return 7
elif len(documents) >= 2:
return 5
else:
return 3
# 统计来源多样性
sources = set()
for meta in metadatas:
if isinstance(meta, dict):
source = meta.get("source", "")
if source:
sources.add(source)
source_count = len(sources)
if source_count >= 3:
return 9
elif source_count >= 2:
return 7
elif source_count == 1:
return 5
else:
return 3
def _summarize_documents(self, documents: List[str],
metadatas: List[dict] = None) -> str:
"""生成文档摘要用于 LLM 评估"""
summary_parts = []
for i, doc in enumerate(documents[:5], 1): # 最多 5 个文档
source = ""
if metadatas and i <= len(metadatas):
meta = metadatas[i - 1]
if isinstance(meta, dict):
source = meta.get("source", "未知来源")
page = meta.get("page", "")
if page:
source += f" (第{page}页)"
content = doc[:300] + "..." if len(doc) > 300 else doc
summary_parts.append(f"### 文档 {i} ({source})\n{content}")
return "\n\n".join(summary_parts)
def _empty_assessment(self) -> QualityAssessment:
"""空评估结果"""
return QualityAssessment(
relevance=DimensionScore(
dimension=QualityDimension.RELEVANCE,
score=0,
reason="无检索结果",
issues=[]
),
completeness=DimensionScore(
dimension=QualityDimension.COMPLETENESS,
score=0,
reason="无检索结果",
issues=[]
),
accuracy=DimensionScore(
dimension=QualityDimension.ACCURACY,
score=0,
reason="无检索结果",
issues=[]
),
coverage=DimensionScore(
dimension=QualityDimension.COVERAGE,
score=0,
reason="无检索结果",
issues=[]
),
total_score=0,
is_sufficient=False,
summary="无检索结果可供评估",
recommendations=["请尝试其他查询方式"]
)
def _default_assessment(self) -> QualityAssessment:
"""默认评估结果(解析失败时)"""
return QualityAssessment(
relevance=DimensionScore(
dimension=QualityDimension.RELEVANCE,
score=5,
reason="评估解析失败,使用默认分数",
issues=[]
),
completeness=DimensionScore(
dimension=QualityDimension.COMPLETENESS,
score=5,
reason="评估解析失败,使用默认分数",
issues=[]
),
accuracy=DimensionScore(
dimension=QualityDimension.ACCURACY,
score=5,
reason="评估解析失败,使用默认分数",
issues=[]
),
coverage=DimensionScore(
dimension=QualityDimension.COVERAGE,
score=5,
reason="评估解析失败,使用默认分数",
issues=[]
),
total_score=20,
is_sufficient=False,
summary="LLM 评估解析失败",
recommendations=["请检查 LLM 响应格式"]
)
def get_threshold_info(self) -> dict:
"""获取阈值信息"""
return {
"quality_threshold": self.QUALITY_THRESHOLD,
"max_score": 40,
"pass_percentage": f"{self.QUALITY_THRESHOLD / 40 * 100}%",
"dimensions": ["relevance", "completeness", "accuracy", "coverage"]
}
def create_assessor() -> QualityAssessor:
"""
创建质量评估器实例
自动从配置获取 LLM 客户端。
Returns:
QualityAssessor: 质量评估器实例
"""
try:
from openai import OpenAI
try:
from config import API_KEY, BASE_URL, RAG_CHAT_MODEL
MODEL = RAG_CHAT_MODEL
except ImportError:
from config import API_KEY, BASE_URL
MODEL = "qwen3.5-flash" # fallback
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
return QualityAssessor(llm_client=client, model=MODEL)
except Exception as e:
logger.warning(f"创建质量评估器失败,使用降级模式: {e}")
return QualityAssessor()
def assess_quality(query: str, documents: List[str],
metadatas: List[dict] = None) -> QualityAssessment:
"""
便捷函数:评估检索结果质量
Args:
query: 用户查询
documents: 检索到的文档列表
metadatas: 文档元数据(可选)
Returns:
QualityAssessment: 质量评估结果
"""
assessor = create_assessor()
return assessor.assess(query, documents, metadatas)
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("多维质量评估测试")
print("=" * 60)
# 测试用例
test_cases = [
{
"query": "公司报销制度是怎样的?",
"documents": [
"公司报销制度规定员工可以报销差旅费用,需提供发票和审批单。报销流程:提交申请 -> 部门审批 -> 财务审核 -> 打款。",
"差旅报销标准高铁一等座、飞机经济舱、住宿每天500元内。超过标准需特批。",
"报销时限费用发生后30天内提交逾期不予受理。"
],
"description": "高质量检索结果"
},
{
"query": "量子计算的基本原理",
"documents": [
"文档中提到了一些技术细节...",
"另一个不相关的内容..."
],
"description": "低质量检索结果"
}
]
assessor = QualityAssessor() # 不使用 LLM 的规则评估
print(f"\n阈值配置: {assessor.get_threshold_info()}")
print()
for i, case in enumerate(test_cases, 1):
print(f"测试 {i}: {case['description']}")
print(f"查询: {case['query']}")
print(f"文档数: {len(case['documents'])}")
result = assessor.assess(case['query'], case['documents'])
print(f"\n评分结果:")
print(f" 相关性: {result.relevance.score}/10 - {result.relevance.reason}")
print(f" 完整性: {result.completeness.score}/10 - {result.completeness.reason}")
print(f" 准确性: {result.accuracy.score}/10 - {result.accuracy.reason}")
print(f" 覆盖率: {result.coverage.score}/10 - {result.coverage.reason}")
print(f"\n 总分: {result.total_score}/40")
print(f" 达标: {'✅ 是' if result.is_sufficient else '❌ 否'} (阈值: 32)")
print(f" 总结: {result.summary}")
print()

26
core/query_classifier.py Normal file
View File

@@ -0,0 +1,26 @@
"""
查询分类工具
当前仅保留枚举查询检测函数(被生产管线使用)。
原有的 QueryClassifier 类和 classify() 方法未被生产路径调用,已清理。
使用方式:
from core.query_classifier import is_enumeration_query
if is_enumeration_query(query):
# 枚举类查询需要连续上下文
...
"""
import re
def is_enumeration_query(query: str) -> bool:
"""Return True for list/numbered-clause questions that need contiguous context."""
if not query:
return False
strong_markers = ("哪些", "有哪些", "列出", "严禁", "禁止", "不得", "包括", "要求")
if any(marker in query for marker in strong_markers):
return True
return bool(re.search(r"(哪几|几类|几种|多少项|第[一二三四五六七八九十\d]+条)", query))

454
core/query_decomposer.py Normal file
View File

@@ -0,0 +1,454 @@
"""
Query 拆分器 - 复杂查询分解
核心功能:
1. 识别需要拆分的复杂查询
2. 将对比类、推理类问题拆分为子查询
3. 支持并行检索和结果合并
使用方式:
from core.query_decomposer import QueryDecomposer
decomposer = QueryDecomposer()
result = decomposer.decompose("Transformer 和 CNN 的区别是什么?")
print(result.sub_queries) # ["Transformer的核心原理", "CNN的核心原理", "两者的主要区别"]
"""
from dataclasses import dataclass, field
from typing import List, Optional, Tuple
import re
@dataclass
class DecomposedQuery:
"""拆分后的查询结果"""
original_query: str # 原始查询
sub_queries: List[str] # 子查询列表
query_type: str # 拆分类型 (comparison, multi_concept, reasoning)
entities: List[str] # 识别的实体
needs_merge: bool # 是否需要合并答案
merge_strategy: str # 合并策略 (compare, summarize, synthesize)
def to_dict(self) -> dict:
return {
"original_query": self.original_query,
"sub_queries": self.sub_queries,
"query_type": self.query_type,
"entities": self.entities,
"needs_merge": self.needs_merge,
"merge_strategy": self.merge_strategy
}
class QueryDecomposer:
"""
查询拆分器
拆分策略:
1. 对比类查询:拆分为各实体的独立查询 + 对比查询
2. 多概念查询:拆分为各概念的独立查询
3. 推理类查询:拆分为前提验证 + 推理步骤
触发条件:
- 包含"区别""对比""比较"等关键词
- 包含多个实体
- 复杂推理问题
"""
# 对比类关键词
COMPARISON_KEYWORDS = [
"区别", "对比", "比较", "差异", "不同", "差别",
"哪个更好", "哪个更", "vs", "VS", "还是",
"一样吗", "有什么不同", "有什么区别",
"优缺点", "利弊", "优劣", "相比"
]
# 多实体连接词
ENTITY_CONNECTORS = ["", "", "", "", "以及", "", ""]
# 推理类关键词
REASONING_KEYWORDS = [
"为什么", "原因", "怎么导致", "如何影响",
"怎么会", "为何", "是什么导致"
]
# 列举类关键词
LIST_KEYWORDS = [
"有哪些", "有什么", "列举", "分别",
"都有哪些", "各有什么"
]
def __init__(self, llm_client=None, llm_model: str = None):
"""
初始化拆分器
Args:
llm_client: LLM客户端可选用于复杂拆分
llm_model: 模型名称
"""
self.llm_client = llm_client
self.llm_model = llm_model
def should_decompose(self, query: str) -> Tuple[bool, str]:
"""
判断是否需要拆分
Args:
query: 用户查询
Returns:
(needs_decompose, decompose_type)
"""
# 对比类查询
if any(kw in query for kw in self.COMPARISON_KEYWORDS):
# 检查是否有多个实体
entities = self._extract_entities_for_comparison(query)
if len(entities) >= 2:
return True, "comparison"
# 推理类查询(复杂)
if any(kw in query for kw in self.REASONING_KEYWORDS):
# 简单推理不拆分
if len(query) > 30: # 长推理问题
return True, "reasoning"
return False, ""
def decompose(self, query: str) -> DecomposedQuery:
"""
拆分查询
Args:
query: 用户查询
Returns:
DecomposedQuery: 拆分结果
"""
needs_decompose, decompose_type = self.should_decompose(query)
if not needs_decompose:
return DecomposedQuery(
original_query=query,
sub_queries=[query],
query_type="simple",
entities=[],
needs_merge=False,
merge_strategy="none"
)
if decompose_type == "comparison":
return self._decompose_comparison(query)
elif decompose_type == "reasoning":
return self._decompose_reasoning(query)
return DecomposedQuery(
original_query=query,
sub_queries=[query],
query_type="unknown",
entities=[],
needs_merge=False,
merge_strategy="none"
)
def _decompose_comparison(self, query: str) -> DecomposedQuery:
"""
拆分对比类查询
示例:
"A和B的区别是什么" → ["A的核心原理是什么", "B的核心原理是什么", "A和B的主要区别有哪些"]
"""
entities = self._extract_entities_for_comparison(query)
entities = [e for e in entities if len(e) >= 2] # 过滤短实体
if len(entities) < 2:
# 无法识别实体,返回原查询
return DecomposedQuery(
original_query=query,
sub_queries=[query],
query_type="comparison_fallback",
entities=[],
needs_merge=False,
merge_strategy="none"
)
sub_queries = []
# 为每个实体创建独立查询
for entity in entities:
sub_queries.append(f"{entity}是什么?")
sub_queries.append(f"{entity}的主要特点有哪些?")
# 添加对比查询
entity_str = "".join(entities[:2]) # 最多两个实体
sub_queries.append(f"{entity_str}的主要区别有哪些?")
# 去重
sub_queries = list(dict.fromkeys(sub_queries))
return DecomposedQuery(
original_query=query,
sub_queries=sub_queries,
query_type="comparison",
entities=entities,
needs_merge=True,
merge_strategy="compare"
)
def _decompose_reasoning(self, query: str) -> DecomposedQuery:
"""
拆分推理类查询
示例:
"为什么A会导致B" → ["A是什么", "B是什么", "A和B的关系是什么", "A导致B的原因是什么"]
"""
# 简单实现:提取关键概念
# 尝试使用LLM进行更精确的拆分
if self.llm_client:
return self._llm_decompose(query, "reasoning")
# 降级:返回原查询 + 相关概念查询
return DecomposedQuery(
original_query=query,
sub_queries=[query, f"{query[:10]}...的相关背景"],
query_type="reasoning",
entities=[],
needs_merge=True,
merge_strategy="synthesize"
)
def _llm_decompose(self, query: str, query_type: str) -> DecomposedQuery:
"""
使用LLM进行查询拆分
Args:
query: 原始查询
query_type: 查询类型
Returns:
DecomposedQuery
"""
if not self.llm_client:
return DecomposedQuery(
original_query=query,
sub_queries=[query],
query_type=query_type,
entities=[],
needs_merge=False,
merge_strategy="none"
)
prompt = f"""请将以下复杂查询拆分为多个简单的子查询,便于分别检索。
原始查询:{query}
要求:
1. 每个子查询应该简单明确,便于检索
2. 子查询应该覆盖原查询的关键信息需求
3. 返回JSON格式{{"sub_queries": ["子查询1", "子查询2", ...], "entities": ["实体1", "实体2"], "merge_strategy": "compare/summarize/synthesize"}}
只返回JSON不要其他内容。"""
try:
from core.llm_utils import call_llm
result_text = call_llm(
self.llm_client,
prompt,
self.llm_model,
temperature=0.1,
max_tokens=300
)
if result_text is None:
return DecomposedQuery(
original_query=query,
sub_queries=[query],
query_type=query_type,
entities=[],
needs_merge=False,
merge_strategy="none"
)
import json
# 解析JSON
json_match = re.search(r'\{[^}]+\}', result_text, re.DOTALL)
if json_match:
data = json.loads(json_match.group())
sub_queries = data.get('sub_queries', [query])
entities = data.get('entities', [])
merge_strategy = data.get('merge_strategy', 'synthesize')
return DecomposedQuery(
original_query=query,
sub_queries=sub_queries if sub_queries else [query],
query_type=query_type,
entities=entities,
needs_merge=True,
merge_strategy=merge_strategy
)
except Exception as e:
# LLM拆分失败返回原查询
pass
return DecomposedQuery(
original_query=query,
sub_queries=[query],
query_type=query_type,
entities=[],
needs_merge=False,
merge_strategy="none"
)
def _extract_entities_for_comparison(self, query: str) -> List[str]:
"""
从对比类查询中提取实体
示例:
"A和B的区别" → ["A", "B"]
"年假和病假有什么不同" → ["年假", "病假"]
"""
entities = []
# 预处理:移除常见的疑问后缀
query_clean = query
suffixes = ["有什么区别", "的区别是什么", "有什么不同", "的不同是什么",
"的对比是什么", "的比较是什么", "的差异是什么",
"的区别", "的不同", "的对比", "的比较", "的差异",
"对比", "比较", "哪个更好", "哪个更"]
for suffix in suffixes:
if query_clean.endswith(suffix):
query_clean = query_clean[:-len(suffix)]
break
# 方法1使用连接词分割
for connector in self.ENTITY_CONNECTORS:
if connector in query_clean:
parts = query_clean.split(connector)
if len(parts) >= 2:
# 提取第一个部分的主语
first = self._clean_entity(parts[0])
# 提取第二个部分的主语
second = self._clean_entity(parts[1])
if first and 2 <= len(first) <= 15:
entities.append(first)
if second and 2 <= len(second) <= 15:
entities.append(second)
break
# 方法2使用正则匹配常见模式
if not entities:
# 模式实体1和实体2
match = re.search(r'([^\s!??和与跟及]+)[和与跟及]([^\s!??的]+)', query)
if match:
e1 = match.group(1).strip()
e2 = match.group(2).strip()
if len(e1) >= 2 and len(e1) <= 15:
entities.append(e1)
if len(e2) >= 2 and len(e2) <= 15:
entities.append(e2)
# 去重
entities = list(dict.fromkeys(entities))
return entities[:3] # 最多3个实体
def _clean_entity(self, text: str) -> str:
"""
清理实体文本
移除常见的修饰词和标点
"""
text = text.strip()
# 移除开头的修饰词
prefixes = ["请问", "我想知道", "帮我查", "查一下"]
for prefix in prefixes:
if text.startswith(prefix):
text = text[len(prefix):]
# 移除结尾的标点和疑问词
text = re.sub(r'[?!。,,、]+$', '', text)
text = re.sub(r'(是什么|有什么|有哪些|怎么样|如何|有什么区别|有什么不同)$', '', text)
# 移除结尾的"区别"、"不同"等
text = re.sub(r'(的区别|的不同|区别|不同)$', '', text)
return text.strip()
# ==================== 扩展接口 ====================
def decompose_with_context(
self,
query: str,
history: List[dict] = None,
context: str = None
) -> DecomposedQuery:
"""
带上下文的查询拆分
Args:
query: 用户查询
history: 对话历史
context: 额外上下文
Returns:
DecomposedQuery
"""
# 当前实现不需要上下文,预留接口
return self.decompose(query)
# ==================== 便捷函数 ====================
def decompose_query(query: str, llm_client=None, llm_model: str = None) -> DecomposedQuery:
"""
便捷函数:拆分查询
Args:
query: 用户查询
llm_client: LLM客户端可选
llm_model: 模型名称
Returns:
DecomposedQuery: 拆分结果
"""
decomposer = QueryDecomposer(llm_client=llm_client, llm_model=llm_model)
return decomposer.decompose(query)
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
# 测试用例
test_queries = [
# 对比类
"年假和病假有什么区别?",
"Transformer和CNN的对比",
"A和B哪个更好",
"主导品规和护卫品规有什么区别?",
# 推理类
"为什么2022年三峡电站发电量较低",
# 简单查询(不需要拆分)
"货源投放的总体要求是什么?",
"报销标准",
]
decomposer = QueryDecomposer()
print("=" * 60)
print("查询拆分器测试")
print("=" * 60)
for query in test_queries:
result = decomposer.decompose(query)
print(f"\n原始查询: {query}")
print(f"拆分类型: {result.query_type}")
print(f"实体: {result.entities}")
print(f"子查询: {result.sub_queries}")
print(f"需要合并: {result.needs_merge} ({result.merge_strategy})")

271
core/query_expansion.py Normal file
View File

@@ -0,0 +1,271 @@
# -*- coding: utf-8 -*-
"""
Query Expansion 模块(安全版)
功能:
- 查询扩展:扩展查询词,提升召回率
- 安全过滤:扩展词必须与原查询相似度 > threshold
- 防止噪声词污染检索
使用方式:
from core.query_expansion import expand_query_safe, expand_query_data_driven
# 方案A相似度过滤
expansions = expand_query_safe(query, threshold=0.8)
# 方案B数据驱动扩展
expansions = expand_query_data_driven(query, vector_store)
"""
import logging
from typing import List, Dict, Optional, Set
import numpy as np
logger = logging.getLogger(__name__)
# ==================== 领域术语词典 ====================
# 可根据实际业务扩展
DOMAIN_TERMS = {
# 报销相关
"报销": ["差旅报销", "费用报销", "报销审批", "报销标准", "报销流程"],
"出差": ["差旅", "出差申请", "出差审批", "差旅费"],
"请假": ["休假申请", "请假审批", "年假", "事假", "病假"],
# 人事相关
"入职": ["入职办理", "新员工", "入职流程", "试用期"],
"离职": ["离职办理", "辞职", "离职流程", "离职审批"],
"薪资": ["工资", "薪酬", "薪资结构", "绩效考核"],
# 通用
"流程": ["办理流程", "操作流程", "审批流程"],
"标准": ["标准规范", "规定", "制度"],
"申请": ["申请流程", "申请条件", "申请材料"],
}
def get_domain_terms(query: str) -> List[str]:
"""
从领域词典获取扩展词
Args:
query: 用户查询
Returns:
扩展词列表
"""
expansions = []
for keyword, terms in DOMAIN_TERMS.items():
if keyword in query:
expansions.extend(terms)
return expansions
def cosine_similarity(vec1: np.ndarray, vec2: np.ndarray) -> float:
"""计算余弦相似度"""
norm1 = np.linalg.norm(vec1)
norm2 = np.linalg.norm(vec2)
if norm1 == 0 or norm2 == 0:
return 0.0
return float(np.dot(vec1, vec2) / (norm1 * norm2))
def expand_query_safe(
query: str,
embedding_model=None,
threshold: float = 0.8,
max_expansions: int = 5
) -> List[str]:
"""
安全的查询扩展(带相似度过滤)
Args:
query: 用户查询
embedding_model: embedding 模型(用于计算相似度)
threshold: 相似度阈值,扩展词必须 > threshold
max_expansions: 最大扩展数量
Returns:
扩展后的查询列表(包含原查询)
"""
expansions = [query]
# 1. 从领域词典获取候选扩展词
domain_candidates = get_domain_terms(query)
if not domain_candidates:
return expansions
# 2. 如果没有 embedding 模型,直接返回领域词(但限制数量)
if embedding_model is None:
# 没有 embedding只取前几个
expansions.extend(domain_candidates[:max_expansions])
return list(set(expansions))
# 3. 有 embedding 模型,做相似度过滤
try:
query_emb = embedding_model.encode(query)
scored_candidates = []
for candidate in domain_candidates:
cand_emb = embedding_model.encode(candidate)
similarity = cosine_similarity(query_emb, cand_emb)
if similarity > threshold:
scored_candidates.append((candidate, similarity))
# 按相似度排序,取前 max_expansions
scored_candidates.sort(key=lambda x: x[1], reverse=True)
filtered = [c[0] for c in scored_candidates[:max_expansions]]
expansions.extend(filtered)
except Exception as e:
logger.warning(f"Query expansion embedding 计算失败: {e}")
# 降级:直接使用领域词
expansions.extend(domain_candidates[:max_expansions])
return list(set(expansions))
def expand_query_data_driven(
query: str,
search_func=None,
top_k: int = 3
) -> List[str]:
"""
数据驱动的查询扩展
从向量库中查找相似查询,而非使用规则词典
Args:
query: 用户查询
search_func: 向量检索函数
top_k: 扩展数量
Returns:
扩展后的查询列表
"""
expansions = [query]
if search_func is None:
return expansions
try:
# 在向量库中搜索相似文档
# 取文档的前几个关键词作为扩展
results = search_func(query, top_k=top_k)
if results and results.get('documents') and results['documents'][0]:
for doc in results['documents'][0][:top_k]:
# 从文档中提取关键词
keywords = extract_keywords(doc, top_n=2)
expansions.extend(keywords)
except Exception as e:
logger.warning(f"数据驱动扩展失败: {e}")
return list(set(expansions))
def extract_keywords(text: str, top_n: int = 3) -> List[str]:
"""
从文本中提取关键词(简单实现)
Args:
text: 文本内容
top_n: 提取数量
Returns:
关键词列表
"""
try:
import jieba
import jieba.analyse
keywords = jieba.analyse.extract_tags(text, topK=top_n)
return keywords
except ImportError:
# 没有 jieba简单分词
words = text.split()[:top_n]
return [w for w in words if len(w) > 1]
def merge_expansion_results(
query: str,
expansions: List[str],
search_func,
top_k_per_query: int = 3,
final_top_k: int = 10
) -> List[Dict]:
"""
合并多个扩展查询的检索结果
Args:
query: 原始查询
expansions: 扩展查询列表
search_func: 检索函数
top_k_per_query: 每个查询返回数量
final_top_k: 最终返回数量
Returns:
合并后的结果列表
"""
all_results = []
seen_ids = set()
for q in expansions:
try:
results = search_func(q, top_k=top_k_per_query)
if results and results.get('ids') and results['ids'][0]:
for i, doc_id in enumerate(results['ids'][0]):
if doc_id not in seen_ids:
seen_ids.add(doc_id)
all_results.append({
'id': doc_id,
'content': results['documents'][0][i] if results.get('documents') else '',
'metadata': results['metadatas'][0][i] if results.get('metadatas') else {},
'score': results['distances'][0][i] if results.get('distances') else 0,
'query': q
})
except Exception as e:
logger.warning(f"扩展查询检索失败: {q}, 错误: {e}")
# 按分数排序
all_results.sort(key=lambda x: x.get('score', 0), reverse=True)
return all_results[:final_top_k]
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("Query Expansion 测试")
print("=" * 60)
# 测试领域词典扩展
test_queries = [
"报销标准是什么?",
"出差流程怎么走?",
"入职需要什么材料?"
]
for query in test_queries:
print(f"\n原查询: {query}")
# 无 embedding 的扩展
expansions = expand_query_safe(query, embedding_model=None, threshold=0.8)
print(f"扩展词(无 embedding: {expansions}")
# 领域词
domain_terms = get_domain_terms(query)
print(f"领域词: {domain_terms}")

476
core/reasoning_reflector.py Normal file
View File

@@ -0,0 +1,476 @@
"""
推理反思模块Re²Search
在答案生成过程中,自动识别未经验证的声明,触发补充检索进行验证。
核心机制:
1. 从生成的答案中提取关键声明claims
2. 识别哪些声明缺乏检索证据支持
3. 对未验证声明触发补充检索
4. 根据补充信息修正或增强答案
使用方式:
from core.reasoning_reflector import ReasoningReflector, reflect_and_verify
reflector = ReasoningReflector(llm_client)
result = reflector.reflect(query, answer, contexts)
if result.has_unverified_claims:
# 触发补充检索
...
"""
from dataclasses import dataclass
from typing import List, Optional, Tuple
from enum import Enum
from config import RAG_CHAT_MODEL
import logging
logger = logging.getLogger(__name__)
class ClaimType(Enum):
"""声明类型"""
FACTUAL = "factual" # 事实性声明(可验证)
OPINION = "opinion" # 观点性声明(主观)
INFERENCE = "inference" # 推论性声明(逻辑推导)
HEDGED = "hedged" # 保留性声明(可能、也许)
@dataclass
class Claim:
"""单个声明"""
content: str # 声明内容
claim_type: ClaimType # 声明类型
is_verified: bool # 是否已验证
supporting_contexts: List[str] # 支持该声明的上下文
confidence: float # 置信度0-1
@dataclass
class ReflectionResult:
"""反思结果"""
original_answer: str # 原始答案
claims: List[Claim] # 提取的声明列表
unverified_claims: List[Claim] # 未验证的声明
has_unverified_claims: bool # 是否存在未验证声明
verification_queries: List[str] # 建议的验证查询
reflection_summary: str # 反思总结
should_supplement: bool # 是否需要补充检索
class ReasoningReflector:
"""
推理反思器
在答案生成后,分析答案中的声明,识别未经验证的部分,
触发补充检索以提高答案质量。
"""
# 需要关注的关键词(可能表示未验证声明)
UNVERIFIED_MARKERS = [
"可能", "也许", "大概", "应该", "估计",
"通常", "一般", "往往", "多半",
"我认为", "我猜测", "似乎", "看起来"
]
# 事实性声明关键词
FACTUAL_MARKERS = [
"", "", "包括", "规定", "要求", "标准",
"流程", "步骤", "时间", "金额", "数量"
]
def __init__(self, llm_client=None, model: str = None):
"""
初始化反思器
Args:
llm_client: LLM 客户端
model: 模型名称
"""
self.llm_client = llm_client
self.model = model or RAG_CHAT_MODEL
def reflect(self, query: str, answer: str,
contexts: List[str] = None) -> ReflectionResult:
"""
对答案进行推理反思
Args:
query: 用户查询
answer: 生成的答案
contexts: 检索上下文
Returns:
ReflectionResult: 反思结果
"""
if not answer:
return self._empty_reflection()
# 使用 LLM 进行声明提取和验证
if self.llm_client:
return self._llm_reflect(query, answer, contexts)
else:
# 降级:基于规则的反思
return self._rule_based_reflect(query, answer, contexts)
def _llm_reflect(self, query: str, answer: str,
contexts: List[str] = None) -> ReflectionResult:
"""
使用 LLM 进行深度反思
"""
# 构建上下文摘要
context_summary = ""
if contexts:
context_summary = "\n\n".join([
f"[上下文 {i+1}] {ctx[:300]}..."
for i, ctx in enumerate(contexts[:5])
])
prompt = f"""请对以下答案进行推理反思分析。
## 用户问题
{query}
## 生成的答案
{answer}
## 检索到的上下文
{context_summary if context_summary else "(无检索上下文)"}
## 分析要求
1. 从答案中提取关键声明claims
2. 判断每个声明是否有上下文支持
3. 识别未验证的声明
4. 生成验证查询建议
请以 JSON 格式返回:
```json
{{
"claims": [
{{
"content": "声明内容",
"type": "factual/opinion/inference/hedged",
"verified": true/false,
"supporting_evidence": "支持证据或''",
"confidence": 0.8
}}
],
"unverified_claims_count": 2,
"verification_queries": ["建议的验证查询1", "建议的验证查询2"],
"summary": "反思总结",
"should_supplement": true/false
}}
```"""
try:
from core.llm_utils import call_llm
content = call_llm(
self.llm_client,
prompt,
self.model,
temperature=0.1,
max_tokens=1000
)
if content is None:
return self._rule_based_reflect(query, answer, contexts)
return self._parse_llm_response(answer, content)
except Exception as e:
logger.warning(f"LLM 反思失败: {e}")
return self._rule_based_reflect(query, answer, contexts)
def _parse_llm_response(self, original_answer: str,
content: str) -> ReflectionResult:
"""解析 LLM 返回的 JSON"""
import json
import re
# 提取 JSON 块
json_match = re.search(r'```json\s*([\s\S]*?)\s*```', content)
if json_match:
json_str = json_match.group(1)
else:
json_str = content
try:
data = json.loads(json_str)
except json.JSONDecodeError:
return self._default_reflection(original_answer)
# 解析声明
claims = []
unverified_claims = []
for claim_data in data.get("claims", []):
claim_type_str = claim_data.get("type", "factual")
claim_type = {
"factual": ClaimType.FACTUAL,
"opinion": ClaimType.OPINION,
"inference": ClaimType.INFERENCE,
"hedged": ClaimType.HEDGED
}.get(claim_type_str, ClaimType.FACTUAL)
claim = Claim(
content=claim_data.get("content", ""),
claim_type=claim_type,
is_verified=claim_data.get("verified", False),
supporting_contexts=[claim_data.get("supporting_evidence", "")],
confidence=claim_data.get("confidence", 0.5)
)
claims.append(claim)
if not claim.is_verified:
unverified_claims.append(claim)
return ReflectionResult(
original_answer=original_answer,
claims=claims,
unverified_claims=unverified_claims,
has_unverified_claims=len(unverified_claims) > 0,
verification_queries=data.get("verification_queries", []),
reflection_summary=data.get("summary", ""),
should_supplement=data.get("should_supplement", False)
)
def _rule_based_reflect(self, query: str, answer: str,
contexts: List[str] = None) -> ReflectionResult:
"""
基于规则的反思(降级方案)
"""
claims = []
unverified_claims = []
verification_queries = []
# 简单句子分割
sentences = self._split_sentences(answer)
for sentence in sentences:
if len(sentence.strip()) < 10:
continue
# 检测声明类型
claim_type = self._detect_claim_type(sentence)
# 检测是否已验证
is_verified = self._check_verification(sentence, contexts)
confidence = self._estimate_confidence(sentence, is_verified)
claim = Claim(
content=sentence.strip(),
claim_type=claim_type,
is_verified=is_verified,
supporting_contexts=[],
confidence=confidence
)
claims.append(claim)
if not is_verified and claim_type in [ClaimType.FACTUAL, ClaimType.INFERENCE]:
unverified_claims.append(claim)
# 生成验证查询
verification_queries.append(f"验证:{sentence.strip()[:50]}")
has_unverified = len(unverified_claims) > 0
should_supplement = has_unverified and len(unverified_claims) <= 3
summary = f"共提取 {len(claims)} 个声明,其中 {len(unverified_claims)} 个未验证"
if has_unverified:
summary += ",建议补充检索验证"
return ReflectionResult(
original_answer=answer,
claims=claims,
unverified_claims=unverified_claims,
has_unverified_claims=has_unverified,
verification_queries=verification_queries[:3],
reflection_summary=summary,
should_supplement=should_supplement
)
def _split_sentences(self, text: str) -> List[str]:
"""分割句子"""
import re
# 按中英文标点分割
sentences = re.split(r'[。!?\.\!\?]\s*', text)
return [s.strip() for s in sentences if s.strip()]
def _detect_claim_type(self, sentence: str) -> ClaimType:
"""检测声明类型"""
# 保留性声明
if any(marker in sentence for marker in self.UNVERIFIED_MARKERS):
return ClaimType.HEDGED
# 事实性声明
if any(marker in sentence for marker in self.FACTUAL_MARKERS):
return ClaimType.FACTUAL
# 默认为推论
return ClaimType.INFERENCE
def _check_verification(self, sentence: str,
contexts: List[str] = None) -> bool:
"""检查声明是否已验证"""
if not contexts:
return False
# 简单关键词匹配
keywords = self._extract_keywords(sentence)
if not keywords:
return False
for ctx in contexts:
matches = sum(1 for kw in keywords if kw in ctx)
if matches >= len(keywords) * 0.5:
return True
return False
def _extract_keywords(self, text: str) -> List[str]:
"""提取关键词"""
try:
import jieba
keywords = []
for word in jieba.cut(text):
word = word.strip()
if len(word) >= 2 and word.isalpha():
keywords.append(word)
return list(set(keywords))[:10]
except ImportError:
return []
def _estimate_confidence(self, sentence: str, is_verified: bool) -> float:
"""估计置信度"""
base_confidence = 0.8 if is_verified else 0.4
# 保留性声明降低置信度
if any(marker in sentence for marker in self.UNVERIFIED_MARKERS):
base_confidence -= 0.2
return max(0.1, min(1.0, base_confidence))
def _empty_reflection(self) -> ReflectionResult:
"""空反思结果"""
return ReflectionResult(
original_answer="",
claims=[],
unverified_claims=[],
has_unverified_claims=False,
verification_queries=[],
reflection_summary="无答案可供反思",
should_supplement=False
)
def _default_reflection(self, answer: str) -> ReflectionResult:
"""默认反思结果(解析失败时)"""
return ReflectionResult(
original_answer=answer,
claims=[],
unverified_claims=[],
has_unverified_claims=False,
verification_queries=[],
reflection_summary="反思解析失败",
should_supplement=False
)
def get_info(self) -> dict:
"""获取反思器信息"""
return {
"model": self.model,
"has_llm": self.llm_client is not None,
"unverified_markers_count": len(self.UNVERIFIED_MARKERS),
"factual_markers_count": len(self.FACTUAL_MARKERS)
}
def create_reflector() -> ReasoningReflector:
"""
创建反思器实例
Returns:
ReasoningReflector: 反思器实例
"""
try:
from openai import OpenAI
try:
from config import API_KEY, BASE_URL, RAG_CHAT_MODEL
MODEL = RAG_CHAT_MODEL
except ImportError:
from config import API_KEY, BASE_URL
MODEL = "qwen3.5-flash" # fallback
client = OpenAI(api_key=API_KEY, base_url=BASE_URL)
return ReasoningReflector(llm_client=client, model=MODEL)
except Exception as e:
logger.warning(f"创建反思器失败,使用降级模式: {e}")
return ReasoningReflector()
def reflect_and_verify(query: str, answer: str,
contexts: List[str] = None) -> ReflectionResult:
"""
便捷函数:反思并验证答案
Args:
query: 用户查询
answer: 生成的答案
contexts: 检索上下文
Returns:
ReflectionResult: 反思结果
"""
reflector = create_reflector()
return reflector.reflect(query, answer, contexts)
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("推理反思测试")
print("=" * 60)
# 测试用例
test_query = "公司的报销制度是怎样的?"
test_answer = """
根据公司规定,员工可以报销差旅费用。
报销流程通常包括:提交申请、部门审批、财务审核、打款。
可能需要提供发票和审批单大概在30天内完成。
我认为公司对报销标准有明确要求,但具体金额我不太确定。
"""
test_contexts = [
"公司报销制度规定员工可以报销差旅费用,需提供发票和审批单。",
"报销流程:提交申请 -> 部门审批 -> 财务审核 -> 打款。"
]
reflector = ReasoningReflector() # 不使用 LLM 的规则反思
print(f"\n反思器信息: {reflector.get_info()}")
print()
result = reflector.reflect(test_query, test_answer, test_contexts)
print(f"反思总结: {result.reflection_summary}")
print(f"需要补充检索: {'' if result.should_supplement else ''}")
print(f"\n声明分析:")
for i, claim in enumerate(result.claims, 1):
status = "✅ 已验证" if claim.is_verified else "⚠️ 未验证"
print(f" {i}. [{claim.claim_type.value}] {status}")
print(f" 内容: {claim.content[:50]}...")
print(f" 置信度: {claim.confidence:.2f}")
if result.verification_queries:
print(f"\n建议验证查询:")
for q in result.verification_queries:
print(f" - {q}")
print("\n" + "=" * 60)
print("✅ 测试完成")
print("=" * 60)

295
core/semantic_cache.py Normal file
View File

@@ -0,0 +1,295 @@
# -*- coding: utf-8 -*-
"""
语义缓存模块FAISS 版)
功能:
- 使用 FAISS 向量索引实现 O(1) 查找
- 语义级缓存:相似查询也能命中
- 高性能10万缓存量下查询 < 1ms
使用方式:
from core.semantic_cache import SemanticCache
cache = SemanticCache(dim=768, threshold=0.92)
# 查找
result = cache.get(query_embedding)
# 存储
cache.set(query_embedding, result)
"""
import numpy as np
import logging
from typing import Dict, Optional, List, Any
import threading
logger = logging.getLogger(__name__)
# FAISS 可选依赖
try:
import faiss
FAISS_AVAILABLE = True
except ImportError:
FAISS_AVAILABLE = False
logger.warning("FAISS 未安装,语义缓存将使用降级方案")
class SemanticCache:
"""
语义缓存FAISS 向量索引)
使用 FAISS 实现高性能向量检索,支持:
- O(1) 查找复杂度
- 10万+ 缓存量
- 亚毫秒级响应
"""
def __init__(
self,
dim: int = 768,
threshold: float = 0.92,
max_size: int = 10000
):
"""
初始化语义缓存
Args:
dim: 向量维度
threshold: 相似度阈值cosine 相似度)
max_size: 最大缓存数量
"""
self.dim = dim
self.threshold = threshold
self.max_size = max_size
self._lock = threading.RLock()
self._cache: Dict[int, Any] = {} # id -> result
self._next_id = 0
# 统计信息
self._hits = 0
self._misses = 0
if FAISS_AVAILABLE:
# 使用内积索引(需要归一化向量)
self._index = faiss.IndexFlatIP(dim)
self._use_faiss = True
logger.info(f"语义缓存初始化FAISS维度={dim},阈值={threshold}")
else:
# 降级方案:使用 numpy
self._embeddings: List[np.ndarray] = []
self._use_faiss = False
logger.warning("语义缓存降级为 numpy 方案")
def get(self, query_emb: np.ndarray) -> Optional[Dict]:
"""
查找语义缓存
Args:
query_emb: 查询向量(已归一化)
Returns:
缓存结果,未命中返回 None
"""
with self._lock:
if self._use_faiss:
return self._get_faiss(query_emb)
else:
return self._get_numpy(query_emb)
def _get_faiss(self, query_emb: np.ndarray) -> Optional[Dict]:
"""FAISS 查找"""
if self._index.ntotal == 0:
self._misses += 1
return None
# 归一化并搜索
query = self._normalize(query_emb).reshape(1, -1).astype('float32')
D, I = self._index.search(query, k=1)
if D[0][0] > self.threshold:
cache_id = int(I[0][0])
self._hits += 1
logger.debug(f"语义缓存命中,相似度={D[0][0]:.3f}")
return self._cache.get(cache_id)
self._misses += 1
return None
def _get_numpy(self, query_emb: np.ndarray) -> Optional[Dict]:
"""Numpy 降级查找"""
if not self._embeddings:
self._misses += 1
return None
query = self._normalize(query_emb)
best_score = 0
best_id = -1
for i, emb in enumerate(self._embeddings):
score = float(np.dot(query, emb))
if score > best_score:
best_score = score
best_id = i
if best_score > self.threshold:
self._hits += 1
logger.debug(f"语义缓存命中numpy相似度={best_score:.3f}")
return self._cache.get(best_id)
self._misses += 1
return None
def set(self, query_emb: np.ndarray, result: Dict) -> None:
"""
存储到语义缓存
Args:
query_emb: 查询向量
result: 缓存结果
"""
with self._lock:
if self._use_faiss:
self._set_faiss(query_emb, result)
else:
self._set_numpy(query_emb, result)
def _set_faiss(self, query_emb: np.ndarray, result: Dict) -> None:
"""FAISS 存储"""
# 检查容量
if self._index.ntotal >= self.max_size:
# LRU 淘汰:重建索引(简单实现)
logger.debug("语义缓存已满,执行淘汰")
self.clear()
# 归一化并添加
query = self._normalize(query_emb).reshape(1, -1).astype('float32')
self._index.add(query)
self._cache[self._next_id] = result
self._next_id += 1
def _set_numpy(self, query_emb: np.ndarray, result: Dict) -> None:
"""Numpy 存储"""
if len(self._embeddings) >= self.max_size:
# 淘汰最早的
self._embeddings.pop(0)
# 重建 cacheID 偏移)
old_cache = self._cache
self._cache = {}
for i, (k, v) in enumerate(old_cache.items()):
if i > 0:
self._cache[i - 1] = v
query = self._normalize(query_emb)
self._embeddings.append(query)
self._cache[len(self._embeddings) - 1] = result
def _normalize(self, emb: np.ndarray) -> np.ndarray:
"""归一化向量"""
emb = np.array(emb, dtype='float32')
norm = np.linalg.norm(emb)
if norm > 0:
emb = emb / norm
return emb
def clear(self) -> None:
"""清空缓存"""
with self._lock:
if self._use_faiss:
self._index = faiss.IndexFlatIP(self.dim)
else:
self._embeddings.clear()
self._cache.clear()
self._next_id = 0
logger.info("语义缓存已清空")
def get_stats(self) -> Dict:
"""获取统计信息"""
with self._lock:
total = self._hits + self._misses
hit_rate = self._hits / total if total > 0 else 0
return {
"total_entries": self._index.ntotal if self._use_faiss else len(self._embeddings),
"max_size": self.max_size,
"hits": self._hits,
"misses": self._misses,
"hit_rate": hit_rate,
"use_faiss": self._use_faiss
}
# ==================== 全局语义缓存实例 ====================
_semantic_cache: Optional[SemanticCache] = None
_semantic_cache_lock = threading.Lock()
def get_semantic_cache(dim: int = 768) -> SemanticCache:
"""获取全局语义缓存实例"""
global _semantic_cache
if _semantic_cache is None:
with _semantic_cache_lock:
if _semantic_cache is None:
try:
from config import SEMANTIC_CACHE_THRESHOLD
threshold = SEMANTIC_CACHE_THRESHOLD
except ImportError:
threshold = 0.92
_semantic_cache = SemanticCache(dim=dim, threshold=threshold)
return _semantic_cache
# ==================== 测试 ====================
if __name__ == "__main__":
import sys
if sys.platform == 'win32':
sys.stdout.reconfigure(encoding='utf-8')
print("=" * 60)
print("语义缓存测试")
print("=" * 60)
cache = SemanticCache(dim=128, threshold=0.9, max_size=100)
# 生成测试向量
np.random.seed(42)
def random_embedding():
emb = np.random.randn(128)
return emb / np.linalg.norm(emb)
# 存储一些向量
print("\n存储测试向量...")
base_emb = random_embedding()
cache.set(base_emb, {"answer": "测试答案1", "confidence": 0.9})
for i in range(10):
emb = random_embedding()
cache.set(emb, {"answer": f"测试答案{i+2}", "confidence": 0.8})
print(f"缓存统计: {cache.get_stats()}")
# 测试精确命中
print("\n测试精确命中...")
result = cache.get(base_emb)
print(f"结果: {result}")
# 测试相似命中
print("\n测试相似命中...")
similar_emb = base_emb + np.random.randn(128) * 0.05
similar_emb = similar_emb / np.linalg.norm(similar_emb)
result = cache.get(similar_emb)
print(f"结果: {result}")
# 测试未命中
print("\n测试未命中...")
different_emb = random_embedding()
result = cache.get(different_emb)
print(f"结果: {result}")
print(f"\n最终统计: {cache.get_stats()}")

125
core/status_codes.py Normal file
View File

@@ -0,0 +1,125 @@
"""
状态码定义
统一的业务状态码,用于 API 响应中标识操作结果。
状态码格式4位数字前两位表示类别后两位表示具体状态
- 10xx: 处理中
- 20xx: 成功
- 40xx: 客户端错误
- 50xx: 服务端错误
"""
from typing import Dict
# 状态码到消息的映射
_STATUS_MESSAGES: Dict[int, str] = {
# 处理中状态 (10xx)
1000: "处理中",
1001: "文件接收中",
1002: "文件解析中",
1003: "向量化中",
1010: "同步进行中",
1020: "出题生成中",
1021: "批阅进行中",
# 成功状态 (20xx)
2000: "操作成功",
2001: "创建成功",
2002: "文件上传成功",
2003: "批量上传完成",
2004: "删除成功",
2005: "更新成功",
2010: "同步完成",
2020: "出题成功",
2021: "批阅完成",
2030: "图片描述更新成功",
# 客户端错误 (40xx)
4000: "请求参数错误",
4001: "未授权",
4002: "禁止访问",
4003: "资源不存在",
4004: "没有上传文件",
4005: "没有选择文件",
4006: "请指定目标向量库",
4007: "不支持的文件格式",
4008: "文件大小超过限制",
4009: "文档解析失败",
4010: "文件不存在",
4011: "向量库不存在",
4012: "文件内容为空",
4013: "权限不足",
# 服务端错误 (50xx)
5000: "服务器内部错误",
5001: "服务不可用",
5002: "向量化失败",
5003: "LLM调用失败",
5010: "同步失败",
5020: "出题失败",
5021: "批阅失败",
5030: "图片描述更新失败",
}
def get_status_message(status_code: int) -> str:
"""
根据状态码获取默认消息
Args:
status_code: 业务状态码
Returns:
状态码对应的默认消息
"""
return _STATUS_MESSAGES.get(status_code, "未知状态")
# ==================== 常用状态码常量 ====================
# 处理中 (10xx)
PROCESSING = 1000
FILE_RECEIVING = 1001
FILE_PARSING = 1002
VECTORIZING = 1003
SYNC_RUNNING = 1010
EXAM_GENERATING = 1020
EXAM_GRADING = 1021
# 成功 (20xx)
SUCCESS = 2000
CREATED = 2001
UPLOAD_SUCCESS = 2002
BATCH_UPLOAD_SUCCESS = 2003
DELETE_SUCCESS = 2004
UPDATE_SUCCESS = 2005
SYNC_SUCCESS = 2010
EXAM_SUCCESS = 2020
GRADE_SUCCESS = 2021
IMAGE_DESC_SUCCESS = 2030
# 客户端错误 (40xx)
BAD_REQUEST = 4000
UNAUTHORIZED = 4001
FORBIDDEN = 4002
NOT_FOUND = 4003
NO_FILE = 4004
NO_FILE_SELECTED = 4005
NO_COLLECTION = 4006
UNSUPPORTED_FORMAT = 4007
FILE_TOO_LARGE = 4008
PARSE_ERROR = 4009
FILE_NOT_FOUND = 4010
COLLECTION_NOT_FOUND = 4011
NO_CONTENT = 4012
PERMISSION_DENIED = 4013
# 服务端错误 (50xx)
INTERNAL_ERROR = 5000
SERVICE_UNAVAILABLE = 5001
VECTORIZE_ERROR = 5002
LLM_ERROR = 5003
SYNC_ERROR = 5010
EXAM_ERROR = 5020
GRADE_ERROR = 5021
IMAGE_DESC_ERROR = 5030