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
+5
View File
@@ -137,6 +137,9 @@ function libraryPredicate(libraries: string[]): string {
// D1 LIKE keyword search (base; semantic search is the optional embed module).
// entry_type: optional base filter (generic — caller passes any type, base stays type-agnostic).
// library: optional 多值庫 filterportal-auth P1);未帶=行為與舊版一字不變(向後相容)。
// source: metadata_json.$.source filterissue #66——#5.1 只接了 listEntries 那半,keyword search
// 路徑 route 解析完即丟;謂詞與 listEntries 同款 json_extract,不動表)。加在參數尾端,
// 既有 positional caller 一個都不用改(向後相容)。
export async function searchEntries(
db: D1Database,
q: string,
@@ -144,11 +147,13 @@ export async function searchEntries(
entry_type?: string,
limit = 50,
library?: string[],
source?: string,
): Promise<Entry[]> {
const conds = ['content LIKE ?'];
const params: unknown[] = [`%${q}%`];
if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); }
if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); }
if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); }
if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); }
const res = await db
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
+14 -9
View File
@@ -222,11 +222,13 @@ export interface SemanticHit {
* workers-types 原生 typingdesign §3.3 的 fan-out fallback 不需啟用)。未帶=行為不變。
* 註:向量 metadata 的 library 在寫入端已正規化(未標記='general'),故 $in 不需 NULL 處理;
* 但「建 library metadata index 之前」upsert 的既有向量沒有此欄 → 部署清單強制 reindex backfill。
* min_scoreissue #67):分數閾值——Vectorize 只會硬湊 topK 筆,低分尾全是無關內容;
* 過濾放查詢端(非 Vectorize 端,API 無此參數)。預設 0=不過濾(行為與舊版一字不變,向後相容)。
*/
export async function semanticSearch(
env: Bindings,
q: string,
opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number } = {},
opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number; min_score?: number } = {},
): Promise<SemanticHit[] | null> {
if (!embedEnabled(env)) return null;
const vec = await embedText(env, q);
@@ -241,12 +243,15 @@ export async function semanticSearch(
returnMetadata: 'indexed',
...(Object.keys(filter).length ? { filter } : {}),
});
return (res.matches ?? []).map((m) => ({
id: m.id,
score: m.score,
owner_id: m.metadata?.owner_id as string | undefined,
entry_type: m.metadata?.entry_type as string | undefined,
source: m.metadata?.source as string | undefined,
library: m.metadata?.library as string | undefined,
}));
const minScore = opts.min_score ?? 0;
return (res.matches ?? [])
.filter((m) => m.score >= minScore)
.map((m) => ({
id: m.id,
score: m.score,
owner_id: m.metadata?.owner_id as string | undefined,
entry_type: m.metadata?.entry_type as string | undefined,
source: m.metadata?.source as string | undefined,
library: m.metadata?.library as string | undefined,
}));
}
+24 -6
View File
@@ -60,6 +60,11 @@ entryRoutes.get('/', async (c) => {
// - entry_typebase 通用 filtercaller 傳任意 type,如 workflowbase 不寫死語意,workflow-discovery Q4)。
// - library:多值庫 filter(逗號分隔,portal-auth P1)。keyword 走 json_extractNULL→general
// semantic 走 Vectorize $in。未帶=全庫(行為不變)。
// - sourcekeyword 走 json_extract 謂詞(#66——#5.1 只接了 list 那半,這裡原本解析完即丟);
// semantic 走 Vectorize metadata filter(原本就有)。
// - top_k / min_score#67semantic 專用):topK 可調(預設 20、上限 100)+分數閾值
// (預設 0=不過濾)。未帶=行為與舊版一致(向後相容);semantic 回應的 entry 另附 score
// 欄讓 caller 自裁(加欄不改形,keyword 路徑不受影響)。
entryRoutes.get('/search', async (c) => {
const q = c.req.query('q');
if (!q) return c.json({ success: false, error: 'q required' }, 400);
@@ -68,12 +73,19 @@ entryRoutes.get('/search', async (c) => {
const entry_type = c.req.query('entry_type') || undefined;
const library = parseLibraryParam(c.req.query('library'));
const mode = c.req.query('mode') === 'semantic' ? 'semantic' : 'keyword';
// 數字參數防呆:非數字/非正 → 當沒帶(回預設),不 400——與其他 filter「壞值靜默忽略」一致。
const topKNum = Number(c.req.query('top_k'));
const top_k = Number.isFinite(topKNum) && topKNum > 0 ? Math.floor(topKNum) : undefined;
const minScoreNum = Number(c.req.query('min_score'));
const min_score = Number.isFinite(minScoreNum) && minScoreNum > 0 ? minScoreNum : undefined;
if (mode === 'semantic') {
const hits = await semanticSearch(c.env, q, { owner_id, source, entry_type, library });
const hits = await semanticSearch(c.env, q, {
owner_id, source, entry_type, library, topK: top_k, min_score,
});
if (hits === null) {
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library);
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source);
return c.json({
success: true,
entries,
@@ -85,13 +97,19 @@ entryRoutes.get('/search', async (c) => {
});
}
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
const entries = (await Promise.all(hits.map((h) => getEntry(c.env.DB, h.id)))).filter(
(e): e is NonNullable<typeof e> => e !== null,
);
// #67entry 附 score(相似分數)——加欄不改形,既有 caller 不解析多的欄位不受影響。
const entries = (
await Promise.all(
hits.map(async (h) => {
const e = await getEntry(c.env.DB, h.id);
return e ? { ...e, score: h.score } : null;
}),
)
).filter((e): e is NonNullable<typeof e> => e !== null);
return c.json({ success: true, entries, count: entries.length, mode: 'semantic' });
}
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library);
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source);
return c.json({ success: true, entries, count: entries.length, mode: 'keyword' });
});