Contents
Multi-Model Retrieval
Multi-model retrieval lets an old and a new embedding profile serve the same collection while a backfill is incomplete. It is Stable. Each profile keeps its own vector representation, dimensions, metric, query value, HNSW index, and lifecycle. pgContext never compares native distance scores between profiles; it combines one-based ranks with weighted reciprocal-rank fusion (RRF).
The source table remains authoritative. One source row and one pgContext point
ID identify the occurrence across every profile. PostgreSQL MVCC, SELECT
privileges, row-level security, registered filters, deleted-point state, and
source-version checks are reapplied independently to every branch.
Register Versioned Profiles
A profile used by query_multi_model must bind two distinct bigint columns:
source_version_columnis the current content version.embedding_version_columnis the content version used to create that profile’s vector.
The vector is eligible only when both versions are non-null and equal. A source edit therefore makes the old embedding stale immediately, before a replacement vector is written.
CREATE TABLE documents (
id bigint PRIMARY KEY,
tenant_id text NOT NULL,
source_version bigint NOT NULL,
legacy_version bigint,
modern_version bigint,
legacy_embedding pgcontext.vector(1536),
modern_embedding pgcontext.vector(3072)
);
CREATE INDEX documents_legacy_hnsw ON documents
USING pgcontext_hnsw
(legacy_embedding pgcontext.vector_hnsw_cosine_ops);
CREATE INDEX documents_modern_hnsw ON documents
USING pgcontext_hnsw
(modern_embedding pgcontext.vector_hnsw_cosine_ops);
SELECT pgcontext.register_embedding_profile(
'documents',
'legacy_v1',
'legacy_embedding',
'public.documents_legacy_hnsw',
jsonb_build_object(
'representation', 'dense',
'dimensions', 1536,
'normalization', 'unit_l2',
'metric', 'cosine',
'provider', 'example',
'model', 'legacy',
'revision', '1',
'input_template', '{text}',
'output_template', '{vector}',
'bit_order', NULL,
'byte_order', NULL,
'scale', NULL,
'zero_point', NULL,
'configuration_hash', '0123456789abcdef',
'source_version_column', 'source_version',
'embedding_version_column', 'legacy_version'
),
'active'
);
Register the second profile in shadow while its embeddings are being
generated, then inspect coverage and promote it:
SELECT * FROM pgcontext.embedding_profile_coverage('documents');
SELECT pgcontext.set_embedding_profile_lifecycle(
'documents', 'modern_v2', 'active'
);
Coverage is invoker-scoped. Its active, current, and stale counts include only
mapped source rows visible under the caller’s current SELECT privileges and
row-level security policy; it does not reveal a collection-wide row count to a
tenant-restricted caller.
Only active and draining profiles serve queries. shadow accepts a
backfill without serving it. An active profile can move to draining, roll
back to active, or retire. retired is terminal. A failed profile must
return through shadow before serving again.
Query Multiple Profiles
SELECT pgcontext.query_multi_model(
'documents',
jsonb_build_array(
jsonb_build_object(
'profile', 'legacy_v1',
'configuration_hash', '0123456789abcdef',
'query', '[0.1, ...]',
'limit', 50,
'weight', 1.0
),
jsonb_build_object(
'profile', 'modern_v2',
'configuration_hash', 'fedcba9876543210',
'query', '[0.2, ...]',
'limit', 50,
'weight', 1.0
)
),
'{"must":[{"key":"tenant_id","match":"acme"}]}'::jsonb,
20,
60,
102,
true
);
Arguments after filter are final limit, RRF k, the global candidate
allowance, and require_all_profiles. The allowance includes one completeness
probe per branch, so two branch limits of 50 require at least 102 admissions.
Profile names must be unique and must select distinct source columns. Every
weight must be finite and positive. The configuration hash, vector syntax,
representation, dimensions, metric, version columns, source relation, and live
non-partial pgcontext_hnsw index are validated before branch execution.
For partitioned sources, pgContext resolves only indexes attached beneath the
registered parent index and admits at most 4,096 parent/child index identities
across the complete request. A larger hierarchy fails before EXPLAIN or
candidate work. EXPLAIN inspection also uses an iterative, node- and
depth-bounded walk, so a deeply nested partition plan fails closed.
The canonical 16 MiB extension-memory allowance covers retained request and profile metadata, candidate identity state, executor allocations, and the final JSON report. The SQL adapter preflights the simultaneous copies needed to cross the JSONB, typed-query, and executor boundaries; a request can therefore hit the memory allowance before the transport-neutral 16 MiB opaque query-text ceiling. Filter depth, nodes, and the 64 KiB scalar-byte allowance are checked before the adapter serializes, clones, or resolves catalog state. Only filter keys referenced by the shared filter are loaded; each registered JSONB path is capped at 16 segments and 8,192 bytes before Rust materializes it. Source identities longer than 1,024 bytes likewise fail before Rust materializes their text values.
With require_all_profiles = true, any missing, non-serving, changed, or stale
profile contract fails before candidate work. Setting it to false explicitly
allows ready branches to serve; the report then uses completion: "degraded"
and names every skipped profile and reason. Permission, malformed-request, and
source-contract failures never degrade.
The JSON report contains:
completionand orderedmissing_profiles;- one
branchesentry per declaration, including lifecycle, status, strategy, registration revision, index/source OIDs, candidate count, recheck count, retained count, and probe exhaustion; - ordered unique
resultswith point/source identity and fused score; - per-result
contributionswith profile, registration revision, current source version, exact-or-HNSW source kind, source authority, one-based rank, diagnostic native score, weight, and RRF contribution.
Native scores are diagnostic only. They are never normalized, averaged, or used to order candidates from different profiles. Final ties use ascending stable point ID.
Each branch normally reports hnsw_with_authoritative_recheck. pgContext
first verifies that PostgreSQL selected the registered HNSW index, charges the
access method’s reported traversal work plus one native score comparison for
every returned candidate to the query-wide budget, and then rereads the
bounded candidate identities under current
MVCC, ACL, RLS, deletion, filter, and version state.
PostgreSQL can occasionally complete a verified HNSW plan without reporting
any access-method work, including a cold first query behind RLS. In that case
pgContext does not treat an empty probe as a complete result. It counts the
complete invoker-visible, version-current corpus against the remaining
comparison allowance. If the corpus fits, the branch runs an exact scan and
reports exact_fallback_with_authoritative_recheck; otherwise the statement
fails with budget exhaustion. The fallback never returns a silently partial
branch.
Cutover and Migration Records
Embedding migrations now reference immutable profile names, not mutable model version rows:
SELECT * FROM pgcontext.create_embedding_migration(
'documents', 'legacy_v1', 'modern_v2', 100000
);
SELECT * FROM pgcontext.update_embedding_migration(1, 25000, 'running');
SELECT * FROM pgcontext.embedding_migrations();
The migration catalog tracks bounded progress; it does not run provider model
inference. The removed _model_versions, register_model_version, and
model_versions surfaces have no compatibility shim.
Operational Policy
Weights are workload policy, not a universal model calibration. The frozen
certification workload declares equal 1:1 weights before execution, uses
eight held-out queries over independent 4D and 8D spaces, and reports A-only,
B-only, fused, filtered-partial, and degraded curves. Each fused query uses two
50-candidate branches, k = 60, and a 102-candidate allowance; each single
baseline gets the same allowance. Dataset and workload hashes, raw samples,
latency/cost summaries, PostgreSQL version, hardware, and relevant GUCs are
part of the report contract.
The frozen equal-weight workload passes on PostgreSQL 17 and 18 at one million rows: fused recall is no worse than the stronger single-profile baseline under the same declared global budget. The workload, weights, dataset generator, and decision rule were fixed before those certification runs; no post-result tuning was used to obtain the Stable result. An application may intentionally choose a different calibrated tradeoff, but it must freeze and validate that policy on its own held-out workload.
Cancellation and PostgreSQL statement timeouts cover preparation, candidate
work, authoritative recheck, and final JSON construction. They abort the
statement and return no partial JSON report. Without a collection override,
the elapsed allowance is 500 ms. A collection owner may set
query_timeout_ms to widen this mixed-model path up to the global 60-second
ceiling; other canonical query surfaces continue to treat that setting only as
a tighter cap. Reported elapsed and memory usage
include preparation and report finalization. Default telemetry does not record query vectors,
filters, source keys, tenant values, or source text. Run the PG17 and PG18
tests/heavy/multi_model_coverage.sh and multi_model_rls_acl.sh gates before
a production cutover.
See Collections, SQL API contract, Security, and Operations.