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:
adapterparameter when supplied (test seam, dual-running migrations).qdrantUrltruthy → Qdrant. Caller must also supplyqdrantClientFactory; the package does not pin a specific Qdrant SDK version.- 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 },
});
mcontrols graph degree. Bump to 24 for collections >500k vectors when recall lags.efConstructionis 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
payloadJSONB column for filter selectivity. - B-tree on the generated
tenant_idcolumn 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_URLwithoutQDRANT_API_KEYat 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
VectorDimensionMismatchErrorso 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:
- Deploy with
VECTOR_STORE_DEFAULT_ADAPTER=qdrantandQDRANT_URLset. - Run
rag:reindex(ships with #50). Embeddings are recomputed via the embedding provider and written into Qdrant. - Smoke-search to confirm parity, then drop the pgvector tables via
vector-store:gconce 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.