Skip to content
5 min read · 1,080 words

Vector Storage

This is a featured battery

Vector Storage is large enough to warrant its own section. This page is the hub; the spokes below cover each facet in depth. If you read nothing else, read this page and The Query Builder.

We built this battery because we could not figure out why putting the word "vector" before the word "database" suddenly meant developers were expected to learn four new mental models, twenty-nine vendor-specific APIs, and a different definition of "query" for every backend they might want to try.

What it is

One abstraction over many vector databases, selected knex-style. You write the same chainable query whether the rows live in Postgres with pgvector, Qdrant, an in-process HNSW index, or a managed service like Pinecone or Cloudflare Vectorize. The backends are wildly different machines underneath; the builder compiles to a backend-neutral plan and the differences stay where they belong — inside the adapter, not inside your application code.

Nobody — the author included — has a native mental model for how a vector query is supposed to differ from a SQL query, because there isn't a good reason for it to. So it doesn't. The DX is the one you already own:

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

// The factory is async; `client` can be a class, a sync resolver, or a dynamic import.
const vs = await createVectorStore({
  client: () => import('@nhtio/adk/batteries/vector/qdrant').then((m) => m.QdrantVectorStore),
  options: { metric: 'cosine', dimensions: 384, connection: { url: 'http://localhost:6333' } },
})
await vs.connect()

// similarity search — "order by similarity" is implicit; .limit() is top-k
const hits = await vs('docs')
  .where('source', '/assembly/byo-llm')
  .where('year', '>=', 2024)
  .whereIn('kind', ['policy', 'reference'])
  .nearText('how do I register a tool')
  .select('id', 'document', { metadata: { fields: ['source', 'kind'] } })
  .limit(10)

// the SAME chain with no .near*() is a metadata filter-scan, not a similarity search
const policies = await vs('docs').where('kind', 'policy').select('id', 'document').limit(50)

// mutations are knex-shaped terminals
await vs('docs').upsert([{ id, document, metadata }])
await vs('docs').where('source', oldUrl).delete()

The store instance is callablevs(collection) opens a fresh builder — and the builder is thenable, so awaiting it runs the query. There is no separate .execute() to learn, no params object to memorize, no per-vendor SDK to wrap your head around. You query records like a reasonable person, and the adapter does the translation you would otherwise be doing by hand at 2am.

Why knex, of all things?

Because it won. knex is a load-bearing artifact of Node.js application development — old enough that "knex-style query builder" is a shape working developers recognize on sight, without a tutorial, and quite possibly older than some of the developers now reading this doc. We didn't pick it to be clever or nostalgic; we picked it because the whole point of this battery is to make a vector query feel like something you already know, and there is no more widely-internalized "chain methods, await the thing" mental model in this ecosystem. Borrowing a vocabulary thousands of developers already speak beats inventing a tasteful new one nobody does.

The one import that matters

createVectorStore({ client, options }) is the whole entry point. It is async and returns the callable store. The client is the adapter, supplied one of three ways:

FormExampleWhen
Constructorclient: QdrantVectorStoreYou already imported the adapter
Sync resolverclient: () => QdrantVectorStoreDefer construction
Async resolverclient: () => import('…/qdrant').then(m => m.QdrantVectorStore)Recommended — the dynamic import pulls the driver only when used

Deep-importing an adapter subpath (@nhtio/adk/batteries/vector/qdrant) is what loads its optional-peer driver. The core barrel @nhtio/adk/batteries/vector ships driver-free, so importing it costs no backend dependency. Adapters are also directly new-able for power users.

The adapter landscape

29 adapters span every architectural class — in-process libraries, SQL extensions, self-hosted servers, managed services. That breadth isn't a trophy count; it's the proof the abstraction holds. A contract that only ever ran against one backend would be a wrapper, not an abstraction. This one was made to survive backends that disagree about almost everything. Two adapters are cross-env (re-exported from the core barrel and browser-capable); the rest are Node-only and reached by deep import, so their driver loads only when you actually use it.

ClassAdapters
In-process / browserin_memory, orama, hnswlib, lancedb, sqlite_vec, duckdb
SQL-extensionpgvector, mariadb, oracle23ai, clickhouse
Self-hosted serverqdrant, weaviate, milvus, chroma, redis (Valkey), elasticsearch, opensearch, solr, typesense, meilisearch, mongodb, surrealdb, neo4j, arangodb, couchbase, vespa
Managed / serverlesspinecone, s3vectors (AWS), cloudflare (Vectorize)

Every adapter passes the same conformance contract, so cross-backend semantics are identical. Where a backend has a hard constraint — edition requirements, a dimension floor, a metric it doesn't support, or aggressive eventual consistency — that is a documented per-adapter caveat, not a behavioral difference in the contract. See The Adapter Matrix for the full list with each backend's caveats and environment gating.

Batteries are provided as-is

The kit does not enforce a backend's licensing or edition. Some adapters require a specific edition for vector search (for example, Couchbase Enterprise); whether that fits your deployment is your call, not something the battery gates on.

How the facets fit together

The battery has more surface than a single page can hold. Each facet has its own spoke:

  • The Query Builder & Filters — the knex-style chain, the neutral filter tree, .whereRaw() with safe bindings, and the projection contract (.select() is required on reads).
  • Schema & Migrationsvs.schema (knex-shaped collection lifecycle) and vs.migrate (latest / rollback with a ledger).
  • Consistency & Capabilitiescapabilities as static truth, .consistency(mode), and why transactions are capability-gated and never faked.
  • Encoders: vectors or text — the BYO VectorEncoderFn, built-in server-side encoding, and encoderFromEmbeddings() — the one seam to the Embeddings battery.
  • Retrievable GluecreateVectorRetrievableCallbacks() turns any store into the four Retrievable TurnRunner callbacks, the seam to Bring your own retrieval.
  • The Adapter Matrix — all 29 backends by class, per-backend caveats, and the TEST_VECTOR_* env gating.
  • Writing an Adapter — implement BaseVectorStore for a backend the battery doesn't ship, and prove it with the conformance harness.

Where this sits in an assembly

The vector battery is the storage and search primitive for vectors — it holds number[] and ranks them. It is not RAG by itself: embedding runtime, fusion, rerank, and citation gates stay in your application, above the store. Two seams connect it to the rest of the ADK:

That's the whole battery: pick a backend, query it like a database, wire it into a turn. You can go back to querying records like a reasonable person — at least until someone puts "quantum" in front of "database" and makes us debug spooky updates at a distance.