語意門檻改相對式+下架連帶刪向量(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:
uncle6me-web
2026-08-05 18:47:10 +08:00
parent 0ff369f818
commit 05b215c9f7
5 changed files with 161 additions and 10 deletions
+26 -2
View File
@@ -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