Files
HiveGrid-Wireframes/hivegrid-cloud/docs/architecture/vector-store.md
T

5.7 KiB

Vector Store

@toolctrl/vector-store is a thin adapter layer over two interchangeable nearest-neighbour stores. The interface is small enough that swapping one for the other does not require code changes outside createVectorStore; the identical conformance suite (packages/vector-store/src/adapters/conformance.test.ts) guarantees behavioural parity.

Why an adapter layer at all

The 80% case for tenants in this stack — knowledge bases with a few thousand chunks, RAG-backed help-desk replies — sits comfortably inside pgvector. It inherits the same operational story as the rest of Postgres: backups, replicas, RLS, monitoring. No extra container, no extra credentials, no extra firewall rule.

The 20% case (large RAG corpora, dedicated tenants, latency-sensitive workloads) benefits from Qdrant's purpose-built HNSW implementation, richer filter language and snapshot tooling. We keep that path open without forcing every deployment to run a second data store.

Adapter comparison

Concern pgvector Qdrant
Operational cost Same Postgres you already run Separate container + storage volume
Backups Free — covered by backup:create Snapshot API; cron script ships separately
Recall@10 on cosine 0.95+ once HNSW is built 0.97+ with default config
P95 search latency (10k items) <30 ms <15 ms
Filter expressiveness payload ->> 'key' = … JSON ops Native payload filter DSL
Multi-vector / sparse Not yet Native (not exposed in adapter)
Failure mode on missing collection Throws Postgres 42P01; adapter converts to empty result Throws 404; adapter converts to empty result

Selection logic

import { createVectorStore } from "@toolctrl/vector-store";

const store = createVectorStore({
  namespace: "rag-prod",            // becomes a collection prefix
  adapter: undefined,                // optional override
  qdrantUrl: process.env.QDRANT_URL, // forward env at the boundary
  qdrantApiKey: process.env.QDRANT_API_KEY,
  qdrantClientFactory: ({ url, apiKey }) =>
    new QdrantClient({ url, apiKey }),
  prismaWrite: getWriteDbClient(),
  prismaRead: getReadDbClient(),
});

The factory picks an adapter in this order:

  1. adapter parameter when supplied (test seam, dual-running migrations).
  2. qdrantUrl truthy → Qdrant. Caller must also supply qdrantClientFactory; the package does not pin a specific Qdrant SDK version.
  3. Otherwise pgvector with prismaWrite / prismaRead.

The package never reads process.env directly — it would break the env-access-enforcement boundary test. Callers thread env values in.

Tenant scoping

createVectorStore always returns a TenantScopedVectorStore. The bare searchVectors method throws on this wrapper: searches must go through searchForTenant({ tenantId, ... }). The wrapper injects the tenantId as the first must filter clause and discards any caller-supplied tenantId clause before forwarding the call, so a buggy or hostile caller cannot escape the boundary.

Writes are guarded at the type level: VectorItem.payload extends { tenantId: string }, so omitting the tenant id at upsert time is a TS compile error.

pgvector tuning

HNSW parameters live on the adapter constructor and default to pgvector 0.7's recommended starting point:

new PgvectorAdapter({
  write, read,
  hnsw: { m: 16, efConstruction: 64 },
});
  • m controls graph degree. Bump to 24 for collections >500k vectors when recall lags.
  • efConstruction is the candidate-list size during index build. Bump to 128 for higher-recall corpora; build time roughly doubles.

The adapter creates one table per collection (vec_<namespace>_<name>) with three indexes:

  • HNSW on the vector column with the metric-specific operator class.
  • GIN on the payload JSONB column for filter selectivity.
  • B-tree on the generated tenant_id column for the (almost always) tenant-scoped read path.

Qdrant deployment notes

  • Pin to a 1.x release. Adapter is tested against 1.12.
  • API key is mandatory whenever the URL is not localhost. The factory's caller should reject QDRANT_URL without QDRANT_API_KEY at the boundary.
  • Use a dedicated PVC; Qdrant cannot recover from a wiped storage directory.
  • The adapter auto-creates collections on first upsert. Dimension mismatches surface as VectorDimensionMismatchError so the embedding pipeline can classify them for retry vs. dead-letter.

Backups

  • pgvector: piggy-backs on the Postgres backup pipeline (backup:create, backup:restore). No additional work.
  • Qdrant: use the Snapshot API. A dedicated cron job + S3 upload script will ship with task #50; until then run manual snapshots before each schema rollout.

Migration paths

We do not provide a direct pgvector → Qdrant data transfer. Source-of-truth is always the document/chunk table in Postgres, so the migration is a re-embed:

  1. Deploy with VECTOR_STORE_DEFAULT_ADAPTER=qdrant and QDRANT_URL set.
  2. Run rag:reindex (ships with #50). Embeddings are recomputed via the embedding provider and written into Qdrant.
  3. Smoke-search to confirm parity, then drop the pgvector tables via vector-store:gc once the warm-window is clear.

Same procedure in reverse, with the obvious caveat that pgvector imposes a practical ceiling around 1M vectors per collection before HNSW build cost becomes painful.

Namespace conventions

The factory's namespace argument is prefixed onto every collection name — namespace="rag-prod", collection="docs" becomes the pgvector table vec_rag_prod_docs and the Qdrant collection rag-prod_docs. Pick a namespace per workspace or per environment so multiple deployments can share the same store without colliding.