0ff369f818
leo 2026-08-05 實撞:「新上傳的『loop-engine-north-star.md』語義 0 命中,
但舊的『人力媒合系統規劃書』語義 2 命中」。
## 根因不在向量——向量是好的
直打 Vectorize 實測(youlin 實例、bge-m3、1024 維 index):
搜「閉環機」→ loop-engine-north-star.md 穩坐第 1-4 名(0.638/0.603/0.588/0.552)
D1 與 Vectorize 也對得上:659 筆 embeddable 全 is_embedded=1、index vectorCount 659。
真兇是 **min_score 門檻綁在舊模型的分數尺度上**:
舊 bge-base-en-v1.5:中文分數全擠 0.65-0.90(沒區辨力)⇒ t183 取 0.75 砍雜訊,對
新 bge-m3 :尺度整體下移(相關 0.5-0.85、雜訊 0.4 上下)⇒ 0.75 砍掉的是正解
leo 看到的「舊檔中、新檔不中」由此而來——「人力媒合系統規劃書」拿 0.842 僥倖存活,
其餘全被門檻掃掉。08-05 換 bge-m3 的「四處同步」清單(embed.ts/deploy.ts/
deploy-all.mjs/worker.js)**漏了這第五處**,因為它不在 kbdb 而在 portal 呼叫端。
## 改動
· kbdb/src/embed.ts:新增 DEFAULT_MIN_SCORE=0.5,**緊鄰 DEFAULT_EMBED_MODEL**
——門檻是模型的性質,放模型旁邊,下次換模型的人一定會看到
· cypher-executor/src/routes/portal-data.ts:拿掉硬寫的 0.75,
只在使用者顯式指定時才傳 min_score(不再各自持有一份數字=不再漂移)
· console-ui/public/portal/os-split.test.mjs:修好被今天 fe0ee82 弄壞的自測
(結尾標記寫死文案 ⇒ 改文案就炸「抽不到函式區塊」;改成錨定結構)
+斷言同步改成 Mac 給 DMG
## 0.5 怎麼來的(實測分布,不是猜的)
「閉環機」 0.638/0.603/0.588/0.552 全是目標檔 ── 斷崖 ── 0.446 才是雜訊
「火星座標 奧林帕斯山」 0.750…0.500 全對,0.475 以下才是雜訊
「人力媒合系統規劃書」 0.842 對,0.550 起是雜訊
誠實 trade-off:0.5 非每個查詢都乾淨(「AI 上課名冊」0.658 的 ax-academy 會擠進來),
但「偶有雜訊」遠優於現況「什麼都搜不到」。
## 驗
· kbdb 87/87 綠(三筆斷言隨新契約更新:預設不再是「不過濾」)
· cypher-executor 9 failed/301 passed=**與改動前逐數相同**(git stash 前後各跑一次)⇒ 既有債
· portal os-split 自測 10/10 綠
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
12 KiB
TypeScript
224 lines
12 KiB
TypeScript
// Gitea #66/#67 — /entries/search 兩個檢索缺口的回歸測試。
|
||
// #66:keyword 路徑 source 參數解析後丟棄(#5.1 只接了 listEntries 那半)→ searchEntries 補
|
||
// json_extract 謂詞、route 傳入;含向後相容(不帶 source = SQL 一字不變)。
|
||
// #67:semantic 固定 topK=20、零分數閾值 → route 曝 top_k/min_score、hit 依 min_score 過濾、
|
||
// 回應 entry 附 score;含向後相容(不帶新參數 = 行為不變,僅多 score 資訊)。
|
||
// 測試手法同 library-filter.test.ts:fake D1 捕 SQL 形狀、mock VECTORIZE 捕 query opts——
|
||
// 真 SQL 語意由本機 miniflare 驗(PR 驗收證據)。
|
||
import { describe, it, expect } from 'vitest';
|
||
import { Hono } from 'hono';
|
||
import { entryRoutes } from '../src/routes/entries';
|
||
import { searchEntries } from '../src/actions/entry-crud';
|
||
import { semanticSearch } from '../src/embed';
|
||
import type { Bindings, Entry } from '../src/types';
|
||
|
||
const SOURCE_PREDICATE = "json_extract(metadata_json, '$.source') = ?";
|
||
|
||
// ── fake D1:捕捉 prepared SQL 與 bound params;getEntry(SELECT … WHERE id = ?)回假 entry
|
||
// 讓 semantic hydrate 路徑走得完 ──
|
||
interface Captured { sql: string; params: unknown[] }
|
||
function makeCaptureDB(captured: Captured[]) {
|
||
const prepare = (sql: string) => {
|
||
const rec: Captured = { sql, params: [] };
|
||
captured.push(rec);
|
||
const stmt = {
|
||
bind(...args: unknown[]) { rec.params = args; return stmt; },
|
||
async all<T>() { return { results: [] as T[] }; },
|
||
async first<T>() {
|
||
if (sql.includes('WHERE id = ?')) return mkEntry(String(rec.params[0])) as unknown as T;
|
||
return { total: 0, c: 0 } as unknown as T;
|
||
},
|
||
async run() { return { success: true }; },
|
||
};
|
||
return stmt;
|
||
};
|
||
return { prepare } as unknown as D1Database;
|
||
}
|
||
|
||
function mkEntry(id: string): Entry {
|
||
return {
|
||
id, content: 'some content', entry_type: 'block', owner_id: 'tenant1', parent_id: null,
|
||
page_name: null, refs_json: '[]', tags_json: '[]', task_status: null, content_hash: null,
|
||
is_embedded: 0, confidence: null, metadata_json: null, created_at: 1, updated_at: 1,
|
||
};
|
||
}
|
||
|
||
function makeApp(captured: Captured[], extraEnv: Record<string, unknown> = {}) {
|
||
const app = new Hono<{ Bindings: Bindings }>();
|
||
app.route('/entries', entryRoutes);
|
||
const env = { DB: makeCaptureDB(captured), ENVIRONMENT: 'test', ...extraEnv } as unknown as Bindings;
|
||
return { app, env };
|
||
}
|
||
|
||
// ══ #66 source filter ══════════════════════════════════════════════════════
|
||
|
||
describe('#66 — searchEntries source filter(SQL 形狀)', () => {
|
||
it('帶 source → LIKE+json_extract($.source) 謂詞+參數(與 listEntries #5.1 同款)', async () => {
|
||
const captured: Captured[] = [];
|
||
await searchEntries(makeCaptureDB(captured), '遷移', 'tenant1', undefined, undefined, undefined, 'gitea:Leo/kb@main/foo.md');
|
||
expect(captured[0].sql).toContain('content LIKE ?');
|
||
expect(captured[0].sql).toContain(SOURCE_PREDICATE);
|
||
expect(captured[0].params).toContain('gitea:Leo/kb@main/foo.md');
|
||
});
|
||
|
||
it('不帶 source → SQL 無 $.source 謂詞(向後相容:行為一字不變)', async () => {
|
||
const captured: Captured[] = [];
|
||
await searchEntries(makeCaptureDB(captured), '遷移', 'tenant1');
|
||
expect(captured[0].sql).not.toContain('$.source');
|
||
});
|
||
|
||
it('source+library 併用 → 兩謂詞都在、參數順序對(source 先於 library)', async () => {
|
||
const captured: Captured[] = [];
|
||
await searchEntries(makeCaptureDB(captured), '遷移', undefined, undefined, undefined, ['finance'], 'src-a');
|
||
expect(captured[0].sql).toContain(SOURCE_PREDICATE);
|
||
expect(captured[0].sql).toContain('$.library');
|
||
// params: [%遷移%, 'src-a', 'finance', limit]
|
||
expect(captured[0].params[1]).toBe('src-a');
|
||
expect(captured[0].params[2]).toBe('finance');
|
||
});
|
||
});
|
||
|
||
describe('#66 — route GET /entries/search(keyword)source 下傳', () => {
|
||
it('?q=x&source=… → 謂詞下到 searchEntries(原 bug:解析完即丟)', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured);
|
||
const res = await app.request('/entries/search?q=x&source=gitea%3ALeo%2Fkb%40main%2Ffoo.md', {}, env);
|
||
expect(res.status).toBe(200);
|
||
expect(captured[0].sql).toContain(SOURCE_PREDICATE);
|
||
expect(captured[0].params).toContain('gitea:Leo/kb@main/foo.md');
|
||
});
|
||
|
||
it('不帶 source → SQL 無 $.source(向後相容)', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured);
|
||
const res = await app.request('/entries/search?q=x', {}, env);
|
||
expect(res.status).toBe(200);
|
||
expect(captured[0].sql).not.toContain('$.source');
|
||
});
|
||
|
||
it('semantic 模組未開+帶 source → 降級 keyword 仍套 source filter(不因降級洩 source)', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured); // 無 VECTORIZE/AI → semanticSearch 回 null
|
||
const res = await app.request('/entries/search?q=x&mode=semantic&source=src-a', {}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { mode: string };
|
||
expect(body.mode).toBe('keyword');
|
||
expect(captured[0].sql).toContain(SOURCE_PREDICATE);
|
||
expect(captured[0].params).toContain('src-a');
|
||
});
|
||
});
|
||
|
||
// ══ #67 top_k / min_score ══════════════════════════════════════════════════
|
||
|
||
// mock VECTORIZE:捕 query opts、回三筆遞減分數(0.9 / 0.5 / 0.2)供閾值截斷驗證。
|
||
function makeSemanticEnv(queryCalls: { opts: Record<string, unknown> }[]) {
|
||
return {
|
||
AI: { async run() { return { data: [[0.1, 0.2, 0.3]] }; } },
|
||
VECTORIZE: {
|
||
async query(_vec: number[], opts: Record<string, unknown>) {
|
||
queryCalls.push({ opts });
|
||
return {
|
||
matches: [
|
||
{ id: 'e-high', score: 0.9, metadata: {} },
|
||
{ id: 'e-mid', score: 0.5, metadata: {} },
|
||
{ id: 'e-low', score: 0.2, metadata: {} },
|
||
],
|
||
};
|
||
},
|
||
async upsert(v: unknown[]) { return { count: (v as unknown[]).length }; },
|
||
},
|
||
};
|
||
}
|
||
|
||
describe('#67 — semanticSearch topK / min_score', () => {
|
||
// 🔴 2026-08-05:預設 min_score 由 0(不過濾)改為 DEFAULT_MIN_SCORE(跟著 embed 模型走)。
|
||
// 原因=閾值原本硬寫在 portal 呼叫端,換 bge-m3 後沒人回頭改 ⇒ 語義搜尋全 0 命中。
|
||
// 測資分數 0.9 / 0.5 / 0.2:預設閾值 0.5 ⇒ 只有 0.2 的低分尾被砍。
|
||
it('不帶 min_score → topK=20、套用預設閾值(低分尾 0.2 被砍)', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
|
||
const hits = await semanticSearch(env, 'query', {});
|
||
expect(calls[0].opts.topK).toBe(20);
|
||
expect(hits?.map((h) => h.id)).toEqual(['e-high', 'e-mid']);
|
||
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5]); // score 帶回
|
||
});
|
||
|
||
it('min_score=0.5 → 低分尾截掉(>= 閾值者留)', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
|
||
const hits = await semanticSearch(env, 'query', { min_score: 0.5 });
|
||
expect(hits?.map((h) => h.id)).toEqual(['e-high', 'e-mid']);
|
||
});
|
||
|
||
it('topK 透傳且封頂 100', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const env = { DB: makeCaptureDB([]), ENVIRONMENT: 'test', ...makeSemanticEnv(calls) } as unknown as Bindings;
|
||
await semanticSearch(env, 'query', { topK: 5 });
|
||
expect(calls[0].opts.topK).toBe(5);
|
||
await semanticSearch(env, 'query', { topK: 500 });
|
||
expect(calls[1].opts.topK).toBe(100);
|
||
});
|
||
});
|
||
|
||
describe('#67 — route GET /entries/search(semantic)top_k / min_score / score 欄', () => {
|
||
function makeSemanticApp(calls: { opts: Record<string, unknown> }[], captured: Captured[] = []) {
|
||
return makeApp(captured, makeSemanticEnv(calls));
|
||
}
|
||
|
||
// daemon-beta t24(07-24 補位變更):route 現在對 Vectorize 的實際查詢 topK 會做「補位」
|
||
// (預設過濾 deprecated 時 ×3 封頂 100,見 entries.ts 補位註解),不再是 top_k 原封透傳到
|
||
// VECTORIZE.query。route 對 caller 的回應仍會照 top_k 截斷(見 body.count/entries 斷言不變)
|
||
// ——這裡改的只是「送進 Vectorize 那次呼叫的 topK 參數」,非對外契約。三筆測試同步更新
|
||
// calls[0].opts.topK 期望值(5→15=5×3、20→60=20×3),其餘斷言(回應筆數/內容/score)不動。
|
||
|
||
it('?top_k=5&min_score=0.5 → Vectorize 補位 topK=15(5×3)、回應仍照 top_k 截後低分尾、entry 附 score', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const { app, env } = makeSemanticApp(calls);
|
||
const res = await app.request('/entries/search?q=x&mode=semantic&top_k=5&min_score=0.5', {}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { mode: string; count: number; entries: (Entry & { score?: number })[] };
|
||
expect(body.mode).toBe('semantic');
|
||
expect(calls[0].opts.topK).toBe(15); // t24 補位:5 × 3
|
||
expect(body.count).toBe(2); // 0.2 的低分尾被 min_score 截掉
|
||
expect(body.entries.map((e) => e.id)).toEqual(['e-high', 'e-mid']);
|
||
expect(body.entries.map((e) => e.score)).toEqual([0.9, 0.5]);
|
||
});
|
||
|
||
it('不帶新參數 → Vectorize 補位 topK=60(預設 20×3),套用預設閾值後回 2 筆,entry 仍附 score(加欄不改形)', async () => {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const { app, env } = makeSemanticApp(calls);
|
||
const res = await app.request('/entries/search?q=x&mode=semantic', {}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { count: number; entries: (Entry & { score?: number })[] };
|
||
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
|
||
expect(body.count).toBe(2); // 08-05:預設閾值生效,0.2 的低分尾被砍
|
||
expect(body.entries[0].score).toBe(0.9);
|
||
// 原有欄位一個不少(回應形狀向後相容)
|
||
expect(body.entries[0].id).toBe('e-high');
|
||
expect(body.entries[0].entry_type).toBe('block');
|
||
});
|
||
|
||
it('壞值防呆:top_k=abc / top_k=0 / min_score=-1 → 視同沒帶(回預設 20,補位後 Vectorize topK=60,不 400)', async () => {
|
||
for (const qs of ['top_k=abc', 'top_k=0', 'min_score=-1', 'top_k=abc&min_score=xyz']) {
|
||
const calls: { opts: Record<string, unknown> }[] = [];
|
||
const { app, env } = makeSemanticApp(calls);
|
||
const res = await app.request(`/entries/search?q=x&mode=semantic&${qs}`, {}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { count: number };
|
||
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
|
||
// 壞值=視同沒帶 ⇒ 落回預設閾值(08-05 起非 0),故仍砍掉 0.2 的低分尾。
|
||
expect(body.count).toBe(2);
|
||
}
|
||
});
|
||
|
||
it('keyword 路徑不受 top_k/min_score 影響(參數只作用於 semantic)', async () => {
|
||
const captured: Captured[] = [];
|
||
const { app, env } = makeApp(captured);
|
||
const res = await app.request('/entries/search?q=x&top_k=5&min_score=0.9', {}, env);
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as { mode: string };
|
||
expect(body.mode).toBe('keyword');
|
||
expect(captured[0].sql).toContain('content LIKE ?'); // SQL 形狀不變
|
||
});
|
||
});
|