feat(kbdb): embed 自我檢查端點(檢修孔第一塊,2026-08-07 leo 直接指令)

GET /embed/selftest?owner_id= —— 挑一筆已標記「已嵌入」的卡片,拿它自己的內容做一次
真實語義查詢,檢查「自己是否搜得到自己」。backfillStatus 的 pending/embedded 計數
看不出 Arcrun#11 那種「嵌了但查不到」的故障模式(metadata index 事後才建、既有向量
沒被收錄),本端點是唯一能端到端驗證 index 真的可用的方法。

隱私邊界:只回 {enabled, tested, passed, note} 四個布林/字串欄位,不回卡片內容、
不回 entry id(測試 embed-selftest.test.ts 最後一案專門斷言不洩漏)。

12/12 kbdb vitest 全綠(含既有 embed-backfill 6 案未壞)。
This commit is contained in:
uncle6me-web
2026-08-07 18:35:06 +08:00
parent 7ba78552a4
commit 9344562258
3 changed files with 177 additions and 1 deletions
+57
View File
@@ -283,6 +283,63 @@ export async function backfillStatus(
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
}
export interface SelfTestResult {
enabled: boolean; // embed 模組是否開(binding 都在)
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測)
note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用
}
/**
* Embed 自我檢查(檢修孔用,2026-08-07 leo 直接指令:「先把檢修孔做出來發版」)。
*
* 為什麼需要這個,不只是 backfillStatus 的 pending/embedded 計數:08-05 撞過的真實故障
* 是「is_embedded=1(已嵌入)但語義搜尋還是搜不到」——metadata index 事後才建,既有向量
* 沒被收錄(Arcrun#11)。計數看不出這種病,因為計數只問「有沒有嵌」,不問「嵌完查得到嗎」。
* 本函式挑一筆「已標記已嵌入」的既有 entry,拿它自己的內容做一次真實語義查詢,檢查
* 「自己是否搜得到自己」——這是唯一能端到端驗證 index 真的可用的方法。
*
* 隱私邊界(檢修孔規格紅線:診斷檔不准帶卡片內容本體):本函式只回布林 + 一句話 note,
* 不回傳卡片內容、不回傳 entry id。取樣內容只在函式內部這一次查詢中用過即丟。
*/
export async function embedSelfTest(
env: Bindings,
opts: { owner_id?: string } = {},
): Promise<SelfTestResult> {
if (!embedEnabled(env)) {
return { enabled: false, tested: false, passed: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' };
}
const conds = ["is_embedded = 1", "content IS NOT NULL AND content <> ''"];
const params: unknown[] = [];
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
const where = conds.join(' AND ');
const row = await env.DB
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY updated_at DESC LIMIT 1`)
.bind(...params)
.first<Entry>();
if (!row) {
return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' };
}
const sample = (row.content ?? '').trim().slice(0, 200);
if (!sample) {
return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' };
}
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
const hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 });
if (hits === null) {
return { enabled: false, tested: false, passed: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' };
}
const passed = hits.some((h) => h.id === row.id);
return {
enabled: true,
tested: true,
passed,
note: passed
? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的'
: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)',
};
}
export interface SemanticHit {
id: string;
score: number;
+11 -1
View File
@@ -8,7 +8,7 @@
// base 對內容語意無知:只認通用 metadata.embed===true 旗標,不知 triplet/wiki(解耦)。
import { Hono } from 'hono';
import type { Bindings } from '../types';
import { embedEnabled, backfillEmbeddings, backfillStatus } from '../embed';
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest } from '../embed';
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
@@ -56,4 +56,14 @@ embedRoutes.get('/backfill/status', async (c) => {
return c.json({ success: true, ...status });
});
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),
// 本端點端到端驗證 index 真的可用。模組未開仍誠實回 enabled:false(不 409,讓檢修孔
// 永遠能拿到一個可解讀的結論,不必先判斷該不該打這支)。
embedRoutes.get('/selftest', async (c) => {
const result = await embedSelfTest(c.env, { owner_id: c.req.query('owner_id') || undefined });
return c.json({ success: true, ...result });
});
export default embedRoutes;