feat(kebab-chunk): P7-2 pdf-page-v1 chunker #38
Reference in New Issue
Block a user
Delete Branch "feat/p7-2-pdf-page-chunker"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
요약
P7-2
PdfPageV1Chunker추가.kebab-parse-pdf가 emit 한CanonicalDocument(블록당 한 페이지, 모두SourceSpan::Page) 를 받아 페이지 경계를 절대 넘지 않는Chunk들을 생성. citation locality 가 §3.4SourceSpan::Page의 도입 이유인 만큼 cross-page splitting 은 구조적으로 차단.핵심 결정
target × BYTES_PER_TOKEN안이면 한 chunk; 초과 시\n\n(paragraph) / sentence-end 경계로 segment 화 후 greedy 누적. 다음 chunk prefix 에overlap × BYTES_PER_TOKENbyte 만큼 직전 꼬리 prepend.BYTES_PER_TOKEN = 3: md-heading-v1 실코드와 일치 (Korean ≈ 3 b/tok 커버, English over-estimate). spec literal 의 "/ 4" 는 그 자체로 md 와 어긋나 있어 일관성 쪽을 택함. cross-chunkerpolicy_hash동일성을 unit test 로 잠금.chunk_id충돌 가드 (§4.2 deviation): 한 페이지가 여러 chunk 로 분할되면block_ids가 모두 같아 §4.2 recipe 가 충돌.id_for_chunk의policy_hash입력만 per-chunk 로format!("{base}#c{char_start}")변형해 회피. recipe 자체는 불변.Chunk.policy_hash필드는 base 유지 (workspace-wide 정책 lookup 호환).두 deviation 모두
tasks/HOTFIXES.md에 entry 추가.테스트 (10개 신규, 22 total in kebab-chunk)
chunker_version_is_pdf_page_v1three_page_small_emits_one_chunk_per_page— 페이지별 1 chunk + Page span 검증one_page_huge_text_splits_into_multiple_chunks_with_overlap— overlap 적용 + 모든 chunk 가 page=1 + chunk_id 유일성empty_page_produces_no_chunks_for_that_pagewhitespace_only_page_skipped_toonon_pdf_doc_returns_error— Markdown shape (Line span) 거부no_chunk_crosses_page_boundary— 4-page mixed-size doc 에서 page 가 chunk 순서로 non-decreasingdeterministic_chunk_ids_1000snapshot_three_page_chunks_stable— version label + heading_path + Page span shape stablepolicy_hash_matches_md_heading_v1_for_identical_policy— cross-chunker fingerprint identity검증
cargo test -p kebab-chunk pdf10 passedcargo test -p kebab-chunk22 passed (md-heading-v1 회귀 없음)cargo clippy -p kebab-chunk --all-targets -- -D warningscleancargo check --workspacecleanSpec 매핑
tasks/p7/p7-2-pdf-page-chunker.mdchunker_version = "pdf-page-v1"Test plan
-D warnings통과`PdfPageV1Chunker` 가 `kebab-parse-pdf` 가 emit 한 `CanonicalDocument` (블록당 한 페이지, 모두 `SourceSpan::Page`) 를 받아 페이지 경계를 절대 넘지 않는 `Chunk` 들을 생성. `chunker_version = "pdf-page-v1"`. 핵심 동작: - 페이지 텍스트가 `target_tokens × BYTES_PER_TOKEN` (= 3) 안이면 한 덩어리. 초과 시 `\n\n` (paragraph) 또는 sentence-end 구두점 + whitespace 경계를 segment 로 보고 greedy 누적, 기본 한 chunk 당 최소 한 segment. - 다음 chunk 의 prefix 에 `overlap_tokens × BYTES_PER_TOKEN` 만큼의 직전 꼬리를 prepend (char 단위, 이전 chunk 시작 너머로 backtrack 안 함). - 빈/공백-only 페이지는 0 chunk (페이지의 `Provenance::Warning` 으로 `kebab-parse-pdf` 단계에 이미 표시됨). - 비-PDF doc (Block::Paragraph 가 아니거나 SourceSpan 이 Page 아님) → 명시 에러. Spec deviation (HOTFIXES 2026-05-02 P7-2): - `chunk_id` 충돌 가드: 같은 페이지에서 여러 chunk 가 나오면 `block_ids` 가 모두 같아 §4.2 recipe 가 충돌. `id_for_chunk` 의 `policy_hash` 인풋을 per-chunk 로 `format!("{base}#c{char_start}")` 변형해 회피. recipe 자체는 불변. `Chunk.policy_hash` 필드는 base 유지. - `BYTES_PER_TOKEN = 3` (md-heading-v1 실제 코드와 일치). spec 본문은 "/ 4" 라고 했지만 그 자체가 md-heading-v1 의 실코드와 어긋나 있어 일관성 쪽을 택함. cross-chunker `policy_hash` 동일성 unit test 로 잠금. 테스트 (10개 신규): - chunker_version label, 3-page small, 1-page huge + overlap + chunk_id 유일성, empty page skip, whitespace-only skip, non-PDF error, cross-page boundary 절대 안 만들어짐, determinism (1000회), snapshot shape 안정, md-heading-v1 와 policy_hash 동일. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>회차 1 — overlap walk-back cap 누락이 가장 중요. 병적 정책 (overlap >> target/2) 에서 chunk-explosion 위험. md-heading-v1 의 클램프 패턴을 동일하게 적용 필요. 그 외는 nit (silent u32 truncation, sentence-end 약어 케이스 doc 보강). 전체 디자인은 견고 — chunk_id 충돌 가드 + cross-chunker policy_hash 검증 + dev-dep 없는 합성 픽스처 모두 잘 정리됨.
@@ -0,0 +136,4 @@{let span = SourceSpan::Page {page: page_num,char_start: Some(char_start as u32),(nit / 보강)
char_start as u32/char_end as u32가 silent truncation 입니다. PDF 한 페이지가 4 G chars 를 넘는 케이스는 현실적으로 없지만 (PDF spec 상 종이 1 장 한계),as캐스팅은 fuzz / corrupted 파일에서 의미 없이 잘리고 잘못된 span 을 만듭니다.How to apply:
u32::try_from(char_start).expect("page chars fit in u32")정도로 dev-build 에서 명시 panic. 본 task spec 의char_start: Option<u32>는 §3.4 의 SourceSpan::Page 시그니처로 변경 불가 —as→try_from로 의도 명시만 바꾸는 것이라 scope 안.@@ -0,0 +174,4 @@}/// Split a single page's text into ordered chunks, each represented as/// `(char_start, char_end, text_slice)`. Char positions are within the(칭찬)
format!("{base_policy_hash}#c{char_start}")라는 per-chunk policy_hash 변형이 §4.2 chunk_id recipe 자체를 손대지 않으면서 collision 을 푸는 가장 작은-surface 해법입니다. recipe 의 4 슬롯 (doc_id, chunker_version, block_ids, policy_hash) 중 "입력만" per-chunk 로 다양화하고 —Chunk.policy_hash필드는 base 유지로 workspace 정책 lookup 호환 — 두 invariant 가 합쳐서 깔끔합니다. md-heading-v1 가 미래에 같은 패턴을 만나면 그대로 베껴 쓸 수 있는 모듈 boundary.@@ -0,0 +200,4 @@let is_paragraph_break = c == '\n' && nx == '\n';let is_sentence_end =matches!(c, '.' | '?' | '!') && nx.is_whitespace();if (is_paragraph_break || is_sentence_end) && k + 2 <= n {(nit / 문서화) sentence-end 휴리스틱이 "Mr. Smith", "i.e.", "e.g.", "Fig. 3" 등의 흔한 약어에서 spurious 경계를 만듭니다. 결과적으로 chunk 가 약어 직후에 잘려 retrieval 시 "Mr. Smith was…" 의 "Smith was…" 부분이 별도 chunk 로 떨어질 수 있습니다.
Why: spec § Risks 에 "sentence-splitting uses simple regex; languages without clear sentence punctuation may produce uneven chunks" 만 적혀 있는데, 약어 케이스도 같은 카테고리이므로 모듈 doc 에 한 줄 명시하는 편이 미래 reader 에게 친절합니다.
How to apply: 모듈 doc
## Splitting policy섹션에 "common English abbreviations (Mr.,i.e.,e.g.) trigger spurious sentence boundaries — accepted v1 limit, full sentence segmentation lands with the P+ tokenizer slot" 같은 두 줄 추가.@@ -0,0 +243,4 @@let prev_min = prev.0;let mut a = start;let mut acc_o: usize = 0;while a > prev_min {(issue) overlap walk-back cap 가 부족합니다. 현재
prev_min = prev.0(이전 chunk 의 시작 포함, overlap 까지 적용된 상태) 까지 backwalk 가 허용됩니다.overlap_bytes >> target_bytes/2인 병적인 정책이 들어오면:prev_min=0까지 →actual_start=0→ chunk 2 의 text 가
chars[0..20]으로 chunk 1 의 text 를 완전히 포함. 사실상 한 페이지가 1 chunk-같은-다른-chunk 를 무한 증식할 수 있는 패턴.md-heading-v1은 정확히 같은 함정을seed_budget = overlap_tokens.min(target_tokens / 2)로 막아둔 history 가 있습니다 (md_heading_v1.rs:258의overlap_clamped_when_overlap_exceeds_target회귀 테스트 — 본 PR 의 deviation 노트에서 이미 md-heading-v1 와 일관성을 맞추고 있는 만큼 이 가드도 같이 들여오는 게 자연스럽습니다).Why: 사용자가 실수로
overlap_tokens >= target_tokens정책을 넣었을 때 chunk-explosion 으로 KB 디스크 / 임베딩 비용이 폭발하는 걸 막는 운영 가드입니다.How to apply: chunk 메서드 진입부에서 한 줄 clamp + 같은 패턴의 회귀 테스트 추가:
그리고
pdf_page_overlap_clamped_when_exceeds_target같은 unit test 로overlap=200, target=50케이스에서 chunk 가actual_start <= chunks[i-1].1 - overlap_clamped_chars정도로 안정 되는지 확인.@@ -0,0 +354,4 @@let chunks = PdfPageV1Chunker.chunk(&doc, &default_policy(500, 80)).unwrap();assert_eq!(chunks.len(), 3);(칭찬) 합성
make_pdf_doc(pages: &[&str])헬퍼가kebab-parse-pdf를 dev-dep 으로 끌어오지 않고 직접Block::Paragraph+SourceSpan::Page를 생성합니다. spec § Forbidden deps 의 "kebab-parse-pdf" 제약을 dev-dep level 까지 일관되게 지키는 동시에, 본 chunker 의 input shape 가 정확히 무엇인지 reader 가 같은 파일에서 한 눈에 볼 수 있게 됩니다 —kebab-parse-pdf의 미래 변경에 영향 받지 않는 self-contained test 입니다.@@ -0,0 +507,4 @@// must claim exactly one page in its single source_span.let big_x = "x".repeat(2000);let big_y = "y".repeat(800);let pages = vec![(칭찬)
policy_hash_matches_md_heading_v1_for_identical_policy가 cross-chunker fingerprint 동일성을 unit test 로 잠근 게 좋습니다. 본 PR 의 BYTES_PER_TOKEN deviation 노트가 "md-heading-v1 와 calibration 일치" 라고 주장하는데, 그 주장이 실제로 한 슬롯 (policy_hash) 에서라도 검증된다는 사실이 미래에 누군가 BYTES_PER_TOKEN 만 다시 4 로 되돌리려고 할 때 즉시 빨갛게 만듭니다.@@ -14,6 +14,26 @@ historical contract that was implemented; this file accumulates thedeltas so phase 5+ readers can find the live behavior without diffinggit history.## 2026-05-02 — P7-2 pdf-page-v1: chunk_id collision + BYTES_PER_TOKEN(칭찬) HOTFIXES entry 가 두 가지 deviation (
chunk_id충돌 가드,BYTES_PER_TOKEN=3 vs 4) 을 각각 "Symptom → Root cause → Fix → Trust note → Amends" 의 동일한 형식으로 정리한 게 좋습니다. 특히BYTES_PER_TOKEN케이스에서 spec literal 이md-heading-v1의 실코드와 어긋나 있다는 사실 ("spec's '/4' claim is inconsistent with the implementation it claims to match") 을 직시한 것이 중요합니다 — frozen task spec 을 retroactively 손대지 않고 HOTFIXES 가 살아있는 source of truth 라는 워크스페이스 컨벤션을 정확히 활용한 사례.회차 2 — 회차 1 지적 3건 (overlap clamp, u32 silent truncation, 약어 케이스 doc) 모두 반영 + overlap clamp 회귀 테스트 추가. clippy clean, 11 pdf tests + 22 chunk tests pass. 머지 가능.
@@ -0,0 +156,4 @@// is preferable to an off-by-2^32 span.let char_start_u32 = u32::try_from(char_start).expect("page chars fit in u32");let char_end_u32 =(칭찬)
target_bytes / 2clamp 가 정확히 md-heading-v1 의seed_budget = overlap_tokens.min(target_tokens / 2)와 같은 형태로 들어갔습니다. 두 chunker 가 같은 invariant 를 같은 표현으로 보장하면 미래에 누군가 한 쪽만 만지면 즉시 발견할 수 있는 symmetry — 본 PR 의 cross-chunker 일관성 약속 (BYTES_PER_TOKEN 동일 + policy_hash 동일성 unit test) 라인을 한 줄 더 강화합니다.@@ -0,0 +185,4 @@});}}(칭찬)
as u32→u32::try_from(...).expect(...)+ 코멘트 (PDF chars-per-page comfortably fits in u32 (a single page maxes out around ~10k chars even for dense typography)) 로 silent truncation 의도성을 명시한 게 좋습니다. fuzzed / corrupted 페이지에서 의미 없는 잘림 대신 명시 panic — debugging 친화적이고, expect 메시지가 invariant 를 그대로 코드 안에 박아둡니다.@@ -0,0 +667,4 @@// Cross-chunker policy fingerprint identity — important so a// workspace-wide "show me chunks with policy_hash = X" query// covers both chunkers without per-chunker logic.let p = default_policy(500, 80);(칭찬)
overlap_clamped_when_overlap_exceeds_target가actual_start가 인접 chunk 사이에 엄격 증가 하는지 직접 단정합니다. 단순히 chunk 개수만 보지 않고 "chunk N 이 chunk N-1 을 포함하지 않는다" 라는 invariant 를 표현해서, 미래에 누군가 clamp 를 잘못 풀어 chunk-explosion 을 다시 만들면 정확한 위치에서 빨갛게 됩니다.