diff --git a/kbdb/src/embed.ts b/kbdb/src/embed.ts index 02cff22..e4e22cc 100644 --- a/kbdb/src/embed.ts +++ b/kbdb/src/embed.ts @@ -13,7 +13,35 @@ 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 { @@ -24,7 +52,7 @@ export function embedEnabled(env: Bindings): boolean { 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); + }); +});