// D1 的 LIKE pattern 有 50 bytes 硬上限 —— 長查詢 500 的修復(2026-08-03,t152 途中發現) // // 病徵(在 1.4.4 實例上逐 byte 二分實測,非推論): // `/entries/search?q=…` 只要 q 超過 48 bytes 就回 HTTP 500「Internal Server Error」, // 沒有錯誤訊息。q=48 → 200/q=49 → 500;中文 16 字 → 200/17 字 → 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 上限 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.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 === 'content LIKE ?')).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); } }); });