Skip to content

embedding 服务 ​

1. 定位 ​

把一段文本编码成稠密向量(text → number[])的提供者,是语义检索 / 向量记忆的底层能力。

  • 服务注册名:'embedding'(消费方经 embedding.use 调用)。
  • 契约包:@aalis/api-embedding。
  • 该契约有运行时服务(非纯类型契约),但 -api 包本身只导出接口类型与服务描述符 embedding(defineService),不含实现;实现来自 plugin-embedding-* 提供者插件。

2. 契约 ​

@aalis/api-embedding 的全部导出(packages/api-embedding/src/index.ts):

ts
// packages/api-embedding/src/index.ts
export interface EmbeddingRequestOptions {
  /** 调用方取消或超时后停止请求,不再重试。 */
  signal?: AbortSignal;
}

export interface EmbeddingService {
  /**
   * 向量空间标识:modelId 相同的两次 embed 结果可直接比较;换模型必须换值。
   * 消费方把它并入向量缓存的失效键,换模型后即可识别并重算旧向量(含同维度换模型)。
   * 不声明时消费方无法区分模型。
   */
  readonly modelId?: string;
  /** 将文本转为向量;支持取消的 provider 应将 signal 传至底层请求。 */
  embed(text: string, options?: EmbeddingRequestOptions): Promise<number[]>;
}

服务描述符 export const embedding = defineService<EmbeddingService>('embedding') 携带类型,消费方把它写进 uses 后 embedding.use 即为 EmbeddingService 形状的强类型活句柄。

要点:

  • embed(text) 是唯一必须实现的方法,返回单条文本的向量。契约未约定向量维度——维度由具体模型决定,跨提供者 / 跨模型不可混用(见 §6)。
  • modelId 可选,是向量空间标识:同值即向量可比,换模型必须换值。它让消费方能在同维度换模型时识别旧向量;不声明则消费方无从区分。
  • 契约里没有列模型的方法。提供者要给自己配置里的模型字段出下拉,经贡献点 webui:options 交自己的可选项来源(字段的 dynamicOptions 写同一个名字),只列自己的模型,见 §4。
  • 契约没有批量接口(如 embedBatch)。消费者要批量时需自行并发调 embed()(参考实现的连接细节见 §3)。

@aalis/api-embedding/package.json 的 keywords 含 aalis-api——是契约包,不是可加载插件。

3. 谁提供 / 谁消费 ​

参考实现(provider) ​

两个一等公民提供者,互为「同名服务」竞争者:

包端点说明
@aalis/plugin-embedding-openaiPOST {baseUrl}/embeddings(baseUrl 为完整前缀,含版本段)OpenAI 兼容接口,默认 text-embedding-3-small
@aalis/plugin-embedding-ollamaPOST {baseUrl}/api/embed(新)或 /api/embeddings(旧)本地 Ollama,默认 nomic-embed-text

OpenAI 实现(packages/plugin-embedding-openai/src/index.ts):

  • embed:取响应 data.data[0].embedding;失败抛 Error,不静默。
  • 模型下拉:可选项来源 embedding-openai,拉 {baseUrl}/models,失败返回空。
  • modelId:openai:<model>。
  • 注册:provide.use(embedding, service, { label: \OpenAI / ${model}` })`。

Ollama 实现(packages/plugin-embedding-ollama/src/index.ts):

  • 自动探测新旧 API:首次 embed 先试 /api/embed,失败则缓存为旧版走 /api/embeddings。
  • 自带超时(AbortController)+ 5xx 重试(postJson);embed 失败时同样抛 Error。
  • 模型下拉:可选项来源 embedding-ollama,拉 /api/tags。
  • modelId:ollama:<model>。
  • 注册:provide.use(embedding, service, { label: \Ollama / ${model}` })`。

两者 apply 都做了启动连通性自检:调一次 embed('ping'),失败只 warn 不阻塞注册——即服务可能注册成功但实际不可用,消费者不应假设 embed 一定成功。

典型消费点 ​

参考消费者 @aalis/plugin-memory-vector(向量记忆,硬依赖):

  • 声明依赖:uses: { vectorstore, embedding, memory: optional(memory) }(packages/plugin-memory-vector/src/index.ts),并同步写在 package.json 的 aalis.service.required。
  • 取用与调用点:索引与查询各取一次选中那一条的 use 固定下来,用它 embed,得到的向量交给 vectorstore 写入或检索。
  • 模型标识:写入的向量 metadata 带同一句柄读到的 modelId;检索只保留与查询向量同模型的候选,升级前不带 modelId 的存量向量按记忆元数据里的存量标记认定模型(见 plugin-memory-vector)。

可选消费者 @aalis/plugin-user-relation(实体 / 事件去重的语义召回,软依赖):

  • 取用:const embedding = this.embedding?.present ? this.embedding.use : undefined(构造时传入 caps.embedding,packages/plugin-user-relation/src/service.ts)。
  • 缺失即降级:if (!embedding) return null;(ensureEntityEmbedding),不报错、走非语义路径。
  • 向量失效键:节点的 embeddingHash 由文本指纹并入 modelId 得出,换模型后,下一次用到向量的整理(配置了 consolidationModel 且开启 auto-link 的 consolidate / maintain)或 /relation event-duplicates 会重算旧向量。

WebUI(@aalis/plugin-webui-server) 按字段的 dynamicOptions 找贡献点 webui:options 里同名的来源取可选项:两个提供者各交各的来源,同时装了两个时各自的「Embedding 模型」下拉只列自己的模型。来源名按插件定:同一个提供者插件开了几个实例时来源同名,WebUI 取全局键最前的那家,各实例的下拉都列那一家端点的模型。

4. 写一个 provider ​

必须 vs 可选 ​

  • 必须:实现 embed(text): Promise<number[]>;在 apply 里 provide.use(embedding, impl)。
  • 可选:给模型字段出下拉,经贡献点 webui:options 交自己的可选项来源(不交也能正常 embed)。
  • 建议:声明 modelId(如 foo:<model>),让缓存向量的消费方能识别换模型。
  • 强烈建议:启动连通性自检失败时 warn 而非 throw(与两个参考实现一致),让插件能装上、错误暴露在第一次真实调用。

provides / uses 双源必须同步 ​

DI 靠包清单 + 代码导出双源声明(见 manifest-metadata)。provider 两处都要写 provides: ['embedding']:

package.json:

jsonc
{
  "keywords": ["aalis", "aalis-plugin"],
  "aalis": { "service": { "provides": ["embedding"] } }
}

src/index.ts 入口是 export default definePlugin({ name, subsystem: 'embedding', reusable: true, provides: [embedding], uses: { config, logger, provide }, apply })(packages/plugin-embedding-openai/src/index.ts)。同子系统归组靠定义上的 subsystem,多实例靠 reusable;不要再写具名 export const subsystem。

最小可编译骨架 ​

ts
import { embedding } from '@aalis/api-embedding';
import type { EmbeddingService } from '@aalis/api-embedding';
import { config, definePlugin, logger, provide } from '@aalis/core';

class FooEmbedding implements EmbeddingService {
  endpoint = 'http://localhost:9000';
  model = 'foo-embed-v1';

  async embed(text: string): Promise<number[]> {
    const res = await fetch(`${this.endpoint}/embed`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ model: this.model, input: text }),
    });
    if (!res.ok) throw new Error(`foo embedding 失败: ${res.status} ${res.statusText}`);
    const data = (await res.json()) as { vector: number[] };
    return data.vector;
  }
}

export default definePlugin({
  name: '@yourscope/plugin-embedding-foo',
  subsystem: 'embedding',
  reusable: true,
  provides: [embedding],
  uses: { provide, logger, config },
  async apply({ provide, logger, config }) {
    const endpoint = (config.use.endpoint as string) ?? 'http://localhost:9000';
    const model = (config.use.model as string) ?? 'foo-embed-v1';
    const service = new FooEmbedding();
    service.endpoint = endpoint;
    service.model = model;
    try {
      await service.embed('ping');
      logger.use.info(`Foo Embedding 已就绪: ${model} @ ${endpoint}`);
    } catch (err) {
      logger.use.warn(`Foo Embedding 连通性检查失败,服务仍将注册: ${String(err)}`);
    }
    provide.use(embedding, service, { label: `Foo / ${model}` });
  },
});

priority / entryId / label ​

provide.use(descriptor, instance, { priority?, label?, entryId? })(packages/core/src/composition/core-services.ts):

  • priority:默认 0。同名服务竞争时,消费方选中哪一家按 本插件偏好 > 全局偏好 > priority > 注册顺序;要默认压过普通后端取更高值(如 50)。普通第三方提供者保持 0 即可,让用户在 WebUI 里用 preference 选。
  • entryId:默认本次激活 id,是条目的逻辑身份(偏好与展示认它);core 不检查格式,卸载清理与 provides 校验都按激活身份归属。一个插件想登记多个 embedding 实例(如多端点)时用 ${lifecycle.use.id}/${sub}。
  • label:人类可读名,WebUI 选择器和 list() 返回的条目里展示(两个参考实现都用 \OpenAI / ${model}`` 这种形态)。

详见 service-model 与 core/service。

5. 标准消费方式 ​

业务句柄是活的 ​

embedding.use 是活的业务句柄,选择、缺席与固定同一家的规则见 选择层与业务层。embed 与 modelId 要出自同一家时,取 list() 里选中那一条的 use 固定下来:

ts
const provider = embedding.list().find(p => p.selected)?.use;
if (!provider) throw new Error('没有可用的 embedding 提供者');
// 先读 modelId 再 embed:那一家在 embed 途中撤回后,钉住的句柄就不能再访问
const { modelId } = provider;
const vec = await provider.embed(text);
return { vec, modelId }; // 两者出自同一家,一起记下

硬依赖 vs 可选依赖 ​

  • 硬依赖:声明 uses required = ['embedding'](双源同步到 package.json,如 memory-vector)。缺席时的抛错由调用点的错误边界兜住(见下文)。
  • 可选依赖:声明 uses optional,先看 present 降级:
ts
if (!embedding.present) {
  // 降级:跳过语义召回,走纯结构化路径(user-relation 的做法)
  return null;
}
const vec = await embedding.use.embed(text);

错误边界 ​

embed() 会抛(网络错误 / 非 2xx)。消费者批量索引时要自己兜异常,不要让单条失败中断整批——memory-vector 是逐条入队 + 后台并发索引(indexing.concurrency,index.ts、默认 10),并提示「过高可能压垮本地 embedding 服务」。

6. 能力 / 风险 → 影响 ​

  • 不是 authority 风险面:embedding 不直接挂 authority 风险等级或确认(embed 只读、无副作用)。但触发 embed 的上层动作可能要走授权(如 user-relation 的去重写回)——那是上层契约的事,见 authority / security-model。
  • SSRF:参考实现直接用裸 fetch 打配置里的 baseUrl。第三方 provider 若让用户配置任意 URL 且可被不可信输入间接驱动,应改用 safeFetch(@aalis/util-network-guard)做 SSRF 收口。本地 Ollama(localhost:11434)/ 受信 OpenAI 端点属常规场景,风险低。
  • 维度一致性(最重要的隐性契约):向量库里所有向量必须同维度。切换 embedding 提供者或模型会改变维度,与既有 vectorstore 数据不兼容——消费者(如 memory-vector)需要重建索引,provider 作者切模型时要让用户知道这点。契约本身不暴露维度,无法在 DI 层校验。
  • 跨会话隔离:embedding 服务无状态、不持有会话数据,本身不涉隔离;隔离责任在持有向量的 vectorstore / memory 消费者。

7. 注意事项与边界情形 ​

  • 「注册成功 ≠ 可用」:连通性自检失败只 warn(§3),服务照样注册。消费者第一次 embed 才会真正暴露端点不可达 / key 错误,要做好首调错误处理。
  • 无批量 API:契约只有单条 embed。大批量索引靠消费者并发,注意限流(memory-vector 的 indexing.concurrency / maxQueueSize)以免压垮本地服务。
  • Ollama 的模型下拉语义弱:可选项来源 embedding-ollama 把 /api/tags 的所有模型原样返回,刻意不按名字筛(选错模型的代价小于选不到,模型不可用由 doctor 检查项报出,见 plugin-embedding-ollama/src/index.ts 的注释),下拉里会混入非 embedding 模型,用户可能选错。
  • OpenAI 端点路径固定:openai provider 在 baseUrl(完整前缀)后拼 /embeddings,仅适配 OpenAI 兼容协议;非兼容服务要单独写 provider。
  • 维度漂移(承 §6):换模型后老向量与新查询向量不可比,余弦相似度结果无意义;这是运维层最常见的问题,文档 / 配置项应显式提醒重建。

8. 交叉链接 ​

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布