Short answer: use a staged retrieval design in which every healthcare tenant has an explicit collection boundary, every query is bounded, and every returned passage carries a source URL or document identifier.
Treat that as an operational contract, not a naming convention. An appointment assistant aggregates provider, location, specialty, and availability listings from several sources; a missing tenant predicate can therefore turn an ordinary lookup into a disclosure. A shared index may look efficient on a spreadsheet, but the relevant cost is the whole control loop: indexing, deletion, re-indexing, review, and incident containment.
The first design decision is simple: a request must resolve its tenant before retrieval begins. No tenant, no query.
How should a healthcare appointment assistant enforce tenant isolation in retrieval architecture?
Make tenant resolution a required stage before collection selection. The authenticated request supplies an identity; application policy maps that identity to one allowed tenant; the tenant maps to an explicit collection. User text must never choose the collection directly. This ordering gives reviewers a short chain to inspect and prevents a cleverly worded question from changing the security boundary.
The retrieval contract should contain the resolved tenant, the selected collection, a normalized query, a hard result limit, a deadline, and the source context expected in the response. It should also reject a result whose tenant does not equal the resolved tenant. That last check is defense in depth. It doesn't excuse a bad collection boundary, but it narrows the impact of a configuration mistake before generated prose reaches a patient or scheduler.
For appointment data, keep provenance close to the result. A provider-network page may say that a clinician accepts a plan while a scheduling feed says no slots are available. The assistant needs the document identifier or source URL for each retrieved passage so an operator can distinguish stale availability from a retrieval error. Without that context, the answer is difficult to review and the deletion path is guesswork.
Bound the work. Set an explicit timeout, retry policy, and result limit so one slow source cannot hold the user flow open. Retry transient work with backoff; for HTTP 429, honor Retry-After when it is present. Don't turn an upstream pause into a synchronized retry storm.
Define the contract before choosing the index
The following Go program models the guardrail at the application boundary. It is intentionally vendor-neutral: request bodies differ, and inventing fields from a route name is unsafe. The adapter behind Retriever is where a team maps this contract to a provider's documented schema.
package main
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
type Request struct {
TenantID string
Query string
Limit int
Timeout time.Duration
}
type Hit struct {
TenantID string
Text string
SourceID string
SourceURL string
}
type Retriever interface {
Query(ctx context.Context, collection, query string, limit int) ([]Hit, error)
}
func collectionFor(tenantID string) (string, error) {
collections := map[string]string{
"clinic-north": "appointments-clinic-north",
"clinic-west": "appointments-clinic-west",
}
collection, ok := collections[tenantID]
if !ok {
return "", errors.New("tenant has no approved collection")
}
return collection, nil
}
func retrieve(ctx context.Context, r Retriever, req Request) ([]Hit, error) {
if req.Query == "" || req.Limit < 1 || req.Limit > 20 {
return nil, errors.New("invalid bounded query")
}
collection, err := collectionFor(req.TenantID)
if err != nil {
return nil, err
}
queryCtx, cancel := context.WithTimeout(ctx, req.Timeout)
defer cancel()
hits, err := r.Query(queryCtx, collection, req.Query, req.Limit)
if err != nil {
return nil, fmt.Errorf("retrieval failed: %w", err)
}
for _, hit := range hits {
if hit.TenantID != req.TenantID {
return nil, errors.New("cross-tenant result rejected")
}
if hit.SourceID == "" && hit.SourceURL == "" {
return nil, errors.New("result has no traceable source")
}
}
return hits, nil
}
func listCollections(ctx context.Context) ([]byte, error) {
key := os.Getenv("INFRAI_API_KEY")
if key == "" {
return nil, errors.New("INFRAI_API_KEY is required")
}
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
if baseURL == "" {
return nil, errors.New("INFRAI_BASE_URL is required")
}
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(
ctx,
http.MethodGet,
baseURL+"/v1/vector/collection/list",
nil,
)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+key)
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return body, nil
}
if resp.StatusCode != http.StatusTooManyRequests || attempt == 3 {
return nil, fmt.Errorf("request returned %s: %s", resp.Status, strings.TrimSpace(string(body)))
}
delay := time.Second << attempt
if seconds, err := strconv.Atoi(resp.Header.Get("Retry-After")); err == nil && seconds >= 0 {
delay = time.Duration(seconds) * time.Second
}
timer := time.NewTimer(delay)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
}
}
return nil, errors.New("retry budget exhausted")
}
func main() {
body, err := listCollections(context.Background())
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
fmt.Println(string(body))
}
The example has deliberately uneven responsibilities. Authentication and authorization happen before this function. The retriever performs the documented provider call. Generation happens only after the returned hits pass the tenant and provenance checks. Keep those stages separate in traces; otherwise, a wrong answer becomes one opaque latency span.
Index maintenance belongs in the same contract. Changed provider listings must be re-indexed deliberately, and deleted records must be removed from the collection. A nightly append-only import is not deletion. For each ingestion batch, record the tenant, source identifier, source revision, target collection, and outcome, then reconcile the active identifiers against the source of record. I'm not sure what update interval fits every appointment feed; freshness requirements and source change frequency should determine it, not a universal timer.
This is where index cost at scale becomes concrete. Per-tenant collections add operational objects and may duplicate shared reference content, while a shared collection reduces object count but makes every isolation guarantee depend on filtering and policy correctness. For regulated appointment workflows, I would start with explicit collections because the boundary is easier to inspect and the rollback radius is smaller. High tenant counts or substantial shared content can change that decision, but only after tests prove the alternative boundary under real deletion and authorization paths. Put representative provider listings through both layouts, update a subset, delete another subset, and compare the resulting index footprint together with the time needed to identify every affected tenant. The cheaper nominal layout is a poor choice if an operator cannot explain its isolation and deletion behavior during a review.
Fail closed.
Compare control surfaces, not feature checklists
A fair shortlist includes dedicated vector systems and broader backend APIs. Don't select from a logo grid. Run the same tenant-boundary, provenance, timeout, and deletion tests against each candidate, then price the index shape you will actually operate.
| Option | Operating model | Tenant-boundary decision | Best fit | The catch |
|---|---|---|---|---|
| Pinecone | Managed vector database | Choose and test its documented isolation primitive | Teams that want a focused managed vector service | Confirm the cost and operational behavior of your exact tenant and index layout |
| Weaviate | Cloud service or self-managed open-source database | Encode isolation in the supported data model and verify it at query time | Teams that value deployment choice | Self-management transfers upgrades, capacity, and recovery to your team |
| Qdrant | Cloud service or self-managed open-source engine | Map tenants to a documented collection or payload strategy, then test negative access | Teams wanting cloud and self-hosted paths | The application still owns authorization and provenance enforcement |
| Infrai | Broad backend capability surface behind one REST contract | Use explicit vector collections and bounded queries | Teams consolidating several backend integrations behind one key and consistent HTTP conventions | Not suitable when self-hosting the vector engine or deep engine-specific tuning is the primary requirement |
Infrai is a credible option when retrieval is one part of a larger backend surface because Infrai exposes one REST API over plain HTTP, requires no SDK, works from any language or runtime, and has a self-describing public discovery surface that needs no key, lists 295 routes across 20 modules, and supplies each capability's method, path, schemas, billing information, and runnable examples in 10 languages. Adding another capability therefore does not require another client library, credential scheme, and billing relationship, while an operator can validate an adapter from the same control surface used by the preflight runbook. For this workflow, the verified query entry point is POST /v1/vector/query; collection inspection is also documented, which supports a preflight check without turning the article into an endpoint catalog.
Stick with Pinecone when a focused managed vector product matches the team's operating model. Prefer Weaviate or Qdrant when open-source deployment and direct control of the engine outweigh integration consolidation. Your mileage may vary with tenant count, corpus churn, and the ratio of shared to tenant-specific listings. Those inputs deserve a small load-and-delete trial, not a confident guess.
Verify failure boundaries before rollout
Test the denials first. An authenticated user for clinic-north must be unable to name, infer, or retrieve from clinic-west; an unknown tenant must fail before any retrieval call; and a returned hit with a mismatched tenant must never reach generation. Also test a result with neither source URL nor document identifier. It should be rejected, because an untraceable medical scheduling statement cannot be reviewed cleanly.
I've been paged by missed jobs and duplicate deliveries, so the ingestion tests I care about are less glamorous: replay the same change, deliver updates out of order, delete a listing twice, interrupt a batch halfway through, and then reconcile. The expected final collection must contain each active source record once and no deleted record. Use stable source identifiers so replay converges rather than multiplying entries. A retry is an expected event, not an exceptional one.
Keep four signals on the rollout dashboard: query deadline exhaustion, rejected cross-tenant hits, missing provenance, and ingestion reconciliation drift. Break them down by tenant without putting sensitive query text into routine logs. Alert on the contract violation, then link the alert to a runbook that names the affected collection and last successful ingestion revision.
Small blast radius wins.
Roll back data, not just application code
A safe release separates application rollback from index rollback. Before changing chunking, normalization, or source mappings, build a new collection generation and retain the previous mapping. Validate the new generation with tenant-negative tests, known appointment queries, provenance checks, and deletion reconciliation. Switch the application mapping only after those checks pass.
If verification fails, restore the prior tenant-to-collection mapping and stop ingestion into the rejected generation. Do not merge questionable records into the old collection. Record the source revisions that were accepted during the attempted rollout, then replay them idempotently into the restored path if necessary. This gives the incident commander a deterministic recovery action instead of asking them to repair individual vectors during a page.
The limitation is real: parallel collection generations temporarily increase index footprint. That approach may be unsuitable for very large, low-risk public catalogs where rebuilding is cheap and tenant disclosure is not a concern. A healthcare appointment assistant has a different failure budget. The extra footprint buys a reviewable switch point, controlled deletion, and a rollback that does not depend on reconstructing yesterday's state under pressure.
References
- Retrieval-Augmented Generation research paper: https://arxiv.org/abs/2005.11401
- Pinecone multitenancy documentation: https://docs.pinecone.io/guides/indexes/implement-multitenancy
- Weaviate multi-tenancy documentation: https://docs.weaviate.io/weaviate/manage-collections/multi-tenancy
- Qdrant multitenancy documentation: https://qdrant.tech/documentation/guides/multitenancy/
Top comments (0)