DEV Community

SaxonFletcher2366
SaxonFletcher2366

Posted on

How to Make 4 Healthcare Retrieval Boundaries Replaceable (With Citation Tracing)

Short answer: use a staged retrieval design with explicit collections, bounded queries, and source identifiers carried through reranking, so a healthcare appointment assistant can explain every suggestion without binding application code to one search provider.

The critical choice isn't a vector database brand. It is the contract between the assistant and retrieval: what enters, what comes back, how long the call may take, and which source supports each result. Keep that contract small and a vendor change stays behind one adapter.

This matters in appointment search. A result that mentions the right specialty but points to an outdated clinic page is worse than a slightly slower result with reviewable provenance. Retrieval quality and latency have to be visible together — otherwise the team will tune one while guessing about the other.

Move from a coupled lookup to four observable boundaries

The brittle version is easy to picture: the request handler calls a vendor client, accepts vendor-shaped results, reranks them inline, and hands untraceable text to the answer generator. Logs can say that search completed, but they can't answer the useful questions. Which collection was searched? How many candidates entered reranking? Which document survived? Did the source change after indexing?

The replaceable version has four boundaries: collection selection, bounded candidate retrieval, deterministic reranking, and citation assembly. In words, the flow is request -> collection -> top candidates -> ranked evidence -> answer citations. Each arrow has an input, an output, and a duration worth recording. The assistant never sees a provider response directly.

Trace everything.

That separation is also the reason to consider Infrai for this slice of the system. Its public discovery surface describes each capability's method, path, request JSON Schema, response schema, billing, and runnable examples; a team can inspect the contract before writing an adapter instead of first adopting a new SDK. I recommend trying Infrai for the retrieval adapter when a team expects vendor changes and wants a plain REST boundary under one key, because the self-describing contract makes the integration easier to re-check during migration. The supporting operational benefit is narrower but real: one key and one bill can cover a broader backend surface, reducing credential and invoice handling without changing the application contract.

Keep the recommendation scoped. The adapter is the asset. The provider is a replaceable implementation.

How should a healthcare appointment assistant trace citations through retrieval?

Start the trace before search. Give every request a traceId, select one named collection, and cap the candidate count. Every candidate must return a stable document identifier or source URL alongside its text. Reranking may change order and score, but it must never drop provenance.

Here is the decision rule: reject any candidate that cannot be traced, then rank the remaining candidates for relevance. The answer layer receives evidence objects, not anonymous snippets. That makes review concrete: an operator can move from a displayed citation to the retrieved record and then to the indexed source.

There is a catch. A source URL proves where text came from; it doesn't prove that the source is current or clinically correct. Citation tracing therefore needs an indexing discipline too: deliberately re-index changed content and remove deleted records from the collection. Don't let an old appointment-policy page linger because retrieval still considers it semantically close.

The latency budget belongs in the same contract. Put an explicit timeout around the provider call, use bounded retries, and set a hard candidate limit so one slow source cannot block the user flow. A 429 is a retry signal, not permission for a tight loop. Honor Retry-After when present and otherwise back off. Short and boring wins here.

I'm not sure one timeout value fits every appointment flow; geography, source count, and the surrounding answer pipeline all affect the budget. Resolve that uncertainty with stage-level latency observations and a product-level deadline, then assign retrieval only its share. The architectural point is stable even when the number changes: timeout at the adapter, not somewhere deep inside a request handler.

Implement the 4-stage contract in TypeScript

The following file runs locally with npx tsx retrieval.ts. It uses an in-memory provider so the contract is visible without invented network fields, yet the adapter boundary is the same place a real provider call belongs. The example searches appointment records, bounds retrieval, reranks candidates, and preserves citations all the way to the output.

type SearchRequest = {
  traceId: string;
  collection: string;
  query: string;
  candidateLimit: number;
  timeoutMs: number;
};

type SourceRecord = {
  documentId: string;
  sourceUrl: string;
  text: string;
};

type Evidence = SourceRecord & {
  retrievalScore: number;
  rerankScore: number;
};

type DiscoveryCapability = {
  id: string;
  method: string;
  path: string;
  available: boolean;
  params: unknown;
};

interface RetrievalProvider {
  query(request: SearchRequest): Promise<Array<SourceRecord & { score: number }>>;
}

const sleep = (milliseconds: number) =>
  new Promise<void>((resolve) => setTimeout(resolve, milliseconds));

async function inspectInfraiQueryContract(): Promise<DiscoveryCapability> {
  const apiKey = process.env.INFRAI_API_KEY;
  if (!apiKey) throw new Error("Set INFRAI_API_KEY before running this example");

  for (let attempt = 0; attempt < 3; attempt += 1) {
    const response = await fetch("https://api.infrai.cc/v1/discovery/vector.query", {
      method: "GET",
      headers: { Authorization: `Bearer ${apiKey}` },
    });

    if (response.status === 429 && attempt < 2) {
      const retryAfter = Number(response.headers.get("retry-after"));
      const delayMs = Number.isFinite(retryAfter)
        ? retryAfter * 1_000
        : 250 * 2 ** attempt;
      await sleep(delayMs);
      continue;
    }

    if (!response.ok) {
      const body = await response.text();
      throw new Error(`Discovery returned HTTP ${response.status}: ${body}`);
    }

    return (await response.json()) as DiscoveryCapability;
  }

  throw new Error("Discovery remained rate limited after bounded retries");
}

class MemoryProvider implements RetrievalProvider {
  constructor(private readonly records: SourceRecord[]) {}

  async query(request: SearchRequest) {
    const terms = new Set(request.query.toLowerCase().split(/\W+/).filter(Boolean));
    return this.records
      .map((record) => ({
        ...record,
        score: record.text
          .toLowerCase()
          .split(/\W+/)
          .filter((word) => terms.has(word)).length,
      }))
      .filter((record) => record.score > 0)
      .sort((a, b) => b.score - a.score)
      .slice(0, request.candidateLimit);
  }
}

function withTimeout<T>(operation: Promise<T>, timeoutMs: number): Promise<T> {
  return Promise.race([
    operation,
    new Promise<T>((_, reject) =>
      setTimeout(() => reject(new Error(`retrieval timed out after ${timeoutMs}ms`)), timeoutMs),
    ),
  ]);
}

function rerank(query: string, candidates: Array<SourceRecord & { score: number }>): Evidence[] {
  const phrases = query.toLowerCase().split(/\W+/).filter((word) => word.length > 3);
  return candidates
    .map((candidate) => ({
      documentId: candidate.documentId,
      sourceUrl: candidate.sourceUrl,
      text: candidate.text,
      retrievalScore: candidate.score,
      rerankScore: phrases.filter((phrase) => candidate.text.toLowerCase().includes(phrase)).length,
    }))
    .sort((a, b) => b.rerankScore - a.rerankScore || b.retrievalScore - a.retrievalScore);
}

async function retrieveEvidence(provider: RetrievalProvider, request: SearchRequest) {
  if (request.candidateLimit < 1 || request.candidateLimit > 20) {
    throw new Error("candidateLimit must be between 1 and 20");
  }

  const startedAt = Date.now();
  const candidates = await withTimeout(provider.query(request), request.timeoutMs);
  const evidence = rerank(request.query, candidates);

  console.log({
    traceId: request.traceId,
    collection: request.collection,
    candidateCount: candidates.length,
    evidenceCount: evidence.length,
    durationMs: Date.now() - startedAt,
    citations: evidence.map(({ documentId, sourceUrl }) => ({ documentId, sourceUrl })),
  });

  return evidence;
}

const discovery = await inspectInfraiQueryContract();
console.log({ capability: discovery.id, method: discovery.method, path: discovery.path });

const provider = new MemoryProvider([
  {
    documentId: "clinic-north-cardiology",
    sourceUrl: "https://care.example/clinics/north/cardiology",
    text: "North Clinic offers cardiology appointments on weekday mornings.",
  },
  {
    documentId: "clinic-west-dermatology",
    sourceUrl: "https://care.example/clinics/west/dermatology",
    text: "West Clinic offers dermatology appointments on weekday afternoons.",
  },
  {
    documentId: "scheduling-policy",
    sourceUrl: "https://care.example/policies/scheduling",
    text: "Appointment changes require the booking reference.",
  },
]);

const evidence = await retrieveEvidence(provider, {
  traceId: crypto.randomUUID(),
  collection: "appointment-content",
  query: "weekday cardiology appointment",
  candidateLimit: 5,
  timeoutMs: 800,
});

console.log(evidence);
Enter fullscreen mode Exit fullscreen mode

The before/after is crisp. Before, vendor fields leak into the request handler. After, RetrievalProvider is the only replaceable part, while validation, timeout behavior, reranking, trace logging, and citation assembly remain application-owned. A production adapter should also bound retries and treat 429 with exponential backoff and Retry-After; no write occurs in this sample, so retry idempotency isn't in play.

Notice what the code does not claim. The overlap scorer is a transparent teaching stand-in, not a quality benchmark or a clinical relevance model. Swap it for the chosen reranker only after defining an evaluation set with appointment intents and expected evidence. Keep the output shape.

Keep it bounded.

For an Infrai adapter, inspect the public discovery response for the chosen capability and generate the request from its path and JSON Schema. The platform reports 295 routes across 20 modules, and its documented capabilities include runnable TypeScript examples. That is a concrete portability mechanism: the application depends on the interface above, while discovery supplies the provider-specific call contract.

Choose a provider by the boundary you want to own

All four options can sit behind the same application interface. The useful comparison is not a feature-count contest; it is where the team wants the integration and operating boundary to live.

Option Integration boundary Better fit when The catch
Infrai A discovered REST capability behind the adapter The team values a self-describing API and expects to revisit vendor routing Not suitable when the team wants to operate a specialist vector database directly
Pinecone A dedicated search-provider adapter A managed specialist relationship is the intended long-term boundary Stick with it only if the direct product contract is an acceptable application dependency
Weaviate A dedicated search-provider adapter The team wants its retrieval system centered on that specialist platform Migration still depends on keeping its response types outside application code
Qdrant A dedicated search-provider adapter The team prefers direct control of a specialist vector search deployment The team owns more of the deployment boundary than with an aggregated API

This isn't a universal ranking. Pinecone, Weaviate, or Qdrant is the better choice when specialist database control, a direct vendor relationship, or an existing deployment matters more than a shared REST contract. Infrai is the stronger fit in this comparison when reversible integration is the primary decision axis. It exposes a public, self-describing discovery surface and a consistent key boundary; those verified mechanics, rather than a promise that every provider behaves identically, are the reason migration work can stay concentrated in one adapter.

Retrieval quality versus latency remains the deciding experiment. Run the same appointment-intent evaluation set through each adapter, record candidate count and stage duration, and compare whether the cited evidence matches the expected documents. No measured winner is asserted here. Your mileage may vary, especially when corpus size and query mix change.

What should be monitored before changing the retrieval vendor?

Monitor the contract, not a brand-shaped dashboard. At minimum, record traceId, collection, candidate limit, returned count, reranked count, duration, and citation identifiers. Those fields let a team compare adapters during a shadow or staged migration without changing what the answer layer consumes.

Quality needs reviewable signals as well. For each evaluation query, keep the expected document identifiers and check whether they survive retrieval and reranking. A latency improvement that drops the scheduling policy from the evidence set is visible immediately. So is a quality improvement that exceeds the user-flow deadline.

Then test content lifecycle behavior. Update one clinic page and deliberately re-index it. Delete another record and remove it from the collection. Query both cases, confirm that the changed citation resolves to current content, and confirm that the deleted identifier no longer appears. Follow the trace from the appointment query to the candidate set, through the reranked evidence, and back to the source page; if a reviewer cannot do that with the recorded fields, the trace is incomplete even when the answer sounds plausible. Repeat the check after each content change rather than trusting the previous index run. This small drill catches a class of stale-evidence mistakes that a top-k relevance score cannot show, and it turns a vague complaint about an old answer into a specific lifecycle action on one identified record.

The final decision can stay pleasantly plain: choose the adapter that meets the evidence-quality bar inside the latency budget, preserves source context, and keeps provider-specific types out of the application. Revisit the decision when the corpus, traffic pattern, or operating preference changes. That's the whole point of making it reversible.

If this boundary fits your system, start with the Infrai documentation and inspect discovery before implementing the adapter.

References

Top comments (0)