// Entry CRUD — atomic data + tree (project/workflow via parent_id). Base, D1 only. import type { Bindings, Entry } from '../types'; function uid(prefix: string): string { // deterministic-enough unique id without Math.random in hot path is fine here; // crypto.randomUUID is available in Workers runtime. return `${prefix}_${crypto.randomUUID()}`; } export interface CreateEntryInput { content?: string | null; entry_type: string; owner_id?: string | null; parent_id?: string | null; page_name?: string | null; refs_json?: string; tags_json?: string; task_status?: string | null; confidence?: number | null; metadata_json?: string | null; id?: string; } export async function createEntry(db: D1Database, input: CreateEntryInput): Promise { const id = input.id ?? uid('e'); await db .prepare( `INSERT INTO entries (id, content, entry_type, owner_id, parent_id, page_name, refs_json, tags_json, task_status, confidence, metadata_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( id, input.content ?? null, input.entry_type, input.owner_id ?? null, input.parent_id ?? null, input.page_name ?? null, input.refs_json ?? '[]', input.tags_json ?? '[]', input.task_status ?? null, input.confidence ?? null, input.metadata_json ?? null, ) .run(); const row = await getEntry(db, id); if (!row) throw new Error('createEntry: insert succeeded but row not found'); return row; } export async function getEntry(db: D1Database, id: string): Promise { const row = await db.prepare('SELECT * FROM entries WHERE id = ?').bind(id).first(); return row ?? null; } export interface ListEntriesFilter { entry_type?: string; owner_id?: string; parent_id?: string; page_name?: string; // exact-match lookup (e.g. skill-/example- idempotency key) source?: string; // filter by metadata_json.$.source (ingest envelope source.uri). issue #5.1 library?: string[]; // filter by metadata_json.$.library(多值 OR;portal-auth P1,#24/#25)。 // 未帶=不過濾(向後相容硬驗收);未標記的舊資料視同 'general'(design §3.2)。 q?: string; // keyword filter on content (LIKE). Arcrun#3 發現①:list 端點原本完全不吃 // search/q,caller 帶了也被靜默丟棄(不是 458K 筆搜不到,是這個 filter 沒接)。 limit?: number; offset?: number; } export interface ListEntriesResult { entries: Entry[]; total: number; // 符合本次篩選條件的「全部」筆數(不受 limit/offset 影響)。 // Arcrun#3 發現①「count 欄位語意誤導」:舊版 route 把 entries.length(分頁筆數) // 當 count 回傳,容易被誤讀成「總共只有這幾筆」。total 才是真總數,count 仍保留=本頁筆數。 } export async function listEntries(db: D1Database, f: ListEntriesFilter = {}): Promise { const conds: string[] = []; const params: unknown[] = []; if (f.entry_type) { conds.push('entry_type = ?'); params.push(f.entry_type); } if (f.owner_id) { conds.push('owner_id = ?'); params.push(f.owner_id); } if (f.parent_id) { conds.push('parent_id = ?'); params.push(f.parent_id); } if (f.page_name) { conds.push('page_name = ?'); params.push(f.page_name); } // source is queryable via SQLite json_extract on the existing metadata_json TEXT column — // no new column / no migration (表不變鐵律). Per issue #5.1 (頂層化 source 成可查 filter). if (f.source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(f.source); } if (f.library && f.library.length > 0) { conds.push(libraryPredicate(f.library)); params.push(...f.library); } if (f.q) { const m = buildContentLike(f.q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike conds.push(...m.conds); params.push(...m.params); } const where = conds.length ? `WHERE ${conds.join(' AND ')}` : ''; const limit = Math.min(f.limit ?? 100, 1000); const offset = f.offset ?? 0; const [rowsRes, countRow] = await Promise.all([ db // `, rowid DESC` 二級排序(KV 額度事故修復,2026-08-07 發現):created_at 是 // unixepoch()=秒級解析度,高頻寫入(例如 execution_log 一秒內多筆執行)常同秒, // 單靠 created_at DESC 的同分排序不保證插入序,「最新一筆」可能取到錯的一列。 // rowid 是 SQLite/D1 一般表的隱含遞增欄,同分時退回插入序,不改變既有排序結果 // (created_at 不同時完全一字不變),純粹補上同分時的決定性。 .prepare(`SELECT * FROM entries ${where} ORDER BY created_at DESC, rowid DESC LIMIT ? OFFSET ?`) .bind(...params, limit, offset) .all(), db.prepare(`SELECT COUNT(*) as total FROM entries ${where}`).bind(...params).first<{ total: number }>(), ]); return { entries: rowsRes.results ?? [], total: countRow?.total ?? 0 }; } export interface UpdateEntryInput { content?: string | null; parent_id?: string | null; page_name?: string | null; refs_json?: string; tags_json?: string; task_status?: string | null; confidence?: number | null; metadata_json?: string | null; } export async function updateEntry(db: D1Database, id: string, patch: UpdateEntryInput): Promise { const cols: string[] = []; const params: unknown[] = []; const map: Record = patch as Record; for (const k of ['content', 'parent_id', 'page_name', 'refs_json', 'tags_json', 'task_status', 'confidence', 'metadata_json']) { if (k in map && map[k] !== undefined) { cols.push(`${k} = ?`); params.push(map[k]); } } if (cols.length === 0) return getEntry(db, id); cols.push('updated_at = unixepoch()'); await db.prepare(`UPDATE entries SET ${cols.join(', ')} WHERE id = ?`).bind(...params, id).run(); return getEntry(db, id); } export async function deleteEntry(db: D1Database, id: string): Promise { await db.prepare('DELETE FROM entries WHERE id = ?').bind(id).run(); } /** * 把某 owner 下某庫的所有 entries 標 deprecated(t135 by-name 移除語意)。 * 沿用既有 deprecated 機制:metadata_json.status='deprecated' → 搜尋端過濾、庫列表排除。 * 回 deprecated 的筆數(0 = 庫名不存在或早已全部 deprecated)。 */ /** * 撈出某 owner 下某庫、**目前還有向量**的 entry id(供下架時連帶清向量用)。 * * 🔴 2026-08-05 leo:「已經被刪掉的內容?理論上它的向量也要刪掉,就不會有殘影了吧?」——對。 * 單筆真刪(`DELETE /entries/:id`)已經接了 `VECTORIZE.deleteByIds`(b7af622), * 但「移除整個庫」走軟刪(只標 status),**向量原地不動** ⇒ 殘影就是這樣長出來的: * 搜尋端每次都要靠事後過濾擋它,而它還會頂著高分去影響門檻計算。 * ⇒ 標 deprecated 的同時把向量刪掉,讓殘影**在源頭就不存在**。 * 不違背 t135「資料保留可還原」:**D1 那列原封不動**,還原後跑 * `POST /embed/backfill` 重嵌即可(backfill 已排除 deprecated,所以不會自己跑回來)。 */ export async function embeddedIdsByLibrary(db: D1Database, ownerId: string, library: string): Promise { const rows = await db .prepare( `SELECT id FROM entries WHERE owner_id = ? AND COALESCE(NULLIF(json_extract(metadata_json, '$.library'), ''), 'general') = ? AND is_embedded = 1`, ) .bind(ownerId, library) .all<{ id: string }>(); return (rows.results ?? []).map((r) => r.id); } /** 把這些 entry 標成「已無向量」(配合 deleteByIds,讓 D1 與 Vectorize 不說兩套話)。 */ export async function markUnembedded(db: D1Database, ids: string[]): Promise { if (ids.length === 0) return; const holes = ids.map(() => '?').join(','); await db.prepare(`UPDATE entries SET is_embedded = 0 WHERE id IN (${holes})`).bind(...ids).run(); } export async function deprecateEntriesByLibrary(db: D1Database, ownerId: string, library: string): Promise { const result = await db .prepare( `UPDATE entries SET metadata_json = json_set(COALESCE(metadata_json, '{}'), '$.status', 'deprecated'), updated_at = unixepoch() WHERE owner_id = ? AND COALESCE(json_extract(metadata_json, '$.library'), 'general') = ? AND (json_extract(metadata_json, '$.status') IS NULL OR json_extract(metadata_json, '$.status') != 'deprecated')`, ) .bind(ownerId, library) .run(); return (result.meta?.changes as number | undefined) ?? 0; } // ── content 關鍵字比對:D1 的 LIKE pattern 有 50 bytes 硬上限 ─────────────────── // // 病徵(2026-08-03 在 1.4.4 實例上二分實測):`/entries/search?q=…` 只要 q **超過 48 bytes** // 就回 HTTP 500「Internal Server Error」——不是 400、沒有錯誤訊息,從外面看像伺服器壞了。 // q = 48 bytes → 200|q = 49 bytes → 500(ASCII 逐 byte 二分) // 中文 16 字(48 bytes)→ 200|中文 17 字(51 bytes)→ 500 // 判別實驗(排除「整句 SQL 太長」這個猜想):q 固定 48 bytes、把 owner_id/entry_type/source/ // library 全塞滿讓 SQL 變很長 → 仍然 200 ⇒ **會爆的是 LIKE 的 pattern,不是 statement**。 // pattern = '%' + q + '%' ⇒ 48+2 = 50 ⇒ 上限就是 50 bytes。 // 對照:同一個長 q 走 mode=semantic 完全正常(那條路不經過 LIKE)。 // // 為什麼要修(不是邊角):**中文問句超過 16 個字是常態**。 // rag_chat 的 kw_search 用整句問題當 q ⇒ 使用者問任何一句正常長度的中文, // 整條問答鏈在第二個節點就 500 ⇒ 聊天功能等於不能用。 // (這也是 InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。) // // 修法(**短查詢行為逐字不變**): // · q ≤ 48 bytes → 走原本那條路,單一 `content LIKE '%q%'`,一個字都沒改。 // · q > 48 bytes → 拆成詞,每個詞各一個 LIKE 用 AND 串(「每個詞都要出現」)。 // 沒有空白可拆的長句(中文常見)→ 切成 ≤48 bytes 的片段(切在 UTF-8 邊界上,不切壞字)。 // 詞數上限 6:再多對 D1 是白花成本,而且「要同時命中 7 個詞」本來就不會有結果。 // // 誠實限制:對「無空白的長中文句」,拆片段是機械切分、不是斷詞 ⇒ 命中率不會變好。 // 但它的對照組是 **500**,不是「更好的結果」;而且這種查詢原本就算不炸也幾乎命不中 // (整句子字串比對)。真正的中文關鍵字檢索要走 FTS5 或斷詞,那是另一件事、要另外立案。 const MAX_LIKE_Q_BYTES = 48; // D1: LIKE pattern 上限 50 bytes,pattern = '%' + q + '%' const MAX_LIKE_TERMS = 6; const utf8Len = (s: string): number => new TextEncoder().encode(s).length; // ── 使用者打的 `%` 與 `_` 是「要找的字」,不是萬用字元(Arcrun#94)──────────────── // // 病徵:搜尋框打 `100%` 或 `owner_id`,回來一堆跟那些字無關的東西。 // SQLite LIKE 只有兩個萬用字元——`%`(任意長度)與 `_`(任意一個字元),而且 // **沒有預設跳脫字元**(不寫 ESCAPE 就沒有任何辦法表示「字面上的 %」)。 // 我們把使用者輸入直接內插成 `'%' + q + '%'` ⇒ 他打的符號被當成 pattern 語法: // `100%` → `%100%%` → 「100」開頭後面接什麼都算 ⇒ 撈回一堆不相干的 // `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ `ownerXid`、`owner-id` 也中 // `%`/`_` 單打 → `%%%`/`%_%` → **整個庫都回來**(`_` 只要有一個字元就中) // // 這是舊病,不是 08-10 斷詞(47c6aae→本檔上一段)引進的:pattern 一直都是這樣拼的。 // 之前關鍵字搜尋幾乎恆為 0 命中(整串比對),這個洞被那個洞蓋住,看不出來; // 斷詞讓搜尋真的會回東西之後它才浮出來。**斷詞那段一個字都沒動。** // // 修法:三個字元都跳脫,並在每個 LIKE 後面掛 `ESCAPE '\'`。 // // 為什麼**跳脫字元本身(`\`)也要跳脫**(邊界問題的答案,不是順手多做): // 一旦宣告了 ESCAPE,`\` 在 pattern 裡就變成有意義的字元,於是「使用者打的 `\`」 // 同樣會被誤讀——而且是更糟的一種,因為它會**把後面那個字吃掉**: // 使用者打 `100\%` → 不跳脫 `\` ⇒ pattern `%100\%%` ⇒ `\%`=字面 % // ⇒ 實際找的是 `100%`,**跟他打的字不一樣** // 使用者打 `C:\` → pattern `%C:\%` ⇒ 尾巴 `\%`=字面 % // ⇒ 找的是 `C:%`,而真正的 `C:\` 反而找不到 // ⇒ 三個字元是一組的:宣告 ESCAPE 卻不跳脫 `\` 等於用新的漏洞換掉舊的。 // (SQLite 對「`\` 後面接其他字元」是寬容的——照字面匹配下一個字、不報錯—— // 所以不跳脫不會炸,只會靜靜地找錯東西,正是最難發現的那種。) // // 為什麼**只有這三個**:SQLite 的 LIKE 萬用字元就只有 `%` 和 `_`(`[...]`、`?`、`*` // 是別的方言/GLOB 的東西,LIKE 不吃),加上自己宣告的跳脫字元 `\`,就這三個。 // 不多跳脫其他字元——跳脫沒有語法意義的字元只會白白吃掉 pattern 的 byte 預算。 // // 🔴 與 50 bytes 上限的交互作用(不能只加跳脫就收工):跳脫會**變長**(`%`→`\%`), // 所以所有 byte 預算改用「跳脫後」的長度算(likeBytes),否則使用者打一串 `%` // 會讓 pattern 膨脹回 50 bytes 以上 ⇒ 退回 2026-08-03 那個 500。 // 不含這三個字元的查詢,likeBytes ≡ utf8Len ⇒ **既有查詢的行為逐字不變**。 const LIKE_ESCAPE = '\\'; /** 每個 `content LIKE ?` 都要帶著它的 ESCAPE 宣告,否則跳脫過的 pattern 反而被當字面。 */ const CONTENT_LIKE = `content LIKE ? ESCAPE '${LIKE_ESCAPE}'`; /** 把使用者輸入當「字面字串」送進 LIKE(純函式,單測用 export)。 */ export function escapeLikeLiteral(s: string): string { // 一次掃描、每個字元各自替換 ⇒ 不會發生「先換 % 再換 \ 把剛加的跳脫又跳脫一次」。 return s.replace(/[\\%_]/g, (ch) => LIKE_ESCAPE + ch); } /** 這段文字**跳脫後**佔的 byte 數(=它在 LIKE pattern 裡真正佔的長度)。 */ const likeBytes = (s: string): number => utf8Len(escapeLikeLiteral(s)); /** 子字串比對用的 pattern:只有頭尾那兩個 `%` 是萬用字元,中間全是字面。 */ const likePattern = (s: string): string => `%${escapeLikeLiteral(s)}%`; /** 依 UTF-8 byte 上限切片,不切壞多位元組字元。上限算的是**跳脫後**的長度。 */ function chunkByBytes(s: string, maxBytes: number): string[] { const out: string[] = []; let cur = ''; for (const ch of s) { if (likeBytes(cur + ch) > maxBytes) { if (cur) out.push(cur); cur = ch; } else { cur += ch; } } if (cur) out.push(cur); return out; } /** * 把 q 轉成一組 `content LIKE ?` 謂詞與參數(純函式,單測用 export)。 * 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。 */ export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } { if (likeBytes(q) <= MAX_LIKE_Q_BYTES) { return { conds: [CONTENT_LIKE], params: [likePattern(q)], split: false }; } const terms: string[] = []; for (const word of q.split(/\s+/).filter(Boolean)) { for (const piece of chunkByBytes(word, MAX_LIKE_Q_BYTES)) { terms.push(piece); if (terms.length >= MAX_LIKE_TERMS) break; } if (terms.length >= MAX_LIKE_TERMS) break; } // 理論上不會空(q 非空才進得來),但空陣列會產出 `WHERE` 沒有條件 ⇒ 保底退回單一截斷 LIKE if (terms.length === 0) terms.push(chunkByBytes(q, MAX_LIKE_Q_BYTES)[0] ?? ''); return { conds: terms.map(() => CONTENT_LIKE), params: terms.map(likePattern), split: true, }; } // ── 查詢斷詞 + 覆蓋率排序:讓「AI 問一個問句」查得到東西 ───────────────────────── // // 病徵(2026-08-10 總管在 leo21c 上實測,有對照組): // kbdb_search("Gemini 逃生口") → 0 筆 // kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡 // kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰 // ⇒ 對照組證明:**查詢字串是整串拿去比對的,從來沒有被拆開**。 // 上面 buildContentLike 只在 q > 48 bytes(=那次 500 的閘)時才拆,短查詢一律單一 // `content LIKE '%整句%'`;而且拆開後是 AND(每個詞都要出現)。 // // 為什麼這是**結構性**故障、不是準度問題: // **AI 問的永遠是問句,不是單一關鍵字。** 一個問句的詞幾乎不可能在原文裡剛好相鄰 // ⇒ 對 AI 而言這條路的回傳值恆為 0。leo 2026-08-10:「沒有 MCP 你就是瞎的」—— // 接上了也還是瞎的,因為接上之後查什麼都沒有。 // (語意搜尋救不了:同一次實測 50 筆裡 41 筆沒有向量,82% 的內容語意搜尋看不見。) // // 這件 47c6aae(2026-08-03 修 50 bytes 500)就寫明是「另一件事、要另外立案」的那件事; // 本次只動**查詢端**,buildContentLike 一個字不動(那支修的是 pattern 長度,不是斷詞)。 // // 修法:查詢端斷詞 → 每個詞各自比對 → **用覆蓋率排序**,不是用 AND 過濾。 // · 只要命中任一個詞就是候選(OR),但**排序由「命中了多少份量的詞」決定**, // 所以「詞存在但不相鄰」查得到東西,而相關的排在前面。 // · 詞的份量=詞長(字數)。長詞/英數詞比較專指,雙字詞比較泛 // ⇒「Gemini 在這套系統裡的角色是什麼」裡 Gemini(6) 的份量遠大於 系統(2)、角色(2) // ⇒ 含 Gemini 的內容自然壓過只含「系統」的雜訊。這就是相關性不崩壞的機制。 // · **整句相鄰**另外加一份重賞(phraseBonus)⇒ 舊行為(字面相鄰)永遠排第一, // `local arcrun` 那 5 筆不會被稀釋掉。 // · 相對門檻砍低分尾(沿用 embed.ts relativeMinScore 的既有做法,不另立第二套): // 只留 >= 最高分 × KEYWORD_RELATIVE_CUT 的,避免「為了有結果就把整個庫撈回來」。 // // 回歸保證(不是靠測試碰運氣,是靠構造): // · **單詞查詢送出的 SQL 與舊版逐字相同**(一個 LIKE、同一個 pattern), // 所有分數相等 ⇒ 排序也退化回 updated_at DESC。一個字都沒變。 // · 多詞查詢的結果集是舊版的**超集**(含整句的內容一定也含每一個詞), // 而整句命中因 phraseBonus 排最前 ⇒ 原本查得到的不可能變成查不到。 // // 誠實限制:這是「查詢端斷詞」,不是真正的中文斷詞器(沒有詞典)。CJK 靠虛詞切段 // +長段補雙字組合,命中率一定不如詞典;真正的解是 FTS5/斷詞索引,那要動索引端、 // 要另外立案。本次的對照組是 **0 筆**,不是「更好的排序」。 // 成本:一次查詢最多掃 MAX_SEARCH_TERMS(+1) 個 LIKE,而舊版是 1 個 ⇒ 全表掃描成本上升到 // 最多 7 倍。**單詞查詢仍是 1 個**(最常見的路徑不受影響);多詞查詢用這個成本換掉「恆為 0」。 const MAX_SEARCH_TERMS = 6; // 每多一個詞就多比對一次,6 是成本與召回的折衷(與 MAX_LIKE_TERMS 同數) const MAX_TERM_WEIGHT = 8; // 單一詞份量上限,避免一個超長詞獨大到蓋掉其他訊號 // 相對門檻取 0.6 是**實測調出來的**,不是拍的(2026-08-10,3915 筆真實語料本機對照): // 0.5 時「這個系統的搜尋是怎麼做的」把只含「系統」或只含「搜尋」的也撈進來(滿 50 筆雜訊尾); // 0.6 時只留同時含兩個詞的 ⇒ 尾巴收乾淨,而驗收題(Gemini 逃生口)不受影響 // ——那題最高分那群本來就只有 Gemini 一個詞命中,相對門檻是對「最高分」取比例,不是對「滿分」, // 所以「全庫沒有第二個詞」的情況不會被自己的門檻誤殺(這正是不能用滿分當分母的原因)。 const KEYWORD_RELATIVE_CUT = 0.6; // CJK 虛詞:**只拿來過濾雙字組合,絕不拿來切段。** // // 🔴 這條是自己的測試擋出來的(2026-08-10):第一版用虛詞「切段」,結果 // 「向量化」被 `向` 切成「量化」、「功能」被 `能` 切掉 ⇒ **把使用者真正要查的詞切爛了**。 // 沒有詞典的中文,切段一定會誤傷實詞(能/更/要/者/使/則/因/項/過/得 全都 // 同時是虛詞與實詞的組成部分)。 // ⇒ 改成:**整段原樣保留**,雙字組合只是補充;只有「雙字裡有虛詞」的組合才丟掉。 // 這個方向誤傷不了實詞——因為實詞從來沒有被拆過,只是多了幾個候選。 // // 收字原則:**拿不準就不收**。噪音組合很便宜(比不中就是 0 分,只佔一個名額), // 誤殺實詞很貴(那個查詢就永遠找不到了)。所以像 個/為/能/要/者/因/所/中/裡 // 這些「也會出現在實詞裡」的字**一律不收**,寧可留下「一個」「為什」這種比不中的噪音。 const CJK_STOP_CHARS = new Set( '的了是在我你他她它們這那哪誰嗎呢吧啊呀嘛喔哦什麼怎之乎而但並卻就都也很太只還又再每些把被跟讓若'.split(''), ); // 英文虛詞:同理,問句裡的 what/how/why 不是查詢訊號。 const ASCII_STOP_WORDS = new Set([ 'the', 'a', 'an', 'and', 'or', 'of', 'to', 'in', 'on', 'at', 'is', 'are', 'was', 'were', 'be', 'do', 'does', 'did', 'for', 'it', 'its', 'this', 'that', 'these', 'those', 'with', 'what', 'how', 'why', 'when', 'where', 'who', 'which', 'can', 'could', 'should', 'would', 'my', 'our', 'your', 'their', 'me', 'we', 'you', 'they', ]); const isCjkChar = (ch: string): boolean => /[぀-ヿ㐀-䶿一-鿿豈-﫿]/.test(ch); const isWordChar = (ch: string): boolean => /[A-Za-z0-9_.-]/.test(ch); /** 把查詢切成「連續的同類字串」:CJK 一段、英數一段,其餘(空白/標點/全形符號)當分隔。 */ export function splitRuns(q: string): { text: string; cjk: boolean }[] { const runs: { text: string; cjk: boolean }[] = []; let cur = ''; let curCjk = false; const flush = () => { if (cur) runs.push({ text: cur, cjk: curCjk }); cur = ''; }; for (const ch of q) { const cjk = isCjkChar(ch); if (!cjk && !isWordChar(ch)) { flush(); continue; } // 空白與標點=分隔 if (cur && cjk !== curCjk) flush(); // CJK↔英數 邊界也切(吸收 t95 normalizeCjkQuery 的用意) cur += ch; curCjk = cjk; } flush(); return runs; } /** 相鄰雙字組合,丟掉「含虛詞」的那些(在這/的角/是什…=噪音,不是查詢訊號)。 */ function contentBigrams(run: string): string[] { const chars = [...run]; const out: string[] = []; for (let i = 0; i + 1 < chars.length; i++) { if (CJK_STOP_CHARS.has(chars[i]) || CJK_STOP_CHARS.has(chars[i + 1])) continue; out.push(chars[i] + chars[i + 1]); } return out; } export interface SearchTerm { term: string; weight: number } /** * 把查詢句拆成帶份量的查詢詞(純函式,單測用 export)。 * 份量=字數(上限 MAX_TERM_WEIGHT);愈長愈專指 ⇒ 排序時壓過泛詞。 * 依份量由大到小截斷到 MAX_SEARCH_TERMS,確保被砍掉的是最泛的那幾個。 */ export function tokenizeQuery(q: string): SearchTerm[] { const found = new Map(); const add = (t: string, w: number) => { for (const piece of chunkByBytes(t, MAX_LIKE_Q_BYTES)) { // 仍受 D1 LIKE pattern 50 bytes 上限約束 if (!piece) continue; found.set(piece, Math.max(found.get(piece) ?? 0, Math.min(w, MAX_TERM_WEIGHT))); } }; const runs = splitRuns(q); // 「使用者只打一個詞」vs「AI 問一句話」是兩種東西,處理方式必須不同: // · 只有一段 → **就照舊版做**(一個 LIKE),這條路本來就好好的,不准動它。 // · 有多段(=問句)→ 才補雙字組合去拉召回。這是本次要修的那條路。 // 🔴 這個判斷是既有回歸測試擋出來的(search-long-query.test.ts「短查詢:SQL 裡只有 // 一個 content LIKE」):不分情況一律補雙字組合,會讓「語意檢索」這種**最常見的 // 中文單詞查詢**從 1 個 LIKE 變 5 個 ⇒ 最熱路徑成本 ×5,而它根本沒壞。 const isQuestion = runs.length > 1; for (const run of runs) { if (!run.cjk) { const w = run.text.toLowerCase(); if (w.length >= 2 && !ASCII_STOP_WORDS.has(w)) add(run.text, run.text.length); continue; } const chars = [...run.text]; // 短段(≤4 字)多半**本身就是一個詞**(語意檢索/專案管理/系統/角色)→ 原樣當查詢詞。 if (chars.length >= 2 && chars.length <= 4) add(run.text, chars.length); // 長段(>4 字)多半是「一句話沒有空白」,整段拿去比對必然比不中 ⇒ 只靠雙字組合。 // 問句裡的每一段也補雙字組合(含實詞的那些),這才是「拆得開」的來源。 if (isQuestion || chars.length > 4) for (const bg of contentBigrams(run.text)) add(bg, 2); } return [...found.entries()] .map(([term, weight]) => ({ term, weight })) .sort((a, b) => b.weight - a.weight || a.term.localeCompare(b.term)) .slice(0, MAX_SEARCH_TERMS); } export interface SearchScorePlan { /** SQL 算分表達式(含 ? 佔位符),對應 scoreParams。 */ scoreExpr: string; scoreParams: string[]; terms: SearchTerm[]; /** true = 送出的 SQL 與舊版單一 LIKE 逐字相同(單詞查詢的回歸保證)。 */ legacyShape: boolean; } /** * 產生「覆蓋率分數」的 SQL 表達式(純函式,單測用 export)。 * * 整句相鄰另給一份重賞(=所有詞份量總和),確保**舊行為排最前**: * 含整句的內容分數必然高於只含零散詞的,`local arcrun` 那 5 筆永遠在最上面。 */ export function buildSearchScore(q: string): SearchScorePlan { const trimmed = q.trim(); const terms = tokenizeQuery(trimmed); // 一個詞都拆不出來(例:全是標點/單字虛詞)→ 退回舊版單一 LIKE,行為不變、不會空條件。 if (terms.length === 0) { const m = buildContentLike(trimmed); return { scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(' + '), scoreParams: m.params, terms: [], legacyShape: true, }; } const parts: string[] = []; const params: string[] = []; for (const { term, weight } of terms) { parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${weight} ELSE 0 END`); params.push(likePattern(term)); } // 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。 const single = terms.length === 1 && terms[0].term === trimmed; if (!single && likeBytes(trimmed) <= MAX_LIKE_Q_BYTES) { const bonus = terms.reduce((s, t) => s + t.weight, 0); parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`); params.push(likePattern(trimmed)); } return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single }; } /** 相對門檻:砍掉低於「最高分 × KEYWORD_RELATIVE_CUT」的雜訊尾巴(純函式,單測用 export)。 */ export function applyRelativeCut(rows: T[]): T[] { if (rows.length <= 1) return rows; const cut = rows[0].match_score * KEYWORD_RELATIVE_CUT; return rows.filter((r) => r.match_score >= cut); } // 「庫」filter 的 SQL 謂詞(portal-auth P1,design §3.2/§3.3;零建表,同 #5.1 source 的 json_extract 先例)。 // COALESCE(x,'general') IN (…) ≡ SDD §3.3 寫的 (x IN (…) OR (x IS NULL AND 'general' IN (…)))—— // 語意完全相同(未標記/無 metadata_json 的舊資料歸 'general'),但單組佔位符、不用重複綁參數。 function libraryPredicate(libraries: string[]): string { const placeholders = libraries.map(() => '?').join(','); return `COALESCE(json_extract(metadata_json, '$.library'), 'general') IN (${placeholders})`; } // daemon-beta t24(總管 0.971 親復現、t11 斷點①②)——下架(rag_takedown_direct)只把 // metadata_json.status 標成 'deprecated'(軟刪,append-only,見 KBDB 表不變鐵律),從不刪列。 // 濾層過去只存在 cypher-executor/src/routes/portal-data.ts 的 filterDeprecatedEntries(客端治標, // Arcrun#46),rag_chat 沒部署的實例(如 leo21c)等於完全沒濾——AI 實際會用到的 MCP/raw // /entries/search 面直接把已下架內容當現役回傳(semantic 甚至最高分回傳,見 t11 斷點②)。 // 本謂詞把過濾下沉到 KBDB 服務端(薄殼原則 07:能力只長一次),source-of-truth 修好後 // portal-data.ts 的客端治標理論上可拔(未在本 PR 動,範圍只限 kbdb/)。 // // 用 json_extract 判等(不用 NOT LIKE '%"status":"deprecated"%')——LIKE 對 JSON 序列化格式敏感 // (key 順序、空白、字串轉義都可能讓子字串比對誤判/漏判,例如 metadata_json 裡若有其他欄位的 // 值恰好含這段子字串就會被誤殺),json_extract 是結構化取值,只認真正的 $.status 欄位,同一謂詞 // 家族(source/library)已驗證過這個模式對 SQLite/D1 穩定可靠(issue #5.1、#18 mistake)。 // NULL(沒有 metadata_json 或沒有 status 欄)視為未下架(保留,不誤殺——大多數既有資料沒有 // status 欄)。 const NOT_DEPRECATED_PREDICATE = "(json_extract(metadata_json, '$.status') IS NULL OR json_extract(metadata_json, '$.status') != 'deprecated')"; /** * JS 側判斷單筆 entry 是否已下架(status==='deprecated')。給 semantic 路徑用——Vectorize * hit 的 metadata 沒有存 status(見 embed.ts upsert 的 indexed metadata 只有 * owner_id/entry_type/source/library),要濾必須先 hydrate 回完整 entry 再判斷,故無法像 * keyword 走 SQL 謂詞,只能在拿到 metadata_json 後用同一套判準(status==='deprecated')在 * JS 層濾。metadata_json parse 失敗 → 視為保留(治標不誤殺,與 portal-data.ts * filterDeprecatedEntries 同慣例)。 */ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boolean { if (!entry.metadata_json) return false; try { const meta = JSON.parse(entry.metadata_json) as { status?: unknown } | null; return !!meta && meta.status === 'deprecated'; } catch { return false; } } // D1 LIKE keyword search (base; semantic search is the optional embed module). // entry_type: optional base filter (generic — caller passes any type, base stays type-agnostic). // library: optional 多值庫 filter(portal-auth P1);未帶=行為與舊版一字不變(向後相容)。 // source: metadata_json.$.source filter(issue #66——#5.1 只接了 listEntries 那半,keyword search // 路徑 route 解析完即丟;謂詞與 listEntries 同款 json_extract,不動表)。加在參數尾端, // 既有 positional caller 一個都不用改(向後相容)。 // includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。 // 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。 // 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。 // 2026-08-12(Arcrun#94):q 裡的 `%` `_` `\` 一律當字面字元(escapeLikeLiteral + ESCAPE 宣告) // ——使用者打什麼字就照那些字找。舊病,見上面 LIKE_ESCAPE 那段。 // 2026-08-10:q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。 // 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例; // 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。 export async function searchEntries( db: D1Database, q: string, owner_id?: string, entry_type?: string, limit = 50, library?: string[], source?: string, includeDeprecated = false, ): Promise<(Entry & { match_score: number })[]> { const plan = buildSearchScore(q); // 斷詞+算分;單詞查詢=與舊版逐字相同的單一 LIKE const conds: string[] = []; const params: unknown[] = [...plan.scoreParams]; if (owner_id) { conds.push('owner_id = ?'); params.push(owner_id); } if (entry_type) { conds.push('entry_type = ?'); params.push(entry_type); } if (source) { conds.push("json_extract(metadata_json, '$.source') = ?"); params.push(source); } if (library && library.length > 0) { conds.push(libraryPredicate(library)); params.push(...library); } if (!includeDeprecated) { conds.push(NOT_DEPRECATED_PREDICATE); } // 分數在子查詢算、外層才篩 match_score > 0:SQLite 不保證能在 WHERE 引用 SELECT 別名, // 用子查詢就不必把整組 LIKE 參數再綁一次(參數重複=將來改一邊漏一邊的漂移來源)。 // 其他 filter 留在**內層**,讓 owner/library/deprecated 先篩掉,算分只發生在該算的列上。 const inner = conds.length > 0 ? `WHERE ${conds.join(' AND ')}` : ''; const res = await db .prepare( `SELECT * FROM ( SELECT *, (${plan.scoreExpr}) AS match_score FROM entries ${inner} ) WHERE match_score > 0 ORDER BY match_score DESC, updated_at DESC LIMIT ?`, ) .bind(...params, Math.min(limit, 200)) .all(); // 相對門檻砍雜訊尾巴(「有結果」不等於「把整個庫撈回來」)。單詞查詢分數全等 ⇒ 一筆都不會被砍。 return applyRelativeCut(res.results ?? []); }