生成搜索嵌入

ShellBeginner
立即练习

介绍

关键词搜索会查找相同的词语。语义搜索则尝试查找含义相同的文本。例如,「我无法登录」应该与一篇介绍重置密码的文章接近,即使这两句话并不包含完全相同的词语。

嵌入模型会将文本转换为一个向量:这是一个由数字按顺序组成的列表,用于表示模型从语言中学习到的特征。含义相关的文本通常会指向相似的方向。本实验使用余弦相似度比较这些方向。余弦相似度会为方向更接近的向量返回更大的分数。只有在向量由相同的模型、维度数量和池化方式生成时,这个分数才适合用于比较;它不是表示真实性的通用百分比。

你将构建 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 池化。池化用于将令牌级别的信息压缩为一个向量。即使 clsmean 池化都生成包含 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"}'

预期结果包含 modeldimensions: 384pooling: "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: 384count: 4pooling: cls 和请求 ID。查询、文档和向量都不应出现。

Bindings 页面会显示这条连接:Worker 有一个名为 AI 的 Workers AI 绑定。绑定是代码通过 env.AI 使用的安全句柄;你不需要将 API 密钥粘贴到源文件中。

Worker Bindings 页面显示了一个名为 AI 的已连接 Workers AI 绑定

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

Worker Observability 页面显示成功的搜索请求和零个错误

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

展开的嵌入日志包含 count、dimensions、pooling 和 model 字段

然后打开 Workers AI。在当天的用量中找到 BGE Small 模型,并确认这次受控运行仍处于共享的 10,000-Neuron Free 配额之内。Dashboard 数据可能会延迟;请稍等,不要反复执行推理来强制更新图表。

在测试使用的 Free 账户中,嵌入模型只使用了 0.29 Neurons,而总用量为 295.6 / 10k。较大的总用量还包括同一天完成的其他课程测试,因此这些数字只是示例,不是必须达到的结果。重要检查点是:BGE Small 行出现,并且你的每日总用量低于 Free 配额。

Workers AI 用量显示 BGE Small 嵌入使用量处于每日免费配额之内

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 授权。