feat(chunk): md-heading-v2 — 예산 초과 청크 일반 분할 #209

Merged
altair823 merged 1 commits from feat/md-heading-v2-oversize-split into main 2026-06-24 01:26:53 +00:00
15 changed files with 1524 additions and 49 deletions

48
Cargo.lock generated
View File

@@ -4751,7 +4751,7 @@ dependencies = [
[[package]]
name = "kebab-app"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"base64 0.22.1",
@@ -4799,7 +4799,7 @@ dependencies = [
[[package]]
name = "kebab-chunk"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"blake3",
@@ -4817,7 +4817,7 @@ dependencies = [
[[package]]
name = "kebab-cli"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"clap",
@@ -4838,7 +4838,7 @@ dependencies = [
[[package]]
name = "kebab-config"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"dirs 5.0.1",
@@ -4854,7 +4854,7 @@ dependencies = [
[[package]]
name = "kebab-core"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"blake3",
@@ -4868,7 +4868,7 @@ dependencies = [
[[package]]
name = "kebab-embed"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"blake3",
@@ -4882,7 +4882,7 @@ dependencies = [
[[package]]
name = "kebab-embed-candle"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"candle-core",
@@ -4902,7 +4902,7 @@ dependencies = [
[[package]]
name = "kebab-embed-local"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"fastembed",
@@ -4915,7 +4915,7 @@ dependencies = [
[[package]]
name = "kebab-embed-ollama"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"kebab-config",
@@ -4930,7 +4930,7 @@ dependencies = [
[[package]]
name = "kebab-eval"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"kebab-app",
@@ -4949,7 +4949,7 @@ dependencies = [
[[package]]
name = "kebab-llm"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -4958,7 +4958,7 @@ dependencies = [
[[package]]
name = "kebab-llm-local"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"kebab-config",
@@ -4975,7 +4975,7 @@ dependencies = [
[[package]]
name = "kebab-mcp"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"kebab-app",
@@ -4993,7 +4993,7 @@ dependencies = [
[[package]]
name = "kebab-nli"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"hf-hub",
@@ -5008,7 +5008,7 @@ dependencies = [
[[package]]
name = "kebab-parse-code"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"gix",
@@ -5031,7 +5031,7 @@ dependencies = [
[[package]]
name = "kebab-parse-image"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"ab_glyph",
"anyhow",
@@ -5059,7 +5059,7 @@ dependencies = [
[[package]]
name = "kebab-parse-md"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"kebab-core",
@@ -5076,7 +5076,7 @@ dependencies = [
[[package]]
name = "kebab-parse-pdf"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"blake3",
@@ -5091,7 +5091,7 @@ dependencies = [
[[package]]
name = "kebab-rag"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"blake3",
@@ -5113,7 +5113,7 @@ dependencies = [
[[package]]
name = "kebab-search"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"globset",
@@ -5132,7 +5132,7 @@ dependencies = [
[[package]]
name = "kebab-source-fs"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"blake3",
@@ -5150,7 +5150,7 @@ dependencies = [
[[package]]
name = "kebab-store-sqlite"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"blake3",
@@ -5170,7 +5170,7 @@ dependencies = [
[[package]]
name = "kebab-store-vector"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"arrow",
@@ -5194,7 +5194,7 @@ dependencies = [
[[package]]
name = "kebab-tui"
version = "0.29.0"
version = "0.30.0"
dependencies = [
"anyhow",
"crossterm",

View File

@@ -32,7 +32,7 @@ edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
repository = "https://github.com/altair823/kebab"
version = "0.29.0" # v0.29.0 — provenance 출처 필터: `[[workspace.sources]]` 멀티소스 + 검색 `--source <id>` / `--source-type <type>`(lexical+vector 두 site, OR). `documents.source_id` 컬럼(V014, additive·DEFAULT 'default'·재색인 0) + config v3→v4 migration(`step_3_to_4`, 단일 root→implicit `default` source 미러, 멱등). per-source `trust_level`/`source_type` 기본값(우선순위 frontmatter > source 기본값 > Primary). 단일 root 사용자 무영향. 설계 근거: 전역 trust 곱셈가중(weighted-RRF)은 A/B 반증(incident MRR 절벽), 출처 필터가 see-saw 없는 레버. 신규 CLI flag + config 키 + migration → minor. — CLAUDE.md §Release
version = "0.30.0" # v0.30.0 — md-heading-v2 청커: 예산 초과 청크 일반 분할. v1 의 "블록 미분할" 한계(거대 list/code/table/paragraph 가 한 청크→임베더 ctx 초과)를 일반화 — `token_estimate > max_chunk_tokens`(신규 config, byte/3, default 4000)인 청크만 줄(→UTF-8 char) 경계로 분할, 각 조각 ≤ 예산. 분할 조각 chunk_id 는 `#seg{i}` 접미사로 충돌 회피, `max_chunk_tokens` 는 v2 policy_hash 에 fold(공유 ChunkPolicy 미변경). 미분할 청크는 v1 과 byte-identical. `chunker_version` v1→v2 → 다음 ingest 에서 markdown 1회 자동 재청크(코드/PDF 무영향). 동기: strict 임베더(AMD Lemonade)는 oversize 입력을 truncate 아닌 거부 → 청커가 애초에 안 만드는 게 옳음. 신규 config 키 + 청커 동작(검색 hit 분할) 변경 → minor + 도그푸딩 트리거. — CLAUDE.md §Release
# pre-v0.18 workspace-wide cleanup: enable clippy::pedantic group with
# intentional allow-list. The allowed lints are either cosmetic (doc style),

View File

@@ -35,6 +35,7 @@ P0~P5 직렬. P6~P9 P5 이후 병렬 가능.
머지 후 발견된 모든 deviation / hotfix 의 dated 로그는 [tasks/HOTFIXES.md](tasks/HOTFIXES.md). 본 요약은 \"누군가가 인수받을 때 알아두면 시간을 많이 절약하는\" 항목만:
- **2026-06-24 md-heading-v2: 예산 초과 청크 일반 분할** — v0.30.0. markdown 청커가 v1 의 "블록 미분할" 한계를 일반화 — 거대 list/code/table/paragraph 가 한 청크로 임베더 ctx 를 초과하던 문제를, `token_estimate > max_chunk_tokens`(신규 config, byte/3, default 4000)인 청크만 줄(→UTF-8 char) 경계로 분할해 해소. 미분할 청크는 v1 과 byte-identical. 분할 조각 chunk_id 는 `#seg{i}` 접미사로 충돌 회피, `max_chunk_tokens` 는 v2 policy_hash 에 fold(공유 ChunkPolicy 미변경). `chunker_version` v1→v2 라 다음 plain ingest 에서 markdown 1회 자동 재청크(코드/PDF 무영향). **동기**: strict 임베더(AMD Lemonade `/api/embed`)는 oversize 입력을 truncate 아닌 거부(`500 too large`) — ollama 가 조용히 truncate 하던 걸 청커가 애초에 안 만들도록. **known limitation**: 분할 조각 citation 은 블록 단위(sub-line 정밀 아님). 도그푸딩(실험 KB, arctic@Lemonade): v2 전 620 중 2 doc 임베드 실패 → v2 후 620/620·7114 청크 전부 ≤4000, "WiredTiger excessive memory" 질의에 거대 doc SERVER-22906 가 1위(0.977). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-24), 설계 `docs/superpowers/plans/2026-06-24-md-heading-v2-oversize-split.md`.
- **2026-06-21 provenance 출처 필터: `[[workspace.sources]]` 멀티소스 + `--source`/`--source-type`** — v0.29.0. 혼합 출처 KB(위키+jira 등)에서 색인은 전부 하되 질의 시 출처로 좁히는 레버. config `[[workspace.sources]]`(각 id/root/trust_level/source_type) + `documents.source_id` 컬럼(V014, additive, 재색인 0) + config v3→v4 migration(`step_3_to_4`, 단일 root→implicit `default` source, 멱등) + 검색 `--source <id>` / `--source-type <type>`(lexical+vector 두 site, OR). trust precedence = frontmatter > per-source 기본값 > Primary. **설계 근거**: 전역 trust 곱셈가중(weighted-RRF)은 A/B 에서 반증(θ=0.85 만으로 incident MRR 0.918→0.340 절벽) — 필터가 see-saw 없는 올바른 레버. 도그푸딩(620 doc, jira400+wiki220): `--source wiki` concept 0.780→0.810, `--source jira` incident 0.918→0.975. **follow-up**: MCP search 필터 미노출 · `kebab list` source_id 미표시 · RAG provenance 라벨 미구현. 자세한 내용: `tasks/HOTFIXES.md` (2026-06-21).
- **2026-06-04 PP-OCRv5 ONNX Rust 네이티브 OCR** — v0.27.0. `[image.ocr] engine = "paddle-onnx"` 로 PP-OCRv5(검출+인식) ONNX 를 in-process(`ort` =2.0.0-rc.9) 실행 — Python 런타임/원격 호출 없이 큰 페이지 CPU <4초(Ollama vision ~50초 대비). default 는 여전히 `"ollama-vision"`. 후처리(min-area rect/unclip)는 pure-Rust. **함정**: unclip 은 corner 를 centroid 에서 방사 확장하면 안 되고 edge 별 polygon offset 이어야 함(방사 확장 시 wide/short 텍스트 박스 높이가 안 커져 글자 윗부분 잘림 → ㄷ→ㄴ, e2e CER 0.26). 수정 후 CER 0.005. 모델 ONNX 는 `crates/kebab-parse-image/assets/paddleocr-onnx/`(LFS). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-04 PP-OCRv5 ONNX), spec/plan `docs/superpowers/{specs,plans}/2026-06-04-rust-native-ocr-*.md`.
- **2026-06-03 ingest 설정 변경 자동 재색인** — v0.26.2. ingest 산출에 영향 주는 설정(청킹/이미지 OCR·caption/pdf.ocr/`[ingest.code]`)을 변경하면 `--force-reingest` 없이 영향 자산만 자동 재색인. 그 설정들의 결정적 서명(`ingest_config_signature`)을 effective parser_version(skip 비교 + 저장 doc 필드 양쪽)에 폴딩 → 다음 ingest 비교가 mismatch. 비산출 설정(search/rag/ui/log + max_pixels/languages/timeout)은 제외(과도 무효화 회피), doc_id 는 base 로 안정 유지. **업그레이드 후 첫 ingest 는 전 자산 1회 재색인**(저장된 상수 parser_version ≠ 새 composite; embedding 은 V012 캐시 히트). 결과 포맷·CLI·wire 불변(내부 skip 판정 정정). 자세한 내용: `tasks/HOTFIXES.md` (2026-06-03 ingest 설정 변경 자동 재색인), spec/plan `docs/superpowers/{specs,plans}/2026-06-03-*invalidation*.md`.

View File

@@ -196,6 +196,7 @@ nli_threshold = 0.0 # >0 (예: 0.5) 면 mDeBERTa XNLI groundedn
```
- **`[ingest]`** (v0.28.0) — 모든 형식 ingest 설정의 우산. 병렬도(`max_parallel_extractors`/`max_parallel_embeddings`/`watch_filesystem`, ← 옛 `[indexing]`)와 형식별 하위 절(`[ingest.chunking]` ← 옛 `[chunking]`, `[ingest.code]`, `[ingest.image.ocr]` ← 옛 `[image.ocr]`, `[ingest.pdf.ocr]` ← 옛 `[pdf.ocr]`)이 전부 이 아래로 모인다. 기존 v2 `config.toml` 은 그대로 둬도 로드 시 메모리에서 자동 변환되며, 파일을 새 레이아웃으로 갱신하려면 `kebab config migrate` (값·주석 보존).
- **`[ingest.chunking]`** — 청크 크기·오버랩·heading 존중. `chunker_version` 기본 `"md-heading-v2"` (v0.30.0). **`max_chunk_tokens`** (default 4000, byte/3 토큰) — 이 값을 넘는 청크는 줄(→UTF-8 char) 경계로 분할해 각 조각이 예산 이하가 되게 한다. 거대 list/code/log 덤프가 한 청크로 임베더 컨텍스트를 초과하던 문제를 막는다(미분할 청크는 v0.29.0 `md-heading-v1` 과 출력 동일). 이 값을 바꾸면 markdown 자산이 자동 재청크된다.
- **파생물 캐시** — embedding 결과를 내용 해시로 자동 캐싱한다 (위 「핵심 기능」 참고). 설정 항목 없음.
- **`[ingest.code]`** — code ingest 의 skip 정책 (`skip_generated_header`, `max_file_bytes`, `extra_skip_globs`). `.gitignore` 자동 honor, `.kebabignore` 는 추가 layer.
- **`[ingest.image.ocr]`** — 이미지 OCR (default off / opt-in). `engine` 으로 백엔드 선택: `"ollama-vision"` (default, 원격 vision LM) 또는 `"paddle-onnx"` (PP-OCRv5 ONNX 를 in-process 로 실행, Python 런타임 불필요, 큰 페이지 CPU <4초, 오프라인). `paddle-onnx` 는 워크스페이스에 번들된 모델을 쓰며 `det_model`/`rec_model`/`dict` 로 경로 override, `score_thresh`(0.3)/`unclip_ratio`(1.5)/`max_boxes`(1000) 로 검출 튜닝 가능 (`KEBAB_IMAGE_OCR_*` env 동일 지원 — env 이름은 v3 에서도 불변). engine 또는 모델을 바꾸면 영향 이미지가 자동 재색인된다.

View File

@@ -43,7 +43,7 @@ use kebab_chunk::{
CodeCAstV1Chunker, CodeCppAstV1Chunker, CodeGoAstV1Chunker, CodeJavaAstV1Chunker,
CodeJsAstV1Chunker, CodeKotlinAstV1Chunker, CodePythonAstV1Chunker, CodeRustAstV1Chunker,
CodeTextParagraphV1Chunker, CodeTsAstV1Chunker, DockerfileFileV1Chunker,
K8sManifestResourceV1Chunker, ManifestFileV1Chunker, MdHeadingV1Chunker, PdfPageV1Chunker,
K8sManifestResourceV1Chunker, ManifestFileV1Chunker, MdHeadingV2Chunker, PdfPageV1Chunker,
};
use kebab_core::{
Answer, Block, CanonicalDocument, Chunk, ChunkId, ChunkPolicy, Chunker, ChunkerVersion,
@@ -1388,7 +1388,7 @@ fn ingest_one_asset(
app,
asset,
&eff_parser_version,
&MdHeadingV1Chunker.chunker_version(),
&md_chunker_from_config(&app.config).chunker_version(),
embedder.map(|e| e.model_version()).as_ref(),
force_reingest,
None,
@@ -1441,9 +1441,9 @@ fn ingest_one_asset(
let parse_ms = u64::try_from(t_parse.elapsed().as_millis()).unwrap_or(u64::MAX);
let t_chunk = std::time::Instant::now();
let chunks = MdHeadingV1Chunker
let chunks = md_chunker_from_config(&app.config)
.chunk(&canonical, chunk_policy)
.context("kb-chunk::MdHeadingV1Chunker::chunk")?;
.context("kb-chunk::MdHeadingV2Chunker::chunk")?;
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.24.0: surface the chunk count immediately, before the (potentially
@@ -1465,7 +1465,7 @@ fn ingest_one_asset(
// Stamp chunker + embedding versions so Task 7's skip detection has
// data on the second run.
canonical.last_chunker_version = Some(MdHeadingV1Chunker.chunker_version());
canonical.last_chunker_version = Some(md_chunker_from_config(&app.config).chunker_version());
if let Some(emb) = embedder {
canonical.last_embedding_version = Some(emb.model_version());
}
@@ -1607,7 +1607,7 @@ fn ingest_one_asset(
block_count: u32::try_from(canonical.blocks.len()).ok(),
chunk_count: u32::try_from(chunks.len()).ok(),
parser_version: Some(parser_version.clone()),
chunker_version: Some(MdHeadingV1Chunker.chunker_version()),
chunker_version: Some(md_chunker_from_config(&app.config).chunker_version()),
warnings: warning_notes,
pdf_ocr_pages: None,
pdf_ocr_ms_total: None,
@@ -1666,7 +1666,7 @@ fn ingest_one_image_asset(
};
// p9-fb-23 task 7: incremental-ingest early-skip for the image flow.
// Image docs use the `image-meta-v1` parser_version + the same
// MdHeadingV1Chunker as the markdown flow (single-block doc). The
// MdHeadingV2Chunker as the markdown flow (single-block doc). The
// embedding-version check matches the markdown path: when the
// active embedder's model_version equals what was stamped on the
// existing doc, the asset is Unchanged.
@@ -1679,7 +1679,7 @@ fn ingest_one_image_asset(
app,
asset,
&eff_parser_version,
&MdHeadingV1Chunker.chunker_version(),
&md_chunker_from_config(&app.config).chunker_version(),
embedder.map(|e| e.model_version()).as_ref(),
force_reingest,
None,
@@ -1828,14 +1828,17 @@ fn ingest_one_image_asset(
}
}
// 4. Chunk via the same `MdHeadingV1Chunker` markdown uses — its
// 4. Chunk via the same `MdHeadingV2Chunker` markdown uses — its
// `Block::ImageRef` arm already produces a single chunk per
// image (P1-5). The chunk text now follows the (β) plain-concat
// contract per the kebab-chunk render_block_text update.
// image (P1-5). The chunk text follows the (β) plain-concat
// contract per the kebab-chunk render_block_text update. Using v2
// here keeps the markdown family consistent: a pathologically
// large OCR text dump splits at line boundaries just like a giant
// fenced code block would, instead of overflowing the embedder.
let t_chunk = std::time::Instant::now();
let chunks = MdHeadingV1Chunker
let chunks = md_chunker_from_config(&app.config)
.chunk(&canonical, chunk_policy)
.context("kb-chunk::MdHeadingV1Chunker::chunk (image)")?;
.context("kb-chunk::MdHeadingV2Chunker::chunk (image)")?;
let chunk_ms = u64::try_from(t_chunk.elapsed().as_millis()).unwrap_or(u64::MAX);
// v0.24.0: surface chunk count for the image path too.
@@ -1849,9 +1852,9 @@ fn ingest_one_image_asset(
);
// 5. Persist + embed — identical sequence to markdown.
// Stamp chunker + embedding versions (image uses MdHeadingV1Chunker
// Stamp chunker + embedding versions (image uses MdHeadingV2Chunker
// for its single-block doc, so we record that version).
canonical.last_chunker_version = Some(MdHeadingV1Chunker.chunker_version());
canonical.last_chunker_version = Some(md_chunker_from_config(&app.config).chunker_version());
if let Some(emb) = embedder {
canonical.last_embedding_version = Some(emb.model_version());
}
@@ -1956,7 +1959,7 @@ fn ingest_one_image_asset(
block_count: u32::try_from(canonical.blocks.len()).ok(),
chunk_count: u32::try_from(chunks.len()).ok(),
parser_version: Some(canonical.parser_version.clone()),
chunker_version: Some(MdHeadingV1Chunker.chunker_version()),
chunker_version: Some(md_chunker_from_config(&app.config).chunker_version()),
warnings: warning_notes,
pdf_ocr_pages: None,
pdf_ocr_ms_total: None,
@@ -3154,6 +3157,19 @@ fn chunk_policy_from_config(config: &kebab_config::Config) -> ChunkPolicy {
}
}
/// Construct the markdown chunker (the hardcoded `md-heading-v2`) with the
/// split budget threaded from config. Used by the markdown ingest path
/// AND the image-OCR / caption path (which flows its synthetic
/// `Block::ImageRef` text through the same chunker), so a giant OCR dump
/// is split like any other oversize chunk. The PDF path stays pinned to
/// `pdf-page-v1` and code paths keep their own AST chunkers — only the
/// markdown-family default moved v1 → v2.
fn md_chunker_from_config(config: &kebab_config::Config) -> MdHeadingV2Chunker {
MdHeadingV2Chunker {
max_chunk_tokens: config.ingest.chunking.max_chunk_tokens,
}
}
/// v0.26.2: deterministic signature of the **ingest-output-affecting**
/// config for an asset's media type, folded into the effective
/// `parser_version` (both the `try_skip_unchanged` compare field AND the
@@ -3240,9 +3256,20 @@ fn ingest_config_signature(config: &kebab_config::Config, media: &MediaType) ->
// boundaries. `target_tokens` / `overlap_tokens` change re-chunking for
// markdown / image / pdf / code alike, so a change re-indexes all types.
let c = &config.ingest.chunking;
// `max_chunk_tokens` is appended as a 5th field: md-heading-v2
// splits any oversize chunk (list, code, paragraph, table) at this
// budget, so changing it moves markdown chunk boundaries and must
// re-index. It also folds into the v2 policy_hash, but the signature
// is what the no-`--force` skip-check compares, so it must be here
// too. Appended (not inserted) so the existing 4-field prefix
// `chunk:T:O:H:V` stays a stable substring for any existing golden.
let mut sig = format!(
"chunk:{}:{}:{}:{}",
c.target_tokens, c.overlap_tokens, c.respect_markdown_headings, c.chunker_version
"chunk:{}:{}:{}:{}:{}",
c.target_tokens,
c.overlap_tokens,
c.respect_markdown_headings,
c.chunker_version,
c.max_chunk_tokens
);
match media {
MediaType::Image(_) => {

View File

@@ -160,8 +160,10 @@ fn ingest_signature_image_paddle_byte_stable() {
&kebab_core::MediaType::Image(kebab_core::ImageType::Png),
);
// 골든: chunk:... |ocr:1:paddle-onnx:<engine_version> |cap:0
// md-heading-v2 가 markdown 기본값 + max_chunk_tokens(4000) 가
// signature 5번째 필드로 추가됐다 — budget 변경 시 자동 재색인용.
assert!(
sig.starts_with("chunk:500:80:true:md-heading-v1"),
sig.starts_with("chunk:500:80:true:md-heading-v2:4000"),
"chunk prefix drift: {sig}"
);
assert!(sig.contains("|ocr:1:paddle-onnx:"), "ocr token drift: {sig}");

View File

@@ -7,8 +7,15 @@
//!
//! * [`MdHeadingV1Chunker`] — heading-aware chunker for Markdown
//! `CanonicalDocument`s, emitting `chunker_version = "md-heading-v1"`.
//! * [`MdHeadingV2Chunker`] — byte-identical to v1 in its chunking pass,
//! then applies a generic post-pass: any chunk whose byte/3 estimate
//! exceeds `max_chunk_tokens` is split at line (then UTF-8 char)
//! boundaries. Covers all block kinds (list, code, paragraph, table).
//! Emits `chunker_version = "md-heading-v2"`; the hardcoded markdown
//! default (design §9 label bump).
//!
//! Behavior contract is enumerated on [`MdHeadingV1Chunker`].
//! Behavior contract is enumerated on [`MdHeadingV1Chunker`] (v2 inherits
//! it; the divergence is the generic post-pass documented on [`MdHeadingV2Chunker`]).
//!
//! This crate must NOT depend on any parser implementation
//! (`kb-parse-md`, `kb-parse-pdf`, …), the document/vector store, the
@@ -29,6 +36,7 @@ pub mod dockerfile_file_v1;
pub mod k8s_manifest_resource_v1;
pub mod manifest_file_v1;
mod md_heading_v1;
mod md_heading_v2;
mod pdf_page_v1;
mod tier2_shared;
@@ -46,6 +54,7 @@ pub use dockerfile_file_v1::DockerfileFileV1Chunker;
pub use k8s_manifest_resource_v1::K8sManifestResourceV1Chunker;
pub use manifest_file_v1::ManifestFileV1Chunker;
pub use md_heading_v1::MdHeadingV1Chunker;
pub use md_heading_v2::MdHeadingV2Chunker;
pub use pdf_page_v1::PdfPageV1Chunker;
// ── Korean morphological tokenizer ───────────────────────────────────────────

File diff suppressed because it is too large Load Diff

View File

@@ -175,6 +175,25 @@ pub struct ChunkingCfg {
pub overlap_tokens: usize,
pub respect_markdown_headings: bool,
pub chunker_version: String,
/// Max byte/3 token estimate per emitted chunk (md-heading-v2).
/// After the v1-equivalent chunking pass, any chunk whose estimate
/// exceeds this value is split at line (then UTF-8 char) boundaries
/// into sub-pieces each ≤ budget. Covers all block kinds: list, code,
/// paragraph, table. The default (4000) is large enough to keep normal
/// content atomic while splitting pathological log / stacktrace / Jira
/// list dumps that would otherwise overflow an embedder context window.
/// `#[serde(default)]` so pre-v2 config files that predate the key
/// still load (migration injects it additively).
#[serde(default = "default_max_chunk_tokens")]
pub max_chunk_tokens: usize,
}
/// Default md-heading-v2 chunk split budget. 4000 byte/3 tokens
/// (~12 KB) keeps ordinary source files and prose atomic while
/// splitting the pathological 20k76k token Jira log blocks that fail
/// to embed on strict servers.
fn default_max_chunk_tokens() -> usize {
4000
}
impl ChunkingCfg {
@@ -183,7 +202,12 @@ impl ChunkingCfg {
target_tokens: 500,
overlap_tokens: 80,
respect_markdown_headings: true,
chunker_version: "md-heading-v1".to_string(),
// md-heading-v2 is the hardcoded markdown default (it splits
// oversize chunks of any block kind; v1 never did). Stamping it
// here means the lib.rs skip-check re-chunks md docs on next
// ingest via the version cascade (design §9).
chunker_version: "md-heading-v2".to_string(),
max_chunk_tokens: default_max_chunk_tokens(),
}
}
}
@@ -1251,6 +1275,11 @@ impl Config {
self.ingest.chunking.respect_markdown_headings = parse_bool(v);
}
"KEBAB_CHUNKING_CHUNKER_VERSION" => self.ingest.chunking.chunker_version = v.clone(),
"KEBAB_CHUNKING_MAX_CHUNK_TOKENS" => {
if let Ok(n) = v.parse::<usize>() {
self.ingest.chunking.max_chunk_tokens = n;
}
}
// models.embedding
"KEBAB_MODELS_EMBEDDING_PROVIDER" => self.models.embedding.provider = v.clone(),

View File

@@ -108,6 +108,9 @@ fn key_comment(path: &str) -> Option<&'static str> {
"ingest.max_parallel_embeddings" => "동시 임베딩 수.",
"ingest.chunking.target_tokens" => "청크 목표 토큰(전 형식 공통).",
"ingest.chunking.respect_markdown_headings" => "markdown heading 경계 존중.",
"ingest.chunking.max_chunk_tokens" => {
"md-heading-v2 청크 최대 토큰(byte/3). 초과 시 줄/문자 경계로 분할(list·code·단락 공통)."
}
"ingest.image.ocr.enabled" => "이미지 OCR(기본 off, asset 당 비용).",
"ingest.image.ocr.engine" => "ollama-vision | paddle-onnx.",
"ingest.image.ocr.model" => "ollama-vision 전용. paddle-onnx 는 번들 모델 사용(이 값 무시).",

View File

@@ -106,7 +106,8 @@ watch_filesystem = false
target_tokens = 500
overlap_tokens = 80
respect_markdown_headings = true
chunker_version = "md-heading-v1"
chunker_version = "md-heading-v2"
max_chunk_tokens = 4000 # v0.30.0 — 이 byte/3 토큰 초과 청크는 줄(→UTF-8 char) 경계로 분할(거대 list/code/log 덤프 대비)
[models.embedding]
provider = "fastembed" # "fastembed"(기본, onnxruntime) / "candle"(순수 Rust, NUMA-안전)

View File

@@ -7,7 +7,7 @@
| Crate | 역할 |
|-------|------|
| `kebab-normalize` | `ParsedBlock` (markdown only) → `CanonicalDocument` lift. NFC + heading-path ordinal + provenance 합성 + title fallback chain (p9-fb-07). |
| `kebab-chunk` | `CanonicalDocument``Vec<Chunk>`. v1 두 변종: `md-heading-v1` (markdown + image), `pdf-page-v1` (PDF). |
| `kebab-chunk` | `CanonicalDocument``Vec<Chunk>`. markdown 기본 `md-heading-v2` (v1 + 예산 초과 청크 일반 분할; v0.30.0), `pdf-page-v1` (PDF). `md-heading-v1` 은 historical 변종으로 잔존. |
## 구조
@@ -30,6 +30,11 @@ classDiagram
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"
BYTES_PER_TOKEN = 3
@@ -42,11 +47,20 @@ classDiagram
chunker_version
}
Chunker <|.. MdHeadingV1Chunker
Chunker <|.. MdHeadingV2Chunker
Chunker <|.. PdfPageV1Chunker
MdHeadingV1Chunker ..> ChunkPolicy
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
```mermaid

View File

@@ -0,0 +1,97 @@
---
title: kebab v0.30.0 release notes (draft)
created: 2026-06-24
status: draft
release_trigger:
- 신규 config `[ingest.chunking] max_chunk_tokens` — 인터페이스 추가 (pre-1.0 minor)
- markdown 청커 동작 변경 `md-heading-v1``md-heading-v2` (거대 청크 분할 → 검색 hit 분할) — 도그푸딩 트리거
---
# kebab v0.30.0 — md-heading-v2: 거대 청크가 임베딩을 깨지 않도록
v0.29.0(provenance 출처 필터) 후속 minor release. markdown 청커가 **하나의 거대
블록이 임베더 컨텍스트를 초과해 임베딩을 통째로 실패시키던** 문제를 해소한다.
긴 로그·스택트레이스·표가 통으로 들어간 문서(예: jira 이슈)를 색인할 때 그
문서가 검색에서 통째로 사라지던 회귀를 막는다. **일반 사용자는 업그레이드 후
다음 `kebab ingest` 한 번이면 끝** — markdown 자산만 1회 자동 재청크된다.
---
## 변경 사실
**1) `md-heading-v2` 청커가 기본값이 됐다.** v1은 코드/테이블 블록을 (크기와
무관하게) 절대 분할하지 않았고, list·paragraph도 블록 하나가 곧 청크 하나였다.
그래서 거대한 단일 블록 — 예컨대 로그 덤프가 1000줄짜리 불릿 리스트로 변환된 것 —
이 임베더의 입력 한도를 넘으면 그 문서 전체의 벡터 색인이 실패했다. v2는 v1과
**모든 출력이 동일**하되, 마지막에 **예산을 넘는 청크만** 줄(`\n`) 경계로,
한 줄이 홀로 예산을 넘으면 UTF-8 문자 경계로 잘라 각 조각이 예산 이하가 되게 한다.
**2) 신규 config `[ingest.chunking] max_chunk_tokens`** (byte/3 토큰, default
**4000**). 이 값을 넘는 청크가 분할 대상이 된다. 4000은 8192-context 임베더(예:
snowflake-arctic-embed2)에 약 2배 안전마진을 두면서 일반적인 코드·문단은 그대로
한 청크로 유지하는 값이다. `kebab config migrate`로 기존 config에 additive 주입.
```toml
[ingest.chunking]
chunker_version = "md-heading-v2"
max_chunk_tokens = 4000
```
**3) 분할 조각의 chunk_id·citation.** 한 블록에서 나온 분할 조각들은 같은
`block_ids`를 공유하므로 chunk_id에 `#seg{i}` 접미사를 붙여 충돌을 막는다. 분할
조각의 citation은 **블록 단위**(원 블록 범위) — sub-line 정밀도는 아니다.
미분할 청크(대다수)는 v0.29.0과 출력·citation이 완전히 동일하다.
## Trade-off
- **거대 블록이 여러 검색 hit로 나뉜다.** 이전엔 그 문서가 (임베딩 실패로)
벡터 검색에서 아예 안 나왔거나, ollama처럼 조용히 잘린 채 한 hit였다. 이제는
여러 조각으로 정상 색인되어 각각 hit로 잡힐 수 있다 — 같은 문서가 상위에 여러
번 보일 수 있다.
- **분할 조각 citation은 블록 단위다.** fenced 코드의 줄 span이 fence를 포함하는
비대칭 때문에 조각별 정확한 줄 범위를 (span, code)만으로 복원할 수 없어,
"절대 틀리지 않되 블록 단위"를 택했다. 정밀 sub-line citation이 필요하면 향후
파서가 content 줄 범위를 직접 제공하는 개선이 필요하다.
- **byte/3 휴리스틱.** 토큰 수는 실제 토크나이저가 아니라 바이트/3 근사다. CJK는
과대추정(더 안전), 영문 코드는 실토큰과 비슷. strict 임베더에 대해 보수적이다.
## Mitigation
- **결과·CLI·wire 포맷 불변.** `--json` 스키마, exit code, citation 모양 모두
동일. 내부적으로 거대 블록이 분할될 뿐이다.
- **공유 `ChunkPolicy` 미변경.** `max_chunk_tokens`는 v2 청커의 policy_hash에만
fold되어 코드·PDF 청커의 cascade를 건드리지 않는다 — 코드/PDF 자산은 재청크되지
않는다.
- **strict 임베더 호환.** v1 시절 ollama가 조용히 truncate하던 것을 청커가 애초에
예산 이하로 만들므로, oversize 입력을 truncate가 아닌 거부(`500 too large`)로
처리하는 백엔드(예: AMD Lemonade)에서도 색인이 깨지지 않는다.
- **이미지 OCR 텍스트도 보호.** 분할 판정은 청크의 실제 임베드 text 크기로 한다 —
이미지 청크는 내부적으로 token_estimate가 0이지만 그 OCR/캡션 text는 클 수 있어
(빽빽한 스크린샷), 실제 text 길이로 판정해야 oversize 이미지 OCR도 분할된다.
### Known limitation
PDF는 별도 청커(`pdf-page-v1.1`)를 쓰므로 이 oversize-split이 적용되지 않는다 —
초고밀도 scanned page 한 장의 OCR이 한 청크로 예산을 넘으면 그대로 임베드된다
(일반 PDF는 무관). 후속 작업으로 PDF 청커에도 동일 분할을 넣을 수 있다.
## 업그레이드 절차
1. 새 바이너리로 교체.
2. (선택) `kebab config migrate` — config 파일에 `max_chunk_tokens = 4000`
additive 주입(주석·값 보존). 안 해도 기본값으로 동작한다.
3. `kebab ingest``chunker_version``md-heading-v1``md-heading-v2`
바뀌었으므로 **markdown 자산이 1회 자동 재청크**된다(`--force-reingest`
불필요). 코드/PDF 자산은 영향 없음. embedding은 파생물 캐시(V012) 히트로
대부분 재계산을 피하지만, 분할된 거대 블록의 새 조각은 새로 임베드된다.
4. 대용량 KB라면 첫 ingest가 markdown 재청크로 평소보다 길어질 수 있다(1회성).
### 도그푸딩 evidence
실험 KB(MongoDB 문서 220 + jira 400, arctic-embed-l-v2 @ Lemonade GPU). v2 전:
620 문서 중 2개(`SERVER-22906`=76189토큰 list, `SERVER-23097`=20643토큰 list)가
임베드 실패. v2 후: **620/620, errors=0**, 전 코퍼스 7114 청크가 모두 ≤ 4000
(초과 0). `SERVER-22906`은 14 청크로, `SERVER-23097`은 5 청크로 분할. "WiredTiger
excessive memory cache size" 질의에서 그동안 색인조차 안 되던 `SERVER-22906`
**1위 결과(0.977)**로 — "임베드 불가"에서 "최상위 검색 결과"로 전환됐다. 출처
필터(`--trust-min primary` 등)는 정상 유지.

View File

@@ -0,0 +1,140 @@
---
title: "md-heading-v2 — 예산 초과 청크 일반 분할 (oversize-chunk split)"
created: 2026-06-24
status: implemented
extends: tasks/p1/p1-5-chunk.md (md-heading-v1, frozen)
contract_sections: [§3.5 Chunk, §4.2 chunk_id recipe, §7.2 Chunker, §9 versioning]
design_doc_change: none # 설계 §9 가 라벨 bump 를 변경 메커니즘으로 이미 명시
---
# md-heading-v2 — 예산 초과 청크 일반 분할
## 문제
`md-heading-v1`(p1-5)은 규칙 2로 "코드/테이블 블록은 `target_tokens`를 넘어도
절대 분할하지 않는다"를 둔다. 실제로는 **모든** atomic/single 블록(코드뿐 아니라
`list`·`table`·거대 `paragraph`)이 한 청크가 될 수 있고, 그 청크가 임베더
컨텍스트를 초과하면 임베딩이 실패한다.
도그푸딩에서 jira 이슈 일부(예: `SERVER-22906`)가 임베드에 실패했다. 긴 MongoDB
로그/스택트레이스가 md 변환 시 **하나의 거대 `list` 블록**(76189 byte/3 토큰,
소스 601303 줄)으로 렌더된 것이 원인이었다.
- 기존 ollama(`/api/embed`)는 이런 입력을 서버측 8192로 **조용히 truncate**
0 errors 였지만 사실상 잘려 색인됨.
- AMD Lemonade 같은 strict 백엔드는
`500 "input (N tokens) is too large ... increase the physical batch size"`
**거부**한다.
임베더-무관하게 견고하려면 청커가 애초에 예산 초과 청크를 만들지 않아야 한다.
임베더 측 전역 truncate(silently 잘림)는 reject.
## 결정 — 왜 v2(새 라벨)인가, 왜 일반(generic) 분할인가
- **새 변종 `md-heading-v2`, frozen doc 미변경.** 설계 doc은 "코드 블록 미분할"을
계약 불변식으로 못박지 않는다(예제 출력 텍스트일 뿐). 규칙은 frozen task spec
p1-5 소유. 설계 §9가 `chunk boundary/policy 변화 → 라벨(md-heading-v2)`을 변경
메커니즘으로 **명시**한다. → 설계 §1/§3.5/§7.2/§9 byte-identical, p1-5 frozen
유지. 선례: pdf-page-v1 → pdf-page-v1.1(HOTFIXES, frozen doc 미변경).
- **코드 한정이 아닌 일반 분할.** 실패의 실제 원인은 코드가 아니라 list 블록.
블록 종류별 특수 로직 대신 "예산 초과 청크"를 일반적으로 분할하면 list·code·
table·paragraph를 균일하게 덮고, fenced-code의 span 비대칭 문제(아래)도 피한다.
## 설계
`md-heading-v2``chunk()`는 v1과 **출력이 동일**하다(같은 블록 처리, 같은
soft-split). 마지막에 후처리 패스를 둔다:
```
let chunks = <v1-equivalent chunking>;
chunks.into_iter().flat_map(|c| {
// 판정 기준 = 실제 임베드 text 크기 (token_estimate 아님)
let embed_tokens = c.text.len().div_ceil(BYTES_PER_TOKEN);
if embed_tokens <= max_chunk_tokens { vec![c] } // v1 parity
else { split_oversize_chunk(c, max_chunk_tokens) } // 분할
})
```
**왜 `token_estimate` 가 아니라 `text.len()` 인가.** text 청크는 둘이 같다
(`token_estimate == text.len()/BYTES_PER_TOKEN`) → 비이미지 출력 불변. 하지만
`ImageRef`/`AudioRef` 청크는 `build_chunk` 가 image-only 규약으로
`token_estimate=0` 을 박는데, 그 `text`(alt+OCR+caption)는 임의로 클 수 있다
(빽빽한 스크린샷이 수십 KB 로 OCR 되는 경우). 임베더가 실제로 받는 건 `text` 이므로
거기에 맞춰 판정해야 oversize 이미지 OCR 도 분할된다. 이 구멍은 markdown-only
검증에선 안 보였고, **사용자 실 config(이미지 OCR ON) 재현 도그푸딩에서 발견**됐다
(회귀 테스트 `oversize_image_ocr_chunk_splits`).
`split_oversize_chunk`:
1. `chunk.text`를 줄(`\n`) 경계로 그리디 누적 — 다음 줄을 더하면 예산(byte/3)을
넘을 때 조각을 닫는다.
2. **단일 줄이 홀로 예산을 넘으면**(거대 paragraph는 개행 없는 한 줄로 렌더)
그 줄을 **UTF-8 char 경계**(`char_indices`)로 ≤ `budget * 3` 바이트씩 분할 —
codepoint 중간을 자르지 않는다. → 예산 상한이 모든 입력에 대해 **하드 보장**.
3. 각 조각 i → `Chunk`: 동일 `doc_id`/`block_ids`/`heading_path`/`chunker_version`,
`text`=조각, `token_estimate`=조각 byte/3, 저장 `policy_hash`=**bare** base 해시,
`chunk_id = id_for_chunk(doc_id, version, block_ids, "{base}#seg{i}")`.
### chunk_id 충돌 회피
분할 조각은 동일 `block_ids`를 공유하므로 `id_for_chunk`의 기본 recipe로는
충돌한다. id-input 해시에만 `#seg{i}`(i = 0-based 조각 인덱스, 단조증가) 접미사를
붙여 disambiguate하고, 저장 `Chunk.policy_hash`에는 bare base를 남긴다 —
pdf-page-v1의 `#L` recipe(HOTFIXES 2026-05-02 P7-2)와 동형.
### `max_chunk_tokens`를 policy_hash에 fold
신규 config `[ingest.chunking] max_chunk_tokens`(byte/3, default 4000)를 공유
`ChunkPolicy`에 넣으면 **모든** 청커(코드·PDF 포함)의 policy_hash가 바뀌어
cascade가 번진다. 대신 v2의 `policy_hash()`만 override해 canonical `ChunkPolicy`
바이트 뒤에 `max_chunk_tokens.to_le_bytes()`를 이어 blake3에 먹인다 → 값을 바꾸면
markdown만 재청크, v1·공유 ChunkPolicy 무영향.
### citation 정밀도 (known limitation)
분할 조각은 원 청크의 `source_spans`(원 블록 전체 범위)를 **그대로** 보존한다 →
거대 블록을 쪼갠 조각의 citation은 **블록 단위**(sub-line 정밀 아님). fenced
코드의 `SourceSpan::Line`은 fence 줄을 포함하는데 `code`는 content만 담아
(`kebab-parse-md/src/blocks.rs` `span_for(full range)`), 조각별 줄 범위를 정확히
좁히는 건 (span, code)만으로 일반적으로 불가능 → "절대 틀리지 않되 블록 단위"를
택했다. 미분할 청크는 v1과 byte-identical이라 영향 없음.
## cascade / 업그레이드
- 마크다운 청커 dispatch는 하드코딩(`kebab-app`) — config `chunker_version`
문자열은 impl 선택에 쓰이지 않는다. v2는 type swap으로 기본값 승격.
- `chunker_version` 라벨 `md-heading-v1``md-heading-v2` → 다음 plain
`kebab ingest`에서 markdown 자산 1회 자동 재청크(`--force-reingest` 불필요,
skip 비교 mismatch). 코드/PDF는 chunker_version unchanged → 무영향. 마이그레이션
불필요(chunks 스키마 V001부터 동일, chunk_id 키 재계산).
- **wrinkle**: 기존 config가 `chunker_version = "md-heading-v1"`로 핀돼 있어도
실제로는 v2가 돈다(문자열은 정보성). `config migrate`/새 default config는
`max_chunk_tokens`를 additive 주입.
## 검증 (도그푸딩)
실험 KB `/home/user/large_data/out/kebab-ab/xdg_sources`, arctic-embed-l-v2 @
Lemonade `.243`. v2 전: 620 중 2 doc(`SERVER-22906`/`SERVER-23097`) 임베드 실패.
v2 후: **620/620, errors=0**, 7114 청크 전부 ≤ 4000(초과 0). `SERVER-22906`
14 청크(max 3897), `SERVER-23097` → 5 청크(max 3850). 검색 payoff: "WiredTiger
excessive memory cache size" 질의에 `SERVER-22906`**1위(0.977)** — "임베드
불가"에서 "최상위 검색 결과"로. `--trust-min primary` 등 출처 필터 정상.
**일치 재테스트 (사용자 실 config 재현)**. 사용자 실 config 가 이미지 OCR + PDF
OCR 를 paddle-onnx 로 ON 함을 반영해, 미디어(생성 이미지 2 + repo scanned PDF 2)를
같은 paddle-onnx + arctic@Lemonade 로 재인덱싱(624 자산 errors=0). PDF OCR →
청크(`pdf-page-v1.1`) → arctic 임베드 정상. **여기서 image-OCR 구멍 발견·수정**
(위 "왜 token_estimate 가 아닌가" 참조). **known limitation**: PDF 는 별도 청커
`pdf-page-v1.1` 이라 v2 의 oversize-split 미적용 — 초고밀도 scanned page 가 한
청크로 budget 초과 시 잔존. 후속 후보: pdf-page 청커에도 동일 oversize-split.
단위 테스트(`crates/kebab-chunk/src/md_heading_v2.rs`): `oversize_list_block_splits`,
`oversize_paragraph_single_line_char_splits`(다국어 UTF-8 경계), `oversize_code_block_still_splits`,
`oversize_image_ocr_chunk_splits`(token_estimate=0 이미지 OCR), `non_oversize_identical_to_v1`(v1 parity),
`split_pieces_unique_deterministic_ids`(1000-iter 결정성), `budget_in_policy_hash`.
kebab-chunk / kebab-config / kebab-app 전체 pass, clippy `-D warnings` clean.
## 버전
`Cargo.toml` 0.29.0 → **0.30.0**. 신규 config 키 + 청커 동작 변경(검색 hit 분할)
= pre-1.0 minor + 도그푸딩 트리거(CLAUDE.md §Versioning/§Dogfood).

View File

@@ -14,6 +14,79 @@ historical contract that was implemented; this file accumulates the
deltas so phase 5+ readers can find the live behavior without diffing
git history.
## 2026-06-24 — md-heading-v2: 예산 초과 청크 일반 분할 (oversize-chunk split) (v0.30.0)
**무엇을 바꿨나.** markdown 청커에 새 변종 `md-heading-v2` 를 추가하고
기본값으로 승격했다. v1 의 규칙 2("코드/테이블 블록은 `target_tokens`
넘어도 절대 분할하지 않는다")는 **모든** 블록 종류로 일반화된 한계였다 — 하나의
거대 블록(코드뿐 아니라 list·table·paragraph 도)이 통째로 한 청크가 되어
임베더 컨텍스트를 초과할 수 있었다. v2 는 v1 과 **모든 출력이 동일**하되,
마지막에 청크의 **실제 임베드 text 크기**(`text.len()/3`)가 `max_chunk_tokens`
를 넘는 청크만 줄(`\n`) 경계로, 단일 거대 줄은 UTF-8 char 경계로 잘라 **각 조각이
예산 이하**가 되도록 분할한다. (판정 기준이 저장 `token_estimate`**아니라**
실제 text 길이인 이유: ImageRef/AudioRef 청크는 image-only 규약으로
`token_estimate=0` 인데 그 text(alt+OCR+caption)는 거대할 수 있다 — 빽빽한
스크린샷 OCR 이 대표 사례. 도그푸딩 일치 재테스트에서 발견·수정.)
신규 config `[ingest.chunking] max_chunk_tokens` (byte/3 토큰, default **4000**).
분할 조각의 chunk_id 는 동일 `block_ids` 를 공유하므로 id-input 해시에
`#seg{i}` 접미사를 붙여 충돌을 막는다(저장 `policy_hash` 는 bare — pdf-page-v1
`#L` 레시피와 동형). `max_chunk_tokens` 는 v2 의 `policy_hash`
folding 되어(공유 `ChunkPolicy` 는 미변경 → 코드/PDF 청커 cascade 무영향)
값을 바꾸면 markdown 만 재청크된다.
**왜 — strict 임베더는 oversize 입력을 truncate 가 아니라 거부한다.** 도그푸딩
도중 jira 이슈 일부가 임베드에 실패했다. 원인은 긴 MongoDB 로그/스택트레이스가
md 변환 시 **하나의 거대 `list` 블록**(예: SERVER-22906 = 76189 토큰 / 소스
601303 줄)으로 렌더된 것. 기존 ollama(`/api/embed`)는 이런 입력을 조용히
서버측 8192 로 **truncate** 해서 0 errors 였지만(=사실상 잘려 색인됨), AMD
Lemonade 같은 strict 백엔드는 `500 "input (N tokens) is too large ... increase
the physical batch size"` 로 **거부**한다. 임베더-무관하게 견고하려면 청커가
애초에 예산 초과 청크를 만들지 않는 게 옳다. (전역 truncate 를 임베더 측에
넣는 대안은 silently-잘림이라 reject.)
**cascade / 업그레이드.** `chunker_version` 라벨이 `md-heading-v1``md-heading-v2`
로 바뀌므로, **다음 plain `kebab ingest` 에서 markdown 자산이 1회 자동
재청크**된다(`--force-reingest` 불필요 — skip 비교가 mismatch). 코드/PDF 자산은
각자 chunker_version 이 unchanged 라 영향 없음. wire / CLI / `--json` 포맷
불변(검색 hit 의 텍스트·citation 모양 동일, 단 거대 블록이 여러 hit 로 나뉠 수
있음). **알아둘 wrinkle**: markdown 청커 dispatch 는 하드코딩이고 config
`chunker_version` 문자열은 impl 선택에 쓰이지 않는다 → 기존 config 가
`chunker_version = "md-heading-v1"` 로 핀돼 있어도 실제로는 v2 가 돈다(문자열은
정보성). 새 default config 와 `kebab config migrate``max_chunk_tokens`
additive 로 주입한다.
**citation 정밀도(known limitation).** 분할 조각은 원 블록의 `source_spans`
(블록 전체 범위)를 그대로 갖는다 — 즉 거대 블록을 쪼갠 조각의 인용은
**블록 단위**(sub-line 정밀 아님)다. fenced 코드의 `SourceSpan::Line` 이 fence
줄을 포함하는 비대칭 때문에 조각별 줄 범위를 정확히 좁히는 건 (span,code) 만으로
일반적으로 불가능 → "절대 틀리지 않되 블록 단위" 를 택했다. 일반 청크(미분할)는
v1 과 byte-identical 이라 영향 없음.
**도그푸딩 evidence** (실험 KB `/home/user/large_data/out/kebab-ab/xdg_sources`,
arctic-embed-l-v2 @ Lemonade `.243`). v2 전: 620 중 2 doc(SERVER-22906/23097)
임베드 실패. v2 후: **620/620, errors=0**, 7114 청크 전부 ≤ 4000(초과 0).
SERVER-22906 → 14 청크(max 3897), SERVER-23097 → 5 청크(max 3850). 검색 payoff:
"WiredTiger excessive memory cache size" 질의에 SERVER-22906 가 **1위(0.977)**
"임베드 불가" 에서 "최상위 검색 결과" 로. `--trust-min primary` 등 출처 필터
정상 유지.
**일치 재테스트 (사용자 실 config 재현 — 이미지 OCR + PDF OCR ON, paddle-onnx)**.
초기 검증은 markdown-only 였으나 사용자 실 config 가 image/pdf OCR ON 임을 반영해
미디어(이미지 2 + scanned PDF 2)를 같은 paddle-onnx + arctic@Lemonade 로 재인덱싱.
**여기서 image-OCR 구멍 발견·수정**: 분할 판정을 `token_estimate` 로 하면
ImageRef 청크는 `token_estimate=0`(image-only) 이라 OCR text 가 거대해도 분할이
안 됨 → 빽빽한 스크린샷 OCR 이 임베더 ctx 초과 시 strict 백엔드에서 실패 가능.
판정을 실제 text 길이로 교정 + 회귀 테스트(`oversize_image_ocr_chunk_splits`).
검증: scanned_page1/2.pdf OCR→청크(pdf-page-v1.1)→arctic 임베드 정상(max tok
443/672), 624 자산 errors=0. **known limitation**: PDF 는 별도 청커 `pdf-page-v1.1`
이라 이 oversize-split 미적용 — 초고밀도 scanned page 가 한 청크로 budget 초과 시
잔존(현 fixture 는 무관). md 청커(이미지 OCR text 포함)만 v2 가 커버. 후속 후보:
pdf-page 청커에도 동일 oversize-split.
p1-5(md-heading-v1) 를 확장하며 frozen 설계 doc / frozen p1-5 spec 은
미변경(설계 §9 가 `md-heading-v2` 라벨 bump 를 이미 변경 메커니즘으로 명시 —
pdf-page-v1→v1.1 선례 동형). 설계: `docs/superpowers/plans/2026-06-24-md-heading-v2-oversize-split.md`.
## 2026-06-21 — provenance 출처 필터: `[[workspace.sources]]` 멀티소스 + `--source` / `--source-type` (v0.29.0)
**무엇을 추가했나.** 혼합 출처 KB(예: 위키 문서 + jira 이슈)에서 "출처별로