RAG/sync/base_sync.py

315 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Base synchronization interface for all data sources"""
from abc import ABC, abstractmethod
from typing import List, Dict, Any, Set, Optional
from config import BaseDataSourceConfig
from rag.file_parser import FileParser
class BaseSync(ABC):
"""Abstract base class for all data source syncers"""
def __init__(self, config: BaseDataSourceConfig, vector_store_manager=None):
"""
Initialize sync with data source configuration
Args:
config: Configuration for the data source
vector_store_manager: Vector store manager for document existence checks
"""
self.config = config
self.file_parser = FileParser()
self.vector_store_manager = vector_store_manager
def _get_file_bytes(self, file_path: str, source_type: str, source_config: Optional[Dict[str, Any]] = None) -> Optional[bytes]:
"""
Get file content as bytes based on source type
Args:
file_path: Path to the file
source_type: Type of file source (api, filesystem, scp)
source_config: Additional configuration for file source
Returns:
File content as bytes if successfully loaded, None otherwise
"""
try:
import requests
if source_type == 'api':
# 从 API 获取文件内容
from config import settings
url = f"{settings.FILE_DOWNLOAD_BASE_URL}?identifier={file_path}"
response = requests.get(url, timeout=30)
response.raise_for_status()
return response.content
elif source_type == 'filesystem':
# 从本地文件系统获取文件内容
base_path = source_config.get('file_system_base_path', '') if source_config else ''
full_path = f"{base_path}/{file_path}" if base_path else file_path
with open(full_path, 'rb') as f:
return f.read()
elif source_type == 'scp':
# 通过 SCP 获取文件内容
import paramiko
ssh_config = source_config or {}
ssh = paramiko.SSHClient()
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# Connect to remote server
if ssh_config.get('scp_key_path'):
# Use SSH key for authentication
private_key = paramiko.RSAKey.from_private_key_file(ssh_config['scp_key_path'])
ssh.connect(
hostname=ssh_config.get('scp_host', ''),
port=ssh_config.get('scp_port', 22),
username=ssh_config.get('scp_username', ''),
pkey=private_key
)
else:
# Use password for authentication
ssh.connect(
hostname=ssh_config.get('scp_host', ''),
port=ssh_config.get('scp_port', 22),
username=ssh_config.get('scp_username', ''),
password=ssh_config.get('scp_password', '')
)
# Use SFTP to download file as bytes
with paramiko.SFTPClient.from_transport(ssh.get_transport()) as sftp:
with sftp.open(file_path, 'rb') as f:
content = f.read()
ssh.close()
return content
else:
from loguru import logger
logger.error(f"Unsupported file source type: {source_type}")
return None
except Exception as e:
from loguru import logger
import traceback
logger.error(f"Error getting file bytes for {file_path}: {e}")
logger.error(f"Traceback:\n{traceback.format_exc()}")
return None
@abstractmethod
def fetch_all_documents(self) -> List[Dict[str, Any]]:
"""
Fetch all documents from the data source
Returns:
List of documents
"""
pass
@abstractmethod
def doc_to_llamaindex_doc(self, doc: Dict) -> 'Document':
"""
Convert data source document to LlamaIndex Document
Args:
doc: Document from the data source
Returns:
LlamaIndex Document object
"""
pass
def process_documents(self, docs: List[Dict]) -> List['Document']:
"""
Process multiple documents into LlamaIndex Documents
Args:
docs: List of documents from the data source
Returns:
List of LlamaIndex Document objects
"""
from loguru import logger
import traceback
documents = []
for doc in docs:
try:
llamaindex_doc = self.doc_to_llamaindex_doc(doc)
if len(llamaindex_doc.text.strip()) >= 10:
documents.append(llamaindex_doc)
else:
logger.warning(f"跳过过短文档 (id: {llamaindex_doc.id_}),内容长度: {len(llamaindex_doc.text)} 字符")
except Exception as e:
doc_id = doc.get('id', 'unknown')
logger.error(f"Error processing document {doc_id}: {e}")
logger.error(f"Traceback:\n{traceback.format_exc()}")
continue
logger.info(f"Processed {len(documents)} documents")
return documents
@staticmethod
def chunk_documents(documents: List['Document']) -> List['Document']:
"""
Chunk documents into smaller pieces
Args:
documents: List of Document objects to chunk
Returns:
List of chunked Document objects
"""
from llama_index.core import Document
from llama_index.core.node_parser import SentenceSplitter
from loguru import logger
from config import settings
node_parser = SentenceSplitter(
chunk_size=settings.CHUNK_SIZE, #NOTE: 从settings中获取默认1024
chunk_overlap=settings.CHUNK_OVERLAP
)
chunked_docs = []
for doc in documents:
try:
nodes = node_parser.get_nodes_from_documents([doc])
# Get unique_doc_id from doc.id_ or metadata['doc_id']
unique_doc_id = getattr(doc, 'id_', None) or (doc.metadata.get('doc_id') if doc.metadata else None)
if not unique_doc_id:
# Fallback: use node.node_id if unique_doc_id is not available
unique_doc_id = f"doc_{id(doc)}"
logger.warning(f"Document has no id_ or doc_id in metadata, using fallback: {unique_doc_id}")
# Convert nodes back to documents for storage
for index, node in enumerate(nodes):
chunk_id = f"{unique_doc_id}_chunk_{index}"
chunked_doc = Document(
text=node.text,
id_=chunk_id,
metadata={
**doc.metadata,
"chunk_id": chunk_id,
"chunk_index": index,
"total_chunks": len(nodes),
"original_node_id": node.node_id
}
)
chunked_docs.append(chunked_doc)
except Exception as e:
# Access id_ property (not doc_id) for error logging
doc_id = getattr(doc, 'id_', getattr(doc, 'doc_id', 'unknown'))
import traceback
logger.error(f"Error chunking document {doc_id}: {e}")
logger.error(f"Traceback:\n{traceback.format_exc()}")
continue
logger.info(f"Chunked {len(documents)} documents into {len(chunked_docs)} chunks")
return chunked_docs
@abstractmethod
def generate_doc_id(self, identifier: str) -> str:
"""
Generate a unique document ID for different data sources
Args:
identifier: Unique identifier for the document (file path, record ID, etc.)
Returns:
Unique document ID
"""
pass
@abstractmethod
def fetch_new_documents(self, last_sync_time=None) -> List[Dict[str, Any]]:
"""
Fetch new/updated documents from the data source since last sync time
Args:
last_sync_time: Last synchronization time
Returns:
List of new/updated documents
"""
pass
@abstractmethod
def get_synced_document_ids(self) -> Set[str]:
"""
Get IDs of all synced documents from the data source
Returns:
Set of document IDs
"""
pass
@staticmethod
@abstractmethod
def check_data_source_exists(config: BaseDataSourceConfig) -> bool:
"""
Check if the data source exists and is accessible
Args:
config: Data source configuration
Returns:
True if data source exists and is accessible, False otherwise
"""
pass
@staticmethod
def check_data_sources_exist(configs: List[BaseDataSourceConfig]) -> tuple[bool, List[str]]:
"""
Check if all data sources exist and are accessible
Args:
configs: List of data source configurations
Returns:
Tuple of (all_exist, missing_sources)
"""
missing = []
for config in configs:
# 如果是数据库类型使用db_type参数
if config.type == 'database':
sync_class = get_sync_class(config.type, getattr(config, 'db_type', 'mysql'))
else:
sync_class = get_sync_class(config.type)
if not sync_class.check_data_source_exists(config):
missing.append(config.name)
return len(missing) == 0, missing
def get_sync_class(source_type: str, db_type: str = 'mysql') -> type[BaseSync]:
"""
Get the appropriate sync class based on data source type
Args:
source_type: Type of data source (database, folder, git)
db_type: Type of database (mysql, dameng, etc.) - only used when source_type is 'database'
Returns:
Sync class corresponding to the data source type
Raises:
ValueError: If source type is not supported
"""
from sync.mysql_sync import MySQLSync
from sync.folder_sync import FolderSync
from sync.dameng_sync import DaMengSync
from sync.git_sync import GitSync
if source_type == 'database':
# 根据数据库类型选择相应的同步类
db_type_lower = db_type.lower()
if db_type_lower == 'dameng':
return DaMengSync
else: # 默认为mysql
return MySQLSync
elif source_type == 'folder':
return FolderSync
elif source_type == 'git':
return GitSync
else:
raise ValueError(f"Unsupported data source type: {source_type}")