From 584247f1ea9881bdaf4585b1a3ab2eb446b15d63 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 23 May 2026 00:43:31 +0000 Subject: [PATCH 01/10] spec+plan(v0.17.0): korean trigram tokenizer + dogfood fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P10 도그푸딩 round 2 (2026-05-22) follow-up. SQLite FTS5 tokenizer unicode61 → trigram 으로 교체해 한국어 lexical 검색 지원 + 작은 버그픽스 2 (C typedef-wrapped struct 미노출, code_lang_breakdown 집계 단위). Codex + Gemini round 1/2/3 리뷰 반영: - [r1] 2자 한국어 query 0-hit, build_match_string() multi-token 깨짐, contentless → shadow, parser_version cascade, BM25/heading_path/디스크 - [r2] same-workspace_path orphan purge (parser bump cascade 실제 동작), trigram 테스트 예시 sqlite 3.45.1 검증, builder 권장안 (whole phrase OR) - [r3] SMOKE 시나리오 정정, TUI stale hint 방지, search_response.v1 hint 필드, new purge helpers, single quote raw mode 통일, fixture 도입 PR 구성: PR-A (trigram + builder + 안내), PR-B (C typedef + orphan purge), PR-C (stats + wire). 셋 머지 후 v0.17.0 release cut. design: docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md plan: docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-22-korean-trigram-tokenizer.md | 371 ++++++++++++++++++ ...6-05-22-korean-trigram-tokenizer-design.md | 143 +++++++ 2 files changed, 514 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md create mode 100644 docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md diff --git a/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md new file mode 100644 index 0000000..8393497 --- /dev/null +++ b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md @@ -0,0 +1,371 @@ +# 한국어 trigram FTS tokenizer + dogfood 버그픽스 구현 Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** kebab 의 FTS5 tokenizer 를 `unicode61` → `trigram` 으로 교체해 한국어 lexical 검색을 가능하게 하고, 같은 도그푸딩 라운드의 작은 버그 둘(C typedef struct 미노출, code_lang_breakdown 집계 단위)을 함께 닫는다. + +**Architecture:** 3개 독립 변경을 별도 PR(A/B/C)로 진행. PR-A 는 V007 migration 으로 `chunks_fts` shadow 테이블만 재구축(원본 `chunks`·embedding 불변) + `lexical.rs::build_match_string()` trigram 대응 재설계 + CLI/TUI 짧은 query 안내. PR-B 는 C extractor 에 typedef alias unit 방출 추가 + **`parser_version` `code-c-v1`→`code-c-v2` bump + same-workspace_path orphan purge** (Codex round 2 검증으로 추가). PR-C 는 wire additive 필드 + 기존 stats 필드 설명 정정. 셋 머지 후 v0.17.0 release cut. + +**Tech Stack:** Rust 2024, SQLite FTS5, refinery migrations, tree-sitter-c, cargo test. + +**작업 방식:** 코드는 Claude(`executor` agent), 각 PR diff 는 Codex + Gemini 가 리뷰(`/ask codex`·`/ask gemini`), PR 은 gitea-ops. design: `docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md`. + +--- + +## File Structure + +**생성:** +- `migrations/V007__fts_trigram.sql` — chunks_fts 를 trigram tokenizer 로 재구축 + backfill + +**수정:** +- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` — §5.5 verbatim SQL 블록 + contentless 표현 정정 +- `crates/kebab-store-sqlite/tests/fts.rs` — CI diff-check 테스트 + 한국어 trigram (3자 이상) + 2자 query 0-hit 핀 + 영어 substring 테스트 +- `crates/kebab-search/src/lexical.rs` — `build_match_string()` 재설계 (필수) + BM25 snapshot 갱신 +- `crates/kebab-cli/src/main.rs` (또는 search wrapper) — 2자 미만 query + 0 결과 시 안내 메시지 +- `crates/kebab-tui/src/search.rs` — 동일 안내 +- `crates/kebab-parse-code/src/c.rs` — typedef-wrapped struct → synthetic unit + `PARSER_VERSION` bump +- `crates/kebab-app/src/lib.rs` — ingest 경로의 same-workspace_path orphan purge (parser_version mismatch + asset 동일 케이스) +- `crates/kebab-store-sqlite/src/fts.rs` — 모듈 헤더 주석의 "contentless FTS5" 표현 정정 (실제는 일반 FTS5 shadow) +- `crates/kebab-store-sqlite/src/store.rs` — `code_lang_chunk_breakdown()` (JOIN documents) +- `crates/kebab-app/src/schema.rs` — `Stats.code_lang_chunk_breakdown` +- `docs/wire-schema/v1/schema.schema.json` — `code_lang_chunk_breakdown` additive 필드 +- `docs/SMOKE.md` — 한국어 검색 시나리오 추가 +- `README.md`, `HANDOFF.md`, `tasks/HOTFIXES.md`, `tasks/p10/p10-1d-c-cpp-ast-chunker.md` +- `Cargo.toml` — workspace `version` + +(`crates/kebab-cli/src/wire.rs` 는 수정하지 않음 — `wire_schema()` 가 `SchemaV1` 을 serde 로 통째 직렬화하므로 변경 2 의 새 필드가 자동 포함됨.) + +--- + +## PR-A — FTS5 trigram tokenizer + +브랜치: `feat/korean-trigram-tokenizer`. design doc 도 이 PR 에 포함(아직 main 에 commit 안 됨). + +### Task A1: 현재 query builder 동작 파악 + SQLite 버전 확인 + +Codex 리뷰로 현재 `build_match_string()` (lexical.rs:177) 이 trigram 비호환이라는 점은 이미 확정 (whitespace split → `"..."` AND 결합 → 한국어 multi-token 0-hit). 본 task 는 builder 의 정확한 동작 기록과 SQLite 버전 확인이 목적이며, 재설계 자체는 Task A5 (필수). + +**Files:** +- Read: `crates/kebab-search/src/lexical.rs` (`build_match_string()` 본문, MATCH query 빌드 라인 260-290, lexical snapshot 라인 506 부근) + +- [ ] **Step 1: builder 동작 기록** — `build_match_string()` 의 정확한 동작 (raw query 입력 처리, mode 분기, escape, prefix `*` 처리, raw FTS mode 진입 조건 — `lexical.rs:167` 기준 **사용자가 single quote `'...'` 로 감싼 경우 raw FTS**) 을 Task A5 의 노트에 baseline 으로 기록 (재설계 시 회귀 방지). + +- [ ] **Step 2: SQLite 버전 확인** — `sqlite3 --version` 또는 cargo 가 링크하는 `libsqlite3-sys` 번들 버전. trigram 은 SQLite 3.34.0+ 필요 (대부분 충족). `tokenize = 'trigram'` 단독 사용 (case-insensitive 기본). `remove_diacritics` 옵션은 SQLite 3.45.0+ 요구라 호환성 위해 미사용. + +- [ ] **Step 3: lexical snapshot 위치 확인** — `lexical.rs:506` 근처 BM25 snapshot 테스트가 어느 파일·함수인지 (`crates/kebab-search/tests/` 또는 `insta` 스냅샷 디렉토리) 확인. Task A4 Step 5 에서 갱신 대상. + +### Task A2: V007 migration 작성 + +**Files:** +- Create: `migrations/V007__fts_trigram.sql` +- Read: `migrations/V002__fts.sql` (trigger 본문 verbatim 복사용) + +- [ ] **Step 1: V007 작성** — 아래 내용으로 생성. 컬럼 구성은 V002 와 동일, `tokenize` 만 교체. trigger 본문은 V002 와 동일. + +```sql +-- V007__fts_trigram.sql +-- Replace the chunks_fts tokenizer: unicode61 -> trigram. +-- Korean is agglutinative; unicode61 tokenizes whole eojeol (with +-- particles attached) so substring matching fails. trigram indexes +-- 3-character grams, enabling Korean partial matches. See design §5.5 +-- and tasks/HOTFIXES.md (2026-05-22). +-- +-- chunks_fts is a shadow of chunks; this migration rebuilds it in +-- place and backfills from chunks, so no re-ingest is required. + +DROP TRIGGER IF EXISTS chunks_au; +DROP TRIGGER IF EXISTS chunks_ad; +DROP TRIGGER IF EXISTS chunks_ai; +DROP TABLE IF EXISTS chunks_fts; + +CREATE VIRTUAL TABLE chunks_fts USING fts5( + chunk_id UNINDEXED, + doc_id UNINDEXED, + heading_path, + text, + tokenize = 'trigram' +); + +CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, new.text); +END; +CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; +END; +CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN + DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; + INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, new.text); +END; + +INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + SELECT chunk_id, doc_id, heading_path_json, text FROM chunks; +``` + +> Step 1 전에 `migrations/V002__fts.sql` 의 `CREATE VIRTUAL TABLE` 컬럼 목록과 trigger 본문을 실제로 대조해, 위 SQL 이 V002 와 trigger 본문·컬럼명(`heading_path_json` 등)에서 정확히 일치하는지 확인한다. 다르면 V002 를 source 로 맞춘다. + +- [ ] **Step 2: migration 적용 확인** — `cargo test -p kebab-store-sqlite` 를 돌려 refinery 가 V007 을 무오류로 적용하는지 확인한다. Expected: 컴파일 + 기존 store 테스트 통과 (단 A3 의 diff-check 테스트는 아직 실패 — 다음 task). + +### Task A3: design §5.5 verbatim + CI diff-check 갱신 + +**Files:** +- Modify: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` (§5.5, 라인 ~1024-1043) +- Modify: `crates/kebab-store-sqlite/tests/fts.rs` (`fts_v002_matches_design_section_5_5_verbatim`, 라인 ~408-435) + +- [ ] **Step 1: diff-check 테스트 실행(실패 확인)** — `cargo test -p kebab-store-sqlite fts_v002_matches_design` 실행. Expected: FAIL — design §5.5 는 아직 unicode61, V007 은 trigram (또는 테스트가 V002 만 본다면 PASS 인 채로 남아 trigram 을 검증 안 함 — Step 2 에서 V007 로 대상 변경). + +- [ ] **Step 2: design §5.5 갱신** — §5.5 의 verbatim SQL 블록의 `tokenize = 'unicode61 remove_diacritics 2'` 를 `tokenize = 'trigram'` 으로 바꾸고, 블록이 V007 의 `CREATE VIRTUAL TABLE` + 3 trigger 와 정확히 일치하도록 맞춘다. §5.5 본문에 한국어 trigram 채택 사유 한 문장 추가(unicode61 의 한국어 한계, HOTFIXES 2026-05-22 cross-link). + +- [ ] **Step 3: diff-check 테스트를 V007 대상으로 갱신** — `fts.rs` 의 테스트 함수를 `fts_v007_matches_design_section_5_5_verbatim` 으로 rename, 대조 파일을 `V007__fts_trigram.sql` 로, 기대 design 섹션을 갱신된 §5.5 로 바꾼다. whitespace-normalized 비교 로직은 그대로. + +- [ ] **Step 4: 테스트 통과 확인** — `cargo test -p kebab-store-sqlite fts_v007_matches_design` → PASS. + +- [ ] **Step 5: Commit** — `git add migrations/V007__fts_trigram.sql crates/kebab-store-sqlite/tests/fts.rs docs/superpowers/specs/` → `git commit` (feat: trigram tokenizer migration + design §5.5). + +### Task A4: 한국어/영어 trigram 매칭 테스트 + +**Files:** +- Create: `fixtures/search/korean/hash-table.md` (또는 동등) — 도그푸딩 한국어 문서 복사 +- Modify: `crates/kebab-store-sqlite/tests/fts.rs` +- Modify: `crates/kebab-app/tests/search_korean.rs` (회귀 핀 + multi-token assert + fixture 통합) +- Update: lexical BM25 snapshot (A1 Step 3 위치) + +- [ ] **Step 0: 한국어 fixture 도입 (Gemini round 3 medium)** — 도그푸딩에 사용한 `/build/cache/dogfood-p10b/` 한국어 위키 문서 중 대표적인 것 (예: `hash-table.md`) 을 `fixtures/search/korean/` 으로 복사 + git add. 위키 문서가 CC-BY 등 외부 라이선스라면 `fixtures/search/korean/LICENSE` 에 출처·라이선스 표기 같이 commit. 통합 테스트가 이 fixture 를 ingest 해 재현성 확보. + +- [ ] **Step 1: 한국어 trigram 매칭 테스트 (실패 확인)** — fixture chunk text `"해시 충돌은 키와 값을 매핑할 때 발생한다"` (V007 적용 store). Codex sqlite 3.45.1 검증 기준 동작: + - raw `MATCH '충돌은'` (공백 없는 3자 연속 substring) → hit. ✓ + - quoted `MATCH '"해시 충돌"'` (whole phrase) → hit. ✓ + - quoted `MATCH '"시 충"'` (phrase 2 chars + space + 1 char) → hit. ✓ + - raw `MATCH '해시충'` → 0-hit (원문에 "해시충" 3-gram 이 연속으로 없음 — "해시" 공백 "충돌"). + - raw `MATCH '시 충'` (공백 포함 unquoted) → 0-hit (FTS5 가 공백을 토큰 경계로 처리). + 위 5개 assert. Expected: V007 적용 store 에서 PASS. store 테스트가 migration 을 V006 까지만 적용한다면 V007 까지 적용되도록 수정. + +- [ ] **Step 1b: 2자 query 0-hit 핀 (회귀 감지)** — `MATCH '충돌'` (2 Unicode chars) 이 반드시 0 결과를 반환. trigram 구조 변경 감지 회귀 테스트. + +- [ ] **Step 1c: multi-token 한국어 query 테스트** — `crates/kebab-search` 또는 `crates/kebab-app` 통합 레벨. 사용자 query `해시 충돌` 이 `build_match_string()` 을 통해 hit 하는지. Expected: A4 시점 FAIL (현재 builder 가 `"해시" "충돌"` AND 로 trigram 0-hit), Task A5 builder 재설계 후 PASS. + +- [ ] **Step 2: 영어 substring 동작 핀** — 영어 텍스트에 대해 trigram substring 매칭 (예: `tokenizer` 텍스트가 `MATCH 'token'` 에 hit) 을 명시적으로 문서화·고정. + +- [ ] **Step 3: 통과 확인 (부분)** — `cargo test -p kebab-store-sqlite` → Step 1 / 1b / 2 PASS. Step 1c 는 A5 후. + +- [ ] **Step 4: 통합 회귀 확인** — `cargo test -p kebab-app search_korean` (`러스트` 3자라 trigram 으로도 통과). `search_korean.rs` 에 `해시 충돌` multi-token assert 추가 (A5 후 통과). + +- [ ] **Step 5: lexical BM25 snapshot 갱신** — A1 Step 3 에서 식별한 snapshot 파일을 trigram token stream 기준으로 갱신 (`cargo insta accept` 또는 수동). snippet token 단위가 trigram 으로 바뀌므로 word budget 관련 테스트 기대값도 함께 검토. + +- [ ] **Step 6: Commit** — `git commit` (test: korean + english trigram matching + bm25 snapshot). + +### Task A5: lexical.rs query builder 재설계 (필수) + +Codex 검증: 현재 `build_match_string()` (lexical.rs:177) 은 whitespace split 후 각 토큰을 `"..."` 로 감싸 implicit AND 결합. 각 토큰이 2자 이하면 trigram MATCH 가 0-hit → `해시 충돌` 같은 multi-token 한국어 query 가 깨짐. 본 task 는 builder 를 trigram 대응으로 재설계. + +**사용자 결정** (2자 이하 한국어 query 정책): lexical core 는 정상 0-hit (변경 없음), 안내 메시지는 CLI/TUI 레이어가 출력 ("3자 이상 키워드 권장"). + +**A1 baseline 노트:** _(Task A1 Step 1 에서 채움 — 현재 builder 의 raw query 처리, mode 분기, escape, raw FTS 진입 조건)_ + +**Files:** +- Modify: `crates/kebab-search/src/lexical.rs` (`build_match_string()`) +- Modify: `crates/kebab-cli/src/main.rs` 또는 search 결과 처리 wrapper — 안내 메시지 +- Modify: `crates/kebab-tui/src/search.rs` 또는 결과 렌더 — 안내 메시지 + +- [ ] **Step 1: builder 재설계 테스트 작성 (실패 확인)** — `해시 충돌` multi-token 한국어 query + 한영 혼합 query (`Rust 충돌은`) 가 hit 하는 테스트. raw FTS mode 진입 (사용자가 single quote `'...'` 로 감싼 경우, `lexical.rs:167`) 회귀 테스트. Expected: FAIL. + +- [ ] **Step 2: `build_match_string()` 재설계** — Codex round 2 권장안 (검증된 알고리즘): + 1. raw single-quote mode (사용자가 single quote `'...'` 로 감싼 경우, `lexical.rs:167`) 는 기존 유지. + 2. `whole = escape_fts5_phrase(trimmed)` 를 항상 첫 후보로 — 단 `trimmed.chars().count() >= 3` 일 때만. + 3. whitespace 로 분리된 토큰 중 `chars().count() >= 3` 만 escaped token AND 후보 생성. + 4. 후보가 둘 다 있으면 `() OR ()`, 하나만 있으면 그대로. + 5. **후보가 하나도 없으면 `None` 반환 (빈 MATCH 금지 — FTS5 syntax error).** 호출자는 None 시 SQL 실행 자체를 회피하고 빈 결과를 반환. + 이러면 `해시 충돌` (각 토큰 2자, whole 5자) → whole phrase 후보로 hit, `충돌` (whole 2자, token 0개) → None → 0-hit, `Rust 충돌은` (token 2개 모두 ≥3) → AND + whole 모두 후보 → OR hit. escape 는 trigram 도 `"`, `*` 처리 필요 — 기존 로직 보강. + +- [ ] **Step 3: 테스트 통과 확인** — Step 1 신규 + Task A4 Step 1c·4 (`해시 충돌`) PASS. + +- [ ] **Step 4: 안내 메시지 — CLI** — `crates/kebab-cli/src/main.rs` 의 `kebab search` 결과 처리에서, 결과가 비어 있고 **`query.trim().chars().count() < 3`** (trimmed 전체 기준) 일 때 stderr 에 "3자 이상 키워드 권장 (trigram tokenizer 제약)" 한 줄. **"모든 토큰이 3자 미만" 조건은 사용 금지** (Codex round 3 medium) — `해시 충돌` 같은 valid whole-phrase query 에 false trigger 회피. `--json` 모드에서는 stderr 안내 미출력 (wire hint 는 Step 4b 에서 별도 전달). + +- [ ] **Step 4b: wire `search_response.v1` 에 `hint` 필드 추가 (MCP 가시성, Gemini round 3 high)** — `--json` 모드와 MCP 가 사용하는 search response 에도 hint 가 전달돼야 LLM/agent 가 "0 결과 + 3자 미만" 케이스를 이해함. 변경: + - `crates/kebab-app/src/schema.rs` (또는 search 응답 type 정의 위치) 의 `SearchResponse` 에 `hint: Option` additive 필드 추가. + - search 실행 결과가 비어 있고 query trimmed.chars().count() < 3 일 때 `hint = Some("3자 이상 키워드 권장 (trigram tokenizer 제약)")`, 그 외 None. + - `crates/kebab-mcp` 의 `search` tool 결과 직렬화에 hint 포함 (serde 자동이면 OK, 확인). + - `docs/wire-schema/v1/search_response.schema.json` (또는 search 응답 스키마 파일) 에 `hint: { type: ["string", "null"] }` additive 필드 명세. + - CLI 의 Step 4 stderr 안내는 사람 가시성, wire hint 는 agent 가시성 — 둘은 보완적, 같은 조건 사용. + +- [ ] **Step 5: 안내 메시지 — TUI** — Codex round 2/3 권장 구현 (`search.rs`/`app.rs`/`run.rs` 실제 구조 기반): + - `SearchState` (`crates/kebab-tui/src/app.rs:116` 근처) 에 `short_query_hint: Option` 필드 추가. + - **Stale hint 방지 (Codex round 3 high)**: 현재 generation 은 `fire_search` 때만 증가하고 input mutation 때는 증가 안 함 — `poll_worker` 가 worker 결과 수신 시 `last_query == 현재 SearchState.input.content && last_mode == 현재 mode` 일치 시만 hint 를 세팅한다. 불일치 시 (사용자가 새 query 입력 중) hint 세팅 skip — stale worker 결과로 새 input 화면이 덮이지 않게. + - 추가로 input 이 변경되면 (`set_input` 등) `short_query_hint = None` reset. + - hint 세팅 조건: `last_query.trim().chars().count() < 3` (trimmed 전체 기준, Codex round 3 medium 으로 통일 — 토큰 기반 분기 사용 금지) + hits 비어 있음 + raw mode 아님. + - 표시: `dynamic_status` (`crates/kebab-tui/src/run.rs:389` 근처) 또는 Search pane 의 결과 영역 empty render 분기에서 `short_query_hint` 가 Some 일 때 한 줄 표시. + +- [ ] **Step 6: 안내 메시지 테스트** — CLI stderr 캡처 + 미출력 케이스 (`--json`, 3자 이상 query, 결과 ≥ 1) 각각 테스트. TUI 안내 표시 unit 테스트. + +- [ ] **Step 7: 전체 검증** — `cargo test -p kebab-search -p kebab-cli -p kebab-tui` → 신규 + 기존 PASS. + +- [ ] **Step 8: Commit** — `git commit` (feat: trigram-aware query builder + short-query guidance). + +### Task A6: 사용자 문서 동기화 + +**Files:** +- Modify: `README.md`, `HANDOFF.md`, `tasks/HOTFIXES.md`, `docs/SMOKE.md` + +- [ ] **Step 1: README** — 검색/Configuration 절에 한 줄: 한국어 포함 KB 의 `--mode lexical`/`hybrid` 가 trigram 3-gram substring 으로 동작 (3자 이상 query 권장). SQLite 파일 (`kebab.sqlite`) 크기가 trigram 인덱스 비대화로 증가 (도그푸딩 KB 기준 ~2-5배 또는 수백 MB 단위, Gemini round 3 low) 한 줄. + +- [ ] **Step 2: HANDOFF** — "머지 후 발견된 버그/결정" 절의 2026-05-22 한국어 lexical 항목을 "v0.17.0 trigram 으로 해소" 로 갱신. "P10 dogfooding 백로그" 의 한국어 tokenizer 항목 상태 갱신. + +- [ ] **Step 3: HOTFIXES** — 2026-05-22 한국어 lexical 항목의 "Next step (미진행)" 을 v0.17.0 / V007 으로 closure 처리. trigram 채택, 영어 동작 변경, 디스크 용량 증가, `heading_path` JSON 노이즈 후속을 dated 항목으로 기록. + +- [ ] **Step 4: SMOKE.md** — 한국어 검색 시나리오 추가 (Codex round 3 high: hit query 가 자기 단언과 모순되지 않게): + - fixture: A4 Step 0 에서 commit 한 `fixtures/search/korean/hash-table.md` (또는 동등) 를 ingest. + - `kebab search --mode lexical '충돌은'` (원문에 공백 없이 3자 연속 substring) → hit 확인. + - `kebab search '해시 충돌'` (multi-token, builder 가 whole phrase 후보로 hit) → hit 확인. + - `kebab search --mode lexical '충돌'` (2자) → 0-hit + "3자 이상 키워드 권장" stderr 안내 확인. + - `kebab search --mode lexical '충돌' --json` → 결과 hits 빈 배열 + `hint` 필드 (Step 4b) 포함 확인. + - V007 자동 backfill (re-ingest 불필요) + SQLite 파일 크기 증가 안내 (도그푸딩 KB 기준 ~2-5배 또는 수백 MB). + +- [ ] **Step 4b: SKILL.md (Gemini round 3 medium)** — `integrations/claude-code/kebab/SKILL.md` 의 `mcp__kebab__search` 섹션 또는 Don't 섹션에 한 줄 추가: "한국어 lexical 검색 시 3자 이상의 키워드를 사용하는 것이 검색 품질·recall 측면에서 유리. 2자 이하 한국어 query (예: '값', '키', '충돌') 는 trigram tokenizer 구조상 lexical 0-hit — search_response 의 `hint` 필드 확인 권장." + +- [ ] **Step 5: Commit** — `git commit` (docs: trigram tokenizer — README/HANDOFF/HOTFIXES/SMOKE/SKILL). + +### Task A7: PR-A 생성 + 리뷰 루프 + +- [ ] **Step 1: 전체 검증** — `cargo test --workspace --no-fail-fast -j 1` + `cargo clippy --workspace --all-targets -- -D warnings`. 둘 다 통과 확인. +- [ ] **Step 2: PR 생성** — gitea-ops 로 `feat/korean-trigram-tokenizer` → main PR. 본문에 design doc 링크 + V007 자동 backfill(re-ingest 불필요) 명시. +- [ ] **Step 3: 리뷰** — PR diff 를 `/ask codex` + `/ask gemini` 로 리뷰. 두 리뷰 종합 후 반영 — 반영 시 같은 브랜치에 commit, 재검증. +- [ ] **Step 4: 머지** — 리뷰 반영 완료 + CI green 후 머지. + +--- + +## PR-B — C typedef-wrapped struct fix + +브랜치: `feat/c-typedef-struct-unit`. + +### Task B1: typedef extractor fix (TDD) + +**Files:** +- Modify: `crates/kebab-parse-code/src/c.rs` (extractor 라인 ~254-262, `PARSER_VERSION` 라인 34, 테스트 라인 ~492-505) + +- [ ] **Step 1: 기존 테스트 재작성(실패 확인)** — `c_extractor_typedef_struct_falls_into_glue` 를 `c_extractor_typedef_struct_emits_unit` 으로 바꾼다. `typedef struct { int x; int y; } Point;` 입력에서 `Point` 라는 이름의 unit 이 방출되는지 assert. Expected: FAIL (현재는 glue 로 빠짐). + +- [ ] **Step 2: extractor 수정** — top-level `type_definition` 노드 처리: 내부에 anonymous `struct_specifier`/`enum_specifier`/`union_specifier`(name 필드 없음)가 있으면, `type_definition` 의 `declarator`(typedef alias)에서 이름을 추출해 그 이름으로 unit 을 방출한다. named struct 경로는 그대로 둔다. 코드 변경 전 `c.rs` 의 현재 노드 분기(`struct_specifier | enum_specifier | union_specifier` arm)와 tree-sitter-c 의 `type_definition` 자식 구조를 읽고 맞춘다. + +- [ ] **Step 3: 테스트 통과 확인** — `cargo test -p kebab-parse-code c_extractor_typedef` → PASS. + +- [ ] **Step 4: named struct 회귀 확인** — `cargo test -p kebab-parse-code` 전체 → 기존 C extractor 테스트(named struct, glue 등) 모두 PASS. + +- [ ] **Step 5: parser_version bump** — `crates/kebab-parse-code/src/c.rs:34` 의 `PARSER_VERSION = "code-c-v1"` 을 `"code-c-v2"` 로 bump. **chunker (`crates/kebab-chunk/src/code_c_ast_v1.rs` 의 `code-c-ast-v1`) 는 건드리지 않는다** — extractor output 만 바뀌고 chunker 로직 동일. C extractor 스냅샷/통합 테스트가 `parser_version` 문자열을 assert 하면 `code-c-v2` 로 갱신. + +- [ ] **Step 5b: same-workspace_path orphan purge (Codex round 2 critical)** — parser_version bump 만으로 doc_id 가 갱신되지만, **파일 bytes 동일 (asset_id 동일) 케이스에서 기존 ingest 의 `stale_chunk_ids_at` (asset_id 변경 기반) 가 발동하지 않아 옛 doc_id row + 옛 chunk row + Lance vector 가 orphan 으로 남고 `idx_docs_workspace_path` UNIQUE 충돌이 날 수 있다**. 보강: + - **신규 helper 도입 (Codex round 3 medium)**: P7-3 의 `stale_chunk_ids_at` (`store.rs:440`) / `purge_orphan_at_workspace_path` (`store.rs:497`) 는 `asset_id != new_asset_id` 전용이라 parser-only bump 케이스에 no-op. 기존 helper 그대로 호출/확장보다 새 helper 두 개를 `crates/kebab-store-sqlite/src/store.rs` 에 추가: + - `stale_chunk_ids_for_workspace_path_except_doc_id(workspace_path, new_doc_id) -> Vec` — 같은 workspace_path 의 다른 doc_id 가 가진 chunk_ids 수집. + - `purge_document_at_workspace_path_except_doc_id(workspace_path, new_doc_id)` — 같은 workspace_path 의 다른 doc_id row 와 그 chunks 제거. + - `crates/kebab-app/src/lib.rs` 의 code asset ingest 분기 (parser mismatch 판정 직후, `lib.rs:812`/`882` 근처) 에서 위 두 helper 순차 호출: chunk_ids 수집 → `VectorStore::delete_by_chunk_ids` (P7-3 hotfix helper, 이건 chunk_id 기반이라 재사용 가능) → document/chunks row delete → 새 doc_id 로 정상 ingest 계속. + - 테스트: fixture C 파일을 `code-c-v1` 로 한 번 ingest → `PARSER_VERSION` 을 `v2` 로 모의 변경 후 같은 fixture 재 ingest → 옛 doc_id row 사라지고 새 doc_id 만 남음 + Lance vector 도 새 chunk_ids 만 존재 + UNIQUE 충돌 없음 확인. + +- [ ] **Step 5c: 회귀 테스트 — 다른 asset 시 기존 purge 동작 유지** — bytes 가 실제로 바뀐 케이스 (asset_id 변경) 에서 `stale_chunk_ids_at` 가 기존대로 정리하는지 확인 (Step 5b 변경이 기존 경로 안 깨뜨리는지). + +- [ ] **Step 6: 테스트 통과 확인** — `cargo test -p kebab-parse-code` 전체 → PASS. + +- [ ] **Step 7: Commit** — `git commit` (fix: C typedef-wrapped struct emits named unit, parser_version code-c-v2). + +### Task B2: HOTFIXES + spec 갱신, PR-B + +**Files:** +- Modify: `tasks/HOTFIXES.md`, `tasks/p10/p10-1d-c-cpp-ast-chunker.md` + +- [ ] **Step 1: HOTFIXES** — 2026-05-21 "typedef-wrapped struct/enum in C falls into glue" 항목의 Status/Next step 을 v0.17.0 closure 로 갱신. +- [ ] **Step 2: spec Risks** — `p10-1d-c-cpp-ast-chunker.md` 의 Risks/notes 에 typedef alias unit 방출(top-level 한정, nested 익명 struct 는 여전히 glue) 을 한 줄로 갱신. frozen spec 본문은 건드리지 않고 Risks 절만. +- [ ] **Step 3: Commit + PR** — `git commit` (docs) → gitea-ops 로 PR-B 생성. +- [ ] **Step 4: 리뷰 루프** — `/ask codex` + `/ask gemini` 리뷰 → 반영 → 머지. + +--- + +## PR-C — code_lang_chunk_breakdown + +브랜치: `feat/code-lang-chunk-breakdown`. + +### Task C1: store 함수 추가 (TDD) + +**Files:** +- Modify: `crates/kebab-store-sqlite/src/store.rs` (`code_lang_breakdown` 인접, 라인 ~801-825) + +- [ ] **Step 1: 테스트 작성(실패 확인)** — `code_lang_chunk_breakdown()` 이 `chunks` 테이블 기준 언어별 chunk 수를 반환하는지 보는 store 테스트 추가. 한 doc 에 여러 chunk 인 fixture 로 doc 집계와 다른 값이 나옴을 확인. Expected: FAIL (함수 미존재). + +- [ ] **Step 2: 함수 구현** — 기존 `code_lang_breakdown()` 패턴을 그대로 따르되 source 를 `chunks` 로: 언어 식별 컬럼을 `chunks` 에서 끌어온다. `chunks` 에 code_lang 이 직접 없으면 `chunks JOIN documents` 로 `documents` 의 code_lang 을 끌어 `COUNT(chunks)`. Step 2 전에 `chunks` 와 `documents` 스키마에서 code_lang 이 어디에 있는지 확인한다. 반환 타입은 `code_lang_breakdown` 과 동일한 `BTreeMap`. + +- [ ] **Step 3: 테스트 통과 확인** — `cargo test -p kebab-store-sqlite code_lang_chunk` → PASS. + +- [ ] **Step 4: Commit** — `git commit` (feat: code_lang_chunk_breakdown store query). + +### Task C2: wire 필드 추가 (TDD) + +**Files:** +- Modify: `crates/kebab-app/src/schema.rs` (`Stats`, 라인 ~69·170·202-219) +- Modify: `docs/wire-schema/v1/schema.schema.json` (`code_lang_chunk_breakdown` 필드) + +- [ ] **Step 1: stats 테스트 확장 (실패 확인)** — `schema.rs` 의 `stats_includes_code_lang_and_repo_breakdown_fields` 테스트에 `code_lang_chunk_breakdown` 필드 존재·값 검증 추가. fixture 는 한 doc 에 여러 chunks (doc count 와 chunk count 가 다른 값으로 채워지는지 확인). Expected: FAIL (필드 미존재). + +- [ ] **Step 2: Stats 필드 추가** — `Stats` 에 `code_lang_chunk_breakdown: BTreeMap` 추가, stats 빌드 지점에서 Task C1 의 `code_lang_chunk_breakdown()` 호출로 채운다. 기존 `code_lang_breakdown` 필드는 유지 (제거 시 wire breaking). + +- [ ] **Step 3: wire.rs 자동 직렬화 확인** — `crates/kebab-cli/src/wire.rs::wire_schema()` 는 `SchemaV1` 을 serde 로 통째 직렬화하므로 별도 코드 수정 불필요. 신규 필드가 wire JSON 출력에 자동 포함됨을 `cargo test -p kebab-cli wire` 의 기존 schema wrapper 테스트가 확인 (또는 신규 assertion 추가). + +- [ ] **Step 4: 테스트 통과 확인** — `cargo test -p kebab-app schema` + `cargo test -p kebab-cli` → PASS. + +- [ ] **Step 5: wire schema JSON 갱신 (필수) + 기존 필드 설명 정정** — `docs/wire-schema/v1/schema.schema.json` 의 `Stats` 정의에: + - `code_lang_chunk_breakdown` 을 기존 `code_lang_breakdown` 과 동일한 형태 (`{"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}}`) 로 additive 추가. + - Gemini round 2 발견: 기존 `code_lang_breakdown`·`repo_breakdown` 의 description 이 "chunk count" 로 잘못 적혀 있으면 (실제 구현은 doc count) "doc count" 로 정정. 추가 필드 `code_lang_chunk_breakdown` description 은 "chunk count" 로 명시. + CI 가 schema-vs-impl 대조를 한다면 함께 통과 확인. + +- [ ] **Step 6: Commit + PR** — `git commit` (feat: code_lang_chunk_breakdown wire field) → gitea-ops 로 PR-C 생성. + +- [ ] **Step 7: 리뷰 루프** — `/ask codex` + `/ask gemini` 리뷰 → 반영 → 머지. + +--- + +## Release — v0.17.0 + +### Task R1: version bump + release cut + +- [ ] **Step 1: 선행 확인** — PR-A·B·C 셋 다 main 에 머지됐는지 확인. `git pull` 후 `cargo test --workspace --no-fail-fast -j 1` green. +- [ ] **Step 2: version bump** — `Cargo.toml` workspace `version` `0.16.1` → `0.17.0`. `cargo build` 로 `Cargo.lock` 자동 갱신. +- [ ] **Step 3: Commit** — `git commit` (`chore: bump version 0.16.1 → 0.17.0`). +- [ ] **Step 4: release** — gitea-ops 의 `gitea-release v0.17.0`. release notes: 한국어 lexical 검색 trigram 동작, 영어 lexical substring 동작 변경, C typedef symbol 노출, `schema.v1.stats.code_lang_chunk_breakdown` 신규 필드, V007 자동 마이그레이션(re-ingest 불필요). +- [ ] **Step 5: HANDOFF/INDEX** — `HANDOFF.md` 한 줄 요약의 version (`v0.17.0`)·Phase 표 갱신. `tasks/INDEX.md` 의 P10 섹션 하단에 "P10 Dogfooding Feedback" 섹션을 만들어 v0.17.0 작업 (한국어 trigram + C typedef + code_lang_chunk_breakdown) 을 listup (P9 의 fb-01~42 형식 참고, Gemini round 2 권장). + +--- + +## Self-Review (Codex+Gemini 리뷰 반영 후) + +**Spec coverage:** design §3(변경 1)→PR-A Task A1-A7, §4(변경 2)→PR-C, §5(변경 3)→PR-B, §6(PR 구성/release)→Task R1, §8(테스트)→각 task 의 test step + A4 의 2자/multi-token/snippet, §9 Risks→A5(builder 재설계)·A4(영어 동작/heading_path 노이즈)·B1(nested typedef). §10 버전 cascade→B1 Step 5 (parser_version), R1 (workspace version). 누락 없음. + +**Placeholder scan:** Task A5 의 "A1 baseline 노트" 는 의도적 plan-내 동적 슬롯 — A1 Step 1 이 채워 A5 가 참조. 그 외 "TBD/TODO" 없음. V007 SQL 전문 박음. 정확한 코드 (build_match_string 재설계, c.rs typedef 노드 분기, chunks JOIN documents 위치) 는 "해당 파일을 읽어 구현" 으로 명시 — placeholder 가 아닌 실행 지시. + +**Type consistency:** `code_lang_chunk_breakdown` 명칭이 store 함수(C1)·Stats 필드(C2 Step 2)·wire JSON schema(C2 Step 5) 전체 동일. `BTreeMap` 반환 타입이 기존 `code_lang_breakdown` 과 일치. `chunks_fts` 컬럼명이 V007·design §5.5·diff-check 테스트 동일. `parser_version = "code-c-v2"` 문자열이 B1 Step 5·테스트 갱신·design §5·§10 일치. + +**리뷰 반영 변경 (round 1):** +- 변경 1 본체에 `lexical.rs::build_match_string()` 재설계 추가 (A5 필수화). +- 2자 이하 한국어 query 정책 = 0-hit + CLI/TUI 안내 (사용자 결정). +- C typedef cascade 를 chunker_version → **parser_version** 으로 정정 (`code-c-v1` → `code-c-v2`). +- design §3.1 의 "contentless" 표현 정정 (V002 는 일반 FTS5 shadow). +- heading_path JSON 노이즈, 디스크 용량 증가, BM25 snapshot drift 를 Risks 등재. +- 누락 task 추가: SMOKE.md 갱신 (A6 Step 4), `docs/wire-schema/v1/schema.schema.json` 갱신 (C2 Step 5). +- 잘못된 task 제거: `wire.rs` 수정 (serde 자동 직렬화이므로 불필요). + +**리뷰 반영 변경 (round 2):** +- **[Critical]** PR-B 에 same-workspace_path orphan purge step 추가 (B1 Step 5b/5c) — parser_version bump 만으로는 같은-asset 케이스에서 옛 doc_id/chunk/vector 가 orphan, UNIQUE 충돌 위험. design §5 본문에 실제 cascade 동작 명시. +- **[High]** design §2 표 + plan Architecture 의 잔존 "code-c-ast-v2 chunker bump" → "code-c-v2 parser_version bump" 로 정정. +- **[High]** A4 Step 1 의 trigram 테스트 예시를 Codex sqlite 3.45.1 검증 동작으로 정정 — quoted phrase 와 공백 없는 연속 substring 으로 (`'해시충'`/`'시 충'` 는 0-hit 가 맞음). +- **[High]** A5 Step 2 의 builder 알고리즘을 Codex 권장안으로 — whole phrase 후보 + 3자 이상 토큰 AND → OR 결합, 후보 없음 시 `None` 반환 (빈 MATCH 금지). +- **[Medium]** A5 Step 5 의 TUI 안내 구현을 `SearchState.short_query_hint` 필드 + `poll_worker` 세팅 + `dynamic_status` 표시로 구체화. +- **[Low]** File Structure 에 `crates/kebab-store-sqlite/src/fts.rs` (코드 주석의 contentless 정정) 추가. +- **[Low]** C2 Step 5 에 기존 stats 필드 (`code_lang_breakdown`·`repo_breakdown`) description 정정 추가 (실제는 doc count). +- **[Low]** R1 Step 5 의 INDEX.md 갱신 위치를 "P10 Dogfooding Feedback" 섹션으로 구체화. + +**리뷰 반영 변경 (round 3):** +- **[Codex High]** SMOKE.md 시나리오의 hit query 를 `해시충` (원문 미존재) → `충돌은` (3자 연속) + `해시 충돌` (whole phrase) 로 정정. JSON 모드 hint 필드 검증도 시나리오에 포함. +- **[Codex High]** TUI short_query_hint 의 stale 방지 — `poll_worker` 가 `last_query == 현재 input + mode` 일치 시만 hint 세팅, input 변경 시 reset. +- **[Gemini High]** `search_response.v1` 에 `hint: Option` additive 필드 추가 (A5 Step 4b) — `--json`/MCP 가시성 보강. CLI stderr 안내와 보완적. +- **[Codex Medium]** PR-B helper 이름 명시 — `stale_chunk_ids_for_workspace_path_except_doc_id` + `purge_document_at_workspace_path_except_doc_id` 새 helper. P7-3 helper 의 asset_id 조건 우회. +- **[Codex Medium]** raw FTS mode 표기 single quote `'...'` 로 통일 (A1 Step 1, A5 Step 1, A5 Step 2 권장안 1) — 실제 코드 `lexical.rs:167` 기준. +- **[Codex Medium]** short-query CLI 조건을 `query.trim().chars().count() < 3` 으로 고정 — "모든 토큰 < 3" 분기 제거 (valid whole-phrase query false trigger 회피). TUI 도 동일. +- **[Gemini Medium]** A4 Step 0 — `fixtures/search/korean/` 으로 한국어 도그푸딩 fixture 복사·commit, LICENSE 표기. +- **[Gemini Medium]** A6 Step 4b — `integrations/claude-code/kebab/SKILL.md` 에 3자 권장 + hint 필드 안내 한 줄. +- **[Gemini Low]** README 디스크 용량 수치화 (~2-5배 또는 수백 MB 단위). diff --git a/docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md b/docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md new file mode 100644 index 0000000..127dae8 --- /dev/null +++ b/docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md @@ -0,0 +1,143 @@ +--- +title: "v0.17.0 설계 — 한국어 trigram FTS tokenizer + P10 round-2 dogfood 버그픽스" +date: 2026-05-22 +status: draft +contract_sections: ["§5.5", "§9"] +--- + +# v0.17.0 설계 — 한국어 trigram FTS tokenizer + P10 round-2 dogfood 버그픽스 + +## 1. 배경 + +P10 종합 도그푸딩 round 2 (2026-05-22, `tasks/HOTFIXES.md`) 에서 세 가지가 드러났다: + +- 한국어 `kebab search --mode lexical` 이 FTS5 `unicode61` 토크나이저에서 거의 0 hit. unicode61 은 공백·구두점 경계로만 토큰을 끊어, 한국어 어절(조사·어미 포함)이 통째로 한 토큰이 되고 부분 매칭이 안 된다. +- `code_lang_breakdown` 이 chunk 가 아닌 doc 수를 집계 — 코드가 많은 KB 에서 언어별 chunk 분포 granularity 가 떨어진다. +- C `typedef struct {...} Foo;` 의 alias 가 검색 symbol 로 노출되지 않는다. + +이 설계는 셋을 v0.17.0 한 release 사이클에 묶어 처리한다. 본체는 한국어 tokenizer (변경 1), 나머지 둘은 같은 도그푸딩 라운드의 작은 버그픽스 (변경 2·3). + +## 2. 범위 + +| # | 변경 | crate | cascade | +|---|------|-------|---------| +| 1 | FTS5 `unicode61` → `trigram` tokenizer | kebab-store-sqlite, migrations | V007 migration, design §5.5 갱신, release cut | +| 2 | `code_lang_chunk_breakdown` wire 필드 | kebab-store-sqlite, kebab-app, kebab-cli | wire additive (release 트리거 아님) | +| 3 | C typedef-wrapped struct → synthetic unit | kebab-parse-code, kebab-app(ingest), kebab-store-sqlite(purge) | **`parser_version`** bump (`code-c-v1`→`code-c-v2`) + same-workspace_path orphan purge | + +3개는 서로 독립적인 코드 경로다. 각각 별도 PR 로, 한 작업 세션에서 연속 진행하고, 셋 다 머지된 뒤 v0.17.0 release 를 한 번 cut 한다. + +## 3. 변경 1 — FTS5 trigram tokenizer (본체) + +### 3.1 현재 상태 + +`migrations/V002__fts.sql` 의 `chunks_fts` 는 FTS5 가상 테이블 (V002 DDL 에 `content=''` 가 없어 contentless 가 아닌 일반 FTS5 shadow table) 이고 `tokenize = 'unicode61 remove_diacritics 2'` 로 생성된다. `chunks` 테이블의 INSERT/UPDATE/DELETE 가 trigger (`chunks_ai` / `chunks_ad` / `chunks_au`) 로 `chunks_fts` 와 동기화된다. 즉 `chunks` 가 source-of-truth, `chunks_fts` 는 검색용 shadow 다. + +design §5.5 (`docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 라인 1024-1043) 에 동일한 SQL 이 verbatim 으로 박혀 있고, 테스트 `fts_v002_matches_design_section_5_5_verbatim` (`crates/kebab-store-sqlite/tests/fts.rs`) 이 둘을 whitespace-normalized 로 대조하는 CI diff-check 다. + +### 3.2 변경 내용 + +새 마이그레이션 `migrations/V007__fts_trigram.sql`: + +1. `DROP TRIGGER` (`chunks_ai`/`chunks_ad`/`chunks_au`) + `DROP TABLE chunks_fts;` — 가상 테이블과 연결 trigger 를 명시적으로 제거. +2. `CREATE VIRTUAL TABLE chunks_fts USING fts5(..., tokenize = 'trigram');` — 컬럼 구성(`chunk_id`/`doc_id` UNINDEXED, `heading_path`, `text`)은 V002 와 동일, tokenizer 만 교체. +3. `chunks_ai`/`chunks_ad`/`chunks_au` trigger 재생성 — V002 와 동일 본문. +4. `INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) SELECT chunk_id, doc_id, heading_path_json, text FROM chunks;` — 기존 chunk 전부 재색인 (V002 backfill 과 동일 패턴). + +`chunks` 원본·embedding·vector index 는 전혀 건드리지 않는다. 마이그레이션이 FTS shadow 만 재구축하므로 **사용자는 `kebab ingest` 를 다시 돌릴 필요가 없다** — 0.17.0 바이너리가 기존 DB 를 열면 V007 이 자동 적용되며 backfill 까지 끝난다. 비싼 fastembed 재계산이 없다. + +### 3.3 동반 갱신 + +- design §5.5 verbatim 블록을 V007 의 SQL 로 갱신한다. frozen design 변경이므로 release 트리거 중 하나다. design 본문 어디든 "contentless" 표현이 있으면 함께 "shadow / non-contentless" 로 정정. +- CI diff-check 테스트: 함수명에 `v002` 가 박혀 있으므로 `fts_v007_matches_design_section_5_5_verbatim` 으로 갱신하고, 대조 대상을 V007 파일로 바꾼다. +- `crates/kebab-store-sqlite/src/fts.rs` 의 `rebuild_chunks_fts` 는 컬럼 구성이 동일하므로 코드 변경이 불필요하다 (tokenizer 는 테이블 DDL 에만 존재). 동작만 확인. +- `crates/kebab-search/src/lexical.rs:177` 의 `build_match_string()` **재설계가 본 PR 의 본체다**. Codex 리뷰 검증 결과: 현재 builder 는 whitespace split 후 각 토큰을 `"..."` 로 감싸 implicit AND 결합 → trigram 에서 2자 이하 토큰 (예: `해시`, `충돌`) 은 매칭 불가 → `해시 충돌` 같은 multi-token 한국어 query 가 0-hit. trigram 대응 재설계 필요 — 권장: 3자 미만 토큰을 drop 또는 raw 처리, 전체 query 가 3자 이상이면 전체 query phrase 도 OR 후보로 추가. +- **2자 이하 한국어 query 정책 (사용자 결정)**: lexical core 는 정상 0-hit (변경 없음), CLI/TUI 레이어가 결과 0 + query 3자 미만일 때 "3자 이상 키워드 권장 (trigram tokenizer 제약)" 한 줄 안내. `--json` 모드는 wire 무결성 위해 안내 미출력. hybrid 모드는 vector 가 결과를 받쳐 안내가 안 나오는 케이스가 많다. +- `crates/kebab-search/src/lexical.rs:506` 부근의 lexical BM25 snapshot 테스트 갱신 — token stream 이 word → trigram 으로 바뀌어 raw score 분포·`snippet()` token 단위가 달라진다. +- `docs/wire-schema/v1/schema.schema.json` 에 변경 2 의 `code_lang_chunk_breakdown` 추가 (PR-C 에서 처리). +- `docs/SMOKE.md` 에 한국어 검색 시나리오 추가 (PR-A 에서 처리). + +### 3.4 trade-off + +- trigram 은 3자 (Unicode chars) 이상 substring 만 색인한다 (Codex 가 sqlite 3.45.1 로 검증). 3자 미만 query (`값`/`키`/`충돌`) 는 lexical 0-hit — unicode61 에서도 어절 단위 토큰화라 단일 토큰 부분 매칭은 안 됐으므로 단일 토큰 측면은 회귀가 아니다. +- 단 multi-token 한국어 query (`해시 충돌`) 는 §3.3 의 query builder 재설계가 동반돼야 hit 한다. builder 재설계가 본 PR 의 본체. +- 2자 이하 query 0-hit 시 CLI/TUI 가 안내 출력 (§3.3, 사용자 결정). +- 영어 lexical 검색도 substring 매칭으로 바뀐다: recall 상승, 단어 경계 정밀도 하락 가능. lexical-only KB 의 영어 검색 동작이 변경된다 — 의도된 동작 변경, 테스트로 핀. +- **BM25 score 분포 변경**: 알고리즘은 유지되지만 token stream 이 word → overlapping trigram 으로 바뀌어 raw score, term frequency, document length 모두 달라진다. lexical snapshot 갱신 (§3.3). `snippet()` 의 token 도 trigram 기준이라 word budget 의미가 달라진다. hybrid (RRF) 는 rank 기반이라 ranking 자체 영향은 미미, 단 `retrieval.lexical_score` 노출값은 변동. +- **DB 디스크 용량 증가**: trigram 인덱스는 unicode61 대비 통상 2-10배 크다 (chunk 본문 + heading_path 모두 trigram 색인). 기존 KB 가 V007 적용 후 `kebab.sqlite` 파일 크기 증가. release notes 명시. +- **`heading_path_json` JSON 노이즈**: trigram 이 JSON 표기 (`[`, `"`, `,`) 와 그 안의 단어 (예: `app`, `src`) 까지 3-gram 색인 → query 가 우연히 JSON 구문이나 흔한 경로 단어와 겹쳐 false positive 가능. v0.17.0 에서는 컬럼 구성 유지 (column filter / 평문 heading 변환 결정은 도그푸딩 후), Risks 등재. +- `remove_diacritics` 는 trigram tokenizer 에서 SQLite 버전 의존 (3.45.0+). 호환성 위해 `tokenize = 'trigram'` 단독 사용 (case-insensitive 기본). 빌드 환경 SQLite 버전은 plan 단계에서 확인. + +### 3.5 사용자 영향 + +- 옛 binary (≤0.16.x) 는 V007 적용 DB 와 비호환 → v0.17.0 release cut 이 필요하다 (CLAUDE.md release cascade: V00X migration 트리거). +- 한국어 문서 KB 에서 `--mode lexical` / `--mode hybrid` 가 정상 동작한다 (3자 이상 substring). 도그푸딩에서 확인된 "한국어 hybrid 의 lexical 기여가 0" 문제가 해소된다. +- `kebab.sqlite` 파일 크기가 trigram 인덱스 비대화로 증가한다 (V007 자동 backfill 후). release notes 에 안내. +- 2자 이하 query 검색 시 lexical 0-hit + CLI/TUI 안내 메시지 표시 (§3.3). + +## 4. 변경 2 — code_lang_chunk_breakdown + +`crates/kebab-store-sqlite/src/store.rs` 의 기존 `code_lang_breakdown()` (doc 수, `documents` GROUP BY) 는 그대로 두고, `code_lang_chunk_breakdown()` 을 추가한다. `chunks` 테이블에는 `code_lang` 컬럼이 직접 없으므로 `chunks JOIN documents ON chunks.doc_id = documents.doc_id` 로 `documents.metadata_json` 의 `code_lang` 을 끌어와 `COUNT(chunks.chunk_id)` GROUP BY. 반환 타입은 기존과 동일 `BTreeMap`. + +`crates/kebab-app/src/schema.rs` 의 `Stats` 에 `code_lang_chunk_breakdown: BTreeMap` 필드를 추가하고, stats 빌드 지점에서 신규 함수 호출로 채운다. `crates/kebab-cli/src/wire.rs::wire_schema()` 는 `SchemaV1` 을 serde 로 통째 직렬화하므로 **별도 수정 불필요** — 신규 필드가 자동으로 wire 출력에 포함된다. 단 `docs/wire-schema/v1/schema.schema.json` 에 `code_lang_chunk_breakdown` 을 additive 로 추가 (필수). + +기존 `code_lang_breakdown` 필드는 유지 (제거 시 wire breaking). additive 추가 → migration·`schema_version` bump 불필요, release 트리거 아님. + +## 5. 변경 3 — C typedef-wrapped struct fix + +`crates/kebab-parse-code/src/c.rs` 의 extractor 가 top-level `type_definition` 노드를 만나면, 그 내부의 anonymous `struct_specifier`/`enum_specifier`/`union_specifier` 를 탐지해 **typedef alias 이름** (`type_definition` 의 `declarator` 에서 추출) 으로 synthetic unit 을 방출한다. named struct 는 기존 경로를 그대로 유지한다. + +**`parser_version` bump** (`crates/kebab-parse-code/src/c.rs:34` 의 `PARSER_VERSION = "code-c-v1"` → `"code-c-v2"`) 가 본 변경의 cascade 키다 — extractor output 이 바뀌기 때문이다. design §9 cascade: `doc_id` 는 `(workspace_path, asset_id, parser_version)` 기반이라 parser_version bump 만으로 doc_id 가 갱신된다. chunker (`crates/kebab-chunk/src/code_c_ast_v1.rs` 의 `code-c-ast-v1`) 는 **건드리지 않는다** — chunker 로직 동일. + +**Cascade 실제 동작 (Codex round 2 검증)**: parser_version 만 바뀌고 파일 bytes 가 동일하면 `asset_id` 가 같아 기존 ingest 경로의 `stale_chunk_ids_at` (asset_id 변경 기반) 가 발동하지 않는다. 새 doc_id 로 `documents` INSERT 시 `idx_docs_workspace_path` UNIQUE 가 충돌하거나, 옛 doc_id row 와 옛 chunk/vector row 가 orphan 으로 잔존한다. 따라서 본 PR 은 **same-workspace_path orphan purge** 를 동반해야 한다 — ingest 의 parser-mismatch 분기에서 `(workspace_path, 다른 doc_id)` 옛 row 의 chunk_id 를 수집해 `VectorStore::delete_by_chunk_ids` (P7-3 hotfix helper) 호출 + `documents` row 교체. plan B1 에 별도 step. + +현재는 dogfood 단계라 prod KB 가 없다. + +기존 테스트 `c_extractor_typedef_struct_falls_into_glue` 는 동작이 반대로 바뀌므로 `c_extractor_typedef_struct_emits_unit` 으로 재작성한다. HOTFIXES 2026-05-21 항목을 closure 로 갱신하고, spec `tasks/p10/p10-1d-c-cpp-ast-chunker.md` 의 Risks/notes 를 갱신한다. + +## 6. PR 구성 / release + +- **PR-A**: 변경 1 (trigram tokenizer). `feat/*` 브랜치 — 코드 + V007 migration + design §5.5 + task spec 을 한 PR 에 (design 변경과 그것을 참조하는 task spec 은 같은 PR 규칙). +- **PR-B**: 변경 3 (C typedef). `feat/*` 브랜치. +- **PR-C**: 변경 2 (code_lang_chunk_breakdown). `feat/*` 브랜치. +- 셋 머지 후 `chore: bump version 0.16.1 → 0.17.0` 같은 commit 직후 같은 commit 에 `gitea-release v0.17.0`. release notes 는 도그푸딩 영향 surface 위주 — 한국어 lexical 검색 동작, C symbol 노출, `schema.v1.stats` 신규 필드. + +PR-A 가 design 변경을 포함하므로 README/HANDOFF/ARCHITECTURE sync 규칙이 적용된다 — 한국어 검색 동작을 README 검색/Configuration 절에 한 줄, HANDOFF "머지 후 발견된 버그/결정" 절, HOTFIXES round-2 항목 status 갱신. + +## 7. 작업 방식 (team) + +- **코드 작성**: Claude Code — OMC `executor` agent, migration·extractor 같은 복잡 부분은 `model=opus`. +- **리뷰**: Codex + Gemini 가 각 PR 의 diff 를 리뷰한다 (`/ask codex`, `/ask gemini` — OMC ask 라우팅). Claude 가 두 리뷰를 종합해 반영한다. +- **PR 생성·머지**: gitea-ops skill (Gitea REST API). +- 각 PR = 구현 → codex+gemini 리뷰 → 반영 → 머지 루프. + +## 8. 테스트 전략 + +- 변경 1: + - `crates/kebab-store-sqlite/tests/fts.rs`: V007 ↔ design §5.5 diff-check (테스트명 `fts_v007_matches_design_section_5_5_verbatim` 으로 rename). + - 한국어 trigram 매칭 테스트 — **3자 이상 연속 substring 만 hit**. fixture `"해시 충돌은 키와 값을 매핑할 때 발생한다"` 기준 (Codex sqlite 3.45.1 검증): raw `MATCH '충돌은'` hit (공백 없는 3자 연속), `MATCH '"해시 충돌"'` quoted phrase hit, `MATCH '"시 충"'` quoted phrase hit; 반면 raw `MATCH '해시충'`/`MATCH '시 충'` 은 0-hit (전자는 원문에 해당 trigram 없음, 후자는 FTS5 가 raw 입력의 공백을 토큰 경계로 처리). quoted phrase 또는 공백 없는 연속 substring 으로 테스트. + - **2자 query 0-hit 핀 테스트** — `MATCH '충돌'` 같은 2자 query 가 반드시 0 결과 (trigram 구조 회귀 감지). + - **multi-token 한국어 query 테스트** (kebab-search / kebab-app 통합) — 사용자 query `해시 충돌` 이 재설계된 `build_match_string()` 을 거쳐 hit (whole phrase 후보 `"해시 충돌"` 경로). A4 작성 시점 FAIL, A5 후 PASS. + - 영어 substring 동작 핀 (`token` query 가 `tokenizer`/`testbed` 등 hit). + - lexical BM25 snapshot (`crates/kebab-search/src/lexical.rs:506` 근처 또는 `crates/kebab-search/tests/`) 갱신. + - 기존 `crates/kebab-app/tests/search_korean.rs` 회귀 핀 (`러스트` 3자) + `해시 충돌` multi-token assert 추가. + - CLI/TUI 안내 메시지 (3자 미만 query + 0 결과) 테스트 — `kebab-cli` stderr 검증, `kebab-tui` Search pane 단위 테스트. +- 변경 2: `crates/kebab-app/src/schema.rs` stats 테스트에 `code_lang_chunk_breakdown` 필드 검증 (한 doc 다중 chunks fixture 로 doc count 와 다른 값). `docs/wire-schema/v1/schema.schema.json` JSON 검증. +- 변경 3: `c.rs` typedef 테스트 재작성 (`Point` alias 가 unit 방출), `parser_version = "code-c-v2"` 확인, named struct 회귀 없음. +- 전체: `cargo test --workspace --no-fail-fast -j 1`, `cargo clippy --workspace --all-targets -- -D warnings`. + +## 9. Risks / notes + +- `lexical.rs::build_match_string()` 재설계가 본 PR 의 본체 — multi-token 한국어 query, 3자 미만 토큰 정책, lexical snapshot drift. Codex 검증으로 현재 builder 가 trigram 비호환임이 확정됨 (`해시 충돌` 0-hit). 빈 MATCH 는 FTS5 syntax error 이므로 후보 없음 시 `None` 반환 (SQL 미실행). +- PR-B 의 parser_version cascade — 같은 bytes + parser bump 케이스 (orphan vector/document row) 가 ingest 의 기존 asset_id 기반 purge 로 정리 안 됨 (Codex round 2 검증). same-workspace_path 명시 purge 가 PR-B 의 구성 요소. (미래의 모든 parser_version bump 에도 같은 보강이 필요할 수 있는 일반 케이스.) +- `heading_path_json` JSON 노이즈 — v0.17.0 에서는 컬럼 구성 유지, 도그푸딩 후 column filter (lexical query 를 `{text} : ` 한정) 또는 평문 heading 변환 재검토. HOTFIXES 후속 entry 로 등재. +- SQLite 파일 크기 증가 (trigram 인덱스) — release notes 명시. 검색 정확도와 무관. +- 영어 lexical 동작 변경 (substring 매칭) — release notes 명시. +- lexical BM25 raw score 분포 변경 — hybrid (RRF) 는 rank 기반이라 ranking 영향 미미, 단 `retrieval.lexical_score` 노출값 변동. wire schema 는 그대로지만 score 값 비교 기반 외부 도구가 있다면 영향. +- C typedef fix synthetic unit naming: nested typedef (`typedef struct { struct {...} inner; } Outer;`) 의 inner 익명 struct 는 여전히 glue. 1차 범위는 top-level typedef alias 만. spec Risks 명시. + +## 10. contract_sections / 버전 cascade + +- design §5.5 (Chunks + FTS5) — 변경 1 이 갱신 (tokenize 값 + "shadow / non-contentless" 표현). +- design §9 (versioning cascade) — 변경 3 의 **`parser_version` bump** (`code-c-v1` → `code-c-v2`) 가 cascade 사례. doc_id 가 `(workspace_path, asset_id, parser_version)` 기반이라 parser bump 만으로 다음 ingest 가 전체 재처리. chunker_version 은 chunk_id 에만 영향이라 본 fix 에는 불필요. +- 버전: workspace `Cargo.toml` 의 `version` 을 0.16.1 → 0.17.0 (minor bump, pre-1.0 단계 surface 변경 누적). From 14197b5e02a561014b1d51f0f192afaa881ce2af Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 23 May 2026 00:43:31 +0000 Subject: [PATCH 02/10] docs(p10-round-2): HANDOFF + HOTFIXES sync for v0.17.0 follow-up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P10 도그푸딩 round 2 의 follow-up 후보를 HANDOFF "다음 task" / "P10 백로그" 절에 반영. HOTFIXES 의 round 2 항목 (한국어 lexical 한계 + code_lang_breakdown + ranking deferred) 정합. Co-Authored-By: Claude Opus 4.7 (1M context) --- HANDOFF.md | 27 ++++++++++++++++++--------- tasks/HOTFIXES.md | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 9 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index 386bf0f..bd3c3ed 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -4,7 +4,7 @@ ## 한 줄 요약 -P0–P5 + P6 + P7 + P9-1/2/3/4 (Library / Search / Ask / Inspect) 머지 완료. `kebab ingest` 가 markdown / image / PDF / 소스코드 (Rust / Python / TS / JS / Go / Java / Kotlin) / Tier 2 리소스 파일 (yaml/k8s / dockerfile / toml / json / xml / groovy / go-mod) + Tier 3 paragraph fallback (shell / 비-k8s YAML / AST 실패 케이스) 처리. `kebab search` / `kebab ask` 가 매체 가로질러 결과 + page / code citation 반환. `kebab tui` 가 4 패널 (Library + Search + Ask + Inspect) 제공. P10-3 (Tier 3 paragraph fallback) 완료. P10-1D (C + C++) 완료로 Tier 1 chunker family 마무리 — 다음 후보 = P9-5 (desktop tauri) 또는 보류 중인 P8 (audio). +P0–P5 + P6 + P7 + P9-1/2/3/4 (Library / Search / Ask / Inspect) + P10 전체 머지 완료 (현재 **v0.16.1**). `kebab ingest` 가 markdown / image / PDF / 소스코드 (Rust / Python / TS / JS / Go / Java / Kotlin / C / C++) / Tier 2 리소스 파일 (yaml/k8s / dockerfile / toml / json / xml / groovy / go-mod) + Tier 3 paragraph fallback (shell / 비-k8s YAML / AST 실패 케이스) 처리. `kebab search` / `kebab ask` 가 매체 가로질러 결과 + page / code citation 반환. `kebab tui` 가 4 패널 (Library + Search + Ask + Inspect) 제공. P10-1D (C + C++) 완료로 Tier 1 chunker family 마무리 + 직후 도그푸딩 round 2 의 k8s multi-resource chunk_id 충돌을 v0.16.1 핫픽스로 해결 — 구조적으로 남은 component 는 P9-5 (desktop tauri) 하나뿐, P8 (audio) 는 사용자 보류. ## Phase 로드맵 @@ -32,6 +32,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. 머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만: +- **2026-05-22 P10 종합 도그푸딩 round 2 (한국어 lexical 검색 한계)** — `kebab search --mode lexical` 의 한국어 query 가 FTS5 `unicode61` 토크나이저에서 거의 0 hit (어절 단위 토큰화 → 부분 매칭 불가). 기본 hybrid 모드는 `multilingual-e5-small` vector 가 carry 해 한국어 검색 정상 (검증: 한국어 4 query 전부 vector/hybrid 10 hit vs lexical 0~1) — **한국어 문서 KB 는 embedding 활성화 필수**. `trigram` tokenizer 로의 fix 는 V00X migration + 전체 re-index 동반이라 보류. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-22). - **2026-05-20 P10-1B (Rust 1A symbol path 비일관 + expression-level 함수 미방출)** — (a) Rust `code-rust-ast-v1` 은 file-scope nesting 만 (workspace path prefix 없음), 1B 의 Python/TypeScript/JavaScript 는 workspace 경로 → module path prefix 사용 (비일관 수용, retrofit = chunker_version bump + reindex 필요, 사용자 명시 요청까지 보류); (b) TS/JS 의 `const foo = () => {...}` 같은 expression-level 함수는 `` glue 로 처리됨 (declaration-level 단위만 1B 1차 범위). 자세한 내용: `tasks/HOTFIXES.md` (2026-05-20) 두 항목. - **2026-05-19 P10-1A-2 (code_rust_ast_v1.rs + SourceType)** — `AST_CHUNK_MAX_LINES` 상수가 `IngestCodeCfg.ast_chunk_max_lines` 를 읽지 않고 모듈 상수 200 고정 (Chunker trait 이 per-medium config 미노출); `SourceType::Code` variant 부재로 code 파일이 `SourceType::Note` 로 분류됨 — 두 항목 모두 `tasks/HOTFIXES.md` (2026-05-19) 에 기록. - **2026-05-07 fb-26 (progress.rs)** — `Aborted` unconditional writeln (TTY duplicate) + `Completed` TTY no summary fixed; `KEBAB_PROGRESS=plain` env + quiet suppression added @@ -81,13 +82,13 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. ## 다음 task 후보 -- **P9-2 TUI search** — `App.search` slot 채움. Library 의 `/` 가 enable 됨. -- **P9-3 TUI ask** — `App.ask` slot 채움. `?` enable. -- **P9-4 TUI inspect** — `App.inspect` slot 채움. `Enter` enable. -- **P9-5 desktop tauri** — 별도 분기. PDF citation rendering UI 가치 큼. -- **P8 audio brainstorm** — whisper-rs 시스템 dep 받을지 / 외부 transcription endpoint 사용할지 사용자 결정 필요. 사용자 패턴 (책+PDF 위주, audio 의향 없음) 상 후순위. +구조적으로 미완인 component 는 P9-5 하나뿐. 나머지는 도그푸딩 follow-up (아래 "P10 dogfooding 백로그") 또는 사용자 결정 대기. -P9-2/3/4 는 P9-1 의 parallel-safety contract (sub-state slot 패턴) 덕에 병렬 진행 가능 — 같은 `App` 손대지 않음. +- **P9-5 desktop tauri** — 마지막 남은 P9 component. `kebab-desktop` crate + Tauri 앱, 별도 분기. PDF citation rendering UI 가치 큼. 사용자 우선순위 (P9 우선 · 책/PDF 위주) 와 부합. +- **P10 도그푸딩 round 2 follow-up** — 한국어 lexical tokenizer (MEDIUM) / code_lang_breakdown chunk 단위 집계 (LOW) / C typedef-wrapped struct (LOW, 관망). 상세는 아래 "P10 dogfooding 백로그" 절. +- **P8 audio brainstorm** — whisper-rs 시스템 dep 받을지 / 외부 transcription endpoint 사용할지 사용자 결정 필요. 사용자 패턴 (책+PDF 위주, audio 의향 없음) 상 보류. +- **fb-41 multi-hop reasoning** — ⏳ 미구현, XL, eval 인프라 선행 + brainstorm 필요. +- **Rust symbol path retrofit** — Rust `code-rust-ast-v1` symbol 이 file-scope-only (1B+ 는 module prefix). `code-rust-ast-v2` bump + Rust corpus re-ingest 비용 → 사용자 명시 요청까지 보류. HOTFIXES `2026-05-20`. ### P9 dogfooding 백로그 (fb-26 ~ fb-42) — release 분할 @@ -96,11 +97,19 @@ P9-2/3/4 는 P9-1 의 parallel-safety contract (sub-state slot 패턴) 덕에 - **0.3.0 — agent foundation** ✅ cut 2026-05-07: fb-26 (log), fb-27 (introspection/error wire), fb-28 (readonly/quiet). ~~fb-29 (daemon)~~ → 🚫 **deferred** — fb-30 stdio MCP 가 동일 가치를 daemon 복잡도 없이 제공. - **0.4.0 — agent integration (MCP)** ✅ cut: fb-30 (MCP stdio), fb-31 (single-file/stdin ingest). - **0.5.0 — agent surface refinement (additive)** ✅ cut 2026-05-10: fb-32 (stale doc indicator), fb-33 (streaming ask), fb-34 (output budget controls), fb-35 (verbatim fetch), fb-36 (search filter args), fb-37 (trace + stats). 모두 wire schema additive minor. -- **0.6.0 — RAG quality** 🟡 진행: fb-38 (score semantics) ✅ 머지 (2026-05-10), fb-40 (fact-grounded answer / rag-v2 prompt) ✅ 머지 (2026-05-10), fb-39 (retrieval precision tuning, embedding_version cascade) — 미진행 (eval golden set 선행 필요). -- **0.7.0 또는 P+**: fb-41 (multi-hop reasoning, XL), fb-42 (bulk multi-query / rerank, Nice). +- **0.6.0 — RAG quality** ✅ 대부분 머지 (2026-05-10): fb-38 (score semantics) ✅, fb-39 (eval foundation — `precision_at_k_chunk` metric) ✅, fb-39b (embedding upgrade — multilingual-e5-large default) ✅, fb-40 (fact-grounded answer / rag-v2 prompt) ✅. 잔여 = fb-39 의 retrieval precision lever 실제 적용 (eval golden set 확장 선행 필요). +- **0.7.0 또는 P+**: fb-41 (multi-hop reasoning, XL) — ⏳ 미구현 · brainstorm 필요; fb-42 (bulk multi-query) ✅ 머지 (2026-05-10, bulk only — rerank hint 은 deferred). 각 fb spec frontmatter 의 `target_version` 필드가 source of truth. INDEX.md 의 release subheader 도 동일 grouping. +### P10 dogfooding 백로그 (2026-05-22 round 2) + +P10 종합 도그푸딩 round 2 (`/build/cache/dogfood-p10b/`, OSS 8 repo + 한국어 위키 문서 10편) 에서 발견된 follow-up 후보. 자세한 내용 + 우선순위 근거는 `tasks/HOTFIXES.md` (2026-05-22). + +- **한국어 lexical tokenizer (MEDIUM)** — `chunks_fts` 를 FTS5 `trigram` tokenizer 로 교체 → 한국어 3-gram 부분 매칭. V00X migration + 전체 chunk re-index + design §5.5 verbatim 블록 갱신 동반 (breaking schema, release cascade). 기본 hybrid 가 한국어를 cover 하므로 HIGH 아님 — 사용자 결정 대기. +- **code_lang_breakdown chunk 단위 집계 (LOW)** — `schema.v1.stats` 의 언어별 분포를 doc 수 → chunk 수로. 소규모, wire additive 필드. +- **ranking glue chunk 편향 (deferred)** — 자동 heuristic 은 user intent misalignment 위험. 사용자 명시 요청 전까지 surface 변경 0 유지. 1주+ 실사용 후 재 brainstorm. + ## 검증된 운영 동작 (release binary, fastembed enabled) P7-3 머지 직후 25 시나리오 smoke 통과 — markdown + image + PDF 5 자산 워크스페이스에서 doctor / ingest / list / inspect / search (lex/vec/hybrid) / re-ingest / byte-edit re-ingest / corrupt PDF / RAG ask + page citation 모두. 자세한 시나리오 표는 conversation 기록 참조; 워크스페이스에 직접 돌려보는 절차는 [docs/SMOKE.md](docs/SMOKE.md). diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 4155ea4..26e1988 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,41 @@ historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history. +## 2026-05-22 — p10 종합 도그푸딩 (round 2): 한국어 lexical 검색 한계 + code_lang_breakdown + +**Origin**: P10 종합 도그푸딩 round 2 (`/build/cache/dogfood-p10b/`). 다양한 OSS 코드베이스 8 repo (rust / python / go / ts / js / java / c / cpp) + 한국어 위키 기술 문서 10편 (pandoc HTML→gfm 변환). `multilingual-e5-small` embedding 활성화 후 ingest — `scanned=2663 updated=2080 errors=0` (k8s multi-resource chunk_id collision 은 같은 라운드에서 발견·수정 — 아래 2026-05-21 항목). + +### 한국어 lexical 검색이 FTS5 unicode61 토크나이저에서 무용 (vector/hybrid 가 우회) + +**Symptom**: `kebab search --mode lexical` 의 한국어 query 가 거의 0 hit. "충돌" 은 hash-table.md 본문에 37회(21회 단독 어절) 등장하나 lexical 0 hit. 4개 한국어 query 측정 — lexical: `충돌` 0 / `해시 충돌` 0 / `컴파일러 최적화` 0 / `트리 순회 방법` 1. + +**원인**: `chunks_fts` 의 `tokenize = 'unicode61 remove_diacritics 2'` (`migrations/V002__fts.sql:24`, design §5.5 verbatim 블록). unicode61 은 공백·구두점 경계로만 토큰을 끊는다 — 한국어는 어절 전체가 한 토큰이 되고 조사·어미가 붙은 채라 부분 매칭이 안 된다. V002 헤더 주석이 이미 "Korean morphological tokenizer is a P+ note" 로 예고한 사항. + +**검증 (vector/hybrid 우회 확인)**: 동일 4 query 를 `--mode vector` / `--mode hybrid` 로 측정 — 전부 10 hit. `multilingual-e5-small` semantic 검색이 한국어를 정상 처리. 즉 embedding 켠 KB 는 **기본 hybrid 모드에서 한국어 검색이 동작**한다. 단 hybrid 는 RRF(lexical+vector) fusion 이라 한국어 query 는 lexical 기여가 0 → 사실상 vector-only 로 reduced (score 증거: lexical 도 hit 한 `트리 순회 방법` 만 hybrid score 1.000, 나머지 한국어 query 는 0.500). + +**Status**: `--mode lexical` 단독은 한국어 무용. 기본 hybrid 는 vector 가 carry → 한국어 KB 사용 가능. 단 embedding `provider = "none"` 인 lexical-only KB 는 한국어 검색 불가. + +**Workaround**: 한국어 문서 KB 는 embedding 활성화 (`[models.embedding] provider = "fastembed"`) 를 사실상 필수로 둔다. + +**Next step (미진행 — 사용자 결정 대기)**: FTS5 builtin `trigram` tokenizer (`tokenize = 'trigram'`) 로 교체 시 한국어 3-gram 부분 매칭 가능. 비용·제약: +- `chunks_fts` 재생성 = V00X migration + 전체 chunk re-index. design §5.5 verbatim 블록 + CI diff-check 동반 갱신 필요 (breaking schema → release cascade 트리거). +- CJK 형태소 분석기는 SQLite 번들 FTS5 가 미지원 — 외부 tokenizer extension 은 단일 바이너리 정책과 충돌. trigram 이 현실적 선택. +- 우선순위: 기본 hybrid 가 한국어를 cover 하므로 HIGH 아님. lexical 한국어 정확 키워드 매칭 + hybrid 완전 작동 가치만 남음 → MEDIUM. + +### code_lang_breakdown 이 chunk 수가 아닌 doc 수를 집계 + +**Symptom**: `schema.v1.stats.code_lang_breakdown` 이 언어별 *문서* 수를 보고. 코드가 많은 KB 에서 언어별 chunk 분포를 보려 할 때 granularity 가 doc 단위라 덜 유용. + +**Status**: LOW. `code_lang_breakdown` 은 p10-1A-2 가 의도적으로 doc count 로 구현 (`store.rs::code_lang_breakdown` doc 주석 + `COUNT(*) FROM documents GROUP BY code_lang`). design §3.5 의 "언어별 분포" 의도와 엄밀히는 어긋나나 통계 표시 한정 — 검색/ingest 동작 무관. + +**Next step**: chunk 단위 집계를 추가/교체하는 소규모 follow-up. wire schema 영향 시 additive 필드 (`code_lang_chunk_breakdown`) 로 처리 검토. + +### ranking — glue chunk 이 top hit (deferred 유지) + +multi-root 도그푸딩(2026-05-20)에서 관찰한 본문 vs 테스트 / glue chunk ranking 편향이 round 2 에서도 재확인됨. 자동 heuristic 은 user intent misalignment 위험 → 사용자 명시 요청 전까지 surface 변경 0 으로 유지 (project memory `project_ranking_deferred` 결정 그대로). + +Cross-link: `tasks/p10/INDEX.md`, `migrations/V002__fts.sql`, design §5.5 / §3.5. + ## 2026-05-21 — p10-2: k8s multi-resource YAML chunk_id collision **Origin**: P10 종합 도그푸딩 (`/tmp/kebab-p10-dogfood/`, 16 파일). 한 파일에 2+ k8s document (Deployment + Service, `---` 구분) 인 YAML 이 ingest 실패. From 8781c6112b54d85de92c14bf47405a122280ebc0 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 23 May 2026 00:47:24 +0000 Subject: [PATCH 03/10] task(A1): builder baseline + sqlite version + snapshot locations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task A1 step 1-3 완료. plan A5 의 baseline 노트 슬롯 채움. 핵심 발견: - build_match_string() (lexical.rs:177-200): trim → strip_single_quotes raw FTS verbatim / 그 외 whitespace split + escape_fts5_token (\"...\" + inner doubling) + space join (implicit AND). - raw mode = single quote '...' 가 trimmed 전체 감쌈 (lexical.rs:167). - SQLite: rusqlite 0.32 + libsqlite3-sys 0.30.1 bundled (in-tree, SQLite ~3.46.x) → trigram 사용 가능. - Snapshot: tests/lexical.rs::lexical_snapshot_run_1 + tests/hybrid.rs:: hybrid_snapshot_run_1 (KEBAB_UPDATE_SNAPSHOTS=1 로 regenerate). inline normalize_bm25_top_score 는 numerical 무관. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-22-korean-trigram-tokenizer.md | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md index 8393497..f1072df 100644 --- a/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md +++ b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md @@ -48,11 +48,22 @@ Codex 리뷰로 현재 `build_match_string()` (lexical.rs:177) 이 trigram 비 **Files:** - Read: `crates/kebab-search/src/lexical.rs` (`build_match_string()` 본문, MATCH query 빌드 라인 260-290, lexical snapshot 라인 506 부근) -- [ ] **Step 1: builder 동작 기록** — `build_match_string()` 의 정확한 동작 (raw query 입력 처리, mode 분기, escape, prefix `*` 처리, raw FTS mode 진입 조건 — `lexical.rs:167` 기준 **사용자가 single quote `'...'` 로 감싼 경우 raw FTS**) 을 Task A5 의 노트에 baseline 으로 기록 (재설계 시 회귀 방지). +- [x] **Step 1: builder 동작 기록** — `build_match_string()` (lexical.rs:177-200) baseline: + 1. `text.trim()` → trimmed. 빈 → `None` 반환. + 2. `strip_single_quotes(trimmed)` 매치 시 (= `'...'` 전체 감싸기, closing quote 가 trimmed 의 마지막 char) → inner.trim() 빈 아니면 `Some(inner.to_string())` (raw FTS5 verbatim mode). + 3. 그 외 → `trimmed.split_whitespace().map(escape_fts5_token).collect()` → 빈이면 `None`, 아니면 ` ` join (FTS5 default implicit AND). + - `escape_fts5_token` (lexical.rs:218): 토큰을 `"..."` 으로 wrap, inner `"` 은 doubling. + - prefix `*` 별도 처리 없음 — 사용자가 raw mode 로 입력해야. + - raw mode 진입 조건: 사용자가 single quote `'...'` 로 trimmed 전체를 감싼 경우 (`lexical.rs:167` 주석에 명시). + - MATCH 호출: lexical.rs:281 `WHERE chunks_fts MATCH ?` (bound parameter). -- [ ] **Step 2: SQLite 버전 확인** — `sqlite3 --version` 또는 cargo 가 링크하는 `libsqlite3-sys` 번들 버전. trigram 은 SQLite 3.34.0+ 필요 (대부분 충족). `tokenize = 'trigram'` 단독 사용 (case-insensitive 기본). `remove_diacritics` 옵션은 SQLite 3.45.0+ 요구라 호환성 위해 미사용. +- [x] **Step 2: SQLite 버전 확인** — `Cargo.toml`: `rusqlite = { version = "0.32", features = ["bundled"] }` + `Cargo.lock` `libsqlite3-sys = "0.30.1"` (system sqlite 무관, in-tree 빌드). libsqlite3-sys 0.30.1 의 번들 SQLite ~3.46.x — trigram (3.34+) 사용 가능. design 결정대로 `tokenize = 'trigram'` 단독 사용 (case-insensitive 기본). `remove_diacritics` 옵션 미사용. -- [ ] **Step 3: lexical snapshot 위치 확인** — `lexical.rs:506` 근처 BM25 snapshot 테스트가 어느 파일·함수인지 (`crates/kebab-search/tests/` 또는 `insta` 스냅샷 디렉토리) 확인. Task A4 Step 5 에서 갱신 대상. +- [x] **Step 3: lexical snapshot 위치 확인** — Codex round 1 의 "lexical.rs:506" 은 `fn normalize_bm25` (BM25 score → (0,1] mapping) 였음 — numerical transformation 이라 token stream 영향 없음. 진짜 snapshot 은: + - `crates/kebab-search/tests/lexical.rs:1012` `lexical_snapshot_run_1` — fixture 기반, `KEBAB_UPDATE_SNAPSHOTS=1` env 로 regenerate, "baseline snapshot must exist; run with KEBAB_UPDATE_SNAPSHOTS=1 to seed". + - `crates/kebab-search/tests/hybrid.rs:121` `hybrid_snapshot_run_1` — 동일 패턴 (`hybrid_snapshot drift`). 한국어 trigram 영향 받음 (token stream 변경). + - inline `crates/kebab-search/src/lexical.rs:592` `normalize_bm25_top_score_in_unit_interval` — numerical, 영향 없음 (회귀 없음 확인만). + Task A4 Step 5 에서 lexical_snapshot_run_1 + hybrid_snapshot_run_1 둘 다 regenerate. ### Task A2: V007 migration 작성 @@ -161,7 +172,21 @@ Codex 검증: 현재 `build_match_string()` (lexical.rs:177) 은 whitespace spli **사용자 결정** (2자 이하 한국어 query 정책): lexical core 는 정상 0-hit (변경 없음), 안내 메시지는 CLI/TUI 레이어가 출력 ("3자 이상 키워드 권장"). -**A1 baseline 노트:** _(Task A1 Step 1 에서 채움 — 현재 builder 의 raw query 처리, mode 분기, escape, raw FTS 진입 조건)_ +**A1 baseline 노트** (Task A1 Step 1 에서 채움): + +`build_match_string(text: &str) -> Option` (lexical.rs:177-200) baseline: + +1. `text.trim()` → trimmed. 빈 → `None`. +2. `strip_single_quotes(trimmed)` 매치 시 (single quote `'...'` 가 trimmed 전체 감쌈, closing quote 가 마지막 char — `'foo' bar` 는 raw 아님) → inner.trim() 빈 아니면 `Some(inner.to_string())` (raw FTS5 verbatim). +3. 그 외 → `trimmed.split_whitespace().map(escape_fts5_token).collect()` → 빈이면 `None`, 아니면 ` ` join (FTS5 default implicit AND). + +`escape_fts5_token(tok)` (lexical.rs:218): `"..."` wrap + inner `"` doubling. + +재설계 시 회귀 방지 — raw mode (single quote `'...'`) 진입 조건은 그대로 유지. escape_fts5_token 도 그대로 (trigram 도 FTS5 special char escape 필요). 변경은 비-raw 경로의 토큰 합성만. + +SQLite: rusqlite 0.32 + libsqlite3-sys 0.30.1 **bundled** (in-tree). SQLite ~3.46.x → trigram 사용 가능. + +Snapshot: `crates/kebab-search/tests/lexical.rs::lexical_snapshot_run_1` + `crates/kebab-search/tests/hybrid.rs::hybrid_snapshot_run_1` (둘 다 `KEBAB_UPDATE_SNAPSHOTS=1` 로 regenerate). inline `normalize_bm25_top_score_in_unit_interval` 는 numerical 영향 없음. **Files:** - Modify: `crates/kebab-search/src/lexical.rs` (`build_match_string()`) From 8dcedc4b11c351240f16410fb191eddd20ad43f3 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 23 May 2026 00:52:40 +0000 Subject: [PATCH 04/10] =?UTF-8?q?feat(p10-r2):=20V007=20trigram=20migratio?= =?UTF-8?q?n=20+=20design=20=C2=A75.5=20+=20fts=20diff-check?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task A2 + A3 한 묶음. migrations/V007__fts_trigram.sql 신규: - chunks_fts shadow 를 DROP + 재생성 (tokenize = trigram). - chunks_ai/ad/au trigger 재생성 (V002 와 동일). - chunks 에서 backfill INSERT — 사용자 re-ingest 불필요, V007 자동. - V002 는 historical cold-upgrade replay 위해 그대로 유지. design §5.5 갱신: - verbatim block 의 tokenize 만 trigram 으로 교체. - §5.5 본문 상단에 한국어 채택 사유 + trade-off (영어 lexical 변경, BM25 분포, 디스크 ~2-10x, contentless 아님) prose 한 단락 추가. crates/kebab-store-sqlite/tests/fts.rs: - fts_v002_matches_design_section_5_5_verbatim → fts_v007_matches_design_section_5_5_verbatim 으로 rename. - extract_migration_5_5_verbatim_block() 의 include_str! path 를 V007__fts_trigram.sql 로 변경. 주석/assertion msg V007 로. - V002 cold-upgrade test 들 (fts_v002_backfill_*) 은 그대로 유지. 검증: cargo test -p kebab-store-sqlite --test fts → 10/10 PASS (`fts_v007_matches_design_section_5_5_verbatim` 포함). Codex round 1/2 의 design §5.5 contentless 정정·trigram tokenizer 채택 사유 명시 발견 반영. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-store-sqlite/tests/fts.rs | 26 ++++---- .../2026-05-22-korean-trigram-tokenizer.md | 14 ++--- .../2026-04-27-kebab-final-form-design.md | 13 +++- migrations/V007__fts_trigram.sql | 60 +++++++++++++++++++ 4 files changed, 94 insertions(+), 19 deletions(-) create mode 100644 migrations/V007__fts_trigram.sql diff --git a/crates/kebab-store-sqlite/tests/fts.rs b/crates/kebab-store-sqlite/tests/fts.rs index 2d66204..11e1fc9 100644 --- a/crates/kebab-store-sqlite/tests/fts.rs +++ b/crates/kebab-store-sqlite/tests/fts.rs @@ -370,17 +370,19 @@ fn extract_design_5_5_fts_block() -> String { fts_slice[..last_end + "END;".len()].to_string() } -/// Extract the §5.5 verbatim block from the V002 migration, between the -/// `── §5.5 verbatim block ──` anchor markers the file already carries. +/// Extract the §5.5 verbatim block from the V007 migration (replaced V002 +/// 's unicode61 tokenizer with trigram — V002 stays in place for +/// historical cold-upgrade replay but V007 is now the source of truth), +/// between the `── §5.5 verbatim block ──` anchor markers V007 carries. fn extract_migration_5_5_verbatim_block() -> String { - let migration = include_str!("../../../migrations/V002__fts.sql"); + let migration = include_str!("../../../migrations/V007__fts_trigram.sql"); // The opening anchor line ends with `── §5.5 verbatim block ─...`. let open_marker = "§5.5 verbatim block"; let close_marker = "End §5.5 verbatim block"; let open_idx = migration .find(open_marker) - .expect("V002 must carry the `§5.5 verbatim block` opening anchor"); + .expect("V007 must carry the `§5.5 verbatim block` opening anchor"); let after_open_line = open_idx + migration[open_idx..] .find('\n') @@ -389,7 +391,7 @@ fn extract_migration_5_5_verbatim_block() -> String { let close_idx = migration[after_open_line..] .find(close_marker) - .expect("V002 must carry the `End §5.5 verbatim block` closing anchor") + .expect("V007 must carry the `End §5.5 verbatim block` closing anchor") + after_open_line; // Walk back from the close marker to the start of its comment line. let close_line_start = migration[..close_idx] @@ -400,12 +402,14 @@ fn extract_migration_5_5_verbatim_block() -> String { migration[after_open_line..close_line_start].to_string() } -/// CI diff guard: the §5.5 block in `migrations/V002__fts.sql` must -/// match the design doc verbatim (whitespace-normalized). If the -/// design doc moves the section, renames the heading, or edits the -/// SQL, this test fails first. Same for migration drift. +/// CI diff guard: the §5.5 block in `migrations/V007__fts_trigram.sql` +/// must match the design doc verbatim (whitespace-normalized). V007 +/// replaced V002 's unicode61 tokenizer with trigram (2026-05-23). +/// V002 stays in place for historical replay of cold-upgrade paths +/// but is no longer compared against the design doc — V007 is now +/// the source of truth. #[test] -fn fts_v002_matches_design_section_5_5_verbatim() { +fn fts_v007_matches_design_section_5_5_verbatim() { let design = extract_design_5_5_fts_block(); let migration_block = extract_migration_5_5_verbatim_block(); @@ -428,7 +432,7 @@ fn fts_v002_matches_design_section_5_5_verbatim() { let migration_n = normalize_ws(&migration_block); assert_eq!( design_n, migration_n, - "V002__fts.sql §5.5 block must match design doc §5.5 verbatim \ + "V007__fts_trigram.sql §5.5 block must match design doc §5.5 verbatim \ (whitespace-normalized). If you intentionally changed one, \ update the other in the same commit." ); diff --git a/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md index f1072df..55bca25 100644 --- a/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md +++ b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md @@ -71,7 +71,7 @@ Codex 리뷰로 현재 `build_match_string()` (lexical.rs:177) 이 trigram 비 - Create: `migrations/V007__fts_trigram.sql` - Read: `migrations/V002__fts.sql` (trigger 본문 verbatim 복사용) -- [ ] **Step 1: V007 작성** — 아래 내용으로 생성. 컬럼 구성은 V002 와 동일, `tokenize` 만 교체. trigger 본문은 V002 와 동일. +- [x] **Step 1: V007 작성** — 아래 내용으로 생성. 컬럼 구성은 V002 와 동일, `tokenize` 만 교체. trigger 본문은 V002 와 동일. ```sql -- V007__fts_trigram.sql @@ -116,7 +116,7 @@ INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) > Step 1 전에 `migrations/V002__fts.sql` 의 `CREATE VIRTUAL TABLE` 컬럼 목록과 trigger 본문을 실제로 대조해, 위 SQL 이 V002 와 trigger 본문·컬럼명(`heading_path_json` 등)에서 정확히 일치하는지 확인한다. 다르면 V002 를 source 로 맞춘다. -- [ ] **Step 2: migration 적용 확인** — `cargo test -p kebab-store-sqlite` 를 돌려 refinery 가 V007 을 무오류로 적용하는지 확인한다. Expected: 컴파일 + 기존 store 테스트 통과 (단 A3 의 diff-check 테스트는 아직 실패 — 다음 task). +- [x] **Step 2: migration 적용 확인** — `cargo test -p kebab-store-sqlite` 통과 (10/10 fts tests + 모든 store test PASS). V007 backfill 도 정상 동작. ### Task A3: design §5.5 verbatim + CI diff-check 갱신 @@ -124,15 +124,15 @@ INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) - Modify: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` (§5.5, 라인 ~1024-1043) - Modify: `crates/kebab-store-sqlite/tests/fts.rs` (`fts_v002_matches_design_section_5_5_verbatim`, 라인 ~408-435) -- [ ] **Step 1: diff-check 테스트 실행(실패 확인)** — `cargo test -p kebab-store-sqlite fts_v002_matches_design` 실행. Expected: FAIL — design §5.5 는 아직 unicode61, V007 은 trigram (또는 테스트가 V002 만 본다면 PASS 인 채로 남아 trigram 을 검증 안 함 — Step 2 에서 V007 로 대상 변경). +- [x] **Step 1: diff-check 테스트 baseline 확인** — A2 검증에서 `fts_v002_matches_design_section_5_5_verbatim` 는 PASS (V002 vs design 둘 다 unicode61 시점이라 match). V007 추가 자체는 기존 test 안 깨뜨림. -- [ ] **Step 2: design §5.5 갱신** — §5.5 의 verbatim SQL 블록의 `tokenize = 'unicode61 remove_diacritics 2'` 를 `tokenize = 'trigram'` 으로 바꾸고, 블록이 V007 의 `CREATE VIRTUAL TABLE` + 3 trigger 와 정확히 일치하도록 맞춘다. §5.5 본문에 한국어 trigram 채택 사유 한 문장 추가(unicode61 의 한국어 한계, HOTFIXES 2026-05-22 cross-link). +- [x] **Step 2: design §5.5 갱신** — `tokenize = 'unicode61 remove_diacritics 2'` → `'trigram'`. §5.5 본문 위에 한국어 trigram 채택 사유 + trade-off + "contentless 가 아님" 명시 prose 한 단락 추가. -- [ ] **Step 3: diff-check 테스트를 V007 대상으로 갱신** — `fts.rs` 의 테스트 함수를 `fts_v007_matches_design_section_5_5_verbatim` 으로 rename, 대조 파일을 `V007__fts_trigram.sql` 로, 기대 design 섹션을 갱신된 §5.5 로 바꾼다. whitespace-normalized 비교 로직은 그대로. +- [x] **Step 3: diff-check 테스트를 V007 대상으로 갱신** — `extract_migration_5_5_verbatim_block()` 의 `include_str!` path 를 `V007__fts_trigram.sql` 로, 함수명 `fts_v002_matches_design_section_5_5_verbatim` → `fts_v007_matches_design_section_5_5_verbatim`, assertion msg 갱신. -- [ ] **Step 4: 테스트 통과 확인** — `cargo test -p kebab-store-sqlite fts_v007_matches_design` → PASS. +- [x] **Step 4: 테스트 통과 확인** — `cargo test -p kebab-store-sqlite --test fts` → 10/10 PASS (`fts_v007_matches_design_section_5_5_verbatim` 포함). -- [ ] **Step 5: Commit** — `git add migrations/V007__fts_trigram.sql crates/kebab-store-sqlite/tests/fts.rs docs/superpowers/specs/` → `git commit` (feat: trigram tokenizer migration + design §5.5). +- [ ] **Step 5: Commit** — A2 + A3 한 묶음으로 commit. ### Task A4: 한국어/영어 trigram 매칭 테스트 diff --git a/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md b/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md index 3e2c7a9..c0c33f9 100644 --- a/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md +++ b/docs/superpowers/specs/2026-04-27-kebab-final-form-design.md @@ -1004,6 +1004,17 @@ CREATE INDEX idx_blocks_doc_id ON blocks(doc_id); ### 5.5 Chunks + FTS5 +Tokenizer = `trigram` (V007, 2026-05-23). 한국어 어절(조사·어미가 붙은 단위)이 +unicode61 에서 단일 토큰화돼 lexical 부분 매칭이 불가능했던 문제를 해소 +(2자 미만 한국어 query 는 trigram 구조상 여전히 0-hit — 단일 토큰 측면에서는 +회귀 아님, multi-token query 는 `lexical.rs::build_match_string()` 가 whole-phrase +후보 OR 결합으로 매칭). trade-off: 영어 lexical 도 substring 매칭으로 이동 +(recall↑, 단어 경계 정밀도↓), BM25 raw score 분포 변경 (RRF rank 기반 hybrid +는 영향 미미), SQLite 파일 크기 ~2-10× 증가. 자세한 내용 = `tasks/HOTFIXES.md` +(2026-05-22) + `docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md`. +`chunks_fts` 는 일반 FTS5 shadow table 이며 contentless 가 아님 (V002 / V007 +DDL 에 `content=''` 없음). + ```sql CREATE TABLE chunks ( chunk_id TEXT PRIMARY KEY, @@ -1026,7 +1037,7 @@ CREATE VIRTUAL TABLE chunks_fts USING fts5( doc_id UNINDEXED, heading_path, text, - tokenize = 'unicode61 remove_diacritics 2' + tokenize = 'trigram' ); CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN diff --git a/migrations/V007__fts_trigram.sql b/migrations/V007__fts_trigram.sql new file mode 100644 index 0000000..4546b05 --- /dev/null +++ b/migrations/V007__fts_trigram.sql @@ -0,0 +1,60 @@ +-- V007__fts_trigram.sql — Replace chunks_fts tokenizer: unicode61 → trigram. +-- +-- Per design §5.5 (chunks_fts virtual table + chunks_ai/ad/au triggers). +-- The CREATE VIRTUAL TABLE / CREATE TRIGGER block below is reproduced +-- VERBATIM from `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` +-- §5.5; CI diff-checks this against the design doc (test +-- `fts_v007_matches_design_section_5_5_verbatim` in +-- `crates/kebab-store-sqlite/tests/fts.rs`). +-- +-- Tokenizer choice: trigram. Korean is agglutinative — unicode61 tokenizes +-- whole eojeol (조사·어미 attached) so substring matching fails. trigram +-- indexes 3-character grams, enabling Korean partial matches. Trade-offs: +-- DB size grows (~2-10×), English lexical also moves to substring match +-- (recall↑, precision↓), BM25 score distribution shifts. See +-- `tasks/HOTFIXES.md` (2026-05-22) and the v0.17.0 design doc. +-- +-- chunks_fts is a shadow of chunks (NOT contentless — V002 DDL has no +-- `content=''`); this migration drops the old shadow, recreates it with +-- the new tokenizer, recreates the sync triggers (verbatim from V002), +-- and backfills from `chunks`. The `chunks` table and embeddings are +-- untouched, so users do NOT need to re-ingest after upgrading to +-- v0.17.0 — the migration is fully automatic. + +DROP TRIGGER IF EXISTS chunks_au; +DROP TRIGGER IF EXISTS chunks_ad; +DROP TRIGGER IF EXISTS chunks_ai; +DROP TABLE IF EXISTS chunks_fts; + +-- ── §5.5 verbatim block ──────────────────────────────────────────────── + +CREATE VIRTUAL TABLE chunks_fts USING fts5( + chunk_id UNINDEXED, + doc_id UNINDEXED, + heading_path, + text, + tokenize = 'trigram' +); + +CREATE TRIGGER chunks_ai AFTER INSERT ON chunks BEGIN + INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, new.text); +END; +CREATE TRIGGER chunks_ad AFTER DELETE ON chunks BEGIN + DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; +END; +CREATE TRIGGER chunks_au AFTER UPDATE ON chunks BEGIN + DELETE FROM chunks_fts WHERE chunk_id = old.chunk_id; + INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + VALUES (new.chunk_id, new.doc_id, new.heading_path_json, new.text); +END; + +-- ── End §5.5 verbatim block ─────────────────────────────────────────── + +-- One-shot backfill from existing chunks. Mirrors the V002 backfill +-- pattern — direct INSERT into chunks_fts bypasses chunks_ai trigger +-- (trigger fires on chunks INSERT, not chunks_fts INSERT), so no +-- double-insert. Refinery runs V007 exactly once via its bookkeeping +-- table, so this is naturally idempotent across restarts. +INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) + SELECT chunk_id, doc_id, heading_path_json, text FROM chunks; From 753b1ff5e5457e77000ec798b8056efd1474686b Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 23 May 2026 00:54:30 +0000 Subject: [PATCH 05/10] task(A4-step0): synthetic korean fixture for trigram tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 도그푸딩 실 한국어 위키 문서 (hash-table.md, 4512줄 mediawiki HTML, CC-BY-SA) 는 크기·라이선스 부담으로 직접 commit 회피. 대신 도그푸딩 query 들 (해시 충돌·충돌은·시 충·해시충·충돌) 을 모두 cover 하는 합성 fixture 작성. trigram tokenizer 의 정확한 매칭 동작 (3자 substring hit, 2자 0-hit, raw vs quoted phrase) 검증용. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../2026-05-22-korean-trigram-tokenizer.md | 8 +++++- fixtures/search/korean/hash-table.md | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) create mode 100644 fixtures/search/korean/hash-table.md diff --git a/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md index 55bca25..70b5690 100644 --- a/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md +++ b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md @@ -142,7 +142,13 @@ INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) - Modify: `crates/kebab-app/tests/search_korean.rs` (회귀 핀 + multi-token assert + fixture 통합) - Update: lexical BM25 snapshot (A1 Step 3 위치) -- [ ] **Step 0: 한국어 fixture 도입 (Gemini round 3 medium)** — 도그푸딩에 사용한 `/build/cache/dogfood-p10b/` 한국어 위키 문서 중 대표적인 것 (예: `hash-table.md`) 을 `fixtures/search/korean/` 으로 복사 + git add. 위키 문서가 CC-BY 등 외부 라이선스라면 `fixtures/search/korean/LICENSE` 에 출처·라이선스 표기 같이 commit. 통합 테스트가 이 fixture 를 ingest 해 재현성 확보. +- [x] **Step 0: 한국어 fixture 도입 (Gemini round 3 medium)** — 도그푸딩 실 문서 (`/build/cache/dogfood-p10b/workspace/docs/hash-table.md`, 한국어 위키 mediawiki HTML 출력 4512줄, CC-BY-SA) 는 크기·라이선스 부담으로 직접 commit 회피. 대신 도그푸딩 query 들 (`충돌은`/`해시 충돌`/`시 충`/`해시충`/`충돌`) 을 모두 cover 하는 **합성 fixture** `fixtures/search/korean/hash-table.md` 작성 + commit. 검증 query 별 기대 동작: + - raw `MATCH '충돌은'` → hit (`해시 충돌은 발생한다` 가 원문에 있음) + - quoted `MATCH '"해시 충돌"'` → hit (whole phrase) + - quoted `MATCH '"시 충"'` → hit (phrase) + - raw `MATCH '해시충'` → 0-hit (원문에 공백 없는 `해시충` 연속 없음) + - raw `MATCH '충돌'` (2자) → 0-hit (trigram 구조) + 실 위키 문서 fixture 가 필요한 후속 검증은 별도 task 로 deferral. - [ ] **Step 1: 한국어 trigram 매칭 테스트 (실패 확인)** — fixture chunk text `"해시 충돌은 키와 값을 매핑할 때 발생한다"` (V007 적용 store). Codex sqlite 3.45.1 검증 기준 동작: - raw `MATCH '충돌은'` (공백 없는 3자 연속 substring) → hit. ✓ diff --git a/fixtures/search/korean/hash-table.md b/fixtures/search/korean/hash-table.md new file mode 100644 index 0000000..b546fec --- /dev/null +++ b/fixtures/search/korean/hash-table.md @@ -0,0 +1,27 @@ +# 해시 테이블 + +해시 테이블은 키와 값을 매핑하는 자료 구조다. 해시 함수로 키를 인덱스로 +변환해 평균 상수 시간에 조회·삽입·삭제한다. + +## 해시 충돌 + +두 개 이상의 서로 다른 키가 같은 인덱스로 매핑될 때 해시 충돌이 발생한다. +해시 충돌은 잘 설계된 해시 함수에서도 피할 수 없으며, 적재율이 올라갈수록 +충돌 빈도가 증가한다. + +### 해시 충돌 해결법 + +- **체이닝**: 같은 버킷에 연결 리스트로 충돌한 항목들을 묶는다. 구현이 + 단순하고 적재율이 1을 넘어도 동작한다. +- **개방 주소법**: 빈 버킷을 찾아 다음 위치에 저장한다. 선형 탐사, 제곱 + 탐사, 이중 해싱이 있다. + +## 적재율과 재해싱 + +적재율은 저장된 항목 수를 버킷 수로 나눈 값이다. 임계 적재율을 넘으면 +테이블을 키워 재해싱한다 — 모든 항목을 새 테이블에 다시 매핑한다. + +## 응용 + +캐시, 색인, 중복 제거, 데이터베이스 인덱스, 컴파일러의 심볼 테이블 등 +광범위하게 쓰인다. From fe123c0c6dd53ac6d45415b7ec5625ea6b77bd8d Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 23 May 2026 00:57:37 +0000 Subject: [PATCH 06/10] test(A4): korean + english trigram matching at FTS level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3개 신규 unit tests in tests/fts.rs §7: 1. fts_trigram_korean_3char_substring_hits — Codex sqlite 3.45.1 검증 동작 5개 assert pin: raw 3자 substring hit (충돌은/발생한), quoted phrase hit (\"해시 충돌\"/\"시 충\"), raw 해시충 0-hit (원문 미존재). 2. fts_trigram_korean_short_query_zero_hit_pinned — 2자 한국어 query (충돌·키) 0-hit 회귀 감지. trigram 구조 변경 시 먼저 fail. 3. fts_trigram_english_substring_hits — substring recall 동작 변경 pin (token→tokenizer, to 0-hit). 검증: cargo test -p kebab-store-sqlite --test fts → 13/13 PASS (신규 3 + 기존 10). Step 1c (multi-token 한국어 query e.g. \"해시 충돌\") 와 Step 5 (lexical BM25 snapshot 갱신) 는 Task A5 의 build_match_string() 재설계 후 진행. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-store-sqlite/tests/fts.rs | 112 ++++++++++++++++++ .../2026-05-22-korean-trigram-tokenizer.md | 16 +-- 2 files changed, 117 insertions(+), 11 deletions(-) diff --git a/crates/kebab-store-sqlite/tests/fts.rs b/crates/kebab-store-sqlite/tests/fts.rs index 11e1fc9..7f6d346 100644 --- a/crates/kebab-store-sqlite/tests/fts.rs +++ b/crates/kebab-store-sqlite/tests/fts.rs @@ -481,3 +481,115 @@ fn fts_store_drop_releases_wal_files() { .expect("main DB file should be removable after store drop"); } } + +// ── 7. Trigram tokenizer behavior (V007) — Korean + English ────────── + +/// V007 의 trigram tokenizer 가 한국어 3자 이상 연속 substring 을 +/// 매칭하는지. Codex round 1/2 가 sqlite 3.45.1 로 검증한 동작을 pin: +/// - raw query 가 3자 이상 공백 없는 substring 인 경우 hit. +/// - raw query 가 공백을 포함하면 FTS5 가 토큰 경계로 분리 → +/// 양 토큰이 3자 미만이면 0-hit. +/// - quoted phrase ("..." 안에 공백 포함) 는 통째로 substring 매칭. +#[test] +fn fts_trigram_korean_3char_substring_hits() { + let env = common::TestEnv::new(); + let store = SqliteStore::open(&env.config()).unwrap(); + store.run_migrations().unwrap(); + + let conn = raw_conn_no_fk(&env); + insert_chunk( + &conn, + &"k".repeat(32), + &"d".repeat(32), + "[]", + "해시 충돌은 키와 값을 매핑할 때 발생한다", + ); + + // raw 3+ chars 공백 없는 연속 substring → hit. + assert_eq!( + count_match(&conn, "충돌은"), + 1, + "raw 3-char 공백 없는 substring '충돌은' must hit" + ); + assert_eq!( + count_match(&conn, "발생한"), + 1, + "raw 3-char 공백 없는 substring '발생한' must hit" + ); + + // quoted phrase (공백 포함) → substring 매칭으로 hit. + assert_eq!( + count_match(&conn, "\"해시 충돌\""), + 1, + "quoted whole phrase '해시 충돌' (5 chars including space)" + ); + assert_eq!( + count_match(&conn, "\"시 충\""), + 1, + "quoted phrase '시 충' across the space boundary" + ); + + // raw with no whitespace but substring not present in source → 0-hit. + assert_eq!( + count_match(&conn, "해시충"), + 0, + "원문에 공백 없는 '해시충' trigram 이 없으므로 0-hit" + ); +} + +/// V007 trigram 의 핵심 제약: 3 Unicode chars 미만 query 는 색인 단위가 +/// 없어 항상 0-hit. design §3.4 + 사용자 결정 (lexical core 정상 0-hit, +/// CLI/TUI wrapper 가 안내 메시지 출력). 회귀 감지 — trigram 구조 변경 +/// 또는 다른 tokenizer 도입 시 이 test 가 먼저 fail 한다. +#[test] +fn fts_trigram_korean_short_query_zero_hit_pinned() { + let env = common::TestEnv::new(); + let store = SqliteStore::open(&env.config()).unwrap(); + store.run_migrations().unwrap(); + + let conn = raw_conn_no_fk(&env); + insert_chunk( + &conn, + &"k".repeat(32), + &"d".repeat(32), + "[]", + "해시 충돌은 키와 값을 매핑할 때 발생한다", + ); + + // 2자 한국어 query — 도그푸딩에서 보고된 핵심 케이스 ('충돌'/'값'). + assert_eq!(count_match(&conn, "충돌"), 0, "2-char Korean query"); + // 1자 한국어 query. + assert_eq!(count_match(&conn, "키"), 0, "1-char Korean query"); +} + +/// V007 trigram 은 영어에도 substring 매칭으로 동작 — recall ↑, 단어 +/// 경계 정밀도 ↓. design §3.4 의 동작 변경을 명시적으로 핀. +#[test] +fn fts_trigram_english_substring_hits() { + let env = common::TestEnv::new(); + let store = SqliteStore::open(&env.config()).unwrap(); + store.run_migrations().unwrap(); + + let conn = raw_conn_no_fk(&env); + insert_chunk( + &conn, + &"e".repeat(32), + &"d".repeat(32), + "[]", + "the tokenizer normalizes whitespace before matching", + ); + + // trigram substring — 'token' hits inside 'tokenizer'. + assert_eq!( + count_match(&conn, "token"), + 1, + "substring of 'tokenizer' — trigram recall" + ); + assert_eq!( + count_match(&conn, "izer"), + 1, + "substring of 'tokenizer'" + ); + // 3-char-minimum applies to English too. + assert_eq!(count_match(&conn, "to"), 0, "2-char English query"); +} diff --git a/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md index 70b5690..d7bcabf 100644 --- a/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md +++ b/docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md @@ -150,21 +150,15 @@ INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) - raw `MATCH '충돌'` (2자) → 0-hit (trigram 구조) 실 위키 문서 fixture 가 필요한 후속 검증은 별도 task 로 deferral. -- [ ] **Step 1: 한국어 trigram 매칭 테스트 (실패 확인)** — fixture chunk text `"해시 충돌은 키와 값을 매핑할 때 발생한다"` (V007 적용 store). Codex sqlite 3.45.1 검증 기준 동작: - - raw `MATCH '충돌은'` (공백 없는 3자 연속 substring) → hit. ✓ - - quoted `MATCH '"해시 충돌"'` (whole phrase) → hit. ✓ - - quoted `MATCH '"시 충"'` (phrase 2 chars + space + 1 char) → hit. ✓ - - raw `MATCH '해시충'` → 0-hit (원문에 "해시충" 3-gram 이 연속으로 없음 — "해시" 공백 "충돌"). - - raw `MATCH '시 충'` (공백 포함 unquoted) → 0-hit (FTS5 가 공백을 토큰 경계로 처리). - 위 5개 assert. Expected: V007 적용 store 에서 PASS. store 테스트가 migration 을 V006 까지만 적용한다면 V007 까지 적용되도록 수정. +- [x] **Step 1: 한국어 trigram 매칭 테스트** — `fts_trigram_korean_3char_substring_hits` (fts.rs §7). 5개 assert (raw 3자 hit, quoted phrase hit, `해시충` 0-hit) 모두 통과. -- [ ] **Step 1b: 2자 query 0-hit 핀 (회귀 감지)** — `MATCH '충돌'` (2 Unicode chars) 이 반드시 0 결과를 반환. trigram 구조 변경 감지 회귀 테스트. +- [x] **Step 1b: 2자 query 0-hit 핀** — `fts_trigram_korean_short_query_zero_hit_pinned` (`충돌`/`키` 0-hit). -- [ ] **Step 1c: multi-token 한국어 query 테스트** — `crates/kebab-search` 또는 `crates/kebab-app` 통합 레벨. 사용자 query `해시 충돌` 이 `build_match_string()` 을 통해 hit 하는지. Expected: A4 시점 FAIL (현재 builder 가 `"해시" "충돌"` AND 로 trigram 0-hit), Task A5 builder 재설계 후 PASS. +- [ ] **Step 1c: multi-token 한국어 query 테스트** — `crates/kebab-search` 또는 `crates/kebab-app` 통합 레벨. 사용자 query `해시 충돌` 이 `build_match_string()` 을 통해 hit. Expected: A4 시점 FAIL (현재 builder 가 `"해시" "충돌"` AND 로 trigram 0-hit), Task A5 builder 재설계 후 PASS. -- [ ] **Step 2: 영어 substring 동작 핀** — 영어 텍스트에 대해 trigram substring 매칭 (예: `tokenizer` 텍스트가 `MATCH 'token'` 에 hit) 을 명시적으로 문서화·고정. +- [x] **Step 2: 영어 substring 동작 핀** — `fts_trigram_english_substring_hits` (`token`→`tokenizer`, `to` 0-hit). -- [ ] **Step 3: 통과 확인 (부분)** — `cargo test -p kebab-store-sqlite` → Step 1 / 1b / 2 PASS. Step 1c 는 A5 후. +- [x] **Step 3: 통과 확인 (부분)** — `cargo test -p kebab-store-sqlite --test fts` → 13/13 PASS (Step 1/1b/2 + 기존 10). Step 1c 는 A5 후. - [ ] **Step 4: 통합 회귀 확인** — `cargo test -p kebab-app search_korean` (`러스트` 3자라 trigram 으로도 통과). `search_korean.rs` 에 `해시 충돌` multi-token assert 추가 (A5 후 통과). From 6ac7fea7b96a5f7ba6834c9c7e49cb9667fb1e5d Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 24 May 2026 11:54:25 +0000 Subject: [PATCH 07/10] feat(v0.17.0/A5): trigram-aware build_match_string + SearchResponse.hint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR-A 본체. plan Task A4 Step 1c + A5. - lexical.rs::build_match_string 재설계: whole-phrase + token-AND OR-combined, 3자 미만 토큰 drop, 후보 없음 시 None (빈 MATCH 회피). raw single-quote mode 유지. - SearchResponse.hint additive — empty result + trimmed < 3 chars + non-raw 케이스에 short_query_hint helper 가 set. - CLI 'kebab search' 가 [hint] stderr 한 줄 (text mode). - TUI SearchState.short_query_hint + poll_worker stale-aware set + fire_search/mark_input_changed reset + dynamic_status 표시. - docs/wire-schema/v1/search_response.schema.json hint additive. - 신규 unit tests (lexical 9 PASS, 기존 2 expectation 갱신) + 통합 회귀 (search_korean: multi_token + mixed, 3 PASS) + BM25 snapshot regen (trigram token stream). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-app/src/app.rs | 35 +++++ crates/kebab-app/src/bulk.rs | 5 + crates/kebab-app/src/lib.rs | 2 +- crates/kebab-app/tests/search_korean.rs | 85 +++++++++++ crates/kebab-cli/src/main.rs | 9 ++ crates/kebab-cli/src/wire.rs | 11 ++ crates/kebab-search/src/lexical.rs | 136 ++++++++++++++---- .../tests/fixtures/search/lexical/run-1.json | 8 +- crates/kebab-tui/src/app.rs | 7 + crates/kebab-tui/src/run.rs | 14 ++ crates/kebab-tui/src/search.rs | 40 +++++- .../v1/search_response.schema.json | 4 + 12 files changed, 317 insertions(+), 39 deletions(-) diff --git a/crates/kebab-app/src/app.rs b/crates/kebab-app/src/app.rs index dab36b2..0b07e0f 100644 --- a/crates/kebab-app/src/app.rs +++ b/crates/kebab-app/src/app.rs @@ -73,6 +73,37 @@ pub struct SearchResponse { /// p9-fb-37: present when caller passed `SearchOpts.trace = true`. /// Consumers that ignore trace should leave this `None`. pub trace: Option, + /// v0.17.0 A5 Step 4b: human / agent-readable advisory string set + /// when the empty hit list is likely due to a query shorter than the + /// FTS5 trigram tokenizer's 3-char minimum. `None` otherwise. CLI + /// surfaces it on stderr (text mode); MCP / `--json` consumers + /// surface it however they prefer. See + /// `docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md` + /// §3.3. + pub hint: Option, +} + +/// v0.17.0 A5 Step 4b: decide whether to attach a "3자 이상 키워드 권장" +/// hint to a `SearchResponse`. Fires only when the result set is empty +/// *and* the trimmed query is shorter than the trigram tokenizer can +/// resolve. Raw FTS5 mode (`'...'`) opts out — the user explicitly +/// invoked FTS5 syntax. Identical condition powers the CLI stderr line +/// and (separately) the TUI status bar. +pub fn short_query_hint(query_text: &str, hits_empty: bool) -> Option { + if !hits_empty { + return None; + } + let trimmed = query_text.trim(); + let bytes = trimmed.as_bytes(); + // Raw single-quote mode: user opted into FTS5 syntax, no advisory. + if bytes.len() >= 2 && bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'' { + return None; + } + if trimmed.chars().count() < 3 { + Some("3자 이상 키워드 권장 (trigram tokenizer 제약)".to_string()) + } else { + None + } } /// Facade state — see module docs for lifetime rules. @@ -418,11 +449,13 @@ impl App { // Trace path skips the budget loop. Caller will inspect // `hits.len()` and `trace.timing` rather than paginate. + let hint = short_query_hint(&query.text, hits.is_empty()); return Ok(SearchResponse { hits, next_cursor: None, truncated: false, trace: Some(trace), + hint, }); } @@ -505,11 +538,13 @@ impl App { None }; + let hint = short_query_hint(&query.text, hits.is_empty()); Ok(SearchResponse { hits, next_cursor, truncated, trace: None, + hint, }) } diff --git a/crates/kebab-app/src/bulk.rs b/crates/kebab-app/src/bulk.rs index 36be6c4..6ba14bf 100644 --- a/crates/kebab-app/src/bulk.rs +++ b/crates/kebab-app/src/bulk.rs @@ -96,6 +96,11 @@ fn serialize_search_response(r: &SearchResponse) -> Value { None => Value::Null, }; map.insert("trace".to_string(), trace_v); + // v0.17.0 A5 Step 4b: only emit `hint` when set — matches + // the CLI wire wrapper's additive emit pattern. + if let Some(hint) = &r.hint { + map.insert("hint".to_string(), Value::String(hint.clone())); + } } v } diff --git a/crates/kebab-app/src/lib.rs b/crates/kebab-app/src/lib.rs index 37013e3..19b77e5 100644 --- a/crates/kebab-app/src/lib.rs +++ b/crates/kebab-app/src/lib.rs @@ -69,7 +69,7 @@ pub mod reset; pub mod schema; mod staleness; -pub use app::{App, SearchResponse}; +pub use app::{App, SearchResponse, short_query_hint}; pub use ingest_progress::{AggregateCounts, IngestEvent, render_skipped_breakdown}; pub use reset::{ResetReport, ResetScope, enumerate_orphans}; pub use error_wire::{ERROR_V1_ID, ErrorV1, StructuredError, classify}; diff --git a/crates/kebab-app/tests/search_korean.rs b/crates/kebab-app/tests/search_korean.rs index 625b2d5..eaff918 100644 --- a/crates/kebab-app/tests/search_korean.rs +++ b/crates/kebab-app/tests/search_korean.rs @@ -46,3 +46,88 @@ fn korean_lexical_query_returns_korean_document() { hits.iter().map(|h| &h.doc_path.0).collect::>() ); } + +/// A4 Step 1c — multi-token Korean query (`해시 충돌`) must hit when +/// the lexical builder routes it through a whole-phrase MATCH candidate. +/// +/// Expected: FAIL until A5 (`build_match_string` redesign) lands — the +/// current builder emits `"해시" "충돌"` AND, but FTS5 trigram tokenizer +/// has no 2-char terms so each side is 0-hit. A5 introduces a whole- +/// phrase candidate (`"해시 충돌"`) OR'd with the token AND, restoring +/// hits for the dominant Korean usage pattern. +#[test] +fn lexical_multi_token_korean_query_hits() { + let env = TestEnv::lexical_only(); + + // Copy the synthetic Korean fixture (introduced in A4 Step 0) into + // the test workspace. The fixture contains the exact phrase + // "해시 충돌" multiple times. + let dest = env.workspace_root.join("hash-table.md"); + let src = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("..") + .join("..") + .join("fixtures") + .join("search") + .join("korean") + .join("hash-table.md"); + std::fs::copy(&src, &dest).expect("copy korean fixture"); + + kebab_app::ingest_with_config(env.config.clone(), env.scope(), true) + .expect("ingest must succeed"); + + let hits = kebab_app::search_with_config( + env.config.clone(), + common::lexical_query("해시 충돌"), + ) + .expect("search must succeed"); + + assert!( + !hits.is_empty(), + "multi-token Korean query '해시 충돌' must hit the hash-table fixture; got {:?}", + hits.iter().map(|h| &h.doc_path.0).collect::>() + ); + let any_hash_table = hits.iter().any(|h| h.doc_path.0.contains("hash-table")); + assert!( + any_hash_table, + "expected at least one hit on the hash-table fixture, got: {:?}", + hits.iter().map(|h| &h.doc_path.0).collect::>() + ); +} + +/// A4 Step 1c — mixed Korean+English multi-token query (`Rust 충돌은`). +/// Both tokens are ≥3 chars, so the redesigned builder (A5) emits +/// `("Rust 충돌은") OR ("Rust" AND "충돌은")`. With trigram tokenizer +/// each side has substring coverage in the document, so the AND branch +/// alone is enough. Expected: FAIL pre-A5, PASS post-A5. +#[test] +fn lexical_mixed_korean_english_multi_token_query_hits() { + let env = TestEnv::lexical_only(); + let doc_path = env.workspace_root.join("rust-hash.md"); + std::fs::write( + &doc_path, + "# Rust 해시 테이블\n\nRust 의 std::collections::HashMap 에서 \ + 해시 충돌은 SipHash 로 완화한다.\n", + ) + .expect("write rust-hash fixture"); + + kebab_app::ingest_with_config(env.config.clone(), env.scope(), true) + .expect("ingest must succeed"); + + let hits = kebab_app::search_with_config( + env.config.clone(), + common::lexical_query("Rust 충돌은"), + ) + .expect("search must succeed"); + + assert!( + !hits.is_empty(), + "mixed Korean+English multi-token query 'Rust 충돌은' must hit the rust-hash fixture; got {:?}", + hits.iter().map(|h| &h.doc_path.0).collect::>() + ); + let any_rust_hash = hits.iter().any(|h| h.doc_path.0.contains("rust-hash")); + assert!( + any_rust_hash, + "expected at least one hit on the rust-hash fixture, got: {:?}", + hits.iter().map(|h| &h.doc_path.0).collect::>() + ); +} diff --git a/crates/kebab-cli/src/main.rs b/crates/kebab-cli/src/main.rs index fd32cc8..2c2db0a 100644 --- a/crates/kebab-cli/src/main.rs +++ b/crates/kebab-cli/src/main.rs @@ -933,6 +933,15 @@ fn run(cli: &Cli) -> anyhow::Result<()> { let next = resp.next_cursor.as_deref().unwrap_or("(none)"); eprintln!("[truncated; use --cursor {next} for the next page]"); } + // v0.17.0 A5 Step 4: short-query advisory. `resp.hint` + // is `Some` only when the result list is empty and the + // trimmed query is shorter than the trigram tokenizer + // can resolve (raw FTS5 mode opts out). stderr so it + // doesn't pollute the stdout hit list. `--json` skips + // this branch entirely; the field rides the wire. + if let Some(hint) = &resp.hint { + eprintln!("[hint] {hint}"); + } if *trace { if let Some(t) = &resp.trace { eprintln!(); diff --git a/crates/kebab-cli/src/wire.rs b/crates/kebab-cli/src/wire.rs index 01951c9..cf5293f 100644 --- a/crates/kebab-cli/src/wire.rs +++ b/crates/kebab-cli/src/wire.rs @@ -92,6 +92,14 @@ pub fn wire_search_response(r: &kebab_app::SearchResponse) -> Value { map.insert("trace".to_string(), trace_v); } } + // v0.17.0 A5 Step 4b: emit `hint` only when set. Keeps responses + // that don't carry a hint backward-compatible with v0 consumers + // that don't know the field. + if let Some(hint) = &r.hint { + if let Value::Object(ref mut map) = v { + map.insert("hint".to_string(), Value::String(hint.clone())); + } + } tag_object(v, "search_response.v1") } @@ -292,6 +300,7 @@ mod tests { next_cursor: Some("opaque-cursor-abc".to_string()), truncated: true, trace: None, + hint: None, }; let v = wire_search_response(&r); assert_eq!(schema_of(&v), Some("search_response.v1")); @@ -405,6 +414,7 @@ mod tests { }], timing: TraceTiming { lexical_ms: 5, vector_ms: 0, fusion_ms: 1, total_ms: 7 }, }), + hint: None, }; let v = wire_search_response(&r); assert_eq!(schema_of(&v), Some("search_response.v1")); @@ -420,6 +430,7 @@ mod tests { next_cursor: None, truncated: false, trace: None, + hint: None, }; let v = wire_search_response(&r); assert!(v.get("trace").is_none(), "trace field absent when None"); diff --git a/crates/kebab-search/src/lexical.rs b/crates/kebab-search/src/lexical.rs index 67c21d5..f5c9080 100644 --- a/crates/kebab-search/src/lexical.rs +++ b/crates/kebab-search/src/lexical.rs @@ -162,18 +162,35 @@ impl Retriever for LexicalRetriever { /// Translate a user-typed query into an FTS5 match string. /// -/// Rules (from the task spec): +/// v0.17.0 — trigram-aware redesign (see design §5.5 + plan +/// `docs/superpowers/plans/2026-05-22-korean-trigram-tokenizer.md` +/// Task A5). The FTS5 tokenizer is `trigram` so any term shorter than +/// three Unicode chars has no index entry and would zero out an AND +/// branch. Korean compounds typically split into 2-char eojeols (e.g. +/// `해시 충돌`), so a naive token AND drops the dominant usage pattern. /// -/// - The query is wrapped in a single pair of `'...'` → strip the quotes -/// and pass the inner text through verbatim. The user has explicitly -/// opted into FTS5 syntax (e.g. `'rust AND cargo'`, `'foo*'`). +/// Rules: /// -/// - Otherwise: split on whitespace, escape every token by wrapping it -/// in `"..."` (FTS5 string literal), with any inner `"` doubled. Join -/// with spaces — FTS5 default operator is implicit AND. +/// - Raw mode (unchanged): the query is wrapped in a single pair of +/// `'...'` → strip the quotes and pass the inner text through verbatim. +/// The user has explicitly opted into FTS5 syntax (e.g. +/// `'rust AND cargo'`, `'foo*'`). /// -/// - An empty / whitespace-only token list → return `None` (caller -/// short-circuits to `Ok(vec![])`). +/// - Otherwise build up to two MATCH candidates: +/// 1. **whole-phrase**: the entire trimmed input wrapped as one FTS5 +/// string literal, *only* if it has ≥3 Unicode chars. FTS5 treats +/// a quoted string with spaces as a phrase match. +/// 2. **token AND**: whitespace-split tokens, kept only when each has +/// ≥3 Unicode chars (shorter ones are dropped — they would zero +/// out the AND under trigram). +/// +/// - Combine: `(whole) OR (token_and)` when both exist *and differ*; +/// either alone when only one exists; `None` when neither exists +/// (caller short-circuits to `Ok(vec![])`, avoiding an FTS5 syntax +/// error from an empty MATCH). +/// +/// - A single-token long query (`러스트`, `foo`) yields `whole == token_and` +/// → return the bare quoted form so the OR doesn't duplicate. fn build_match_string(text: &str) -> Option { let trimmed = text.trim(); if trimmed.is_empty() { @@ -186,14 +203,27 @@ fn build_match_string(text: &str) -> Option { } return Some(inner_trim.to_string()); } - let tokens: Vec = trimmed - .split_whitespace() - .map(escape_fts5_token) - .collect(); - if tokens.is_empty() { - None - } else { - Some(tokens.join(" ")) + + const MIN_TRIGRAM_CHARS: usize = 3; + + let whole_candidate: Option = (trimmed.chars().count() >= MIN_TRIGRAM_CHARS) + .then(|| escape_fts5_token(trimmed)); + + let token_and_candidate: Option = { + let toks: Vec = trimmed + .split_whitespace() + .filter(|t| t.chars().count() >= MIN_TRIGRAM_CHARS) + .map(escape_fts5_token) + .collect(); + (!toks.is_empty()).then(|| toks.join(" ")) + }; + + match (whole_candidate, token_and_candidate) { + (None, None) => None, + (Some(w), None) => Some(w), + (None, Some(a)) => Some(a), + (Some(w), Some(a)) if w == a => Some(w), + (Some(w), Some(a)) => Some(format!("({w}) OR ({a})")), } } @@ -555,30 +585,31 @@ mod tests { } #[test] - fn build_match_string_default_is_quoted_and_anded() { + fn build_match_string_default_emits_or_of_phrase_and_and() { + // Two long tokens: both whole-phrase and token-AND candidates + // exist and differ, so the builder combines them with OR. let s = build_match_string("rust cargo").unwrap(); - // Two tokens, each quoted, joined by a space (implicit AND). - assert_eq!(s, r#""rust" "cargo""#); + assert_eq!(s, r#"("rust cargo") OR ("rust" "cargo")"#); } #[test] fn build_match_string_escapes_special_chars() { // `*`, `(`, `)`, `:`, `^`, `"` should all be wrapped inside // FTS5 string-literal quotes so they're treated as literal - // text rather than FTS5 operators. + // text rather than FTS5 operators. Every token is ≥3 chars, + // so both the whole-phrase and token-AND candidates exist. let s = build_match_string(r#"foo* (bar) baz:qux ^head he"llo"#).unwrap(); assert_eq!( s, - r#""foo*" "(bar)" "baz:qux" "^head" "he""llo""# + r#"("foo* (bar) baz:qux ^head he""llo") OR ("foo*" "(bar)" "baz:qux" "^head" "he""llo")"# ); // The doubled `""` is FTS5's way of embedding a literal quote - // inside a string literal. + // inside a string literal. Appears in both whole-phrase and + // token-AND halves. assert!(s.contains(r#"he""llo"#)); - // Sanity: every special character lives between matching `"` - // delimiters — there is no bare-token (unquoted) span anywhere. - // We check this by confirming the string starts and ends with `"` - // and the count of unescaped `"` is even (each token is wrapped). - assert!(s.starts_with('"') && s.ends_with('"')); + // Sanity: the combined expression is `(...) OR (...)` so it + // starts with `(` and ends with `)`. + assert!(s.starts_with('(') && s.ends_with(')')); } #[test] @@ -588,6 +619,55 @@ mod tests { assert_eq!(s, "foo OR bar*"); } + // ── v0.17.0 trigram-aware redesign coverage ────────────────────────── + + /// 2-char Korean query (`충돌`) yields neither a whole-phrase nor a + /// token-AND candidate → `None`. Caller short-circuits to an empty + /// hit list rather than executing an FTS5 syntax error on `""` MATCH. + #[test] + fn build_match_string_short_korean_returns_none() { + assert!(build_match_string("충돌").is_none()); + assert!(build_match_string("키").is_none()); + assert!(build_match_string(" 충돌 ").is_none()); + } + + /// `해시 충돌` — both tokens are 2 chars (dropped from the AND), but + /// the whole-phrase candidate (`"해시 충돌"`, 5 chars total) survives. + /// This is the dominant Korean usage pattern targeted by A5. + #[test] + fn build_match_string_whole_phrase_only_when_all_tokens_short() { + let s = build_match_string("해시 충돌").unwrap(); + assert_eq!(s, r#""해시 충돌""#); + } + + /// Single long token: whole-phrase and token-AND candidates collapse + /// to the same string. The builder returns the bare quoted form so + /// the MATCH expression doesn't carry a redundant `(x) OR (x)`. + #[test] + fn build_match_string_single_long_token_no_duplicate_or() { + assert_eq!(build_match_string("러스트").unwrap(), r#""러스트""#); + assert_eq!(build_match_string("rust").unwrap(), r#""rust""#); + } + + /// Mixed Korean+English multi-token query where every token is ≥3 + /// chars: both candidates exist and differ, OR-combined. + #[test] + fn build_match_string_mixed_lang_emits_or_of_phrase_and_and() { + let s = build_match_string("Rust 충돌은").unwrap(); + assert_eq!(s, r#"("Rust 충돌은") OR ("Rust" "충돌은")"#); + } + + /// One ≥3 token + one <3 token: short token is dropped from the + /// AND, leaving a single long token there; whole-phrase exists + /// independently. Both candidates differ → OR-combined. + #[test] + fn build_match_string_drops_short_token_in_and_keeps_whole() { + // "키" (1 char) dropped from AND; "해시테이블" (5 chars) kept. + // Whole phrase "키 해시테이블" (7 chars) keeps the short token. + let s = build_match_string("키 해시테이블").unwrap(); + assert_eq!(s, r#"("키 해시테이블") OR ("해시테이블")"#); + } + #[test] fn normalize_bm25_top_score_in_unit_interval() { // A "perfect" hit is bm25 = -1.0 → normalized 0.5. diff --git a/crates/kebab-search/tests/fixtures/search/lexical/run-1.json b/crates/kebab-search/tests/fixtures/search/lexical/run-1.json index d6ae0dc..c16a495 100644 --- a/crates/kebab-search/tests/fixtures/search/lexical/run-1.json +++ b/crates/kebab-search/tests/fixtures/search/lexical/run-1.json @@ -19,9 +19,9 @@ "indexed_at": "2024-01-01T00:00:00Z", "rank": 1, "retrieval": { - "fusion_score": 1.4490997273242101e-6, + "fusion_score": 1.4615362715630908e-6, "lexical_rank": 1, - "lexical_score": 1.4490997273242101e-6, + "lexical_score": 1.4615362715630908e-6, "method": "lexical", "vector_rank": null, "vector_score": null @@ -51,9 +51,9 @@ "indexed_at": "2024-01-01T00:00:00Z", "rank": 2, "retrieval": { - "fusion_score": 9.641424867368187e-7, + "fusion_score": 9.207039965986041e-7, "lexical_rank": 2, - "lexical_score": 9.641424867368187e-7, + "lexical_score": 9.207039965986041e-7, "method": "lexical", "vector_rank": null, "vector_score": null diff --git a/crates/kebab-tui/src/app.rs b/crates/kebab-tui/src/app.rs index a87f8c8..a5be019 100644 --- a/crates/kebab-tui/src/app.rs +++ b/crates/kebab-tui/src/app.rs @@ -153,6 +153,12 @@ pub struct SearchState { /// `Ctrl-L`); the previous draft kept one for "symmetry" but /// it was dead code. pub worker_rx: Option>, + /// v0.17.0 A5 Step 5: advisory text shown when the last completed + /// search returned no hits and the (trimmed) query is shorter than + /// the FTS5 trigram tokenizer's 3-char minimum. `None` whenever + /// the input changes (so a stale hint never overlaps a fresh + /// typing session) or the next search returns ≥1 hit. + pub short_query_hint: Option, } /// p9-fb-08: payload posted by the search worker on completion. @@ -179,6 +185,7 @@ impl Default for SearchState { preview: None, generation: 0, worker_rx: None, + short_query_hint: None, } } } diff --git a/crates/kebab-tui/src/run.rs b/crates/kebab-tui/src/run.rs index fb24b22..cceca0f 100644 --- a/crates/kebab-tui/src/run.rs +++ b/crates/kebab-tui/src/run.rs @@ -393,6 +393,20 @@ fn dynamic_status(app: &App) -> String { if app.search.as_ref().map(|s| s.searching).unwrap_or(false) { return "searching…".to_string(); } + // v0.17.0 A5 Step 5: short-query advisory has higher priority than + // the idle slot but lower than active operations (streaming / + // searching / ingest progress) — the user should always see what + // is happening *now* before reading guidance about the last + // empty result. Slot only fires while focused on Search. + if app.focus == Pane::Search { + if let Some(hint) = app + .search + .as_ref() + .and_then(|s| s.short_query_hint.as_deref()) + { + return hint.to_string(); + } + } if let Some(state) = app.ingest_state.as_ref() { return crate::ingest_progress::status_line(state); } diff --git a/crates/kebab-tui/src/search.rs b/crates/kebab-tui/src/search.rs index 13c9f43..1e6bf75 100644 --- a/crates/kebab-tui/src/search.rs +++ b/crates/kebab-tui/src/search.rs @@ -333,7 +333,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { s.mode = cycle_mode(s.mode); // Force re-search at the new mode if there's a query. if !s.input.as_str().trim().is_empty() { - s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + mark_input_changed(s); } KeyOutcome::Continue } @@ -360,7 +360,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { (KeyCode::Backspace, _) => { if !s.input.is_empty() { s.input.pop_char(); - s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + mark_input_changed(s); } KeyOutcome::Continue } @@ -388,7 +388,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { } (KeyCode::Delete, _) => { if s.input.delete_after().is_some() { - s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + mark_input_changed(s); } KeyOutcome::Continue } @@ -402,7 +402,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { s.preview = None; } else { s.input.push_char('j'); - s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + mark_input_changed(s); } KeyOutcome::Continue } @@ -412,7 +412,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { s.preview = None; } else { s.input.push_char('k'); - s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + mark_input_changed(s); } KeyOutcome::Continue } @@ -426,7 +426,7 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { // bindings (and don't currently match any Search // command, so they're a safe fall-through to Continue). s.input.push_char(c); - s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + mark_input_changed(s); KeyOutcome::Continue } // Normal mode + un-handled Char → no-op (no typing in @@ -435,6 +435,16 @@ pub fn handle_key_search(state: &mut App, key: KeyEvent) -> KeyOutcome { } } +/// v0.17.0 A5 Step 5: every input-mutation site in `handle_key_search` +/// funnels through this helper so the debounce stamp and the +/// short-query advisory stay in sync. Reset is eager — the stale +/// advisory from the previous result set must not visually overlap +/// with a fresh typing session. +fn mark_input_changed(s: &mut crate::app::SearchState) { + s.input_dirty_at = Some(time::OffsetDateTime::now_utc()); + s.short_query_hint = None; +} + fn cycle_mode(m: SearchMode) -> SearchMode { match m { SearchMode::Lexical => SearchMode::Vector, @@ -603,6 +613,11 @@ pub(crate) fn fire_search(state: &mut App) -> anyhow::Result<()> { s.generation = s.generation.wrapping_add(1); s.searching = true; s.input_dirty_at = None; + // v0.17.0 A5 Step 5: hint belongs to the *prior* result set — + // a fresh worker spawn invalidates it so the status bar + // doesn't keep showing the old advisory while the new + // query is in flight. + s.short_query_hint = None; let q_text = s.input.as_str().to_string(); s.last_query = Some((q_text.clone(), s.mode)); (q_text, s.mode, s.generation) @@ -676,6 +691,18 @@ pub fn poll_worker(state: &mut App) { s.searching = false; match result { Ok(hits) => { + // v0.17.0 A5 Step 5: stale-aware short-query hint. + // The worker carries no copy of the query text; + // we ground the advisory on `s.last_query` which + // was snapshotted at `fire_search` time and (by + // the generation guard above) still matches what + // the user submitted for *this* result set. If + // input has drifted since spawn, the gen-check + // already returned early. + let q_text = + s.last_query.as_ref().map(|(t, _)| t.as_str()).unwrap_or(""); + s.short_query_hint = + kebab_app::short_query_hint(q_text, hits.is_empty()); s.hits = hits; s.selected_hit = 0; s.preview = None; @@ -683,6 +710,7 @@ pub fn poll_worker(state: &mut App) { Err(e) => { s.hits.clear(); s.selected_hit = 0; + s.short_query_hint = None; state.error_overlay = Some(crate::error_popup::ErrorOverlay::from_anyhow(&e)); } diff --git a/docs/wire-schema/v1/search_response.schema.json b/docs/wire-schema/v1/search_response.schema.json index ca89792..2a23523 100644 --- a/docs/wire-schema/v1/search_response.schema.json +++ b/docs/wire-schema/v1/search_response.schema.json @@ -29,6 +29,10 @@ } } } + }, + "hint": { + "type": "string", + "description": "v0.17.0 A5 Step 4b: advisory string set when the empty hit list is likely due to a query shorter than the FTS5 trigram tokenizer's 3-char minimum. Field is omitted when no advisory applies. Raw FTS5 mode ('...') opts out. MCP / agent consumers should surface this so users understand the empty result rather than retrying the same short query." } } } From 8a68289499ae212df7a13193a29c3d60ef2a3e0f Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 24 May 2026 11:54:44 +0000 Subject: [PATCH 08/10] =?UTF-8?q?docs(v0.17.0/A6):=20HANDOFF=20+=20HOTFIXE?= =?UTF-8?q?S=20+=20README=20+=20SMOKE=20+=20SKILL=20=E2=80=94=20=ED=95=9C?= =?UTF-8?q?=EA=B5=AD=EC=96=B4=20trigram=20closure?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - HOTFIXES: 새 2026-05-24 절 — v0.17.0 closure 영향 (한국어 lexical 3-gram, 영어 substring 변경, BM25 분포, 디스크 용량, heading_path JSON 노이즈 관찰). 기존 2026-05-22 한국어 lexical 항목의 Status / Next step 을 closure 표현으로 갱신. - HANDOFF: 머지 후 발견 deviation 절에 2026-05-24 entry + 기존 2026-05-22 항목을 closure cross-link 로 정리. P10 백로그 한국어 tokenizer 항목 ✅ v0.17.0 + "다음 task 후보" follow-up 라인의 상태 갱신. - README: 검색 명령 행에 trigram 동작 + hint + 디스크 용량 한 줄. - SMOKE: 새 "한국어 trigram 검색 (v0.17.0)" 절 — 도그푸딩 query 시퀀스 (충돌은 raw / 해시 충돌 multi-token / Rust 충돌은 mixed / 충돌 2자 + stderr / --json hint 검증) + 영어 substring 동작 변경 안내. - SKILL.md: search 절에 hint 필드 안내 한 줄 — agent 가 short query 케이스에서 같은 query 재시도 대신 사용자에게 surface 하도록. Co-Authored-By: Claude Opus 4.7 (1M context) --- HANDOFF.md | 7 +++--- README.md | 2 +- docs/SMOKE.md | 31 +++++++++++++++++++++++++ integrations/claude-code/kebab/SKILL.md | 1 + tasks/HOTFIXES.md | 27 ++++++++++++++++----- 5 files changed, 58 insertions(+), 10 deletions(-) diff --git a/HANDOFF.md b/HANDOFF.md index bd3c3ed..1889ed3 100644 --- a/HANDOFF.md +++ b/HANDOFF.md @@ -32,7 +32,8 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. 머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만: -- **2026-05-22 P10 종합 도그푸딩 round 2 (한국어 lexical 검색 한계)** — `kebab search --mode lexical` 의 한국어 query 가 FTS5 `unicode61` 토크나이저에서 거의 0 hit (어절 단위 토큰화 → 부분 매칭 불가). 기본 hybrid 모드는 `multilingual-e5-small` vector 가 carry 해 한국어 검색 정상 (검증: 한국어 4 query 전부 vector/hybrid 10 hit vs lexical 0~1) — **한국어 문서 KB 는 embedding 활성화 필수**. `trigram` tokenizer 로의 fix 는 V00X migration + 전체 re-index 동반이라 보류. 자세한 내용: `tasks/HOTFIXES.md` (2026-05-22). +- **2026-05-24 v0.17.0 한국어 trigram tokenizer 채택 (closure of 2026-05-22 한국어 lexical)** — `chunks_fts` 가 FTS5 `unicode61` → `trigram` 으로 V007 migration (자동 backfill, re-ingest 불필요). `lexical.rs::build_match_string` trigram-aware 재설계 — multi-token 한국어 query (`해시 충돌`) 가 whole-phrase 후보로 hit, 한영 혼합 (`Rust 충돌은`) 도 OR-combined. 2자 이하 query 는 0-hit + CLI/TUI/wire `hint` 안내. 영어 lexical 도 substring 매칭으로 바뀜 (recall ↑ / 단어 경계 ↓). `kebab.sqlite` 크기 ~2-5배 증가 (trigram index). 자세한 내용: `tasks/HOTFIXES.md` (2026-05-24). +- **2026-05-22 P10 종합 도그푸딩 round 2 (한국어 lexical 검색 한계)** — `kebab search --mode lexical` 의 한국어 query 가 FTS5 `unicode61` 토크나이저에서 거의 0 hit (어절 단위 토큰화 → 부분 매칭 불가). 기본 hybrid 모드는 `multilingual-e5-small` vector 가 carry 해 한국어 검색 정상. **closure**: 위 2026-05-24 v0.17.0 entry. - **2026-05-20 P10-1B (Rust 1A symbol path 비일관 + expression-level 함수 미방출)** — (a) Rust `code-rust-ast-v1` 은 file-scope nesting 만 (workspace path prefix 없음), 1B 의 Python/TypeScript/JavaScript 는 workspace 경로 → module path prefix 사용 (비일관 수용, retrofit = chunker_version bump + reindex 필요, 사용자 명시 요청까지 보류); (b) TS/JS 의 `const foo = () => {...}` 같은 expression-level 함수는 `` glue 로 처리됨 (declaration-level 단위만 1B 1차 범위). 자세한 내용: `tasks/HOTFIXES.md` (2026-05-20) 두 항목. - **2026-05-19 P10-1A-2 (code_rust_ast_v1.rs + SourceType)** — `AST_CHUNK_MAX_LINES` 상수가 `IngestCodeCfg.ast_chunk_max_lines` 를 읽지 않고 모듈 상수 200 고정 (Chunker trait 이 per-medium config 미노출); `SourceType::Code` variant 부재로 code 파일이 `SourceType::Note` 로 분류됨 — 두 항목 모두 `tasks/HOTFIXES.md` (2026-05-19) 에 기록. - **2026-05-07 fb-26 (progress.rs)** — `Aborted` unconditional writeln (TTY duplicate) + `Completed` TTY no summary fixed; `KEBAB_PROGRESS=plain` env + quiet suppression added @@ -85,7 +86,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. 구조적으로 미완인 component 는 P9-5 하나뿐. 나머지는 도그푸딩 follow-up (아래 "P10 dogfooding 백로그") 또는 사용자 결정 대기. - **P9-5 desktop tauri** — 마지막 남은 P9 component. `kebab-desktop` crate + Tauri 앱, 별도 분기. PDF citation rendering UI 가치 큼. 사용자 우선순위 (P9 우선 · 책/PDF 위주) 와 부합. -- **P10 도그푸딩 round 2 follow-up** — 한국어 lexical tokenizer (MEDIUM) / code_lang_breakdown chunk 단위 집계 (LOW) / C typedef-wrapped struct (LOW, 관망). 상세는 아래 "P10 dogfooding 백로그" 절. +- **P10 도그푸딩 round 2 follow-up** — 한국어 lexical tokenizer ✅ v0.17.0. 잔여: code_lang_breakdown chunk 단위 집계 (LOW, PR-C) / C typedef-wrapped struct (LOW, PR-B). 상세는 아래 "P10 dogfooding 백로그" 절. - **P8 audio brainstorm** — whisper-rs 시스템 dep 받을지 / 외부 transcription endpoint 사용할지 사용자 결정 필요. 사용자 패턴 (책+PDF 위주, audio 의향 없음) 상 보류. - **fb-41 multi-hop reasoning** — ⏳ 미구현, XL, eval 인프라 선행 + brainstorm 필요. - **Rust symbol path retrofit** — Rust `code-rust-ast-v1` symbol 이 file-scope-only (1B+ 는 module prefix). `code-rust-ast-v2` bump + Rust corpus re-ingest 비용 → 사용자 명시 요청까지 보류. HOTFIXES `2026-05-20`. @@ -106,7 +107,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능. P10 종합 도그푸딩 round 2 (`/build/cache/dogfood-p10b/`, OSS 8 repo + 한국어 위키 문서 10편) 에서 발견된 follow-up 후보. 자세한 내용 + 우선순위 근거는 `tasks/HOTFIXES.md` (2026-05-22). -- **한국어 lexical tokenizer (MEDIUM)** — `chunks_fts` 를 FTS5 `trigram` tokenizer 로 교체 → 한국어 3-gram 부분 매칭. V00X migration + 전체 chunk re-index + design §5.5 verbatim 블록 갱신 동반 (breaking schema, release cascade). 기본 hybrid 가 한국어를 cover 하므로 HIGH 아님 — 사용자 결정 대기. +- **한국어 lexical tokenizer** — ✅ v0.17.0 (2026-05-24) 머지. V007 trigram migration 자동 backfill + `build_match_string` 재설계 + CLI/TUI/wire hint. HOTFIXES `2026-05-24` 참조. - **code_lang_breakdown chunk 단위 집계 (LOW)** — `schema.v1.stats` 의 언어별 분포를 doc 수 → chunk 수로. 소규모, wire additive 필드. - **ranking glue chunk 편향 (deferred)** — 자동 heuristic 은 user intent misalignment 위험. 사용자 명시 요청 전까지 surface 변경 0 유지. 1주+ 실사용 후 재 brainstorm. diff --git a/README.md b/README.md index 1455e14..2f3c009 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ kebab doctor |------|------| | `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 | | `kebab ingest []` | Markdown / 이미지 / PDF / Rust 소스코드 색인 (idempotent). TTY 에서는 stderr 진행 바, non-TTY (CI / pipe) 는 stderr 한 줄씩, `--json` 은 stdout 에 `ingest_progress.v1` 라인 streaming 후 마지막에 `ingest_report.v1`. Ctrl-C 한 번이면 현재 asset 마무리 후 abort (부분 commit 보존, idempotent re-run), 두 번째 Ctrl-C 는 hard exit. Markdown title 이 frontmatter 에 없어도 첫 H1 → H2 → 첫 paragraph 80 자 → 파일명 순으로 자동 채움 (parser_version `md-frontmatter-v2`) — 기존 색인된 doc 도 다음 ingest 에서 새 title 로 갱신. **Incremental** (p9-fb-23): 두 번째 이후의 ingest 는 변하지 않은 doc (blake3 + parser/chunker/embedder version 모두 동일) 의 parse/chunk/embed/vector upsert 를 자동 스킵. final summary 에 `N unchanged` 카운트 표시. `--force-reingest` 로 skip 무시 강제 재처리. **지원 형식** (extractor 자동 결정 — config 에 명시 불가): Markdown (`.md`), 이미지 (`.png` / `.jpg` / `.jpeg`, OCR + caption), PDF (`.pdf`), **소스코드** (`.rs` → `code-rust-ast-v1`, `.py` → `code-python-ast-v1`, `.ts`/`.tsx` → `code-ts-ast-v1`, `.js`/`.mjs`/`.cjs`/`.jsx` → `code-js-ast-v1`, `.go` → `code-go-ast-v1`, `.java` → `code-java-ast-v1`, `.kt`/`.kts` → `code-kotlin-ast-v1`, `.c`/`.h` → `code-c-ast-v1`, `.cpp`/`.cc`/`.cxx`/`.hpp`/`.hh`/`.hxx` → `code-cpp-ast-v1` — 모두 tree-sitter AST chunker; **Tier 2 리소스 파일**: `.yaml`/`.yml` → `k8s-manifest-resource-v1` (apiVersion+kind 파싱), `Dockerfile`/`Dockerfile.*`/`*.dockerfile` → `dockerfile-file-v1` (전체 파일), `Cargo.toml`/`pyproject.toml`/`.toml`/`package.json`/`tsconfig.json`/`.json`/`pom.xml`/`.xml`/`build.gradle`/`.gradle`/`go.mod` → `manifest-file-v1` (전체 파일) — yaml (k8s) / dockerfile / toml / json / xml / groovy / go-mod 지원); **Tier 3 paragraph fallback** (`.sh`/`.bash`/`.zsh` → `code-text-paragraph-v1`, blank-line paragraph split + 80-line/20-overlap line-window. Tier 1/2 가 0 chunk 또는 Err 시 자동 fallback — 비-k8s YAML 같은 케이스 picked up. symbol = None, lang 은 원본 보존.). 다른 확장자는 자동 skip — `IngestItem.warnings` 에 사유 (`"unsupported media type: .docx"` 등), `IngestReport.skipped_by_extension` 에 카운트 분류, CLI / TUI summary 에 breakdown 표시. 코드 chunk 는 `citation.kind = "code"` 에 `citation.lang = ""` + `symbol` + line range 를 담고, SearchHit top-level 에 `code_lang` + `repo` (`.git/` walk-up 의 디렉토리 이름) 가 backfill 됨. `--code-lang rust` / `--code-lang python` / `--code-lang typescript` / `--code-lang javascript` / `--code-lang go` / `--code-lang java` / `--code-lang kotlin` / `--code-lang yaml` / `--code-lang dockerfile` / `--code-lang toml` / `--code-lang json` / `--code-lang xml` / `--code-lang groovy` / `--code-lang go-mod` / `--code-lang shell` / `--code-lang c` / `--code-lang cpp` / `--media code` filter 로 언어별·코드 전용 검색 가능 (p10-1A-1 filter flags). Python symbol 은 workspace 경로 → dotted module path prefix (예: `kebab_eval.metrics.compute_mrr`), TS/JS symbol 은 slash-style module path prefix (예: `src/Foo.Foo.search`), Go symbol 은 `package.Func` / `package.(*Receiver).Method` 형식, Java / Kotlin symbol 은 `com.foo.Foo.bar` 형식 (패키지 + 클래스 + 메서드/필드). | -| `kebab search --mode {lexical,vector,hybrid} "" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor ] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace] [--bulk] [--repo NAME ...] [--code-lang LIST]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media` 는 `,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min` 은 `primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md` 는 `markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). **`--trace` (p9-fb-37)** — `search_response.v1.trace` 에 lexical / vector pre-fusion 후보 + RRF union + per-stage timing (`lexical_ms` / `vector_ms` / `fusion_ms` / `total_ms`) 노출. trace 요청은 캐시 우회 (`--no-cache` 없이도 항상 cold). **`--bulk` (p9-fb-42)** — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson (`bulk_search_item.v1`) + stderr summary (`bulk_summary: total=N succeeded=S failed=F`). Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip — App instance 재사용으로 캐시 / embedder cold-start 비용 한 번만. Per-query failure 는 item 의 `error` (error.v1) 에 격리, 다른 query 계속 진행. **code corpus filters (p10-1A-1):** `--repo` 는 반복 가능 (`--repo kebab --repo other`) OR 매칭. `--code-lang` 는 반복 또는 comma 다중 값 (`--code-lang rust,python`), 알 수 없는 값은 빈 hits. `--media code` 는 Tier 1/2/3 모든 code chunk 포함. 1A-1 시점에서는 indexed 된 code chunk 가 없어 filter 가 항상 빈 결과 — 1A-2 (Rust AST chunker) 머지 이후 실효. | +| `kebab search --mode {lexical,vector,hybrid} "" [--no-cache] [--max-tokens N] [--snippet-chars N] [--cursor ] [--tag T] [--lang L] [--path-glob G] [--trust-min LEVEL] [--media TYPE] [--ingested-after RFC3339] [--doc-id ID] [--trace] [--bulk] [--repo NAME ...] [--code-lang LIST]` | 검색. hybrid는 RRF fusion, citation 포함. 같은 process 안에서 동일 query (NFKC + trim + lowercase 정규화) 반복 시 in-process LRU 캐시 hit (capacity = `[search] cache_capacity`, default 256). `--no-cache` 로 강제 bypass — 디버깅용. ingest commit 발생 시 `kv['corpus_revision']` bump 으로 모든 entry 자동 stale. **`--max-tokens` / `--snippet-chars` / `--cursor` (p9-fb-34)** — agent budget controls. `--json` 출력은 `search_response.v1` wrapper (`{hits, next_cursor, truncated}`) — pre-fb-34 의 bare array 와 호환 안 됨. mismatched cursor → `error.v1.code = stale_cursor`. **filter flags (p9-fb-36):** `--tag` 는 반복 가능 flag (`--tag rust --tag async`) 로 OR 매칭, `--media` 는 `,` 구분 다중 값 OR 매칭, 나머지 flags 간은 AND 조합. `--trust-min` 은 `primary\|secondary\|generated` 중 하나 (해당 level 이상 포함). `--ingested-after` 는 RFC3339 UTC — 파싱 실패 시 `error.v1.code = config_invalid` (exit 2). `--media md` 는 `markdown` alias 로 정규화. 알 수 없는 `--media` 값은 무조건 empty hits (오류 아님). **`--trace` (p9-fb-37)** — `search_response.v1.trace` 에 lexical / vector pre-fusion 후보 + RRF union + per-stage timing (`lexical_ms` / `vector_ms` / `fusion_ms` / `total_ms`) 노출. trace 요청은 캐시 우회 (`--no-cache` 없이도 항상 cold). **`--bulk` (p9-fb-42)** — stdin ndjson 으로 N query 한 번에 실행. `--json` 면 stdout per-query ndjson (`bulk_search_item.v1`) + stderr summary (`bulk_summary: total=N succeeded=S failed=F`). Cap 100. agent 가 query decomposition 후 sub-query 일괄 실행 시 single round-trip — App instance 재사용으로 캐시 / embedder cold-start 비용 한 번만. Per-query failure 는 item 의 `error` (error.v1) 에 격리, 다른 query 계속 진행. **code corpus filters (p10-1A-1):** `--repo` 는 반복 가능 (`--repo kebab --repo other`) OR 매칭. `--code-lang` 는 반복 또는 comma 다중 값 (`--code-lang rust,python`), 알 수 없는 값은 빈 hits. `--media code` 는 Tier 1/2/3 모든 code chunk 포함. 1A-1 시점에서는 indexed 된 code chunk 가 없어 filter 가 항상 빈 결과 — 1A-2 (Rust AST chunker) 머지 이후 실효. **v0.17.0 trigram tokenizer (한국어 + 영어 동작 변경):** `chunks_fts` 가 FTS5 `trigram` 으로 동작 — 한국어 query 는 3자 이상 substring 매칭 (`해시 충돌` 같은 multi-token 도 whole-phrase 후보로 hit), 영어도 substring 매칭 (`token` 이 `tokenizer` 도 hit, recall ↑ / 단어 경계 ↓). 2자 이하 query 는 0-hit + stderr `[hint] 3자 이상 키워드 권장` + `search_response.v1.hint` 필드 (raw FTS5 mode `'...'` 제외). `kebab.sqlite` 파일 크기는 trigram index 비대화로 ~2-5배 또는 수백 MB 증가 (V007 자동 backfill, re-ingest 불필요). | | `kebab list docs` | 색인된 문서 목록 | | `kebab inspect doc ` / `kebab inspect chunk ` | raw record 보기 | | `kebab fetch chunk [--context N]` / `kebab fetch doc [--max-tokens N]` / `kebab fetch span [--max-tokens N]` | (p9-fb-35) verbatim text fetch from indexed corpus. wire = `fetch_result.v1` (kind discriminator). chunk: target + ±N ordinal-context chunks. doc: full normalized markdown. span: 1-based line range (PDF/audio rejected as `error.v1.code = span_not_supported`). chars/4 budget on doc/span. | diff --git a/docs/SMOKE.md b/docs/SMOKE.md index 961ec0a..c191cea 100644 --- a/docs/SMOKE.md +++ b/docs/SMOKE.md @@ -140,6 +140,37 @@ KB ask "이 KB 안에서 ..." --mode hybrid --k 5 # 9. RAG 답변 (Ollama KB --json ask "..." --mode hybrid # 10. 기계 친화 출력 검증 ``` +### 한국어 trigram 검색 (v0.17.0) + +`chunks_fts` 가 FTS5 `trigram` tokenizer 로 동작 — 한국어 query 는 3자 이상 substring 매칭. V007 자동 backfill 이라 기존 KB 의 binary 만 v0.17.0+ 로 교체하면 즉시 적용 (re-ingest 불필요). `kebab.sqlite` 파일 크기가 trigram index 비대화로 ~2-5배 또는 수백 MB 증가. + +`fixtures/search/korean/hash-table.md` (또는 등가) 를 워크스페이스에 두고 ingest 한 후: + +```bash +# 3자 연속 substring (raw, 원문에 "해시 충돌은" / "충돌은 발생" 가 있음) +KB search --mode lexical "충돌은" + +# multi-token Korean — builder 가 ("해시 충돌") OR ("해시" "충돌") 으로 +# 변환 (각 토큰 2자라 token-AND 후보는 trigram 비호환, whole-phrase 가 hit) +KB search --mode lexical "해시 충돌" + +# 한영 혼합 — 둘 다 3자 이상이라 whole-phrase + token-AND 모두 후보 +KB search --mode lexical "Rust 충돌은" + +# 2자 query — 정상 0 hit + stderr `[hint] 3자 이상 키워드 권장` +KB search --mode lexical "충돌" + +# 동일 케이스의 --json 출력에는 search_response.v1.hint 필드 포함 +KB search --mode lexical "충돌" --json | jq '.hits | length, .hint' +# → 0 +# → "3자 이상 키워드 권장 (trigram tokenizer 제약)" + +# raw FTS5 mode (single quote 로 감싼 입력) — 사용자 명시 의도, hint 미출력 +KB search --mode lexical "'충돌'" +``` + +영어 lexical 도 substring 매칭으로 바뀜 — `KB search --mode lexical "token"` 이 `tokenizer` / `tokenize` 도 hit (recall ↑, 단어 경계 정밀도 ↓). + ### Stale doc indicator Each search hit and RAG citation carries `indexed_at` (RFC3339 of the doc's last diff --git a/integrations/claude-code/kebab/SKILL.md b/integrations/claude-code/kebab/SKILL.md index ebd6089..edcd2aa 100644 --- a/integrations/claude-code/kebab/SKILL.md +++ b/integrations/claude-code/kebab/SKILL.md @@ -60,6 +60,7 @@ Input: - Cite back to the user as `doc_path § heading_path[-1]` so they can open the source. - When `truncated: true`, the budget loop modified the page (snippet shortening or k reduction). `next_cursor` is **independent** — non-null whenever more hits may be reachable. Caller may widen `max_tokens` (re-issue same query for fuller snippets / more hits per page) or follow `next_cursor` (advance through more hits) or both. Mismatched cursor (corpus_revision changed) returns `error.v1.code = stale_cursor` — re-issue the search to obtain a fresh one. - **`trace: true` (p9-fb-37)** — debug aid. Response carries an extra `trace` block: `lexical[]` + `vector[]` (pre-fusion candidates), `rrf_inputs[]` (RRF union before final cut), and `timing` (`lexical_ms`, `vector_ms`, `fusion_ms`, `total_ms`). Trace bypasses the search cache (always cold). Use sparingly — it bloats the wire response and is for diagnosing "why did this hit / not hit", not normal retrieval. +- **`hint` (v0.17.0)** — optional advisory string on `search_response.v1`. Present only when the result is empty AND the trimmed query is shorter than the FTS5 trigram tokenizer's 3-char minimum. Surface it to the user instead of retrying the same short query. Korean lexical search benefits most from ≥3-char keywords (`충돌` zero-hit, `충돌은` substring-hit). Raw FTS5 mode (`'...'`) opts out — the user opted into FTS5 syntax. Vector / hybrid modes carry the field too but it's rarely triggered (semantic embeddings handle short queries). ### `mcp__kebab__bulk_search` diff --git a/tasks/HOTFIXES.md b/tasks/HOTFIXES.md index 26e1988..023126c 100644 --- a/tasks/HOTFIXES.md +++ b/tasks/HOTFIXES.md @@ -14,6 +14,24 @@ historical contract that was implemented; this file accumulates the deltas so phase 5+ readers can find the live behavior without diffing git history. +## 2026-05-24 — v0.17.0: 한국어 trigram FTS5 tokenizer 채택 (closure of 2026-05-22 한국어 lexical) + +V007 migration 으로 `chunks_fts` 의 tokenizer 를 `unicode61` → `trigram` 으로 교체. `chunks` 원본 + embedding + vector index 는 그대로, FTS shadow 만 재구축 + 자동 backfill — 사용자는 `kebab ingest` 재실행 불필요 (binary 만 교체하면 다음 open 시 V007 가 즉시 적용). 같은 라운드의 다른 두 follow-up (`code_lang_chunk_breakdown`, C typedef) 은 별 PR (PR-C / PR-B). + +**한국어 lexical 동작**: 3자 이상 substring 매칭. `해시 충돌` 같은 2자 토큰 multi-token query 는 `crates/kebab-search/src/lexical.rs::build_match_string` 의 trigram-aware 재설계로 `("해시 충돌") OR ("해시" "충돌")` 형태가 되어 whole-phrase 후보로 hit (각 토큰 2자라 token-AND 후보는 trigram 에서 0-hit, 자동 drop). 한영 혼합 `Rust 충돌은` (둘 다 ≥3자) 도 OR-combined. 2자 이하 query (`충돌` / `키`) 는 정상 0 hit + CLI stderr `[hint] 3자 이상 키워드 권장 (trigram tokenizer 제약)` + `search_response.v1.hint` additive 필드 + TUI status bar 동일 안내. raw FTS5 single-quote mode (`'...'`) 는 사용자 명시 의도이므로 hint 안 나옴. 회귀 핀: `lexical_multi_token_korean_query_hits` + `lexical_mixed_korean_english_multi_token_query_hits` (`crates/kebab-app/tests/search_korean.rs`). + +**영어 lexical 동작 변경**: substring 매칭으로 바뀜. `token` query 가 `tokenizer` 도 hit (recall ↑, 단어 경계 정밀도 ↓). 의도된 변경, 회귀 핀 = `fts_trigram_english_substring_hits` (`crates/kebab-store-sqlite/tests/fts.rs`). + +**lexical BM25 score 분포**: 알고리즘 동일하지만 token stream 이 word → overlapping trigram 으로 바뀌어 raw score / TF / doc-length 모두 달라짐. `crates/kebab-search/tests/lexical.rs::lexical_snapshot_run_1` + `crates/kebab-search/tests/hybrid.rs::hybrid_snapshot_run_1` 둘 다 trigram baseline 으로 regenerate. hybrid (RRF) 는 rank 기반이라 ranking 영향 미미하나 `retrieval.lexical_score` 노출값은 변동. + +**디스크 용량**: trigram 인덱스는 unicode61 대비 통상 2-10배. V007 자동 backfill 후 `kebab.sqlite` 파일 크기 증가 (도그푸딩 KB 기준 ~2-5배 또는 수백 MB). release notes 명시. + +**`heading_path_json` JSON 노이즈 (관찰, 미수정)**: trigram 이 JSON 표기 (`[`, `"`, `,`) 와 그 안의 단어 (`app`, `src`) 까지 3-gram 색인 → query 가 우연히 JSON 구문 / 흔한 경로 단어와 겹쳐 false positive 가능. v0.17.0 에서는 컬럼 구성 유지, 도그푸딩 후 column filter (`{text} : ` 한정) 또는 평문 heading 변환 결정. 후속 도그푸딩 entry 로 등재 예정. + +**MCP / agent 가시성**: `search_response.v1` 에 `hint: Option` additive 필드. 결과가 비어 있고 query trimmed.chars().count() < 3 + raw mode 아닐 때만 set (helper `kebab_app::short_query_hint`). `integrations/claude-code/kebab/SKILL.md` 의 search 절에 "한국어 lexical 은 3자 이상 권장, `hint` 필드 확인" 안내 추가. + +Cross-link: `migrations/V007__fts_trigram.sql`, `crates/kebab-search/src/lexical.rs::build_match_string`, design §5.5, `docs/superpowers/specs/2026-05-22-korean-trigram-tokenizer-design.md`. + ## 2026-05-22 — p10 종합 도그푸딩 (round 2): 한국어 lexical 검색 한계 + code_lang_breakdown **Origin**: P10 종합 도그푸딩 round 2 (`/build/cache/dogfood-p10b/`). 다양한 OSS 코드베이스 8 repo (rust / python / go / ts / js / java / c / cpp) + 한국어 위키 기술 문서 10편 (pandoc HTML→gfm 변환). `multilingual-e5-small` embedding 활성화 후 ingest — `scanned=2663 updated=2080 errors=0` (k8s multi-resource chunk_id collision 은 같은 라운드에서 발견·수정 — 아래 2026-05-21 항목). @@ -26,14 +44,11 @@ git history. **검증 (vector/hybrid 우회 확인)**: 동일 4 query 를 `--mode vector` / `--mode hybrid` 로 측정 — 전부 10 hit. `multilingual-e5-small` semantic 검색이 한국어를 정상 처리. 즉 embedding 켠 KB 는 **기본 hybrid 모드에서 한국어 검색이 동작**한다. 단 hybrid 는 RRF(lexical+vector) fusion 이라 한국어 query 는 lexical 기여가 0 → 사실상 vector-only 로 reduced (score 증거: lexical 도 hit 한 `트리 순회 방법` 만 hybrid score 1.000, 나머지 한국어 query 는 0.500). -**Status**: `--mode lexical` 단독은 한국어 무용. 기본 hybrid 는 vector 가 carry → 한국어 KB 사용 가능. 단 embedding `provider = "none"` 인 lexical-only KB 는 한국어 검색 불가. +**Status**: ✅ closed — v0.17.0 (2026-05-24) 에서 V007 trigram migration + `lexical.rs::build_match_string` trigram-aware 재설계로 해소. 영향은 위 2026-05-24 절 참조. 이하는 closure 전 원래 round-2 관찰 기록 (frozen). -**Workaround**: 한국어 문서 KB 는 embedding 활성화 (`[models.embedding] provider = "fastembed"`) 를 사실상 필수로 둔다. +**Workaround (pre-v0.17.0)**: 한국어 문서 KB 는 embedding 활성화 (`[models.embedding] provider = "fastembed"`) 가 사실상 필수였다 — vector / hybrid 가 한국어를 carry. -**Next step (미진행 — 사용자 결정 대기)**: FTS5 builtin `trigram` tokenizer (`tokenize = 'trigram'`) 로 교체 시 한국어 3-gram 부분 매칭 가능. 비용·제약: -- `chunks_fts` 재생성 = V00X migration + 전체 chunk re-index. design §5.5 verbatim 블록 + CI diff-check 동반 갱신 필요 (breaking schema → release cascade 트리거). -- CJK 형태소 분석기는 SQLite 번들 FTS5 가 미지원 — 외부 tokenizer extension 은 단일 바이너리 정책과 충돌. trigram 이 현실적 선택. -- 우선순위: 기본 hybrid 가 한국어를 cover 하므로 HIGH 아님. lexical 한국어 정확 키워드 매칭 + hybrid 완전 작동 가치만 남음 → MEDIUM. +**Resolution (v0.17.0)**: FTS5 builtin `trigram` tokenizer 채택. `chunks_fts` 재생성 = V007 migration (`chunks` 원본 / embedding / vector 불변, FTS shadow 만 자동 backfill — re-ingest 불필요). design §5.5 verbatim 블록 + CI diff-check (`fts_v007_matches_design_section_5_5_verbatim`) 동반 갱신. ### code_lang_breakdown 이 chunk 수가 아닌 doc 수를 집계 From 0ee18149e7eaa9c62c1257680b0b03c3dac01591 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 24 May 2026 12:21:34 +0000 Subject: [PATCH 09/10] test(v0.17.0/A5 follow-up): trigram tokenizer downstream test fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit trigram tokenizer 가 snippet 단위 + 단어 경계 + BM25 raw score 분포를 모두 바꿔서 unicode61 assumption 기반의 3 test 가 regression. - wire_search_response::search_json_truncates_with_max_tokens + search_plain_emits_truncated_hint_to_stderr: 단일 doc + 작은 max_tokens 로는 snippet 이 짧아서 budget loop 가 trip 안 함. 다중 doc fixture (5 doc) + budget 30 token 으로 hit-pop 경로 통해 truncated=true 보장. - fetch_integration::fetch_chunk_with_context_returns_neighbors: fixture body 의 2-char tokens (A1/A3 등) 가 trigram 비호환으로 0-hit. apples/banana/cherry/durian/elder 5-char unique words 로 갱신, query 도 cherry 로 deterministic pin. - eval/runner::runner_per_query_snapshot_matches_fixture: trigram token stream 으로 BM25 raw score 변동. UPDATE_SNAPSHOTS=1 로 regenerate. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/kebab-app/tests/fetch_integration.rs | 8 ++++-- .../kebab-cli/tests/wire_search_response.rs | 27 ++++++++++++++++--- .../kebab-eval/tests/fixtures/eval/run-1.json | 4 +-- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/crates/kebab-app/tests/fetch_integration.rs b/crates/kebab-app/tests/fetch_integration.rs index 44ee58c..0fd40a4 100644 --- a/crates/kebab-app/tests/fetch_integration.rs +++ b/crates/kebab-app/tests/fetch_integration.rs @@ -38,12 +38,16 @@ fn fetch_chunk_returns_target_only_when_no_context() { #[test] fn fetch_chunk_with_context_returns_neighbors() { let env = common::TestEnv::new(); - let body = "# H1\n\nA1\n\n# H2\n\nA2\n\n# H3\n\nA3\n\n# H4\n\nA4\n\n# H5\n\nA5\n"; + // v0.17.0 trigram tokenizer: terms must be ≥3 Unicode chars to + // match. The earlier fixture used 2-char tokens like `A1`/`A3` for + // section bodies — those zero-hit under trigram. Use 5-char unique + // words per section so the query can pin one chunk deterministically. + let body = "# H1\n\napples\n\n# H2\n\nbanana\n\n# H3\n\ncherry\n\n# H4\n\ndurian\n\n# H5\n\nelder\n"; common::ingest_md(&env, "multi.md", body); let app = env.app(); let q = kebab_core::SearchQuery { - text: "A3".to_string(), + text: "cherry".to_string(), mode: kebab_core::SearchMode::Lexical, k: 1, filters: kebab_core::SearchFilters::default(), diff --git a/crates/kebab-cli/tests/wire_search_response.rs b/crates/kebab-cli/tests/wire_search_response.rs index 60e1c0f..b7742ba 100644 --- a/crates/kebab-cli/tests/wire_search_response.rs +++ b/crates/kebab-cli/tests/wire_search_response.rs @@ -47,8 +47,20 @@ fn search_json_emits_search_response_v1_wrapper() { fn search_json_truncates_with_max_tokens() { let dir = tempfile::tempdir().unwrap(); let (cfg, workspace, _data) = common::write_config(dir.path(), 30); - let body: String = "rust ownership is a memory model. ".repeat(10); - fs::write(workspace.join("a.md"), format!("# T\n\n{body}\n")).unwrap(); + // v0.17.0 trigram tokenizer makes FTS5 snippet() tokens 3-char wide + // (was full words under unicode61), so an individual snippet stays + // around ~60 chars — too short to ever exceed the snippet-shorten + // budget cap on a single-hit fixture. To still exercise the budget + // loop deterministically, we ingest multiple hits and pick a budget + // small enough that the loop has to *pop* hits, which flips + // truncated=true regardless of snippet length. + for i in 0..5 { + fs::write( + workspace.join(format!("d{i}.md")), + format!("# T{i}\n\nrust ownership is a memory model.\n"), + ) + .unwrap(); + } common::ingest(&cfg, &workspace); let (stdout, _stderr) = common::run_search_with_args( @@ -211,8 +223,15 @@ fn search_stale_cursor_returns_error_v1_with_stale_cursor_code() { fn search_plain_emits_truncated_hint_to_stderr() { let dir = tempfile::tempdir().unwrap(); let (cfg, workspace, _data) = common::write_config(dir.path(), 30); - let body: String = "rust ownership is a memory model. ".repeat(10); - fs::write(workspace.join("a.md"), format!("# T\n\n{body}\n")).unwrap(); + // v0.17.0 trigram tokenizer — same multi-doc rationale as + // `search_json_truncates_with_max_tokens` above. + for i in 0..5 { + fs::write( + workspace.join(format!("d{i}.md")), + format!("# T{i}\n\nrust ownership is a memory model.\n"), + ) + .unwrap(); + } common::ingest(&cfg, &workspace); let (_stdout, stderr) = common::run_search_with_args( diff --git a/crates/kebab-eval/tests/fixtures/eval/run-1.json b/crates/kebab-eval/tests/fixtures/eval/run-1.json index 0f6e93d..0d8dc18 100644 --- a/crates/kebab-eval/tests/fixtures/eval/run-1.json +++ b/crates/kebab-eval/tests/fixtures/eval/run-1.json @@ -5,7 +5,7 @@ "chunk_id": "chunk000000000000000000000000000000", "doc_id": "doc00000000000000000000000000000000", "heading_path": [], - "score": 0.3429983854293823 + "score": 0.35202541947364807 }, "has_answer": false, "hits_count": 1, @@ -19,7 +19,7 @@ "chunk_id": "chunk000000000000000000000000000002", "doc_id": "doc00000000000000000000000000000002", "heading_path": [], - "score": 0.3585492968559265 + "score": 0.3414848744869232 }, "has_answer": false, "hits_count": 1, From d79e4329167276aa87250b7676015ceb861bc7e5 Mon Sep 17 00:00:00 2001 From: altair823 Date: Sun, 24 May 2026 12:45:11 +0000 Subject: [PATCH 10/10] test(v0.17.0/A5): CLI hint surface e2e coverage (worker-1 nit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #159 worker-1 review 의 LOW 가독성 nit 반영 — CLI stderr [hint] line + --json hint shape 통합 test 가 없었음. - search_plain_emits_short_query_hint_to_stderr — 빈 KB + 2자 query → stderr 가 "[hint]" + "3자 이상" 포함 확인. - search_json_emits_hint_field_for_short_query — 동일 입력 --json → search_response.v1.hint 필드 set + 표준 advisory 문자열 정합. - search_json_omits_hint_field_when_query_is_long_enough — 3자 query → hint 필드 absent (additive serializer 의 None 제외 동작). wire_search_response 5 → 8 PASS. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kebab-cli/tests/wire_search_response.rs | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/crates/kebab-cli/tests/wire_search_response.rs b/crates/kebab-cli/tests/wire_search_response.rs index b7742ba..740081a 100644 --- a/crates/kebab-cli/tests/wire_search_response.rs +++ b/crates/kebab-cli/tests/wire_search_response.rs @@ -243,3 +243,76 @@ fn search_plain_emits_truncated_hint_to_stderr() { "stderr must carry truncated hint: {stderr:?}" ); } + +#[test] +fn search_plain_emits_short_query_hint_to_stderr() { + // v0.17.0 A5 Step 6: 2-char query under trigram tokenizer emits + // empty hits + stderr `[hint]` advisory. Empty workspace is enough + // — hits are always empty so the hint condition depends only on + // query length (<3 chars trimmed) + non-raw mode + hits.is_empty. + let dir = tempfile::tempdir().unwrap(); + let (cfg, workspace, _data) = common::write_config(dir.path(), 30); + common::ingest(&cfg, &workspace); + + let (_stdout, stderr) = common::run_search_with_args( + &cfg, + &["--mode", "lexical", "ab"], + ); + assert!( + stderr.contains("[hint]"), + "stderr must carry short-query hint: {stderr:?}" + ); + assert!( + stderr.contains("3자 이상"), + "hint message must mention '3자 이상' (Korean advisory): {stderr:?}" + ); +} + +#[test] +fn search_json_emits_hint_field_for_short_query() { + // v0.17.0 A5 Step 6: --json mode carries the same advisory on the + // `search_response.v1.hint` additive field. Empty hits + 2-char + // query + non-raw mode trips the helper. Verifies the MCP-visible + // surface (agents read the field instead of parsing stderr). + let dir = tempfile::tempdir().unwrap(); + let (cfg, workspace, _data) = common::write_config(dir.path(), 30); + common::ingest(&cfg, &workspace); + + let (stdout, _stderr) = common::run_search_with_args( + &cfg, + &["--json", "--mode", "lexical", "ab"], + ); + let v: Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("not JSON: {stdout:?}: {e}")); + assert!( + v["hits"].as_array().unwrap().is_empty(), + "empty hits expected for short query in empty KB: {v}" + ); + assert_eq!( + v["hint"].as_str().expect("hint field set on short empty result"), + "3자 이상 키워드 권장 (trigram tokenizer 제약)", + "hint must carry the standard advisory: {v}" + ); +} + +#[test] +fn search_json_omits_hint_field_when_query_is_long_enough() { + // v0.17.0 A5 Step 6 (negative case): 3+ char query never trips + // hint, even on an empty KB. Verifies `serialize_search_response` + // omits the additive `hint` field when `None` so existing wire + // consumers stay backward-compatible. + let dir = tempfile::tempdir().unwrap(); + let (cfg, workspace, _data) = common::write_config(dir.path(), 30); + common::ingest(&cfg, &workspace); + + let (stdout, _stderr) = common::run_search_with_args( + &cfg, + &["--json", "--mode", "lexical", "abc"], + ); + let v: Value = serde_json::from_str(stdout.trim()) + .unwrap_or_else(|e| panic!("not JSON: {stdout:?}: {e}")); + assert!( + v.get("hint").is_none(), + "hint must be absent for ≥3-char queries: {v}" + ); +}