检索相似帮助文章

JavaScriptBeginner
立即练习

简介

V01 保存了已识别的向量,V02 让这些记录保持最新。本实验将补上缺失的读取路径:用户输入问题,应用程序将文本转换为兼容的向量,然后请求 Vectorize 找出存储文档中方向最接近的内容。

这就是语义搜索。它比较关注含义的嵌入向量,而不是要求查询内容重复文章中的确切词语。查询和存储文档必须使用相同的模型、384 个维度以及 cls 池化方式。Vectorize 的相似度分数可以帮助你对某个查询的兼容向量进行排序;它不是通用的置信度百分比,也不能证明某篇文章一定能回答该问题。

你将构建一个包含两个 Cloudflare 绑定的一次性 Worker。AI 会将短文本发送给 Cloudflare 托管的 @cf/baai/bge-small-en-v1.5 嵌入模型。DOCUMENTS 会写入并查询一个一次性的 Vectorize 索引。Worker 提供固定的 /seed 操作,用于处理三篇合成帮助文章;还提供 /search 操作,用于接收查询、topK 和可选的最小分数。topK 表示「最多返回这么多个最近的候选项」,并不表示「这些候选项一定相关」。

这是第三个 Vectorize 实验。如果你是直接进入本实验,请先完成 将 LabEx 连接到你的 Cloudflare 账户,然后完成 V01 和 V02,以熟悉索引兼容性、稳定 ID 和异步变更。

Vectorize 和 Workers AI 都提供 Free 配额。本实验只存储三个很小的向量,并且只发起少量短文本嵌入请求。不需要 Workers Paid。如果模型或 Free 配额不可用,请停止操作,不要反复重试,因为本地或已部署的推理都会消耗共享的 Workers AI 每日配额。

设置过程会在 /home/labex/project/vector-search 中安装 Node.js 22.22.0 和项目本地的 Wrangler 4.132.0。设置会提供确定性测试和独立检查,但不会授权 Wrangler、调用模型、创建索引、部署 Worker 或向云端写入数据。

授权并命名搜索资源

本步骤将授权新的 VM,并创建一个配置,用于命名 Worker 及其配套的 Vectorize 索引。

进入准备好的项目目录,并确认固定版本的 CLI:

cd /home/labex/project/vector-search
npx wrangler --version

预期输出为 4.132.0。Wrangler 使用设备授权流程,因此你的密码不会输入到 VM 中。为这次一次性实验请求账户身份、Worker、Vectorize 和 Workers AI 的访问权限:

Wrangler 会将索引操作与脚本部署和清理检查分开。为 Vectorize 请求 workers:write,为 Worker 请求 workers_scripts:write,为 Wrangler 的依赖安全删除检查请求 workers_kv:write,并为模型绑定请求 ai:write

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,并且账户是本次学习使用的账户。生成一个随机后缀,然后根据它生成两个资源名称,避免清理时与其他无关资源混淆:

RUN="labex-c08-v03-$(openssl rand -hex 6)"
INDEX="$RUN-docs"
printf 'Worker: %s\nIndex:  %s\n' "$RUN" "$INDEX"

YOUR_ACCOUNT_ID 替换为所选账户的实际 ID。绑定是 Worker 代码用来接收 Cloudflare 托管服务的名称。AI 将提供模型推理,DOCUMENTS 将提供由 index_name 指定的确切 Vectorize 索引。

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 },
  "ai": { "binding": "AI", "remote": true },
  "vectorize": [
    { "binding": "DOCUMENTS", "index_name": "$INDEX", "remote": true }
  ]
}
JSON

该配置只会命名资源,不会创建资源。这样,在账户发生任何变化前,你可以先检查预期的资源归属边界。

构建绑定搜索 Worker

本步骤将在部署代码前,实现固定的文档写入操作和面向学习者的搜索端点。

这三篇源文档保存在应用程序代码中,因为 Vectorize 存储的是向量和元数据,而不是完整的文章主数据。/seed 会对这组固定文档执行一次嵌入。/search 会对经过验证的查询执行嵌入,请求 Vectorize 返回最近的 topK 个候选项,然后应用 minScore 进行筛选。返回的元数据可以帮助应用程序将向量 ID 转换为有用的引用信息。

cat > src/index.js <<'JS'
const MODEL = "@cf/baai/bge-small-en-v1.5";
const POOLING = "cls";
const DIMENSIONS = 384;

const DOCUMENTS = [
  {
    id: "password-reset",
    category: "account",
    title: "Reset an expired password",
    text: "Reset an expired or forgotten password to regain access to your account."
  },
  {
    id: "upload-pdf",
    category: "files",
    title: "Upload a PDF",
    text: "Upload a PDF document and troubleshoot file size or format errors."
  },
  {
    id: "billing-receipt",
    category: "billing",
    title: "Download a billing receipt",
    text: "Download a receipt for a completed invoice or payment."
  }
];

function json(value, status = 200) {
  return Response.json(value, { status, headers: { "cache-control": "no-store" } });
}

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;
}

export function parseSearchInput(value) {
  const query = typeof value?.query === "string" ? value.query.trim() : "";
  const topK = value?.topK === undefined ? 3 : value.topK;
  const minScore = value?.minScore === undefined ? 0 : value.minScore;
  if (!query || query.length > 200) throw new Error("query_required");
  if (!Number.isInteger(topK) || topK < 1 || topK > 3) throw new Error("topk_invalid");
  if (typeof minScore !== "number" || !Number.isFinite(minScore) || minScore < 0 || minScore > 1) throw new Error("minscore_invalid");
  return { query, topK, minScore };
}

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,
    values: vectors[index],
    metadata: {
      category: document.category,
      published: true,
      title: document.title,
      model: MODEL,
      pooling: POOLING
    }
  }));
  const mutation = await env.DOCUMENTS.upsert(records);
  console.log(JSON.stringify({ event: "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 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: input.topK, returnMetadata: "all" });
  const matches = result.matches
    .filter((match) => Number.isFinite(match.score) && match.score >= input.minScore)
    .map((match) => ({
      id: match.id,
      score: match.score,
      title: match.metadata?.title,
      category: match.metadata?.category
    }));
  console.log(JSON.stringify({ event: "documents_retrieved", candidateCount: result.matches.length, returnedCount: matches.length, topK: input.topK }));
  return json({ model: MODEL, dimensions: DIMENSIONS, pooling: POOLING, 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

运行确定性测试。测试会用小型内存 fixture 替换两个绑定,因此可以在不消耗 AI 或 Vectorize 配额的情况下检查验证逻辑和控制流程:

node --test test/worker.test.mjs

预期五个测试全部通过。然后生成绑定类型,并让 Wrangler 在不部署的情况下打包 Worker:

npx wrangler types
npx wrangler deploy --dry-run --outdir /tmp/v03-dry-run

生成的类型文件应同时包含 AI: AiDOCUMENTS: VectorizeIndex。试运行可以证明模块和配置能够一起完成打包,但不能证明云端服务已经存在。

创建索引并部署两个绑定

本步骤将先创建一个兼容的空索引,然后部署接收两个托管绑定的 Worker。

嵌入模型会返回 384 个数字。余弦距离会比较向量的方向,因此请使用相同的不可变契约创建索引:

npx wrangler vectorize create "$INDEX" --dimensions=384 --metric=cosine --update-config=false

只有在索引存在后再部署 Worker,因为 Cloudflare 必须将配置中的 DOCUMENTS 绑定解析为真实资源:

set -o pipefail
npx wrangler deploy 2>&1 | tee .labex/deploy-output.txt

Wrangler 应列出两个绑定,并打印 workers.dev URL。保存准确的 URL,不要猜测子域名并手动拼接:

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 the deployment before continuing.' >&2
else
  printf '%s\n' "$DEPLOY_URL" | tee .labex/deploy-url.txt
fi

此时索引仍然是空的。部署只会连接服务,不会自动嵌入或写入文档。

写入实时文档嵌入

本步骤将调用一次固定的 /seed 操作,记录其变更,并等待三个模型嵌入全部可读取。

Worker 会将三篇简短文档的文本一次性发送给 BGE Small。Worker 会验证返回的数据形状,附加稳定 ID 和有用的元数据,然后写入这些记录。由于文档集合由服务器控制,因此使用空 JSON 对象调用该操作:

curl --fail-with-body --silent --show-error \
  -X POST "$DEPLOY_URL/seed" \
  -H 'content-type: application/json' \
  --data '{}' | tee .labex/seed-response.json

预期 HTTP 202 响应数据中包含 count: 3、384 个维度、cls 池化方式以及一个变更 UUID。已接受的变更是异步执行的,因此请创建与之前实验相同的有界稳定性检查。execFileSync 用于运行固定版本的 Wrangler,readFileSync 用于读取保存的 seed 响应;它们来自不同的 Node.js 内置模块:

cat > scripts/wait-for-vectorize.mjs <<'JS'
import { execFileSync } from "node:child_process";
import { readFileSync } from "node:fs";

const indexName = process.argv[2];
const seed = JSON.parse(readFileSync(".labex/seed-response.json", "utf8"));
const mutationId = seed.mutationId;
if (!/^[0-9a-f-]{36}$/i.test(mutationId)) throw new Error("seed response has no mutation ID");
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 (info.processedUpToMutation === mutationId && info.vectorCount === 3) consecutiveMatches += 1;
  else consecutiveMatches = 0;
  if (consecutiveMatches === 3) {
    console.log(`mutation ${mutationId} is consistently readable with three 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 readable within four minutes`);
JS
node scripts/wait-for-vectorize.mjs "$INDEX"

连续三次读取结果一致,可以避免短暂过时的副本影响可见结果。等待脚本成功后,列出稳定的应用程序 ID:

npx wrangler vectorize list-vectors "$INDEX" --count=10

资源清单中应包含 password-resetupload-pdfbilling-receipt。这些向量的实际值来自实时的 Cloudflare 托管模型,而不是 V01 和 V02 中使用的确定性教学向量。

检索并解读相似文章

本步骤将发送一个明确的密码问题,检查两个最近的候选项,并区分排序结果与显式空结果。

请求 topK: 2。Vectorize 可能会检查整个小型索引,但最多只返回两个最近的候选项。第一条结果应为密码文章,因为查询和文章表达的是相近含义,即使两者使用的确切措辞不同:

curl --fail-with-body --silent --show-error \
  -X POST "$DEPLOY_URL/search" \
  -H 'content-type: application/json' \
  --data '{"query":"My old password expired and I cannot sign in","topK":2}' \
  | tee .labex/password-search.json
node -e '
  const value = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"));
  console.table(value.matches);
' .labex/password-search.json

预期会得到两行按分数降序排列的结果,其中 password-reset 位于第一行。请以相对方式解读分数:对于这个兼容的查询和索引,更大的值表示距离更近,但 0.8 不代表「80% 正确」。topK 也不会应用相关性阈值。

现在发送一个无关问题,并设置 minScore: 1。Vectorize 仍会向应用程序返回三个候选项,但应用程序会删除所有低于该阈值的候选项:

curl --fail-with-body --silent --show-error \
  -X POST "$DEPLOY_URL/search" \
  -H 'content-type: application/json' \
  --data '{"query":"volcanic basalt crystallization","topK":3,"minScore":1}' \
  | tee .labex/empty-search.json

预期结果中包含 candidateCount: 3matches: []。空的匹配列表是应用程序明确做出的决定,并不表示索引中没有向量。

最后,发送空输入:

curl --silent --show-error \
  -o .labex/empty-input.json \
  -w 'HTTP %{http_code}\n' \
  -X POST "$DEPLOY_URL/search" \
  -H 'content-type: application/json' \
  --data '{"query":""}'
cat .labex/empty-input.json

预期 HTTP 400,并且响应中包含 query_required。验证会在调用模型或数据库之前执行,因此错误输入不会消耗推理或查询容量。

打开 Workers & Pages → 你的 labex-c08-v03-... Worker → Bindings。绑定为 Worker 代码访问其他 Cloudflare 服务提供了安全名称。在本实验中,AIenv.AI 用来运行嵌入模型的名称,DOCUMENTSenv.DOCUMENTS 用来查询这个确切 Vectorize 索引的名称。

Worker Bindings 视图将 AI 连接到 Workers AI,并将 DOCUMENTS 连接到 Vectorize 索引

接着打开 AI → Vectorize → 对应的 -docs 索引。摘要应显示三个当前向量,每篇帮助文章对应一个。查询总数可能与示例不同,因为每次成功的搜索都会增加一次查询,包括重复检查。

Vectorize 摘要显示最近的查询以及三个已存储的向量

滚动到 MetricsP50P75P95 是延迟百分位数。例如,P95 表示 95% 的成功查询都能在该时间内完成。这些数字描述的是速度,而不是匹配结果的相关性。当你只执行搜索、不添加或删除文档时,Stored Vectors 图表应保持为三个。

查询延迟百分位数与稳定的三个已存储向量数量并列显示

Dashboard 中的计数器可能会比终端延迟几秒更新。请将 API 响应、返回的 ID 和独立检查视为权威结果;使用 Dashboard 将这些结果与可查看和操作的资源对应起来。

删除搜索 Worker 和索引

本步骤将删除两个一次性云资源,并在 Wrangler 仍处于授权状态时证明它们已经不存在。登出是单独的最后一步,因为清理检查需要访问 Cloudflare 的读取权限。

首先从 wrangler.jsonc 中恢复准确的名称。即使你打开了新的终端、之前的 RUNINDEX 变量已经不存在,这样也能安全执行清理:

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-v03-... 前缀开头。

先删除 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"

请在登出前完成本步骤。评估程序会独立读取 Cloudflare,并且不会将身份验证失败或网络失败视为删除成功的证据。

登出学习 VM

本步骤将移除该 VM 的临时 Wrangler 授权。云资源已经删除,经过身份验证的清理检查也已通过,现在可以安全登出:

npx wrangler logout
npx wrangler whoami --json

预期输出中包含 loggedIn: false。Cloudflare Dashboard 的浏览器会话是独立的,仍可用于访问你的学习账户。

总结

你构建了一个 Worker,让存储文档嵌入和实时查询嵌入使用同一个模型契约;你向 Vectorize 写入了三个稳定 ID,并等待真实的异步变更完成。你使用 topK 限制候选项数量,将分数解读为相对排序信号,返回元数据而不是原始向量,并在应用程序执行阈值筛选后生成了明确的空结果。最后,你在 Dashboard 中确认了两个云绑定,在仍处于授权状态时删除了一次性的 Worker 和索引,然后登出了 VM。

V04 将添加由服务器控制的客户命名空间和元数据筛选条件,使语义相似的记录只有在属于已授权搜索范围时才会返回。