feat(#59): embed 模型可配置,預設換成 bge-m3——英文模型嵌中文會排錯
leo 2026-08-03 拍板「換 m3」。#59 本體(模型應可配置+index 版本化)。
## 為什麼換:實測,不是憑感覺
5 組中文問答測資(每組 1 問 + 2 段相關 + 3 段無關,無關的刻意放同一知識庫裡的其他主題),
margin = min(相關分數) − max(無關分數),margin ≤ 0 代表排序是錯的:
@cf/baai/bge-base-en-v1.5 768 排序正確 2/5 平均 margin -0.0413 1660ms ← 舊
@cf/google/embeddinggemma-300m 768 4/5 +0.1275 1174ms
@cf/baai/bge-m3 1024 5/5 +0.1410 959ms ← 新(最好且最快)
@cf/qwen/qwen3-embedding-0.6b 1024 4/5 +0.1381 3238ms
最刺眼的一組:問「知識庫問答為什麼要標出處?」→「會議室預約規則」0.7789
竟然高於真正相關的 0.7306。這正是 leo 2026-07-18 回報的「問 RAG 卻引用會議室規範」,
過去只被描述成「semantic 排名近乎雜訊」,其實是**排序錯誤**,而且五組錯三組。
英文 bge 系列的分數全擠在 0.65-0.81(區辨力接近沒有)——中文對它就是看不懂的 token。
## 改動
- `EMBED_MODEL` 常數 → `DEFAULT_EMBED_MODEL='@cf/baai/bge-m3'` + `embedModel(env)`,
可由 `env.EMBED_MODEL` 覆寫(#59 要的「可配置」),空字串/空白視為沒設。
- 寫入端(embedOnWrite / backfill)與查詢端(semanticSearch)共用同一個 getter
——兩邊不同步是最惡毒的 bug:不報錯、分數全垃圾、外面完全看不出來。已加測試守。
## 換代必須換 index(安裝器那半在 arcrun-rag 同名分支)
① 維度 768→1024,舊 index 收不進新向量
② 就算維度一樣也不能沿用——不同模型的向量混在同一個 index 比對出來是垃圾,
而 #58(Vectorize vector delete 未接)代表舊向量刪不掉
⇒ 開新 index 反而順手繞開 #58。
既有實例遷移:建新 index → 重部署 kbdb(binding 指新 index)
→ POST /embed/backfill {reindex:true} 重嵌到 remaining=0 → 舊 index 可刪。
驗證:kbdb 全套 87/87 綠(含新增 4 項);tsc 與基線相同(只剩既有的 auth.test.ts 那筆);
打包實跑產物 grep:kbdb bundle 只有 @cf/baai/bge-m3、舊英文模型 0 殘留。
This commit is contained in:
+31
-3
@@ -13,7 +13,35 @@
|
|||||||
|
|
||||||
import type { Bindings, Entry } from './types';
|
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 動作先過這關。 */
|
/** embed 模組是否啟用(binding 都在才算開)。base 一切 embed 動作先過這關。 */
|
||||||
export function embedEnabled(env: Bindings): boolean {
|
export function embedEnabled(env: Bindings): boolean {
|
||||||
@@ -24,7 +52,7 @@ export function embedEnabled(env: Bindings): boolean {
|
|||||||
async function embedText(env: Bindings, text: string): Promise<number[] | null> {
|
async function embedText(env: Bindings, text: string): Promise<number[] | null> {
|
||||||
const t = (text ?? '').trim();
|
const t = (text ?? '').trim();
|
||||||
if (!t || !env.AI) return null;
|
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;
|
return res?.data?.[0] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +177,7 @@ export async function backfillEmbeddings(
|
|||||||
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
||||||
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
||||||
const texts = embeddable.map((e) => (e.content ?? '').trim());
|
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 data = out?.data ?? [];
|
||||||
const vectors = embeddable
|
const vectors = embeddable
|
||||||
.map((e, i) => ({ e, vec: data[i] }))
|
.map((e, i) => ({ e, vec: data[i] }))
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ export type Bindings = {
|
|||||||
// requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed.
|
// requires them; code checks `if (env.VECTORIZE && env.AI)` before touching embed.
|
||||||
VECTORIZE?: VectorizeIndex;
|
VECTORIZE?: VectorizeIndex;
|
||||||
AI?: Ai;
|
AI?: Ai;
|
||||||
|
// 嵌入模型(Arcrun#59)。未設=用 embed.ts 的預設。設成別的模型時,**Vectorize index 的
|
||||||
|
// dimensions 必須跟著對**(維度不合 upsert 會被 CF 拒絕),且換模型必須換 index:
|
||||||
|
// 不同模型的向量不可共存於同一個 index(比對出來是垃圾),詳見 embed.ts 檔頭。
|
||||||
|
EMBED_MODEL?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type EntryType =
|
export type EntryType =
|
||||||
|
|||||||
@@ -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<Bindings> = {}) {
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user