feat(chunk): md-heading-v2 — 예산 초과 청크 일반 분할 #209
48
Cargo.lock
generated
48
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 또는 모델을 바꾸면 영향 이미지가 자동 재색인된다.
|
||||
|
||||
@@ -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(_) => {
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
1078
crates/kebab-chunk/src/md_heading_v2.rs
Normal file
1078
crates/kebab-chunk/src/md_heading_v2.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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 20k–76k 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(),
|
||||
|
||||
@@ -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 는 번들 모델 사용(이 값 무시).",
|
||||
|
||||
@@ -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-안전)
|
||||
|
||||
@@ -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
|
||||
|
||||
97
docs/release-notes/v0.30.0-draft.md
Normal file
97
docs/release-notes/v0.30.0-draft.md
Normal 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` 등)는 정상 유지.
|
||||
@@ -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 토큰,
|
||||
소스 60–1303 줄)으로 렌더된 것이 원인이었다.
|
||||
|
||||
- 기존 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).
|
||||
@@ -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 토큰 / 소스
|
||||
60–1303 줄)으로 렌더된 것. 기존 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 이슈)에서 "출처별로
|
||||
|
||||
Reference in New Issue
Block a user