// 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'; // ── 嵌入模型(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」可查)。不動表結構。 await env.DB.prepare('UPDATE entries SET is_embedded = 1 WHERE id = ?').bind(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"; export interface BackfillResult { enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。 processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。 skipped: number; // 掃到但沒嵌(例如 embedText 回 null)的筆數。 remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0)。 scanned: number; // 本批掃出的候選筆數(受 limit 限制)。 } /** * Backfill(回填):對「開 Vectorize 之前就寫入、或 embed-on-write 當時漏掉」的既有 entry 批次補嵌。 * 冪等(重跑已補嵌的不會重複算,upsert 同 id 冪等)、分批(單次 limit 上限,避開 subrequest/CPU/timeout)、 * 回傳處理筆數 + 剩餘筆數(caller 重複呼叫直到 remaining=0)。 * - 模組未開(無 VECTORIZE+AI)→ 誠實回 { enabled:false },不假裝成功(mindset §7 禁假綠)。 * - 只補「isEmbeddable(metadata.embed===true)且 is_embedded=0」的 entry——與 embedOnWrite 同一契約, * base 維持對內容語意無知(不知 triplet/wiki,只認通用 embed 旗標)。 * - 效率:整批用「單次 AI.run(陣列輸入)+ 單次 VECTORIZE.upsert(陣列)+ 單次 UPDATE ... IN(...)」, * 一批 ≈ 3 個 subrequest,不隨 limit 線性增長 → free/paid tier 都安全。 */ export async function backfillEmbeddings( env: Bindings, opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {}, ): Promise { if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 }; const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100); const 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 的都重推)。 const conds = [basePredicate, "COALESCE(json_extract(metadata_json, '$.status'), '') != 'deprecated'"]; const params: unknown[] = []; if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); } if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); } const where = conds.join(' AND '); const res = await env.DB .prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`) .bind(...params, limit, offset) .all(); const rows = res.results ?? []; const scanned = rows.length; let processed = 0; const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0); if (embeddable.length > 0 && env.AI && env.VECTORIZE) { const texts = embeddable.map((e) => (e.content ?? '').trim()); const out = (await env.AI.run(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(','); await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run(); processed = vectors.length; } } const remRow = await env.DB .prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`) .bind(...params) .first<{ c: number }>(); 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 }; } /** 補嵌進度統計(回報用;模組未開仍可查 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 SelfTestResult { enabled: boolean; // embed 模組是否開(binding 都在) tested: boolean; // 是否真的跑了一次自我查詢(false=連測都測不了,非失敗) passed: boolean | null; // 拿已嵌入卡片的內容查自己,能不能搜到自己(null=沒測) note: string; // 給人看的一句話結論,供檢修孔診斷檔直接引用 // 🔴 2026-08-11 新增(Arcrun#85 D70 leo21c 全盲事件):分辨**兩種處方相反**的故障。 // null=沒測到這一層(模組未開/沒帶 owner_id/或帶 filter 就通過了,不必再探) // true =不帶 filter 搜得到,帶 filter 搜不到 ⇒ **Vectorize metadata filter 失效** // false=連不帶 filter 都搜不到 ⇒ 向量根本不在現役 index 裡 // // 為什麼非分不可:舊版兩種病都只回一句「需要重新 reindex」。但 metadata index // **不存在**時,Vectorize 不會索引該欄位,reindex 重推幾萬筆也不會生效 // ——leo21c 就是照著這個處方修不好。錯的處方比沒有處方更貴。 filter_blind: boolean | null; } /** * 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, filter_blind: 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, filter_blind: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' }; } const sample = (row.content ?? '').trim().slice(0, 200); if (!sample) { return { enabled: true, tested: false, passed: null, filter_blind: null, note: '取樣卡片內容為空,跳過自我檢查' }; } // min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。 // 第一段=**使用者真正走的那條路**(帶 owner_id filter),先測它;通了就不必多花第二次查詢。 const probe = async (o: { owner_id?: string }) => semanticSearch(env, sample, { ...o, topK: 10, min_score: 0 }); let hits: SemanticHit[] | null; try { hits = await probe({ owner_id: opts.owner_id }); } catch (e) { if (e instanceof EmbedQueryFailedError) { // 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。 return { enabled: true, tested: false, passed: null, filter_blind: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` }; } throw e; } if (hits === null) { return { enabled: false, tested: false, passed: null, filter_blind: null, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' }; } const passed = hits.some((h) => h.id === row.id); if (passed) { return { enabled: true, tested: true, passed: true, filter_blind: null, note: '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的', }; } // ── 沒搜到自己:第二段,判斷是「向量不在 index」還是「filter 失效」─────────────── // 🔴 2026-08-11(Arcrun#85 D70,leo21c 實撞):這兩種病的處方**相反**,不能都叫人 reindex。 // 自己查自己相似度接近 1.0,所以「搜不到自己」絕不是分數問題(MIN_SCORE_ABS_FLOOR 也被 // min_score:0 關掉了)。剩下兩種可能,用「拿掉 filter 再查一次」一刀切開: // 拿掉 filter 就找得到 → 向量在 index 裡,是 **metadata filter 死的** // (metadata index 沒建,或向量早於該 index 建立時間) // ⇒ 修法是**先建 metadata index,再 reindex**;只 reindex 沒用 // 拿掉 filter 還是找不到 → 向量真的不在現役 index(常見:換 index 世代後沒重嵌) // ⇒ 修法才是 reindex // 只有在「有帶 owner_id」時第二段才有意義(沒帶 filter 的查詢,兩段是同一件事)。 if (!opts.owner_id) { return { enabled: true, tested: true, passed: false, filter_blind: false, note: '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0', }; } let unfiltered: SemanticHit[] | null = null; try { unfiltered = await probe({}); } catch (e) { if (!(e instanceof EmbedQueryFailedError)) throw e; // 第二段查詢自己壞了 → 不硬猜,誠實回「分不出是哪一種」。 return { enabled: true, tested: true, passed: false, filter_blind: null, note: `拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己;追查用的第二次查詢也失敗(${e.message}),無法判斷是索引沒收錄還是過濾條件失效`, }; } const foundWithoutFilter = (unfiltered ?? []).some((h) => h.id === row.id); if (foundWithoutFilter) { return { enabled: true, tested: true, passed: false, filter_blind: true, note: '拿一張已標記「已嵌入」的卡片自我查詢:**不帶歸屬條件搜得到、一帶上去就搜不到** ⇒ ' + '向量在索引裡,壞的是 Vectorize 的 metadata 過濾(該欄位的 metadata index 沒建,' + '或這些向量是在該 index 建立之前寫進去的)。所有真實查詢都會帶歸屬條件做租戶隔離,' + '所以語意搜尋等於全盲。修法有先後:**先**建 metadata index' + '(owner_id/entry_type/source/library),**再** POST /embed/backfill {"reindex":true}' + '——順序反了或只做 reindex 都不會生效。', }; } return { enabled: true, tested: true, passed: false, filter_blind: false, note: '拿一張已標記「已嵌入」的卡片自我查詢,不論帶不帶歸屬條件都搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0', }; } 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, })); }