Files
Arcrun/kbdb/src/embed.ts
T
uncle6me-web 674e1b4fa2 fix(kbdb): D69 節流世代核對+標庫共用 D1 額度,補「挑哪一批」的統一 SelectionCriteria(Arcrun#85)
leo 逐行複核 fix/embed-backfill-d68 後點出的破口+二次裁決(票上全文見 Leo/Arcrun#85):

一、向量化優先序(今天寫的立刻/本週在跑的先跑/有查詢紀錄的庫優先/半年前慢慢跑)表達
不出來——策略要能從外面(工作流)指定,不能焊死在資料層。新增 embed.ts 的
`SelectionCriteria`(owner_id/source/library/since/until),backfillEmbeddings 與
reconcileEmbedGeneration 共用同一套形狀;「按庫」那一層現在有資料可用即可運作(見下)。

二、世代核對(reconcileEmbedGeneration)不打 AI 但逐筆寫 D1,47 萬筆候選 ≈ 4.7 倍 D1
100,000 rows/日免費額度,先前零保護。新增 actions/maintenance-quota.ts(單一 entries
列/日的共用計數器,精神同 execution-log.ts/embed.ts 既有慣例,不新增表)。

三、leo 二度裁決:「標庫」與「時間分層」其實是一件事,判定標準要從第一天同時容納兩者,
不能先做一半再回頭改。新增 actions/library-backfill.ts 的 backfillEntryLibraryTags——
呼叫端(ingest/daemon/Arcrun#87)決定要貼哪個庫、用 page_names(Gitea 原稿卡名精準
點名,leo 定案的正解)或 source_prefix/page_name_prefix 過渡 fallback 篩選候選,base
只負責安全、節流地寫入。owner_id 刻意必填(leo 點出「補錯 owner 等於白做」——實查卡片
掛在 owner_id=bfezv28v,換成 'leo' 查卻是空的)。

D69:reconcile 與標庫 backfill 共用同一顆「今天還剩多少 D1 寫入額度」計數器(不共用的話
其中一個會把另一個的閘繞過去);新增 POST /entries/backfill-library + GET .../status,
擴充 POST /embed/backfill 與 /embed/reconcile 吃 library/since/until 參數。

測試:92 → 39 個新增/擴充案例覆蓋 since/until/library 篩選、reconcile 額度真的擋
(含「拿掉 cap 會變紅」反向驗證)、標庫 backfill 冪等/owner_id 必填/page_names 精準比對、
以及兩個操作共用同一顆額度計數器的跨模組驗證(雙向:先 reconcile 耗盡再標庫、反之亦然)。
kbdb 全套 192 個測試綠燈,tsc --noEmit 除既有 auth.test.ts 舊缺陷外無新增錯誤。

紅線:未併 main、未部署、未動任何實例的 is_embedded 旗標(只在本地 SQLite 測試治具跑過)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-11 18:39:55 +08:00

723 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// KBDB optional embed module (issue #7 / mira-dissolve SDD T2.4).
//
// 鐵律對齊:
// - embedding 屬 **base 的 optional 模組**(非 graph/ingest)。CF 內建(Vectorize+AI),程式薄。
// - **不拆 repobinding 開/關**:有 env.VECTORIZE + env.AI 才啟用;沒有 → base 維持 LIKE keywordAPI 不變。
// - 不動三表結構(只標既有 entries.is_embedded / content_hash bookkeeping 欄;那些 base 從不讀,embed 才寫)。
// - 不對每個 block 地毯式 embed(精耕,非 RAG 一股腦灌):只 embed「被標記為 embeddable」的 entry
// wiki 段落 + graph node gloss)。標記方式=寫入時 metadata_json.embed === truecaller 顯式標)。
//
// 為何用 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#58Vectorize vector delete 未接)代表舊向量**刪不掉**。
// ⇒ 開新 index 反而順手繞開 #58:新 index 天生乾淨,舊的整個丟掉。
//
// 換代步驟(installer 已把新 index 名與維度對齊):建新 index → 重新部署 kbdbbinding 指新 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-m308-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<number[] | null> {
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;
}
/**
* 寫入時選擇性 embedembed-on-write#5 第4點併入此)。
* - 模組未開 → no-opbase 輕量)。
* - 只 embed 被標 embeddable 的 entrymetadata_json.embed === true)。其餘略過(非地毯式)。
* 失敗不致命(fire-and-forget 由 caller 用 waitUntil 包;這裡只負責「能 embed 就 embed」)。
* 回傳是否真的 embed 了(讓 caller 決定要不要標 is_embedded)。
*/
export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean> {
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 過濾與語義共用)、
// libraryportal-auth P1「庫」filter)。library 在寫入端正規化:未標記='general'design §3.2
// 「未蓋章的舊資料視同 general」——D1 側用查詢端 COALESCE fallbackVectorize 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 是否該被 embedcaller 在 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.$.libraryportal-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<string, unknown> | null {
if (!json) return null;
try {
const p = JSON.parse(json);
return p && typeof p === 'object' ? (p as Record<string, unknown>) : null;
} catch {
return null;
}
}
// SQL predicate for "an entry that SHOULD be embedded but isn't yet".
// - isEmbeddable 契約 = metadata_json.embed === truebase 通用旗標,對內容語意無知,不寫死 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";
// ── 每日額度上限(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();
}
}
// ── 「挑哪一批」可以從外面指定(Arcrun#852026-08-11 leo 二度裁決)───────────────
//
// leo 的優先序不是「一律新到舊」的單一佇列,是**分層**:今天寫的立刻/這週在跑的先跑/
// 有查詢紀錄的庫優先/半年前的慢慢跑。分層要能實作,前提是「這次補哪一批」要能從外面
// (工作流)指定,不能只靠資料層自己決定的固定排序——策略要住在 leo 打得開的地方
// (工作流頁),不是焊死在這裡看不見也改不動。
//
// 這裡不預先幫 caller 決定「四層怎麼切」(那是策略,屬於呼叫端/工作流,見 Arcrun#85
// D70 段落的意圖草案),只提供**同一套篩選形狀**讓任何一層都能表達:
// - sinceuntil:時間窗(unix secondscreated_at 半開區間 [since, until))——時間分層
// (①今天/②本週/④半年前)都是同一個 since/until 參數,差別只在呼叫端傳的值。
// - library:依 metadata_json.$.library 過濾——一旦資料身上有庫這個資訊(Arcrun#87),
// 「有查詢紀錄的庫優先」這層可以直接用同一個參數,不必再改介面形狀。
// 三個操作(backfillEmbeddingsreconcileEmbedGenerationbackfillEntryLibraryTags
// 見 actions/library-backfill.ts)共用這個形狀,這就是「判定標準只有一份」的意思——
// 不是先做時間、之後為了庫再回頭改介面。
export interface SelectionCriteria {
owner_id?: string;
source?: string;
library?: string; // 精確比對 metadata_json.$.library(未標記的舊資料一律歸 'general',同 embedOnWrite 慣例)
since?: number; // created_at >= sinceunix seconds
until?: number; // created_at < untilunix 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 禁假綠)。
* - 只補「isEmbeddablemetadata.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<BackfillResult> {
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);
// reindexArcrun#11 根因修復):對「既有已嵌」向量原樣重嵌重推 upsert,讓它們被『事後才建立』的
// Vectorize metadata indexowner_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<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 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;
// 非 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,
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 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 寫回。
*
* D69Arcrun#852026-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<SelectionCriteria, 'owner_id' | 'library' | 'since' | 'until'> & { limit?: number } = {},
): Promise<ReconcileResult> {
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<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——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
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')。模組未開 → 回 nullcaller 降級 keyword + 告知缺能力)。
* owner_id / source / entry_type 過濾走 Vectorize metadata filterentry_type 已 index,見上 upsert metadata)。
* entry_type 是 base 通用 filtercaller 傳任意 typebase 不寫死語意)。
* libraryportal-auth P1):多值庫 filter 走 `$in`(官方支援已核實 2026-07-14:文件明列 $in/$nin
* workers-types 原生 typingdesign §3.3 的 fan-out fallback 不需啟用)。未帶=行為不變。
* 註:向量 metadata 的 library 在寫入端已正規化(未標記='general'),故 $in 不需 NULL 處理;
* 但「建 library metadata index 之前」upsert 的既有向量沒有此欄 → 部署清單強制 reindex backfill。
* min_scoreissue #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<SemanticHit[] | null> {
if (!embedEnabled(env)) return null;
// 空查詢=真的沒東西可查(route 層已擋 q 必填,這裡只兜底),不算故障。
if (!(q ?? '').trim()) return [];
// 🔴 2026-08-09leo 直令):向量化失敗**不准**回空結果集。空結果=「你的庫裡沒有」,
// 向量化失敗=「我們沒查成」——兩者對使用者是完全不同的事實,混在一起就是說謊。
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,
}));
}