Skip to content
← All writing

Citations that survive the question

5 min read
.md
Cover illustration for Citations that survive the question

TL;DR

Attach page numbers and chunk ids as vector metadata at indexing time, pass them through retrieval into the prompt, and require the model to cite the chunk id it used. Resolve those ids to page-anchored links after generation, so every claim opens the exact page it came from.

A teacher tried our study assistant, got an answer about photosynthesis, and asked a fair question: where does that come from? The app said “Source: Form 4 Science”. She had the book on her desk. She still could not find the sentence.

That is the whole problem. She did not need the app to be right. She needed to be able to check it in ten seconds, and we had given her a book title and 300 pages.

So my team and I built the thing backwards from her. Every answer has to open the textbook at the page it came from. That one rule threw out most of the designs we had been considering, including the obvious one: asking the model to write its own citations. A model that invents the prose will happily invent the page number too.

Attach the page number when you chunk, or lose it forever

The page number has to ride along with the text from the moment you split the book up.

You might think you can work it out later by matching the answer against the source. You cannot. The model paraphrases, so the words in the answer are not the words on the page, and the matching fails hardest on exactly the answers a teacher most wants to check.

So the chunker records where each piece came from:

chunks.append({
    "id": f"{doc_id}:{page_num}:{idx}",
    "text": chunk_text,
    "metadata": {
        "doc_id": doc_id,
        "subject": subject,
        "page": page_num,          # 1-based, matches the printed page
        "chunk_index": idx,
    },
})

Every vector database worth using stores metadata beside the embedding and returns it on query. Chroma does, which is what we index into.

The chunk id encodes document, page and position, so a citation is resolvable without a second lookup. That is a small decision that removes a join from the hot path.

Make the model cite the id, not the source

The prompt puts the retrieved chunks in with their ids visible, and requires the answer to reference them:

Answer using only the passages below. After each claim, cite the passage id
in square brackets, for example [sci4:112:2]. If the passages do not contain
the answer, say so rather than answering from general knowledge.

[sci4:112:2] (page 112)
Photosynthesis converts light energy into chemical energy stored as glucose...

[sci4:113:0] (page 113)
The light-dependent reactions occur in the thylakoid membrane...

I ask for the id and not the page number on purpose. An id can be checked. Either it matches a chunk I actually put in the context or it does not, and anything that does not match gets stripped out before the answer reaches the screen.

A page number cannot be checked that way. It is just a number, and a model will happily produce a believable one.

What you ask the model to citeVerifiable after generationWhat a wrong one looks like
Document nameNoCorrect, and useless: 300 pages to search
Page numberNoA plausible number pointing at the wrong page
Chunk idYes, against what you retrievedFails the lookup and gets stripped before display
Quoted sentencePartly, by string matchParaphrase defeats the match, which is when you need it most

The check is about four lines of code. It is also the only thing standing between citations that mean something and citations that merely look like they do.

$cited = collect($matches)->unique()
    ->filter(fn ($id) => isset($retrievedById[$id]));

What happens when nothing relevant comes back?

The model says so. This is a retrieval configuration question as much as a prompt one, and it is the failure mode with the worst consequences in an education product, because a confident wrong answer to a syllabus question is actively harmful.

Two controls. A similarity floor, below which a chunk is not passed to the model at all, so a question about an unindexed topic arrives with an empty context. And an explicit instruction for the empty case, because a model given no passages and no instruction will answer from its pretraining and sound exactly as confident.

Routing helps here too. An orchestrator detects the subject first and queries only that subject’s collection, which keeps a Bahasa Malaysia question from retrieving loosely similar Science passages.

ControlStopsWithout it
Similarity floorWeak matches reaching the promptLoosely related passages arrive and look authoritative
Explicit empty-context instructionAnswering from pretrainingThe model answers anyway, at the same confidence
Subject routing before retrievalCross-subject matchesA language question retrieves science passages that scored well
Id validation after generationInvented citationsEverything looks right in testing and fails on a real question

The last step is the one users notice. A citation resolves to a link that opens the source at the cited page.

For a PDF in a viewer that supports it, that is a fragment: #page=112. The textbooks are not public, so the link is a time-limited signed URL generated per request, the same mechanism as R2 presigned URLs or the equivalent on other object stores. Access control stays with the storage layer instead of being reimplemented in the app.

LayerCarriesFailure if missing
ChunkingPage number, chunk idCitations cannot be resolved at all
Vector metadataSame, returned on queryRetrieval loses provenance
PromptIds visible to the modelModel cites something ungrounded
Post-processingId validationInvented citations reach the user
RenderingPage-anchored signed linkTeacher cannot check the claim

Every row has to hold. Skipping the fourth is the common one, because everything still looks right in testing.

Sources

Common questions

How do you make RAG citations link to a specific page?

Record the page number as chunk metadata at indexing time, carry it through retrieval into the prompt, and resolve the cited chunk id to a page-anchored link after generation. For a PDF viewer that means a #page= fragment on a time-limited signed URL, so access control stays in the storage layer.

Why should the model cite a chunk id rather than a page number?

Because an id can be checked. It either matches a chunk you actually put in the context or it does not, and anything that does not match gets stripped before the answer reaches the screen. A page number is just a number, and a model will produce a believable wrong one.

Can you work out the source page after the fact by matching the answer to the text?

No. The model paraphrases, so the words in the answer are not the words on the page. The matching fails hardest on exactly the answers a reader most wants to verify, which is why the page number has to ride along from the moment you split the document up.

How do you stop a RAG system answering when retrieval finds nothing relevant?

Two controls together. A similarity floor below which a chunk is never passed to the model, so an unindexed topic arrives with empty context, and an explicit instruction for the empty case. A model given no passages and no instruction will answer from pretraining and sound exactly as confident.

Should you let the model write its own citations?

No. A model that invents the prose will invent the page number with equal fluency. Citations have to be resolved from ids you can validate against what you actually retrieved, which is about four lines of code and the only thing separating citations that mean something from citations that look like they do.

Written by Elson Tan, Head of Technology and co-founder at Nedex Group, working on AI harness and agent infrastructure.

AboutRSS
  • 5 min read

    Metering tokens when the bill is the product

    Billing per message is easy and wrong: one user sends a sentence, another uploads a report. Metering the tokens you actually spend is harder, and the hard parts are idempotency, allowance checks and what to do mid-conversation.

  • 4 min read

    Route cheap models to orchestration, not to the work

    Most agent platforms pick one model and use it everywhere. Splitting model selection by role rather than by task is where the cost curve actually bends.

  • 5 min read

    Confidence scores are only useful if something acts on them

    An LLM that returns a per-field confidence score has told you nothing until a threshold routes the low ones to a person. How to design the routing, calibrate the threshold, and build a review queue people can actually clear.

Get in touch

Tell me who you are and what you are working on.

Your details are used only to reply to this message.