fix(kbdb): search keyword 補 source filter(#66)+semantic 曝 top_k/min_score 帶 score(#67)

#66:/entries/search keyword 路徑 source 解析後丟棄(#5.1 只接了 listEntries 那半)——
searchEntries 尾端加 source?(既有 positional caller 全不用改),conds 補與 listEntries
同款 json_extract(metadata_json,'$.source') 謂詞;route keyword 分支與 semantic 降級
分支兩處傳入。

#67:semantic 固定 topK=20、零分數閾值、低分尾硬湊數——route 曝 top_k(預設 20、封頂
100)與 min_score(預設 0=不過濾)query 參數;semanticSearch 依 min_score 截低分尾;
semantic 回應 entry 附 score 欄(加欄不改形)。壞值(非數字/非正)視同沒帶,不 400。

向後相容:不帶新參數時輸出與現況一致(semantic 僅多 score 資訊);不動表(D6)、
不動 D1 結構(API-as-Wall)。測試:新增 search-source-and-score.test.ts 13 條
(source 謂詞形狀/route 下傳/降級不洩 filter/min_score 截斷/topK 透傳封頂/壞值防呆/
不帶參數行為不變),kbdb vitest 33/33 綠、tsc 0。

關聯 #66 #67。merge 後需 gated redeploy kbdb worker(leo 閘)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3
This commit is contained in:
Claude
2026-07-19 07:53:46 +00:00
parent 2324537165
commit 65d85eb08f
5 changed files with 269 additions and 16 deletions
+213
View File
@@ -0,0 +1,213 @@
// 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', () => {
it('不帶新參數 → topK=20、全 matches 回傳(行為與舊版一致)', 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?.length).toBe(3);
expect(hits?.map((h) => h.score)).toEqual([0.9, 0.5, 0.2]); // 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));
}
it('?top_k=5&min_score=0.5 → topK 透傳、低分截掉、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(5);
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('不帶新參數 → topK=20、全量回傳(行為不變),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(20);
expect(body.count).toBe(3);
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 → 視同沒帶(回預設,不 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(20);
expect(body.count).toBe(3); // 無閾值 → 全量
}
});
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 形狀不變
});
});