feat(llm): UI-configurable embedding models with per-KB binding and dynamic factory

This commit is contained in:
matevip 2026-04-16 17:12:00 +08:00
parent 04ad281ffd
commit 25978ceb12
16 changed files with 910 additions and 45 deletions

View File

@ -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<ProviderInfoDTO> 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<List<ModelConfigEntity>> listByType(@RequestParam(defaultValue = "chat") String modelType) {
return R.ok(modelConfigService.listByType(modelType));
}
@Operation(summary = "测试 Embedding 模型连通性(嵌入一个短文本验证 API key")
@PostMapping("/embedding/{modelId}/test")
public R<Map<String, Object>> testEmbedding(@PathVariable Long modelId) {
Map<String, Object> 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<Map<String, Object>> getDefaultEmbedding() {
SystemSettingEntity entity = systemSettingMapper.selectOne(
new LambdaQueryWrapper<SystemSettingEntity>()
.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<Void> setDefaultEmbedding(@RequestBody Map<String, Object> body) {
Object v = body.get("modelId");
String value = v == null ? "" : v.toString();
SystemSettingEntity existing = systemSettingMapper.selectOne(
new LambdaQueryWrapper<SystemSettingEntity>()
.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();
}
}

View File

@ -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 模型工厂
* <p>
* 根据 {@link ModelConfigEntity}model_type='embedding'构造对应的 {@link EmbeddingModel}
* <ul>
* <li>DashScope复用 provider UI 配置的 apiKey/baseUrl构造 {@link DashScopeEmbeddingModel}</li>
* <li>OpenAI 兼容OpenAI / DeepSeek / 智谱 / Kimi / Moonshot / Ollama 本地等构造 {@link OpenAiEmbeddingModel}</li>
* </ul>
* <p>
* 设计要点
* <ol>
* <li><b>Provider api_key 共用</b> chat 模型共用 {@code mate_model_provider.api_key}避免重复配置</li>
* <li><b>缓存</b> ModelConfigEntity.id 缓存 EmbeddingModel 实例避免每次查询都重建</li>
* <li><b>回落</b>provider apiKey 未配时DashScope yml {@code spring.ai.dashscope.api-key}
* 兼容老用户其他 provider 直接报错让用户去 UI 配置</li>
* <li><b>独立实现</b>不引用 {@code AgentGraphBuilder} chat-specific 重写 reasoning content patch
* embedding 的请求格式稳定不需要这些补丁</li>
* </ol>
*
* @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 缓存 EmbeddingModelconfig 变更时调用 {@link #evict} 清除 */
private final ConcurrentHashMap<Long, EmbeddingModel> 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;
}
}

View File

@ -0,0 +1,47 @@
package vip.mate.llm.model;
/**
* Embedding 模型协议
* <p>
* {@link ModelProtocol}Chat 协议分离chat embedding 在同一个 Provider
* 可能走不同的请求格式
* <ul>
* <li>DashScope embedding endpoint 是专用 path/api/v1/services/embeddings/text-embedding/text-embedding</li>
* <li>OpenAI 兼容协议的 embedding 统一走 /v1/embeddings</li>
* </ul>
* <p>
* 通过 {@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 协议
* <ul>
* <li>dashscope / 任何包含 "dashscope" / "qwen" / "aliyun" DASHSCOPE_EMBEDDING</li>
* <li>其他openai / deepseek / kimi / zhipu / moonshot / 任何 OpenAI 兼容 OPENAI_EMBEDDING</li>
* </ul>
*/
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;
}
}

View File

@ -47,6 +47,14 @@ public class ModelConfigEntity {
private Boolean isDefault;
/**
* 模型类型chat默认LLM 对话 / embedding文本向量化
* <p>
* 参考 Dify ModelType 抽象允许同一 Provider 下同时管理 chat embedding 两类模型
* API Key 共用存于 mate_model_provider
*/
private String modelType;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -33,6 +33,9 @@ public class ModelConfigService {
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getEnabled, true)
.eq(ModelConfigEntity::getProvider, "dashscope")
// chat 类型排除 embeddingNULL 兼容老数据
.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 配置
* <p>
* modelType 参数
* <ul>
* <li>{@code "chat"} 对话模型默认包括老数据 modelType IS NULL</li>
* <li>{@code "embedding"} 文本向量化模型</li>
* </ul>
*/
public List<ModelConfigEntity> listByType(String modelType) {
if ("chat".equals(modelType)) {
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
.and(w -> w.isNull(ModelConfigEntity::getModelType)
.or().eq(ModelConfigEntity::getModelType, "chat"))
.orderByDesc(ModelConfigEntity::getIsDefault)
.orderByAsc(ModelConfigEntity::getName));
}
return modelConfigMapper.selectList(new LambdaQueryWrapper<ModelConfigEntity>()
.eq(ModelConfigEntity::getModelType, modelType)
.orderByDesc(ModelConfigEntity::getIsDefault)
.orderByAsc(ModelConfigEntity::getName));
}
/**
* 查找第一个 enabled embedding 模型WikiEmbeddingService fallback 路径
*/
public ModelConfigEntity findFirstEnabledEmbedding() {
return modelConfigMapper.selectOne(new LambdaQueryWrapper<ModelConfigEntity>()
.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<ModelConfigEntity>()
.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<ModelConfigEntity>()
.eq(ModelConfigEntity::getEnabled, true)
.and(w -> w.isNull(ModelConfigEntity::getModelType)
.or().eq(ModelConfigEntity::getModelType, "chat"))
.orderByAsc(ModelConfigEntity::getName)
.last("LIMIT 1"));
if (entity == null) {

View File

@ -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")

View File

@ -45,6 +45,14 @@ public class WikiKnowledgeBaseEntity {
/** 所属工作区 ID默认 1 = default */
private Long workspaceId;
/**
* 绑定的 Embedding 模型 IDmate_model_config.idmodel_type='embedding'
* <p>
* NULL = 使用系统默认mate_system_setting embedding.default.model.id
* 再无则取任意 enabled embedding 模型最终全无则语义搜索降级为不可用
*/
private Long embeddingModelId;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -94,7 +94,7 @@ public class HybridRetriever {
public List<ChunkHit> 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<WikiChunkEntity> allChunks = chunkService.listByKbId(kbId);
@ -118,7 +118,7 @@ public class HybridRetriever {
/** 语义搜索chunk cosine → 聚合到 page同页多 chunk 取最高分) */
private List<RankedItem> 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<WikiChunkEntity> allChunks = chunkService.listByKbId(kbId);

View File

@ -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 嵌入服务
* <p>
* 使用 Spring AI {@link EmbeddingModel}DashScope auto-config chunk 做向量化
* <ul>
* <li>{@link #embedMissingChunks} 批量嵌入缺失 embedding chunk材料处理后异步调用</li>
* <li>{@link #embedQuery} 查询向量化混合搜索时调用</li>
* </ul>
* 按知识库KB动态解析应使用的 Embedding 模型解析优先级
* <ol>
* <li>KB 级绑定{@link WikiKnowledgeBaseEntity#getEmbeddingModelId()}</li>
* <li>系统默认{@code mate_system_setting.setting_key = 'embedding.default.model.id'}</li>
* <li>任意 enabled embedding 模型取第一个</li>
* <li>全无 返回不可用上层降级语义搜索返回空关键词搜索仍可用</li>
* </ol>
*
* @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<EmbeddingModel> 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) {
// 优先级 1KB 级绑定
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
* <p>
* 只嵌入 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<WikiChunkEntity> pending = chunkMapper.selectList(
new LambdaQueryWrapper<WikiChunkEntity>()
.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<SystemSettingEntity>()
.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;
}
}

View File

@ -112,6 +112,25 @@ public class WikiKnowledgeBaseService {
return entity;
}
/**
* 更新 KB 绑定的 embedding 模型 ID
* <p>
* 切换模型后旧的向量维度/语义空间与新模型不一致下次搜索/处理时会被
* 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);

View File

@ -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());

View File

@ -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;

View File

@ -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`),

View File

@ -0,0 +1,238 @@
<template>
<div class="provider-group embedding-section">
<h3 class="group-title">
<svg class="group-title__icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"/>
<circle cx="12" cy="12" r="4"/>
<line x1="4.93" y1="4.93" x2="9.17" y2="9.17"/>
<line x1="14.83" y1="14.83" x2="19.07" y2="19.07"/>
<line x1="14.83" y1="9.17" x2="19.07" y2="4.93"/>
<line x1="4.93" y1="19.07" x2="9.17" y2="14.83"/>
</svg>
Embedding 模型
<span class="group-hint">知识库语义检索使用 Chat 模型共享 Provider API Key</span>
</h3>
<div v-if="loading" class="loading-state">加载中...</div>
<div v-else-if="models.length === 0" class="empty-state">
暂无可用的 Embedding 模型系统已预置 DashScope Text Embedding v3/v2
请在"云端模型"下的 <strong>DashScope</strong> Provider 中配置 API Key
</div>
<div v-else class="embedding-grid">
<div v-for="model in models" :key="model.id" class="embedding-card">
<div class="embedding-card-header">
<div class="embedding-name">
{{ model.name }}
<span v-if="String(model.id) === defaultModelId" class="default-badge">默认</span>
</div>
<span class="provider-badge">{{ model.provider }}</span>
</div>
<div class="embedding-model-id">{{ model.modelName }}</div>
<div v-if="model.description" class="embedding-desc">{{ model.description }}</div>
<!-- 测试结果 -->
<div v-if="testResults[String(model.id)]" class="test-result" :class="testResults[String(model.id)].success ? 'success' : 'error'">
<span v-if="testResults[String(model.id)].success">
测试通过 · 维度 {{ testResults[String(model.id)].dimensions }}
</span>
<span v-else> {{ testResults[String(model.id)].message }}</span>
</div>
<div class="embedding-actions">
<button
class="card-btn test-btn"
:disabled="testingId === String(model.id)"
@click="onTest(model)"
>
{{ testingId === String(model.id) ? '测试中...' : '测试连通性' }}
</button>
<button
v-if="String(model.id) !== defaultModelId"
class="card-btn"
@click="onSetDefault(model)"
>
设为系统默认
</button>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { modelApi } from '@/api'
interface EmbeddingModel {
id: string | number
name: string
provider: string
modelName: string
description?: string
enabled?: boolean
isDefault?: boolean
}
const models = ref<EmbeddingModel[]>([])
const loading = ref(false)
const defaultModelId = ref<string>('')
const testingId = ref<string>('')
const testResults = ref<Record<string, { success: boolean; dimensions?: number; message?: string }>>({})
async function loadAll() {
loading.value = true
try {
const [listRes, defaultRes] = await Promise.all([
modelApi.listByType('embedding'),
modelApi.getDefaultEmbedding(),
])
models.value = (listRes.data as any[]) || []
defaultModelId.value = String((defaultRes.data as any)?.defaultModelId || '')
} catch (e: any) {
console.error('[EmbeddingModels] Load failed:', e?.message)
} finally {
loading.value = false
}
}
async function onTest(model: EmbeddingModel) {
testingId.value = String(model.id)
try {
const res = await modelApi.testEmbedding(model.id)
const data = res.data as any
testResults.value[String(model.id)] = {
success: !!data?.success,
dimensions: data?.dimensions,
message: data?.message,
}
} catch (e: any) {
testResults.value[String(model.id)] = {
success: false,
message: e?.message || '请求失败',
}
} finally {
testingId.value = ''
}
}
async function onSetDefault(model: EmbeddingModel) {
try {
await modelApi.setDefaultEmbedding(model.id)
defaultModelId.value = String(model.id)
} catch (e: any) {
console.error('[EmbeddingModels] Set default failed:', e?.message)
}
}
onMounted(loadAll)
defineExpose({ refresh: loadAll })
</script>
<style scoped>
.embedding-section {
margin-top: 24px;
}
.group-hint {
font-size: 12px;
font-weight: 400;
color: var(--mc-text-tertiary);
margin-left: 12px;
}
.loading-state, .empty-state {
padding: 32px;
text-align: center;
color: var(--mc-text-tertiary);
background: var(--mc-bg-sunken);
border-radius: 8px;
}
.empty-state strong { color: var(--mc-primary); }
.embedding-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px;
}
.embedding-card {
padding: 16px;
background: var(--mc-bg-surface);
border: 1px solid var(--mc-border);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 8px;
}
.embedding-card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.embedding-name {
font-weight: 600;
font-size: 14px;
color: var(--mc-text-primary);
display: flex;
align-items: center;
gap: 6px;
}
.default-badge {
font-size: 10px;
padding: 2px 6px;
background: var(--mc-primary-bg);
color: var(--mc-primary);
border-radius: 4px;
font-weight: 600;
}
.provider-badge {
font-size: 11px;
padding: 2px 8px;
background: var(--mc-bg-sunken);
color: var(--mc-text-secondary);
border-radius: 999px;
}
.embedding-model-id {
font-size: 12px;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
color: var(--mc-text-tertiary);
}
.embedding-desc {
font-size: 12px;
color: var(--mc-text-secondary);
line-height: 1.5;
}
.test-result {
font-size: 12px;
padding: 6px 8px;
border-radius: 4px;
}
.test-result.success {
background: rgba(34, 197, 94, 0.1);
color: rgb(21, 128, 61);
}
.test-result.error {
background: var(--mc-danger-bg);
color: var(--mc-danger);
}
.embedding-actions {
display: flex;
gap: 8px;
margin-top: 4px;
}
.card-btn {
flex: 1;
padding: 6px 12px;
font-size: 12px;
border-radius: 4px;
border: 1px solid var(--mc-border);
background: transparent;
color: var(--mc-text-primary);
cursor: pointer;
}
.card-btn:hover:not(:disabled) { background: var(--mc-bg-sunken); }
.card-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.test-btn {
background: var(--mc-primary-bg);
color: var(--mc-primary);
border-color: var(--mc-primary);
}
</style>

View File

@ -64,6 +64,9 @@
</div>
</div>
<!-- Embedding 模型RFC Embedding UI -->
<EmbeddingModelsSection />
<div v-if="savedTip" class="save-tip">{{ savedTip }}</div>
<!-- Provider Config Modal -->
@ -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'

View File

@ -5,6 +5,25 @@
<p class="config-desc">{{ t('wiki.configDesc') }}</p>
</div>
<!-- Embedding 模型绑定RFC Embedding UI -->
<div class="embedding-config">
<label class="embedding-label">
Embedding 模型
<span class="embedding-hint">用于该知识库的语义检索留空走系统默认</span>
</label>
<div class="embedding-row">
<select v-model="embeddingModelId" class="embedding-select" :disabled="savingEmbedding">
<option value="">跟随系统默认</option>
<option v-for="m in embeddingOptions" :key="m.id" :value="String(m.id)">
{{ m.name }} ({{ m.modelName }})
</option>
</select>
<button class="btn-secondary" @click="saveEmbeddingBinding" :disabled="savingEmbedding">
{{ savingEmbedding ? '保存中...' : '保存绑定' }}
</button>
</div>
</div>
<textarea
v-model="configContent"
class="config-editor"
@ -25,7 +44,7 @@
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useWikiStore } from '@/stores/useWikiStore'
import { wikiApi } from '@/api/index'
import { wikiApi, modelApi } from '@/api/index'
const { t } = useI18n()
const store = useWikiStore()
@ -33,6 +52,43 @@ const store = useWikiStore()
const configContent = ref('')
const saving = ref(false)
// RFC Embedding UI: KB embedding
interface EmbeddingOption { id: string | number; name: string; modelName: string }
const embeddingModelId = ref<string>('')
const embeddingOptions = ref<EmbeddingOption[]>([])
const savingEmbedding = ref(false)
async function loadEmbeddingOptions() {
try {
const res = await modelApi.listByType('embedding')
embeddingOptions.value = ((res.data as any[]) || []).filter(m => m.enabled !== false)
} catch (e) {
console.error('[WikiConfig] Failed to load embedding options', e)
}
}
function loadEmbeddingBinding() {
const kb: any = store.currentKB
embeddingModelId.value = kb?.embeddingModelId ? String(kb.embeddingModelId) : ''
}
async function saveEmbeddingBinding() {
if (!store.currentKB) return
savingEmbedding.value = true
try {
await wikiApi.updateKB(store.currentKB.id, {
embeddingModelId: embeddingModelId.value === '' ? null : embeddingModelId.value,
})
// store KB
const kb: any = store.currentKB
kb.embeddingModelId = embeddingModelId.value === '' ? null : Number(embeddingModelId.value)
} catch (e) {
console.error('[WikiConfig] Failed to save embedding binding', e)
} finally {
savingEmbedding.value = false
}
}
async function loadConfig() {
if (!store.currentKB) return
try {
@ -57,7 +113,10 @@ async function saveConfig() {
watch(() => store.currentKB, () => {
loadConfig()
loadEmbeddingBinding()
}, { immediate: true })
loadEmbeddingOptions()
</script>
<style scoped>
@ -74,6 +133,15 @@ watch(() => store.currentKB, () => {
.config-title { font-size: 18px; font-weight: 700; color: var(--mc-text-primary); margin: 0 0 6px; letter-spacing: -0.02em; }
.config-desc { font-size: 13px; color: var(--mc-text-tertiary); margin: 0; line-height: 1.6; }
/* Embedding binding */
.embedding-config { display: flex; flex-direction: column; gap: 8px; padding: 12px 14px; background: var(--mc-bg-sunken); border-radius: 10px; border: 1px solid var(--mc-border-light); }
.embedding-label { font-size: 13px; font-weight: 600; color: var(--mc-text-primary); display: flex; align-items: baseline; gap: 8px; }
.embedding-hint { font-size: 11px; font-weight: 400; color: var(--mc-text-tertiary); }
.embedding-row { display: flex; gap: 8px; align-items: center; }
.embedding-select { flex: 1; padding: 7px 12px; border: 1px solid var(--mc-border); border-radius: 8px; background: var(--mc-bg-elevated); color: var(--mc-text-primary); font-size: 13px; outline: none; }
.embedding-select:focus { border-color: var(--mc-primary); }
.embedding-select:disabled { opacity: 0.6; cursor: not-allowed; }
/* Editor */
.config-editor { width: 100%; flex: 1; min-height: 0; padding: 16px; border: 1px solid var(--mc-border); border-radius: 14px; font-family: 'JetBrains Mono', 'Fira Code', Consolas, monospace; font-size: 13px; line-height: 1.7; resize: none; overflow: auto; background: var(--mc-bg-elevated); color: var(--mc-text-primary); outline: none; }
.config-editor:focus { border-color: var(--mc-primary); box-shadow: 0 0 0 2px rgba(217,119,87,0.1); }