489 lines
18 KiB
Python
489 lines
18 KiB
Python
"""
|
|
知识库业务服务
|
|
|
|
实现知识库的 CRUD 操作和文件管理。
|
|
对应 Java: com.deepdata.rag.indexer.application.KnowledgeBaseService
|
|
"""
|
|
import logging
|
|
import uuid
|
|
from typing import List, Tuple, Optional
|
|
|
|
from fastapi import BackgroundTasks
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from app.core.exception import BusinessError, ErrorCodes
|
|
from app.db.models.dataset_management import DatasetFiles
|
|
from app.db.models.knowledge_gen import KnowledgeBase, RagFile, FileStatus, RagType
|
|
from app.db.models.models import Models
|
|
from app.module.rag.infra.embeddings import EmbeddingFactory
|
|
from app.module.rag.infra.vectorstore import (
|
|
drop_collection,
|
|
rename_collection,
|
|
delete_chunks_by_rag_file_ids,
|
|
update_chunk_by_id,
|
|
delete_chunk_by_id,
|
|
)
|
|
from app.module.rag.repository import KnowledgeBaseRepository, RagFileRepository
|
|
from app.module.rag.schema.request import (
|
|
KnowledgeBaseCreateReq,
|
|
KnowledgeBaseUpdateReq,
|
|
KnowledgeBaseQueryReq,
|
|
AddFilesReq,
|
|
DeleteFilesReq,
|
|
RagFileReq,
|
|
)
|
|
from app.module.rag.schema.response import KnowledgeBaseResp, PagedResponse, RagFileResp, ModelConfig
|
|
from app.module.rag.service.file_processor import FileProcessor
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class KnowledgeBaseService:
|
|
"""知识库业务服务类
|
|
|
|
功能:
|
|
1. 知识库 CRUD 操作
|
|
2. 文件管理(添加、删除、查询)
|
|
"""
|
|
|
|
def __init__(self, db: AsyncSession):
|
|
"""初始化服务
|
|
|
|
Args:
|
|
db: 数据库异步 session
|
|
"""
|
|
self.db = db
|
|
self.kb_repo = KnowledgeBaseRepository(db)
|
|
self.file_repo = RagFileRepository(db)
|
|
self.file_processor = FileProcessor()
|
|
|
|
# ==================== 知识库 CRUD ====================
|
|
|
|
async def create(self, request: KnowledgeBaseCreateReq) -> str:
|
|
"""创建知识库
|
|
|
|
Args:
|
|
request: 创建请求
|
|
|
|
Returns:
|
|
知识库 ID
|
|
"""
|
|
knowledge_base = KnowledgeBase(
|
|
id=str(uuid.uuid4()),
|
|
name=request.name,
|
|
description=request.description,
|
|
type=request.type,
|
|
embedding_model=request.embedding_model,
|
|
chat_model=request.chat_model,
|
|
)
|
|
|
|
knowledge_base = await self.kb_repo.create(knowledge_base)
|
|
await self.db.commit()
|
|
|
|
logger.info("成功创建知识库: %s", request.name)
|
|
return knowledge_base.id
|
|
|
|
async def update(self, knowledge_base_id: str, request: KnowledgeBaseUpdateReq) -> None:
|
|
"""更新知识库
|
|
|
|
Args:
|
|
knowledge_base_id: 知识库 ID
|
|
request: 更新请求
|
|
"""
|
|
knowledge_base = await self.kb_repo.get_by_id(knowledge_base_id)
|
|
if not knowledge_base:
|
|
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
|
|
|
old_name = str(knowledge_base.name)
|
|
new_name = request.name
|
|
kb_type = knowledge_base.type
|
|
|
|
if new_name and new_name != old_name:
|
|
if await self.kb_repo.exists_by_name(new_name, exclude_id=knowledge_base_id):
|
|
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_ALREADY_EXISTS, data={"name": new_name})
|
|
|
|
knowledge_base.name = request.name
|
|
knowledge_base.description = request.description
|
|
|
|
await self.kb_repo.update(knowledge_base)
|
|
|
|
if old_name != new_name:
|
|
try:
|
|
if kb_type == RagType.DOCUMENT.value:
|
|
rename_collection(old_name, new_name)
|
|
elif kb_type == RagType.GRAPH.value:
|
|
from app.module.rag.service.strategy.graph_strategy import GraphKnowledgeBaseStrategy
|
|
GraphKnowledgeBaseStrategy.rename_workspace(old_name, new_name)
|
|
GraphKnowledgeBaseStrategy.clear_cache(old_name)
|
|
except BusinessError:
|
|
await self.db.rollback()
|
|
raise
|
|
|
|
await self.db.commit()
|
|
|
|
async def delete(self, knowledge_base_id: str) -> None:
|
|
"""删除知识库
|
|
|
|
Args:
|
|
knowledge_base_id: 知识库 ID
|
|
"""
|
|
knowledge_base = await self.kb_repo.get_by_id(knowledge_base_id)
|
|
if not knowledge_base:
|
|
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
|
|
|
kb_name = str(knowledge_base.name)
|
|
kb_type = knowledge_base.type
|
|
|
|
await self.file_repo.delete_by_knowledge_base(knowledge_base_id)
|
|
await self.kb_repo.delete(knowledge_base_id)
|
|
|
|
if kb_type == RagType.DOCUMENT.value:
|
|
try:
|
|
drop_collection(kb_name)
|
|
except Exception as e:
|
|
logger.error("删除 Milvus 集合失败: %s", e)
|
|
elif kb_type == RagType.GRAPH.value:
|
|
try:
|
|
from app.module.rag.service.strategy.graph_strategy import GraphKnowledgeBaseStrategy
|
|
import shutil
|
|
from pathlib import Path
|
|
from app.core.config import settings
|
|
workspace_path = Path(settings.rag_storage_dir) / kb_name
|
|
if workspace_path.exists():
|
|
shutil.rmtree(workspace_path)
|
|
logger.info("已删除知识图谱 workspace: %s", kb_name)
|
|
GraphKnowledgeBaseStrategy.clear_cache(kb_name)
|
|
except Exception as e:
|
|
logger.error("删除知识图谱 workspace 失败: %s", e)
|
|
|
|
await self.db.commit()
|
|
|
|
async def get_by_id(self, knowledge_base_id: str) -> KnowledgeBaseResp:
|
|
"""获取知识库详情"""
|
|
knowledge_base = await self.kb_repo.get_by_id(knowledge_base_id)
|
|
if not knowledge_base:
|
|
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
|
|
|
file_count = await self.file_repo.count_by_knowledge_base(knowledge_base_id)
|
|
chunk_count = await self.file_repo.count_chunks_by_knowledge_base(knowledge_base_id)
|
|
|
|
data = self._kb_to_dict(knowledge_base)
|
|
data.update({
|
|
"file_count": file_count,
|
|
"chunk_count": chunk_count,
|
|
"embedding": await self._get_model_config(knowledge_base.embedding_model),
|
|
"chat": await self._get_model_config(knowledge_base.chat_model),
|
|
})
|
|
return KnowledgeBaseResp(**data)
|
|
|
|
@staticmethod
|
|
def _kb_to_dict(kb: KnowledgeBase) -> dict:
|
|
"""知识库实体转字典"""
|
|
return {
|
|
"id": kb.id,
|
|
"name": kb.name,
|
|
"description": kb.description,
|
|
"type": kb.type,
|
|
"embedding_model": kb.embedding_model,
|
|
"chat_model": kb.chat_model,
|
|
"created_at": kb.created_at,
|
|
"updated_at": kb.updated_at,
|
|
"created_by": kb.created_by,
|
|
"updated_by": kb.updated_by,
|
|
}
|
|
|
|
async def _get_model_config(self, model_id: Optional[str]) -> Optional[ModelConfig]:
|
|
"""获取模型配置"""
|
|
if not model_id:
|
|
return None
|
|
|
|
result = await self.db.execute(
|
|
select(Models).where(
|
|
Models.id == model_id,
|
|
(Models.is_deleted == False) | (Models.is_deleted.is_(None))
|
|
)
|
|
)
|
|
model = result.scalar_one_or_none()
|
|
return ModelConfig.model_validate(model) if model else None
|
|
|
|
async def list(self, request: KnowledgeBaseQueryReq) -> PagedResponse:
|
|
"""分页查询知识库列表"""
|
|
items, total = await self.kb_repo.list(
|
|
keyword=request.keyword,
|
|
rag_type=request.type,
|
|
page=request.page,
|
|
page_size=request.page_size,
|
|
)
|
|
|
|
responses = []
|
|
for item in items:
|
|
file_count = await self.file_repo.count_by_knowledge_base(item.id)
|
|
chunk_count = await self.file_repo.count_chunks_by_knowledge_base(item.id)
|
|
data = self._kb_to_dict(item)
|
|
data.update({
|
|
"file_count": file_count,
|
|
"chunk_count": chunk_count,
|
|
})
|
|
responses.append(KnowledgeBaseResp(**data))
|
|
|
|
return PagedResponse.create(
|
|
content=responses,
|
|
total_elements=total,
|
|
page=request.page,
|
|
size=request.page_size,
|
|
)
|
|
|
|
# ==================== 文件管理 ====================
|
|
|
|
async def add_files(
|
|
self,
|
|
request: AddFilesReq,
|
|
background_tasks: BackgroundTasks = None,
|
|
) -> dict:
|
|
"""添加文件到知识库
|
|
|
|
验证知识库、创建文件记录、启动后台处理。
|
|
数据库提交后立即返回,不等待文件处理完成。
|
|
|
|
Args:
|
|
request: 添加文件请求
|
|
background_tasks: FastAPI 后台任务
|
|
|
|
Returns:
|
|
包含成功和跳过文件数量的字典
|
|
"""
|
|
knowledge_base = await self.kb_repo.get_by_id(request.knowledge_base_id)
|
|
if not knowledge_base:
|
|
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
|
|
|
rag_files, skipped_file_ids = await self._create_rag_files(request)
|
|
|
|
await self.db.commit()
|
|
|
|
if rag_files and background_tasks:
|
|
kb_type = knowledge_base.type if knowledge_base.type else "DOCUMENT"
|
|
self.file_processor.start_background_processing(
|
|
background_tasks=background_tasks,
|
|
knowledge_base_id=str(knowledge_base.id),
|
|
knowledge_base_name=str(knowledge_base.name),
|
|
knowledge_base_type=str(kb_type),
|
|
request_data=request.model_dump(),
|
|
)
|
|
|
|
return {
|
|
"success_count": len(rag_files),
|
|
"skipped_count": len(skipped_file_ids),
|
|
"skipped_file_ids": skipped_file_ids,
|
|
}
|
|
|
|
async def _create_rag_files(self, request: AddFilesReq) -> Tuple[List[RagFile], List[str]]:
|
|
"""创建 RAG 文件记录"""
|
|
if not request.files:
|
|
raise BusinessError(ErrorCodes.BAD_REQUEST, "文件列表不能为空")
|
|
|
|
rag_files = []
|
|
skipped_file_ids = []
|
|
|
|
for file_info in request.files:
|
|
try:
|
|
result = await self.db.execute(
|
|
select(DatasetFiles).where(DatasetFiles.id == file_info.id)
|
|
)
|
|
dataset_file = result.scalar_one_or_none()
|
|
|
|
if not dataset_file:
|
|
logger.warning("文件不存在,跳过: file_id=%s", file_info.id)
|
|
skipped_file_ids.append(file_info.id)
|
|
continue
|
|
|
|
rag_file = RagFile(
|
|
id=str(uuid.uuid4()),
|
|
knowledge_base_id=request.knowledge_base_id,
|
|
file_name=dataset_file.file_name,
|
|
file_id=file_info.id,
|
|
file_metadata={
|
|
"process_type": request.process_type.value,
|
|
"dataset_id": dataset_file.dataset_id,
|
|
"file_path": dataset_file.file_path,
|
|
},
|
|
status=FileStatus.UNPROCESSED,
|
|
)
|
|
rag_files.append(rag_file)
|
|
|
|
except Exception as e:
|
|
logger.error("处理文件信息失败: file_id=%s, error=%s", file_info.id, e)
|
|
skipped_file_ids.append(file_info.id)
|
|
|
|
if rag_files:
|
|
await self.file_repo.batch_create(rag_files)
|
|
logger.info("成功添加 %d 个文件到知识库", len(rag_files))
|
|
|
|
return rag_files, skipped_file_ids
|
|
|
|
async def list_files(self, knowledge_base_id: str, request: RagFileReq) -> PagedResponse:
|
|
"""获取知识库文件列表"""
|
|
if not await self.kb_repo.get_by_id(knowledge_base_id):
|
|
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
|
|
|
items, total = await self.file_repo.list_by_knowledge_base(
|
|
knowledge_base_id=knowledge_base_id,
|
|
keyword=request.keyword,
|
|
status=request.status,
|
|
page=request.page,
|
|
page_size=request.page_size,
|
|
)
|
|
|
|
responses = [RagFileResp.model_validate(item) for item in items]
|
|
|
|
return PagedResponse.create(
|
|
content=responses,
|
|
total_elements=total,
|
|
page=request.page,
|
|
size=request.page_size,
|
|
)
|
|
|
|
async def delete_files(self, knowledge_base_id: str, request: DeleteFilesReq) -> None:
|
|
"""删除知识库文件
|
|
|
|
Args:
|
|
knowledge_base_id: 知识库 ID
|
|
request: 删除文件请求
|
|
"""
|
|
knowledge_base = await self.kb_repo.get_by_id(knowledge_base_id)
|
|
if not knowledge_base:
|
|
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
|
|
|
if not request.file_ids:
|
|
raise BusinessError(ErrorCodes.BAD_REQUEST, "文件ID列表不能为空")
|
|
|
|
kb_type = knowledge_base.type
|
|
kb_name = str(knowledge_base.name)
|
|
|
|
rag_files = []
|
|
for file_id in request.file_ids:
|
|
rag_file = await self.file_repo.get_by_id(file_id)
|
|
if rag_file:
|
|
rag_files.append(rag_file)
|
|
|
|
if rag_files:
|
|
if kb_type == RagType.DOCUMENT.value:
|
|
try:
|
|
delete_chunks_by_rag_file_ids(
|
|
kb_name,
|
|
[r.id for r in rag_files],
|
|
)
|
|
except Exception as e:
|
|
logger.error("删除 Milvus 数据失败: %s", e)
|
|
elif kb_type == RagType.GRAPH.value:
|
|
try:
|
|
from app.module.rag.service.strategy.graph_strategy import GraphKnowledgeBaseStrategy
|
|
strategy = GraphKnowledgeBaseStrategy(self.db)
|
|
rag_instance = await strategy._get_or_create_graph_rag(knowledge_base)
|
|
for rag_file in rag_files:
|
|
doc_id = str(rag_file.id)
|
|
await rag_instance.adelete_by_doc_id(doc_id)
|
|
logger.info("已从知识图谱删除文件: %s, doc_id=%s", rag_file.file_name, doc_id)
|
|
except Exception as e:
|
|
logger.error("删除知识图谱数据失败: %s", e)
|
|
|
|
for file_id in request.file_ids:
|
|
try:
|
|
await self.file_repo.delete(file_id)
|
|
except Exception as e:
|
|
logger.error("删除数据库记录失败: %s, error=%s", file_id, e)
|
|
|
|
await self.db.commit()
|
|
logger.info("成功删除 %d 个文件", len(rag_files))
|
|
|
|
async def update_chunk(
|
|
self,
|
|
knowledge_base_id: str,
|
|
chunk_id: str,
|
|
text: str,
|
|
metadata: dict = None,
|
|
) -> None:
|
|
"""更新指定分块的文本和元数据
|
|
|
|
Args:
|
|
knowledge_base_id: 知识库 ID
|
|
chunk_id: 分块 ID
|
|
text: 新的文本内容
|
|
metadata: 新的元数据(可选)
|
|
"""
|
|
knowledge_base = await self.kb_repo.get_by_id(knowledge_base_id)
|
|
if not knowledge_base:
|
|
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
|
|
|
if knowledge_base.type != RagType.DOCUMENT.value:
|
|
raise BusinessError(
|
|
ErrorCodes.RAG_INVALID_REQUEST,
|
|
f"知识库类型 {knowledge_base.type} 不支持分块更新"
|
|
)
|
|
|
|
from app.module.system.service.common_service import get_model_by_id
|
|
import asyncio
|
|
|
|
embedding_entity = await get_model_by_id(self.db, knowledge_base.embedding_model)
|
|
if not embedding_entity:
|
|
raise BusinessError(ErrorCodes.RAG_MODEL_NOT_FOUND)
|
|
|
|
embedding = 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),
|
|
)
|
|
|
|
await asyncio.to_thread(
|
|
update_chunk_by_id,
|
|
collection_name=str(knowledge_base.name),
|
|
chunk_id=chunk_id,
|
|
text=text,
|
|
metadata=metadata,
|
|
embedding_instance=embedding,
|
|
)
|
|
|
|
logger.info(
|
|
"成功更新分块: kb=%s chunk_id=%s",
|
|
knowledge_base_id, chunk_id
|
|
)
|
|
|
|
async def delete_chunk(
|
|
self,
|
|
knowledge_base_id: str,
|
|
chunk_id: str,
|
|
) -> None:
|
|
"""删除指定分块
|
|
|
|
Args:
|
|
knowledge_base_id: 知识库 ID
|
|
chunk_id: 分块 ID
|
|
"""
|
|
knowledge_base = await self.kb_repo.get_by_id(knowledge_base_id)
|
|
if not knowledge_base:
|
|
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
|
|
|
if knowledge_base.type != RagType.DOCUMENT.value:
|
|
raise BusinessError(
|
|
ErrorCodes.RAG_INVALID_REQUEST,
|
|
f"知识库类型 {knowledge_base.type} 不支持分块删除"
|
|
)
|
|
|
|
import asyncio
|
|
rag_file_id = await asyncio.to_thread(
|
|
delete_chunk_by_id,
|
|
collection_name=str(knowledge_base.name),
|
|
chunk_id=chunk_id,
|
|
)
|
|
|
|
if rag_file_id:
|
|
rag_file = await self.file_repo.get_by_id(rag_file_id)
|
|
if rag_file and rag_file.chunk_count and rag_file.chunk_count > 0:
|
|
rag_file.chunk_count = rag_file.chunk_count - 1
|
|
await self.db.commit()
|
|
|
|
logger.info(
|
|
"成功删除分块: kb=%s chunk_id=%s",
|
|
knowledge_base_id, chunk_id
|
|
)
|