AI Search は、回答の生成に使った出典チャンクを返します。そのチャンクを使い、アプリケーションで引用、参照、ソースリンクを表示できます。
このガイドでは、AI が生成した回答と、その根拠になったドキュメントを返す Cloudflare Worker の作り方を説明します。利用者が回答を確認したい、出典を見たい、取得品質をデバッグしたい場合に、このパターンを使います。
次の処理をする Worker エンドポイントを作ります。
- 利用者の質問を
chatCompletions()に送ります - 生成した回答と、ソース識別子、スニペット、メタデータ、関連度スコアを返します
- 同じ出典から繰り返されるチャンクをまとめ、ドキュメントごとに 1 件の引用にします
- 通常レスポンスとストリーミングレスポンスの両方で引用を扱います
AI Search は、回答を生成する前に出典チャンクを取得します。
- インデックス済みドキュメントから、一致するチャンクを見つけます。
- それらのチャンクをコンテキストとしてモデルに送ります。
- レスポンスに回答とチャンクを返します。
返される各チャンクには、key(ファイル名または URL)、timestamp、インデックス時に付けたカスタム metadata を持つ item オブジェクトがあります。引用では、出典ドキュメントを特定できる item.key がもっとも役立つことが多いです。
score フィールドは、チャンクがクエリにどれだけ関連していたかを示します。chunks 配列は search() のレスポンスでも使え、同じ考え方を適用できます。
引用の例用に Worker プロジェクトを作成します。
npm create cloudflare@latest -- ai-search-citationsyarn create cloudflare ai-search-citationspnpm create cloudflare@latest ai-search-citationsプロンプトでは、Hello World example、Worker only、TypeScript を選びます。
プロジェクトディレクトリへ移動します。
cd ai-search-citationsWrangler の設定に、AI Search の namespace バインディングを追加します。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "ai-search-citations",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-09-20",
"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-09-20"
[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"このバインディングで、Worker から default namespace の 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 でグループ化し、出典ドキュメントごとに 1 件の引用を表示します。
出典ごとに 1 件の引用を出すには、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 という名前の独立した Server-Sent Events (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 フィールドもあります。複数インスタンスを横断して検索またはチャットするには、namespace メソッド を参照してください。