Job search on Postgres and pgvector

Keeping vectors next to the rows they describe removes a whole category of problems — chiefly that your two data stores can disagree.

JobsDart Editorial5 min read

Key takeaways

  • One transaction for the job and its embedding removes the reconciliation problem entirely.
  • One row per embedded chunk, with the model and chunking version recorded.
  • Build time and incremental updates usually matter more than peak query speed here.
  • Filters are ordinary SQL in the same query — check the plan rather than assuming.
  • Leave on measurements, not on the corpus feeling large.

The argument for staying in Postgres

A job matching system needs vectors and also needs everything else: employers, applications, candidates, statuses, permissions. Those relationships are relational, and putting the vectors in a separate system means every query spans two stores.

The decisive benefit is transactional consistency. Insert the job and its embedding in one transaction and they cannot disagree. With a separate vector store, a partial failure leaves an orphaned vector or a job with no vector, and reconciling that is ongoing work.

The failure that removes is the one candidates notice. A posting closing in the primary database while its vector survives in a separate index produces search results pointing at roles that no longer exist, and two dead links are enough to lose a user’s trust in the whole feed.

Schema: one row per embedded unit

Do not put a single vector column on the jobs table, because you will want to embed individual requirements rather than whole postings. Use a separate table with one row per embedded chunk, referencing its parent.

Store the model name and chunking version on each row. Without that you cannot migrate models safely, and mixing vector generations produces results that look reasonable and are meaningless.

Make the active version part of the query rather than a column somebody remembers to filter on. A query that cannot return the wrong generation cannot accidentally mix two, which is a stronger guarantee than a convention every future query must observe.

  • job_embeddings: job_id, chunk_index, chunk_text, embedding, model, version
  • A foreign key with cascade so deleting a job removes its vectors
  • An index on job_id for retrieving all chunks of one posting
  • The vector index built on the embedding column
Decisions worth making deliberately
DecisionDefault that worksWhy
Where the vector livesA separate chunk tableYou will embed requirements, not postings
Version trackingModel and chunking on each rowMigration is otherwise impossible
DeletionCascade from the jobOrphaned vectors serve dead roles
ExpiryActive flag, filteredHard deletes from an ANN index are costly
Index buildA maintenance windowIt interferes with write traffic
RecallMeasured against exact searchIt degrades silently otherwise

Index choice, briefly

The practical trade-off is between an index that builds quickly and queries adequately, and one that builds slowly, uses more memory and queries faster with better recall. For a job board with continuous inserts, build time and incremental updates matter more than peak query speed.

Whichever you pick, the mistake to avoid is tuning for latency without measuring recall. Compare against an exact scan on a sample — an index returning results quickly while missing the best matches is invisible until someone notices the matching "got worse".

Track that number over time rather than measuring once. Recall drifts as the data distribution changes, so a parameter that was right at fifty thousand vectors is not necessarily right at five hundred thousand, and nothing will alert you.

Combining vector search with SQL filters

This is where Postgres genuinely shines: your filters are ordinary SQL against columns that already exist. Active jobs, in these countries, within this salary band, posted recently — all in the same query as the similarity ordering.

Watch the planner though. With a highly selective filter, an approximate index can be the wrong choice and an exact scan over the filtered subset is faster and more accurate. Check the plan rather than assuming the vector index is always the right path.

The case that catches people is the middle one. A filter selective enough to matter but not enough for the planner to abandon the index leaves the approximate search exploring rows the filter then removes, so the query returns fewer results than requested — which reads as a thin market rather than a plan problem.

Keeping the index fresh

Job data turns over constantly, which makes freshness a correctness property rather than housekeeping. A posting that becomes searchable six hours after publication is one the early applicants already took, and a filled role still in the index costs trust.

Embed on insert where you can and run a sweeper for whatever the insert path missed. A pending-embedding flag processed in batches with retries degrades gracefully during a provider outage, where a nightly full scan hides the backlog until postings go missing.

Prefer soft deletion with an active flag over hard deletes from an approximate index. Removing rows is expensive and sometimes incomplete, and filtering on a boolean is both cheaper and easier to reason about — with space reclaimed on a schedule instead.

When to leave

The honest limits are index build time as the corpus grows, memory pressure from a large index competing with your ordinary workload, and query concurrency at a level where vector search starts affecting the rest of the database.

Those are measurable. Watch build duration, index size against available memory, and p95 latency under real concurrency — and move when the numbers say so rather than when the corpus merely feels large.

Try a read replica first, because it is usually enough. A replica dedicated to matching removes the contention that triggers most of these thresholds without introducing a second data model, a second consistency story or another system to operate.

Frequently asked questions

Is pgvector good enough for a production job board?

For most, yes. Keeping vectors beside the relational data gives transactional consistency and lets you combine similarity with ordinary SQL filters in one query.

Should the embedding be a column on the jobs table?

No. Use a separate table with one row per embedded chunk, and store the model and chunking version on each row so a migration is possible.

Which pgvector index should I use?

Weigh build time and incremental updates against query speed — for a job board with continuous inserts the former usually matters more. Whatever you pick, measure recall against an exact scan.

When should I move off Postgres for vectors?

When measurements say so — and try a dedicated read replica first, which removes the contention behind most of those thresholds.

How should expired postings be removed from the index?

Soft-delete with an active flag and filter on it, reclaiming space periodically. Hard deletes from an approximate index are expensive and sometimes incomplete.

Why does a filtered query sometimes return too few rows?

The approximate search explores candidates the filter then removes. A filter selective enough to matter but not to change the plan produces a thin result set.

Further reading

Check this against your own resume

Scan your CV against a real job description, or build a parse-safe one from scratch. Your first scan costs nothing.

Keep reading

Referenced in these guides

All career guides