Agent Development Kit · Reference · the storage seam

Vector backends behind one interface

memory() implements neither half of what it needs: something that turns text into vectors, and something that stores them. Both are interfaces. Four index implementations ship, and a fifth can be your own object. This chapter is a reference, not a notebook — every backend here needs a native module or a running server, so its code is read rather than run.

Audience · engineers wiring memory to an index Runs here · nothing (no cells on this page) Imports · @animahealth/adk · /qdrant

Step 1

Two contracts, one seam

A memory is a config: model, index, collection. The model is an Embedder; the index is a VectorIndex. Everything else on the memory chapter — search, slices, variants, the context renderer, the tool — is built on those two methodsets and nothing more.

The embedder is the smaller of the two.

interface Embedder {
  readonly dimensions: number
  readonly modelName?: string
  embed(input: string[], options?: { inputType?: 'query' | 'document' }): Promise<EmbedResult>
}

interface EmbedResult {
  embeddings: number[][]
  model: string
  usage?: { totalTokens: number }
}

dimensions is load-bearing: it sizes the column, the virtual table, or the named vector the index creates. So is modelName — it prefixes the vector name that memory() reads and writes, as <modelName>_<variant>. Rename the model and you are addressing a different vector, in the same collection, holding nothing.

The index is the larger contract. Nine methods, and an optional tenth.

interface VectorIndex {
  search(
    collection: string,
    embedding: number[],
    options?: {
      topK?: number
      filter?: VectorFilter
      variant?: string
      minScore?: number
    },
  ): Promise<VectorMatch[]>
  upsert(collection: string, points: Point[], options?: { variant?: string }): Promise<void>
  delete(collection: string, ids: string[]): Promise<void>
  deleteByFilter(collection: string, filter: VectorFilter): Promise<void>
  updateMetadata(collection: string, id: string, metadata: Record<string, unknown>): Promise<void>
  distanceMatrix(
    collection: string,
    options?: {
      sample?: number
      limit?: number
      filter?: VectorFilter
      variant?: string
    },
  ): Promise<DistanceMatrixResult>
  get(
    collection: string,
    ids: string[],
    options?: { variant?: string },
  ): Promise<Array<{ id: string; metadata: Record<string, unknown> }>>
  scroll(
    collection: string,
    options?: {
      filter?: VectorFilter
      limit?: number
      offset?: string
      variant?: string
      includeVectors?: boolean
    },
  ): Promise<ScrollResult>
  count(
    collection: string,
    options?: {
      filter?: VectorFilter
      variant?: string
    },
  ): Promise<number>
  close?(): Promise<void>
}

Three shapes recur. A collection is a namespace, passed on every call rather than bound at construction — one index instance serves many. A variant is a second vector for the same id: the same note embedded as a summary, or by a second model. A filter is the must/should/must_not tree of VectorFilter; the served backends translate it into their own query language, and the file-backed ones evaluate it with the same predicate the reference index uses.

memory() always names the variant it reads and writes, even the default one. The contract's latitude about which variant an unnamed search returns therefore only matters when you hold a VectorIndex yourself.

Descriptors, not clients. sqliteVec(), pgvector() and qdrant() return plain data — { provider: 'sqlite-vec', path } and friends — so a config stays inspectable and serializable. memory() resolves it to a live index on use. The sqlite-vec and Qdrant descriptors resolve lazily: their native module or client is imported on the first call that needs it, never at import time. Pass an object that already implements VectorIndex and it is used as-is, with no registration step.

Step 2

What ships

Four implementations, one interface. The first three are in the main entry; Qdrant sits behind its own subpath so importing the ADK never pulls its client in.

Backend Import Also install Where points live Collection creation
inMemoryIndex() @animahealth/adk nothing one process's heap on first write
sqliteVec({ path }) @animahealth/adk better-sqlite3, sqlite-vec a local file, or ':memory:' on first write
pgvector({ connectionString }) @animahealth/adk pg, a Postgres with vector one table per collection on first write
qdrant({ url }) @animahealth/adk/qdrant @qdrant/js-client-rest, a server a Qdrant collection yours, before first write

Every "also install" entry is an optional peer dependency. The ADK declares them and imports them dynamically, so a consumer who never touches a backend never installs it. That last column is the deepest difference between the backends, and section 5 is about it.

inMemoryIndex() is the reference implementation, not a placeholder: it is the index the contract is defined against, and the yardstick the other three are held to. It keeps nothing across a process restart.

Step 3

sqlite-vec — durable, with no server

A file on disk, two native modules, no infrastructure. This is the backend for local development, CLIs, desktop apps, and single-process deployments.

import { memory, sqliteVec } from '@animahealth/adk'

const notes = memory({
  model: myEmbedder,
  index: sqliteVec({ path: './data/notes.db' }),
  collection: 'notes',
})

await notes.upsert({
  id: 'n1',
  content: 'the boiler was serviced in March',
  metadata: { room: 'plant' },
})

const { matches } = await notes.search('boiler service', { topK: 3 })

path: ':memory:' gives an ephemeral index with the same code path; any other path has its parent directory created for it, and the database opens in WAL mode. Each collection becomes two tables: an ordinary one holding the metadata and the variants, and a vec0 virtual table holding the vectors under a cosine metric.

CREATE TABLE IF NOT EXISTS "<collection>" (
  _rowid INTEGER PRIMARY KEY AUTOINCREMENT,
  id TEXT NOT NULL,
  variant TEXT NOT NULL,
  metadata TEXT NOT NULL DEFAULT '{}',
  embedding BLOB NOT NULL,
  UNIQUE(id, variant)
)

CREATE VIRTUAL TABLE IF NOT EXISTS "<collection>_vec"
  USING vec0(embedding float[<dimensions>] distance_metric=cosine)

Three consequences worth knowing before you ship it. The virtual table's width is fixed by the first vector the collection ever sees, so one collection serves exactly one embedder. Collection names are interpolated into that DDL and are therefore held to [A-Za-z0-9_-]+. And a filtered search cannot filter inside the KNN query: the index over-fetches — ten times topK, at least a hundred rows — filters the page, and widens the window tenfold again until it has topK matches, exhausts the index, or drops below minScore. Correct, at the cost of extra reads on a filter that matches almost nothing.

Step 4

pgvector — the database you already run

If the rest of the system is on Postgres, so is this. The provider needs the vector extension available and a role that may create it, plus pg as a peer.

import { memory, pgvector } from '@animahealth/adk'

const notes = memory({
  model: myEmbedder,
  index: pgvector({
    connectionString: process.env.DATABASE_URL!,
    schema: 'memory',
  }),
  collection: 'notes',
})

The index opens its own pool and, on close(), ends it. To share the pool your application already has, pass it — anything with a query(text, values?) method satisfies the PgPool shape. An injected pool is never ended for you, and connectionString stays required even when you pass one.

import { Pool } from 'pg'

const pool = new Pool({ connectionString: process.env.DATABASE_URL })

const index = pgvector({
  connectionString: process.env.DATABASE_URL!,
  pool,
  batchSize: 500,
})

Each collection is one table in schema (default public), plus an HNSW index.

CREATE TABLE IF NOT EXISTS "<schema>"."<collection>" (
  id TEXT NOT NULL,
  variant TEXT NOT NULL,
  embedding vector(<dimensions>),
  metadata JSONB DEFAULT '{}',
  PRIMARY KEY (id, variant)
)

CREATE INDEX IF NOT EXISTS "<schema>_<collection>_hnsw_idx"
  ON "<schema>"."<collection>" USING hnsw (embedding vector_cosine_ops)

Filters compile to SQL against the metadata column, so scoring and filtering happen in one statement and there is no over-fetch window to widen: a match becomes metadata->>key = $n, a text.contains becomes a lowercased LIKE, and a range casts the field to numeric — or to timestamptz when the bound you passed is a string. The score is 1 - (embedding <=> $1::vector), cosine similarity, matching every other backend.

Identifiers — the schema and every collection name — must match [a-zA-Z_][a-zA-Z0-9_]*. That is stricter than sqlite-vec: a collection named user-notes works on one backend and throws on the other. Search, scroll, count and upsert address the 'default' variant when none is named. Every operation retries with exponential backoff — three attempts, 500 ms, doubling, capped at 30 s — unless you pass your own retry.

Step 5

Qdrant — provisioned, not created

Qdrant is the one backend that will not build its own collection. Its provider never issues a create call: a collection's named vectors are fixed when the collection is created, and Qdrant cannot add a named vector to a collection that already exists. So provisioning is a step you run, in the shape the ADK will later address.

import { memory } from '@animahealth/adk'
import { qdrant } from '@animahealth/adk/qdrant'

const notes = memory({
  model: myEmbedder,
  index: qdrant({ url: process.env.QDRANT_URL!, apiKey: process.env.QDRANT_API_KEY }),
  collection: 'notes',
  variants: ['default', 'summary'],
})

Import qdrant from @animahealth/adk/qdrant. The main entry re-exports it for compatibility and marks it deprecated; the subpath is the one that bundles the client.

collectionSpec() computes what to provision from the same config, so the vector names are derived rather than retyped. It creates nothing.

import { collectionSpec } from '@animahealth/adk'

const spec = collectionSpec({
  model: myEmbedder,
  collection: 'notes',
  variants: ['default', 'summary'],
})

// spec.collection   'notes'
// spec.vectors      { 'my-encoder_default': { dimensions: 256, distance: 'Cosine' },
//                     'my-encoder_summary': { dimensions: 256, distance: 'Cosine' } }
// spec.textIndexes  ['_variant_default', '_variant_summary']

Those keys are exactly what the running memory addresses: one named vector per variant, prefixed by the embedder's modelName, and one payload key per variant holding the text that was embedded. spec.payloadIndexes appears only when the config has slices, and carries the key their kind is stored under.

Applying the spec is a dozen lines you own — a script, a migration job, a Terraform generator. Note the one translation: the spec says dimensions, Qdrant's client says size.

import { QdrantClient } from '@qdrant/js-client-rest'

const client = new QdrantClient({ url: process.env.QDRANT_URL! })

await client.createCollection(spec.collection, {
  vectors: Object.fromEntries(
    Object.entries(spec.vectors).map(([name, v]) => [
      name,
      { size: v.dimensions, distance: v.distance },
    ]),
  ),
})

for (const field of spec.textIndexes) {
  await client.createPayloadIndex(spec.collection, { field_name: field, field_schema: 'text' })
}

Adding a variant later means adding it to variants, re-running the spec against a new collection, and re-indexing into it. That is the cost the other backends do not charge, and the reason to reach for Qdrant deliberately: a served index with payload indexes, tenant keys, and a server-side distance matrix, in exchange for a provisioning step.

One more thing to expect in the dashboard. Qdrant point ids must be unsigned integers or UUIDs, so any other id — 'note-17', a slug, a composite key — is hashed to a deterministic UUIDv5 and the original is kept in the point's _original_id payload key. The ADK maps it back on every read and strips the key from the metadata you see. Your ids round-trip; the ids in Qdrant's own UI are UUIDs.

Step 6

Your own index, and your own embedder

Both seams are structural. An object with the right methods is an Embedder; an object with the other nine is a VectorIndex. Neither needs a factory, a registration call, or a provider tag.

import type { Embedder } from '@animahealth/adk'

// `encode` is yours: a local model, an HTTP call, anything returning `dimensions` numbers.
const myEmbedder: Embedder = {
  dimensions: 256,
  modelName: 'my-encoder',
  async embed(input) {
    return { embeddings: await Promise.all(input.map(encode)), model: 'my-encoder' }
  },
}

An index is the same move with more surface. Pass the object as index and memory() uses it directly. Read inMemoryIndex in the package source first — it is the shortest complete implementation of the contract, and the one the others are checked against.

Then hold your implementation to the same suite. It is written as a function over a factory, so registering a backend is three lines.

// src/memory/providers/index-compliance.test.ts
export function runVectorIndexTests(
  name: string,
  createIndex: () => Promise<VectorIndex>,
  cleanup?: () => Promise<void> | void,
)

That file is package source, not a published entry point: @animahealth/adk/testing does not export it. To run it against an index of your own today, copy it out of the repository. The next section is what it proves for the backends that ship.

Step 7

What is actually proven

One suite defines the contract, and the in-memory index is its reference implementation. It asserts shared behaviour only, and stays deliberately quiet where the contract leaves latitude: which variant an unnamed search reads is provider business, and scroll tokens are paged through opaquely rather than parsed.

Every method is pinned down, and most of them from several directions:

Area What it pins down
search Cosine ranking, topK, minScore, metadata filters, one row per id when no variant is named, and a named variant searching its own vectors. One case buries the filter's only match behind 120 closer points — a backend that KNN-fetches a fixed window and filters afterwards comes back empty and fails here.
upsert Re-upserting an id replaces its embedding; metadata merges across writes, and a null value deletes a key.
delete · deleteByFilter The named ids only, and the matching points only.
updateMetadata · get Updates merge into existing metadata; get returns an empty metadata object for an id that does not exist, rather than throwing or dropping the row.
scroll Every point is reachable by following nextOffset, filters apply, and includeVectors round-trips the embedding it stored.
count · distanceMatrix Counts respect filters and count only the ids carrying a named variant; the matrix returns one pair per unordered pair, with sample and limit bounding it. Empty collections answer empty everywhere.

Where it runs is a property of the public repository's CI workflow: a unit job with no services, and a backend-compliance job against service containers. Stores: the sleeping agent has both job definitions.

Backend Registered in the suite Unit job Backend-compliance job
inMemoryIndex yes runs runs
sqliteVec yes — against a throwaway file database runs runs
pgvector yes — when TEST_PGVECTOR_URL is set skipped, no endpoint runs, against the container
qdrant no — see below

SQLite needs no environment, which is why it gets the full run everywhere. The pgvector registration wipes and recreates the public schema before it starts: the suite reuses fixed collection names, so point it only at a dedicated test database.

Qdrant does not run the shared suite, and this is deliberate. The suite encodes the lazy-creation semantics the in-memory reference, sqlite-vec and pgvector share: write to a collection that does not exist and it appears. Qdrant is provisioning-based, as section 5 describes, so those cases cannot be true of it. Whether the VectorIndex contract should grow a provisioning seam — so Qdrant can run the suite too — is an open design question in the package. Until it is settled, any conformance claim for Qdrant must read provisioned, does not run the shared suite.

An unexercised provider is an undocumented deviation waiting to be found by a user rather than by CI, so here are the three the source shows today, none of them caught by anything. get() throws for an id that is not in the collection, where the contract returns an empty metadata object — so pass ids you already know exist, or catch. get() and count() both ignore the variant option: a count is a count of points, not of a named vector. Everything else in the provider tracks the contract, but "tracks" here means read, not proven.