fix(semantic): 故障照實說是故障——不再把壞掉說成「沒開通」(leo 2026-08-09 直令)

一、文案(portal/console/kbdb hint):語意搜尋是一安裝就提供的功能,
   降級=故障。橫幅改「語意搜尋目前故障/我們的問題/你不用做任何事」,
   拿掉「還沒開通、想開通請匯出診斷檔」這種要使用者申請開通的假框架。
   kbdb 降級回應加 degraded_reason(module_off / embed_query_failed)。

二、查詢向量化失敗不再偽裝成空結果(leo 點名的謊):
   semanticSearch 舊行為「AI 額度用完 → 回 []」會讓使用者以為
   自己的知識庫裡沒有這筆資料。改丟 EmbedQueryFailedError,
   route 誠實降級 keyword+照實告知是暫時故障。

三、源頭機制(裝好的實例為什麼會失去語意搜尋):
   - acr update:kbdb_embed 判斷 ===true → !==false。config 缺欄位時
     redeploy 會把 [[vectorize]]+[ai] binding 靜默剝掉(wrangler deploy
     整份覆蓋),一台正常實例就此壞掉。init 預設同步翻成 [Y/n]。
   -(另 repo)deploy-all.mjs ensureVectorizeIndex 失敗改致命中止。

四、順手自癒:孤兒向量/下架殘影搜尋時背景清除;空結果且 pending>0
   背景 backfill;no_index 拆「故障」vs「還沒有資料」兩態。

測試:kbdb 146/146(新增 degraded 6 案+selftest 1 案);cli 10/10;
瀏覽器端到端兩種故障畫面實測(local wrangler dev+portal 真登入)。
無 SDD 對應:leo 直令修故障(同 08-07 檢修孔前例的人閘直接授權路徑)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-08-09 02:05:12 +08:00
parent 8eb10049b8
commit 6846d6ddae
10 changed files with 425 additions and 49 deletions
+37 -3
View File
@@ -325,7 +325,16 @@ export async function embedSelfTest(
return { enabled: true, tested: false, passed: null, note: '取樣卡片內容為空,跳過自我檢查' };
}
// min_score:0——自我檢查要看「找不找得到」,不能被查詢端的相對門檻先濾掉。
const hits = await semanticSearch(env, sample, { owner_id: opts.owner_id, 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 });
} 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 檢查期間消失,罕見)' };
}
@@ -349,6 +358,22 @@ export interface SemanticHit {
library?: string;
}
/**
* 查詢向量化失敗(2026-08-09 leo 直令:「查詢的向量化如果失敗(例如當天額度用完),
* 目前會回一個空的結果集——那是騙人,不是降級」)。
*
* 舊行為:embedText 拿不到向量 → semanticSearch 回 []caller 分不出
* 「真的沒命中」和「根本沒查成」,使用者看到「查無資料」,以為知識庫裡沒有這筆東西。
* 新行為:AI.run 丟錯(額度用完/模型故障)或回不出向量 → 丟這個錯,
* 由 route 層誠實降級 keyword +告知「這是我們的故障」,不再偽裝成空結果。
*/
export class EmbedQueryFailedError extends Error {
constructor(detail: string) {
super(`查詢向量化失敗:${detail}`);
this.name = 'EmbedQueryFailedError';
}
}
/**
* 語義搜尋(mode:'semantic')。模組未開 → 回 nullcaller 降級 keyword + 告知缺能力)。
* owner_id / source / entry_type 過濾走 Vectorize metadata filterentry_type 已 index,見上 upsert metadata)。
@@ -369,8 +394,17 @@ export async function semanticSearch(
opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number; min_score?: number } = {},
): Promise<SemanticHit[] | null> {
if (!embedEnabled(env)) return null;
const vec = await embedText(env, q);
if (!vec) return [];
// 空查詢=真的沒東西可查(route 層已擋 q 必填,這裡只兜底),不算故障。
if (!(q ?? '').trim()) return [];
// 🔴 2026-08-09leo 直令):向量化失敗**不准**回空結果集。空結果=「你的庫裡沒有」,
// 向量化失敗=「我們沒查成」——兩者對使用者是完全不同的事實,混在一起就是說謊。
let vec: number[] | null;
try {
vec = await embedText(env, q);
} catch (e) {
throw new EmbedQueryFailedError(e instanceof Error ? e.message : String(e));
}
if (!vec) throw new EmbedQueryFailedError('Workers AI 沒有回出向量(回應形狀異常或空回應)');
const filter: VectorizeVectorMetadataFilter = {};
if (opts.owner_id) filter.owner_id = opts.owner_id;
if (opts.source) filter.source = opts.source;
+96 -23
View File
@@ -13,11 +13,28 @@ import {
searchEntries,
isDeprecatedEntry,
} from '../actions/entry-crud';
import { embedEnabled, embedOnWrite, semanticSearch, relativeMinScore, backfillStatus } from '../embed';
import {
embedEnabled,
embedOnWrite,
semanticSearch,
relativeMinScore,
backfillStatus,
backfillEmbeddings,
EmbedQueryFailedError,
} from '../embed';
import { migrateLegacyCredentialsForOwner } from '../actions/credential-legacy-migration';
export const entryRoutes = new Hono<{ Bindings: Bindings }>();
// fire-and-forget:有 executionCtxworkerd)就 waitUntil,測試環境沒有就 detach(吞錯不吵)。
// 給搜尋路徑的「自癒」動作用——修復是順手做的背景事,絕不拖慢也絕不弄壞查詢本身。
function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknown>): void {
let ctx: ExecutionContext | undefined;
try { ctx = c.executionCtx; } catch { ctx = undefined; }
if (ctx) ctx.waitUntil(p.catch(() => {}));
else void p.catch(() => {});
}
// library 多值參數(逗號分隔,portal-auth P1design §3.3)。空值/全空白 → undefined(=不過濾,
// 行為與未帶參數一字不變——向後相容硬驗收)。
function parseLibraryParam(raw: string | undefined): string[] | undefined {
@@ -116,9 +133,12 @@ entryRoutes.get('/', async (c) => {
// GET /entries/search?q=...&owner_id=...&source=...&entry_type=...&library=...&mode=keyword|semantic
// - mode=keyword(預設):D1 LIKEbase,永遠可用)。
// - mode=semantic:需 embed 模組開(Vectorize+AI binding)。未開 → 降級 keyword +
// capability_hint 告知缺能力(#7 發現閉環)。capability_hint 是講給非技術使用者聽的人話
// capability_hint。capability_hint 是講給非技術使用者聽的人話
// 2026-08-08 修:曾經直接透傳到封測用戶眼前的工程師導向文字,見該欄位旁註);
// 技術細節另放 admin_hint 給維運者/CC 看。
// 🔴 2026-08-09leo 直令):語意搜尋是**一安裝就提供**的功能,模組不在=故障,
// 文案照實說「壞了、是我們的問題、使用者不用做任何事」,禁止說成「還沒開通/未啟用」。
// 降級回應帶 degraded_reasonmodule_off / embed_query_failed)供前端與診斷分流。
// - entry_typebase 通用 filtercaller 傳任意 type,如 workflowbase 不寫死語意,workflow-discovery Q4)。
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extractNULL→general
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
@@ -158,19 +178,41 @@ entryRoutes.get('/search', async (c) => {
// 已在 PR 描述向 leo 說明這個 trade-off(多倍 margin vs 迴圈重撈的取捨)。
const requestedTopK = top_k ?? 20; // 與 embed.ts semanticSearch 的預設 topK 對齊
const fetchTopK = include_deprecated ? requestedTopK : Math.min(requestedTopK * 3, 100);
const hits = await semanticSearch(c.env, q, {
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
});
// 🔴 2026-08-09leo 直令):語意搜尋壞掉時**照實說是故障**。
// - 語意搜尋是一安裝就提供的功能。走到下面任一降級分支=這台實例壞了,
// 不是「還沒開通」「未啟用」——禁止把 bug 美化成沒提供(那會製造
// 「請幫我開通」的客服工單,而真正的故障沒人修)。
// - capability_hint 給一般使用者看:說清楚「是我們的問題、不是你的錯、
// 你不用做任何事」;技術細節放 admin_hint 給維運者/CC。
// - 降級仍回關鍵字結果:有退化的結果比空白有用,但誠實標示,不假裝是語意結果。
let hits;
try {
hits = await semanticSearch(c.env, q, {
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
});
} catch (e) {
if (e instanceof EmbedQueryFailedError) {
// 查詢向量化失敗(Workers AI 額度用完/服務故障):舊版在這裡回空結果集
// =把「我們沒查成」偽裝成「你的庫裡沒有」——leo 08-09 點名的謊。改誠實降級。
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
return c.json({
success: true,
entries,
count: entries.length,
mode: 'keyword',
requested_mode: 'semantic',
degraded_reason: 'embed_query_failed',
capability_hint:
'語意搜尋暫時故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,稍後它會自動恢復。',
admin_hint: `${e.message}。常見原因:Workers AI 當日額度用完或服務暫時異常;本次已降級關鍵字搜尋,資料與索引皆未受影響。`,
});
}
throw e;
}
if (hits === null) {
// 模組沒開:誠實降級 keyword(不假裝有語義)。
// 🔴 2026-08-08(總管交辦,Oscar 封測回報「語義搜尋搜不到」的根因修復):
// capability_hint 是「誠實透傳」鏈路(cypher-executor portal-data.ts → portal 前端)
// 唯一一次主動告訴非技術使用者「發生什麼事+下一步」的機會,故預設文案改成人話:
// - 不假設讀者懂 vectorize / binding / redeploy / 「叫 CC」這些我們內部的修法指令
// - 誠實承認這次是降級(不是「一樣好,只是換個名字」)
// - 給一個他自己做得到的下一步(換字重試 / 聯絡我們開通),不是要他自己修系統
// 技術細節不丟掉,換到 admin_hint(機器可讀,給真正的維運者/CC 看)——薄殼原則:
// 這裡是 base API 的一個回應形狀,兩個欄位並存,caller 自己挑要顯示哪一個。
// embed 模組不在(缺 VECTORIZE/AI binding):對一安裝就提供的功能而言,這**是故障**
// ——多半是某次部署把 binding 弄丟了(更新時沒帶 kbdb_embed、或安裝時 Vectorize
// 建立失敗被靜默放行)。誠實降級 keyword,照實說壞了,不說「還沒開通」。
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
return c.json({
success: true,
@@ -178,24 +220,43 @@ entryRoutes.get('/search', async (c) => {
count: entries.length,
mode: 'keyword',
requested_mode: 'semantic',
degraded_reason: 'module_off',
capability_hint:
'語意搜尋還沒開通,這次顯示的是關鍵字比對結果,不是用「意思」找的——你打的字要盡量貼近資料裡實際出現的詞才容易搜到。想啟用語意搜尋,請聯絡我們協助開通。',
'語意搜尋目前故障,先用關鍵字幫你找了下面的結果。這是我們系統的問題,不是你的操作問題,你不需要做任何事,我們會修好它。',
admin_hint:
'語義查詢需先開 vectorize(embed 模組)。叫 CC「幫我開語義查詢」即可(設 kbdb_embed:true + redeploy。本次已降級關鍵字搜尋。',
'故障:kbdb worker 缺 VECTORIZE/AI bindingembedEnabled=false)。語意搜尋是安裝即提供的功能,缺 binding=部署層事故(常見:redeploy 沒帶 kbdb_embed 注入、或安裝時 Vectorize index 建立失敗被放行)。修法:確認 Vectorize index 存在後以 kbdb_embed:true 重部 kbdb。本次已降級關鍵字搜尋。',
});
}
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
// #67entry 附 score(相似分數)——加欄不改形,既有 caller 不解析多的欄位不受影響。
// 2026-08-09 自癒:hydrate 過程順手記下「索引裡有、資料已不在」的向量
// - 孤兒(getEntry 找不到)→ 該向量已無對應資料,直接刪;
// - 殘影(已下架但向量還在,0.971 案的病原)→ 刪向量+is_embedded 歸零。
// 背景執行(fireAndForget),失敗下次搜尋再清;查詢本身不受影響。
const orphanIds: string[] = [];
const deprecatedIds: string[] = [];
let entries = (
await Promise.all(
hits.map(async (h) => {
const e = await getEntry(c.env.DB, h.id);
return e ? { ...e, score: h.score } : null;
if (!e) { orphanIds.push(h.id); return null; }
return { ...e, score: h.score };
}),
)
).filter((e): e is NonNullable<typeof e> => e !== null);
if (!include_deprecated) {
entries = entries.filter((e) => !isDeprecatedEntry(e));
entries = entries.filter((e) => {
const dep = isDeprecatedEntry(e);
if (dep) deprecatedIds.push(e.id);
return !dep;
});
}
const staleIds = [...orphanIds, ...deprecatedIds];
if (staleIds.length > 0 && c.env.VECTORIZE) {
fireAndForget(c, (async () => {
await c.env.VECTORIZE!.deleteByIds(staleIds);
await markUnembedded(c.env.DB, deprecatedIds);
})());
}
// 🔴 2026-08-05:相對門檻砍低分尾(leo 實測「關懷型 AI」命中 20 筆、只有前 3 筆相關)。
// **一定要接在濾掉下架的後面**——否則一筆 0.971 的下架殘影會把 0.6 的正解一起帶走
@@ -233,21 +294,33 @@ entryRoutes.get('/search', async (c) => {
let admin_hint: string;
if (hits.length === 0) {
const status = await backfillStatus(c.env, { owner_id });
if (status.embedded === 0) {
if (status.embedded === 0 && status.pending > 0) {
// 資料在、索引卻一筆都沒建=故障(寫入時嵌入沒成功過)。順手自癒:
// 背景補嵌一批(冪等、分批),下次搜尋就有機會直接好——不叫使用者做任何事。
empty_reason = 'no_index';
capability_hint =
'這個知識庫目前還沒有可供語意搜尋的資料,所以搜不到——不是你打的字有問題。請聯絡我們確認索引有沒有建好。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 backfillStatus.embedded=0:從未 embed,或 backfill 未跑過`;
'語意搜尋的索引出了狀況,所以暫時搜不到——這是我們系統的問題,不是你打的字有問題。系統正在自動重建,稍後再搜一次看看。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 但 pending=${status.pending}:資料在、索引從沒建成=寫入端嵌入從未成功(故障)。本次已背景觸發 backfill 自癒(每批 100,冪等)`;
fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
} else if (status.embedded === 0) {
// 連「該被嵌的資料」都沒有=這個庫還沒有整理好的內容(新裝好還沒同步),不是故障。
empty_reason = 'no_index';
capability_hint =
'這個知識庫還沒有整理好的內容可以搜尋——通常是剛裝好、資料還沒同步進來。等同步小幫手跑完再來搜就有了。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 範圍 embedded=0 且 pending=0:沒有任何標記 embed:true 的 entry——多半是 ingest 還沒跑(正常的空),少數情況是 ingest 管線沒標 embed 旗標(要查管線)。`;
} else {
empty_reason = 'no_match';
capability_hint = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
admin_hint = `owner_id=${owner_id ?? '(all)'} 已有 ${status.embedded} 筆嵌入資料,但本次查詢在 Vectorize 端零命中(含 embed.ts 絕對門檻過濾)。`;
// 順手自癒:pending>0=有卡片在寫入時漏嵌(embedOnWrite 失敗是 fire-and-forget
// 沒有別的機制會回來補)。status 已經查了,不多花查詢,背景補一批。
if (status.pending > 0) fireAndForget(c, backfillEmbeddings(c.env, { owner_id, limit: 100 }));
}
} else {
empty_reason = 'stale_index';
capability_hint =
'到的內容已經被下架或移除了,所以沒有可顯示的結果——換個關鍵字再試試看,或聯絡我們確認索引有沒有過期。';
admin_hint = `Vectorize 命中 ${hits.length} 筆,但 hydrate 後全部是已下架或找不到對應資料(孤兒向量),非分數門檻造成——相對門檻數學上不可能砍光非空結果(cut<=top)。`;
'這次比對到的內容源頭已經被移除或下架了,所以沒有可顯示的結果。系統已自動清理過期索引(我們的問題,你不用做任何事),換個關鍵字就能正常搜。';
admin_hint = `Vectorize 命中 ${hits.length} 筆,但 hydrate 後全部是已下架或找不到對應資料(孤兒向量),非分數門檻造成——相對門檻數學上不可能砍光非空結果(cut<=top)。本次已背景觸發向量清理(deleteByIds)。`;
}
return c.json({
success: true, entries, count: entries.length, mode: 'semantic',