語意門檻改相對式+下架連帶刪向量(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:
@@ -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(
|
||||
|
||||
+34
-3
@@ -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) => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user