teachyou.ai academy
← All posts
RAGsource attributionLLM evaluationretrievalhallucination

Adding Citations and Source Attribution to RAG

Pramod Dutta · Jun 22, 2026 · 11 min read

Rag citations turn a plausible-sounding answer into a verifiable one. Without them, a retrieval-augmented generation system is just a chatbot that read some documents before guessing, and users have no way to tell which parts came from your knowledge base versus the model's imagination. This article walks through the actual mechanics: how to track chunk provenance through the pipeline, how to force the model to cite what it used, how to verify those citations before they ship, and how to render them so a user can click straight to the source.

Why rag citations matter more than answer quality alone

A RAG system without citations asks users to trust it blindly. Even a highly accurate system will occasionally retrieve a weak chunk, blend two sources incorrectly, or have the model state something not actually present in the context. Citations turn every one of those failure modes from a silent error into a visible, checkable one. A user who sees "According to refund-policy.md, refunds are processed within 5 business days" can click through and confirm it in two seconds. A user who sees a bare sentence has no such option.

Citations also change how you can evaluate the system. Once every claim is tied to a source chunk, you can run automated checks: does the cited chunk actually contain the claimed fact? Is the citation pointing at the right paragraph, or just the right document? This turns "does the answer sound right" into "does the answer factually match its cited source," which is a much more tractable QA problem.

There's a compliance angle too. In regulated domains (healthcare, finance, legal), an answer without a traceable source is often unusable no matter how accurate it is. The audit trail is the product requirement, not an optional nicety.

The core architecture: carry metadata all the way through

The single biggest reason citation systems break is that provenance metadata gets dropped somewhere in the pipeline. It has to survive four stages: chunking, embedding/indexing, retrieval, and generation. Design for this from the start rather than bolting it on later.

At chunking time, attach an identifier to every chunk that lets you trace it back to an exact location in the source document:

def chunk_document(doc_id, text, source_url, chunk_size=500, overlap=50):
    chunks = []
    start = 0
    chunk_index = 0
    while start < len(text):
        end = min(start + chunk_size, len(text))
        chunk_text = text[start:end]
        chunks.append({
            "chunk_id": f"{doc_id}::{chunk_index}",
            "doc_id": doc_id,
            "text": chunk_text,
            "source_url": source_url,
            "char_start": start,
            "char_end": end,
            "chunk_index": chunk_index,
        })
        start += chunk_size - overlap
        chunk_index += 1
    return chunks

The char_start and char_end fields matter more than people expect. They let you highlight the exact span in the original document later, and they let you reconstruct a citation even if the chunk boundaries change on a re-index. Store chunk_id, doc_id, source_url, and the character offsets as metadata fields in your vector store, not just as free text glued onto the chunk content.

When you index into a vector store (Postgres with pgvector, Pinecone, Qdrant, Weaviate, whatever you're using), keep this metadata as structured fields, not embedded in the vector itself:

import psycopg2

def index_chunk(conn, chunk, embedding):
    cur = conn.cursor()
    cur.execute(
        """
        INSERT INTO document_chunks
            (chunk_id, doc_id, text, source_url, char_start, char_end, embedding)
        VALUES (%s, %s, %s, %s, %s, %s, %s)
        ON CONFLICT (chunk_id) DO UPDATE SET
            text = EXCLUDED.text,
            embedding = EXCLUDED.embedding
        """,
        (
            chunk["chunk_id"],
            chunk["doc_id"],
            chunk["text"],
            chunk["source_url"],
            chunk["char_start"],
            chunk["char_end"],
            embedding,
        ),
    )
    conn.commit()

At retrieval time, pull the metadata alongside the vector match, not just the text:

def retrieve(conn, query_embedding, top_k=5):
    cur = conn.cursor()
    cur.execute(
        """
        SELECT chunk_id, doc_id, text, source_url, char_start, char_end,
               embedding <=> %s AS distance
        FROM document_chunks
        ORDER BY distance ASC
        LIMIT %s
        """,
        (query_embedding, top_k),
    )
    rows = cur.fetchall()
    return [
        {
            "chunk_id": r[0],
            "doc_id": r[1],
            "text": r[2],
            "source_url": r[3],
            "char_start": r[4],
            "char_end": r[5],
            "distance": r[6],
        }
        for r in rows
    ]

By the time chunks reach the LLM, each one already carries an id you can reference. The generation step's only job is to point back at those ids correctly.

Getting the model to cite correctly

There are two broad strategies: inline citation markers the model inserts itself, and post-hoc attribution where you match generated sentences back to source chunks after the fact. Use inline citations as the primary approach and post-hoc matching as a verification layer, not the other way around.

For inline citations, label each retrieved chunk with a short reference tag and instruct the model to use it:

def build_context_block(chunks):
    lines = []
    for i, c in enumerate(chunks, start=1):
        lines.append(f"[{i}] (source: {c['doc_id']})\n{c['text']}")
    return "\n\n".join(lines)

SYSTEM_PROMPT = """You answer questions using only the numbered sources below.
Every factual claim must end with a citation marker like [1] or [2] that
points to the source it came from. If a claim draws on multiple sources,
cite all of them, like [1][3]. If the sources do not contain the answer,
say so explicitly instead of guessing.

Sources:
{context}
"""

Then call the model with the filled-in prompt:

def answer_with_citations(client, question, chunks):
    context = build_context_block(chunks)
    system = SYSTEM_PROMPT.format(context=context)
    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1024,
        system=system,
        messages=[{"role": "user", "content": question}],
    )
    return response.content[0].text

Two things matter for reliability here. First, keep the numbering scheme simple and consistent, [1], [2], not a mix of formats, because models follow the pattern you establish more faithfully than one they have to infer. Second, explicitly tell the model what to do when the sources don't answer the question. Without that instruction, models tend to cite the closest-matching chunk even when it doesn't actually support the claim, which is worse than no citation at all.

After generation, parse the citation markers back out and map them to your stored chunk metadata:

import re

def resolve_citations(answer_text, chunks):
    marker_pattern = re.compile(r"\[(\d+)\]")
    used_indices = set(int(m) for m in marker_pattern.findall(answer_text))
    citations = []
    for i, c in enumerate(chunks, start=1):
        if i in used_indices:
            citations.append({
                "marker": i,
                "chunk_id": c["chunk_id"],
                "doc_id": c["doc_id"],
                "source_url": c["source_url"],
                "char_start": c["char_start"],
                "char_end": c["char_end"],
            })
    return citations

This gives you a clean, structured list of everything the answer actually referenced, ready to render as links or footnotes.

Verifying that citations are honest

A model that cites [1] next to a claim is not proof the claim came from source 1. Models sometimes cite plausibly without the underlying text actually supporting the statement. This is worth a dedicated verification pass, especially for anything user-facing in a regulated or high-stakes context.

The simplest check is sentence-level entailment: for each cited sentence, does the cited chunk support it? You can do this cheaply with a second, smaller model call rather than a full NLI pipeline:

def verify_citation(client, sentence, cited_chunk_text):
    prompt = f"""Source text:
{cited_chunk_text}

Claim: {sentence}

Does the source text support this claim? Answer with exactly one word:
SUPPORTED, CONTRADICTED, or UNSUPPORTED."""
    response = client.messages.create(
        model="claude-haiku-5",
        max_tokens=10,
        messages=[{"role": "user", "content": prompt}],
    )
    return response.content[0].text.strip()

Run this over every cited sentence before showing the answer to a user, or run it as an offline batch job over a sample of production traffic to catch drift in citation quality over time. Either way, treat CONTRADICTED results as a hard failure that should block the answer or trigger a fallback, and treat UNSUPPORTED as a signal worth logging even if you don't block on it.

A second useful check is coverage: does every sentence in the answer that states a fact actually have a citation attached? Split the answer into sentences, and flag any sentence that makes a factual-sounding claim but carries no marker:

def find_uncited_claims(answer_text):
    sentences = re.split(r"(?<=[.!?])\s+", answer_text)
    uncited = []
    for s in sentences:
        has_marker = bool(re.search(r"\[\d+\]", s))
        looks_factual = len(s.split()) > 4 and not s.strip().startswith(
            ("I ", "Note:", "In general")
        )
        if looks_factual and not has_marker:
            uncited.append(s)
    return uncited

This is a heuristic, not a guarantee, but it's cheap and catches a real category of bug: models sometimes cite the first two sentences of a paragraph and then drift into an uncited generalization by the third.

Rendering citations for users

Structured citation data is only useful if the interface makes it clickable and verifiable. The pattern that works well: render inline superscript markers in the answer text, and a source list below it that links each marker to the actual passage.

On the backend, once you have the resolved citations list, build a response payload the frontend can render directly:

def build_response_payload(answer_text, citations):
    return {
        "answer": answer_text,
        "sources": [
            {
                "marker": c["marker"],
                "title": c["doc_id"],
                "url": f"{c['source_url']}#char={c['char_start']}-{c['char_end']}",
            }
            for c in citations
        ],
    }

On the frontend, replace [1] style markers with clickable superscript links, and deep-link into the source document using the character offsets if your document viewer supports scrolling to a range. If it doesn't, at minimum link to the document and show the quoted passage in a tooltip or expandable panel so the user doesn't have to hunt for it.

Avoid two common UI mistakes. First, don't just show a flat list of "sources used" without inline markers, users can't tell which claim came from which source, which defeats the point. Second, don't hide the citation list behind an extra click by default for anything factual or decision-relevant, surface it inline so skepticism is the path of least resistance, not an opt-in.

Handling multi-hop and synthesized answers

Citations get harder when the answer synthesizes information across several chunks rather than restating one directly, for example "Plan A costs less upfront but Plan B has lower total cost after 18 months," where each half of the sentence draws on a different source. Instruct the model explicitly to split synthesized claims into separate cited clauses rather than one uncited compound sentence:

SYNTHESIS_INSTRUCTION = """When combining information from multiple sources,
break the answer into separate clauses, each with its own citation, rather
than one sentence that blends sources without indicating which part came
from where. Prefer: "Plan A costs less upfront [1], but Plan B has lower
total cost after 18 months [2]." over an uncited blended sentence."""

Append this to your system prompt when you expect comparison or synthesis-heavy queries. It costs a small amount of fluency but meaningfully improves traceability, which is the tradeoff you want for anything users will act on.

Common failure modes and how to catch them

Citation number drift is the most frequent bug: if you deduplicate or reorder chunks after building the prompt but before parsing the response, the [1], [2] markers no longer match your chunk list. Freeze the chunk order the moment you build the context block, and use that exact same ordered list when resolving markers afterward.

Stale citations after re-indexing are another common issue. If you re-chunk a document with a different chunk size or overlap, old chunk_id values stop matching, and any previously generated answer with cached citations now points at nothing. Version your chunk ids (doc_id::v2::chunk_index) so you can tell at a glance whether a stored citation is still valid against the current index.

Over-citing is subtler but still a real problem: models that are heavily prompted to cite everything sometimes attach a marker to a sentence that only loosely relates to the source, just to satisfy the instruction. The entailment check described above catches this if you run it, don't skip verification just because the model dutifully produced markers, markers alone are not evidence.

FAQ

Do rag citations need to point to the exact sentence, or is document-level attribution enough? Document-level attribution is better than nothing but noticeably less useful. Chunk-level or passage-level citation, ideally with character offsets so you can highlight the exact span, is worth the extra engineering because it's what actually lets a user verify a claim in seconds instead of skimming a whole document.

Should citation verification block the response, or just log for later review? For anything low-stakes (internal search, casual Q&A), log and monitor. For anything a user might act on financially, medically, or legally, block the response or fall back to "I couldn't verify this from the source material" rather than shipping an unverified claim.

What happens when the retrieved chunks genuinely don't answer the question? The system prompt should instruct the model to say so explicitly rather than cite the nearest chunk anyway. Test this path directly: send queries you know aren't covered by your knowledge base and confirm the model declines rather than fabricates a citation.

Can this work with a hosted RAG platform that doesn't expose chunk metadata? Only partially. If the platform returns retrieved passages as plain text with no stable id or offset, you can still do sentence-to-passage matching as a post-hoc step, but you lose the ability to deep-link into the exact source location. Push for a platform or self-hosted setup that preserves metadata if citation accuracy is a hard requirement.

How much does adding citation instructions hurt answer quality or latency? In practice the main cost is prompt length (the numbered source block) and a small amount of generation verbosity, not a fundamental quality hit. The verification pass, if you add one, is the bigger latency cost since it's an extra model call per cited sentence, so batch it or run it asynchronously rather than blocking the user-facing response on every check.

Is a simple keyword match between the answer and source text enough to verify citations? It catches egregious errors (a citation to a completely unrelated chunk) cheaply, but it misses paraphrased or synthesized claims that are wrong in substance while sharing vocabulary with the source. Use keyword overlap as a fast first filter and the entailment-style model check for anything that passes it, rather than relying on keyword matching alone.