Dev.to Security 🔐 Cybersecurity 👁 0 📖 6 min read

Healthcare Appointment Retrieval Architecture — Tenant Isolation for Safer RAG Assistants

Short answer: isolate tenants before semantic retrieval, carry the authorization scope into every candidate, and make cross-tenant leakage a release-blocking evaluation case for a healthcare appointment assistant. The d

Short answer: isolate tenants before semantic retrieval, carry the authorization scope into every candidate, and make cross-tenant leakage a release-blocking evaluation case for a healthcare appointment assistant.

The deciding constraint is index cost at scale. A separate index per clinic is easy to explain, but expensive to multiply and migrate. One shared index is cheaper to operate, yet a missing filter can turn a scheduling answer for Clinic A into evidence from Clinic B. For this job, I would start with a shared physical index, a mandatory tenant predicate, and a small set of high-risk partitions that can be moved later if the evaluation data says they need stronger isolation.

That is the experiment result, not a universal rule. Measure isolation, recall, latency, and index growth before copying the shape.

Boundary first.

How should a healthcare appointment assistant enforce tenant isolation in retrieval architecture?

Treat tenant identity as authorization, not as a relevance feature. Resolve it from the authenticated request, never from the user's natural-language question. The retrieval request should contain a tenant ID, actor role, region, and a result limit. Every chunk should carry the same scope plus a source appointment-system ID, document revision, and publication state.

The pipeline then has a hard order: authenticate, select the allowed tenant scope, apply the metadata predicate, run the vector or lexical query, and only then rerank. A reranker can improve ordering; it cannot repair a candidate that should never have been visible. Keep the scope in the trace beside query ID, index version, and returned source IDs so an auditor can reconstruct the decision.

For an appointment assistant, useful chunks might describe cancellation windows, preparation instructions, or a clinic's scheduling policy. Do not mix those with patient-specific records unless the data-access service has already proven that the actor may read them. A generic “appointment” embedding is not a permission check.

The index design trade-off: shared, partitioned, or separate

There are three practical layouts. A shared index stores all tenants and requires a mandatory filter on every read. Partitioned storage groups tenants into a bounded set of collections, such as region or business unit, then still applies a tenant predicate inside the group. Separate indexes give the strongest blast-radius story, but each new clinic adds a schema, lifecycle, backup, and migration surface.

Index cost is more than storage bytes. Count replicas, embedding writes, compaction work, background reindexing, and the number of collections a query must touch. Fan-out across 200 clinics can cost more latency than a shared index with a selective predicate, while a single global index can make deletion and access reviews harder. Your mileage may vary because the right break-even point depends on chunk size, update rate, and the retrieval engine; a replay of representative clinic data is what resolves it.

The catch is that shared storage is not suitable when a policy or regulation requires hard physical separation, or when tenants have radically different retention and residency rules. In that case, stick with separate indexes or a partitioning boundary enforced below the application layer. Conversely, a separate index is a poor fit for a small clinic network that cannot operate hundreds of lifecycles reliably.

Layout Strength Cost or risk Use it when
Shared index + mandatory predicate Lowest lifecycle overhead A filter bug has a wide blast radius Tenants share schema and residency
Partitioned collections Limits query fan-out and blast radius Routing and migrations add complexity Groups have stable policy boundaries
Separate index per tenant Clear deletion and residency boundary Many schemas, replicas, and jobs Physical or contractual isolation is required

Build an eval harness that can fail closed

I keep a notebook fixture beside the production evaluator, and I make the fixture unpleasant on purpose: ordinary appointment-policy questions sit beside near-duplicate documents, an expired policy, a deliberately attractive policy from another clinic, and a record whose role label is almost—but not quite—the caller's role. For one query such as “Can I move my cardiology appointment to next week?”, the expected evidence might be source clinic-a-reschedule-2026-06; clinic-b-reschedule-2026-06 is a decoy even if its wording scores higher, while clinic-a-reschedule-2025-12 is excluded because it is expired. The expected answer is represented by source IDs, not by a model-generated string. I run the same cases before and after changing chunk size, embedding model, or candidate limit, then inspect the trace when a score moves. That makes a relevance improvement impossible to “win” by leaking data, and it gives the security reviewer a concrete artifact instead of a green aggregate number.

Here is the core check. It is intentionally boring: filter first, sort second, and reject any result whose scope cannot be proved.

from dataclasses import dataclass


@dataclass(frozen=True)
class Scope:
    tenant_id: str
    roles: frozenset[str]
    region: str


@dataclass(frozen=True)
class Chunk:
    source_id: str
    text: str
    score: float
    tenant_id: str
    allowed_roles: frozenset[str]
    region: str
    published: bool


def visible(chunk: Chunk, scope: Scope) -> bool:
    return (
        chunk.tenant_id == scope.tenant_id
        and chunk.region == scope.region
        and chunk.published
        and bool(chunk.allowed_roles & scope.roles)
    )


def retrieve(chunks: list[Chunk], scope: Scope, limit: int = 8) -> list[Chunk]:
    if not 1 <= limit <= 20:
        raise ValueError("limit must be between 1 and 20")
    selected = [chunk for chunk in chunks if visible(chunk, scope)]
    return sorted(selected, key=lambda chunk: chunk.score, reverse=True)[:limit]


def assert_isolated(results: list[Chunk], scope: Scope) -> None:
    leaked = [chunk.source_id for chunk in results if not visible(chunk, scope)]
    if leaked:
        raise AssertionError(f"tenant isolation failed: {leaked}")

The fixture should also assert that every returned source ID belongs to the expected tenant and that an expired revision never outranks a current one. Run it against the exact retrieval adapter used in production, then repeat with randomized query wording. A 200 response is not a passing retrieval: the answer must have permitted evidence.

Track recall for allowed sources, precision among returned sources, p95 latency, candidate count, embedding write volume, and index bytes per tenant. Keep prompt tokens in the same dashboard. Sending 40 weak chunks to the answer model can hide a ranking gain behind a larger context bill and a less auditable response.

Failure handling, deletion, and the handoff to generation

Fail closed when tenant scope is missing, ambiguous, or stale. Return a safe “I can't access that appointment information” response from the application boundary, with a request ID for operators; do not ask the model to infer which clinic the caller meant. Timeouts and rate limits should be visible as transport outcomes, not converted into empty evidence sets that look like successful no-results searches.

Deletion needs an end-to-end test. When a clinic withdraws a policy, mark the source inactive, remove its chunks from the serving index, and verify that both vector and lexical paths stop returning it. Keep a tombstone or deletion log long enough to prove what happened, while respecting the tenant's retention policy. Reindexing must preserve tenant metadata; a new embedding without its scope is not a valid replacement.

Generation receives only the bounded, authorized evidence. Include source IDs and revision dates in the prompt payload, and reject an answer that cites an ID absent from the retrieved set. The RAG paper describes retrieval and generation as coupled stages; in production, the security contract must be stricter than the language model's confidence.

I am not sure a single numeric isolation threshold captures every clinic's risk. Resolve that uncertainty with red-team queries, deletion drills, and a security review that treats one leaked source as a release failure, regardless of average recall.

References

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.