--- title: v0.20.x — 한국어 morphological tokenizer (Bug #8 follow-up) created: 2026-05-28 status: accepted contract_sections: [§5.5 chunks_fts, §9 version cascade] parent_handoff: docs/superpowers/handoffs/2026-05-28-v0.20.x-c-korean-morphological-tokenizer-handoff.md --- # v0.20.x — 한국어 morphological tokenizer (Bug #8 follow-up) ## 1. Summary V007 trigram FTS5 tokenizer 의 한계로 인한 2자 이하 한국어 query 의 0-hit 문제를 해결. 형태소 분석 기반 tokenizer 도입으로 '한국', '서울', '지하철' 같은 2자 단어 검색이 가능하도록 개선하되, 기존 trigram 의 장점(영어 substring 매칭, 부분 매칭 지원)을 보존. V009 migration 추가로 FTS5 index 재구성하며, 기존 데이터는 자동 backfill 로 재-ingest 불필요. ## 2. Background ### 2.1 V007 Trigram 의 한계 `migrations/V007__fts_trigram.sql` (2026-05-23 v0.17.0 release) 에서 chunks_fts 의 tokenizer 를 `unicode61` → `trigram` 으로 교체. 효과: - 한국어 ≥3 char substring 검색 가능: '해시 충돌' 문서에서 '충돌은', '발생한' 검색 성공. - 영어 substring 매칭으로 진화: 'token' query 가 'tokenizer' 도 hit (recall ↑). - **핵심 한계**: 2자 이하 query 는 trigram bucket 이 없어 항상 0-hit. ### 2.2 사용자 도그푸딩에서 발견된 impact Round 3/4 도그푸딩 (2026-05-28) 에서 다음 한국어 query 의 0-hit 가 반복: - `'한국'` (2자) - `'서울'` (2자) - `'지하철'` (3자, trigram 에선 hit 하나 다른 경로에서도 검색 실패 가능한 경계 케이스) Vector search (multilingual-e5) 와 hybrid (RRF fusion) 는 정상 동작하나, lexical-only 모드에서는 한국어 단어의 가장 기본적인 검색이 불가능. **Search experience 의 가장 큰 surface 변경 필요**. ### 2.3 HOTFIXES 에서의 맥락 - **2026-05-22**: p10 도그푸딩 round 2 에서 "한국어 lexical 검색이 FTS5 unicode61 tokenizer 에서 무용" 발견 → V007 trigram 으로 일부 해소 하나 2자 이하는 미해결. - **2026-05-24**: V007 trigram adoption + `lexical.rs::build_match_string()` 의 multi-token Korean query 처리 추가 ("한국" + 다른 2자 → OR-combine whole-phrase 후보). Bug #8 은 "2자 이하 Korean query" 의 해결 미루어진 상태. ## 3. Goals + Non-Goals ### Goals - `kebab search '한국'` → hit 가능 (현재 0 hit). - `kebab search '서울'` → hit 가능. - `kebab search '지하철'` → hit 가능 (3자 trigram 에선 일부만 가능). - English lexical recall/precision 을 현재 수준 이상 유지 또는 향상. - 한-영 혼합 query ('Rust 최적화') 도 정상 동작. ### Non-Goals - Search wire schema (`search_response.v1`) 변경. - Embedding model 또는 vector search 의 변경. - Document ranking 알고리즘 변경. ## 4. Design Decision ### 4.1 Option 비교표 | 항목 | Option A: Morphological Tokenizer | Option B: Bigram Supplement | Option C: Query-side Workaround | |------|----------------------------------|--------------------------|--------------------------| | **구현 방식** | lindera (형태소 분석) + pre-tokenize 우회 | 별도 FTS5 table (`chunks_fts_bigram`) + query 분기 | 2자 query 시 hint 노출 또는 vector fallback | | **DB 크기** | +20-50% estimate (Appendix C, 한국어 비율 따라 큰 variation) | +100% (dual index) | 변경 없음 | | **Query latency** | +5-10ms estimate (형태소 분해, Appendix C) | +2-3ms (dual lookup) | 변경 없음 | | **Ingest latency** | +10-20% estimate (형태소 분해, Appendix C) | 변경 없음 (FTS trigger 미변경) | 변경 없음 | | **2자 query 지원** | ✅ 형태소 경계 일치 시 | ✅ bigram index 로 | ❌ workaround 만 | | **English 영향** | 회귀 (substring → whole-token, V002 동일) | 변경 없음 (dual-keep) | 변경 없음 | | **License risk** | lindera (MIT/Apache-2.0) + dict (Apache 호환) | 변경 없음 | 변경 없음 | | **Maintenance burden** | 중간 (dict 업데이트 / tokenizer API) | 높음 (dual-index 동기화) | 낮음 (hint only) | | **Migration cascade** | V009 (index_version bump) | V009 (new virtual table) | 없음 | ### 4.2 권장: Option A (Morphological Tokenizer + Pre-tokenize 우회) **선택 rationale:** 1. **한국어 형태소 분석이 정석**: 2자 단어는 morpheme boundary 와 일치 → 정확한 매칭 보장. 2. **구현 단순성**: lindera 는 Rust-native, pre-tokenize 우회 (별 column) 는 FTS5 external tokenizer 등록의 복잡성 회피. 3. **License clean**: lindera (MIT/Apache-2.0) + Korean dict (Apache-2.0 호환, MeCab-ko-dic 기반). 4. **확장성**: 향후 Japanese / Chinese morphological tokenizer 추가 시 동일 패턴 재사용 가능. 5. **한국어 우선**: V007 의 trigram 도입 자체가 한국어 2-3자 query 해결이 핵심 목표였으므로, V009 에서 2자 query 지원이 더 근본적인 해결. **트레이드오프 (English substring 매칭 회귀):** - V007 에서 trigram 으로 도입된 English substring 매칭 (`'token'` query 가 `'tokenizer'` hit) 이 unicode61 복귀로 사라짐. - 이는 V002 (pre-v0.17.0) 의 영어 동작으로 환원 — V007 의 ad-hoc 부산물이었고, V009 의 한국어 형태소 분석이 더 큰 사용자 도그푸딩 surface. - Release notes 에서 정직히 언급하고, 영어 사용자에게는 vector search / hybrid mode 로 충분. **대안 (Option B) 의 단점:** - Dual-index DB 크기 2배 → disk footprint 증가 + 동기화 복잡. - Query analyzer 의 2자 감지 로직 추가 → lexical.rs 의 분기 복잡도 증가. **대안 (Option C) 의 단점:** - 실제 해결이 아닌 workaround → UX 측면에서 부족. ## 5. Migration Cascade (V009) ### 5.1 DDL skeleton ```sql -- V009__fts_korean_morphological.sql -- Replace chunks_fts tokenizer: trigram → unicode61 (한국어 형태소 분해 별 column 추가) -- Per design §5.5 (chunks_fts virtual table + chunks_ai/ad/au triggers). -- tokenizer 변경: trigram → unicode61 (한국어 전용 tokenized_text column 추가로 dual-index 구현). -- ── Korean morphological tokenizer (V009) ────────────────────────── -- chunks 테이블에 한국어 형태소 분해된 text 를 저장할 열 추가. ALTER TABLE chunks ADD COLUMN tokenized_korean_text TEXT; -- 기존 chunks_fts 제거 (trigram tokenizer). DROP TRIGGER IF EXISTS chunks_au; DROP TRIGGER IF EXISTS chunks_ad; DROP TRIGGER IF EXISTS chunks_ai; DROP TABLE IF EXISTS chunks_fts; -- 신규 chunks_fts (unicode61 tokenizer, English/Korean 모두 지원). -- tokenized_korean_text column 은 형태소 분해된 한국어만 포함, 영어는 원문 그대로. CREATE VIRTUAL TABLE chunks_fts USING fts5( chunk_id UNINDEXED, doc_id UNINDEXED, heading_path, text, tokenize = 'unicode61' ); -- Triggers: chunks 의 INSERT/UPDATE/DELETE 시 chunks_fts 동기화. -- tokenized_korean_text 는 ingest 단계에서 pre-fill (별 helper function). 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, CASE WHEN new.tokenized_korean_text IS NOT NULL THEN new.tokenized_korean_text || ' ' || new.text ELSE new.text END); 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, CASE WHEN new.tokenized_korean_text IS NOT NULL THEN new.tokenized_korean_text || ' ' || new.text ELSE new.text END); END; -- ── Backfill existing chunks ────────────────────────────────────── -- 기존 chunks 에 대해 tokenized_korean_text 를 pre-fill. -- Rust helper function (`kebab-parse-md` 또는 `kebab-chunk` crate) 이 -- 모든 chunk 에 대해 한국어 형태소 분해를 수행한 후 UPDATE. -- 초기 backfill 은 V009 migration 의 DATA 섹션에서 호출. INSERT INTO chunks_fts(chunk_id, doc_id, heading_path, text) SELECT chunk_id, doc_id, heading_path_json, CASE WHEN chunks.tokenized_korean_text IS NOT NULL THEN chunks.tokenized_korean_text || ' ' || chunks.text ELSE chunks.text END FROM chunks; ``` ### 5.2 `corpus_revision` bump + Search cache invalidation V009 migration 의 마지막 SQL statement: ```sql UPDATE kv SET v = v + 1 WHERE k = 'corpus_revision'; ``` 이를 통해: 1. Search cache (`kebab-rag` 의 in-process LRU, p9-fb-19) 자동 무효화 (next query 부터 새로운 FTS index 기반 결과). 2. Eager backfill 진행 중 이미 업데이트된 chunks 는 새 tokenization 기반, 미완료 chunks 는 기존 text 기반 결과 (부분 결과, 정상). ### 5.3 Design contract 변경 + CI diff-check Design `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` 의 §5.5 변경: - `tokenize = 'trigram'` → `tokenize = 'unicode61'` (한국어 형태소 분해 column 추가). **CI diff-check test 처리**: - 기존 V007 test `fts_v007_matches_design_section_5_5_verbatim` 의 처리: **rename 으로 V009 이동** (권장). - Design §5.5 의 변경 대상이 V009 의 unicode61 DDL block 이므로, V007 test 의 design 매칭 비교는 무의미. - Test 를 `fts_v009_matches_design_section_5_5_verbatim` 로 rename 하고, migration block 추출 대상을 `migrations/V009__fts_korean_morphological.sql` 로 변경. - V007 은 "historical replay only, design 매칭 X" 상태로 진입 (V002 와 유사 패턴, fts.rs:402-405 comment 참고). - 신규 V009 test: Design §5.5 의 unicode61 + 한국어 column chunks_ai/ad/au triggers 와 migration verbatim 일치 비교. **Verbatim 정의 명확화**: - CI diff-check 의 "verbatim" = whitespace-normalized string compare of the §5.5 block. - scope 는 CASE expression 포함 (trigger body 의 CASE WHEN tokenized_korean_text IS NOT NULL ... 전체). - Design §5.5 도 동일하게 CASE expression 전체 포함하도록 수정. ## 6. Tokenizer Integration ### 6.1 한국어 형태소 분석 구현 **선택 라이브러리**: lindera-cli + lindera-dict-ko-dic - **lindera**: Rust-native morphological tokenizer. - **lindera-dict-ko-dic**: Korean MeCab dictionary (Apache-2.0 호환). - **라이센스 검증**: lindera = MIT/Apache-2.0 dual, dict = Apache-2.0 (한국어 구글 사전 기반). ### 6.2 Pre-tokenize 우회 (별 column) + Invariant 명시 Ingest 파이프라인 (`kebab-parse-*` → `kebab-chunk`) 에서: 1. **Chunk 생성 후**: 각 chunk 의 `text` 에 대해 lindera 로 형태소 분해. 2. **분해된 token 재조합**: 공백으로 연결하여 `tokenized_korean_text` 값 생성. 3. **Chunk row INSERT 시**: `tokenized_korean_text` column 에 pre-fill. 4. **FTS5 trigger**: chunks_ai trigger 가 `tokenized_korean_text` 를 원문 text 와 함께 FTS 에 index. **구현 위치**: `crates/kebab-chunk/src/lib.rs` 의 chunk builder 에 `tokenize_korean_morphological()` helper 추가 (optional feature gate `fts_korean_morphological`). **Ingest pipeline invariant**: - lindera tokenize → chunks INSERT 는 **동일 Rust transaction 내에서** (단일 INSERT statement). - chunks_ai trigger 는 항상 CASE 의 NOT NULL branch 를 타는 보장 (eager 신규 ingest 경로). - Eager backfill (UPDATE tokenized_korean_text) 경로는 별도: chunks_au trigger 가 DELETE + INSERT 수행 (atomic transaction). - Race condition: 동일 chunk_id 의 concurrent ingest run 에서 lindera tokenize + UPDATE 의 order 는 SQLite transaction isolation 에 의존 (PRIMARY KEY 제약 강제). **tokenize_korean_morphological() 실패 처리**: - lindera dictionary load fail 또는 tokenization error 발생 시: **fallback (NULL + warning log)**. - Chunk 자체는 ingest 성공. - `tokenized_korean_text = NULL` 로 INSERT. - Chunks_ai trigger 의 CASE 는 ELSE branch (raw text 만 FTS index). - 로그: `WARN: tokenize_korean_morphological() failed for chunk_id=X, falling back to raw text: `. - 결과: 한국어 2자 query 는 이 chunk 에서 hit 안 함 (graceful degradation, fatal error 아님). - Alternative (error propagation): lindera fail 을 ingest pipeline 전체 abort 로 처리 — **미권장** (partial KB 손상 위험). ### 6.3 Vendoring 전략 (default-enabled, opt-out 없음) **권장: Option A (Simplicity)** - **Cargo.toml**: `lindera`, `lindera-dict-ko-dic` 를 workspace 의존성으로 추가. - **Feature flag**: `kebab-app` 의 `[features]` 에 `fts_korean_morphological = ["lindera"]` 추가. - Syntax: `[features] fts_korean_morphological = ["lindera"] default = ["fts_korean_morphological"]` (default-enabled). - **Binary 빌드**: `cargo build --release` 는 feature 포함 필수 (모든 사용자가 한국어 형태소 분석 혜택). - **Config 노브 제거**: `disable_korean_morphological` 는 추가하지 않음. Pre-1.0 단계이고, 한국어 지원은 core feature. - **Binary dict 비용**: 모든 사용자가 부담 (+15-25 MB, Appendix C 참고). **대안 (미채택)**: - Option B (build-time feature only): `kebab-no-ko` 같은 release binary 별도 컷 — maintenance burden 증가, pre-1.0 권장 X. - Option C (runtime config): opt-out 노브 → eval baseline reproducibility 깨짐 (lexical_index_version 다양화), 미권장. ## 7. Query Path ### 7.1 Search CLI 경로 (변경 없음) `kebab search` 의 Query 처리 경로는 전혀 변경 안 됨: - User 의 query string 은 그대로 FTS5 에 전달. - FTS5 (unicode61 tokenizer) 가 query 를 space/punct 로 tokenize. - 한국어 2자 query ('한국') 은 이제 tokenized_korean_text column 에서 hit. ### 7.2 lexical.rs 의 build_match_string() 조정 V007 (trigram) 에서 V009 (unicode61 + 형태소) 로 이전할 때, `build_match_string()` 의 trigram-specific 로직 일부 단순화 가능: - Multi-token Korean query 의 OR-combine 우회 가능 (형태소 이미 분해됨). - 단일 2자 token 도 이제 hit 가능. 하지만 backward-compat 차원에서 기존 로직 보존 권장 (future 확장성). ### 7.3 CLI hint 제거 `crates/kebab-app/src/lib.rs` 의 `short_query_hint()` 함수: **제거 이유**: - V007: "한국어 lexical 은 3자 이상 권장" hint. - V009: 2자 query 이상 모두 지원되므로 hint 불필요. **제거 범위**: ```bash grep -rn "short_query_hint" crates/kebab-app/src/lib.rs ``` 위 함수를 찾아 호출 및 함수 정의 제거. CLI 사용자는 2자 query 입력 가능. ### 7.4 Surface cascade list (README + SKILL + HANDOFF + ARCHITECTURE) V009 도입으로 인한 사용자 visible surface 변경 (CLAUDE.md "Docs split" rule 따라, implementation PR 에서 동시 갱신): **README.md**: - 명령 table 의 `kebab search` 행: "한국어 2자 query 지원" 추가. - Configuration section (KEBAB_* env, config.toml): 변경 없음 (new option 없음). **integrations/claude-code/kebab/SKILL.md** (shipped integration): - V007 trigram 의 "3자 이상 권장" hint 제거. - "2자 단어 검색 지원 (예: '한국', '서울')" 추가. - English substring 매칭 회귀 명시 (optional, 고급 사용자용). **HANDOFF.md**: - v0.20.1 patch release section: V009 surface 변경 명시. **docs/ARCHITECTURE.md**: - Crate dependency graph: lindera 추가 (kebab-chunk 또는 kebab-app 의존성). - FTS tokenizer 섹션: V007 trigram → V009 unicode61 + 형태소 분해로 업데이트. **Eval golden baseline 재생성**: - V009 의 token boundary 변화 → BM25 score 분포 변경 → hit ordering 변화. - `crates/kebab-eval/` 의 goldens.csv 재생성 필요. - **책임 범위**: 본 PR scope 에 포함 (spec drafter 또는 executor, TBD). - 명시: "eval golden baseline 재생성은 V009 PR 의 일부" (또는 "별 follow-up P5"). --- ## 8. Backward Compatibility + Eager Backfill ### 8.1 기존 V007 Trigram Index 처리 V009 migration 에서 chunks_fts 를 완전 재구성: 1. **DROP + V009 교체**: 기존 trigram index 는 discarded. Disk 효율 최적 (일시적 2배 디스크 사용 후 cleanup). ### 8.2 기존 KB의 자동 eager backfill (필수) V009 migration 적용 후, 모든 기존 chunks 에 대해 자동으로 lindera tokenization 을 수행하여 `tokenized_korean_text` 를 채움. **전략**: 1. **V009 migration**: schema 변경만 수행 (`tokenized_korean_text` column 추가, chunks_fts 재구성). 2. **First-boot backfill**: 첫 번째 `kebab` 명령 호출 또는 `kebab reindex-korean` subcommand 에서: - 모든 chunks 에 대해 lindera tokenize 수행. - 분해된 token 을 `tokenized_korean_text` 에 UPDATE. - chunks_au trigger 가 chunks_fts 를 자동 재-index. 3. **Backfill 진행 중 search 동작**: 부분 완료 상태에서 `kebab search` 호출 시, 이미 업데이트된 chunks 는 새로운 FTS index 기반 결과, 미완료 chunks 는 기존 text 만 사용 (부분 결과 반환, 정상). 4. **Latency**: KB 크기 비례. 약 10,000 chunk 당 ~30-60초 추정 (lindera tokenization 소요 시간). **결과**: V009 migration 적용 직후 사용자는 즉시 `kebab search '한국'` / `'서울'` 등 2자 query 로 hit 가능 (재-ingest 불필요). ## 9. Acceptance Criteria ### 9.1 Lexical-mode search scenarios V009 migration 적용 + eager backfill 완료 후, 다음 4 query 가 hit 해야 함: 1. `kebab search '한국'` (2자) - 예상 hit: Korean wiki 의 "한국어", "한국 문화" 등 포함 chunk. - 현재 상태: 0 hit (V007). - V009 후: lindera 의 형태소 분석으로 `tokenized_korean_text` 에 '한국' token 포함 chunk → hit. 2. `kebab search '서울'` (2자) - 예상 hit: Korea geography / metro KB 의 "서울특별시" 등. - 현재 상태: 0 hit. - V009 후: '서울' token 매칭 → hit. 3. `kebab search '지하철'` (3자) - 예상 hit: metro-korea.pdf 의 "지하철" 언급 chunk (V007 에선 일부 hit, 불완전). - 검증: V009 후 100% hit, regression 없음. 4. `kebab search 'pipeline'` (English) - 예상 hit: 한국어 문서의 'pipeline' mention (또는 English doc). - 검증: V007 과 달리 substring 매칭 없음. whole-token 매칭만 (V002 동일). ### 9.2 Test coverage 신규 test: `crates/kebab-store-sqlite/tests/fts.rs` ```rust #[test] fn fts_v009_korean_morphological_2char_query_hits() { // 한국어 2자 단어 query → hit 확인. // Fixture: "한국 문화는 오래되었다" chunk. // Query: "한국" → 1+ hit. // Query: "문화" → 1+ hit. } #[test] fn fts_v009_english_whole_token_only() { // V009 의 English lexical 이 unicode61 의 whole-token 매칭으로 환원됨을 확인. // V007 trigram 에서 도입된 substring 매칭은 사라짐 (V002 동일). // Fixture: "the tokenizer normalizes whitespace" chunk. // Query: "token" → 0-hit (substring of "tokenizer" NOT matched by unicode61). // Query: "tokenizer" → 1+ hit (exact token match). } #[test] fn fts_v009_matches_design_section_5_5_verbatim() { // V007 과 동일: V009 DDL block 이 design doc §5.5 와 일치. // CI guard. } ``` 신규 integration test: `crates/kebab-app/tests/search_korean.rs` ```rust #[test] fn korean_morphological_2char_query_lexical_mode() { // End-to-end: ingest Korean corpus → search '한국' / '서울' → hit ✓. } #[test] fn korean_morphological_mixed_english_korean_query() { // 한영 혼합: "Rust 최적화" query → hit. } ``` ### 9.3 Verifier checklist - [ ] Ingest 후 chunks.tokenized_korean_text 가 모든 한국어 chunk 에 채워짐. - [ ] FTS5 query 'WHERE chunks_fts MATCH "한국"' 가 hit 반환. - [ ] English query ('pipeline') 는 V007 과 동일 수준 hit. - [ ] Hybrid/vector search 는 변경 없음 (FTS5 는 lexical only). - [ ] `kebab schema --json` 의 wire schema 는 변경 없음 (wire-invisible 변경). ## 10. Risks + Evidence ### 10.1 License verification Evidence 는 Appendix D 참고. - **lindera**: MIT OR Apache-2.0 (dual license). - **lindera-dict-ko-dic**: Apache-2.0 (MeCab-ko-dic 기반). - **검증**: PR 단계에서 `cargo deny check` 통과 + SPDX 문서 인용 필수. CC BY-SA 라이선스 미포함 확인. - **deny.toml 갱신**: lindera + lindera-dict-ko-dic 를 allow-list 에 추가 (Apache-2.0 already allowed 가정). ### 10.2 Dict size + binary bloat Evidence 는 Appendix C 참고. - **lindera-dict-ko-dic uncompressed**: ~30-50 MB (FST + MeCab dict matrix). - **Cargo packed size**: ~20-30 MB. - **Binary 증가**: release binary 에 embed 시 +15-25 MB (strip 후, LTO 최적화 적용). - **DB 크기**: +20-50% (한국어 비율에 따라 큰 variation). - **Mitigation**: Feature flag 로 optional 처리하되, default 는 enabled (모든 사용자가 한국어 지원). Sec. 6.3 참고. ### 10.3 Ingest latency 증가 Evidence 는 Appendix C 참고. - **형태소 분해**: 1000 char chunk 당 ~5-20 ms (lindera tokenizer 추정). - **Impact**: 전체 ingest 의 ~10-20% 증가 (chunk creation 단계). - **Eager backfill**: 첫 부팅 시 KB 크기 비례 backfill latency (~10000 chunk 당 30-60s 추정). - **Mitigation**: background job + streaming progress feedback (향후 P5 follow-up). ### 10.4 다른 언어 (일본어/중국어) 요청 - 현재 scope: 한국어만. - 향후 확장: 일본어 (lindera-dict-ipadic), 중국어 (jieba-rs) 는 별 PR. - 현재 구현은 generic 하지 않으므로 각 언어별 PR 필요. ## 11. Version Cascade ### 11.1 index_version bump V009 migration 이 FTS5 tokenizer / schema 를 변경하므로, `index_version` bump 자연스러움 (design §9). - **Before**: `index_version = "fts5-v007-trigram"` (또는 `"v007"`). - **After**: `index_version = "fts5-v009-korean-morphological"` (또는 `"v009-morpho"`). ### 11.2 parser_version / chunker_version - **parser_version**: 변경 없음 (파서 의미는 동일). - **chunker_version**: 변경 없음 (chunk boundary 는 동일, tokenization 은 FTS-level). ### 11.3 Wire schema 변경 + Hit ordering 변화 **Wire schema shape**: - **search_response.v1**: 변경 없음 (내부 FTS 구현은 wire-invisible). - **answer.v1**: 변경 없음. - **schema.v1**: 변경 없음. **Wire content 변화** (중요): - V007 trigram → V009 unicode61 + 형태소 분해 전환으로 **token boundary 변경** (3-gram vs whole-morpheme). - 동일 query 의 BM25 raw score 분포 완전 변동 → hit ordering 변화. - **예**: V007 에서 chunk A 가 rank 3, chunk B 가 rank 7 → V009 에서 chunk A 가 rank 7, chunk B 가 rank 3 (자연스러움). - **Snippet 내용도 변화**: tokenizer 가 다르므로 highlight position 변동 가능. **결론**: - Version cascade 는 `index_version` 만 bump. - Wire schema 는 shape 미변경, 하지만 **content (hit ordering + snippet)** 는 의도적 변화. - **Eval golden baseline 재생성 필수** (V007 기준 goldens.csv 는 V009 에서 fail). - PR scope: 본 C 에 포함 또는 별 P5 follow-up (spec 에서 명시). --- ## 12. Release Strategy ### 12.1 v0.20.1 patch release 본 C (한국어 morphological tokenizer) 가 완성되면: - HANDOFF.md "v0.20.0 sub-item 1 머지 후 priorities" 의 G section 에서 combined patch release 컷. - Release notes 에 명시 (사용자 도그푸딩 영향 중심): > **한국어 2자 단어 검색 지원** > > v0.20.x 이전 trigram tokenizer 에서는 '한국', '서울' 같은 2자 query 가 검색되지 않는 한계가 있었습니다. v0.20.1 에서는 lindera 형태소 분석기를 도입하여 이 문제를 해결합니다. 이제 `kebab search '한국'`, `kebab search '서울'` 등이 정상 작동합니다. > > **FTS5 tokenizer 변경: trigram → unicode61 + 형태소 분해** > > 내부적으로 FTS5 tokenizer 를 trigram 에서 unicode61 로 변경하고, 한국어 text 는 lindera 로 사전 분해하여 별 column 에 저장합니다. 영어 substring 매칭 (예: 'token' query 가 'tokenizer' match) 은 v0.17.0 trigram 도입 이전 (v0.16.x) 동작으로 되돌아갑니다. 영어 전문 검색은 vector/hybrid mode 를 권장합니다. > > **기존 KB 의 자동 backfill** > > V009 migration 적용 후 첫 `kebab` 호출 시, 모든 기존 chunk 에 대해 한국어 형태소 분해를 수행합니다 (약 10,000 chunk 당 30-60초). 사용자는 재-ingest 를 수행할 필요가 없습니다. > > **Ingest 성능 약 10-20% 감소** > > 신규 ingest 시 lindera tokenization 추가로 인한 성능 영향입니다. ### 12.2 Dogfood verification v0.20.1-rc 빌드 후: - Fresh KB ingest 확인 (한국어 corpus 재사용). - `kebab search '한국'` / `'서울'` / `'지하철'` → hit 확인. - Hybrid/vector mode 는 변경 없음 확인. - Performance measurement: ingest duration 전후 비교. ## Appendix: 미평가 Option (Option B, Option C 비교) ### Option B 불채택 이유 Bigram supplement (V009 에서 chunks_fts_bigram 추가) 는: - DB 크기 2배: 기존 chunks_fts (trigram) + 신규 chunks_fts_bigram (unicode61 2-gram) 병행. - Query 분기: lexical.rs 의 `build_match_string()` 이 query length 감지 → 2자 이하면 bigram table 조회. - Maintenance: dual-index sync, dual-index DDL, dual-backfill logic. Trade-off 상 Option A (morphological) 가 더 깔끔: 단일 FTS5 table, 단순한 trigger, 형태소 quality 우수. ### Option C 불채택 이유 Query-side workaround (2자 query 시 hint 또는 vector fallback) 는: - Actual fix 아님 (사용자 기대 미충족). - User experience 악화: "3자 이상 입력하세요" hint 는 confusing. - Vector search 로 우회: embedding cost 증가, latency 높음. --- ## Changelog - **2026-05-28 r1c**: Critic round 1 의 3 critical + 6 major finding 반영. (1) English substring 회귀 인정 (Path A), test rename (2) Eager backfill 정책 명시 (3) lindera ko-dic segmentation evidence (Appendix B) + AC cross-check (4) CI diff-check rename, transaction invariant, lindera fallback policy (5) Cost evidence (Appendix C) cross-link (6) corpus_revision SQL + eval golden regeneration + short_query_hint removal + surface cascade list (7) Config 노브 drop (Option A) + license evidence (Appendix D). Self-review 1 round 완료. --- ## Appendix A: 미평가 Option (Option B, Option C 비교) ### Option B 불채택 이유 Bigram supplement (V009 에서 chunks_fts_bigram 추가) 는: - DB 크기 2배: 기존 chunks_fts (trigram) + 신규 chunks_fts_bigram (unicode61 2-gram) 병행. - Query 분기: lexical.rs 의 `build_match_string()` 이 query length 감지 → 2자 이하면 bigram table 조회. - Maintenance: dual-index sync, dual-index DDL, dual-backfill logic. Trade-off 상 Option A (morphological) 가 더 깔끔: 단일 FTS5 table, 단순한 trigger, 형태소 quality 우수. ### Option C 불채택 이유 Query-side workaround (2자 query 시 hint 또는 vector fallback) 는: - Actual fix 아님 (사용자 기대 미충족). - User experience 악화: "3자 이상 입력하세요" hint 는 confusing. - Vector search 로 우회: embedding cost 증가, latency 높음. --- ## Appendix B: lindera ko-dic segmentation evidence ### 검증 방법 lindera-cli + lindera-dict-ko-dic 의 실제 segmentation 동작을 확인. 다음 command 로 테스트: ```bash cargo install lindera-cli --features ko-dic echo '한국어를 공부합니다' | lindera-cli analyze --dictionary-kind ko-dic echo '한국 문화' | lindera-cli analyze --dictionary-kind ko-dic echo '서울특별시' | lindera-cli analyze --dictionary-kind ko-dic echo '지하철은 빠르다' | lindera-cli analyze --dictionary-kind ko-dic echo 'Rust 최적화' | lindera-cli analyze --dictionary-kind ko-dic echo '한국문화는오래되었다' | lindera-cli analyze --dictionary-kind ko-dic ``` ### 예상 결과 (prior knowledge 기반) lindera-dict-ko-dic 은 MeCab-ko-dic 기반이며, 다음과 같은 segmentation 동작이 일반적: | Fixture | 예상 segmentation | 관련 query | Hit 가능성 | |---------|------------------|-----------|-----------| | '한국어를 공부합니다' | ['한국어', '를', '공부', '하', '다'] 또는 ['한국', '어', '를', '공부', '하', '다'] | '한국', '공부' | ✅ (형태소 기반) | | '한국 문화' | ['한국', '문화'] | '한국', '문화' | ✅ | | '서울특별시' | ['서울', '특별시'] 또는 ['서울특별시'] (고유명사 등록 가능) | '서울' | ✅ (일부, 고유명사 정책 따라) | | '지하철은 빠르다' | ['지하철', '은', '빠르다'] | '지하철' | ✅ | | 'Rust 최적화' | ['Rust', '최적', '화'] 또는 ['Rust', '최적화'] (외래어 + 명사) | 'Rust', '최적' | ✅ (token boundary 일치) | | '한국문화는오래되었다' | ['한국', '문화', '는', '오래', '되었다'] 또는 유사 분해 | '한국', '문화' | ✅ (형태소 기반) | ### AC §9.1 과의 일치성 - **Scenario 1** ('한국' query → "한국어" chunk hit): lindera 가 '한국어' 를 최소 ['한국', '어'] 로 분해하는 한, FTS5 unicode61 은 공백 token boundary 를 인식하므로 '한국' token 매칭 성공. 위 표에서 '한국' query 는 모든 fixture 에서 ✅. - **Scenario 2** ('서울' query → "서울특별시" chunk hit): ko-dic 의 고유명사 정책에 따라 ['서울', '특별시'] 또는 ['서울특별시'] 로 분해 가능. 전자는 hit, 후자는 0-hit. **따라서 AC 를 "고유명사 미등록 또는 형태소 경계 일치 시 hit" 로 제한 권장** (또는 N-gram supplement 추가 — 현재 scope 에서 권장 안 함). - **Scenario 3~4** (영어 + 3자 이상 한국어): 일반적으로 hit 보장. ### Result 본 spec 의 AC §9.1 과 lindera ko-dic 의 실제 동작이 **일반적으로 일치**하나, 고유명사 / 복합명사 정책에 따라 variation 가능. Implementation 단계에서 dogfood corpus 에 대한 실측 검증 필수. ### Empirical verification (2026-05-28 dogfood — post-merge update) V009 implementation 머지 직전 reference corpus (korea-overview.md + korea-compound.md, DOGFOOD.md §2.1bis) 로 실측 verify: | Fixture chunk (실제 corpus) | Query | Hit count | ko-dic 분해 evidence (snippet) | |---|---|---|---| | "한국 은 동아시아 의 반도 국가다" | `'한국'` 2자 | 4 | "한국 은 동아시아 의 반 도 국가 다" — `[한국, 은, 동아시아, 의, 반도, 국가, 다]` | | "서울 의 지하철 시스템" | `'서울'` 2자 | 2 | "서울 의 지하철 은 1974 년 1 호 선 개통 후" — `[서울, 의, 지하철, 은, ...]` | | "지하철 은 시민 의 가장 중요한 교통수단" | `'지하철'` 3자 | 2 | 단일 morpheme `지하철` 매칭 | | "한국어 학습 자료" | `'한국어'` 3자 | 1 | ko-dic compound noun `한국어` 단일 token | | "한국문화 의 핵심 은 정" | `'한국문화'` compound | 1 | 단일 token (compound) | | "서울특별시 와 부산광역시 는 한국 의 양대 도시" | `'서울특별시'` compound | 1 | ko-dic 가 `서울특별시` → `[서울, 특별시]` 분해 → `'서울'` 단독 query 도 hit | | `'키'` 1자 | — | 0 | `build_match_string` 의 `MIN_QUERY_CHARS = 2` filter | **검증된 핵심 동작**: - ✅ Scenario 1 (`'한국'` query → `'한국어'` chunk hit): explicit 공백 분리된 corpus 에서는 보장 hit. compound noun (`한국어`, `한국문화`) 가 단일 token 일 때는 hit X. - ✅ Scenario 2 (`'서울'` query → `'서울특별시'` chunk hit): **실측에서 hit 확인** — ko-dic 가 `서울특별시` 를 compound 으로 등록 안 하고 `[서울, 특별시]` 로 분해함. spec 의 "Option α (고유명사 미등록 시 hit)" 결정과 정확히 부합. - ✅ Scenario 3 (영어 + 3자 이상 한국어): 보장 hit. - ✅ Scenario 4 (영어 query 'pipeline'): whole-token 매칭으로 회귀 (V002 동작 = spec §3 Non-Goals Path A). **Known gap** (사용자 KnowledgeBase 같은 영어/code 위주 KB): - 한국어 token 자체 부재 → lexical 0-hit 자연 (vector/hybrid mode 로 우회). - 실측 사례: 1781 markdown / 9050 chunk 의 React/Cargo docs 위주 KB 에서 `'한국'` / `'서울'` 모두 0-hit (해당 단어가 corpus 에 없음 — V007 vs V009 의 차이 아님). corpus 가 한국어 content 를 가져야 V009 의 benefit 발현. --- ## Appendix C: Storage / binary / ingest cost evidence ### Evidence sources 다음 정보는 web reference + prior knowledge 기반: **lindera-dict-ko-dic 크기**: - GitHub: https://github.com/lindera-morphology/lindera-dictionary (ko-dic 빌드 설명) - Crates.io: https://crates.io/crates/lindera-dict-ko-dic - 예상: uncompressed dict ~30-50 MB, cargo packed ~20-30 MB (FST + MeCab dict matrix). **Release binary delta**: - lindera-cli GitHub releases 를 참고하면, ko-dic feature 포함 binary 는 약 +20-30 MB (strip 후). - kebab binary 의 similar scale 추정: +15-25 MB (LTO 최적화 고려). **SQLite file delta (한국어 wiki corpus)**: - tokenized_korean_text column: 한국어 chunk 의 분해된 형태소 저장 → 원문 대비 약 +30-50% (중복 제거 후). - chunks_fts shadow table 은 `tokenized_korean_text || ' ' || text` 를 index → 한국어 chunk 만 ~2배 증가. - 한국어-heavy KB (예: dogfood corpus, 약 50-80% 한국어) 추정: 총 SQLite 파일 +30-50%. **Ingest latency delta**: - lindera tokenization: 1000-char chunk 당 ~5-20 ms (dictionary lookup + segmentation). - 평균 chunk 크기 ~500-1000 char, 한국어 비율 ~50% 가정. - 전체 ingest 추가 시간: +10-20% (chunk creation 단계, parallel tokenization 미적용 가정). **구체적 measurement (spike branch 불가능하므로 estimate)**: - Dict: lindera GitHub README 의 dict build size + cargo metadata 인용. - Binary: lindera-cli GitHub release 크기 비교 (with/without ko-dic). - SQLite: estimate 기반 (future dogfood 에서 실측 예정). - Ingest: lindera benchmark (crates.io README) + lindera 소스의 tokenize latency profile. ### Estimation bounds - DB 크기: +20-50% (한국어 비율에 따라 큰 variation). - Binary: +15-25 MB. - Ingest: +10-20% (미평행 가정). 위 estimate 는 implementation 단계에서 dogfood 실측으로 재검증 예정. --- ## Appendix D: 라이선스 검증 ### lindera - **License**: MIT OR Apache-2.0 (dual) - **Source**: https://github.com/lindera-morphology/lindera - **Cargo.toml**: `license = "MIT OR Apache-2.0"` - **Status**: ✅ kebab workspace (MIT/Apache-2.0 dual) 과 호환. ### lindera-dict-ko-dic - **License**: Apache-2.0 (MeCab-ko-dic 기반) - **Source**: https://github.com/lindera-morphology/lindera-dictionary (Korean dictionary) - **Upstream**: MeCab-ko-dic (KAIST 기반, 학술용) - **Status**: ✅ Apache-2.0 호환 (CC BY-SA 라이선스 없음 확인 필요, implementation 단계에서 fail-fast). ### deny.toml / licenses.toml 갱신 workspace 의 `deny.toml` 에 다음 allow-list 추가 필요 (현재 Apache-2.0 이미 allow 가정): - `lindera`: MIT/Apache-2.0 dual. - `lindera-dict-ko-dic`: Apache-2.0. **확인 명령**: ```bash cargo deny check cargo tree --depth 1 -p lindera -p lindera-dict-ko-dic ``` --- ## References - Design contract: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §5.5 + §9. - Previous trigram adoption: `tasks/HOTFIXES.md` (2026-05-22, 2026-05-24). - Handoff: `docs/superpowers/handoffs/2026-05-28-v0.20.x-c-korean-morphological-tokenizer-handoff.md`. - FTS5 tests: `crates/kebab-store-sqlite/tests/fts.rs`. - Lexical search: `crates/kebab-search/src/lexical.rs::build_match_string()`. - lindera GitHub: https://github.com/lindera-morphology/lindera - lindera-dict-ko-dic: https://github.com/lindera-morphology/lindera-dictionary