AI Search 可以在一个请求中查询多个实例,合并结果,并使用结果来自的实例标记每个结果。本指南使用该功能一同搜索两个知识库:每一个用户都可以搜索的共享 **general(通用)**知识库,加上包含单户客户私有内容的 **tenant-specific(租户专用)**知识库。单个查询会返回来自两者的相关内容。
将每个租户的内容保持在其自身的实例中是隔离租户的推荐方式(请参阅多租户搜索隔离)。跨实例搜索允许你在查询时将租户的实例与共享内容结合起来,因此你无需将共享内容复制到每个租户的实例中。
你将构建的内容: 一个一同搜索共享的 general-knowledge 实例和按租户实例的 Worker,然后按来源对结果进行分组。
- 注册 Cloudflare 账户 ↗。
- 安装
Node.js↗。
Node.js 版本管理器
使用 Volta ↗ 或 nvm ↗ 等 Node 版本管理器,以避免权限问题并切换 Node.js 版本。本指南后续将介绍的 Wrangler 需要 Node 版本 16.17.0 或更高。
你跨其搜索的实例必须属于同一个命名空间 (namespace)。本指南将为你创建它们。
使用 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-search将 AI Search 命名空间绑定 添加到你的 Wrangler 配置文件。跨实例搜索是命名空间绑定上的一个方法,因此单个绑定就能访问命名空间中的每个实例。
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "multi-source-search",
"main": "src/index.ts",
// Set this to today's date
"compatibility_date": "2026-08-17",
"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-08-17"
[[ai_search_namespaces]]
binding = "AI_SEARCH"
namespace = "default"
remote = trueremote 选项允许 wrangler dev 将请求代理到已部署的实例,因为 AI Search 不在本地运行。
更新 src/index.ts。此 Worker 从请求标头中识别租户,然后在一次调用中搜索共享实例和该租户的实例。每个返回的块 (chunk) 都带有 instance_id,因此 Worker 可以按来源对结果进行分组。
// 所有租户都可以搜索的共享实例。
const GENERAL_INSTANCE = "general-knowledge";
// 每个租户也有其自身的实例,仅保存该租户的内容。
const tenantInstance = (tenantId) => `tenant-${tenantId}`;
// 种子内容,以便每个实例在首次查询时都返回内容。在实际应用中,你会提前预置实例及其内容。
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) {
// 识别租户并读取查询。
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 });
}
// 本 Demo 的一次性设置:创建两个实例并为各自填充种子数据。
await ensureInstance(
env,
GENERAL_INSTANCE,
"support-hours.md",
GENERAL_DOC,
);
await ensureInstance(env, tenantInstance(tenantId), "plan.md", TENANT_DOC);
// 在一次调用中搜索共享实例和该租户的实例。
// AI Search 会扇出 (fan out) 到两者、合并结果,并用其来自的 instance_id 标记每个 chunk。你可以传递 1 到 10 个实例 ID。
const results = await env.AI_SEARCH.search({
query,
ai_search_options: {
instance_ids: [GENERAL_INSTANCE, tenantInstance(tenantId)],
},
});
// 使用 instance_id 标记将共享结果与租户结果区分开来。
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,
})),
// 如果某个实例失败,另一个实例仍会返回。失败会落在此处。
errors: results.errors,
});
},
};
// 创建带有内置存储的实例并填充一份文档种子。每次请求调用都是安全的:如果实例已存在则 create() 会抛出异常,因此 try/catch 会在就位后跳过设置。
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 {
// 实例已存在且已被填充种子数据。
}
}export interface Env {
AI_SEARCH: AiSearchNamespace;
}
// 所有租户都可以搜索的共享实例。
const GENERAL_INSTANCE = "general-knowledge";
// 每个租户也有其自身的实例,仅保存该租户的内容。
const tenantInstance = (tenantId: string) => `tenant-${tenantId}`;
// 种子内容,以便每个实例在首次查询时都返回内容。在实际应用中,你会提前预置实例及其内容。
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> {
// 识别租户并读取查询。
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 });
}
// 本 Demo 的一次性设置:创建两个实例并为各自填充种子数据。
await ensureInstance(
env,
GENERAL_INSTANCE,
"support-hours.md",
GENERAL_DOC,
);
await ensureInstance(env, tenantInstance(tenantId), "plan.md", TENANT_DOC);
// 在一次调用中搜索共享实例和该租户的实例。
// AI Search 会扇出 (fan out) 到两者、合并结果,并用其来自的 instance_id 标记每个 chunk。你可以传递 1 到 10 个实例 ID。
const results = await env.AI_SEARCH.search({
query,
ai_search_options: {
instance_ids: [GENERAL_INSTANCE, tenantInstance(tenantId)],
},
});
// 使用 instance_id 标记将共享结果与租户结果区分开来。
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,
})),
// 如果某个实例失败,另一个实例仍会返回。失败会落在此处。
errors: results.errors,
});
},
} satisfies ExportedHandler<Env>;
// 创建带有内置存储的实例并填充一份文档种子。每次请求调用都是安全的:如果实例已存在则 create() 会抛出异常,因此 try/catch 会在就位后跳过设置。
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 {
// 实例已存在且已被填充种子数据。
}
}env.AI_SEARCH.search() 是命名空间级别的搜索。它不同于 env.AI_SEARCH.get(id).search(),后者仅搜索单个实例。传递 instance_ids 会将查询扇出到这些实例上,并返回一个合并且排序的块列表。因为每个块都包含 instance_id,你始终知道结果来自共享实例还是租户实例。
如果某个实例失败(例如因为 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 数组会列出失败原因,同时其他实例的结果仍然返回。
要返回一个基于两个实例的单篇撰写的回答而不是原始 chunks,请使用带有相同 instance_ids 的 chatCompletions。它会从列出的每个实例中进行检索,然后从组合的上下文生成一个响应:
const completion = await env.AI_SEARCH.chatCompletions({
query,
ai_search_options: {
instance_ids: [GENERAL_INSTANCE, tenantInstance(tenantId)],
},
});
// 生成的回答建立在共享内容与租户内容之上。
const answer = completion.choices[0]?.message.content;响应还包含检索到的 chunks(每个都用其 instance_id 进行标记),以便你可以引用来源,以及针对任何失败实例的 errors 数组。
登录你的 Cloudflare 账户,然后部署你的 Worker:
npx wrangler login
npx wrangler deploy