Skip to content

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 实现一致(flat index.ts、lancedb index.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(见 lancedb search 的注释)——若你看到旧库/旧版本表现异常,先确认 distanceType('cosine') 生效。
  • 维度不匹配(换了 embedding 模型却复用旧库):
    • flat:dotProduct 对长度不等返回 Number.NEGATIVE_INFINITY,不匹配项被排到末尾并被下游 minScore 过滤,不会读越界产 NaN、不会静默清空;并一次性告警提示清库重建。注:审计早期记录的「flat dim-mismatch 产 NaN」已修复为 -Infinity。自研 provider 应照此处理(骨架里的 cosine 已对齐)。
    • lancedb:维度由表 schema 固定,写入不同维向量会由 LanceDB 自身报错。
  • 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」这类数字前缀误删),交 LanceDB table.delete() 原地删除,不把整表读进 JS(旧实现 query().toArray() 全表载入 + 复制重建,在大库上会 OOM 硬崩,现已改)。删除经结构性串行锁与后台压实、clear() 互斥;空过滤器直接返回 0、不删(防误清全库)。谓词无索引,按 LIKE 全表扫并生成新版本待压实回收,频繁按会话删仍有成本。

8. 交叉链接 ​

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