fix(kbdb/embed): 語意查詢 metadata 過濾根因修復(Arcrun#11)

根因:Vectorize index 建了卻從沒建 metadata index。CF Vectorize v2 要對某
metadata 欄位下 filter,必須先為該欄建 metadata index,否則帶 owner_id/
entry_type/source 過濾的語意查詢一律回 0 命中(app 端 filter 接線本來就對)。
且 metadata index 只索引「建立後 upsert」的向量 → 既有向量須重推才會被收錄。

- deploy.ts:加 ensureVectorizeMetadataIndexes(),隨部署冪等建 owner_id/
  entry_type/source(string)三個 metadata index(self-host/官方帳號皆自動)。
- embed.ts / routes/embed.ts:backfillEmbeddings 加 reindex+offset,重推「所有
  embeddable(含 is_embedded=1)」既有向量,讓事後建立的 metadata index 收錄;
  POST /embed/backfill {"reindex":true} 觸發,offset 分頁到 remaining=0。
- wrangler.toml:註解補 create-metadata-index 手動步驟 + reindex 提示。
- tests:mock DB 對齊 LIMIT?/OFFSET? 與 reindex predicate;補 reindex 測試。

leo21c 已驗:owner_id=leo / entry_type 過濾修前 0→修後命中,不帶過濾不變。
已知後續(非本 bug 症狀):source 值 89-91 bytes 超過 Vectorize string
metadata index 的 64-byte 索引上限 → source 過濾對長值失效,另案。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o
This commit is contained in:
kbdb-cc
2026-07-06 06:03:43 +00:00
parent 4439880bbd
commit befc63cfe0
5 changed files with 102 additions and 15 deletions
+18 -5
View File
@@ -107,20 +107,29 @@ export interface BackfillResult {
*/
export async function backfillEmbeddings(
env: Bindings,
opts: { limit?: number; owner_id?: string; source?: string } = {},
opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {},
): Promise<BackfillResult> {
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
const limit = Math.min(Math.max(opts.limit ?? 25, 1), 100);
const offset = Math.max(opts.offset ?? 0, 0);
const conds = [BACKFILL_PREDICATE];
// reindexArcrun#11 根因修復):對「既有已嵌」向量原樣重嵌重推 upsert,讓它們被『事後才建立』的
// Vectorize metadata indexowner_id/entry_type/source)收錄。Vectorize 只索引「metadata index
// 建立之後 upsert」的向量 → 既有向量不重推就永遠 filter 不到(= 本 bug)。upsert 同 id 冪等。
// 非 reindex(預設)=原行為:只補 is_embedded=0 的漏網。
const basePredicate = opts.reindex
? "content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1"
: BACKFILL_PREDICATE;
const conds = [basePredicate];
const params: unknown[] = [];
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
const where = conds.join(' AND ');
const res = await env.DB
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`)
.bind(...params, limit)
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`)
.bind(...params, limit, offset)
.all<Entry>();
const rows = res.results ?? [];
const scanned = rows.length;
@@ -156,7 +165,11 @@ export async function backfillEmbeddings(
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
.bind(...params)
.first<{ c: number }>();
return { enabled: true, processed, skipped: scanned - processed, remaining: remRow?.c ?? 0, scanned };
const totalMatching = remRow?.c ?? 0;
// 非 reindexpredicate 含 is_embedded=0,處理後該筆變 1 → COUNT 自然遞減(重呼直到 0)。
// reindexpredicate 不含 is_embeddedCOUNT 恆等於總數 → 改用 offset 分頁計 remaining(否則永不終止)。
const remaining = opts.reindex ? Math.max(0, totalMatching - (offset + scanned)) : totalMatching;
return { enabled: true, processed, skipped: scanned - processed, remaining, scanned };
}
/** 補嵌進度統計(回報用;模組未開仍可查 pending 數,誠實標 enabled:false)。 */
+8 -1
View File
@@ -16,9 +16,11 @@ const OFF_HINT =
'語義補嵌需先開 embed 模組(Vectorize+AI binding)。叫 CC「幫我開語義查詢」(設 kbdb_embed:true + redeploy 注入 binding)後再呼叫本端點。';
// POST /embed/backfill — batch-embed existing embeddable entries with is_embedded=0.
// body(皆選填):{ limit?:1-100(預設25, owner_id?, source? }。
// body(皆選填):{ limit?:1-100(預設25, owner_id?, source?, reindex?, offset? }。
// 冪等:重跑不會重複嵌(已 is_embedded=1 的不再入選;upsert 同 id 冪等)。
// 分批:單次最多 limit 筆;回傳 remaining>0 表示還有 → 重複呼叫直到 remaining=0。
// reindex:trueArcrun#11):改重推「所有 embeddable」既有向量(含 is_embedded=1),
// 讓事後建立的 Vectorize metadata index 收錄它們(否則帶過濾語意查詢回 0);配 offset 分頁。
// 模組未開 → 409 + capability_hint(不假綠)。
embedRoutes.post('/backfill', async (c) => {
if (!embedEnabled(c.env)) {
@@ -31,11 +33,16 @@ embedRoutes.post('/backfill', async (c) => {
limit?: number | string;
owner_id?: string;
source?: string;
reindex?: boolean;
offset?: number | string;
};
const result = await backfillEmbeddings(c.env, {
limit: body.limit !== undefined ? Number(body.limit) : undefined,
owner_id: body.owner_id || undefined,
source: body.source || undefined,
// reindexArcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。
reindex: body.reindex === true,
offset: body.offset !== undefined ? Number(body.offset) : undefined,
});
return c.json({ success: true, ...result });
});