diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts index 8908f36..077cd84 100644 --- a/kbdb/src/actions/entry-crud.ts +++ b/kbdb/src/actions/entry-crud.ts @@ -134,6 +134,37 @@ export async function deleteEntry(db: D1Database, id: string): Promise { * 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。 * 回 deprecated 的筆數(0 = 庫名不存在或早已全部 deprecated)。 */ +/** + * 撈出某 owner 下某庫、**目前還有向量**的 entry id(供下架時連帶清向量用)。 + * + * 🔴 2026-08-05 leo:「已經被刪掉的內容?理論上它的向量也要刪掉,就不會有殘影了吧?」——對。 + * 單筆真刪(`DELETE /entries/:id`)已經接了 `VECTORIZE.deleteByIds`(b7af622), + * 但「移除整個庫」走軟刪(只標 status),**向量原地不動** ⇒ 殘影就是這樣長出來的: + * 搜尋端每次都要靠事後過濾擋它,而它還會頂著高分去影響門檻計算。 + * ⇒ 標 deprecated 的同時把向量刪掉,讓殘影**在源頭就不存在**。 + * 不違背 t135「資料保留可還原」:**D1 那列原封不動**,還原後跑 + * `POST /embed/backfill` 重嵌即可(backfill 已排除 deprecated,所以不會自己跑回來)。 + */ +export async function embeddedIdsByLibrary(db: D1Database, ownerId: string, library: string): Promise { + const rows = await db + .prepare( + `SELECT id FROM entries + WHERE owner_id = ? + AND COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ? + AND is_embedded = 1`, + ) + .bind(ownerId, library) + .all<{ id: string }>(); + return (rows.results ?? []).map((r) => r.id); +} + +/** 把這些 entry 標成「已無向量」(配合 deleteByIds,讓 D1 與 Vectorize 不說兩套話)。 */ +export async function markUnembedded(db: D1Database, ids: string[]): Promise { + if (ids.length === 0) return; + const holes = ids.map(() => '?').join(','); + await db.prepare(`UPDATE entries SET is_embedded = 0 WHERE id IN (${holes})`).bind(...ids).run(); +} + export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise { const result = await db .prepare( diff --git a/kbdb/src/embed.ts b/kbdb/src/embed.ts index 6785e3f..716578b 100644 --- a/kbdb/src/embed.ts +++ b/kbdb/src/embed.ts @@ -55,7 +55,34 @@ const DEFAULT_EMBED_MODEL = '@cf/baai/bge-m3'; // 1024-dim,與 Vectorize index // 原本硬寫在 `cypher-executor/src/routes/portal-data.ts`,換模型時那裡沒人想到要改 // ——這正是 bge-m3 換代「四處同步」清單漏掉的第五處。放在模型常數旁邊, // 下次換模型的人一定會看到它。**別再把數字複製回呼叫端。** -const DEFAULT_MIN_SCORE = 0.5; +// 🔴 2026-08-05 二修(leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關 ⇒「閾值設太寬?」——對): +// **固定門檻兩頭都不對**,因為每個查詢的分數尺度不一樣: +// 查詢 正解區間 雜訊起點 +// 關懷型 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(絕對下限, top × 比例)`。 +// 實測五組(上表+「閉環機是什麼」「火星座標 奧林帕斯山」): +// 固定 0.5 → 正解全留,但混入 9 筆雜訊 +// 固定 0.6 → 雜訊 0,但「閉環機」兩組正解被砍到 2/4、1/4 +// 相對 → 四組雜訊 0 且正解全留;「火星座標」留 3/6 +// (被砍的是同一份檔的其他段落,使用者照樣找得到那份檔) +// 絕對下限的作用:整批分數都很低時(查詢與知識庫無關),純比例會讓垃圾等比放行 ⇒ 兜底。 +const MIN_SCORE_ABS_FLOOR = 0.45; +const MIN_SCORE_TOP_RATIO = 0.8; + +/** + * 相對門檻:由「這批結果的最高分」推出要砍在哪。 + * + * 🔴 為什麼不在 semanticSearch 裡直接套(寫測試時才發現的真問題,不是 fixture 過時): + * Vectorize 端**不知道哪些已下架**(indexed metadata 沒有 status,見 upsert)。 + * 若最高分那筆是已下架的殘影(實測有 0.971 這種),拿它當基準算出的門檻 + * 會把真正的正解(0.6)一起砍光 ⇒ **又變成 0 命中**,正是 leo 08-05 早上撞的那個病。 + * ⇒ 門檻必須在「hydrate+濾掉下架」**之後**、對倖存者的最高分計算(見 routes/entries.ts)。 + */ +export function relativeMinScore(topScore: number): number { + return Math.max(MIN_SCORE_ABS_FLOOR, topScore * MIN_SCORE_TOP_RATIO); +} /** 實際使用的嵌入模型:env 可覆寫(#59),未設用預設。 */ function embedModel(env: Bindings): string { @@ -180,7 +207,10 @@ export async function backfillEmbeddings( ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE; - const conds = [basePredicate]; + // 🔴 2026-08-05:**已下架的一律不嵌**(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」)。 + // 沒有這條,下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活, + // 而且 `reindex=true` 那條路更嚴重(它連 is_embedded=1 的都重推)。 + const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"]; const params: unknown[] = []; if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); } if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); } @@ -294,7 +324,8 @@ export async function semanticSearch( returnMetadata: 'indexed', ...(Object.keys(filter).length ? { filter } : {}), }); - const minScore = opts.min_score ?? DEFAULT_MIN_SCORE; + // 這裡只套**絕對下限**;相對門檻要等「濾掉已下架」之後才能算(見 relativeMinScore 的註解)。 + const minScore = opts.min_score ?? MIN_SCORE_ABS_FLOOR; return (res.matches ?? []) .filter((m) => m.score >= minScore) .map((m) => ({ diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index 4315d65..ec4aa5e 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -4,6 +4,8 @@ import type { Bindings } from '../types'; import { createEntry, deprecateEntriesByLibrary, + embeddedIdsByLibrary, + markUnembedded, getEntry, listEntries, updateEntry, @@ -11,7 +13,7 @@ import { searchEntries, isDeprecatedEntry, } from '../actions/entry-crud'; -import { embedEnabled, embedOnWrite, semanticSearch } from '../embed'; +import { embedEnabled, embedOnWrite, semanticSearch, relativeMinScore } from '../embed'; export const entryRoutes = new Hono<{ Bindings: Bindings }>(); @@ -170,6 +172,14 @@ entryRoutes.get('/search', async (c) => { if (!include_deprecated) { entries = entries.filter((e) => !isDeprecatedEntry(e)); } + // 🔴 2026-08-05:相對門檻砍低分尾(leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關)。 + // **一定要接在濾掉下架的後面**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走 + // (=同日早上「0 命中」的翻版;t24 的 0.971 復現案就是這種殘影)。 + // caller 顯式帶 min_score 時尊重他的絕對值,不再加碼。 + if (min_score === undefined && entries.length > 1) { + const cut = relativeMinScore(entries[0].score); + entries = entries.filter((e) => e.score >= cut); + } // 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。 entries = entries.slice(0, requestedTopK); return c.json({ success: true, entries, count: entries.length, mode: 'semantic' }); @@ -194,8 +204,22 @@ entryRoutes.patch('/deprecate-by-library', async (c) => { const ownerId = String(body?.owner_id ?? '').trim(); const library = String(body?.library ?? '').trim(); if (!ownerId || !library) return c.json({ success: false, error: 'owner_id 與 library 必填' }, 400); + // 🔴 2026-08-05(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」): + // 先撈 id 再標下架——順序反過來就撈不到「還有向量」的那批(標完 status 不影響 is_embedded, + // 但先撈比較不依賴欄位語意,也讓失敗時不會留下「已標下架但向量還在」的中間態)。 + const ids = embedEnabled(c.env) ? await embeddedIdsByLibrary(c.env.DB, ownerId, library) : []; const count = await deprecateEntriesByLibrary(c.env.DB, ownerId, library); - return c.json({ success: true, deprecated_count: count }); + let vectors_deleted = 0; + if (ids.length > 0) { + // 刪向量+把 is_embedded 歸零(讓 D1 與 Vectorize 不說兩套話)。 + // 失敗不擋下架本體:D1 已標 deprecated,搜尋端仍會濾掉;殘留向量下次再清。 + try { + await c.env.VECTORIZE!.deleteByIds(ids); + await markUnembedded(c.env.DB, ids); + vectors_deleted = ids.length; + } catch { /* 誠實回 0,不假裝清乾淨了 */ } + } + return c.json({ success: true, deprecated_count: count, vectors_deleted }); }); // PATCH /entries/:id diff --git a/kbdb/tests/search-deprecated-filter.test.ts b/kbdb/tests/search-deprecated-filter.test.ts index 23bcc55..737e585 100644 --- a/kbdb/tests/search-deprecated-filter.test.ts +++ b/kbdb/tests/search-deprecated-filter.test.ts @@ -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 }[] = []; + 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 }[] = []; + 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 }[] = []; + const matches = [ + { id: 'dep-ghost', score: 0.971 }, // 下架殘影,分數卻最高 + { id: 'real1', score: 0.60 }, { id: 'real2', score: 0.52 }, + ]; + const entryMeta: Record = { '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 }[] = []; + 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 留著 + }); +}); diff --git a/kbdb/tests/search-source-and-score.test.ts b/kbdb/tests/search-source-and-score.test.ts index dc7bf43..cd69275 100644 --- a/kbdb/tests/search-source-and-score.test.ts +++ b/kbdb/tests/search-source-and-score.test.ts @@ -184,14 +184,15 @@ 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),套用預設閾值後回 2 筆,entry 仍附 score(加欄不改形)', async () => { + it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),套用相對門檻後只回最高分那筆,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(2); // 08-05:預設閾值生效,0.2 的低分尾被砍 + // 08-05:未帶 min_score ⇒ 相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只有 0.9 留下 + expect(body.count).toBe(1); expect(body.entries[0].score).toBe(0.9); // 原有欄位一個不少(回應形狀向後相容) expect(body.entries[0].id).toBe('e-high'); @@ -206,8 +207,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 - // 壞值=視同沒帶 ⇒ 落回預設閾值(08-05 起非 0),故仍砍掉 0.2 的低分尾。 - expect(body.count).toBe(2); + // 壞值=視同沒帶 ⇒ 落回相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只留最高分那筆。 + expect(body.count).toBe(1); } });