diff --git a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java index c4f1d2e9..29eab47e 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/controller/ModelConfigController.java @@ -6,10 +6,18 @@ import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import vip.mate.common.result.R; import vip.mate.llm.model.*; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import vip.mate.llm.embedding.EmbeddingModelFactory; import vip.mate.llm.service.ModelConfigService; import vip.mate.llm.service.ModelDiscoveryService; import vip.mate.llm.service.ModelProviderService; +import vip.mate.system.model.SystemSettingEntity; +import vip.mate.system.repository.SystemSettingMapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import java.util.HashMap; import java.util.List; import java.util.Map; @@ -22,6 +30,10 @@ public class ModelConfigController { private final ModelConfigService modelConfigService; private final ModelProviderService modelProviderService; private final ModelDiscoveryService modelDiscoveryService; + private final EmbeddingModelFactory embeddingModelFactory; + private final SystemSettingMapper systemSettingMapper; + + private static final String SYSTEM_SETTING_DEFAULT_EMBEDDING_ID = "embedding.default.model.id"; @Operation(summary = "获取 Provider 列表") @GetMapping @@ -63,7 +75,10 @@ public class ModelConfigController { @PutMapping("/{providerId}/config") public R updateProviderConfig(@PathVariable String providerId, @RequestBody ProviderConfigRequest request) { - return R.ok(modelProviderService.updateProviderConfig(providerId, request)); + ProviderInfoDTO updated = modelProviderService.updateProviderConfig(providerId, request); + // Provider 的 apiKey/baseUrl 变化时,清空 embedding factory 的缓存,确保下次用新凭证 + embeddingModelFactory.evictAll(); + return R.ok(updated); } @Operation(summary = "创建自定义 Provider") @@ -153,4 +168,75 @@ public class ModelConfigController { @PathVariable String modelId) { return R.ok(modelDiscoveryService.testModel(providerId, modelId)); } + + // ==================== Embedding 模型管理 ==================== + + @Operation(summary = "按类型筛选模型(chat / embedding)") + @GetMapping("/by-type") + public R> listByType(@RequestParam(defaultValue = "chat") String modelType) { + return R.ok(modelConfigService.listByType(modelType)); + } + + @Operation(summary = "测试 Embedding 模型连通性(嵌入一个短文本验证 API key)") + @PostMapping("/embedding/{modelId}/test") + public R> testEmbedding(@PathVariable Long modelId) { + Map result = new HashMap<>(); + try { + ModelConfigEntity config = modelConfigService.getModel(modelId); + if (!"embedding".equals(config.getModelType())) { + result.put("success", false); + result.put("message", "模型类型不是 embedding: " + config.getModelType()); + return R.ok(result); + } + // 清除缓存,确保本次测试用最新的 API key + embeddingModelFactory.evict(modelId); + EmbeddingModel model = embeddingModelFactory.build(config); + EmbeddingResponse resp = model.call(new EmbeddingRequest(List.of("test"), null)); + float[] vec = resp.getResults().get(0).getOutput(); + result.put("success", true); + result.put("dimensions", vec.length); + result.put("model", config.getModelName()); + result.put("message", "连通性测试成功"); + } catch (Exception e) { + result.put("success", false); + result.put("message", e.getMessage()); + } + return R.ok(result); + } + + @Operation(summary = "获取系统默认 Embedding 模型 ID") + @GetMapping("/embedding/default") + public R> getDefaultEmbedding() { + SystemSettingEntity entity = systemSettingMapper.selectOne( + new LambdaQueryWrapper() + .eq(SystemSettingEntity::getSettingKey, SYSTEM_SETTING_DEFAULT_EMBEDDING_ID) + .last("LIMIT 1")); + if (entity == null || entity.getSettingValue() == null || entity.getSettingValue().isBlank()) { + return R.ok(Map.of("defaultModelId", "")); + } + return R.ok(Map.of("defaultModelId", entity.getSettingValue())); + } + + @Operation(summary = "设置系统默认 Embedding 模型") + @PostMapping("/embedding/default") + public R setDefaultEmbedding(@RequestBody Map body) { + Object v = body.get("modelId"); + String value = v == null ? "" : v.toString(); + + SystemSettingEntity existing = systemSettingMapper.selectOne( + new LambdaQueryWrapper() + .eq(SystemSettingEntity::getSettingKey, SYSTEM_SETTING_DEFAULT_EMBEDDING_ID) + .last("LIMIT 1")); + if (existing != null) { + existing.setSettingValue(value); + systemSettingMapper.updateById(existing); + } else { + SystemSettingEntity fresh = new SystemSettingEntity(); + fresh.setSettingKey(SYSTEM_SETTING_DEFAULT_EMBEDDING_ID); + fresh.setSettingValue(value); + fresh.setDescription("Default embedding model id for wiki semantic search"); + systemSettingMapper.insert(fresh); + } + return R.ok(); + } } diff --git a/mateclaw-server/src/main/java/vip/mate/llm/embedding/EmbeddingModelFactory.java b/mateclaw-server/src/main/java/vip/mate/llm/embedding/EmbeddingModelFactory.java new file mode 100644 index 00000000..82b83347 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/embedding/EmbeddingModelFactory.java @@ -0,0 +1,184 @@ +package vip.mate.llm.embedding; + +import com.alibaba.cloud.ai.autoconfigure.dashscope.DashScopeConnectionProperties; +import com.alibaba.cloud.ai.dashscope.api.DashScopeApi; +import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingModel; +import com.alibaba.cloud.ai.dashscope.embedding.DashScopeEmbeddingOptions; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.MetadataMode; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingModel; +import org.springframework.ai.openai.OpenAiEmbeddingOptions; +import org.springframework.ai.openai.api.OpenAiApi; +import org.springframework.ai.retry.RetryUtils; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import vip.mate.exception.MateClawException; +import vip.mate.llm.model.EmbeddingProtocol; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.model.ModelProviderEntity; +import vip.mate.llm.service.ModelProviderService; + +import java.util.concurrent.ConcurrentHashMap; + +/** + * Embedding 模型工厂 + *

+ * 根据 {@link ModelConfigEntity}(model_type='embedding')构造对应的 {@link EmbeddingModel}: + *

    + *
  • DashScope:复用 provider UI 配置的 apiKey/baseUrl,构造 {@link DashScopeEmbeddingModel}
  • + *
  • OpenAI 兼容(OpenAI / DeepSeek / 智谱 / Kimi / Moonshot / Ollama 本地等):构造 {@link OpenAiEmbeddingModel}
  • + *
+ *

+ * 设计要点: + *

    + *
  1. Provider api_key 共用:与 chat 模型共用 {@code mate_model_provider.api_key},避免重复配置
  2. + *
  3. 缓存:按 ModelConfigEntity.id 缓存 EmbeddingModel 实例,避免每次查询都重建
  4. + *
  5. 回落:provider apiKey 未配时,DashScope 走 yml 的 {@code spring.ai.dashscope.api-key} + * (兼容老用户),其他 provider 直接报错让用户去 UI 配置
  6. + *
  7. 独立实现:不引用 {@code AgentGraphBuilder} 的 chat-specific 重写(如 reasoning content patch), + * embedding 的请求格式稳定,不需要这些补丁
  8. + *
+ * + * @author MateClaw Team + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class EmbeddingModelFactory { + + private final ModelProviderService providerService; + private final DashScopeConnectionProperties dashScopeConnectionProperties; + + /** 构造 API 时共享的 retry template(用 Spring AI 默认) */ + private static final RetryTemplate DEFAULT_RETRY = RetryUtils.DEFAULT_RETRY_TEMPLATE; + + /** 按 modelConfig.id 缓存 EmbeddingModel,config 变更时调用 {@link #evict} 清除 */ + private final ConcurrentHashMap cache = new ConcurrentHashMap<>(); + + /** + * 构造或复用指定配置对应的 EmbeddingModel + * + * @throws MateClawException 当 provider 未配置或协议不支持时 + */ + public EmbeddingModel build(ModelConfigEntity modelConfig) { + if (modelConfig == null) { + throw new MateClawException("err.embedding.config_null", "Embedding model config is null"); + } + if (modelConfig.getId() != null) { + EmbeddingModel cached = cache.get(modelConfig.getId()); + if (cached != null) return cached; + } + + EmbeddingModel fresh = doBuild(modelConfig); + if (modelConfig.getId() != null) { + cache.put(modelConfig.getId(), fresh); + } + return fresh; + } + + /** + * 清除指定配置的缓存实例(provider api_key 变更 / 模型切换时调用) + */ + public void evict(Long modelConfigId) { + if (modelConfigId != null) { + cache.remove(modelConfigId); + } + } + + /** 清空所有缓存(provider 表刷新后调用) */ + public void evictAll() { + cache.clear(); + } + + // ==================== 内部实现 ==================== + + private EmbeddingModel doBuild(ModelConfigEntity modelConfig) { + ModelProviderEntity provider = providerService.getProviderConfig(modelConfig.getProvider()); + if (provider == null) { + throw new MateClawException("err.embedding.provider_missing", + "Embedding provider '" + modelConfig.getProvider() + "' not found in mate_model_provider"); + } + + EmbeddingProtocol protocol = EmbeddingProtocol.fromProviderId(provider.getProviderId()); + log.info("[EmbeddingFactory] Building embedding model: provider={}, model={}, protocol={}", + provider.getProviderId(), modelConfig.getModelName(), protocol); + + return switch (protocol) { + case DASHSCOPE_EMBEDDING -> buildDashScope(provider, modelConfig); + case OPENAI_EMBEDDING -> buildOpenAi(provider, modelConfig); + }; + } + + private EmbeddingModel buildDashScope(ModelProviderEntity provider, ModelConfigEntity modelConfig) { + // API Key 回落链:provider UI → yml + String apiKey = provider.getApiKey(); + if (!StringUtils.hasText(apiKey) || !providerService.hasUsableApiKey(apiKey)) { + apiKey = dashScopeConnectionProperties.getApiKey(); + } + if (!providerService.hasUsableApiKey(apiKey)) { + throw new MateClawException("err.embedding.dashscope_key_missing", + "DashScope API Key 未配置。请在模型设置中填写 dashscope provider 的 API Key。"); + } + + DashScopeApi.Builder apiBuilder = DashScopeApi.builder().apiKey(apiKey.trim()); + + // Base URL 可选 + String baseUrl = provider.getBaseUrl(); + if (!StringUtils.hasText(baseUrl)) { + baseUrl = dashScopeConnectionProperties.getBaseUrl(); + } + if (StringUtils.hasText(baseUrl)) { + apiBuilder.baseUrl(baseUrl.trim()); + } + + DashScopeApi api = apiBuilder.build(); + + DashScopeEmbeddingOptions options = DashScopeEmbeddingOptions.builder() + .withModel(modelConfig.getModelName()) + .build(); + + return new DashScopeEmbeddingModel(api, MetadataMode.EMBED, options, DEFAULT_RETRY); + } + + private EmbeddingModel buildOpenAi(ModelProviderEntity provider, ModelConfigEntity modelConfig) { + if (!providerService.isProviderConfigured(provider.getProviderId())) { + throw new MateClawException("err.embedding.provider_not_configured", + "Provider '" + provider.getProviderId() + "' 未完成配置(缺少 API Key 或 Base URL)"); + } + String apiKey = provider.getApiKey(); + if (!providerService.hasUsableApiKey(apiKey)) { + throw new MateClawException("err.embedding.openai_key_invalid", + "Provider API Key 未配置或无效: " + provider.getProviderId()); + } + String baseUrl = normalizeOpenAiBaseUrl(provider.getBaseUrl()); + if (!StringUtils.hasText(baseUrl)) { + throw new MateClawException("err.embedding.openai_baseurl_missing", + "Provider Base URL 未配置: " + provider.getProviderId()); + } + + // 最简构造:不做 chat-specific 的 header 重写、reasoning patch 等 + OpenAiApi api = OpenAiApi.builder() + .baseUrl(baseUrl) + .apiKey(apiKey.trim()) + .embeddingsPath("/v1/embeddings") + .build(); + + OpenAiEmbeddingOptions options = OpenAiEmbeddingOptions.builder() + .model(modelConfig.getModelName()) + .build(); + + return new OpenAiEmbeddingModel(api, MetadataMode.EMBED, options, DEFAULT_RETRY); + } + + /** 归一化 OpenAI base URL:去掉末尾的 /v1 / 斜杠(避免双 /v1) */ + private String normalizeOpenAiBaseUrl(String baseUrl) { + if (!StringUtils.hasText(baseUrl)) return null; + String u = baseUrl.trim(); + while (u.endsWith("/")) u = u.substring(0, u.length() - 1); + if (u.endsWith("/v1")) u = u.substring(0, u.length() - 3); + return u; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/EmbeddingProtocol.java b/mateclaw-server/src/main/java/vip/mate/llm/model/EmbeddingProtocol.java new file mode 100644 index 00000000..2a381d10 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/EmbeddingProtocol.java @@ -0,0 +1,47 @@ +package vip.mate.llm.model; + +/** + * Embedding 模型协议。 + *

+ * 与 {@link ModelProtocol}(Chat 协议)分离——chat 和 embedding 在同一个 Provider 下 + * 可能走不同的请求格式: + *

    + *
  • DashScope 的 embedding endpoint 是专用 path(/api/v1/services/embeddings/text-embedding/text-embedding)
  • + *
  • OpenAI 兼容协议的 embedding 统一走 /v1/embeddings
  • + *
+ *

+ * 通过 {@link #fromProviderId} 从 providerId 推断协议,新增 provider 时只需扩展这里。 + * + * @author MateClaw Team + */ +public enum EmbeddingProtocol { + + DASHSCOPE_EMBEDDING("dashscope-embedding"), + OPENAI_EMBEDDING("openai-embedding"); + + private final String id; + + EmbeddingProtocol(String id) { + this.id = id; + } + + public String getId() { + return id; + } + + /** + * 从 providerId 推断 embedding 协议。 + *

    + *
  • dashscope / 任何包含 "dashscope" / "qwen" / "aliyun" 的 → DASHSCOPE_EMBEDDING
  • + *
  • 其他(openai / deepseek / kimi / zhipu / moonshot / 任何 OpenAI 兼容) → OPENAI_EMBEDDING
  • + *
+ */ + public static EmbeddingProtocol fromProviderId(String providerId) { + if (providerId == null) return OPENAI_EMBEDDING; + String p = providerId.toLowerCase().trim(); + if (p.contains("dashscope") || p.contains("qwen") || p.contains("aliyun")) { + return DASHSCOPE_EMBEDDING; + } + return OPENAI_EMBEDDING; + } +} diff --git a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java index 335c0750..34aa6ca5 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/model/ModelConfigEntity.java @@ -47,6 +47,14 @@ public class ModelConfigEntity { private Boolean isDefault; + /** + * 模型类型:chat(默认,LLM 对话) / embedding(文本向量化) + *

+ * 参考 Dify 的 ModelType 抽象:允许同一 Provider 下同时管理 chat 和 embedding 两类模型, + * API Key 共用(存于 mate_model_provider)。 + */ + private String modelType; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java index c00af994..186cab55 100644 --- a/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java +++ b/mateclaw-server/src/main/java/vip/mate/llm/service/ModelConfigService.java @@ -33,6 +33,9 @@ public class ModelConfigService { return modelConfigMapper.selectList(new LambdaQueryWrapper() .eq(ModelConfigEntity::getEnabled, true) .eq(ModelConfigEntity::getProvider, "dashscope") + // 仅 chat 类型(排除 embedding),NULL 兼容老数据 + .and(w -> w.isNull(ModelConfigEntity::getModelType) + .or().eq(ModelConfigEntity::getModelType, "chat")) .orderByDesc(ModelConfigEntity::getIsDefault) .orderByAsc(ModelConfigEntity::getName)); } @@ -44,6 +47,41 @@ public class ModelConfigService { .orderByAsc(ModelConfigEntity::getName)); } + /** + * 按模型类型筛选(RFC: embedding UI 配置)。 + *

+ * modelType 参数: + *

    + *
  • {@code "chat"} — 对话模型(默认,包括老数据 modelType IS NULL)
  • + *
  • {@code "embedding"} — 文本向量化模型
  • + *
+ */ + public List listByType(String modelType) { + if ("chat".equals(modelType)) { + return modelConfigMapper.selectList(new LambdaQueryWrapper() + .and(w -> w.isNull(ModelConfigEntity::getModelType) + .or().eq(ModelConfigEntity::getModelType, "chat")) + .orderByDesc(ModelConfigEntity::getIsDefault) + .orderByAsc(ModelConfigEntity::getName)); + } + return modelConfigMapper.selectList(new LambdaQueryWrapper() + .eq(ModelConfigEntity::getModelType, modelType) + .orderByDesc(ModelConfigEntity::getIsDefault) + .orderByAsc(ModelConfigEntity::getName)); + } + + /** + * 查找第一个 enabled 的 embedding 模型(WikiEmbeddingService 的 fallback 路径) + */ + public ModelConfigEntity findFirstEnabledEmbedding() { + return modelConfigMapper.selectOne(new LambdaQueryWrapper() + .eq(ModelConfigEntity::getModelType, "embedding") + .eq(ModelConfigEntity::getEnabled, true) + .orderByDesc(ModelConfigEntity::getIsDefault) + .orderByAsc(ModelConfigEntity::getName) + .last("LIMIT 1")); + } + public ModelConfigEntity getModel(Long id) { ModelConfigEntity entity = modelConfigMapper.selectById(id); if (entity == null) { @@ -53,14 +91,19 @@ public class ModelConfigService { } public ModelConfigEntity getDefaultModel() { + // 默认 chat 模型:明确排除 embedding 类型 ModelConfigEntity entity = modelConfigMapper.selectOne(new LambdaQueryWrapper() .eq(ModelConfigEntity::getIsDefault, true) + .and(w -> w.isNull(ModelConfigEntity::getModelType) + .or().eq(ModelConfigEntity::getModelType, "chat")) .last("LIMIT 1")); if (entity != null) { return entity; } entity = modelConfigMapper.selectOne(new LambdaQueryWrapper() .eq(ModelConfigEntity::getEnabled, true) + .and(w -> w.isNull(ModelConfigEntity::getModelType) + .or().eq(ModelConfigEntity::getModelType, "chat")) .orderByAsc(ModelConfigEntity::getName) .last("LIMIT 1")); if (entity == null) { diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java index f9925c26..fff1216c 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/controller/WikiController.java @@ -111,7 +111,17 @@ public class WikiController { String name = (String) body.get("name"); String description = (String) body.get("description"); Long agentId = body.get("agentId") != null ? Long.valueOf(body.get("agentId").toString()) : null; - return R.ok(kbService.update(id, name, description, agentId)); + kbService.update(id, name, description, agentId); + // RFC Embedding UI: 允许通过此接口绑定 / 解绑 embedding 模型 + if (body.containsKey("embeddingModelId")) { + Object v = body.get("embeddingModelId"); + Long embeddingModelId = null; + if (v != null && !v.toString().isBlank()) { + embeddingModelId = Long.valueOf(v.toString()); + } + kbService.updateEmbeddingModelId(id, embeddingModelId); + } + return R.ok(kbService.getById(id)); } @RequireWorkspaceRole("admin") diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java index 5cff40cb..737d1475 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/model/WikiKnowledgeBaseEntity.java @@ -45,6 +45,14 @@ public class WikiKnowledgeBaseEntity { /** 所属工作区 ID(默认 1 = default) */ private Long workspaceId; + /** + * 绑定的 Embedding 模型 ID(mate_model_config.id,model_type='embedding')。 + *

+ * NULL = 使用系统默认(mate_system_setting 的 embedding.default.model.id), + * 再无则取任意 enabled 的 embedding 模型,最终全无则语义搜索降级为不可用。 + */ + private Long embeddingModelId; + @TableField(fill = FieldFill.INSERT) private LocalDateTime createTime; diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java index 1d590b2e..afc1dc8f 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/HybridRetriever.java @@ -94,7 +94,7 @@ public class HybridRetriever { public List searchChunks(Long kbId, String query, int topK) { if (!embeddingService.isAvailable()) return List.of(); - float[] queryVec = embeddingService.embedQuery(query); + float[] queryVec = embeddingService.embedQuery(kbId, query); if (queryVec == null) return List.of(); List allChunks = chunkService.listByKbId(kbId); @@ -118,7 +118,7 @@ public class HybridRetriever { /** 语义搜索:chunk cosine → 聚合到 page(同页多 chunk 取最高分) */ private List semanticSearch(Long kbId, String query, int limit) { - float[] queryVec = embeddingService.embedQuery(query); + float[] queryVec = embeddingService.embedQuery(kbId, query); if (queryVec == null) return List.of(); List allChunks = chunkService.listByKbId(kbId); diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java index dd94540a..71a33beb 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiEmbeddingService.java @@ -2,14 +2,20 @@ package vip.mate.wiki.service; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.embedding.EmbeddingModel; import org.springframework.ai.embedding.EmbeddingRequest; import org.springframework.ai.embedding.EmbeddingResponse; -import org.springframework.beans.factory.ObjectProvider; import org.springframework.stereotype.Service; +import vip.mate.llm.embedding.EmbeddingModelFactory; +import vip.mate.llm.model.ModelConfigEntity; +import vip.mate.llm.service.ModelConfigService; +import vip.mate.system.model.SystemSettingEntity; +import vip.mate.system.repository.SystemSettingMapper; import vip.mate.wiki.WikiProperties; import vip.mate.wiki.model.WikiChunkEntity; +import vip.mate.wiki.model.WikiKnowledgeBaseEntity; import vip.mate.wiki.repository.WikiChunkMapper; import java.nio.ByteBuffer; @@ -17,55 +23,99 @@ import java.nio.ByteOrder; import java.util.List; /** - * RFC-011: Wiki 嵌入服务 + * RFC-011 + Embedding-UI-Config: Wiki 嵌入服务 *

- * 使用 Spring AI 的 {@link EmbeddingModel}(DashScope auto-config)对 chunk 做向量化。 - *

    - *
  • {@link #embedMissingChunks} — 批量嵌入缺失 embedding 的 chunk(材料处理后异步调用)
  • - *
  • {@link #embedQuery} — 查询向量化(混合搜索时调用)
  • - *
+ * 按知识库(KB)动态解析应使用的 Embedding 模型,解析优先级: + *
    + *
  1. KB 级绑定:{@link WikiKnowledgeBaseEntity#getEmbeddingModelId()}
  2. + *
  3. 系统默认:{@code mate_system_setting.setting_key = 'embedding.default.model.id'}
  4. + *
  5. 任意 enabled 的 embedding 模型(取第一个)
  6. + *
  7. 全无 → 返回不可用,上层降级(语义搜索返回空,关键词搜索仍可用)
  8. + *
* * @author MateClaw Team */ @Slf4j @Service +@RequiredArgsConstructor public class WikiEmbeddingService { - private final EmbeddingModel embeddingModel; private final WikiChunkMapper chunkMapper; private final WikiProperties properties; - private final boolean available; + private final EmbeddingModelFactory factory; + private final ModelConfigService modelConfigService; + private final WikiKnowledgeBaseService kbService; + private final SystemSettingMapper systemSettingMapper; - public WikiEmbeddingService(ObjectProvider embeddingModelProvider, - WikiChunkMapper chunkMapper, WikiProperties properties) { - this.chunkMapper = chunkMapper; - this.properties = properties; - EmbeddingModel model = embeddingModelProvider.getIfAvailable(); - this.embeddingModel = model; - this.available = model != null; - if (!available) { - log.warn("[WikiEmbedding] No EmbeddingModel bean found — semantic search disabled. " - + "Ensure spring-ai-alibaba-starter-dashscope is on classpath and DASHSCOPE_API_KEY is set."); - } else { - log.info("[WikiEmbedding] EmbeddingModel available: {}", model.getClass().getSimpleName()); + /** 系统默认 embedding 模型的 mate_system_setting key */ + public static final String SYSTEM_SETTING_DEFAULT_EMBEDDING_ID = "embedding.default.model.id"; + + /** + * 判断全局是否有可用的 embedding 能力(任何 enabled 的 embedding 模型配置) + */ + public boolean isAvailable() { + try { + ModelConfigEntity fallback = modelConfigService.findFirstEnabledEmbedding(); + return fallback != null; + } catch (Exception e) { + return false; } } - public boolean isAvailable() { return available; } + /** + * 解析指定 KB 应使用的 embedding 模型与模型实例 + */ + public Resolved resolveForKb(Long kbId) { + // 优先级 1:KB 级绑定 + try { + WikiKnowledgeBaseEntity kb = kbService.getById(kbId); + if (kb != null && kb.getEmbeddingModelId() != null) { + ModelConfigEntity model = safeGetModel(kb.getEmbeddingModelId()); + if (isUsable(model)) { + return new Resolved(factory.build(model), model.getModelName()); + } + log.warn("[WikiEmbedding] KB {} bound embedding model {} is unusable, falling back", + kbId, kb.getEmbeddingModelId()); + } + } catch (Exception e) { + log.debug("[WikiEmbedding] KB binding resolve failed for kbId={}: {}", kbId, e.getMessage()); + } + + // 优先级 2:系统默认 + Long defaultId = readSystemDefaultEmbeddingId(); + if (defaultId != null) { + ModelConfigEntity model = safeGetModel(defaultId); + if (isUsable(model)) { + return new Resolved(factory.build(model), model.getModelName()); + } + log.warn("[WikiEmbedding] System default embedding model {} is unusable, falling back", defaultId); + } + + // 优先级 3:任意 enabled + ModelConfigEntity anyEnabled = modelConfigService.findFirstEnabledEmbedding(); + if (isUsable(anyEnabled)) { + return new Resolved(factory.build(anyEnabled), anyEnabled.getModelName()); + } + + log.warn("[WikiEmbedding] No usable embedding model configured. " + + "Configure one under Settings → Models → Embedding tab."); + return null; + } /** * 批量嵌入指定 KB 中缺失 embedding 的 chunk。 *

- * 只嵌入 embedding 为 NULL 或 embeddingModel 与当前配置不匹配的 chunk。 - * 模型切换时自动触发全量重嵌(通过 embeddingModel 字段比对)。 + * 只嵌入 embedding 为 NULL 或 embeddingModel 与当前解析出的模型不一致的 chunk。 + * 模型切换时自动触发全量重嵌(通过 embedding_model 字段比对)。 */ public int embedMissingChunks(Long kbId) { - if (!available) { - log.debug("[WikiEmbedding] Skipping — no EmbeddingModel available"); + Resolved r = resolveForKb(kbId); + if (r == null) { + log.debug("[WikiEmbedding] Skipping kbId={} — no embedding model available", kbId); return 0; } - String modelName = properties.getEmbeddingModel(); + String modelName = r.modelName(); List pending = chunkMapper.selectList( new LambdaQueryWrapper() .eq(WikiChunkEntity::getKbId, kbId) @@ -87,8 +137,7 @@ public class WikiEmbeddingService { .map(WikiChunkEntity::getContent) .toList(); - EmbeddingResponse resp = embeddingModel.call( - new EmbeddingRequest(inputs, null)); + EmbeddingResponse resp = r.model().call(new EmbeddingRequest(inputs, null)); for (int i = 0; i < batch.size(); i++) { float[] vec = resp.getResults().get(i).getOutput(); @@ -99,28 +148,33 @@ public class WikiEmbeddingService { } total += batch.size(); } catch (Exception e) { - log.error("[WikiEmbedding] Batch embedding failed (kbId={}, batchSize={}): {}", - kbId, batch.size(), e.getMessage()); + log.error("[WikiEmbedding] Batch embedding failed (kbId={}, batchSize={}, model={}): {}", + kbId, batch.size(), modelName, e.getMessage()); // 继续下一批,不中断 } } - log.info("[WikiEmbedding] Embedded {}/{} chunks for kbId={}, model={}", - total, pending.size(), kbId, modelName); + if (total == 0 && !pending.isEmpty()) { + log.warn("[WikiEmbedding] ALL {} chunks failed for kbId={} model={} — check API key / model availability", + pending.size(), kbId, modelName); + } else { + log.info("[WikiEmbedding] Embedded {}/{} chunks for kbId={}, model={}", + total, pending.size(), kbId, modelName); + } return total; } /** - * 查询向量化(混合搜索时调用) + * 查询向量化(混合搜索时调用,需指定 KB 以便解析对应模型) */ - public float[] embedQuery(String query) { - if (!available) return null; + public float[] embedQuery(Long kbId, String query) { + Resolved r = resolveForKb(kbId); + if (r == null) return null; try { - EmbeddingResponse resp = embeddingModel.call( - new EmbeddingRequest(List.of(query), null)); + EmbeddingResponse resp = r.model().call(new EmbeddingRequest(List.of(query), null)); return resp.getResults().get(0).getOutput(); } catch (Exception e) { - log.error("[WikiEmbedding] Query embedding failed: {}", e.getMessage()); + log.error("[WikiEmbedding] Query embedding failed for kbId={}: {}", kbId, e.getMessage()); return null; } } @@ -136,6 +190,38 @@ public class WikiEmbeddingService { log.info("[WikiEmbedding] Cleared all embeddings for kbId={}", kbId); } + // ==================== 私有 helper ==================== + + private ModelConfigEntity safeGetModel(Long id) { + try { + return modelConfigService.getModel(id); + } catch (Exception e) { + return null; + } + } + + private boolean isUsable(ModelConfigEntity model) { + return model != null + && Boolean.TRUE.equals(model.getEnabled()) + && "embedding".equals(model.getModelType()); + } + + private Long readSystemDefaultEmbeddingId() { + try { + SystemSettingEntity entity = systemSettingMapper.selectOne( + new LambdaQueryWrapper() + .eq(SystemSettingEntity::getSettingKey, SYSTEM_SETTING_DEFAULT_EMBEDDING_ID) + .last("LIMIT 1")); + if (entity == null || entity.getSettingValue() == null || entity.getSettingValue().isBlank()) { + return null; + } + return Long.parseLong(entity.getSettingValue().trim()); + } catch (Exception e) { + log.debug("[WikiEmbedding] Failed to read system default embedding id: {}", e.getMessage()); + return null; + } + } + // ==================== 向量序列化 ==================== public static byte[] floatsToBytes(float[] vec) { @@ -163,4 +249,14 @@ public class WikiEmbeddingService { float denom = (float) (Math.sqrt(normA) * Math.sqrt(normB)); return denom == 0 ? 0f : dot / denom; } + + /** 解析结果 DTO */ + public record Resolved(EmbeddingModel model, String modelName) {} + + /** + * 暴露 factory 给外部(如测试连通性 API) + */ + public EmbeddingModelFactory getFactory() { + return factory; + } } diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java index 91b52cbe..41fcf5b9 100644 --- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java +++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiKnowledgeBaseService.java @@ -112,6 +112,25 @@ public class WikiKnowledgeBaseService { return entity; } + /** + * 更新 KB 绑定的 embedding 模型 ID。 + *

+ * 切换模型后,旧的向量维度/语义空间与新模型不一致,下次搜索/处理时会被 + * WikiEmbeddingService 自动检测为"model 不匹配"触发重嵌。 + * 这里不主动清空 embedding(让 embed_model 字段的差异自己触发重建)。 + * + * @param embeddingModelId null 表示解绑(走系统默认) + */ + @Transactional + public void updateEmbeddingModelId(Long id, Long embeddingModelId) { + WikiKnowledgeBaseEntity entity = kbMapper.selectById(id); + if (entity == null) { + throw new IllegalArgumentException("Knowledge base not found: " + id); + } + entity.setEmbeddingModelId(embeddingModelId); + kbMapper.updateById(entity); + } + @Transactional public void updateConfig(Long id, String configContent) { WikiKnowledgeBaseEntity entity = kbMapper.selectById(id); diff --git a/mateclaw-server/src/main/resources/db/migration/h2/V14__embedding_model_config.sql b/mateclaw-server/src/main/resources/db/migration/h2/V14__embedding_model_config.sql new file mode 100644 index 00000000..84d7d1c5 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/h2/V14__embedding_model_config.sql @@ -0,0 +1,25 @@ +-- V14: Embedding model UI config (对标 Dify) +-- 区分 chat / embedding 两种模型类型,让 embedding 也能通过 Settings/Models UI 管理 +ALTER TABLE mate_model_config ADD COLUMN IF NOT EXISTS model_type VARCHAR(32) DEFAULT 'chat'; + +-- 知识库可绑定特定 embedding 模型;NULL 表示使用系统默认 +ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS embedding_model_id BIGINT DEFAULT NULL; + +-- 播种 DashScope embedding 模型(builtin,共享 dashscope provider 的 api_key) +-- 1000001001..003 段用于 embedding 模型,与 1000000xxx 段的 chat 模型错开 +MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +KEY (id) +VALUES (1000001001, 'Text Embedding v3', 'dashscope', 'text-embedding-v3', + 'DashScope 通义千问 v3 通用文本向量模型(1024 维)', 0, 0, 0, + TRUE, TRUE, TRUE, 'embedding', NOW(), NOW(), 0); + +MERGE INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +KEY (id) +VALUES (1000001002, 'Text Embedding v2', 'dashscope', 'text-embedding-v2', + 'DashScope 通义千问 v2 文本向量模型(1536 维)', 0, 0, 0, + TRUE, TRUE, FALSE, 'embedding', NOW(), NOW(), 0); + +-- 设置 v3 为系统默认 embedding 模型(WikiEmbeddingService 的第 2 优先级) +MERGE INTO mate_system_setting (setting_key, setting_value, description, create_time, update_time) +KEY (setting_key) +VALUES ('embedding.default.model.id', '1000001001', 'Default embedding model id for wiki semantic search', NOW(), NOW()); diff --git a/mateclaw-server/src/main/resources/db/migration/mysql/V14__embedding_model_config.sql b/mateclaw-server/src/main/resources/db/migration/mysql/V14__embedding_model_config.sql new file mode 100644 index 00000000..92050186 --- /dev/null +++ b/mateclaw-server/src/main/resources/db/migration/mysql/V14__embedding_model_config.sql @@ -0,0 +1,20 @@ +-- V14: Embedding model UI config (对标 Dify) +ALTER TABLE mate_model_config ADD COLUMN IF NOT EXISTS model_type VARCHAR(32) DEFAULT 'chat'; +ALTER TABLE mate_wiki_knowledge_base ADD COLUMN IF NOT EXISTS embedding_model_id BIGINT DEFAULT NULL; + +-- 播种 DashScope embedding(与 chat 模型共享 provider apiKey) +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES (1000001001, 'Text Embedding v3', 'dashscope', 'text-embedding-v3', + 'DashScope 通义千问 v3 通用文本向量模型(1024 维)', 0, 0, 0, + TRUE, TRUE, TRUE, 'embedding', NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE model_type = 'embedding'; + +INSERT INTO mate_model_config (id, name, provider, model_name, description, temperature, max_tokens, top_p, builtin, enabled, is_default, model_type, create_time, update_time, deleted) +VALUES (1000001002, 'Text Embedding v2', 'dashscope', 'text-embedding-v2', + 'DashScope 通义千问 v2 文本向量模型(1536 维)', 0, 0, 0, + TRUE, TRUE, FALSE, 'embedding', NOW(), NOW(), 0) +ON DUPLICATE KEY UPDATE model_type = 'embedding'; + +INSERT INTO mate_system_setting (setting_key, setting_value, description, create_time, update_time) +VALUES ('embedding.default.model.id', '1000001001', 'Default embedding model id for wiki semantic search', NOW(), NOW()) +ON DUPLICATE KEY UPDATE setting_value = setting_value; diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts index 70d3875a..c48f40bf 100644 --- a/mateclaw-ui/src/api/index.ts +++ b/mateclaw-ui/src/api/index.ts @@ -269,6 +269,15 @@ export const modelApi = { http.post(`/models/${providerId}/test-connection`), testModel: (providerId: string, modelId: string) => http.post(`/models/${providerId}/models/${encodeURIComponent(modelId)}/test`), + + // ==================== Embedding Model (RFC Embedding UI) ==================== + listByType: (modelType: 'chat' | 'embedding') => + http.get('/models/by-type', { params: { modelType } }), + testEmbedding: (modelId: string | number) => + http.post(`/models/embedding/${modelId}/test`), + getDefaultEmbedding: () => http.get('/models/embedding/default'), + setDefaultEmbedding: (modelId: string | number | '') => + http.post('/models/embedding/default', { modelId }), } // ==================== OAuth ==================== @@ -355,7 +364,7 @@ export const wikiApi = { listKBsByAgent: (agentId: number) => http.get(`/wiki/knowledge-bases/agent/${agentId}`), createKB: (data: { name: string; description?: string; agentId?: number }) => http.post('/wiki/knowledge-bases', data), - updateKB: (id: number, data: { name?: string; description?: string; agentId?: number }) => + updateKB: (id: number, data: { name?: string; description?: string; agentId?: number; embeddingModelId?: string | number | null }) => http.put(`/wiki/knowledge-bases/${id}`, data), deleteKB: (id: number) => http.delete(`/wiki/knowledge-bases/${id}`), getConfig: (id: number) => http.get(`/wiki/knowledge-bases/${id}/config`), diff --git a/mateclaw-ui/src/views/Settings/Models/EmbeddingModelsSection.vue b/mateclaw-ui/src/views/Settings/Models/EmbeddingModelsSection.vue new file mode 100644 index 00000000..9118081f --- /dev/null +++ b/mateclaw-ui/src/views/Settings/Models/EmbeddingModelsSection.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/mateclaw-ui/src/views/Settings/Models/index.vue b/mateclaw-ui/src/views/Settings/Models/index.vue index 275484ac..442ab912 100644 --- a/mateclaw-ui/src/views/Settings/Models/index.vue +++ b/mateclaw-ui/src/views/Settings/Models/index.vue @@ -64,6 +64,9 @@ + + +

{{ savedTip }}
@@ -119,6 +122,7 @@ import { ElMessage } from 'element-plus' import type { ProviderInfo, ProviderModelInfo } from '@/types' import { useProviders } from './useProviders' import ProviderCard from './ProviderCard.vue' +import EmbeddingModelsSection from './EmbeddingModelsSection.vue' import ProviderConfigModal from './modals/ProviderConfigModal.vue' import ManageModelsModal from './modals/ManageModelsModal.vue' diff --git a/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue index 2a7470d4..602b927c 100644 --- a/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue +++ b/mateclaw-ui/src/views/Wiki/components/WikiConfig.vue @@ -5,6 +5,25 @@

{{ t('wiki.configDesc') }}

+ +
+ +
+ + +
+
+