AI Search 返回用于生成答案的来源 chunk。使用这些 chunk 在应用中显示引用、参考或来源链接。
本指南展示如何构建 Cloudflare Worker,返回 AI 生成的答案及其依据的文档。当你希望用户验证答案、检查来源材料或调试检索质量时,可使用此模式。
你将创建一个 Worker 端点,用于:
- 将用户问题发送到
chatCompletions() - 返回生成的答案,附带来源标识符、片段、元数据与相关性分数
- 将重复 chunk 分组为每个来源文档一条引用
- 处理标准与流式响应的引用
AI Search 在生成答案前检索来源 chunk:
- 从已索引文档中查找匹配的 chunk。
- 将这些 chunk 作为上下文发送给 model。
- 在响应中返回答案与 chunk。
每个返回的 chunk 包含 item 对象,其中有 key(文件名或 URL)、timestamp,以及建立索引时附加的任何自定义 metadata。对于引用,item.key 通常是最有用的字段,因为它标识来源文档。
score 字段表示 chunk 与查询的相关程度。chunks 数组在 search() 响应中也可用,方法相同。
为引用示例创建 Worker 项目:
npm create cloudflare@latest -- ai-search-citationsyarn create cloudflare ai-search-citationspnpm create cloudflare@latest ai-search-citations在收到提示时,选择 Hello World example(Hello World 示例)、Worker only(仅 Worker) 和 TypeScript。
进入项目目录:
cd ai-search-citations在您的 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 命令。
从最简单的引用模式开始:在同一个 JSON 响应中返回生成的答案和源文档列表。
将 src/index.ts 的内容替换为以下 Worker 代码:
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>;响应如下所示:
{
"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"
}
}
]
}多个分块可能来自同一个文档。按 item.key 对它们进行分组,以使每个源文档显示一次引用。
要使每个来源仅显示一次引用,请更新 src/index.ts 以按源文档对分块进行分组:
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>;使用 stream: true 时,在流式答案开始之前,分块会作为名为 chunks 的独立服务器发送事件 (SSE) 发送。解析此事件以便在完整答案完成流式传输之前显示引用。
要在完整答案完成流式传输之前显示引用,请更新 src/index.ts 以转换流:
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>;每个分块都包含一个 scoring_details 对象,其中细分了它的评分方式。使用这些详情来过滤低质量的引用或显示置信度指标。
要按相关性过滤引用,请更新 src/index.ts 以使用分数属性:
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>;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 | 使用的融合方法(rrf 或 max)。在使用混合检索时存在。 |
对于多实例搜索,每个分块还包含一个 instance_id 字段,用于标识它来自哪个实例。要跨多个实例进行搜索或聊天,请参阅 命名空间方法。