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 sizes the column, the virtual table, or the named vector the index
creates. modelName matters as much — 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)
That schema has three consequences. 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 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.
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: 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.
export function runVectorIndexTests(
name: string,
createIndex: () => Promise<VectorIndex>,
cleanup?: () => Promise<void> | void,
)
The suite is package source, not a published entry point —
@animahealth/adk/testing does not export it — so run it against an index of your
own by copying it from the package's test sources. The next section is the behaviour it pins
down.
Step 7
What the shared suite pins down
One suite defines the contract, and the in-memory index is its reference implementation. It asserts shared behaviour only, and stays 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.
|
Three of the four shipped backends register against it. The in-memory reference and sqlite-vec
need no environment, so they run anywhere; pgvector registers when you point it at a Postgres
carrying the vector extension. That registration wipes and recreates the
public schema before it starts, and the suite reuses fixed collection names, so
point it only at a dedicated test database.
Qdrant is provisioned, does not run the shared suite. 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 hold for it.
Two Qdrant behaviours therefore differ from the contract, and both matter when you hold the
index yourself. 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. And
get() and count() both ignore the variant option: a
count is a count of points, not of a named vector.