""" 文件处理器 负责文件的后台 ETL 处理:加载、分块、向量化、存储。 支持两种知识库类型:DOCUMENT(向量检索)和 GRAPH(知识图谱)。 使用全局 WorkerPool 实现并发控制,最多 10 个文件并行处理。 """ import asyncio import logging from pathlib import Path from typing import List from fastapi import BackgroundTasks from sqlalchemy.ext.asyncio import AsyncSession from app.db.models.knowledge_gen import KnowledgeBase, RagFile, FileStatus, RagType from app.db.session import AsyncSessionLocal from app.module.rag.infra.document import ingest_file_to_chunks from app.module.rag.infra.embeddings import EmbeddingFactory from app.module.rag.infra.task.worker_pool import get_global_pool from app.module.rag.infra.vectorstore import VectorStoreFactory from app.module.rag.repository import RagFileRepository, KnowledgeBaseRepository from app.module.rag.schema.request import AddFilesReq from app.module.rag.service.common import ( TextCleaner, MetadataBuilder, BatchProcessor, get_file_path, ) from app.module.system.service.common_service import get_model_by_id logger = logging.getLogger(__name__) # xuhuanqing def remove_utf8_surrogates(text: str) -> str: """移除字符串内的UTF‑16代理字符 \ud800‑\udfff,规避surrogates not allowed""" if not text: return text # 用unicode转码,忽略代理 # return text.encode("utf‑16", errors="surrogatepass").decode("utf‑16") return text.encode("utf‑8", errors="ignore").decode("utf‑8") class FileProcessor: def __init__(self): self.worker_pool = get_global_pool(max_workers=10) def start_background_processing( self, background_tasks: BackgroundTasks, knowledge_base_id: str, knowledge_base_name: str, knowledge_base_type: str, request_data: dict, ) -> None: background_tasks.add_task( self._process_files_background, knowledge_base_id, knowledge_base_name, knowledge_base_type, request_data, ) logger.info("已注册后台任务: 知识库=%s, 类型=%s", knowledge_base_name, knowledge_base_type) async def _process_files_background( self, knowledge_base_id: str, knowledge_base_name: str, knowledge_base_type: str, request_data: dict, ) -> None: async with AsyncSessionLocal() as db: try: kb_repo = KnowledgeBaseRepository(db) file_repo = RagFileRepository(db) knowledge_base = await kb_repo.get_by_id(knowledge_base_id) if not knowledge_base: logger.error("知识库不存在: %s", knowledge_base_id) return request = AddFilesReq.model_validate(request_data) files = await file_repo.get_unprocessed_files(knowledge_base_id) if not files: logger.info("知识库 %s 没有待处理的文件", knowledge_base_name) return logger.info("开始处理 %d 个文件,知识库: %s, 类型: %s", len(files), knowledge_base_name, knowledge_base_type) if knowledge_base_type == RagType.GRAPH.value: await self._process_graph_files(db, files, knowledge_base) else: await self._process_document_files(files, knowledge_base, request) logger.info("知识库 %s 文件处理完成", knowledge_base_name) except Exception as e: logger.exception("后台处理文件失败: %s", e) finally: await db.close() async def _process_document_files( self, files: List[RagFile], knowledge_base: KnowledgeBase, request: AddFilesReq, ) -> None: async def process_with_semaphore(rag_file: RagFile): async with self.worker_pool.semaphore: async with AsyncSessionLocal() as file_db: try: await self._process_single_file(file_db, rag_file, knowledge_base, request) finally: await file_db.close() tasks = [process_with_semaphore(f) for f in files] await asyncio.gather(*tasks, return_exceptions=True) async def _process_graph_files( self, db: AsyncSession, files: List[RagFile], knowledge_base: KnowledgeBase, ) -> None: from app.module.shared.common.document_loaders import load_documents try: rag_instance = await self._initialize_graph_rag(db, knowledge_base) for rag_file in files: await self._process_single_graph_file(db, rag_file, rag_instance, load_documents) except Exception as e: logger.exception("初始化知识图谱失败: %s", e) for rag_file in files: file_repo = RagFileRepository(db) await self._mark_failed(db, file_repo, str(rag_file.id), f"知识图谱初始化失败: {str(e)}") @staticmethod async def _initialize_graph_rag(db: AsyncSession, knowledge_base: KnowledgeBase): from app.module.rag.service.strategy.graph_strategy import GraphKnowledgeBaseStrategy strategy = GraphKnowledgeBaseStrategy(db) return await strategy._get_or_create_graph_rag(knowledge_base) async def _process_single_graph_file( self, db: AsyncSession, rag_file: RagFile, rag_instance, load_documents, ) -> None: file_repo = RagFileRepository(db) try: await self._update_status(db, file_repo, str(rag_file.id), FileStatus.PROCESSING, 10) await db.commit() file_path = get_file_path(rag_file) if not file_path or not Path(file_path).exists(): await self._mark_failed(db, file_repo, str(rag_file.id), "文件不存在") return documents = await asyncio.to_thread(load_documents, file_path) if not documents: await self._mark_failed(db, file_repo, str(rag_file.id), "文件解析失败,未生成文档") return await self._update_progress(db, file_repo, str(rag_file.id), 30) await db.commit() all_content = "\n\n".join(doc.page_content for doc in documents) doc_id = str(rag_file.id) logger.info("插入文档到知识图谱: %s, doc_id=%s, 文档数=%d", str(rag_file.file_name), doc_id, len(documents)) # xuhuanqing all_content = remove_utf8_surrogates(all_content) await rag_instance.ainsert(input=all_content, file_paths=[file_path], ids=doc_id) doc_status_data = await rag_instance.doc_status.get_by_id(doc_id) chunk_count = len(doc_status_data.get("chunks_list", [])) if doc_status_data else 0 await self._mark_success(db, file_repo, str(rag_file.id), chunk_count) logger.info("文件 %s 知识图谱处理完成, 实际分块数: %d", str(rag_file.file_name), chunk_count) except Exception as e: logger.exception("文件 %s 知识图谱处理失败: %s", str(rag_file.file_name), e) await self._mark_failed(db, file_repo, str(rag_file.id), str(e)) async def _process_single_file( self, db: AsyncSession, rag_file: RagFile, knowledge_base: KnowledgeBase, request: AddFilesReq, ) -> None: file_repo = RagFileRepository(db) try: await self._update_status(db, file_repo, rag_file.id, FileStatus.PROCESSING, 5) await db.commit() file_path = get_file_path(rag_file) if not file_path or not Path(file_path).exists(): await self._mark_failed(db, file_repo, rag_file.id, "文件不存在") return base_metadata = MetadataBuilder.build_chunk_metadata(rag_file, knowledge_base) chunks = await ingest_file_to_chunks( file_path, process_type=request.process_type, chunk_size=request.chunk_size, overlap_size=request.overlap_size, delimiter=request.delimiter, **base_metadata, ) if not chunks: await self._mark_failed(db, file_repo, rag_file.id, "文档解析后未生成任何分块") return logger.info("文件 %s 分块完成,共 %d 个分块", rag_file.file_name, len(chunks)) valid_chunks = self._filter_and_clean_chunks(chunks, rag_file) if not valid_chunks: await self._mark_failed(db, file_repo, rag_file.id, "文件没有有效的分块内容") return embedding = await self._get_embeddings(db, knowledge_base) vectorstore = VectorStoreFactory.create( collection_name=str(knowledge_base.name), embedding=embedding, ) await self._update_progress(db, file_repo, rag_file.id, 60) await db.commit() MetadataBuilder.add_to_chunks(valid_chunks, { "rag_file_id": str(rag_file.id), "original_file_id": str(rag_file.file_id), "knowledge_base_id": str(knowledge_base.id), }) await BatchProcessor.store_in_batches(vectorstore, valid_chunks) await self._mark_success(db, file_repo, rag_file.id, len(valid_chunks)) logger.info("文件 %s ETL 处理完成", rag_file.file_name) except Exception as e: logger.exception("文件 %s 处理失败: %s", rag_file.file_name, e) await self._mark_failed(db, file_repo, rag_file.id, str(e)) @staticmethod def _filter_and_clean_chunks(chunks: list, rag_file: RagFile) -> list: valid_chunks = [] for chunk in chunks: cleaned_text = TextCleaner.clean(chunk.text) if cleaned_text and TextCleaner.has_printable_content(cleaned_text): chunk.text = cleaned_text valid_chunks.append(chunk) else: logger.warning( "跳过无效分块: rag_file_id=%s, chunk_index=%s", rag_file.id, chunk.metadata.get("chunk_index") ) logger.info("文件 %s 有效分块数量: %d / %d", rag_file.file_name, len(valid_chunks), len(chunks)) return valid_chunks @staticmethod async def _get_embeddings(db: AsyncSession, knowledge_base: KnowledgeBase): embedding_entity = await get_model_by_id(db, str(knowledge_base.embedding_model)) if not embedding_entity: raise ValueError(f"嵌入模型不存在: {knowledge_base.embedding_model}") return EmbeddingFactory.create_embeddings( model_name=str(embedding_entity.model_name), base_url=getattr(embedding_entity, "base_url", None), api_key=getattr(embedding_entity, "api_key", None), ) @staticmethod async def _update_status( db: AsyncSession, file_repo: RagFileRepository, rag_file_id: str, status: FileStatus, progress: int = 0, ) -> None: await file_repo.update_status(rag_file_id, status) await file_repo.update_progress(rag_file_id, progress) await db.flush() @staticmethod async def _update_progress( db: AsyncSession, file_repo: RagFileRepository, rag_file_id: str, progress: int, ) -> None: await file_repo.update_progress(rag_file_id, progress) await db.flush() @staticmethod async def _mark_success( db: AsyncSession, file_repo: RagFileRepository, rag_file_id: str, chunk_count: int, ) -> None: await file_repo.update_chunk_count(rag_file_id, chunk_count) await file_repo.update_status(rag_file_id, FileStatus.PROCESSED) await file_repo.update_progress(rag_file_id, 100) await db.commit() @staticmethod async def _mark_failed( db: AsyncSession, file_repo: RagFileRepository, rag_file_id: str, err_msg: str, ) -> None: logger.error("文件处理失败: %s", err_msg) await file_repo.update_status(rag_file_id, FileStatus.PROCESS_FAILED, err_msg=err_msg) await db.commit()