Show source citations in responses
Last updated Oct 12, 2026 returns the source chunks it uses to generate an answer. Use those chunks to show citations, references, or source links in your application.
This guide shows how to build a Cloudflare Worker that returns an AI-generated answer with the documents that informed it. Use this pattern when you want users to verify answers, inspect source material, or debug retrieval quality.
What you will build
You will create a Worker endpoint that:
- Sends a user question to
chatCompletions() - Returns the generated answer with source identifiers, snippets, metadata, and relevance scores
- Groups repeated chunks into one citation per source document
- Handles citations for standard and streaming responses
How citations work
AI Search retrieves source chunks before it generates an answer:
- Finds matching chunks from your indexed documents.
- Sends those chunks to the model as context.
- Returns the answer and chunks in the response.
Each returned chunk contains an item object with key (filename or URL), timestamp, and any custom metadata you attached during indexing. For citations, item.key is usually the most useful field because it identifies the source document.
The score field indicates how relevant the chunk was to the query. The chunks array is also available in the search() response, and the same approach applies.
1. Create a Worker
Create a Worker project for the citation examples:
npmyarnpnpm
npm create cloudflare@latest -- ai-search-citations
yarn create cloudflare ai-search-citations
pnpm create cloudflare@latest ai-search-citations
When prompted, choose Hello World example, Worker only, and TypeScript.
Move into the project directory:
cd ai-search-citations
2. Configure the binding
Add an AI Search namespace binding to your Wrangler configuration:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ai-search-citations",
"main": "src/index.ts",
"upload_source_maps": true,
// Set this to today's date
"compatibility_date": "2026-10-27",
"ai_search_namespaces": [
{
"binding": "AI_SEARCH",
"namespace": "default"
}
]
}
name = "ai-search-citations"
main = "src/index.ts"
upload_source_maps = true
# Set this to today's date
compatibility_date = "2026-10-27"
[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"
This binding lets your Worker access AI Search instances in the default namespace. The examples use an instance named my-instance.
If you do not have an instance yet, create one and add content before you run the Worker. To create an instance with Wrangler, refer to Wrangler commands.
3. Display citations from chat completions
Start with the simplest citation pattern: return the generated answer and a list of source documents in the same JSON response.
Replace the contents of src/index.ts with the following Worker code:
src/index.jsjs
export default {
async fetch(request, env) {
const url = new URL(request.url);
const query = url.searchParams.get("query") ?? "What is Cloudflare?";
// AI Search returns an answer and the source chunks used as context.
const response = await env.AI_SEARCH.get("my-instance").chatCompletions({
messages: [{ role: "user", content: query }],
});
// Show this model response to the user.
const answer = response.choices[0]?.message?.content ?? "";
// Convert source chunks into citations your UI can display.
const citations = response.chunks.map((chunk, index) => ({
index: index + 1,
source: chunk.item.key,
score: chunk.score,
snippet: chunk.text.slice(0, 200),
metadata: chunk.item.metadata,
}));
return Response.json({ answer, citations });
},
};
src/index.tsts
export interface Env {
AI_SEARCH: AiSearchNamespace;
}
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
const query = url.searchParams.get("query") ?? "What is Cloudflare?";
// AI Search returns an answer and the source chunks used as context.
const response = await env.AI_SEARCH.get("my-instance").chatCompletions({
messages: [{ role: "user", content: query }],
});
// Show this model response to the user.
const answer = response.choices[0]?.message?.content ?? "";
// Convert source chunks into citations your UI can display.
const citations = response.chunks.map((chunk, index) => ({
index: index + 1,
source: chunk.item.key,
score: chunk.score,
snippet: chunk.text.slice(0, 200),
metadata: chunk.item.metadata,
}));
return Response.json({ answer, citations });
},
} satisfies ExportedHandler<Env>;
The response looks like:
{
"answer": "Cloudflare is a global network that provides security, performance, and reliability services...",
"citations": [
{
"index": 1,
"source": "docs/what-is-cloudflare.md",
"score": 0.92,
"snippet": "Cloudflare is one of the world's largest networks. Today, businesses, non-profits, bloggers...",
"metadata": {
"folder": "docs"
}
},
{
"index": 2,
"source": "blog/intro-to-cloudflare.md",
"score": 0.85,
"snippet": "Cloudflare provides a broad range of services to businesses of all sizes...",
"metadata": {
"folder": "blog"
}
}
]
}
4. Deduplicate citations by source
Multiple chunks can come from the same document. Group them by item.key to show one citation per source document.
To show one citation per source, update src/index.ts to group chunks by source document:
src/index.jsjs
export default {
async fetch(request, env) {
const url = new URL(request.url);
const query = url.searchParams.get("query") ?? "What is Cloudflare?";
// AI Search returns an answer and the source chunks used as context.
const response = await env.AI_SEARCH.get("my-instance").chatCompletions({
messages: [{ role: "user", content: query }],
});
// Show this model response to the user.
const answer = response.choices[0]?.message?.content ?? "";
// Group chunks by source document so each source appears once.
const sourceMap = new Map();
for (const chunk of response.chunks) {
// item.key is the source file path or URL.
const key = chunk.item.key;
const existing = sourceMap.get(key);
if (existing) {
// Keep the highest relevance score for each source.
existing.score = Math.max(existing.score, chunk.score);
existing.snippets.push(chunk.text.slice(0, 200));
} else {
sourceMap.set(key, {
score: chunk.score,
snippets: [chunk.text.slice(0, 200)],
metadata: chunk.item.metadata,
});
}
}
const citations = [...sourceMap.entries()].map(
([source, { score, snippets, metadata }], i) => ({
index: i + 1,
source,
score,
snippets,
metadata,
}),
);
return Response.json({ answer, citations });
},
};
src/index.tsts
export interface Env {
AI_SEARCH: AiSearchNamespace;
}
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
const query = url.searchParams.get("query") ?? "What is Cloudflare?";
// AI Search returns an answer and the source chunks used as context.
const response = await env.AI_SEARCH.get("my-instance").chatCompletions({
messages: [{ role: "user", content: query }],
});
// Show this model response to the user.
const answer = response.choices[0]?.message?.content ?? "";
// Group chunks by source document so each source appears once.
const sourceMap = new Map<
string,
{ score: number; snippets: string[]; metadata?: Record<string, unknown> }
>();
for (const chunk of response.chunks) {
// item.key is the source file path or URL.
const key = chunk.item.key;
const existing = sourceMap.get(key);
if (existing) {
// Keep the highest relevance score for each source.
existing.score = Math.max(existing.score, chunk.score);
existing.snippets.push(chunk.text.slice(0, 200));
} else {
sourceMap.set(key, {
score: chunk.score,
snippets: [chunk.text.slice(0, 200)],
metadata: chunk.item.metadata,
});
}
}
const citations = [...sourceMap.entries()].map(
([source, { score, snippets, metadata }], i) => ({
index: i + 1,
source,
score,
snippets,
metadata,
}),
);
return Response.json({ answer, citations });
},
} satisfies ExportedHandler<Env>;
5. Parse citations from a streaming response
When using stream: true, the chunks are sent as a separate Server-Sent Events (SSE) event named chunks before the streamed answer begins. Parse this event to show citations before the full answer finishes streaming.
To show citations before the full answer finishes streaming, update src/index.ts to transform the stream:
src/index.jsjs
```js export default { async fetch(request, env) { const url = new URL(request.url); const query = url.searchParams.get("query") ?? "What is Cloudflare?";
// Stream answer tokens, but extract source chunks first. const stream = await env.AI_SEARCH.get("my-instance").chatCompletions({ messages: [{ role: "user", content: query }], stream: true, });
// Transform the stream: extract the chunks event and forward the rest const { readable, writable } = new TransformStream(); const writer = writable.getWriter(); const encoder = new TextEncoder(); const decoder = new TextDecoder(); const reader = stream.getReader();
// Track the current SSE event type to identify source chunks. let currentEvent = "";
const pump = async () => { try { let buffer = "";
while (true) { const { done, value } = await reader.read(); if (done) break;
