---
url: 'https://adk.nht.io/batteries/vector/custom-adapter.md'
description: >-
  Implement BaseVectorStore for a backend the battery does not ship, and prove
  it against the conformance harness.
---

# Writing an Adapter

## LLM summary — Writing an Adapter

* An adapter is a class `extends BaseVectorStore` (`@nhtio/adk/batteries/vector/contract`). The base owns the callable wiring, `vs.schema`/`vs.migrate`, the text→vector resolver (`protected encode(texts, kind)`), and the `transaction()` throw-by-default. You implement the data-plane + schema-plane + lifecycle primitives.
* Required: `readonly capabilities: VectorStoreCapabilities`; `isAvailable()` (static + instance); `connect()`; `close()`; `executeSearch(plan): Promise<VectorMatch[]>`; `executeUpsert(plan): Promise<void>`; `executeDelete(plan): Promise<void>`; `createCollection(spec, ifNotExists)`; `dropCollection(collection, ifExists)`; `hasCollection(collection)`; `renameCollection(from, to)`.
* Adapters consume neutral plans (`SearchPlan`/`UpsertPlan`/`DeletePlan`/`CollectionSpec`), never the builder. `executeSearch` always gets a fully-formed plan: `near` resolved (vector / serverText / id), a populated `projection`, and the `.select()` requirement already enforced upstream.
* Conventions (match a shipped adapter): one self-contained `src/batteries/vector/<name>/index.ts` with a unique `@module @nhtio/adk/batteries/vector/<name>` JSDoc tag (Vite discovers the subpath; duplicate tags throw at build); `export interface <Name>VectorStoreOptions extends BaseVectorStoreOptions { connection: {...} }`; lazy `await import('<driver>')` in a getter throwing `E_VECTOR_STORE_DRIVER_UNAVAILABLE`; store metadata as JSON + filter via `evaluateFilter` (over-fetch → JS-filter) for parity; recompute `score` via `normalizeScore` for the \[0,1] contract; use `isInstanceOf` to re-throw typed exceptions past a catch.
* Honor projection in results (only selected columns), wrap driver errors in the relevant `E_VECTOR_STORE_*_FAILED` with `cause`, declare capabilities truthfully (don't claim transactions you can't honor — base throws `E_VECTOR_STORE_TRANSACTIONS_UNSUPPORTED` for free).
* Prove it with `runVectorStoreConformance(label, makeStore, dim?, opts?)` (imported from `@nhtio/adk/batteries/vector/conformance`, the same suite the shipped adapters use) — 7 tests; `makeStore` returns a connected store with 'docs' created at `dim` (default 3). `dim` is for backends with a dimension floor; `opts.retry`/`opts.timeout` for aggressively eventually-consistent backends. Gate the spec on `TEST_VECTOR_<NAME>_*`. The conformance subpath imports `vitest` (an optional peer) — install it to run the suite.
* Register a directly-`new`-able adapter via the factory's `client` (class | sync resolver | async resolver) — no central registry, deep-import only.

29 backends won't be the last one you want. An adapter is a single class with a small, fixed set of methods, and the base class does most of the work — the callable wiring, the schema and migration builders, the text-to-vector resolver, the transaction guard. You implement the part that actually talks to your backend, and a shared conformance suite tells you, honestly, whether you got it right.

## What the base gives you, what you implement

`BaseVectorStore` (`@nhtio/adk/batteries/vector/contract`) owns the parts that are the same for every backend:

* the callable `vs(collection)` wiring and `asCallable()`
* `vs.schema` (the knex-shaped builder) and `vs.migrate`
* `protected encode(texts, kind)` — the text→vector resolver that throws `E_VECTOR_STORE_ENCODER_REQUIRED` when there's no encoder and no built-in encoding
* `transaction()` — throws `E_VECTOR_STORE_TRANSACTIONS_UNSUPPORTED` by default, so you get the "never fake a transaction" behaviour for free unless you override it

You implement the primitives — the data plane, the schema plane, and lifecycle:

```typescript
import { BaseVectorStore } from '@nhtio/adk/batteries/vector'

export class MyVectorStore extends BaseVectorStore {
  readonly capabilities = {
    transactions: false,
    namedVectors: false,
    rename: false,
    rawSql: false,
    builtInEncoding: false,
    consistency: { configurable: false, default: 'strong', modes: ['strong'] },
  }

  static isAvailable() { return typeof process !== 'undefined' }
  isAvailable() { return typeof process !== 'undefined' }

  async connect() { /* open the client (idempotent) */ }
  async close()   { /* tear it down */ }

  // data plane — the builder routes here, with fully-formed neutral plans
  async executeSearch(plan: SearchPlan): Promise<VectorMatch[]> { /* … */ }
  async executeUpsert(plan: UpsertPlan): Promise<void> { /* … */ }
  async executeDelete(plan: DeletePlan): Promise<void> { /* … */ }

  // schema plane — vs.schema / vs.migrate route here
  async createCollection(spec: CollectionSpec, ifNotExists: boolean) { /* … */ }
  async dropCollection(collection: string, ifExists: boolean) { /* … */ }
  async hasCollection(collection: string): Promise<boolean> { /* … */ }
  async renameCollection(from: string, to: string) { /* … */ }
}
```

The key simplification: **you consume neutral plans, never the builder.** By the time `executeSearch` is called, the base has already resolved `.nearText()` to a vector (or a server-text directive), enforced the `.select()` requirement, and handed you a `SearchPlan` with a populated `projection` and a fully-formed `near`. You read the plan and talk to your driver. You never parse a chain.

## The conventions every shipped adapter follows

These aren't enforced by the type system, but matching them is what makes an adapter feel like part of the battery rather than a bolt-on:

* **One self-contained file** at `src/batteries/vector/<name>/index.ts`, opening with a unique `@module @nhtio/adk/batteries/vector/<name>` JSDoc tag. Vite discovers the subpath from that tag; a duplicate tag throws at build, so the name is the contract.
* **An options interface** `export interface <Name>VectorStoreOptions extends BaseVectorStoreOptions { connection: { … } }`.
* **Lazy driver import** in a small getter that throws `E_VECTOR_STORE_DRIVER_UNAVAILABLE(['<driver>'])` on failure — so importing the adapter without the peer installed fails with a clear message, not a module-not-found stack.
* **Metadata as JSON + filter via `evaluateFilter`.** Store metadata as a JSON string (or native object) and, for filtering, over-fetch candidates then run the neutral `evaluateFilter` over them. This buys exact cross-adapter parity for free. Only write a native filter translator if your backend makes it cheap and correct — and if you do, add a `<name>_filter.node.spec.ts` unit test like Qdrant/OpenSearch have.
* **Recompute `score` via `normalizeScore`** (from `helpers.ts`) so the `[0,1]` higher-is-better contract holds regardless of your backend's native metric. Several shipped adapters ignore the backend's raw score entirely and recompute from the stored vector — it's the reliable path.
* **Wrap driver errors** in the relevant `E_VECTOR_STORE_*_FAILED` exception with the original on `cause`, and use `isInstanceOf` (not bare `instanceof`) to re-throw the battery's own typed exceptions (`E_VECTOR_STORE_DIMENSION_MISMATCH`, etc.) past your catch block.
* **Honor the projection** — return only the columns named in `plan.projection`, and attach `score` only on a similarity search.
* **Declare capabilities truthfully.** If your backend isn't ACID, leave `transactions: false` and the base throws for you. If it's eventually consistent, say so (`configurable: true`) and settle-poll on writes. The whole point of capabilities is that they're true.

The closest templates to copy: `in_memory` for the pure-reference shape, `sqlite_vec`/`pgvector` for an ACID SQL backend, `qdrant` for a server with native filter translation, `pinecone`/`s3vectors`/`cloudflare` for an eventually-consistent managed service, `solr`/`vespa` for a pure-`fetch` HTTP backend with no driver.

## Prove it: the conformance harness

An adapter isn't done because it compiles — it's done when it passes the same suite all 29 shipped adapters pass. `runVectorStoreConformance`, exported from `@nhtio/adk/batteries/vector/conformance`, is that suite: seven tests covering upsert+search with a `[0,1]` score, text encoding, the required-`.select()` throw, filter-scan without score, projection (vector excluded unless selected), delete-by-id, and the transaction-unsupported throw. It's a public, deep-import-only subpath — the same one the kit's own adapters test against — and it imports `vitest` as an optional peer, so install `vitest` in your project to run it.

```typescript
import { describe } from 'vitest'
import { createVectorStore } from '@nhtio/adk/batteries/vector'
import { runVectorStoreConformance, stubEncoder } from '@nhtio/adk/batteries/vector/conformance'
import { MyVectorStore } from './my-vector-store'
import type { CollectionBuilder } from '@nhtio/adk/batteries/vector'

const url = process.env.TEST_VECTOR_MY_URL
const d = url ? describe : describe.skip

d('MyVectorStore (integration)', () => {
  const makeStore = async () => {
    const vs = await createVectorStore({
      client: MyVectorStore,
      options: { metric: 'cosine', encoder: stubEncoder, dimensions: 3, connection: { url } },
    })
    await vs.connect()
    await vs.schema.createCollection('docs', (c: CollectionBuilder) =>
      c.vector({ dimensions: 3, metric: 'cosine' }),
    )
    return vs
  }
  runVectorStoreConformance('MyVectorStore', makeStore)
})
```

`makeStore` must return a **connected store with a `'docs'` collection already created** at the right dimension — the suite assumes that and drives the rest. Gate the whole `describe` on `TEST_VECTOR_<NAME>_URL` so the suite **skips green** when no backend is configured, and never blocks anyone who doesn't run your backend.

### Two escape hatches for awkward backends

The harness takes two optional arguments, both defaulting to no-ops so the 29 existing specs are unaffected:

```typescript
runVectorStoreConformance(label, makeStore, dim, opts)
```

* **`dim`** (default 3) — for a backend with a **dimension floor**. The harness pads every test vector to `dim`. Cloudflare Vectorize requires 32–1536, so its spec runs at `dim: 32` with `paddedStubEncoder(32)`.
* **`opts.retry` / `opts.timeout`** — for an **aggressively eventually-consistent** backend whose read-after-write flaps for seconds. `retry` re-runs a flaked attempt (re-clearing via `makeStore`) against a more-settled backend, turning transient-consistency flake into deterministic green without weakening a single assertion. Cloudflare uses `{ retry: 4, timeout: 90_000 }`.

Reach for these only when the backend genuinely forces them — they exist because real managed services are messier than a reference implementation, not as a way to paper over an adapter bug. If your adapter needs a retry to pass against a *strongly*-consistent backend, the bug is in the adapter.

## Registering it

There is **no central registry.** An adapter is just a class you pass to the factory, which accepts it three ways (see the [hub](./)):

```typescript
const vs = await createVectorStore({ client: MyVectorStore, options })          // the class
const vs = await createVectorStore({ client: () => MyVectorStore, options })     // sync resolver
const vs = await createVectorStore({ client: () => import('./my'), options })    // async resolver / dynamic import
```

Because the factory validates the resolved constructor against the `BaseVectorStore` shape, a class that doesn't implement the contract is rejected at construction with `E_INVALID_VECTOR_STORE_CONFIG` — you find out immediately, not at first query. And because adapters are deep-import-only, yours pulls its driver exactly when someone constructs it, same as every shipped one. You don't need to live in this repo to write an adapter; the contract is public and the base class is exported.

## Where to go next

* [The Query Builder & Filters](./query-builder) — the plans (`SearchPlan`/`UpsertPlan`/`DeletePlan`) your `execute*` methods receive.
* [Consistency & Capabilities](./consistency) — declaring `capabilities` and handling read-after-write honestly.
* [The Adapter Matrix](./adapters) — the shipped adapters to copy from, by class.
