// 查詢斷詞 + 覆蓋率排序 —— 讓「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 }; }