f3af5d3631
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 殘留。
83 lines
3.6 KiB
TypeScript
83 lines
3.6 KiB
TypeScript
// 嵌入模型可配置+換代(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);
|
||
});
|
||
});
|