From b1c5feb3f360e0fa46d885336aeeefc24353ea28 Mon Sep 17 00:00:00 2001 From: altair823 Date: Tue, 2 Jun 2026 21:36:44 +0000 Subject: [PATCH] =?UTF-8?q?refactor(core):=20Chunk.aliases=20=ED=95=84?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit doc-side expansion(별칭) 제거 — Chunk 의 aliases: Option 필드와 serde default 테스트 제거. Metadata.aliases(Vec, 문서 메타)는 유지. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/kebab-app/src/expansion.rs | 274 ------------------ crates/kebab-core/src/chunk.rs | 12 +- .../kebab-store-sqlite/tests/chunk_aliases.rs | 220 -------------- 3 files changed, 2 insertions(+), 504 deletions(-) delete mode 100644 crates/kebab-app/src/expansion.rs delete mode 100644 crates/kebab-store-sqlite/tests/chunk_aliases.rs diff --git a/crates/kebab-app/src/expansion.rs b/crates/kebab-app/src/expansion.rs deleted file mode 100644 index c57d882..0000000 --- a/crates/kebab-app/src/expansion.rs +++ /dev/null @@ -1,274 +0,0 @@ -//! 색인시 doc-side expansion (Phase 2) — 청크당 "검색용 별칭" 생성. -//! -//! 설계 spec docs/superpowers/specs/2026-05-30-doc-side-expansion-design.md §3.2 / §5. - -use kebab_core::{Chunk, GenerateRequest, LanguageModel}; - -/// 별칭 1줄의 최대 글자 수(이 이상은 문장형/환각으로 보고 drop). -const MAX_ALIAS_CHARS: usize = 120; - -/// 별칭 프롬프트 템플릿 버전. derivation cache 의 alias version_key 에 포함되어 -/// (§3.1), 프롬프트를 바꾸면 bump 해 캐시를 무효화한다(전부 miss → 재생성). -/// `build_request` 의 gemma 프롬프트와 한 쌍 — 프롬프트 수정 시 함께 bump. -pub const PROMPT_VERSION: &str = "expansion-v1"; - -/// 청크당 검색용 별칭을 생성한다. -/// -/// 반환: 검증·상한 적용된 별칭들을 개행 join 한 문자열. 생성 0개 / LLM -/// 실패 / 빈 출력이면 `None` (호출측은 chunk.aliases 를 None 으로 두고 진행). -pub struct ExpansionGenerator<'a> { - llm: &'a dyn LanguageModel, - max_aliases: usize, -} - -impl<'a> ExpansionGenerator<'a> { - pub fn new(llm: &'a dyn LanguageModel, max_aliases: usize) -> Self { - Self { llm, max_aliases } - } - - /// gemma 프롬프트(expansion-v1)를 구성한다. (self 미사용 — associated fn.) - fn build_request(chunk: &Chunk) -> GenerateRequest { - let heading = chunk.heading_path.join(" > "); - let system = "당신은 검색 색인용 별칭 생성기다. 주어진 문단을 찾을 사용자가 \ -입력할 법한 짧은 검색어/질문을 생성한다. 동의어·풀어쓴 표현을 포함하라. \ -문단이 한국어면 영어 표현도, 영어면 한국어 표현도 섞어라. \ -한 줄에 하나씩, 설명·번호·머리기호 없이 검색어만 출력하라." - .to_string(); - let user = format!( - "제목 경로: {heading}\n\n문단:\n{}\n\n검색 별칭(한 줄에 하나):", - chunk.text - ); - GenerateRequest { - system, - user, - stop: vec![], - max_tokens: 256, - temperature: 0.0, - seed: Some(0), - images: vec![], - } - } - - pub fn generate(&self, chunk: &Chunk) -> Option { - // 나무위키 네비게이션 boilerplate 청크는 LLM 호출 없이 skip — 별칭 - // 생성 가치가 없고 노이즈 sentinel 벡터만 만든다. - if is_nav_boilerplate(chunk) { - return None; - } - let req = Self::build_request(chunk); - let raw = match self.llm.generate_stream(req) { - Ok(iter) => { - let mut acc = String::new(); - for ch in iter { - match ch { - Ok(kebab_core::TokenChunk::Token(t)) => acc.push_str(&t), - Ok(kebab_core::TokenChunk::Done { .. }) => {} - Err(_) => return None, // fail-soft - } - } - acc - } - Err(_) => return None, // fail-soft (connection refused 등) - }; - let aliases = parse_aliases(&raw, self.max_aliases); - if aliases.is_empty() { - None - } else { - Some(aliases.join("\n")) - } - } -} - -/// 나무위키 네비게이션 boilerplate 청크 판정. -/// -/// heading_path 가 비어 있고(문서 본문 섹션이 아닌 머리/꼬리 nav), text 앞부분에 -/// nav 키워드("최근 변경" 등)가 하나라도 있으면 boilerplate 로 본다. 둘 다 -/// 만족할 때만 true — 정상 본문(heading 있음, 또는 nav 키워드 없음)은 false. -pub fn is_nav_boilerplate(chunk: &Chunk) -> bool { - const NAV_KEYWORDS: [&str; 5] = [ - "최근 변경", - "Recent changes", - "최근 토론", - "특수 기능", - "편집 토론 역사", - ]; - if !chunk.heading_path.is_empty() { - return false; - } - let head: String = chunk.text.chars().take(200).collect(); - NAV_KEYWORDS.iter().any(|kw| head.contains(kw)) -} - -/// 줄 선두의 목록 마커만 1회 제거한다. **마커 뒤 공백이 필수** — 별칭 내용이 -/// 숫자/하이픈/별표로 시작하는 경우(예: "3D 렌더링", "-fast", "2단계")는 보존한다. -/// (Task 4 리뷰 MAJOR-1: 탐욕적 `trim_start_matches` 가 정당한 별칭을 손상시키던 버그 수정.) -fn strip_list_marker(s: &str) -> &str { - // 1) 머리기호 + 공백 ("- " / "* " / "• "). - for marker in ["- ", "* ", "• "] { - if let Some(rest) = s.strip_prefix(marker) { - return rest.trim_start(); - } - } - // 2) 번호 + ('.' | ')') + 공백 ("1. " / "2) "). 마커 뒤 공백이 없으면 - // ("3D", "2단계") 번호가 아니라 내용으로 보고 보존. - let digit_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()); - if digit_end > 0 { - let after = &s[digit_end..]; - if let Some(rest) = after.strip_prefix(". ").or_else(|| after.strip_prefix(") ")) { - return rest.trim_start(); - } - } - s -} - -/// LLM 출력 문자열 → 검증된 별칭 리스트. -/// 줄 단위 split → trim → 목록 마커 1회 제거 → 빈 줄·과길이 drop → -/// 중복 제거 → 상한 N. -fn parse_aliases(raw: &str, max_aliases: usize) -> Vec { - let mut out: Vec = Vec::new(); - for line in raw.lines() { - let t = strip_list_marker(line.trim()); - if t.is_empty() || t.chars().count() > MAX_ALIAS_CHARS { - continue; - } - let s = t.to_string(); - if !out.contains(&s) { - out.push(s); - } - if out.len() >= max_aliases { - break; - } - } - out -} - -#[cfg(test)] -mod tests { - use super::*; - use kebab_core::{ChunkId, ChunkerVersion, DocumentId, FinishReason, TokenUsage}; - use kebab_llm::MockLanguageModel; - - fn mk_chunk(text: &str) -> Chunk { - Chunk { - chunk_id: ChunkId("c1".into()), - doc_id: DocumentId("d1".into()), - block_ids: vec![], - text: text.into(), - heading_path: vec!["Guide".into()], - source_spans: vec![], - token_estimate: 3, - chunker_version: ChunkerVersion("md-heading-v1".into()), - policy_hash: "h".into(), - tokenized_korean_text: None, - aliases: None, - } - } - - fn mock(resp: &str) -> MockLanguageModel { - MockLanguageModel { - model_id: "gemma4:e4b".into(), - provider: "ollama".into(), - context_tokens: 32768, - canned_response: resp.into(), - canned_finish: FinishReason::Stop, - canned_usage: TokenUsage { - prompt_tokens: 0, - completion_tokens: 0, - latency_ms: 0, - }, - } - } - - #[test] - fn parses_lines_strips_bullets_and_caps() { - let llm = mock("- 메모리 안전성\n1. who owns the value\nborrow checker\n\n* 소유권"); - let generator = ExpansionGenerator::new(&llm, 2); - let out = generator.generate(&mk_chunk("Rust ownership")).unwrap(); - // 상한 2 → 앞 2개만, 접두 제거됨. - assert_eq!(out, "메모리 안전성\nwho owns the value"); - } - - #[test] - fn drops_overlong_lines() { - let long = "x".repeat(200); - let llm = mock(&format!("{long}\n짧은 별칭")); - let generator = ExpansionGenerator::new(&llm, 8); - let out = generator.generate(&mk_chunk("t")).unwrap(); - assert_eq!(out, "짧은 별칭", "120자 초과 줄은 drop"); - } - - #[test] - fn empty_output_returns_none() { - let llm = mock(" \n\n"); - let generator = ExpansionGenerator::new(&llm, 8); - assert_eq!(generator.generate(&mk_chunk("t")), None); - } - - /// Task 4 리뷰 MAJOR-1 회귀: 숫자/하이픈/별표로 시작하는 정당한 별칭은 - /// 손상 없이 보존돼야 한다(목록 마커는 마커 뒤 공백이 있을 때만 제거). - #[test] - fn preserves_numeric_and_dash_leading_aliases() { - let llm = mock("3D 렌더링\n2단계 커밋\n-fast 플래그\n- 메모리 안전성\n1. 첫 항목"); - let generator = ExpansionGenerator::new(&llm, 8); - let out = generator.generate(&mk_chunk("graphics")).unwrap(); - // 마커 없는 선두 숫자/하이픈은 보존; "- "/"1. " 만 마커로 제거. - assert_eq!(out, "3D 렌더링\n2단계 커밋\n-fast 플래그\n메모리 안전성\n첫 항목"); - } - - fn mk_chunk_nav(text: &str, heading: Vec) -> Chunk { - let mut c = mk_chunk(text); - c.heading_path = heading; - c - } - - #[test] - fn nav_boilerplate_skips_alias_generation() { - // heading 없음 + nav 키워드 → boilerplate → LLM 호출 전에 None. - let llm = mock("별칭1\n별칭2"); - let generator = ExpansionGenerator::new(&llm, 8); - let chunk = mk_chunk_nav("최근 변경 최근 토론 특수 기능", vec![]); - assert_eq!(generator.generate(&chunk), None); - } - - #[test] - fn normal_body_chunk_generates_aliases() { - // heading 없지만 nav 키워드도 없음 → 정상 본문 → 별칭 생성. - let llm = mock("별칭1\n별칭2"); - let generator = ExpansionGenerator::new(&llm, 8); - let chunk = mk_chunk_nav("러스트의 소유권과 빌림 검사기 개요", vec![]); - assert_eq!(generator.generate(&chunk).unwrap(), "별칭1\n별칭2"); - } - - #[test] - fn nav_keyword_with_heading_is_not_boilerplate() { - // nav 키워드가 있어도 heading 이 있으면 본문 섹션 → 생성. - let llm = mock("별칭1"); - let generator = ExpansionGenerator::new(&llm, 8); - let chunk = mk_chunk_nav("최근 변경 내역 설명", vec!["문서 변경사항".into()]); - assert_eq!(generator.generate(&chunk).unwrap(), "별칭1"); - } - - #[test] - fn is_nav_boilerplate_unit() { - assert!(is_nav_boilerplate(&mk_chunk_nav("Recent changes list", vec![]))); - assert!(is_nav_boilerplate(&mk_chunk_nav("편집 토론 역사", vec![]))); - assert!(!is_nav_boilerplate(&mk_chunk_nav("일반 본문 텍스트", vec![]))); - assert!(!is_nav_boilerplate(&mk_chunk_nav( - "최근 변경", - vec!["섹션".into()] - ))); - } - - #[test] - fn strip_list_marker_unit() { - assert_eq!(strip_list_marker("- 메모리"), "메모리"); - assert_eq!(strip_list_marker("* 소유권"), "소유권"); - assert_eq!(strip_list_marker("1. who owns"), "who owns"); - assert_eq!(strip_list_marker("2) 항목"), "항목"); - // 마커 뒤 공백 없음 → 보존. - assert_eq!(strip_list_marker("3D 렌더링"), "3D 렌더링"); - assert_eq!(strip_list_marker("-fast"), "-fast"); - assert_eq!(strip_list_marker("2단계"), "2단계"); - assert_eq!(strip_list_marker("2.0 릴리스"), "2.0 릴리스"); - } -} diff --git a/crates/kebab-core/src/chunk.rs b/crates/kebab-core/src/chunk.rs index eaa81db..eed2ec4 100644 --- a/crates/kebab-core/src/chunk.rs +++ b/crates/kebab-core/src/chunk.rs @@ -28,13 +28,6 @@ pub struct Chunk { /// Bug #8 (한국어 2자 query) 해결을 위한 V009 cascade. #[serde(default)] pub tokenized_korean_text: Option, - /// 색인시 doc-side expansion (Phase 2) 으로 생성된 "검색용 별칭" - /// (같은언어 paraphrase + 한↔영 번역, 개행 join). `[ingest.expansion]` - /// flag off 또는 미생성이면 None — 별도 FTS5 테이블 `chunk_aliases_fts` - /// 에만 색인되고 본문 매칭/dense 임베딩에는 영향 없음. 설계 spec - /// `2026-05-30-doc-side-expansion-design.md` §3.3. - #[serde(default)] - pub aliases: Option, } #[cfg(test)] @@ -42,8 +35,8 @@ mod tests { use super::*; #[test] - fn aliases_defaults_to_none_on_deserialize() { - // aliases 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]). + fn tokenized_korean_text_defaults_to_none_on_deserialize() { + // tokenized_korean_text 필드가 없는 과거 JSON 도 파싱되어야 한다 (#[serde(default)]). let json = r#"{ "chunk_id": "c1", "doc_id": "d1", @@ -56,7 +49,6 @@ mod tests { "policy_hash": "abc" }"#; let c: Chunk = serde_json::from_str(json).unwrap(); - assert_eq!(c.aliases, None); assert_eq!(c.tokenized_korean_text, None); } } diff --git a/crates/kebab-store-sqlite/tests/chunk_aliases.rs b/crates/kebab-store-sqlite/tests/chunk_aliases.rs deleted file mode 100644 index cbfc818..0000000 --- a/crates/kebab-store-sqlite/tests/chunk_aliases.rs +++ /dev/null @@ -1,220 +0,0 @@ -//! V010 doc-side expansion: `put_chunks` 가 `chunk.aliases` 를 chunks.aliases -//! 컬럼에 영속화하고, chunk_aliases_ai trigger 가 별도 `chunk_aliases_fts` -//! 가상 테이블로 mirror 하는지 검증. -//! -//! `put_chunks` 는 store-owned conn(FK ON)에서 도므로 chunks 의 -//! `doc_id REFERENCES documents(doc_id)` FK 를 만족시키려면 asset + -//! document 그래프가 먼저 있어야 한다. 헬퍼는 `idempotency.rs` 패턴 복제. -//! 인덱싱 검증은 side-channel `env.with_conn` 으로 chunk_aliases_fts 를 직접 -//! MATCH 한다(같은 established 패턴). - -use std::path::PathBuf; - -use kebab_core::{ - AssetId, AssetStorage, Block, CanonicalDocument, Checksum, Chunk, ChunkerVersion, CommonBlock, - DocumentId, DocumentStore, HeadingBlock, Lang, MediaType, Metadata, ParserVersion, Provenance, - SourceSpan, SourceType, SourceUri, TextBlock, TrustLevel, WorkspacePath, -}; -use kebab_store_sqlite::SqliteStore; -use time::OffsetDateTime; - -mod common; - -fn make_asset() -> kebab_core::RawAsset { - let bytes = b"dummy"; - kebab_core::RawAsset { - asset_id: AssetId("a".repeat(32)), - source_uri: SourceUri::File(PathBuf::from("/tmp/foo.md")), - workspace_path: WorkspacePath::new("notes/foo.md".into()).unwrap(), - media_type: MediaType::Markdown, - byte_len: bytes.len() as u64, - checksum: Checksum(blake3::hash(bytes).to_hex().to_string()), - discovered_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), - stored: AssetStorage::Reference { - path: PathBuf::from("/tmp/foo.md"), - sha: Checksum(blake3::hash(bytes).to_hex().to_string()), - }, - } -} - -fn make_metadata() -> Metadata { - Metadata { - aliases: vec![], - tags: vec![], - created_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), - updated_at: OffsetDateTime::from_unix_timestamp(1_700_000_000).unwrap(), - source_type: SourceType::Markdown, - trust_level: TrustLevel::Primary, - user_id_alias: None, - user: Default::default(), - repo: None, - git_branch: None, - git_commit: None, - code_lang: None, - } -} - -fn make_doc() -> CanonicalDocument { - let doc_id = DocumentId("d".repeat(32)); - let span = SourceSpan::Line { start: 1, end: 1 }; - let block = Block::Heading(HeadingBlock { - common: CommonBlock { - block_id: kebab_core::BlockId("b".repeat(32)), - heading_path: vec![], - source_span: span.clone(), - }, - level: 1, - text: "Title".into(), - }); - let para = Block::Paragraph(TextBlock { - common: CommonBlock { - block_id: kebab_core::BlockId("c".repeat(32)), - heading_path: vec!["Title".into()], - source_span: span, - }, - text: "body".into(), - inlines: vec![], - }); - CanonicalDocument { - doc_id, - source_asset_id: AssetId("a".repeat(32)), - workspace_path: WorkspacePath::new("notes/foo.md".into()).unwrap(), - title: "Title".into(), - lang: Lang("en".into()), - blocks: vec![block, para], - metadata: make_metadata(), - provenance: Provenance { events: vec![] }, - parser_version: ParserVersion("test-parser".into()), - schema_version: 1, - doc_version: 1, - last_chunker_version: None, - last_embedding_version: None, - } -} - -/// 단일 청크 생성. `aliases` 만 호출측이 지정. -fn base_chunk(chunk_id: &str, doc_id: &DocumentId, aliases: Option) -> Chunk { - Chunk { - chunk_id: kebab_core::ChunkId(chunk_id.into()), - doc_id: doc_id.clone(), - block_ids: vec![kebab_core::BlockId("b".repeat(32))], - text: "Rust ownership and borrowing".into(), - heading_path: vec!["Title".into()], - source_spans: vec![SourceSpan::Line { start: 1, end: 1 }], - token_estimate: 5, - chunker_version: ChunkerVersion("md-heading-v1".into()), - policy_hash: "h".into(), - tokenized_korean_text: None, - aliases, - } -} - -/// asset + document 그래프를 깔고 마이그레이션된 store 를 돌려준다. -fn open_store_with_document(env: &common::TestEnv) -> SqliteStore { - let store = SqliteStore::open(&env.config()).unwrap(); - store.run_migrations().unwrap(); - store.put_asset(&make_asset()).expect("put_asset"); - store.put_document(&make_doc()).expect("put_document"); - store -} - -#[test] -fn aliases_indexed_into_chunk_aliases_fts() { - let env = common::TestEnv::new(); - let store = open_store_with_document(&env); - let doc = DocumentId("d".repeat(32)); - let chunk = base_chunk( - &"e".repeat(32), - &doc, - Some("메모리 안전성\nwho owns the value".into()), - ); - store.put_chunks(&doc, &[chunk]).unwrap(); - - // 별칭에만 있는 한국어 term 으로 chunk_aliases_fts 검색 → 청크 회수. - let n: i64 = env.with_conn(|c| { - c.query_row( - "SELECT count(*) FROM chunk_aliases_fts \ - WHERE chunk_aliases_fts MATCH 'aliases : (\"메모리\")'", - [], - |r| r.get(0), - ) - }); - assert_eq!( - n, 1, - "aliases 의 한국어 term 이 chunk_aliases_fts 에 색인돼야 한다" - ); -} - -#[test] -fn none_aliases_not_indexed() { - let env = common::TestEnv::new(); - let store = open_store_with_document(&env); - let doc = DocumentId("d".repeat(32)); - let chunk = base_chunk(&"e".repeat(32), &doc, None); - store.put_chunks(&doc, &[chunk]).unwrap(); - - let n: i64 = env.with_conn(|c| { - c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0)) - }); - assert_eq!( - n, 0, - "aliases=None 이면 chunk_aliases_fts 에 행이 없어야 한다" - ); -} - -/// Task 2 리뷰 M2: 같은 doc 을 두 번 `put_chunks` 해도 `chunk_aliases_fts` -/// 행이 중복되지 않아야 한다. put_chunks 의 DELETE-then-INSERT 가 -/// chunk_aliases_ad → chunk_aliases_ai 를 발화해 멱등 재동기화하는지 검증. -#[test] -fn reput_keeps_single_alias_row() { - let env = common::TestEnv::new(); - let store = open_store_with_document(&env); - let doc = DocumentId("d".repeat(32)); - let mk = || base_chunk(&"e".repeat(32), &doc, Some("메모리 안전성".into())); - - store.put_chunks(&doc, &[mk()]).unwrap(); - store.put_chunks(&doc, &[mk()]).unwrap(); // 같은 doc 재-put - - let n: i64 = env.with_conn(|c| { - c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0)) - }); - assert_eq!(n, 1, "재색인 후에도 별칭 행은 1개여야 한다 (중복/누락 없음)"); -} - -/// Task 2 리뷰 N1: 별칭 term 이 본문 `chunks_fts` 로 새지 않아야 한다(§3.3 격리). -/// 본문엔 없고 별칭에만 있는 한국어 term 으로 chunks_fts 를 MATCH 하면 0행. -#[test] -fn aliases_dont_leak_into_body_fts() { - let env = common::TestEnv::new(); - let store = open_store_with_document(&env); - let doc = DocumentId("d".repeat(32)); - // 본문 "Rust ownership and borrowing" 에 "메모리" 없음, 별칭에만 있음. - let chunk = base_chunk(&"e".repeat(32), &doc, Some("메모리 안전성".into())); - store.put_chunks(&doc, &[chunk]).unwrap(); - - let body_hits: i64 = env.with_conn(|c| { - c.query_row( - "SELECT count(*) FROM chunks_fts WHERE chunks_fts MATCH 'text : (\"메모리\")'", - [], - |r| r.get(0), - ) - }); - assert_eq!(body_hits, 0, "별칭 term 이 본문 chunks_fts 로 누출되면 안 된다"); -} - -/// Task 2 리뷰 M1: 빈 문자열 별칭은 색인하지 않는다(trigger 가드 -/// `AND new.aliases <> ''`). producer 가 Some("") 를 넘겨도 무용한 행이 -/// chunk_aliases_fts 에 쌓이지 않아야 한다. -#[test] -fn empty_string_alias_not_indexed() { - let env = common::TestEnv::new(); - let store = open_store_with_document(&env); - let doc = DocumentId("d".repeat(32)); - let chunk = base_chunk(&"e".repeat(32), &doc, Some(String::new())); - store.put_chunks(&doc, &[chunk]).unwrap(); - - let n: i64 = env.with_conn(|c| { - c.query_row("SELECT count(*) FROM chunk_aliases_fts", [], |r| r.get(0)) - }); - assert_eq!(n, 0, "빈 문자열 별칭은 chunk_aliases_fts 에 색인되면 안 된다"); -}