Browse Source

豆包多模态向量模型能正常工作

master
wanghanlin 17 hours ago
parent
commit
ed3f3b3070
  1. 28
      CLAUDE.md
  2. 38
      src/main/java/com/wok/supportbot/config/EmbeddingModelFactory.java
  3. 189
      src/main/java/com/wok/supportbot/config/VolcengineMultimodalEmbeddingModel.java
  4. 6
      src/main/resources/static/components/ModelConfigManager.js

28
CLAUDE.md

@ -86,23 +86,25 @@ AI 智能客服系统,基于 Spring AI Alibaba + 通义千问 + PGVector,支
- `spring-ai-alibaba-starter` (M6.1) 已移除,不再使用
### EmbeddingModel 架构
- **EmbeddingConfigFixer**:`ApplicationListener<ApplicationReadyEvent>`,启动时检查 EMBEDDING 配置合理性、**校验 EmbeddingModel 实际维度与 yml 配置是否一致**,不一致时 WARN 告警并给出修复步骤。**不再强制修正非 DashScope 配置**,尊重用户在 DB 中配置的提供商和模型
- **EmbeddingConfigFixer**:`ApplicationListener<ApplicationReadyEvent>`,启动时检查 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`
## 前端架构

38
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 指数退避
*/

189
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
* <p>
* 直接调用豆包 /embeddings/multimodal 端点适配其特有的请求/响应格式
* <p>
* OpenAI Embedding API 的核心差异
* - 端点: /embeddings/multimodal /embeddings
* - 请求 input: [{type, text}] 对象数组非纯字符串数组
* - 响应 data: 单个对象 {"embedding": [...]}非数组 [{...}]
* - 语义: 整个 input 数组被视为一个文档只返回一个 embedding 向量
* <p>
* 因此必须逐条调用每条文本单独发一次请求不能批量
* 当前阶段仅处理纯文本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<String> texts = request.getInstructions();
// 豆包 /embeddings/multimodal 把整个 input 数组视为一个文档只返回一个 embedding
// 因此必须逐条调用每条文本单独发一次请求
List<Embedding> 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<String, Object> inputItem = new HashMap<>();
inputItem.put("type", "text");
inputItem.put("text", text);
Map<String, Object> 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<Embedding> 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<String, Object> body = objectMapper.readValue(responseBody,
new TypeReference<Map<String, Object>>() {});
// 解析 data.embedding data 是单个对象 {"embedding": [...]}
@SuppressWarnings("unchecked")
Map<String, Object> data = (Map<String, Object>) body.get("data");
@SuppressWarnings("unchecked")
List<Number> rawEmbedding = (List<Number>) 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<String, Object> usage = (Map<String, Object>) 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<String> texts) {
return call(new EmbeddingRequest(texts, null));
}
@Override
public int dimensions() {
return dimensions;
}
/**
* 内部解析结果
*/
private record EmbeddingResult(float[] embedding, int promptTokens, int totalTokens) {
}
}

6
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',

Loading…
Cancel
Save