Use the pgvector VectorStore
This page shows how to run PgVectorStore ā the VectorStore adapter backed
by Postgres with the vector
extension ā against a real server: starting a backend, constructing the store,
creating its schema, writing and searching, and shutting down.
When to use this page. Reach for pgvector when embeddings must outlive the
process, when more than one process reads them, or when a tenant holds more
vectors than you want resident in memory. Those three reasons are the whole
list.
InMemoryVectorStore is a complete implementation rather than a test double:
both adapters are subclasses of the same compliance suite in
src/redstring/testing/vector_store.py, and both pass its tier 1 ā exact
membership, exact ordering, exact scores ā not just the recall tier an
approximate backend would be held to. So a single-process job that rebuilds
its index each run needs nothing on this page, and moving to Postgres will not
change an answer it already gets.
It will not speed one up either. This adapter deliberately builds **no ANN
index**, so a search scans the querying tenant's rows and costs time linear in
that tenant's data ā the same asymptotics as scoring every vector in memory,
with a network hop added. ADR 0012
records why (an ANN index over a multi-tenant table takes the globally nearest
k and then drops other tenants' rows, which violates the port's
filter-before-k rule while still looking plausible), and BACKLOG B10k lists
the three ways out. Pick pgvector for durability, sharing and memory
footprint; do not pick it expecting sublinear search.
Both adapters are interchangeable behind the port, so this is a composition-
site decision and nothing above it changes. See
ADR 0002 for why the port exists at all, and
implement a store adapter if you are writing a
third.
Prerequisites. A Postgres server where the vector extension is
available ā the files installed ā and a role that may run
CREATE EXTENSION. A server that merely has the extension shipped but has
never created it cannot store a single vector; ensure_schema() runs the
CREATE EXTENSION IF NOT EXISTS for you, which is why the privilege matters.
There is no extra to install. asyncpg is a base dependency of
redstring, not an optional one ā unlike the Neo4j adapter, which needs
redstring[neo4j]. The adapter drives asyncpg directly; there is no ORM and
no SQLAlchemy import anywhere in this library.
Reachability today. PgVectorStore is not in redstring.__all__, so
it is reached by a dotted path:
from redstring.vector.adapters.pgvector import PgVectorStore
Per ADR 0006, a dotted path is
internal and may change without notice, including in a patch release. What is
public is the VectorStore port ā write your code against that, keep the
import above at one composition site, and the day the adapter is exported you
change one line. The types it hands back (VectorRecord, VectorMatch,
EntityId, TenantId) are all exported; see
Prerequisites: a Postgres with the `vector` extension available
You need a Postgres server where the
vector extension is available ā
its files installed on the server ā and a role that may run
CREATE EXTENSION. Availability alone is not enough: pgvector/pgvector:pg16
ships the extension, but a database that has never run CREATE EXTENSION
cannot store a single vector. ensure_schema() runs
CREATE EXTENSION IF NOT EXISTS vector for you on the first call, which is
exactly why the privilege ā not just the files ā is a prerequisite. On a
managed Postgres that means checking vector is on the provider's allow-list
and that your role can create it; on a locked-down server, have a superuser
create the extension once and the store's CREATE EXTENSION IF NOT EXISTS
becomes a no-op.
Nothing else is required. Postgres 16 is what the repo's test backend runs
(pgvector/pgvector:pg16); no plugin, no ANN index build, no schema
migrations of your own.
No extra to install: `asyncpg` is a base dependency, not an optional one
asyncpg sits in [project.dependencies], so uv add redstring is the whole
install ā there is no redstring[pgvector] to remember, and asking for one is
an error rather than a no-op. The optional extras are neo4j, llm, and all
(which is just the other two); the vector adapter is in none of them. This is
deliberately unlike the Neo4j GraphStore, which does need
redstring[neo4j].
The adapter drives asyncpg directly. There is no ORM, no first-party
SQLAlchemy import anywhere under src/, and no SQLAlchemy dependency declared
by this project ā six statements, three of them shaped around pgvector
operators and two of them (EXPLAIN, and an unnest batch insert) exactly the
SQL an ORM makes harder to write.
Be precise about that last claim, because the stronger version is false:
SQLAlchemy is still importable in your environment. eventsource-py is a
base dependency of redstring and requires SQLAlchemy in its base
dependencies, so it is in the lockfile of every install. The distinction that
matters is "nothing here imports it and nothing here asks for it", not "it is
absent" ā a reader who assumes the latter will be surprised by uv.lock.
Practically: the only thing that gates this adapter is a reachable server, not
a package. If from redstring.vector.adapters.pgvector import PgVectorStore
raises ImportError, the install is broken; it is never a missing extra.
Reachability today: `PgVectorStore` is not in `redstring.__all__`
InMemoryVectorStore is exported. PgVectorStore is not, so today you reach
it by a dotted path:
from redstring.vector.adapters.pgvector import PgVectorStore
Per ADR 0006, redstring.__all__
is the promise, and anything reached by a dotted path is internal: it may
move, be renamed, or change signature without notice, including in a patch
release. That is a statement about the import line, not about the adapter's
maturity ā it runs the same compliance suite, at the same tier, as the
exported in-memory one.
Everything that crosses the boundary is public, which is what makes the one
unexported name cheap to absorb:
- the
VectorStoreport itself ā annotate against it, never against
PgVectorStore;
- the values crossing it ā
VectorRecord,VectorMatch,EntityId,
TenantId (see domain value types);
- the errors it raises ā
DimensionMismatchError, andRedstringErroras the
base of every deliberate error here. So the except clauses of step 4 need
no internal import either.
Confine the dotted import to one composition site and the exposure is a single
line:
from redstring import VectorStore
from redstring.vector.adapters.pgvector import PgVectorStore # the one line
async def build_store(dsn: str) -> VectorStore:
store = await PgVectorStore.connect(dsn, dimension=768)
await store.ensure_schema()
return store
The day the adapter is exported, that is the line you change and nothing else;
substituting InMemoryVectorStore for a test is the same edit. That
interchangeability is what ADR 0002 exists
for, and what implement a store adapter asks of
a third implementation.
Step 1: Start a Postgres that ships pgvector
You need a reachable server, a database, and a role that may run
CREATE EXTENSION. Either option below gives you all three; pick A to get
going in a minute, B if the data has to live somewhere you already run.
Option A: the repo's test backend (`docker compose -f docker-compose.test.yml up -d postgres`, port 5434)
docker-compose.test.yml in this repo declares a Postgres for exactly this
purpose. Start it, and wait for it to be ready:
$ docker compose -f docker-compose.test.yml up -d --wait postgres
Three details of that command are worth knowing before the first time it
surprises you.
Name postgres explicitly. The compose file also declares a neo4j
service for the GraphStore integration tests. A bare up -d starts both, so
naming the service is what keeps you from running a graph database you did not
ask for.
--wait, because up -d returns before the server can serve. Without it,
up -d returns when the container is running, and a Postgres still doing
first-boot initialisation accepts TCP connections seconds before it will answer
a query ā so an immediate connect() races, and the failure surfaces as a
connection error that reads like a bad DSN rather than a timing problem. The
service declares a healthcheck for this (`pg_isready -U postgres -d
redstring_test, every 5s, up to 30 retries), and --wait` is what makes Docker
block on it instead of you.
The -d redstring_test inside that healthcheck is load-bearing, and worth
copying if you ever write your own: a bare pg_isready succeeds against the
bootstrap server before POSTGRES_DB has been created, so it reports healthy
while the database you are about to connect to does not exist yet.
The published port is 5434, not 5432. The mapping is 5434:5432 ā
5432 inside the container, 5434 on your host ā chosen (along with 7688 for
Neo4j) so the container cannot collide with a local Postgres install or with
another project's container.
The image is pgvector/pgvector:pg16, so the vector extension files are
already present and nothing has to be installed into the container. It runs as
the default superuser postgres, which settles the CREATE EXTENSION
privilege question from the prerequisites outright: ensure_schema() can
create the extension itself on first call, and you need run nothing by hand.
Password and database name are fixed in the compose file
(POSTGRES_PASSWORD: redstring, POSTGRES_DB: redstring_test), so the DSN is
fully determined:
postgresql://postgres:redstring@localhost:5434/redstring_test
store = await PgVectorStore.connect(
"postgresql://postgres:redstring@localhost:5434/redstring_test",
dimension=768,
)
await store.ensure_schema()
That exact DSN string is also the default the integration suite falls back to
when KG_TEST_POSTGRES_DSN is unset ā so a backend that works for your
application works for pytest -m integration with no environment variable at
all. See
run the integration and mutation suites
for the same backend from the suite's side.
This is a development and test convenience, not a deployment story. The
service declares no named volume, so its data sits in an anonymous one and
docker compose -f docker-compose.test.yml down -v discards the lot ā
convenient for resetting between experiments, and a reason not to point
anything you care about at port 5434. Anything durable belongs on your own
server; see Option B.
Option B: your own server (`CREATE EXTENSION vector` privileges required)
Any Postgres works provided the vector extension is available ā its
files installed on the server ā and your role may create it. Confirm both
before writing any code:
$ psql "$DSN" -c "SELECT name, default_version FROM pg_available_extensions WHERE name = 'vector'"
An empty result means the extension is not installed on the server, and no
amount of privilege will fix it: install the pgvector package for your
Postgres build, or on a managed service check the provider's allow-list of
extensions.
A row means you can proceed to the privilege question, which is separate.
ensure_schema() issues CREATE EXTENSION IF NOT EXISTS vector on every call
(see step 3), so the role the store connects as needs to be able to run it:
$ psql "$DSN" -c "CREATE EXTENSION IF NOT EXISTS vector"
If that succeeds, you are done ā the statement is idempotent, and having run
it by hand simply makes the store's copy a no-op. If it fails on privileges,
have a superuser (or the provider's console) run it once against the database
you will use. The store's CREATE EXTENSION IF NOT EXISTS then finds the
extension already present and succeeds without needing the privilege itself.
Note that the extension is per-database: creating it in postgres does
nothing for myapp.
Two privileges, then, and they are separate questions: CREATE EXTENSION (or
a superuser who has already run it for you), and ordinary CREATE on the
schema the store writes into ā ensure_schema() is the only DDL this library
runs, and it issues CREATE TABLE IF NOT EXISTS plus one
CREATE INDEX IF NOT EXISTS beside the extension. There is no migration tool
to install and no plugin to load. There is also no ANN index to build, and
that is deliberate rather than an omission: see
Two choices are yours rather than the server's, and both are covered in
step 2. Give the store a table name of its own if the database is shared ā
it must be a bare lowercase identifier, unquoted and unqualified by a schema,
so the store lands in whatever schema your search_path resolves to; set that
on the role or in the DSN rather than expecting the store to qualify the name.
And run one table per embedding dimension: vector(n) bakes the dimension
into the column type, so a 768-dimension store and a 1024-dimension one cannot
share a table, and pointing one at the other's table raises
DimensionMismatchError from ensure_schema() (step 4).
Postgres 16 is what this project tests against ā pgvector/pgvector:pg16 ā
and the adapter uses no syntax newer than that. Once your server answers,
point the integration suite at it with KG_TEST_POSTGRES_DSN to confirm it
satisfies the same compliance suite the in-memory adapter does; see
run the integration and mutation suites.
Step 2: Construct the store
Two constructors, and the difference between them is **who owns the connection
pool** ā which is the same thing as deciding what close() does.
`PgVectorStore.connect(dsn, dimension=..., table=...)` ā the store owns and closes the pool
from redstring.vector.adapters.pgvector import PgVectorStore
store = await PgVectorStore.connect(
"postgresql://postgres:redstring@localhost:5434/redstring_test",
dimension=768,
table="kg_vectors",
)
connect() is an async classmethod: it imports asyncpg, builds a pool of
its own with asyncpg.create_pool(dsn, **pool_options), and hands back a store
that has recorded it owns that pool. dsn is positional; dimension is
keyword-only and required; table is keyword-only and defaults to
kg_vectors. Both keywords are validated by the same constructor the injected
form uses, so an invalid dimension or table raises ValueError after the
pool has been created ā call it with values you have already settled.
**Owning the pool is the whole difference, and it is observable through
close().** On a store built this way, close() closes the pool; on a store
built by passing a pool in, close() does nothing to it. The integration
suite pins both halves ā one test closes an injected-pool store and then
proves the pool still answers SELECT 1, the other closes a connect()-built
store and requires the next search() to fail. Ownership follows whoever
created the pool, exactly as it does on the Neo4j adapter.
Reach for this constructor when the store is the only thing in the process
talking to that database ā a worker, a one-shot ingest job, a script. It is
also the shape to use inside a composition function, because the caller then
has one object to shut down rather than two:
from redstring import VectorStore
from redstring.vector.adapters.pgvector import PgVectorStore
async def build_store(dsn: str) -> VectorStore:
store = await PgVectorStore.connect(dsn, dimension=768)
await store.ensure_schema()
return store
Pool options pass straight through. Any further keyword argument goes to
asyncpg.create_pool untouched ā min_size, max_size, command_timeout,
ssl, server_settings and the rest. The adapter deliberately does not
restate asyncpg's signature; narrowing it here would mean maintaining a copy
that goes stale against the driver. Two consequences follow from not
restating it: asyncpg's own defaults apply (a pool of 10 connections unless you
say otherwise), and a misspelled option is asyncpg's TypeError, not a
redstring error.
store = await PgVectorStore.connect(dsn, dimension=768, min_size=1, max_size=4)
connect() creates a pool, not a schema. It runs no DDL and issues no
query of its own ā the first statement this library sends is the one
ensure_schema() sends, which is step 3. So a successful connect() says the
DSN parsed and asyncpg could establish its initial connections; it says nothing
about the vector extension, the table, or its dimension. If the call hangs or
refuses, the problem is reachability ā see the troubleshooting note on port
5434 versus 5432, and on up -d returning before the server can serve.
Whatever you built it with, pair it with close() at shutdown (step 6). The
call is the same on both constructors precisely so that swapping between them
is a one-line change and never an edit to your teardown.
`PgVectorStore(pool, dimension=..., table=...)` ā you injected the pool, `close()` leaves it alone
import asyncpg
from redstring.vector.adapters.pgvector import PgVectorStore
pool = await asyncpg.create_pool(dsn)
store = PgVectorStore(pool, dimension=768, table="kg_vectors")
The plain constructor is not async and connects to nothing: it wraps a pool
you already have. pool is positional; dimension is keyword-only and
required; table is keyword-only and defaults to kg_vectors. Both keywords
are validated here ā this is the constructor connect() itself calls ā so an
invalid dimension or table raises ValueError immediately.
close() is a no-op on an injected pool. Ownership follows whoever created
the pool, exactly as it does on the Neo4j adapter: you made it, you close it.
That is not a documented intention, it is a pinned one ā the integration suite
closes an injected-pool store and then asserts the pool still answers
SELECT 1, and separately closes a connect()-built store and requires the
next search() to raise on a closed pool. The two tests sit side by side in
tests/integration/vector/test_pgvector_store.py because either alone would
pass against a store that always closed, or never closed.
Reach for this constructor whenever the pool outlives the store:
- the pool is shared with the rest of your application, and the store is
one consumer of it among several;
- stores are short-lived ā built per request, per job, per test ā against
a pool that is not;
- you need pool options this adapter would otherwise pass through blind,
or a pool built by a framework or connection manager you do not control.
The compliance suite is the motivating case, and it is worth knowing because it
is what proved the split necessary. It builds a new store per test ā and per
hypothesis example within a property test ā against one pool created by a
fixture, then calls dispose() on each, which calls close(). Without the
ownership split the first disposal would take the pool down, and every test
after it would fail on a closed pool rather than on anything about pgvector.
(The fixture is function-scoped, not session-scoped: an asyncpg pool binds to
the event loop that created it, and the project's
asyncio_default_fixture_loop_scope is function. One pool per test is
still one pool across all of a property test's examples, which is the case the
split exists for.)
Nothing else differs between the two constructors. An injected-pool store runs
the same ensure_schema(), the same reads and writes, and passes the same
compliance suite at the same exact tier; the only observable difference is
what close() does to the pool.
So call close() at shutdown regardless of which constructor you used
(step 6). On an injected-pool store it releases nothing, and that is the point:
the teardown code reads the same either way, so swapping connect() for an
injected pool ā or back ā stays a one-line change.
store = PgVectorStore(pool, dimension=768, table="kg_vectors")
await store.ensure_schema()
try:
...
finally:
await store.close() # the pool is still yours, and still open
await pool.close() # you created it, so you close it
Prefer `async with`, which closes on every path out
The store is an async context manager, so a connect()-built store does not
need the try/finally at all:
async with await PgVectorStore.connect(dsn, dimension=768) as store:
await store.ensure_schema()
...
Note the await inside the async with: PgVectorStore.connect is itself a
coroutine, because it creates the pool.
__aenter__ returns the store. __aexit__ calls close() and returns None,
which is falsy ā so the block never suppresses: an exception from the body,
and a cancellation delivered while the body is suspended, both propagate after
the pool is released. That matters more here than it reads: a request cancelled
mid-query is exactly the case a forgotten finally leaks a pool on.
Since exit goes through close(), ownership is unchanged by the block. An
injected-pool store inside an async with leaves your pool open on the way out,
so the snippet above it is still the shape to write when you own the pool ā
just with the store's own close() in a block if you like.
Both store.dimension and store.table are readable back off the store, which
is the cheap way to confirm at a composition site that a shared pool is being
handed the table and dimension you meant.
Choosing `dimension`: it must match your embedding model exactly (e.g. 768 for nomic-embed-text)
dimension is keyword-only, required, and has no default ā because there is no
defensible one. It is the output width of the embedding model that feeds the
store, and nothing else:
| Model | dimension |
|---|---|
nomic-embed-text |
768 |
bge-m3 |
1024 |
OpenAI text-embedding-3-small |
1536 |
You do not have to keep the two in step by hand. Pass an
EmbeddingProvider and this store to build_graph together and the pair is
checked before anything is embedded:
await build_graph(
document,
provider=llm,
store=graph,
tenant_id=tenant_id,
embedding_provider=embedder, # declares its own `dimension`
vector_store=vectors, # declares its own `dimension`
)
A disagreement raises a ValueError naming both numbers at the call, rather
than surfacing later as a pgvector complaint about a column type after the
embedding API call has been paid for. Supplying one without the other also
raises, because the alternative is a successful-looking run that writes no
vectors. See
Take the number from your model's documentation, or measure it once ā
len((await embedder.embed(["probe"]))[0]) ā and pass that. A non-positive value is
rejected at construction with ValueError (`dimension must be positive, not
0`); every other integer is accepted, because the store has no way to know
which model you are about to use.
**Getting this wrong is a correctness problem, not a tuning one, and it fails
quietly.** Vectors of the wrong shape do not surface as an exception somewhere
downstream ā they surface as mediocre search results, which read as a mediocre
embedding model rather than as a bug. That asymmetry is why the store refuses
rather than coerces, and why DimensionMismatchError exists as a named error
instead of a ValueError.
The value is enforced in two places, and it is worth knowing they are separate
checks:
- On every vector crossing the port.
upsert,upsert_manyandsearch
each raise DimensionMismatchError(expected=ā¦, actual=ā¦) when
len(vector) != store.dimension. This is done **client-side, before the
round trip**, deliberately: Postgres would reject a wrong length too, but as
an opaque expected 8 dimensions, not 3 that names neither the store nor
the model. The same check also rejects a zero vector (with ValueError ā
cosine is undefined at the origin). Step 5 covers both.
- On the table, once, at
ensure_schema(). The column is declared
vector(n), so the dimension is baked into the column type.
ensure_schema() reads the declared width back out of pg_attribute and
raises DimensionMismatchError if an existing table disagrees, rather than
letting your first insert fail. Step 4 is entirely about that case.
Two consequences follow, and both are decisions to take now rather than to
discover later:
**Changing embedding model means a new store and a new table ā never an
in-place rewrite.** Give each model its own table name (kg_vectors_768,
kg_vectors_nomic) and backfill into it; see Choosing table below and the
one-table-per-dimension note in the operational section.
The dimension check is not a model check, and cannot be. Two different
models that both emit 768 components pass every check on this page while
producing vectors that are not comparable with each other ā the store sees
matching lengths and has nothing else to go on. Neither this adapter nor the
port can detect it, so *segregating tables by model, not merely by width, is
the caller's job*. It is the one failure mode on this page with no error
message attached to it.
store.dimension reads the value back, which is the cheap assertion to make at
a composition site where the embedder and the store are wired up separately.
For the shape of the check in your own adapter ā and why a length comparison
here is written to avoid is not ā see
Choosing `table`: bare lowercase identifiers only, no quoting, no schema qualification
table is keyword-only and defaults to kg_vectors. It must match
^[a-z_][a-z0-9_]{0,62}$ ā a leading lowercase letter or underscore, then
lowercase letters, digits and underscores, 63 characters at most. Anything
else raises ValueError("table must be a bare lowercase identifier, not ā¦")
at construction, on both constructors, before any connection is used.
What that excludes, in the words of the cases the unit suite pins:
| Rejected | Why it is not a bare identifier |
|---|---|
kg vectors |
whitespace |
public.kg_vectors |
schema-qualified |
KgVectors |
uppercase |
9lives |
leading digit |
"" |
empty |
"x" * 64 |
longer than Postgres's 63-byte limit |
kg_vectors"; DROP TABLE users; -- |
quoting, and the reason the rule exists |
Accepted, by the same suite: kg_vectors, _v, kg_vectors_test_gw0, and any
63-character run of the allowed alphabet.
Why the rule is a whitelist rather than an escaping routine. Postgres has
no parameter form for an identifier, so unlike every value this adapter sends,
the table name is interpolated into the SQL string ā it reaches
CREATE TABLE, CREATE INDEX, the unnest insert, the search, and both
deletes as text. The adapter therefore proves the name is boring rather than
trying to escape an arbitrary one: the set of names a vector store needs is
small, and a rejected name is a far clearer failure than a subtly mis-escaped
one. Pass a name from configuration and validate it at start-up, not from
per-request input.
**No quoting means the identifier is folded, so pick the folded form
yourself.** Because the name is never wrapped in double quotes, Postgres
down-cases it ā KgVectors would become kgvectors and the store would then
disagree with a table someone created as "KgVectors". Requiring lowercase up
front removes the whole class of question.
**No schema qualification means the table lands wherever search_path
resolves** ā normally public. The store will not qualify the name for you and
there is no schema= argument. If it belongs elsewhere, put the schema on the
connection rather than in the name:
store = await PgVectorStore.connect(
dsn,
dimension=768,
table="kg_vectors",
server_settings={"search_path": "embeddings,public"},
)
server_settings is asyncpg's, passed straight through by connect() (see
above); ALTER ROLE ⦠SET search_path or ?options=-csearch_path%3D⦠in the
DSN do the same job. Whichever you choose, apply it consistently ā the same
bare name under two search_paths is two different tables, and neither the
store nor ensure_schema() can tell.
Give each embedding dimension ā and each model ā its own table. The column
is vector(n), so the dimension is part of the column type and a 768 store
cannot share a table with a 1024 one; ensure_schema() raises
DimensionMismatchError when it finds a table declared at another width
(step 4). Segregating by model is the caller's job on top of that, since two
768-dimension models pass every check while producing incomparable vectors. A
name that says both is the cheapest defence:
store = await PgVectorStore.connect(dsn, dimension=768, table="kg_vectors_nomic")
One database can hold as many of these as you like; nothing in the adapter is
shared between tables.
A distinct name is also how concurrent work stays isolated. The integration
suite gives every xdist worker its own table ā
f"kg_vectors_test_{os.environ.get('PYTEST_XDIST_WORKER', 'main')}" ā so a
worker truncating between tests cannot delete rows another worker is reading.
That is the pattern to copy for anything that shares a database: parallel test
runs, a staging tenant, a backfill written beside a live table.
store.table reads the value back, next to store.dimension, which is the
cheap assertion to make at a composition site where a pool is shared and the
name came from configuration.
Constructing a store connects nothing and creates nothing ā no DDL has run
yet. That is step 3.
Step 3: Run `ensure_schema()` before the first read or write
Constructing the store ran no SQL. ensure_schema() is the first statement
this library sends, and it is the only DDL it ever runs:
store = await PgVectorStore.connect(dsn, dimension=768, table="kg_vectors")
await store.ensure_schema()
Call it once at start-up, before the first upsert, get or search.
Skipping it does not degrade anything gracefully ā the table does not exist,
so the first write fails with an UndefinedTableError from asyncpg.
There is no migration tool to install and no separate DDL script to keep in
step with the code: the statements are built from store.dimension and
store.table, so the schema is a function of the store you constructed.
What it creates: the extension, the table, and the two `tenant_id`-leading btrees
Four things, in one pooled connection:
CREATE EXTENSION IF NOT EXISTS vectorā which is why the
CREATE EXTENSION privilege is a prerequisite (step 1). On a database
where a superuser already created the extension, this is a no-op.
- The table,
CREATE TABLE IF NOT EXISTS, with columnstenant_id uuid,
entity_id uuid, embedding vector(n), entity_type text, and
metadata jsonb NOT NULL DEFAULT '{}'::jsonb.
- The primary key
(tenant_id, entity_id)ā the pair, not the entity id
alone. That is the composite the tenant-isolation properties rest on: a key
on entity_id alone would let one tenant's write replace another's, and
two tenants holding the same entity id is a legal arrangement here.
- A second btree,
_tenant_type_idx on
(tenant_id, entity_type),for the port's type filter.
Both indexes lead with
tenant_id, and that is the load-bearing partrather than an incidental column order: every query this adapter issues filters
on
tenant_idfirst, so a leadingtenant_idturns a tenant-scoped read intoan index seek instead of a scan across every tenant's rows. Correct answers do
not distinguish the two ā this project has already been bitten once by a
Neo4j query that returned exactly the right rows after scanning the whole
database ā so the primary key's column order is pinned by an integration
test that reads it back out of
pg_index, not by one that reads results. Thesecondary index's column order is fixed in the DDL the unit tests read.
Two things
ensure_schema()deliberately does not create:- No index on
embedding. Nohnsw, noivfflat. An integration test
asserts every index on the table uses the
btreeaccess method, so addingone is a decision someone has to come and argue for rather than a drive-by
optimisation. ADR 0012
is that argument; BACKLOG B10k holds the three ways out.
- No third index on
tenant_idalone. Either of the two above already
serves a tenant-only lookup from its leading column.
entity_typebeing a real column is worth knowing when you look at the tableby hand: it is a projection of
metadata["entity_type"], written from themetadata while the metadata is stored whole. The JSON stays the source of
truth. Filtering
metadata->>'entity_type'on every row would be a per-rowJSON parse the planner cannot index, which is why the column exists ā but
filter through the port's
entity_types=[ā¦]argument rather than reaching forthe column yourself.
Why it is idempotent and safe to call on every startup
Every statement it sends carries
IF NOT EXISTSā the extension, the table,and the secondary index alike. A unit test walks the DDL tuple and asserts
IF NOT EXISTSappears in each statement individually, and an integration testcalls
ensure_schema()twice in a row and requires the second call not toraise. So it is safe to run unconditionally on every process start, in every
replica ā no "have I migrated yet?" flag, no first-run branch:
async def build_store(dsn: str) -> VectorStore: store = await PgVectorStore.connect(dsn, dimension=768) await store.ensure_schema() # every start, not just the first return storeIdempotent is not the same as verified, and the distinction cost this project a
real defect. Every other integration test in the module runs against a table an
earlier run had already created, so **the DDL loop could have executed nothing
at all and the whole suite would still have passed** ā cosmic-ray proved it by
replacing the statement iterable with
[], and the mutant survived. The testthat kills it is the one that drops the table first and starts from genuinely
nothing (
test_ensure_schema_creates_the_table_from_nothing), and it assertsthe table is usable ā upsert, get, and a type-filtered search ā not merely
present. If you write your own schema step, that is the shape of test it needs;
see implement a store adapter.
Two limits on what "idempotent" promises here:
- It is not a migration.
CREATE TABLE IF NOT EXISTSwill not alter a
table that already exists, so it cannot add a column or widen one. Changing
the schema means a new table.
- It does not silently accept a mismatch. After running the DDL,
ensure_schema()readsatttypmodfor theembeddingcolumn back out ofpg_attributeand raises `DimensionMismatchError(expected=, actual=
)` when the two disagree. That check runs on every call, not only the first, so a store pointed at another model's table fails
at start-up rather than on its first insert. That is step 4.
Step 4: Handle a dimension mismatch on an existing table
DimensionMismatchErroris exported fromredstring, so catching it needs nointernal import:
from redstring import DimensionMismatchErrorIt is raised from two different places for two different reasons, and telling
them apart is the whole of this step: **
ensure_schema()raises it about atable, once, at start-up;
upsert,upsert_manyandsearchraise itabout a vector**, client-side, on every call.
Why `vector(n)` bakes the dimension into the column type
The embedding column is declared
vector(768), notvector. The width is partof the column type, the same way
varchar(20)is ā so a table created for768-component vectors cannot hold 1024-component ones, and no configuration on
the store changes that. This is why the rule stated in step 2 is a hard one:
one table per embedding dimension, and in practice one table per model,
since two 768-dimension models pass every check while producing incomparable
vectors.
ensure_schema()cannot fix a table that disagrees. `CREATE TABLE IF NOTEXISTS` does nothing when the table is there, and this library runs no other
DDL ā there is no migration step that would
ALTERthe column, and adding onewould not help anyway: re-typing the column means re-embedding every row, which
is a backfill only you can perform.
What `DimensionMismatchError` from `ensure_schema()` means, and the two ways out
After running its DDL,
ensure_schema()reads the declared width straight backout of the catalogue:
SELECT atttypmod FROM pg_attribute WHERE attrelid = $1::regclass AND attname = 'embedding' AND NOT attisdroppedand raises when it differs from
store.dimension. So the error means exactlyone thing: **a table with this name already exists and was created for a
different dimension.** Your store is pointed at another model's data.
store = PgVectorStore(pool, dimension=768, table="kg_vectors") await store.ensure_schema() # redstring.domain.exceptions.DimensionMismatchError: # expected a vector of dimension 1024, got 768**Read the two numbers carefully, because on this call
expectedis thetable.**
raised.value.expectedis the width declared on the existing column(1024 above) and
raised.value.actualis thedimensionyou constructed thestore with (768). That is the opposite way round from the same error raised by
upsertorsearch, whereexpectedis the store's dimension andactualisthe length of the vector you passed. Both readings are natural in place ā
"expected" is whatever the thing being checked against says ā but code that
inspects the attributes has to know which call it caught.
Two ways out, and they are the only two:
- Point this store at its own table. Almost always the right answer, and a
one-line change:
```python
store = PgVectorStore(pool, dimension=768, table="kg_vectors_nomic")
```
Both tables then live in the same database, the old model's rows keep
serving whatever still reads them, and nothing needs re-embedding.
- Retire the old table and re-embed into a new one. Choose this when the
old data is genuinely dead. Create the new table under a new name, backfill
by re-embedding the source documents with the new model, cut readers over,
then drop the old table. Do not
DROP TABLEfirst and re-runensure_schema()in the hope of reusing the name: that discards theembeddings before the replacements exist, and the store cannot regenerate
them ā this library never fetches content, and re-embedding is your
pipeline's job.
There is deliberately no third way. The store will not widen the column, will
not truncate or pad your vectors to fit, and will not fall back to a different
table on its own.
The check runs on every
ensure_schema()call, not only the first, whichis what makes it useful: a replica that starts with a stale
dimensionin itsconfiguration fails at start-up with a message naming both numbers, rather than
serving traffic until its first write. Both directions are pinned by the
integration suite at a realistic 768 ā a table one component larger than the
store fails as loudly as one smaller, because a check that caught only the
too-small case would let the too-large one through to fail on an insert, which
is precisely the error this exists to replace.
The check is also silent on a table that does not exist yet:
atttypmodcomesback
Noneand nothing is raised, which is the fresh-database path fromstep 3.
The same error from `upsert`/`search`: a wrong-length vector, checked client-side
The other source is a vector, not a table.
upsert,upsert_manyandsearcheach validate every vector against
store.dimensionbefore touching the pool:store = PgVectorStore(pool, dimension=768, table="kg_vectors") await store.search([0.1, 0.2], tenant_id) # DimensionMismatchError: expected a vector of dimension 768, got 2Here
expectedis the store's dimension andactualislen(vector).The check is client-side on purpose, and it is not merely a faster version
of what Postgres would do:
- Postgres's own complaint is an opaque
expected 8 dimensions, not 3that
names neither the store, the table, nor the model ā and it costs a round
trip to obtain.
- Postgres would not reject a zero vector at all.
<=>against the origin
yields NaN, which sorts unpredictably and would make ranking depend on the
query plan. The same guard therefore raises `ValueError("a zero vector has
no direction; cosine is undefined for it")
for that case ā aValueError`,not a
DimensionMismatchError, since the length was fine.upsert_manyvalidates every element of the batch you passed, includingrecords that a later record in the same call supersedes, and it does so before
deduplicating or writing anything. So a batch either lands whole or raises
having written nothing, and a record cannot escape validation by being replaced
ā which is the behaviour
InMemoryVectorStorehas too, and the sharedcompliance suite pins for both.
Because the same guard runs in both adapters, a wrong-length vector fails
identically in your unit tests against
InMemoryVectorStoreand in productionagainst Postgres. That is the property ADR 0002
is for, and the shape implement a store adapter
asks a third implementation to reproduce ā note in particular that the length
comparison there is written to avoid
is not, because a store built at arealistic 768 sits outside CPython's small-integer cache and an identity
comparison would reject every legitimate write.
Do not catch this one and retry. A wrong-length vector is a wiring bug:
either the embedder and the store were constructed from different numbers, or
two models are feeding one store. The fix is at the composition site, and
store.dimensionā readable off the store ā is the cheap assertion to makethere. There is no embedding port in this library to compare it against ā the
width is a number you hold ā so assert it against the constant your pipeline
embeds with:
EMBEDDING_DIMENSION = 768 # nomic-embed-text assert store.dimension == EMBEDDING_DIMENSIONIn the projection path, a vector of the wrong length is treated as a **poison
event** rather than a transient failure, for the same reason: retrying it
cannot succeed. See
drive projections from an event store.
Step 5: Write and read
With the schema in place, the store is the
VectorStoreport and nothing more.Write with
upsertorupsert_many, read one record back withget, and rankwith
search:from redstring import TenantId, EntityId, VectorRecord await store.upsert( entity_id, embedding, # len(embedding) == store.dimension tenant_id, metadata={"entity_type": "person", "name": "Ada Lovelace"}, ) record = await store.get(entity_id, tenant_id) # None if this tenant has no such id matches = await store.search(query_vector, tenant_id, k=5, entity_types=["person"])Everything below is port behaviour, not pgvector behaviour:
InMemoryVectorStoreanswers identically, and the shared compliance suite in
src/redstring/testing/vector_store.pyis what holds the two together.Upsert semantics: last-write-wins, metadata replaced wholesale rather than merged
upsertisupsert_manywith one record ā literally, it builds aVectorRecordand delegates ā so everything here holds for both.The key is the ordered pair
(tenant_id, entity_id), which is the table'sprimary key. Writing the same key twice leaves one row holding the later
value: the insert carries
`ON CONFLICT (tenant_id, entity_id) DO UPDATE SET embedding = EXCLUDED.embedding,
entity_type = EXCLUDED.entity_type, metadata = EXCLUDED.metadata`. So an upsert
is idempotent, replaying one is safe, and a projection can be rebuilt from the
event log without a wipe.
Because the key is the pair, two tenants holding the same entity id are two
rows and neither write can touch the other's. That is not incidental: it is the
composite the tenant-isolation properties rest on, and a key on
entity_idalone would let one tenant's write vouch for another's.
metadatais replaced wholesale, never merged. The update setsmetadata = EXCLUDED.metadata. There is no||merge, andmetadata=Noneonupsertmeans the empty mapping (metadata or {}), not "leave what wasthere":
await store.upsert(entity_id, embedding, tenant_id, metadata={"a": 1, "b": 2}) await store.upsert(entity_id, embedding, tenant_id, metadata={"a": 9}) (await store.get(entity_id, tenant_id)).metadata # {"a": 9} ā not {"a": 9, "b": 2}This is a correctness requirement rather than a simplification. A merge would
let a key that a later event removed survive in the store, so the result of a
replay would depend on the order and grouping of the events it happened to
see ā the projection would stop being a function of the log. Wholesale
replacement makes each write state the record's complete metadata, which is
what makes replay deterministic.
If you want the old keys, do the merge yourself, explicitly, at the level that
knows whether the removal was intended:
current = await store.get(entity_id, tenant_id) merged = {**(current.metadata if current else {}), **new_metadata} await store.upsert(entity_id, embedding, tenant_id, metadata=merged)The embedding is replaced too, on the same conflict clause, so re-upserting
a key with a new vector re-points it rather than accumulating versions. There
is no history in this table; the event log is the history.
entity_typefollows the metadata automatically. The column is written onevery insert and on every update from
entity_type_of(record.metadata)āthe port's single reading of the convention ā so it can never drift from the
JSON. A write whose metadata drops the key sets the column back to
NULL, andthe record then matches no type filter. Only a
strcounts: absent,None,7,["person"]and{"x": 1}all yield no type rather than an error or acoercion, so the record is still stored and still found by an unfiltered
search. That rule lives in
ports/vector_store.pyprecisely because the twoadapters once wrote their own and diverged ā pgvector nulling non-strings while
the in-memory store raised
TypeErroron a stored list.What comes back is yours.
getdecodes the row into a freshVectorRecord, so mutating the vector list or the metadata dict it hands backcannot change stored state, and a later read is unaffected. The vector also
round-trips exactly: the read casts
embedding::real[]rather than lettingpgvector render the value as text, because the text form rounds to seven
significant digits (
128.390625comes back as128.39062) even though thestored
float4is exact. That asymmetry was found by a round-trip property,not by reading documentation ā worth knowing if you query the column yourself.
getreturnsNonewhen this tenant has no such id, which is not the samequestion as whether the id exists in the table.
Batch upserts collapse repeated `(tenant_id, entity_id)` keys
upsert_manysends the whole batch as one statement āunnestover fivearrays ā so a thousand-record batch is a single round trip, not a thousand.
Repeated keys within one call are collapsed before the insert, keeping the
last occurrence, which is the same last-write-wins rule that applies across
calls. That collapse is required rather than an optimisation: Postgres refuses
to let one statement affect a row twice (
cannot affect row a second time),and
upsert_many([record, record])is an ordinary call.Validation runs over the list you passed, before any deduplication:
await store.upsert_many([bad_length_record, good_record_with_same_key]) # DimensionMismatchError ā even though the second record supersedes the firstThe ordering matters and is pinned. Deduplicating first would let a rejected
record disappear because a later one happened to replace its key, so the same
call would raise against Postgres and succeed in memory ā exactly the
divergence the compliance suite exists to catch, and it once missed it because
the test used two distinct keys. A batch therefore lands whole or writes
nothing.
Search: filters apply before `k`, results are exact, scores are on the port's 0..1 scale
One statement does filter, then rank, then limit, in that order:
WHERE tenant_id = $1 AND ($3 OR entity_type = ANY($4::text[])) AND ($5::float8 IS NULL OR score >= $5) ORDER BY score DESC, entity_id::text ASC LIMIT $6Filters apply before
k. SQL gives that for free, sinceWHEREruns aheadof
ORDER BYandLIMITā and it is only free because there is no ANN index.A store that took the
knearest and then filtered would return fewer thankresults while matching records sat further down the ranking: correct-looking,
wrong, and indistinguishable from a tenant with little data. That is the
reasoning behind ADR 0012,
and the reason searches here are exact rather than approximate ā this
adapter passes the compliance suite's exact tier for the same reason the
in-memory one does.
entity_types=Nonemeans no filter;entity_types=[]matches nothing. Arecord whose
metadata["entity_type"]is absent,None, a number, a list oran object matches no type filter at all ā only a
strcounts, and7is notcoerced to
"7".Scores are
(1 + cosine) / 2, higher meaning more similar ā1.0foridentical direction,
0.5for orthogonal,0.0for opposite. In SQL that is1 - (embedding <=> $2::vector) / 2, since<=>is pgvector's cosinedistance.
min_scorereads on that same scale and drops results scoringstrictly below it; note
min_score=0.0is not a no-op, and getting thescale backwards is a silent inversion that returns the worst matches first
while still looking like a ranking. The compliance suite pins the numbers
against
redstring.domain.vector.cosine_score, not merely their order.Ties break on ascending
entity_idas its canonical lowercase hyphenatedstring, so the ordering is total and
kcuts through a tie the same way onboth adapters.
Cost is linear in the querying tenant's rows, served by the primary key's
leading
tenant_idā never a scan across other tenants.Rejected inputs: zero vectors, negative `k`
Three rejections, all before the pool is touched:
Input Raises len(vector) != store.dimension(upsert, upsert_many, search)DimensionMismatchError(expected=store.dimension, actual=len(vector))a vector whose every component is zero ValueError("a zero vector has no direction; cosine is undefined for it")search(..., k=-1)ValueError("k must not be negative")A zero vector is rejected because cosine is undefined at the origin and
pgvector's
<=>yields NaN there, which sorts unpredictably ā ranking woulddepend on the query plan. Postgres would not reject it at all, which is half of
why the guard is client-side (step 4 is the other half).
k=0is legal and returns[]without querying, whatever the tenant holds.That boundary is written as an explicit example rather than left to a property
drawing
kfrom a range: whether a sampler reaches0depends on the seed andon
KG_COMPLIANCE_MAX_EXAMPLES, and two mutants wideningk < 0tok <= 0died on one mutation run and survived the next because of it.
None of these are worth catching and retrying ā each is a wiring bug at the
composition site, and a retry cannot succeed. See
implement a store adapter for reproducing the
same guards in a third implementation, and
domain value types for
VectorRecord,VectorMatchand the id types crossing the port. - No index on