kbdb(embed): add batch backfill endpoint for pre-Vectorize entries (issue #7 / T2.4 缺口)
embed 模組原本只有 embedOnWrite(寫入即嵌),對「開 Vectorize binding 之前就寫入」或 embed-on-write 當時漏掉的既有 entry 沒有回填路徑 → is_embedded=0 永遠補不回,語義查詢回 0 筆。 新增(base,對 entries 做;embedding 是 base 唯一職責,非 graph 插件): - embed.ts: backfillEmbeddings()——找 is_embedded=0 且 isEmbeddable(metadata.embed===true) 的 entry, 批次補嵌(單次 AI.run 陣列 + 單次 VECTORIZE.upsert 陣列 + 單次 UPDATE IN,一批≈3 subrequest)、 設 is_embedded=1,冪等、分批(limit 1-100,回傳 processed/remaining,可重複呼叫直到清零)。 模組未開誠實回 enabled:false(不假綠)。backfillStatus() 回 pending/embedded 計數。 - routes/embed.ts: POST /embed/backfill、GET /embed/backfill/status;模組未開回 409 + capability_hint。 - index.ts: mount /embed。 - tests/embed-backfill.test.ts: 5 vitest(off no-op / 補嵌+標記 / 冪等 / 分批 remaining / status)。 base 維持對內容語意無知(只認通用 embed 旗標,不知 triplet/wiki)。tsc exit 0、vitest 5/5。 端到端(leo21c,wrangler 直推、非 acr update):pending 5→processed 5→remaining 0, Vectorize vectorCount 0→5,/entries/search?mode=semantic 由 0 筆→5 筆(語義排序命中)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BDtnGPJpAzp8UqHfAo8o1s
This commit is contained in:
@@ -79,6 +79,107 @@ function parseMeta(json: string | null): Record<string, unknown> | null {
|
||||
}
|
||||
}
|
||||
|
||||
// SQL predicate for "an entry that SHOULD be embedded but isn't yet".
|
||||
// - isEmbeddable 契約 = metadata_json.embed === true(base 通用旗標,對內容語意無知,不寫死 entry_type)。
|
||||
// SQLite json_extract 對 JSON boolean true 回整數 1 → `= 1` 精確對齊 TS 的 `=== true`。
|
||||
// - is_embedded = 0:尚未(對「當前」index)補嵌的 bookkeeping。
|
||||
// - content 非空:空字串 embedText 會回 null,排除以免變成永遠清不掉的殘留候選。
|
||||
const BACKFILL_PREDICATE =
|
||||
"is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
|
||||
|
||||
export interface BackfillResult {
|
||||
enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。
|
||||
processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。
|
||||
skipped: number; // 掃到但沒嵌(例如 embedText 回 null)的筆數。
|
||||
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0)。
|
||||
scanned: number; // 本批掃出的候選筆數(受 limit 限制)。
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill(回填):對「開 Vectorize 之前就寫入、或 embed-on-write 當時漏掉」的既有 entry 批次補嵌。
|
||||
* 冪等(重跑已補嵌的不會重複算,upsert 同 id 冪等)、分批(單次 limit 上限,避開 subrequest/CPU/timeout)、
|
||||
* 回傳處理筆數 + 剩餘筆數(caller 重複呼叫直到 remaining=0)。
|
||||
* - 模組未開(無 VECTORIZE+AI)→ 誠實回 { enabled:false },不假裝成功(mindset §7 禁假綠)。
|
||||
* - 只補「isEmbeddable(metadata.embed===true)且 is_embedded=0」的 entry——與 embedOnWrite 同一契約,
|
||||
* base 維持對內容語意無知(不知 triplet/wiki,只認通用 embed 旗標)。
|
||||
* - 效率:整批用「單次 AI.run(陣列輸入)+ 單次 VECTORIZE.upsert(陣列)+ 單次 UPDATE ... IN(...)」,
|
||||
* 一批 ≈ 3 個 subrequest,不隨 limit 線性增長 → free/paid tier 都安全。
|
||||
*/
|
||||
export async function backfillEmbeddings(
|
||||
env: Bindings,
|
||||
opts: { limit?: number; owner_id?: string; source?: string } = {},
|
||||
): Promise<BackfillResult> {
|
||||
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
|
||||
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
|
||||
|
||||
const conds = [BACKFILL_PREDICATE];
|
||||
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); }
|
||||
const where = conds.join(' AND ');
|
||||
|
||||
const res = await env.DB
|
||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`)
|
||||
.bind(...params, limit)
|
||||
.all<Entry>();
|
||||
const rows = res.results ?? [];
|
||||
const scanned = rows.length;
|
||||
|
||||
let processed = 0;
|
||||
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
|
||||
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
|
||||
const texts = embeddable.map((e) => (e.content ?? '').trim());
|
||||
const out = (await env.AI.run(EMBED_MODEL, { text: texts })) as { data: number[][] };
|
||||
const data = out?.data ?? [];
|
||||
const vectors = embeddable
|
||||
.map((e, i) => ({ e, vec: data[i] }))
|
||||
.filter((x): x is { e: Entry; vec: number[] } => Array.isArray(x.vec) && x.vec.length > 0)
|
||||
.map((x) => ({
|
||||
id: x.e.id,
|
||||
values: x.vec,
|
||||
metadata: {
|
||||
owner_id: x.e.owner_id ?? '',
|
||||
entry_type: x.e.entry_type,
|
||||
source: readSource(x.e) ?? '',
|
||||
},
|
||||
}));
|
||||
if (vectors.length > 0) {
|
||||
await env.VECTORIZE.upsert(vectors);
|
||||
const ids = vectors.map((v) => v.id);
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run();
|
||||
processed = vectors.length;
|
||||
}
|
||||
}
|
||||
|
||||
const remRow = await env.DB
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
return { enabled: true, processed, skipped: scanned - processed, remaining: remRow?.c ?? 0, scanned };
|
||||
}
|
||||
|
||||
/** 補嵌進度統計(回報用;模組未開仍可查 pending 數,誠實標 enabled:false)。 */
|
||||
export async function backfillStatus(
|
||||
env: Bindings,
|
||||
opts: { owner_id?: string; source?: string } = {},
|
||||
): Promise<{ enabled: boolean; pending: number; embedded: number }> {
|
||||
const conds: string[] = [];
|
||||
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); }
|
||||
const extra = conds.length ? ` AND ${conds.join(' AND ')}` : '';
|
||||
const pendingRow = await env.DB
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${BACKFILL_PREDICATE}${extra}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
const embeddedRow = await env.DB
|
||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE is_embedded = 1 AND json_extract(metadata_json, '$.embed') = 1${extra}`)
|
||||
.bind(...params)
|
||||
.first<{ c: number }>();
|
||||
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
|
||||
}
|
||||
|
||||
export interface SemanticHit {
|
||||
id: string;
|
||||
score: number;
|
||||
|
||||
Reference in New Issue
Block a user