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):
// 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-openai | POST {baseUrl}/embeddings(baseUrl 为完整前缀,含版本段) | OpenAI 兼容接口,默认 text-embedding-3-small |
@aalis/plugin-embedding-ollama | POST {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:
{
"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。
最小可编译骨架
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 固定下来:
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降级:
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. 交叉链接
- 概念:service-model(DI 按名解析 / 同名竞争)、lazy-service-access(业务句柄是活的)、manifest-metadata(aalis.service 与 definePlugin provides/uses 双源)、security-model(SSRF / safeFetch)。
- 核心:core/service、core/context、core/plugin、plugins/plugin-authority。
- 相关服务:
vectorstore(向量存储与检索,embedding 的直接下游)、memory(消息历史,memory-vector 的 optional 依赖)。