跳到內容

Vectorize 與 RAG

查證日期
驗證環境wrangler@4.118.0·compatibility_date: 2026-07-24
前置章節34. Workers AI

⚠️ Vectorize binding 沒有本機模擬,實測本機呼叫全部丟 Binding VEC needs to be run remotely。本章的探針需要 remote: true 與真實帳號。

第 34 章的模型會回答問題,但它不知道你的資料。RAG(Retrieval-Augmented Generation)就是把「你的資料」塞進去的標準做法,而 Vectorize 是那個檢索層。

這一章的重點只有兩個,而且兩個都是「第一個 demo 就會壞掉」等級的:

  1. 寫入是非同步的。 寫完立刻查,查不到。
  2. metadata filter 的欄位必須在插入向量之前宣告。 事後補救等於重建索引。

36.1 寫入是非同步的(本章最重要的一節)

Section titled “36.1 寫入是非同步的(本章最重要的一節)”

實測型別:

public insert(vectors: VectorizeVector[]): Promise<VectorizeAsyncMutation>;
public upsert(vectors: VectorizeVector[]): Promise<VectorizeAsyncMutation>;
interface VectorizeAsyncMutation {
/** The unique identifier for the async mutation operation containing the changeset. */
mutationId: string;
}

型別名稱裡就寫了 Async upsert() 的 Promise resolve 代表「你的變更已被接受」,不代表「向量已經可以被查到」

const { mutationId } = await env.VEC.upsert([{ id, values, metadata }]);
const results = await env.VEC.query(values, { topK: 3 });
// results.matches 很可能是空的

這是每一份 Vectorize 教學都應該放在第一段、但幾乎都沒放的事。 讀者照著寫完第一個 demo,查不到東西,然後花半小時懷疑自己的 embedding 算錯了。

範例的 /write-then-read 就是把這件事跑出來給你看;/write-then-poll 則是每 500 ms 輪詢一次 getByIds(),讓你量到自己帳號上的實際延遲。

(一)不要在同一個請求裡「寫入然後驗證」。 索引流程應該是:寫入 → 記下 mutationId → 結束。驗證是另一件事。

(二)使用者上傳文件後,UI 不能馬上說「可以搜尋了」。 正確的流程是 Queue(第 19 章)或 Workflow(第 21 章):

// Workflow:寫入後用 step.sleep 等待,再驗證
const { mutationId } = await step.do("index", async () =>
env.VEC.upsert(vectors.map(toVectorizeVector)));
await step.sleep("settle", "10 seconds");
const landed = await step.do("verify", async () => {
const found = await env.VEC.getByIds(vectors.map((v) => v.id));
if (found.length !== vectors.length) throw new Error("not settled yet"); // 讓 step 重試
return found.length;
});

第 21 章的 step.do 有內建重試,所以「還沒 settle 就丟錯」正好是對的做法 —— 不需要自己寫輪詢迴圈。

(三)刪除也是非同步的。 deleteByIds() 同樣回傳 mutationId。所以「使用者刪除文件後,搜尋結果裡還看得到」是預期行為,UI 要處理。


第二個會讓 demo 壞掉的地方。

Terminal window
npx wrangler vectorize create-metadata-index ch36-docs \
--property-name tenant --type string

這件事必須在插入向量之前做。 已經寫進去的向量不會被回溯索引 —— 你得把它們重新 upsert 一次。

實測型別的 filter 形狀:

type VectorizeVectorMetadataFilterOp = '$eq' | '$ne' | '$lt' | '$lte' | '$gt' | '$gte';
type VectorizeVectorMetadataFilterCollectionOp = '$in' | '$nin';
type VectorizeVectorMetadataFilter = {
[field: string]:
| Exclude<VectorizeVectorMetadataValue, string[]>
| null
| { [Op in VectorizeVectorMetadataFilterOp]?: ... }
| { [Op in VectorizeVectorMetadataFilterCollectionOp]?: ...[] };
};
await env.VEC.query(vector, {
topK: 5,
filter: { tenant: "acme", year: { $gte: 2025 }, kind: { $in: ["doc", "faq"] } },
});

型別裡有一個容易忽略的細節:filter 的值型別是 Exclude<VectorizeVectorMetadataValue, string[]>

metadata 可以存 string[]VectorizeVectorMetadataValue = string | number | boolean | string[]),但 filter 不能對陣列欄位比對。想要「tag 包含 X」的語意,得把每個 tag 拆成獨立的布林欄位,或用 $in 對一個純量欄位比。

限制:filter 的 JSON 序列化後必須 < 2048 bytes;每個 index 最多 10 個 metadata index。

10 個是很緊的預算。規劃時把它當成一個要省著用的資源 —— 先想清楚哪些欄位真的需要在檢索層過濾,其餘的放進 metadata 但不建索引(查回來之後在應用層過濾)。


大綱初稿寫「returnMetadata 是字串列舉,不是 boolean」。實測型別,這句話只對了一半

interface VectorizeQueryOptions {
topK?: number;
namespace?: string;
returnValues?: boolean;
returnMetadata?: boolean | VectorizeMetadataRetrievalLevel; // ← 兩者都接受
filter?: VectorizeVectorMetadataFilter;
}
type VectorizeMetadataRetrievalLevel = "all" | "indexed" | "none";

boolean 仍然在型別裡(向後相容),所以 returnMetadata: true 不會編譯失敗

但三個字串值才是有意義的區分:

回傳什麼代價
"none"不回 metadata最快
"indexed"只回有建 metadata index 的欄位便宜
"all"全部 metadata會把 topK 上限從 100 降到 50

所以 "indexed" 是大多數情況的正解 —— 你通常只需要租戶 ID 和文件 ID 來組裝結果,不需要把整份 metadata 拉回來。

topK 上限 100
── 但 returnValues: true 時 → 50
── 或 returnMetadata: "all" 時 → 50

範例的 /topk 路由把四種組合各打一次,讓你確認邊界。

returnValues: true 幾乎永遠是不必要的。 你已經有查詢向量了,把 50 個 1536 維的 float 陣列傳回來只是浪費頻寬與 topK 預算。唯一的用途是要做 re-ranking 或視覺化。


限制
每個 index 的向量數1,000 萬
維度上限1536
metadata(每個向量)10 KiB
每個 index 的 metadata index 數10
filter JSON 大小2048 bytes
空 index免費

1536 維這個上限決定了你的 embedding 模型選擇。 很多新模型輸出的維度更高(3072、4096),那些不能直接用。要嘛選 ≤1536 維的模型,要嘛做降維(Matryoshka 截斷)。

第 34 章那個 @cf/google/embeddinggemma-300m 是安全的選擇。

index 的設定在建立時就固定了:

type VectorizeIndexConfig = { dimensions: number; metric: VectorizeDistanceMetric };

維度與距離度量都不能事後修改。 換 embedding 模型 = 建新 index + 重新索引全部資料 + 切換 binding。這是一個要在設計時就想清楚的決定。

範例的 /dimensions 路由故意插入一個維度錯誤的向量 —— 這是 RAG 程式碼最常見的執行期錯誤,通常發生在「換了模型但忘了改 index」的時候。


實測型別註解:

  • insert:「If a provided id exists, an error will be thrown.」
  • upsert:「If a provided id exists, it will be replaced with the new values.」

索引流程幾乎永遠該用 upsert 理由是第 36.1 的非同步性 —— 如果一個索引 job 因為任何原因重試(Queue 重投、Workflow step 重試),insert 會在第二次失敗,而 upsert 是冪等的。

「冪等」是這裡的關鍵字。 第 19 章 Queues 的 at-least-once 語意、第 21 章 Workflows 的 replay 語意,都要求下游操作可以安全地重複執行。insert 不滿足這個條件。


await env.VEC.upsert([{ id, values, namespace: tenantId, metadata: { ... } }]);
await env.VEC.query(vector, { topK: 5, namespace: tenantId });

namespace 和 metadata filter 都能做租戶隔離,但它們不是等價的

namespacemetadata filter
需要事先宣告create-metadata-index
佔用 10 個 metadata index 的預算
忘記加的後果查到所有租戶的資料查到所有租戶的資料

兩者都是「忘了加就洩漏」的設計。 這比 D1 的 WHERE tenant_id = ? 更危險,因為漏掉 namespace 不會報錯 —— 它只是安靜地跨租戶檢索。

實務上必須包一層:

// src/vec.ts —— 唯一允許直接碰 env.VEC 的檔案
export async function searchTenant(
env: 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",
});
}

然後用 lint 規則禁止其他檔案 import env.VEC這是第 26 章那個「把危險 API 包起來」模式的又一個實例。

範例的 /namespaces 路由把同一個向量寫進兩個 namespace,讓你確認隔離真的生效、以及不帶 namespace 時會看到什麼。


實測 wrangler dev 沒有 remote: true 時,每一個方法都丟同一個錯:

Error: Binding VEC needs to be run remotely

describe()query()upsert()insert()getByIds() 全部一樣。

binding 本身是真的(實測 ctorName: "VectorizeIndexImpl",prototype 上有完整的方法清單,包含內部的 _sendqueryImplV2 —— 後者印證了 V1/V2 的分界確實存在於實作裡),但它需要遠端連線。

設定 schema 只有三個欄位:bindingindex_name(皆必填)、remote

這代表 Vectorize 和第 34 章的 Workers AI 一樣,是「開發即連線」的 primitive。 差別是 Vectorize 的儲存本身有免費額度(空 index 免費),但你仍然需要一個真實的 index 才能開發。

實務建議:開一個 ch36-docs-dev 的 index 專供開發,和 production 分開。切換靠 wrangler 的 named environment(第 2 章)。


36.8 LinkForge:跨租戶的連結語意搜尋

Section titled “36.8 LinkForge:跨租戶的連結語意搜尋”

需求:租戶能用自然語言搜尋自己的短網址(「上個月那個關於定價的連結」)。

// 第 34 章的掃描完成後,順便索引
async queue(batch: MessageBatch<IndexJob>, env: Env) {
for (const msg of batch.messages) {
const { slug, tenantId, title, summary, url } = msg.body;
const text = [title, summary, new URL(url).hostname].filter(Boolean).join("");
const { data } = await env.AI.run(
"@cf/google/embeddinggemma-300m",
{ text: [text] },
{ gateway: { id: "default", cacheKey: `emb:v1:${await sha256(text)}`, cacheTtl: 86400 * 30 } },
);
await env.VEC.upsert([{
id: `${tenantId}:${slug}`, // 冪等的 id(36.5)
values: data[0],
namespace: tenantId, // 租戶隔離(36.6)
metadata: {
tenant: tenantId, // 已建 metadata index
slug,
createdAt: msg.body.createdAt, // 已建 metadata index(供時間範圍過濾)
title: title.slice(0, 200), // 未建索引,只是回傳用
},
}]);
msg.ack(); // 注意:ack 代表「已送出」,不代表「可查詢」(36.1)
}
}

三個設計點:

  1. embedding 走 gateway 快取(第 35 章)。同一段文字的 embedding 永遠一樣,快取 30 天是純收益。
  2. id${tenantId}:${slug} —— 穩定且冪等,重跑索引不會產生重複。
  3. 只有真的要過濾的欄位建 metadata indextenantcreatedAt),title 只是搭便車回傳。這是 36.2 那個「10 個索引預算」的紀律。
app.get("/api/search", requireAuth, async (c) => {
const tenantId = c.get("session").tenantId;
const query = c.req.query("q") ?? "";
if (query.length < 2) return c.json({ results: [] });
const { data } = await c.env.AI.run(
"@cf/google/embeddinggemma-300m",
{ text: [query] },
{ gateway: { id: "default", cacheKey: `emb:v1:${await sha256(query)}`, cacheTtl: 3600 } },
);
const matches = await searchTenant(c.env, tenantId, data[0], 20);
// 相似度門檻:低於此值的結果對使用者是雜訊
const useful = matches.matches.filter((m) => m.score > 0.5);
// Vectorize 只回 id 與 metadata。真正的資料還是從 D1 拿(第 9 章)
const slugs = useful.map((m) => (m.metadata as { slug: string }).slug);
const rows = slugs.length
? await c.env.DB.prepare(
`select * from links where tenant_id = ?1 and slug in (${slugs.map(() => "?").join(",")})`,
).bind(tenantId, ...slugs).all()
: { results: [] };
return c.json({ results: rows.results, scores: Object.fromEntries(useful.map((m) => [m.id, m.score])) });
});

三個實務要點:

  1. score 門檻是必要的。 向量搜尋永遠會回傳 topK 個結果,即使全部都不相關。沒有門檻的話,搜尋「定價」會回傳一堆完全無關的連結,使用者會覺得功能是壞的。門檻值要用真實資料調。
  2. Vectorize 不是資料庫。 它回傳 id 與 metadata,權威資料在 D1。這個分工讓你可以改 embedding 模型而不動業務資料。
  3. D1 查詢仍然帶 tenant_id 即使 namespace 已經隔離過了 —— 縱深防禦,而且第 36.6 說過漏掉 namespace 不會報錯。

上面的例子索引的是短文字(標題 + 摘要),所以不需要切塊。真正的文件 RAG 需要 chunking,而這是效果差異最大的地方:

// 粗略但實用的起點:按段落切,重疊一部分
function chunk(text: string, target = 800, overlap = 150): string[] {
const paras = text.split(/\n{2,}/);
const out: string[] = [];
let buf = "";
for (const p of paras) {
if (buf.length + p.length > target && buf) {
out.push(buf);
buf = buf.slice(-overlap) + "\n\n" + p; // 帶上尾巴,避免切斷語意
} else {
buf = buf ? `${buf}\n\n${p}` : p;
}
}
if (buf) out.push(buf);
return out;
}

每個 chunk 的 metadata 要能還原上下文

metadata: {
tenant: tenantId,
docId, // 建索引:用來取回整份文件
chunkIndex: i, // 建索引:用來取回相鄰 chunk
heading: nearestHeading, // 不建索引:組 prompt 時當上下文
}

chunkIndex 建索引的理由很具體:命中第 7 個 chunk 時,把第 6、7、8 個一起餵給模型,答案品質會明顯提升。這需要一次 filter: { docId, chunkIndex: { $in: [6,7,8] } } 的查詢 —— 而這個查詢就用掉了兩個 metadata index 預算


#結論影響
1insert/upsert/deleteByIds 回傳 VectorizeAsyncMutation(只有 mutationId型別名稱裡就寫了 Async
2寫完立刻查查不到每份教學都該放在第一段
3刪除也是非同步的「刪了還搜得到」是預期行為
4正確的索引流程是 Queue / Workflow + step.sleep + 驗證重試不要在同一個請求裡寫入然後驗證
5returnMetadata 型別是 boolean | "all" | "indexed" | "none"true 不會編譯失敗;三個字串才是有意義的區分
6"all" 會把 topK 上限從 100 降到 50returnValues: true 也是"indexed" 是多數情況的正解
7metadata 可以存 string[],但 filter 的值型別 Exclude<..., string[]> 不能對陣列比對陣列語意要拆成純量欄位
8filter 運算子:$eq/$ne/$lt/$lte/$gt/$gte 與集合的 $in/$ninfilter JSON < 2048 bytes
9metadata index 必須在插入向量之前建立,且每 index 只有 10 個事後補救等於重新 upsert 全部資料
10維度上限 1536排除了許多新的高維 embedding 模型
11dimensionsmetric 建立後不可改換模型 = 建新 index + 全量重建
12insert 遇到重複 id 丟錯upsert 取代索引流程一律用 upsert(冪等)
13忘記帶 namespace 不會報錯,只是安靜地跨租戶檢索必須包一層強制函式
14本機完全不支援:所有方法都丟 Binding VEC needs to be run remotely需要 remote: true 與真實 index
15binding 是 VectorizeIndexImpl,prototype 上有 _sendqueryImplV2V1/V2 的分界存在於實作裡
16設定 schema 只有 bindingindex_nameremote
17每 index 1,000 萬向量、metadata 10 KiB/向量、空 index 免費

  1. /write-then-poll,記錄你的帳號上寫入真正可查詢所需的時間。這個數字決定你的 UI 該顯示什麼。
  2. returnMetadata: "all"topK: 51 打一次,確認 36.3 那個降級到 50 的行為。
  3. 已經有向量的 index 上建一個新的 metadata index,然後用它 filter,確認舊向量查不到 —— 這是 36.2 那個「必須事先建立」的實證。
  4. 故意省略 namespace 查詢,確認你會拿到別的租戶的資料。然後把 36.6 那個包裝函式加上,再試一次。
  5. 對同一份文件做兩種 chunking(固定長度 vs 段落 + 重疊),比較同一組問題的檢索命中率。

下一章(第 37 章):Agents SDK 與 MCP server on Workers —— 把前四章的推論、gateway、檢索組成一個能自己行動的 agent,以及第 35 章提過的那個名字撞車(MCP Server Portals 不是這件事)。