From befc63cfe0ac5b2c095cb796d9eaeb9285baa06a Mon Sep 17 00:00:00 2001 From: kbdb-cc Date: Mon, 6 Jul 2026 06:03:43 +0000 Subject: [PATCH] =?UTF-8?q?fix(kbdb/embed):=20=E8=AA=9E=E6=84=8F=E6=9F=A5?= =?UTF-8?q?=E8=A9=A2=20metadata=20=E9=81=8E=E6=BF=BE=E6=A0=B9=E5=9B=A0?= =?UTF-8?q?=E4=BF=AE=E5=BE=A9=EF=BC=88Arcrun#11=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因: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 Claude-Session: https://claude.ai/code/session_01HJiLCRUU2o3aSpPEzVCt2o --- cli/src/lib/deploy.ts | 31 ++++++++++++++++++++ kbdb/src/embed.ts | 23 +++++++++++---- kbdb/src/routes/embed.ts | 9 +++++- kbdb/tests/embed-backfill.test.ts | 47 +++++++++++++++++++++++++------ kbdb/wrangler.toml | 7 +++++ 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/cli/src/lib/deploy.ts b/cli/src/lib/deploy.ts index da4dbba..927c968 100644 --- a/cli/src/lib/deploy.ts +++ b/cli/src/lib/deploy.ts @@ -202,6 +202,9 @@ export async function downloadAndDeploy( try { process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...')); await ensureVectorizeIndex(ctx); + // Arcrun#11 根因修復:光建 index 不夠——Vectorize 要 filter 某 metadata 欄位,該欄必須先建 + // metadata index,否則帶 owner_id/entry_type/source 過濾的語意查詢一律回 0。冪等,隨 index 一起確保。 + await ensureVectorizeMetadataIndexes(ctx); console.log(chalk.green(' ✓')); } catch (e) { console.log(chalk.yellow(' ⚠')); @@ -361,6 +364,34 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise { throw new Error(msg); } +/** embed 過濾用的 Vectorize metadata index 欄位(型別 string;對齊 embedOnWrite 寫入的 metadata)。 */ +export const KBDB_VECTORIZE_META_FIELDS = ['owner_id', 'entry_type', 'source'] as const; + +/** + * 確保 KBDB embed index 上的 metadata index(owner_id/entry_type/source)存在(Arcrun#11 根因修復)。 + * Vectorize v2:要對某 metadata 欄位下 filter,必須先為該欄建 metadata index,否則帶過濾的語意查詢一律回 0。 + * REST `POST /accounts/{id}/vectorize/v2/indexes/{index}/metadata_index/create`(indexType=string)。 + * 冪等:已存在(409 / already exists)視為成功。async 生效(建立後才 upsert 的向量才會被收錄 → 既有向量另需 reindex)。 + */ +async function ensureVectorizeMetadataIndexes(ctx: DeployContext): Promise { + const url = `https://api.cloudflare.com/client/v4/accounts/${ctx.accountId}/vectorize/v2/indexes/${KBDB_VECTORIZE_INDEX}/metadata_index/create`; + for (const propertyName of KBDB_VECTORIZE_META_FIELDS) { + const res = await fetch(url, { + method: 'POST', + headers: { Authorization: `Bearer ${ctx.apiToken}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ propertyName, indexType: 'string' }), + signal: AbortSignal.timeout(60_000), + }); + if (res.ok) continue; + const json = (await res.json().catch(() => null)) as + | { success?: boolean; errors?: Array<{ message?: string; code?: number }> } + | null; + const msg = (json?.errors?.map(e => e.message).filter(Boolean).join('; ') || `HTTP ${res.status}`).toLowerCase(); + if (res.status === 409 || /already exists|duplicate|conflict/.test(msg)) continue; + throw new Error(`metadata_index ${propertyName}: ${msg}`); + } +} + /** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。 * * ⚠️ Arcrun#13 P2 根因修復:codeload 的 branch tarball(tar.gz/main)由 GitHub CDN 快取, diff --git a/kbdb/src/embed.ts b/kbdb/src/embed.ts index c86f7dd..088c407 100644 --- a/kbdb/src/embed.ts +++ b/kbdb/src/embed.ts @@ -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 { 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]; + // reindex(Arcrun#11 根因修復):對「既有已嵌」向量原樣重嵌重推 upsert,讓它們被『事後才建立』的 + // Vectorize metadata index(owner_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(); 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; + // 非 reindex:predicate 含 is_embedded=0,處理後該筆變 1 → COUNT 自然遞減(重呼直到 0)。 + // reindex:predicate 不含 is_embedded,COUNT 恆等於總數 → 改用 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)。 */ diff --git a/kbdb/src/routes/embed.ts b/kbdb/src/routes/embed.ts index 1792221..a013d78 100644 --- a/kbdb/src/routes/embed.ts +++ b/kbdb/src/routes/embed.ts @@ -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:true(Arcrun#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, + // reindex(Arcrun#11):重推既有向量讓事後建立的 Vectorize metadata index 收錄(見 embed.ts)。 + reindex: body.reindex === true, + offset: body.offset !== undefined ? Number(body.offset) : undefined, }); return c.json({ success: true, ...result }); }); diff --git a/kbdb/tests/embed-backfill.test.ts b/kbdb/tests/embed-backfill.test.ts index 13fbc32..09df14f 100644 --- a/kbdb/tests/embed-backfill.test.ts +++ b/kbdb/tests/embed-backfill.test.ts @@ -4,11 +4,12 @@ import type { Bindings, Entry } from '../src/types'; // ── Minimal in-memory fakes (no Workers runtime) ───────────────────────────── // The fake DB interprets only the 3 statement shapes backfill issues, by keyword: -// SELECT * ... LIMIT → candidate rows (embeddable & is_embedded=0 & non-empty content) -// UPDATE ... IN (...) → flip is_embedded=1 for the bound ids -// SELECT COUNT(*) → count of remaining candidates -function isCandidate(e: Entry): boolean { - if (e.is_embedded !== 0) return false; +// SELECT * ... LIMIT ? OFFSET ? → candidate rows (embeddable & non-empty content; +// +is_embedded=0 for normal backfill, any for reindex) +// UPDATE ... IN (...) → flip is_embedded=1 for the bound ids +// SELECT COUNT(*) → count of matching candidates +// embeddable = metadata.embed===true & non-empty content(reindex predicate)。 +function isEmbeddable(e: Entry): boolean { if (!e.content || e.content.trim() === '') return false; try { const m = JSON.parse(e.metadata_json ?? 'null'); @@ -17,21 +18,28 @@ function isCandidate(e: Entry): boolean { return false; } } +// normal backfill 額外要求 is_embedded=0(漏網補嵌)。 +function isCandidate(e: Entry): boolean { + return e.is_embedded === 0 && isEmbeddable(e); +} function makeFakeDB(store: Entry[]) { const prepare = (sql: string) => { + // reindex predicate 不含 "is_embedded = 0" → 依 SQL 判斷該用哪個 filter(對齊 embed.ts)。 + const pred = /is_embedded = 0/.test(sql) ? isCandidate : isEmbeddable; let bound: unknown[] = []; const stmt = { bind(...args: unknown[]) { bound = args; return stmt; }, async all() { - // SELECT * ... LIMIT ? (limit is the last bound param) - const limit = Number(bound[bound.length - 1]); - const results = store.filter(isCandidate).slice(0, limit) as unknown as T[]; + // SELECT * ... LIMIT ? OFFSET ? (bound tail = [..., limit, offset]) + const offset = Number(bound[bound.length - 1]); + const limit = Number(bound[bound.length - 2]); + const results = store.filter(pred).slice(offset, offset + limit) as unknown as T[]; return { results }; }, async first() { // SELECT COUNT(*) as c ... - const c = store.filter(isCandidate).length; + const c = store.filter(pred).length; return { c } as unknown as T; }, async run() { @@ -125,6 +133,27 @@ describe('backfillEmbeddings', () => { expect(r2.remaining).toBe(0); }); + it('reindex: 重推所有 embeddable(含 is_embedded=1),offset 分頁到 remaining=0(Arcrun#11)', async () => { + // 三筆皆已 is_embedded=1(既有向量):正常 backfill 不會碰(pending=0),reindex 要全部重推 + // 讓事後建立的 Vectorize metadata index 收錄。 + const store = [ + mkEntry('a', 'x', true, 1), mkEntry('b', 'y', true, 1), mkEntry('c', 'z', true, 1), + ]; + const env = makeEnv(store, true); + // 正常 backfill:沒有 is_embedded=0 → 什麼都不做(證明「不重推就補不到」)。 + const normal = await backfillEmbeddings(env, { limit: 100 }); + expect(normal.processed).toBe(0); + // reindex 分頁:第一批 2 筆、remaining=1;第二批 1 筆、remaining=0。 + const r1 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 0 }); + expect(r1.processed).toBe(2); + expect(r1.remaining).toBe(1); + const r2 = await backfillEmbeddings(env, { reindex: true, limit: 2, offset: 2 }); + expect(r2.processed).toBe(1); + expect(r2.remaining).toBe(0); + const upserts = (env as unknown as { __upserts: { id: string }[] }).__upserts; + expect(upserts.map((u) => u.id).sort()).toEqual(['a', 'b', 'c']); + }); + it('status reports pending/embedded counts', async () => { const store = [mkEntry('e1', 'x', true), mkEntry('e2', 'y', true, 1)]; const env = makeEnv(store, true); diff --git a/kbdb/wrangler.toml b/kbdb/wrangler.toml index 14f9328..bd89489 100644 --- a/kbdb/wrangler.toml +++ b/kbdb/wrangler.toml @@ -19,6 +19,13 @@ ENVIRONMENT = "production" # Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true # → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed # --dimensions=768 --metric=cosine`(bge-base-en-v1.5 = 768 維)。官方帳號同理由 deploy 注入。 +# ⚠️ Arcrun#11:光建 index 不夠。要對 owner_id/entry_type/source 下 filter(owner-scoped/類型-scoped 語意查詢), +# 必須另建 metadata index,否則帶過濾一律回 0 命中: +# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name owner_id --type string +# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name entry_type --type string +# wrangler vectorize create-metadata-index arcrun-kbdb-embed --property-name source --type string +# metadata index 只收「建立後 upsert」的向量 → 既有向量須 `POST /embed/backfill {"reindex":true}` 重推。 +# deploy.ts 的 ensureVectorizeMetadataIndexes() 已把上述三個 index 隨部署冪等建好。 # 沒有這兩個 binding 時,kbdb/src/embed.ts 的 embedEnabled() 回 false → 維持 LIKE keyword、API 不變。 # # [[vectorize]]