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:
@@ -202,6 +202,9 @@ export async function downloadAndDeploy(
|
|||||||
try {
|
try {
|
||||||
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
|
process.stdout.write(chalk.gray(' → 開語義查詢:確保 Vectorize index 存在...'));
|
||||||
await ensureVectorizeIndex(ctx);
|
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(' ✓'));
|
console.log(chalk.green(' ✓'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.log(chalk.yellow(' ⚠'));
|
console.log(chalk.yellow(' ⚠'));
|
||||||
@@ -361,6 +364,34 @@ async function ensureVectorizeIndex(ctx: DeployContext): Promise<void> {
|
|||||||
throw new Error(msg);
|
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<void> {
|
||||||
|
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 路徑。
|
/** 下載 codeload tarball 解壓到暫存目錄,回傳解壓出的 repo root 路徑。
|
||||||
*
|
*
|
||||||
* ⚠️ Arcrun#13 P2 根因修復:codeload 的 branch tarball(tar.gz/main)由 GitHub CDN 快取,
|
* ⚠️ Arcrun#13 P2 根因修復:codeload 的 branch tarball(tar.gz/main)由 GitHub CDN 快取,
|
||||||
|
|||||||
+18
-5
@@ -107,20 +107,29 @@ export interface BackfillResult {
|
|||||||
*/
|
*/
|
||||||
export async function backfillEmbeddings(
|
export async function backfillEmbeddings(
|
||||||
env: Bindings,
|
env: Bindings,
|
||||||
opts: { limit?: number; owner_id?: string; source?: string } = {},
|
opts: { limit?: number; owner_id?: string; source?: string; reindex?: boolean; offset?: number } = {},
|
||||||
): Promise<BackfillResult> {
|
): Promise<BackfillResult> {
|
||||||
if (!embedEnabled(env)) return { enabled: false, processed: 0, skipped: 0, remaining: 0, scanned: 0 };
|
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 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[] = [];
|
const params: unknown[] = [];
|
||||||
if (opts.owner_id) { conds.push('owner_id = ?'); params.push(opts.owner_id); }
|
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); }
|
if (opts.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(opts.source); }
|
||||||
const where = conds.join(' AND ');
|
const where = conds.join(' AND ');
|
||||||
|
|
||||||
const res = await env.DB
|
const res = await env.DB
|
||||||
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ?`)
|
.prepare(`SELECT * FROM entries WHERE ${where} ORDER BY created_at ASC LIMIT ? OFFSET ?`)
|
||||||
.bind(...params, limit)
|
.bind(...params, limit, offset)
|
||||||
.all<Entry>();
|
.all<Entry>();
|
||||||
const rows = res.results ?? [];
|
const rows = res.results ?? [];
|
||||||
const scanned = rows.length;
|
const scanned = rows.length;
|
||||||
@@ -156,7 +165,11 @@ export async function backfillEmbeddings(
|
|||||||
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
|
||||||
.bind(...params)
|
.bind(...params)
|
||||||
.first<{ c: number }>();
|
.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)。 */
|
/** 補嵌進度統計(回報用;模組未開仍可查 pending 數,誠實標 enabled:false)。 */
|
||||||
|
|||||||
@@ -16,9 +16,11 @@ const OFF_HINT =
|
|||||||
'語義補嵌需先開 embed 模組(Vectorize+AI binding)。叫 CC「幫我開語義查詢」(設 kbdb_embed:true + redeploy 注入 binding)後再呼叫本端點。';
|
'語義補嵌需先開 embed 模組(Vectorize+AI binding)。叫 CC「幫我開語義查詢」(設 kbdb_embed:true + redeploy 注入 binding)後再呼叫本端點。';
|
||||||
|
|
||||||
// POST /embed/backfill — batch-embed existing embeddable entries with is_embedded=0.
|
// 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 冪等)。
|
// 冪等:重跑不會重複嵌(已 is_embedded=1 的不再入選;upsert 同 id 冪等)。
|
||||||
// 分批:單次最多 limit 筆;回傳 remaining>0 表示還有 → 重複呼叫直到 remaining=0。
|
// 分批:單次最多 limit 筆;回傳 remaining>0 表示還有 → 重複呼叫直到 remaining=0。
|
||||||
|
// reindex:true(Arcrun#11):改重推「所有 embeddable」既有向量(含 is_embedded=1),
|
||||||
|
// 讓事後建立的 Vectorize metadata index 收錄它們(否則帶過濾語意查詢回 0);配 offset 分頁。
|
||||||
// 模組未開 → 409 + capability_hint(不假綠)。
|
// 模組未開 → 409 + capability_hint(不假綠)。
|
||||||
embedRoutes.post('/backfill', async (c) => {
|
embedRoutes.post('/backfill', async (c) => {
|
||||||
if (!embedEnabled(c.env)) {
|
if (!embedEnabled(c.env)) {
|
||||||
@@ -31,11 +33,16 @@ embedRoutes.post('/backfill', async (c) => {
|
|||||||
limit?: number | string;
|
limit?: number | string;
|
||||||
owner_id?: string;
|
owner_id?: string;
|
||||||
source?: string;
|
source?: string;
|
||||||
|
reindex?: boolean;
|
||||||
|
offset?: number | string;
|
||||||
};
|
};
|
||||||
const result = await backfillEmbeddings(c.env, {
|
const result = await backfillEmbeddings(c.env, {
|
||||||
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
limit: body.limit !== undefined ? Number(body.limit) : undefined,
|
||||||
owner_id: body.owner_id || undefined,
|
owner_id: body.owner_id || undefined,
|
||||||
source: body.source || 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 });
|
return c.json({ success: true, ...result });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import type { Bindings, Entry } from '../src/types';
|
|||||||
|
|
||||||
// ── Minimal in-memory fakes (no Workers runtime) ─────────────────────────────
|
// ── Minimal in-memory fakes (no Workers runtime) ─────────────────────────────
|
||||||
// The fake DB interprets only the 3 statement shapes backfill issues, by keyword:
|
// The fake DB interprets only the 3 statement shapes backfill issues, by keyword:
|
||||||
// SELECT * ... LIMIT → candidate rows (embeddable & is_embedded=0 & non-empty content)
|
// SELECT * ... LIMIT ? OFFSET ? → candidate rows (embeddable & non-empty content;
|
||||||
// UPDATE ... IN (...) → flip is_embedded=1 for the bound ids
|
// +is_embedded=0 for normal backfill, any for reindex)
|
||||||
// SELECT COUNT(*) → count of remaining candidates
|
// UPDATE ... IN (...) → flip is_embedded=1 for the bound ids
|
||||||
function isCandidate(e: Entry): boolean {
|
// SELECT COUNT(*) → count of matching candidates
|
||||||
if (e.is_embedded !== 0) return false;
|
// embeddable = metadata.embed===true & non-empty content(reindex predicate)。
|
||||||
|
function isEmbeddable(e: Entry): boolean {
|
||||||
if (!e.content || e.content.trim() === '') return false;
|
if (!e.content || e.content.trim() === '') return false;
|
||||||
try {
|
try {
|
||||||
const m = JSON.parse(e.metadata_json ?? 'null');
|
const m = JSON.parse(e.metadata_json ?? 'null');
|
||||||
@@ -17,21 +18,28 @@ function isCandidate(e: Entry): boolean {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// normal backfill 額外要求 is_embedded=0(漏網補嵌)。
|
||||||
|
function isCandidate(e: Entry): boolean {
|
||||||
|
return e.is_embedded === 0 && isEmbeddable(e);
|
||||||
|
}
|
||||||
|
|
||||||
function makeFakeDB(store: Entry[]) {
|
function makeFakeDB(store: Entry[]) {
|
||||||
const prepare = (sql: string) => {
|
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[] = [];
|
let bound: unknown[] = [];
|
||||||
const stmt = {
|
const stmt = {
|
||||||
bind(...args: unknown[]) { bound = args; return stmt; },
|
bind(...args: unknown[]) { bound = args; return stmt; },
|
||||||
async all<T>() {
|
async all<T>() {
|
||||||
// SELECT * ... LIMIT ? (limit is the last bound param)
|
// SELECT * ... LIMIT ? OFFSET ? (bound tail = [..., limit, offset])
|
||||||
const limit = Number(bound[bound.length - 1]);
|
const offset = Number(bound[bound.length - 1]);
|
||||||
const results = store.filter(isCandidate).slice(0, limit) as unknown as T[];
|
const limit = Number(bound[bound.length - 2]);
|
||||||
|
const results = store.filter(pred).slice(offset, offset + limit) as unknown as T[];
|
||||||
return { results };
|
return { results };
|
||||||
},
|
},
|
||||||
async first<T>() {
|
async first<T>() {
|
||||||
// SELECT COUNT(*) as c ...
|
// SELECT COUNT(*) as c ...
|
||||||
const c = store.filter(isCandidate).length;
|
const c = store.filter(pred).length;
|
||||||
return { c } as unknown as T;
|
return { c } as unknown as T;
|
||||||
},
|
},
|
||||||
async run() {
|
async run() {
|
||||||
@@ -125,6 +133,27 @@ describe('backfillEmbeddings', () => {
|
|||||||
expect(r2.remaining).toBe(0);
|
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 () => {
|
it('status reports pending/embedded counts', async () => {
|
||||||
const store = [mkEntry('e1', 'x', true), mkEntry('e2', 'y', true, 1)];
|
const store = [mkEntry('e1', 'x', true), mkEntry('e2', 'y', true, 1)];
|
||||||
const env = makeEnv(store, true);
|
const env = makeEnv(store, true);
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ ENVIRONMENT = "production"
|
|||||||
# Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true
|
# Base 預設不開(free-tier 友善)。self-host 開語義查詢時,deploy.ts 偵測 config kbdb_embed:true
|
||||||
# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed
|
# → 取消下面兩段註解(注入 active binding)並 `wrangler vectorize create arcrun-kbdb-embed
|
||||||
# --dimensions=768 --metric=cosine`(bge-base-en-v1.5 = 768 維)。官方帳號同理由 deploy 注入。
|
# --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 不變。
|
# 沒有這兩個 binding 時,kbdb/src/embed.ts 的 embedEnabled() 回 false → 維持 LIKE keyword、API 不變。
|
||||||
#
|
#
|
||||||
# [[vectorize]]
|
# [[vectorize]]
|
||||||
|
|||||||
Reference in New Issue
Block a user