diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index fe552b3..49d9bf0 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -163,8 +163,27 @@ export interface DeployContext { kbdbEmbed?: boolean; } -/** Vectorize index 名(kbdb embed 模組用)。bge-base-en-v1.5 = 768 維、cosine。 */ -export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed'; +/** + * Vectorize index 名(kbdb embed 模組用)。**bge-m3 = 1024 維、cosine。** + * + * 🔴 2026-08-03 換代(leo 拍板;5 組中文測資實證:舊 `bge-base-en-v1.5` 排序 2/5、 + * margin −0.0413=**中文根本不能用**;`bge-m3` 5/5、+0.1410、959ms)。 + * leo 08-05:「換 embed model 當然要合併,當然要換 vectorize,原本的根本不能用」。 + * + * **換模型必須換 index,且必須換「名字」**: + * ① 維度 768→1024,舊 index 收不進新向量 + * ② 就算維度相同也不能沿用——不同模型的向量混在同一 index,比對出來是垃圾; + * 而 #58(Vectorize vector delete 未接)代表舊向量刪不掉 + * ⇒ **開新名字的 index 反而順手繞開 #58**,且新舊並存可回滾。 + * + * ⚠️ 這個常數同時被 `ensureVectorizeMetadataIndexes()` 使用(deploy.ts:426) + * ⇒ t36 的四個 metadata index(owner_id/entry_type/source/library,Arcrun#11 根因修復) + * 會自動建在新 index 上,**不會因為改名而遺失**(已查證,非假設)。 + * + * 既有實例遷移:部署後 `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0, + * 確認語意查詢正常後,舊的 `arcrun-kbdb-embed` 可自行刪除。 + */ +export const KBDB_VECTORIZE_INDEX = 'arcrun-kbdb-embed-m3'; export interface DeployResult { implemented: boolean; @@ -388,7 +407,9 @@ async function applyD1Migration(ctx: DeployContext, sql: string): Promise /** * 確保 KBDB embed 用的 Vectorize index 存在(issue #7 / T2.4)。 - * REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=768/metric=cosine,對齊 bge-base-en-v1.5)。 + * REST `POST /accounts/{id}/vectorize/v2/indexes`(dimensions=1024 / metric=cosine,對齊 bge-m3)。 + * ⚠️ 這行別寫成 `**dimensions=1024**​/metric`——`*` 緊接 `/` 會提早關掉 block comment(實撞 TS1127)。 + * 維度必須與 `kbdb/src/embed.ts` 的 `DEFAULT_EMBED_MODEL` 一致——不一致時 upsert 直接被 CF 拒絕。 * 冪等:已存在(CF 回「already exists」類錯)視為成功,不報錯。用 init 已驗的 apiToken+accountId。 */ async function ensureVectorizeIndex(ctx: DeployContext): Promise { @@ -398,8 +419,8 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise { headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ name: KBDB_VECTORIZE_INDEX, - config: { dimensions: 768, metric: 'cosine' }, - description: 'arcrun KBDB optional embed module (issue #7)', + config: { dimensions: 1024, metric: 'cosine' }, + description: 'arcrun KBDB embed module — bge-m3 1024d (issue #7 / #59)', }), signal: AbortSignal.timeout(60_000), }); diff --git a/kbdb/src/embed.ts b/kbdb/src/embed.ts index 02cff22..68b289c 100644 --- a/kbdb/src/embed.ts +++ b/kbdb/src/embed.ts @@ -13,18 +13,46 @@ import type { Bindings, Entry } from './types'; -const EMBED_MODEL = '@cf/baai/bge-base-en-v1.5'; // 768-dim,與 Vectorize index dimensions=768 對齊 +// ── 嵌入模型(Arcrun#59:模型應可配置+index 版本化,支援換代重刷)──────────────── +// +// 2026-08-03 換代:`@cf/baai/bge-base-en-v1.5`(768-dim)→ `@cf/baai/bge-m3`(1024-dim)。 +// +// 為什麼換(實測,不是憑感覺):舊模型是**英文模型**,拿來嵌中文等於嵌一堆看不懂的 token。 +// 用 5 組中文問答測資(每組 1 問 + 2 段相關 + 3 段無關,無關的刻意放同一知識庫裡的其他主題), +// 算 margin = min(相關分數) − max(無關分數),margin ≤ 0 代表**排序是錯的**: +// @cf/baai/bge-base-en-v1.5 768 排序正確 2/5 平均 margin -0.0413 1660 ms ← 舊 +// @cf/google/embeddinggemma-300m 768 4/5 +0.1275 1174 ms +// @cf/baai/bge-m3 1024 **5/5** **+0.1410** 959 ms ← 新(品質最好且最快) +// @cf/qwen/qwen3-embedding-0.6b 1024 4/5 +0.1381 3238 ms +// 舊模型最刺眼的一組:問「知識庫問答為什麼要標出處?」→「**會議室預約規則**」0.7789 +// 竟然高於真正相關的 0.7306。這正是 leo 2026-07-18 回報的「問 RAG 卻引用會議室規範」。 +// +// 🔴 換模型=**必須換 Vectorize index**,兩個理由: +// ① 維度不同(768→1024),舊 index 收不進新向量; +// ② 就算維度相同也不能沿用——不同模型的向量混在同一個 index,比對出來是垃圾, +// 而 Arcrun#58(Vectorize vector delete 未接)代表舊向量**刪不掉**。 +// ⇒ 開新 index 反而順手繞開 #58:新 index 天生乾淨,舊的整個丟掉。 +// +// 換代步驟(installer 已把新 index 名與維度對齊):建新 index → 重新部署 kbdb(binding 指新 index) +// → 打 backfill 的 `reindex=true`(把 embed=1 的既有 entry 全部重嵌)→ 舊 index 可刪。 +const DEFAULT_EMBED_MODEL = '@cf/baai/bge-m3'; // 1024-dim,與 Vectorize index dimensions=1024 對齊 + +/** 實際使用的嵌入模型:env 可覆寫(#59),未設用預設。 */ +function embedModel(env: Bindings): string { + const m = (env.EMBED_MODEL ?? '').trim(); + return m || DEFAULT_EMBED_MODEL; +} /** embed 模組是否啟用(binding 都在才算開)。base 一切 embed 動作先過這關。 */ export function embedEnabled(env: Bindings): boolean { return !!(env.VECTORIZE && env.AI); } -/** 一段文字 → 768 維向量(Workers AI bge)。空字串回 null(不 embed)。 */ +/** 一段文字 → 1024 維向量(Workers AI bge-m3,可由 env.EMBED_MODEL 覆寫)。空字串回 null(不 embed)。 */ async function embedText(env: Bindings, text: string): Promise { const t = (text ?? '').trim(); if (!t || !env.AI) return null; - const res = (await env.AI.run(EMBED_MODEL, { text: [t] })) as { data: number[][] }; + const res = (await env.AI.run(embedModel(env), { text: [t] })) as { data: number[][] }; return res?.data?.[0] ?? null; } @@ -149,7 +177,7 @@ export async function backfillEmbeddings( const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0); if (embeddable.length > 0 && env.AI && env.VECTORIZE) { const texts = embeddable.map((e) => (e.content ?? '').trim()); - const out = (await env.AI.run(EMBED_MODEL, { text: texts })) as { data: number[][] }; + const out = (await env.AI.run(embedModel(env), { text: texts })) as { data: number[][] }; const data = out?.data ?? []; const vectors = embeddable .map((e, i) => ({ e, vec: data[i] })) diff --git a/kbdb/src/types.ts b/kbdb/src/types.ts index c80c55b..023d205 100644 --- a/kbdb/src/types.ts +++ b/kbdb/src/types.ts @@ -16,6 +16,10 @@ export type Bindings = { // requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed. VECTORIZE?: VectorizeIndex; AI?: Ai; + // 嵌入模型(Arcrun#59)。未設=用 embed.ts 的預設。設成別的模型時,**Vectorize index 的 + // dimensions 必須跟著對**(維度不合 upsert 會被 CF 拒絕),且換模型必須換 index: + // 不同模型的向量不可共存於同一個 index(比對出來是垃圾),詳見 embed.ts 檔頭。 + EMBED_MODEL?: string; }; export type EntryType = diff --git a/kbdb/tests/embed-model-config.test.ts b/kbdb/tests/embed-model-config.test.ts new file mode 100644 index 0000000..ab3a2f9 --- /dev/null +++ b/kbdb/tests/embed-model-config.test.ts @@ -0,0 +1,82 @@ +// 嵌入模型可配置+換代(Arcrun#59)—— 2026-08-03 +// +// 為什麼要有這個測試(別刪): +// 舊版把模型寫死成 `@cf/baai/bge-base-en-v1.5`,那是**英文模型**,拿來嵌中文等於嵌一堆 +// 看不懂的 token。5 組中文測資實測(margin = min(相關) − max(無關),≤0 代表排序錯): +// bge-base-en-v1.5 768 排序正確 2/5 平均 margin -0.0413 ← 舊,五組錯三組 +// embeddinggemma-300m 768 4/5 +0.1275 +// **bge-m3 1024 5/5 +0.1410** ← 新(品質最好、而且最快 959ms) +// qwen3-embedding-0.6b 1024 4/5 +0.1381 +// 最刺眼的一組:問「知識庫問答為什麼要標出處?」→「會議室預約規則」0.7789 竟然高於 +// 真正相關的 0.7306 = leo 2026-07-18 回報「問 RAG 卻引用會議室規範」的直接數字。 +// +// 本檔守三件事: +// ① 預設模型是 m3(有人手滑改回英文模型會紅) +// ② env.EMBED_MODEL 真的能覆寫(#59 要的「可配置」) +// ③ **查詢端與寫入端用同一顆模型**——兩邊不同步是最惡毒的 bug: +// 不會報錯、只是分數全是垃圾,而且從外面完全看不出來。 +import { describe, it, expect } from 'vitest'; +import { embedOnWrite, semanticSearch } from '../src/embed'; +import type { Bindings, Entry } from '../src/types'; + +function mkEnv(over: Partial = {}) { + const calls: { model: string; text: string[] }[] = []; + const env = { + AI: { + run: async (model: string, input: { text: string[] }) => { + calls.push({ model, text: input.text }); + return { data: input.text.map(() => [0.1, 0.2, 0.3]) }; + }, + }, + VECTORIZE: { + upsert: async () => undefined, + query: async () => ({ matches: [] }), + }, + DB: { + prepare: () => ({ bind: () => ({ run: async () => ({}), all: async () => ({ results: [] }) }) }), + }, + ...over, + } as unknown as Bindings; + return { env, calls }; +} + +const entry = { + id: 'e_1', + content: '出處標註讓使用者能回頭核對答案來源。', + entry_type: 'block', + owner_id: 'demo', + metadata_json: JSON.stringify({ embed: true }), +} as unknown as Entry; + +describe('嵌入模型(Arcrun#59)', () => { + it('預設是 bge-m3——不得退回英文模型(中文會排錯)', async () => { + const { env, calls } = mkEnv(); + await embedOnWrite(env, entry); + expect(calls).toHaveLength(1); + expect(calls[0].model).toBe('@cf/baai/bge-m3'); + expect(calls[0].model).not.toContain('-en-'); // 英文模型一律不准當預設 + }); + + it('env.EMBED_MODEL 可覆寫(#59 的「模型應可配置」)', async () => { + const { env, calls } = mkEnv({ EMBED_MODEL: '@cf/google/embeddinggemma-300m' }); + await embedOnWrite(env, entry); + expect(calls[0].model).toBe('@cf/google/embeddinggemma-300m'); + }); + + it('空字串/空白的 EMBED_MODEL 視為沒設,回退預設(不會把空字串當模型名送出去)', async () => { + for (const bad of ['', ' ']) { + const { env, calls } = mkEnv({ EMBED_MODEL: bad }); + await embedOnWrite(env, entry); + expect(calls[0].model).toBe('@cf/baai/bge-m3'); + } + }); + + it('🔴 查詢端與寫入端必須是同一顆模型(不同步=分數全垃圾且不會報錯)', async () => { + const { env, calls } = mkEnv({ EMBED_MODEL: '@cf/baai/bge-m3' }); + await embedOnWrite(env, entry); // 寫入端 + await semanticSearch(env, '為什麼要標出處?'); // 查詢端 + expect(calls.length).toBeGreaterThanOrEqual(2); + const models = new Set(calls.map((c) => c.model)); + expect(models.size).toBe(1); + }); +}); diff --git a/kbdb/wrangler.toml b/kbdb/wrangler.toml index 0e413be..4dd20b0 100644 --- a/kbdb/wrangler.toml +++ b/kbdb/wrangler.toml @@ -31,14 +31,21 @@ ENVIRONMENT = "production" # ── Optional embed module (issue #7 / SDD T2.4) ──────────────────────────────── # Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true -# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed -# --dimensions=768 --metric=cosine`(bge-base-en-v1.5 = 768 維)。官方帳號同理由 deploy 注入。 +# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed-m3 +# --dimensions=1024 --metric=cosine`(**bge-m3 = 1024 維**)。官方帳號同理由 deploy 注入。 +# 🔴 2026-08-03 換代(leo 拍板,5 組中文測資實證:舊英文模型排序 2/5、margin −0.0413=根本不能用; +# bge-m3 5/5、+0.1410、959ms):`bge-base-en-v1.5`(768) → `bge-m3`(1024)。 +# **換模型必須換 index**:① 維度 768→1024,舊 index 收不進新向量 +# ② 就算維度相同也不能沿用——不同模型的向量混在同一 index 比對出來是垃圾, +# 而 #58(Vectorize vector delete 未接)代表舊向量刪不掉 ⇒ 開新 index 反而順手繞開 #58。 +# 既有實例遷移:建新 index → 重部署 kbdb(binding 指新 index) +# → `POST /embed/backfill {"reindex":true}` 重嵌到 remaining=0 → 舊 index 可刪。 # ⚠️ Arcrun#11:光建 index 不夠。要對 owner_id/entry_type/source 下 filter(owner-scoped/類型-scoped 語意查詢), # 必須另建 metadata index,否則帶過濾一律回 0 命中: -# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name owner_id --type string -# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name entry_type --type string -# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name source --type string -# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name library --type string +# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name owner_id --type string +# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name entry_type --type string +# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name source --type string +# wrangler vectorize create-metadata-index arcrun-kbdb-embed-m3 --property-name library --type string # (library=portal-auth P1「庫」filter;upsert 端把未標記正規化成 'general',查詢走 $in) # metadata index 只收「建立後 upsert」的向量 → 既有向量須 `POST /embed/backfill {"reindex":true}` 重推 # (建 library index 後同樣要 reindex,否則舊向量帶 library filter 一律 0 命中)。 @@ -48,7 +55,7 @@ ENVIRONMENT = "production" # # [[vectorize]] # binding = "VECTORIZE" -# index_name = "arcrun-kbdb-embed" +# index_name = "arcrun-kbdb-embed-m3" # bge-m3 1024d(2026-08-03 換代) # # [ai] # binding = "AI"