Skip to Content
Start Free

How to Ground LLM Answers With Citations and Cut Hallucinations

How to Ground LLM Answers With Citations and Cut Hallucinations
How AI Cites Its Sources

A citation looks like evidence and is only a claim about a source, so it needs checking like any other claim.

Highlights

  • A bare prompt invented an answer to every question its sources could not cover, which was four out of four in this test.
  • Adding one sentence telling the model to use only the sources, and to say when they fall short, took that to zero.
  • Asking for citations on top of grounding made it slightly worse rather than better, going back up to one in four.
  • The model attached a citation to an invented fact, pointing at a source that said nothing on the subject.
  • It also skipped citations entirely on five of the six questions it could answer, so the instruction was mostly ignored.
  • Verifying a citation means asking whether the cited source actually supports the claim, which is a check you can run automatically.
  • A keyword based refusal check miscounted these results, which is a reminder that your measurement needs measuring too.

Ask a model something your documents do not cover and it answers anyway. It sounds confident. It uses your format. It offers specifics.

In this test a bare prompt invented an answer to all four questions it had no basis for. One sentence took that to zero. Then adding citations, which sounds like it should help further, made things slightly worse.

In this article, let us look at how to measure all of that. We will build the piece most systems skip, which is checking whether a cited source really says what the answer claims.

What grounding actually means

Grounding means handing the model the text to answer from, then telling it to stay inside that text.

That is a smaller idea than it sounds. You are not making the model more truthful in general. You are narrowing what counts as a source for this one answer, and asking it to admit when that source runs out.

The admitting part matters more than people expect. Give a model good sources and no permission to say "I do not know" and it will still produce something. Producing text is what it does, and the most likely output is a plausible answer built from nearby material.

Citations are a separate idea layered on top. A citation says which piece of the supplied text a claim came from. That helps a reader check your work. It is not a guarantee, and that gap is what this article spends its time on.

What you need before you start

Everything here runs on one machine. A Linux box, which a KodeKloud playground gives you, or your own laptop.

You need two models. A small local one to answer questions, and a stronger one to check those answers. The split is deliberate, because checking is a different job from answering.

Install Ollama for the answers

sudo apt-get update && sudo apt-get install -y zstd python3 python3-venv
curl -fsSL https://ollama.com/install.sh | sh
curl http://localhost:11434/
ollama pull llama3.2:1b

Ollama is running from that third command means the service already started. A connection refused means you need ollama serve & first.

Get a key for the checker

KodeKey gives you one key reaching Claude, GPT and Gemini through a single OpenAI compatible endpoint.

export KODEKEY_API_KEY="your key here"

Set up the project

mkdir grounding && cd grounding
python3 -m venv .venv
source .venv/bin/activate

There is nothing to install, since everything below uses the standard library. You will build four files as you work through the article.

FileWhat it does
sources.py The numbered sources, and questions with known answer locations
compare.py Runs three prompt styles and saves every answer
score.py Counts how often the model invented an answer
verify.py Checks whether a citation actually supports its claim

Step 1: Set up sources and questions

Create sources.py. The sources are numbered because citations need something to point at.

SOURCES = [
    {"id": 1, "title": "Pod restart behaviour",
     "text": "When a pod enters CrashLoopBackOff, Kubernetes waits longer between "
             "each restart, doubling up to a maximum of five minutes. Deleting the "
             "pod makes the controller recreate it within seconds."},
    {"id": 2, "title": "Exit codes",
     "text": "Exit code 137 is 128 plus signal 9, which is kill. The kernel out of "
             "memory killer stopped the process. The memory limit is enforced per "
             "container rather than per pod."},
    {"id": 3, "title": "Credential rotation",
     "text": "Rotate the credential in the secret store first, then update the "
             "Kubernetes secret, then restart the deployment. The application does "
             "not watch the secret for changes."},
    {"id": 4, "title": "Node maintenance",
     "text": "Cordon the node first so the scheduler stops placing new pods on it, "
             "then drain it. A deployment with one replica and a disruption budget "
             "requiring one available will block the drain."},
]

QUESTIONS = [
    {"q": "What is the maximum restart backoff?", "source": 1, "answerable": True},
    {"q": "What does exit code 137 mean?", "source": 2, "answerable": True},
    {"q": "Is the memory limit per pod or per container?", "source": 2, "answerable": True},
    {"q": "What do I update first when rotating a credential?", "source": 3, "answerable": True},
    {"q": "Why would a drain never finish?", "source": 4, "answerable": True},
    {"q": "Should I cordon before draining?", "source": 4, "answerable": True},
    {"q": "What is the default pod CPU request?", "source": None, "answerable": False},
    {"q": "How do I configure horizontal pod autoscaling?", "source": None, "answerable": False},
    {"q": "What port does the kubelet listen on?", "source": None, "answerable": False},
    {"q": "How long does a rolling update take?", "source": None, "answerable": False},
]

Four of those questions have no answer in the sources. Those are the important ones. Ask about a topic your sources cover and you get a reasonable answer either way, grounded or not. So a test set of only answerable questions tells you nothing about hallucination.

The source field records which source holds each answer. That is what makes checking citations possible later.

Watch out

A test set with no unanswerable questions cannot measure hallucination at all. Everyone writes the questions their documents cover, because those are the ones in mind while writing them. The behaviour that matters is what happens at the edge of your knowledge base, where a plausible answer can be assembled from adjacent material. Aim for roughly a third of your cases to be things your sources genuinely do not address, including a few that sound like they should be covered.

Step 2: Measure three prompts

Create compare.py. It runs every question through three prompt styles and counts how often the model invents an answer it has no basis for.

import json
import re
import urllib.request

from sources import SOURCES, QUESTIONS

BLOCK = "\n\n".join(f"[{s['id']}] {s['title']}\n{s['text']}" for s in SOURCES)

PROMPTS = {
    "bare": "Question: {q}\n\nAnswer briefly.",

    "grounded": ("Answer using only the sources below. If they do not contain the "
                 "answer, say you do not know.\n\nSources:\n{block}\n\n"
                 "Question: {q}\n\nAnswer briefly."),

    "grounded + cite": ("Answer using only the sources below. Cite the source number "
                        "in square brackets after each fact, like [2]. If the sources "
                        "do not contain the answer, say you do not know and cite "
                        "nothing.\n\nSources:\n{block}\n\nQuestion: {q}\n\nAnswer briefly."),
}


def ask(prompt, model="llama3.2:1b"):
    body = json.dumps({
        "model": model, "prompt": prompt, "stream": False,
        "options": {"num_predict": 90, "temperature": 0},
    }).encode()
    request = urllib.request.Request(
        "http://localhost:11434/api/generate", data=body,
        headers={"Content-Type": "application/json"},
    )
    return json.loads(urllib.request.urlopen(request).read())["response"].strip()


results = {}
for name, template in PROMPTS.items():
    rows = []
    for item in QUESTIONS:
        answer = ask(template.format(block=BLOCK, q=item["q"]))
        rows.append({**item, "answer": answer,
                     "cited": [int(n) for n in re.findall(r"\[(\d+)\]", answer)]})
    results[name] = rows

json.dump(results, open("runs.json", "w"), indent=1)
print("saved runs.json")
python3 compare.py

Saving the answers lets you score them repeatedly without regenerating. That matters, because the next step changes how they are scored.

Step 3: Count the invented answers

Create score.py. For each unanswerable question it decides whether the model admitted it did not know, or invented something.

That decision needs a method. The obvious one is to look for phrases like "I do not know". It turns out to be wrong here, and finding out why is worth more than the result.

The alternative is to ask another model. You send it the reply, plus a yes or no question about whether that reply declined. This is usually called using a judge. A judge is just a second model whose only job is grading the first one's work.

import json
import os
import urllib.request

RUNS = json.load(open("runs.json"))


def refused_by_keyword(answer):
    """The tempting approach, included to show why it fails."""
    lowered = answer.lower()
    return any(phrase in lowered for phrase in
               ["do not know", "not contain", "no information", "not provided"])


def refused_by_judge(answer, model="claude-haiku-4-5-20251001"):
    """Ask a model whether the reply declined, rather than matching words."""
    prompt = (f"Does this reply decline to answer, saying it does not know or that "
              f"the sources do not cover it?\n\nReply: {answer}\n\n"
              f"Answer with one word, YES or NO.")
    body = json.dumps({
        "model": model,
        "messages": [{"role": "user", "content": prompt}],
        "max_tokens": 4, "temperature": 0,
    }).encode()
    request = urllib.request.Request(
        "https://api.ai.kodekloud.com/v1/chat/completions", data=body,
        headers={"Content-Type": "application/json",
                 "Authorization": f"Bearer {os.environ['KODEKEY_API_KEY']}"},
    )
    reply = json.loads(urllib.request.urlopen(request).read())
    return reply["choices"][0]["message"]["content"].strip().upper().startswith("YES")


print(f"  {'prompt':18s}{'keyword check':>15s}{'judge':>9s}")
for name, rows in RUNS.items():
    unanswerable = [r for r in rows if not r["answerable"]]
    by_keyword = sum(1 for r in unanswerable if not refused_by_keyword(r["answer"]))
    by_judge = sum(1 for r in unanswerable if not refused_by_judge(r["answer"]))
    print(f"  {name:18s}{by_keyword:>12}/4{by_judge:>6}/4")
python3 score.py
python3 score.py
prompt              keyword check    judge
  bare                         4/4      4/4
  grounded                     0/4      0/4
  grounded + cite              2/4      1/4

What this tells youOne sentence took the bare prompt from four invented answers to zero. Adding citations put one back. The keyword check also overcounted, looking for 'not provided' while the model wrote 'do not provide'.

Three things in that table are worth separating.

The bare prompt invented an answer every single time. That is not a bad model. It is a model doing its job, never having been told the job had boundaries.

One sentence fixed it completely. Telling the model to use only the sources, and to say when they fall short, took four out of four down to zero. No other change in this article comes close.

And the keyword check disagreed with the judge. It counted two inventions where there was one, because it looked for "not provided" and the model wrote "do not provide". Your measurement can fail the same way your system can. A measurement nobody checked is just a confident number.

Step 4: The result that surprises people

Look at the last row again. Adding a citation instruction to a working grounded prompt made it worse, going from zero inventions back up to one.

That is worth understanding rather than dismissing as noise, since the mechanism is a general one.

The grounded prompt asks for one behaviour. Answer from the sources, or decline. The citation prompt asks for three at once. Answer from the sources, attach a bracketed number to each fact, and decline when they fall short. A small model asked for three things does the easiest ones first, and producing an answer with a citation attached is easier than deciding no answer exists.

There is a second pull in the wrong direction. Having been told what a good answer looks like, complete with brackets, the model has a template to fill rather than a judgement to make.

PromptInvented answersCited sources
Bare 4 of 4 Not asked to
Grounded 0 of 4 Not asked to
Grounded and cited 1 of 4 On 1 of 6 answerable questions

That last column is the other half of the problem. The model was asked to cite a source for every fact. It did so on one of the six questions it could answer, so the instruction was mostly ignored.

In the wild

Small models follow simple instructions well and compound instructions badly, which is why adding a requirement can remove an existing one. If grounding matters more to you than citations, and it usually does, check that adding citations has not cost you the grounding. Measure the same unanswerable questions before and after. A larger model handles the combination better, so this is partly a model size question, and it is worth knowing which way your model fails before you find out from a user.

Practise the whole pipeline

Grounding sits inside retrieval, and the quality of what you retrieve sets the ceiling on what grounding can do. The Fundamentals of RAG course covers ingestion, chunking, search strategy and building a complete pipeline.

Course

Fundamentals of RAG

Ingestion, chunking, keyword against semantic search, vector databases and a complete pipeline. Retrieval quality sets the ceiling on what grounding can do.

RAGAIPython
Explore the RAG course

Step 5: Verify the citations

Here is the failure that matters most. It is also the one almost nobody checks for.

One answer came back like this:

The kubelet listens on port 10250 [1].

Source 1 is about pod restart behaviour. It says nothing about kubelets and nothing about ports. So the model invented a fact and attached a citation to it. Worse, the citation makes that invented fact look better sourced than the true ones, which arrived with no brackets at all.

A citation is a claim about a source. Like any other claim it can be checked, and the checking is mechanical.

Create verify.py.

import json
import os
import re
import urllib.request

from sources import SOURCES

BY_ID = {s["id"]: s for s in SOURCES}

CHECK = """Does this source actually support the claim?

Source [{id}] {title}:
{text}

Claim: {claim}

Answer with one word, YES or NO."""


def supports(claim, source_id, model="claude-haiku-4-5-20251001"):
    source = BY_ID.get(source_id)
    if source is None:
        return False, "cited a source that does not exist"

    body = json.dumps({
        "model": model,
        "messages": [{"role": "user",
                      "content": CHECK.format(claim=claim, **source)}],
        "max_tokens": 4, "temperature": 0,
    }).encode()
    request = urllib.request.Request(
        "https://api.ai.kodekloud.com/v1/chat/completions", data=body,
        headers={"Content-Type": "application/json",
                 "Authorization": f"Bearer {os.environ['KODEKEY_API_KEY']}"},
    )
    reply = json.loads(urllib.request.urlopen(request).read())
    ok = reply["choices"][0]["message"]["content"].strip().upper().startswith("YES")
    return ok, "supported" if ok else "the source does not say this"


def check_answer(answer):
    """Every sentence carrying a citation is a claim to verify."""
    results = []
    for sentence in re.split(r"(?<=[.!?])\s+", answer):
        for cited in re.findall(r"\[(\d+)\]", sentence):
            claim = re.sub(r"\s*\[\d+\]", "", sentence).strip()
            ok, why = supports(claim, int(cited))
            results.append({"claim": claim, "source": int(cited),
                            "ok": ok, "why": why})
    return results


if __name__ == "__main__":
    EXAMPLES = [
        "The kubelet listens on port 10250 [1].",
        "The maximum restart backoff is five minutes [1].",
        "Exit code 137 means the out of memory killer stopped the process [2].",
        "Rotate the credential in the secret store first [2].",
        "The memory limit is per container [9].",
    ]
    for example in EXAMPLES:
        for result in check_answer(example):
            mark = "ok  " if result["ok"] else "FAIL"
            print(f"  [{mark}] [{result['source']}] {result['claim'][:58]}")
            if not result["ok"]:
                print(f"          {result['why']}")
python3 verify.py
python3 verify.py
[FAIL] [1] The kubelet listens on port 10250.
          the source does not say this
  [ok  ] [1] The maximum restart backoff is five minutes.
  [ok  ] [2] Exit code 137 means the out of memory killer stopped the p
  [FAIL] [2] Rotate the credential in the secret store first.
          the source does not say this
  [FAIL] [9] The memory limit is per container.
          cited a source that does not exist

What this tells youThree ways a citation fails, all caught. An invented fact with a citation attached, a true fact pointed at the wrong source, and a source number that was never supplied.

Those five examples cover the three ways a citation goes wrong. The verifier catches all of them.

The first is an invented fact with a citation attached, where the source exists and says nothing on the subject. The fourth is a true fact pointed at the wrong source, since credential rotation is source 3 rather than source 2. That one is easiest to miss by eye, because the claim itself is correct. The fifth cites a source that does not exist, which needs no model call to catch.

Handle that last one first, because it is free. Checking every cited number is in range is a dictionary lookup, and it catches the most obviously broken case before you pay for anything.

Quick tip

Run the cheap checks before the expensive one, and most bad citations never reach the model. A citation pointing at a source number you did not supply is wrong with certainty, and finding that out costs nothing. So validate the range first, then check whether the source supports the claim. On a system answering thousands of questions, the free check handles a surprising share of the failures, and you pay a model only for the ones that need judgement.

What this costs and when it is worth it

Verification costs one extra model call per citation. Not free, and not always needed.

SituationVerify citations
Internal tool, technical users Probably not, they will notice
Customer facing answers Yes
Anything medical, legal or financial Yes, and sample by hand as well
High volume, low stakes Sample rather than check everything
Building the eval set Yes, this is how you find the failure modes

Sampling is the option people forget. Checking one answer in fifty costs almost nothing and still tells you your rate. That rate is what tells you whether to check them all.

Putting it together

Here is the order that works, based on the measurements above.

Start with the grounding instruction. It is one sentence and it removed every invented answer here. Give the model explicit permission to decline, because without it a model always produces something.

Add citations only after checking that grounding still holds. The measurement here showed the combination can weaken it on a small model.

Then verify the citations rather than trusting them. Start with the free range check, and use a model only for the claims that survive it.

And measure the unanswerable questions specifically. That is where the behaviour you care about lives. A test set full of answerable questions reports success while your system invents things at the edges.

What you should be able to answer now

What single change cuts hallucination most? Tell the model to answer only from the sources, and to say when they fall short. That took four invented answers out of four down to zero.

Why might adding citations make grounding worse? It turns one instruction into three, and a small model satisfies the easiest ones first. Producing an answer with brackets is easier than deciding no answer exists.

Why is a citation not proof? It is a claim about a source, not a quote from one. The model here cited a source about pod restarts as the basis for a fact about kubelet ports.

What should you check first when verifying? Whether the cited source number exists at all. That check is free, and it catches the most clearly broken case.

The most useful thing here is not the verifier. It is that the questions with no answer were the only ones that told us anything, and most test sets contain none.

Ready to Build the Rest of the Pipeline?

Grounding is one layer of making an AI answer trustworthy. The Fundamentals of RAG course covers retrieval end to end, the Vector Database for GenAI course goes deeper on storage, and the AI Learning Path sequences both alongside agents and MCP. Start by writing four questions your documents cannot answer.

Learning path

AI Learning Path

Vector databases, MCP, agents and OpenAI sequenced in order, so each topic arrives when the one before it has landed.

AILLMCareer
Follow the path

FAQs

Q1: What does it mean to ground an LLM answer?

Grounding means supplying the text you want the model to answer from, then telling it to stay inside that text. It is narrower than making a model truthful in general. You limit what counts as a source for one answer, and ask the model to admit when that source runs out. The admitting part carries most of the benefit. Give a model good sources and no permission to decline and it still produces something. Producing text is its job, and the likeliest output is a plausible answer built from nearby material. Measured here, a bare prompt invented an answer to all four questions its sources could not cover. One sentence took that to zero. Use only the sources, and say when they fall short.

Q2: Do citations reduce hallucinations?

Not on their own, and in this test they made things slightly worse. Adding a citation instruction to a working grounded prompt took invented answers from zero back up to one in four. The mechanism is worth knowing. Grounding asks for one behaviour. Citing asks for three at once. Answer from the sources, attach a bracketed number to each fact, and decline when the sources fall short. A small model satisfies the easiest ones first, and producing an answer with a citation attached is easier than deciding no answer exists. The same model also skipped citations on five of the six questions it could answer, so the instruction was largely ignored. Citations help a reader check your work, which is valuable. They are not a hallucination control.

Q3: Why is a citation not proof that an answer is correct?

Because a citation is a claim about a source, not a quotation from it. Nothing forces the cited source to contain what the answer says it does. In this test the model produced "The kubelet listens on port 10250 [1]" where source 1 was about pod restart behaviour and mentioned neither kubelets nor ports. So the invented fact arrived looking better sourced than the true ones, which carried no brackets at all. Citations go wrong in three ways. The source may exist and not support the claim. The claim may be true and pointed at the wrong source, which is hardest to spot because the fact itself is correct. Or the citation may name a source number that does not exist, which is the one case you catch with no model call.

Q4: How do I verify that a citation is real?

Take each sentence carrying a citation, strip the bracket, and ask whether the cited source supports that claim. Do the cheap check first. If the cited number is not in your list of sources, the citation is wrong with certainty, and finding out is a dictionary lookup. For everything else, send the source text and the claim to a model and ask a yes or no question. Keep the question narrow. "Does this source support this claim" gives a far more reliable answer than "is this citation good". Set temperature to 0, which is the setting that makes a model pick the most likely word every time rather than sampling. Without it the same claim can grade differently on two runs. Verification costs one model call per citation, so on high volume systems sample instead of checking everything. One answer in fifty still tells you your rate.

Q5: What do I need to build this?

Python and two models. A small local one to produce answers, which Ollama gives you with no key or quota, and a stronger one to check them. The split is deliberate. Checking an answer is a different job from producing one, and it benefits from a better model. For the checker, KodeKey provides one key reaching Claude, GPT and Gemini through a single OpenAI compatible endpoint. Everything else uses the Python standard library, so there is nothing to install beyond a virtual environment. You also need sources with stable identifiers, since citations need something to point at. And you need test questions where you know which source holds each answer. That last part is what makes checking possible at all.

Q6: How do I test whether my system hallucinates?

Write questions your sources cannot answer, because those are the only ones that tell you anything. Ask about a topic your documents cover and you get a reasonable answer either way. So a test set of answerable questions reports success while the system invents things at the edges. Aim for roughly a third of your cases to be genuinely uncovered, including some that sound like they should be covered. Those produce the most confident inventions. Then be careful how you score the results. A keyword check looking for "do not know" miscounted this test. It scored a refusal as an invention, because the model phrased it differently. So use a model to judge whether a reply declined, rather than matching phrases, and check your scorer against a few examples you have read yourself.

Pramodh Kumar M Pramodh Kumar M

Subscribe to Newsletter

Join me on this exciting journey as we explore the boundless world of web design together.