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 ab8e07201f
commit dd78b770a9
4 changed files with 304 additions and 10 deletions
+40
View File
@@ -134,12 +134,50 @@ function libraryPredicate(libraries: string[]): string {
return `COALESCE(json_extract(metadata_json, '$.library'), 'general') IN (${placeholders})`;
}
// daemon-beta t24(總管 0.971 親復現、t11 斷點①②)——下架(rag_takedown_direct)只把
// metadata_json.status 標成 'deprecated'(軟刪,append-only,見 KBDB 表不變鐵律),從不刪列。
// 濾層過去只存在 cypher-executor/src/routes/portal-data.ts 的 filterDeprecatedEntries(客端治標,
// Arcrun#46),rag_chat 沒部署的實例(如 leo21c)等於完全沒濾——AI 實際會用到的 MCP/raw
// /entries/search 面直接把已下架內容當現役回傳(semantic 甚至最高分回傳,見 t11 斷點②)。
// 本謂詞把過濾下沉到 KBDB 服務端(薄殼原則 07:能力只長一次),source-of-truth 修好後
// portal-data.ts 的客端治標理論上可拔(未在本 PR 動,範圍只限 kbdb/)。
//
// 用 json_extract 判等(不用 NOT LIKE '%"status":"deprecated"%')——LIKE 對 JSON 序列化格式敏感
// (key 順序、空白、字串轉義都可能讓子字串比對誤判/漏判,例如 metadata_json 裡若有其他欄位的
// 值恰好含這段子字串就會被誤殺),json_extract 是結構化取值,只認真正的 $.status 欄位,同一謂詞
// 家族(source/library)已驗證過這個模式對 SQLite/D1 穩定可靠(issue #5.1、#18 mistake)。
// NULL(沒有 metadata_json 或沒有 status 欄)視為未下架(保留,不誤殺——大多數既有資料沒有
// status 欄)。
const NOT_DEPRECATED_PREDICATE =
"(json_extract(metadata_json, '$.status') IS NULL OR json_extract(metadata_json, '$.status') != 'deprecated')";
/**
* JS 側判斷單筆 entry 是否已下架(status==='deprecated')。給 semantic 路徑用——Vectorize
* hit 的 metadata 沒有存 status(見 embed.ts upsert 的 indexed metadata 只有
* owner_id/entry_type/source/library),要濾必須先 hydrate 回完整 entry 再判斷,故無法像
* keyword 走 SQL 謂詞,只能在拿到 metadata_json 後用同一套判準(status==='deprecated')在
* JS 層濾。metadata_json parse 失敗 → 視為保留(治標不誤殺,與 portal-data.ts
* filterDeprecatedEntries 同慣例)。
*/
export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boolean {
if (!entry.metadata_json) return false;
try {
const meta = JSON.parse(entry.metadata_json) as { status?: unknown } | null;
return !!meta && meta.status === 'deprecated';
} catch {
return false;
}
}
// 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 一個都不用改(向後相容)。
// includeDeprecateddaemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。
// 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。
// 加在參數最尾端,既有 positional callersource 之後)一個都不用改。
export async function searchEntries(
db: D1Database,
q: string,
@@ -148,6 +186,7 @@ export async function searchEntries(
limit = 50,
library?: string[],
source?: string,
includeDeprecated = false,
): Promise<Entry[]> {
const conds = ['content LIKE ?'];
const params: unknown[] = [`%${q}%`];
@@ -155,6 +194,7 @@ export async function searchEntries(
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); }
if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); }
const res = await db
.prepare(`SELECT * FROM entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`)
.bind(...params, Math.min(limit, 200))
+26 -4
View File
@@ -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#67semantic 專用):topK 可調(預設 20、上限 100)+分數閾值
// (預設 0=不過濾)。未帶=行為與舊版一致(向後相容);semantic 回應的 entry 另附 score
// 欄讓 caller 自裁(加欄不改形,keyword 路徑不受影響)。
// - include_deprecateddaemon-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 一致)。
// #67entry 附 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' });
});