| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674 |
- """
- RAG服务工具类 (基于LangChain实现)
- 使用LangChain + Milvus实现RAG服务
- 支持从Milvus向量数据库检索相关文档并通过大模型生成回答
- 作者:开发工程师
- 日期:2025年01月
- """
- import logging
- from typing import List, Optional, Dict, Any
- from pydantic import BaseModel
- from langchain_milvus import Milvus
- from langchain_openai import ChatOpenAI
- from langchain_community.callbacks.manager import get_openai_callback
- from langfuse.langchain import CallbackHandler as LangfuseCallbackHandler
- from config import Config
- from rerank_service import get_rerank_service
- # 配置日志
- logger = logging.getLogger(__name__)
- langfuse_handler = LangfuseCallbackHandler()
- class SourceDocument(BaseModel):
- """源文档信息模型"""
- doc_name: str # 文档名称
- doc_path_name: str # 文档完整路径
- doc_type: str # 文档类型
- content_preview: str # 内容预览(前200字符)
- similarity_score: Optional[float] = None # 相似度分数
- class TokenUsage(BaseModel):
- """Token使用情况模型"""
- prompt_tokens: int # 输入token数
- completion_tokens: int # 输出token数
- total_tokens: int # 总token数
- class RAGResponse(BaseModel):
- """RAG服务响应模型"""
- content: str # 大模型回复给用户的文本内容
- source_documents: List[SourceDocument] # 检索使用的源文档列表
- token_usage: Optional[TokenUsage] = None # token使用情况
- success: bool = True # 查询是否成功
- error_message: Optional[str] = None # 错误信息
- scene_info: Optional[Dict[str, Any]] = None # 场景检测信息
- class RAGService:
- """RAG服务类 (基于LangChain实现)
-
- 使用LangChain + Milvus向量数据库实现检索增强生成服务
- 支持OpenAI兼容的大模型和嵌入模型
- """
-
- # 场景检测提示词模板
- SCENE_DETECTION_TEMPLATE = """请分析用户问题属于以下哪种客服场景类型,只返回对应的数字:
- 1. 产品咨询类 - 询问产品功能、规格、价格等基本信息
- 2. 售后服务类 - 退换货、维修、质量问题等售后相关
- 3. 账户相关类 - 登录、注册、密码、个人信息等账户问题
- 4. 投诉建议类 - 对服务或产品的投诉、意见、建议
- 5. 技术支持类 - 使用方法、故障排除、技术配置等
- 6. 其他咨询类 - 不属于以上分类的一般性咨询
- 用户问题: {question}
- 请只返回场景类型对应的数字(1-6):"""
-
- # 统一的提示词模板 - 专业客服版本
- PROMPT_TEMPLATE = """你是一位专业的客服人员,请根据提供的知识库内容来回答用户的问题。
- 知识库内容:
- {context}
- 用户问题: {question}
- 回答要求:
- 1. 【角色定位】你是一位专业、耐心、友善的客服代表
- 2. 【回答原则】严格基于知识库内容回答,不得编造或推测信息
- 3. 【准确性要求】
- - 如果知识库中有明确答案,请准确完整地回答
- - 如果知识库中信息不完整,说明现有信息并提示用户可联系人工客服获取更详细信息
- - 如果知识库中完全没有相关信息,请礼貌地说明无法找到相关资料,建议用户转接人工客服
- 4. 【回答格式】
- - 使用纯文本格式,不使用markdown格式
- - 语言简洁明了,适合微信对话环境
- - 使用礼貌、专业的语调
- - 如需列举,使用数字序号或简单的分行
- 5. 【转人工提示】当遇到以下情况时,主动建议用户转接人工客服:
- - 复杂的售后问题
- - 需要个人账户信息查询的问题
- - 投诉或纠纷相关问题
- - 知识库无法覆盖的专业技术问题
- 请基于以上要求,为用户提供专业的客服回答:"""
-
- def __init__(self):
- """初始化RAG服务"""
- self.vector_store = None
- self.embeddings = None
- self.llm = None
- self.rerank_service = None # 重排序服务
- self._initialize_components()
-
- def _initialize_components(self):
- """初始化所有组件"""
- try:
- # 初始化嵌入模型
- self._initialize_embeddings()
-
- # 初始化大语言模型
- self._initialize_llm()
-
- # 初始化向量存储
- self._initialize_vector_store()
-
- # 初始化重排序服务
- self._initialize_rerank_service()
-
- logger.info("RAG服务初始化完成")
-
- except Exception as e:
- logger.error(f"RAG服务初始化失败: {e}")
- raise
-
- def _initialize_embeddings(self):
- """初始化嵌入模型"""
- try:
- # 使用自定义嵌入类,兼容OpenAI API
- from openai import OpenAI
-
- class CustomEmbeddings:
- def __init__(self, model_name: str, api_key: str, api_base: str, dimensions: int):
- self.model_name = model_name
- self.client = OpenAI(api_key=api_key, base_url=api_base)
- self.dimensions = dimensions
-
- def embed_query(self, text: str) -> List[float]:
- """嵌入单个查询"""
- return self.embed_documents([text])[0]
-
- def embed_documents(self, texts: List[str]) -> List[List[float]]:
- """嵌入多个文档"""
- try:
- response = self.client.embeddings.create(
- model=self.model_name,
- input=texts,
- dimensions=self.dimensions,
- encoding_format="float"
- )
- return [data.embedding for data in response.data]
- except Exception as e:
- logger.error(f"嵌入生成失败: {e}")
- raise
-
- self.embeddings = CustomEmbeddings(
- model_name=Config.LLM_EMBEDDING_MODEL_NAME,
- api_key=Config.LLM_EMBEDDING_API_KEY,
- api_base=Config.LLM_EMBEDDING_BASE_URL,
- dimensions=Config.MILVUS_VECTOR_DIM
- )
-
- # 测试嵌入模型
- test_embedding = self.embeddings.embed_query("测试")
- actual_dim = len(test_embedding)
-
- logger.info(f"嵌入模型初始化成功: {Config.LLM_EMBEDDING_MODEL_NAME}")
- logger.info(f"向量维度: {actual_dim}")
-
- if actual_dim != Config.MILVUS_VECTOR_DIM:
- logger.warning(f"向量维度不匹配! 实际({actual_dim}) != 期望({Config.MILVUS_VECTOR_DIM})")
-
- except Exception as e:
- logger.error(f"嵌入模型初始化失败: {e}")
- raise
-
- def _initialize_llm(self):
- """初始化大语言模型"""
- try:
- self.llm = ChatOpenAI(
- model=Config.LLM_MODEL_NAME,
- openai_api_key=Config.LLM_API_KEY,
- openai_api_base=Config.LLM_BASE_URL,
- temperature=0.1,
- max_tokens=800 # 调整为800,平衡详细度和简洁性
- )
-
- logger.info(f"大语言模型初始化成功: {Config.LLM_MODEL_NAME}")
-
- except Exception as e:
- logger.error(f"大语言模型初始化失败: {e}")
- raise
-
- def _initialize_vector_store(self):
- """初始化向量存储"""
- try:
- # 构建Milvus连接参数
- connection_args = {
- "host": Config.MILVUS_HOST,
- "port": Config.MILVUS_PORT,
- "db_name": Config.MILVUS_DATABASE
- }
-
- # 如果有用户名和密码,添加到连接参数中
- if Config.MILVUS_USER:
- connection_args["user"] = Config.MILVUS_USER
- if Config.MILVUS_PASSWORD:
- connection_args["password"] = Config.MILVUS_PASSWORD
-
- # 配置搜索参数,针对IVF_FLAT索引优化
- search_params = {
- "metric_type": "COSINE", # 与索引保持一致
- "params": {
- "nprobe": 64 # 建议设置为nlist的6.25% (64/1024),平衡性能和召回率
- }
- }
-
- # 初始化Milvus向量存储
- self.vector_store = Milvus(
- embedding_function=self.embeddings,
- collection_name=Config.MILVUS_COLLECTION_NAME,
- connection_args=connection_args,
- text_field="content", # 文本内容字段
- vector_field="content_vector", # 向量字段
- auto_id=True,
- search_params=search_params # 添加搜索参数
- )
-
- logger.info(f"Milvus向量存储初始化成功: {Config.MILVUS_COLLECTION_NAME}")
- logger.info(f"搜索参数: nprobe={search_params['params']['nprobe']}")
-
- except Exception as e:
- logger.error(f"Milvus向量存储初始化失败: {e}")
- raise
-
- def _initialize_rerank_service(self):
- """初始化重排序服务"""
- try:
- self.rerank_service = get_rerank_service()
- if self.rerank_service:
- logger.info("重排序服务初始化成功")
- else:
- logger.info("重排序服务未配置,将跳过rerank步骤")
- except Exception as e:
- logger.warning(f"重排序服务初始化失败: {e}")
- self.rerank_service = None
- def query_service(self, query: str, use_rerank: bool = True) -> RAGResponse:
- """核心查询服务方法
-
- Args:
- query: 用户输入的查询内容
- use_rerank: 是否使用重排序功能
-
- Returns:
- RAGResponse: 包含回答内容、源文档和token使用情况的响应对象
- """
- try:
- logger.info(f"🔍 开始处理查询(rerank={use_rerank}): {query}")
-
- if not query or not query.strip():
- return RAGResponse(
- content="请输入有效的查询内容",
- source_documents=[],
- success=False,
- error_message="查询内容为空"
- )
-
- # 第0步:场景检测(可选)
- # scene_info = self.detect_user_scene(query)
- scene_info = None # 暂时不使用场景检测
-
- # 第一步:向量检索获取候选文档
- initial_k = 10 if use_rerank and self.rerank_service else 3
- retriever = self.vector_store.as_retriever(
- search_type="similarity",
- search_kwargs={"k": initial_k}
- )
-
- # 获取候选文档
- candidate_docs = retriever.invoke(query)
- logger.info(f"📄 初始检索到 {len(candidate_docs)} 个候选文档")
-
- # 第二步:重排序(如果启用)
- final_docs = candidate_docs
- if use_rerank and self.rerank_service and len(candidate_docs) > 1:
- # 提取文档内容用于重排序
- doc_contents = [doc.page_content for doc in candidate_docs]
-
- # 增加重排序的top_n数量,确保不会过滤掉高相关度文档
- rerank_top_n = min(5, len(candidate_docs)) # 从3增加到5,但不超过候选文档数量
-
- # 执行重排序
- rerank_response = self.rerank_service.rerank_documents(
- query=query,
- documents=doc_contents,
- top_n=rerank_top_n
- )
-
- if rerank_response.success:
- # 根据重排序结果重新排列文档
- reranked_docs = []
- for rerank_doc in rerank_response.documents:
- if 0 <= rerank_doc.index < len(candidate_docs):
- original_doc = candidate_docs[rerank_doc.index]
- # 将相关性分数添加到元数据中
- if hasattr(original_doc, 'metadata'):
- original_doc.metadata['rerank_score'] = rerank_doc.relevance_score
- reranked_docs.append(original_doc)
-
- # 安全检查:确保原始最高相似度文档不会被完全过滤掉
- # 如果原始第1个文档不在重排序结果中,将其添加到结果中
- if candidate_docs and len(reranked_docs) > 0:
- first_doc = candidate_docs[0]
- first_doc_in_rerank = any(
- hasattr(doc, 'metadata') and
- doc.metadata.get('doc_name') == first_doc.metadata.get('doc_name') and
- doc.page_content == first_doc.page_content
- for doc in reranked_docs
- )
-
- if not first_doc_in_rerank:
- # 将原始最高相似度文档添加到重排序结果的开头
- if hasattr(first_doc, 'metadata'):
- first_doc.metadata['rerank_score'] = 1.0 # 给予最高分数
- reranked_docs.insert(0, first_doc)
- logger.info(f"🔒 安全检查:将原始最高相似度文档添加到重排序结果中")
-
- final_docs = reranked_docs[:3] # 最终仍然只取前3个
- logger.info(f"🔄 重排序完成,选择了 {len(final_docs)} 个文档")
- else:
- logger.warning(f"重排序失败,使用原始检索结果: {rerank_response.error_message}")
-
- # 第三步:使用选定的文档生成回答
- with get_openai_callback() as cb: ## 在上下文中获取 OpenAI 回调处理器,方便地公开令牌和成本信息
- # 构建上下文
- context = "\n\n".join([doc.page_content for doc in final_docs])
-
- # 使用统一的提示模板生成回答
- prompt = self.PROMPT_TEMPLATE.format(context=context, question=query)
- if (Config.LANGFUSE_ENABLE):
- answer = self.llm.invoke(prompt, config={"callbacks":[langfuse_handler]}).content
- else:
- answer = self.llm.invoke(prompt).content
-
- # 更新文档引用为最终选择的文档
- source_documents = final_docs
-
- logger.info(f"✅ 查询完成,检索到 {len(source_documents)} 个相关文档")
- logger.info(f"📄 答案长度: {len(answer)} 字符")
- logger.info(f"💰 Token使用: 输入{cb.prompt_tokens}, 输出{cb.completion_tokens}, 总计{cb.total_tokens}")
-
- # 处理源文档信息
- processed_source_docs = []
- for i, doc in enumerate(source_documents):
- try:
- # 提取文档元数据
- metadata = doc.metadata if hasattr(doc, 'metadata') else {}
- doc_name = metadata.get("doc_name", f"文档{i+1}")
- doc_path_name = metadata.get("doc_path_name", "")
- doc_type = metadata.get("doc_type", "unknown")
- rerank_score = metadata.get("rerank_score")
-
- # 获取内容预览
- content_preview = doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content
-
- source_doc = SourceDocument(
- doc_name=doc_name,
- doc_path_name=doc_path_name,
- doc_type=doc_type,
- content_preview=content_preview,
- similarity_score=rerank_score # 使用rerank分数
- )
- processed_source_docs.append(source_doc)
-
- score_info = f" (rerank: {rerank_score:.3f})" if rerank_score else ""
- logger.info(f"📖 文档{i+1}: {doc_name}{score_info} - {content_preview[:50]}...")
-
- except Exception as e:
- logger.warning(f"处理源文档{i+1}时出错: {e}")
- # 添加默认文档信息
- processed_source_docs.append(SourceDocument(
- doc_name=f"文档{i+1}",
- doc_path_name="",
- doc_type="unknown",
- content_preview="无法获取文档信息"
- ))
-
- # 构建token使用情况
- token_usage = TokenUsage(
- prompt_tokens=cb.prompt_tokens,
- completion_tokens=cb.completion_tokens,
- total_tokens=cb.total_tokens
- )
-
- return RAGResponse(
- content=answer if answer else "抱歉,我无法根据现有信息回答您的问题。",
- source_documents=processed_source_docs,
- token_usage=token_usage,
- success=True,
- scene_info=scene_info
- )
-
- except Exception as e:
- logger.error(f"❌ 查询服务失败: {e}")
- return RAGResponse(
- content="",
- source_documents=[],
- success=False,
- error_message=f"查询过程中发生错误:{str(e)}",
- scene_info=None
- )
-
- def get_similar_documents(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]:
- """获取相似文档(用于调试和分析)
-
- Args:
- query: 查询内容
- top_k: 返回文档数量
-
- Returns:
- List[Dict]: 相似文档列表
- """
- try:
- logger.info(f"🔍 搜索相似文档: {query} (top_k={top_k})")
-
- # 使用向量存储进行相似性搜索
- docs = self.vector_store.similarity_search_with_score(query, k=top_k)
-
- results = []
- for doc, score in docs:
- result = {
- "content": doc.page_content,
- "metadata": doc.metadata,
- "similarity_score": float(score),
- "doc_name": doc.metadata.get("doc_name", "未知文档"),
- "doc_path_name": doc.metadata.get("doc_path_name", ""),
- "doc_type": doc.metadata.get("doc_type", "unknown")
- }
- results.append(result)
-
- logger.info(f"✅ 找到 {len(results)} 个相似文档")
- return results
-
- except Exception as e:
- logger.error(f"❌ 获取相似文档失败: {e}")
- return []
-
- def health_check(self) -> Dict[str, Any]:
- """健康检查
-
- Returns:
- Dict: 健康状态信息
- """
- status = {
- "service": "RAGService",
- "status": "healthy",
- "components": {},
- "timestamp": None
- }
-
- try:
- from datetime import datetime
- status["timestamp"] = datetime.now().isoformat()
-
- # 检查嵌入模型
- try:
- test_embedding = self.embeddings.embed_query("健康检查")
- status["components"]["embeddings"] = {
- "status": "healthy",
- "model": Config.LLM_EMBEDDING_MODEL_NAME,
- "dimension": len(test_embedding)
- }
- except Exception as e:
- status["components"]["embeddings"] = {
- "status": "error",
- "error": str(e)
- }
- status["status"] = "degraded"
-
- # 检查大语言模型
- try:
- with get_openai_callback() as cb:
- test_response = self.llm.invoke("你好").content
- status["components"]["llm"] = {
- "status": "healthy",
- "model": Config.LLM_MODEL_NAME,
- "response_length": len(test_response) if test_response else 0,
- "test_tokens": cb.total_tokens
- }
- except Exception as e:
- status["components"]["llm"] = {
- "status": "error",
- "error": str(e)
- }
- status["status"] = "degraded"
-
- # 检查向量存储
- try:
- test_docs = self.vector_store.similarity_search("测试", k=1)
- status["components"]["vector_store"] = {
- "status": "healthy",
- "collection": Config.MILVUS_COLLECTION_NAME,
- "test_results": len(test_docs)
- }
- except Exception as e:
- status["components"]["vector_store"] = {
- "status": "error",
- "error": str(e)
- }
- status["status"] = "degraded"
-
- # 检查重排序服务
- try:
- if self.rerank_service:
- rerank_health = self.rerank_service.health_check()
- status["components"]["rerank_service"] = rerank_health
- else:
- status["components"]["rerank_service"] = {
- "status": "not_configured",
- "message": "重排序服务未配置"
- }
- except Exception as e:
- status["components"]["rerank_service"] = {
- "status": "error",
- "error": str(e)
- }
- status["status"] = "degraded"
-
- except Exception as e:
- status["status"] = "error"
- status["error"] = str(e)
-
- return status
- def detect_user_scene(self, query: str) -> Dict[str, Any]:
- """检测用户问题的场景类型
-
- Args:
- query: 用户问题
-
- Returns:
- Dict: 包含场景类型和建议的字典
- """
- try:
- # 使用LLM进行场景检测
- prompt = self.SCENE_DETECTION_TEMPLATE.format(question=query)
- response = self.llm.invoke(prompt).content.strip()
-
- # 解析场景类型
- scene_mapping = {
- "1": {"type": "产品咨询类", "priority": "normal", "suggest_human": False},
- "2": {"type": "售后服务类", "priority": "high", "suggest_human": True},
- "3": {"type": "账户相关类", "priority": "high", "suggest_human": True},
- "4": {"type": "投诉建议类", "priority": "urgent", "suggest_human": True},
- "5": {"type": "技术支持类", "priority": "normal", "suggest_human": False},
- "6": {"type": "其他咨询类", "priority": "normal", "suggest_human": False}
- }
-
- scene_info = scene_mapping.get(response, scene_mapping["6"])
- scene_info["detected_number"] = response
-
- logger.info(f"🎯 场景检测结果: {query} -> {scene_info['type']} (优先级: {scene_info['priority']})")
-
- return scene_info
-
- except Exception as e:
- logger.warning(f"场景检测失败: {e}")
- return {"type": "其他咨询类", "priority": "normal", "suggest_human": False, "detected_number": "6"}
- # 全局RAG服务实例
- _rag_service_instance = None
- def get_rag_service() -> Optional[RAGService]:
- """获取RAG服务实例(单例模式)"""
- global _rag_service_instance
-
- if _rag_service_instance is None:
- try:
- _rag_service_instance = RAGService()
- logger.info("RAG服务实例创建成功")
- except Exception as e:
- logger.error(f"RAG服务实例创建失败: {e}")
- return None
-
- return _rag_service_instance
- # 便捷函数
- def query_question(question: str) -> RAGResponse:
- """查询问题的便捷函数
-
- Args:
- question: 用户问题
-
- Returns:
- RAGResponse: 查询响应
- """
- rag_service = get_rag_service()
- if rag_service is None:
- return RAGResponse(
- content="",
- source_documents=[],
- success=False,
- error_message="RAG服务初始化失败"
- )
-
- return rag_service.query_service(question)
- if __name__ == "__main__":
- # 测试代码 - 专业客服RAG系统
- rag = get_rag_service()
- # 检测健康状态
- health = rag.health_check()
- print(f"健康状态: {health}")
- # 测试不同场景的客服问题
- test_cases = [
- "帮我介绍一下盗窃罪", # 其他咨询类
- "我要退货,怎么办理?", # 售后服务类
- "忘记密码了,如何重置?", # 账户相关类
- "你们的服务太差了!", # 投诉建议类
- "产品无法连接WiFi", # 技术支持类
- ]
- print(100 * "=")
-
- for i, question in enumerate(test_cases, 1):
- print(100 * "*")
- print(f"\n=== 测试案例 {i}: {question} ===")
-
- # 测试场景检测
- scene_info = rag.detect_user_scene(question)
- print(f"🎯 场景类型: {scene_info['type']}")
- print(f"🚨 优先级: {scene_info['priority']}")
- print(f"👤 建议转人工: {'是' if scene_info['suggest_human'] else '否'}")
-
- # 测试完整查询
- response = rag.query_service(question, use_rerank=True)
- print(f"💬 客服回答: {response.content}")
- if response.scene_info:
- print(f"📊 场景信息: {response.scene_info['type']}")
- if response.token_usage:
- print(f"💰 Token使用: {response.token_usage.total_tokens}")
- print(f"📚 参考文档数: {len(response.source_documents)}")
-
- print(100 * "=")
-
- print("\n=== 测试经典产品咨询 ===")
- response = rag.query_service("介绍一下老人与海这本书", use_rerank=True)
- print(f"回答: {response.content}")
- print(f"场景信息: {response.scene_info}")
- print(f"源文档数量: {len(response.source_documents)}")
- if response.token_usage:
- print(f"Token使用: {response.token_usage.total_tokens}")
- for doc in response.source_documents:
- print("--------------------------------")
- print(f"源文档: {doc.doc_name}")
- print(f"相似度分数: {doc.similarity_score}")
- print(f"内容预览: {doc.content_preview[:50]}...")
-
|