跳转到内容
搜索文档

多租户 (Multitenancy)

最后更新 查看 MarkdownAgent 设置

在多租户应用程序中,每个租户绝对只能看到其自身的数据。AI Search 支持两种隔离按租户搜索的方法:为每个租户提供其专有的实例,或者共享一个实例并在查询时按租户进行过滤。

选择一种方法

方法 它是如何隔离的 何时选择它
每个租户一个实例 (推荐) 每个租户获得一个带有独立存储和索引的独立实例 你需要强隔离,或者在运行时创建和删除租户
带有过滤的共享实例 单个实例保存每个租户的数据;元数据过滤器限定每个查询的作用域 你拥有许多小型租户并希望采用最简单的设置

先决条件

两种方法都使用 Cloudflare Worker。先创建项目,然后遵循你选择的选项。

  1. 注册 Cloudflare 账户
  2. 安装 Node.js

Node.js 版本管理器

使用 Voltanvm 等 Node 版本管理器,以避免权限问题并切换 Node.js 版本。本指南后续将介绍的 Wrangler 需要 Node 版本 16.17.0 或更高。

创建 Worker 项目

使用 create-cloudflare CLI (C3) 创建新的 Worker 项目。C3 是一种命令行工具,旨在帮助你设置并部署新的应用程序到 Cloudflare。

通过运行以下命令创建一个名为 tenant-search 的新项目:

npm 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

选项 1:每个租户一个实例 (推荐)

这是 推荐 的方法。每个租户都会获得一个带有自身存储和搜索索引的独立实例,因此一个租户永远不可能检索到另一个租户的文档。

在运行时使用命名空间绑定为每个租户创建一个隔离的 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 = true

remote 选项允许 wrangler dev 将请求代理到已部署的实例,因为 AI Search 不在本地运行。

内置存储 (Built-in storage)

每个租户的实例保存你直接上传给它的文档,无需外部数据源。

更新 src/index.ts。此 Worker 从请求标头中识别租户,然后创建、填充、搜索并删除该租户的实例。

src/index.jsjs
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 });
	},
};
src/index.tsts
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 中,可以用 R2 支持每个租户的实例,而不是将文档上传到内置存储。更改来自内置存储示例的 /onboard 路由以创建由 R2 支持的实例。具体的配置取决于数据的组织方式。

按租户分存储桶: 如果每个租户的数据已经在其自身的存储桶中,请将实例指向整个存储桶:

const instance = await env.TENANTS.create({
	id: `tenant-${tenantId}`,
	type: "r2",
	source: `tenant-${tenantId}-bucket`,
});

共享存储桶: 如果每个租户的数据存放在同一个存储桶中,按文件夹组织:

  • my-bucket
    • customers/
      • acme/
      • globex/

使用路径过滤 (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 并使用与内置存储相同的 onboardsearchoffboard 请求。跳过上传步骤,因为 AI Search 会直接从 R2 索引每个租户的对象。

选项 2:在检索时带元数据过滤的共享实例

使用单个 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 以在查询时按租户的文件夹进行过滤:

src/index.jsjs
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);
	},
};
src/index.tsts
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

后续步骤

这篇文档对您有帮助吗?