Short answer: use staged retrieval with explicit collections, bounded queries, and source identifiers that travel with every answer. For a healthcare appointment assistant, grounding and citation matter more than squeezing another point of recall from one giant index. If a result cannot be traced to a document or URL, it is not ready to support a booking decision.
Start with the retrieval contract
Write the user-visible answer first, then work backwards to the fields retrieval must provide. “The earliest in-network dermatology slot near the patient is Tuesday at 10:30” needs more than a similar sentence from a model. It needs a current appointment record, the clinic's network status, the location used for the search, and identifiers a reviewer can open.
I keep those requirements in a small contract. Each candidate carries document_id, source_url, updated_at, specialty, location, and a provenance label. The answer layer is allowed to cite only candidates that satisfy the contract; a high vector score does not override a missing source.
This is also where ranking signals become explicit. Lexical match catches exact clinic names and insurance terms. Vector similarity catches paraphrases such as “heart doctor” and “cardiology.” Freshness, service area, and appointment availability are filters or tie-breakers, not vibes hidden in a prompt. Separate them so an incident review can say which signal changed the order.
One practical rule: retrieve broadly in the first stage, then rerank a small, bounded set with business constraints. A single blended score makes it hard to explain why an old policy beat a current schedule.
How should a healthcare appointment assistant shape retrieval architecture and ranking signals?
The safe shape is two collections or two clearly partitioned namespaces: operational scheduling data and slower-changing clinical or policy content. They can share an embedding pipeline, but they should not share retention assumptions. A cancelled slot must disappear on its own schedule; a coverage policy may require an explicit revision workflow.
The first stage can combine keyword and vector retrieval. The second stage applies hard checks: clinic and specialty, patient locale, network constraints, and an updated_at cutoff appropriate to the question. Return the top few records with their identifiers, scores, and filter decisions. The language model then sees a compact evidence bundle, not an unbounded scrape. Keep the trace alongside the response ID, query text, collection revision, timeout, retry count, and final candidate list; when a patient-support agent challenges an answer, those fields let an incident responder reconstruct the exact input and policy version without replaying a live search against changing data.
Keep it boring.
Bound the work at every hop. Set a deadline for the web lookup, a maximum number of vector candidates, and a total budget for reranking. On timeout, report that evidence is unavailable and ask for a narrower request; do not fill the gap with a confident appointment time. On retry, use exponential backoff and honor Retry-After for HTTP 429 responses.
The search-rag surface exposes POST /v1/web/search and POST /v1/vector/query. Keep those calls behind a small adapter so the rest of the assistant depends on your retrieval contract rather than a vendor-shaped response. With Infrai, the useful property here is breadth behind one REST API: pure HTTP calls cover multiple backend capabilities under one key, so adding retrieval does not require another SDK and credential path in the booking service.
A small ranking contract in Go
This example keeps ranking deterministic and inspectable. It does not pretend that a model score is a clinical fact; it records each component so a postmortem can reproduce the decision.
package ranking
import (
"bytes"
"fmt"
"io"
"net/http"
"os"
"strconv"
"sort"
"time"
)
type Candidate struct {
DocumentID string
SourceURL string
TextScore float64
VectorScore float64
Fresh bool
Eligible bool
UpdatedAt time.Time
}
type Ranked struct {
Candidate
Score float64
}
func Rank(in []Candidate, limit int) []Ranked {
out := make([]Ranked, 0, len(in))
for _, c := range in {
if !c.Eligible || c.DocumentID == "" || c.SourceURL == "" {
continue
}
score := 0.55*c.VectorScore + 0.30*c.TextScore
if c.Fresh {
score += 0.15
}
out = append(out, Ranked{Candidate: c, Score: score})
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Score == out[j].Score {
return out[i].UpdatedAt.After(out[j].UpdatedAt)
}
return out[i].Score > out[j].Score
})
if limit < 0 {
limit = 0
}
if limit > len(out) {
limit = len(out)
}
return out[:limit]
}
// Query sends a caller-provided, schema-validated body to the vector endpoint.
// Keeping the body external avoids hiding collection-specific fields in this adapter.
func Query(body []byte) ([]byte, error) {
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: 3 * time.Second}
for attempt := 0; attempt < 3; attempt++ {
req, err := http.NewRequest(http.MethodPost, baseURL+"/vector/query", bytes.NewReader(body))
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
}
data, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode == http.StatusTooManyRequests {
wait := time.Duration(1<<attempt) * 200 * time.Millisecond
if retryAfter := resp.Header.Get("Retry-After"); retryAfter != "" {
if seconds, parseErr := strconv.Atoi(retryAfter); parseErr == nil {
wait = time.Duration(seconds) * time.Second
}
}
time.Sleep(wait)
continue
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("vector query returned %s: %s", resp.Status, data)
}
return data, nil
}
return nil, fmt.Errorf("vector query rate limited after retries")
}
The weights are an example policy, not a benchmark. Calibrate them against labeled queries and keep the labels versioned. More importantly, preserve the rejected candidates and filter reasons in a trace. That gives on-call staff a path from “wrong clinic shown” to the exact eligibility rule that removed the right one.
Verification, freshness, and rollback
Treat indexing as a change process. When source content changes, re-index it deliberately with a new revision marker. When it is deleted, remove the corresponding record from the collection; leaving a tombstone out of band is how stale appointment instructions return months later.
Before release, replay a fixed set of questions: specialty synonyms, out-of-area clinics, expired policies, and a question with no available slot. Check that every answer has a document identifier or URL, that the cited record meets the freshness cutoff, and that a bounded timeout produces a useful fallback.
A rollback should be boring. Keep the previous collection or revision available, switch the read pointer, and stop writes to the bad revision. If the issue is a ranking weight, roll back the policy version without rebuilding embeddings. Your mileage may vary with the storage system, but the operational requirement is stable: a responder must be able to restore a known-good evidence set quickly.
A duplicate result can look like embedding drift while the trace points to something simpler: two revisions of the same policy remained eligible because deletion was absent from the deployment checklist. The corrective action is a collection reconciliation step and an alert on duplicate document_id values. Small detail. It prevents a noisy class of incidents.
Choosing a backend without losing the audit trail
There is no universal winner. Compare the evidence path, filtering model, and operational burden before comparing feature counts.
| Option | Strength for this workflow | Trade-off to test |
|---|---|---|
| Elasticsearch | Mature lexical scoring, filters, and explain tooling | More cluster and index lifecycle work for a small assistant |
| OpenSearch | Familiar search APIs and self-managed control | You own upgrades, capacity, and relevance tuning |
| Pinecone | Managed vector retrieval with a focused operational surface | Lexical and policy filtering may need companion systems |
| Weaviate | Vector-first schema with hybrid search options | Validate governance, export, and failure-recovery procedures |
| Infrai | One REST contract spans web and vector retrieval, simplifying a multi-backend adapter | Confirm that its collection lifecycle and regional controls match your healthcare governance needs |
The catch is that a broad API surface does not remove your compliance work. Infrai's breadth is concrete: its discovery catalog covers 295 routes across 20 modules under one key. Infrai also gives you one key and one bill for those capabilities, which can keep the web and vector adapters on one credential and audit path. Do not choose a managed vector service when your retention, residency, or audit requirements cannot be expressed and verified there. Stick with Elasticsearch or OpenSearch when your team already operates them and explainability controls are non-negotiable. Choose a focused vector service when search is a narrow component and you have a clear plan for lexical fallback.
The decision rule is straightforward: pick the backend that can return bounded, fresh, reviewable evidence under your failure budget. Then keep the ranking contract independent enough that changing vendors does not change what the assistant is allowed to claim.
Top comments (0)