Files
Arcrun/kbdb/tests/search-source-and-score.test.ts
uncle6me-web 05b215c9f7 語意門檻改相對式+下架連帶刪向量(leo 兩個實測回饋)
## ① 「關懷型 AI 命中 20 筆、只有前 3 筆相關」⇒ 閾值太寬(leo 判斷正確)
固定門檻兩頭都不對,因為每個查詢的分數尺度不同(實測 youlin 實例):
  關懷型 AI          正解 0.645-0.770,雜訊起於 0.547  ← 固定 0.5 放進 6 筆雜訊
  閉環機             正解 0.552-0.638,雜訊起於 0.446  ← 固定 0.6 砍到剩 2/4(=早上的 0 命中)
  人力媒合系統規劃書   正解 0.842,雜訊起於 0.550
⇒ 改**相對門檻** max(0.45, top×0.8)。五組實測:固定 0.5 混入 9 筆雜訊/
  固定 0.6 有兩組正解被砍/相對式四組雜訊 0 且正解全留。

## 🔴 寫測試才發現的真問題:門檻不能在 Vectorize 那層算
Vectorize 的 indexed metadata 沒有 status ⇒ 那層不知道誰已下架。
若最高分是下架殘影(t24 復現案 0.971),拿它算門檻=0.777,
會把 0.6 的正解一起砍光 ⇒ **又變成 0 命中**。
⇒ 相對門檻移到 routes/entries.ts,接在「hydrate+濾下架」之後;
  embed.ts 只留絕對下限。新增測試鎖住這個順序。

## ② leo:「理論上它的向量也要刪掉,就不會有殘影了吧?」——對,補上
單筆真刪已接 deleteByIds(b7af622),但「移除整個庫」走軟刪、向量原地不動。
⇒ deprecate-by-library 同時 deleteByIds + is_embedded 歸零(D1 與 Vectorize 不說兩套話);
  backfill 兩條路徑(含 reindex)都排除 deprecated,否則下次補嵌會把殘影養回來。
不違背 t135「資料保留可還原」:D1 那列原封不動,還原後跑 backfill 重嵌即可。
回應新增 vectors_deleted,刪失敗誠實回 0 不假裝清乾淨。

驗:kbdb 91/91 綠(新增 4 項相對門檻測試,含「下架殘影不得決定門檻」)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 18:47:10 +08:00

225 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Gitea #66/#67 — /entries/search 兩個檢索缺口的回歸測試。
// #66keyword 路徑 source 參數解析後丟棄(#5.1 只接了 listEntries 那半)→ searchEntries 補
// json_extract 謂詞、route 傳入;含向後相容(不帶 source = SQL 一字不變)。
// #67semantic 固定 topK=20、零分數閾值 → route 曝 top_k/min_score、hit 依 min_score 過濾、
// 回應 entry 附 score;含向後相容(不帶新參數 = 行為不變,僅多 score 資訊)。
// 測試手法同 library-filter.test.tsfake 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 paramsgetEntrySELECT … 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 filterSQL 形狀)', () => {
it('帶 source → LIKEjson_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('sourcelibrary 併用 → 兩謂詞都在、參數順序對(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/searchkeywordsource 下傳', () => {
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/searchsemantictop_k / min_score / score 欄', () => {
function makeSemanticApp(calls: { opts: Record<string, unknown> }[], captured: Captured[] = []) {
return makeApp(captured, makeSemanticEnv(calls));
}
// daemon-beta t2407-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→155×3、20→60=20×3),其餘斷言(回應筆數/內容/score)不動。
it('?top_k=5&min_score=0.5 → Vectorize 補位 topK=155×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),套用相對門檻後只回最高分那筆,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
// 08-05:未帶 min_score ⇒ 相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只有 0.9 留下
expect(body.count).toBe(1);
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
// 壞值=視同沒帶 ⇒ 落回相對門檻 max(0.45, 0.9×0.8)=0.72 ⇒ 只留最高分那筆。
expect(body.count).toBe(1);
}
});
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 形狀不變
});
});