AI Search は、1 回のリクエストで複数インスタンスをクエリし、結果をマージして、各結果に出所のインスタンスを付けます。このガイドでは、2 つのナレッジベースをまとめて検索します。すべての利用者が検索できる共有の general ナレッジベースと、1 顧客の非公開コンテンツを持つ テナント固有 のナレッジベースです。1 回のクエリで、両方から関連コンテンツが返ります。
テナントごとにコンテンツを別インスタンスへ置くのが、テナントを分離する推奨方法です(マルチテナントの検索分離 を参照)。インスタンスを横断して検索すると、クエリ時にテナントのインスタンスと共有コンテンツを組み合わせられるので、共有コンテンツをすべてのテナントインスタンスへコピーする必要はありません。
作るもの: 共有の general-knowledge インスタンスとテナントごとのインスタンスをまとめて検索し、出所ごとに結果をグループ化する Worker。
- Cloudflare アカウント ↗ に登録します。
Node.js↗ をインストールします。
Node.js のバージョンマネージャー
権限の問題を避け、Node.js のバージョンを切り替えられるよう、Volta ↗ や nvm ↗ などの Node バージョンマネージャーを使います。このガイドの後半で説明する Wrangler には、Node バージョン 16.17.0 以降が必要です。
横断検索するインスタンスは、同じ 名前空間 に属している必要があります。このガイドで作成します。
create-cloudflare CLI(C3)で新しい Worker プロジェクトを作成します。C3 ↗ は、Cloudflare への新規アプリケーションのセットアップとデプロイを助けるコマンドラインツールです。
次を実行して、multi-source-search という名前のプロジェクトを作成します。
npm create cloudflare@latest -- multi-source-searchyarn create cloudflare multi-source-searchpnpm create cloudflare@latest multi-source-searchセットアップでは、次のオプションを選びます。
- What would you like to start with? では、
Hello World exampleを選びます。 - Which template would you like to use? では、
Worker onlyを選びます。 - Which language do you want to use? では、
TypeScriptを選びます。 - Do you want to use git for version control? では、
Yesを選びます。 - Do you want to deploy your application? では、
Noを選びます(デプロイ前にいくつか変更します)。
アプリケーションディレクトリへ移動します。
cd multi-source-searchWrangler 設定ファイル に AI Search の名前空間バインディング を追加します。インスタンス横断検索は名前空間バインディングのメソッドなので、1 つのバインディングで名前空間内のすべてのインスタンスへ届きます。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "multi-source-search",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-09-20",
"ai_search_namespaces": [
{
"binding": "AI_SEARCH",
"namespace": "default",
"remote": true
}
]
}name = "multi-source-search"
main = "src/index.ts"
# Set this to today's date
compatibility_date = "2026-09-20"
[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"
remote = trueremote オプションを付けると、wrangler dev がデプロイ済みインスタンスへリクエストをプロキシします。AI Search はローカルでは動きません。
src/index.ts を更新します。この Worker はリクエストヘッダーからテナントを特定し、共有インスタンスとそのテナントのインスタンスを 1 回の呼び出しで検索します。返る各チャンクには instance_id が付くので、出所ごとに結果をグループ化できます。
// The shared instance that every tenant can search.
const GENERAL_INSTANCE = "general-knowledge";
// Each tenant also has its own instance, holding only that tenant's content.
const tenantInstance = (tenantId) => `tenant-${tenantId}`;
// Seed content, so each instance returns something on the first query. In a
// real app you would provision instances and their content ahead of time.
const GENERAL_DOC = `# Support hours
Support is available Monday to Friday, 9am to 5pm UTC for all customers.`;
const TENANT_DOC = `# Your plan
This account is on the Enterprise plan with a dedicated success manager.`;
export default {
async fetch(request, env) {
// Identify the tenant and read the query.
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
const query = new URL(request.url).searchParams.get("q");
if (!query) {
return new Response("Add a ?q= query parameter", { status: 400 });
}
// One-time setup for this demo: create both instances and seed each.
await ensureInstance(
env,
GENERAL_INSTANCE,
"support-hours.md",
GENERAL_DOC,
);
await ensureInstance(env, tenantInstance(tenantId), "plan.md", TENANT_DOC);
// Search the shared instance and this tenant's instance in one call.
// AI Search fans out to both, merges the results, and tags each chunk
// with the instance_id it came from. You can pass 1 to 10 instance IDs.
const results = await env.AI_SEARCH.search({
query,
ai_search_options: {
instance_ids: [GENERAL_INSTANCE, tenantInstance(tenantId)],
},
});
// Use the instance_id tag to separate shared results from tenant results.
const fromGeneral = results.chunks.filter(
(chunk) => chunk.instance_id === GENERAL_INSTANCE,
);
const fromTenant = results.chunks.filter(
(chunk) => chunk.instance_id === tenantInstance(tenantId),
);
return Response.json({
query: results.search_query,
general: fromGeneral.map((chunk) => ({
key: chunk.item.key,
text: chunk.text,
})),
tenant: fromTenant.map((chunk) => ({
key: chunk.item.key,
text: chunk.text,
})),
// If one instance fails, the other still returns. Failures land here.
errors: results.errors,
});
},
};
// Create an instance with built-in storage and seed one document. Safe to call
// on every request: create() throws if the instance already exists, so the
// try/catch skips setup once it is in place.
async function ensureInstance(env, id, key, content) {
try {
await env.AI_SEARCH.create({ id });
await env.AI_SEARCH.get(id).items.uploadAndPoll(key, content, {
timeoutMs: 60_000,
});
} catch {
// The instance already exists and has been seeded.
}
}export interface Env {
AI_SEARCH: AiSearchNamespace;
}
// The shared instance that every tenant can search.
const GENERAL_INSTANCE = "general-knowledge";
// Each tenant also has its own instance, holding only that tenant's content.
const tenantInstance = (tenantId: string) => `tenant-${tenantId}`;
// Seed content, so each instance returns something on the first query. In a
// real app you would provision instances and their content ahead of time.
const GENERAL_DOC = `# Support hours
Support is available Monday to Friday, 9am to 5pm UTC for all customers.`;
const TENANT_DOC = `# Your plan
This account is on the Enterprise plan with a dedicated success manager.`;
export default {
async fetch(request, env): Promise<Response> {
// Identify the tenant and read the query.
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
const query = new URL(request.url).searchParams.get("q");
if (!query) {
return new Response("Add a ?q= query parameter", { status: 400 });
}
// One-time setup for this demo: create both instances and seed each.
await ensureInstance(
env,
GENERAL_INSTANCE,
"support-hours.md",
GENERAL_DOC,
);
await ensureInstance(env, tenantInstance(tenantId), "plan.md", TENANT_DOC);
// Search the shared instance and this tenant's instance in one call.
// AI Search fans out to both, merges the results, and tags each chunk
// with the instance_id it came from. You can pass 1 to 10 instance IDs.
const results = await env.AI_SEARCH.search({
query,
ai_search_options: {
instance_ids: [GENERAL_INSTANCE, tenantInstance(tenantId)],
},
});
// Use the instance_id tag to separate shared results from tenant results.
const fromGeneral = results.chunks.filter(
(chunk) => chunk.instance_id === GENERAL_INSTANCE,
);
const fromTenant = results.chunks.filter(
(chunk) => chunk.instance_id === tenantInstance(tenantId),
);
return Response.json({
query: results.search_query,
general: fromGeneral.map((chunk) => ({
key: chunk.item.key,
text: chunk.text,
})),
tenant: fromTenant.map((chunk) => ({
key: chunk.item.key,
text: chunk.text,
})),
// If one instance fails, the other still returns. Failures land here.
errors: results.errors,
});
},
} satisfies ExportedHandler<Env>;
// Create an instance with built-in storage and seed one document. Safe to call
// on every request: create() throws if the instance already exists, so the
// try/catch skips setup once it is in place.
async function ensureInstance(
env: Env,
id: string,
key: string,
content: string,
) {
try {
await env.AI_SEARCH.create({ id });
await env.AI_SEARCH.get(id).items.uploadAndPoll(key, content, {
timeoutMs: 60_000,
});
} catch {
// The instance already exists and has been seeded.
}
}env.AI_SEARCH.search() は名前空間レベルの検索です。単一インスタンスを検索する env.AI_SEARCH.get(id).search() とは異なります。instance_ids を渡すと、それらのインスタンスへクエリがファンアウトされ、マージしてランク付けされた 1 つのチャンクリストが返ります。すべてのチャンクに instance_id が含まれるため、結果が共有インスタンス由来かテナントのインスタンス由来かを常に判別できます。
1 つのインスタンスが失敗しても(たとえば ID が存在しない場合)、ほかは返り、失敗は例外ではなく errors に報告されます。存在しないテナントインスタンスは、致命的エラーではなく部分結果になります。
ローカル開発サーバーを起動します。
npx wrangler devテナントヘッダーとクエリを付けてリクエストを送ります。最初のリクエストは、インスタンスの作成とシードのため少し時間がかかります。
curl "http://localhost:8787/?q=support+hours+and+my+plan" -H "x-tenant-id: acme"レスポンスは、共有インスタンスの結果とテナントインスタンスの結果を分けます。
{
"query": "support hours and my plan",
"general": [
{
"key": "support-hours.md",
"text": "# Support hours\nSupport is available..."
}
],
"tenant": [
{
"key": "plan.md",
"text": "# Your plan\nThis account is on the Enterprise plan..."
}
]
}すべてのインスタンスが成功すると、レスポンスに errors フィールドはありません。インスタンスが失敗した場合は、errors 配列に失敗が並び、ほかのインスタンスの結果は返ります。
生のチャンクではなく、両方のインスタンスに根拠を置いた 1 つの文章回答を返すには、同じ instance_ids で chatCompletions を使います。列挙したすべてのインスタンスから取得し、結合したコンテキストから 1 つの応答を生成します。
const completion = await env.AI_SEARCH.chatCompletions({
query,
ai_search_options: {
instance_ids: [GENERAL_INSTANCE, tenantInstance(tenantId)],
},
});
// The generated answer, grounded in both the shared and tenant content.
const answer = completion.choices[0]?.message.content;レスポンスには、出典を引用できるように取得した chunks(それぞれ instance_id 付き)と、失敗したインスタンス向けの errors 配列も含まれます。
Cloudflare アカウントでログインしてから、Worker をデプロイします。
npx wrangler login
npx wrangler deploy