語意門檻改相對式+下架連帶刪向量(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
+31
View File
@@ -134,6 +134,37 @@ export async function deleteEntry(db: D1Database, id: string): Promise<void> {
* 沿用既有 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<string[]> {
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<void> {
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<number> {
const result = await db
.prepare(