Skip to main content

Embedding向量化客户端

前六篇把 Chat 子系统从路由选择到流式路由全部讲完了。这一篇离开 Chat,进入 Embedding 子系统。

在 RAG 系统中,Embedding 的核心作用是把文本转换为向量。两个关键场景:文档入库时,对每个 Chunk 调用 Embedding API 生成向量,存入 Pgvector 或 Milvus 这类向量数据库;用户提问时,对 Query 调用 Embedding API 生成向量,用于在向量数据库中做相似度检索。没有 Embedding,RAG 的检索阶段就无从谈起。

从架构上看,Embedding 子系统和 Chat 子系统遵循同样的三层设计——业务层接口、路由服务、供应商客户端——复用同一套路由和熔断机制。但实现上简单不少:Embedding 只有同步调用,没有流式,不需要 StreamCallback、首包探测那套复杂机制。路由层直接用第三篇讲的 executeWithFallback 就够了。

另一个简化来自协议层面。Ollama 现在通过 OpenAI 兼容模式暴露 /v1/embeddings 端点,和硅基流动走的是同一套 OpenAI 风格协议。于是 Embedding 子系统可以像 Chat 一样抽出一个模板方法基类 AbstractOpenAIStyleEmbeddingClient,把请求构建、HTTP 调用、响应解析全部封装起来。两个供应商实现各自只剩十几二十行代码,通过钩子方法微调差异点。这篇就围绕这个基类展开。

Embedding 子系统总览

1. 和 Chat 子系统的对比

先用一张表格建立全局对比:

维度Chat 子系统Embedding 子系统
业务层接口LLMServiceEmbeddingService
供应商接口ChatClientEmbeddingClient
路由服务RoutingLLMServiceRoutingEmbeddingService
调用模式同步 + 流式仅同步
模板方法基类AbstractOpenAIStyleChatClientAbstractOpenAIStyleEmbeddingClient
供应商实现三个(Ollama / 百炼 / 硅基流动)两个(Ollama / 硅基流动)
同步路由executeWithFallbackexecuteWithFallback
流式路由probe-and-commit 首包探测无(没有流式调用)
批量调用embedBatch(支持多条文本)
特有配置supports-thinkingdeep-thinking-modeldimension(向量维度)

和 Chat 一样,Embedding 子系统也用模板方法基类封装 OpenAI 兼容协议的通用逻辑。两个供应商 Ollama 和硅基流动都实现 /v1/embeddings 协议:请求体字段 model / input / dimensions 结构相同,响应体都是 {"data": [{"embedding": [...]}, ...]} 的 OpenAI 风格。差异点集中在三个地方:Ollama 不需要 API Key、硅基流动有 32 条的批量上限、硅基流动要求显式 encoding_format: "float" 字段。这三处用钩子方法覆写就好,没必要让每个子类重写一遍完整的 HTTP 调用流程。

Chat 和 Embedding 的模板方法基类在结构上高度对称:都把请求构建、HTTP 调用、JSON 解析、错误处理封装在基类,子类只负责声明自己是谁、覆写几个钩子方法。读完这一篇,你会发现它和第四篇 AbstractOpenAIStyleChatClient 是同一套思路在不同能力上的复用。

2. 三层接口结构

三层结构和 Chat 完全一致:业务层调 EmbeddingServiceRoutingEmbeddingService 通过 ModelSelector + ModelRoutingExecutor 路由到具体的 EmbeddingClient 实现。业务层不知道背后调的是 Ollama 还是硅基流动。

在实际的代码中,应用了模板方法模式抽象了核心逻辑,优化后 Ollama 和硅基流动只有很薄一层代码了。

接口设计:EmbeddingService 与 EmbeddingClient

1. EmbeddingService 业务层接口

public interface EmbeddingService {

List<Float> embed(String text);

List<Float> embed(String text, String modelId);

List<List<Float>> embedBatch(List<String> texts);

List<List<Float>> embedBatch(List<String> texts, String modelId);

default int dimension() {
return 0;
}
}

五个方法:

  • embed(String text)——单条文本向量化。最常用的场景是 Query Embedding:用户提问“AirPods Pro 2 的保修期是多久?”,把这句话转换为浮点数向量,用于在向量库中做相似度检索
  • embed(String text, String modelId)——指定模型的单条向量化。使用场景:数据 ETL 清洗时用到的
  • embedBatch(List<String> texts)——批量文本向量化。文档入库时用——把 100 个 Chunk 一次性传入,比调 100 次 embed 效率高得多(减少 HTTP 往返次数)
  • embedBatch(List<String> texts, String modelId)——指定模型的批量向量化
  • dimension()——返回向量维度(默认 0),预留方法可暂时忽略

LLMService 的对比:LLMService 返回 String(文本),EmbeddingService 返回 List<Float>(浮点数向量)或 List<List<Float>>(多个向量)。LLMServicestreamChat 流式方法,EmbeddingService 没有——向量化是一次性计算,不存在逐步生成的概念。

2. EmbeddingClient 供应商接口

public interface EmbeddingClient {

String provider();

List<Float> embed(String text, ModelTarget target);

List<List<Float>> embedBatch(List<String> texts, ModelTarget target);
}

ChatClient 一样,多了一个 ModelTarget 参数——包含模型名、供应商 URL、API Key、向量维度等运行时信息。业务层的 EmbeddingService 不暴露 ModelTarget,供应商层的 EmbeddingClient 需要它来构建 HTTP 请求。

provider() 返回供应商标识,RoutingEmbeddingService 构造时把所有 EmbeddingClientprovider() 建立 Map<String, EmbeddingClient> 索引——和 RoutingLLMService 构造 Map<String, ChatClient> 一模一样。

AbstractOpenAIStyleEmbeddingClient 模板方法基类

基类是这篇的核心。它封装了 OpenAI 兼容 /v1/embeddings 协议的所有通用逻辑——请求体构建、HTTP 调用、响应解析、错误处理、批量分片——子类只需要覆写少量钩子方法来声明差异。

1. 三个钩子方法

public abstract class AbstractOpenAIStyleEmbeddingClient implements EmbeddingClient {

protected final OkHttpClient httpClient;

protected AbstractOpenAIStyleEmbeddingClient(OkHttpClient httpClient) {
this.httpClient = httpClient;
}

/**
* 是否要求提供商配置 API Key,默认 true
*/
protected boolean requiresApiKey() {
return true;
}

/**
* 子类可覆写此方法添加提供商特有的请求体字段
* 默认实现:添加 encoding_format=float
*/
protected void customizeRequestBody(JsonObject body, ModelTarget target) {
body.addProperty("encoding_format", "float");
}

/**
* 单次请求最大批量大小,0 表示不限制
*/
protected int maxBatchSize() {
return 0;
}
// ...
}

三个钩子直接对应两个供应商之间的全部差异:

  • requiresApiKey():是否要求配置 API Key 并在请求头里带 Authorization: Bearer <key>。默认 true(硅基流动、百炼这类云服务都要鉴权),Ollama 是本地部署覆写为 false
  • customizeRequestBody():往请求体里追加供应商特有的字段。默认实现添加 encoding_format: "float"(OpenAI 标准字段,硅基流动需要),Ollama 覆写为空实现——它不认识这个字段,传了无害但更干净就别传
  • maxBatchSize():单次请求的批量上限。默认 0 表示不限制,由基类一次性发出去;硅基流动覆写为 32,基类会自动按这个大小分片

这三个钩子覆盖了两个供应商的所有差异点。未来接入新的 OpenAI 兼容 Embedding 供应商(比如 OpenAI 官方、智谱、Voyage),大概率也只需要实现 provider() 加覆写这几个钩子,不需要动基类。

2. embed 与 embedBatch 的默认实现

@Override
public List<Float> embed(String text, ModelTarget target) {
List<List<Float>> result = doEmbed(List.of(text), target);
return result.get(0);
}

@Override
public List<List<Float>> embedBatch(List<String> texts, ModelTarget target) {
if (CollUtil.isEmpty(texts)) {
return Collections.emptyList();
}
int batch = maxBatchSize();
if (batch <= 0 || texts.size() <= batch) {
return doEmbed(texts, target);
}

List<List<Float>> results = new ArrayList<>(Collections.nCopies(texts.size(), null));
for (int i = 0, n = texts.size(); i < n; i += batch) {
int end = Math.min(i + batch, n);
List<String> slice = texts.subList(i, end);
List<List<Float>> part = doEmbed(slice, target);
for (int k = 0; k < part.size(); k++) {
results.set(i + k, part.get(k));
}
}
return results;
}

embed 是简单的适配——把单条文本包成 List.of(text) 走批量逻辑,取第一个结果返回。这样单条和批量共用一套代码。

embedBatch 的核心是分片。逻辑分两条路径:

  • maxBatchSize() <= 0 或者文本数量没超过上限——一次性调 doEmbed(texts, target) 完事。Ollama 走这条路径
  • 文本数量超过 maxBatchSize()——按上限分片,每片调一次 doEmbed,结果回填到预分配的 results 列表里。硅基流动 maxBatchSize = 32,传入 70 条会分成 32 + 32 + 6 三批

分片部分用 Collections.nCopies(size, null) 预分配占位,再用 results.set(i + k, part.get(k)) 精确写入每个位置。i 是当前批次的起始位置,k 是批内位置,两者相加就是原始输入中的全局索引——保证 results[j] 对应 texts[j] 的向量,顺序完全一致。

这种写法比每批 addAll 追加更安全:如果某一批中间抛异常,不会留下长度不完整的半成品列表。要么全成功、所有位置都被正确填充,要么抛异常向上传递——不会出现 70 条输入只有 32 条结果的尴尬中间态。

3. doEmbed 核心请求逻辑

解锁付费内容,👉 戳