From af3edff856469cd4ae20dec861e61b655d39d5de Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Tue, 11 Aug 2026 23:05:20 +0800 Subject: [PATCH] =?UTF-8?q?WIP(kbdb):=20=E8=AA=9E=E6=84=8F=E6=90=9C?= =?UTF-8?q?=E5=B0=8B=E9=9B=B6=E5=91=BD=E4=B8=AD=E7=9A=84=E6=8E=92=E6=9F=A5?= =?UTF-8?q?=E2=80=94=E2=80=94=E2=9A=A0=EF=B8=8F=20=E8=A2=AB=E7=B8=BD?= =?UTF-8?q?=E7=AE=A1=E4=B8=AD=E9=80=94=E5=8F=AB=E5=81=9C=EF=BC=8C=E6=9C=AA?= =?UTF-8?q?=E5=AE=8C=E6=88=90=E9=A9=97=E8=AD=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cli/src/lib/deploy.ts | 71 ++++++++++++++--- cypher-executor/src/routes/portal.ts | 9 ++- kbdb/src/embed.ts | 79 ++++++++++++++++--- kbdb/src/routes/entries.ts | 57 ++++++++++++- kbdb/tests/embed-selftest.test.ts | 64 +++++++++++++++ .../search-semantic-empty-reason.test.ts | 77 ++++++++++++++++++ 6 files changed, 332 insertions(+), 25 deletions(-) diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index 0bf3dc0..965acca 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -271,12 +271,24 @@ export async function downloadAndDeploy( process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...')); await ensureVectorizeIndex(ctx); // Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建 - // metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。 - await ensureVectorizeMetadataIndexes(ctx); + // metadata index,否則帶 owner_id/entry_type/source/library 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。 + const created = await ensureVectorizeMetadataIndexes(ctx); console.log(chalk.green(' ✓')); + // 新建的 metadata index **只收「建立之後 upsert」的向量** ⇒ 既有向量不重推就永遠 filter 不到。 + // 這一步不能靜默:leo21c 全盲事件裡,人看到「✓」就以為好了,實際上舊向量一筆都查不到。 + if (created.length > 0) { + console.log(chalk.yellow( + ` ⚠ 新建了 metadata index(${created.join('/')})。Vectorize 只索引「建立之後寫入」的向量,\n` + + ' 既有向量必須重推才查得到 → 部署完成後打:\n' + + ' POST /embed/backfill {"reindex":true} (重複呼叫直到 remaining=0)', + )); + } } catch (e) { - console.log(chalk.yellow(' ⚠')); - failures.push(`Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}`); + console.log(chalk.red(' ✗')); + failures.push( + `Vectorize index (${KBDB_VECTORIZE_INDEX}): ${e instanceof Error ? e.message : String(e)}` + + ' ⇒ 語意搜尋會「看起來有開、實際全盲」(帶歸屬條件的查詢一律 0 命中),請先修好這項再驗收語意搜尋。', + ); } } @@ -463,8 +475,16 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise { throw new Error(msg); } -/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */ -export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const; +/** + * embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 + * + * 🔴 這份清單必須與 `kbdb/src/embed.ts` 的 upsert metadata 欄位**逐欄對齊**:少一欄, + * 帶那一欄過濾的語意查詢就永遠回 0 命中(Vectorize 只認「已建 metadata index」的欄位), + * **而且不會報錯**——與 bge-m3 換代那次同款的靜默漂移(wiki/mistakes.md「改 A 要連動 B」)。 + * `library` 是 2026-08-11 補的:portal-auth P1 的「庫」filter 早就拿它在查,清單卻一直停在 + * 三欄(`kbdb/wrangler.toml` 自己記著「library 待補進該清單」,那張欠條在這裡還掉)。 + */ +export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source', 'library'] as const; /** * 確保 KBDB embed index 上的 metadata index(owner_id/entry_type/source)存在(Arcrun#11 根因修復)。 @@ -472,16 +492,18 @@ export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] a * REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`(indexType=string)。 * 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。 */ -async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise { - const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`; +async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise { + const base = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}`; + const auth = { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' }; + const created: string[] = []; for (const propertyName of KBDB_VECTORIZE_META_FIELDS) { - const res = await fetch(url, { + const res = await fetch(`${base}/metadata_index/create`, { method: 'POST', - headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' }, + headers: auth, body: JSON.stringify({ propertyName, indexType: 'string' }), signal: AbortSignal.timeout(60_000), }); - if (res.ok) continue; + if (res.ok) { created.push(propertyName); continue; } const json = (await res.json().catch(() => null)) as | { success?: boolean; errors?: Array<{ message?: string; code?: number }> } | null; @@ -489,6 +511,33 @@ async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) continue; throw new Error(`metadata_index ${propertyName}: ${msg}`); } + + // 🔴 建完一定要複驗(2026-08-11 立,Arcrun#85 D70 事故的直接教訓)。 + // leo21c 的現役 index 上**一個 metadata index 都沒有**,於是每一條帶 owner_id 的 + // 語意查詢(=所有真實使用者路徑,租戶隔離一律帶)都回 0 命中,語意搜尋全盲三天。 + // 真兇是 arcrun-rag 安裝器把端點寫成 `metadata-index/create`(連字號,CF 回 404, + // 正解是底線 `metadata_index/create`),而那支把失敗降級成一行 ⚠ 就宣告安裝成功。 + // ⇒ **「我發過 create 請求」不等於「index 真的在」**。這一段就是那個等號。 + // 複驗失敗一律 throw:呼叫端會把它收進 failures 讓部署誠實標紅,而不是 + // 「語意搜尋開起來了、但全盲」這種最貴的假綠(mindset §7 禁假綠)。 + const listRes = await fetch(`${base}/metadata_index/list`, { headers: auth, signal: AbortSignal.timeout(60_000) }); + if (!listRes.ok) { + throw new Error(`metadata_index 複驗失敗:list HTTP ${listRes.status}(無法確認 index 是否真的建起來,不當作成功)`); + } + const listJson = (await listRes.json().catch(() => null)) as + | { result?: { metadataIndexes?: Array<{ propertyName?: string }> } } + | null; + const present = new Set( + (listJson?.result?.metadataIndexes ?? []).map(m => String(m.propertyName ?? '')), + ); + const missing = KBDB_VECTORIZE_META_FIELDS.filter(f => !present.has(f)); + if (missing.length > 0) { + throw new Error( + `metadata_index 複驗不通過:${missing.join('/')} 不在 ${KBDB_VECTORIZE_INDEX} 上。` + + '沒有這些 index,帶 owner_id/library 等條件的語意查詢會一律回 0 命中(不會報錯,只是全盲)。', + ); + } + return created; } /** 下載 Gitea archive tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。 diff --git a/cypher-executor/src/routes/portal.ts b/cypher-executor/src/routes/portal.ts index 9a4edfe..ab7a441 100644 --- a/cypher-executor/src/routes/portal.ts +++ b/cypher-executor/src/routes/portal.ts @@ -1860,7 +1860,7 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise null)) as - | { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; note?: string } + | { success?: boolean; enabled?: boolean; tested?: boolean; passed?: boolean | null; filter_blind?: boolean | null; note?: string } | null; embedding = { checked: true, @@ -1871,6 +1871,13 @@ export async function buildDiagnostics(env: Bindings, tenant: string): Promise { if (!embedEnabled(env)) { - return { enabled: false, tested: false, passed: null, note: 'embed 模組未開(缺 Vectorize/AI binding),語義搜尋這條路目前不存在' }; + 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[] = []; @@ -318,34 +327,80 @@ export async function embedSelfTest( .bind(...params) .first(); if (!row) { - return { enabled: true, tested: false, passed: null, note: '尚無任何卡片被標記為「已嵌入」,無法自我檢查(可能是還沒卡片,也可能是嵌入從未成功過)' }; + 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, note: '取樣卡片內容為空,跳過自我檢查' }; + 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 semanticSearch(env, sample, { owner_id: opts.owner_id, topK: 10, min_score: 0 }); + hits = await probe({ owner_id: opts.owner_id }); } catch (e) { if (e instanceof EmbedQueryFailedError) { // 向量化本身失敗(額度用完/模型故障)=「這條路現在是斷的」,誠實回報,不算 passed/failed。 - return { enabled: true, tested: false, passed: null, note: `自我檢查沒跑成:${e.message}(語義搜尋此刻同樣會故障,多半是 Workers AI 額度或服務問題)` }; + 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, note: 'embed 模組回報未開(binding 檢查期間消失,罕見)' }; + 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, - note: passed - ? '拿一張已標記「已嵌入」的卡片自我查詢,能搜到自己——語義搜尋這條路是通的' - : '拿一張已標記「已嵌入」的卡片自我查詢,卻搜不到自己——像是 index 沒收錄到這批向量(需要重新 reindex)', + enabled: true, tested: true, passed: false, filter_blind: false, + note: '拿一張已標記「已嵌入」的卡片自我查詢,不論帶不帶歸屬條件都搜不到自己——向量不在現役索引裡(常見:換過索引世代卻沒重嵌)。修法:POST /embed/backfill {"reindex":true} 重推到 remaining=0', }; } diff --git a/kbdb/src/routes/entries.ts b/kbdb/src/routes/entries.ts index a980049..a1c15d0 100644 --- a/kbdb/src/routes/entries.ts +++ b/kbdb/src/routes/entries.ts @@ -35,6 +35,41 @@ function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise {}); } +/** + * 「這次零命中,是不是因為 Vectorize 的 metadata 過濾整個是死的?」 + * + * 🔴 2026-08-11 立(Arcrun#85 D70,leo21c 實撞):那台實例的現役 index + * `arcrun-kbdb-embed-m3` 上 **一個 metadata index 都沒有**(換代時漏建),於是 + * Vectorize 對 owner_id/source/entry_type/library 下任何 filter 都回 0 筆。 + * 而**每一條真實使用者路徑都會帶 owner_id 做租戶隔離**(portal、MCP、workflow 搜尋皆然) + * ⇒ 語意搜尋 100% 全盲,但系統只會回「沒有找到符合的內容,換個說法再試試看」。 + * + * 判法不靠猜、也不查 Cloudflare 設定(KBDB 這面牆內打不到那支 API): + * **同一句查詢,把 metadata filter 全部拿掉再打一次**。 + * 有命中 → 向量在 index 裡,死的是 filter(回 true) + * 仍零命中 → 就是這次查詢真的沒撞到東西(回 false,維持 no_match) + * + * 成本紀律:只在「已有嵌入資料卻零命中」這個**本來就已經降級**的分支才會被呼叫, + * 正常有結果的查詢一次都不會多花。多的是一次 AI.run + 一次 Vectorize query。 + * 沒帶任何 filter 的查詢直接回 false(沒有 filter 可以怪,也不必多打一次)。 + * 探針自己出錯一律回 false——診斷絕不能把查詢本身弄壞(誠實限制,mindset §7)。 + */ +async function filterIsBlind( + env: Bindings, + q: string, + f: { owner_id?: string; source?: string; entry_type?: string; library?: string[] }, +): Promise { + const hasFilter = !!(f.owner_id || f.source || f.entry_type || (f.library && f.library.length > 0)); + if (!hasFilter) return false; + try { + // min_score:0 + 小 topK:只問「拿掉 filter 到底有沒有東西」,不問品質。 + const probe = await semanticSearch(env, q, { topK: 5, min_score: 0 }); + return (probe ?? []).length > 0; + } catch { + return false; + } +} + // library 多值參數(逗號分隔,portal-auth P1,design §3.3)。空值/全空白 → undefined(=不過濾, // 行為與未帶參數一字不變——向後相容硬驗收)。 function parseLibraryParam(raw: string | undefined): string[] | undefined { @@ -289,7 +324,7 @@ entryRoutes.get('/search', async (c) => { // 三態都給人話 capability_hint(給使用者)+ admin_hint(技術細節,給維運者/CC)。 // 正常有結果(entries.length>0)完全不受影響,回應形狀不變。 if (entries.length === 0) { - let empty_reason: 'no_index' | 'no_match' | 'stale_index'; + let empty_reason: 'no_index' | 'no_match' | 'stale_index' | 'filter_blind'; let capability_hint: string; let admin_hint: string; if (hits.length === 0) { @@ -308,6 +343,26 @@ entryRoutes.get('/search', async (c) => { capability_hint = '這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。'; admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0:沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑(正常的空),少數情況是 ingest 管線沒標 embed 旗標(要查管線)。`; + } else if (await filterIsBlind(c.env, q, { owner_id, source, entry_type, library })) { + // 🔴 2026-08-11(Arcrun#85 D70,leo21c 實撞,三小時才挖出來的那個病): + // 「有 N 筆嵌入資料卻零命中」在這裡曾一律被歸成 no_match,回給使用者 + // 「換個說法再試試看」——但那台實例的真相是 **Vectorize 的 metadata index + // 一個都沒建**(換 index 世代時漏了),所以**每一次**帶 owner_id 的語意查詢 + // 都回 0,換幾種說法都一樣。把系統故障說成使用者的問題,正是 leo 08-09 + // 直令禁止的那件事;而且它是靜默的——沒人會因為「搜不到」去查 Vectorize 設定。 + // 判法不靠猜:**同一句查詢拿掉 metadata filter 再打一次**,有命中就證明 + // 向量在索引裡、死的是 filter(見 filterIsBlind)。 + empty_reason = 'filter_blind'; + capability_hint = + '語意搜尋目前故障——你的資料都在,是我們的索引設定壞了,所以每一次語意搜尋都會空手而回。' + + '這不是你打的字有問題,換個說法也不會有用。請先用關鍵字搜尋,我們會修好它。'; + admin_hint = + `owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料;帶 metadata filter 零命中,` + + '但同一句查詢拿掉 filter 後有命中 ⇒ 向量在 index 裡,死的是 Vectorize metadata 過濾。' + + '成因:該 index 上沒有對應的 metadata index(換 index 世代/改名時最常漏),' + + '或既有向量早於 metadata index 的建立時間。修法有先後:**先**建 metadata index' + + '(owner_id/entry_type/source/library,acr 的 ensureVectorizeMetadataIndexes 會冪等建),' + + '**再** POST /embed/backfill {"reindex":true} 重推到 remaining=0。只做 reindex 不會生效。'; } else { empty_reason = 'no_match'; capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。'; diff --git a/kbdb/tests/embed-selftest.test.ts b/kbdb/tests/embed-selftest.test.ts index 619eb63..eccce7b 100644 --- a/kbdb/tests/embed-selftest.test.ts +++ b/kbdb/tests/embed-selftest.test.ts @@ -113,6 +113,70 @@ describe('embedSelfTest(檢修孔:卡片自我查詢,驗證 index 真的 expect(r.passed).toBeNull(); }); + // ── Arcrun#85 D70(2026-08-11 leo21c 全盲事故)────────────────────────────── + // 兩種故障的**處方相反**,舊版都只回一句「需要重新 reindex」: + // ① metadata filter 死掉(metadata index 沒建)→ 先建 index 再 reindex;只 reindex 無效 + // ② 向量不在現役 index(換世代沒重嵌) → reindex 才是對的 + // 判法=拿掉 filter 再查一次。下面的假 VECTORIZE 依「有沒有帶 filter」回不同結果, + // 精確重現 leo21c 的現場(不帶 filter score 0.8957 命中、帶 owner_id 0 命中)。 + function makeFilterAwareEnv( + store: Entry[], + opts: { unfilteredMatches: { id: string; score: number }[]; filteredMatches: { id: string; score: number }[] }, + ): Bindings { + return { + DB: makeFakeDB(store), + ENVIRONMENT: 'test', + AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } }, + VECTORIZE: { + async query(_v: number[], o?: { filter?: Record }) { + const filtered = !!(o?.filter && Object.keys(o.filter).length > 0); + return { matches: filtered ? opts.filteredMatches : opts.unfilteredMatches }; + }, + }, + } as unknown as Bindings; + } + + it('不帶 filter 搜得到、帶 owner_id 搜不到 → filter_blind:true,處方是「先建 metadata index 再 reindex」', async () => { + const store = [mkEntry('e1', '淡水河口的黑面琵鷺在退潮時會集體覓食', 'bfezv28v')]; + const env = makeFilterAwareEnv(store, { + unfilteredMatches: [{ id: 'e1', score: 0.8957 }], // leo21c 實測分數 + filteredMatches: [], + }); + const r = await embedSelfTest(env, { owner_id: 'bfezv28v' }); + expect(r.tested).toBe(true); + expect(r.passed).toBe(false); + expect(r.filter_blind).toBe(true); + expect(r.note).toContain('metadata'); + // 🔴 處方順序必須寫出來——只叫人 reindex 正是 leo21c 修不好的原因 + expect(r.note).toContain('reindex'); + expect(r.note).toMatch(/先.*建.*再/s); + }); + + it('帶不帶 filter 都搜不到 → filter_blind:false,處方才是 reindex', async () => { + const store = [mkEntry('e1', 'content', 'o1')]; + const env = makeFilterAwareEnv(store, { unfilteredMatches: [], filteredMatches: [] }); + const r = await embedSelfTest(env, { owner_id: 'o1' }); + expect(r.passed).toBe(false); + expect(r.filter_blind).toBe(false); + expect(r.note).toContain('reindex'); + expect(r.note).not.toContain('metadata index 沒建'); + }); + + it('帶 filter 就搜得到 → passed:true、filter_blind:null,且不多花第二次查詢', async () => { + const store = [mkEntry('e1', 'content', 'o1')]; + let queries = 0; + const env = { + DB: makeFakeDB(store), + ENVIRONMENT: 'test', + AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } }, + VECTORIZE: { async query() { queries++; return { matches: [{ id: 'e1', score: 0.9 }] }; } }, + } as unknown as Bindings; + const r = await embedSelfTest(env, { owner_id: 'o1' }); + expect(r.passed).toBe(true); + expect(r.filter_blind).toBeNull(); + expect(queries).toBe(1); // 健康的情況不該多打一次(成本紀律) + }); + it('回應絕不含卡片內容或 entry id(隱私紅線)', async () => { const store = [mkEntry('e1', 'this is the secret card body, must never leak')]; const env = makeEnv(store, { matches: [{ id: 'e1', score: 0.9 }] }); diff --git a/kbdb/tests/search-semantic-empty-reason.test.ts b/kbdb/tests/search-semantic-empty-reason.test.ts index ee2dd62..6d83601 100644 --- a/kbdb/tests/search-semantic-empty-reason.test.ts +++ b/kbdb/tests/search-semantic-empty-reason.test.ts @@ -107,3 +107,80 @@ describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼 expect(body.capability_hint).toBeUndefined(); }); }); + +// ── 第四態 filter_blind(Arcrun#85 D70,2026-08-11 leo21c 實撞)───────────────── +// +// 現場:現役 Vectorize index `arcrun-kbdb-embed-m3` 上**一個 metadata index 都沒有** +// (真兇=arcrun-rag 安裝器把端點寫成 `metadata-index/create`,連字號版 CF 回 404, +// 底線 `metadata_index/create` 才是對的;而該安裝器把失敗降級成一行 ⚠ 就宣告成功)。 +// ⇒ Vectorize 對 owner_id 下 filter 一律回 0 筆,而**每一條真實使用者路徑都帶 owner_id** +// 做租戶隔離 ⇒ 語意搜尋 100% 全盲。 +// 實測(leo21c,同一句查詢):不帶 filter → 1 命中 score 0.8957;帶 owner_id → 0 命中。 +// +// 舊行為把這個歸成 no_match,回「換個說法或更具體的關鍵字再試試看」 +// =**把系統故障說成使用者的問題**,正是 leo 2026-08-09 直令禁止的那件事, +// 而且沒有人會因為「搜不到」去翻 Cloudflare 的 Vectorize 設定。 +function makeFilterAwareEnv( + dbOpts: Parameters[0], + opts: { unfiltered: { id: string; score: number }[]; filtered: { id: string; score: number }[] }, +): Bindings { + return { + DB: makeFakeDB(dbOpts), + ENVIRONMENT: 'test', + AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } }, + VECTORIZE: { + async query(_v: number[], o?: { filter?: Record }) { + const filtered = !!(o?.filter && Object.keys(o.filter).length > 0); + return { matches: filtered ? opts.filtered : opts.unfiltered }; + }, + }, + } as unknown as Bindings; +} + +describe('empty_reason=filter_blind — Vectorize metadata 過濾整個是死的', () => { + it('帶 owner_id 零命中、拿掉 filter 有命中 → filter_blind,且照實說是我們的故障', async () => { + const app = makeApp(); + const env = makeFilterAwareEnv( + { embeddedCount: 805, hydrateEntry: mkEntry('e1') }, + { unfiltered: [{ id: 'e1', score: 0.8957 }], filtered: [] }, + ); + const res = await app.request('/entries/search?q=黑面琵鷺&mode=semantic&owner_id=bfezv28v', {}, env); + const body = (await res.json()) as Record; + expect(body.count).toBe(0); + expect(body.empty_reason).toBe('filter_blind'); + const hint = body.capability_hint as string; + // 🔴 誠實鐵律:是故障、不是使用者的錯,且**明說換個說法沒有用** + expect(hint).toContain('故障'); + expect(hint).toContain('不是你'); + expect(/換個說法也不會有用/.test(hint)).toBe(true); + // 🔴 人話紅線:不准把 Vectorize/owner_id 這類內部詞漏給使用者 + expect(/vectorize|owner_id|metadata|index/i.test(hint)).toBe(false); + // 技術細節與**處方順序**留給維運者 + const admin = body.admin_hint as string; + expect(admin).toContain('metadata index'); + expect(admin).toContain('reindex'); + }); + + it('沒帶任何 filter 的查詢不做探針,維持 no_match(不多花一次查詢)', async () => { + const app = makeApp(); + let queries = 0; + const env = { + DB: makeFakeDB({ embeddedCount: 42 }), + ENVIRONMENT: 'test', + AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } }, + VECTORIZE: { async query() { queries++; return { matches: [] }; } }, + } as unknown as Bindings; + const res = await app.request('/entries/search?q=x&mode=semantic', {}, env); + const body = (await res.json()) as Record; + expect(body.empty_reason).toBe('no_match'); + expect(queries).toBe(1); + }); + + it('帶 filter 但拿掉 filter 也零命中 → 仍是 no_match(別把正常的找不到誣賴成故障)', async () => { + const app = makeApp(); + const env = makeFilterAwareEnv({ embeddedCount: 42 }, { unfiltered: [], filtered: [] }); + const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=t1', {}, env); + const body = (await res.json()) as Record; + expect(body.empty_reason).toBe('no_match'); + }); +});