介绍
关键词搜索会查找相同的词语。语义搜索则尝试查找含义相同的文本。例如,「我无法登录」应该与一篇介绍重置密码的文章接近,即使这两句话并不包含完全相同的词语。
嵌入模型会将文本转换为一个向量:这是一个由数字按顺序组成的列表,用于表示模型从语言中学习到的特征。含义相关的文本通常会指向相似的方向。本实验使用余弦相似度比较这些方向。余弦相似度会为方向更接近的向量返回更大的分数。只有在向量由相同的模型、维度数量和池化方式生成时,这个分数才适合用于比较;它不是表示真实性的通用百分比。
你将构建 POST /search。Worker 会使用 Cloudflare 托管的 @cf/baai/bge-small-en-v1.5,将一个查询与三篇简短的帮助文章一起生成嵌入。该模型会为每段文本生成 384 个数字。应用会在比较向量前验证每个向量,拒绝不兼容的值和非有限值,并返回按排名排列的文章 ID,而不会暴露向量本身。
这是本课程的第四个实验。如果你是直接进入本实验的,请先完成将 LabEx 连接到你的 Cloudflare 账户,以便了解如何使用 VM 终端、授权 Wrangler、确认学习账户并配置账户 ID。
Workers Free 账户目前每天共享 10,000 Neurons 的配额。该模型每百万个输入令牌的成本约为 1,841 Neurons,本实验只使用几句简短的合成句子,因此在免费配额仍可用时不需要 Workers Paid。即使在本地推理,请求仍会发送到 Cloudflare,并消耗账户配额。如果模型或配额不可用,请停止操作,不要反复重试。
Setup 会在 /home/labex/project/search-embeddings 中安装 Node.js 22.22.0 和项目本地的 Wrangler 4.132.0,同时提供确定性测试和独立检查。Setup 不会授权 Wrangler、调用模型、部署 Worker 或创建云资源。
授权 VM 并配置嵌入 Worker
在本步骤中,你将授权这台全新的 VM,并配置一个临时 Worker。Dashboard 登录属于浏览器;VM 中的 Wrangler 仍需要单独进行有限授权。
进入准备好的项目,并确认固定的 CLI 版本:
cd /home/labex/project/search-embeddings
npx wrangler --version
预期输出为 4.132.0。请求之前 Workers AI 实验使用的最小权限。KV 权限用于支持 Wrangler 4.132.0 的清理依赖检查;本实验不会创建 KV 数据。
npx wrangler login --device --browser=false --scopes account:read user:read workers_scripts:write workers_kv:write ai:write
打开显示的链接,输入当前代码,检查账户和权限,然后授权你的学习账户。接着检查结构化的身份数据:
npx wrangler whoami --json
确认 loggedIn: true,然后生成唯一的 Worker 名称:
RUN="labex-c07-a04-$(openssl rand -hex 6)"
printf '%s\n' "$RUN"
将 YOUR_ACCOUNT_ID 替换为目标账户的实际 ID:
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",
"workers_dev": true,
"preview_urls": false,
"observability": { "enabled": true, "head_sampling_rate": 1 },
"ai": { "binding": "AI", "remote": true }
}
JSON
env.AI 是进程内绑定,不是写在源代码中的模型 API 密钥。remote: true 表示本地开发时仍会调用账户所关联的模型。
理解向量契约
在本步骤中,你将把模型配置与应用必须验证的数字对应起来。
生成环境类型,并确认平台绑定:
npx wrangler types
grep -A4 'interface __BaseEnv_Env' worker-configuration.d.ts
查找 AI: Ai。选定的 BGE Small 模型会为每段输入文本返回一个 384 维向量。维度表示位置数量,因此包含四段文本的批次形状应为 [4, 384]。每个位置都必须是有限数字,不能是 NaN、正无穷或负无穷。
本实验明确请求使用 cls 池化。池化用于将令牌级别的信息压缩为一个向量。即使 cls 和 mean 池化都生成包含 384 个位置的向量,它们也不兼容,因此应用会将池化方式与模型和维度一起记录。
检查提供的确定性测试夹具:
grep -nE 'incompatible|non-finite|cosine similarity' test/worker.test.mjs
这些测试夹具可以在不消耗 Neurons 的情况下重复执行失败测试。它们也不会断言线上相似度的精确分数,因为模型行为可能导致分数变化。
构建经过验证的相似度端点
在本步骤中,你将实现嵌入请求、向量验证和本地余弦比较。Worker 返回文档 ID 和分数,而不是四个向量中的 1,536 个原始数字。
创建入口文件:
cat > src/index.js <<'JS'
const MODEL = "@cf/baai/bge-small-en-v1.5";
const DIMENSIONS = 384;
const POOLING = "cls";
const MAX_QUERY = 300;
const DOCUMENTS = [
{ id: "password-reset", text: "Reset a forgotten password and regain account access." },
{ id: "upload-pdf", text: "Troubleshoot a PDF document that will not upload." },
{ id: "billing-receipt", text: "Download a receipt for a completed payment." }
];
export function validateEmbeddingBatch(result, expectedCount) {
if (!Array.isArray(result?.shape) || result.shape[0] !== expectedCount || result.shape[1] !== DIMENSIONS) {
throw new Error("incompatible embedding shape");
}
if (!Array.isArray(result.data) || result.data.length !== expectedCount) {
throw new Error("incompatible embedding count");
}
for (const vector of result.data) {
if (!Array.isArray(vector) || vector.length !== DIMENSIONS || !vector.every(Number.isFinite)) {
throw new Error("invalid embedding vector");
}
}
return result.data;
}
export function cosineSimilarity(left, right) {
if (left.length !== right.length || left.length === 0) throw new Error("incompatible vectors");
let dot = 0, leftNorm = 0, rightNorm = 0;
for (let index = 0; index < left.length; index += 1) {
dot += left[index] * right[index];
leftNorm += left[index] ** 2;
rightNorm += right[index] ** 2;
}
if (leftNorm === 0 || rightNorm === 0) throw new Error("zero-length direction");
return dot / (Math.sqrt(leftNorm) * Math.sqrt(rightNorm));
}
function json(data, status = 200) { return Response.json(data, { status }); }
async function readQuery(request) {
if (!(request.headers.get("content-type") || "").toLowerCase().includes("application/json")) {
return { error: json({ error: "json_required" }, 415) };
}
let body;
try { body = await request.json(); } catch { return { error: json({ error: "invalid_json" }, 400) }; }
const query = typeof body?.query === "string" ? body.query.trim() : "";
if (!query) return { error: json({ error: "invalid_query" }, 400) };
if (query.length > MAX_QUERY) return { error: json({ error: "query_too_large" }, 413) };
return { query };
}
async function search(request, env) {
const parsed = await readQuery(request);
if (parsed.error) return parsed.error;
const requestId = crypto.randomUUID();
let result;
try {
result = await env.AI.run(MODEL, { text: [parsed.query, ...DOCUMENTS.map((item) => item.text)], pooling: POOLING });
} catch {
console.error(JSON.stringify({ event: "embedding_failed", requestId, model: MODEL }));
return json({ error: "model_unavailable", requestId }, 502);
}
let vectors;
try { vectors = validateEmbeddingBatch(result, DOCUMENTS.length + 1); }
catch {
console.error(JSON.stringify({ event: "embedding_rejected", requestId, model: MODEL }));
return json({ error: "invalid_embeddings", requestId }, 502);
}
const [queryVector, ...documentVectors] = vectors;
const matches = DOCUMENTS.map((document, index) => ({ id: document.id, score: cosineSimilarity(queryVector, documentVectors[index]) }))
.sort((left, right) => right.score - left.score);
console.log(JSON.stringify({ event: "embedding_compared", requestId, model: MODEL, dimensions: DIMENSIONS, count: vectors.length, pooling: POOLING }));
return json({ model: MODEL, dimensions: DIMENSIONS, pooling: POOLING, matches, requestId });
}
export default { async fetch(request, env) {
const url = new URL(request.url);
if (request.method === "GET" && url.pathname === "/health") return json({ status: "ok" });
if (request.method === "POST" && url.pathname === "/search") return search(request, env);
return json({ error: "not_found" }, 404);
} };
JS
验证会在计算相似度之前执行。它可以避免静默截断、无意义的跨维度比较以及 NaN 分数。日志会保留生命周期元数据,但不会记录查询、文章文本和向量。
运行五个确定性测试,然后在不部署的情况下打包:
node --test test/worker.test.mjs
npx wrangler deploy --dry-run
这些测试可以证明本地数学计算和拒绝行为正确。试运行可以证明 Worker 与绑定配置能够一起完成打包。
执行一次真实的嵌入批次
在本步骤中,你将让 AI 绑定执行一次真实的远程嵌入请求,并在本地运行处理程序。
在后台启动 Wrangler,并等待不使用 AI 的健康检查路由:
npx wrangler dev --port 8787 > .labex/dev.log 2>&1 &
echo $! > .labex/dev.pid
for attempt in $(seq 1 30); do
if curl --silent --fail http://127.0.0.1:8787/health; then break; fi
sleep 1
done
发送一条简短的合成查询:
curl --silent --show-error http://127.0.0.1:8787/search \
--header 'Content-Type: application/json' \
--data '{"query":"I cannot sign in because I forgot my password"}'
预期结果包含 model、dimensions: 384、pooling: "cls"、三个按排名排列的 ID,以及有限的分数。不要要求精确的分数。这个排序只反映当前查询的结果,不是模型永久保证的排序。
在推理前拒绝空查询:
curl --silent --show-error --write-out '\nHTTP %{http_code}\n' http://127.0.0.1:8787/search \
--header 'Content-Type: application/json' --data '{"query":""}'
预期结果为 {"error":"invalid_query"},HTTP 状态码为 400。
部署并检查嵌入证据
在本步骤中,你将部署同一个端点,并把运行时证据与 Cloudflare Dashboard 关联起来。
只停止已保存的开发进程,然后进行部署:
kill "$(cat .labex/dev.pid)"
wait "$(cat .labex/dev.pid)" 2>/dev/null || true
npx wrangler deploy
保存 Wrangler 输出的准确 URL,并发送一条公开查询:
WORKER_URL="https://YOUR_WORKER_URL"
curl --silent --show-error "$WORKER_URL/search" \
--header 'Content-Type: application/json' \
--data '{"query":"I cannot sign in because I forgot my password"}'
确认响应记录了模型、384 个维度和 cls 池化,并且按排名返回恰好三个指定的 ID,分数均为有限值。
打开 Workers & Pages → Overview → 你的 labex-c07-a04-... Worker。在 Bindings 中检查 AI 绑定。在 Observability → Logs 中搜索 embedding_compared 并展开该事件。确认其中包含准确的模型、dimensions: 384、count: 4、pooling: cls 和请求 ID。查询、文档和向量都不应出现。
Bindings 页面会显示这条连接:Worker 有一个名为 AI 的 Workers AI 绑定。绑定是代码通过 env.AI 使用的安全句柄;你不需要将 API 密钥粘贴到源文件中。

Observability 概览会显示本次临时运行中成功的 /search 请求且没有错误。由于每个测试请求都会生成一个事件,你看到的具体总数可能不同。

展开一个 embedding_compared 事件。这个聚焦的示例只记录有用的运行信息:比较了四段文本,每个向量有 384 个维度,使用了 cls 池化,模型为 @cf/baai/bge-small-en-v1.5。它不会记录学习者的查询、文档文本或数百个向量数字。

然后打开 Workers AI。在当天的用量中找到 BGE Small 模型,并确认这次受控运行仍处于共享的 10,000-Neuron Free 配额之内。Dashboard 数据可能会延迟;请稍等,不要反复执行推理来强制更新图表。
在测试使用的 Free 账户中,嵌入模型只使用了 0.29 Neurons,而总用量为 295.6 / 10k。较大的总用量还包括同一天完成的其他课程测试,因此这些数字只是示例,不是必须达到的结果。重要检查点是:BGE Small 行出现,并且你的每日总用量低于 Free 配额。

Dashboard 图表可以作为有帮助的可视化检查点,但 JSON 响应和独立验证脚本仍然是判断已部署 Worker 是否正常运行的权威证据。
删除 Worker 并退出登录
在本步骤中,你将删除临时端点,然后移除这台 VM 的授权。Workers AI 用量属于账户历史记录,因此删除 Worker 不会删除用量记录。
删除 wrangler.jsonc 中指定的 Worker:
npx wrangler delete
只有在 Wrangler 显示本实验唯一的 labex-c07-a04-... 名称时才确认删除。要求输出 Successfully deleted,然后在仍保持授权的情况下运行独立的云端不存在检查:
python3 .labex/verify.py deleted
只有在该命令报告 PASS: deleted 后,才退出登录并检查结构化状态:
npx wrangler logout
npx wrangler whoami --json
要求输出 loggedIn: false。关闭浏览器标签页或本地文件缺失,都不能证明云端清理已完成。
总结
你使用 Cloudflare 托管的模型生成了 384 维嵌入,记录了兼容性选择,验证了每个向量,使用余弦相似度比较语义方向,并在排序前拒绝不兼容的数据。你还验证了线上绑定和受隐私限制的日志,最后删除了临时 Worker 和 VM 授权。



