import os import re import time import math import numpy as np from datetime import datetime from typing import List, Dict, Any, Optional from langchain_core.documents import Document from langchain_text_splitters import RecursiveCharacterTextSplitter from appPublic.log import debug, error, info from filetxt.loader import fileloader, File2Text from rag.uapi_service import APIService from rag.service_opts import get_service_params from rag.transaction_manager import TransactionManager, OperationType from pdf2image import convert_from_path import pytesseract import base64 from pathlib import Path class RagOperations: """RAG 操作类,提供所有通用的 RAG 操作""" def __init__(self): self.api_service = APIService() async def load_and_chunk_document(self, realpath: str, timings: Dict, transaction_mgr: TransactionManager = None) -> List[Document]: """加载文件并进行文本分片""" debug(f"加载文件: {realpath}") start_load = time.time() # 检查文件格式支持 supported_formats = File2Text.supported_types() debug(f"支持的文件格式:{supported_formats}") ext = realpath.rsplit('.', 1)[1].lower() if '.' in realpath else '' if ext not in supported_formats: raise ValueError(f"不支持的文件格式: {ext}, 支持的格式: {', '.join(supported_formats)}") text = fileloader(realpath) if ext == 'pdf': debug(f"pdf原生提取结果是:{text}") if not text or len(text.strip()) == 0: # 更严格的空值检查 debug(f"pdf原生提取失败,尝试扫描件提取") ocr_text = self.pdf_to_text(realpath) debug(f"pdf扫描件抽取的文本内容是:{ocr_text}") text = ocr_text # 只在原生提取失败时使用OCR结果 # 只在有文本内容时进行清洗 if text and len(text.strip()) > 0: # 或者保留更多有用字符 text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s.;,\n/]', '', text) else: error(f"文件 {realpath} 无法提取任何文本内容") text = "" # 确保为空字符串 timings["load_file"] = time.time() - start_load debug(f"加载文件耗时: {timings['load_file']:.2f} 秒, 文本长度: {len(text)}") # # 加载文件内容 # text = fileloader(realpath) # debug(f"pdf原生提取结果是:{text}") # if len(text) == 0: # debug(f"pdf原生提取失败,尝试扫描件提取") # text = self.pdf_to_text(realpath) # debug(f"pdf扫描件抽取的文本内容是:{text}") # text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s.;,\n/]', '', text) # timings["load_file"] = time.time() - start_load # debug(f"加载文件耗时: {timings['load_file']:.2f} 秒, 文本长度: {len(text)}") # # if not text or not text.strip(): # raise ValueError(f"文件 {realpath} 加载为空") # 分片处理 document = Document(page_content=text) text_splitter = RecursiveCharacterTextSplitter( chunk_size=500, chunk_overlap=100, length_function=len ) debug("开始分片文件内容") start_split = time.time() chunks = text_splitter.split_documents([document]) timings["split_text"] = time.time() - start_split debug(f"文本分片耗时: {timings['split_text']:.2f} 秒, 分片数量: {len(chunks)}") if not chunks: raise ValueError(f"文件 {realpath} 未生成任何文档块") # 记录事务操作 if transaction_mgr: transaction_mgr.add_operation( OperationType.FILE_LOAD, {'realpath': realpath, 'chunks_count': len(chunks)} ) return chunks def pdf_to_text( self, pdf_path: str, output_txt: Optional[str] = None, dpi: int = 300, lang: str = 'chi_sim+chi_tra+eng' ) -> str: """ 将扫描版 PDF 转为文字(你原来的代码,一行调用版) 参数: pdf_path: PDF 文件路径(字符串) output_txt: 如果提供,会自动保存到这个 txt 文件(可选) dpi: 图片分辨率,默认 300(越高越清晰) lang: 语言包,默认中文简体+繁体+英文 返回: 提取出的完整文字(字符串) """ # PDF 转图片 images = convert_from_path(pdf_path, dpi=dpi) # OCR 识别 text = '' for img in images: text += pytesseract.image_to_string(img, lang=lang) + '\n' # 可选:自动保存到文件 if output_txt: with open(output_txt, 'w', encoding='utf-8') as f: f.write(text) return text async def generate_embeddings(self, request, chunks: List[Document], service_params: Dict, userid: str, timings: Dict, transaction_mgr: TransactionManager = None) -> List[List[float]]: """生成嵌入向量""" debug("调用嵌入服务生成向量") start_embedding = time.time() texts = [chunk.page_content for chunk in chunks] embeddings = [] # 批量处理嵌入 for i in range(0, len(texts), 10): batch_texts = texts[i:i + 10] batch_embeddings = await self.api_service.get_embeddings( request=request, texts=batch_texts, upappid=service_params['embedding'], apiname="BAAI/bge-m3", user=userid ) embeddings.extend(batch_embeddings) if not embeddings or not all(len(vec) == 1024 for vec in embeddings): raise ValueError("所有嵌入向量必须是长度为 1024 的浮点数列表") timings["generate_embeddings"] = time.time() - start_embedding debug(f"生成嵌入向量耗时: {timings['generate_embeddings']:.2f} 秒, 嵌入数量: {len(embeddings)}") # 记录事务操作 if transaction_mgr: transaction_mgr.add_operation( OperationType.EMBEDDING, {'embeddings_count': len(embeddings)} ) return embeddings async def generate_multi_embeddings(self, request, inputs: List[Dict], service_params: Dict, userid: str, timings: Dict, transaction_mgr: TransactionManager = None) -> Dict[str, Dict]: """调用多模态嵌入服务(CLIP)""" debug("调用多模态嵌入服务") start = time.time() result = await self.api_service.get_multi_embeddings( request=request, inputs=inputs, upappid=service_params['embedding'], apiname="black/clip", user=userid ) debug(f"多模态返回结果是{result}") timings["multi_embedding"] = time.time() - start debug(f"多模态嵌入耗时: {timings['multi_embedding']:.2f}秒,处理 {len(result)} 条") # ==================== 新增:错误检查 + 过滤 ==================== valid_results = {} error_count = 0 error_examples = [] for key, info in result.items(): if info.get("type") == "error": error_count += 1 if len(error_examples) < 3: # 只记录前3个 error_examples.append(f"{key} → {info['error']}") # 直接丢弃错误条目 continue valid_results[key] = info if error_count > 0: error(f"多模态嵌入失败 {error_count} 条!示例:{'; '.join(error_examples)}") raise RuntimeError(f"多模态嵌入有{error_count} 条失败") else: debug("多模态嵌入全部成功!") if transaction_mgr: transaction_mgr.add_operation( OperationType.EMBEDDING, {'count': len(result)} ) return result # async def force_l2_normalize(self, vector: List[float]) -> List[float]: # """万无一失的 L2 归一化""" # arr = np.array(vector, dtype=np.float32) # norm = np.linalg.norm(arr) # if norm == 0: # return vector # 全零向量无法归一化 # return (arr / norm).tolist() # 统一插入向量库 async def insert_all_vectors( self, request, text_chunks: List[Document], realpath: str, orgid: str, fiid: str, document_id: str, service_params: Dict, userid: str, db_type: str, timings: Dict, img_paths: List[str] = None, text_embeddings: List[List[float]] = None, multi_results: Dict = None, transaction_mgr: TransactionManager = None ) -> Dict[str, int]: """ 统一插入函数:支持两种模式 1. 纯文本模式:text_embeddings 有值 2. 多模态模式:multi_results 有值(来自 generate_multi_embeddings) """ img_paths = img_paths or [] all_chunks = [] start = time.time() filename = os.path.basename(realpath) upload_time = datetime.now().isoformat() # ==================== 1. 纯文本模式(BGE) ==================== if text_embeddings is not None: debug(f"【纯文本模式】插入 {len(text_embeddings)} 条文本向量") for i, chunk in enumerate(text_chunks): all_chunks.append({ "userid": orgid, "knowledge_base_id": fiid, "text": chunk.page_content, "vector": text_embeddings[i], "document_id": document_id, "filename": filename, "file_path": realpath, "upload_time": upload_time, "file_type": "text", }) # ==================== 2. 多模态模式(CLIP 混排) ==================== if multi_results is not None: debug(f"【多模态模式】解析 {len(multi_results)} 条 CLIP 结果") # 遍历 multi_results for raw_key, info in multi_results.items(): typ = info["type"] # vector = info["vector"] # debug(f"从后端传回来的向量数据是:{vector}") # emb = await self.force_l2_normalize(info["vector"]) # debug(f"归一化后的向量数据是:{emb}") # --- 文本 --- if typ == "text": # raw_key 就是原文 all_chunks.append({ "userid": orgid, "knowledge_base_id": fiid, "text": raw_key, "vector": info["vector"], "document_id": document_id, "filename": filename, "file_path": realpath, "upload_time": upload_time, "file_type": "text", }) continue # --- 图像 --- if typ == "image": img_path = info.get("path") or raw_key img_name = os.path.basename(img_path) # 整图向量 if "vector" in info: all_chunks.append({ "userid": orgid, "knowledge_base_id": fiid, "text": f"[Image: {img_path}]图片来源于文件{realpath}", "vector": info["vector"], "document_id": document_id, "filename": img_name, "file_path": realpath, "upload_time": upload_time, "file_type": "image", }) # 人脸向量 face_vecs = info.get("face_vecs", []) face_count = len(face_vecs) # if face_count > 0: # for f_idx, fvec in enumerate(face_vecs): # debug(f"人脸向量维度是:{len(fvec)}") # all_chunks.append({ # "userid": orgid, # "knowledge_base_id": fiid, # "text": f"[Face {f_idx + 1}/{face_count} in {img_name}]人脸来源于{realpath}的{img_path}图片", # "vector": fvec, # "document_id": document_id, # "filename": img_name, # "file_path": realpath, # "upload_time": upload_time, # "file_type": "face", # }) continue # --- 视频 --- if typ == "video": video_path = info.get("path") or raw_key video_name = os.path.basename(video_path) if "vector" in info: all_chunks.append({ "userid": orgid, "knowledge_base_id": fiid, "text": f"[Video: {video_name}]", "vector": info["vector"], "document_id": document_id, "filename": video_path, "file_path": realpath, "upload_time": upload_time, "file_type": "video", }) # 视频人脸 face_vecs = info.get("face_vecs", []) face_count = len(face_vecs) # if face_count > 0 : # for f_idx, fvec in enumerate(face_vecs): # all_chunks.append({ # "userid": orgid, # "knowledge_base_id": fiid, # "text": f"[Face {f_idx + 1}/{face_count} in video {video_name}]来源于{video_path}", # "vector": fvec, # "document_id": document_id, # "filename": video_path, # "file_path": realpath, # "upload_time": upload_time, # "file_type": "face", # }) continue # --- 音频 --- if typ == "audio": audio_path = info.get("path") or raw_key audio_name = os.path.basename(audio_path) if "vector" in info: all_chunks.append({ "userid": orgid, "knowledge_base_id": fiid, "text": f"[Audio: {audio_name}]", "vector": info["vector"], "document_id": document_id, "filename": audio_path, "file_path": realpath, "upload_time": upload_time, "file_type": "audio", }) continue # --- 未知类型 --- debug(f"未知类型跳过: {typ} → {raw_key}") # ==================== 3. 批量插入 Milvus ==================== if not all_chunks: debug("无向量需要插入") return {"text": 0, "image": 0, "face": 0} for i in range(0, len(all_chunks), 10): batch = all_chunks[i:i + 10] result = await self.api_service.milvus_insert_document( request=request, chunks=batch, upappid=service_params['vdb'], apiname="milvus/insertdocument", user=userid, db_type=db_type ) if result.get("status") != "success": raise ValueError(f"Milvus 插入失败: {result.get('message')}") # ==================== 4. 统一回滚(只登记一次) ==================== if transaction_mgr and all_chunks: async def rollback_all(data, context): try: await self.delete_from_vector_db( request=context['request'], orgid=data['orgid'], realpath=data['realpath'], fiid=data['fiid'], id=data['document_id'], service_params=context['service_params'], userid=context['userid'], db_type=data['db_type'] ) return f"已回滚 document_id={data['document_id']} 的所有向量" except Exception as e: error(f"统一回滚失败: {e}") raise transaction_mgr.add_operation( OperationType.VDB_INSERT, { 'orgid': orgid, 'realpath': realpath, 'fiid': fiid, 'id': document_id, 'db_type': db_type }, rollback_func=rollback_all ) # ==================== 5. 统计返回 ==================== stats = { "text": len([c for c in all_chunks if c["file_type"] == "text"]), "image": len([c for c in all_chunks if c["file_type"] == "image"]), "face": len([c for c in all_chunks if c["file_type"] == "face"]) } timings["insert_all"] = time.time() - start debug( f"统一插入完成: 文本 {stats['text']}, 图像 {stats['image']}, 人脸 {stats['face']}, 耗时 {timings['insert_all']:.2f}s") return stats # async def insert_to_vector_db(self, request, chunks: List[Document], embeddings: List[List[float]], # realpath: str, orgid: str, fiid: str, id: str, service_params: Dict, # userid: str, db_type: str, timings: Dict, # transaction_mgr: TransactionManager = None): # """插入向量数据库""" # debug(f"准备数据并调用插入文件端点: {realpath}") # filename = os.path.basename(realpath).rsplit('.', 1)[0] # ext = realpath.rsplit('.', 1)[1].lower() if '.' in realpath else '' # upload_time = datetime.now().isoformat() # # chunks_data = [ # { # "userid": orgid, # "knowledge_base_id": fiid, # "text": chunk.page_content, # "vector": embeddings[i], # "document_id": id, # "filename": filename + '.' + ext, # "file_path": realpath, # "upload_time": upload_time, # "file_type": ext, # } # for i, chunk in enumerate(chunks) # ] # # start_milvus = time.time() # for i in range(0, len(chunks_data), 10): # batch_chunks = chunks_data[i:i + 10] # debug(f"传入的数据是:{batch_chunks}") # result = await self.api_service.milvus_insert_document( # request=request, # chunks=batch_chunks, # db_type=db_type, # upappid=service_params['vdb'], # apiname="milvus/insertdocument", # user=userid # ) # if result.get("status") != "success": # raise ValueError(result.get("message", "Milvus 插入失败")) # # timings["insert_milvus"] = time.time() - start_milvus # debug(f"Milvus 插入耗时: {timings['insert_milvus']:.2f} 秒") # # # 记录事务操作,包含回滚函数 # if transaction_mgr: # async def rollback_vdb_insert(data, context): # try: # # 防御性检查 # required_context = ['request', 'service_params', 'userid'] # missing_context = [k for k in required_context if k not in context or context[k] is None] # if missing_context: # raise ValueError(f"回滚上下文缺少字段: {', '.join(missing_context)}") # # required_data = ['orgid', 'realpath', 'fiid', 'id', 'db_type'] # missing_data = [k for k in required_data if k not in data or data[k] is None] # if missing_data: # raise ValueError(f"VDB_INSERT 数据缺少字段: {', '.join(missing_data)}") # # await self.delete_from_vector_db( # context['request'], data['orgid'], data['realpath'], # data['fiid'], data['id'], context['service_params'], # context['userid'], data['db_type'] # ) # return f"已回滚向量数据库插入: {data['id']}" # except Exception as e: # error(f"回滚向量数据库失败: document_id={data.get('id', '未知')}, 错误: {str(e)}") # raise # # transaction_mgr.add_operation( # OperationType.VDB_INSERT, # { # 'orgid': orgid, 'realpath': realpath, 'fiid': fiid, # 'id': id, 'db_type': db_type # }, # rollback_func=rollback_vdb_insert # ) # # return chunks_data # # async def insert_image_vectors( # self, # request, # multi_results: Dict[str, Dict], # realpath: str, # orgid: str, # fiid: str, # document_id: str, # service_params: Dict, # userid: str, # db_type: str, # timings: Dict, # transaction_mgr: TransactionManager = None # ) -> tuple[int, int]: # # start = time.time() # image_chunks = [] # face_chunks = [] # # for img_path, info in multi_results.items(): # # img_name = os.path.basename(img_path) # # # 1. 插入整张图 # if info.get("type") in ["image", "video"] and "vector" in info: # image_chunks.append({ # "userid": orgid, # "knowledge_base_id": fiid, # "text": f"[Image: {img_path}]", # "vector": info["vector"], # "document_id": document_id, # "filename": os.path.basename(realpath), # "file_path": realpath, # "upload_time": datetime.now().isoformat(), # "file_type": "image" # }) # # # 2. 插入每张人脸 # face_vecs = info.get("face_vecs") # face_count = info.get("face_count", 0) # # if face_count > 0 and face_vecs and len(face_vecs) == face_count: # for idx, face_vec in enumerate(face_vecs): # face_chunks.append({ # "userid": orgid, # "knowledge_base_id": fiid, # "text": f"[Face {idx + 1}/{face_count} in {img_path}]", # "vector": face_vec, # "document_id": document_id, # "filename": os.path.basename(realpath), # "file_path": realpath, # "upload_time": datetime.now().isoformat(), # "file_type": "face", # }) # # if image_chunks: # for i in range(0, len(image_chunks), 10): # await self.api_service.milvus_insert_document( # request=request, # chunks=image_chunks[i:i + 10], # upappid=service_params['vdb'], # apiname="milvus/insertdocument", # user=userid, # db_type=db_type # ) # # if face_chunks: # for i in range(0, len(face_chunks), 10): # await self.api_service.milvus_insert_document( # request=request, # chunks=face_chunks[i:i + 10], # upappid=service_params['vdb'], # apiname="milvus/insertdocument", # user=userid, # db_type=db_type # ) # timings["insert_images"] = time.time() - start # image_count = len(image_chunks) # face_count = len(face_chunks) # # debug(f"多模态插入完成: 图像 {image_count} 条, 人脸 {face_count} 条") # # if transaction_mgr and (image_count + face_count > 0): # transaction_mgr.add_operation( # OperationType.IMAGE_VECTORS_INSERT, # {"images": image_count, "faces": face_count, "document_id": document_id} # ) # # # 记录事务操作,包含回滚函数 # if transaction_mgr: # async def rollback_multimodal(data, context): # try: # # 防御性检查 # required_context = ['request', 'service_params', 'userid'] # missing_context = [k for k in required_context if k not in context or context[k] is None] # if missing_context: # raise ValueError(f"回滚上下文缺少字段: {', '.join(missing_context)}") # # required_data = ['orgid', 'realpath', 'fiid', 'id', 'db_type'] # missing_data = [k for k in required_data if k not in data or data[k] is None] # if missing_data: # raise ValueError(f"多模态回滚数据缺少字段: {', '.join(missing_data)}") # # await self.delete_from_vector_db( # context['request'], data['orgid'], data['realpath'], # data['fiid'], data['id'], context['service_params'], # context['userid'], data['db_type'] # ) # return f"已回滚多模态向量: {data['id']}" # except Exception as e: # error(f"多模态回滚向量数据库失败: document_id={data.get('id', '未知')}, 错误: {str(e)}") # raise # # transaction_mgr.add_operation( # OperationType.VDB_INSERT, # { # 'orgid': orgid, 'realpath': realpath, 'fiid': fiid, # 'id': id, 'db_type': db_type # }, # rollback_func=rollback_multimodal # ) # # return image_count, face_count async def insert_to_vector_text(self, request, db_type: str, fields: Dict, service_params: Dict, userid: str, timings: Dict) -> List[Dict]: """插入单一纯文本到向量数据库,支持动态 schema""" chunk_data = {} debug("准备单一纯文本数据并调用插入端点") start = time.time() for key, value in fields.items(): chunk_data[key] = value chunks_data = [chunk_data] debug(f"向量库插入传入的数据是:{chunks_data}") # 调用 Milvus 插入 result = await self.api_service.milvus_insert_document( request=request, chunks=chunks_data, upappid=service_params['vdb'], apiname="milvus/insertdocument", user=userid, db_type=db_type ) if result.get("status") != "success": raise ValueError(result.get("message", "Milvus 插入失败")) debug(f"成功插入纯文本到集合 {result.get('collection_name')}") timings["textinsert"] = time.time() - start debug(f"插入纯文本耗时: {timings['textinsert']:.2f} 秒") return chunks_data async def extract_triples(self, request, chunks: List[Document], service_params: Dict, userid: str, timings: Dict, transaction_mgr: TransactionManager = None) -> List[Dict]: """抽取三元组""" debug("调用三元组抽取服务") start_triples = time.time() chunk_texts = [doc.page_content for doc in chunks] triples = [] for i, chunk in enumerate(chunk_texts): result = await self.api_service.extract_triples( request=request, text=chunk, upappid=service_params['triples'], apiname="Babelscape/mrebel-large", user=userid ) if isinstance(result, list): triples.extend(result) debug(f"分片 {i + 1} 抽取到 {len(result)} 个三元组") else: error(f"分片 {i + 1} 处理失败: {str(result)}") # 去重和优化三元组 unique_triples = self._deduplicate_triples(triples) timings["extract_triples"] = time.time() - start_triples debug(f"三元组抽取耗时: {timings['extract_triples']:.2f} 秒, 抽取到 {len(unique_triples)} 个三元组") # 记录事务操作 if transaction_mgr: transaction_mgr.add_operation( OperationType.TRIPLES_EXTRACT, {'triples_count': len(unique_triples)} ) return unique_triples async def insert_to_graph_db(self, request, triples: List[Dict], id: str, fiid: str, orgid: str, service_params: Dict, userid: str, timings: Dict, transaction_mgr: TransactionManager = None): """插入图数据库""" debug(f"插入 {len(triples)} 个三元组到 Neo4j") start_neo4j = time.time() if triples: for i in range(0, len(triples), 30): batch_triples = triples[i:i + 30] neo4j_result = await self.api_service.neo4j_insert_triples( request=request, triples=batch_triples, document_id=id, knowledge_base_id=fiid, userid=orgid, upappid=service_params['gdb'], apiname="neo4j/inserttriples", user=userid ) if neo4j_result.get("status") != "success": raise ValueError(f"Neo4j 三元组插入失败: {neo4j_result.get('message', '未知错误')}") info(f"文件三元组成功插入 Neo4j: {neo4j_result.get('message')}") timings["insert_neo4j"] = time.time() - start_neo4j debug(f"Neo4j 插入耗时: {timings['insert_neo4j']:.2f} 秒") else: debug("未抽取到三元组") timings["insert_neo4j"] = 0.0 # 记录事务操作,包含回滚函数 if transaction_mgr: async def rollback_gdb_insert(data, context): await self.delete_from_graph_db( context['request'], data['id'], context['service_params'], context['userid'] ) return f"已回滚图数据库插入: {data['id']}" transaction_mgr.add_operation( OperationType.GDB_INSERT, {'id': id, 'triples_count': len(triples)}, rollback_func=rollback_gdb_insert ) async def delete_from_vector_db(self, request, orgid: str, realpath: str, fiid: str, id: str, service_params: Dict, userid: str, db_type: str): """从向量数据库删除文档""" debug(f"调用删除文件端点: userid={orgid}, file_path={realpath}, knowledge_base_id={fiid}, document_id={id}") milvus_result = await self.api_service.milvus_delete_document( request=request, userid=orgid, file_path=realpath, knowledge_base_id=fiid, document_id=id, db_type=db_type, upappid=service_params['vdb'], apiname="milvus/deletedocument", user=userid ) if milvus_result.get("status") != "success": raise ValueError(milvus_result.get("message", "Milvus 删除失败")) async def delete_from_graph_db(self, request, id: str, service_params: Dict, userid: str): """从图数据库删除文档""" debug(f"调用 Neo4j 删除文档端点: document_id={id}") neo4j_result = await self.api_service.neo4j_delete_document( request=request, document_id=id, upappid=service_params['gdb'], apiname="neo4j/deletedocument", user=userid ) if neo4j_result.get("status") != "success": raise ValueError(neo4j_result.get("message", "Neo4j 删除失败")) nodes_deleted = neo4j_result.get("nodes_deleted", 0) rels_deleted = neo4j_result.get("rels_deleted", 0) info(f"成功删除 document_id={id} 的 {nodes_deleted} 个 Neo4j 节点和 {rels_deleted} 个关系") return nodes_deleted, rels_deleted async def extract_entities(self, request, query: str, service_params: Dict, userid: str, timings: Dict) -> List[str]: """提取实体""" debug(f"提取查询实体: {query}") start_extract = time.time() entities = await self.api_service.extract_entities( request=request, query=query, upappid=service_params['entities'], apiname="LTP/small", user=userid ) timings["entity_extraction"] = time.time() - start_extract debug(f"提取实体: {entities}, 耗时: {timings['entity_extraction']:.3f} 秒") return entities async def match_triplets(self, request, query: str, entities: List[str], orgid: str, fiids: List[str], service_params: Dict, userid: str, timings: Dict) -> List[Dict]: """匹配三元组""" debug("开始三元组匹配") start_triplet = time.time() all_triplets = [] for kb_id in fiids: debug(f"调用 Neo4j 三元组匹配: knowledge_base_id={kb_id}") try: neo4j_result = await self.api_service.neo4j_match_triplets( request=request, query=query, query_entities=entities, userid=orgid, knowledge_base_id=kb_id, upappid=service_params['gdb'], apiname="neo4j/matchtriplets", user=userid ) if neo4j_result.get("status") == "success": triplets = neo4j_result.get("triplets", []) all_triplets.extend(triplets) debug(f"知识库 {kb_id} 匹配到 {len(triplets)} 个三元组") else: error(f"Neo4j 三元组匹配失败: knowledge_base_id={kb_id}, 错误: {neo4j_result.get('message', '未知错误')}") except Exception as e: error(f"Neo4j 三元组匹配失败: knowledge_base_id={kb_id}, 错误: {str(e)}") continue timings["triplet_matching"] = time.time() - start_triplet debug(f"三元组匹配总耗时: {timings['triplet_matching']:.3f} 秒") return all_triplets async def generate_query_vector( self, request, text: str, service_params: Dict, userid: str, timings: Dict, embedding_mode: int = 0 ) -> List[float]: """生成查询向量(支持文本/多模态)""" debug(f"生成查询向量: mode={embedding_mode}, text='{text[:100]}...'") start_vector = time.time() if embedding_mode == 0: # === 模式 0:纯文本嵌入(BAAI/bge-m3)=== debug("使用 BAAI/bge-m3 文本嵌入") vectors = await self.api_service.get_embeddings( request=request, texts=[text], upappid=service_params['embedding'], apiname="BAAI/bge-m3", user=userid ) if not vectors or not isinstance(vectors, list) or len(vectors) == 0: raise ValueError("bge-m3 返回空结果") query_vector = vectors[0] if len(query_vector) != 1024: raise ValueError(f"bge-m3 返回向量维度错误: {len(query_vector)}") elif embedding_mode == 1: # === 模式 1:多模态嵌入(black/clip)=== debug("使用 black/clip 多模态嵌入") inputs = [{"type": "text", "content": text}] result = await self.api_service.get_multi_embeddings( request=request, inputs=inputs, upappid=service_params['embedding'], apiname="black/clip", user=userid ) query_vector = None for key, info in result.items(): if info.get("type") == "error": debug(f"CLIP 返回错误跳过: {info['error']}") continue if "vector" in info and isinstance(info["vector"], list) and len(info["vector"]) == 1024: query_vector = info["vector"] debug(f"成功获取 CLIP 向量(来自 {info['type']})") break if query_vector is None: raise ValueError("black/clip 未返回任何有效 1024 维向量") else: raise ValueError(f"不支持的 embedding_mode: {embedding_mode}") # 最终统一校验 if not isinstance(query_vector, list) or len(query_vector) != 1024: raise ValueError(f"查询向量必须是长度为 1024 的浮点数列表,实际: {len(query_vector)}") timings["vector_generation"] = time.time() - start_vector debug(f"生成查询向量成功,耗时: {timings['vector_generation']:.3f} 秒,模式: {embedding_mode}") return query_vector async def generate_image_vector( self, request, img_path: str, service_params: Dict, userid: str, timings: Dict, embedding_mode: int = 0 ) -> List[float]: """生成查询向量(支持文本/多模态)""" debug(f"生成查询向量: mode={embedding_mode}, image={img_path}") start_vector = time.time() if embedding_mode == 0: raise ValueError(f"纯文本没有这个功能,请重新选择服务") elif embedding_mode == 1: # === 模式 1:多模态嵌入(black/clip)=== debug("使用 black/clip 多模态嵌入") inputs = [] try: ext = Path(img_path).suffix.lower() if ext not in {".png", ".jpg", ".jpeg", ".webp", ".bmp"}: ext = ".jpg" mime_map = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".bmp": "image/bmp" } mime_type = mime_map.get(ext, "image/jpeg") with open(img_path, "rb") as f: b64 = base64.b64encode(f.read()).decode("utf-8") data_uri = f"data:{mime_type};base64,{b64}" inputs.append({ "type": "image", "data": data_uri }) debug(f"已添加图像({mime_type}, {len(b64) / 1024:.1f}KB): {Path(img_path).name}") except Exception as e: debug(f"图像处理失败,跳过: {img_path} → {e}") result = await self.api_service.get_multi_embeddings( request=request, inputs=inputs, upappid=service_params['embedding'], apiname="black/clip", user=userid ) image_vector = None for key, info in result.items(): if info.get("type") == "error": debug(f"CLIP 返回错误跳过: {info['error']}") continue if "vector" in info and isinstance(info["vector"], list) and len(info["vector"]) == 1024: image_vector = info["vector"] debug(f"成功获取 CLIP 向量(来自 {info['type']})") break if image_vector is None: raise ValueError("black/clip 未返回任何有效 1024 维向量") else: raise ValueError(f"不支持的 embedding_mode: {embedding_mode}") # 最终统一校验 if not isinstance(image_vector, list) or len(image_vector) != 1024: raise ValueError(f"查询向量必须是长度为 1024 的浮点数列表,实际: {len(image_vector)}") timings["vector_generation"] = time.time() - start_vector debug(f"生成查询向量成功,耗时: {timings['vector_generation']:.3f} 秒,模式: {embedding_mode}") return image_vector async def vector_search(self, request, query_vector: List[float], orgid: str, fiids: List[str], limit: int, service_params: Dict, userid: str, timings: Dict) -> List[Dict]: """向量搜索""" debug("开始向量搜索") start_search = time.time() result = await self.api_service.milvus_search_query( request=request, query_vector=query_vector, userid=orgid, knowledge_base_ids=fiids, limit=limit, offset=0, upappid=service_params['vdb'], apiname="mlvus/searchquery", user=userid ) if result.get("status") != "success": raise ValueError(f"向量搜索失败: {result.get('message', '未知错误')}") search_results = result.get("results", []) timings["vector_search"] = time.time() - start_search debug(f"向量搜索耗时: {timings['vector_search']:.3f} 秒") debug(f"从向量数据中搜索到{len(search_results)}条数据") return search_results async def rerank_results(self, request, query: str, results: List[Dict], top_n: int, service_params: Dict, userid: str, timings: Dict) -> List[Dict]: """重排序结果""" debug("开始重排序") start_rerank = time.time() reranked_results = await self.api_service.rerank_results( request=request, query=query, results=results, top_n=top_n, upappid=service_params['reranker'], apiname="BAAI/bge-reranker-v2-m3", user=userid ) reranked_results = sorted(reranked_results, key=lambda x: x.get('rerank_score', 0), reverse=True) timings["reranking"] = time.time() - start_rerank debug(f"重排序耗时: {timings['reranking']:.3f} 秒") debug(f"重排序分数分布: {[round(r.get('rerank_score', 0), 3) for r in reranked_results]}") return reranked_results def _deduplicate_triples(self, triples: List[Dict]) -> List[Dict]: """去重和优化三元组""" unique_triples = [] seen = set() for t in triples: identifier = (t['head'].lower(), t['tail'].lower(), t['type'].lower()) if identifier not in seen: seen.add(identifier) unique_triples.append(t) else: # 如果发现更具体的类型,则替换 for existing in unique_triples: if (existing['head'].lower() == t['head'].lower() and existing['tail'].lower() == t['tail'].lower() and len(t['type']) > len(existing['type'])): unique_triples.remove(existing) unique_triples.append(t) debug(f"替换三元组为更具体类型: {t}") break return unique_triples def format_search_results(self, results: List[Dict], limit: int) -> List[Dict]: formatted = [] for res in results[:limit]: # # 优先 rerank,其次用向量相似度(直接用,不要反) # if res.get('rerank_score') is not None: # score = res.get('rerank_score') # else: # score = res.get('distance', 0.0) distance = res.get('distance', 0.0) rerank_score = res.get('rerank_score', 0.0) formatted.append({ "content": res.get('text', ''), "title": res.get('metadata', {}).get('filename', 'Untitled'), "metadata": { "document_id": res.get('metadata', {}).get('document_id', ''), "distance": distance, "rerank_score": rerank_score, } }) return formatted # async def save_uploaded_photo(self, image_file: FileStorage, orgid: str) -> str: # """ # 把前端上传的图片保存到 /home/wangmeihua/kyrag/data/photo 目录下 # 返回保存后的绝对路径(字符串),供 generate_img_vector 使用 # """ # if not image_file or not hasattr(image_file, "filename"): # raise ValueError("无效的图片上传对象") # # # 为了安全,按 orgid 分目录存放(避免不同公司文件混在一起) # org_dir = UPLOAD_PHOTO_DIR / orgid # org_dir.mkdir(parents=True, exist_ok=True) # # # 生成唯一文件名,保留原始后缀 # suffix = Path(image_file.filename).suffix.lower() # if not suffix or suffix not in {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"}: # suffix = ".jpg" # # unique_name = f"{uuid.uuid4().hex}{suffix}" # save_path = org_dir / unique_name # # # 真正落盘 # image_file.save(str(save_path)) # debug(f"图片已保存: {save_path} (原始名: {image_file.filename})") # # # 返回字符串路径,generate_img_vector 直接收 str 就行 # return str(save_path)