Files
Arcrun/kbdb/tests/search-long-query.test.ts
T
Leo 47c6aaea03 feat(t152): workers_ai_chat 種子(auth: binding,免金鑰)+ 修 /init/seed 吃掉 3.12 欄位+ 修 D1 LIKE 長查詢 500
SDD: workflow-discovery 3.12/3.13(不是新規格;3.12 已 confirmed 並實作完成)

## 1) workers_ai_chat 種子(新)
Cloudflare Workers AI 走 env.AI binding ⇒ 用戶不必填任何 API 金鑰就能問答。
放種子表而非產品安裝器:「裝好後預設有哪些 recipe」是平台能力(rule 07 薄殼原則)。
換模型/換供應商=改這一筆 recipe,workflow 不動。

選型實測(1.4.4 實例,真實長度 RAG prompt,每個模型連跑 2 次):
  llama-4-scout-17b        2373/2173 ms   答案最完整、引用正確 ← 選它
  llama-3.3-70b-fp8-fast   3261/2147 ms   可用但波動較大
  mistral-small-3.1-24b    3560/3631 ms
  qwen2.5-coder-32b        3572/3353 ms
  gpt-oss-120b             1971/2295 ms   回應形狀不同,response 取不到文字
  gemma-3-12b-it            5018 帳號無權限
對照舊路徑 Gemini gemma-4-31b-it:同型提問 16.87 s,且吐整段英文思考草稿。

## 2) 修 /init/seed 靜默吃掉 3.12 欄位
3.12 給 RecipeDefinition 加了 body_template/response_map/auth/binding_name,
但 /init/seed 是**列舉欄位重建** recipe record ⇒ 不在名單上的欄位被丟掉。
最惡劣的地方是「哪裡都不會紅」:recipe 查得到、endpoint 對,只有跑起來像沒設定過。
與 08-02 syncManifest 吃掉 manifest.daemon 欄同型(教訓:東西還在不在也要進機械閘)。
加 tests/init-seed-recipe-fields.test.ts:拿掉修復會紅、補回會綠(已實測會擋)。

## 3) 修 D1 LIKE pattern 50 bytes 上限造成的 500
/entries/search?q=… 只要 q 超過 48 bytes 就回 HTTP 500,沒有錯誤訊息。
逐 byte 二分:48→200/49→500;中文 16 字→200/17 字→500。
判別實驗:q 固定 48 bytes、其他 filter 全塞滿讓 SQL 變很長 → 仍 200
⇒ 爆的是 LIKE 的 pattern('%'+q+'%' = 50),不是 statement 長度。
中文問句超過 16 字是常態,而 rag_chat 用整句問題當 q ⇒ 聊天對正常問句等於不能用。
(=InkStoneCo status.md 待辦第 1 條「KBDB keyword 長查詢會炸」的根因。)
修法:q ≤ 48 bytes 走原路(行為逐字不變),超過才拆詞/切 UTF-8 邊界片段。
kbdb 全套 83 測全綠(含新增 8 項)。

## 4) 順手
- 移除被 commit 進 repo 的 node_modules 壞 symlink(指向 leo Mac 的絕對路徑,
  害任何 fresh clone 裝不起來、切分支還會把裝好的蓋掉——本次撞了兩次)。
- pending-changes.md 加 P2 提案(fan-out 並行執行)+等裁決,未動引擎。

驗證:cypher-executor 新增測試 17/17 綠;tsc 與基線逐字相同;
全套測試失敗集合與基線**逐字相同**(基線 14 個失敗,本分支 t173 既有,非本次引入)。
2026-08-03 02:56:53 +00:00

105 lines
4.7 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 上限
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);
}
});
});