HR-ATS-Portal/docs/architecture/adr/0006-candidate-search-strat...

24 KiB

ADR 0006 — Candidate search strategy

Status: Accepted — 2026-07-29.

Deciders: Talha Ahmed (ranking, trigram thresholds, index maintenance triggers), Ahmed Mujtaba (search index refresh job, facet queries, search API and its test suite, latency instrumentation).


Context

Recruiter search is the most-used screen in the product and the prototype has no server-side search at all. What exists:

Fact Evidence Consequence
All 100 candidates are generated in-browser by a seeded PRNG and held in memory; filtering and sorting happen client-side over that array js/data.js:8-10, js/data.js:110-131, js/candidates.js Nothing about the current search behaviour constrains the backend. Only the screen contract — which filters and columns recruiters expect — is worth preserving
Relevance is an ad-hoc client-side blend computed against a hardcoded "today" of 2026-07-09 js/candidates.js:18, js/data.js:237 The blend weights are unattributable and untestable. Replacing them with a versioned config row is a strict improvement, not a rewrite of working logic
Candidate-searchable text will live across child tables by binding decision: candidate_email, candidate_skill, candidate_employment, candidate_education, candidate_link, candidate_document.extracted_text _decisions.md candidate columns decision This single fact eliminates the otherwise-obvious solution (a generated tsvector column on candidate), because generated columns can only reference their own row
pg_trgm GIN indexes on name_normalised and employer_name_normalised already exist for duplicate detection _decisions.md duplicate detection decision Fuzzy search is nearly free — the indexes are being built anyway. One mechanism gets tuned and understood instead of two
Candidate volume assumption: 10^4 to 10^5 rows over several years (ASSUMPTION, labelled as such) _decisions.md, 02-system-architecture.md A4 Three to four orders of magnitude below any threshold at which a dedicated search cluster is defensible
Two developers, one senior, no ops staff _repo-findings.md §I A second datastore is a permanent weekly tax: dual writes, reindex drift, its own backups, its own monitoring, its own PII footprint
Candidate-controlled strings are rendered through 34 unescaped innerHTML sites _repo-findings.md §E Search results are the single highest-volume path by which attacker-supplied text reaches a recruiter's screen

Hard constraint in force: no Elasticsearch, no second database and no separate search service in Phase 1 unless the repository or real scale demands it. Neither does.


Options considered

# Option Pros (at their strongest) Cons
A PostgreSQL FTS plus pg_trgm over a trigger-maintained candidate_search_index table, ts_rank_cd blended with recency and ATS band from a versioned config, facets by GROUP BY. pgvector in the same database as the Phase 2 hybrid path One datastore, one backup, one security boundary, one place PII lives. Fuzzy search reuses the trigram indexes duplicate detection needs anyway. Search results join directly to job_application, stage, recruiter and score with no cross-system consistency problem, which is exactly what recruiter list screens do on every request. setweight gives real field weighting; ts_rank_cd gives proximity-aware ranking. psql is the debugging tool the team already needs. Keeps the "no separate AI service" constraint achievable in Phase 2 rather than aspirational A search index table maintained by triggers is code we own, and it can drift or lag. Ranking quality is below a tuned BM25 engine. tsvector has a hard 1 MB per-row limit, so very long CV text must be truncated for indexing. GIN write amplification during bulk import. Facets across many dimensions get expensive well before a search engine would
B Generated tsvector column on candidate plus GIN, no separate table, no triggers The cheapest correct thing when it applies: zero maintenance code, zero drift, the index is a column and Postgres keeps it honest by construction. If all searchable text lived on the candidate row, this would be the right answer and this ADR would be one paragraph It does not apply. A generated column may reference only its own row, and skills, education, employment and CV extracted text are all child tables by binding decision. Flattening them onto candidate to enable this would trade a working identity model for an indexing convenience — the prototype's wide-flat-row mistake (js/data.js:117-127) re-committed deliberately
C Elasticsearch or OpenSearch as a dedicated search cluster fed by an indexer Genuinely better at search: BM25 with per-field boosts, live relevance experimentation, learning-to-rank, sub-50 ms typo-tolerant autocomplete, high-cardinality faceting, and read load moved off the transactional database. If search quality were the product, this would win Forbidden in Phase 1 by constraint, and unjustifiable when the current dataset is 100 generated rows (js/data.js:112). Introduces a permanent dual-write plus reindex-drift cost that two developers feel every week, a second thing to back up, patch, secure and restore, and a second full copy of candidate PII to classify in pii_classification, retention-purge and prove erasure over. Every "search says X but the record says Y" incident becomes a distributed-systems investigation
D Dedicated vector database (Pinecone, Qdrant, Weaviate) for semantic matching Best-in-class ANN, purpose-built operational tooling, scales far beyond anything we need A second datastore with the same tax as Option C, plus candidate PII embeddings leaving the boundary in the hosted case. pgvector with an HNSW index is comfortably adequate at 10^5 rows. No Phase 1 feature requires semantic search at all, so this would be infrastructure bought ahead of a requirement
E ILIKE '%term%' scans, no FTS Honestly viable on matching alone: at 10^4-10^5 rows a scan is fast, pg_trgm GIN can even index LIKE, and there is nothing to maintain or keep fresh. Zero new concepts for a junior developer Delivers matching, not search. No ranking, so results arrive in arbitrary order and recruiters cannot tell a strong match from an incidental one. No field weighting, so a surname match and a passing mention deep in a CV score identically. No stemming, so "manage" misses "managing". Facet counts require the same scan repeated per dimension. It fails the actual requirement, which is ordering
F BM25 extension inside Postgres (pg_search / ParadeDB) now Real BM25 ranking without leaving the database — the quality gap in Option A closed with no second datastore Extension availability on managed Azure PostgreSQL Flexible Server is doubtful and would have to be confirmed, which makes it a hosting dependency rather than a schema choice. Premature: it is step 4 of the escalation ladder below, to be reached only if ranking quality specifically is the measured gap. Adopting it now means tuning an unfamiliar ranking function before anyone has complained about the familiar one

Decision

Option A, entirely inside PostgreSQL, with pgvector reserved for Phase 2 in the same database. Four layers, each with a named mechanism.

1. Structured filters — the majority of real traffic

Stage, job, recruiter, department, location, experience range and score band, on btree and composite indexes, with partial indexes for the hot list screens. This is what the prototype's screens actually filter on (js/candidates.js), and it is ordinary relational work. All list reads go through the v_candidate_live view so deleted_at rows require deliberately querying the base table.

2. Free text — a dedicated index table

candidate_search_index (candidate_id PK, document tsvector, refreshed_at timestamptz), GIN on document, one row per candidate.

Weight Fields Text configuration
A display_name, name_normalised simple — names must not be stemmed
B current_title, current_employer_name, candidate_skill labels and canonical ref.skill names simple for skill keys, english for title text
C education institution and qualification, location_text english
D candidate_document.extracted_text for the current CV revision english

unaccent sits in the normalisation pipeline for every field. Aliases from ref.skill_alias are folded into the B band so "JS" and "JavaScript" hit the same index entry — the taxonomy is what makes this possible and is nearly free because the prototype already uses a fixed skills pool.

3. Fuzzy and typo-tolerant matching

pg_trgm GIN on candidate.name_normalised and employer_name_normalised, queried with the % operator and similarity(). These are the same indexes duplicate detection uses (ADR 0008), so the similarity threshold is tuned once, in one versioned config, and both features move together.

4. Ranking and facets

Ranking is ts_rank_cd blended with recency and ATS band. The blend weights live in a versioned config row, not in code — the same rule as scoring configs, so a relevance change is attributable rather than a mystery. Facet counts are a plain GROUP BY over the filtered set.

5. Index maintenance — the implementation shape of "trigger-maintained"

A synchronous full-document recompute on every contributing write would rewrite a candidate's tsvector dozens of times during a single CV parse, once per skill row, with the D-band CV text re-tokenised every time. So:

  • AFTER INSERT OR UPDATE OR DELETE triggers on candidate, candidate_skill, candidate_employment, candidate_education, candidate_link, candidate_document and candidate_email write the affected candidate_id into a small candidate_search_dirty table (candidate_id PK, marked_at) — an upsert, so repeated writes in one transaction collapse to one row.
  • A procrastinate job on the maintenance queue drains that table, recomputes document for each candidate in one statement, and sets refreshed_at. A per-candidate queueing lock prevents two concurrent recomputes of the same row.
  • Accepted tradeoff: search is eventually consistent. Target p95 dirty-to-fresh lag under 5 seconds; alarm above 60 seconds; refreshed_at makes the lag directly measurable rather than assumed. A recruiter who has just edited a candidate sees the change immediately on the profile (which reads base tables) and within seconds in search.
  • The trigger is still the single writer of the dirty marker, so no code path — bulk import, migration, merge routine or psql fix — can mutate a candidate without the index learning about it. That is the property the trigger is for; the queue is only about when.

6. Authorization is part of the query, never a post-filter

Permission scoping is applied as SQL predicates inside the search query, derived from the same iam.can() / scope() resolution the REST API uses (ADR 0009). Results are never fetched broadly and filtered in application code, and facet counts are computed over the permitted set so counts cannot leak the existence of records the user may not see. The assistant reaches search through the identical service function with the human actor propagated (ADR 0010), so there is one authorization implementation, not two.

7. Output escaping is in scope for this decision

Search results are the highest-volume path from attacker-supplied text to a recruiter's screen (_repo-findings.md §E). Snippet generation (ts_headline or an application equivalent) must escape before inserting <mark> markers, never after, and the React renderer escapes by default. Storing the original unsanitised is correct (_decisions.md preserve-the-original rule); rendering it raw is not.

Phase 2 semantic path, in the same database

candidate_embedding (candidate_id, model_id, model_version, source_document_id, embedding vector(N), generated_at) with an HNSW index, used as hybrid retrieval: FTS and trigram generate candidates, the vector reranks. Embeddings are versioned per model so a model swap cannot silently change matching. No Phase 1 feature uses this; the extension may be installed at provisioning, and one line in the provisioning runbook must say so, so nobody assumes semantic search is available.

graph LR
  W["writes: candidate and child tables"] -->|"AFTER trigger"| DIRTY["candidate_search_dirty"]
  DIRTY -->|"maintenance queue job"| IDX["candidate_search_index.document tsvector"]
  Q["recruiter query"] --> FILT["structured filters, btree and partial indexes"]
  Q --> FTS["GIN on document"]
  Q --> TRG["pg_trgm GIN, shared with duplicate detection"]
  FILT --> RANK["ts_rank_cd blended with recency and ATS band, versioned config"]
  FTS --> RANK
  TRG --> RANK
  IDX --> FTS
  RANK --> AUTH["permission predicates applied in SQL"]
  AUTH --> RES["results and GROUP BY facets"]

Justification

The child-table fact decides between A and B, and it is not negotiable. A generated tsvector column is the better engineering when it works, and it breaks the moment skills, education, employment and CV text are separate tables — which they must be, because skills carry proficiency, provenance and confidence, employment date ranges are duplicate-detection signals, and CV revisions are what an ats_result pins. A per-candidate index table is the honest correction: one row per candidate keeps the GIN index small, and a reindex is a targeted UPDATE rather than a table rewrite.

Scale decides against C and D, by three to four orders of magnitude. Under the labelled assumption of 10^4-10^5 candidates, we are nowhere near the point where Postgres FTS is the bottleneck. The honest conclusion — worth stating plainly so it is not quietly revisited by enthusiasm — is that this system will very likely never outgrow Postgres search, and a read replica is the realistic ceiling of what will ever be needed. Most "we need Elasticsearch" moments are an unindexed query or an untuned ranking function, which is why the escalation ladder below is named before the trigger.

Reusing the trigram indexes is the highest-leverage detail in this ADR. Duplicate detection needs GIN trigram on name_normalised and employer_name_normalised regardless. Building search on the same indexes means one similarity threshold is tuned, understood and tested; two separate fuzzy implementations would drift, and recruiters would experience "search found them but dedupe didn't" as a bug we could not explain.

Versioning the ranking blend follows the same rule as scoring configs, for the same reason. The prototype's client-side blend (js/candidates.js:18) cannot answer "why did this candidate move up the list last Tuesday". A config version row can.

Escalation ladder, to be exhausted in order before any second datastore:

  1. Index and query tuning, with EXPLAIN (ANALYZE, BUFFERS) evidence.
  2. A read replica dedicated to search, so search load stops touching write latency.
  3. A materialised search table with scheduled refresh, if per-request ranking cost is the issue.
  4. A BM25 extension (pg_search / ParadeDB) if, and only if, ranking quality alone is the measured gap.

Only after all four are exhausted does a dedicated search service become a legitimate proposal.


Consequences

Positive

  • One datastore. One backup and restore story, one PII footprint to classify in pii_classification, one place a retention purge has to reach, one thing to patch. At two developers this is the dominant benefit and it compounds every week.
  • Search joins natively to job_application, stage, recruiter, score band and history, so "candidates in Interview for this requisition with an ATS band of A, matching 'kubernetes'" is one query with no cross-system consistency question.
  • No dual-write and no reindex drift, therefore no class of bug where search and the record disagree.
  • Fuzzy search and duplicate detection cannot diverge, because they are the same indexes.
  • refreshed_at makes index freshness a measurable SLO rather than a hope.
  • Phase 2 semantic search needs no new infrastructure, which is what makes the "no separate AI service in Phase 1" constraint practically achievable instead of merely stated.
  • psql is the debugging tool for search, and both developers need it anyway.

Negative — costs we are accepting

  • Search is eventually consistent, by design, with a target p95 lag under 5 seconds. A synchronous index would remove this at the cost of write amplification during parsing. We chose staleness over write cost and we are measuring it.
  • We own index-maintenance code: seven triggers, a dirty table, a drain job and a per-candidate lock. Option B would have had none of it. This is the price of the child-table identity model.
  • Ranking quality is below a tuned BM25 engine. ts_rank_cd has no per-field boost experimentation, no learning-to-rank and no relevance-tuning workbench. Accepted because recruiter queries at this scale are mostly filter-plus-keyword, not open-web retrieval.
  • tsvector has a hard 1 MB per-row limit. Long CVs (or a maliciously padded one) can exceed it. D-band extracted_text is truncated to a documented byte budget for indexing purposes; the full text remains intact in candidate_document, so the truncation affects recall on the weakest band only. Untruncated, an oversized CV would cause an index-update failure, which is worse.
  • GIN write amplification during bulk import. A 90-day mailbox backfill produces a burst of index churn. Mitigated by draining the dirty table in batches and by fastupdate behaviour, but import throughput is lower than it would be with no FTS.
  • Facets do not scale indefinitely. GROUP BY over the filtered set is fine at our cardinality; beyond roughly ten simultaneous facet dimensions it becomes the slowest part of the page, and that is a named revisit trigger rather than a surprise.
  • Deep pagination is expensive. Ranked results cannot use keyset pagination cleanly, so OFFSET cost grows. Result sets are capped (recommend 500) with an explicit "refine your filters" affordance rather than silently degrading. This is a product constraint we are choosing.
  • English-centric stemming. The english configuration is wrong for CVs in other languages, and Utopia Brands hires across multiple markets. See R4.

Risks

# Risk Severity Mitigation
R1 Dirty-queue drain falls behind, so search silently serves stale data Medium refreshed_at lag as a monitored metric with a 60-second alarm; the drain job is idempotent and re-entrant; a full-rebuild command exists and is tested
R2 A trigger is missed on a table that later contributes searchable text Medium Enumerate contributing tables in one migration file next to the trigger definitions; a test asserts that a write to each contributing table marks the candidate dirty. This mirrors the merge-completeness test in ADR 0008
R3 Trigram similarity threshold too low (noise) or too high (misses) Medium Threshold lives in the versioned matching config shared with duplicate detection; changing it is an attributable config version, and precision/recall are evaluated on a labelled fixture set before publication
R4 Non-English CVs stem incorrectly, so recall is quietly poor for some markets Medium ASSUMPTION: the working language of CVs is predominantly English. If false, add a per-document language column set at parse time and select the text configuration per document rather than globally. Do not paper over it with trigram matching
R5 tsvector 1 MB limit hit by an oversized or padded CV Low Documented truncation budget for D-band text; the parse pipeline records that truncation occurred so recall gaps are explicable
R6 Permission predicates omitted on a new search endpoint, leaking candidates High One search service function is the only entry point; a test asserts every search-reading endpoint routes through it; facet counts computed over the permitted set. This is the Phase 1 backstop given RLS is deferred to Phase 2
R7 Snippet generation reintroduces XSS by inserting markup around unescaped text High Escape before marker insertion, never after; react/no-danger is a CI error; the Phase 0 escaping patch covers the prototype in the interim
R8 Search load degrades transactional write latency as usage grows Medium Measure first; step 2 of the ladder (read replica) is the designed response, and it is cheap
R9 A ranking-blend change is made in code rather than config, and becomes unattributable Low Blend weights are read from the config version row; a test asserts no numeric literal blend weight exists in the ranking function
R10 Bulk job-board feeds push candidate volume 1-2 orders above assumption A4 Medium The thresholds below are absolute, not relative, so this surfaces as a trigger firing rather than as a slow decay

Revisit conditions

This ADR is reopened when any of these fires — and not before, because otherwise the decision gets made by enthusiasm rather than evidence.

Trigger Threshold
Corpus size Candidate rows exceed roughly 2,000,000, or indexed searchable text exceeds roughly 50 GB
Latency p95 search latency exceeds 500 ms on the tuned FTS plus trigram path, after index tuning and after moving search to a read replica
Throughput Sustained search throughput exceeds roughly 50 queries/second and search measurably degrades transactional write latency
Capability gap A product requirement appears that Postgres genuinely cannot serve: live per-field BM25 relevance experimentation, learning-to-rank, sub-second facets across more than about ten dimensions, or cross-entity typo-tolerant autocomplete under 50 ms
Index freshness p95 dirty-to-fresh lag exceeds 60 seconds for 7 consecutive days after the drain job has been tuned and parallelised
Ranking quality Recruiters report irrelevant top-10 results on more than 10% of sampled searches in a structured review of at least 50 queries — this reopens ranking (ladder step 4), not the datastore
Multilingual recall More than 5% of CVs are recorded as non-English at parse time — triggers per-document text configuration, not a new engine
Facet cost Facet computation exceeds 40% of total query time on the main candidate list screen
Semantic requirement lands A committed requirement for "find candidates like this one" or natural-language requisition matching — activates the Phase 2 pgvector hybrid path in the same database, which is already designed and is not a revisit of this ADR's datastore decision
Write amplification Bulk import throughput becomes the binding constraint on backfill, with GIN maintenance measured above 30% of import wall time

  • _decisions.md — Phase 1 search strategy; threshold for a separate search service; candidate first-class columns and child tables; enum and reference-data strategy; chatbot and AI query isolation.
  • 03-database-design.md — index inventory and the concrete DDL for candidate_search_index.
  • ADR 0002 — the primary relational database this search lives inside.
  • ADR 0004 — the queue that drains candidate_search_dirty.
  • ADR 0007 — versioning; the ranking blend is versioned by the same rule as scoring configs.
  • ADR 0008 — duplicate resolution; shares the trigram indexes and the matching config version.
  • ADR 0009 — permission enforcement; search predicates come from the same scope() resolution.
  • ADR 0010 — chatbot query architecture; the assistant reaches search through this service function.