跳到內容

ch36-vectorize

對應 36. Vectorize 與 RAG

取得並執行

這個範例可以獨立 clone 執行,不依賴其他章節。

npx degit mrsuner/cloudflare-serverless-platform-manuals/examples/ch36-vectorize
cd ch36-vectorize
npm install

可用指令

npm run dev	# wrangler dev
npm run types	# wrangler types --env-interface CloudflareBindings

說明

Probe project for chapter 36.

⚠️ Vectorize has no local simulation. Every method throws Binding VEC needs to be run remotely in wrangler dev. These routes need "remote": true plus a real index.

Terminal window
npm install
npx wrangler vectorize create ch36-docs --dimensions 32 --metric cosine
npx wrangler vectorize create-metadata-index ch36-docs --property-name tenant --type string
npx wrangler vectorize create-metadata-index ch36-docs --property-name year --type number
npx wrangler vectorize create-metadata-index ch36-docs --property-name kind --type string
npx wrangler types --env-interface CloudflareBindings
export CLOUDFLARE_API_TOKEN=...
npx wrangler dev --port 9045

Create the metadata indexes before inserting anything — see below.

RouteWhat it shows
/shapeVectorizeIndexImpl and its full method list.
/write-then-readThe one everyone hits. Write, then query immediately.
/write-then-pollPolls getByIds() every 500 ms so you can measure the real lag.
/metadata-levelsnone / indexed / all, plus the legacy boolean.
/topk100 vs 101, and the drop to 50 with values or full metadata.
/filterOperators, and an oversized filter against the 2048-byte cap.
/namespacesThe same vector in two namespaces, queried three ways.
/insert-vs-upsertDuplicate id: insert throws, upsert replaces.
/dimensionsA wrong-dimension vector — the most common RAG runtime error.
public upsert(vectors: VectorizeVector[]): Promise<VectorizeAsyncMutation>;
interface VectorizeAsyncMutation { mutationId: string }

The type name says Async. The promise resolving means accepted, not queryable. deleteByIds is the same, so “I deleted it and it’s still in the results” is expected behaviour, not a bug.

Index in a Queue consumer or a Workflow, and verify separately:

const { mutationId } = await step.do("index", () => env.VEC.upsert(vectors));
await step.sleep("settle", "10 seconds");
await step.do("verify", async () => {
const found = await env.VEC.getByIds(ids);
if (found.length !== ids.length) throw new Error("not settled"); // step retries
return found.length;
});
Terminal window
npx wrangler vectorize create-metadata-index <index> --property-name tenant --type string

Vectors written before the metadata index exists are not retroactively indexed — you have to re-upsert them. And there are only 10 metadata indexes per index, so budget them: index only the fields you actually filter on, and let everything else ride along in metadata for display.

Filters cap at 2048 bytes of JSON.

Note what the type excludes:

type VectorizeVectorMetadataValue = string | number | boolean | string[];
// but filters are typed Exclude<VectorizeVectorMetadataValue, string[]>

You can store arrays. You cannot filter on them.

returnMetadata?: boolean | "all" | "indexed" | "none";

true still compiles (backwards compatibility), but the three strings are the real distinction:

ValueReturnsCost
"none"nothingfastest
"indexed"only fields with a metadata indexcheap
"all"everythingdrops topK from 100 to 50

returnValues: true also drops topK to 50 — and is almost never needed, since you already have the query vector.

"indexed" is the right default.

insert throws on an existing id; upsert replaces. Because indexing runs behind a Queue or Workflow (both at-least-once), the operation must be idempotent. insert is not.

Forgetting namespace doesn’t error — it silently searches every tenant. Wrap it once and forbid direct access:

export async function searchTenant(env, tenantId: string, vector: number[], topK = 5) {
if (!tenantId) throw new Error("refusing to query Vectorize without a tenant");
return env.VEC.query(vector, { topK, namespace: tenantId, returnMetadata: "indexed" });
}
  • 1536 dimensions max — rules out many newer high-dimension embedding models. @cf/google/embeddinggemma-300m (ch34) fits.
  • dimensions and metric are fixed at creation. Changing embedding models means a new index and a full re-index.
  • 10M vectors per index, 10 KiB metadata per vector, empty indexes are free.
Terminal window
curl -s localhost:9045/shape | jq
{
"ctorName": "VectorizeIndexImpl",
"protoKeys": ["constructor","describe","query","queryById","insert","upsert",
"getByIds","deleteByIds","_send","queryImplV2"],
"describe": { "threwName": "Error", "threw": "Binding VEC needs to be run remotely" }
}

The binding is real — note queryImplV2 on the prototype, confirming the V1/V2 split lives in the implementation — but every call needs a remote connection. Keep a separate -dev index and switch with a named environment (ch2).

原始碼

wrangler.jsonc

{
  "$schema": "./node_modules/wrangler/config-schema.json",
  "name": "ch36-vectorize",
  "main": "src/index.ts",
  "compatibility_date": "2026-07-24",
  "observability": { "enabled": true },
  "vectorize": [{ "binding": "VEC", "index_name": "ch36-docs" }],
  // "ai" removed for offline probing -- see /embed

  "r2_buckets": [{ "binding": "DOCS", "bucket_name": "ch36-docs" }]
}

package.json

{
  "name": "ch36-vectorize",
  "private": true,
  "type": "module",
  "scripts": { "dev": "wrangler dev", "types": "wrangler types --env-interface CloudflareBindings" },
  "devDependencies": { "wrangler": "^4.118.0", "typescript": "^5.9.2" }
}

src/index.ts

const t = async (fn: () => unknown): Promise<Record<string, unknown>> => {
  const started = Date.now();
  try {
    const v = await fn();
    return { ok: v === undefined ? "(undefined)" : v, ms: Date.now() - started };
  } catch (e) {
    const err = e as Error & { code?: number };
    return { threwName: err.name, code: err.code, threw: String(err.message ?? e).slice(0, 300), ms: Date.now() - started };
  }
};

/** Deterministic pseudo-embedding so the probes need no AI calls. */
function fakeEmbedding(text: string, dims = 32): number[] {
  const out = new Array(dims).fill(0);
  for (let i = 0; i < text.length; i++) out[i % dims] += text.charCodeAt(i) / 255;
  const norm = Math.hypot(...out) || 1;
  return out.map((v) => v / norm);
}

export default {
  async fetch(request: Request, env: CloudflareBindings, ctx: ExecutionContext): Promise<Response> {
    const url = new URL(request.url);
    const q = url.searchParams;

    switch (url.pathname) {
      case "/shape": {
        const b = env.VEC as unknown as object;
        return Response.json({
          ctorName: Object.getPrototypeOf(b)?.constructor?.name ?? null,
          protoKeys: Object.getOwnPropertyNames(Object.getPrototypeOf(b)),
          describe: await t(() => env.VEC.describe()),
        });
      }

      /**
       * THE FIRST THING EVERY TUTORIAL GETS WRONG.
       *
       * Writes are asynchronous. insert/upsert return a mutationId, and the
       * vectors are NOT queryable yet. This route writes then immediately
       * queries, so you can watch it come back empty.
       */
      case "/write-then-read": {
        const id = `probe-${crypto.randomUUID().slice(0, 8)}`;
        const vector = fakeEmbedding("cloudflare workers are v8 isolates");

        const mutation = await t(() =>
          env.VEC.upsert([
            { id, values: vector, namespace: "acme", metadata: { tenant: "acme", kind: "doc", year: 2026 } },
          ]),
        );

        // Immediately. This is the point of the probe.
        const immediately = await t(() => env.VEC.query(vector, { topK: 3, namespace: "acme" }));
        const byId = await t(() => env.VEC.getByIds([id]));

        return Response.json({
          note: "In production the immediate query typically returns nothing. upsert() resolves with a mutationId, not with durability.",
          id,
          mutation,
          immediately,
          byId,
        });
      }

      // Poll until the write lands, so you can measure the real lag.
      case "/write-then-poll": {
        const id = `poll-${crypto.randomUUID().slice(0, 8)}`;
        const vector = fakeEmbedding("durable objects are single threaded actors");
        const started = Date.now();
        await env.VEC.upsert([{ id, values: vector, namespace: "acme", metadata: { tenant: "acme" } }]);

        const attempts: unknown[] = [];
        for (let i = 0; i < 10; i++) {
          const found = await env.VEC.getByIds([id]);
          attempts.push({ afterMs: Date.now() - started, found: found.length });
          if (found.length) break;
          await new Promise((r) => setTimeout(r, 500));
        }
        return Response.json({ id, attempts });
      }

      // returnMetadata is NOT a boolean in spirit, even though the type allows one.
      case "/metadata-levels": {
        const vector = fakeEmbedding(q.get("q") ?? "workers");
        const out: Record<string, unknown> = {};
        for (const level of ["none", "indexed", "all"] as const) {
          out[level] = await t(() =>
            env.VEC.query(vector, { topK: 3, namespace: "acme", returnMetadata: level }),
          );
        }
        // Still accepted by the type (boolean | VectorizeMetadataRetrievalLevel).
        out["true (legacy boolean)"] = await t(() =>
          env.VEC.query(vector, { topK: 3, namespace: "acme", returnMetadata: true }),
        );
        return Response.json(out);
      }

      // topK is capped at 100 -- but 50 when returning values or full metadata.
      case "/topk": {
        const vector = fakeEmbedding("workers");
        return Response.json({
          k100: await t(() => env.VEC.query(vector, { topK: 100 })),
          k101: await t(() => env.VEC.query(vector, { topK: 101 })),
          k51WithValues: await t(() => env.VEC.query(vector, { topK: 51, returnValues: true })),
          k51WithAllMetadata: await t(() =>
            env.VEC.query(vector, { topK: 51, returnMetadata: "all" }),
          ),
        });
      }

      // Metadata filters. The field must have a metadata index created BEFORE
      // the vectors were inserted.
      case "/filter": {
        const vector = fakeEmbedding(q.get("q") ?? "workers");
        return Response.json({
          eq: await t(() => env.VEC.query(vector, { topK: 5, filter: { tenant: "acme" } })),
          operators: await t(() =>
            env.VEC.query(vector, {
              topK: 5,
              filter: { year: { $gte: 2025 }, kind: { $in: ["doc", "faq"] } },
            }),
          ),
          // Filters are limited to 2048 bytes of JSON.
          oversized: await t(() =>
            env.VEC.query(vector, {
              topK: 5,
              filter: Object.fromEntries(
                Array.from({ length: 200 }, (_, i) => [`f${i}`, "x".repeat(20)]),
              ) as never,
            }),
          ),
        });
      }

      // Namespaces are the tenant-isolation primitive.
      case "/namespaces": {
        const vector = fakeEmbedding("shared phrase");
        await env.VEC.upsert([
          { id: "ns-a", values: vector, namespace: "acme", metadata: { tenant: "acme" } },
          { id: "ns-b", values: vector, namespace: "other", metadata: { tenant: "other" } },
        ]);
        return Response.json({
          note: "Same vector in two namespaces. A namespaced query must only ever see its own.",
          acme: await t(() => env.VEC.query(vector, { topK: 5, namespace: "acme" })),
          other: await t(() => env.VEC.query(vector, { topK: 5, namespace: "other" })),
          noNamespace: await t(() => env.VEC.query(vector, { topK: 5 })),
        });
      }

      // insert() throws on an existing id; upsert() replaces.
      case "/insert-vs-upsert": {
        const id = "dup-probe";
        const v = fakeEmbedding("dup");
        return Response.json({
          firstInsert: await t(() => env.VEC.insert([{ id, values: v }])),
          secondInsert: await t(() => env.VEC.insert([{ id, values: v }])),
          upsert: await t(() => env.VEC.upsert([{ id, values: v }])),
        });
      }

      // Dimension mismatch -- the most common runtime error in RAG code.
      case "/dimensions": {
        return Response.json({
          wrongDims: await t(() =>
            env.VEC.upsert([{ id: "bad-dims", values: fakeEmbedding("x", 7) }]),
          ),
          describe: await t(() => env.VEC.describe()),
        });
      }

      default:
        return new Response(
          "/shape /write-then-read /write-then-poll /metadata-levels /topk /filter\n" +
            "/namespaces /insert-vs-upsert /dimensions\n",
          { status: 404 },
        );
    }
  },
} satisfies ExportedHandler<CloudflareBindings>;

.gitignore

node_modules/
.wrangler/
worker-configuration.d.ts

tsconfig.json

{
  "compilerOptions": {
    "target": "esnext", "lib": ["esnext"], "module": "esnext",
    "moduleResolution": "bundler", "types": ["./worker-configuration.d.ts"],
    "strict": true, "skipLibCheck": true, "noEmit": true, "isolatedModules": true
  },
  "include": ["src/**/*.ts", "worker-configuration.d.ts"]
}