feat: KBDB self-hosted 查詢 + embed 模組 + thin-shell 收窄 + search_workflow(code done 待端到端)
按 issue 分段標明(檔 #5/#8 改動交疊處無法乾淨拆檔,故併一個 commit): #4 thin-shell §3.1 自力救濟階梯 + code-node 規則(純文檔/規則,code-node 零件未實作) #5 KBDB source filter(json_extract metadata_json 零建表)+ 能力對照;documents 聚合與 DELETE proxy 部分擱置等頂層 T8 #7 base embed 模組(kbdb/src/embed.ts)+ vectorize 開關(deploy/config/wrangler.toml 註解範本) + 語義查詢降級閉環(mode=semantic 未開→LIKE+capability_hint) #8 部分(workflow-discovery): - KBDB /entries/search 加 base 通用 entry_type filter(entry-crud/embed/route/kbdb-proxy 透傳) - /webhooks/named 強制 description(空→400,訊息要求操盤 AI 據實寫一句) - 部署雙寫 entry_type=workflow embeddable entry(waitUntil 非阻塞,供 search) - cypher GET /workflows/search + MCP u6u_search_workflows(優先語意、降級 hint) - cypher POST /workflows/backfill-search-entries(無 desc 列出不編造) - GET /webhooks/named 補回 description/created_at 欄位(為 list 來源收斂備) ⚠️ tsc 綠 = code done,非完成(mindset §7 禁假綠): - #7/#8 端到端待 leo21c 部署驗(Vectorize 需官方憑證、CC 跑不了) - #8 ①-a(MCP deploy 改打 /webhooks/named)未做、MCP deploy 那半仍 404 - #8 端到端(強制填擋空/語義命中/租戶隔離/降級 hint)未驗 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
// KBDB optional embed module (issue #7 / mira-dissolve SDD T2.4).
|
||||
//
|
||||
// 鐵律對齊:
|
||||
// - embedding 屬 **base 的 optional 模組**(非 graph/ingest)。CF 內建(Vectorize+AI),程式薄。
|
||||
// - **不拆 repo,binding 開/關**:有 env.VECTORIZE + env.AI 才啟用;沒有 → base 維持 LIKE keyword,API 不變。
|
||||
// - 不動三表結構(只標既有 entries.is_embedded / content_hash bookkeeping 欄;那些 base 從不讀,embed 才寫)。
|
||||
// - 不對每個 block 地毯式 embed(精耕,非 RAG 一股腦灌):只 embed「被標記為 embeddable」的 entry
|
||||
// (wiki 段落 + graph node gloss)。標記方式=寫入時 metadata_json.embed === true(caller 顯式標)。
|
||||
//
|
||||
// 為何用 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 寫入時選擇性 embed(embed-on-write,#5 第4點併入此)。
|
||||
* - 模組未開 → no-op(base 輕量)。
|
||||
* - 只 embed 被標 embeddable 的 entry(metadata_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 過濾與語義共用)。
|
||||
metadata: {
|
||||
owner_id: entry.owner_id ?? '',
|
||||
entry_type: entry.entry_type,
|
||||
source: readSource(entry) ?? '',
|
||||
},
|
||||
},
|
||||
]);
|
||||
// 標記 bookkeeping(既有欄,base 不讀、僅供「已 embed」可查)。不動表結構。
|
||||
await env.DB.prepare('UPDATE entries SET is_embedded = 1 WHERE id = ?').bind(entry.id).run();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** entry 是否該被 embed:caller 在 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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export interface SemanticHit {
|
||||
id: string;
|
||||
score: number;
|
||||
owner_id?: string;
|
||||
entry_type?: string;
|
||||
source?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 語義搜尋(mode:'semantic')。模組未開 → 回 null(caller 降級 keyword + 告知缺能力)。
|
||||
* owner_id / source / entry_type 過濾走 Vectorize metadata filter(entry_type 已 index,見上 upsert metadata)。
|
||||
* entry_type 是 base 通用 filter(caller 傳任意 type,base 不寫死語意)。
|
||||
*/
|
||||
export async function semanticSearch(
|
||||
env: Bindings,
|
||||
q: string,
|
||||
opts: { owner_id?: string; source?: string; entry_type?: string; topK?: number } = {},
|
||||
): Promise<SemanticHit[] | null> {
|
||||
if (!embedEnabled(env)) return null;
|
||||
const vec = await embedText(env, q);
|
||||
if (!vec) return [];
|
||||
const filter: Record<string, string> = {};
|
||||
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;
|
||||
const res = await env.VECTORIZE!.query(vec, {
|
||||
topK: Math.min(opts.topK ?? 20, 100),
|
||||
returnMetadata: 'indexed',
|
||||
...(Object.keys(filter).length ? { filter } : {}),
|
||||
});
|
||||
return (res.matches ?? []).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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user