マルチテナントアプリケーションでは、各テナントは自分のデータだけを見る必要があります。AI Search は、テナント単位の検索を隔離する 2 つの方法をサポートします。テナントごとに独自のインスタンスを与えるか、1 つのインスタンスを共有し、クエリ時にテナントでフィルタリングします。
| アプローチ | 隔離の方法 | 選ぶタイミング |
|---|---|---|
| テナントごとに 1 インスタンス(推奨) | 各テナントが独自のストレージとインデックスを持つ別インスタンスを持ちます | 強い隔離が必要、または実行時にテナントを作成・削除するとき |
| フィルタリング付きの共有インスタンス | 1 つのインスタンスがすべてのテナントを保持し、メタデータフィルターが各クエリの範囲を絞ります | 小さなテナントが多く、最も単純なセットアップが欲しいとき |
どちらのアプローチも Cloudflare Worker を使います。先にプロジェクトを作成し、選んだオプションに進みます。
- Cloudflare アカウント ↗ に登録します。
Node.js↗ をインストールします。
Node.js のバージョンマネージャー
権限の問題を避け、Node.js のバージョンを切り替えられるよう、Volta ↗ や nvm ↗ などの Node バージョンマネージャーを使います。このガイドの後半で説明する Wrangler には、Node バージョン 16.17.0 以降が必要です。
create-cloudflare CLI (C3) で新しい Worker プロジェクトを作成します。C3 ↗ は、Cloudflare への新しいアプリケーションのセットアップとデプロイを支援するコマンドラインツールです。
次を実行して、tenant-search という新しいプロジェクトを作成します。
npm create cloudflare@latest -- tenant-searchyarn create cloudflare tenant-searchpnpm create cloudflare@latest tenant-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 tenant-searchこれが 推奨 アプローチです。各テナントは独自のストレージと検索インデックスを持つ別インスタンスを持つため、あるテナントが別テナントのドキュメントを取得することはありません。
namespace バインディング を使い、実行時にテナントごとに隔離された AI Search インスタンスを作成します。
Wrangler 設定ファイル に namespace バインディングを追加します。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"ai_search_namespaces": [
{
"binding": "TENANTS",
"namespace": "default",
"remote": true
}
]
}[[ai_search_namespaces]]
binding = "TENANTS"
namespace = "default"
remote = trueremote オプションにより、wrangler dev がデプロイ済みインスタンスへリクエストをプロキシします。AI Search はローカルでは動かないためです。
各テナントのインスタンスは、外部データソースなしで直接アップロードしたドキュメントを保持します。
src/index.ts を更新します。この Worker はリクエストヘッダーからテナントを識別し、そのテナントのインスタンスを作成、投入、検索、削除します。
export default {
async fetch(request, env) {
const url = new URL(request.url);
// Identify the tenant from the request header.
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// Create a new instance for the tenant.
if (url.pathname === "/onboard" && request.method === "POST") {
const instance = await env.TENANTS.create({
id: `tenant-${tenantId}`,
});
return Response.json({ success: true, instance: await instance.info() });
}
// Upload a document to the tenant's instance.
if (url.pathname === "/upload" && request.method === "POST") {
const formData = await request.formData();
const file = formData.get("file");
const item = await env.TENANTS.get(`tenant-${tenantId}`).items.upload(
file.name,
await file.arrayBuffer(),
);
return Response.json({ success: true, item });
}
// Search the tenant's instance. Search is isolated to their instance.
if (url.pathname === "/search") {
const query = url.searchParams.get("q") || "";
const results = await env.TENANTS.get(`tenant-${tenantId}`).search({
messages: [{ role: "user", content: query }],
});
return Response.json(results);
}
// Delete the tenant's instance and all its data.
if (url.pathname === "/offboard" && request.method === "DELETE") {
await env.TENANTS.delete(`tenant-${tenantId}`);
return Response.json({ success: true });
}
return new Response("Not found", { status: 404 });
},
};export type Env = {
TENANTS: AiSearchNamespace;
};
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
// Identify the tenant from the request header.
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// Create a new instance for the tenant.
if (url.pathname === "/onboard" && request.method === "POST") {
const instance = await env.TENANTS.create({
id: `tenant-${tenantId}`,
});
return Response.json({ success: true, instance: await instance.info() });
}
// Upload a document to the tenant's instance.
if (url.pathname === "/upload" && request.method === "POST") {
const formData = await request.formData();
const file = formData.get("file") as File;
const item = await env.TENANTS.get(`tenant-${tenantId}`).items.upload(
file.name,
await file.arrayBuffer(),
);
return Response.json({ success: true, item });
}
// Search the tenant's instance. Search is isolated to their instance.
if (url.pathname === "/search") {
const query = url.searchParams.get("q") || "";
const results = await env.TENANTS.get(`tenant-${tenantId}`).search({
messages: [{ role: "user", content: query }],
});
return Response.json(results);
}
// Delete the tenant's instance and all its data.
if (url.pathname === "/offboard" && request.method === "DELETE") {
await env.TENANTS.delete(`tenant-${tenantId}`);
return Response.json({ success: true });
}
return new Response("Not found", { status: 404 });
},
} satisfies ExportedHandler<Env>;ローカル開発サーバーを起動します。
npx wrangler devその後、テナントをオンボードし、そのインスタンスへドキュメントをアップロードし、検索し、オフボードします。x-tenant-id ヘッダーが、すべてのリクエストをそのテナントのインスタンスにスコープします。
# Create an isolated instance for tenant "acme"
curl -X POST http://localhost:8787/onboard -H "x-tenant-id: acme"
# Upload a document to acme's instance
curl -X POST http://localhost:8787/upload -H "x-tenant-id: acme" -F "file=@./handbook.pdf"
# Search acme's instance
curl "http://localhost:8787/search?q=vacation+policy" -H "x-tenant-id: acme"
# Delete acme's instance and all of its data
curl -X DELETE http://localhost:8787/offboard -H "x-tenant-id: acme"AI Search はアップロードを非同期でインデックスするため、検索する前にアップロード後少し待ちます。
テナントのデータがすでに R2 にある場合は、組み込みストレージへのアップロードではなく、各テナントのインスタンスを R2 で裏打ちします。組み込みストレージ の例の /onboard ルートを変更し、R2 バックエンドのインスタンスを作成します。設定方法は、データの整理方法によって異なります。
テナントごとにバケット: 各テナントのデータがすでに独自のバケットにある場合は、インスタンスをそのバケット全体へ向けます。
const instance = await env.TENANTS.create({
id: `tenant-${tenantId}`,
type: "r2",
source: `tenant-${tenantId}-bucket`,
});共有バケット: すべてのテナントのデータが 1 つのバケットにあり、フォルダーで整理されている場合:
- my-bucket
- customers/
- acme/
- globex/
- customers/
パスフィルタリング で各インスタンスをそのテナントのフォルダーにスコープし、そのテナントのオブジェクトだけをインデックスして検索するようにします。
const instance = await env.TENANTS.create({
id: `tenant-${tenantId}`,
type: "r2",
source: "my-bucket",
source_params: {
include_items: [`/customers/${tenantId}/**`],
},
});どちらの配置でも、AI Search は次回の 同期 で各テナントのオブジェクトをインデックスします。ドキュメントの追加は Worker 経由のアップロードではなく R2 への書き込みで行い、検索とオフボードのルートは組み込みストレージの例のままにします。各インスタンスは常に自テナントの結果だけを返し、インスタンスの削除は検索インデックスを削除しますが、R2 オブジェクトは残します。
試すには npx wrangler dev を実行し、組み込みストレージ と同じ onboard、search、offboard リクエストを使います。AI Search は各テナントのオブジェクトを R2 から直接インデックスするため、アップロード手順はスキップします。
単一の AI Search インスタンスを使い、フォルダーパスでテナント別にコンテンツを整理します。このアプローチは R2 バケット と 組み込みストレージ の両方で使えます。クエリ時に メタデータフィルター を適用し、各テナントが自分のドキュメントだけを取得するようにします。
このオプションは既存インスタンスを検索するため、先に shared-instance という名前のインスタンスを作成し、コンテンツを追加します。始める を参照してください。
Wrangler 設定ファイル にインスタンスバインディングを追加します。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"ai_search": [
{
"binding": "SHARED_INSTANCE",
"instance_name": "shared-instance",
"remote": true
}
]
}[[ai_search]]
binding = "SHARED_INSTANCE"
instance_name = "shared-instance"
remote = true一意のフォルダーパスでテナント別にコンテンツを整理します。
- customer-a
- logs/
- contracts/
- customer-b
- contracts/
src/index.ts を更新し、クエリ時にテナントのフォルダーでフィルタリングします。
export default {
async fetch(request, env) {
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// Filter results to only return documents from this tenant's folder.
const results = await env.SHARED_INSTANCE.search({
messages: [{ role: "user", content: "When did I sign my agreement?" }],
ai_search_options: {
retrieval: {
filters: {
folder: { $gte: `${tenantId}/`, $lt: `${tenantId}0` },
},
},
},
});
return Response.json(results);
},
};export type Env = {
SHARED_INSTANCE: AiSearchInstance;
};
export default {
async fetch(request, env): Promise<Response> {
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// Filter results to only return documents from this tenant's folder.
const results = await env.SHARED_INSTANCE.search({
messages: [{ role: "user", content: "When did I sign my agreement?" }],
ai_search_options: {
retrieval: {
filters: {
folder: { $gte: `${tenantId}/`, $lt: `${tenantId}0` },
},
},
},
});
return Response.json(results);
},
} satisfies ExportedHandler<Env>;この例は 「前方一致」フィルター を使い、サブフォルダーを含むテナントフォルダー配下のすべてのファイルに一致します。
ローカル開発サーバーを起動します。
npx wrangler dev各テナントとしてリクエストを送ります。Worker は検索をそのテナントのフォルダーにスコープするため、結果は重なりません。
curl http://localhost:8787/ -H "x-tenant-id: customer-a"
curl http://localhost:8787/ -H "x-tenant-id: customer-b"Cloudflare アカウントでログインし、Worker をデプロイしてインターネットからアクセスできるようにします。
npx wrangler login
npx wrangler deploy