提交qiming-mcp-proxy
This commit is contained in:
106
qiming-mcp-proxy/fastembed/src/cli/mod.rs
Normal file
106
qiming-mcp-proxy/fastembed/src/cli/mod.rs
Normal file
@@ -0,0 +1,106 @@
|
||||
use clap::{Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub mod models;
|
||||
|
||||
/// FastEmbed - 文本向量化服务
|
||||
#[derive(Parser, Debug)]
|
||||
#[command(name = "fastembed")]
|
||||
#[command(about = "FastEmbed 文本向量化服务", long_about = None)]
|
||||
pub struct Cli {
|
||||
#[command(subcommand)]
|
||||
pub command: Commands,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// 启动 HTTP 服务
|
||||
Server(ServerArgs),
|
||||
|
||||
/// 模型管理
|
||||
Models(ModelsCmd),
|
||||
}
|
||||
|
||||
/// HTTP 服务启动参数
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ServerArgs {
|
||||
/// 监听端口
|
||||
#[arg(short, long, default_value = "8080")]
|
||||
pub port: u16,
|
||||
|
||||
/// 配置文件路径
|
||||
#[arg(short, long)]
|
||||
pub config: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// 模型管理子命令
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ModelsCmd {
|
||||
#[command(subcommand)]
|
||||
pub command: ModelsSubcommand,
|
||||
}
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum ModelsSubcommand {
|
||||
/// 下载模型到本地缓存
|
||||
Download(DownloadArgs),
|
||||
|
||||
/// 列出已下载的模型
|
||||
List(ListArgs),
|
||||
}
|
||||
|
||||
/// 模型下载参数
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct DownloadArgs {
|
||||
/// 模型类型: text | image | sparse
|
||||
#[arg(long, default_value = "text")]
|
||||
pub r#type: String,
|
||||
|
||||
/// 内置模型变体名,如 BGELargeZHV15
|
||||
#[arg(long)]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// Hugging Face 模型代码,如 Xenova/bge-large-zh-v1.5
|
||||
#[arg(long)]
|
||||
pub code: Option<String>,
|
||||
|
||||
/// BYO 模式:ONNX 文件名
|
||||
#[arg(long)]
|
||||
pub onnx: Option<String>,
|
||||
|
||||
/// BYO 模式:Tokenizer 文件名
|
||||
#[arg(long)]
|
||||
pub tokenizer: Option<String>,
|
||||
|
||||
/// BYO 模式:Config 文件名
|
||||
#[arg(long)]
|
||||
pub config: Option<String>,
|
||||
|
||||
/// BYO 模式:Special tokens map 文件名
|
||||
#[arg(long, alias = "special_tokens")]
|
||||
pub special_tokens_map: Option<String>,
|
||||
|
||||
/// BYO 模式:Tokenizer config 文件名
|
||||
#[arg(long)]
|
||||
pub tokenizer_config: Option<String>,
|
||||
|
||||
/// 缓存目录
|
||||
#[arg(long, default_value = ".fastembed_cache")]
|
||||
pub cache_dir: PathBuf,
|
||||
|
||||
/// 显示下载进度
|
||||
#[arg(long, default_value_t = true)]
|
||||
pub progress: bool,
|
||||
}
|
||||
|
||||
/// 模型列表参数
|
||||
#[derive(Parser, Debug)]
|
||||
pub struct ListArgs {
|
||||
/// 模型类型筛选: text | image | sparse
|
||||
#[arg(long, default_value = "text")]
|
||||
pub r#type: String,
|
||||
|
||||
/// 缓存目录
|
||||
#[arg(long, default_value = ".fastembed_cache")]
|
||||
pub cache_dir: PathBuf,
|
||||
}
|
||||
96
qiming-mcp-proxy/fastembed/src/cli/models.rs
Normal file
96
qiming-mcp-proxy/fastembed/src/cli/models.rs
Normal file
@@ -0,0 +1,96 @@
|
||||
use super::{DownloadArgs, ListArgs};
|
||||
use crate::models::{ModelInfo, list_available_models};
|
||||
use anyhow::Result;
|
||||
|
||||
/// 执行模型下载
|
||||
pub async fn download_model(args: DownloadArgs) -> Result<()> {
|
||||
use fastembed::{InitOptions, TextEmbedding};
|
||||
|
||||
tracing::info!("Start downloading the model...");
|
||||
|
||||
// 解析模型
|
||||
let model = if let Some(model_name) = args.model {
|
||||
// 使用内置模型变体名
|
||||
crate::models::parse_model(&model_name)?
|
||||
} else if let Some(code) = args.code {
|
||||
// 使用模型代码
|
||||
crate::models::parse_model(&code)?
|
||||
} else {
|
||||
anyhow::bail!("必须指定 --model 或 --code 参数");
|
||||
};
|
||||
|
||||
// 显示下载信息
|
||||
let model_info = ModelInfo::from_embedding_model(&model);
|
||||
println!("📦 Download model:");
|
||||
println!("Variant name: {}", model_info.variant);
|
||||
println!("Model code: {}", model_info.code);
|
||||
println!("Vector dimensions: {}", model_info.dim);
|
||||
println!("Cache directory: {}", args.cache_dir.display());
|
||||
println!();
|
||||
|
||||
// 初始化模型(会自动下载)
|
||||
let mut options = InitOptions::new(model.clone());
|
||||
options = options.with_cache_dir(args.cache_dir.clone());
|
||||
options = options.with_show_download_progress(args.progress);
|
||||
|
||||
println!("⬇️ Downloading model files...");
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let _embedding = TextEmbedding::try_new(options)?;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
println!();
|
||||
println!("✅ Model download completed!");
|
||||
println!("Time taken: {:?}", elapsed);
|
||||
println!("Cache location: {}", args.cache_dir.display());
|
||||
|
||||
// 验证文件
|
||||
println!();
|
||||
println!("🔍 Verify model file...");
|
||||
let available = list_available_models(args.cache_dir.to_str().unwrap())?;
|
||||
|
||||
if available.iter().any(|m| m.variant == model_info.variant) {
|
||||
println!("✅ Model file verification successful!");
|
||||
} else {
|
||||
println!("⚠️ WARNING: Model file may be incomplete");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 列出已下载的模型
|
||||
pub async fn list_models(args: ListArgs) -> Result<()> {
|
||||
use crate::models::list_available_models;
|
||||
|
||||
println!("📋 Query downloaded models...");
|
||||
println!("Type: {}", args.r#type);
|
||||
println!("Cache directory: {}", args.cache_dir.display());
|
||||
println!();
|
||||
|
||||
// 检查缓存目录是否存在
|
||||
if !args.cache_dir.exists() {
|
||||
println!("⚠️ The cache directory does not exist: {}", args.cache_dir.display());
|
||||
println!("Tip: Please download the model first");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// 列出可用模型
|
||||
let models = list_available_models(args.cache_dir.to_str().unwrap())?;
|
||||
|
||||
if models.is_empty() {
|
||||
println!("📭 No downloaded model found");
|
||||
println!("Tip: Use 'fastembed models download --model BGELargeZHV15' to download the model");
|
||||
} else {
|
||||
println!("✅ Found {} downloaded models:", models.len());
|
||||
println!();
|
||||
println!("{:<20} {:<40} {:<10}", "Variant", "Model Code", "Dim");
|
||||
println!("{}", "─".repeat(72));
|
||||
|
||||
for model in models {
|
||||
println!("{:<20} {:<40} {:<10}", model.variant, model.code, model.dim);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
144
qiming-mcp-proxy/fastembed/src/config.rs
Normal file
144
qiming-mcp-proxy/fastembed/src/config.rs
Normal file
@@ -0,0 +1,144 @@
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// 服务器配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ServerConfig {
|
||||
/// 监听地址
|
||||
#[serde(default = "default_host")]
|
||||
pub host: String,
|
||||
|
||||
/// 监听端口
|
||||
#[serde(default = "default_port")]
|
||||
pub port: u16,
|
||||
}
|
||||
|
||||
fn default_host() -> String {
|
||||
"0.0.0.0".to_string()
|
||||
}
|
||||
|
||||
fn default_port() -> u16 {
|
||||
8080
|
||||
}
|
||||
|
||||
impl Default for ServerConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
host: default_host(),
|
||||
port: default_port(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// FastEmbed 配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FastEmbedConfig {
|
||||
/// 缓存目录
|
||||
#[serde(default = "default_cache_dir")]
|
||||
pub cache_dir: String,
|
||||
|
||||
/// 默认模型
|
||||
#[serde(default = "default_model")]
|
||||
pub default_model: String,
|
||||
|
||||
/// 批处理大小
|
||||
#[serde(default = "default_batch_size")]
|
||||
pub batch_size: usize,
|
||||
}
|
||||
|
||||
fn default_cache_dir() -> String {
|
||||
".fastembed_cache".to_string()
|
||||
}
|
||||
|
||||
fn default_model() -> String {
|
||||
"BGELargeZHV15".to_string()
|
||||
}
|
||||
|
||||
fn default_batch_size() -> usize {
|
||||
256
|
||||
}
|
||||
|
||||
impl Default for FastEmbedConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
cache_dir: default_cache_dir(),
|
||||
default_model: default_model(),
|
||||
batch_size: default_batch_size(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 应用配置
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub server: ServerConfig,
|
||||
|
||||
#[serde(default)]
|
||||
pub fastembed: FastEmbedConfig,
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
server: ServerConfig::default(),
|
||||
fastembed: FastEmbedConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// 从文件加载配置
|
||||
pub fn from_file(path: &PathBuf) -> Result<Self> {
|
||||
let content = std::fs::read_to_string(path)
|
||||
.with_context(|| format!("无法读取配置文件: {:?}", path))?;
|
||||
|
||||
let config: AppConfig = serde_yaml::from_str(&content)
|
||||
.with_context(|| format!("无法解析配置文件: {:?}", path))?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// 生成默认配置文件
|
||||
pub fn generate_default_config(path: &PathBuf) -> Result<()> {
|
||||
let default_config = AppConfig::default();
|
||||
let yaml = serde_yaml::to_string(&default_config).context("无法序列化默认配置")?;
|
||||
|
||||
std::fs::write(path, yaml).with_context(|| format!("无法写入配置文件: {:?}", path))?;
|
||||
|
||||
tracing::info!("Default configuration file has been generated: {:?}", path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 应用环境变量覆盖
|
||||
pub fn apply_env_overrides(&mut self) {
|
||||
// FASTEMBED_CACHE_DIR 可以覆盖 cache_dir
|
||||
if let Ok(cache_dir) = std::env::var("FASTEMBED_CACHE_DIR") {
|
||||
tracing::info!("Environment variable FASTEMBED_CACHE_DIR overrides the cache directory: {}", cache_dir);
|
||||
self.fastembed.cache_dir = cache_dir;
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载或生成配置
|
||||
pub fn load_or_generate(config_path: Option<PathBuf>) -> Result<Self> {
|
||||
let path = config_path.unwrap_or_else(|| PathBuf::from("./config.yml"));
|
||||
|
||||
let mut config = if path.exists() {
|
||||
tracing::info!("Load configuration from file: {:?}", path);
|
||||
Self::from_file(&path)?
|
||||
} else {
|
||||
tracing::warn!("Configuration file does not exist: {:?}, generate default configuration", path);
|
||||
Self::generate_default_config(&path)?;
|
||||
Self::default()
|
||||
};
|
||||
|
||||
// 应用环境变量覆盖
|
||||
config.apply_env_overrides();
|
||||
|
||||
// 打印最终配置
|
||||
tracing::info!("Final configuration: {:?}", config);
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
}
|
||||
167
qiming-mcp-proxy/fastembed/src/handlers/embeddings.rs
Normal file
167
qiming-mcp-proxy/fastembed/src/handlers/embeddings.rs
Normal file
@@ -0,0 +1,167 @@
|
||||
use axum::{Json, extract::State, http::StatusCode};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::models::{ModelInfo, get_or_init_model, parse_model};
|
||||
use crate::server::AppState;
|
||||
|
||||
/// 文本嵌入请求
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
pub struct EmbedRequest {
|
||||
/// 模型名称(变体名或模型代码)
|
||||
#[schema(example = "BGELargeZHV15")]
|
||||
pub model: Option<String>,
|
||||
|
||||
/// 待嵌入的文本列表
|
||||
#[schema(example = json!(["query: 搜索文本", "passage: 文档内容"]))]
|
||||
pub texts: Vec<String>,
|
||||
|
||||
/// 批处理大小
|
||||
#[schema(example = 256)]
|
||||
pub batch_size: Option<usize>,
|
||||
}
|
||||
|
||||
/// 文本嵌入响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct EmbedResponse {
|
||||
/// 模型信息
|
||||
pub model: ModelInfo,
|
||||
|
||||
/// 嵌入向量数量
|
||||
#[schema(example = 2)]
|
||||
pub count: usize,
|
||||
|
||||
/// 嵌入向量列表
|
||||
#[schema(example = json!([[0.00123, -0.00456], [0.00078, 0.00234]]))]
|
||||
pub embeddings: Vec<Vec<f32>>,
|
||||
|
||||
/// 耗时(毫秒)
|
||||
#[schema(example = 12)]
|
||||
pub elapsed_ms: u128,
|
||||
}
|
||||
|
||||
/// 错误响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ErrorResponse {
|
||||
/// 错误代码
|
||||
#[schema(example = "INVALID_MODEL")]
|
||||
pub error: String,
|
||||
|
||||
/// 错误消息
|
||||
#[schema(example = "未知模型")]
|
||||
pub message: String,
|
||||
|
||||
/// HTTP 状态码
|
||||
#[schema(example = 400)]
|
||||
pub status: u16,
|
||||
}
|
||||
|
||||
/// 文本嵌入处理器
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/embeddings",
|
||||
tag = "文本嵌入",
|
||||
request_body = EmbedRequest,
|
||||
responses(
|
||||
(status = 200, description = "嵌入成功", body = EmbedResponse),
|
||||
(status = 400, description = "请求参数错误", body = ErrorResponse),
|
||||
(status = 413, description = "请求负载过大", body = ErrorResponse),
|
||||
(status = 500, description = "服务器错误", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn handle_embed(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Json(req): Json<EmbedRequest>,
|
||||
) -> Result<Json<EmbedResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
let start = Instant::now();
|
||||
|
||||
// 参数验证
|
||||
if req.texts.is_empty() {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "EMPTY_TEXTS".to_string(),
|
||||
message: "texts 不能为空".to_string(),
|
||||
status: 400,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// 检查文本数量限制(最大 1024)
|
||||
if req.texts.len() > 1024 {
|
||||
return Err((
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(ErrorResponse {
|
||||
error: "TOO_MANY_TEXTS".to_string(),
|
||||
message: format!("texts 数量不能超过 1024,当前: {}", req.texts.len()),
|
||||
status: 413,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// 解析模型
|
||||
let model_name = req
|
||||
.model
|
||||
.as_deref()
|
||||
.unwrap_or(&state.config.fastembed.default_model);
|
||||
let embedding_model = parse_model(model_name).map_err(|e| {
|
||||
(
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "INVALID_MODEL".to_string(),
|
||||
message: format!("未知模型: {}, 错误: {}", model_name, e),
|
||||
status: 400,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 获取或初始化模型
|
||||
let model_arc = get_or_init_model(
|
||||
embedding_model.clone(),
|
||||
Some(state.config.fastembed.cache_dir.clone()),
|
||||
None, // 使用模型默认的 max_length
|
||||
)
|
||||
.map_err(|e| {
|
||||
tracing::error!("Model initialization failed: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "MODEL_INIT_ERROR".to_string(),
|
||||
message: format!("模型初始化失败: {}", e),
|
||||
status: 500,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 执行嵌入
|
||||
let batch_size = req.batch_size.unwrap_or(state.config.fastembed.batch_size);
|
||||
|
||||
let mut model_guard = model_arc.lock().unwrap();
|
||||
let embeddings = model_guard
|
||||
.embed(req.texts.clone(), Some(batch_size))
|
||||
.map_err(|e| {
|
||||
tracing::error!("Embedding calculation failed: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "EMBED_ERROR".to_string(),
|
||||
message: format!("嵌入计算失败: {}", e),
|
||||
status: 500,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
// 转换为 Vec<Vec<f32>>
|
||||
let embeddings_vec: Vec<Vec<f32>> = embeddings.into_iter().map(|e| e.to_vec()).collect();
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
Ok(Json(EmbedResponse {
|
||||
model: ModelInfo::from_embedding_model(&embedding_model),
|
||||
count: embeddings_vec.len(),
|
||||
embeddings: embeddings_vec,
|
||||
elapsed_ms: elapsed.as_millis(),
|
||||
}))
|
||||
}
|
||||
41
qiming-mcp-proxy/fastembed/src/handlers/health.rs
Normal file
41
qiming-mcp-proxy/fastembed/src/handlers/health.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use axum::{Json, extract::State};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::server::AppState;
|
||||
|
||||
/// 健康检查响应
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct HealthResponse {
|
||||
/// 服务状态
|
||||
#[schema(example = "ok")]
|
||||
pub status: String,
|
||||
|
||||
/// 服务运行时长(毫秒)
|
||||
#[schema(example = 123456)]
|
||||
pub uptime_ms: u128,
|
||||
|
||||
/// 模型缓存是否就绪
|
||||
#[schema(example = true)]
|
||||
pub model_cache_ready: bool,
|
||||
}
|
||||
|
||||
/// 健康检查处理器
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/health",
|
||||
tag = "健康检查",
|
||||
responses(
|
||||
(status = 200, description = "服务健康", body = HealthResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn handle_health(State(state): State<Arc<AppState>>) -> Json<HealthResponse> {
|
||||
let uptime = state.start_time.elapsed();
|
||||
|
||||
Json(HealthResponse {
|
||||
status: "ok".to_string(),
|
||||
uptime_ms: uptime.as_millis(),
|
||||
model_cache_ready: *state.model_cache_ready.lock().unwrap(),
|
||||
})
|
||||
}
|
||||
3
qiming-mcp-proxy/fastembed/src/handlers/mod.rs
Normal file
3
qiming-mcp-proxy/fastembed/src/handlers/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
pub mod embeddings;
|
||||
pub mod health;
|
||||
pub mod models;
|
||||
87
qiming-mcp-proxy/fastembed/src/handlers/models.rs
Normal file
87
qiming-mcp-proxy/fastembed/src/handlers/models.rs
Normal file
@@ -0,0 +1,87 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::Arc;
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use crate::handlers::embeddings::ErrorResponse;
|
||||
use crate::models::{ModelInfo, list_available_models};
|
||||
use crate::server::AppState;
|
||||
|
||||
/// 查询参数
|
||||
#[derive(Debug, Deserialize, IntoParams)]
|
||||
pub struct ModelsQuery {
|
||||
/// 模型类型: text | image | sparse
|
||||
#[serde(rename = "type")]
|
||||
#[param(example = "text")]
|
||||
pub model_type: Option<String>,
|
||||
}
|
||||
|
||||
/// 模型列表响应
|
||||
#[derive(Debug, Serialize, ToSchema)]
|
||||
pub struct ModelsResponse {
|
||||
/// 模型类型
|
||||
#[schema(example = "text")]
|
||||
pub r#type: String,
|
||||
|
||||
/// 模型数量
|
||||
#[schema(example = 2)]
|
||||
pub count: usize,
|
||||
|
||||
/// 模型列表
|
||||
pub models: Vec<ModelInfo>,
|
||||
}
|
||||
|
||||
/// 列出可用模型处理器
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/models/available",
|
||||
tag = "模型管理",
|
||||
params(ModelsQuery),
|
||||
responses(
|
||||
(status = 200, description = "模型列表", body = ModelsResponse),
|
||||
(status = 400, description = "请求参数错误", body = ErrorResponse),
|
||||
(status = 500, description = "服务器错误", body = ErrorResponse)
|
||||
)
|
||||
)]
|
||||
pub async fn handle_list_models(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Query(query): Query<ModelsQuery>,
|
||||
) -> Result<Json<ModelsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
||||
// 验证类型参数
|
||||
let model_type = query.model_type.as_deref().unwrap_or("text");
|
||||
|
||||
// 目前仅支持 text 类型
|
||||
if model_type != "text" {
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
Json(ErrorResponse {
|
||||
error: "INVALID_TYPE".to_string(),
|
||||
message: format!("不支持的模型类型: {},当前仅支持 text", model_type),
|
||||
status: 400,
|
||||
}),
|
||||
));
|
||||
}
|
||||
|
||||
// 列出可用模型
|
||||
let models = list_available_models(&state.config.fastembed.cache_dir).map_err(|e| {
|
||||
tracing::error!("Failed to list available models: {}", e);
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ErrorResponse {
|
||||
error: "LIST_ERROR".to_string(),
|
||||
message: format!("列出可用模型失败: {}", e),
|
||||
status: 500,
|
||||
}),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Json(ModelsResponse {
|
||||
r#type: model_type.to_string(),
|
||||
count: models.len(),
|
||||
models,
|
||||
}))
|
||||
}
|
||||
51
qiming-mcp-proxy/fastembed/src/main.rs
Normal file
51
qiming-mcp-proxy/fastembed/src/main.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
mod cli;
|
||||
mod config;
|
||||
mod handlers;
|
||||
mod models;
|
||||
mod server;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
use cli::{Cli, Commands, ModelsSubcommand};
|
||||
use config::AppConfig;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
// 初始化日志
|
||||
tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| "fastembed=info,tower_http=debug".into()),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Server(args) => {
|
||||
// 加载或生成配置
|
||||
let mut config = AppConfig::load_or_generate(args.config)?;
|
||||
|
||||
// 命令行端口覆盖配置文件
|
||||
if args.port != 8080 {
|
||||
config.server.port = args.port;
|
||||
}
|
||||
|
||||
// 启动服务器
|
||||
server::start_server(config).await?;
|
||||
}
|
||||
Commands::Models(models_cmd) => match models_cmd.command {
|
||||
ModelsSubcommand::Download(download_args) => {
|
||||
cli::models::download_model(download_args).await?;
|
||||
}
|
||||
ModelsSubcommand::List(list_args) => {
|
||||
cli::models::list_models(list_args).await?;
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
162
qiming-mcp-proxy/fastembed/src/models/mod.rs
Normal file
162
qiming-mcp-proxy/fastembed/src/models/mod.rs
Normal file
@@ -0,0 +1,162 @@
|
||||
use anyhow::{Context, Result, anyhow};
|
||||
use dashmap::DashMap;
|
||||
use fastembed::{EmbeddingModel, InitOptions, TextEmbedding};
|
||||
use once_cell::sync::Lazy;
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
/// 全局模型缓存
|
||||
pub static MODEL_CACHE: Lazy<DashMap<EmbeddingModel, Arc<Mutex<TextEmbedding>>>> =
|
||||
Lazy::new(DashMap::new);
|
||||
|
||||
/// 解析模型标识(支持变体名和模型代码)
|
||||
pub fn parse_model(user_input: &str) -> Result<EmbeddingModel> {
|
||||
// 尝试直接匹配变体名
|
||||
match user_input {
|
||||
"BGELargeZHV15" => Ok(EmbeddingModel::BGELargeZHV15),
|
||||
"BGESmallZHV15" => Ok(EmbeddingModel::BGESmallZHV15),
|
||||
"BGEBaseENV15" => Ok(EmbeddingModel::BGEBaseENV15),
|
||||
"BGESmallENV15" => Ok(EmbeddingModel::BGESmallENV15),
|
||||
"BGELargeENV15" => Ok(EmbeddingModel::BGELargeENV15),
|
||||
"AllMiniLML6V2" => Ok(EmbeddingModel::AllMiniLML6V2),
|
||||
"AllMiniLML12V2" => Ok(EmbeddingModel::AllMiniLML12V2),
|
||||
// 如果不是变体名,尝试使用 FromStr 解析模型代码
|
||||
other => EmbeddingModel::from_str(other).map_err(|_| anyhow!("未知模型: {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取或初始化模型
|
||||
pub fn get_or_init_model(
|
||||
model: EmbeddingModel,
|
||||
cache_dir: Option<String>,
|
||||
max_length: Option<usize>,
|
||||
) -> Result<Arc<Mutex<TextEmbedding>>> {
|
||||
// 检查缓存
|
||||
if let Some(existing) = MODEL_CACHE.get(&model) {
|
||||
tracing::debug!("Get model from cache: {:?}", model);
|
||||
return Ok(existing.clone());
|
||||
}
|
||||
|
||||
// 初始化模型
|
||||
tracing::info!("Initialization model: {:?}", model);
|
||||
let mut options = InitOptions::new(model.clone());
|
||||
|
||||
if let Some(dir) = cache_dir {
|
||||
options = options.with_cache_dir(PathBuf::from(dir));
|
||||
}
|
||||
|
||||
if let Some(len) = max_length {
|
||||
options = options.with_max_length(len);
|
||||
}
|
||||
|
||||
// 显示下载进度
|
||||
options = options.with_show_download_progress(true);
|
||||
|
||||
let embedding =
|
||||
TextEmbedding::try_new(options).with_context(|| format!("无法初始化模型: {:?}", model))?;
|
||||
|
||||
let arc = Arc::new(Mutex::new(embedding));
|
||||
let model_key = model.clone();
|
||||
MODEL_CACHE.insert(model_key, arc.clone());
|
||||
|
||||
tracing::info!("Model initialization successful: {:?}", model);
|
||||
Ok(arc)
|
||||
}
|
||||
|
||||
/// 模型信息
|
||||
#[derive(Debug, Clone, serde::Serialize, utoipa::ToSchema)]
|
||||
pub struct ModelInfo {
|
||||
/// 模型变体名称
|
||||
#[schema(example = "BGELargeZHV15")]
|
||||
pub variant: String,
|
||||
|
||||
/// 模型代码(Hugging Face 仓库)
|
||||
#[schema(example = "Xenova/bge-large-zh-v1.5")]
|
||||
pub code: String,
|
||||
|
||||
/// 向量维度
|
||||
#[schema(example = 1024)]
|
||||
pub dim: usize,
|
||||
}
|
||||
|
||||
impl ModelInfo {
|
||||
pub fn from_embedding_model(model: &EmbeddingModel) -> Self {
|
||||
let (variant, code, dim) = match model {
|
||||
EmbeddingModel::BGELargeZHV15 => ("BGELargeZHV15", "Xenova/bge-large-zh-v1.5", 1024),
|
||||
EmbeddingModel::BGESmallZHV15 => ("BGESmallZHV15", "Xenova/bge-small-zh-v1.5", 512),
|
||||
EmbeddingModel::BGEBaseENV15 => ("BGEBaseENV15", "Xenova/bge-base-en-v1.5", 768),
|
||||
EmbeddingModel::BGESmallENV15 => ("BGESmallENV15", "Xenova/bge-small-en-v1.5", 384),
|
||||
EmbeddingModel::BGELargeENV15 => ("BGELargeENV15", "Xenova/bge-large-en-v1.5", 1024),
|
||||
EmbeddingModel::AllMiniLML6V2 => (
|
||||
"AllMiniLML6V2",
|
||||
"sentence-transformers/all-MiniLM-L6-v2",
|
||||
384,
|
||||
),
|
||||
EmbeddingModel::AllMiniLML12V2 => (
|
||||
"AllMiniLML12V2",
|
||||
"sentence-transformers/all-MiniLM-L12-v2",
|
||||
384,
|
||||
),
|
||||
_ => ("Unknown", "unknown", 0),
|
||||
};
|
||||
|
||||
Self {
|
||||
variant: variant.to_string(),
|
||||
code: code.to_string(),
|
||||
dim,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 列出本地已下载的模型(仅离线检查)
|
||||
pub fn list_available_models(cache_dir: &str) -> Result<Vec<ModelInfo>> {
|
||||
let cache_path = PathBuf::from(cache_dir);
|
||||
|
||||
// 如果缓存目录不存在,返回空列表
|
||||
if !cache_path.exists() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
|
||||
let all_models = vec![
|
||||
EmbeddingModel::BGELargeZHV15,
|
||||
EmbeddingModel::BGESmallZHV15,
|
||||
EmbeddingModel::BGEBaseENV15,
|
||||
EmbeddingModel::BGESmallENV15,
|
||||
EmbeddingModel::BGELargeENV15,
|
||||
EmbeddingModel::AllMiniLML6V2,
|
||||
EmbeddingModel::AllMiniLML12V2,
|
||||
];
|
||||
|
||||
let available: Vec<ModelInfo> = all_models
|
||||
.into_iter()
|
||||
.filter(|model| check_model_files_exist(&cache_path, model))
|
||||
.map(|model| ModelInfo::from_embedding_model(&model))
|
||||
.collect();
|
||||
|
||||
Ok(available)
|
||||
}
|
||||
|
||||
/// 检查模型文件是否存在(简化版本)
|
||||
fn check_model_files_exist(cache_path: &PathBuf, model: &EmbeddingModel) -> bool {
|
||||
// 这是一个简化实现
|
||||
// fastembed 使用 hf-hub 的缓存结构
|
||||
// 例如 "Xenova/bge-large-zh-v1.5" -> "models--Xenova--bge-large-zh-v1.5"
|
||||
|
||||
let model_info = ModelInfo::from_embedding_model(model);
|
||||
let model_code = model_info.code;
|
||||
|
||||
// 从模型代码转换为 hf-hub 缓存目录名
|
||||
// "Xenova/bge-large-zh-v1.5" -> "models--Xenova--bge-large-zh-v1.5"
|
||||
let model_dir_name = format!("models--{}", model_code.replace('/', "--"));
|
||||
let model_dir = cache_path.join(&model_dir_name);
|
||||
|
||||
// 检查目录是否存在且不为空
|
||||
if model_dir.exists() && model_dir.is_dir() {
|
||||
if let Ok(entries) = std::fs::read_dir(&model_dir) {
|
||||
return entries.count() > 0;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
193
qiming-mcp-proxy/fastembed/src/server/mod.rs
Normal file
193
qiming-mcp-proxy/fastembed/src/server/mod.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
use anyhow::Result;
|
||||
use axum::{
|
||||
Router,
|
||||
routing::{get, post},
|
||||
};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Instant;
|
||||
use tokio::signal;
|
||||
use tower_http::{
|
||||
cors::{Any, CorsLayer},
|
||||
limit::RequestBodyLimitLayer,
|
||||
trace::TraceLayer,
|
||||
};
|
||||
use utoipa::OpenApi;
|
||||
use utoipa_swagger_ui::SwaggerUi;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use crate::handlers::{
|
||||
embeddings::handle_embed, health::handle_health, models::handle_list_models,
|
||||
};
|
||||
|
||||
/// OpenAPI 文档定义
|
||||
#[derive(OpenApi)]
|
||||
#[openapi(
|
||||
info(
|
||||
title = "FastEmbed API",
|
||||
version = "0.1.0",
|
||||
description = "基于 FastEmbed 的文本嵌入服务",
|
||||
contact(
|
||||
name = "API Support",
|
||||
)
|
||||
),
|
||||
paths(
|
||||
crate::handlers::health::handle_health,
|
||||
crate::handlers::embeddings::handle_embed,
|
||||
crate::handlers::models::handle_list_models,
|
||||
),
|
||||
components(
|
||||
schemas(
|
||||
crate::handlers::health::HealthResponse,
|
||||
crate::handlers::embeddings::EmbedRequest,
|
||||
crate::handlers::embeddings::EmbedResponse,
|
||||
crate::handlers::embeddings::ErrorResponse,
|
||||
crate::handlers::models::ModelsResponse,
|
||||
crate::models::ModelInfo,
|
||||
)
|
||||
),
|
||||
tags(
|
||||
(name = "健康检查", description = "服务健康状态监控"),
|
||||
(name = "文本嵌入", description = "文本向量化接口"),
|
||||
(name = "模型管理", description = "模型列表与管理"),
|
||||
)
|
||||
)]
|
||||
struct ApiDoc;
|
||||
|
||||
/// 应用状态
|
||||
#[derive(Clone)]
|
||||
pub struct AppState {
|
||||
pub config: AppConfig,
|
||||
pub start_time: Instant,
|
||||
pub model_cache_ready: Arc<Mutex<bool>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
pub fn new(config: AppConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
start_time: Instant::now(),
|
||||
model_cache_ready: Arc::new(Mutex::new(false)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 创建路由
|
||||
pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
// CORS 中间件
|
||||
let cors = CorsLayer::new()
|
||||
.allow_origin(Any)
|
||||
.allow_methods(Any)
|
||||
.allow_headers(Any);
|
||||
|
||||
// Body 限制:20MB
|
||||
let body_limit = RequestBodyLimitLayer::new(20 * 1024 * 1024);
|
||||
|
||||
// 创建 Swagger UI(无状态路由)
|
||||
let swagger = SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", ApiDoc::openapi());
|
||||
|
||||
// 创建 API 路由(有状态)
|
||||
Router::new()
|
||||
.merge(swagger)
|
||||
.route("/health", get(handle_health))
|
||||
.route("/api/embeddings", post(handle_embed))
|
||||
.route("/api/models/available", get(handle_list_models))
|
||||
.layer(cors)
|
||||
.layer(body_limit)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// 启动服务器
|
||||
pub async fn start_server(config: AppConfig) -> Result<()> {
|
||||
let host = config.server.host.clone();
|
||||
let port = config.server.port;
|
||||
let addr = format!("{}:{}", host, port);
|
||||
|
||||
let state = Arc::new(AppState::new(config.clone()));
|
||||
|
||||
// 预热模型(异步执行)
|
||||
let warmup_state = state.clone();
|
||||
let warmup_config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = warmup_model(warmup_state, warmup_config).await {
|
||||
tracing::warn!("Model warm-up failed: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let app = create_router(state);
|
||||
|
||||
tracing::info!("FastEmbed service is starting...");
|
||||
tracing::info!("Listening address: {}", addr);
|
||||
|
||||
let listener = tokio::net::TcpListener::bind(&addr).await?;
|
||||
|
||||
tracing::info!("✅ FastEmbed service has been started: http://{}", addr);
|
||||
tracing::info!("Health check: http://{}/health", addr);
|
||||
tracing::info!("Text embedding: POST http://{}/api/embeddings", addr);
|
||||
tracing::info!("Available models: GET http://{}/api/models/available", addr);
|
||||
tracing::info!("📚 Swagger UI: http://{}/swagger-ui/", addr);
|
||||
|
||||
axum::serve(listener, app)
|
||||
.with_graceful_shutdown(shutdown_signal())
|
||||
.await?;
|
||||
|
||||
tracing::info!("✅ FastEmbed service has been gracefully closed");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 模型预热
|
||||
async fn warmup_model(state: Arc<AppState>, config: AppConfig) -> Result<()> {
|
||||
use crate::models::{get_or_init_model, parse_model};
|
||||
|
||||
tracing::info!("Start preheating model: {}", config.fastembed.default_model);
|
||||
let start = Instant::now();
|
||||
|
||||
let model = parse_model(&config.fastembed.default_model)?;
|
||||
let model_arc = get_or_init_model(
|
||||
model,
|
||||
Some(config.fastembed.cache_dir.clone()),
|
||||
None, // 使用模型默认的 max_length
|
||||
)?;
|
||||
|
||||
// 执行一次微型嵌入
|
||||
let warmup_text = vec!["passage: warmup".to_string()];
|
||||
let mut model_guard = model_arc.lock().unwrap();
|
||||
model_guard.embed(warmup_text, Some(1))?;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
// 标记预热完成
|
||||
*state.model_cache_ready.lock().unwrap() = true;
|
||||
|
||||
tracing::info!("✅ Model preheating completed, time consuming: {:?}", elapsed);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 优雅关闭信号
|
||||
async fn shutdown_signal() {
|
||||
let ctrl_c = async {
|
||||
signal::ctrl_c().await.expect("无法安装 Ctrl+C 信号处理器");
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
let terminate = async {
|
||||
signal::unix::signal(signal::unix::SignalKind::terminate())
|
||||
.expect("无法安装 SIGTERM 信号处理器")
|
||||
.recv()
|
||||
.await;
|
||||
};
|
||||
|
||||
#[cfg(not(unix))]
|
||||
let terminate = std::future::pending::<()>();
|
||||
|
||||
tokio::select! {
|
||||
_ = ctrl_c => {
|
||||
tracing::info!("Receive Ctrl+C signal and start graceful shutdown...");
|
||||
},
|
||||
_ = terminate => {
|
||||
tracing::info!("Receive SIGTERM signal and start graceful shutdown...");
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user