From 6985bf48508aa5a61f33075b5beb6927c92c24bf Mon Sep 17 00:00:00 2001 From: uncle6me-web Date: Wed, 12 Aug 2026 11:59:36 +0800 Subject: [PATCH] =?UTF-8?q?fix(kbdb):=20=E6=90=9C=E5=B0=8B=E6=A1=86?= =?UTF-8?q?=E6=89=93=E7=9A=84=20%=20=E5=92=8C=20=5F=20=E6=98=AF=E5=AD=97?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E6=98=AF=E8=90=AC=E7=94=A8=E5=AD=97=E5=85=83?= =?UTF-8?q?=EF=BC=88Arcrun#94=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 使用者在搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字無關的東西。 病根:pattern 一直是 `'%' + 使用者輸入 + '%'` 直接內插,而 SQLite 的 LIKE 有 兩個萬用字元(`%` 任意長度、`_` 任意一字)且**沒有預設跳脫字元**——不寫 ESCAPE 就沒有任何辦法表示「字面上的 %」。所以他打的符號被當成 pattern 語法: `100%` → `%100%%` → 「100」後面接什麼都算 ⇒ 撈回一堆不相干的 `owner_id` → `%owner_id%` → `_` 匹配任一字元 ⇒ ownerXid 也中 單打 `%`/`_` → `%%%`/`%_%` → 整個庫都回來 舊病,不是 08-10 斷詞(#84)引進的:pattern 從來就是這樣拼的。之前關鍵字搜尋 幾乎恆為 0 命中,這個洞被那個洞蓋住;斷詞讓搜尋真的會回東西之後才浮出來。 斷詞那段一個字都沒動。 修法:pattern 產生點全部收斂到兩支 helper—— · escapeLikeLiteral():跳脫 `%` `_` `\` 三個字元 · CONTENT_LIKE 常數:每個 `content LIKE ?` 一律帶 `ESCAPE '\'` 為什麼跳脫字元本身(`\`)也要處理:宣告 ESCAPE 之後 `\` 就變成 pattern 裡有 意義的字元,而且它會把後面那個字吃掉、且不報錯——打 `C:\` 會變成去找 `C:%` (真正的 `C:\` 反而漏掉);只跳脫 %/_ 而漏掉 `\`,打 `C:\_temp` 時 `\\` 先被 讀成字面 `\`、後面的 `_` 變回萬用字元 ⇒ 撈到 `C:\Xtemp`。三個是一組的。 `[` `]` `?` `*` 不需要跳脫(那是 GLOB/別的方言,LIKE 不吃),多跳只會白白吃掉 pattern 的 byte 預算。 連帶:跳脫會變長(`%`→`\%`),所以 50 bytes 上限改用「跳脫後」的長度算 (likeBytes),否則打 48 個 `%` 會產生 98 bytes 的 pattern ⇒ 退回 2026-08-03 修掉的那個 HTTP 500。不含這三個字元的查詢 likeBytes ≡ utf8Len ⇒ 既有查詢逐字不變。 search-long-query.test.ts 兩條「逐字比對謂詞字串」的斷言跟著新字串更新——改的是 比對用的常數、不是放寬檢查(仍逐字相等比對),pattern 本身一個字都沒變。 沒有引進第三種搜尋機制,沒有動資料層(三表不變),沒有動斷詞。 --- kbdb/src/actions/entry-crud.ts | 79 +++++++++++++++++++++++----- kbdb/tests/search-long-query.test.ts | 8 ++- 2 files changed, 72 insertions(+), 15 deletions(-) diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts index febde62..d7bf4d6 100644 --- a/kbdb/src/actions/entry-crud.ts +++ b/kbdb/src/actions/entry-crud.ts @@ -216,12 +216,63 @@ const MAX_LIKE_TERMS = 6; const utf8Len = (s: string): number => new TextEncoder().encode(s).length; -/** 依 UTF-8 byte 上限切片,不切壞多位元組字元。 */ +// ── 使用者打的 `%` 與 `_` 是「要找的字」,不是萬用字元(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 (utf8Len(cur + ch) > maxBytes) { + if (likeBytes(cur + ch) > maxBytes) { if (cur) out.push(cur); cur = ch; } else { @@ -237,8 +288,8 @@ function chunkByBytes(s: string, maxBytes: number): string[] { * 回 `split=false` 代表走的是與舊版逐字相同的單一 LIKE。 */ export function buildContentLike(q: string): { conds: string[]; params: string[]; split: boolean } { - if (utf8Len(q) <= MAX_LIKE_Q_BYTES) { - return { conds: ['content LIKE ?'], params: [`%${q}%`], split: false }; + 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)) { @@ -251,8 +302,8 @@ export function buildContentLike(q: string): { conds: string[]; params: string[] // 理論上不會空(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((t) => `%${t}%`), + conds: terms.map(() => CONTENT_LIKE), + params: terms.map(likePattern), split: true, }; } @@ -428,7 +479,7 @@ export function buildSearchScore(q: string): SearchScorePlan { if (terms.length === 0) { const m = buildContentLike(trimmed); return { - scoreExpr: m.conds.map(() => 'CASE WHEN content LIKE ? THEN 1 ELSE 0 END').join(' + '), + scoreExpr: m.conds.map(() => `CASE WHEN ${CONTENT_LIKE} THEN 1 ELSE 0 END`).join(' + '), scoreParams: m.params, terms: [], legacyShape: true, @@ -438,16 +489,16 @@ export function buildSearchScore(q: string): SearchScorePlan { 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(`%${term}%`); + 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 && utf8Len(trimmed) <= MAX_LIKE_Q_BYTES) { + 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(`%${trimmed}%`); + parts.push(`CASE WHEN ${CONTENT_LIKE} THEN ${bonus} ELSE 0 END`); + params.push(likePattern(trimmed)); } return { scoreExpr: parts.join(' + '), scoreParams: params, terms, legacyShape: single }; @@ -512,7 +563,9 @@ export function isDeprecatedEntry(entry: { metadata_json?: string | null }): boo // includeDeprecated(daemon-beta t24):預設 false=濾掉 status=deprecated 的下架內容。 // 保留 true 選項給管理面查殘留(審計/驗證下架有沒有真的生效)用,正常搜尋路徑不帶。 // 加在參數最尾端,既有 positional caller(source 之後)一個都不用改。 -// 2026-08-10(本次):q 改走 buildSearchScore——**斷詞 + 覆蓋率排序**,取代整串 LIKE。 +// 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( diff --git a/kbdb/tests/search-long-query.test.ts b/kbdb/tests/search-long-query.test.ts index b7eae32..7e461ee 100644 --- a/kbdb/tests/search-long-query.test.ts +++ b/kbdb/tests/search-long-query.test.ts @@ -16,12 +16,16 @@ import { buildContentLike, searchEntries } from '../src/actions/entry-crud'; const bytes = (s: string) => new TextEncoder().encode(s).length; const MAX_PATTERN = 50; // D1 上限 +// 謂詞字串在 Arcrun#94 多了 ESCAPE 宣告(`content LIKE ? ESCAPE '\'`)。這裡跟著改的是 +// **比對用的常數**,不是放寬檢查——底下仍然逐字相等比對,只是比的是現在正確的那個字串。 +// pattern 本身('%語意檢索%')一個字都沒變:那句話裡沒有 % _ \,跳脫後與原文相同。 +const LIKE_PRED = "content LIKE ? ESCAPE '\\'"; describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () => { it('短查詢(≤48 bytes)=與舊版逐字相同的單一 LIKE', () => { const m = buildContentLike('語意檢索'); expect(m.split).toBe(false); - expect(m.conds).toEqual(['content LIKE ?']); + expect(m.conds).toEqual([LIKE_PRED]); expect(m.params).toEqual(['%語意檢索%']); }); @@ -43,7 +47,7 @@ describe('buildContentLike:不得產生超過 D1 上限的 LIKE pattern', () = const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫'); expect(m.split).toBe(true); expect(m.conds.length).toBeGreaterThan(1); - expect(m.conds.every((c) => c === 'content LIKE ?')).toBe(true); + expect(m.conds.every((c) => c === LIKE_PRED)).toBe(true); expect(m.params).toContain('%語意檢索%'); expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限 });