af3edff856
leo 2026-08-11 判斷「如果是 Vectorize 沒完成就不用查了」,總管據此停線。 真因已經寫在 repo 自己的註解裡(kbdb/wrangler.toml:43-51,Arcrun#11): metadata index 只收「建立後 upsert」的向量,既有向量須 reindex, 否則帶 owner_id filter 一律 0 命中——與實測每一格吻合 (805 筆在、關鍵字搜得到、語意 0、拿自己查自己也 0 ⇒ 不是分數門檻)。 ⚠️ 這批改動是排查途中的產物,**沒有走完驗證**,不要當成可用的修法。 保留只是不讓它憑空消失(總管中斷造成,不是它做壞)。 接手的人請先讀 Arcrun#85 上的結論再決定要不要用。 真正的補救是 reindex,而 reindex 要燒 AI 額度 ⇒ 卡在 Arcrun#85 的每日額度閘上線之後才能做。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
486 lines
29 KiB
TypeScript
486 lines
29 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';
|
||
|
||
// ── 嵌入模型(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」可查)。不動表結構。
|
||
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<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";
|
||
|
||
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<BackfillResult> {
|
||
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<Entry>();
|
||
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<SelfTestResult> {
|
||
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<Entry>();
|
||
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<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,
|
||
}));
|
||
}
|