Files
kebab/docs/components/normalize-chunk/README.md
altair823 5e6d977281 docs: CLAUDE 3구역 정책 + DOCS.md 지도 + component README task-spec 링크 정리
doc-reorg(C) Phase 4b+5:
- CLAUDE.md: doc-list 에 DOCS.md/CHANGELOG 추가 + zones(living/계약/증거) 정책 +
  "새 plan/handoff/per-feature spec 누적 금지(결정→HOTFIXES, 구조→ARCHITECTURE,
  릴리스→CHANGELOG)" 명문화. §Spec contract 재작성(task spec 삭제 반영: 유일
  frozen 계약 = 설계 doc, 구현 진실 = 코드). stale per-task-spec 참조 정정.
- docs/components/*/README.md (12): 삭제될 tasks/p*/ task-spec markdown 링크
  제거/plain-text 화 + 흡수된 crate(kebab-normalize→parse-md::normalize,
  kebab-parse-types→parse-md::types) 참조 정정.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
2026-06-27 12:46:29 +00:00

9.3 KiB

Normalize + Chunk

Markdown 의 ParsedBlock 을 도메인 CanonicalDocument 로 lift 하고, 모든 미디어의 CanonicalDocument 를 검색 단위 Chunk 로 자른다.

구성 crate

Crate 역할
kebab-parse-md::normalize (모듈; 구 kebab-normalize, v0.19.0 에 kebab-parse-md 로 흡수) ParsedBlock (markdown only) → CanonicalDocument lift. NFC + heading-path ordinal + provenance 합성 + title fallback chain (p9-fb-07).
kebab-chunk CanonicalDocumentVec<Chunk>. markdown 기본 md-heading-v2, PDF pdf-page-v1.2 — 둘 다 공유 crate::oversize (line→char 분할)로 예산 초과 청크를 잘라 모든 청크 ≤ max_chunk_tokens. md-heading-v1/pdf-page-v1.1 은 historical 변종으로 잔존.

구조

classDiagram
    class Normalize {
        <<kebab-parse-md::normalize>>
        build_canonical_document(asset, metadata, blocks, parser_version, warnings) CanonicalDocument
        derive_title(frontmatter, blocks, file_stem) String
        nfc(s) String
    }
    class Chunker {
        <<trait kebab-core>>
        chunker_version() ChunkerVersion
        policy_hash(policy) String
        chunk(doc, policy) Vec~Chunk~
    }
    class MdHeadingV1Chunker {
        VERSION = "md-heading-v1"
        BYTES_PER_TOKEN = 3
        POLICY_HASH_HEX_LEN = 16
    }
    class MdHeadingV2Chunker {
        VERSION = "md-heading-v2"
        max_chunk_tokens = 4000
        split_oversize_chunk(line→char)
    }
    class PdfPageV1Chunker {
        VERSION = "pdf-page-v1.2"
        max_chunk_tokens
        BYTES_PER_TOKEN = 3
        POLICY_HASH_HEX_LEN = 16
    }
    class ChunkPolicy {
        target_tokens
        overlap_tokens
        respect_markdown_headings
        chunker_version
    }
    Chunker <|.. MdHeadingV1Chunker
    Chunker <|.. MdHeadingV2Chunker
    Chunker <|.. PdfPageV1Chunker
    MdHeadingV2Chunker ..> ChunkPolicy
    PdfPageV1Chunker ..> ChunkPolicy

md-heading-v2 (기본, v0.30.0) 는 v1 과 모든 출력이 동일하되, 마지막에 token_estimate > max_chunk_tokens 인 청크만 줄(\n) 경계로 — 단일 거대 줄은 UTF-8 char 경계로 — 잘라 각 조각이 예산 이하가 되도록 한다. 분할 조각은 동일 block_ids 를 공유하므로 chunk_id 충돌을 막기 위해 id-input 해시에 #seg{i} 접미사를 붙이고(저장 policy_hash 는 bare), max_chunk_tokens 는 v2 의 policy_hash 에 fold 된다(공유 ChunkPolicy 미변경). 분할 조각의 source_spans 는 원 블록 범위를 그대로 보존(블록 단위 citation).

Data flow

flowchart TD
    Pblock["Vec~ParsedBlock~<br/>(kebab-parse-md)"]
    Asset["RawAsset<br/>(kebab-source-fs)"]
    Meta["Metadata<br/>(frontmatter)"]
    Pv["ParserVersion"]
    Norm["build_canonical_document<br/>NFC heading_path<br/>+ ordinal per (path, kind)<br/>+ derive_title fallback chain<br/>+ Provenance accumulator"]
    CDoc["CanonicalDocument<br/>(blocks + metadata + provenance)"]
    DirectCDoc["CanonicalDocument<br/>(kebab-parse-pdf / kebab-parse-image)"]
    MdC["MdHeadingV1Chunker<br/>(markdown + image)"]
    PdfC["PdfPageV1Chunker<br/>(PDF)"]
    Chunks["Vec~Chunk~"]
    Asset --> Norm
    Pblock --> Norm
    Meta --> Norm
    Pv --> Norm
    Norm --> CDoc
    CDoc --> MdC
    DirectCDoc --> MdC
    DirectCDoc --> PdfC
    MdC --> Chunks
    PdfC --> Chunks
    Chunks --> Store["kebab-store-* (다음 그룹)"]

주요 type / trait / 함수

Normalize (kebab-parse-md::normalize):

  • build_canonical_document(asset: &RawAsset, metadata: Metadata, blocks: Vec<ParsedBlock>, parser_version: &ParserVersion, warnings: Vec<Warning>) -> Result<CanonicalDocument>.
    • doc_id = id_for_doc(workspace_path, asset_id, parser_version).
    • 모든 heading_path 에 NFC 정규화 적용 (NFD \u{1100}\u{1161} 와 NFC \u{AC00} = "가" 가 같은 block_id 로 hash 되도록).
    • ordinal = (heading_path, block_kind) 별 0-based, document order (§4.3).
    • title 은 metadata.user["title"] lift 후, 비어 있으면 derive_title(frontmatter_title, blocks, file_stem) chain.
    • langmetadata.user["lang"] lift; non-string 이면 빈 Lang.
    • Provenance::events = Discovered (asset.discovered_at) + Parsed + Normalized + 각 warning 1개 + lift-stage warning (e.g. AudioRef pre-P8 drop).
  • derive_title(frontmatter, &[Block], file_stem) -> String — fallback chain (p9-fb-07): frontmatter title → 첫 H1 → 첫 H2 → 첫 paragraph 80 chars → file stem → "untitled" sentinel.
  • nfc(s: &str) -> String, to_posix(p: &Path) -> Result<WorkspacePath> — 재export from kebab-core.

Chunker trait (kebab-core):

  • Chunker::chunker_version() -> ChunkerVersion.
  • Chunker::policy_hash(&ChunkPolicy) -> Stringblake3(canonical_json(policy))[..16]. v1 두 chunker 가 같은 recipe.
  • Chunker::chunk(&CanonicalDocument, &ChunkPolicy) -> Result<Vec<Chunk>>.

MdHeadingV1Chunker (kebab-chunk):

  • 우선순위 (§0/§14): heading 경계 → code/table 한 chunk → paragraph greedy + overlap → heading_path propagation.
  • BYTES_PER_TOKEN = 3 (한국어 ≈ 3 b/tok 커버, 영어 ≈ 4 b/tok 는 over-estimate). 실제 tokenizer 도입 (P+) 까지 proxy.
  • ImageRef / AudioRef 는 자체 chunk (text = alt/caption preview, token_estimate = 0).

PdfPageV1Chunker (kebab-chunk):

  • 모든 chunk 가 single SourceSpan::Page { page, char_start, char_end } — 페이지 cross 금지 (citation locality).
  • 페이지가 budget 초과 시 paragraph break (\n\n) → sentence end (./?/! + ws) → 강제 over-size 순서로 split.
  • chunk_id 충돌 회피: §4.2 가 한 block_id 페어 → 한 chunk_id 가정인데 PDF 의 한 페이지 (= 한 block) 가 여러 chunk 로 split 됨. policy_hash slot 에 format!("{base}#c{char_start}") 변형 주입, Chunk.policy_hash 자체는 unmodified base 보존.

외부 의존

  • crate dep:
    • kebab-parse-md::normalize (구 kebab-normalize crate, 이제 kebab-parse-md 모듈) → kebab-core, kebab-parse-md::types (ParsedBlock/ParsedPayload/Warning), unicode-normalization, time.
    • kebab-chunkkebab-core, serde_json_canonicalizer, blake3. parser/store/embed 의존 금지.
  • 외부 lib: unicode-normalization (NFC), blake3 (policy_hash), serde_json_canonicalizer (JCS), time (provenance timestamps).
  • 외부 서비스: 없음.

핵심 결정

  • Markdown 만 normalize 거침; PDF / Image 는 우회. : Markdown 의 frontmatter / heading path 추적 + ordinal 부여가 normalize 의 본업. PDF 는 "페이지 = block", Image 는 "single block" 이라 IR 거치는 가치 없음. 결과: 두 path 가 chunker 단계에서 합류.

  • heading_path NFC 정규화 (parsedBlock → canonical 시점). : pulldown-cmark 가 NFC 안 함, serde_json_canonicalizer 도 NFC 안 함. 한국어 자모 분리/조합 두 표현이 다른 block_id hash 로 가면 idempotent re-ingest 가 깨짐. lift 시 NFC → on-disk CommonBlock.heading_path + ID input 동일 보장.

  • ordinal rule = (heading_path, block_kind) 별 0-based, document order. : 한 heading 아래 같은 종류의 블록 (paragraph 0/1/2, code 0/1) 만 ordinal 공유. 다른 heading 으로 가면 ordinal 리셋. 같은 heading 내 paragraph 추가/삭제가 다른 종류 ordinal 안 망가뜨림.

  • derive_title fallback chain (5단계 + sentinel). : spec literal 의 frontmatter-only title 정책이 실제 사용자 노트 (frontmatter 없이 H1 으로 시작) 와 충돌. 5단계: frontmatter → H1 → H2 → 첫 paragraph 80자 → file stem → "untitled". 각 단계 NFC, 빈 문자열 절대 반환 안 함. parser_versionpulldown-cmark-0.xmd-frontmatter-v2 bump 해서 기존 doc 자동 재처리.

  • BYTES_PER_TOKEN = 3 (spec literal 의 4 거부). : 한국어가 E5/M-BERT 에서 ≈ 3 bytes/token. 영어는 4 b/tok 라 3 으로 잡으면 over-estimate → 실제 tokenizer 가 봤을 때 budget 초과 안 함. 두 chunker (md/pdf) 가 같은 상수 써서 cross-chunker comparable. (HOTFIXES P7-2.)

  • PDF chunk_id 충돌 회피 = policy_hash slot 에 #c{char_start} 변형. : 한 페이지 (= 한 block) 가 여러 chunk 로 split 되면 §4.2 의 (doc_id, chunker_version, block_ids, policy_hash) tuple 가 동일 → 같은 chunk_id 충돌. chunker policy_hash 슬롯에만 변형 주입, Chunk.policy_hash 필드는 base 보존 ("어떤 policy 가 active 였는지" 답변 정확). §4.2 recipe 자체는 안 바꿈. (HOTFIXES P7-2.)

  • chunker 가 store/embed 의존 금지. : 순수 변환 함수. test 에서 CanonicalDocument 만 만들어서 chunker 호출 가능. Storage / embedding 부재가 chunker 단위 테스트 막지 않음.

관련 spec / HOTFIXES

  • frozen 설계 §3.4 (Block / CanonicalDocument), §3.5 (Chunk), §3.6 (Provenance), §3.7b (ParsedBlock), §4.2 (ID recipe), §4.3 (ordinal), §0/§14 (chunking priority): docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
  • task specs: 삭제됨(2026-06-27 doc-reorg) — 설계는 frozen 계약, 동작은 tasks/HOTFIXES.md, 상세 git history.
  • HOTFIXES (P7-2 BYTES_PER_TOKEN/=3, chunk_id 충돌 회피, p9-fb-07 title chain): tasks/HOTFIXES.md