Vectorize 與 RAG
⚠️ Vectorize binding 沒有本機模擬,實測本機呼叫全部丟
Binding VEC needs to be run remotely。本章的探針需要remote: true與真實帳號。
第 34 章的模型會回答問題,但它不知道你的資料。RAG(Retrieval-Augmented Generation)就是把「你的資料」塞進去的標準做法,而 Vectorize 是那個檢索層。
這一章的重點只有兩個,而且兩個都是「第一個 demo 就會壞掉」等級的:
- 寫入是非同步的。 寫完立刻查,查不到。
- 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(),讓你量到自己帳號上的實際延遲。
這對架構的影響
Section titled “這對架構的影響”(一)不要在同一個請求裡「寫入然後驗證」。 索引流程應該是:寫入 → 記下 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 要處理。
36.2 metadata index 必須事先建立
Section titled “36.2 metadata index 必須事先建立”第二個會讓 demo 壞掉的地方。
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 但不建索引(查回來之後在應用層過濾)。
36.3 returnMetadata 的真相
Section titled “36.3 returnMetadata 的真相”大綱初稿寫「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 的兩段式上限
Section titled “topK 的兩段式上限”topK 上限 100 ── 但 returnValues: true 時 → 50 ── 或 returnMetadata: "all" 時 → 50範例的 /topk 路由把四種組合各打一次,讓你確認邊界。
returnValues: true 幾乎永遠是不必要的。 你已經有查詢向量了,把 50 個 1536 維的 float 陣列傳回來只是浪費頻寬與 topK 預算。唯一的用途是要做 re-ranking 或視覺化。
36.4 index 的限制
Section titled “36.4 index 的限制”| 限制 | 值 |
|---|---|
| 每個 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」的時候。
36.5 insert vs upsert
Section titled “36.5 insert vs upsert”實測型別註解:
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 不滿足這個條件。
36.6 namespace 是租戶隔離的工具
Section titled “36.6 namespace 是租戶隔離的工具”await env.VEC.upsert([{ id, values, namespace: tenantId, metadata: { ... } }]);await env.VEC.query(vector, { topK: 5, namespace: tenantId });namespace 和 metadata filter 都能做租戶隔離,但它們不是等價的:
| namespace | metadata 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 時會看到什麼。
36.7 本機開發:完全不支援
Section titled “36.7 本機開發:完全不支援”實測 wrangler dev 沒有 remote: true 時,每一個方法都丟同一個錯:
Error: Binding VEC needs to be run remotelydescribe()、query()、upsert()、insert()、getByIds() 全部一樣。
binding 本身是真的(實測 ctorName: "VectorizeIndexImpl",prototype 上有完整的方法清單,包含內部的 _send 與 queryImplV2 —— 後者印證了 V1/V2 的分界確實存在於實作裡),但它需要遠端連線。
設定 schema 只有三個欄位:binding、index_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:跨租戶的連結語意搜尋”需求:租戶能用自然語言搜尋自己的短網址(「上個月那個關於定價的連結」)。
索引:Queue → embedding → upsert
Section titled “索引:Queue → embedding → upsert”// 第 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) }}三個設計點:
- embedding 走 gateway 快取(第 35 章)。同一段文字的 embedding 永遠一樣,快取 30 天是純收益。
id是${tenantId}:${slug}—— 穩定且冪等,重跑索引不會產生重複。- 只有真的要過濾的欄位建 metadata index(
tenant、createdAt),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])) });});三個實務要點:
score門檻是必要的。 向量搜尋永遠會回傳topK個結果,即使全部都不相關。沒有門檻的話,搜尋「定價」會回傳一堆完全無關的連結,使用者會覺得功能是壞的。門檻值要用真實資料調。- Vectorize 不是資料庫。 它回傳 id 與 metadata,權威資料在 D1。這個分工讓你可以改 embedding 模型而不動業務資料。
- D1 查詢仍然帶
tenant_id。 即使 namespace 已經隔離過了 —— 縱深防禦,而且第 36.6 說過漏掉 namespace 不會報錯。
chunking:RAG 真正的難點
Section titled “chunking:RAG 真正的難點”上面的例子索引的是短文字(標題 + 摘要),所以不需要切塊。真正的文件 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 預算。
36.9 本章實測結論彙整
Section titled “36.9 本章實測結論彙整”| # | 結論 | 影響 |
|---|---|---|
| 1 | insert/upsert/deleteByIds 回傳 VectorizeAsyncMutation(只有 mutationId) | 型別名稱裡就寫了 Async |
| 2 | 寫完立刻查查不到 | 每份教學都該放在第一段 |
| 3 | 刪除也是非同步的 | 「刪了還搜得到」是預期行為 |
| 4 | 正確的索引流程是 Queue / Workflow + step.sleep + 驗證重試 | 不要在同一個請求裡寫入然後驗證 |
| 5 | returnMetadata 型別是 boolean | "all" | "indexed" | "none" | true 不會編譯失敗;三個字串才是有意義的區分 |
| 6 | "all" 會把 topK 上限從 100 降到 50;returnValues: true 也是 | "indexed" 是多數情況的正解 |
| 7 | metadata 可以存 string[],但 filter 的值型別 Exclude<..., string[]> 不能對陣列比對 | 陣列語意要拆成純量欄位 |
| 8 | filter 運算子:$eq/$ne/$lt/$lte/$gt/$gte 與集合的 $in/$nin | filter JSON < 2048 bytes |
| 9 | metadata index 必須在插入向量之前建立,且每 index 只有 10 個 | 事後補救等於重新 upsert 全部資料 |
| 10 | 維度上限 1536 | 排除了許多新的高維 embedding 模型 |
| 11 | dimensions 與 metric 建立後不可改 | 換模型 = 建新 index + 全量重建 |
| 12 | insert 遇到重複 id 丟錯,upsert 取代 | 索引流程一律用 upsert(冪等) |
| 13 | 忘記帶 namespace 不會報錯,只是安靜地跨租戶檢索 | 必須包一層強制函式 |
| 14 | 本機完全不支援:所有方法都丟 Binding VEC needs to be run remotely | 需要 remote: true 與真實 index |
| 15 | binding 是 VectorizeIndexImpl,prototype 上有 _send 與 queryImplV2 | V1/V2 的分界存在於實作裡 |
| 16 | 設定 schema 只有 binding、index_name、remote | |
| 17 | 每 index 1,000 萬向量、metadata 10 KiB/向量、空 index 免費 |
36.10 動手練習
Section titled “36.10 動手練習”- 跑
/write-then-poll,記錄你的帳號上寫入真正可查詢所需的時間。這個數字決定你的 UI 該顯示什麼。 - 用
returnMetadata: "all"加topK: 51打一次,確認 36.3 那個降級到 50 的行為。 - 在已經有向量的 index 上建一個新的 metadata index,然後用它 filter,確認舊向量查不到 —— 這是 36.2 那個「必須事先建立」的實證。
- 故意省略
namespace查詢,確認你會拿到別的租戶的資料。然後把 36.6 那個包裝函式加上,再試一次。 - 對同一份文件做兩種 chunking(固定長度 vs 段落 + 重疊),比較同一組問題的檢索命中率。
- Vectorize · Best practices · Metadata filtering · Limits
wrangler vectorize指令- 型別來源:
wrangler types產生的worker-configuration.d.ts(Vectorize、VectorizeQueryOptions、VectorizeAsyncMutation、VectorizeVectorMetadataFilter、VectorizeMetadataRetrievalLevel)
下一章(第 37 章):Agents SDK 與 MCP server on Workers —— 把前四章的推論、gateway、檢索組成一個能自己行動的 agent,以及第 35 章提過的那個名字撞車(MCP Server Portals 不是這件事)。