deepdata: core product code (backend, frontend, runtime, deepflow module)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
RAG 基础设施层
|
||||
|
||||
提供文档处理、向量存储、嵌入模型和后台任务功能。
|
||||
|
||||
使用示例:
|
||||
from app.module.rag.infra.document import ingest_file_to_chunks
|
||||
from app.module.rag.infra.vectorstore import VectorStoreFactory
|
||||
from app.module.rag.infra.task import get_global_pool
|
||||
"""
|
||||
from app.module.rag.infra.document import (
|
||||
SplitOptions,
|
||||
default_split_options,
|
||||
ingest_file_to_chunks,
|
||||
load_and_split,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"load_and_split",
|
||||
"ingest_file_to_chunks",
|
||||
"SplitOptions",
|
||||
"default_split_options",
|
||||
]
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
文档处理模块
|
||||
|
||||
提供文档加载、分块和处理管道功能。
|
||||
|
||||
使用示例:
|
||||
from app.module.rag.infra.document import (
|
||||
ingest_file_to_chunks,
|
||||
SplitOptions,
|
||||
DocumentChunk,
|
||||
)
|
||||
|
||||
chunks = await ingest_file_to_chunks(
|
||||
"/path/to/doc.pdf",
|
||||
chunk_size=500,
|
||||
overlap_size=50,
|
||||
)
|
||||
"""
|
||||
from app.module.rag.infra.document.processor import (
|
||||
SplitOptions,
|
||||
default_split_options,
|
||||
ingest_file_to_chunks,
|
||||
load_and_split,
|
||||
)
|
||||
from app.module.rag.infra.document.types import (
|
||||
DocumentChunk,
|
||||
ParsedDocument,
|
||||
langchain_documents_to_parsed,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# 处理管道入口
|
||||
"load_and_split",
|
||||
"ingest_file_to_chunks",
|
||||
# 选项
|
||||
"SplitOptions",
|
||||
"default_split_options",
|
||||
# 类型
|
||||
"DocumentChunk",
|
||||
"ParsedDocument",
|
||||
"langchain_documents_to_parsed",
|
||||
]
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
文档加载器
|
||||
|
||||
封装 UniversalDocLoader,提供统一的文档加载接口。
|
||||
"""
|
||||
import asyncio
|
||||
from typing import List
|
||||
|
||||
from langchain_core.documents import Document
|
||||
|
||||
from app.module.shared.common.document_loaders import UniversalDocLoader
|
||||
|
||||
|
||||
async def load_document(file_path: str) -> List[Document]:
|
||||
"""加载文档(异步封装)
|
||||
|
||||
使用 UniversalDocLoader 加载文档,支持多种格式。
|
||||
|
||||
Args:
|
||||
file_path: 文件绝对路径
|
||||
|
||||
Returns:
|
||||
LangChain Document 列表
|
||||
"""
|
||||
loader = UniversalDocLoader(file_path)
|
||||
return await asyncio.to_thread(loader.load)
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
文档处理管道
|
||||
|
||||
提供统一的文档加载和分块入口,合并原有的 pipeline.py 和 options.py。
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.module.rag.infra.document.loader import load_document
|
||||
from app.module.rag.infra.document.splitter import DocumentSplitterFactory
|
||||
from app.module.rag.infra.document.types import (
|
||||
DocumentChunk,
|
||||
ParsedDocument,
|
||||
langchain_documents_to_parsed,
|
||||
)
|
||||
from app.module.rag.schema.enums import ProcessType
|
||||
|
||||
|
||||
@dataclass
|
||||
class SplitOptions:
|
||||
"""文档分片选项
|
||||
|
||||
Attributes:
|
||||
process_type: 分片策略
|
||||
chunk_size: 块大小(字符)
|
||||
overlap_size: 块间重叠
|
||||
delimiter: 仅 CUSTOM_SEPARATOR_CHUNK 时有效
|
||||
"""
|
||||
process_type: ProcessType = ProcessType.DEFAULT_CHUNK
|
||||
chunk_size: int = 500
|
||||
overlap_size: int = 50
|
||||
delimiter: Optional[str] = None
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""转换为字典"""
|
||||
return {
|
||||
"process_type": self.process_type,
|
||||
"chunk_size": self.chunk_size,
|
||||
"overlap_size": self.overlap_size,
|
||||
"delimiter": self.delimiter,
|
||||
}
|
||||
|
||||
|
||||
def default_split_options() -> SplitOptions:
|
||||
"""默认分片选项:递归分块 500/50"""
|
||||
return SplitOptions()
|
||||
|
||||
|
||||
async def load_and_split(
|
||||
file_path: str,
|
||||
split_options: Optional[SplitOptions] = None,
|
||||
**chunk_metadata: Any,
|
||||
) -> List[DocumentChunk]:
|
||||
"""加载文档并分块
|
||||
|
||||
使用 UniversalDocLoader 加载文档,然后按指定策略分块。
|
||||
|
||||
Args:
|
||||
file_path: 文件绝对路径
|
||||
split_options: 分片选项,None 表示使用默认
|
||||
**chunk_metadata: 写入每个 chunk.metadata 的额外字段
|
||||
|
||||
Returns:
|
||||
分块列表
|
||||
"""
|
||||
documents = await load_document(file_path)
|
||||
|
||||
parser_metadata = {}
|
||||
for key in ["original_file_id", "rag_file_id", "file_name"]:
|
||||
if key in chunk_metadata:
|
||||
parser_metadata[key] = chunk_metadata[key]
|
||||
|
||||
parsed = langchain_documents_to_parsed(documents, file_path, **parser_metadata)
|
||||
|
||||
options = split_options or default_split_options()
|
||||
|
||||
base_chunk_metadata = {
|
||||
"file_name": parsed.metadata.get("file_name", ""),
|
||||
"file_extension": parsed.metadata.get("file_extension", ""),
|
||||
"absolute_directory_path": parsed.metadata.get("absolute_directory_path", ""),
|
||||
"original_file_id": parsed.metadata.get("original_file_id", ""),
|
||||
"rag_file_id": parsed.metadata.get("rag_file_id", ""),
|
||||
}
|
||||
base_chunk_metadata.update(chunk_metadata)
|
||||
|
||||
splitter = DocumentSplitterFactory.create_splitter(
|
||||
options.process_type,
|
||||
chunk_size=options.chunk_size,
|
||||
overlap_size=options.overlap_size,
|
||||
delimiter=options.delimiter,
|
||||
)
|
||||
|
||||
return await splitter.split(parsed.text, **base_chunk_metadata)
|
||||
|
||||
|
||||
async def ingest_file_to_chunks(
|
||||
file_path: str,
|
||||
process_type: ProcessType = ProcessType.DEFAULT_CHUNK,
|
||||
chunk_size: int = 500,
|
||||
overlap_size: int = 50,
|
||||
delimiter: Optional[str] = None,
|
||||
**chunk_metadata: Any,
|
||||
) -> List[DocumentChunk]:
|
||||
"""从本地文件加载文档并分块(便捷入口)
|
||||
|
||||
可被 ETL、URL 抓取、S3 等场景复用。
|
||||
|
||||
Args:
|
||||
file_path: 文件绝对路径
|
||||
process_type: 分块策略
|
||||
chunk_size: 块大小
|
||||
overlap_size: 重叠大小
|
||||
delimiter: 自定义分隔符
|
||||
**chunk_metadata: 写入每个 chunk.metadata 的额外字段
|
||||
|
||||
Returns:
|
||||
分块列表
|
||||
"""
|
||||
split_options = SplitOptions(
|
||||
process_type=process_type,
|
||||
chunk_size=chunk_size,
|
||||
overlap_size=overlap_size,
|
||||
delimiter=delimiter,
|
||||
)
|
||||
return await load_and_split(file_path, split_options=split_options, **chunk_metadata)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
文档分块器
|
||||
|
||||
包含 DocumentSplitter 基类、工厂和 LangChain 实现。
|
||||
根据 ProcessType 创建对应的分块策略。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, List, Optional
|
||||
|
||||
from langchain_text_splitters import (
|
||||
CharacterTextSplitter,
|
||||
RecursiveCharacterTextSplitter,
|
||||
)
|
||||
|
||||
from app.module.rag.infra.document.types import DocumentChunk
|
||||
from app.module.rag.schema.enums import ProcessType
|
||||
|
||||
|
||||
# 各 ProcessType 对应的分隔符配置(优先保持较大语义块)
|
||||
SEPARATORS_BY_PROCESS_TYPE = {
|
||||
ProcessType.PARAGRAPH_CHUNK: ["\n\n", "\n", " ", ""],
|
||||
ProcessType.SENTENCE_CHUNK: ["\n\n", "\n", "。", "!", "?", ". ", "! ", "? ", " ", ""],
|
||||
ProcessType.DEFAULT_CHUNK: ["\n\n", "\n", " ", ""],
|
||||
ProcessType.CUSTOM_SEPARATOR_CHUNK: None,
|
||||
}
|
||||
|
||||
|
||||
class DocumentSplitter(ABC):
|
||||
"""文档分块器基类
|
||||
|
||||
所有具体的分块器都需要继承此类并实现 split 方法。
|
||||
"""
|
||||
|
||||
def __init__(self, chunk_size: int = 500, overlap_size: int = 50):
|
||||
"""初始化分块器
|
||||
|
||||
Args:
|
||||
chunk_size: 分块大小
|
||||
overlap_size: 重叠大小
|
||||
"""
|
||||
self.chunk_size = chunk_size
|
||||
self.overlap_size = overlap_size
|
||||
|
||||
@abstractmethod
|
||||
async def split(self, text: str, **metadata: Any) -> List[DocumentChunk]:
|
||||
"""分割文档
|
||||
|
||||
Args:
|
||||
text: 文档文本
|
||||
**metadata: 额外的元数据
|
||||
|
||||
Returns:
|
||||
分块列表
|
||||
"""
|
||||
pass
|
||||
|
||||
def _create_chunk(self, text: str, chunk_index: int, **metadata: Any) -> DocumentChunk:
|
||||
"""创建文档分块"""
|
||||
chunk_metadata = {"chunk_index": chunk_index, **metadata}
|
||||
return DocumentChunk(text=text, metadata=chunk_metadata)
|
||||
|
||||
|
||||
class LangChainDocumentSplitter(DocumentSplitter):
|
||||
"""基于 LangChain 的分块器实现
|
||||
|
||||
根据 ProcessType 选择 RecursiveCharacterTextSplitter 或 CharacterTextSplitter。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
process_type: ProcessType,
|
||||
chunk_size: int = 500,
|
||||
overlap_size: int = 50,
|
||||
delimiter: Optional[str] = None,
|
||||
):
|
||||
super().__init__(chunk_size=chunk_size, overlap_size=overlap_size)
|
||||
self._process_type = process_type
|
||||
self._delimiter = delimiter or "\n\n"
|
||||
self._splitter = self._create_splitter()
|
||||
|
||||
def _create_splitter(self) -> RecursiveCharacterTextSplitter | CharacterTextSplitter:
|
||||
"""创建 LangChain 分块器实例"""
|
||||
if self._process_type == ProcessType.LENGTH_CHUNK:
|
||||
return CharacterTextSplitter(
|
||||
chunk_size=self.chunk_size,
|
||||
chunk_overlap=self.overlap_size,
|
||||
length_function=len,
|
||||
)
|
||||
|
||||
separators = SEPARATORS_BY_PROCESS_TYPE.get(self._process_type)
|
||||
if self._process_type == ProcessType.CUSTOM_SEPARATOR_CHUNK:
|
||||
separators = [self._delimiter, "\n", " ", ""]
|
||||
if separators is None:
|
||||
separators = ["\n\n", "\n", " ", ""]
|
||||
|
||||
return RecursiveCharacterTextSplitter(
|
||||
chunk_size=self.chunk_size,
|
||||
chunk_overlap=self.overlap_size,
|
||||
separators=separators,
|
||||
length_function=len,
|
||||
)
|
||||
|
||||
async def split(self, text: str, **metadata: Any) -> List[DocumentChunk]:
|
||||
"""分割文档(异步)"""
|
||||
if not text or not text.strip():
|
||||
return []
|
||||
|
||||
texts = await asyncio.to_thread(self._splitter.split_text, text)
|
||||
return [
|
||||
DocumentChunk(text=t, metadata={**metadata, "chunk_index": i})
|
||||
for i, t in enumerate(texts)
|
||||
]
|
||||
|
||||
|
||||
class DocumentSplitterFactory:
|
||||
"""文档分块器工厂
|
||||
|
||||
根据处理类型创建对应的分块器实例。
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def create_splitter(
|
||||
cls,
|
||||
process_type: ProcessType,
|
||||
chunk_size: int = 500,
|
||||
overlap_size: int = 50,
|
||||
delimiter: Optional[str] = None,
|
||||
) -> DocumentSplitter:
|
||||
"""创建分块器
|
||||
|
||||
Args:
|
||||
process_type: 处理类型
|
||||
chunk_size: 分块大小
|
||||
overlap_size: 重叠大小
|
||||
delimiter: 自定义分隔符(仅用于 CUSTOM_SEPARATOR_CHUNK)
|
||||
|
||||
Returns:
|
||||
DocumentSplitter 实例
|
||||
"""
|
||||
return LangChainDocumentSplitter(
|
||||
process_type=process_type,
|
||||
chunk_size=chunk_size,
|
||||
overlap_size=overlap_size,
|
||||
delimiter=delimiter,
|
||||
)
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
文档处理类型定义
|
||||
|
||||
包含 ParsedDocument(解析后文档)和 DocumentChunk(文档分块)。
|
||||
"""
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, Any, List, Optional
|
||||
from pathlib import Path
|
||||
|
||||
from langchain_core.documents import Document
|
||||
|
||||
|
||||
@dataclass
|
||||
class DocumentChunk:
|
||||
"""文档分块
|
||||
|
||||
包含分块的文本内容和元数据。
|
||||
|
||||
Attributes:
|
||||
text: 分块文本内容
|
||||
metadata: 分块元数据(包含文件信息、分块索引等)
|
||||
"""
|
||||
text: str
|
||||
metadata: dict
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DocumentChunk(text_length={len(self.text)}, metadata_keys={list(self.metadata.keys())})>"
|
||||
|
||||
|
||||
class ParsedDocument:
|
||||
"""解析后的文档
|
||||
|
||||
包含文档的完整文本内容和元数据,由文档加载器生成,供分块器使用。
|
||||
|
||||
Attributes:
|
||||
text: 文档完整文本内容
|
||||
metadata: 文档元数据(如文件名、扩展名、路径等)
|
||||
file_name: 文件名
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
text: str,
|
||||
metadata: Dict[str, Any],
|
||||
file_name: str
|
||||
):
|
||||
"""初始化解析后的文档
|
||||
|
||||
Args:
|
||||
text: 文档文本内容
|
||||
metadata: 文档元数据(如作者、创建时间等)
|
||||
file_name: 文件名
|
||||
"""
|
||||
self.text = text
|
||||
self.metadata = metadata
|
||||
self.file_name = file_name
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ParsedDocument(file_name={self.file_name}, text_length={len(self.text)})>"
|
||||
|
||||
|
||||
def langchain_documents_to_parsed(
|
||||
documents: List[Document],
|
||||
file_path: str,
|
||||
file_name: Optional[str] = None,
|
||||
**extra_metadata: Any,
|
||||
) -> ParsedDocument:
|
||||
"""将 LangChain Document 列表转换为 ParsedDocument
|
||||
|
||||
多页/多段结果合并为一个文档,用于后续分块处理。
|
||||
|
||||
Args:
|
||||
documents: LangChain 加载器返回的 Document 列表
|
||||
file_path: 源文件路径
|
||||
file_name: 文件名,若提供则优先使用
|
||||
**extra_metadata: 额外的元数据字段(会合并到返回的 metadata 中)
|
||||
|
||||
Returns:
|
||||
ParsedDocument: 合并后的文档对象
|
||||
"""
|
||||
path = Path(file_path)
|
||||
name = file_name or path.name
|
||||
|
||||
if not documents:
|
||||
base_metadata = {
|
||||
"file_name": name,
|
||||
"file_extension": path.suffix.lower(),
|
||||
"file_size": path.stat().st_size if path.exists() else 0,
|
||||
}
|
||||
base_metadata.update(extra_metadata)
|
||||
return ParsedDocument(
|
||||
text="",
|
||||
metadata=base_metadata,
|
||||
file_name=name,
|
||||
)
|
||||
|
||||
texts = [d.page_content for d in documents if d.page_content]
|
||||
merged_text = "\n\n".join(texts)
|
||||
|
||||
meta: Dict[str, Any] = {
|
||||
"file_name": name,
|
||||
"file_extension": path.suffix.lower(),
|
||||
"file_size": path.stat().st_size if path.exists() else 0,
|
||||
"absolute_directory_path": str(path.parent),
|
||||
"file_path": str(path),
|
||||
}
|
||||
|
||||
meta.update(extra_metadata)
|
||||
|
||||
if documents and isinstance(documents[0].metadata, dict):
|
||||
first_meta = documents[0].metadata
|
||||
for k, v in first_meta.items():
|
||||
if k not in meta and v is not None:
|
||||
meta[k] = v
|
||||
|
||||
return ParsedDocument(text=merged_text, metadata=meta, file_name=name)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
LangChain Embeddings 封装
|
||||
|
||||
直接使用 LangChain 的 embeddings 功能,支持多种提供商:
|
||||
- OpenAI: langchain-openai
|
||||
- Ollama: langchain-community
|
||||
- 其他: 通过 LangChain 生态
|
||||
"""
|
||||
from typing import Optional, Any
|
||||
|
||||
from langchain_core.embeddings import Embeddings
|
||||
from langchain_openai import OpenAIEmbeddings
|
||||
|
||||
|
||||
class EmbeddingFactory:
|
||||
"""LangChain Embeddings 工厂类"""
|
||||
|
||||
@staticmethod
|
||||
def create_embeddings(
|
||||
model_name: str,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
**kwargs: Any,
|
||||
) -> Embeddings:
|
||||
"""
|
||||
创建 LangChain Embeddings 实例
|
||||
|
||||
Args:
|
||||
model_name: 模型名称(如 text-embedding-3-small)
|
||||
base_url: API 基础 URL
|
||||
api_key: API 密钥
|
||||
**kwargs: 其他参数
|
||||
|
||||
Returns:
|
||||
LangChain Embeddings 实例
|
||||
"""
|
||||
# ========== 新增修复开始 ==========
|
||||
# DashScope兼容模式不支持encoding_format=base64,强制删除该key
|
||||
if "encoding_format" in kwargs:
|
||||
del kwargs["encoding_format"]
|
||||
# ========== 新增修复结束 ==========
|
||||
|
||||
# OpenAI / OpenAI 兼容接口
|
||||
if "openai" in model_name.lower() or model_name.startswith("text-embedding"):
|
||||
return OpenAIEmbeddings(
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
check_embedding_ctx_length=False,
|
||||
**kwargs,
|
||||
)
|
||||
# Ollama
|
||||
if base_url and "ollama" in base_url.lower():
|
||||
from langchain_community.embeddings.ollama import OllamaEmbeddings
|
||||
|
||||
return OllamaEmbeddings(
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
**kwargs,
|
||||
)
|
||||
# 默认使用 OpenAI 兼容
|
||||
return OpenAIEmbeddings(
|
||||
model=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# xuhuanqing
|
||||
@staticmethod
|
||||
async def aembed_texts(
|
||||
texts: list[str],
|
||||
model_name: str,
|
||||
base_url: str,
|
||||
api_key: str,
|
||||
**kwargs,
|
||||
) -> list[list[float]]:
|
||||
"""
|
||||
LangChain Embeddings 的异步包装
|
||||
专门给 LightRAG / 非 LangChain 调用方使用
|
||||
"""
|
||||
embeddings = EmbeddingFactory.create_embeddings(
|
||||
model_name=model_name,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
# LangChain 的 embed_documents 是同步的
|
||||
# 在 async 环境中用 run_in_executor 包装
|
||||
import asyncio
|
||||
loop = asyncio.get_running_loop()
|
||||
return await loop.run_in_executor(
|
||||
None,
|
||||
embeddings.embed_documents,
|
||||
texts,
|
||||
)
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
异步任务处理模块
|
||||
|
||||
提供工作池和异步任务处理功能。
|
||||
"""
|
||||
from app.module.rag.infra.task.worker_pool import WorkerPool, get_global_pool
|
||||
|
||||
__all__ = ["WorkerPool", "get_global_pool"]
|
||||
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
工作协程池
|
||||
|
||||
使用 asyncio.Semaphore 控制并发数,替代 Java 的虚拟线程 + 信号量。
|
||||
提供全局单例,确保所有文件处理共享同一个并发池。
|
||||
"""
|
||||
import asyncio
|
||||
from typing import Callable, Any, Coroutine, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全局单例
|
||||
_global_pool: Optional["WorkerPool"] = None
|
||||
|
||||
|
||||
def get_global_pool(max_workers: int = 10) -> "WorkerPool":
|
||||
"""获取全局 WorkerPool 单例
|
||||
|
||||
所有文件处理任务共享同一个并发池,确保最多 10 个文件并行处理。
|
||||
|
||||
Args:
|
||||
max_workers: 最大并发数(仅在首次创建时生效)
|
||||
|
||||
Returns:
|
||||
全局 WorkerPool 实例
|
||||
"""
|
||||
global _global_pool
|
||||
if _global_pool is None:
|
||||
_global_pool = WorkerPool(max_workers)
|
||||
logger.info("创建全局 WorkerPool,最大并发数: %d", max_workers)
|
||||
return _global_pool
|
||||
|
||||
|
||||
class WorkerPool:
|
||||
"""工作协程池
|
||||
|
||||
对应 Java 的虚拟线程 + 信号量方案
|
||||
|
||||
使用 asyncio.Semaphore 控制并发数,避免资源耗尽
|
||||
|
||||
使用示例:
|
||||
pool = WorkerPool(max_workers=10)
|
||||
|
||||
async def task_func(item):
|
||||
# 处理任务
|
||||
return result
|
||||
|
||||
# 并发执行多个任务
|
||||
tasks = [pool.submit(task_func, item) for item in items]
|
||||
results = await asyncio.gather(*tasks)
|
||||
"""
|
||||
|
||||
def __init__(self, max_workers: int = 10):
|
||||
"""初始化工作协程池
|
||||
|
||||
Args:
|
||||
max_workers: 最大并发数(默认 10)
|
||||
"""
|
||||
self.semaphore = asyncio.Semaphore(max_workers)
|
||||
self.max_workers = max_workers
|
||||
self._lock = asyncio.Lock()
|
||||
self._active_count = 0
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
coro: Callable[..., Coroutine],
|
||||
*args: Any,
|
||||
**kwargs: Any
|
||||
) -> Any:
|
||||
"""提交异步任务并等待完成
|
||||
|
||||
Args:
|
||||
coro: 异步协程函数
|
||||
*args: 位置参数
|
||||
**kwargs: 关键字参数
|
||||
|
||||
Returns:
|
||||
协程的返回值
|
||||
"""
|
||||
async with self.semaphore:
|
||||
async with self._lock:
|
||||
self._active_count += 1
|
||||
|
||||
try:
|
||||
result = await coro(*args, **kwargs)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"任务执行失败: {e}")
|
||||
raise
|
||||
finally:
|
||||
async with self._lock:
|
||||
self._active_count -= 1
|
||||
|
||||
async def submit_batch(
|
||||
self,
|
||||
coro: Callable[..., Coroutine],
|
||||
items: list[Any],
|
||||
*args: Any,
|
||||
**kwargs: Any
|
||||
) -> list[Any]:
|
||||
"""批量提交异步任务
|
||||
|
||||
Args:
|
||||
coro: 异步协程函数
|
||||
items: 要处理的项目列表
|
||||
*args: 额外的位置参数
|
||||
**kwargs: 额外的关键字参数
|
||||
|
||||
Returns:
|
||||
结果列表
|
||||
"""
|
||||
tasks = [
|
||||
self.submit(coro, item, *args, **kwargs)
|
||||
for item in items
|
||||
]
|
||||
|
||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||
|
||||
# 检查是否有任务失败
|
||||
for i, result in enumerate(results):
|
||||
if isinstance(result, Exception):
|
||||
logger.error(f"批次任务 {i} 失败: {result}")
|
||||
|
||||
return [r for r in results if not isinstance(r, Exception)]
|
||||
|
||||
async def get_status(self) -> dict:
|
||||
"""获取工作池状态
|
||||
|
||||
Returns:
|
||||
状态字典,包含 max_workers, active_count, available
|
||||
"""
|
||||
return {
|
||||
"max_workers": self.max_workers,
|
||||
"active_count": self._active_count,
|
||||
"available": self.max_workers - self._active_count,
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
向量存储模块
|
||||
|
||||
提供 Milvus 向量存储的创建、管理和数据操作功能。
|
||||
"""
|
||||
from app.module.rag.infra.vectorstore.factory import VectorStoreFactory
|
||||
from app.module.rag.infra.vectorstore.milvus_client import get_milvus_client
|
||||
from app.module.rag.infra.vectorstore.store import (
|
||||
chunks_to_documents,
|
||||
create_collection,
|
||||
delete_chunks_by_rag_file_ids,
|
||||
drop_collection,
|
||||
get_vector_dimension,
|
||||
rename_collection,
|
||||
update_chunk_by_id,
|
||||
delete_chunk_by_id,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"VectorStoreFactory",
|
||||
"get_milvus_client",
|
||||
"create_collection",
|
||||
"drop_collection",
|
||||
"rename_collection",
|
||||
"get_vector_dimension",
|
||||
"delete_chunks_by_rag_file_ids",
|
||||
"chunks_to_documents",
|
||||
"update_chunk_by_id",
|
||||
"delete_chunk_by_id",
|
||||
]
|
||||
@@ -0,0 +1,120 @@
|
||||
"""
|
||||
向量存储工厂
|
||||
|
||||
使用 LangChain Milvus 创建向量存储实例,支持混合检索(向量 + BM25)。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from langchain_core.embeddings import Embeddings
|
||||
|
||||
from app.core.config import settings
|
||||
from app.module.rag.infra.vectorstore.store import (
|
||||
create_collection,
|
||||
drop_collection,
|
||||
get_vector_dimension,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VectorStoreFactory:
|
||||
"""LangChain Milvus 向量存储工厂
|
||||
|
||||
使用单例模式缓存 Milvus 实例,确保每个 collection 只有一个实例。
|
||||
"""
|
||||
|
||||
_instances: Dict[str, Any] = {}
|
||||
_lock = threading.Lock()
|
||||
|
||||
@staticmethod
|
||||
def get_connection_args() -> dict:
|
||||
"""获取 Milvus 连接参数"""
|
||||
args: dict = {"uri": settings.milvus_uri}
|
||||
if getattr(settings, "milvus_token", None):
|
||||
args["token"] = settings.milvus_token
|
||||
return args
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
collection_name: str,
|
||||
embedding: Embeddings,
|
||||
*,
|
||||
drop_old: bool = False,
|
||||
consistency_level: str = "Strong",
|
||||
force_new: bool = False,
|
||||
) -> Any:
|
||||
"""创建或获取 Milvus 向量存储实例(支持混合检索)
|
||||
|
||||
Args:
|
||||
collection_name: 集合名称(知识库名称)
|
||||
embedding: LangChain Embeddings 实例
|
||||
drop_old: 是否删除已存在同名集合(默认 False,避免数据丢失)
|
||||
consistency_level: 一致性级别
|
||||
force_new: 是否强制创建新实例(默认 False,优先使用缓存)
|
||||
|
||||
Returns:
|
||||
langchain_milvus.Milvus 实例
|
||||
"""
|
||||
if drop_old:
|
||||
drop_collection(collection_name)
|
||||
with cls._lock:
|
||||
cls._instances.pop(collection_name, None)
|
||||
|
||||
if not force_new and collection_name in cls._instances:
|
||||
logger.debug("使用缓存的 Milvus 实例: %s", collection_name)
|
||||
return cls._instances[collection_name]
|
||||
|
||||
from langchain_milvus import BM25BuiltInFunction, Milvus
|
||||
|
||||
dimension = get_vector_dimension(
|
||||
embedding_model="",
|
||||
embedding_instance=embedding,
|
||||
)
|
||||
|
||||
create_collection(
|
||||
collection_name=collection_name,
|
||||
dimension=dimension,
|
||||
consistency_level=consistency_level,
|
||||
)
|
||||
|
||||
instance = Milvus(
|
||||
embedding_function=embedding,
|
||||
collection_name=collection_name,
|
||||
connection_args=cls.get_connection_args(),
|
||||
builtin_function=BM25BuiltInFunction(),
|
||||
text_field="text",
|
||||
vector_field=["vector"],
|
||||
drop_old=False,
|
||||
consistency_level=consistency_level,
|
||||
auto_id=False,
|
||||
primary_field="id",
|
||||
metadata_field="metadata",
|
||||
enable_dynamic_field=False,
|
||||
metadata_schema={"metadata": "JSON"},
|
||||
)
|
||||
|
||||
with cls._lock:
|
||||
cls._instances[collection_name] = instance
|
||||
logger.info("创建并缓存 Milvus 实例: %s", collection_name)
|
||||
|
||||
return instance
|
||||
|
||||
@classmethod
|
||||
def clear_cache(cls, collection_name: Optional[str] = None) -> None:
|
||||
"""清除缓存
|
||||
|
||||
Args:
|
||||
collection_name: 集合名称,如果为 None 则清除所有缓存
|
||||
"""
|
||||
with cls._lock:
|
||||
if collection_name:
|
||||
cls._instances.pop(collection_name, None)
|
||||
logger.info("清除 Milvus 实例缓存: %s", collection_name)
|
||||
else:
|
||||
cls._instances.clear()
|
||||
logger.info("清除所有 Milvus 实例缓存")
|
||||
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
Milvus 客户端单例管理器
|
||||
|
||||
确保 MilvusClient 在全局范围内只创建一个实例,避免重复连接。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import Optional
|
||||
|
||||
from pymilvus import MilvusClient
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MilvusClientManager:
|
||||
"""Milvus 客户端单例管理器
|
||||
|
||||
使用线程安全的单例模式,确保全局只有一个 MilvusClient 实例。
|
||||
"""
|
||||
|
||||
_instance: Optional[MilvusClientManager] = None
|
||||
_lock = threading.Lock()
|
||||
_client: Optional[MilvusClient] = None
|
||||
|
||||
def __new__(cls) -> MilvusClientManager:
|
||||
"""单例模式:确保只有一个管理器实例"""
|
||||
if cls._instance is None:
|
||||
with cls._lock:
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@staticmethod
|
||||
def _get_connection_args() -> dict:
|
||||
"""获取 Milvus 连接参数"""
|
||||
args: dict = {"uri": settings.milvus_uri}
|
||||
token = getattr(settings, "milvus_token", None)
|
||||
if token:
|
||||
args["token"] = token
|
||||
return args
|
||||
|
||||
def get_client(self) -> MilvusClient:
|
||||
"""获取 Milvus 客户端实例(单例)
|
||||
|
||||
Returns:
|
||||
MilvusClient 实例
|
||||
"""
|
||||
if self._client is None:
|
||||
with self._lock:
|
||||
if self._client is None:
|
||||
conn_args = self._get_connection_args()
|
||||
self._client = MilvusClient(
|
||||
uri=conn_args["uri"],
|
||||
token=conn_args.get("token", "")
|
||||
)
|
||||
logger.info("创建 Milvus 客户端单例: uri=%s", conn_args["uri"])
|
||||
return self._client
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭 Milvus 客户端连接"""
|
||||
if self._client is not None:
|
||||
with self._lock:
|
||||
if self._client is not None:
|
||||
try:
|
||||
self._client.close()
|
||||
logger.info("关闭 Milvus 客户端连接")
|
||||
except Exception as e:
|
||||
logger.warning("关闭 Milvus 客户端时出错: %s", e)
|
||||
finally:
|
||||
self._client = None
|
||||
|
||||
|
||||
def get_milvus_client() -> MilvusClient:
|
||||
"""获取 Milvus 客户端实例(全局单例)
|
||||
|
||||
Returns:
|
||||
MilvusClient 实例
|
||||
"""
|
||||
return MilvusClientManager().get_client()
|
||||
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
向量存储管理
|
||||
|
||||
提供 Milvus 集合的创建、删除、重命名和数据操作功能。
|
||||
使用 Milvus 2.6+ 的 BM25 内置函数实现混合检索。
|
||||
|
||||
字段结构:
|
||||
- id (VarChar, 主键)
|
||||
- text (VarChar, with analyzer for BM25)
|
||||
- metadata (JSON)
|
||||
- vector (FloatVector, 密集向量)
|
||||
- sparse (SparseFloatVector, BM25 稀疏向量)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import List, Optional
|
||||
|
||||
from langchain_core.documents import Document
|
||||
from pymilvus import DataType, FunctionType, CollectionSchema, FieldSchema, Function
|
||||
|
||||
from app.core.exception import BusinessError, ErrorCodes
|
||||
from app.module.rag.infra.document.types import DocumentChunk
|
||||
from app.module.rag.infra.embeddings import EmbeddingFactory
|
||||
from app.module.rag.infra.vectorstore.milvus_client import get_milvus_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BATCH_DELETE_SIZE = 500
|
||||
|
||||
|
||||
def _delete_chunks_by_rag_file_id_batched(client, collection_name: str, rag_file_id: str) -> int:
|
||||
"""分批删除指定 rag_file_id 的所有 chunks
|
||||
|
||||
Args:
|
||||
client: Milvus 客户端
|
||||
collection_name: 集合名称
|
||||
rag_file_id: RAG 文件 ID
|
||||
|
||||
Returns:
|
||||
删除的总数量
|
||||
"""
|
||||
filter_expr = f'metadata["rag_file_id"] == "{rag_file_id}"'
|
||||
total_deleted = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
results = client.query(
|
||||
collection_name=collection_name,
|
||||
filter=filter_expr,
|
||||
output_fields=["id"],
|
||||
limit=BATCH_DELETE_SIZE,
|
||||
)
|
||||
if not results:
|
||||
break
|
||||
|
||||
chunk_ids = [r["id"] for r in results]
|
||||
id_filter = ' || '.join([f'id == "{cid}"' for cid in chunk_ids])
|
||||
client.delete(collection_name=collection_name, filter=f"({id_filter})")
|
||||
total_deleted += len(chunk_ids)
|
||||
|
||||
if len(chunk_ids) < BATCH_DELETE_SIZE:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.warning("分批删除失败: collection=%s rag_file_id=%s error=%s", collection_name, rag_file_id, e)
|
||||
break
|
||||
|
||||
return total_deleted
|
||||
|
||||
|
||||
def drop_collection(collection_name: str) -> None:
|
||||
"""删除 Milvus 集合
|
||||
|
||||
Args:
|
||||
collection_name: 集合名称
|
||||
"""
|
||||
try:
|
||||
client = get_milvus_client()
|
||||
if client.has_collection(collection_name):
|
||||
client.drop_collection(collection_name)
|
||||
logger.info("成功删除集合: %s", collection_name)
|
||||
except Exception as e:
|
||||
logger.error("删除集合失败: %s", e)
|
||||
raise BusinessError(ErrorCodes.RAG_MILVUS_ERROR, f"删除集合失败: {str(e)}") from e
|
||||
|
||||
|
||||
def rename_collection(old_name: str, new_name: str) -> None:
|
||||
"""重命名 Milvus 集合
|
||||
|
||||
Args:
|
||||
old_name: 原集合名称
|
||||
new_name: 新集合名称
|
||||
"""
|
||||
from pymilvus import utility, connections
|
||||
from app.core.config import settings
|
||||
|
||||
try:
|
||||
uri = settings.milvus_uri
|
||||
token = getattr(settings, "milvus_token", None) or ""
|
||||
connections.connect(
|
||||
alias="default",
|
||||
uri=uri,
|
||||
token=token,
|
||||
)
|
||||
if utility.has_collection(old_name, using="default"):
|
||||
utility.rename_collection(old_name, new_name, using="default")
|
||||
logger.info("成功重命名集合: %s -> %s", old_name, new_name)
|
||||
except Exception as e:
|
||||
logger.error("重命名集合失败: %s", e)
|
||||
raise BusinessError(ErrorCodes.RAG_MILVUS_ERROR, f"重命名集合失败: {str(e)}") from e
|
||||
|
||||
#xuhuanqing
|
||||
def create_collection(
|
||||
collection_name: str,
|
||||
dimension: int,
|
||||
consistency_level: str = "Strong",
|
||||
) -> None:
|
||||
"""创建 Milvus 集合(Milvus 2.6 + Woodpecker + BM25 稳定版)"""
|
||||
BM25_INDEX_PARAMS = {
|
||||
"inverted_index_algo": "DAAT_MAXSCORE",
|
||||
"bm25_k1": 1.2,
|
||||
"bm25_b": 0.75,
|
||||
}
|
||||
|
||||
try:
|
||||
client = get_milvus_client()
|
||||
|
||||
if client.has_collection(collection_name):
|
||||
logger.info("集合 %s 已存在,跳过创建", collection_name)
|
||||
return
|
||||
|
||||
fields = [
|
||||
FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=36, is_primary=True, auto_id=False),
|
||||
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535, enable_analyzer=True),
|
||||
FieldSchema(name="metadata", dtype=DataType.JSON),
|
||||
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
|
||||
FieldSchema(name="sparse", dtype=DataType.SPARSE_FLOAT_VECTOR),
|
||||
]
|
||||
|
||||
bm25_function = Function(
|
||||
name="text_bm25_emb",
|
||||
function_type=FunctionType.BM25,
|
||||
input_field_names=["text"],
|
||||
output_field_names=["sparse"],
|
||||
# ❌ 不写 params
|
||||
)
|
||||
|
||||
schema = CollectionSchema(
|
||||
fields=fields,
|
||||
functions=[bm25_function],
|
||||
description="Knowledge base collection",
|
||||
enable_dynamic_field=False,
|
||||
)
|
||||
|
||||
index_params = client.prepare_index_params()
|
||||
|
||||
# ✅ BM25 稀疏索引(唯一合法写法)
|
||||
index_params.add_index(
|
||||
field_name="sparse",
|
||||
index_type="SPARSE_INVERTED_INDEX",
|
||||
metric_type="BM25",
|
||||
params=BM25_INDEX_PARAMS,
|
||||
)
|
||||
|
||||
# ✅ 稠密向量索引
|
||||
index_params.add_index(
|
||||
field_name="vector",
|
||||
index_type="FLAT",
|
||||
metric_type="COSINE",
|
||||
params={},
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"开始创建集合: %s (维度: %d, consistency: %s)",
|
||||
collection_name, dimension, consistency_level,
|
||||
)
|
||||
|
||||
client.create_collection(
|
||||
collection_name=collection_name,
|
||||
schema=schema,
|
||||
index_params=index_params,
|
||||
consistency_level=consistency_level,
|
||||
timeout=None, # ✅ 防止首次 BM25 建库超时
|
||||
)
|
||||
|
||||
logger.info("成功创建集合: %s (维度: %d),索引创建完成", collection_name, dimension)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("创建集合失败: %s", e)
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_MILVUS_ERROR,
|
||||
f"创建集合失败: {str(e)}",
|
||||
) from e
|
||||
|
||||
'''
|
||||
def create_collection(
|
||||
collection_name: str,
|
||||
dimension: int,
|
||||
consistency_level: str = "Strong",
|
||||
) -> None:
|
||||
"""创建 Milvus 集合
|
||||
|
||||
使用标准的5字段结构:id、text、metadata、vector、sparse
|
||||
|
||||
Args:
|
||||
collection_name: 集合名称
|
||||
dimension: 向量维度
|
||||
consistency_level: 一致性级别
|
||||
"""
|
||||
try:
|
||||
client = get_milvus_client()
|
||||
|
||||
if client.has_collection(collection_name):
|
||||
logger.info("集合 %s 已存在,跳过创建", collection_name)
|
||||
return
|
||||
|
||||
# 创建字段
|
||||
fields = [
|
||||
FieldSchema(name="id", dtype=DataType.VARCHAR, max_length=36, is_primary=True, auto_id=False),
|
||||
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=65535, enable_analyzer=True),
|
||||
FieldSchema(name="metadata", dtype=DataType.JSON),
|
||||
FieldSchema(name="vector", dtype=DataType.FLOAT_VECTOR, dim=dimension),
|
||||
FieldSchema(name="sparse", dtype=DataType.SPARSE_FLOAT_VECTOR),
|
||||
]
|
||||
|
||||
# 创建 BM25 函数
|
||||
bm25_function = Function(
|
||||
name="text_bm25_emb",
|
||||
function_type=FunctionType.BM25,
|
||||
input_field_names=["text"],
|
||||
output_field_names=["sparse"],
|
||||
)
|
||||
|
||||
schema = CollectionSchema(
|
||||
fields=fields,
|
||||
functions=[bm25_function],
|
||||
description="Knowledge base collection",
|
||||
enable_dynamic_field=False,
|
||||
)
|
||||
|
||||
# BM25 索引参数
|
||||
sparse_index_params = {
|
||||
"inverted_index_algo": "DAAT_MAXSCORE",
|
||||
"bm25_k1": 1.2,
|
||||
"bm25_b": 0.75,
|
||||
}
|
||||
|
||||
index_params = client.prepare_index_params()
|
||||
index_params.add_index(
|
||||
field_name="sparse",
|
||||
index_type="SPARSE_INVERTED_INDEX",
|
||||
metric_type="BM25",
|
||||
params=sparse_index_params
|
||||
)
|
||||
index_params.add_index(
|
||||
field_name="vector",
|
||||
index_type="FLAT",
|
||||
metric_type="COSINE",
|
||||
params={}
|
||||
)
|
||||
|
||||
client.create_collection(
|
||||
collection_name=collection_name,
|
||||
schema=schema,
|
||||
index_params=index_params,
|
||||
consistency_level=consistency_level,
|
||||
)
|
||||
|
||||
logger.info("成功创建集合: %s (维度: %d)", collection_name, dimension)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("创建集合失败: %s", e)
|
||||
raise BusinessError(ErrorCodes.RAG_MILVUS_ERROR, f"创建集合失败: {str(e)}") from e
|
||||
'''
|
||||
|
||||
def get_vector_dimension(
|
||||
embedding_model: str = "",
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
embedding_instance=None,
|
||||
) -> int:
|
||||
"""获取嵌入模型的向量维度
|
||||
|
||||
Args:
|
||||
embedding_model: 模型名称
|
||||
base_url: API 基础 URL
|
||||
api_key: API 密钥
|
||||
embedding_instance: 已有的 Embeddings 实例(优先使用)
|
||||
|
||||
Returns:
|
||||
向量维度
|
||||
"""
|
||||
try:
|
||||
if embedding_instance:
|
||||
embedding = embedding_instance
|
||||
else:
|
||||
embedding = EmbeddingFactory.create_embeddings(
|
||||
model_name=embedding_model,
|
||||
base_url=base_url,
|
||||
api_key=api_key,
|
||||
)
|
||||
|
||||
test_vector = embedding.embed_query("test")
|
||||
dimension = len(test_vector)
|
||||
logger.info("获取向量维度: %d", dimension)
|
||||
return dimension
|
||||
|
||||
except Exception as e:
|
||||
logger.error("获取向量维度失败: %s", e)
|
||||
raise BusinessError(ErrorCodes.RAG_EMBEDDING_FAILED, f"获取向量维度失败: {str(e)}") from e
|
||||
|
||||
def delete_chunks_by_rag_file_ids(collection_name: str, rag_file_ids: List[str]) -> None:
|
||||
"""按 RAG 文件 ID 列表删除 Milvus 中的分块
|
||||
|
||||
Args:
|
||||
collection_name: 集合名称
|
||||
rag_file_ids: RAG 文件 ID 列表
|
||||
"""
|
||||
if not rag_file_ids:
|
||||
return
|
||||
|
||||
try:
|
||||
client = get_milvus_client()
|
||||
|
||||
for rid in rag_file_ids:
|
||||
deleted = _delete_chunks_by_rag_file_id_batched(client, collection_name, rid)
|
||||
logger.info("删除文件分块: collection=%s rag_file_id=%s deleted=%d", collection_name, rid, deleted)
|
||||
|
||||
logger.info("已按 rag_file_id 删除集合 %s 中的分块: %s", collection_name, rag_file_ids)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("删除 Milvus 分块失败: %s", e)
|
||||
raise BusinessError(ErrorCodes.RAG_MILVUS_ERROR, f"删除分块失败: {str(e)}") from e
|
||||
|
||||
|
||||
def chunks_to_documents(
|
||||
chunks: List[DocumentChunk],
|
||||
ids: Optional[List[str]] = None,
|
||||
) -> tuple[List[Document], List[str]]:
|
||||
"""将 DocumentChunk 转换为 LangChain Document 格式
|
||||
|
||||
Args:
|
||||
chunks: DocumentChunk 列表
|
||||
ids: 可选的 ID 列表
|
||||
|
||||
Returns:
|
||||
(documents, ids): LangChain Document 列表和对应的 ID 列表
|
||||
"""
|
||||
if ids is None:
|
||||
import uuid
|
||||
ids = [str(uuid.uuid4()) for _ in chunks]
|
||||
|
||||
documents = []
|
||||
for chunk, chunk_id in zip(chunks, ids):
|
||||
doc = Document(page_content=chunk.text, metadata=chunk.metadata)
|
||||
documents.append(doc)
|
||||
|
||||
return documents, ids
|
||||
|
||||
|
||||
def update_chunk_by_id(
|
||||
collection_name: str,
|
||||
chunk_id: str,
|
||||
text: str,
|
||||
metadata: Optional[dict] = None,
|
||||
embedding_instance=None,
|
||||
) -> None:
|
||||
"""更新指定 ID 的分块
|
||||
|
||||
Args:
|
||||
collection_name: 集合名称
|
||||
chunk_id: 分块 ID
|
||||
text: 新的文本内容
|
||||
metadata: 新的元数据(可选)
|
||||
embedding_instance: Embeddings 实例
|
||||
"""
|
||||
try:
|
||||
client = get_milvus_client()
|
||||
|
||||
filter_expr = f'id == "{chunk_id}"'
|
||||
existing = client.query(
|
||||
collection_name=collection_name,
|
||||
filter=filter_expr,
|
||||
output_fields=["metadata"],
|
||||
)
|
||||
|
||||
if not existing:
|
||||
raise BusinessError(
|
||||
ErrorCodes.RAG_CHUNK_NOT_FOUND,
|
||||
f"Chunk not found: {chunk_id}"
|
||||
)
|
||||
|
||||
existing_metadata = existing[0].get("metadata", {})
|
||||
|
||||
if metadata is None:
|
||||
metadata = existing_metadata
|
||||
else:
|
||||
# 确保保留原有的 rag_file_id 字段,防止用户修改时丢失
|
||||
if "rag_file_id" in existing_metadata and "rag_file_id" not in metadata:
|
||||
metadata = {**metadata, "rag_file_id": existing_metadata["rag_file_id"]}
|
||||
|
||||
if embedding_instance:
|
||||
embedding = embedding_instance
|
||||
else:
|
||||
from app.module.rag.infra.embeddings import EmbeddingFactory
|
||||
embedding = EmbeddingFactory.create_embeddings()
|
||||
|
||||
vector = embedding.embed_query(text)
|
||||
|
||||
client.delete(collection_name=collection_name, filter=filter_expr)
|
||||
|
||||
client.insert(
|
||||
collection_name=collection_name,
|
||||
data=[{
|
||||
"id": chunk_id,
|
||||
"text": text,
|
||||
"metadata": metadata,
|
||||
"vector": vector,
|
||||
}]
|
||||
)
|
||||
|
||||
logger.info("成功更新分块: collection=%s chunk_id=%s", collection_name, chunk_id)
|
||||
|
||||
except BusinessError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("更新分块失败: %s", e)
|
||||
raise BusinessError(ErrorCodes.RAG_MILVUS_ERROR, f"更新分块失败: {str(e)}") from e
|
||||
|
||||
|
||||
def delete_chunk_by_id(collection_name: str, chunk_id: str) -> Optional[str]:
|
||||
"""删除指定 ID 的分块
|
||||
|
||||
Args:
|
||||
collection_name: 集合名称
|
||||
chunk_id: 分块 ID
|
||||
|
||||
Returns:
|
||||
被删除分块对应的 rag_file_id(如果存在),否则返回 None
|
||||
"""
|
||||
try:
|
||||
client = get_milvus_client()
|
||||
|
||||
filter_expr = f'id == "{chunk_id}"'
|
||||
|
||||
# 先查询 chunk 的 metadata 获取 rag_file_id
|
||||
existing = client.query(
|
||||
collection_name=collection_name,
|
||||
filter=filter_expr,
|
||||
output_fields=["metadata"],
|
||||
)
|
||||
|
||||
rag_file_id = None
|
||||
if existing:
|
||||
metadata = existing[0].get("metadata", {})
|
||||
rag_file_id = metadata.get("rag_file_id")
|
||||
|
||||
client.delete(collection_name=collection_name, filter=filter_expr)
|
||||
|
||||
logger.info("成功删除分块: collection=%s chunk_id=%s rag_file_id=%s", collection_name, chunk_id, rag_file_id)
|
||||
|
||||
return rag_file_id
|
||||
|
||||
except Exception as e:
|
||||
logger.error("删除分块失败: %s", e)
|
||||
raise BusinessError(ErrorCodes.RAG_MILVUS_ERROR, f"删除分块失败: {str(e)}") from e
|
||||
Reference in New Issue
Block a user