WIP(kbdb): 語意搜尋零命中的排查——⚠️ 被總管中途叫停,未完成驗證
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>
This commit is contained in:
+67
-12
@@ -288,6 +288,15 @@ export interface SelfTestResult {
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,7 +316,7 @@ export async function embedSelfTest(
|
||||
opts: { owner_id?: string } = {},
|
||||
): Promise<SelfTestResult> {
|
||||
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<Entry>();
|
||||
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',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,41 @@ function fireAndForget(c: { executionCtx?: ExecutionContext }, p: Promise<unknow
|
||||
else void p.catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* 「這次零命中,是不是因為 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<boolean> {
|
||||
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 = '沒有找到符合的內容,換個說法或更具體的關鍵字再試試看。';
|
||||
|
||||
@@ -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<string, unknown> }) {
|
||||
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 }] });
|
||||
|
||||
@@ -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<typeof makeFakeDB>[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<string, unknown> }) {
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
expect(body.empty_reason).toBe('no_match');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user