deepdata: core product code (backend, frontend, runtime, deepflow module)
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""
|
||||
公共工具模块
|
||||
|
||||
提供文本清理、元数据构建、批量处理等工具类。
|
||||
"""
|
||||
from .text_cleaner import TextCleaner
|
||||
from .metadata_builder import MetadataBuilder
|
||||
from .batch_processor import BatchProcessor
|
||||
from .file_utils import get_file_path
|
||||
|
||||
__all__ = [
|
||||
"TextCleaner",
|
||||
"MetadataBuilder",
|
||||
"BatchProcessor",
|
||||
"get_file_path",
|
||||
]
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
批量处理工具
|
||||
|
||||
提供分批处理文档分块的工具方法。
|
||||
"""
|
||||
import uuid
|
||||
import logging
|
||||
from typing import List
|
||||
from app.module.rag.infra.vectorstore import chunks_to_documents
|
||||
from app.module.rag.infra.document.types import DocumentChunk
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class BatchProcessor:
|
||||
"""批量处理工具类"""
|
||||
|
||||
DEFAULT_BATCH_SIZE: int = 20
|
||||
|
||||
@staticmethod
|
||||
async def store_in_batches(
|
||||
vectorstore,
|
||||
chunks: List[DocumentChunk],
|
||||
batch_size: int = DEFAULT_BATCH_SIZE
|
||||
) -> int:
|
||||
"""分批存储分块到向量数据库
|
||||
|
||||
Args:
|
||||
vectorstore: 向量存储实例
|
||||
chunks: 分块列表
|
||||
batch_size: 批次大小
|
||||
|
||||
Returns:
|
||||
成功存储的分块数量
|
||||
"""
|
||||
if not chunks:
|
||||
return 0
|
||||
|
||||
batch_size = batch_size or BatchProcessor.DEFAULT_BATCH_SIZE
|
||||
total_chunks = len(chunks)
|
||||
stored_count = 0
|
||||
|
||||
for batch_start in range(0, total_chunks, batch_size):
|
||||
batch_end = min(batch_start + batch_size, total_chunks)
|
||||
batch_chunks = chunks[batch_start:batch_end]
|
||||
|
||||
await BatchProcessor._store_single_batch(
|
||||
vectorstore,
|
||||
batch_chunks,
|
||||
batch_start,
|
||||
batch_end,
|
||||
total_chunks
|
||||
)
|
||||
stored_count += len(batch_chunks)
|
||||
|
||||
logger.info("批量存储完成,总数量: %d", stored_count)
|
||||
return stored_count
|
||||
|
||||
@staticmethod
|
||||
async def _store_single_batch(
|
||||
vectorstore,
|
||||
batch_chunks: List[DocumentChunk],
|
||||
batch_start: int,
|
||||
batch_end: int,
|
||||
total_chunks: int,
|
||||
) -> None:
|
||||
"""存储单个批次
|
||||
|
||||
Args:
|
||||
vectorstore: 向量存储实例
|
||||
batch_chunks: 批次分块列表
|
||||
batch_start: 批次起始索引
|
||||
batch_end: 批次结束索引
|
||||
total_chunks: 总分块数
|
||||
"""
|
||||
logger.info("处理分块批次 %d-%d / %d", batch_start + 1, batch_end, total_chunks)
|
||||
|
||||
ids = [str(uuid.uuid4()) for _ in batch_chunks]
|
||||
documents, doc_ids = chunks_to_documents(batch_chunks, ids=ids)
|
||||
|
||||
try:
|
||||
# 使用异步方法避免阻塞事件循环
|
||||
await vectorstore.aadd_documents(documents=documents, ids=doc_ids)
|
||||
logger.info("批次 %d-%d 存储成功", batch_start + 1, batch_end)
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"批次 %d-%d 存储失败: %s\n第一个文档内容: %.200s",
|
||||
batch_start + 1,
|
||||
batch_end,
|
||||
str(e),
|
||||
documents[0].page_content if documents else "N/A"
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,25 @@
|
||||
"""
|
||||
文件工具类
|
||||
|
||||
提供文件路径提取等通用工具方法。
|
||||
"""
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_file_path(rag_file) -> Optional[str]:
|
||||
"""获取 RAG 文件的绝对路径
|
||||
|
||||
Args:
|
||||
rag_file: RAG 文件实体(需要有 file_metadata 属性)
|
||||
|
||||
Returns:
|
||||
文件绝对路径,不存在返回 None
|
||||
"""
|
||||
if not rag_file.file_metadata:
|
||||
return None
|
||||
|
||||
file_path = rag_file.file_metadata.get("file_path")
|
||||
if file_path:
|
||||
return str(Path(file_path).absolute())
|
||||
return None
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
元数据构建工具
|
||||
|
||||
提供构建和管理分块元数据的工具方法。
|
||||
"""
|
||||
from typing import Dict, Any
|
||||
from app.db.models.knowledge_gen import KnowledgeBase, RagFile
|
||||
|
||||
|
||||
class MetadataBuilder:
|
||||
"""元数据构建工具类"""
|
||||
|
||||
@staticmethod
|
||||
def build_chunk_metadata(rag_file: RagFile, knowledge_base: KnowledgeBase) -> Dict[str, Any]:
|
||||
"""构建分块基础元数据
|
||||
|
||||
Args:
|
||||
rag_file: RAG 文件实体
|
||||
knowledge_base: 知识库实体
|
||||
|
||||
Returns:
|
||||
元数据字典
|
||||
"""
|
||||
return {
|
||||
"rag_file_id": str(rag_file.id),
|
||||
"original_file_id": str(rag_file.file_id),
|
||||
"knowledge_base_id": str(knowledge_base.id),
|
||||
"file_name": rag_file.file_name,
|
||||
"knowledge_base_name": knowledge_base.name,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def add_to_chunks(chunks: list, base_metadata: Dict[str, Any]) -> None:
|
||||
"""为基础元数据添加到分块列表
|
||||
|
||||
Args:
|
||||
chunks: 分块列表
|
||||
base_metadata: 基础元数据
|
||||
"""
|
||||
for chunk in chunks:
|
||||
if hasattr(chunk, 'metadata') and isinstance(chunk.metadata, dict):
|
||||
chunk.metadata.update(base_metadata)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""
|
||||
文本清理工具
|
||||
|
||||
提供文本清理和标准化的工具方法。
|
||||
"""
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TextCleaner:
|
||||
"""文本清理工具类"""
|
||||
|
||||
@staticmethod
|
||||
def clean(text: Optional[str]) -> str:
|
||||
"""清理文本
|
||||
|
||||
Args:
|
||||
text: 原始文本
|
||||
|
||||
Returns:
|
||||
清理后的文本
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
text = TextCleaner._remove_control_characters(text)
|
||||
text = TextCleaner._normalize_whitespace(text)
|
||||
text = TextCleaner._remove_empty_lines(text)
|
||||
|
||||
return text.strip()
|
||||
|
||||
@staticmethod
|
||||
def _remove_control_characters(text: str) -> str:
|
||||
"""移除控制字符"""
|
||||
control_chars = re.compile(r'[\x00-\x08\x0b-\x0c\x0e-\x1f\x7f-\x9f]')
|
||||
return control_chars.sub('', text)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_whitespace(text: str) -> str:
|
||||
"""标准化空白字符"""
|
||||
text = re.sub(r'[ \t]+', ' ', text)
|
||||
text = re.sub(r'\n\s*\n\s*\n+', '\n\n', text)
|
||||
return text
|
||||
|
||||
@staticmethod
|
||||
def _remove_empty_lines(text: str) -> str:
|
||||
"""移除空行"""
|
||||
lines = text.split('\n')
|
||||
return '\n'.join(line for line in lines if line.strip())
|
||||
|
||||
@staticmethod
|
||||
def has_printable_content(text: str) -> bool:
|
||||
"""检查是否包含可打印内容
|
||||
|
||||
Args:
|
||||
text: 文本
|
||||
|
||||
Returns:
|
||||
是否包含可打印内容
|
||||
"""
|
||||
if not text:
|
||||
return False
|
||||
return any(c.isprintable() and not c.isspace() for c in text)
|
||||
@@ -0,0 +1,325 @@
|
||||
"""
|
||||
文件处理器
|
||||
|
||||
负责文件的后台 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()
|
||||
@@ -0,0 +1,488 @@
|
||||
"""
|
||||
知识库业务服务
|
||||
|
||||
实现知识库的 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
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
策略模式实现
|
||||
|
||||
提供知识库检索和处理的策略模式实现。
|
||||
"""
|
||||
from app.db.models.knowledge_gen import RagType
|
||||
from .base import KnowledgeBaseStrategy
|
||||
from .factory import KnowledgeBaseStrategyFactory
|
||||
from .vector_strategy import VectorKnowledgeBaseStrategy
|
||||
from .graph_strategy import GraphKnowledgeBaseStrategy
|
||||
|
||||
KnowledgeBaseStrategyFactory.register_strategy(
|
||||
RagType.DOCUMENT.value,
|
||||
VectorKnowledgeBaseStrategy
|
||||
)
|
||||
|
||||
KnowledgeBaseStrategyFactory.register_strategy(
|
||||
RagType.GRAPH.value,
|
||||
GraphKnowledgeBaseStrategy
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"KnowledgeBaseStrategy",
|
||||
"KnowledgeBaseStrategyFactory",
|
||||
"VectorKnowledgeBaseStrategy",
|
||||
"GraphKnowledgeBaseStrategy",
|
||||
]
|
||||
@@ -0,0 +1,105 @@
|
||||
"""
|
||||
策略模式 - 知识库策略基类
|
||||
|
||||
定义知识库检索和处理的抽象接口,支持不同类型的知识库(向量、知识图谱等)。
|
||||
"""
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
|
||||
class KnowledgeBaseStrategy(ABC):
|
||||
"""知识库策略抽象基类
|
||||
|
||||
所有知识库策略(向量、知识图谱等)必须实现此接口。
|
||||
|
||||
接口设计原则:
|
||||
- query(): 直接获取数据,不涉及相似度计算,返回值格式不统一
|
||||
- search(): 基于输入文本的相似度检索,返回值必须统一为 UnifiedSearchResult
|
||||
- process_file(): 处理单个文件,不同策略有不同的处理逻辑
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""初始化策略
|
||||
|
||||
Args:
|
||||
db: 数据库异步 session
|
||||
"""
|
||||
self.db = db
|
||||
|
||||
@abstractmethod
|
||||
async def query(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""直接查询知识库数据(不涉及相似度计算)
|
||||
|
||||
不同知识库类型的返回格式可以不同:
|
||||
- 向量知识库:返回 PagedResponse[RagChunkResp](分页分块列表)
|
||||
- 知识图谱:返回图谱数据(格式由 LightRAG 定义)
|
||||
|
||||
Args:
|
||||
knowledge_base_id: 知识库 ID
|
||||
**kwargs: 额外参数(如 rag_file_id, paging_query 等)
|
||||
|
||||
Returns:
|
||||
查询结果(格式由具体策略决定)
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def search(
|
||||
self,
|
||||
query_text: str,
|
||||
knowledge_base_ids: List[str],
|
||||
top_k: int = 10,
|
||||
threshold: Optional[float] = None,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""基于输入文本的相似度检索
|
||||
|
||||
所有知识库类型必须返回统一格式:UnifiedSearchResult
|
||||
|
||||
Args:
|
||||
query_text: 查询文本
|
||||
knowledge_base_ids: 知识库 ID 列表
|
||||
top_k: 返回结果数量
|
||||
threshold: 相似度阈值(可选)
|
||||
**kwargs: 额外参数
|
||||
|
||||
Returns:
|
||||
统一格式的检索结果列表,每个元素包含:
|
||||
- id: 结果 ID
|
||||
- text: 文本内容
|
||||
- score: 相似度分数
|
||||
- metadata: 元数据字典
|
||||
- resultType: 结果类型("vector" 或 "graph")
|
||||
- knowledgeBaseId: 知识库 ID
|
||||
- knowledgeBaseName: 知识库名称
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def process_file(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
rag_file_id: str,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""处理单个文件
|
||||
|
||||
不同知识库类型有不同的处理逻辑:
|
||||
- 向量知识库:文档分块 -> 向量化 -> 存储到 Milvus
|
||||
- 知识图谱:文档分块 -> 构建知识图谱 -> 存储到 LightRAG
|
||||
|
||||
Args:
|
||||
knowledge_base_id: 知识库 ID
|
||||
rag_file_id: RAG 文件 ID
|
||||
**kwargs: 额外参数(如 process_type, chunk_size 等)
|
||||
|
||||
Raises:
|
||||
BusinessError: 处理失败时抛出
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
策略工厂
|
||||
|
||||
根据知识库类型(DOCUMENT/GRAPH)创建对应的知识库策略实例。
|
||||
"""
|
||||
from typing import Dict
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.db.models.knowledge_gen import RagType
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
from .base import KnowledgeBaseStrategy
|
||||
|
||||
|
||||
class KnowledgeBaseStrategyFactory:
|
||||
"""知识库策略工厂
|
||||
|
||||
使用工厂模式创建知识库策略实例,支持策略缓存以提高性能。
|
||||
"""
|
||||
|
||||
_strategy_cache: Dict[str, type] = {}
|
||||
|
||||
@classmethod
|
||||
def register_strategy(cls, rag_type: str, strategy_class: type):
|
||||
"""注册策略类
|
||||
|
||||
Args:
|
||||
rag_type: 知识库类型(DOCUMENT/GRAPH)
|
||||
strategy_class: 策略类
|
||||
"""
|
||||
cls._strategy_cache[rag_type.upper()] = strategy_class
|
||||
|
||||
@classmethod
|
||||
def create_strategy(
|
||||
cls,
|
||||
rag_type: str,
|
||||
db: AsyncSession
|
||||
) -> KnowledgeBaseStrategy:
|
||||
"""创建知识库策略实例
|
||||
|
||||
Args:
|
||||
rag_type: 知识库类型(DOCUMENT/GRAPH)
|
||||
db: 数据库异步 session
|
||||
|
||||
Returns:
|
||||
知识库策略实例
|
||||
|
||||
Raises:
|
||||
BusinessError: 不支持的知识库类型
|
||||
"""
|
||||
rag_type_upper = rag_type.upper() if isinstance(rag_type, str) else str(rag_type).upper()
|
||||
|
||||
if rag_type_upper not in cls._strategy_cache:
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_UNSUPPORTED_TYPE,
|
||||
f"不支持的 RAG 类型: {rag_type}"
|
||||
)
|
||||
|
||||
strategy_class = cls._strategy_cache[rag_type_upper]
|
||||
return strategy_class(db)
|
||||
|
||||
@classmethod
|
||||
def get_supported_types(cls) -> list:
|
||||
"""获取支持的知识库类型列表
|
||||
|
||||
Returns:
|
||||
支持的类型列表
|
||||
"""
|
||||
return list(cls._strategy_cache.keys())
|
||||
@@ -0,0 +1,366 @@
|
||||
"""
|
||||
知识图谱策略
|
||||
|
||||
实现 GRAPH 类型知识库的检索和处理逻辑。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional
|
||||
|
||||
import numpy as np
|
||||
from lightrag import LightRAG
|
||||
from lightrag.base import QueryParam
|
||||
from lightrag.constants import DEFAULT_ENTITY_TYPES
|
||||
from lightrag.llm.openai import openai_complete_if_cache, openai_embed
|
||||
from lightrag.utils import EmbeddingFunc, get_env_value, setup_logger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
from app.db.models.knowledge_gen import KnowledgeBase
|
||||
from app.module.rag.repository import KnowledgeBaseRepository
|
||||
from app.module.rag.service.common import get_file_path
|
||||
from app.module.system.service.common_service import get_model_by_id
|
||||
from .base import KnowledgeBaseStrategy
|
||||
|
||||
|
||||
setup_logger("lightrag", level="INFO")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_WORKING_DIR = str(settings.rag_storage_dir)
|
||||
|
||||
|
||||
def _create_llm_func(model_name: str, base_url: str, api_key: str) -> Callable[..., Awaitable[str]]:
|
||||
async def _llm(prompt: str, system_prompt: str = None, history_messages: list = None, **kwargs) -> str:
|
||||
return await openai_complete_if_cache(
|
||||
model_name, prompt,
|
||||
system_prompt=system_prompt,
|
||||
history_messages=history_messages or [],
|
||||
api_key=api_key, base_url=base_url, **kwargs,
|
||||
)
|
||||
return _llm
|
||||
|
||||
|
||||
def _create_embedding_func(model_name: str, base_url: str, api_key: str, embedding_dim: int) -> EmbeddingFunc:
|
||||
async def _embed(texts: list[str]) -> np.ndarray:
|
||||
return await openai_embed.func(texts, model=model_name, api_key=api_key, base_url=base_url)
|
||||
return EmbeddingFunc(embedding_dim=embedding_dim, func=_embed, max_token_size=8192)
|
||||
|
||||
|
||||
async def _create_rag(
|
||||
llm_func: Callable[..., Awaitable[str]],
|
||||
embedding_func: EmbeddingFunc,
|
||||
working_dir: str,
|
||||
workspace: str = "",
|
||||
) -> LightRAG:
|
||||
os.makedirs(working_dir, exist_ok=True)
|
||||
rag = LightRAG(
|
||||
working_dir=working_dir,
|
||||
workspace=workspace,
|
||||
llm_model_func=llm_func,
|
||||
embedding_func=embedding_func,
|
||||
addon_params={
|
||||
"language": "Chinese",
|
||||
"entity_types": get_env_value("ENTITY_TYPES", DEFAULT_ENTITY_TYPES, list),
|
||||
},
|
||||
)
|
||||
await rag.initialize_storages()
|
||||
return rag
|
||||
|
||||
|
||||
class GraphKnowledgeBaseStrategy(KnowledgeBaseStrategy):
|
||||
# 类级别的缓存,允许跨实例共享
|
||||
_rag_cache: Dict[str, Any] = {}
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
super().__init__(db)
|
||||
self.kb_repo = KnowledgeBaseRepository(db)
|
||||
|
||||
async def query(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
node_label = kwargs.get("node_label")
|
||||
if not node_label:
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_INVALID_REQUEST,
|
||||
"Missing 'node_label' parameter for graph query"
|
||||
)
|
||||
|
||||
kb = await self._get_knowledge_base(knowledge_base_id)
|
||||
if not kb:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
|
||||
rag_instance = await self._get_or_create_graph_rag(kb)
|
||||
return await rag_instance.get_knowledge_graph(node_label=node_label)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query_text: str,
|
||||
knowledge_base_ids: List[str],
|
||||
top_k: int = 10,
|
||||
threshold: Optional[float] = None,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
if len(knowledge_base_ids) != 1:
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_INVALID_REQUEST,
|
||||
"At least one knowledge base required for graph search"
|
||||
)
|
||||
|
||||
all_results = []
|
||||
for kb_id in knowledge_base_ids:
|
||||
kb = await self._get_knowledge_base(kb_id)
|
||||
if not kb:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
|
||||
rag_instance = await self._get_or_create_graph_rag(kb)
|
||||
# Use aquery_data for content retrieval (not get_knowledge_graph)
|
||||
query_param = QueryParam(mode="mix", top_k=top_k, only_need_context=True)
|
||||
retrieval_results = await rag_instance.aquery_data(query_text, query_param)
|
||||
|
||||
unified_results = self._convert_retrieval_results_into_unified(
|
||||
retrieval_results, str(kb.id), str(kb.name)
|
||||
)
|
||||
all_results.extend(unified_results)
|
||||
|
||||
all_results.sort(key=lambda x: x.get("score", 1.0), reverse=True)
|
||||
logger.info("知识图谱检索完成: 结果数=%d", len(all_results))
|
||||
return all_results
|
||||
|
||||
@staticmethod
|
||||
def _convert_retrieval_results_into_unified(
|
||||
retrieval_results: Dict[str, Any],
|
||||
knowledge_base_id: str,
|
||||
knowledge_base_name: str
|
||||
) -> List[Dict[str, Any]]:
|
||||
if not isinstance(retrieval_results, dict):
|
||||
return []
|
||||
|
||||
data = retrieval_results.get("data", retrieval_results)
|
||||
entities = data.get("entities", [])
|
||||
relationships = data.get("relationships", [])
|
||||
chunks = data.get("chunks", [])
|
||||
|
||||
if not entities and not relationships and not chunks:
|
||||
return []
|
||||
|
||||
reference_files: Dict[str, str] = {}
|
||||
ref_counter = 1
|
||||
|
||||
for chunk in chunks:
|
||||
file_path = chunk.get("file_path", "")
|
||||
if file_path and file_path not in reference_files:
|
||||
reference_files[file_path] = str(ref_counter)
|
||||
ref_counter += 1
|
||||
|
||||
for entity in entities:
|
||||
file_path = entity.get("file_path", "")
|
||||
if file_path and file_path not in reference_files:
|
||||
reference_files[file_path] = str(ref_counter)
|
||||
ref_counter += 1
|
||||
|
||||
for rel in relationships:
|
||||
file_path = rel.get("file_path", "")
|
||||
if file_path and file_path not in reference_files:
|
||||
reference_files[file_path] = str(ref_counter)
|
||||
ref_counter += 1
|
||||
|
||||
text_parts = []
|
||||
|
||||
if entities:
|
||||
text_parts.append("\nKnowledge Graph Data (Entity):\n")
|
||||
text_parts.append("\n```json")
|
||||
for entity in entities:
|
||||
entity_data = {
|
||||
"entity": entity.get("entity_name", ""),
|
||||
"type": entity.get("entity_type", ""),
|
||||
"description": entity.get("description", ""),
|
||||
}
|
||||
text_parts.append(f"\n{json.dumps(entity_data, ensure_ascii=False)}")
|
||||
text_parts.append("\n```")
|
||||
|
||||
if relationships:
|
||||
text_parts.append("\n\nKnowledge Graph Data (Relationship):\n")
|
||||
text_parts.append("\n```json")
|
||||
for rel in relationships:
|
||||
rel_data = {
|
||||
"entity1": rel.get("src_id", ""),
|
||||
"entity2": rel.get("tgt_id", ""),
|
||||
"description": rel.get("description", ""),
|
||||
}
|
||||
text_parts.append(f"\n{json.dumps(rel_data, ensure_ascii=False)}")
|
||||
text_parts.append("\n```")
|
||||
|
||||
if chunks:
|
||||
text_parts.append(
|
||||
"\n\nDocument Chunks (Each entry has a reference_id refer to the `Reference Document List`):\n"
|
||||
)
|
||||
text_parts.append("\n```json")
|
||||
for chunk in chunks:
|
||||
file_path = chunk.get("file_path", "")
|
||||
ref_id = reference_files.get(file_path, "")
|
||||
chunk_data = {
|
||||
"reference_id": ref_id,
|
||||
"content": chunk.get("content", ""),
|
||||
}
|
||||
text_parts.append(f"\n{json.dumps(chunk_data, ensure_ascii=False)}")
|
||||
text_parts.append("\n```")
|
||||
|
||||
if reference_files:
|
||||
text_parts.append(
|
||||
"\n\nReference Document List (Each entry starts with a [reference_id] "
|
||||
"that corresponds to entries in the Document Chunks):\n"
|
||||
)
|
||||
text_parts.append("\n```")
|
||||
for file_path, ref_id in reference_files.items():
|
||||
file_name = Path(file_path).name if file_path else ""
|
||||
text_parts.append(f"\n[{ref_id}] {file_name}")
|
||||
text_parts.append("\n```")
|
||||
|
||||
formatted_text = "".join(text_parts)
|
||||
|
||||
return [{
|
||||
"id": knowledge_base_id,
|
||||
"text": formatted_text,
|
||||
"score": 1.0,
|
||||
"metadata": {
|
||||
"entity_count": len(entities),
|
||||
"relationship_count": len(relationships),
|
||||
"chunk_count": len(chunks),
|
||||
"reference_files": {v: k for k, v in reference_files.items()},
|
||||
},
|
||||
"resultType": "graph_retrieval",
|
||||
"knowledgeBaseId": knowledge_base_id,
|
||||
"knowledgeBaseName": knowledge_base_name,
|
||||
}]
|
||||
|
||||
async def process_file(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
rag_file_id: str,
|
||||
**kwargs
|
||||
) -> None:
|
||||
from app.module.rag.infra.document.processor import ingest_file_to_chunks
|
||||
from app.db.models.knowledge_gen import FileStatus
|
||||
from app.module.rag.repository import RagFileRepository
|
||||
|
||||
kb_repo = KnowledgeBaseRepository(self.db)
|
||||
file_repo = RagFileRepository(self.db)
|
||||
|
||||
kb = await kb_repo.get_by_id(knowledge_base_id)
|
||||
if not kb:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
|
||||
rag_file = await file_repo.get_by_id(rag_file_id)
|
||||
if not rag_file:
|
||||
raise BusinessError(ErrorCodes.RAG_FILE_NOT_FOUND)
|
||||
|
||||
try:
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESSING)
|
||||
await self.db.commit()
|
||||
|
||||
file_path = get_file_path(rag_file)
|
||||
if not file_path or not Path(file_path).exists():
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESS_FAILED)
|
||||
await self.db.commit()
|
||||
logger.error("文件不存在: %s", file_path)
|
||||
return
|
||||
|
||||
chunks = await ingest_file_to_chunks(
|
||||
file_path,
|
||||
process_type=kwargs.get("process_type", "DEFAULT_CHUNK"),
|
||||
chunk_size=kwargs.get("chunk_size", 500),
|
||||
overlap_size=kwargs.get("overlap_size", 50),
|
||||
delimiter=kwargs.get("delimiter"),
|
||||
)
|
||||
|
||||
if not chunks:
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESS_FAILED)
|
||||
await self.db.commit()
|
||||
logger.error("文件解析失败,未生成任何文档")
|
||||
return
|
||||
|
||||
rag_instance = await self._get_or_create_graph_rag(kb)
|
||||
|
||||
for idx, chunk in enumerate(chunks):
|
||||
logger.info(
|
||||
"插入文档到知识图谱: %s, 进度: %d/%d",
|
||||
rag_file.file_name, idx + 1, len(chunks)
|
||||
)
|
||||
await rag_instance.ainsert(
|
||||
input=chunk.text,
|
||||
file_paths=[file_path]
|
||||
)
|
||||
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESSED)
|
||||
await file_repo.update_chunk_count(rag_file_id, len(chunks))
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("文件 %s 知识图谱构建完成,文档数=%d", rag_file.file_name, len(chunks))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("文件 %s 知识图谱构建失败: %s", rag_file.file_name, e)
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESS_FAILED)
|
||||
await self.db.commit()
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_FILE_PROCESS_FAILED,
|
||||
f"知识图谱构建失败: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def _get_knowledge_base(self, knowledge_base_id: str) -> KnowledgeBase:
|
||||
kb = await self.kb_repo.get_by_id(knowledge_base_id)
|
||||
if not kb:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
return kb
|
||||
|
||||
async def _get_or_create_graph_rag(self, kb: KnowledgeBase) -> Any:
|
||||
kb_name = str(kb.name)
|
||||
if kb_name in self._rag_cache:
|
||||
return self._rag_cache[kb_name]
|
||||
|
||||
chat_model = await get_model_by_id(self.db, str(kb.chat_model))
|
||||
embedding_model = await get_model_by_id(self.db, str(kb.embedding_model))
|
||||
|
||||
if not chat_model or not embedding_model:
|
||||
raise BusinessError(ErrorCodes.RAG_MODEL_NOT_FOUND)
|
||||
|
||||
llm_func = _create_llm_func(
|
||||
str(chat_model.model_name),
|
||||
str(chat_model.base_url),
|
||||
str(chat_model.api_key),
|
||||
)
|
||||
|
||||
from app.module.shared.llm import LLMFactory
|
||||
embedding_func = _create_embedding_func(
|
||||
str(embedding_model.model_name),
|
||||
str(embedding_model.base_url),
|
||||
str(embedding_model.api_key),
|
||||
LLMFactory.get_embedding_dimension(
|
||||
str(embedding_model.model_name),
|
||||
str(embedding_model.base_url),
|
||||
str(embedding_model.api_key),
|
||||
),
|
||||
)
|
||||
|
||||
rag = await _create_rag(llm_func, embedding_func, DEFAULT_WORKING_DIR, workspace=kb_name)
|
||||
self._rag_cache[kb_name] = rag
|
||||
return rag
|
||||
|
||||
@classmethod
|
||||
def rename_workspace(cls, old_name: str, new_name: str) -> None:
|
||||
old_path = Path(DEFAULT_WORKING_DIR) / old_name
|
||||
new_path = Path(DEFAULT_WORKING_DIR) / new_name
|
||||
if old_path.exists() and old_path.is_dir():
|
||||
old_path.rename(new_path)
|
||||
logger.info("知识图谱 workspace 重命名: %s -> %s", old_name, new_name)
|
||||
cls.clear_cache(old_name)
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls, name: str) -> None:
|
||||
if name in cls._rag_cache:
|
||||
del cls._rag_cache[name]
|
||||
logger.info("已清除知识图谱缓存: %s", name)
|
||||
@@ -0,0 +1,481 @@
|
||||
"""
|
||||
向量知识库策略
|
||||
|
||||
实现 DOCUMENT 类型知识库的检索和处理逻辑。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pymilvus import AnnSearchRequest, Function, FunctionType
|
||||
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
from app.module.rag.infra.embeddings import EmbeddingFactory
|
||||
from app.module.rag.infra.vectorstore.milvus_client import get_milvus_client
|
||||
from app.module.rag.repository import KnowledgeBaseRepository, RagFileRepository
|
||||
from app.module.rag.schema.response import PagedResponse, RagChunkResp
|
||||
from app.module.rag.schema.request import ChunkFilterQuery
|
||||
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
|
||||
from .base import KnowledgeBaseStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VectorKnowledgeBaseStrategy(KnowledgeBaseStrategy):
|
||||
"""向量知识库策略实现
|
||||
|
||||
提供 DOCUMENT 类型知识库的 query、 search 和 process_file 功能。
|
||||
"""
|
||||
|
||||
async def query(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
**kwargs
|
||||
) -> PagedResponse:
|
||||
"""查询知识库分块数据(不涉及相似度计算)
|
||||
|
||||
Args:
|
||||
knowledge_base_id: 知识库 ID
|
||||
**kwargs: 额外参数,必须包含:
|
||||
- rag_file_id: RAG 文件 ID
|
||||
- chunk_filter_query: 分页和过滤参数
|
||||
|
||||
Returns:
|
||||
分页分块列表
|
||||
"""
|
||||
rag_file_id = kwargs.get("rag_file_id")
|
||||
chunk_filter_query: ChunkFilterQuery = kwargs.get("chunk_filter_query")
|
||||
|
||||
if not rag_file_id or not chunk_filter_query:
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_INVALID_REQUEST,
|
||||
"Missing rag_file_id or chunk_filter_query parameters"
|
||||
)
|
||||
|
||||
kb_repo = KnowledgeBaseRepository(self.db)
|
||||
file_repo = RagFileRepository(self.db)
|
||||
|
||||
knowledge_base = await kb_repo.get_by_id(knowledge_base_id)
|
||||
if not knowledge_base:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
|
||||
rag_file = await file_repo.get_by_id(rag_file_id)
|
||||
if not rag_file:
|
||||
raise BusinessError(ErrorCodes.RAG_FILE_NOT_FOUND)
|
||||
|
||||
client = get_milvus_client()
|
||||
|
||||
try:
|
||||
base_filter = f'metadata["rag_file_id"] == "{rag_file_id}"'
|
||||
combined_filter = self._build_combined_filter(base_filter, chunk_filter_query.expr)
|
||||
|
||||
count_res = client.query(
|
||||
collection_name=knowledge_base.name,
|
||||
filter=combined_filter,
|
||||
output_fields=["id"],
|
||||
)
|
||||
total = len(count_res)
|
||||
|
||||
offset = (chunk_filter_query.page - 1) * chunk_filter_query.size
|
||||
results = client.query(
|
||||
collection_name=knowledge_base.name,
|
||||
filter=combined_filter,
|
||||
output_fields=["id", "text", "metadata"],
|
||||
limit=chunk_filter_query.size,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
chunks = [
|
||||
RagChunkResp(
|
||||
id=item.get("id", ""),
|
||||
text=item.get("text", ""),
|
||||
metadata=item.get("metadata", {}),
|
||||
score=0.0,
|
||||
distance=None,
|
||||
)
|
||||
for item in results
|
||||
]
|
||||
|
||||
logger.info(
|
||||
"查询文件分块成功: kb=%s file=%s total=%d",
|
||||
knowledge_base_id, rag_file_id, total
|
||||
)
|
||||
|
||||
return PagedResponse.create(
|
||||
content=chunks,
|
||||
total_elements=total,
|
||||
page=chunk_filter_query.page,
|
||||
size=chunk_filter_query.size,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
"查询文件分块失败: kb=%s file=%s error=%s",
|
||||
knowledge_base_id, rag_file_id, e
|
||||
)
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_MILVUS_ERROR,
|
||||
f"查询文件分块失败: {str(e)}"
|
||||
) from e
|
||||
|
||||
@staticmethod
|
||||
def _build_combined_filter(base_filter: str, user_expr: Optional[str]) -> str:
|
||||
if not user_expr:
|
||||
return base_filter
|
||||
return f"{base_filter} && {user_expr}"
|
||||
|
||||
async def search(
|
||||
self,
|
||||
query_text: str,
|
||||
knowledge_base_ids: List[str],
|
||||
top_k: int = 10,
|
||||
threshold: Optional[float] = None,
|
||||
**kwargs
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""基于输入文本的相似度检索(混合检索: 向量 + BM25)
|
||||
|
||||
Args:
|
||||
query_text: 查询文本
|
||||
knowledge_base_ids: 知识库 ID 列表
|
||||
top_k: 返回结果数量
|
||||
threshold: 相似度阈值(可选)
|
||||
**kwargs: 额外参数
|
||||
|
||||
Returns:
|
||||
统一格式的检索结果列表
|
||||
"""
|
||||
kb_repo = KnowledgeBaseRepository(self.db)
|
||||
|
||||
knowledge_bases = []
|
||||
for kb_id in knowledge_base_ids:
|
||||
kb = await kb_repo.get_by_id(kb_id)
|
||||
if not kb:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
knowledge_bases.append(kb)
|
||||
|
||||
embedding_entity = await get_model_by_id(self.db, knowledge_bases[0].embedding_model)
|
||||
if not embedding_entity:
|
||||
raise BusinessError(ErrorCodes.RAG_MODEL_NOT_FOUND)
|
||||
|
||||
embedding = EmbeddingFactory.create_embeddings(
|
||||
model_name=embedding_entity.model_name,
|
||||
base_url=getattr(embedding_entity, "base_url", None),
|
||||
api_key=getattr(embedding_entity, "api_key", None),
|
||||
)
|
||||
|
||||
try:
|
||||
query_vector = await asyncio.to_thread(embedding.embed_query, query_text)
|
||||
except Exception as e:
|
||||
logger.error("查询向量化失败: %s", e)
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_EMBEDDING_FAILED,
|
||||
f"查询向量化失败: {str(e)}"
|
||||
) from e
|
||||
|
||||
all_results = await self._execute_hybrid_search(
|
||||
knowledge_bases, query_vector, query_text, top_k
|
||||
)
|
||||
|
||||
all_results.sort(
|
||||
key=lambda x: x.get("score") or x.get("distance", 0),
|
||||
reverse=True
|
||||
)
|
||||
|
||||
if threshold is not None:
|
||||
all_results = [
|
||||
r for r in all_results
|
||||
if (r.get("score") or r.get("distance", 0)) >= 0
|
||||
]
|
||||
|
||||
formatted = self._format_unified_results(all_results)
|
||||
logger.info("向量检索完成: 结果数=%d", len(formatted))
|
||||
return formatted
|
||||
|
||||
@staticmethod
|
||||
async def _execute_hybrid_search(
|
||||
knowledge_bases: list,
|
||||
query_vector: list,
|
||||
query_text: str,
|
||||
top_k: int,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""执行混合检索"""
|
||||
all_results = []
|
||||
client = get_milvus_client()
|
||||
|
||||
for kb in knowledge_bases:
|
||||
try:
|
||||
if not client.has_collection(kb.name):
|
||||
logger.warning("集合 %s 不存在,跳过", kb.name)
|
||||
continue
|
||||
|
||||
dense_search = AnnSearchRequest(
|
||||
data=[query_vector],
|
||||
anns_field="vector",
|
||||
param={"nprobe": 10},
|
||||
limit=top_k,
|
||||
)
|
||||
|
||||
sparse_search = AnnSearchRequest(
|
||||
data=[query_text],
|
||||
anns_field="sparse",
|
||||
param={"drop_ratio_search": 0.2},
|
||||
limit=top_k,
|
||||
)
|
||||
|
||||
ranker = Function(
|
||||
name="weight",
|
||||
input_field_names=[],
|
||||
function_type=FunctionType.RERANK,
|
||||
params={
|
||||
"reranker": "weighted",
|
||||
"weights": [0.1, 0.9],
|
||||
"norm_score": True,
|
||||
}
|
||||
)
|
||||
search_results = client.hybrid_search(
|
||||
collection_name=kb.name,
|
||||
reqs=[dense_search, sparse_search],
|
||||
ranker=ranker,
|
||||
output_fields=["id", "text", "metadata"],
|
||||
limit=top_k,
|
||||
)
|
||||
|
||||
if search_results and len(search_results) > 0:
|
||||
for result in search_results[0]:
|
||||
result["knowledge_base_id"] = kb.id
|
||||
result["knowledge_base_name"] = kb.name
|
||||
all_results.append(result)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("知识库 %s 混合检索失败: %s", kb.name, e)
|
||||
continue
|
||||
|
||||
return all_results
|
||||
|
||||
@staticmethod
|
||||
def _format_unified_results(
|
||||
all_results: List[Dict[str, Any]]
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""格式化为统一检索结果"""
|
||||
formatted = []
|
||||
for r in all_results:
|
||||
entity = r.get("entity", {})
|
||||
metadata = entity.get("metadata", {})
|
||||
if isinstance(metadata, dict):
|
||||
metadata_str = json.dumps(metadata, ensure_ascii=False)
|
||||
else:
|
||||
metadata_str = metadata if metadata else "{}"
|
||||
|
||||
formatted.append({
|
||||
"id": entity.get("id", ""),
|
||||
"text": entity.get("text", ""),
|
||||
"score": r.get("score") or r.get("distance", 0),
|
||||
"metadata": json.loads(metadata_str) if isinstance(metadata_str, str) else metadata,
|
||||
"resultType": "vector",
|
||||
"knowledgeBaseId": r.get("knowledge_base_id", ""),
|
||||
"knowledgeBaseName": r.get("knowledge_base_name", ""),
|
||||
})
|
||||
|
||||
return formatted
|
||||
|
||||
async def process_file(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
rag_file_id: str,
|
||||
**kwargs
|
||||
) -> None:
|
||||
"""处理单个文件(文档向量化)
|
||||
|
||||
Args:
|
||||
knowledge_base_id: 知识库 ID
|
||||
rag_file_id: RAG 文件 ID
|
||||
**kwargs: 额外参数
|
||||
- process_type: 分块类型
|
||||
- chunk_size: 分块大小
|
||||
- overlap_size: 重叠大小
|
||||
- delimiter: 分隔符
|
||||
"""
|
||||
from pathlib import Path
|
||||
from app.module.rag.infra.vectorstore import VectorStoreFactory
|
||||
from app.module.rag.infra.document.processor import ingest_file_to_chunks
|
||||
from app.db.models.knowledge_gen import FileStatus
|
||||
from app.module.rag.service.common import TextCleaner, MetadataBuilder, BatchProcessor
|
||||
|
||||
kb_repo = KnowledgeBaseRepository(self.db)
|
||||
file_repo = RagFileRepository(self.db)
|
||||
|
||||
kb = await kb_repo.get_by_id(knowledge_base_id)
|
||||
if not kb:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
|
||||
rag_file = await file_repo.get_by_id(rag_file_id)
|
||||
if not rag_file:
|
||||
raise BusinessError(ErrorCodes.RAG_FILE_NOT_FOUND)
|
||||
|
||||
try:
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESSING)
|
||||
await self.db.commit()
|
||||
|
||||
file_path = self._get_file_path(rag_file)
|
||||
if not file_path or not Path(file_path).exists():
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESS_FAILED)
|
||||
await self.db.commit()
|
||||
logger.error("文件不存在: %s", file_path)
|
||||
return
|
||||
|
||||
chunks = await ingest_file_to_chunks(
|
||||
file_path,
|
||||
process_type=kwargs.get("process_type", "DEFAULT_CHUNK"),
|
||||
chunk_size=kwargs.get("chunk_size", 500),
|
||||
overlap_size=kwargs.get("overlap_size", 50),
|
||||
delimiter=kwargs.get("delimiter"),
|
||||
)
|
||||
|
||||
if not chunks:
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESS_FAILED)
|
||||
await self.db.commit()
|
||||
logger.error("文件解析失败,未生成任何分块")
|
||||
return
|
||||
|
||||
valid_chunks = self._filter_and_clean_chunks(chunks)
|
||||
if not valid_chunks:
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESS_FAILED)
|
||||
await self.db.commit()
|
||||
logger.error("文件没有有效的分块内容")
|
||||
return
|
||||
|
||||
embedding_entity = await get_model_by_id(self.db, kb.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),
|
||||
)
|
||||
|
||||
vectorstore = VectorStoreFactory.create(
|
||||
collection_name=str(kb.name),
|
||||
embedding=embedding,
|
||||
)
|
||||
|
||||
base_metadata = MetadataBuilder.build_chunk_metadata(rag_file, kb)
|
||||
MetadataBuilder.add_to_chunks(valid_chunks, base_metadata)
|
||||
|
||||
await BatchProcessor.store_in_batches(vectorstore, valid_chunks)
|
||||
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESSED)
|
||||
await file_repo.update_chunk_count(rag_file_id, len(valid_chunks))
|
||||
await self.db.commit()
|
||||
|
||||
logger.info("文件 %s 向量化处理完成,分块数=%d", rag_file.file_name, len(valid_chunks))
|
||||
|
||||
except Exception as e:
|
||||
logger.exception("文件 %s 处理失败: %s", rag_file.file_name, e)
|
||||
await file_repo.update_status(rag_file_id, FileStatus.PROCESS_FAILED)
|
||||
await self.db.commit()
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_FILE_PROCESS_FAILED,
|
||||
f"文件处理失败: {str(e)}"
|
||||
) from e
|
||||
|
||||
def _get_file_path(self, rag_file) -> Optional[str]:
|
||||
from app.module.rag.service.common import get_file_path
|
||||
return get_file_path(rag_file)
|
||||
|
||||
def _filter_and_clean_chunks(self, chunks: list) -> list:
|
||||
"""过滤和清理分块"""
|
||||
from app.module.rag.service.common import TextCleaner
|
||||
|
||||
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(
|
||||
"跳过无效分块: chunk_index=%s",
|
||||
chunk.metadata.get("chunk_index")
|
||||
)
|
||||
|
||||
logger.info("有效分块数量: %d / %d", len(valid_chunks), len(chunks))
|
||||
return valid_chunks
|
||||
|
||||
async def update_chunk(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
chunk_id: str,
|
||||
text: str,
|
||||
metadata: Optional[dict] = None,
|
||||
) -> None:
|
||||
"""更新指定分块的文本和元数据
|
||||
|
||||
Args:
|
||||
knowledge_base_id: 知识库 ID
|
||||
chunk_id: 分块 ID
|
||||
text: 新的文本内容
|
||||
metadata: 新的元数据(可选)
|
||||
"""
|
||||
from app.module.rag.infra.vectorstore import update_chunk_by_id
|
||||
|
||||
kb_repo = KnowledgeBaseRepository(self.db)
|
||||
kb = await kb_repo.get_by_id(knowledge_base_id)
|
||||
if not kb:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
|
||||
embedding_entity = await get_model_by_id(self.db, kb.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(kb.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
|
||||
"""
|
||||
from app.module.rag.infra.vectorstore import delete_chunk_by_id
|
||||
|
||||
kb_repo = KnowledgeBaseRepository(self.db)
|
||||
kb = await kb_repo.get_by_id(knowledge_base_id)
|
||||
if not kb:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
|
||||
await asyncio.to_thread(
|
||||
delete_chunk_by_id,
|
||||
collection_name=str(kb.name),
|
||||
chunk_id=chunk_id,
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"成功删除分块: kb=%s chunk_id=%s",
|
||||
knowledge_base_id, chunk_id
|
||||
)
|
||||
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
统一检索服务
|
||||
|
||||
提供知识库内容的统一检索接口,支持多种知识库类型(向量、知识图谱等)。
|
||||
使用策略模式实现不同知识库类型的检索逻辑。
|
||||
"""
|
||||
import logging
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
from app.db.models.knowledge_gen import KnowledgeBase
|
||||
from app.module.rag.repository import KnowledgeBaseRepository
|
||||
from app.module.rag.schema.request import PagingQuery, ChunkFilterQuery, RetrieveReq
|
||||
from app.module.rag.schema.response import PagedResponse
|
||||
from .strategy import KnowledgeBaseStrategyFactory
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UnifiedRetrievalService:
|
||||
"""统一检索服务
|
||||
|
||||
揯供统一的检索接口,根据知识库类型自动选择策略。
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
"""初始化服务
|
||||
|
||||
Args:
|
||||
db: 数据库异步 session
|
||||
"""
|
||||
self.db = db
|
||||
self.kb_repo = KnowledgeBaseRepository(db)
|
||||
|
||||
async def query(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
**kwargs
|
||||
) -> Any:
|
||||
"""直接查询知识库数据(不涉及相似度计算)
|
||||
|
||||
Args:
|
||||
knowledge_base_id: 知识库 ID
|
||||
**kwargs: 额外参数
|
||||
- 向量知识库: rag_file_id, paging_query
|
||||
- 知识图谱: node_label
|
||||
|
||||
Returns:
|
||||
查询结果(格式由具体知识库类型决定)
|
||||
"""
|
||||
kb = await self._get_knowledge_base(knowledge_base_id)
|
||||
strategy = KnowledgeBaseStrategyFactory.create_strategy(kb.type, self.db)
|
||||
return await strategy.query(knowledge_base_id, **kwargs)
|
||||
|
||||
async def search(
|
||||
self,
|
||||
request: RetrieveReq
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""基于输入文本的相似度检索
|
||||
|
||||
Args:
|
||||
request: 检索请求
|
||||
|
||||
Returns:
|
||||
统一格式的检索结果列表(UnifiedSearchResult)
|
||||
"""
|
||||
if not request.knowledge_base_ids:
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_INVALID_REQUEST,
|
||||
"至少需要一个知识库 ID"
|
||||
)
|
||||
|
||||
kb = await self._get_knowledge_base(request.knowledge_base_ids[0])
|
||||
strategy = KnowledgeBaseStrategyFactory.create_strategy(kb.type, self.db)
|
||||
|
||||
return await strategy.search(
|
||||
query_text=request.query,
|
||||
knowledge_base_ids=request.knowledge_base_ids,
|
||||
top_k=request.top_k,
|
||||
threshold=request.threshold,
|
||||
)
|
||||
|
||||
async def get_chunks(
|
||||
self,
|
||||
knowledge_base_id: str,
|
||||
rag_file_id: str,
|
||||
query: ChunkFilterQuery,
|
||||
) -> PagedResponse:
|
||||
"""获取指定 RAG 文件的分块列表(仅向量知识库)
|
||||
|
||||
Args:
|
||||
knowledge_base_id: 知识库 ID
|
||||
rag_file_id: RAG 文件 ID
|
||||
query: 分页和过滤参数
|
||||
|
||||
Returns:
|
||||
分块列表(分页)
|
||||
"""
|
||||
return await self.query(
|
||||
knowledge_base_id,
|
||||
rag_file_id=rag_file_id,
|
||||
chunk_filter_query=query,
|
||||
)
|
||||
|
||||
async def _get_knowledge_base(self, knowledge_base_id: str) -> KnowledgeBase:
|
||||
"""获取知识库实体
|
||||
|
||||
Args:
|
||||
knowledge_base_id: 知识库 ID
|
||||
|
||||
Returns:
|
||||
知识库实体
|
||||
|
||||
Raises:
|
||||
BusinessError: 知识库不存在
|
||||
"""
|
||||
kb = await self.kb_repo.get_by_id(knowledge_base_id)
|
||||
if not kb:
|
||||
raise BusinessError(ErrorCodes.RAG_KNOWLEDGE_BASE_NOT_FOUND)
|
||||
return kb
|
||||
Reference in New Issue
Block a user