Files
Arcrun/kbdb/src/embed.ts
T
Claude 65d85eb08f fix(kbdb): search keyword 補 source filter(#66)+semantic 曝 top_k/min_score 帶 score(#67)
#66:/entries/search keyword 路徑 source 解析後丟棄(#5.1 只接了 listEntries 那半)——
searchEntries 尾端加 source?(既有 positional caller 全不用改),conds 補與 listEntries
同款 json_extract(metadata_json,'$.source') 謂詞;route keyword 分支與 semantic 降級
分支兩處傳入。

#67:semantic 固定 topK=20、零分數閾值、低分尾硬湊數——route 曝 top_k(預設 20、封頂
100)與 min_score(預設 0=不過濾)query 參數;semanticSearch 依 min_score 截低分尾;
semantic 回應 entry 附 score 欄(加欄不改形)。壞值(非數字/非正)視同沒帶,不 400。

向後相容:不帶新參數時輸出與現況一致(semantic 僅多 score 資訊);不動表(D6)、
不動 D1 結構(API-as-Wall)。測試:新增 search-source-and-score.test.ts 13 條
(source 謂詞形狀/route 下傳/降級不洩 filter/min_score 截斷/topK 透傳封頂/壞值防呆/
不帶參數行為不變),kbdb vitest 33/33 綠、tsc 0。

關聯 #66 #67。merge 後需 gated redeploy kbdb worker(leo 閘)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUmjwkHLVBHM3ydhT1WSW3
2026-07-19 07:53:46 +00:00

258 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// KBDB optional embed module (issue #7 / mira-dissolve SDD T2.4).
//
// 鐵律對齊:
// - embedding 屬 **base 的 optional 模組**(非 graph/ingest)。CF 內建(Vectorize+AI),程式薄。
// - **不拆 repobinding 開/關**:有 env.VECTORIZE + env.AI 才啟用;沒有 → base 維持 LIKE keywordAPI 不變。
// - 不動三表結構(只標既有 entries.is_embedded / content_hash bookkeeping 欄;那些 base 從不讀,embed 才寫)。
// - 不對每個 block 地毯式 embed(精耕,非 RAG 一股腦灌):只 embed「被標記為 embeddable」的 entry
// wiki 段落 + graph node gloss)。標記方式=寫入時 metadata_json.embed === truecaller 顯式標)。
//
// 為何用 metadata flag 而非 entry_type 白名單:base 不該寫死「哪些 entry_type 該 embed」(那是上游語意,
// 會讓 base 知道 wiki/graph 概念,破壞解耦)。改由 caller(wiki/gloss 寫入端)顯式標 embed:true
// base 只認這個通用旗標 → base 維持對內容語意無知。
import type { Bindings, Entry } from './types';
const EMBED_MODEL = '@cf/baai/bge-base-en-v1.5'; // 768-dim,與 Vectorize index dimensions=768 對齊
/** embed 模組是否啟用(binding 都在才算開)。base 一切 embed 動作先過這關。 */
export function embedEnabled(env: Bindings): boolean {
return !!(env.VECTORIZE && env.AI);
}
/** 一段文字 → 768 維向量(Workers AI bge)。空字串回 null(不 embed)。 */
async function embedText(env: Bindings, text: string): Promise<number[] | null> {
const t = (text ?? '').trim();
if (!t || !env.AI) return null;
const res = (await env.AI.run(EMBED_MODEL, { text: [t] })) as { data: number[][] };
return res?.data?.[0] ?? null;
}
/**
* 寫入時選擇性 embedembed-on-write#5 第4點併入此)。
* - 模組未開 → no-opbase 輕量)。
* - 只 embed 被標 embeddable 的 entrymetadata_json.embed === true)。其餘略過(非地毯式)。
* 失敗不致命(fire-and-forget 由 caller 用 waitUntil 包;這裡只負責「能 embed 就 embed」)。
* 回傳是否真的 embed 了(讓 caller 決定要不要標 is_embedded)。
*/
export async function embedOnWrite(env: Bindings, entry: Entry): Promise<boolean> {
if (!embedEnabled(env)) return false;
if (!isEmbeddable(entry)) return false;
const vec = await embedText(env, entry.content ?? '');
if (!vec) return false;
await env.VECTORIZE!.upsert([
{
id: entry.id,
values: vec,
// metadata 走 indexed 範圍:owner_id(租戶隔離)、entry_type、source#5.1 過濾與語義共用)、
// libraryportal-auth P1「庫」filter)。library 在寫入端正規化:未標記='general'design §3.2
// 「未蓋章的舊資料視同 general」——D1 側用查詢端 COALESCE fallbackVectorize filter 做不了
// COALESCE,故在 upsert 時蓋 'general',查詢端單純 $in 即可)。
metadata: {
owner_id: entry.owner_id ?? '',
entry_type: entry.entry_type,
source: readSource(entry) ?? '',
library: readLibrary(entry) ?? 'general',
},
},
]);
// 標記 bookkeeping(既有欄,base 不讀、僅供「已 embed」可查)。不動表結構。
await env.DB.prepare('UPDATE entries SET is_embedded = 1 WHERE id = ?').bind(entry.id).run();
return true;
}
/** entry 是否該被 embedcaller 在 metadata_json 標 embed:true(精耕,非地毯式)。 */
function isEmbeddable(entry: Entry): boolean {
const meta = parseMeta(entry.metadata_json);
return meta?.embed === true;
}
function readSource(entry: Entry): string | null {
const meta = parseMeta(entry.metadata_json);
const s = meta?.source;
return typeof s === 'string' ? s : null;
}
/** metadata_json.$.libraryportal-auth P1)。非字串/空字串一律視同未標記(→ caller fallback 'general')。 */
function readLibrary(entry: Entry): string | null {
const meta = parseMeta(entry.metadata_json);
const l = meta?.library;
return typeof l === 'string' && l.trim() !== '' ? l : null;
}
function parseMeta(json: string | null): Record<string, unknown> | null {
if (!json) return null;
try {
const p = JSON.parse(json);
return p && typeof p === 'object' ? (p as Record<string, unknown>) : null;
} catch {
return null;
}
}
// SQL predicate for "an entry that SHOULD be embedded but isn't yet".
// - isEmbeddable 契約 = metadata_json.embed === truebase 通用旗標,對內容語意無知,不寫死 entry_type)。
// SQLite json_extract 對 JSON boolean true 回整數 1 → `= 1` 精確對齊 TS 的 `=== true`。
// - is_embedded = 0:尚未(對「當前」index)補嵌的 bookkeeping。
// - content 非空:空字串 embedText 會回 null,排除以免變成永遠清不掉的殘留候選。
const BACKFILL_PREDICATE =
"is_embedded = 0 AND content IS NOT NULL AND content <> '' AND json_extract(metadata_json, '$.embed') = 1";
export interface BackfillResult {
enabled: boolean; // 模組是否開(false → 什麼都沒做,caller 該誠實回錯,不假裝)。
processed: number; // 本次真的嵌進 Vectorize 並標 is_embedded=1 的筆數。
skipped: number; // 掃到但沒嵌(例如 embedText 回 null)的筆數。
remaining: number; // 本次之後仍待補嵌的筆數(可重複呼叫直到 0)。
scanned: number; // 本批掃出的候選筆數(受 limit 限制)。
}
/**
* Backfill(回填):對「開 Vectorize 之前就寫入、或 embed-on-write 當時漏掉」的既有 entry 批次補嵌。
* 冪等(重跑已補嵌的不會重複算,upsert 同 id 冪等)、分批(單次 limit 上限,避開 subrequest/CPU/timeout)、
* 回傳處理筆數 + 剩餘筆數(caller 重複呼叫直到 remaining=0)。
* - 模組未開(無 VECTORIZE+AI)→ 誠實回 { enabled:false },不假裝成功(mindset §7 禁假綠)。
* - 只補「isEmbeddablemetadata.embed===true)且 is_embedded=0」的 entry——與 embedOnWrite 同一契約,
* base 維持對內容語意無知(不知 triplet/wiki,只認通用 embed 旗標)。
* - 效率:整批用「單次 AI.run(陣列輸入)+ 單次 VECTORIZE.upsert(陣列)+ 單次 UPDATE ... IN(...)」,
* 一批 ≈ 3 個 subrequest,不隨 limit 線性增長 → free/paid tier 都安全。
*/
export async function backfillEmbeddings(
env: Bindings,
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);
// 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 ? OFFSET ?`)
.bind(...params, limit, offset)
.all<Entry>();
const rows = res.results ?? [];
const scanned = rows.length;
let processed = 0;
const embeddable = rows.filter((e) => (e.content ?? '').trim().length > 0);
if (embeddable.length > 0 && env.AI && env.VECTORIZE) {
const texts = embeddable.map((e) => (e.content ?? '').trim());
const out = (await env.AI.run(EMBED_MODEL, { text: texts })) as { data: number[][] };
const data = out?.data ?? [];
const vectors = embeddable
.map((e, i) => ({ e, vec: data[i] }))
.filter((x): x is { e: Entry; vec: number[] } => Array.isArray(x.vec) && x.vec.length > 0)
.map((x) => ({
id: x.e.id,
values: x.vec,
metadata: {
owner_id: x.e.owner_id ?? '',
entry_type: x.e.entry_type,
source: readSource(x.e) ?? '',
library: readLibrary(x.e) ?? 'general', // 同 embedOnWrite:寫入端正規化(P1
},
}));
if (vectors.length > 0) {
await env.VECTORIZE.upsert(vectors);
const ids = vectors.map((v) => v.id);
const placeholders = ids.map(() => '?').join(',');
await env.DB.prepare(`UPDATE entries SET is_embedded = 1 WHERE id IN (${placeholders})`).bind(...ids).run();
processed = vectors.length;
}
}
const remRow = await env.DB
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${where}`)
.bind(...params)
.first<{ c: number }>();
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)。 */
export async function backfillStatus(
env: Bindings,
opts: { owner_id?: string; source?: string } = {},
): Promise<{ enabled: boolean; pending: number; embedded: number }> {
const conds: string[] = [];
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 extra = conds.length ? ` AND ${conds.join(' AND ')}` : '';
const pendingRow = await env.DB
.prepare(`SELECT COUNT(*) as c FROM entries WHERE ${BACKFILL_PREDICATE}${extra}`)
.bind(...params)
.first<{ c: number }>();
const embeddedRow = await env.DB
.prepare(`SELECT COUNT(*) as c FROM entries WHERE is_embedded = 1 AND json_extract(metadata_json, '$.embed') = 1${extra}`)
.bind(...params)
.first<{ c: number }>();
return { enabled: embedEnabled(env), pending: pendingRow?.c ?? 0, embedded: embeddedRow?.c ?? 0 };
}
export interface SemanticHit {
id: string;
score: number;
owner_id?: string;
entry_type?: string;
source?: string;
library?: string;
}
/**
* 語義搜尋(mode:'semantic')。模組未開 → 回 nullcaller 降級 keyword + 告知缺能力)。
* owner_id / source / entry_type 過濾走 Vectorize metadata filterentry_type 已 index,見上 upsert metadata)。
* entry_type 是 base 通用 filtercaller 傳任意 typebase 不寫死語意)。
* libraryportal-auth P1):多值庫 filter 走 `$in`(官方支援已核實 2026-07-14:文件明列 $in/$nin
* workers-types 原生 typingdesign §3.3 的 fan-out fallback 不需啟用)。未帶=行為不變。
* 註:向量 metadata 的 library 在寫入端已正規化(未標記='general'),故 $in 不需 NULL 處理;
* 但「建 library metadata index 之前」upsert 的既有向量沒有此欄 → 部署清單強制 reindex backfill。
* min_scoreissue #67):分數閾值——Vectorize 只會硬湊 topK 筆,低分尾全是無關內容;
* 過濾放查詢端(非 Vectorize 端,API 無此參數)。預設 0=不過濾(行為與舊版一字不變,向後相容)。
*/
export async function semanticSearch(
env: Bindings,
q: string,
opts: { owner_id?: string; source?: string; entry_type?: string; library?: string[]; topK?: number; min_score?: number } = {},
): Promise<SemanticHit[] | null> {
if (!embedEnabled(env)) return null;
const vec = await embedText(env, q);
if (!vec) return [];
const filter: VectorizeVectorMetadataFilter = {};
if (opts.owner_id) filter.owner_id = opts.owner_id;
if (opts.source) filter.source = opts.source;
if (opts.entry_type) filter.entry_type = opts.entry_type;
if (opts.library && opts.library.length > 0) filter.library = { $in: opts.library };
const res = await env.VECTORIZE!.query(vec, {
topK: Math.min(opts.topK ?? 20, 100),
returnMetadata: 'indexed',
...(Object.keys(filter).length ? { filter } : {}),
});
const minScore = opts.min_score ?? 0;
return (res.matches ?? [])
.filter((m) => m.score >= minScore)
.map((m) => ({
id: m.id,
score: m.score,
owner_id: m.metadata?.owner_id as string | undefined,
entry_type: m.metadata?.entry_type as string | undefined,
source: m.metadata?.source as string | undefined,
library: m.metadata?.library as string | undefined,
}));
}