修語意搜尋全 0 命中:分數門檻沒跟著 bge-m3 換代(換模型漏掉的第五處)

leo 2026-08-05 實撞:「新上傳的『loop-engine-north-star.md』語義 0 命中,
但舊的『人力媒合系統規劃書』語義 2 命中」。

## 根因不在向量——向量是好的
直打 Vectorize 實測(youlin 實例、bge-m3、1024 維 index):
  搜「閉環機」→ loop-engine-north-star.md 穩坐第 1-4 名(0.638/0.603/0.588/0.552)
D1 與 Vectorize 也對得上:659 筆 embeddable 全 is_embedded=1、index vectorCount 659。

真兇是 **min_score 門檻綁在舊模型的分數尺度上**:
  舊 bge-base-en-v1.5:中文分數全擠 0.65-0.90(沒區辨力)⇒ t183 取 0.75 砍雜訊,對
  新 bge-m3          :尺度整體下移(相關 0.5-0.85、雜訊 0.4 上下)⇒ 0.75 砍掉的是正解
leo 看到的「舊檔中、新檔不中」由此而來——「人力媒合系統規劃書」拿 0.842 僥倖存活,
其餘全被門檻掃掉。08-05 換 bge-m3 的「四處同步」清單(embed.ts/deploy.ts/
deploy-all.mjs/worker.js)**漏了這第五處**,因為它不在 kbdb 而在 portal 呼叫端。

## 改動
· kbdb/src/embed.ts:新增 DEFAULT_MIN_SCORE=0.5,**緊鄰 DEFAULT_EMBED_MODEL**
    ——門檻是模型的性質,放模型旁邊,下次換模型的人一定會看到
· cypher-executor/src/routes/portal-data.ts:拿掉硬寫的 0.75,
    只在使用者顯式指定時才傳 min_score(不再各自持有一份數字=不再漂移)
· console-ui/public/portal/os-split.test.mjs:修好被今天 fe0ee82 弄壞的自測
    (結尾標記寫死文案 ⇒ 改文案就炸「抽不到函式區塊」;改成錨定結構)
    +斷言同步改成 Mac 給 DMG

## 0.5 怎麼來的(實測分布,不是猜的)
「閉環機」            0.638/0.603/0.588/0.552 全是目標檔 ── 斷崖 ── 0.446 才是雜訊
「火星座標 奧林帕斯山」 0.750…0.500 全對,0.475 以下才是雜訊
「人力媒合系統規劃書」   0.842 對,0.550 起是雜訊
誠實 trade-off:0.5 非每個查詢都乾淨(「AI 上課名冊」0.658 的 ax-academy 會擠進來),
但「偶有雜訊」遠優於現況「什麼都搜不到」。

## 驗
· kbdb 87/87 綠(三筆斷言隨新契約更新:預設不再是「不過濾」)
· cypher-executor 9 failed/301 passed=**與改動前逐數相同**(git stash 前後各跑一次)⇒ 既有債
· portal os-split 自測 10/10 綠

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-05 18:00:43 +08:00
parent fe0ee8296a
commit 0ff369f818
4 changed files with 56 additions and 16 deletions
+10 -6
View File
@@ -131,13 +131,16 @@ function makeSemanticEnv(queryCalls: { opts: Record<string, unknown> }[]) {
}
describe('#67 — semanticSearch topK / min_score', () => {
it('不帶新參數 → topK=20、全 matches 回傳(行為與舊版一致)', async () => {
// 🔴 2026-08-05:預設 min_score 由 0(不過濾)改為 DEFAULT_MIN_SCORE(跟著 embed 模型走)。
// 原因=閾值原本硬寫在 portal 呼叫端,換 bge-m3 後沒人回頭改 ⇒ 語義搜尋全 0 命中。
// 測資分數 0.9 / 0.5 / 0.2:預設閾值 0.5 ⇒ 只有 0.2 的低分尾被砍。
it('不帶 min_score → topK=20、套用預設閾值(低分尾 0.2 被砍)', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
const hits = await semanticSearch(env, 'query', {});
expect(calls[0].opts.topK).toBe(20);
expect(hits?.length).toBe(3);
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5, 0.2]); // score 帶回
expect(hits?.map((h) => h.id)).toEqual(['e-high', 'e-mid']);
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5]); // score 帶回
});
it('min_score=0.5 → 低分尾截掉(>= 閾值者留)', async () => {
@@ -181,14 +184,14 @@ describe('#67 — route GET /entries/searchsemantictop_k / min_score / sco
expect(body.entries.map((e) => e.score)).toEqual([0.9, 0.5]);
});
it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),回應仍全量回傳(行為不變)entry 仍附 score(加欄不改形)', async () => {
it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),套用預設閾值後回 2 筆entry 仍附 score(加欄不改形)', async () => {
const calls: { opts: Record<string, unknown> }[] = [];
const { app, env } = makeSemanticApp(calls);
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
expect(res.status).toBe(200);
const body = (await res.json()) as { count: number; entries: (Entry & { score?: number })[] };
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
expect(body.count).toBe(3);
expect(body.count).toBe(2); // 08-05:預設閾值生效,0.2 的低分尾被砍
expect(body.entries[0].score).toBe(0.9);
// 原有欄位一個不少(回應形狀向後相容)
expect(body.entries[0].id).toBe('e-high');
@@ -203,7 +206,8 @@ describe('#67 — route GET /entries/searchsemantictop_k / min_score / sco
expect(res.status).toBe(200);
const body = (await res.json()) as { count: number };
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
expect(body.count).toBe(3); // 無閾值 → 全量
// 壞值=視同沒帶 ⇒ 落回預設閾值(08-05 起非 0),故仍砍掉 0.2 的低分尾。
expect(body.count).toBe(2);
}
});