Short answer: for a healthcare appointment assistant, index immutable document versions with exact character spans, retrieve the smallest passage that answers the scheduling question, and make citation validation a required step before the answer can leave the system. Favor retrieval quality for policy and eligibility claims, but keep a separate low-latency path for live slot availability. A citation to yesterday's schedule is traceable and still wrong.

This split matters in a marketplace where provider profiles, preparation instructions, cancellation policies, and appointment inventory change on different clocks. Chunking all four into one vector index gives a tidy diagram, yet it mixes evidence that can tolerate minutes of ingestion lag with inventory that cannot. The architecture should reflect those clocks before anyone picks an embedding model or tunes a similarity threshold.

How should a healthcare appointment assistant trace citations through retrieval?

Treat a citation as a verifiable record, not a URL pasted onto generated prose. Each retrieved passage needs a tenant, source document ID, immutable version, character range, content digest, and the time at which that version became effective. The generated answer should refer to the passage ID; the presentation layer can then render a human-readable source label and link. This preserves the chain from answer claim to retrieved text to the exact source version.

The long path is deliberate: source event, normalized document, immutable version, chunk, embedding, retrieval result, answer claim, citation. If a marketplace policy says patients must arrive 15 minutes early, an operator must be able to recover the exact text that supported that claim after the source has changed. A pointer to the current document is insufficient because the current text may no longer contain the wording that the assistant saw.

Consider one ordinary request: “Can I book cardiology tomorrow, and when should I arrive?” It contains two claims with different authorities. The slot search should ask the transactional scheduler about tomorrow, using the patient's permitted marketplace and clinic scope. The arrival question should retrieve the effective cardiology policy, preserving the chunk that contains both the 15-minute instruction and any nearby exception. The response assembler can present those results together, but its internal record should keep them separate: a structured slot result for the booking claim and a versioned passage ID for the policy claim. Before rendering, the validator reloads that document version, checks the recorded character span and digest, and associates the validated passage with only the arrival sentence. If the slot disappears during confirmation, the booking flow reports the current scheduling result without invalidating the policy citation. If the policy retrieval returns nothing, the assistant can still show live slots but must decline to invent an arrival instruction. This example is why one global “RAG succeeded” flag is too coarse. Each claim needs an authority, freshness rule, and failure behavior, or a fluent combined answer will conceal which half was actually verified.

Keep live appointment slots out of that chain. Query the scheduling system of record for availability, then cite the stable scheduling policy separately. Vector search is useful for finding explanatory text; it isn't the authority for whether a 10:30 slot remains bookable. This is the same compliance instinct used in OTP delivery: a successful enqueue is not proof of receipt, and a plausible passage is not proof of current availability.

Separate the clocks.

Claim type Authority Retrieval path Safe fallback
Live slot Transactional scheduler Scoped structured query Show no unverified slot
Arrival or preparation rule Versioned policy source Filtered passage retrieval Escalate when evidence is absent
Cancellation terms Versioned booking policy Filtered passage retrieval Link to the applicable policy
Provider description Approved profile version Search index Omit unsupported detail

The RAG paper describes generation conditioned on retrieved passages and distinguishes retrieval-backed generation from relying only on model parameters. That pattern supplies the broad mechanism here. Citation tracing adds an application-level evidence contract around the retrieved passages; it should not be confused with a claim that the generator itself guarantees attribution.

Build immutable chunks before tuning retrieval

Start with normalization that is boring and repeatable. Preserve the original text, normalize line endings, and compute offsets against the preserved representation. If an ingestion worker silently collapses whitespace after recording offsets, every later citation can point a few characters away from its evidence. That's a nasty failure because the answer may look correct while the audit trail is broken.

The following Python example makes chunks at paragraph boundaries, retains oversized paragraphs rather than inventing sentence-boundary rules, and gives every version and chunk a content-derived identity. It uses only the standard library, so it can run as written. The tenant_id is part of the record and must also be an enforced filter at query time.

from __future__ import annotations from dataclasses import asdict, dataclass from hashlib import sha256 import json @dataclass(frozen=True) class Chunk: chunk_id: str tenant_id: str document_id: str version_id: str start: int end: int text: str content_sha256: str def digest(value: str) -> str: return sha256(value.encode("utf-8")).hexdigest() def chunk_document( tenant_id: str, document_id: str, text: str, target_chars: int = 700, ) -> list[Chunk]: version_id = digest(text) chunks: list[Chunk] = [] start = 0 while start < len(text): proposed_end = min(start + target_chars, len(text)) if proposed_end < len(text): boundary = text.rfind("\n\n", start, proposed_end) end = boundary if boundary > start else proposed_end else: end = len(text) passage = text[start:end] passage_hash = digest(passage) identity = f"{tenant_id}:{document_id}:{version_id}:{start}:{end}" chunks.append( Chunk( chunk_id=digest(identity), tenant_id=tenant_id, document_id=document_id, version_id=version_id, start=start, end=end, text=passage, content_sha256=passage_hash, ) ) start = end + 2 if text.startswith("\n\n", end) else end return chunks policy = ( "Cardiology appointments require arrival 15 minutes before check-in.\n\n" "Cancellations must follow the policy shown during booking." ) records = chunk_document("market-17", "clinic-42-policy", policy) print(json.dumps([asdict(record) for record in records], indent=2)) 

Stable identity buys safe retries. Sending the same source version through ingestion twice produces the same chunk IDs, so storage can use an upsert without creating duplicate candidates. A changed source creates a new version_id; do not overwrite the old version until its retention window and audit obligations permit deletion. The exact retention rule depends on the marketplace's legal and operational requirements, and I'm not sure a universal duration exists. Counsel and the data owner have to settle it.

Chunk size is not a magic constant. Smaller passages can isolate one cancellation rule and reduce irrelevant context, but they may sever qualifications such as “except for same-day imaging.” Larger passages preserve those qualifications while consuming more context and sometimes diluting similarity. Test several sizes against real questions and score both answer support and citation completeness. For this code, 700 is an explicit starting configuration, not a benchmark or recommendation.

Retrieve evidence under freshness and scope constraints

Embedding similarity should generate candidates, not grant permission to answer. Apply tenant, locale, document status, effective time, and audience filters before returning evidence. In a real vector store, these belong in the query predicate rather than in an application-side loop that fetches broad results and filters them later. The application also needs a deterministic tie-break, because changing result order can change the generated answer even when scores are equal.

Scope first.

This compact in-memory implementation shows the contract. The hash-based vector is intentionally a runnable test double, not a semantic embedding model. Swap it behind the embed function in production, then measure the replacement with the same labeled query set.

from collections import Counter from dataclasses import dataclass from math import sqrt import re def embed(text: str, dimensions: int = 256) -> list[float]: vector = [0.0] * dimensions for token in re.findall(r"[a-z0-9]+", text.lower()): vector[int(sha256(token.encode()).hexdigest(), 16) % dimensions] += 1.0 length = sqrt(sum(value * value for value in vector)) or 1.0 return [value / length for value in vector] def cosine(left: list[float], right: list[float]) -> float: return sum(a * b for a, b in zip(left, right)) @dataclass(frozen=True) class Hit: chunk: Chunk score: float def retrieve(query: str, tenant_id: str, chunks: list[Chunk], limit: int = 4) -> list[Hit]: query_vector = embed(query) candidates = [ Hit(chunk=chunk, score=cosine(query_vector, embed(chunk.text))) for chunk in chunks if chunk.tenant_id == tenant_id ] return sorted(candidates, key=lambda hit: (-hit.score, hit.chunk.chunk_id))[:limit] hits = retrieve("When should I arrive for cardiology?", "market-17", records) print([(hit.chunk.chunk_id[:12], round(hit.score, 3)) for hit in hits]) 

Fail closed on scope. A zero-result retrieval should produce a narrow response such as “I can't verify that from the current scheduling policy,” followed by a path to the booking team; it should never relax the tenant filter or substitute a neighboring clinic's rule. Compliance-aware systems distinguish absence of evidence from evidence of absence.

Latency still matters. Record separate timings for normalization, embedding, vector query, reranking, generation, and citation validation. A single end-to-end percentile can hide an ingestion backlog that leaves policies stale or a reranker that dominates the interactive path. Don't optimize the vector query because it is easy to graph while ignoring the slower stage that controls the user's wait.

Validate every cited span before rendering

The final gate should reload each cited immutable version and verify its range and digest. It should also reject citations that were retrieved but do not support any answer claim. That second check needs a claim-to-evidence evaluation policy; pure string containment is too weak for paraphrases, while an unconstrained model judge can be inconsistent. Use reviewed examples to set the policy and keep an escalation state for uncertain cases.

Digest validation itself is deterministic:

from dataclasses import dataclass @dataclass(frozen=True) class Citation: chunk_id: str document_id: str version_id: str start: int end: int content_sha256: str def validate_citation(citation: Citation, source_text: str) -> str: if digest(source_text) != citation.version_id: raise ValueError("citation version does not match the loaded source") passage = source_text[citation.start:citation.end] if digest(passage) != citation.content_sha256: raise ValueError("citation span digest does not match") return passage chosen = records[0] citation = Citation( chunk_id=chosen.chunk_id, document_id=chosen.document_id, version_id=chosen.version_id, start=chosen.start, end=chosen.end, content_sha256=chosen.content_sha256, ) print(validate_citation(citation, policy)) 

Test the ugly boundaries. Include a policy exception split across two paragraphs, two clinics with nearly identical names, a superseded version, a deleted provider, an empty retrieval set, equal-scoring chunks, Unicode text, and a user who asks for one tenant's policy while authenticated to another. Track citation validity separately from answer relevance: a perfectly valid citation can support the wrong claim, and a correct answer without recoverable evidence still fails the audit requirement.

The quality-versus-latency decision becomes concrete in these tests. For preparation, cancellation, eligibility, and referral questions, rerank enough candidates to preserve qualifiers and require a validated source. For live inventory, call the transactional scheduler and keep generated prose minimal. If the latency budget cannot accommodate generation, return structured availability directly. Fast and factual beats fluent and stale.

Roll out with shadow traffic and explicit escape hatches

Begin with offline replay of de-identified, access-controlled queries and reviewed expected passages. Then shadow the retrieval path without showing its answers, comparing old and new evidence sets while monitoring cross-tenant leakage, stale-version selection, empty results, citation digest failures, and stage latency. Canary by tenant only after those checks are stable, and keep versioned index aliases so rollback changes the read target rather than rewriting data under pressure.

This architecture is not suitable when every answer comes from a small, strongly typed, transactional dataset. In that case, stick with direct database queries and templated responses; vector retrieval adds ambiguity without useful recall. It is also a poor fit for live slot truth, where the scheduling system must remain authoritative. Use citation-traced retrieval for the unstructured policy material around the transaction, not as a substitute for the transaction.

The catch is operational weight. Immutable source storage, index generations, claim-level citations, evaluation sets, and audit retention require ownership across application, data, security, and compliance teams. A smaller team can start with lexical search plus the same lineage fields, because the evidence contract matters more than the ranking technique. Later, a vector candidate stage can be introduced behind that contract without changing how citations are verified.

Ship the evidence trail first.

Sources