674e1b4fa2
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>
723 lines
40 KiB
TypeScript
723 lines
40 KiB
TypeScript
// 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<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;
|
||
}
|
||
|
||
/**
|
||
* 寫入時選擇性 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<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 過濾與語義共用)、
|
||
// 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<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 === 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<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 額度用量 +by(upsert:讀現有列 → +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#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<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);
|
||
|
||
// 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<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;
|
||
// 非 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<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')。模組未開 → 回 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<SemanticHit[] | null> {
|
||
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,
|
||
}));
|
||
}
|