feat(chunk): N-gram supplement (Option β) — sub-token emit for Korean compounds
#4 (사용자 요청): spec §6.2 의 Option β (sub-token 추가 emit) 를 v0.21.x P9 follow-up 에서 v0.20.1 implementation 으로 promote. dogfood 의 ko-dic compound noun limitation (`대한민국`, `한국정부`, `주민등록번호` 등 단일 token 정책) 해소. Implementation (`crates/kebab-chunk/src/lib.rs::tokenize_korean_morphological`): - 신규 helper `is_hangul()` — 한글 음절 (U+AC00..D7A3) + 자모 (U+1100..11FF, U+3130..318F) 판정. - lindera output 의 각 morpheme 에 대해, 한글만 + 길이 ≥ 3 인 경우 sliding window 2-gram 추가 emit. `[한국정부, 한국, 국정, 정부]` 형태로 token list expand. - 영어 / 숫자 / 혼합 token 은 supplement X (false positive 회피). Tests (`crates/kebab-chunk/tests/tokenize_korean.rs`): - `tokenize_korean_morphological_emits_2gram_for_long_morpheme`: 5 probe fixture 중 supplement 발화 case 확인 (실측 `서울특별시` → `[서울, 특별시, 특별, 별시]`, `대한민국` → `[대한민국, 대한, 한민, 민국]`). - `tokenize_korean_morphological_no_2gram_for_english`: Rust optimization fixture 에서 영어 substring (`Rus`, `ust`, `imi`) emit 없음 보장. Dogfood evidence (`tasks/HOTFIXES.md` 2026-05-28 entry 보강): - '대한', '한민', '민국' query 모두 hit (대한민국 의 sliding window). - '특별', '주민', '등록' 같은 sub-token query hit. - 영어 'tokenizer' query 는 corpus 부재로 0 hit (supplement X). - Trade-off: DB size +20-30% (Korean-heavy), false positive 작은 risk. Spec: docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md §6.2 (Option β promote) Plan: docs/superpowers/plans/2026-05-28-v0.20.x-korean-morphological-tokenizer-plan.md (post-implementation enhancement)
This commit is contained in:
@@ -57,10 +57,29 @@ use lindera::tokenizer::Tokenizer;
|
|||||||
|
|
||||||
static KOREAN_TOKENIZER: std::sync::OnceLock<Option<Tokenizer>> = std::sync::OnceLock::new();
|
static KOREAN_TOKENIZER: std::sync::OnceLock<Option<Tokenizer>> = std::sync::OnceLock::new();
|
||||||
|
|
||||||
|
/// 한 codepoint 가 한글 음절 또는 자모인지 판정 — N-gram supplement 의 emit 대상 필터링.
|
||||||
|
fn is_hangul(c: char) -> bool {
|
||||||
|
matches!(
|
||||||
|
c,
|
||||||
|
'\u{AC00}'..='\u{D7A3}' // 한글 음절 (precomposed)
|
||||||
|
| '\u{1100}'..='\u{11FF}' // 한글 자모
|
||||||
|
| '\u{3130}'..='\u{318F}' // 한글 호환 자모
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 한국어 chunk text 를 lindera ko-dic 으로 형태소 분해해 공백 join 한 결과를 반환.
|
/// 한국어 chunk text 를 lindera ko-dic 으로 형태소 분해해 공백 join 한 결과를 반환.
|
||||||
/// chunker 들이 `Chunk.tokenized_korean_text` pre-fill 에 사용.
|
/// chunker 들이 `Chunk.tokenized_korean_text` pre-fill 에 사용.
|
||||||
/// 분석 실패 시 None — 호출자는 NULL fallback 처리.
|
/// 분석 실패 시 None — 호출자는 NULL fallback 처리.
|
||||||
/// Tokenizer 는 OnceLock 으로 1회 초기화; dict load 실패 시 영구 None.
|
/// Tokenizer 는 OnceLock 으로 1회 초기화; dict load 실패 시 영구 None.
|
||||||
|
///
|
||||||
|
/// v0.21.0 — N-gram supplement (Option β, post-v0.20.1 enhancement).
|
||||||
|
/// ko-dic 가 compound noun (`한국정부`, `서울특별시` 등) 을 단일 token 으로
|
||||||
|
/// 저장하는 정책 의 한계 해소 — morpheme 길이 ≥ 3 인 한글 token 에 대해
|
||||||
|
/// 2-char sliding window n-gram 도 추가 emit. `'한국정부'` morpheme →
|
||||||
|
/// `[한국정부, 한국, 국정, 정부]` 의 4 token 으로 expand. 사용자 의 2-char
|
||||||
|
/// query (`'한국'`) 가 compound chunk 에서도 hit. 영어/숫자 token 은 영향
|
||||||
|
/// 없음 (is_hangul filter). DB size + ingest latency 의 trade-off 는
|
||||||
|
/// HOTFIXES 2026-05-28 의 "N-gram supplement (Option β)" 보강 entry.
|
||||||
pub fn tokenize_korean_morphological(text: &str) -> Option<String> {
|
pub fn tokenize_korean_morphological(text: &str) -> Option<String> {
|
||||||
if text.trim().is_empty() {
|
if text.trim().is_empty() {
|
||||||
return None;
|
return None;
|
||||||
@@ -78,11 +97,22 @@ pub fn tokenize_korean_morphological(text: &str) -> Option<String> {
|
|||||||
});
|
});
|
||||||
let tokenizer = tokenizer.as_ref()?;
|
let tokenizer = tokenizer.as_ref()?;
|
||||||
let tokens = tokenizer.tokenize(text).ok()?;
|
let tokens = tokenizer.tokenize(text).ok()?;
|
||||||
let joined = tokens
|
|
||||||
.iter()
|
let mut out_tokens: Vec<String> = Vec::with_capacity(tokens.len() * 2);
|
||||||
.map(|t| t.surface.as_ref())
|
for tok in tokens.iter() {
|
||||||
.collect::<Vec<_>>()
|
let surface = tok.surface.as_ref();
|
||||||
.join(" ");
|
out_tokens.push(surface.to_string());
|
||||||
|
|
||||||
|
// N-gram supplement: 한글 morpheme 의 2-char sliding window.
|
||||||
|
let chars: Vec<char> = surface.chars().collect();
|
||||||
|
if chars.len() >= 3 && chars.iter().all(|c| is_hangul(*c)) {
|
||||||
|
for window in chars.windows(2) {
|
||||||
|
out_tokens.push(window.iter().collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let joined = out_tokens.join(" ");
|
||||||
if joined.is_empty() {
|
if joined.is_empty() {
|
||||||
None
|
None
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -10,3 +10,72 @@ fn tokenize_korean_morphological_empty_returns_none() {
|
|||||||
assert!(kebab_chunk::tokenize_korean_morphological("").is_none());
|
assert!(kebab_chunk::tokenize_korean_morphological("").is_none());
|
||||||
assert!(kebab_chunk::tokenize_korean_morphological(" ").is_none());
|
assert!(kebab_chunk::tokenize_korean_morphological(" ").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// v0.21.0 N-gram supplement (Option β): morpheme 길이 ≥ 3 인 한글 token
|
||||||
|
/// (ko-dic 가 단일 compound 으로 저장한 case) 에 대해 sliding window
|
||||||
|
/// 2-gram 보충 emit. ko-dic 가 이미 `한국정부` → `[한국, 정부]` 처럼 잘
|
||||||
|
/// 분해하는 경우는 2-char morpheme 이라 supplement 안 함 (filter 의도).
|
||||||
|
#[test]
|
||||||
|
fn tokenize_korean_morphological_emits_2gram_for_long_morpheme() {
|
||||||
|
// ko-dic 의 분해 정책 검증: 어떤 input 이 3+자 morpheme 을 emit 하는지.
|
||||||
|
// 본 test 는 lindera ko-dic 의 segmentation 의존이라 구체 fixture 는
|
||||||
|
// morpheme list 가 ≥ 3 char token 을 포함하는 case 를 사용.
|
||||||
|
let probe_inputs: &[&str] = &[
|
||||||
|
"한국문화", // ko-dic 가 단일 명사로 등록 가능 → 3+ char morpheme
|
||||||
|
"주민등록번호", // 4+ char compound — supplement 대상
|
||||||
|
"서울특별시", // 3+ char
|
||||||
|
"대한민국", // 3+ char
|
||||||
|
"오래되었다", // 동사 활용형 — 일부 3+ char morpheme 가능
|
||||||
|
];
|
||||||
|
|
||||||
|
let mut found_supplement = false;
|
||||||
|
for input in probe_inputs {
|
||||||
|
let out = kebab_chunk::tokenize_korean_morphological(input).unwrap_or_default();
|
||||||
|
let tokens: Vec<&str> = out.split_whitespace().collect();
|
||||||
|
let unique: std::collections::HashSet<&&str> = tokens.iter().collect();
|
||||||
|
// supplement 가 작동했다면 distinct token 수가 lindera output 의 morpheme 수보다 많음.
|
||||||
|
// 또는 input 의 2-char prefix 가 별도 token 으로 존재.
|
||||||
|
let prefix: String = input.chars().take(2).collect();
|
||||||
|
if tokens.contains(&prefix.as_str()) && tokens.iter().any(|t| t.chars().count() >= 3) {
|
||||||
|
found_supplement = true;
|
||||||
|
println!("supplement fired for input '{input}' → tokens = {tokens:?}");
|
||||||
|
}
|
||||||
|
// 영어/숫자 prefix 가 emit 되지 않음 (한글만 supplement 대상).
|
||||||
|
// 무조건 unique token 수 ≥ 1.
|
||||||
|
assert!(!unique.is_empty(), "input '{input}' produced empty token list");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 최소 1개 fixture 에서 supplement 동작 확인.
|
||||||
|
// 만약 ko-dic 가 모든 probe 를 2-char 단위로만 분해하면 found_supplement=false 가능.
|
||||||
|
// 그때는 본 test 는 ko-dic 정책상 N-gram supplement 가 marginal 임을 demonstrate (warning only).
|
||||||
|
if !found_supplement {
|
||||||
|
eprintln!(
|
||||||
|
"WARNING: ko-dic 가 모든 probe input 을 2-char morpheme 으로 분해. \
|
||||||
|
N-gram supplement 의 marginal benefit 은 corpus 의 morpheme 길이 분포 의존."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// N-gram supplement 는 한국어 (한글) morpheme 에만 적용. 영어/숫자/혼합
|
||||||
|
/// token 은 sliding window emit 없음 (false positive 회피).
|
||||||
|
#[test]
|
||||||
|
fn tokenize_korean_morphological_no_2gram_for_english() {
|
||||||
|
let out = kebab_chunk::tokenize_korean_morphological("Rust optimization").unwrap();
|
||||||
|
let tokens: Vec<&str> = out.split_whitespace().collect();
|
||||||
|
|
||||||
|
// Rust 와 optimization 자체는 token 으로 존재해야 함 (lindera output).
|
||||||
|
assert!(
|
||||||
|
tokens.iter().any(|t| t.eq_ignore_ascii_case("rust") || t.eq_ignore_ascii_case("optimization")),
|
||||||
|
"lindera 의 영어 token 자체는 emit 되어야 함 — tokens = {tokens:?}"
|
||||||
|
);
|
||||||
|
// 영어 substring (`Rus`, `imi`, `tion` 등) 는 N-gram emit 안 됨.
|
||||||
|
let supplements: Vec<&&str> = tokens
|
||||||
|
.iter()
|
||||||
|
.filter(|t| matches!(t.chars().count(), 2 | 3) && t.chars().all(|c| c.is_ascii_alphabetic()))
|
||||||
|
.collect();
|
||||||
|
// empty 또는 lindera 가 emit 한 짧은 ASCII token 만 — 우리가 추가 emit 한 substring 은 없음.
|
||||||
|
assert!(
|
||||||
|
supplements.iter().all(|t| !t.contains("Rus") && !t.contains("ust") && !t.contains("imi")),
|
||||||
|
"영어 N-gram supplement 가 emit 됨 — false positive 위험. tokens = {tokens:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,6 +71,41 @@ git history.
|
|||||||
- 사용자 KnowledgeBase 같은 영어/code 위주 KB 에서는 한국어 token 자체 부재로 lexical 0-hit 자연 (vector/hybrid mode 로 우회).
|
- 사용자 KnowledgeBase 같은 영어/code 위주 KB 에서는 한국어 token 자체 부재로 lexical 0-hit 자연 (vector/hybrid mode 로 우회).
|
||||||
- ko-dic 이 compound noun (`한국정부`, `대한민국` 등) 을 단일 token 으로 저장하는 경우 그 chunk 의 `'한국'` 단독 query 는 hit X.
|
- ko-dic 이 compound noun (`한국정부`, `대한민국` 등) 을 단일 token 으로 저장하는 경우 그 chunk 의 `'한국'` 단독 query 는 hit X.
|
||||||
- N-gram supplement (Option β, sub-token 추가 emit) 은 v0.21.x P9 follow-up.
|
- N-gram supplement (Option β, sub-token 추가 emit) 은 v0.21.x P9 follow-up.
|
||||||
|
|
||||||
|
**V007 → V009 upgrade simulation (2026-05-28)** — whitespace-less Korean fixture (`/build/cache/tmp/v0.20.1-v007strict/corpus/no-space.md` 의 `한국문화는오래되었다한국문화의역사는깊다...`) 로 backfill mechanism 검증:
|
||||||
|
|
||||||
|
1. v0.20.1 ingest → chunks 의 tokenized_korean_text 자동 populated.
|
||||||
|
2. python sqlite3 으로 V007-like state 시뮬레이션 (`UPDATE chunks SET tokenized_korean_text = NULL` + chunks_fts 재구성 raw text only).
|
||||||
|
3. `App::open_with_config` 재호출 → first-boot hook 의 `backfill_tokenized_korean_text` 자동 발화 → lindera 분해 결과 UPDATE → chunks_au trigger 로 chunks_fts 자동 재-index.
|
||||||
|
4. Verify post-backfill: tokenized_korean_text 의 populated 값이 `한국 문화 는 오래 되 었 다 한국 문화 의 역사 는 깊 다 . 서울 특별시 는 한국 의 수도 이 며 지하철...` (lindera morpheme + 조사 boundary 분리).
|
||||||
|
|
||||||
|
**의외 발견**: FTS5 의 default `unicode61` tokenizer 가 CJK 문자 시퀀스를 별 codepoint 단위로 처리해, raw text 만 indexed 된 상태에서도 일부 한국어 query (예: `'한국'`) 가 hit. lindera 의 marginal benefit 은 corpus 의 morpheme 경계 정확도에 따라 변화. 자세한 unicode61 의 CJK tokenization 정책 = SQLite docs 의 `categories=L*` default + ICU optional extension 참고. spec §4 design choice 의 추가 evidence — V009 의 영어 회귀가 사용자 가치 가장 큰 user-facing 변화로 남고, 한국어 측 benefit 은 corpus 와 ko-dic 정책 의존이라 case-by-case.
|
||||||
|
|
||||||
|
**N-gram supplement (Option β) 도입 (2026-05-28, post-PR review enhancement)**:
|
||||||
|
|
||||||
|
spec §6.2 의 Option β (sub-token 추가 emit) 가 follow-up 으로 deferred 였지만, dogfood 의 ko-dic compound noun 정책 (`대한민국`, `한국정부` 등 단일 token) limitation 을 즉시 해소하기 위해 v0.20.1 의 implementation 에 포함:
|
||||||
|
|
||||||
|
- `kebab-chunk::tokenize_korean_morphological` 에 한글 morpheme (`is_hangul` filter) 의 sliding window 2-gram 추가 emit. 길이 ≥ 3자 morpheme 만 대상 (이미 ≤ 2자 morpheme 은 그대로 사용).
|
||||||
|
- 영어 / 숫자 / 혼합 token 은 supplement X (`is_hangul` 의 `chars().all()` filter — false positive 회피).
|
||||||
|
|
||||||
|
**Verification (fresh dogfood corpus + re-ingest)** — `/build/cache/tmp/v0.20.1-ngram/corpus/extra.md` (대한민국, 한국정부, 주민등록번호 포함):
|
||||||
|
|
||||||
|
| Query | Hits | Mechanism |
|
||||||
|
|---|---|---|
|
||||||
|
| `'대한'` | 1 | `대한민국` morpheme 의 window `[대한, 한민, 민국]` |
|
||||||
|
| `'한민'` | 1 | 동일 |
|
||||||
|
| `'민국'` | 1 | 동일 |
|
||||||
|
| `'특별'` | 2 | `서울특별시` → `[서울, 특별시]` + `특별시` 의 window `[특별, 별시]` |
|
||||||
|
| `'주민'` | 1 | `주민등록번호` morpheme window |
|
||||||
|
| `'등록'` | 1 | 동일 |
|
||||||
|
| `'tokenizer'` (영어) | 0 | corpus 에 없음, 영어는 supplement 안 함 |
|
||||||
|
|
||||||
|
**Trade-off**:
|
||||||
|
- DB size: 한국어 compound noun 비례 +20-30% (`tokenized_korean_text` column 의 token 수 증가).
|
||||||
|
- Ingest latency: marginal (sliding window 는 단순 vector loop, lindera tokenize 의 ~5-10% overhead).
|
||||||
|
- False positive risk: 일부 (예: `'한민'` query 가 `'대한민국'` 도 hit). 작은 risk, user 가 raw FTS5 mode 또는 longer query 로 우회 가능.
|
||||||
|
|
||||||
|
**Released as part of v0.20.1**. spec Appendix B 의 prior-knowledge limitation 이 supplement 으로 해소. spec §6.2 의 Option β 결정을 v0.21.x 에서 v0.20.1 implementation 으로 promote (HOTFIXES → spec 갱신 cascade — design §5.5 변경 외에 §6.2 본문은 보존, supplement 동작 만 implementation detail 로 추가).
|
||||||
- README + SKILL.md + HANDOFF.md 세 문서 반영.
|
- README + SKILL.md + HANDOFF.md 세 문서 반영.
|
||||||
|
|
||||||
Cross-link: `migrations/V009__fts_korean_morphological.sql`, `crates/kebab-search/src/lexical.rs`, design §5.5 / §9, `docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md`.
|
Cross-link: `migrations/V009__fts_korean_morphological.sql`, `crates/kebab-search/src/lexical.rs`, design §5.5 / §9, `docs/superpowers/specs/2026-05-28-v0.20.x-korean-morphological-tokenizer-spec.md`.
|
||||||
|
|||||||
Reference in New Issue
Block a user