fix(kbdb): /entries/search 服務端濾 deprecated(keyword+semantic),修下架不生效

daemon-beta t24(t11 斷點①②,總管 0.971 親復現):rag_takedown_direct 下架只把
metadata_json.status 標 deprecated(軟刪,append-only),但 /entries/search 兩 mode
都不濾,已下架內容照樣回傳——semantic 甚至最高分照吐。過去唯一的濾層只在
cypher-executor/portal-data.ts 客端(Arcrun#46),沒部署 rag_chat 的實例等於完全沒濾。

- entry-crud.ts:新增 json_extract($.status) NOT_DEPRECATED_PREDICATE(同 source/library
  既有 json_extract 模式,issue #5.1/#18;不用 LIKE 避免 JSON 序列化格式誤判);
  searchEntries 新增 includeDeprecated 參數(尾端新增,向後相容)。
  新增 isDeprecatedEntry:semantic 路徑用(Vectorize metadata 沒存 status,只能 hydrate
  回完整 entry 後 JS 側判斷)。
- entries.ts:/entries/search 新增 include_deprecated 開關(預設 false,濾掉;true 給
  管理面查殘留)。semantic 分支補位——過濾生效時先以 topK×3(封頂 100)向 Vectorize
  多撈,hydrate+濾完再截斷回 caller 要求的量,避免命中大半下架時整頁被吃光
  (t11 ZZ-T10 實測案例)。
- Vectorize 向量殘留本 PR 不動(只做查詢時過濾,not 同步刪向量)——取捨見 PR 描述。

測試:新增 search-deprecated-filter.test.ts 15 案(keyword 濾/include_deprecated 開關/
semantic 濾+補位+封頂+截斷/isDeprecatedEntry 單元);同步更新 search-source-and-score.test.ts
3 案的 topK 期望值(route 補位改變送進 Vectorize 的實際 topK,回應對外契約不變)。
kbdb 全套 60/60 綠、tsc --noEmit 乾淨。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
uncle6me-web
2026-07-24 16:35:17 +08:00
parent e28e19069f
commit 55283f4a3b
4 changed files with 304 additions and 10 deletions
+12 -6
View File
@@ -162,26 +162,32 @@ describe('#67 — route GET /entries/searchsemantictop_k / min_score / sco
return makeApp(captured, makeSemanticEnv(calls));
}
it('?top_k=5&min_score=0.5 → topK 透傳、低分截掉、entry 附 score', async () => {
// 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(5);
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('不帶新參數 → topK=20、全量回傳(行為不變),entry 仍附 score(加欄不改形)', async () => {
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(20);
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
expect(body.count).toBe(3);
expect(body.entries[0].score).toBe(0.9);
// 原有欄位一個不少(回應形狀向後相容)
@@ -189,14 +195,14 @@ describe('#67 — route GET /entries/searchsemantictop_k / min_score / sco
expect(body.entries[0].entry_type).toBe('block');
});
it('壞值防呆:top_k=abc / top_k=0 / min_score=-1 → 視同沒帶(回預設,不 400)', async () => {
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(20);
expect(calls[0].opts.topK).toBe(60); // t24 補位:預設 20 × 3
expect(body.count).toBe(3); // 無閾值 → 全量
}
});