Retrieval-Augmented Generation (RAG) systems produce answers grounded in retrieved documents, but the raw output from the language model is a flat text stream. Citations—markers like [1], [doc:abc123], or [[Source Title]]—arrive inline with the prose. The frontend must parse these markers, resolve them to source documents, assign sequential display numbers, and render them as interactive elements that users can click to verify the claim against its source.
This is a string-processing and UI-rendering problem that sits at the intersection of regex-based parsing, state management for citation numbering, and component composition for rendering citations as footnotes, inline links, or side-panel references. Getting it right is critical for user trust: a RAG system that answers questions but cannot show where each fact came from is no better than an ungrounded chatbot.
As an example, a search-powered AI assistant must handle multiple citation formats produced by different prompt templates and model versions. The parser normalizes all formats into a uniform internal representation, assigns sequential numbers based on order of first appearance, and renders them as clickable superscript badges that expand a source preview panel.
RAG systems emit citations in varying formats depending on the prompt template: numbered brackets [1], resource ID references [doc:abc123], markdown-style links [title](url), or custom delimiters like <<source_id>>. A robust parser uses a union of regex patterns to detect all supported formats in a single pass, normalizing each match into a common CitationRef structure with the raw text, resolved source ID, and character position.
Users expect citations numbered [1], [2], [3] in the order they first appear in the text, regardless of the internal document ID. A numbering pass walks the parsed citations left to right, assigning the next sequential number to each unique source ID the first time it appears. Subsequent references to the same source reuse the assigned number. This creates a stable, human-readable numbering scheme independent of the retrieval system’s internal ordering.
The citation marker in the model’s output is typically just an identifier—a document ID, chunk hash, or index position. Resolution maps this identifier to the full source metadata: title, URL, snippet, relevance score, and document type. This resolution step bridges the gap between what the model outputs and what the UI needs to render a meaningful source card with a preview and link.
When the RAG response streams token by token, citation markers arrive incrementally. A bracket [ might appear in one chunk and 1] in the next. The parser must buffer partial matches across chunks, only emitting a resolved citation once the full marker is received. This requires a small state machine that tracks whether the current position is inside a potential citation marker.
Citations can render as superscript numbers (academic style), inline pills/badges, footnotes at the end of the response, or entries in a collapsible side panel. The choice depends on the UI density and the number of sources per response. Superscript works well for 3–5 sources; a side panel is better when responses reference 10+ documents. The rendering layer should be decoupled from parsing so the same citation data drives multiple presentation modes.
Start with a type system that models the lifecycle of a citation from raw text match through resolution to rendered element.
// types/citation.ts
/** Raw match from regex parsing */
export interface CitationMatch {
raw: string; // The full matched text, e.g. "[doc:abc123]"
sourceKey: string; // Extracted identifier, e.g. "abc123"
startIndex: number; // Character position in the original text
endIndex: number; // End position (exclusive)
format: CitationFormat;
}
export type CitationFormat =
| "numbered" // [1], [2]
| "resource_id" // [doc:abc123]
| "markdown_link" // [Title](url)
| "angle_bracket"; // <<source_id>>
/** Resolved citation with display metadata */
export interface ResolvedCitation {
displayNumber: number;
sourceKey: string;
title: string;
url: string;
snippet: string;
documentType: "webpage" | "pdf" | "database_row" | "api_response";
relevanceScore?: number;
}
/** A parsed text segment: either plain text or a citation reference */
export type TextSegment =
| { type: "text"; content: string }
| { type: "citation"; citation: ResolvedCitation };
The parser uses a combined regex with named capture groups to match all supported citation formats in a single pass. This avoids the complexity of running multiple regex passes that might produce overlapping matches.
// lib/citationParser.ts
import { CitationMatch, CitationFormat } from "@/types/citation";
// Combined pattern matching all supported citation formats
const CITATION_PATTERN = new RegExp(
[
/\[(?<numbered>\d{1,3})\]/, // [1], [12]
/\[doc:(?<resource_id>[a-zA-Z0-9_-]+)\]/, // [doc:abc123]
/\[(?<md_title>[^\]]+)\]\((?<md_url>[^)]+)\)/, // [Title](url)
/<<(?<angle>[a-zA-Z0-9_-]+)>>/, // <<source_id>>
]
.map((r) => r.source)
.join("|"),
"g"
);
export function parseCitations(text: string): CitationMatch[] {
const matches: CitationMatch[] = [];
let match: RegExpExecArray | null;
while ((match = CITATION_PATTERN.exec(text)) !== null) {
const groups = match.groups!;
let sourceKey: string;
let format: CitationFormat;
if (groups.numbered !== undefined) {
sourceKey = groups.numbered;
format = "numbered";
} else if (groups.resource_id !== undefined) {
sourceKey = groups.resource_id;
format = "resource_id";
} else if (groups.md_title !== undefined) {
sourceKey = groups.md_url;
format = "markdown_link";
} else if (groups.angle !== undefined) {
sourceKey = groups.angle;
format = "angle_bracket";
} else {
continue;
}
matches.push({
raw: match[0],
sourceKey,
startIndex: match.index,
endIndex: match.index + match[0].length,
format,
});
}
return matches;
}
Walk the matches in order of appearance, assigning display numbers. Each unique source key gets the next available number; repeated keys reuse the same number.
// lib/citationNumbering.ts
import { CitationMatch } from "@/types/citation";
export interface NumberedCitation extends CitationMatch {
displayNumber: number;
}
export function assignDisplayNumbers(
matches: CitationMatch[]
): NumberedCitation[] {
const keyToNumber = new Map<string, number>();
let nextNumber = 1;
return matches.map((m) => {
let displayNumber = keyToNumber.get(m.sourceKey);
if (displayNumber === undefined) {
displayNumber = nextNumber++;
keyToNumber.set(m.sourceKey, displayNumber);
}
return { ...m, displayNumber };
});
}
/**
* Returns deduplicated sources in display order.
* Useful for rendering a footnote list at the bottom.
*/
export function getOrderedSources(
numbered: NumberedCitation[]
): { sourceKey: string; displayNumber: number }[] {
const seen = new Set<string>();
const sources: { sourceKey: string; displayNumber: number }[] = [];
for (const n of numbered) {
if (!seen.has(n.sourceKey)) {
seen.add(n.sourceKey);
sources.push({
sourceKey: n.sourceKey,
displayNumber: n.displayNumber,
});
}
}
return sources;
}
The resolution step maps source keys to full metadata from the retrieval context, then splits the original text into alternating text and citation segments for rendering.
// lib/citationResolver.ts
import { CitationMatch, ResolvedCitation, TextSegment } from "@/types/citation";
import { NumberedCitation } from "@/lib/citationNumbering";
export interface RetrievalSource {
id: string;
title: string;
url: string;
snippet: string;
documentType: "webpage" | "pdf" | "database_row" | "api_response";
relevanceScore?: number;
}
export function resolveCitations(
numbered: NumberedCitation[],
sources: Map<string, RetrievalSource>
): ResolvedCitation[] {
return numbered
.filter((n) => sources.has(n.sourceKey))
.map((n) => {
const src = sources.get(n.sourceKey)!;
return {
displayNumber: n.displayNumber,
sourceKey: n.sourceKey,
title: src.title,
url: src.url,
snippet: src.snippet,
documentType: src.documentType,
relevanceScore: src.relevanceScore,
};
});
}
/**
* Split raw text into segments of plain text and resolved citations.
* This is what the React renderer consumes.
*/
export function buildSegments(
text: string,
numbered: NumberedCitation[],
sources: Map<string, RetrievalSource>
): TextSegment[] {
const segments: TextSegment[] = [];
let cursor = 0;
for (const n of numbered) {
// Emit any text before this citation
if (n.startIndex > cursor) {
segments.push({ type: "text", content: text.slice(cursor, n.startIndex) });
}
const src = sources.get(n.sourceKey);
if (src) {
segments.push({
type: "citation",
citation: {
displayNumber: n.displayNumber,
sourceKey: n.sourceKey,
title: src.title,
url: src.url,
snippet: src.snippet,
documentType: src.documentType,
relevanceScore: src.relevanceScore,
},
});
} else {
// Unresolved citation: render as plain text
segments.push({ type: "text", content: n.raw });
}
cursor = n.endIndex;
}
// Emit any trailing text
if (cursor < text.length) {
segments.push({ type: "text", content: text.slice(cursor) });
}
return segments;
}
The rendering layer consumes TextSegment[] and maps each segment to the appropriate React element. Citation badges show the display number and expand a tooltip or side panel on click.
// components/CitationRenderer.tsx
"use client";
import { useState } from "react";
import { TextSegment, ResolvedCitation } from "@/types/citation";
interface CitationRendererProps {
segments: TextSegment[];
}
export function CitationRenderer({ segments }: CitationRendererProps) {
const [activeCitation, setActiveCitation] =
useState<ResolvedCitation | null>(null);
return (
<div className="citation-text">
{segments.map((seg, i) =>
seg.type === "text" ? (
<span key={i}>{seg.content}</span>
) : (
<CitationBadge
key={i}
citation={seg.citation}
isActive={activeCitation?.sourceKey === seg.citation.sourceKey}
onClick={() =>
setActiveCitation(
activeCitation?.sourceKey === seg.citation.sourceKey
? null
: seg.citation
)
}
/>
)
)}
{activeCitation && <SourcePreview citation={activeCitation} />}
</div>
);
}
function CitationBadge({
citation,
isActive,
onClick,
}: {
citation: ResolvedCitation;
isActive: boolean;
onClick: () => void;
}) {
return (
<button
className={`citation-badge ${isActive ? "active" : ""}`}
onClick={onClick}
title={citation.title}
aria-label={`Source ${citation.displayNumber}: ${citation.title}`}
>
{citation.displayNumber}
</button>
);
}
function SourcePreview({ citation }: { citation: ResolvedCitation }) {
return (
<aside className="source-preview" role="complementary">
<h4>
[{citation.displayNumber}] {citation.title}
</h4>
<p className="source-snippet">{citation.snippet}</p>
<a href={citation.url} target="_blank" rel="noopener noreferrer">
View source →
</a>
</aside>
);
}
As an example, an AI search assistant uses this citation pipeline to ground every AI-generated answer in the documents retrieved from the search index. When a user asks a question, the backend performs a vector search to retrieve relevant chunks, includes them in the LLM context, and instructs the model to cite sources using [doc:chunk_id] format. The frontend parser extracts these markers, resolves them against the retrieval context included in the API response, and renders numbered badges inline with the text.
This architecture enables a “trust but verify” workflow: users can read the AI’s answer at face value for quick triage, then click any citation badge to expand the source chunk and confirm that the answer is faithful to the underlying data. In production, approximately 15% of users click at least one citation per session, and that number rises to 40% for compliance-sensitive queries where users need to document their evidence chain.
The streaming parser handles the case where a citation marker spans two SSE chunks. For example, the model might emit [doc:ab in one event and c123] in the next. The parser buffers the partial bracket, detects the opening [ without a closing ], and holds the text until the next chunk completes the marker. This prevents the UI from briefly rendering [doc:ab as literal text before replacing it with a citation badge.
Below the AI response, a footnote section lists all referenced sources in display-number order with titles, snippets, and links. This mirrors academic citation conventions and provides an at-a-glance summary of the evidence base without requiring users to click each inline badge.
When processing a streaming response, maintain a buffer for text that might contain an incomplete citation marker. Only flush the buffer as plain text when you can confirm no citation pattern starts at the buffer boundary.
// lib/streamingCitationBuffer.ts
export class StreamingCitationBuffer {
private buffer = "";
private readonly openMarkers = ["[", "<<"];
/**
* Feed a new chunk from the SSE stream.
* Returns segments that are safe to render immediately.
*/
feed(chunk: string): { safe: string; pending: string } {
this.buffer += chunk;
// Find the last position where a citation marker could start
let lastPotentialStart = -1;
for (const marker of this.openMarkers) {
const idx = this.buffer.lastIndexOf(marker);
if (idx !== -1 && idx > lastPotentialStart) {
// Check if this potential marker is still unclosed
const afterMarker = this.buffer.slice(idx);
const isClosed =
(marker === "[" && afterMarker.includes("]")) ||
(marker === "<<" && afterMarker.includes(">>"));
if (!isClosed) {
lastPotentialStart = idx;
}
}
}
if (lastPotentialStart === -1) {
// No pending markers: everything is safe to render
const safe = this.buffer;
this.buffer = "";
return { safe, pending: "" };
}
// Split at the potential marker boundary
const safe = this.buffer.slice(0, lastPotentialStart);
this.buffer = this.buffer.slice(lastPotentialStart);
return { safe, pending: this.buffer };
}
/** Flush remaining buffer (e.g., when the stream ends) */
flush(): string {
const remaining = this.buffer;
this.buffer = "";
return remaining;
}
}
The getOrderedSources function handles deduplication: if the model cites [doc:abc123] three times in one response, the footnote list shows it once with display number [1]. The inline badges all show the same number, making it clear they reference the same source.
Citation badges must be keyboard-navigable and announce their purpose to screen readers. Use <button> elements (not <span> with click handlers), provide aria-label with the source title, and manage focus when the source preview panel opens. The footnote list should use an <ol> element with role="doc-endnotes" for semantic correctness.
arr[1], a naive \[\d+\] pattern will match it as a citation. Guard against this by first splitting the text on code fences and only running the citation parser on non-code segments, or use a negative lookbehind to skip patterns preceded by word characters.[2] in one render might become [3] in the next if a new earlier citation appeared in the latest chunk. Lock display numbers once assigned: maintain a persistent keyToNumber map across stream chunks rather than rebuilding it each time.[title](url) format is valid Markdown. If the response also contains regular Markdown links that are not citations, you need additional heuristics (like checking whether the URL matches a known retrieval source) to distinguish citations from navigation links.