From 0ff369f81817d6282a81c829e82f151a696835a5 Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 5 Aug 2026 18:00:43 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E8=AA=9E=E6=84=8F=E6=90=9C=E5=B0=8B?= =?UTF-8?q?=E5=85=A8=200=20=E5=91=BD=E4=B8=AD=EF=BC=9A=E5=88=86=E6=95=B8?= =?UTF-8?q?=E9=96=80=E6=AA=BB=E6=B2=92=E8=B7=9F=E8=91=97=20bge-m3=20?= =?UTF-8?q?=E6=8F=9B=E4=BB=A3=EF=BC=88=E6=8F=9B=E6=A8=A1=E5=9E=8B=E6=BC=8F?= =?UTF-8?q?=E6=8E=89=E7=9A=84=E7=AC=AC=E4=BA=94=E8=99=95=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- console-ui/public/portal/os-split.test.mjs | 17 ++++++++++---- cypher-executor/src/routes/portal-data.ts | 12 +++++++--- kbdb/src/embed.ts | 27 ++++++++++++++++++++-- kbdb/tests/search-source-and-score.test.ts | 16 ++++++++----- 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/console-ui/public/portal/os-split.test.mjs b/console-ui/public/portal/os-split.test.mjs index 3075676..1b1b6bc 100644 --- a/console-ui/public/portal/os-split.test.mjs +++ b/console-ui/public/portal/os-split.test.mjs @@ -1,10 +1,15 @@ import fs from 'node:fs'; const html = fs.readFileSync(new URL('./index.html', import.meta.url).pathname,'utf8'); // 抽出 daemonPick 相關函式(從 DAEMON_BASE_DEFAULT 到 daemonHint 結尾) +// +// 🔴 2026-08-05:結尾標記本來寫死 daemonHint 的**整句文案**,於是同日改 Mac 提示語 +// (zip→DMG 的步驟不同)就讓這支自測直接炸「抽不到函式區塊」,而且沒人發現。 +// ⇒ 改成錨定「函式結束」這個結構,不再綁文案——文案本來就會改,測試不該為此壞掉。 const start = html.indexOf('var DAEMON_BASE_DEFAULT'); -const endMark = "return '(封測版未簽章,第一次請右鍵→打開)';\n }"; -const end = html.indexOf(endMark) + endMark.length; -if (start < 0 || end < start) throw new Error('抽不到函式區塊'); +const hintAt = html.indexOf('function daemonHint', start); +const endMark = '\n }'; +const end = hintAt < 0 ? -1 : html.indexOf(endMark, hintAt) + endMark.length; +if (start < 0 || hintAt < 0 || end < start) throw new Error('抽不到函式區塊'); const src = html.slice(start, end); const cases = [ @@ -26,11 +31,13 @@ for (const [name, ua] of cases) { console.log(` url: ${url}`); if (name==='Windows') { chk('Windows 給 win zip', d.sure && d.pick.url.endsWith('ArcrunRAG-win-unsigned.zip'), d.pick&&d.pick.url); - chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('mac-unsigned.zip')); + chk('Windows 另一版是 Mac', d.other && d.other.url.endsWith('ArcrunRAG-mac.dmg')); chk('Windows 話術提 藍色視窗', api.daemonHint('win').includes('仍要執行')); } if (name==='Mac') { - chk('Mac 給 mac zip', d.sure && d.pick.url.endsWith('ArcrunRAG-mac-unsigned.zip')); + // 2026-08-05:Mac 一律給 DMG(拖進 Applications 的標準安裝畫面),不再給 zip + // ——zip 解開就是一個裸 .app,使用者會直接在「下載」資料夾雙擊執行,自更新會蓋錯位置。 + chk('Mac 給 dmg(不是 zip)', d.sure && d.pick.url.endsWith('ArcrunRAG-mac.dmg')); chk('Mac 另一版是 Windows', d.other && d.other.url.endsWith('win-unsigned.zip')); chk('Mac 話術提 右鍵打開', api.daemonHint('mac').includes('右鍵')); } diff --git a/cypher-executor/src/routes/portal-data.ts b/cypher-executor/src/routes/portal-data.ts index 035d0cc..53a6c98 100644 --- a/cypher-executor/src/routes/portal-data.ts +++ b/cypher-executor/src/routes/portal-data.ts @@ -240,10 +240,16 @@ portalDataRouter.get('/portal/data/search', (c) => // 斷崖落在 0.787 與 0.742 之間 ⇒ 取 0.75:相關的全留、雜訊全砍。 // // 允許前端覆寫(想放寬看更多可傳 min_score),但**不接受 0/負數** - // ——那等於關掉閾值,正是這次要修的病本身。 + // ——那等於關掉閾值,正是 t183 要修的病本身。 + // + // 🔴 2026-08-05 修正(leo 實撞「語義搜尋 0 命中」):**這裡不再硬寫預設值**。 + // 上面 0.75 是照**舊模型 bge-base-en-v1.5** 的分數分布定的;08-05 換 bge-m3 後 + // 分數尺度整體下移,0.75 砍掉的變成正解 ⇒ 新上傳的檔一律 0 命中。 + // 根因=**閾值是模型的性質,卻被複製到呼叫端**,換模型時沒人想到要回來改這行。 + // ⇒ 預設值移到 `kbdb/src/embed.ts` 的 `DEFAULT_MIN_SCORE`(緊鄰 DEFAULT_EMBED_MODEL), + // portal 只在**使用者顯式指定**時才傳。**不要把數字搬回來。** const msRaw = Number(c.req.query('min_score')); - const minScore = Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1 ? msRaw : 0.75; - params.set('min_score', String(minScore)); + if (Number.isFinite(msRaw) && msRaw > 0 && msRaw < 1) params.set('min_score', String(msRaw)); } const entryType = c.req.query('entry_type'); if (entryType) params.set('entry_type', entryType); diff --git a/kbdb/src/embed.ts b/kbdb/src/embed.ts index 68b289c..6785e3f 100644 --- a/kbdb/src/embed.ts +++ b/kbdb/src/embed.ts @@ -37,6 +37,26 @@ import type { Bindings, Entry } from './types'; // → 打 backfill 的 `reindex=true`(把 embed=1 的既有 entry 全部重嵌)→ 舊 index 可刪。 const DEFAULT_EMBED_MODEL = '@cf/baai/bge-m3'; // 1024-dim,與 Vectorize index dimensions=1024 對齊 +// 🔴 2026-08-05 leo 實撞:換 bge-m3 後**語義搜尋全 0 命中**(新上傳的檔搜不到、舊檔偶爾才中)。 +// 根因不在向量——實測 Vectorize 端排序完全正確(搜「閉環機」,目標檔穩坐 1-4 名)—— +// 而在**分數閾值是綁在舊模型的分數尺度上的**: +// 舊 bge-base-en-v1.5:中文分數全擠 0.65-0.90(沒區辨力)⇒ t183 取 0.75 砍雜訊,對 +// 新 bge-m3 :分數尺度整體下移(相關 0.5-0.85、雜訊 0.4 上下)⇒ 0.75 砍掉的是**正解** +// youlin 實例實測分布(bge-m3,08-05,直打 Vectorize query): +// 「閉環機」 0.638 / 0.603 / 0.588 / 0.552 ← 全是目標檔,**全被 0.75 砍光** +// ────── 斷崖 ────── 0.446 以下才是雜訊 +// 「火星座標 奧林帕斯山」 0.750…0.500 全是火星座標,0.475 以下才是雜訊 +// 「人力媒合系統規劃書」 0.842 ← 僥倖 >0.75 存活。**這就是 leo 看到「舊檔中、新檔不中」的由來** +// ⇒ 斷崖普遍落在 0.5 附近,取 **0.5**:相關的全留、雜訊仍砍。 +// 誠實 trade-off:0.5 不是每個查詢都乾淨(實測「AI 上課名冊」0.658 的 ax-academy 會擠進來), +// 但「偶有雜訊」遠優於「什麼都搜不到」——後者是現在的狀態。 +// +// 🔴 為什麼閾值住在這裡(而不是 portal):它是**模型的性質**,不是頁面的偏好。 +// 原本硬寫在 `cypher-executor/src/routes/portal-data.ts`,換模型時那裡沒人想到要改 +// ——這正是 bge-m3 換代「四處同步」清單漏掉的第五處。放在模型常數旁邊, +// 下次換模型的人一定會看到它。**別再把數字複製回呼叫端。** +const DEFAULT_MIN_SCORE = 0.5; + /** 實際使用的嵌入模型:env 可覆寫(#59),未設用預設。 */ function embedModel(env: Bindings): string { const m = (env.EMBED_MODEL ?? '').trim(); @@ -251,7 +271,10 @@ export interface SemanticHit { * 註:向量 metadata 的 library 在寫入端已正規化(未標記='general'),故 $in 不需 NULL 處理; * 但「建 library metadata index 之前」upsert 的既有向量沒有此欄 → 部署清單強制 reindex backfill。 * min_score(issue #67):分數閾值——Vectorize 只會硬湊 topK 筆,低分尾全是無關內容; - * 過濾放查詢端(非 Vectorize 端,API 無此參數)。預設 0=不過濾(行為與舊版一字不變,向後相容)。 + * 過濾放查詢端(非 Vectorize 端,API 無此參數)。 + * 🔴 2026-08-05 起預設=`DEFAULT_MIN_SCORE`(跟著模型走,見該常數的實測分布), + * 不再是 0(0=不過濾=#67 要修的病本身,而呼叫端各自硬寫數字則是 08-05 全 0 命中的根因)。 + * caller 顯式傳值仍優先。 */ export async function semanticSearch( env: Bindings, @@ -271,7 +294,7 @@ export async function semanticSearch( returnMetadata: 'indexed', ...(Object.keys(filter).length ? { filter } : {}), }); - const minScore = opts.min_score ?? 0; + const minScore = opts.min_score ?? DEFAULT_MIN_SCORE; return (res.matches ?? []) .filter((m) => m.score >= minScore) .map((m) => ({ diff --git a/kbdb/tests/search-source-and-score.test.ts b/kbdb/tests/search-source-and-score.test.ts index e1ae948..dc7bf43 100644 --- a/kbdb/tests/search-source-and-score.test.ts +++ b/kbdb/tests/search-source-and-score.test.ts @@ -131,13 +131,16 @@ function makeSemanticEnv(queryCalls: { opts: Record }[]) { } 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 }[] = []; 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/search(semantic)top_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 }[] = []; 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/search(semantic)top_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); } });