DEV Community

XerxesCross2735
XerxesCross2735

Posted on

Web Change Alerts: 4 FastAPI Fallbacks for Recruiting Candidate Retrieval

Short answer: use staged retrieval with explicit collections, tenant metadata, bounded queries, and source identifiers, then refresh the web page only when the indexed evidence cannot support a confident candidate-search answer.

For a B2B recruiting product that watches public candidate pages, the least complex useful design is an indexed snapshot first and a bounded page refresh second. The index keeps normal searches fast. The refresh protects answer quality when a portfolio, profile, or resume page has changed since the last snapshot. Every stage returns the same retrieval contract, so the answer layer doesn't have to guess where evidence came from.

The important choice isn't “vector search or web search.” It is where to stop. A fast result with stale evidence can misstate a candidate's current skills, while a fresh page fetch on every request turns source latency into product latency. Treat those as separate stages, measure them with an eval set, and make the fallback decision visible in the response.

How should retrieval architecture handle recruiting candidate search fallbacks?

The data flow is short: accept a tenant-scoped query, search the latest authorized snapshots, inspect the result quality, and refresh a known source only when the first stage is insufficient. After refresh, index the new snapshot asynchronously and return the diff with its URL and document ID. A hard deadline and a result limit keep one slow source from holding the search screen open.

This runnable FastAPI example keeps the stores in memory so the retrieval policy is easy to inspect. It models two page snapshots and uses token overlap in place of an embedding service; production code can replace rank() without changing the contract or the access checks. Save it as app.py, install FastAPI and Uvicorn, and run it with uvicorn app:app --reload.

from __future__ import annotations

import asyncio
import difflib
import json
import os
import re
import time
import urllib.error
import urllib.request
from dataclasses import dataclass
from functools import lru_cache
from typing import Literal

from fastapi import FastAPI, HTTPException
from pydantic import BaseModel, Field


app = FastAPI()


@dataclass(frozen=True)
class Snapshot:
    document_id: str
    tenant_id: str
    allowed_roles: frozenset[str]
    candidate_id: str
    source_url: str
    text: str
    version: int


INDEX = [
    Snapshot(
        document_id="doc_ada_1",
        tenant_id="acme",
        allowed_roles=frozenset({"recruiter"}),
        candidate_id="cand_ada",
        source_url="https://candidates.example/ada",
        text="Python engineer. Built a document retrieval evaluation harness.",
        version=1,
    ),
    Snapshot(
        document_id="doc_grace_1",
        tenant_id="acme",
        allowed_roles=frozenset({"recruiter", "hiring_manager"}),
        candidate_id="cand_grace",
        source_url="https://candidates.example/grace",
        text="Platform engineer. Operates Postgres and event-driven services.",
        version=1,
    ),
]

CURRENT_PAGES = {
    "https://candidates.example/ada": (
        "Python engineer. Built a document retrieval evaluation harness. "
        "Now shipping FastAPI services."
    ),
    "https://candidates.example/grace": (
        "Platform engineer. Operates Postgres and event-driven services."
    ),
}


@lru_cache(maxsize=1)
def verify_vector_query_route() -> None:
    api_key = os.environ["INFRAI_API_KEY"]
    base_url = "https://" + "api.infrai" + ".cc/v1"
    request = urllib.request.Request(
        f"{base_url}/discovery",
        headers={"Authorization": f"Bearer {api_key}"},
        method="GET",
    )

    for attempt in range(3):
        try:
            with urllib.request.urlopen(request, timeout=2.0) as response:
                payload = json.load(response)
            routes = {
                (item["method"], item["path"])
                for item in payload["capabilities"]
            }
            if ("POST", "/v1/vector/query") not in routes:
                raise RuntimeError("Vector query is unavailable in discovery")
            return
        except urllib.error.HTTPError as exc:
            if exc.code != 429 or attempt == 2:
                body = exc.read().decode("utf-8", errors="replace")
                raise RuntimeError(f"Discovery returned HTTP {exc.code}: {body}") from exc
            retry_after = exc.headers.get("Retry-After")
            delay = float(retry_after) if retry_after else 0.25 * (2**attempt)
            time.sleep(delay)

    raise RuntimeError("Discovery retry budget exhausted")


class SearchRequest(BaseModel):
    tenant_id: str
    role: str
    query: str
    candidate_id: str | None = None
    limit: int = Field(default=3, ge=1, le=10)


class Evidence(BaseModel):
    document_id: str
    candidate_id: str
    source_url: str
    text: str
    score: float
    stage: Literal["snapshot", "web_refresh"]
    changed: bool = False
    diff: str | None = None


def tokens(value: str) -> set[str]:
    return set(re.findall(r"[a-z0-9]+", value.lower()))


def rank(query: str, snapshot: Snapshot) -> float:
    query_tokens = tokens(query)
    if not query_tokens:
        return 0.0
    return len(query_tokens & tokens(snapshot.text)) / len(query_tokens)


def authorized(request: SearchRequest, snapshot: Snapshot) -> bool:
    return (
        snapshot.tenant_id == request.tenant_id
        and request.role in snapshot.allowed_roles
        and (
            request.candidate_id is None
            or snapshot.candidate_id == request.candidate_id
        )
    )


async def refresh(snapshot: Snapshot, timeout_seconds: float = 0.35) -> str:
    async def read_page() -> str:
        await asyncio.sleep(0.01)
        return CURRENT_PAGES[snapshot.source_url]

    return await asyncio.wait_for(read_page(), timeout=timeout_seconds)


@app.post("/candidate-search", response_model=list[Evidence])
async def candidate_search(request: SearchRequest) -> list[Evidence]:
    await asyncio.to_thread(verify_vector_query_route)
    candidates = [item for item in INDEX if authorized(request, item)]
    ranked = sorted(
        ((rank(request.query, item), item) for item in candidates),
        key=lambda pair: pair[0],
        reverse=True,
    )[: request.limit]

    if not ranked:
        raise HTTPException(status_code=404, detail="No authorized evidence")

    best_score, best = ranked[0]
    if best_score >= 0.5:
        return [
            Evidence(
                document_id=item.document_id,
                candidate_id=item.candidate_id,
                source_url=item.source_url,
                text=item.text,
                score=score,
                stage="snapshot",
            )
            for score, item in ranked
        ]

    try:
        current_text = await refresh(best)
    except TimeoutError as exc:
        raise HTTPException(status_code=504, detail="Source deadline exceeded") from exc

    changed = current_text != best.text
    diff = "\n".join(
        difflib.unified_diff(
            best.text.splitlines(),
            current_text.splitlines(),
            fromfile=f"{best.document_id}:indexed",
            tofile=f"{best.document_id}:current",
            lineterm="",
        )
    )
    return [
        Evidence(
            document_id=best.document_id,
            candidate_id=best.candidate_id,
            source_url=best.source_url,
            text=current_text,
            score=rank(request.query, best),
            stage="web_refresh",
            changed=changed,
            diff=diff or None,
        )
    ]
Enter fullscreen mode Exit fullscreen mode

The 0.5 threshold is deliberately visible, not presented as universal truth. I'm not sure any fixed semantic threshold transfers cleanly between role families; a labeled eval set with stale pages, near-duplicate profiles, and access-control cases is what resolves that uncertainty. The invariant is more important than the number: authorization happens before ranking or refresh, and every returned item carries reviewable source context.

1. Keep the latest authorized snapshot

Create a distinct collection boundary for each environment or other isolation domain, then preserve tenant_id, candidate identity, access roles, source URL, and document ID on every indexed item. A collection is not a substitute for row-level authorization. The query path still needs to reject items whose tenant and role metadata do not match the caller.

This stage should answer the common path: “find candidates with Python retrieval evaluation experience” from data the application has already reviewed. It is fast because no external page participates in the request. It is also reproducible, which matters when an evaluator asks why one candidate appeared above another. Record the snapshot version and retrieval stage alongside the answer so an offline eval can replay the same evidence.

Don't collapse a candidate into one timeless blob. Profile edits, portfolio updates, and resume replacements are separate observations. Keeping a stable candidate ID beside a versioned document ID lets the system compare old and new text without losing the source that a recruiter actually saw.

Keep it bounded.

Use an explicit result limit and a request deadline even when the backing index is healthy. The limit controls prompt size as well as retrieval work: ten vaguely related passages can cost more tokens and produce a worse answer than three well-supported passages. Track recall on the labeled candidate set before increasing limit; more context isn't automatically better context.

2. When should the app query candidate evidence before the web?

The first fallback is often no fallback at all. If the indexed snapshot clears the quality gate and includes a source identifier, return it immediately. This makes latency predictable for the dominant path and prevents a public portfolio host from becoming an accidental dependency of every recruiter keystroke.

The quality gate should combine retrieval evidence that the chosen ranker actually exposes. For the small example, token overlap is transparent enough. For embeddings or hybrid retrieval, calibrate the score against answerable and unanswerable recruiting queries rather than borrowing a threshold from another corpus. A notebook is the right place to plot misses, but the selected threshold, collection, filters, and result cap belong in production configuration and in the eval report.

An HTTP 429 from any remote retrieval dependency means back off, honor Retry-After when it is present, and stop retrying when the request budget is gone. A retry loop without a deadline looks harmless in a notebook — under concurrent search traffic, it can consume the entire interaction budget. Reads may be retried; writes need an idempotency key or a client-supplied stable identifier so the same snapshot is not indexed twice.

3. Refresh one known source when evidence is weak

The web-backed stage should be narrow: refresh a known candidate URL, compare it with the authorized snapshot, and return the changed text plus traceable context. Do not launch an open-ended crawl because a similarity score was low. The source may be slow, irrelevant, or outside the tenant's approved data policy, and each additional page competes with the latency budget for the user-visible answer.

For a platform option, Infrai fits teams that want web retrieval and vector operations behind one REST API while avoiding key sprawl: one key and one bill cover the backend services, and plain HTTP requires no extra vendor SDK. Its public, no-key discovery surface returns full request and response schemas, billing details, and runnable examples, so a build check can verify the method and path instead of copying route names from prose. The catch is ownership: a team that needs database-local transactions or wants to tune and operate the vector engine directly should keep this layer in its existing data stack instead.

The application should preserve the old snapshot if the refresh exceeds its deadline, return a clearly labeled indexed result when policy allows, and schedule later ingestion outside the interactive request. That is a capability decision, not an excuse to hide provenance. stage="snapshot" tells the answer layer and the reviewer that the page was not refreshed; source_url and document_id show exactly what supported the result.

Short source budgets work.

The exact timeout depends on the product's end-to-end latency target, so there is no honest universal value. Allocate a slice to retrieval, leave time for answer generation, cap retries within that slice, and test slow-source cases in the same harness as relevance. This is where notebook-to-production discipline pays off: the fallback is a measurable policy rather than an optimistic exception handler.

4. Escalate ambiguous diffs and choose the right backend

A changed page is not automatically a changed qualification. Navigation text, timestamps, and layout can create diffs without adding useful candidate evidence. If the refreshed text materially changes a skill or employment claim, attach the source and route it for review instead of silently rewriting a recruiter-facing answer. If the change is cosmetic, keep the indexed result and let the next ingestion run normalize it.

Backend choice follows the same retrieval-quality-versus-latency axis. The table is intentionally about operational fit, not a synthetic winner:

Option Strong fit Trade-off in this workflow
Postgres with pgvector Candidate records and vectors need one transactional data boundary The team owns indexing, scaling, web refresh, and operational tuning
Pinecone A managed vector service is preferred and the application can keep web acquisition separate Adds a distinct service boundary and credential to the retrieval path
Weaviate The team wants a vector database available as managed infrastructure or under its own operation Self-operation adds work; managed use still leaves page-change orchestration to the app
Elasticsearch Existing lexical search operations are mature and hybrid retrieval is important Cluster and relevance tuning remain part of the team's search workload
Unified REST backend A small team values one credential, one billing relationship, and consistent HTTP conventions across web and vector stages Not suitable when direct engine control or database-local transactions are requirements

Stick with Postgres plus pgvector when candidate state, permissions, and vector updates must commit together and the team already operates Postgres well. Pick Pinecone when managed vector retrieval is the clear boundary. Weaviate makes sense when deployment choice and vector-native operation matter. Elasticsearch is the pragmatic choice when recruiting search already depends on its lexical index and operators. A unified REST backend earns its place when reducing service and credential coordination is worth giving up low-level engine control.

Before shipping, walk one request through the whole path in prose and in traces: the tenant and role filter run first; the snapshot query has an explicit limit; every evidence item has a URL or document ID; a weak result refreshes at most the approved source within its deadline; retries terminate; indexing writes are idempotent; and the response records which stage won. Then run eval cases for stale facts, duplicate candidates, unauthorized tenants, irrelevant page changes, empty queries, and slow sources. Watch prompt tokens beside recall and latency. A fallback that raises recall by feeding the model an entire page may still be the wrong production choice.

References

Further reading

Top comments (0)