简介
在 V03 中,你将问题转换为嵌入向量,并检索相近的帮助文章。不过,真实的支持系统通常服务多个客户。语义相似度本身绝不能决定调用者可以查看哪个客户的文档。
本实验会增加两层搜索边界。Vectorize 命名空间是单个索引中的分区;在一个命名空间中搜索时,相似度排序开始前就会排除其他所有命名空间中的向量。然后,元数据过滤器会根据 category 等字段,进一步缩小该客户的分区。你可以把命名空间理解为选择正确的文件柜,把类别过滤器理解为选择文件柜中的某个抽屉。
这两种机制都不会验证用户身份。应用必须先验证登录信息、令牌或其他身份信号,再由服务器确定命名空间。为了让练习安全且结果可重复,本 Worker 使用两个公开的合成会话标签,模拟已经验证过的会话。它们只是教学用测试装置,不是真实凭据,也不是完整的身份验证系统。请求永远不能自行选择客户或命名空间。
你将部署一个临时 Worker,并为它配置 Workers AI 和 Vectorize 绑定。四篇合成文章会在两个客户命名空间中故意包含相同的密码文本。实时嵌入让搜索更接近真实场景;精确的命名空间、类别和 ID 检查则可以证明隔离效果,而不需要根据模型的精确分数进行评分。你还会测试一个经过授权的空结果,并在请求调用任一云服务之前拒绝一次试图覆盖搜索范围的请求。
如果你是直接进入本课程的,请先完成将 LabEx 连接到 Cloudflare 账户。V01–V03 也是前置实验:它们介绍兼容的索引、异步变更和语义检索。
这个小型索引和有界的 BGE Small 请求符合文档中 Workers Free 计划的配额;不需要 Workers Paid。无论是本地调用还是部署后的模型调用,都会消耗账户共享的 Workers AI 每日配额。如果该配额不可用,请停止操作,不要反复重试。
安装过程会在 /home/labex/project/scoped-vector-search 中安装 Node.js 22.22.0 和项目本地的 Wrangler 4.132.0。它会提供确定性测试和独立的只读检查,但不会授权 Wrangler、创建索引、部署 Worker、运行推理或向云端写入数据。
授权并命名限定搜索资源
在本步骤中,你将为新 VM 授权,并在普通的 Wrangler 配置中描述一个 Worker 及其配套的 Vectorize 索引。
进入已准备好的项目,并确认固定版本的 CLI:
cd /home/labex/project/scoped-vector-search
npx wrangler --version
预期输出为 4.132.0。设备授权可以让 VM 获得临时 OAuth 授权,而不需要向 VM 提供你的 Cloudflare 密码。请求的权限包括账户身份、临时索引、Worker 部署,以及用于生成嵌入向量的 Workers AI 绑定:
npx wrangler login --device --browser=false --scopes account:read user:read workers:write workers_scripts:write workers_kv:write ai:write
npx wrangler whoami --json
在浏览器中打开显示的链接,输入当前代码,并批准用于学习的账户。回到终端后,确认 loggedIn: true、authType: OAuth Token 和账户名称,然后再复制账户 ID。
生成一个随机后缀,再根据 Worker 名称生成索引名称。这样的归属命名方式可以让后续清理更加精确:
RUN="labex-c08-v04-$(openssl rand -hex 6)"
INDEX="$RUN-docs"
printf 'Worker: %s\nIndex: %s\n' "$RUN" "$INDEX"
将 YOUR_ACCOUNT_ID 替换为 whoami 显示的 ID。绑定会为 Worker 代码提供一个访问 Cloudflare 服务的本地名称:AI 用于创建嵌入向量,DOCUMENTS 用于查询 index_name 指定的精确索引。
cat > wrangler.jsonc <<JSON
{
"\$schema": "./node_modules/wrangler/config-schema.json",
"name": "$RUN",
"account_id": "YOUR_ACCOUNT_ID",
"main": "src/index.js",
"compatibility_date": "2026-09-16",
"compatibility_flags": ["nodejs_compat"],
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true },
"ai": { "binding": "AI", "remote": true },
"vectorize": [
{ "binding": "DOCUMENTS", "index_name": "$INDEX", "remote": true }
]
}
JSON
该文件只会声明目标资源,目前还不会创建任何内容。在共享学习账户中,先明确身份和资源归属,再执行写入操作尤其重要。
构建服务器限定范围的搜索 Worker
在本步骤中,你将实现搜索边界,然后再部署它。
x-lab-session 请求头只使用两个公开标签,用来模拟前置身份验证层的结果。resolveSession 会将已经验证的上下文映射到服务器端的命名空间。请求正文可以选择查询内容和允许的类别,但不能指定客户或命名空间。在生产应用中,应将这些标签替换为经过正确验证的会话或身份提供商;命名空间用于组织数据,不负责身份验证。
四篇文档中,蓝色客户和绿色客户的文档包含完全相同的密码文本。这样可以清楚地观察安全结果:相似度无法区分这些副本,只有由服务器控制的搜索范围才能将它们隔离。
cat > src/index.js <<'JS'
const MODEL = "@cf/baai/bge-small-en-v1.5";
const POOLING = "cls";
const DIMENSIONS = 384;
const ALLOWED_CATEGORIES = new Set(["account", "billing", "files"]);
const SESSION_CONTEXTS = Object.freeze({
"blue-session": Object.freeze({ customer: "blue", namespace: "customer-blue" }),
"green-session": Object.freeze({ customer: "green", namespace: "customer-green" })
});
const DOCUMENTS = [
{
id: "blue-password",
namespace: "customer-blue",
category: "account",
title: "Reset a password",
text: "Reset an expired or forgotten password to regain access to your account."
},
{
id: "blue-invoice",
namespace: "customer-blue",
category: "billing",
title: "Download an invoice",
text: "Download an invoice or receipt for a completed payment."
},
{
id: "green-password",
namespace: "customer-green",
category: "account",
title: "Reset a password",
text: "Reset an expired or forgotten password to regain access to your account."
},
{
id: "green-upload",
namespace: "customer-green",
category: "files",
title: "Upload a PDF",
text: "Upload a PDF document and troubleshoot file size or format errors."
}
];
function json(value, status = 200) {
return Response.json(value, { status, headers: { "cache-control": "no-store" } });
}
export function resolveSession(label) {
const context = SESSION_CONTEXTS[label];
if (!context) throw new Error("session_invalid");
return context;
}
export function parseSearchInput(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("invalid_json");
for (const key of ["customer", "customerId", "namespace"]) {
if (Object.prototype.hasOwnProperty.call(value, key)) throw new Error("scope_override_not_allowed");
}
const query = typeof value.query === "string" ? value.query.trim() : "";
const category = typeof value.category === "string" ? value.category.trim() : "";
if (!query || query.length > 200) throw new Error("query_required");
if (!ALLOWED_CATEGORIES.has(category)) throw new Error("category_invalid");
return { query, category };
}
export function validateEmbeddingBatch(result, expectedCount) {
const vectors = result?.data;
if (!Array.isArray(vectors) || vectors.length !== expectedCount || result?.shape?.[1] !== DIMENSIONS) {
throw new Error("incompatible embedding batch");
}
for (const vector of vectors) {
if (!Array.isArray(vector) || vector.length !== DIMENSIONS || !vector.every(Number.isFinite)) {
throw new Error("invalid embedding vector");
}
}
return vectors;
}
async function embed(env, texts) {
const result = await env.AI.run(MODEL, { text: texts, pooling: POOLING });
return validateEmbeddingBatch(result, texts.length);
}
async function seed(env) {
const vectors = await embed(env, DOCUMENTS.map((document) => document.text));
const records = DOCUMENTS.map((document, index) => ({
id: document.id,
namespace: document.namespace,
values: vectors[index],
metadata: {
category: document.category,
title: document.title,
model: MODEL,
pooling: POOLING
}
}));
const mutation = await env.DOCUMENTS.upsert(records);
console.log(JSON.stringify({ event: "scoped_documents_seeded", count: records.length, mutationId: mutation.mutationId }));
return json({ mutationId: mutation.mutationId, count: records.length, model: MODEL, dimensions: DIMENSIONS, pooling: POOLING }, 202);
}
async function search(request, env) {
let context;
try {
context = resolveSession(request.headers.get("x-lab-session") ?? "");
} catch (error) {
return json({ error: "session_invalid" }, 401);
}
let input;
try {
input = parseSearchInput(await request.json());
} catch (error) {
return json({ error: error instanceof Error ? error.message : "invalid_json" }, 400);
}
const [queryVector] = await embed(env, [input.query]);
const result = await env.DOCUMENTS.query(queryVector, {
topK: 3,
namespace: context.namespace,
filter: { category: input.category },
returnMetadata: "all"
});
const matches = result.matches.map((match) => ({
id: match.id,
score: match.score,
namespace: match.namespace,
title: match.metadata?.title,
category: match.metadata?.category
}));
console.log(JSON.stringify({
event: "scoped_search",
customer: context.customer,
namespace: context.namespace,
category: input.category,
returnedCount: matches.length
}));
return json({
customer: context.customer,
namespace: context.namespace,
category: input.category,
candidateCount: result.matches.length,
matches
});
}
export default {
async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "POST" && url.pathname === "/seed") return seed(env);
if (request.method === "POST" && url.pathname === "/search") return search(request, env);
return json({ error: "not_found" }, 404);
}
};
JS
运行确定性测试。测试使用内存绑定,证明 Worker 会根据服务器端上下文构造两个搜索边界,同时不会消耗云端配额:
node --test test/worker.test.mjs
预期通过 6 个测试。根据实际配置生成绑定类型,然后在不部署的情况下打包:
npx wrangler types
npx wrangler deploy --dry-run --outdir /tmp/v04-dry-run
生成的文件应包含 AI: Ai 和 DOCUMENTS: VectorizeIndex。试运行证明源代码和配置可以一起打包,但不会创建或测试任一云端资源。
创建可过滤索引并部署
在本步骤中,你将创建兼容的索引,为 category 字段准备过滤能力,并在该准备工作处理完成后再部署 Worker。
向量可以存储元数据,但不代表这些元数据已经可搜索。元数据索引会告诉 Vectorize 要为哪些字段组织可优先过滤的查询。它必须在插入文档向量之前存在,否则之前写入的记录不会参与该元数据过滤。
创建一个与 BGE Small 匹配的 384 维余弦索引。--update-config=false 可以防止 Wrangler 重写你已经检查过的显式绑定:
npx wrangler vectorize create "$INDEX" --dimensions=384 --metric=cosine --update-config=false
现在为字符串字段 category 排队准备工作,并保存其变更 ID:
set -o pipefail
npx wrangler vectorize create-metadata-index "$INDEX" \
--propertyName=category \
--type=string 2>&1 | tee .labex/category-index-output.txt
META_MUTATION=$(grep -Eo '[0-9a-fA-F]{8}-[0-9a-fA-F-]{27}' .labex/category-index-output.txt | tail -n 1)
if [ -z "$META_MUTATION" ]; then
printf '%s\n' 'No metadata mutation ID was returned; fix the command before continuing.' >&2
else
printf '%s\n' "$META_MUTATION" | tee .labex/category-mutation.txt
fi
已接受的变更只是排队等待处理,并不代表处理已经完成。编写一个有界的只读等待脚本,后续在写入种子数据后还会复用它。脚本要求连续 3 次读取到相同的变更和向量数量,避免短暂的过期读取成为本实验的最终证据:
cat > scripts/wait-for-vectorize.mjs <<'JS'
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";
const [indexName, mutationFile, expectedText] = process.argv.slice(2);
const mutationId = readFileSync(mutationFile, "utf8").trim();
const expectedCount = Number(expectedText);
if (!/^[0-9a-f-]{36}$/i.test(mutationId)) throw new Error("mutation file has no UUID");
if (!Number.isInteger(expectedCount) || expectedCount < 0) throw new Error("expected count is invalid");
const wrangler = "./node_modules/wrangler/bin/wrangler.js";
let consecutiveMatches = 0;
for (let attempt = 1; attempt <= 120; attempt += 1) {
const output = execFileSync(process.execPath, [wrangler, "vectorize", "info", indexName, "--json"], { encoding: "utf8" });
const info = JSON.parse(output);
if (String(info.processedUpToMutation) === mutationId && info.vectorCount === expectedCount) consecutiveMatches += 1;
else consecutiveMatches = 0;
if (consecutiveMatches === 3) {
console.log("mutation " + mutationId + " is consistently readable with " + expectedCount + " vectors");
console.log(JSON.stringify(info, null, 2));
process.exit(0);
}
await new Promise((resolve) => setTimeout(resolve, 2000));
}
throw new Error("mutation " + mutationId + " was not stable within four minutes");
JS
node scripts/wait-for-vectorize.mjs "$INDEX" .labex/category-mutation.txt 0
变更可能已经处理完成,但单独的列表视图还没有同步。使用有界的只读循环,等待列表中出现可见的 category 行,不要因为一次过期的列表响应就判断失败:
for attempt in {1..15}; do
METADATA_INDEXES=$(npx wrangler vectorize list-metadata-index "$INDEX" 2>&1)
if grep -Eq 'category.*String' <<<"$METADATA_INDEXES"; then
break
fi
sleep 2
done
printf '%s\n' "$METADATA_INDEXES"
grep -Eq 'category.*String' <<<"$METADATA_INDEXES" || {
printf '%s\n' 'The category metadata index is processed but not yet visible; rerun this read-only check.' >&2
exit 1
}
预期看到类型为 String 的 category。最后,部署 DOCUMENTS 绑定指向这个已准备好的索引的 Worker:
set -o pipefail
npx wrangler deploy 2>&1 | tee .labex/deploy-output.txt
DEPLOY_URL=$(sed -nE 's#.*(https://[^[:space:]]+\.workers\.dev).*#\1#p' .labex/deploy-output.txt | tail -n 1)
if [ -z "$DEPLOY_URL" ]; then
printf '%s\n' 'No workers.dev URL was returned; fix deployment before continuing.' >&2
else
printf '%s\n' "$DEPLOY_URL" | tee .labex/deploy-url.txt
fi
此时索引仍然为空。部署只会连接绑定,不会自动创建文档嵌入向量。
写入两个客户命名空间
在本步骤中,你将创建实时嵌入向量,并将每条记录存储在且仅存储在一个客户命名空间中,同时写入类别元数据。
命名空间属于向量记录本身。两条密码记录的文本完全相同,但处于不同的分区中。category 是独立的元数据,因此一条记录可以同时属于蓝色命名空间和 account 类别。
调用固定的种子数据端点一次。语料库由服务器控制,因此请求正文为空:
DEPLOY_URL=$(cat .labex/deploy-url.txt)
curl --fail-with-body --silent --show-error \
-X POST "$DEPLOY_URL/seed" \
-H 'content-type: application/json' \
--data '{}' | tee .labex/seed-response.json
node -e '
const value = JSON.parse(require("fs").readFileSync(".labex/seed-response.json", "utf8"));
if (!/^[0-9a-f-]{36}$/i.test(value.mutationId)) throw new Error("seed mutation is missing");
require("fs").writeFileSync(".labex/seed-mutation.txt", value.mutationId + "\n");
console.log("accepted " + value.count + " scoped vectors in mutation " + value.mutationId);
'
预期看到 count: 4、384 维、cls 池化方式以及一个变更 UUID。等待该确切的变更和数量,不要猜测服务需要多少秒:
node scripts/wait-for-vectorize.mjs "$INDEX" .labex/seed-mutation.txt 4
for attempt in {1..15}; do
VECTOR_LIST=$(npx wrangler vectorize list-vectors "$INDEX" --count=10 2>&1)
if grep -q 'blue-password' <<<"$VECTOR_LIST" &&
grep -q 'blue-invoice' <<<"$VECTOR_LIST" &&
grep -q 'green-password' <<<"$VECTOR_LIST" &&
grep -q 'green-upload' <<<"$VECTOR_LIST"; then
break
fi
sleep 2
done
printf '%s\n' "$VECTOR_LIST"
for id in blue-password blue-invoice green-password green-upload; do
grep -q "$id" <<<"$VECTOR_LIST" || {
printf 'The processed vector %s is not visible in the list yet; rerun this read-only check.\n' "$id" >&2
exit 1
}
done
向量清单应包含 blue-password、blue-invoice、green-password 和 green-upload。ID 可以将匹配结果关联回源文档;命名空间和类别决定一个本来相似的记录是否有资格参与查询。
验证客户和类别隔离
在本步骤中,你将让两个客户执行相同的语义问题,然后测试一个经过授权的空结果和一次不安全的覆盖操作。
先使用蓝色合成会话和 account 类别:
curl --fail-with-body --silent --show-error \
-X POST "$DEPLOY_URL/search" \
-H 'content-type: application/json' \
-H 'x-lab-session: blue-session' \
--data '{"query":"My password expired","category":"account"}' \
| tee .labex/blue-account.json
响应应报告 customer: blue、namespace: customer-blue,并且只包含 blue-password。现在使用绿色会话发送完全相同的问题:
curl --fail-with-body --silent --show-error \
-X POST "$DEPLOY_URL/search" \
-H 'content-type: application/json' \
-H 'x-lab-session: green-session' \
--data '{"query":"My password expired","category":"account"}' \
| tee .labex/green-account.json
这次只有 green-password 符合条件。两篇文档的文本完全相同,因此差异来自经过验证的会话所选定的命名空间,而不是嵌入模型或偶然的分数。
接下来,让蓝色会话查询 files 类别。绿色客户中存在高度相关的上传文章,但蓝色命名空间中没有文件文章:
curl --fail-with-body --silent --show-error \
-X POST "$DEPLOY_URL/search" \
-H 'content-type: application/json' \
-H 'x-lab-session: blue-session' \
--data '{"query":"Upload a PDF","category":"files"}' \
| tee .labex/blue-files-empty.json
预期看到 candidateCount: 0 和 matches: []。空结果是正确的授权结果;从其他命名空间借用相关记录会造成数据泄露。
最后,尝试在请求正文中覆盖命名空间:
curl --silent --show-error \
-o .labex/override-response.json \
-w 'HTTP %{http_code}\n' \
-X POST "$DEPLOY_URL/search" \
-H 'content-type: application/json' \
-H 'x-lab-session: blue-session' \
--data '{"query":"Upload a PDF","category":"files","namespace":"customer-green"}'
cat .labex/override-response.json
预期 HTTP 状态码为 400,并看到 scope_override_not_allowed。Worker 会在生成嵌入向量或执行 Vectorize 查询之前拒绝该字段。客户端可以请求允许的类别,但只有可信的服务器逻辑才能将身份映射到客户命名空间。
打开 Workers & Pages → 你的 labex-c08-v04-... Worker → Bindings。确认 AI 指向 Workers AI,DOCUMENTS 指向准确的临时 Vectorize 索引。这个可视化关系说明代码中的 env.AI 和 env.DOCUMENTS 如何访问托管服务;独立检查仍然会验证绑定的精确身份。

然后打开 AI → Vectorize → 对应的 -docs 索引。当前向量数量最终应变为 4,查询指标也应开始反映限定范围的搜索。仪表板计数器可能会延迟;经过身份验证的记录读取和 HTTP 响应仍然是精确 ID、命名空间和类别的权威依据。

如果可以使用 Workers Logs,请打开 Worker 的 Observability → Logs 视图,并检查一条 scoped_search 记录。它只记录合成客户标签、命名空间、类别和返回数量,不记录问题文本或会话标签。结构化且限制隐私范围的日志有助于诊断执行了哪个服务器选定的搜索范围,同时不会复制敏感请求内容。

这些截图是某次临时测试运行中的示例。你的随机资源名称、时间戳、延迟和查询总数会有所不同;请比较绑定名称、当前向量数量和字段关系,不要照抄示例值。
删除限定搜索资源
在本步骤中,你将删除临时 Worker 和索引,并在 Wrangler 仍处于授权状态时证明它们已经不存在。
从 wrangler.jsonc 中恢复准确名称,这样清理过程不依赖之前终端会话中的变量:
RUN=$(node -p 'JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).name')
INDEX=$(node -p 'JSON.parse(require("fs").readFileSync("wrangler.jsonc", "utf8")).vectorize.find((item) => item.binding === "DOCUMENTS").index_name')
printf 'Worker: %s\nIndex: %s\n' "$RUN" "$INDEX"
确认两个值都以唯一的 labex-c08-v04-... 前缀开头。先删除 Worker,确保没有已部署的代码继续保留该绑定;然后只删除与它配套的索引:
npx wrangler delete --name "$RUN" --force
npx wrangler vectorize delete "$INDEX" --force
保存一次成功的已授权资源清单,并检查准确的名称:
npx wrangler vectorize list --json > .labex/indexes-after-cleanup.json
node -e '
const rows = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
if (rows.some((row) => row.name === process.argv[2])) throw new Error("lab index still exists");
console.log("lab index is absent");
' .labex/indexes-after-cleanup.json "$INDEX"
在退出登录前完成本步骤。网络或授权失败不能证明资源已经删除;评估会独立要求成功读取账户信息,并确认准确名称不存在。
退出学习 VM 的登录状态
在本步骤中,你将删除此 VM 的临时 Wrangler 授权。云端资源已经不存在,并且已通过授权清理检查:
npx wrangler logout
npx wrangler whoami --json
预期看到 loggedIn: false。Cloudflare Dashboard 的浏览器会话是独立的,仍然可以供你的学习账户使用。
总结
你为语义检索增加了两个相互独立的资格检查。经过验证的合成会话在服务器端选择一个客户命名空间,经过索引的 category 字段则在 Vectorize 对结果排序之前缩小该分区。相同的密码文档证明了相似度本身无法实现客户隔离;蓝色客户的文件查询则说明,返回经过授权的空结果比从其他客户借用相关记录更安全。
你还在推理之前拒绝了客户端提供的客户和命名空间覆盖,检查了 Dashboard 中真实的绑定、索引和隐私范围受限的日志关系,在保持授权的情况下删除了临时 Worker 和索引,最后退出登录。V05 将复用这个安全的检索边界,在语言模型生成回答前组装有界的源证据。



