How to build a citation-grounded answer engine

By Chinedu Obi · 16 July 20260 views

What citation grounding actually means

Retrieval-augmented generation (RAG) improves factual accuracy by injecting retrieved context before the model answers. But basic RAG still has a problem: the model synthesises across chunks and produces prose that could draw from any of the retrieved passages, leaving users with no way to verify individual claims. Citation grounding solves this by requiring the model to tag each sentence or claim with the exact source chunk it drew from, and then surfacing those tags as hyperlinks or footnotes in the final UI.

The distinction matters enormously in high-stakes domains — legal research, medical information, financial analysis — where a user needs to open the source document and read the surrounding context before acting on any answer. A plain RAG answer that says "the contract allows termination with 30 days notice" is less useful than the same answer with a link to the exact clause in the PDF where that sentence appears.

This article builds a citation-grounded answer engine from first principles. The stack is TypeScript, PostgreSQL with pgvector, and the Anthropic Claude API. By the end you will have a retrieval pipeline that stores chunks with their document provenance, a prompting strategy that coerces the model to emit structured citations, a post-processing step that validates those citations against the source corpus, and an API endpoint that returns answers with clickable source references.

Designing the data model

The foundation is a document corpus stored in Postgres. Each document is split into chunks, and each chunk carries enough metadata to reconstruct where it came from: document ID, page number, character offset, and the chunk's text verbatim. The verbatim text is critical — it is what you will use during the validation step to confirm the model cited a passage that actually exists.

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title       TEXT NOT NULL,
  source_url  TEXT,
  created_at  TIMESTAMPTZ DEFAULT now()
);

CREATE TABLE chunks (
  id          UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  document_id UUID REFERENCES documents(id) ON DELETE CASCADE,
  page        INT,
  char_start  INT,
  char_end    INT,
  text        TEXT NOT NULL,
  embedding   VECTOR(1536),
  created_at  TIMESTAMPTZ DEFAULT now()
);

CREATE INDEX ON chunks USING ivfflat (embedding vector_cosine_ops)
  WITH (lists = 100);

When you ingest a document, split it into overlapping chunks of roughly 400 tokens with a 50-token overlap. Overlapping chunks prevent important sentences from being split across two chunks and never appearing fully in retrieved context.

import Anthropic from "@anthropic-ai/sdk";
import { Pool } from "pg";

const db = new Pool({ connectionString: process.env.DATABASE_URL });
const anthropic = new Anthropic();

interface Chunk {
  documentId: string;
  page: number;
  charStart: number;
  charEnd: number;
  text: string;
}

async function ingestDocument(
  title: string,
  sourceUrl: string,
  fullText: string
): Promise<string> {
  const { rows } = await db.query(
    "INSERT INTO documents (title, source_url) VALUES ($1, $2) RETURNING id",
    [title, sourceUrl]
  );
  const documentId = rows[0].id;

  const chunks = splitIntoChunks(fullText, 400, 50);

  for (const chunk of chunks) {
    const embeddingResponse = await anthropic.embeddings.create({
      model: "voyage-3",
      input: chunk.text,
    });
    const embedding = embeddingResponse.data[0].embedding;

    await db.query(
      `INSERT INTO chunks (document_id, page, char_start, char_end, text, embedding)
       VALUES ($1, $2, $3, $4, $5, $6)`,
      [
        documentId,
        chunk.page,
        chunk.charStart,
        chunk.charEnd,
        chunk.text,
        JSON.stringify(embedding),
      ]
    );
  }

  return documentId;
}

function splitIntoChunks(
  text: string,
  chunkTokens: number,
  overlapTokens: number
): Chunk[] {
  // Approximate: 1 token ≈ 4 characters
  const chunkSize = chunkTokens * 4;
  const overlapSize = overlapTokens * 4;
  const chunks: Chunk[] = [];

  let start = 0;
  while (start < text.length) {
    const end = Math.min(start + chunkSize, text.length);
    chunks.push({
      documentId: "",
      page: Math.floor(start / 3000) + 1,
      charStart: start,
      charEnd: end,
      text: text.slice(start, end),
    });
    start += chunkSize - overlapSize;
  }

  return chunks;
}

Retrieving and ranking candidate chunks

When a query arrives, embed it and perform an approximate nearest-neighbour search against the chunks table. Retrieve more candidates than you will eventually use — typically 20 — and then apply a lightweight re-ranking step before selecting the top 6 to 8 for the prompt.

The re-ranking step can be as simple as a reciprocal rank fusion over multiple retrieval signals: vector similarity, BM25 keyword overlap (available via the pg_trgm similarity function), and recency. In production you may want a dedicated cross-encoder reranker, but for most applications the dual-signal approach is sufficient.

interface RetrievedChunk {
  id: string;
  documentId: string;
  documentTitle: string;
  sourceUrl: string;
  page: number;
  charStart: number;
  charEnd: number;
  text: string;
  vectorScore: number;
}

async function retrieveChunks(
  query: string,
  topK = 8
): Promise<RetrievedChunk[]> {
  const embeddingResponse = await anthropic.embeddings.create({
    model: "voyage-3",
    input: query,
  });
  const queryEmbedding = embeddingResponse.data[0].embedding;

  const { rows } = await db.query(
    `SELECT
       c.id,
       c.document_id,
       d.title AS document_title,
       d.source_url,
       c.page,
       c.char_start,
       c.char_end,
       c.text,
       1 - (c.embedding <=> $1::vector) AS vector_score
     FROM chunks c
     JOIN documents d ON d.id = c.document_id
     ORDER BY c.embedding <=> $1::vector
     LIMIT $2`,
    [JSON.stringify(queryEmbedding), topK]
  );

  return rows.map((r) => ({
    id: r.id,
    documentId: r.document_id,
    documentTitle: r.document_title,
    sourceUrl: r.source_url,
    page: r.page,
    charStart: r.char_start,
    charEnd: r.char_end,
    text: r.text,
    vectorScore: parseFloat(r.vector_score),
  }));
}

Prompting for structured citations

The most reliable way to get the model to emit citations is to define a citation format in the system prompt and require the model to reference chunk IDs inline. Assigning each retrieved chunk a short identifier (C1, C2, … C8) keeps the output compact and unambiguous.

The system prompt should accomplish four things: explain the citation format, prohibit the model from asserting facts not supported by the retrieved chunks, instruct it to say "I could not find information about X" rather than hallucinating, and specify the JSON output schema.

function buildSystemPrompt(): string {
  return `You are a precise research assistant. You will receive a question and a set of source chunks labeled C1 through CN.

Your response must follow this JSON schema:
{
  "answer": "string — your answer with inline citations like [C1] or [C1, C3]",
  "citations": [
    {
      "id": "C1",
      "chunkId": "uuid",
      "quote": "exact verbatim sentence from the chunk that supports the claim"
    }
  ],
  "confidence": "high | medium | low"
}

Rules:
1. Every factual claim in "answer" must be followed by at least one inline citation in square brackets.
2. The "quote" field must be copied verbatim from the chunk — do not paraphrase.
3. If the chunks do not contain enough information to answer confidently, say so in the answer and set confidence to "low".
4. Do not introduce facts from your training data. Only use information present in the provided chunks.
5. Return valid JSON only. No markdown code fences around it.`;
}

function buildUserPrompt(
  query: string,
  chunks: RetrievedChunk[]
): string {
  const chunkSection = chunks
    .map((c, i) => `[C${i + 1}] (chunkId: ${c.id}, doc: "${c.documentTitle}", page ${c.page})\n${c.text}`)
    .join("\n\n---\n\n");

  return `Question: ${query}\n\nSource chunks:\n\n${chunkSection}`;
}

Now call the model and parse the structured response:

interface CitationRef {
  id: string;
  chunkId: string;
  quote: string;
}

interface AnswerResponse {
  answer: string;
  citations: CitationRef[];
  confidence: "high" | "medium" | "low";
}

async function generateAnswer(
  query: string,
  chunks: RetrievedChunk[]
): Promise<AnswerResponse> {
  const message = await anthropic.messages.create({
    model: "claude-sonnet-4-5",
    max_tokens: 2048,
    system: buildSystemPrompt(),
    messages: [
      {
        role: "user",
        content: buildUserPrompt(query, chunks),
      },
    ],
  });

  const raw = message.content[0].type === "text" ? message.content[0].text : "";

  try {
    return JSON.parse(raw) as AnswerResponse;
  } catch {
    throw new Error(`Model returned non-JSON output: ${raw.slice(0, 200)}`);
  }
}

Validating citations before returning them

Model-emitted citations can hallucinate in two ways: the chunk ID might not exist, or the verbatim quote might not actually appear in the chunk. Both failures undermine trust. A post-processing validation pass catches these before the response reaches the user.

interface ValidatedCitation {
  id: string;
  chunkId: string;
  quote: string;
  documentTitle: string;
  sourceUrl: string;
  page: number;
  charStart: number;
  charEnd: number;
  valid: boolean;
  invalidReason?: string;
}

function normalisedIncludes(haystack: string, needle: string): boolean {
  const norm = (s: string) =>
    s.toLowerCase().replace(/\s+/g, " ").replace(/[""'']/g, '"').trim();
  return norm(haystack).includes(norm(needle));
}

async function validateCitations(
  citations: CitationRef[],
  chunks: RetrievedChunk[]
): Promise<ValidatedCitation[]> {
  const chunkMap = new Map(chunks.map((c) => [c.id, c]));

  return citations.map((citation) => {
    const chunk = chunkMap.get(citation.chunkId);

    if (!chunk) {
      return {
        ...citation,
        documentTitle: "",
        sourceUrl: "",
        page: 0,
        charStart: 0,
        charEnd: 0,
        valid: false,
        invalidReason: "Referenced chunk ID not found in retrieved set",
      };
    }

    const quotePresent = normalisedIncludes(chunk.text, citation.quote);

    return {
      ...citation,
      documentTitle: chunk.documentTitle,
      sourceUrl: chunk.sourceUrl,
      page: chunk.page,
      charStart: chunk.charStart,
      charEnd: chunk.charEnd,
      valid: quotePresent,
      invalidReason: quotePresent
        ? undefined
        : "Verbatim quote not found in chunk text",
    };
  });
}

The normalisation step handles minor differences in curly versus straight quotes and excess whitespace, which are the most common sources of quote-not-found false positives when the model copies text from the context window.

Wiring it all together in an API endpoint

Combine retrieval, generation, and validation into a single Hono route handler that returns a self-contained response object:

import { Hono } from "hono";

const app = new Hono();

interface AnswerEndpointResponse {
  answer: string;
  citations: ValidatedCitation[];
  confidence: "high" | "medium" | "low";
  validCitationCount: number;
  invalidCitationCount: number;
  retrievedChunkCount: number;
}

app.post("/answer", async (c) => {
  const { query } = await c.req.json<{ query: string }>();

  if (!query || query.trim().length < 3) {
    return c.json({ error: "Query must be at least 3 characters" }, 400);
  }

  const chunks = await retrieveChunks(query, 8);

  if (chunks.length === 0) {
    return c.json({
      answer: "No relevant documents found in the knowledge base.",
      citations: [],
      confidence: "low",
      validCitationCount: 0,
      invalidCitationCount: 0,
      retrievedChunkCount: 0,
    } satisfies AnswerEndpointResponse);
  }

  const raw = await generateAnswer(query, chunks);
  const validatedCitations = await validateCitations(raw.citations, chunks);

  const validCount = validatedCitations.filter((c) => c.valid).length;
  const invalidCount = validatedCitations.filter((c) => !c.valid).length;

  // Log invalid citations for monitoring
  if (invalidCount > 0) {
    console.warn("Invalid citations detected", {
      query,
      invalidCitations: validatedCitations.filter((c) => !c.valid),
    });
  }

  return c.json({
    answer: raw.answer,
    citations: validatedCitations,
    confidence: raw.confidence,
    validCitationCount: validCount,
    invalidCitationCount: invalidCount,
    retrievedChunkCount: chunks.length,
  } satisfies AnswerEndpointResponse);
});

export default app;

Improving citation accuracy over time

A few prompt engineering adjustments can push citation accuracy above 95% in production. First, instruct the model to copy a shorter phrase (one sentence, not a paragraph) as the verbatim quote. Long quotes are more likely to accumulate minor transcription differences. Second, add a few-shot example in the system prompt that demonstrates exactly the JSON format you want, including a plausible inline citation. Models trained on JSON generation are sensitive to format demonstrations.

Third, track your invalidCitationCount metric per query in your observability layer. A spike in invalid citations usually signals one of three problems: the retrieved chunks are too long and the model is pulling quote fragments that span chunk boundaries, the model is paraphrasing instead of quoting, or a prompt caching collision is serving a stale system prompt with outdated instructions.

For very high-stakes deployments, add a second verification call that receives the answer, the cited chunks, and nothing else, and asks the model to output true/false for each citation's accuracy. This costs roughly 20% more in tokens but catches the remaining edge cases where quote normalisation alone is insufficient.

async function verifyCitationsWithLLM(
  answer: string,
  citations: ValidatedCitation[]
): Promise<Map<string, boolean>> {
  const citationList = citations
    .map((c) => `${c.id}: "${c.quote}" (from chunk ${c.chunkId})`)
    .join("\n");

  const verificationPrompt = `Answer: ${answer}

Citations to verify:
${citationList}

For each citation ID, output a JSON object mapping the ID to true (the quote accurately represents a fact in the answer) or false.
Example: { "C1": true, "C2": false }`;

  const message = await anthropic.messages.create({
    model: "claude-haiku-4-5",
    max_tokens: 256,
    messages: [{ role: "user", content: verificationPrompt }],
  });

  const text = message.content[0].type === "text" ? message.content[0].text : "{}";
  return new Map(Object.entries(JSON.parse(text)));
}

Using Haiku for the verification pass keeps the cost manageable — it is checking logical consistency rather than generating new content, which is a task well within the smaller model's capabilities.

Displaying citations in the UI

The final piece is rendering citations in a way that lets users jump directly to the source passage. Store the charStart and charEnd offsets in the response and, on the frontend, use them to construct deep links into your document viewer.

For PDFs, the page field is sufficient for most PDF.js-based viewers. For HTML documents, you can use URL fragment identifiers combined with JavaScript range selection to highlight the exact passage. For plain text stored in your own CMS, encode the offsets as query parameters and build a server-side endpoint that returns the document with the relevant passage highlighted.

A minimal inline citation component in React:

interface CitationBadgeProps {
  citation: ValidatedCitation;
  index: number;
}

function CitationBadge({ citation, index }: CitationBadgeProps) {
  if (!citation.valid) return null;

  const href = citation.sourceUrl
    ? `${citation.sourceUrl}#page=${citation.page}`
    : "#";

  return (
    <a
      href={href}
      target="_blank"
      rel="noopener noreferrer"
      title={`"${citation.quote}" — ${citation.documentTitle}, p.${citation.page}`}
      className="inline-flex items-center justify-center w-5 h-5 rounded-full bg-blue-100 text-blue-700 text-xs font-semibold ml-0.5 hover:bg-blue-200 transition-colors"
    >
      {index + 1}
    </a>
  );
}

Parse the inline [C1] markers in the answer string and replace them with CitationBadge components keyed to the validated citation array. The user then sees superscript numbers inline in the text that open the source document to the exact page when clicked.

The combination of embedding-based retrieval, structured citation prompting, verbatim quote validation, and deep-linked UI components gives you an answer engine that is both accurate and auditable — the two properties that matter most when users need to act on information they did not personally verify.

Comments

No comments yet. Be the first!

Sign in to leave a comment.

How to build a citation-grounded answer engine — ANN Tech