vectorstore 服务
1. 定位
向量数据库服务:存放 embedding 向量 + 元数据,提供近邻检索(topK)。它是语义记忆等子系统的「底层向量存储后端」,本身不做 embedding、不懂消息语义——只认 number[] 与 Record<string, unknown>。
- 服务注册名:
'vectorstore'(描述符vectorstore,消费方经vectorstore.use调用) - 契约包:
@aalis/api-vectorstore(packages/api-vectorstore) - 内置参考实现:
@aalis/plugin-vectorstore-flat(零依赖,JSON 文件)、@aalis/plugin-vectorstore-lancedb(LanceDB 高性能)
2. 契约
packages/api-vectorstore/src/index.ts:
ts
// VectorSearchResult(index.ts)
export interface VectorSearchResult {
/** 余弦相似度分数 */
score: number;
/** 存储时附带的元数据 */
metadata: Record<string, unknown>;
}
// VectorStoreService(index.ts)
export interface VectorStoreService {
add(vector: number[], metadata: Record<string, unknown>): Promise<void>;
search(queryVector: number[], topK: number): Promise<VectorSearchResult[]>;
size(): Promise<number>;
clear(): Promise<void>;
deleteByFilter?(filter: Record<string, unknown>): Promise<number>; // 可选
save(): Promise<void>;
}类型随描述符提供:defineService<VectorStoreService>('vectorstore')(index.ts),消费方把描述符写进 uses 即得类型,vectorstore.use 推断为 VectorStoreService。
方法语义(以契约注释 + 参考实现为准):
add(vector, metadata):追加一条向量。契约未规定唯一性/去重——同向量重复add会得到多条记录。元数据是后续search结果与deleteByFilter的唯一寻址依据。search(queryVector, topK):返回最多 topK 条,按score降序。score约定为余弦相似度(见 §6 跨后端可比性)。size():当前向量总数。clear():清空全部数据。deleteByFilter?(filter):删除「metadata 中 filter 的每个键都精确相等」的条目,返回删除条数。是可选方法——消费者调用前必须判存在性(!!store.deleteByFilter)。语义是「全部键匹配才删」,flat 与 lancedb 实现一致(flatindex.ts、lancedbindex.ts);空 filter 返回 0、不删任何东西(防误清全库,两实现均如此)。save():持久化。契约把持久化时机交给调用方:消费者在写入后应显式save()(参考实现里 LanceDB 是 no-op,flat 才真正落盘)。
3. 谁提供 / 谁消费
提供方(provides: [vectorstore]):
@aalis/plugin-vectorstore-flat:FlatVectorStore,packages/plugin-vectorstore-flat/src/index.ts,按默认 priority 注册。@aalis/plugin-vectorstore-lancedb:LanceDBVectorStore,packages/plugin-vectorstore-lancedb/src/index.ts,以{ priority: 10 }注册(比 flat 高 → 同时装两个、没有偏好时消费方选中 lancedb)。
消费方:
@aalis/plugin-memory-vector(向量记忆,提供semantic-memory):packages/plugin-memory-vector/src/index.ts- 声明依赖:
uses中vectorstore、embedding为必需。 - 取服务:单次调用(
search、size)经业务句柄vectorstore.use(活句柄,每次访问成员重新解析);一段操作(写入再落盘、清空再落盘、判方法再删除落盘)取list()里选中那一条的use固定在同一家,选不到时退回vectorstore.use。 - 典型调用:索引时
add+save;按会话删deleteByFilter;清库clear+save;检索search,并对结果做score >= minScore过滤。
- 声明依赖:
@aalis/plugin-commands仅探测可用性:services.use.get('vectorstore') !== undefined(packages/plugin-commands/src/index.ts)。
4. 写一个 provider
最小必须实现(契约非可选方法):add、search、size、clear、save。可选:deleteByFilter(不实现则消费者会跳过按会话删除)。
双源元数据必须同步声明(manifest 与导出都要写,见 docs/concepts/manifest-metadata.md):
package.json的aalis.service.provides:
jsonc
{
"keywords": ["aalis", "aalis-plugin"],
"aalis": { "service": { "provides": ["vectorstore"] } }
}- 入口导出
provides: [vectorstore];
两处必须一致(test/architecture/manifest-parity.test.ts 守着)。若 provider 还依赖别的服务,uses required 也要双源同步——内置的 flat 与 lancedb 都落盘经 storage,两者都另有 required: ['storage']。
可编译最小骨架:
ts
import { vectorstore } from '@aalis/api-vectorstore';
import type { VectorSearchResult, VectorStoreService } from '@aalis/api-vectorstore';
import { definePlugin, lifecycle, provide } from '@aalis/core';
class MyVectorStore implements VectorStoreService {
private rows: Array<{ vector: number[]; metadata: Record<string, unknown> }> = [];
async add(vector: number[], metadata: Record<string, unknown>): Promise<void> {
this.rows.push({ vector, metadata });
}
async search(queryVector: number[], topK: number): Promise<VectorSearchResult[]> {
const scored = this.rows.map(r => ({ score: cosine(queryVector, r.vector), metadata: r.metadata }));
scored.sort((a, b) => b.score - a.score);
return scored.slice(0, Math.min(topK, scored.length));
}
async size(): Promise<number> {
return this.rows.length;
}
async clear(): Promise<void> {
this.rows = [];
}
async deleteByFilter(filter: Record<string, unknown>): Promise<number> {
if (Object.keys(filter).length === 0) return 0;
const before = this.rows.length;
this.rows = this.rows.filter(r => Object.entries(filter).some(([k, v]) => r.metadata[k] !== v));
return before - this.rows.length;
}
async save(): Promise<void> {}
}
function cosine(a: number[], b: number[]): number {
if (a.length !== b.length) return Number.NEGATIVE_INFINITY;
let dot = 0,
na = 0,
nb = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
na += a[i] * a[i];
nb += b[i] * b[i];
}
const denom = Math.sqrt(na) * Math.sqrt(nb);
return denom === 0 ? 0 : dot / denom;
}
export default definePlugin({
name: '@aalis/plugin-vectorstore-mine',
provides: [vectorstore],
uses: { provide, lifecycle },
apply({ provide, lifecycle }) {
const store = new MyVectorStore();
provide.use(vectorstore, store);
lifecycle.use.onDispose(() => void store.save());
},
});注册细节:
provide.use(vectorstore, store, opts?)。opts.priority是普通数字(越大越优先,含义自行记载);lancedb 用10表「优先于 flat 默认」。同名竞争时消费方选中哪一家:本插件偏好 > 全局偏好 > priority > 注册顺序(DI 仅按名,无能力匹配,见 docs/concepts/service-model.md)。- 存储路径用 storage URI(如
data:/vectorstore),经toStorageUri()归一;需要本地真实路径(LanceDB 这类原生库)用caps.storage.use.resolveLocalPath(uri, 'write')。注意:vectorstore 自身不是单 owner 上下文里的「按会话隔离」资源,隔离靠消费者写进 metadata 的字段(见 §6)。
5. 消费者标准写法
ts
import { vectorstore } from '@aalis/api-vectorstore';
import { definePlugin, logger } from '@aalis/core';
export default definePlugin({
name: '@acme/plugin-example',
uses: { vectorstore, logger },
async apply({ vectorstore, logger }) {
if (!vectorstore.present) {
logger.use.warn('无 vectorstore,语义检索关闭');
return;
}
const s = vectorstore.use;
await s.add([0], { sessionId: 's', timestamp: Date.now() });
await s.save();
const topK = 5;
const candidates = await s.search([0], Math.min(topK * 4, await s.size()));
void candidates;
},
});错误边界:search 在空库返回 [](flat、lancedb 均如此)。上例对 required 依赖也先看 present,缺席规则见 选择层与业务层。
6. 跨后端可比性、风险与隔离
- score 必须是余弦相似度语义。flat 用归一化点积(真余弦);lancedb 显式用
distanceType('cosine')后取1 - _distance(index.ts),与 flat 量纲对齐。provider 若返回别的度量(如 1 − L2、内积未归一),会破坏消费者跨后端通用的阈值 ——memory-vector的minScore(0~1)与时间加权融合(search.timeWeight)都假设 score 是余弦相似度。换后端不应要求用户重调阈值。 - 跨会话/隔离不在本服务。Aalis 是单 owner,但向量库会混装所有会话的数据;隔离由消费者写入 metadata(如
{ sessionId })并用deleteByFilter/检索过滤实现。provider 不得擅自基于 metadata 做可见性裁剪——它不懂业务语义。 - 本服务不涉及 authority risk/visibility 标注、确认与审批、SSRF(safeFetch)——它不直接对外发请求,也不暴露危险动作。涉及落盘的安全边界归 storage(storage 不是沙箱,见 docs/concepts/security-model.md / storage-uri-grammar.md)。LanceDB 的
resolveLocalPath把绝对路径交给原生库,仍受 storage root 授权约束,但绕过了 storage 的 URI 边界——provider 应只用它指向自有数据目录。
7. 边界情形与注意事项
- 跨后端 score 不严格等价(审计项)。lancedb 取
1 - _distance(cosine 距离 → 相似度),与 flat 的归一化点积理论一致;但两后端浮点路径、归一化时机不同,绝对分值在边界处可能有微小差异,迁移后端后命中集合可能轻微漂移。早期 lancedb 曾用默认 L2(1 - L2既非相似度也与 flat 不可比),现已改为显式 cosine(见 lancedbsearch的注释)——若你看到旧库/旧版本表现异常,先确认distanceType('cosine')生效。 - 维度不匹配(换了 embedding 模型却复用旧库):
- flat:
dotProduct对长度不等返回Number.NEGATIVE_INFINITY,不匹配项被排到末尾并被下游minScore过滤,不会读越界产 NaN、不会静默清空;并一次性告警提示清库重建。注:审计早期记录的「flat dim-mismatch 产 NaN」已修复为-Infinity。自研 provider 应照此处理(骨架里的cosine已对齐)。 - lancedb:维度由表 schema 固定,写入不同维向量会由 LanceDB 自身报错。
- flat:
- flat 并发写竞态(已加固):索引默认
concurrency=10会并发save(),裸writeFile同路径并发写可能交错损坏 JSON、致下次init解析失败、只能按空库启动。flat 用saveChain串行化所有写,失败重标脏下次重试。自研「文件型」provider 必须同样串行化持久化。 - flat 全量内存 + 全量重写:所有向量常驻内存、每次 save 整库
JSON.stringify落盘——大规模数据用 lancedb。 - lancedb 建表 single-flight:并发首批
add复用同一建表 promise,避免「table already exists」吞掉向量;clear()必须同步重置tableInit,否则下次add会 await 到指向已删表的旧 promise 而崩。 - lancedb
deleteByFilter走原生 SQL 删除:把 filter 各键拼成metadata_json上的 LIKE 谓词(metaJsonFieldPredicate:字符串值自带引号定界、数字值靠尾随,/}定界,杜绝「1751 误配 17510」这类数字前缀误删),交 LanceDBtable.delete()原地删除,不把整表读进 JS(旧实现query().toArray()全表载入 + 复制重建,在大库上会 OOM 硬崩,现已改)。删除经结构性串行锁与后台压实、clear()互斥;空过滤器直接返回 0、不删(防误清全库)。谓词无索引,按 LIKE 全表扫并生成新版本待压实回收,频繁按会话删仍有成本。
8. 交叉链接
- 概念:service-model(DI 按名、挑选规则)、lazy-service-access(业务句柄是活的)、manifest-metadata(provides 双源)、storage-uri-grammar、security-model(storage 非沙箱)。
- 内核:core/service、core/context、core/plugin.md。
- 相关契约/服务:
@aalis/api-storage(落盘后端,api/api-storage)、@aalis/api-embedding(产生queryVector,api/api-embedding)、消费方@aalis/plugin-memory-vector(semantic-memory)。