v1 의 "블록 미분할" 한계를 일반화. 거대 list/code/table/paragraph 가 한 청크로
임베더 컨텍스트를 초과해 임베딩이 통째로 실패하던 문제를 해소한다. v2 는 v1 과
모든 출력이 동일하되, 청크의 실제 임베드 text 크기(`text.len()/3`)가
`max_chunk_tokens`(신규 config, byte/3, default 4000)를 넘는 청크만 줄(`\n`)
경계로, 단일 거대 줄은 UTF-8 char 경계로 잘라 각 조각이 예산 이하가 되게 한다.
- 판정 기준은 저장 `token_estimate` 가 아니라 실제 `text` 길이: ImageRef/AudioRef
청크는 image-only 규약으로 token_estimate=0 이지만 OCR/caption text 는 클 수
있다(빽빽한 스크린샷). 도그푸딩 일치 재테스트에서 이 image-OCR 구멍 발견·수정.
- 분할 조각 chunk_id 는 동일 block_ids 를 공유하므로 id-input 해시에 `#seg{i}`
접미사로 충돌 회피(저장 policy_hash 는 bare — pdf-page-v1 의 `#L` 레시피 동형).
- `max_chunk_tokens` 는 v2 의 policy_hash 에만 fold(공유 ChunkPolicy 미변경 →
코드/PDF 청커 cascade 무영향). 값 변경 시 markdown 만 재청크.
- 미분할 청크는 v1 과 byte-identical. `chunker_version` v1→v2 → 다음 plain
`kebab ingest` 에서 markdown 1회 자동 재청크(--force 불필요, 코드/PDF 무영향).
동기: strict 임베더(AMD Lemonade `/api/embed`)는 oversize 입력을 truncate 아닌
거부(`500 too large`) — ollama 가 조용히 truncate 하던 걸 청커가 애초에 안 만들게.
검증(실험 KB, arctic-embed-l-v2 @ Lemonade): v2 전 620 중 2 doc(거대 jira list
블록) 임베드 실패 → v2 후 620/620 errors=0, 7114 청크 전부 ≤ 4000. 사용자 실
config 일치 재테스트(이미지 OCR + PDF OCR paddle-onnx ON)에서 image-OCR 구멍
발견·수정 후 dense 이미지 OCR 텍스트가 budget 초과 시 분할(token_estimate=0 →
1청크였던 것이 실제 text 기준 다중 청크로) 실증. frozen 설계 doc / frozen p1-5
spec 미변경(설계 §9 가 md-heading-v2 라벨 bump 를 변경 메커니즘으로 명시).
known limitation: PDF 는 별도 청커 pdf-page-v1.1 이라 이 split 미적용(후속 후보).
Cargo.toml 0.29.0 → 0.30.0 (신규 config 키 + 청커 동작 변경 = pre-1.0 minor +
도그푸딩 트리거). docs cascade: HOTFIXES / release-notes-v0.30.0-draft /
plan(2026-06-24) / normalize-chunk README / README / SMOKE / HANDOFF.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012Mc6W1fgsrbFKTsqA6P8La
172 lines
6.3 KiB
Rust
172 lines
6.3 KiB
Rust
//! v0.26.2: ingest-config invalidation — changing a setting that affects
|
|
//! ingest output auto-re-indexes the affected assets on the next ingest
|
|
//! (no `--force-reingest`), while changing an unrelated setting does not.
|
|
//!
|
|
//! These end-to-end tests exercise the model-free signals (chunking +
|
|
//! `[ingest.code]` options vs `search` settings). The exhaustive per-setting
|
|
//! mapping (image OCR / caption, pdf.ocr, code options, search/rag/ui
|
|
//! invariance) is unit-tested in
|
|
//! `kebab-app/src/lib.rs::ingest_config_signature_tests` — those toggles
|
|
//! (OCR/caption) require a live vision endpoint to ingest, so the wiring is
|
|
//! verified here via the signature-driven chunking path that shares the same
|
|
//! `effective_parser_version` plumbing.
|
|
|
|
mod common;
|
|
|
|
use common::TestEnv;
|
|
|
|
use kebab_app::{IngestOpts, ingest_with_config, ingest_with_config_opts};
|
|
use kebab_core::IngestItemKind;
|
|
|
|
/// Seed a workspace with a markdown + a rust file so both the markdown and
|
|
/// the code ingest paths are exercised. Returns the first-ingest report.
|
|
fn seed_and_first_ingest(env: &TestEnv) -> kebab_core::IngestReport {
|
|
std::fs::write(
|
|
env.workspace_root.join("demo.rs"),
|
|
"/// adds two integers\npub fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
|
|
)
|
|
.unwrap();
|
|
let first = ingest_with_config(env.config.clone(), env.scope(), false).expect("first ingest");
|
|
assert_eq!(first.errors, 0, "first ingest must not error: {first:?}");
|
|
assert!(first.new >= 1, "first ingest creates docs: {first:?}");
|
|
assert_eq!(first.unchanged, 0, "first ingest has no unchanged: {first:?}");
|
|
first
|
|
}
|
|
|
|
fn reingest(env: &TestEnv) -> kebab_core::IngestReport {
|
|
ingest_with_config_opts(env.config.clone(), env.scope(), false, IngestOpts::default())
|
|
.expect("re-ingest")
|
|
}
|
|
|
|
/// Re-running with the identical config skips every asset (no spurious
|
|
/// re-index). Regression guard for over-invalidation.
|
|
#[test]
|
|
fn identical_config_skips_all_assets() {
|
|
let env = TestEnv::lexical_only();
|
|
let first = seed_and_first_ingest(&env);
|
|
let scanned = first.scanned;
|
|
|
|
let second = reingest(&env);
|
|
assert_eq!(second.scanned, scanned);
|
|
assert_eq!(second.new, 0, "no new docs: {second:?}");
|
|
assert_eq!(second.updated, 0, "nothing re-indexed: {second:?}");
|
|
assert_eq!(second.unchanged, scanned, "every doc Unchanged: {second:?}");
|
|
assert_eq!(second.errors, 0);
|
|
}
|
|
|
|
/// Changing a common chunking parameter re-indexes EVERY media type
|
|
/// (markdown + code here) without `--force-reingest`.
|
|
#[test]
|
|
fn chunking_change_reindexes_all_types() {
|
|
let mut env = TestEnv::lexical_only();
|
|
let first = seed_and_first_ingest(&env);
|
|
let scanned = first.scanned;
|
|
|
|
// Bump target_tokens — folds into every type's signature.
|
|
env.config.ingest.chunking.target_tokens += 100;
|
|
|
|
let second = reingest(&env);
|
|
assert_eq!(second.scanned, scanned);
|
|
assert_eq!(second.new, 0, "no new docs: {second:?}");
|
|
assert_eq!(
|
|
second.unchanged, 0,
|
|
"chunking change must re-index all: {second:?}"
|
|
);
|
|
assert_eq!(
|
|
second.updated, scanned,
|
|
"every doc re-indexed as Updated: {second:?}"
|
|
);
|
|
assert_eq!(second.errors, 0);
|
|
}
|
|
|
|
/// Changing an `[ingest.code]` option re-indexes only the code asset; the
|
|
/// markdown assets stay Unchanged.
|
|
#[test]
|
|
fn code_option_change_reindexes_code_only() {
|
|
let mut env = TestEnv::lexical_only();
|
|
let first = seed_and_first_ingest(&env);
|
|
let scanned = first.scanned;
|
|
|
|
// Raise max_file_lines (keeps the tiny demo.rs in-scope; only the code
|
|
// signature changes).
|
|
env.config.ingest.code.max_file_lines += 1000;
|
|
|
|
let second = reingest(&env);
|
|
assert_eq!(second.scanned, scanned);
|
|
assert_eq!(second.new, 0, "no new docs: {second:?}");
|
|
assert_eq!(second.errors, 0);
|
|
assert_eq!(
|
|
second.updated, 1,
|
|
"exactly the code asset re-indexed: {second:?}"
|
|
);
|
|
assert_eq!(
|
|
second.unchanged,
|
|
scanned - 1,
|
|
"all markdown assets stay Unchanged: {second:?}"
|
|
);
|
|
|
|
let items = second.items.as_ref().expect("items present");
|
|
let code = items
|
|
.iter()
|
|
.find(|i| i.doc_path.0.ends_with("demo.rs"))
|
|
.expect("demo.rs item");
|
|
assert_eq!(
|
|
code.kind,
|
|
IngestItemKind::Updated,
|
|
"demo.rs must be re-indexed: {code:?}"
|
|
);
|
|
for i in items.iter().filter(|i| i.doc_path.0.ends_with(".md")) {
|
|
assert_eq!(
|
|
i.kind,
|
|
IngestItemKind::Unchanged,
|
|
"markdown must be Unchanged: {i:?}"
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Regression guard: changing a non-ingest setting (`search.default_k`) does
|
|
/// NOT re-index anything.
|
|
#[test]
|
|
fn search_setting_change_reindexes_nothing() {
|
|
let mut env = TestEnv::lexical_only();
|
|
let first = seed_and_first_ingest(&env);
|
|
let scanned = first.scanned;
|
|
|
|
env.config.search.default_k += 5;
|
|
env.config.search.snippet_chars += 50;
|
|
env.config.rag.score_gate = 0.5;
|
|
|
|
let second = reingest(&env);
|
|
assert_eq!(second.scanned, scanned);
|
|
assert_eq!(
|
|
second.unchanged, scanned,
|
|
"search/rag changes must not re-index: {second:?}"
|
|
);
|
|
assert_eq!(second.updated, 0, "nothing re-indexed: {second:?}");
|
|
assert_eq!(second.new, 0);
|
|
assert_eq!(second.errors, 0);
|
|
}
|
|
|
|
/// v3 불변식 #1: `ingest_config_signature` 출력 문자열은 값 기반이라 struct
|
|
/// 경로 재편(미디어 ingest 통합) 후에도 v2 와 **바이트 동일**해야 한다. 깨지면
|
|
/// 업그레이드 시 전체 재색인 발생. paddle-onnx image 분기 형식 골든.
|
|
#[test]
|
|
fn ingest_signature_image_paddle_byte_stable() {
|
|
let mut cfg = kebab_config::Config::defaults();
|
|
cfg.ingest.image.ocr.enabled = true;
|
|
cfg.ingest.image.ocr.engine = "paddle-onnx".into();
|
|
let sig = kebab_app::test_ingest_config_signature(
|
|
&cfg,
|
|
&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-v2:4000"),
|
|
"chunk prefix drift: {sig}"
|
|
);
|
|
assert!(sig.contains("|ocr:1:paddle-onnx:"), "ocr token drift: {sig}");
|
|
assert!(sig.ends_with("|cap:0"), "cap token drift: {sig}");
|
|
}
|