// KBDB optional embed module (issue #7 / mira-dissolve SDD T2.4). // // 鐵律對齊: // - embedding 屬 **base 的 optional 模組**(非 graph/ingest)。CF 內建(Vectorize+AI),程式薄。 // - **不拆 repo,binding 開/關**:有 env.VECTORIZE + env.AI 才啟用;沒有 → base 維持 LIKE keyword,API 不變。 // - 不動三表結構(只標既有 entries.is_embedded / content_hash bookkeeping 欄;那些 base 從不讀,embed 才寫)。 // - 不對每個 block 地毯式 embed(精耕,非 RAG 一股腦灌):只 embed「被標記為 embeddable」的 entry // (wiki 段落 + graph node gloss)。標記方式=寫入時 metadata_json.embed === true(caller 顯式標)。 // // 為何用 metadata flag 而非 entry_type 白名單:base 不該寫死「哪些 entry_type 該 embed」(那是上游語意, // 會讓 base 知道 wiki/graph 概念,破壞解耦)。改由 caller(wiki/gloss 寫入端)顯式標 embed:true, // base 只認這個通用旗標 → base 維持對內容語意無知。 import type { Bindings, Entry } from './types'; import { maintenanceBudgetToday, addMaintenanceUsage } from './actions/maintenance-quota'; // ── 嵌入模型(Arcrun#59:模型應可配置+index 版本化,支援換代重刷)──────────────── // // 2026-08-03 換代:`@cf/baai/bge-base-en-v1.5`(768-dim)→ `@cf/baai/bge-m3`(1024-dim)。 // // 為什麼換(實測,不是憑感覺):舊模型是**英文模型**,拿來嵌中文等於嵌一堆看不懂的 token。 // 用 5 組中文問答測資(每組 1 問 + 2 段相關 + 3 段無關,無關的刻意放同一知識庫裡的其他主題), // 算 margin = min(相關分數) − max(無關分數),margin ≤ 0 代表**排序是錯的**: // @cf/baai/bge-base-en-v1.5 768 排序正確 2/5 平均 margin -0.0413 1660 ms ← 舊 // @cf/google/embeddinggemma-300m 768 4/5 +0.1275 1174 ms // @cf/baai/bge-m3 1024 **5/5** **+0.1410** 959 ms ← 新(品質最好且最快) // @cf/qwen/qwen3-embedding-0.6b 1024 4/5 +0.1381 3238 ms // 舊模型最刺眼的一組:問「知識庫問答為什麼要標出處?」→「**會議室預約規則**」0.7789 // 竟然高於真正相關的 0.7306。這正是 leo 2026-07-18 回報的「問 RAG 卻引用會議室規範」。 // // 🔴 換模型=**必須換 Vectorize index**,兩個理由: // ① 維度不同(768→1024),舊 index 收不進新向量; // ② 就算維度相同也不能沿用——不同模型的向量混在同一個 index,比對出來是垃圾, // 而 Arcrun#58(Vectorize vector delete 未接)代表舊向量**刪不掉**。 // ⇒ 開新 index 反而順手繞開 #58:新 index 天生乾淨,舊的整個丟掉。 // // 換代步驟(installer 已把新 index 名與維度對齊):建新 index → 重新部署 kbdb(binding 指新 index) // → 打 backfill 的 `reindex=true`(把 embed=1 的既有 entry 全部重嵌)→ 舊 index 可刪。 const DEFAULT_EMBED_MODEL = '@cf/baai/bge-m3'; // 1024-dim,與 Vectorize index dimensions=1024 對齊 // 🔴 2026-08-05 leo 實撞:換 bge-m3 後**語義搜尋全 0 命中**(新上傳的檔搜不到、舊檔偶爾才中)。 // 根因不在向量——實測 Vectorize 端排序完全正確(搜「閉環機」,目標檔穩坐 1-4 名)—— // 而在**分數閾值是綁在舊模型的分數尺度上的**: // 舊 bge-base-en-v1.5:中文分數全擠 0.65-0.90(沒區辨力)⇒ t183 取 0.75 砍雜訊,對 // 新 bge-m3 :分數尺度整體下移(相關 0.5-0.85、雜訊 0.4 上下)⇒ 0.75 砍掉的是**正解** // youlin 實例實測分布(bge-m3,08-05,直打 Vectorize query): // 「閉環機」 0.638 / 0.603 / 0.588 / 0.552 ← 全是目標檔,**全被 0.75 砍光** // ────── 斷崖 ────── 0.446 以下才是雜訊 // 「火星座標 奧林帕斯山」 0.750…0.500 全是火星座標,0.475 以下才是雜訊 // 「人力媒合系統規劃書」 0.842 ← 僥倖 >0.75 存活。**這就是 leo 看到「舊檔中、新檔不中」的由來** // ⇒ 斷崖普遍落在 0.5 附近,取 **0.5**:相關的全留、雜訊仍砍。 // 誠實 trade-off:0.5 不是每個查詢都乾淨(實測「AI 上課名冊」0.658 的 ax-academy 會擠進來), // 但「偶有雜訊」遠優於「什麼都搜不到」——後者是現在的狀態。 // // 🔴 為什麼閾值住在這裡(而不是 portal):它是**模型的性質**,不是頁面的偏好。 // 原本硬寫在 `cypher-executor/src/routes/portal-data.ts`,換模型時那裡沒人想到要改 // ——這正是 bge-m3 換代「四處同步」清單漏掉的第五處。放在模型常數旁邊, // 下次換模型的人一定會看到它。**別再把數字複製回呼叫端。** // 🔴 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 { const m = (env.EMBED_MODEL ?? '').trim(); return m || DEFAULT_EMBED_MODEL; } /** embed 模組是否啟用(binding 都在才算開)。base 一切 embed 動作先過這關。 */ export function embedEnabled(env: Bindings): boolean { return !!(env.VECTORIZE && env.AI); } /** 一段文字 → 1024 維向量(Workers AI bge-m3,可由 env.EMBED_MODEL 覆寫)。空字串回 null(不 embed)。 */ async function embedText(env: Bindings, text: string): Promise { const t = (text ?? '').trim(); if (!t || !env.AI) return null; const res = (await env.AI.run(embedModel(env), { text: [t] })) as { data: number[][] }; return res?.data?.[0] ?? null; } /** * 寫入時選擇性 embed(embed-on-write,#5 第4點併入此)。 * - 模組未開 → no-op(base 輕量)。 * - 只 embed 被標 embeddable 的 entry(metadata_json.embed === true)。其餘略過(非地毯式)。 * 失敗不致命(fire-and-forget 由 caller 用 waitUntil 包;這裡只負責「能 embed 就 embed」)。 * 回傳是否真的 embed 了(讓 caller 決定要不要標 is_embedded)。 */ export async function embedOnWrite(env: Bindings, entry: Entry): Promise { if (!embedEnabled(env)) return false; if (!isEmbeddable(entry)) return false; const vec = await embedText(env, entry.content ?? ''); if (!vec) return false; await env.VECTORIZE!.upsert([ { id: entry.id, values: vec, // metadata 走 indexed 範圍:owner_id(租戶隔離)、entry_type、source(#5.1 過濾與語義共用)、 // library(portal-auth P1「庫」filter)。library 在寫入端正規化:未標記='general'(design §3.2 // 「未蓋章的舊資料視同 general」——D1 側用查詢端 COALESCE fallback,Vectorize filter 做不了 // COALESCE,故在 upsert 時蓋 'general',查詢端單純 $in 即可)。 metadata: { owner_id: entry.owner_id ?? '', entry_type: entry.entry_type, source: readSource(entry) ?? '', library: readLibrary(entry) ?? 'general', }, }, ]); // 標記 bookkeeping(既有欄,base 不讀、僅供「已 embed」可查)。不動表結構。 // 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; } /** entry 是否該被 embed:caller 在 metadata_json 標 embed:true(精耕,非地毯式)。 */ function isEmbeddable(entry: Entry): boolean { const meta = parseMeta(entry.metadata_json); return meta?.embed === true; } function readSource(entry: Entry): string | null { const meta = parseMeta(entry.metadata_json); const s = meta?.source; return typeof s === 'string' ? s : null; } /** metadata_json.$.library(portal-auth P1)。非字串/空字串一律視同未標記(→ caller fallback 'general')。 */ function readLibrary(entry: Entry): string | null { const meta = parseMeta(entry.metadata_json); const l = meta?.library; return typeof l === 'string' && l.trim() !== '' ? l : null; } function parseMeta(json: string | null): Record | null { if (!json) return null; try { const p = JSON.parse(json); return p && typeof p === 'object' ? (p as Record) : null; } catch { return 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"; // ── 每日額度上限(D68,2026-08-11:leo「補算向量照時間新到舊、且每天有額度上限」)───────── // // backfill 與「寫入即嵌」「萃取」共用同一份 Workers AI 每日免費 10,000 neurons(UTC 午夜重置, // 見頂層 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): 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 { 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 額度用量 +by(upsert:讀現有列 → +by → UPDATE,不存在則 INSERT,冪等日切)。 */ async function addBackfillUsage(db: D1Database, by: number): Promise { 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(); } } // ── 「挑哪一批」可以從外面指定(Arcrun#85,2026-08-11 leo 二度裁決)─────────────── // // leo 的優先序不是「一律新到舊」的單一佇列,是**分層**:今天寫的立刻/這週在跑的先跑/ // 有查詢紀錄的庫優先/半年前的慢慢跑。分層要能實作,前提是「這次補哪一批」要能從外面 // (工作流)指定,不能只靠資料層自己決定的固定排序——策略要住在 leo 打得開的地方 // (工作流頁),不是焊死在這裡看不見也改不動。 // // 這裡不預先幫 caller 決定「四層怎麼切」(那是策略,屬於呼叫端/工作流,見 Arcrun#85 // D70 段落的意圖草案),只提供**同一套篩選形狀**讓任何一層都能表達: // - since/until:時間窗(unix seconds,created_at 半開區間 [since, until))——時間分層 // (①今天/②本週/④半年前)都是同一個 since/until 參數,差別只在呼叫端傳的值。 // - library:依 metadata_json.$.library 過濾——一旦資料身上有庫這個資訊(Arcrun#87), // 「有查詢紀錄的庫優先」這層可以直接用同一個參數,不必再改介面形狀。 // 三個操作(backfillEmbeddings/reconcileEmbedGeneration/backfillEntryLibraryTags, // 見 actions/library-backfill.ts)共用這個形狀,這就是「判定標準只有一份」的意思—— // 不是先做時間、之後為了庫再回頭改介面。 export interface SelectionCriteria { owner_id?: string; source?: string; library?: string; // 精確比對 metadata_json.$.library(未標記的舊資料一律歸 'general',同 embedOnWrite 慣例) since?: number; // created_at >= since(unix seconds) until?: number; // created_at < until(unix seconds) } function selectionCriteriaPredicate(opts: SelectionCriteria): { conds: string[]; params: unknown[] } { 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); } if (opts.library) { conds.push("COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ?"); params.push(opts.library); } if (typeof opts.since === 'number') { conds.push('created_at >= ?'); params.push(opts.since); } if (typeof opts.until === 'number') { conds.push('created_at < ?'); params.push(opts.until); } return { conds, params }; } export interface BackfillResult { enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。 processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。 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,等明天/調高上限)。 } /** * 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: SelectionCriteria & { limit?: number; reindex?: boolean; offset?: number } = {}, ): Promise { 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); // reindex(Arcrun#11 根因修復):對「既有已嵌」向量原樣重嵌重推 upsert,讓它們被『事後才建立』的 // Vectorize metadata index(owner_id/entry_type/source)收錄。Vectorize 只索引「metadata index // 建立之後 upsert」的向量 → 既有向量不重推就永遠 filter 不到(= 本 bug)。upsert 同 id 冪等。 // 非 reindex(預設)=原行為:只補 is_embedded=0 的漏網。 const basePredicate = opts.reindex ? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1" : BACKFILL_PREDICATE; // 🔴 2026-08-05:**已下架的一律不嵌**(leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」)。 // 沒有這條,下架時清掉的向量會在下一次 backfill 又被嵌回來 ⇒ 殘影復活, // 而且 `reindex=true` 那條路更嚴重(它連 is_embedded=1 的都重推)。 // 「挑哪一批」(Arcrun#85):owner_id/source/library/since/until 全部走同一套 // selectionCriteriaPredicate,讓呼叫端(工作流)能表達時間分層與庫分層,不必等 // base 幫忙決定;本函式不預設任何一層,caller 傳什麼就篩什麼。 const sel = selectionCriteriaPredicate(opts); const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds]; const params: unknown[] = [...sel.params]; 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 DESC LIMIT ? OFFSET ?`) .bind(...params, limit, offset) .all(); 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 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[][] }; 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) ?? '', library: readLibrary(x.e) ?? 'general', // 同 embedOnWrite:寫入端正規化(P1) }, })); if (vectors.length > 0) { await env.VECTORIZE.upsert(vectors); const ids = vectors.map((v) => v.id); const placeholders = ids.map(() => '?').join(','); // 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)。 } } } const remRow = await env.DB .prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`) .bind(...params) .first<{ c: number }>(); const totalMatching = remRow?.c ?? 0; // 非 reindex:predicate 含 is_embedded=0,處理後該筆變 1 → COUNT 自然遞減(重呼直到 0)。 // reindex:predicate 不含 is_embedded,COUNT 恆等於總數 → 改用 offset 分頁計 remaining(否則永不終止)。 const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching; return { enabled: true, processed, skipped: scanned - processed, remaining, scanned, quota_limit: dailyCap, quota_used_today: usedToday + processed, quota_exceeded: quotaExceeded, }; } /** 補嵌進度統計(回報用;模組未開仍可查 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 ReconcileResult { enabled: boolean; checked: number; // 本批「真的核對+寫回」的筆數(受下方 D1 額度截斷後的量)。 confirmed_current: number; // 核對後確認已在現行 Vectorize index:只補標 content_hash,未打 AI。 reset_to_pending: number; // 核對後確認不在現行 index:重置 is_embedded=0,回到正常 backfill 佇列。 remaining: number; // 本次之後仍待核對的筆數(不受額度影響,可重複呼叫直到 0)。 scanned: number; // 本批掃到的候選筆數(受 limit 限制,額度截斷前)。 quota_limit: number; // 今日「背景維護 D1 寫入」額度上限(與標庫 backfill 共用,見 maintenance-quota.ts)。 quota_used_today: number; // 本次呼叫後,今日累積已消耗的背景維護寫入額度。 quota_exceeded: boolean; // 本批是否因額度不足被截斷(true=還有候選但今天不再寫 D1,等明天/調高上限)。 } /** * 世代核對(Generation reconciliation,D68 配套修復,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 寫回。 * * D69(Arcrun#85,2026-08-11 leo 逐行複核找到的破口):**這一步雖不打 AI,但逐筆寫 D1**—— * 每個候選最多消耗一次 row write(補標 content_hash 或重置 is_embedded,兩條路互斥、恰好一次), * 47 萬筆候選 ≈ 4.7 倍 D1 100,000 rows written/日免費額度。與標庫 backfill(同樣是多筆 D1 * write、不打 AI)共用 `actions/maintenance-quota.ts` 的同一顆每日計數器——不共用的話, * 補標庫時會把這裡的閘繞過去(反之亦然)。額度用完 → 誠實截斷候選,不再寫 D1,等明天。 * * 「挑哪一批」:owner_id/library/since/until 走 SelectionCriteria(同 backfillEmbeddings/ * backfillEntryLibraryTags 共用的篩選形狀),讓時間分層/庫分層能從外面指定。 */ export async function reconcileEmbedGeneration( env: Bindings, opts: Pick & { limit?: number } = {}, ): Promise { if (!embedEnabled(env)) { return { enabled: false, checked: 0, confirmed_current: 0, reset_to_pending: 0, remaining: 0, scanned: 0, quota_limit: 0, quota_used_today: 0, quota_exceeded: false, }; } const limit = Math.min(Math.max(opts.limit ?? 50, 1), 200); const currentModel = embedModel(env); const sel = selectionCriteriaPredicate(opts); const conds = [ 'is_embedded = 1', '(content_hash IS NULL OR content_hash != ?)', "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'", ...sel.conds, ]; const params: unknown[] = [currentModel, ...sel.params]; 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 scannedIds = (res.results ?? []).map((r) => r.id); const scanned = scannedIds.length; // D69:額度截斷——每個候選最多 1 次 D1 write,直接照剩餘額度砍候選清單長度。 const budget = await maintenanceBudgetToday(env, env.DB); const ids = scannedIds.slice(0, budget.remaining); const quotaExceeded = scanned > ids.length; 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 }>(); const written = confirmed_current + reset_to_pending; try { await addMaintenanceUsage(env.DB, written); } catch { // fail-open:額度計數寫入失敗不影響已經完成的核對寫入(精神同 backfillEmbeddings 的 // addBackfillUsage 失敗處理——寧可下次呼叫少算一點用量,也不讓計數故障吞掉已做的工)。 } return { enabled: true, checked, confirmed_current, reset_to_pending, remaining: remRow?.c ?? 0, scanned, quota_limit: budget.limit, quota_used_today: budget.used + written, quota_exceeded: quotaExceeded, }; } 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 { 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(); 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——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。 let hits: SemanticHit[] | null; try { hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 }); } catch (e) { if (e instanceof EmbedQueryFailedError) { // 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。 return { enabled: true, tested: false, passed: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` }; } throw e; } 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; owner_id?: string; entry_type?: string; source?: string; library?: string; } /** * 查詢向量化失敗(2026-08-09 leo 直令:「查詢的向量化如果失敗(例如當天額度用完), * 目前會回一個空的結果集——那是騙人,不是降級」)。 * * 舊行為:embedText 拿不到向量 → semanticSearch 回 [],caller 分不出 * 「真的沒命中」和「根本沒查成」,使用者看到「查無資料」,以為知識庫裡沒有這筆東西。 * 新行為:AI.run 丟錯(額度用完/模型故障)或回不出向量 → 丟這個錯, * 由 route 層誠實降級 keyword +告知「這是我們的故障」,不再偽裝成空結果。 */ export class EmbedQueryFailedError extends Error { constructor(detail: string) { super(`查詢向量化失敗:${detail}`); this.name = 'EmbedQueryFailedError'; } } /** * 語義搜尋(mode:'semantic')。模組未開 → 回 null(caller 降級 keyword + 告知缺能力)。 * owner_id / source / entry_type 過濾走 Vectorize metadata filter(entry_type 已 index,見上 upsert metadata)。 * entry_type 是 base 通用 filter(caller 傳任意 type,base 不寫死語意)。 * library(portal-auth P1):多值庫 filter 走 `$in`(官方支援已核實 2026-07-14:文件明列 $in/$nin+ * workers-types 原生 typing;design §3.3 的 fan-out fallback 不需啟用)。未帶=行為不變。 * 註:向量 metadata 的 library 在寫入端已正規化(未標記='general'),故 $in 不需 NULL 處理; * 但「建 library metadata index 之前」upsert 的既有向量沒有此欄 → 部署清單強制 reindex backfill。 * min_score(issue #67):分數閾值——Vectorize 只會硬湊 topK 筆,低分尾全是無關內容; * 過濾放查詢端(非 Vectorize 端,API 無此參數)。 * 🔴 2026-08-05 起預設=`DEFAULT_MIN_SCORE`(跟著模型走,見該常數的實測分布), * 不再是 0(0=不過濾=#67 要修的病本身,而呼叫端各自硬寫數字則是 08-05 全 0 命中的根因)。 * caller 顯式傳值仍優先。 */ export async function semanticSearch( env: Bindings, q: string, opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number; min_score?: number } = {}, ): Promise { if (!embedEnabled(env)) return null; // 空查詢=真的沒東西可查(route 層已擋 q 必填,這裡只兜底),不算故障。 if (!(q ?? '').trim()) return []; // 🔴 2026-08-09(leo 直令):向量化失敗**不准**回空結果集。空結果=「你的庫裡沒有」, // 向量化失敗=「我們沒查成」——兩者對使用者是完全不同的事實,混在一起就是說謊。 let vec: number[] | null; try { vec = await embedText(env, q); } catch (e) { throw new EmbedQueryFailedError(e instanceof Error ? e.message : String(e)); } if (!vec) throw new EmbedQueryFailedError('Workers AI 沒有回出向量(回應形狀異常或空回應)'); const filter: VectorizeVectorMetadataFilter = {}; if (opts.owner_id) filter.owner_id = opts.owner_id; if (opts.source) filter.source = opts.source; if (opts.entry_type) filter.entry_type = opts.entry_type; if (opts.library && opts.library.length > 0) filter.library = { $in: opts.library }; const res = await env.VECTORIZE!.query(vec, { topK: Math.min(opts.topK ?? 20, 100), returnMetadata: 'indexed', ...(Object.keys(filter).length ? { filter } : {}), }); // 這裡只套**絕對下限**;相對門檻要等「濾掉已下架」之後才能算(見 relativeMinScore 的註解)。 const minScore = opts.min_score ?? MIN_SCORE_ABS_FLOOR; return (res.matches ?? []) .filter((m) => m.score >= minScore) .map((m) => ({ id: m.id, score: m.score, owner_id: m.metadata?.owner_id as string | undefined, entry_type: m.metadata?.entry_type as string | undefined, source: m.metadata?.source as string | undefined, library: m.metadata?.library as string | undefined, })); }