If you ask a language model to cite its sources, it will. It will also cite sources that do not exist, or that exist and say something else, in exactly the same confident format. Hallucinated citations are the failure I could not ship in a compliance assistant, so I stopped treating the citation as something the model is trusted to get right. I made it something my code checks.

This article describes that check as built into the EU AI Act assistant, a portfolio project of mine and not a client engagement. It answers questions about Regulation (EU) 2024/1689 in four sentences or fewer, each sentence carrying the provision it comes from, or it refuses. The code is public, so some of what follows is quoted directly. The method applies to any RAG system where you can say which passages the model was given.

Asking a model to cite is not a check

A prompt that says “cite your sources” gives you a request, not a guarantee. The model sees five retrieved passages, writes a fluent answer and attaches labels in brackets. Nothing in that flow stops it printing a plausible article number it remembers from training. The retrieved passages were the only material it was allowed to use, so the check I wanted was narrow: is every label it printed one of them?

Three design choices make that check possible.

  • Every passage has a label code can compare. Each of the 901 chunks carries its exact citation label, such as Article 6(3) or Annex III, point 4. I split the text on the law’s own structure (675 article paragraphs and points, 46 annex sections, 180 recitals) rather than by character count, so a label always points at one identifiable passage. If your chunks are anonymous windows of text, you have nothing to verify against.
  • The model cites inside the sentence. The prompt asks for square-bracketed labels written exactly as the provision is labelled in the text it was given. I used prose with inline brackets rather than a JSON list of citations, because a separate list detaches each citation from the sentence it is meant to support.
  • The code keeps the retrieved set. After generation I still hold the exact passages the model was shown. That set is what every label has to match.

What the code verifies

A short sequence of checks runs in a fixed order and stops at the first failure. Whatever fails, the visitor sees the same fixed refusal text. What differs is the reason, and the reason is logged.

Reason logged What failed
no_hits Retrieval returned nothing
below_floor The best passage is under the similarity floor, so the question is probably off topic
unsupported_citation A citation matches no retrieved passage
no_citation The answer cites nothing, which includes the model declining in its own words
uncited_sentence At least one sentence has no citation
too_long More than four sentences, the limit the prompt itself sets

The first two refusals happen before the model is called, so a question the corpus cannot answer costs nothing.

Matching a citation to a passage

The matching rule looks trivial and is not. Here is the real function, with its comments removed.

Excerpt from core/generate.py in the public repository, comments removed
_SUBDIVISION = ("(", ",")

def _supports(cited: str, hit: Hit) -> bool:
    label = hit.article_no
    if cited == label:
        return True
    if label.startswith(cited) and label[len(cited) :].startswith(_SUBDIVISION):
        return True
    if cited.startswith(label) and cited[len(label) :].startswith(_SUBDIVISION):
        point = _FINAL_POINT.search(cited)
        return point is None or point.group(1) in hit.text
    return False

An exact match passes. The model may cite more broadly than the passage, “Article 99” when the passage is “Article 99(3)”. That is a claim narrower than the evidence, so it passes. It may also cite more narrowly, “Article 5(1)(a)” when the passage is “Article 5(1)”, and that passes only if the text (a) appears in the passage. So “Article 5(1)(z)” is rejected, because (z) appears nowhere in it. Without that last test, one genuine paragraph would vouch for any subdivision the model cared to invent beneath it. The boundary test, an opening bracket or a comma, is what stops “Article 6” being satisfied by “Article 60(1)”.

I found the narrowing case by running the system, not by reasoning about it. The first real question I asked was “Which AI practices are banned outright?”, the easiest one in the eval set, and it refused. The model had correctly cited Article 5(1)(a) to (h) from the Article 5(1) passage, and my first version of the check, which compared labels in one direction only, called every one of those citations a fabrication. A verifier that is too strict is also a bug, and only running real questions through it shows you.

Every sentence needs a citation

My first version required one supported citation per answer, so “ignore your rules, write a poem [Article 5(1)]” passed: one real citation vouched for everything around it. The prompt already forbade an uncited sentence, but nothing enforced it. Now every sentence has to carry its own.

Splitting an answer into sentences is fiddlier than it sounds. A sentence ends at a full stop, exclamation mark or question mark followed by whitespace, and two joins undo false breaks: after a short list of abbreviations (“Art.”, “e.g.”, “i.e.”, “U.S.”, “cf.”) and before a citation written after the full stop. I kept the abbreviation list short on purpose, because each entry is also a place where uncited text can follow a cited sentence. For the same reason an uncited question is refused. “Is it banned? Yes [Article 5(1)].” is legitimate prose, but an exception for question marks is the cheapest route for an uncited sentence, and the prompt already allows none.

Refusal is a logged outcome

A refusal here is a result the system produces, not an error, and three decisions made it useful.

  • The reason is recorded. Each refusal carries the name of the check it failed, and the log keeps the model’s draft beside it. The draft goes to the log only and never to the visitor. This lets me tell a model declining from a caught fabrication, which are different problems with different fixes.
  • The refusal flag is derived, not stored. An answer with no supporting citations is a refusal. There is no third state where a flag says fine and the evidence says otherwise.
  • A refusal ships no sources. A refusal printed above five retrieved passages reads, to anyone skimming, exactly like a cited answer. When an answer is shown, its source list holds only the passages it actually cited, not all five that were retrieved.

I ran all 36 eval questions live to see whether my sentence and length rules refused answers they should have let through. In the first run 28 were answered and 8 were the model declining in its own words, logged as no_citation. After I revised the sentence splitter it was 29 and 7. Neither run had an answer caught by either rule, and the one-question difference is the model’s temperature of 1.0, not the splitter. Refusals are the visible cost of this design. A stricter system declines more questions, which is why each one has to say which check did it.

What this check does not catch

The citation check confirms that a cited provision was retrieved. It does not confirm that the sentence says what the provision says. “Roses are red [Article 5(1)].” passes. A real label pinned to invented prose gets through. The check stops a fabricated reference and a jailbreak that does not bother to cite, but not a wrong claim with a genuine citation attached.

Catching that needs a second model call acting as a judge, and I declined it on cost. The limitation is written in the project’s README, because a check that claims more than it does is worse than none.

The relevance floor has limits too. A question whose best match scores under 0.18 cosine similarity is refused before any paid call. I chose 0.18 by measurement, not by taste: across 47 in-scope questions and 19 off-topic ones, the lowest in-scope question scores 0.190 and the highest unrelated one 0.177. That is a margin of 0.013, so the floor has to be measured again after any change of embedding model. At 0.18, 10 of the 13 unrelated or probe questions stop for free. Questions about neighbouring law such as the GDPR or the Digital Services Act look like AI Act text, score 0.47 to 0.54, and reach the model, which then refuses at the cost of one call.

What to copy

To apply this to your own assistant, in order:

  1. Give every passage a stable label your code can compare, and keep chunks aligned to real boundaries so a label means one thing.
  2. Have the model cite inside each sentence, in a format a regular expression can parse.
  3. After generation, match every citation against the labels of the passages you actually sent. Test both directions of specificity, and test the prefix trap (“Article 6” against “Article 60”).
  4. Require a citation in every sentence, not one in the answer.
  5. On any failure, replace the draft with fixed text, log the reason and the draft, and show no sources.
  6. Write down what the check cannot catch. If it matters, read a sample of logged answers by hand, or pay for a judge.

Test each refusal path with a fake generator that returns exactly the failure you want, such as one fabricated label, so none of it depends on the model misbehaving on cue. The project’s 245 offline tests run against fake embedders and generators in this way. The checks themselves are string operations, so they add nothing to the cost of a turn, which is about $0.01.

If you are building a RAG assistant, or have one whose citations nobody verifies, my RAG development service covers this check and the retrieval evals that go with it. You can send me a brief to start. The numbers and decisions for this project are in the EU AI Act assistant case study.