← Přehled / ai-search / how-to
Zobrazení citací zdrojů v odpovědích
AI Search vrací zdrojové části obsahu, které použije k vygenerování odpovědi. Tyto části můžete ve své aplikaci využít k zobrazení citací, odkazů na zdroje nebo referencí.
Tento návod ukazuje, jak sestavit Cloudflare Worker která vrátí odpověď vygenerovanou umělou inteligencí spolu s dokumenty, ze kterých vycházela. Tento vzor použijte, pokud chcete, aby si uživatelé mohli odpovědi ověřit, prohlédnout zdrojové materiály nebo odladit kvalitu vyhledávání.
Co vytvoříte
Vytvoříte endpoint Workeru, který:
- Odesílá dotaz uživatele na
chatCompletions() - Vrátí vygenerovanou odpověď s identifikátory zdrojů, úryvky, metadaty a skóre relevance
- Seskupí opakující se části obsahu do jedné citace na zdrojový dokument
- Zpracovává citace u standardních i streamovaných odpovědí
Jak fungují citace
AI Search před vygenerováním odpovědi načte zdrojové části obsahu:
- Vyhledá odpovídající části obsahu z vašich indexovaných dokumentů.
- Odesílá tyto části obsahu modelu jako kontext.
- Ve své odpovědi vrátí vygenerovanou odpověď i chunky.
Každá vrácená část obsahu obsahuje item objekt s key (název souboru nebo URL), timestamp, a jakýkoli vlastní metadata které jste připojili během indexování. Pro citace item.key je obvykle nejužitečnější pole, protože identifikuje zdrojový dokument.
score je pole, které udává, jak relevantní byl daný chunk vzhledem k dotazu. chunks pole je k dispozici také v search() odpověď a stejný postup platí i zde.
1. Vytvořte Worker
Vytvořte projekt Worker pro příklady citací:
npm create cloudflare@latest -- ai-search-citationsKdyž se zobrazí výzva, zvolte Příklad Hello World, Pouze Worker, a TypeScript.
Přejděte do adresáře projektu:
cd ai-search-citations2. Nakonfigurujte vazbu
Přidejte vazbu jmenného prostoru AI Search do konfigurace Wrangler:
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ai-search-citations",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-28",
"ai_search_namespaces": [
{
"binding": "AI_SEARCH",
"namespace": "default"
}
]
}name = "ai-search-citations"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-08-28"
[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"Tato vazba umožňuje vašemu Workeru přistupovat k instancím AI Search v default jmenný prostor. Příklady používají instanci s názvem my-instance.
Pokud ještě nemáte žádnou instanci, vytvořte ji a přidejte obsah, než Worker spustíte. Návod na vytvoření instance pomocí Wrangleru najdete v Příkazy Wrangleru.
3. Zobrazte citace z Chat Completions
Začněte s nejjednodušším vzorem citací: vraťte vygenerovanou odpověď a seznam zdrojových dokumentů ve stejné odpovědi JSON.
Nahraďte obsah src/index.ts s následujícím kódem Workeru:
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 });
},
};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>;Odpověď vypadá takto:
{
"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. Odstraňte duplicitní citace podle zdroje
Ze stejného dokumentu může pocházet více částí obsahu. Seskupte je podle item.key a zobrazit tak jednu citaci na jeden zdrojový dokument.
Chcete-li zobrazit jednu citaci na zdroj, upravte src/index.ts a seskupte chunky podle zdrojového dokumentu:
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 });
},
};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. Zpracujte citace ze streamované odpovědi
Při použití stream: true, fragmenty se odesílají jako samostatná událost Server-Sent Events (SSE) s názvem chunks před začátkem streamované odpovědi. Tuto událost zpracujte a zobrazte citace ještě před dokončením streamování celé odpovědi.
Chcete-li zobrazit citace ještě před dokončením streamování celé odpovědi, upravte src/index.ts a transformovat tak stream:
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;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
// The chunks event arrives before the streamed answer.
if (line.startsWith("event: ")) {
currentEvent = line.slice(7).trim();
continue;
}
// Transform the chunks data line into a citations event for your UI.
if (currentEvent === "chunks" && line.startsWith("data: ")) {
const chunks = JSON.parse(line.slice(6));
const citations = chunks.map((chunk) => ({
source: chunk.item.key,
score: chunk.score,
}));
await writer.write(
encoder.encode(
`event: citations\ndata: ${JSON.stringify(citations)}\n\n`,
),
);
currentEvent = "";
continue;
}
// Forward answer tokens and other SSE data unchanged.
currentEvent = "";
await writer.write(encoder.encode(line + "\n"));
}
}
} finally {
reader.releaseLock();
await writer.close();
}
};
pump().catch(() => writer.close());
return new Response(readable, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
},
});
},
};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?";
// 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;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
// The chunks event arrives before the streamed answer.
if (line.startsWith("event: ")) {
currentEvent = line.slice(7).trim();
continue;
}
// Transform the chunks data line into a citations event for your UI.
if (currentEvent === "chunks" && line.startsWith("data: ")) {
const chunks = JSON.parse(line.slice(6));
const citations = chunks.map(
(chunk: { item: { key: string }; score: number }) => ({
source: chunk.item.key,
score: chunk.score,
}),
);
await writer.write(
encoder.encode(
`event: citations\ndata: ${JSON.stringify(citations)}\n\n`,
),
);
currentEvent = "";
continue;
}
// Forward answer tokens and other SSE data unchanged.
currentEvent = "";
await writer.write(encoder.encode(line + "\n"));
}
}
} finally {
reader.releaseLock();
await writer.close();
}
};
pump().catch(() => writer.close());
return new Response(readable, {
headers: {
"content-type": "text/event-stream",
"cache-control": "no-cache",
},
});
},
} satisfies ExportedHandler<Env>;6. Seřaďte citace pomocí podrobností skóre
Každá část obsahu obsahuje scoring_details objekt s rozpisem, jak bylo skóre vypočítáno. Tyto podrobnosti použijte k odfiltrování nekvalitních citací nebo k zobrazení ukazatelů spolehlivosti.
Chcete-li citace filtrovat podle relevance, upravte src/index.ts pro použití polí skóre:
export default {
async fetch(request, env) {
const url = new URL(request.url);
const query = url.searchParams.get("query") ?? "What is Cloudflare?";
// AI Search returns scoring details with each source chunk.
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 ?? "";
const citations = response.chunks
// Filter out lower-scoring chunks for stronger citations.
.filter((chunk) => chunk.score > 0.5)
// Expose scoring details if your UI shows confidence indicators.
.map((chunk, index) => ({
index: index + 1,
source: chunk.item.key,
score: chunk.score,
vectorScore: chunk.scoring_details?.vector_score,
keywordScore: chunk.scoring_details?.keyword_score,
rerankingScore: chunk.scoring_details?.reranking_score,
confidence: chunk.score > 0.8 ? "high" : "medium",
snippet: chunk.text.slice(0, 200),
}));
return Response.json({ answer, citations });
},
};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 scoring details with each source chunk.
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 ?? "";
const citations = response.chunks
// Filter out lower-scoring chunks for stronger citations.
.filter((chunk) => chunk.score > 0.5)
// Expose scoring details if your UI shows confidence indicators.
.map((chunk, index) => ({
index: index + 1,
source: chunk.item.key,
score: chunk.score,
vectorScore: chunk.scoring_details?.vector_score,
keywordScore: chunk.scoring_details?.keyword_score,
rerankingScore: chunk.scoring_details?.reranking_score,
confidence: chunk.score > 0.8 ? "high" : "medium",
snippet: chunk.text.slice(0, 200),
}));
return Response.json({ answer, citations });
},
} satisfies ExportedHandler<Env>;Použití položek citací
Každá část obsahu v chunks pole může obsahovat následující položky:
| Pole | Typ | Popis |
|---|---|---|
id |
string | Jedinečný identifikátor části obsahu. |
type |
string | Typ obsahu, obvykle text. |
score |
number | Celkové skóre relevance mezi 0 a 1. |
text |
string | Textový obsah dané části. |
item.key |
string | Cesta k souboru nebo URL adresa zdrojového dokumentu. |
item.timestamp |
number | Časové razítko Unix poslední indexace položky. |
item.metadata |
objekt | Vlastní metadata přiřazená ke zdrojové položce. |
scoring_details.vector_score |
number | Skóre sémantické podobnosti (0 až 1). |
scoring_details.keyword_score |
number | Skóre shody klíčových slov BM25. Je k dispozici při použití hybridního nebo klíčového vyhledávání. |
scoring_details.keyword_rank |
number | Pozice v pořadí podle klíčových slov. |
scoring_details.vector_rank |
number | Pořadí podle vektorového vyhledávání. |
scoring_details.reranking_score |
number | Skóre rerankingu (0 až 1). Uvedeno, pokud je reranking povolen. |
scoring_details.fusion_method |
string | Použitá metoda fúze (rrf nebo max). Přítomno při použití hybridního vyhledávání. |
Při vyhledávání napříč více instancemi obsahuje každá část obsahu také instance_id je pole, které určuje, ze které instance pochází. Chcete-li vyhledávat nebo chatovat napříč více instancemi, přečtěte si metody jmenného prostoru.