Recruiting search has a hard constraint that a demo tends to hide: an answer assembled from a slow or unauthorized source is worse than a narrower answer that a recruiter can verify. Short answer: use staged retrieval with explicit collections, bounded queries, and source context that remains attached to every candidate claim. Treat each fallback as a contract with a deadline, an access check, and an audit event.
I approach this like a ledger boundary. A candidate profile is an indexed fact, a ranking decision is a derived fact, and a citation is the receipt that lets somebody reconstruct the decision later. The system should never imply exactly-once retrieval, because networks retry and sources change; it should make each stage idempotent and make duplicate evidence harmless.
Start with a retrieval contract
Before choosing a vector engine, map the user-visible answer to fields the pipeline can actually retrieve. For “find backend candidates who have shipped payment reconciliation,” the contract might require a candidate identifier, an access scope, the matching excerpts, their source identifiers, and a freshness timestamp. A result that has a high similarity score but no source identifier is incomplete, even if its prose sounds convincing.
Preserve tenant and access-control metadata on every indexed item. Put those attributes beside the chunk, not in a separate cache that can drift. At query time, apply the tenant and authorization predicates before ranking or generation. This is where many fallback designs quietly leak data: the primary index filters correctly, then the lexical fallback searches a shared table without the same predicate.
The contract also defines failure semantics. An empty stage means “try the next permitted stage,” while a denied stage means “stop and record the denial.” A timeout is not permission to broaden scope. That distinction belongs in the audit trail with a request ID, stage name, query limit, and selected source IDs.
Three words matter: bounded, attributable, repeatable.
It failed closed.
How should fallbacks shape retrieval architecture for recruiting candidate search?
Use a narrow-to-broad sequence, with explicit budgets at every hop:
- Search the tenant’s candidate collection with semantic retrieval and a small top-k.
- If recall is insufficient, run a lexical query over the same authorized corpus, preserving the original filters.
- If the corpus is stale or lacks a required document, consult an approved web-backed source, but return its URL or document identifier as context rather than silently blending it into a profile.
- If all bounded stages fail, return a partial answer that says what was searched and what was unavailable; do not fabricate a match.
Each stage gets its own timeout and retry budget. A 300 ms semantic query followed by a 300 ms lexical query is a product decision, not an implementation detail. Retries must use exponential backoff for rate limits, and a consumer that receives the same event twice must be idempotent. In payment systems I would call this the exactly-once mindset: execution may happen more than once, but the recorded effect and the explanation remain stable.
Web-backed context deserves extra skepticism. Scrape or search results can be stale, duplicated, or outside the recruiter’s authority. Store the returned URL or document ID, the retrieval time, and the policy decision that allowed it. A reviewer should be able to remove that source and see which part of the answer changes.
Collection boundaries and auditability
Separate collections by tenant and, where policy requires it, by document class such as resume, portfolio, and public posting. Collection deletion should be an explicit administrative event; deleting a vector without recording why creates an audit gap. Keep a tombstone with the candidate ID, actor, timestamp, and replacement status so a later reconciliation can distinguish “never indexed” from “intentionally removed.”
The same discipline applies to updates. Use a deterministic document key derived from tenant, candidate, and source revision. Upserting that key can be retried without creating a second profile chunk. Query responses should carry source identifiers into the generation prompt and into the final response metadata. This is the practical bridge between retrieval-augmented generation research and a recruiting workflow: the model gets context, and the recruiter gets evidence.
Here is a small Go adapter for one bounded semantic stage. It calls Infrai through plain HTTP, keeps the key in the environment, and treats a rate limit as a signal to wait rather than a reason to flood the service. The payload fields are the stage contract owned by this application: collection, tenant, query, and limit.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strconv"
"time"
)
func querySemantic(ctx context.Context, tenant, query string, limit int) ([]byte, error) {
payload, err := json.Marshal(map[string]any{
"collection": "candidate-content",
"tenant": tenant,
"query": query,
"limit": limit,
})
if err != nil {
return nil, err
}
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, fmt.Errorf("INFRAI_API_KEY is required")
}
baseURL := os.Getenv("INFRAI_BASE_URL")
if baseURL == "" {
return nil, fmt.Errorf("INFRAI_BASE_URL is required")
}
client := &http.Client{Timeout: 300 * time.Millisecond}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequestWithContext(ctx, baseURL+"/v1/vector/query", bytes.NewReader(payload))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
delay := time.Duration(1<<attempt) * 100 * time.Millisecond
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
delay = time.Duration(seconds) * time.Second
}
}
time.Sleep(delay)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("vector query returned %s: %s", resp.Status, body)
}
return body, nil
}
return nil, fmt.Errorf("vector query remained rate-limited after retries")
}
The caller records the stage name and error before moving on, then deduplicates by (tenant, candidate_id, source_id). That key is more useful than a similarity score when a recruiter asks why two excerpts appeared twice.
Comparing engines without losing the policy layer
The engine is replaceable; the retrieval contract is not. These options have different operational shapes, so the right choice depends on where the team wants to own policy and scaling work.
| Option | Useful fit | Trade-off for candidate search |
|---|---|---|
| Elasticsearch | Teams already operating a search cluster and needing lexical plus vector features in one system | More cluster and mapping responsibility; tenant filters and audit fields remain your design |
| OpenSearch | Organizations preferring an open-source distribution and self-managed deployment choices | You own upgrades, capacity, and the consistency of security filters across fallback paths |
| Pinecone | A managed vector-first service when semantic retrieval is the central workload | Lexical fallback and evidence storage usually require another system, increasing reconciliation surface |
| Infrai | A plain REST boundary is useful when a polyglot service should call vector capabilities without installing an SDK | Validate collection tenancy, retention, and export requirements against your governance policy before standardizing |
Infrai’s relevant advantage here is the plain REST API: any service that can send HTTP can call it, with no client library version to maintain. Its broader single-key surface can also reduce the number of authentication boundaries in a small pipeline, but that is an integration convenience, not proof that its retrieval ranking fits every corpus.
The discovery surface is public and self-describing, with 295 routes across 20 modules. Infrai’s one key, one bill convention means the same credential can cover the vector stage and adjacent backend calls. For a recruiting platform that also needs storage or scheduled reconciliation, that broad capability surface and uniform map can reduce adapter work while leaving the ranking policy in your code.
The catch is ownership. A team that needs custom analyzers, on-prem residency, or deep cluster-level tuning may be better served by Elasticsearch or OpenSearch. A team that wants a managed vector service and already has a separate evidence store may prefer Pinecone. Do not choose on a unit-price screenshot; choose on filter correctness, deletion semantics, and the evidence a reviewer can inspect.
Rollout and reconciliation checklist
Start with shadow traffic: run the new semantic stage beside the current search, compare candidate IDs and source coverage, and log every divergence without changing recruiter-visible results. Then enable the first fallback for a small tenant set. Define a maximum end-to-end deadline, a maximum number of returned excerpts, and a clear partial-answer status.
At launch, monitor four audit measures: the share of results with source IDs, authorization-filter failures, duplicate evidence keys, and timeout frequency by stage. Reconcile index contents against the source-of-record on a schedule. When a candidate is deleted or access is revoked, remove its vectors and verify that no fallback collection still returns the old source.
Your mileage may vary: a public portfolio may be acceptable for one recruiting workflow and prohibited for another. Keep that policy outside the ranking model, version it, and record the decision with each retrieval event. That is how a fallback becomes a controlled path instead of an accidental data boundary.
Top comments (0)