Files
Arcrun/kbdb/tests/search-long-query.test.ts
T
uncle6me-web 6985bf4850 fix(kbdb): 搜尋框打的 % 和 _ 是字,不是萬用字元(Arcrun#94)
使用者在搜尋框打 `%` 或 `_`,搜出來一堆跟他打的字無關的東西。

病根: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 本身一個字都沒變。

沒有引進第三種搜尋機制,沒有動資料層(三表不變),沒有動斷詞。
2026-08-12 11:59:36 +08:00

109 lines
5.0 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
// D1 的 LIKE pattern 有 50 bytes 硬上限 —— 長查詢 500 的修復(2026-08-03t152 途中發現)
//
// 病徵(在 1.4.4 實例上逐 byte 二分實測,非推論):
// `/entries/search?q=…` 只要 q 超過 48 bytes 就回 HTTP 500「Internal Server Error」,
// 沒有錯誤訊息。q=48 → 200q=49 → 500;中文 16 字 → 20017 字 → 500。
// 判別實驗:q 固定 48 bytes、其他 filter 全塞滿讓 SQL 變很長 → 仍 200
// ⇒ 爆的是 **LIKE 的 pattern**'%'+q+'%' = 50 bytes),不是 statement 長度。
// 同一個長 q 走 mode=semantic 正常(那條不經過 LIKE)。
//
// 為什麼這是產品級的洞:中文問句超過 16 個字是常態,而 rag_chat 的 kw_search 拿整句問題當 q
// ⇒ 使用者問任何一句正常長度的中文,問答鏈在第二個節點就 500 ⇒ 聊天等於不能用。
//
// 本檔守兩件事:① 短查詢行為**逐字不變**(回歸保護)② 長查詢不再產生超長 pattern。
import { describe, it, expect } from 'vitest';
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([LIKE_PRED]);
expect(m.params).toEqual(['%語意檢索%']);
});
it('剛好 48 bytes 仍走單一 LIKE(邊界:pattern 正好 50', () => {
const q = 'a'.repeat(48);
const m = buildContentLike(q);
expect(m.split).toBe(false);
expect(bytes(m.params[0])).toBe(MAX_PATTERN);
});
it('49 bytes 起改走拆詞,且每個 pattern 都在上限內', () => {
const q = 'a'.repeat(49);
const m = buildContentLike(q);
expect(m.split).toBe(true);
for (const p of m.params) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
});
it('有空白的長查詢=按詞拆,每詞一個 LIKE(AND 語意由 caller join', () => {
const m = buildContentLike('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫');
expect(m.split).toBe(true);
expect(m.conds.length).toBeGreaterThan(1);
expect(m.conds.every((c) => c === LIKE_PRED)).toBe(true);
expect(m.params).toContain('%語意檢索%');
expect(m.conds.length).toBeLessThanOrEqual(6); // 詞數上限
});
it('無空白的長中文句:切在 UTF-8 邊界上,不切出壞字', () => {
const q = '為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢';
expect(bytes(q)).toBeGreaterThan(48);
const m = buildContentLike(q);
expect(m.split).toBe(true);
for (const p of m.params) {
expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
expect(p).not.toContain(''); // 替換字元=切壞了
expect(q).toContain(p.slice(1, -1)); // 每片都是原句的真子字串
}
});
it('永遠不會回空條件(空條件會讓 WHERE 塌掉、把全表撈回來)', () => {
for (const q of ['a'.repeat(200), ' '.repeat(60), '。'.repeat(60)]) {
const m = buildContentLike(q);
expect(m.conds.length).toBeGreaterThan(0);
expect(m.params.length).toBe(m.conds.length);
}
});
});
describe('searchEntries 實際送出的 SQL', () => {
function fakeDb() {
const captured: { sql: string; params: unknown[] }[] = [];
const db = {
prepare(sql: string) {
return {
bind(...params: unknown[]) {
captured.push({ sql, params });
return { all: async () => ({ results: [] }) };
},
};
},
} as unknown as D1Database;
return { db, captured };
}
it('短查詢:SQL 裡只有一個 content LIKE(回歸保護)', async () => {
const { db, captured } = fakeDb();
await searchEntries(db, '語意檢索', 'demo');
expect(captured[0].sql.match(/content LIKE \?/g)).toHaveLength(1);
expect(captured[0].params[0]).toBe('%語意檢索%');
});
it('長查詢:拆成多個 content LIKE,且沒有任何 pattern 超過 50 bytes', async () => {
const { db, captured } = fakeDb();
await searchEntries(db, '為什麼 rag_chat 不直接用語意檢索排名來選頁而要用字面重疊', 'demo');
const sql = captured[0].sql;
expect((sql.match(/content LIKE \?/g) ?? []).length).toBeGreaterThan(1);
for (const p of captured[0].params) {
if (typeof p === 'string' && p.startsWith('%')) expect(bytes(p)).toBeLessThanOrEqual(MAX_PATTERN);
}
});
});