INTEGRITY Документация

Мультитенантность

В мультитенантном приложении каждый тенант должен видеть только свои собственные данные. AI Search поддерживает два способа изоляции поиска по тенантам: выделить каждому тенанту отдельный экземпляр или использовать один общий экземпляр и фильтровать данные по тенанту во время запроса.

Выберите подход

Подход Как это изолирует Выбирайте этот вариант, если
Экземпляр для каждого тенанта (рекомендуется) Каждый тенант получает отдельный экземпляр с собственным хранилищем и индексом Вам нужна строгая изоляция, либо вы создаёте и удаляете тенанты во время выполнения
Общий экземпляр с фильтрацией Один экземпляр содержит всех тенантов; фильтр метаданных ограничивает каждый запрос У вас много небольших тенантов, и вам нужна максимально простая настройка

Предварительные требования

Оба подхода используют Cloudflare Worker. Сначала создайте проект, а затем следуйте выбранному варианту.

  1. Зарегистрируйтесь для получения Аккаунт Cloudflare ↗.
  2. Установка Node.js ↗.

менеджер версий Node.js

Используйте менеджер версий Node, например Volta ↗ или nvm ↗ чтобы избежать проблем с правами доступа и переключать версии Node.js. Wrangler, о котором пойдёт речь далее в этом руководстве, требует версию Node 16.17.0 или более поздней версии.

Создайте проект Worker

Создайте новый проект Worker с помощью create-cloudflare CLI (C3). C3 ↗ это инструмент командной строки, который помогает настраивать и развёртывать новые приложения в Cloudflare.

Создайте новый проект с именем tenant-search выполнив:

npm create cloudflare@latest -- tenant-search

Для настройки выберите следующие параметры:

Перейдите в каталог вашего приложения:

cd tenant-search

Вариант 1: Один экземпляр на тенанта

Это рекомендуется подход. Каждый тенант получает отдельный экземпляр с собственным хранилищем и поисковым индексом, поэтому один тенант никогда не сможет получить документы другого тенанта.

Во время выполнения создавайте для каждого тенанта изолированный экземпляр AI Search с помощью привязка пространства имён.

Входящие запросы арендаторов
Тенант AТенант BТенант C
Fan-in: каждый запрос арендатора поступает на один и тот же Worker Worker: направляет каждый запрос по идентификатору тенанта через привязку пространства имён env.AI_SEARCH.get(id)Worker Линия в область пространства имён
namespace: tenants Разделение: привязка получает или создаёт отдельный экземпляр для каждого тенанта Один изолированный экземпляр на каждого арендатора

Добавьте привязку пространства имён в свой конфигурационный файл 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 не выполняется локально.

Встроенное хранилище

Экземпляр тенанта содержит документы, которые вы загружаете в него напрямую, без внешнего источника данных.

Обновите src/index.ts. Этот Worker определяет тенанта по заголовку запроса, а затем создаёт, наполняет, ищет и удаляет экземпляр этого тенанта.

src/index.js
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 });
	},
};
src/index.ts
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, используйте R2 для хранения экземпляра каждого арендатора вместо загрузки документов во встроенное хранилище. Измените /onboard маршрут из встроенное хранилище пример создания экземпляра на основе R2. Способ настройки зависит от того, как организованы данные.

Один bucket на тенанта: если данные каждого тенанта уже находятся в отдельном bucket, укажите экземпляру на весь этот bucket:

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

Общий bucket: если данные всех тенантов находятся в одном bucket, организованном по папкам:

Ограничьте каждый экземпляр папкой этого тенанта с помощью фильтрация по пути, поэтому он всегда индексирует и ищет только среди объектов этого арендатора:

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.

Вариант 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

Организуйте свой контент по тенантам с помощью уникальных путей к папкам:

Обновите src/index.ts и фильтровать по папке тенанта во время запроса:

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

В этом примере используется фильтр «starts with» чтобы соответствовать всем файлам в папке тенанта, включая вложенные папки.

Запустите локальный сервер разработки:

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

Следующие шаги

Пространства имён

Группируйте экземпляры и управляйте ими динамически через привязку.

Фильтрация

Фильтруйте результаты поиска по атрибутам метаданных во время выполнения запроса.