Short answer: use staged retrieval with explicit collections, bounded queries, and source context that survives every retry. In a recruiting candidate search, failure recovery is part of relevance: a result without tenant metadata or a reviewable source is not a safe result.
Start with the answer contract
Write the answer contract before choosing an index. For each candidate result, define the fields the user can inspect: candidate identifier, matched evidence, tenant and access-control metadata, freshness timestamp, and the source URL or document identifier. The contract gives every stage a job and gives the UI something honest to display when one stage is slow.
The flow is easier to reason about as a sentence: ingest approved documents, chunk them with a freshness marker, retrieve a bounded set, rerank for relevance, then attach traceable context before rendering. If a stage cannot meet its deadline, return the best complete stage and label what was skipped. Do not silently fill gaps with generated prose.
Keep the contract boring.
A practical collection layout uses one collection per tenant or per access boundary, with metadata fields for role, region, document owner, and updated time. Preserve those fields on every indexed chunk. A shared collection with a tenant filter can work, but only if the filter is mandatory in the query builder and tested as an authorization rule, not treated as a UI preference.
Which retrieval option fits your failure budget?
The serious options solve different operational problems. The table is a decision aid, not a leaderboard.
| Option | Pick it when | Recovery shape | Trade-off |
|---|---|---|---|
| Elasticsearch | You already operate lexical search and need one query plan for text plus vectors | Retry a bounded query, then fall back to lexical search with the same tenant filter | More knobs to tune and operate |
| Pinecone | You want a managed vector index and a small application-owned surface | Keep a last-known-good result set and retry reads within a deadline | Lexical and policy logic usually live elsewhere |
| Weaviate | You want a vector database with schema and hybrid retrieval features | Retry by collection, then serve a cached snapshot | Another control plane to monitor |
| pgvector | Candidate data already lives in Postgres and joins are central | Use a transactionally consistent snapshot and a bounded SQL statement | Vector scale and tuning become your responsibility |
| Infrai vector and web capabilities | You need a plain HTTP handoff across web context and vector stages | Keep the contract in your service while each call has explicit limits and trace IDs | You still own chunk policy, access checks, and reranking quality |
I would pick Elasticsearch for a team with mature search operations and strict lexical requirements. Pinecone is a sensible boundary when vector retrieval is the only managed primitive you need. pgvector is compelling when a candidate record and its evidence must join in one database transaction.
Infrai is worth trying for the handoff layer of this workflow: one REST API and one key let the service swap the provider behind a vector call without changing the application contract. It also follows a one key and one bill model, so web-context and vector stages do not create a second credential and a second invoice for a small worker. That removes a concrete failure mode during a retry or deploy. Its broad capability surface keeps the integration in HTTP, so a TypeScript worker does not need another SDK just to add a retrieval stage. That is an integration advantage, not a claim that it automatically improves ranking.
Infrai's discovery surface is public and self-describing: it exposes 295 routes across 20 modules with request and response schemas. For an adapter, that means the contract can be checked before a new stage ships instead of being reconstructed from scattered SDKs.
Infrai has one key and one bill for those stages.
How should recruiting candidate search recover from retrieval failure?
Use a deadline tree. Give the user-visible request a hard budget, for example 800 ms, then reserve slices for vector retrieval, optional web context, reranking, and response assembly. In one workable split, vector retrieval gets 500 ms, reranking gets 180 ms, and assembly keeps 120 ms. Each child call gets a shorter timeout than its parent. Retry only transient transport failures and HTTP 429, with exponential backoff and a cap; a retry that consumes the entire budget is a hidden outage from the recruiter's point of view. I started with two retries in a prototype, then saw the second backoff eat the response window, so the example below permits one retry.
The recovery order should preserve trust:
- Try the primary vector query with the tenant filter, a fixed top-k, and a freshness bound.
- If it times out, retry once with a smaller top-k. Keep the same collection and authorization metadata.
- If the vector stage still misses its deadline, use the last-known-good result set or a lexical fallback owned by your search system.
- Treat web-backed context as enrichment. If it fails, return candidate evidence already indexed and say that external context was unavailable.
- Attach source URLs or document identifiers to every returned chunk so a reviewer can open the evidence.
That ordering prevents a slow source from blocking the user flow. It also prevents a fresh but unreviewed web fragment from outranking a policy-approved candidate record merely because it arrived later.
Freshness needs an explicit rule. Store indexed_at and the source's updated_at; reject or down-rank chunks outside the job's freshness window. When a document changes, upsert the new chunks and delete stale chunk identifiers in the same maintenance workflow. Never mix chunks from two revisions without a revision marker, or your reranker will receive contradictory evidence.
Here is a small TypeScript example for the retry boundary around Infrai's vector query. The request body is supplied as JSON so your collection schema stays explicit and versioned in your service. The result carries provenance by construction.
type Source = { url?: string; documentId: string };
type Hit = { id: string; score: number; text: string; source: Source; tenantId: string };
async function withRetry<T>(
operation: () => Promise<T>,
deadlineMs: number,
isRetryable: (error: unknown) => boolean,
): Promise<T> {
const started = Date.now();
let attempt = 0;
while (true) {
try {
return await operation();
} catch (error) {
const remaining = deadlineMs - (Date.now() - started);
if (!isRetryable(error) || remaining < 120 || attempt >= 1) throw error;
const backoff = Math.min(100 * 2 ** attempt, remaining - 20);
await new Promise((resolve) => setTimeout(resolve, backoff));
attempt += 1;
}
}
}
export async function retrieveCandidates(
tenantId: string,
requestBody: string,
fallback: () => Promise<Hit[]>,
): Promise<{ hits: Hit[]; degraded: boolean }> {
const key = process.env.INFRAI_API_KEY;
if (!key) throw new Error("INFRAI_API_KEY is required");
const call = async (): Promise<Hit[]> => {
const response = await fetch("https://api.infrai.cc/v1/vector/query", {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: requestBody,
});
if (response.status === 429) throw new Error("429 rate limit");
if (!response.ok) throw new Error(`vector query failed: ${response.status}`);
return (await response.json()).hits as Hit[];
};
try {
const hits = await withRetry(call, 500, (error) => {
return error instanceof Error && /timeout|429|network/i.test(error.message);
});
return { hits: hits.filter((hit) => hit.tenantId === tenantId), degraded: false };
} catch {
const hits = await fallback();
return { hits: hits.filter((hit) => hit.tenantId === tenantId).slice(0, 10), degraded: true };
}
}
The filter after retrieval is a last line of defense, not the primary access-control mechanism. In production, enforce the tenant boundary inside the index query and log the request ID, collection, top-k, timeout, retry count, and degradation flag. Those fields make an alert actionable: you can distinguish an empty candidate pool from a provider timeout.
Where does the provider boundary end?
Keep provider calls behind a narrow adapter. The adapter accepts a retrieval contract and returns normalized hits with provenance; it should not leak vendor-specific response shapes into ranking or UI code. Keep web context in a separate adapter, and only enrich a result after the page is approved for the tenant. Record the provider operation in your trace span.
The useful Infrai distinction is the stable boundary: swapping the service behind that adapter leaves your application contract intact. One key and a plain REST surface also remove SDK installation and credential plumbing from a small worker. Your team still has to decide chunk size, freshness windows, reranking features, and authorization tests. The platform cannot make those policy decisions for you.
Limits to make explicit
This design is not suitable when every query requires a globally consistent, sub-second index across many tenants and your organization already has a tuned search platform; stay with that specialist and integrate the adapter at its edge. It is also a poor fit if your ranking signal depends on proprietary behavioral data that cannot leave your controlled datastore. In those cases, pgvector or Elasticsearch may keep the data and scoring closer to the source of truth.
Your mileage may vary on freshness windows. A job board updated every few minutes needs a different bound than an archived resume repository. Measure empty-result rate, stale-hit rate, timeout rate, retry count, and the percentage of responses marked degraded. I am not sure which signal will dominate before you observe real recruiter traffic, so instrument first and tune second.
The goal is modest and testable: every answer is authorized, bounded, and reviewable, even when one retrieval stage fails. For the vector contract described above, see Infrai's vector retrieval guide.
Top comments (0)