INTEGRITY Dokumentace

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ý:

Jak fungují citace

AI Search před vygenerováním odpovědi načte zdrojové části obsahu:

  1. Vyhledá odpovídající části obsahu z vašich indexovaných dokumentů.
  2. Odesílá tyto části obsahu modelu jako kontext.
  3. 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-citations

Když se zobrazí výzva, zvolte Příklad Hello World, Pouze Worker, a TypeScript.

Přejděte do adresáře projektu:

cd ai-search-citations

2. 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:

src/index.js
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.ts
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:

src/index.js
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.ts
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:

src/index.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;

					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",
			},
		});
	},
};
src/index.ts
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:

src/index.js
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 });
	},
};
src/index.ts
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.