// 語意搜尋回空時「為什麼空」的回歸測試(2026-08-08,總管交辦二修,Oscar 封測案更正後的真因)。 // // 背景:原本以為 Oscar 撞到的是「capability_hint 文案太工程師」,後來查出模組其實有開, // 真正發生的是 GET /entries/search?mode=semantic 零命中時回 {success:true, entries:[], count:0} // ——誠實(沒假裝有結果)但完全不說為什麼,用戶看到的是「這裡沒有這筆資料」, // 真相可能是「索引根本沒建好」。三態: // - no_index :這個 owner 範圍從沒 embed 過(backfillStatus.embedded===0) // - no_match :有索引,這次查詢在 Vectorize 端零命中(正常的「找不到」) // - stale_index :Vectorize 端有命中,但 hydrate 後全部是已下架/找不到對應資料(孤兒向量) // (不是「相對門檻濾光」——relativeMinScore 的 cut 數學上 <= 最高分,不可能讓非空結果變空) // 正常有結果(count>0)不受影響,不應該出現 empty_reason 欄位。 import { describe, it, expect } from 'vitest'; import { Hono } from 'hono'; import { entryRoutes } from '../src/routes/entries'; import type { Bindings, Entry } from '../src/types'; function mkEntry(id: string, opts: { deprecated?: boolean } = {}): Entry { return { id, content: '一些內容', entry_type: 'block', owner_id: 'oscar-tenant', parent_id: null, page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null, is_embedded: 1, confidence: null, metadata_json: opts.deprecated ? JSON.stringify({ status: 'deprecated' }) : JSON.stringify({ embed: true }), created_at: 1, updated_at: 1, }; } // fake D1:COUNT 查詢回傳可配置的 embeddedCount;`WHERE id = ?`(getEntry)回傳可配置的 entry。 function makeFakeDB(opts: { embeddedCount?: number; hydrateEntry?: Entry | null } = {}) { const embeddedCount = opts.embeddedCount ?? 0; const prepare = (sql: string) => { let bound: unknown[] = []; const stmt = { bind(...args: unknown[]) { bound = args; return stmt; }, async first() { if (sql.includes('WHERE id = ?')) { return (opts.hydrateEntry ?? null) as unknown as T; } return { c: embeddedCount } as unknown as T; }, async all() { return { results: [] as T[] }; }, async run() { return { success: true }; }, }; return stmt; }; return { prepare } as unknown as D1Database; } function makeApp() { const app = new Hono<{ Bindings: Bindings }>(); app.route('/entries', entryRoutes); return app; } function makeEnv(dbOpts: Parameters[0], matches: { 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() { return { matches }; } }, } as unknown as Bindings; } describe('GET /entries/search?mode=semantic — 零命中時分辨「為什麼空」', () => { it('embedded=0(從沒 embed 過)→ empty_reason=no_index,人話不含 vectorize/redeploy/CC', async () => { const app = makeApp(); const env = makeEnv({ embeddedCount: 0 }, []); const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env); const body = (await res.json()) as Record; expect(body.mode).toBe('semantic'); expect(body.count).toBe(0); expect(body.empty_reason).toBe('no_index'); const hint = body.capability_hint as string; expect(hint).toBeTruthy(); expect(/vectorize|redeploy|CC「|binding|kbdb_embed/i.test(hint)).toBe(false); expect(body.admin_hint).toBeTruthy(); }); it('embedded>0 但這次零命中 → empty_reason=no_match(正常的「找不到」,非故障)', async () => { const app = makeApp(); const env = makeEnv({ embeddedCount: 42 }, []); const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env); const body = (await res.json()) as Record; expect(body.mode).toBe('semantic'); expect(body.count).toBe(0); expect(body.empty_reason).toBe('no_match'); }); it('Vectorize 有命中但對應資料已下架 → empty_reason=stale_index(非分數門檻)', async () => { const app = makeApp(); // 命中一筆,但 hydrate 回來的 entry 是已下架的 → 濾光 → entries=0,hits.length=1(>0)。 const env = makeEnv({ hydrateEntry: mkEntry('e1', { deprecated: true }) }, [{ id: 'e1', score: 0.6 }]); const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env); const body = (await res.json()) as Record; expect(body.mode).toBe('semantic'); expect(body.count).toBe(0); expect(body.empty_reason).toBe('stale_index'); }); it('正常有結果(count>0)不受影響:無 empty_reason 欄位', async () => { const app = makeApp(); const env = makeEnv({ hydrateEntry: mkEntry('e1') }, [{ id: 'e1', score: 0.6 }]); const res = await app.request('/entries/search?q=x&mode=semantic&owner_id=oscar-tenant', {}, env); const body = (await res.json()) as Record; expect(body.mode).toBe('semantic'); expect(body.count).toBe(1); expect(body.empty_reason).toBeUndefined(); 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'); }); });