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:
2026-08-03 03:48:28 +00:00
parent 47c6aaea03
commit f3af5d3631
3 changed files with 117 additions and 3 deletions
+31 -3
View File
@@ -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#58Vectorize vector delete 未接)代表舊向量**刪不掉**。
// ⇒ 開新 index 反而順手繞開 #58:新 index 天生乾淨,舊的整個丟掉。
//
// 換代步驟(installer 已把新 index 名與維度對齊):建新 index → 重新部署 kbdbbinding 指新 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<number[] | null> {
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] }))
+4
View File
@@ -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 =
+82
View File
@@ -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);
});
});