Multi-Tenancy Runbook

pgContext deliberately avoids introducing a separate, opaque tenant store. Instead it builds on ordinary PostgreSQL tables, grants, row-level security (RLS), partitioning, standard B-tree indexes, and telemetry labels. pgContext search paths recheck source rows against these PostgreSQL controls on every query, so tenant isolation is enforced by PostgreSQL’s own RLS and grants, not by a separate access-control layer.

Single Collection, Tenant Filter

For shared-table tenants, store a tenant discriminator on the source table and register it as a filter field:

CREATE TABLE docs (
  id bigint PRIMARY KEY,
  tenant_id text NOT NULL,
  embedding vector NOT NULL,
  body text NOT NULL
);

SELECT pgcontext.create_collection('docs', 'public.docs');
SELECT pgcontext.register_vector('docs', 'embedding', 'embedding', 3, 'l2');
SELECT pgcontext.register_filter_column('docs', 'tenant_id', 'tenant_id');

Every tenant-scoped read should include the tenant filter:

SELECT point_id, source_key, score
FROM pgcontext.search(
  'docs',
  '[0,0,0]'::vector,
  '{"must":[{"key":"tenant_id","match":"acme"}]}',
  10
);

Create a B-tree index for the tenant discriminator, or run pgcontext.index_advisor('docs') and apply the suggested SQL when it reports a missing filter index.

Row-Level Security

Use RLS when tenant isolation must not depend on application-provided filters:

ALTER TABLE docs ENABLE ROW LEVEL SECURITY;
ALTER TABLE docs FORCE ROW LEVEL SECURITY;

CREATE POLICY docs_tenant_policy
  ON docs
  USING (tenant_id = current_setting('app.tenant_id', true));

Search, count, facet, grouped search, backfill, and hybrid query functions read source tables as the invoking role, so PostgreSQL RLS policies still decide which source rows are visible.

Partitioned Tenants

For large tenants or tenant-local maintenance windows, partition the source table by tenant and register the partitioned parent:

CREATE TABLE docs (
  id bigint NOT NULL,
  tenant_id text NOT NULL,
  embedding vector NOT NULL,
  PRIMARY KEY (tenant_id, id)
) PARTITION BY LIST (tenant_id);

CREATE TABLE docs_acme PARTITION OF docs FOR VALUES IN ('acme');

Registered tenant filters remain ordinary SQL predicates, so PostgreSQL can apply partition pruning while pgContext keeps one logical collection contract.

Per-Tenant Recall

Compute exact tenant results first, then compare candidate IDs with pgcontext.recall_check:

WITH exact AS (
  SELECT array_agg(point_id ORDER BY score, point_id) AS point_ids
  FROM pgcontext.search(
    'docs',
    '[0,0,0]'::vector,
    '{"must":[{"key":"tenant_id","match":"acme"}]}',
    100
  )
)
SELECT *
FROM pgcontext.recall_check(
  (SELECT point_ids FROM exact),
  ARRAY[101, 205, 309]::bigint[],
  0.95
);

Run this per tenant or per tenant cohort when changing indexes, quantization, or candidate budgets.

Noisy-Neighbor Diagnostics

Record query stats with bounded cohort labels such as tenant:acme or tier:enterprise:

SELECT pgcontext.record_query_stat(
  'docs',
  'tenant:acme',
  'search_filtered',
  10,
  200,
  12.5
);

SELECT cohort, query_kind, avg_latency_ms, latency_bucket, total_candidates
FROM pgcontext.query_cohort_stats()
WHERE collection_name = 'docs'
ORDER BY avg_latency_ms DESC;

Use these rows to identify tenants with larger candidate sets, slower latency buckets, or degraded recall before changing global collection limits.