跳转到内容
搜索文档

在响应中显示来源引用

最后更新 查看 MarkdownAgent 设置

AI Search 返回用于生成答案的来源 chunk。使用这些 chunk 在应用中显示引用、参考或来源链接。

本指南展示如何构建 Cloudflare Worker,返回 AI 生成的答案及其依据的文档。当你希望用户验证答案、检查来源材料或调试检索质量时,可使用此模式。

你将构建的内容

你将创建一个 Worker 端点,用于:

  • 将用户问题发送到 chatCompletions()
  • 返回生成的答案,附带来源标识符、片段、元数据与相关性分数
  • 将重复 chunk 分组为每个来源文档一条引用
  • 处理标准与流式响应的引用

引用工作原理

AI Search 在生成答案前检索来源 chunk:

  1. 从已索引文档中查找匹配的 chunk。
  2. 将这些 chunk 作为上下文发送给 model。
  3. 在响应中返回答案与 chunk。

每个返回的 chunk 包含 item 对象,其中有 key(文件名或 URL)、timestamp,以及建立索引时附加的任何自定义 metadata。对于引用,item.key 通常是最有用的字段,因为它标识来源文档。

score 字段表示 chunk 与查询的相关程度。chunks 数组在 search() 响应中也可用,方法相同。

1. 创建 Worker

为引用示例创建 Worker 项目:

npm create cloudflare@latest -- ai-search-citations

在收到提示时,选择 Hello World example(Hello World 示例)Worker only(仅 Worker)TypeScript

进入项目目录:

cd ai-search-citations

2. 配置绑定

在您的 Wrangler 配置中添加 AI Search 命名空间绑定:

{
  "$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-17",
  "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-17"

[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"

此绑定允许您的 Worker 访问 default 命名空间中的 AI Search 实例。示例中使用名为 my-instance 的实例。

如果您还没有实例,请在运行 Worker 之前创建一个并添加内容。要使用 Wrangler 创建实例,请参阅 Wrangler 命令

3. 从对话生成中显示引用

从最简单的引用模式开始:在同一个 JSON 响应中返回生成的答案和源文档列表。

src/index.ts 的内容替换为以下 Worker 代码:

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>;

响应如下所示:

{
	"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. 按来源对引用去重

多个分块可能来自同一个文档。按 item.key 对它们进行分组,以使每个源文档显示一次引用。

要使每个来源仅显示一次引用,请更新 src/index.ts 以按源文档对分块进行分组:

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. 从流式响应中解析引用

使用 stream: true 时,在流式答案开始之前,分块会作为名为 chunks 的独立服务器发送事件 (SSE) 发送。解析此事件以便在完整答案完成流式传输之前显示引用。

要在完整答案完成流式传输之前显示引用,请更新 src/index.ts 以转换流:

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

		// 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. 使用评分详情对引用进行排序

每个分块都包含一个 scoring_details 对象,其中细分了它的评分方式。使用这些详情来过滤低质量的引用或显示置信度指标。

要按相关性过滤引用,请更新 src/index.ts 以使用分数属性:

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 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.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 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>;

使用引用字段

chunks 数组中的每个分块都可以包含以下字段:

字段 (Field) 类型 (Type) 描述 (Description)
id string 分块的唯一标识符。
type string 内容类型,通常为 text
score number 介于 0 和 1 之间的总体相关性分数。
text string 分块的文本内容。
item.key string 源文档的文件路径或 URL。
item.timestamp number 该项目最后一次建立索引的 Unix 时间戳。
item.metadata object 与源项目关联的自定义元数据。
scoring_details.vector_score number 语义相似度分数(0 到 1)。
scoring_details.keyword_score number BM25 关键词匹配分数。在使用混合或关键词检索时存在。
scoring_details.keyword_rank number 关键词排名位置。
scoring_details.vector_rank number 向量排名位置。
scoring_details.reranking_score number 重排序分数(0 到 1)。在启用了重排序时存在。
scoring_details.fusion_method string 使用的融合方法(rrfmax)。在使用混合检索时存在。

对于多实例搜索,每个分块还包含一个 instance_id 字段,用于标识它来自哪个实例。要跨多个实例进行搜索或聊天,请参阅 命名空间方法

这篇文档对您有帮助吗?