RAG/sync/base_sync.py

315 lines
11 KiB
Python
Raw Permalink Normal View History

2026-01-22 13:04:29 +08:00
"""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"""
2026-01-25 21:59:25 +08:00
def __init__(self, config: BaseDataSourceConfig, vector_store_manager=None):
2026-01-22 13:04:29 +08:00
"""
Initialize sync with data source configuration
Args:
config: Configuration for the data source
2026-01-25 21:59:25 +08:00
vector_store_manager: Vector store manager for document existence checks
2026-01-22 13:04:29 +08:00
"""
self.config = config
self.file_parser = FileParser()
2026-01-25 21:59:25 +08:00
self.vector_store_manager = vector_store_manager
2026-01-22 13:04:29 +08:00
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
2026-01-27 11:20:01 +08:00
import traceback
2026-01-22 13:04:29 +08:00
logger.error(f"Error getting file bytes for {file_path}: {e}")
2026-01-27 11:20:01 +08:00
logger.error(f"Traceback:\n{traceback.format_exc()}")
2026-01-22 13:04:29 +08:00
return None
2026-01-25 21:59:25 +08:00
@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':
2026-01-22 13:04:29 +08:00
"""
2026-01-25 21:59:25 +08:00
Convert data source document to LlamaIndex Document
2026-01-22 13:04:29 +08:00
Args:
2026-01-25 21:59:25 +08:00
doc: Document from the data source
2026-01-22 13:04:29 +08:00
Returns:
2026-01-25 21:59:25 +08:00
LlamaIndex Document object
2026-01-22 13:04:29 +08:00
"""
2026-01-25 21:59:25 +08:00
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
2026-01-27 11:20:01 +08:00
import traceback
2026-01-25 21:59:25 +08:00
documents = []
for doc in docs:
try:
llamaindex_doc = self.doc_to_llamaindex_doc(doc)
if len(llamaindex_doc.text.strip()) >= 10:
2026-01-25 21:59:25 +08:00
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}")
2026-01-27 11:20:01 +08:00
logger.error(f"Traceback:\n{traceback.format_exc()}")
2026-01-25 21:59:25 +08:00
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
2026-01-25 21:59:25 +08:00
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'))
2026-01-27 11:20:01 +08:00
import traceback
2026-01-25 21:59:25 +08:00
logger.error(f"Error chunking document {doc_id}: {e}")
2026-01-27 11:20:01 +08:00
logger.error(f"Traceback:\n{traceback.format_exc()}")
2026-01-25 21:59:25 +08:00
continue
logger.info(f"Chunked {len(documents)} documents into {len(chunked_docs)} chunks")
return chunked_docs
2026-01-22 13:04:29 +08:00
@abstractmethod
2026-01-25 21:59:25 +08:00
def generate_doc_id(self, identifier: str) -> str:
2026-01-22 13:04:29 +08:00
"""
2026-01-25 21:59:25 +08:00
Generate a unique document ID for different data sources
2026-01-22 13:04:29 +08:00
2026-01-25 21:59:25 +08:00
Args:
identifier: Unique identifier for the document (file path, record ID, etc.)
2026-01-22 13:04:29 +08:00
Returns:
2026-01-25 21:59:25 +08:00
Unique document ID
2026-01-22 13:04:29 +08:00
"""
pass
@abstractmethod
2026-01-25 21:59:25 +08:00
def fetch_new_documents(self, last_sync_time=None) -> List[Dict[str, Any]]:
2026-01-22 13:04:29 +08:00
"""
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)
2026-01-22 13:04:29 +08:00
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]:
2026-01-22 13:04:29 +08:00
"""
Get the appropriate sync class based on data source type
2026-01-22 13:04:29 +08:00
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'
2026-01-22 13:04:29 +08:00
Returns:
Sync class corresponding to the data source type
2026-01-22 13:04:29 +08:00
Raises:
ValueError: If source type is not supported
"""
from sync.mysql_sync import MySQLSync
2026-01-25 21:59:25 +08:00
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:
2026-01-22 13:04:29 +08:00
raise ValueError(f"Unsupported data source type: {source_type}")