在多租户应用程序中,每个租户绝对只能看到其自身的数据。AI Search 支持两种隔离按租户搜索的方法:为每个租户提供其专有的实例,或者共享一个实例并在查询时按租户进行过滤。
| 方法 | 它是如何隔离的 | 何时选择它 |
|---|---|---|
| 每个租户一个实例 (推荐) | 每个租户获得一个带有独立存储和索引的独立实例 | 你需要强隔离,或者在运行时创建和删除租户 |
| 带有过滤的共享实例 | 单个实例保存每个租户的数据;元数据过滤器限定每个查询的作用域 | 你拥有许多小型租户并希望采用最简单的设置 |
两种方法都使用 Cloudflare Worker。先创建项目,然后遵循你选择的选项。
- 注册 Cloudflare 账户 ↗。
- 安装
Node.js↗。
Node.js 版本管理器
使用 Volta ↗ 或 nvm ↗ 等 Node 版本管理器,以避免权限问题并切换 Node.js 版本。本指南后续将介绍的 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这是 推荐 的方法。每个租户都会获得一个带有自身存储和搜索索引的独立实例,因此一个租户永远不可能检索到另一个租户的文档。
在运行时使用命名空间绑定为每个租户创建一个隔离的 AI Search 实例。
将命名空间绑定添加到你的 Wrangler 配置文件:
{
"$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);
// 从请求标头中识别租户。
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// 为该租户创建一个新实例。
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() });
}
// 将文档上传到该租户的实例。
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 });
}
// 搜索该租户的实例。搜索隔离在该租户的实例中。
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);
}
// 删除该租户的实例及其所有数据。
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);
// 从请求标头中识别租户。
const tenantId = request.headers.get("x-tenant-id");
if (!tenantId) {
return new Response("Missing x-tenant-id header", { status: 400 });
}
// 为该租户创建一个新实例。
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() });
}
// 将文档上传到该租户的实例。
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 });
}
// 搜索该租户的实例。搜索隔离在该租户的实例中。
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);
}
// 删除该租户的实例及其所有数据。
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然后入驻 (onboard) 一个租户,向其实例上传一个文档,对其进行搜索,然后退驻 (offboard)。x-tenant-id 标头将每个请求限定在该租户实例的作用域内:
# 为租户 "acme" 创建一个隔离的实例
curl -X POST http://localhost:8787/onboard -H "x-tenant-id: acme"
# 向 acme 的实例上传一个文档
curl -X POST http://localhost:8787/upload -H "x-tenant-id: acme" -F "file=@./handbook.pdf"
# 搜索 acme 的实例
curl "http://localhost:8787/search?q=vacation+policy" -H "x-tenant-id: acme"
# 删除 acme 的实例及其所有数据
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`,
});共享存储桶: 如果每个租户的数据存放在同一个存储桶中,按文件夹组织:
- my-bucket
- customers/
- acme/
- globex/
- customers/
使用路径过滤 (path filtering) 将每个实例限制在该租户的文件夹,因此它只索引和搜索该租户的对象:
const instance = await env.TENANTS.create({
id: `tenant-${tenantId}`,
type: "r2",
source: "my-bucket",
source_params: {
include_items: [`/customers/${tenantId}/**`],
},
});在任何一种布局下,AI Search 都会在下次同步时索引每个租户的对象。通过将文档写入 R2 而不是通过 Worker 上传来添加文档,并保留内置存储示例中的 search 和 offboard 路由:每个实例仅返回其自身租户的结果,删除实例会移除其搜索索引,同时保持 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 });
}
// 过滤结果以仅返回来自该租户文件夹的文档。
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 });
}
// 过滤结果以仅返回来自该租户文件夹的文档。
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