pgvector on Postgres you already run: prove you picked the right distance operator
Use pgvector in the Postgres you already run instead of standing up a dedicated vector database, and prove the distance operator you query with (<-> L2, <=> cosine, or <#> inner product) matches your embeddings, so your semantic search doesn't silently rank the wrong rows first.
Run this workflow
CI-verified, 2/2 fixtures passing.
Build this with your agent
One copy-paste hands Claude Code, Codex, or Cursor the full recipe, steps included, nothing to fetch.
Intended Use
Anyone adding retrieval to an app who is about to install a dedicated vector DB and probably does not need one yet. CI ranks the same query under all three pgvector operators over sample vectors and asserts they pick different nearest rows, then shows cosine and inner product agree after L2-normalization, so <=> is the safe default. Pure Python stdlib, no server, no network, no key. Which operator matches YOUR embedding model, the actual SQL, and ANN recall tuning are fenced.
Not for
- Assuming any operator works: if your model is cosine-trained and you query with <-> (L2) or un-normalized <#> (inner product), the top-k is silently wrong; match the operator to your embeddings, or L2-normalize and use <=> (vector_cosine_ops)
- Trusting approximate-index recall: IVFFlat and HNSW trade recall for speed, so measure recall@k on your own data and tune ivfflat.probes / hnsw.ef_search rather than assuming 100%
- Expecting a vector store to fix bad retrieval: quality is bounded by your embeddings, chunking, and query strategy; swapping stores will not rescue results doomed by 2,000-token chunks
- Billions of vectors, or many millions with heavy filtered query load: at true scale reach for Qdrant or Milvus; pgvector shines from thousands to a few million rows on Postgres you already run
- Reading a store's headline license as covering its cloud: most dedicated vector DBs are open-core, so auth, backups, and HA are usually the paid tier, price the version you will actually run
The Stack
Tested Against
github.com/pgvector/pgvector (2026-07)python@3.9-3.14Side effects & data flow
- Network
- none, local only
- Writes
- no filesystem writes
- Credentials
- none required
Prerequisites
- Python 3 for the operator check (no packages)
- For the real query: Postgres 12+ with the vector extension (CREATE EXTENSION vector)
Steps
- 1
Prove the three pgvector operators disagree, and which one is safe
pgvector exposes <-> (L2), <=> (cosine), and <#> (negative inner product). Over three sample rows and one query, CI ranks the query under each operator: L2 rewards the row closest by raw distance, cosine the row closest by direction, and inner product the row with the largest magnitude, so they pick three different nearest rows. Then it L2-normalizes and shows cosine and inner product now agree, which is why <=> (or normalized <#>) is the safe default for cosine-trained embeddings.
python3 - <<'EOF' import math docs = {"near_by_distance": [0.9, 0.4], "right_direction": [3.0, 0.05], "big_magnitude": [9.0, 7.0]} q = [1.0, 0.0] def dot(u, v): return sum(a * b for a, b in zip(u, v)) def l2(u, v): return math.sqrt(sum((a - b) * (a - b) for a, b in zip(u, v))) def cosine_distance(u, v): return 1 - dot(u, v) / (math.sqrt(dot(u, u)) * math.sqrt(dot(v, v))) def neg_inner_product(u, v): return -dot(u, v) def nearest(fn, D, query): return min(D, key=lambda k: fn(D[k], query)) l2_top = nearest(l2, docs, q) cos_top = nearest(cosine_distance, docs, q) ip_top = nearest(neg_inner_product, docs, q) assert len({l2_top, cos_top, ip_top}) == 3, "the three operators should pick different rows on un-normalized vectors" def unit(v): n = math.sqrt(dot(v, v)) return [x / n for x in v] ndocs = {k: unit(v) for k, v in docs.items()} nq = unit(q) ncos = nearest(cosine_distance, ndocs, nq) nip = nearest(neg_inner_product, ndocs, nq) assert ncos == nip, "after L2-normalization cosine and inner product must agree" print("operator check OK: on un-normalized embeddings pgvector's three operators pick DIFFERENT nearest rows (<-> L2=" + l2_top + ", <=> cosine=" + cos_top + ", <#> inner-product=" + ip_top + "); after L2-normalization <=> and <#> agree (" + ncos + "), so cosine is the safe default. Which operator matches YOUR embedding model, the SQL, and ANN recall tuning are fenced") EOF - 2
Wire it into your Postgres (the parts CI cannot do for you)
In your own database run CREATE EXTENSION vector, add a vector(N) column sized to your model, and build an index whose op-class matches your operator: hnsw (embedding vector_cosine_ops) for <=>, vector_l2_ops for <->, vector_ip_ops for <#>. Query with the SAME operator, for example ORDER BY embedding <=> query LIMIT k. Then measure recall@k against an exact scan and tune hnsw.ef_search or ivfflat.probes. Which operator matches your embeddings and what recall you accept are the fenced parts.
Eval, 2 fixtures
Last passed: verified todayoperator-okcontainstimeout 30s · max $0Expected:
operator check OK: on un-normalized embeddings pgvector's three operators pick DIFFERENT nearest rows (<-> L2=near_by_distance, <=> cosine=right_direction, <#> inner-product=big_magnitude); after L2-normalization <=> and <#> agree (right_direction), so cosine is the safe default. Which operator matches YOUR embedding model, the SQL, and ANN recall tuning are fencedclean-exitexit_codetimeout 30s · max $0Expected:
0
Results
The vector database is the most over-adopted piece of the AI stack. If you are searching thousands to a few million chunks, pgvector, which is just Postgres you likely already run, does vector search without a second system to deploy, back up, and secure (it is what Supabase's vector offering is built on). But pgvector gives you three distance operators, and the one you pick has to match how your embedding model was trained: query cosine-trained embeddings with <-> (L2) or un-normalized <#> (inner product) and your nearest neighbor is silently the wrong row. This recipe turns that into a check, and reaching for Qdrant, Milvus, or Weaviate is for when real scale or hybrid search demands it, not before.
Did this work for you?
Our CI checks the setup runs. You tell us if the whole thing worked. Tell us straight.
Related workflows
- zvec: run vector search inside your app, no server, offline
- LlamaIndex: index your documents and query them at runtime
- Crawl4AI: a page to clean, LLM-ready markdown (no API key)
- Khoj + Fable 5: A Second Brain That Knows Your Notes
- Cognee: Knowledge-Graph Memory Over Your Documents
- Obsidian × MCPVault: Write a Note from Any MCP Client
Liked this workflow?
Get new verified workflows in WebAfterAI, three issues a week (Tue, Thu, Sat).