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:
@@ -8,6 +8,7 @@ import {
|
||||
updateEntry,
|
||||
deleteEntry,
|
||||
searchEntries,
|
||||
isDeprecatedEntry,
|
||||
} from '../actions/entry-crud';
|
||||
import { embedEnabled, embedOnWrite, semanticSearch } from '../embed';
|
||||
|
||||
@@ -65,6 +66,10 @@ entryRoutes.get('/', async (c) => {
|
||||
// - top_k / min_score(#67,semantic 專用):topK 可調(預設 20、上限 100)+分數閾值
|
||||
// (預設 0=不過濾)。未帶=行為與舊版一致(向後相容);semantic 回應的 entry 另附 score
|
||||
// 欄讓 caller 自裁(加欄不改形,keyword 路徑不受影響)。
|
||||
// - include_deprecated(daemon-beta t24,預設 false):兩 mode 預設都濾掉已下架
|
||||
// (metadata_json.status==='deprecated')的 entry——這是本次修的洞(t11 斷點①②,總管
|
||||
// 0.971 親復現:下架後 keyword/semantic 都照樣回傳)。傳 `include_deprecated=true`
|
||||
// 保留給管理面查殘留(驗證下架有沒有真的生效、盤點待清的向量殘留),一般搜尋不帶。
|
||||
entryRoutes.get('/search', async (c) => {
|
||||
const q = c.req.query('q');
|
||||
if (!q) return c.json({ success: false, error: 'q required' }, 400);
|
||||
@@ -73,6 +78,7 @@ 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';
|
||||
const include_deprecated = c.req.query('include_deprecated') === 'true';
|
||||
// 數字參數防呆:非數字/非正 → 當沒帶(回預設),不 400——與其他 filter「壞值靜默忽略」一致。
|
||||
const topKNum = Number(c.req.query('top_k'));
|
||||
const top_k = Number.isFinite(topKNum) && topKNum > 0 ? Math.floor(topKNum) : undefined;
|
||||
@@ -80,12 +86,23 @@ entryRoutes.get('/search', async (c) => {
|
||||
const min_score = Number.isFinite(minScoreNum) && minScoreNum > 0 ? minScoreNum : undefined;
|
||||
|
||||
if (mode === 'semantic') {
|
||||
// 補位(daemon-beta t24):Vectorize 的 indexed metadata 沒存 status(見 embed.ts upsert,
|
||||
// 只有 owner_id/entry_type/source/library),下架與否只能在 hydrate 回完整 entry 後才知道
|
||||
// ——換句話說 Vectorize 端沒辦法直接濾掉已下架向量,濾一定發生在 hydrate 之後。
|
||||
// 若濾完才截斷到請求的 topK,遇到「這頁命中大半已下架」(t11 ZZ-T10 實測案例:命中
|
||||
// 25 顆全下架)就會整頁被吃光、回傳筆數遠低於 caller 要的量。故过濾生效時(非
|
||||
// include_deprecated)**先多撈一批再濾再截斷**:單次 Vectorize query 成本不變(同一次
|
||||
// query 只是 topK 參數變大,非多一次 subrequest),用查詢端的餘量換掉「整頁被下架品吃光」
|
||||
// 的體驗劣化。這是單輪補位(非重試迴圈到湊滿為止)——若下架比例極高仍可能不足額,
|
||||
// 已在 PR 描述向 leo 說明這個 trade-off(多倍 margin vs 迴圈重撈的取捨)。
|
||||
const requestedTopK = top_k ?? 20; // 與 embed.ts semanticSearch 的預設 topK 對齊
|
||||
const fetchTopK = include_deprecated ? requestedTopK : Math.min(requestedTopK * 3, 100);
|
||||
const hits = await semanticSearch(c.env, q, {
|
||||
owner_id, source, entry_type, library, topK: top_k, min_score,
|
||||
owner_id, source, entry_type, library, topK: fetchTopK, min_score,
|
||||
});
|
||||
if (hits === null) {
|
||||
// 模組沒開:誠實降級 keyword + 告知「叫 CC 幫你開 vectorize」(不假裝有語義)。
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source);
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
|
||||
return c.json({
|
||||
success: true,
|
||||
entries,
|
||||
@@ -98,7 +115,7 @@ entryRoutes.get('/search', async (c) => {
|
||||
}
|
||||
// hydrate vector hits → 完整 entry(保持回應形狀與 keyword 一致)。
|
||||
// #67:entry 附 score(相似分數)——加欄不改形,既有 caller 不解析多的欄位不受影響。
|
||||
const entries = (
|
||||
let entries = (
|
||||
await Promise.all(
|
||||
hits.map(async (h) => {
|
||||
const e = await getEntry(c.env.DB, h.id);
|
||||
@@ -106,10 +123,15 @@ entryRoutes.get('/search', async (c) => {
|
||||
}),
|
||||
)
|
||||
).filter((e): e is NonNullable<typeof e> => e !== null);
|
||||
if (!include_deprecated) {
|
||||
entries = entries.filter((e) => !isDeprecatedEntry(e));
|
||||
}
|
||||
// 補位後截斷回 caller 實際要的量(多撈的餘量只用來墊背,不多回傳超過請求的筆數)。
|
||||
entries = entries.slice(0, requestedTopK);
|
||||
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, source);
|
||||
const entries = await searchEntries(c.env.DB, q, owner_id, entry_type, undefined, library, source, include_deprecated);
|
||||
return c.json({ success: true, entries, count: entries.length, mode: 'keyword' });
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user