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>
187 lines
9.3 KiB
TypeScript
187 lines
9.3 KiB
TypeScript
// 語意搜尋回空時「為什麼空」的回歸測試(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<T>() {
|
||
if (sql.includes('WHERE id = ?')) {
|
||
return (opts.hydrateEntry ?? null) as unknown as T;
|
||
}
|
||
return { c: embeddedCount } as unknown as T;
|
||
},
|
||
async all<T>() { 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<typeof makeFakeDB>[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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<string, unknown>;
|
||
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<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');
|
||
});
|
||
});
|