feat(rag): 优化 vllm 作为 LLM 提供商支持

- 在 RAGEngine 中实现 vllm 提供商的支持
- 添加 OpenAILike 集成用于 vllm 兼容性
- 更新 docker-compose.yml 中的默认 LLM 配置为 vllm
- 添加 llama-index-llms-openai-like 依赖
- 优化同步模块中的文件路径处理逻辑
- 修复 SSH 连接和 SFTP 文件操作的路径兼容性问题
This commit is contained in:
Bennie61 2026-06-11 09:15:39 +08:00
parent 1cb82f4216
commit 8401d90efb
4 changed files with 136 additions and 112 deletions

View File

@ -1,5 +1,5 @@
# Docker Compose 配置说明
#
#
# 网络模式: 使用 host 网络模式,容器直接使用宿主机的网络
# - 优点: 可以直接访问宿主机上的服务MySQL、Ollama 等),无需额外网络配置
# - 注意: 使用 host 网络模式时,不能使用 ports 映射,容器直接使用宿主机的端口
@ -28,6 +28,7 @@ services:
# ChromaDB 容器默认将数据存储在 /data 目录
- ./chroma_db_data:/data
# 将主机端口映射为可配置(从 .env 读取 CHROMA_SERVER_PORT默认 8002
# 注意以下 8000端口是ChromaDB默认端口不用更改
ports:
- "${CHROMA_SERVER_PORT:-8002}:8000"
environment:
@ -73,37 +74,44 @@ services:
- API_PORT=${API_PORT:-8001}
- API_TITLE=${API_TITLE:-RAG API}
- API_VERSION=${API_VERSION:-1.0.0}
# ChromaDB 配置
- CHROMA_SERVER_HOST=${CHROMA_SERVER_HOST:-localhost}
- CHROMA_SERVER_PORT=${CHROMA_SERVER_PORT:-8002}
- CHROMA_COLLECTION_NAME=${CHROMA_COLLECTION_NAME:-rag_collection}
# MySQL 配置
- MYSQL_HOST=${MYSQL_HOST:-localhost}
- MYSQL_PORT=${MYSQL_PORT:-3306}
- MYSQL_USER=${MYSQL_USER:-root}
- MYSQL_PASSWORD=${MYSQL_PASSWORD:-}
- MYSQL_DATABASE=${MYSQL_DATABASE:-forgeplus}
# 多数据库配置(可选)
- MYSQL_DATABASES_CONFIG=${MYSQL_DATABASES_CONFIG:-}
# Ollama 配置
- OLLAMA_BASE_URL=${OLLAMA_BASE_URL:-http://localhost:11434}
- OLLAMA_MODEL=${OLLAMA_MODEL:-qwen3:1.7b}
- OLLAMA_EMBEDDING_MODEL=${OLLAMA_EMBEDDING_MODEL:-qwen3-embedding:0.6b}
# LLM 配置
# LLM provider: ollama, openai 等
- LLM_PROVIDER=${LLM_PROVIDER:-vllm}
- LLM_BASE_URL=${LLM_BASE_URL:-http://172.20.32.236:8000/v1}
- LLM_MODEL=${LLM_MODEL:-MiniMax-M2.7}
- LLM_API_KEY=${LLM_API_KEY:-none} # 如使用openai等需要API Key的服务
# RAG 配置
- EMBEDDING_DIMENSION=${EMBEDDING_DIMENSION:-768}
- CHUNK_SIZE=${CHUNK_SIZE:-1024}
- CHUNK_OVERLAP=${CHUNK_OVERLAP:-200}
- TOP_K=${TOP_K:-5}
# 同步配置
- SYNC_INTERVAL=${SYNC_INTERVAL:-300}
- AUTO_SYNC=${AUTO_SYNC:-true}
# NLTK 配置
- NLTK_DATA=${NLTK_DATA:-./nltk_data/}
# 注意:使用 host 网络模式时depends_on 可能无法正常工作

View File

@ -81,18 +81,18 @@ QA_PROMPT_NO_HISTORY = PromptTemplate(QA_PROMPT_STR_NO_HISTORY)
class RAGEngine:
"""Main RAG engine for query processing"""
@staticmethod
def check_llm_connection(provider: str, base_url: str, model: str, api_key: Optional[str] = None) -> Tuple[bool, str]:
"""
Check if LLM server is accessible and connection can be established
Args:
provider: LLM provider (ollama, vllm, openai)
base_url: Base URL for the LLM service
model: Model name
api_key: Optional API key for authentication
Returns:
Tuple of (is_connected: bool, error_message: str)
If connected, error_message will be empty string
@ -101,7 +101,7 @@ class RAGEngine:
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
if provider == "ollama":
logger.info(f"Checking Ollama connection to {base_url}...")
with httpx.Client(timeout=10.0) as client:
@ -134,22 +134,22 @@ class RAGEngine:
else:
logger.warning(f"Unknown LLM provider: {provider}, skipping connection check")
return True, ""
except httpx.ConnectError as e:
error_message = f"Cannot connect to {provider} server at {base_url}. " \
f"Please check if server is running and accessible."
f"Please check if server is running and accessible."
logger.error(f"{provider} connection failed: {error_message}")
return False, error_message
except httpx.TimeoutException:
error_message = f"Connection to {provider} server at {base_url} timed out. " \
f"Please check if server is running and accessible."
f"Please check if server is running and accessible."
logger.error(f"{provider} connection failed: {error_message}")
return False, error_message
except Exception as e:
error_message = f"Unexpected error while checking {provider} connection: {str(e)}"
logger.error(f"{provider} connection check failed: {error_message}")
return False, error_message
def __init__(
self,
vector_store_manager: VectorStoreManager,
@ -163,13 +163,13 @@ class RAGEngine:
self._system_prompt = system_prompt
self._temperature = temperature
self._request_timeout = request_timeout
llm_config = settings.get_llm_config()
provider = llm_config["provider"]
base_url = llm_config["base_url"]
model = llm_config["model"]
api_key = llm_config["api_key"]
is_connected, error_message = self.check_llm_connection(provider, base_url, model, api_key)
if not is_connected:
error_msg = (
@ -184,7 +184,7 @@ class RAGEngine:
)
logger.error(error_msg)
raise RuntimeError(error_msg)
if provider == "ollama":
self.llm = Ollama(
model=model,
@ -200,6 +200,17 @@ class RAGEngine:
temperature=self._temperature,
timeout=self._request_timeout,
)
elif provider == "vllm":
from llama_index.llms.openai_like import OpenAILike
self.llm = OpenAILike(
model=model,
api_base=base_url,
api_key=api_key or "none",
is_chat_model=True,
context_window=128000,
temperature=self._temperature,
timeout=self._request_timeout,
)
else:
self.llm = Ollama(
model=model,
@ -207,7 +218,7 @@ class RAGEngine:
temperature=self._temperature,
request_timeout=self._request_timeout,
)
self._llm_provider = provider
def extract_text_from_chunk(self, chunk) -> Optional[str]:
@ -229,12 +240,12 @@ class RAGEngine:
async def query_stream(self, query: str, history: str, top_k: Optional[int] = None) -> AsyncIterator[str]:
"""
Query the RAG system and stream the response
Args:
query: User query string
history: Chat history string
top_k: Number of documents to retrieve (optional)
Yields:
Response text chunks
"""
@ -243,7 +254,7 @@ class RAGEngine:
retriever = self.vector_store_manager.get_retriever(top_k=top_k or settings.TOP_K)
# query index
retrieved_nodes = await retriever.aretrieve(query)
# 2. 构建上下文
context_parts = []
for i, node in enumerate(retrieved_nodes[:top_k or settings.TOP_K], 1): # 限制数量
@ -253,47 +264,47 @@ class RAGEngine:
if len(text) > 400:
text = text[:400] + "..."
context_parts.append(f"【参考信息{i}{text}")
context_str = "\n\n".join(context_parts) if context_parts else "未找到相关参考信息"
if history is not None:
qa_prompt = QA_PROMPT_HISTORY
filled_prompt = qa_prompt.format(history=history, context_str=context_str, query_str=query)
else:
qa_prompt = QA_PROMPT_NO_HISTORY
filled_prompt = qa_prompt.format(context_str=context_str, query_str=query)
stream_response = await self.llm.astream_complete(
prompt=filled_prompt
)
)
full_response = ""
think_filter = OptimizedDeltaThinkFilter()
async for chunk in stream_response:
# 提取文本内容
# text_chunk = self.extract_text_from_chunk(chunk)
delta, full_text, has_output = think_filter.process_delta_robust(chunk)
if delta is not None:
full_response += delta
yield delta.encode('utf-8')
await asyncio.sleep(0.001) # slight delay to yield control
logger.info(f"响应完成,长度: {len(full_response)}字符")
except Exception as e:
logger.error(f"Error in RAG query: {e}")
async def query(self, query: str, history: str, top_k: Optional[int] = None) -> str:
"""
Query the RAG system and return complete response
Args:
query: User query string
history: Chat history string
top_k: Number of documents to retrieve (optional)
Returns:
Complete response string
"""
@ -302,7 +313,7 @@ class RAGEngine:
retriever = self.vector_store_manager.get_retriever(top_k=top_k or settings.TOP_K)
# query index
retrieved_nodes = await retriever.aretrieve(query)
# 2. 构建上下文
context_parts = []
for i, node in enumerate(retrieved_nodes[:top_k or settings.TOP_K], 1): # 限制数量
@ -312,16 +323,16 @@ class RAGEngine:
if len(text) > 400:
text = text[:400] + "..."
context_parts.append(f"【参考信息{i}{text}")
context_str = "\n\n".join(context_parts) if context_parts else "未找到相关参考信息"
if history is not None:
qa_prompt = QA_PROMPT_HISTORY
filled_prompt = qa_prompt.format(history=history, context_str=context_str, query_str=query)
else:
qa_prompt = QA_PROMPT_NO_HISTORY
filled_prompt = qa_prompt.format(context_str=context_str, query_str=query)
response = await self.llm.acomplete(
prompt=filled_prompt
)

View File

@ -42,4 +42,6 @@ markdown2>=2.5.4 # Markdown parsing
# Remote file access
paramiko>=3.4.0 # For SSH/SCP functionality
requests~=2.32.5
requests~=2.32.5
llama-index-llms-openai-like>=0.2.0

View File

@ -1,5 +1,6 @@
"""Folder synchronization implementation for local and remote folders"""
import os
import posixpath
import re
from typing import List, Dict, Any, Set
from datetime import datetime
@ -14,11 +15,11 @@ import paramiko
class SSHClient:
"""SSH client for testing connections to remote servers"""
def __init__(self, host, port=22, username=None, password=None):
"""
Initialize SSH client
Args:
host: Host address
port: Port number
@ -30,11 +31,11 @@ class SSHClient:
self.username = username
self.password = password
self._client = None
def test_connection(self):
"""
Test SSH connection
Returns:
bool: True if connection is successful, False otherwise
"""
@ -42,7 +43,7 @@ class SSHClient:
# Create SSH client
self._client = paramiko.SSHClient()
self._client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# Connect with timeout
self._client.connect(
hostname=self.host,
@ -53,7 +54,7 @@ class SSHClient:
allow_agent=True,
look_for_keys=False
)
# Connection successful
return True
finally:
@ -64,11 +65,11 @@ class SSHClient:
class FolderSync(BaseSync):
"""Handle synchronization between folder (local or remote) and ChromaDB"""
def __init__(self, config: BaseDataSourceConfig, vector_store_manager=None):
"""
Initialize folder sync with configuration
Args:
config: Folder configuration
"""
@ -77,14 +78,14 @@ class FolderSync(BaseSync):
self._ssh_client = None
self._sftp_client = None
self.vector_store_manager = vector_store_manager
def fetch_all_documents(self, last_sync_time=None) -> List[Dict[str, Any]]:
"""
Fetch documents from the folder
Args:
last_sync_time: Last synchronization time (for incremental sync)
Returns:
List of documents
"""
@ -92,19 +93,19 @@ class FolderSync(BaseSync):
self._connect()
try:
files = self._get_all_files()
for file_path in files:
# Parse file content
try:
# 检查文件扩展名是否在支持的列表中
if Path(file_path).suffix.lower() not in FileParser.SUPPORTED_EXTENSIONS:
logger.debug(f"Skipping unsupported file: {file_path}")
continue
# Generate document ID
doc_id = self.generate_doc_id(file_path)
if last_sync_time is not None:
# Check if document has already been synced
if self.vector_store_manager and self.vector_store_manager.document_exists(doc_id + "_chunk_0"):
@ -115,11 +116,11 @@ class FolderSync(BaseSync):
# Skip if not modified since last sync
if file_mtime <= last_sync_time:
continue
# Read file content
with self._sftp_client.open(file_path, 'rb') as f:
file_bytes = f.read()
# Parse file content
if file_bytes:
try:
@ -136,7 +137,7 @@ class FolderSync(BaseSync):
content = f"[无法读取文件:{Path(file_path).name}]"
else:
content = f"[无法读取文件:{Path(file_path).name}]"
# Build document
document = {
'id': doc_id,
@ -152,22 +153,22 @@ class FolderSync(BaseSync):
logger.error(f"Error processing file {file_path}: {e}")
finally:
self._disconnect()
return documents
def fetch_new_documents(self, last_sync_time=None) -> List[Dict[str, Any]]:
"""
Fetch new/updated documents from the folder since last sync time
Args:
last_sync_time: Last synchronization time
Returns:
List of new/updated documents
"""
# Call fetch_all_documents which now handles document existence checks
return self.fetch_all_documents(last_sync_time)
def get_synced_document_ids(self) -> Set[str]:
"""
Get IDs of all files in the folder
@ -181,68 +182,68 @@ class FolderSync(BaseSync):
return set(files)
finally:
self._disconnect()
def generate_doc_id(self, file_path: str) -> str:
"""
Generate a unique document ID for files
Args:
file_path: Path to the file
Returns:
Unique document ID based on host IP and file path
"""
# 使用配置中的主机地址
host_address = self.config.host or 'unknown'
# 替换路径中的特殊字符避免生成无效的doc_id
sanitized_path = file_path.replace('/', '_').replace('\\', '_').replace(':', '_').replace(' ', '_')
return f"{host_address}_{sanitized_path}"
def doc_to_llamaindex_doc(self, doc: Dict) -> 'Document':
"""
Convert folder document to LlamaIndex Document
Args:
doc: Folder document dictionary
Returns:
LlamaIndex Document object
"""
content = doc.get('content', "")
doc_id = doc.get('id', "")
metadata = doc.get('metadata', {})
# Ensure metadata has source information
metadata['source'] = 'folder'
metadata['host'] = self.config.host
# Create Document
return Document(
text=content,
id_=doc_id,
metadata=metadata
)
def _connect(self):
"""
Connect to the server via SSH/SFTP
Raises:
Exception: If connection fails with detailed error message
"""
import paramiko
self._ssh_client = paramiko.SSHClient()
self._ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# Connect to SSH server
# 获取用户名
username = self.config.username
if not username:
raise Exception("SSH connection failed: Username is required")
# 建立SSH连接的参数
ssh_params = {
'hostname': self.config.host,
@ -253,11 +254,11 @@ class FolderSync(BaseSync):
'allow_agent': True, # 允许使用SSH代理
'look_for_keys': False # 禁用查找本地密钥文件
}
try:
# 连接到SSH服务器
self._ssh_client.connect(**ssh_params)
# Create SFTP client
self._sftp_client = self._ssh_client.open_sftp()
except paramiko.AuthenticationException:
@ -266,7 +267,7 @@ class FolderSync(BaseSync):
raise Exception(f"SSH connection failed: {str(ssh_error)}")
except Exception as e:
raise Exception(f"Connection failed: {str(e)}")
def _disconnect(self):
"""
Disconnect from the server
@ -274,18 +275,18 @@ class FolderSync(BaseSync):
if self._sftp_client:
self._sftp_client.close()
self._sftp_client = None
if self._ssh_client:
self._ssh_client.close()
self._ssh_client = None
def _get_all_files(self) -> List[str]:
"""
Get all files in the folder
Returns:
List of file paths
Note:
This method assumes that a connection has already been established by the caller
"""
@ -296,21 +297,22 @@ class FolderSync(BaseSync):
except Exception as e:
logger.error(f"Error getting all files: {e}")
return files
def _get_files_recursive(self, folder_path: str, files: List[str]):
"""
Recursively get all files in the folder
Args:
folder_path: Current folder path
files: List to store found files
"""
try:
items = self._sftp_client.listdir_attr(folder_path)
for item in items:
item_path = os.path.join(folder_path, item.filename)
# old:relative_path = os.path.relpath(file_path, self.config.folder_path)
item_path = posixpath.join(folder_path, item.filename)
if item.filename not in ('.', '..'):
if item.st_mode & 0o040000: # Check if it's a directory
if self.config.recursive:
@ -321,37 +323,38 @@ class FolderSync(BaseSync):
files.append(item_path)
except Exception as e:
logger.error(f"Error listing folder {folder_path}: {e}")
def _should_ignore_file(self, file_path: str) -> bool:
"""
Check if file should be ignored based on ignore patterns
Args:
file_path: File path to check
Returns:
True if file should be ignored, False otherwise
"""
if not hasattr(self.config, 'ignore_patterns') or not self.config.ignore_patterns:
return False
# Get relative path from folder root
relative_path = os.path.relpath(file_path, self.config.folder_path)
# old: relative_path = os.path.relpath(file_path, self.config.folder_path)
relative_path = posixpath.relpath(file_path, self.config.folder_path)
for pattern in self.config.ignore_patterns:
if self._match_pattern(relative_path, pattern):
return True
return False
def _match_pattern(self, path: str, pattern: str) -> bool:
"""
Match a path against a pattern (similar to .gitignore)
Args:
path: Path to match
pattern: Pattern to match against
Returns:
True if path matches pattern, False otherwise
"""
@ -360,45 +363,45 @@ class FolderSync(BaseSync):
regex_pattern = regex_pattern.replace('.', r'\.')
regex_pattern = regex_pattern.replace('*', r'.*')
regex_pattern = regex_pattern.replace('?', r'.')
# Handle directory patterns
if pattern.endswith('/'):
regex_pattern = f'^{regex_pattern}.*$'
else:
regex_pattern = f'^{regex_pattern}$'
return bool(re.match(regex_pattern, path))
@staticmethod
def check_data_source_exists(config: BaseDataSourceConfig) -> bool:
"""
Check if the folder exists and is accessible
Args:
config: Folder configuration
Returns:
True if folder exists and is accessible, False otherwise
Raises:
Exception: If connection fails with detailed error message
"""
import paramiko
ssh_client = None
sftp_client = None
try:
# 检查必要的配置
if not config.host:
raise Exception("SSH connection failed: Host is required")
username = config.username
if not username:
raise Exception("SSH connection failed: Username is required")
ssh_client = paramiko.SSHClient()
ssh_client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# Connect to SSH server - use SSH agent if available, otherwise password
try:
ssh_client.connect(
@ -416,14 +419,14 @@ class FolderSync(BaseSync):
raise Exception(f"SSH connection failed: {str(ssh_error)}")
except Exception as e:
raise Exception(f"Connection failed: {str(e)}")
# Create SFTP client and check folder exists
try:
sftp_client = ssh_client.open_sftp()
sftp_client.stat(config.folder_path)
except Exception as e:
raise Exception(f"Folder access failed: {str(e)}")
return True
except Exception as e:
logger.error(f"Error checking folder: {e}")