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.