fix(kbdb/embed): D68 補算向量照新到舊排序+每日額度上限+修復舊世代 is_embedded 誤判

leo 2026-08-11 拍板(D68,system-dev/wiki/decisions-summary.md):補算向量要照時間
由新到舊、且每天有額度上限,不能一次把 Workers AI 每日免費 10,000 neurons 燒光
(與萃取共用同一份額度,見 ops-facts.md)。對應 Leo/Arcrun#85 列出的三個缺口:
① 補算是由舊到新(ORDER BY created_at ASC)② 沒有每日額度上限 ③ 沒有任何自動觸發。

改動:
- backfillEmbeddings:ORDER BY created_at DESC(新到舊),並在打 AI 前依
  env.EMBED_BACKFILL_DAILY_LIMIT(未設用推導出的預設值 1800,算式見 embed.ts 註解)
  截斷候選、額度用完即停手不再打 AI。額度用量存在 entries 表單一列
  (entry_type='embed_backfill_usage',UTC 日期切),不新增表(D38)。
- embedOnWrite / backfillEmbeddings 成功嵌入後在既有 content_hash 欄位蓋上
  現行模型名(世代戳記),修復 leo21c 資料還原案:從備份整批灌回的列帶著對已退役
  768 維索引的 is_embedded=1,現行 1024 維索引永遠不會補到它們。
- 新增 reconcileEmbedGeneration + POST /embed/reconcile:對 is_embedded=1 但
  content_hash 非現行世代的候選,問 Vectorize.getByIds 是否真的在現行 index——
  在→只補 content_hash 不打 AI;不在→重置 is_embedded=0 交回正常 backfill 佇列。
- 新增 kbdb/tests/embed-backfill.test.ts(改走真 SQLite,比舊版手刻假 DB 更硬):
  14 個測試涵蓋新到舊排序、額度真的擋(含「拿掉 cap 會變紅」的反向驗證)、
  世代核對端到端(reconcile → 重置 → backfill 真的補回來)、既有行為不迴歸。

現況誠實回報:目前沒有任何東西會自動觸發補算(無 cron/scheduled handler)——
唯一的「自動」路徑是 entries.ts 的語意搜尋回 0 命中時 fire-and-forget 觸發一次
(既有行為,本次未改動),仍需人或 CC 主動呼叫 /embed/backfill 或掛排程。

紅線:未動 leo 正式實例 leo21c;未動資料層形狀(三表不變,仍走既有 content_hash
bookkeeping 欄);未 push main,本 commit 在獨立分支 fix/embed-backfill-d68。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-11 16:42:54 +08:00
parent 507620e313
commit 1d6dde4a01
4 changed files with 566 additions and 118 deletions
+225 -8
View File
@@ -132,7 +132,12 @@ export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean
},
]);
// 標記 bookkeeping(既有欄,base 不讀、僅供「已 embed」可查)。不動表結構。
await env.DB.prepare('UPDATE entries SET is_embedded = 1 WHERE id = ?').bind(entry.id).run();
// content_hash 順手蓋成「這次嵌入用的模型」(世代戳記,見下方 reconcileEmbedGeneration 的
// 說明)——這裡是「新寫的立刻算」的路徑,寫入當下 model 必為現行 model,不會有世代落差。
await env.DB
.prepare('UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id = ?')
.bind(embedModel(env), entry.id)
.run();
return true;
}
@@ -173,12 +178,96 @@ function parseMeta(json: string | null): Record<string, unknown> | null {
const BACKFILL_PREDICATE =
"is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
// ── 每日額度上限(D682026-08-11:leo「補算向量照時間新到舊、且每天有額度上限」)─────────
//
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日免費 10,000 neuronsUTC 午夜重置,
// 見頂層 wiki ops-facts.md「萃取與向量化吃同一份 Workers AI 額度」)。backfill 是背景低優先
// 動作,不該把當天額度燒光讓萃取/今天的新寫入整天卡死(embedOnWrite 不受此上限——「新寫的
// 立刻算」是 D68 三條之一,不能被 backfill 的節制連坐)。自設「軟上限」,非 Cloudflare 硬限制,
// 可用 env.EMBED_BACKFILL_DAILY_LIMIT 覆寫(精神比照 execution-log.ts 的 DEFAULT_DAILY_LIMIT)。
//
// 預設值怎麼選(不是拍腦袋,2026-08-11 查證 Cloudflare 官方定價後回推):
// bge-m3 定價:1,075 neurons / 1,000,000 input tokens(無輸出 token 成本,embedding 只有輸入)。
// 保守估計每筆中文知識卡片 ~800 tokens(寧可高估——CJK tokenizer 密度通常高於英文,
// 高估 token 數 ⇒ 算出的「每日可嵌筆數」偏保守,不會撞真的 CF 額度):
// 800 tokens × 1,075 / 1,000,000 ≈ 0.86 neurons/entry
// backfill 分到日配額 20%(比照 execution-log.ts「自我節制、留大部分給主流程」的既有慣例):
// 10,000 × 20% = 2,000 neurons/日
// 2,000 ÷ 0.86 ≈ 2,325 entries/日,再打八折留緩衝(token 估計誤差/其他背景消耗):
// 2,325 × 0.8 ≈ 1,860 → 取整數 1,800。
const DEFAULT_BACKFILL_DAILY_LIMIT = 1800;
function backfillDailyLimit(env: Pick<Bindings, 'EMBED_BACKFILL_DAILY_LIMIT'>): number {
const raw = env.EMBED_BACKFILL_DAILY_LIMIT;
const n = raw ? parseInt(raw, 10) : NaN;
return Number.isFinite(n) && n > 0 ? n : DEFAULT_BACKFILL_DAILY_LIMIT;
}
function utcDay(): string {
return new Date().toISOString().slice(0, 10);
}
/** 額度計數器 entries id(單一列/日,UTC 日期字串,換日自然歸零;不分租戶——Workers AI 額度是帳號級)。 */
function backfillUsageId(): string {
return `embed-backfill-usage:${utcDay()}`;
}
/**
* 今天 backfill 已消耗的筆數。儲存精神完全比照 execution-log.ts 的 checkUsage:單一 entries 列/日
* entry_type='embed_backfill_usage',計數包進 metadata_json),不新增表。
* 讀取失敗(含壞資料)誠實視為 0(caller 決定是否 fail-open)。
*/
async function getBackfillUsageToday(db: D1Database): Promise<number> {
const row = await db
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
.bind(backfillUsageId())
.first<{ metadata_json: string | null }>();
if (!row) return 0;
try {
const parsed = row.metadata_json ? (JSON.parse(row.metadata_json) as { embedded?: number }) : {};
return Number(parsed.embedded) || 0;
} catch {
return 0; // 壞資料誠實視為 0,不讓損毀的計數器卡死額度機制
}
}
/** 今天 backfill 額度用量 +byupsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */
async function addBackfillUsage(db: D1Database, by: number): Promise<void> {
if (by <= 0) return;
const id = backfillUsageId();
const existing = await db
.prepare('SELECT metadata_json FROM entries WHERE id = ?')
.bind(id)
.first<{ metadata_json: string | null }>();
let prev = 0;
if (existing) {
try {
const parsed = existing.metadata_json ? (JSON.parse(existing.metadata_json) as { embedded?: number }) : {};
prev = Number(parsed.embedded) || 0;
} catch {
prev = 0;
}
await db
.prepare('UPDATE entries SET metadata_json = ?, updated_at = unixepoch() WHERE id = ?')
.bind(JSON.stringify({ day: utcDay(), embedded: prev + by }), id)
.run();
} else {
await db
.prepare(`INSERT INTO entries (id, entry_type, metadata_json) VALUES (?, 'embed_backfill_usage', ?)`)
.bind(id, JSON.stringify({ day: utcDay(), embedded: by }))
.run();
}
}
export interface BackfillResult {
enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。
processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。
skipped: number; // 掃到但沒嵌(例如 embedText 回 null)的筆數。
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0)。
skipped: number; // 掃到但沒嵌(例如 embedText 回 null,或本批被額度擋下)的筆數。
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0,與額度無關——單純候選總量)。
scanned: number; // 本批掃出的候選筆數(受 limit 限制)。
quota_limit: number; // 今日 backfill 額度上限(env.EMBED_BACKFILL_DAILY_LIMIT 或預設值)。
quota_used_today: number; // 本次呼叫後,今日累積已消耗的 backfill 額度。
quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有可嵌的候選但今天不再打 AI,等明天/調高上限)。
}
/**
@@ -195,7 +284,12 @@ export async function backfillEmbeddings(
env: Bindings,
opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {},
): Promise<BackfillResult> {
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
if (!embedEnabled(env)) {
return {
enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0,
quota_limit: 0, quota_used_today: 0, quota_exceeded: false,
};
}
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
const offset = Math.max(opts.offset ?? 0, 0);
@@ -216,15 +310,31 @@ export async function backfillEmbeddings(
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
const where = conds.join(' AND ');
// D68:由新到舊——最可能被查到的最先補回來(見檔頭 DEFAULT_BACKFILL_DAILY_LIMIT 段的決策脈絡)。
const res = await env.DB
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`)
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`)
.bind(...params, limit, offset)
.all<Entry>();
const rows = res.results ?? [];
const scanned = rows.length;
// D68:每日額度上限。額度是「這次呼叫要不要打 AI」的唯一守門——reindex 一樣要打 AI.run
// 同樣受限(不因為是 reindex 就例外,會打 Workers AI 的動作都算)。
const dailyCap = backfillDailyLimit(env);
let usedToday = 0;
try {
usedToday = await getBackfillUsageToday(env.DB);
} catch {
usedToday = 0; // fail-open:計數器本身故障(含 D1 額度打滿)不該連 backfill 都不做
}
const remainingQuota = Math.max(0, dailyCap - usedToday);
let processed = 0;
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
const candidates = rows.filter((e) => (e.content ?? '').trim().length > 0);
// 額度截斷:candidates 已按 created_at DESC 排序,取前 remainingQuota 筆=優先保留最新的。
const embeddable = candidates.slice(0, remainingQuota);
const quotaExceeded = candidates.length > embeddable.length;
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
const texts = embeddable.map((e) => (e.content ?? '').trim());
const out = (await env.AI.run(embedModel(env), { text: texts })) as { data: number[][] };
@@ -246,8 +356,18 @@ export async function backfillEmbeddings(
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();
// content_hash 順手蓋成現行模型(世代戳記,見 reconcileEmbedGeneration)。
await env.DB
.prepare(`UPDATE entries SET is_embedded = 1, content_hash = ? WHERE id IN (${placeholders})`)
.bind(embedModel(env), ...ids)
.run();
processed = vectors.length;
try {
await addBackfillUsage(env.DB, processed);
} catch {
// fail-open:額度計數寫入失敗不影響已經完成的嵌入(別讓 bookkeeping 故障吞掉已做的工);
// 代價是下次呼叫可能少算一點用量——比「明明做了卻沒生效」安全(誠實限制,mindset §7)。
}
}
}
@@ -259,7 +379,16 @@ export async function backfillEmbeddings(
// 非 reindexpredicate 含 is_embedded=0,處理後該筆變 1 → COUNT 自然遞減(重呼直到 0)。
// reindexpredicate 不含 is_embeddedCOUNT 恆等於總數 → 改用 offset 分頁計 remaining(否則永不終止)。
const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
return { enabled: true, processed, skipped: scanned - processed, remaining, scanned };
return {
enabled: true,
processed,
skipped: scanned - processed,
remaining,
scanned,
quota_limit: dailyCap,
quota_used_today: usedToday + processed,
quota_exceeded: quotaExceeded,
};
}
/** 補嵌進度統計(回報用;模組未開仍可查 pending 數,誠實標 enabled:false)。 */
@@ -283,6 +412,94 @@ export async function backfillStatus(
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
}
export interface ReconcileResult {
enabled: boolean;
checked: number; // 本批檢查筆數(is_embedded=1 且 content_hash 非現行世代的候選)。
confirmed_current: number; // 核對後確認已在現行 Vectorize index:只補標 content_hash,未打 AI。
reset_to_pending: number; // 核對後確認不在現行 index:重置 is_embedded=0,回到正常 backfill 佇列。
remaining: number; // 本次之後仍待核對的筆數(可重複呼叫直到 0)。
}
/**
* 世代核對(Generation reconciliationD68 配套修復,2026-08-11)。
*
* 背景:`is_embedded=1` 只代表「曾經對某個 Vectorize index 嵌過」,不保證是**現行**的
* index/模型(見檔頭 2026-08-03 換代註解:換模型必須換 index,舊向量收不進新 index、也刪不掉)。
* 從備份整批灌回的資料尤其會帶著對**已退役索引**(例:768 維 `arcrun-kbdb-embed`)的
* `is_embedded=1`——現行 backfill 的預設路徑(只補 `is_embedded=0`)永遠不會碰它們,
* 語意搜尋對現行(1024 維 `arcrun-kbdb-embed-m3`)索引而言永遠搜不到那批東西,畫面不會說壞掉。
*
* 做法:不猜(`is_embedded` 本身此刻不可信),直接問現行 Vectorize index「這些 id 真的在你這嗎」
* `env.VECTORIZE.getByIds`ground truth,而非比對 content_hash 字串本身——後者在這次修復
* 之前從未被寫過,所有既有 is_embedded=1 的列 content_hash 皆為 NULL,無法只憑字串判斷「哪些是
* 這次修復前的正常資料、哪些是真正的舊世代殘留」,必須問 Vectorize 本身):
* - 真的在現行 index → 只是這次修復之前的正常資料,沒補寫過 content_hash。補標記,不重打 AI
* (不浪費額度在已經正確的資料上)。
* - 不在現行 index → 對現行 index 而言等於沒嵌過,重置 is_embedded=0、清空 content_hash
* 交回正常 backfill 佇列(下一輪照樣受「新到舊」排序+每日額度上限保護,不特別優待)。
*
* 不消耗 Workers AI 額度:零 AI.run,只有一次 D1 掃描 + 一次 Vectorize.getByIds + D1 寫回。
*/
export async function reconcileEmbedGeneration(
env: Bindings,
opts: { limit?: number; owner_id?: string } = {},
): Promise<ReconcileResult> {
if (!embedEnabled(env)) return { enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0 };
const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200);
const currentModel = embedModel(env);
const conds = [
'is_embedded = 1',
'(content_hash IS NULL OR content_hash != ?)',
"COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'",
];
const params: unknown[] = [currentModel];
if (opts.owner_id) {
conds.push('owner_id = ?');
params.push(opts.owner_id);
}
const where = conds.join(' AND ');
const res = await env.DB
.prepare(`SELECT id FROM entries WHERE ${where} ORDER BY created_at DESC LIMIT ?`)
.bind(...params, limit)
.all<{ id: string }>();
const ids = (res.results ?? []).map((r) => r.id);
const checked = ids.length;
let confirmed_current = 0;
let reset_to_pending = 0;
if (ids.length > 0 && env.VECTORIZE) {
const found = await env.VECTORIZE.getByIds(ids);
const foundIds = new Set(found.map((v) => v.id));
const presentIds = ids.filter((id) => foundIds.has(id));
const missingIds = ids.filter((id) => !foundIds.has(id));
if (presentIds.length > 0) {
const ph = presentIds.map(() => '?').join(',');
await env.DB
.prepare(`UPDATE entries SET content_hash = ? WHERE id IN (${ph})`)
.bind(currentModel, ...presentIds)
.run();
confirmed_current = presentIds.length;
}
if (missingIds.length > 0) {
const ph = missingIds.map(() => '?').join(',');
await env.DB
.prepare(`UPDATE entries SET is_embedded = 0, content_hash = NULL WHERE id IN (${ph})`)
.bind(...missingIds)
.run();
reset_to_pending = missingIds.length;
}
}
const remRow = await env.DB
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
.bind(...params)
.first<{ c: number }>();
return { enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0 };
}
export interface SelfTestResult {
enabled: boolean; // embed 模組是否開(binding 都在)
tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗)
+21 -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, embedSelfTest } from '../embed';
import { embedEnabled, backfillEmbeddings, backfillStatus, embedSelfTest, reconcileEmbedGeneration } from '../embed';
export const embedRoutes = new Hono<{ Bindings: Bindings }>();
@@ -56,6 +56,26 @@ embedRoutes.get('/backfill/status', async (c) => {
return c.json({ success: true, ...status });
});
// POST /embed/reconcile — 世代核對(D68 配套修復,2026-08-11):
// 對「is_embedded=1 但 content_hash 非現行模型」的候選,問現行 Vectorize index 是否真的收錄;
// 真的在 → 補標 content_hash(不打 AI);不在 → 重置 is_embedded=0,回到正常 /embed/backfill 佇列。
// 解「從備份整批灌回、帶著對已退役索引的 is_embedded=1,永遠不被 backfill 碰到」這個坑。
// body(皆選填):{ limit?:1-200(預設50, owner_id? }。重複呼叫直到 remaining=0。
embedRoutes.post('/reconcile', async (c) => {
if (!embedEnabled(c.env)) {
return c.json(
{ success: false, error: 'embed module not enabled (need VECTORIZE + AI bindings)', capability_hint: OFF_HINT },
409,
);
}
const body = (await c.req.json().catch(() => ({}))) as { limit?: number | string; owner_id?: string };
const result = await reconcileEmbedGeneration(c.env, {
limit: body.limit !== undefined ? Number(body.limit) : undefined,
owner_id: body.owner_id || undefined,
});
return c.json({ success: true, ...result });
});
// GET /embed/selftest?owner_id= — 語義自我檢查(檢修孔,2026-08-07):
// 挑一筆已嵌入的卡片,拿它自己的內容查自己,只回布林診斷(不回卡片內容、不回 entry id)。
// 計數(backfill/status)看不出「嵌了但查不到」這種故障模式(Arcrun#11 撞過的真實案例),
+7 -1
View File
@@ -24,6 +24,11 @@ export type Bindings = {
// kbdb/src/actions/execution-log.ts DEFAULT_DAILY_LIMIT 說明)。未設 → 20000
// D1 100,000 rows written/日的 20%,留 80% 給知識卡 entries)。
EXECUTION_LOG_DAILY_WRITE_LIMIT?: string;
// embed backfill 每日軟上限(D682026-08-11:補算向量照時間新到舊、且每天有額度上限)。
// backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日 10,000 免費 neurons(見頂層
// wiki ops-facts.md);backfill 是背景低優先動作,自設軟上限不把當天額度燒光。未設 → 見
// kbdb/src/embed.ts DEFAULT_BACKFILL_DAILY_LIMIT 說明(含選值算式,非拍腦袋)。
EMBED_BACKFILL_DAILY_LIMIT?: string;
};
export type EntryType =
@@ -35,7 +40,8 @@ export type EntryType =
| 'workflow'
| 'recipe_stat'
| 'execution_log'
| 'execution_log_usage';
| 'execution_log_usage'
| 'embed_backfill_usage';
export interface Entry {
id: string;