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.
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.