語意門檻改相對式+下架連帶刪向量(leo 兩個實測回饋)
## ① 「關懷型 AI 命中 20 筆、只有前 3 筆相關」⇒ 閾值太寬(leo 判斷正確) 固定門檻兩頭都不對,因為每個查詢的分數尺度不同(實測 youlin 實例): 關懷型 AI 正解 0.645-0.770,雜訊起於 0.547 ← 固定 0.5 放進 6 筆雜訊 閉環機 正解 0.552-0.638,雜訊起於 0.446 ← 固定 0.6 砍到剩 2/4(=早上的 0 命中) 人力媒合系統規劃書 正解 0.842,雜訊起於 0.550 ⇒ 改**相對門檻** max(0.45, top×0.8)。五組實測:固定 0.5 混入 9 筆雜訊/ 固定 0.6 有兩組正解被砍/相對式四組雜訊 0 且正解全留。 ## 🔴 寫測試才發現的真問題:門檻不能在 Vectorize 那層算 Vectorize 的 indexed metadata 沒有 status ⇒ 那層不知道誰已下架。 若最高分是下架殘影(t24 復現案 0.971),拿它算門檻=0.777, 會把 0.6 的正解一起砍光 ⇒ **又變成 0 命中**。 ⇒ 相對門檻移到 routes/entries.ts,接在「hydrate+濾下架」之後; embed.ts 只留絕對下限。新增測試鎖住這個順序。 ## ② leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」——對,補上 單筆真刪已接 deleteByIds(b7af622),但「移除整個庫」走軟刪、向量原地不動。 ⇒ deprecate-by-library 同時 deleteByIds + is_embedded 歸零(D1 與 Vectorize 不說兩套話); backfill 兩條路徑(含 reindex)都排除 deprecated,否則下次補嵌會把殘影養回來。 不違背 t135「資料保留可還原」:D1 那列原封不動,還原後跑 backfill 重嵌即可。 回應新增 vectors_deleted,刪失敗誠實回 0 不假裝清乾淨。 驗:kbdb 91/91 綠(新增 4 項相對門檻測試,含「下架殘影不得決定門檻」) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -203,7 +203,10 @@ describe('t24 案② — semantic 濾 deprecated + 補位(t11 斷點②:0.
|
||||
};
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta });
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5', {}, env);
|
||||
// 顯式帶 min_score:本案要測的是「濾下架+不硬湊」,不是分數門檻。
|
||||
// 2026-08-05 起未帶 min_score 會套相對門檻(top×0.8),0.6 的 a3 會被砍掉
|
||||
// ⇒ 那會把這個測試變成在測門檻。帶一個寬鬆的絕對值,把門檻這個變因移開。
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5&min_score=0.4', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[]; count: number };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['a1', 'a2', 'a3']);
|
||||
expect(body.count).toBe(3);
|
||||
@@ -224,3 +227,64 @@ describe('t24 案② — semantic 濾 deprecated + 補位(t11 斷點②:0.
|
||||
expect(body.count).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 相對門檻(2026-08-05,leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關)────────────
|
||||
//
|
||||
// 這組鎖住兩件事:
|
||||
// ① 門檻跟著「這次查詢的最高分」走,不是固定值
|
||||
// (固定 0.5 放太多雜訊;固定 0.6 會把「閉環機」那種整體偏低的查詢砍成 0 命中)
|
||||
// ② 🔴 **門檻必須在濾掉下架之後才算**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走,
|
||||
// 那正是 leo 08-05 早上撞的「0 命中」的翻版。t24 的 0.971 復現案就是這種殘影。
|
||||
describe('相對門檻(08-05)— 跟著最高分走,且在濾下架之後才算', () => {
|
||||
it('低分尾被砍:0.77/0.74/0.64 留下,0.55 以下砍掉(門檻 0.77×0.8=0.616)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const matches = [
|
||||
{ id: 'hit1', score: 0.77 }, { id: 'hit2', score: 0.74 }, { id: 'hit3', score: 0.64 },
|
||||
{ id: 'noise1', score: 0.55 }, { id: 'noise2', score: 0.53 }, { id: 'noise3', score: 0.52 },
|
||||
];
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[]; count: number };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['hit1', 'hit2', 'hit3']);
|
||||
});
|
||||
|
||||
it('整體偏低的查詢不會被砍光:0.638/0.603/0.588/0.552 全留(門檻 0.638×0.8=0.510)', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const matches = [
|
||||
{ id: 'l1', score: 0.638 }, { id: 'l2', score: 0.603 },
|
||||
{ id: 'l3', score: 0.588 }, { id: 'l4', score: 0.552 }, { id: 'noise', score: 0.446 },
|
||||
];
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[] };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['l1', 'l2', 'l3', 'l4']);
|
||||
});
|
||||
|
||||
it('🔴 下架殘影不得決定門檻:0.971 已下架 → 門檻要用倖存者的 0.6 算,正解不被帶走', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const matches = [
|
||||
{ id: 'dep-ghost', score: 0.971 }, // 下架殘影,分數卻最高
|
||||
{ id: 'real1', score: 0.60 }, { id: 'real2', score: 0.52 },
|
||||
];
|
||||
const entryMeta: Record<string, string | null> = { 'dep-ghost': JSON.stringify({ status: 'deprecated' }) };
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, { ...makeSemanticEnv(calls, matches), _entryMeta: entryMeta });
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[] };
|
||||
// 若拿 0.971 算門檻=0.777 ⇒ real1/real2 全被砍 ⇒ 0 命中(就是那個病)。
|
||||
// 正解:殘影先被濾掉,門檻用 0.6×0.8=0.48 算 ⇒ 兩筆都留。
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['real1', 'real2']);
|
||||
});
|
||||
|
||||
it('caller 顯式帶 min_score → 尊重絕對值,不再加碼相對門檻', async () => {
|
||||
const calls: { opts: Record<string, unknown> }[] = [];
|
||||
const matches = [{ id: 'a', score: 0.9 }, { id: 'b', score: 0.5 }, { id: 'c', score: 0.3 }];
|
||||
const captured: Captured[] = [];
|
||||
const { app, env } = makeApp(captured, makeSemanticEnv(calls, matches));
|
||||
const res = await app.request('/entries/search?q=x&mode=semantic&min_score=0.4', {}, env);
|
||||
const body = (await res.json()) as { entries: Entry[] };
|
||||
expect(body.entries.map((e) => e.id)).toEqual(['a', 'b']); // 0.3 被絕對門檻砍,0.5 留著
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user