From ed3f3b307047f1dc2eac4156afd1ef9bb3d1e1bf Mon Sep 17 00:00:00 2001 From: wanghanlin <1533525126@qq.com> Date: Fri, 26 Jun 2026 14:46:52 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B1=86=E5=8C=85=E5=A4=9A=E6=A8=A1=E6=80=81?= =?UTF-8?q?=E5=90=91=E9=87=8F=E6=A8=A1=E5=9E=8B=E8=83=BD=E6=AD=A3=E5=B8=B8?= =?UTF-8?q?=E5=B7=A5=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CLAUDE.md | 28 +-- .../config/EmbeddingModelFactory.java | 38 +++- .../VolcengineMultimodalEmbeddingModel.java | 189 ++++++++++++++++++ .../static/components/ModelConfigManager.js | 6 +- 4 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 src/main/java/com/wok/supportbot/config/VolcengineMultimodalEmbeddingModel.java diff --git a/CLAUDE.md b/CLAUDE.md index be45978..315cdf7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -86,23 +86,25 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支 - `spring-ai-alibaba-starter` (M6.1) 已移除,不再使用 ### EmbeddingModel 架构 -- **EmbeddingConfigFixer**:`ApplicationListener`,启动时检查 EMBEDDING 配置合理性、**校验 EmbeddingModel 实际维度与 yml 配置是否一致**,不一致时 WARN 告警并给出修复步骤。**不再强制修正非 DashScope 配置**,尊重用户在 DB 中配置的提供商和模型 +- **EmbeddingConfigFixer**:`ApplicationListener`,启动时检查 EMBEDDING 配置合理性、**校验 EmbeddingModel 实际维度与配置维度是否一致**,不一致时 WARN 告警并给出修复步骤。**不再强制修正非 DashScope 配置**,尊重用户在 DB 中配置的提供商和模型 - **EmbeddingModelFactory**:按 DB 活跃配置动态创建/缓存 EmbeddingModel,**支持多种提供商**: - DashScope(通义千问):`DashScopeEmbeddingModel` - - OpenAI 兼容提供商(DeepSeek / 豆包 / Kimi / 智谱 / OpenAI):通过 `OpenAiEmbeddingModel` + 自定义 baseUrl + `embeddingsPath` 创建 + - OpenAI 兼容提供商(DeepSeek / Kimi / 智谱 / OpenAI):通过 `OpenAiEmbeddingModel` + 自定义 baseUrl + `embeddingsPath` 创建 + - **豆包文本模型**(volcengine + 非 vision):通过 `OpenAiEmbeddingModel`,embeddingsPath=`/embeddings` + - **豆包多模态模型**(volcengine + `*vision*`):通过 `VolcengineMultimodalEmbeddingModel`,手写 `RestClient` 直调 `/embeddings/multimodal`,适配 `{type, text}` 格式 - **各厂商 embeddingsPath 映射**: - | 提供商 | embeddingsPath | 说明 | - |--------|---------------|------| - | dashscope | — | 不走 OpenAI 兼容,使用 DashScopeEmbeddingModel | - | volcengine | `/embeddings` | baseUrl 已含 `/api/v3`,不能重复加 `/v1` | - | moonshot | `/embeddings` | baseUrl 已含 `/v1` | - | zhipu | `/embeddings` | baseUrl 已含 `/api/paas/v4` | - | deepseek | `/v1/embeddings` | 标准 OpenAI 路径 | - | openai | `/v1/embeddings` | 标准 OpenAI 路径 | - - **豆包多模态模型**(`doubao-embedding-vision*`)使用 `/embeddings/multimodal` 端点,请求格式与 OpenAI 不兼容,Factory 会检测并拒绝创建,提示改用纯文本模型 - - 注意:各提供商的 embedding 端点兼容性由用户自行验证,向量维度需与 PgVectorStore 的 `dimensions(1536)` 一致 + | 提供商 | embeddingsPath | 实现类 | + |--------|---------------|--------| + | dashscope | — | DashScopeEmbeddingModel | + | volcengine (vision) | `/embeddings/multimodal` | VolcengineMultimodalEmbeddingModel | + | volcengine (text) | `/embeddings` | OpenAiEmbeddingModel | + | moonshot | `/embeddings` | OpenAiEmbeddingModel | + | zhipu | `/embeddings` | OpenAiEmbeddingModel | + | deepseek | `/v1/embeddings` | OpenAiEmbeddingModel | + | openai | `/v1/embeddings` | OpenAiEmbeddingModel | + - 注意:各提供商的 embedding 端点兼容性由用户自行验证,向量维度需与 PgVectorStore 的 `dimensions` 一致 - **DynamicEmbeddingModel**:代理类实现 `EmbeddingModel` 接口,每次调用委托给 Factory,使 VectorStore 无需重建即可热切换 -- **前端**:`ModelConfigManager.js` 对 EMBEDDING 类型不再限制 provider,可自由选择任意提供商 +- **前端**:`ModelConfigManager.js` 对 EMBEDDING 类型不再限制 provider,可自由选择任意提供商;EMBEDDING 类型弹窗增加「向量维度」输入框(写入 `extraConfig.dimensions`) ## 前端架构 diff --git a/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java b/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java index b897aaf..a8596ef 100644 --- a/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java +++ b/src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java @@ -92,12 +92,13 @@ public class EmbeddingModelFactory { /** * 创建 EmbeddingModel 实例 * - dashscope:手动构造 DashScopeApi + DashScopeEmbeddingModel - * - volcengine 豆包多模态模型:抛出异常提示选用纯文本模型 + * - volcengine 豆包多模态模型:手写 VolcengineMultimodalEmbeddingModel 直调 /embeddings/multimodal * - 其他提供商:通过 OpenAI 兼容 API 创建 */ private EmbeddingModel createEmbeddingModel(AiModelConfig config) { RetryTemplate retryTemplate = createRetryTemplate(); + // DashScope(通义千问) if ("dashscope".equalsIgnoreCase(config.getProvider())) { log.info("创建 DashScope EmbeddingModel: model={}", config.getModelName()); DashScopeApi api = DashScopeApi.builder() @@ -109,16 +110,16 @@ public class EmbeddingModelFactory { return new DashScopeEmbeddingModel(api, MetadataMode.EMBED, options, retryTemplate); } - // 豆包多模态模型检测:doubao-embedding-vision* 使用 /embeddings/multimodal 端点, - // 请求/响应格式与 OpenAI 不兼容,无法通过 OpenAiEmbeddingModel 调用。 - // 提示用户改用纯文本模型。 + // 豆包多模态模型(doubao-embedding-vision*) if ("volcengine".equalsIgnoreCase(config.getProvider()) && config.getModelName() != null && config.getModelName().contains("vision")) { - throw new IllegalArgumentException( - "豆包多模态向量模型 (" + config.getModelName() + ") 不支持当前使用的 OpenAI 兼容 API 格式。" - + "请将 EMBEDDING 模型名称改为纯文本模型,如 doubao-embedding-text-240515(2048维)或 doubao-embedding-large(4096维)。" - + "注意:切换向量维度后需调整 PgVectorStoreConfig.dimensions 并重建 vector_store 表。"); + String baseUrl = resolveBaseUrl(config); + int dimensions = resolveDimensions(config); + log.info("创建豆包多模态 EmbeddingModel: model={}, baseUrl={}, dimensions={}", + config.getModelName(), baseUrl, dimensions); + return new VolcengineMultimodalEmbeddingModel( + config.getApiKey(), baseUrl, config.getModelName(), dimensions, retryTemplate); } // OpenAI 兼容提供商 @@ -128,8 +129,9 @@ public class EmbeddingModelFactory { config.getProvider(), baseUrl, embeddingsPath, config.getModelName()); // 维度一致性提示 - log.info("⚠ 请确认模型 [{}] 的向量维度与 PgVectorStoreConfig.dimensions(1536) 一致,否则需调整并重建向量表", - config.getModelName()); + int dimensions = resolveDimensions(config); + log.info("⚠ 请确认模型 [{}] 的向量维度与配置的 {} 一致,否则需调整并重建向量表", + config.getModelName(), dimensions); OpenAiApi api = OpenAiApi.builder() .apiKey(config.getApiKey()) @@ -167,6 +169,22 @@ public class EmbeddingModelFactory { return known != null ? known : "/v1/embeddings"; } + /** + * 从 extraConfig 中解析向量维度,无配置时回退到默认值 1536 + */ + private int resolveDimensions(AiModelConfig config) { + if (config.getExtraConfig() != null) { + Object dimObj = config.getExtraConfig().get("dimensions"); + if (dimObj instanceof Number) { + int dim = ((Number) dimObj).intValue(); + if (dim > 0) { + return dim; + } + } + } + return 1536; + } + /** * 创建简单的重试模板(最多 3 次,指数退避) */ diff --git a/src/main/java/com/wok/supportbot/config/VolcengineMultimodalEmbeddingModel.java b/src/main/java/com/wok/supportbot/config/VolcengineMultimodalEmbeddingModel.java new file mode 100644 index 0000000..9294823 --- /dev/null +++ b/src/main/java/com/wok/supportbot/config/VolcengineMultimodalEmbeddingModel.java @@ -0,0 +1,189 @@ +package com.wok.supportbot.config; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.ai.document.Document; +import org.springframework.ai.embedding.Embedding; +import org.springframework.ai.embedding.EmbeddingModel; +import org.springframework.ai.embedding.EmbeddingRequest; +import org.springframework.ai.embedding.EmbeddingResponse; +import org.springframework.ai.embedding.EmbeddingResponseMetadata; +import org.springframework.ai.chat.metadata.DefaultUsage; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.retry.support.RetryTemplate; +import org.springframework.web.client.RestClient; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 豆包多模态向量化模型(Volcengine Multimodal Embedding Model) + *

+ * 直接调用豆包 /embeddings/multimodal 端点,适配其特有的请求/响应格式。 + *

+ * 与 OpenAI Embedding API 的核心差异: + * - 端点: /embeddings/multimodal(非 /embeddings) + * - 请求 input: [{type, text}] 对象数组(非纯字符串数组) + * - 响应 data: 单个对象 {"embedding": [...]}(非数组 [{...}]) + * - 语义: 整个 input 数组被视为一个文档,只返回一个 embedding 向量 + *

+ * 因此必须逐条调用:每条文本单独发一次请求,不能批量。 + * 当前阶段仅处理纯文本(type=text),未来可扩展图片/视频。 + */ +@Slf4j +public class VolcengineMultimodalEmbeddingModel implements EmbeddingModel { + + private static final String EMBEDDINGS_PATH = "/embeddings/multimodal"; + + private final String apiKey; + private final String baseUrl; + private final String modelName; + private final int dimensions; + private final RetryTemplate retryTemplate; + private final RestClient restClient; + private final ObjectMapper objectMapper = new ObjectMapper(); + + public VolcengineMultimodalEmbeddingModel(String apiKey, String baseUrl, String modelName, + int dimensions, RetryTemplate retryTemplate) { + this.apiKey = apiKey; + this.baseUrl = baseUrl; + this.modelName = modelName; + this.dimensions = dimensions; + this.retryTemplate = retryTemplate; + this.restClient = RestClient.builder() + .baseUrl(baseUrl) + .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + @Override + public float[] embed(Document document) { + if (document.isText()) { + return embedSingleText(document.getText()); + } + // 未来扩展:检查 document.getMedia() 处理图片/视频 + throw new IllegalArgumentException( + "豆包多模态 Embedding 暂不支持非文本内容,Document 需为纯文本"); + } + + @Override + public EmbeddingResponse call(EmbeddingRequest request) { + List texts = request.getInstructions(); + + // 豆包 /embeddings/multimodal 把整个 input 数组视为一个文档,只返回一个 embedding。 + // 因此必须逐条调用,每条文本单独发一次请求。 + List embeddings = new ArrayList<>(); + int totalPromptTokens = 0; + int totalTotalTokens = 0; + + for (int i = 0; i < texts.size(); i++) { + final int index = i; + final String text = texts.get(i); + + // 构造单条文本的多模态 input + Map inputItem = new HashMap<>(); + inputItem.put("type", "text"); + inputItem.put("text", text); + + Map requestBody = new HashMap<>(); + requestBody.put("model", modelName); + requestBody.put("input", List.of(inputItem)); + // 豆包 vision 模型默认输出 2048 维,超过 PGVector HNSW 索引上限 2000。 + // 传入 dimensions 参数让 API 端降维(支持 Matryoshka 降维到 1024 等) + requestBody.put("dimensions", dimensions); + + log.debug("豆包多模态 Embedding 请求: model={}, index={}/{}", modelName, index + 1, texts.size()); + + EmbeddingResult result = retryTemplate.execute(ctx -> { + String responseBody = restClient.post() + .uri(EMBEDDINGS_PATH) + .body(requestBody) + .retrieve() + .body(String.class); + + return parseResponse(responseBody); + }); + + embeddings.add(new Embedding(result.embedding, index)); + + // 累计 usage + if (result.promptTokens > 0) { + totalPromptTokens += result.promptTokens; + totalTotalTokens += result.totalTokens; + } + } + + EmbeddingResponseMetadata metadata = new EmbeddingResponseMetadata(modelName, + new DefaultUsage(totalPromptTokens, 0, totalTotalTokens, null)); + + return new EmbeddingResponse(embeddings, metadata); + } + + /** + * 单条文本向量化(供 embed(Document) 使用) + */ + private float[] embedSingleText(String text) { + EmbeddingResponse response = call(new EmbeddingRequest(List.of(text), null)); + List results = response.getResults(); + return results.isEmpty() ? new float[0] : results.get(0).getOutput(); + } + + /** + * 解析豆包 /embeddings/multimodal 响应。 + * 响应格式:{"created":..., "data":{"embedding":[0.1,0.2,...]}, "id":"...", "model":"...", "object":"...", "usage":{...}} + * 注意:data 是单个对象(非数组),embedding 值可能是 Integer 或 Double。 + */ + private EmbeddingResult parseResponse(String responseBody) { + try { + Map body = objectMapper.readValue(responseBody, + new TypeReference>() {}); + + // 解析 data.embedding — data 是单个对象 {"embedding": [...]} + @SuppressWarnings("unchecked") + Map data = (Map) body.get("data"); + @SuppressWarnings("unchecked") + List rawEmbedding = (List) data.get("embedding"); + + float[] vec = new float[rawEmbedding.size()]; + for (int i = 0; i < rawEmbedding.size(); i++) { + vec[i] = rawEmbedding.get(i).floatValue(); + } + + // 解析 usage + int promptTokens = 0; + int totalTokens = 0; + @SuppressWarnings("unchecked") + Map usage = (Map) body.get("usage"); + if (usage != null) { + promptTokens = ((Number) usage.getOrDefault("prompt_tokens", 0)).intValue(); + totalTokens = ((Number) usage.getOrDefault("total_tokens", 0)).intValue(); + } + + return new EmbeddingResult(vec, promptTokens, totalTokens); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { + throw new RuntimeException("解析豆包多模态 Embedding 响应失败: " + e.getMessage() + + ", 响应体前500字符: " + responseBody.substring(0, Math.min(500, responseBody.length())), e); + } + } + + @Override + public EmbeddingResponse embedForResponse(List texts) { + return call(new EmbeddingRequest(texts, null)); + } + + @Override + public int dimensions() { + return dimensions; + } + + /** + * 内部解析结果 + */ + private record EmbeddingResult(float[] embedding, int promptTokens, int totalTokens) { + } +} diff --git a/src/main/resources/static/components/ModelConfigManager.js b/src/main/resources/static/components/ModelConfigManager.js index 437950c..88a77b2 100644 --- a/src/main/resources/static/components/ModelConfigManager.js +++ b/src/main/resources/static/components/ModelConfigManager.js @@ -42,9 +42,9 @@ const PROVIDER_DEFAULTS = { }, volcengine: { baseUrl: 'https://ark.cn-beijing.volces.com/api/v3', - models: [], - defaultModel: '', - tip: '豆包模型的模型名称需填入 Endpoint ID(如 ep-xxxxx),请在火山引擎控制台获取' + models: ['doubao-embedding-text-240515', 'doubao-embedding-large', 'doubao-embedding-vision-251215'], + defaultModel: 'doubao-embedding-text-240515', + tip: '豆包文本向量化推荐 doubao-embedding-text-240515(2048维)或 doubao-embedding-large(4096维);多模态推荐 doubao-embedding-vision-251215。注意维度需与前端配置的向量维度一致' }, moonshot: { baseUrl: 'https://api.moonshot.cn/v1',