IA Apr 30, 2026 · 7 min read

Embeddings + pgvector: semantic matching in PostgreSQL

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

When I built the matching engine at JXBS, the first decision wasn't which embedding model to use — it was where to store the vectors. And I chose not to add a dedicated vector database. The embeddings live in the same PostgreSQL that already holds everything else: candidates, openings, applications. With the pgvector extension, Postgres stores, indexes, and searches by similarity without me having to keep two systems in sync. This article is the reasoning behind that call and how I actually built it.

Why I stayed in Postgres

The pull toward Pinecone, Weaviate, or Qdrant is real. But every new datastore is one more thing to operate, monitor, back up, and keep consistent. In a recruitment product, semantic similarity never travels alone: I always cross it with structured data. Location, seniority, availability, whether the opening is still active. If the vectors live in another system, every search becomes two queries and a join stitched together by hand in application code.

Keeping it all in Postgres buys me three concrete things. Transactions: when I update a candidate and their embedding, either both land or neither does. Joins: I filter on structured columns and order by vector distance in the same query. And operational simplicity: one backup, one place to monitor, one Prisma migration. On ECS Fargate with OpenTofu, every extra piece of infrastructure is paid for in maintenance time.

How embeddings work, at a practical level

An embedding is a vector of numbers representing the meaning of a piece of text. Similar texts land close together in that space; unrelated ones land far apart. I don't need to understand high-dimensional geometry to use it: I hand the text to an embedding model, it returns an array of floats, and I store it.

What matters is which text I feed it. For a candidate I don't pass the raw résumé wholesale — I build a structured summary of experience, technologies, and role. For an opening, the title plus the actual requirements. Garbage in, garbage out: embedding quality depends on the quality of the source text far more than on the model.

Storing vectors with pgvector

pgvector adds a vector type and distance operators. The table looks like this:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE candidate_embedding (
  candidate_id  UUID PRIMARY KEY REFERENCES candidate(id),
  embedding     vector(1536) NOT NULL,
  seniority     TEXT NOT NULL,
  location      TEXT NOT NULL,
  is_available  BOOLEAN NOT NULL DEFAULT true,
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ON candidate_embedding
  USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

The <=> operator is cosine distance. Smaller distance, higher similarity. I pick cosine because I care about the vector's direction, not its magnitude.

Index choice: HNSW vs IVFFlat

pgvector offers two index types and the difference matters. IVFFlat groups vectors into lists and searches only the nearest ones: it builds fast and stays small, but its recall depends on how many lists you probe and it struggles as the data grows. HNSW builds a layered navigable graph: better recall at lower query latency, in exchange for slower builds and higher memory use.

I went with HNSW. In recruitment, a relevant candidate who never surfaces is a silent failure — nobody reports it, but it quietly degrades the product. I'd rather pay in index build time and RAM for stable recall. ef_construction controls graph quality at build time; at query time, raising ef_search improves recall at the cost of latency. That's the lever for the trade-off, and I move it based on what I measure, not on a hunch.

💡 Cosine distance tells you what's similar; it doesn't tell you what's right. That gap is the whole product.

Hybrid search: why I don't trust cosine alone

This is the point that took me longest to learn. Semantic similarity is great at finding things that resemble each other, but "resembles" isn't "correct." A senior backend engineer in Caracas and a junior one in another time zone can have close embeddings because they share technologies. Cosine has no idea the opening demands high seniority and local presence.

So I run hybrid search: vector similarity is a signal, not the verdict. I filter with WHERE on structured columns and blend the distance with a structured score.

SELECT
  c.candidate_id,
  1 - (c.embedding <=> $1::vector)              AS similarity,
  CASE WHEN c.seniority = $2 THEN 0.3 ELSE 0 END AS seniority_boost
FROM candidate_embedding c
WHERE c.is_available = true
  AND c.location = $3
ORDER BY (c.embedding <=> $1::vector)
         - CASE WHEN c.seniority = $2 THEN 0.3 ELSE 0 END
LIMIT 20;

The WHERE trims the space to eligible candidates before ranking. Semantic similarity orders within that set, and a structured boost adjusts the final order. The weights are configurable, and I tune them against what the recruitment team considers a good match, not against an abstract cosine metric.

Chunking and keeping embeddings fresh

Long résumés don't go in as a single pass. I split them into meaningful sections (experience, skills, education) so the "backend experience" embedding isn't diluted by three paragraphs of hobbies. It's pragmatic chunking, guided by document structure rather than a fixed token size.

Freshness: an embedding is a snapshot of the text at a moment in time. If a candidate updates their profile or an opening changes its requirements, the vector goes stale. I store a hash of the source text; when it changes, I re-enqueue regeneration. That way I only recompute what actually changed, and I don't burn model calls for nothing.

Costs, and when I would reach for a vector DB

Generating embeddings costs per token, and that's the real spend — not storage. Re-enqueuing only on a hash change keeps the bill in check. HNSW is heavy in RAM, so I size the Postgres instance with the index in mind, not just the rows.

When would I leave Postgres? If I hit hundreds of millions of vectors with very high, sustained search traffic, where the index no longer fits comfortably in memory and latency becomes the business bottleneck. At that scale, a dedicated vector engine with sharding earns its keep. But that's a decision you make with real load numbers, not by front-running a problem that may never arrive. Until then, Postgres with pgvector does the job and leaves me with fewer moving parts that can break.

Yohangel Ramos

Written by Yohangel Ramos

Senior Fullstack Developer and Tech Lead. I build with React, Next.js, Nest.js and AWS — and I write about what I learn along the way.

Let's talk →

Keep reading

IA

AI-built startups in 2026: the real numbers behind the hype

IA

How to survive as a programmer in the AI era (without turning cynical or naive)