fix: 28.4k 문서 도그푸딩이 잡은 결함 3건 + eval 회귀 게이트
나무위키 18,282 + Apache Jira 10,145 = 28,427 문서 / 600,808 청크로 종단 도그푸딩. 기존 최대 규모(620 문서)의 46배라 그 규모에서 안 보이던 결함이 드러났다. 상세 evidence 는 tasks/HOTFIXES.md 2026-08-16 엔트리. 1) Lance fragment 무한 누적 (이슈 #230 제안 2) upsert 가 asset 1건당 merge_insert 를 1회 호출하고 Lance 는 그때마다 새 버전 + fragment 를 만든다. manifest 는 그 시점의 전 fragment 를 나열하므로 N번째 쓰기가 N줄짜리 manifest 를 새로 쓴다 — 쓰기 비용이 문서 수에 비례, manifest 총량은 제곱. 코드베이스에 optimize/compact/cleanup 호출이 0건이었다. 16,828 문서 시점: fragment 16,814 · _versions 12.2 GB (실데이터는 1.7 GB) · ingest 30.7 → 4.3 문서/분으로 단조 하락. COMPACT_EVERY_N_UPSERTS=512 마다 Compact + Prune 추가 후: manifest 336 · _versions 6.6 MB · 28,427 문서 끝까지 32~36 문서/분 유지. 기존 테이블 1회 압축은 153초에 15 GB → 1.7 GB (행 365,991 보존). 같은 이슈의 삭제 경로 배치화 / geodatafusion / doctor 지표는 미해결. 2) 묶음 인용 마커가 answer.v1 에서 조용히 사라짐 마커 정규식이 괄호 하나에 마커 하나만 인정해서, 모델이 한 주장에 여러 근거를 다는 [#2, #10] 형태를 못 잡았다. citations 는 추출 마커와 packed entry 의 교집합이라 그 근거들이 배열에서 빠지고, 본문에는 [#2] 가 남아 해소 불가능한 인용이 됐다. 프롬프트는 [#번호] 로 귀속하라고만 하고 한 괄호에 하나씩 쓰라고 지시한 적이 없으므로 모델 잘못이 아니다. 실측: 본문 1,2,6,7,8,9,10 vs citations 1,8,9,10 → 3건 끊김. 수정 후 7/7. 기존 엄격함(vec![1] · [1] · [ #1 ] · [#foo] · [#1234] 불인정)은 유지. 3) ollama 요청이 max_tokens 를 안 보냄 GenerateRequest::max_tokens 를 RAG 파이프라인이 계산해 넘기는데 OllamaOptions 가 그걸 직렬화하지 않았다. ollama 기본 num_predict 는 -1(무제한)이고 컨텍스트가 차면 창을 밀어 계속 생성한다. 연결에 바이트가 계속 흐르므로 request_timeout_secs 로도 못 막는다. eval run --with-rag 216 질의가 1시간 45분에 21개만 끝냈고, 붙잡고 있던 질의 하나가 13 MB 를 받은 상태였다. num_predict 전송 후 같은 216 질의가 31분에 완료. 4) eval compare --fail-under (신규) 이전에는 delta 만 출력하고 exit code 가 항상 0 이라 "회귀했는가"를 기계가 판정할 수 없었다. empty_result_rate 는 반대 방향으로 검사하고, A 에서 재던 지표가 B 에서 NaN 이 되면 위반으로 잡는다 — 골든셋이 ground truth 를 잃은 경우가 정확히 그 모양이라 조용히 통과시키면 안 된다. 테스트: 워크스페이스 186 결과 전부 통과. 신규 회귀 테스트 7개 (compaction 1 · 마커 그룹 1 · num_predict 1 · fail-under 4). clippy 는 kebab-parse-code 의 기존 question_mark 지적으로 red 인데 main 도 동일하며 이 PR 이 건드리지 않은 크레이트다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -4611,6 +4611,7 @@ dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
"blake3",
|
||||
"chrono",
|
||||
"futures",
|
||||
"kebab-config",
|
||||
"kebab-core",
|
||||
|
||||
@@ -145,6 +145,10 @@ fastembed = "4.9"
|
||||
# crate matches that major to share the same Arrow types without a
|
||||
# re-export adapter.
|
||||
lancedb = { version = "0.23", default-features = false }
|
||||
# lancedb 의 OptimizeAction::Prune 이 chrono::Duration 을 받는다 (lancedb 는
|
||||
# chrono 를 re-export 하지 않는다). 이미 트리에 들어와 있는 크레이트라 빌드
|
||||
# 비용은 늘지 않는다.
|
||||
chrono = { version = "0.4", default-features = false }
|
||||
arrow = "56"
|
||||
arrow-array = "56"
|
||||
arrow-schema = "56"
|
||||
|
||||
@@ -89,7 +89,7 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
|
||||
| `kebab list docs` | 색인된 문서 목록 |
|
||||
| `kebab inspect doc <id>` / `inspect chunk <id>` | raw record 보기 |
|
||||
| `kebab fetch chunk\|doc\|span <id> [flags]` | indexed corpus 에서 verbatim text fetch |
|
||||
| `kebab eval run \| aggregate \| compare \| variants` | golden query 회귀 측정 + 변형 일관성 진단 |
|
||||
| `kebab eval run \| aggregate \| compare \| variants` | golden query 회귀 측정 + 변형 일관성 진단. `compare --fail-under <허용치>` 는 지표가 그만큼보다 나빠지면 exit 1 (없으면 delta 만 출력하고 항상 exit 0) |
|
||||
| `kebab schema [--json]` | introspection — wire schemas / capabilities / models / stats |
|
||||
| `kebab doctor` | 설정 / 모델 / DB 헬스 체크 |
|
||||
| `kebab mcp` | MCP stdio server (`search` / `bulk_search` / `ask` / `fetch` / `schema` / `doctor` / `ingest_file` / `ingest_stdin`) |
|
||||
|
||||
@@ -37,3 +37,18 @@ impl fmt::Display for NoHitSignal {
|
||||
}
|
||||
|
||||
impl std::error::Error for NoHitSignal {}
|
||||
|
||||
/// `kebab eval compare --fail-under` found a metric that regressed past
|
||||
/// the tolerance. Like the others here this is normal output, not a
|
||||
/// failure — it just has to reach the shell as a non-zero status so a
|
||||
/// golden set can actually gate something.
|
||||
#[derive(Debug)]
|
||||
pub struct EvalRegression;
|
||||
|
||||
impl fmt::Display for EvalRegression {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("eval regression")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EvalRegression {}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
//!
|
||||
//! See `docs/superpowers/specs/2026-05-07-p9-fb-27-introspection-and-error-wire-design.md`.
|
||||
|
||||
pub use crate::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
|
||||
pub use crate::doctor_signal::{DoctorUnhealthy, EvalRegression, NoHitSignal, RefusalSignal};
|
||||
|
||||
pub use kebab_config::{ConfigInvalid, ConfigNotFound};
|
||||
pub use kebab_llm_local::LlmError;
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::process::ExitCode;
|
||||
use anyhow::Context;
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use kebab_app::doctor_signal::{DoctorUnhealthy, NoHitSignal, RefusalSignal};
|
||||
use kebab_app::doctor_signal::{DoctorUnhealthy, EvalRegression, NoHitSignal, RefusalSignal};
|
||||
|
||||
mod cancel;
|
||||
mod progress;
|
||||
@@ -457,6 +457,13 @@ enum EvalWhat {
|
||||
/// `runs_dir/<run_b>/report.md`.
|
||||
#[arg(long)]
|
||||
write_report: bool,
|
||||
/// Exit 1 when any metric got worse by more than this much
|
||||
/// (e.g. `--fail-under 0.03`). Without it `compare` prints
|
||||
/// deltas and always exits 0, so a golden set cannot gate
|
||||
/// anything. `empty_result_rate` is checked in the opposite
|
||||
/// direction — a rise there is the regression.
|
||||
#[arg(long)]
|
||||
fail_under: Option<f64>,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -576,6 +583,9 @@ fn exit_code(err: &anyhow::Error) -> u8 {
|
||||
if err.downcast_ref::<NoHitSignal>().is_some() {
|
||||
return 1;
|
||||
}
|
||||
if err.downcast_ref::<EvalRegression>().is_some() {
|
||||
return 1;
|
||||
}
|
||||
if err.downcast_ref::<DoctorUnhealthy>().is_some() {
|
||||
return 3;
|
||||
}
|
||||
@@ -1451,6 +1461,7 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
run_b,
|
||||
strict_chunker_version,
|
||||
write_report,
|
||||
fail_under,
|
||||
} => {
|
||||
let opts = kebab_eval::CompareOpts {
|
||||
strict_chunker_version: *strict_chunker_version,
|
||||
@@ -1477,6 +1488,19 @@ fn run(cli: &Cli) -> anyhow::Result<()> {
|
||||
eprintln!("wrote {}", path.display());
|
||||
}
|
||||
}
|
||||
if let Some(tol) = *fail_under {
|
||||
let bad = kebab_eval::regressions(&report, tol);
|
||||
if !bad.is_empty() {
|
||||
eprintln!("eval regression (허용치 {tol}):");
|
||||
for line in &bad {
|
||||
eprintln!(" {line}");
|
||||
}
|
||||
return Err(EvalRegression.into());
|
||||
}
|
||||
if !cli.json {
|
||||
eprintln!("eval: 허용치 {tol} 안 — 회귀 없음");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -488,6 +488,89 @@ fn build_deltas(
|
||||
})
|
||||
}
|
||||
|
||||
/// Metrics that got worse by more than `tolerance` from run A to run B.
|
||||
///
|
||||
/// Returns one line per violation, empty when B is within tolerance of A.
|
||||
/// This is what turns a golden set into a gate: `compare` on its own only
|
||||
/// prints deltas and always exits 0, so "did this change make retrieval
|
||||
/// worse" had no machine-checkable answer.
|
||||
///
|
||||
/// Direction matters per metric. Everything here is higher-is-better
|
||||
/// except `empty_result_rate`, where a rise means more queries came back
|
||||
/// with nothing.
|
||||
///
|
||||
/// A metric that was measurable in A but is `NaN` in B counts as a
|
||||
/// violation rather than a skip. That case means the measurement itself
|
||||
/// disappeared — usually a golden set that lost its ground truth — and
|
||||
/// silently reporting "no regression" for it would be the worst possible
|
||||
/// answer.
|
||||
#[must_use]
|
||||
pub fn regressions(report: &CompareReport, tolerance: f64) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let (a, b) = (&report.aggregate_a, &report.aggregate_b);
|
||||
|
||||
let mut check = |name: String, av: f32, bv: f32, lower_is_better: bool| {
|
||||
if av.is_nan() {
|
||||
// Never measured on the baseline side — nothing to regress from.
|
||||
return;
|
||||
}
|
||||
if bv.is_nan() {
|
||||
out.push(format!("{name}: {av:.4} -> 측정 불가 (B 에서 지표가 사라짐)"));
|
||||
return;
|
||||
}
|
||||
let delta = f64::from(bv - av);
|
||||
let worse = if lower_is_better { delta } else { -delta };
|
||||
if worse > tolerance {
|
||||
out.push(format!(
|
||||
"{name}: {av:.4} -> {bv:.4} ({delta:+.4}, 허용치 {tolerance:.4})"
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
for k in crate::metrics::TOP_K_VARIANTS {
|
||||
let nan = f32::NAN;
|
||||
check(
|
||||
format!("hit_at_k[{k}]"),
|
||||
a.hit_at_k.get(k).copied().unwrap_or(nan),
|
||||
b.hit_at_k.get(k).copied().unwrap_or(nan),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
format!("recall_at_k_doc[{k}]"),
|
||||
a.recall_at_k_doc.get(k).copied().unwrap_or(nan),
|
||||
b.recall_at_k_doc.get(k).copied().unwrap_or(nan),
|
||||
false,
|
||||
);
|
||||
check(
|
||||
format!("precision_at_k_chunk[{k}]"),
|
||||
a.precision_at_k_chunk.get(k).copied().unwrap_or(nan),
|
||||
b.precision_at_k_chunk.get(k).copied().unwrap_or(nan),
|
||||
false,
|
||||
);
|
||||
}
|
||||
check("mrr".into(), a.mrr, b.mrr, false);
|
||||
check(
|
||||
"citation_coverage".into(),
|
||||
a.citation_coverage,
|
||||
b.citation_coverage,
|
||||
false,
|
||||
);
|
||||
check("groundedness".into(), a.groundedness, b.groundedness, false);
|
||||
check(
|
||||
"refusal_correctness".into(),
|
||||
a.refusal_correctness,
|
||||
b.refusal_correctness,
|
||||
false,
|
||||
);
|
||||
check(
|
||||
"empty_result_rate".into(),
|
||||
a.empty_result_rate,
|
||||
b.empty_result_rate,
|
||||
true,
|
||||
);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -545,6 +628,84 @@ mod tests {
|
||||
assert_eq!(d["chunker_version_match"], "exact");
|
||||
}
|
||||
|
||||
fn agg(mrr: f32, recall10: f32, empty: f32) -> AggregateMetrics {
|
||||
AggregateMetrics {
|
||||
hit_at_k: Default::default(),
|
||||
mrr,
|
||||
recall_at_k_doc: [(10u32, recall10)].into_iter().collect(),
|
||||
precision_at_k_chunk: Default::default(),
|
||||
citation_coverage: f32::NAN,
|
||||
groundedness: 1.0,
|
||||
empty_result_rate: empty,
|
||||
refusal_correctness: f32::NAN,
|
||||
total_queries: 10,
|
||||
failed_queries: 0,
|
||||
}
|
||||
}
|
||||
|
||||
fn report(a: AggregateMetrics, b: AggregateMetrics) -> CompareReport {
|
||||
CompareReport {
|
||||
run_a: "a".into(),
|
||||
run_b: "b".into(),
|
||||
deltas: build_deltas(&a, &b, "exact"),
|
||||
aggregate_a: a,
|
||||
aggregate_b: b,
|
||||
per_query: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regressions_empty_when_within_tolerance() {
|
||||
// 0.01 하락은 허용치 0.03 안이다.
|
||||
let r = report(agg(0.50, 0.95, 0.0), agg(0.49, 0.94, 0.0));
|
||||
assert!(regressions(&r, 0.03).is_empty());
|
||||
// 개선은 당연히 위반이 아니다.
|
||||
let up = report(agg(0.50, 0.95, 0.0), agg(0.80, 0.99, 0.0));
|
||||
assert!(regressions(&up, 0.03).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regressions_flag_metric_drops_beyond_tolerance() {
|
||||
let r = report(agg(0.50, 0.95, 0.0), agg(0.40, 0.80, 0.0));
|
||||
let v = regressions(&r, 0.03);
|
||||
assert_eq!(v.len(), 2, "mrr + recall@10 두 개여야 한다: {v:?}");
|
||||
assert!(v.iter().any(|s| s.starts_with("mrr:")), "{v:?}");
|
||||
assert!(
|
||||
v.iter().any(|s| s.starts_with("recall_at_k_doc[10]:")),
|
||||
"{v:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regressions_treat_empty_result_rate_as_lower_is_better() {
|
||||
// 빈 결과 비율은 오르는 쪽이 나빠지는 것이다.
|
||||
let worse = report(agg(0.5, 0.95, 0.10), agg(0.5, 0.95, 0.30));
|
||||
assert_eq!(regressions(&worse, 0.03).len(), 1);
|
||||
// 내려가는 것은 개선이므로 위반이 아니다.
|
||||
let better = report(agg(0.5, 0.95, 0.30), agg(0.5, 0.95, 0.10));
|
||||
assert!(regressions(&better, 0.03).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regressions_flag_metric_that_stopped_being_measurable() {
|
||||
// A 에서는 재던 지표가 B 에서 NaN 이면 "회귀 없음"이 아니라 위반이다.
|
||||
// 골든셋이 ground truth 를 잃은 경우가 정확히 이 모양으로 나타난다.
|
||||
let a = agg(0.5, 0.95, 0.0);
|
||||
let mut b = agg(0.5, 0.95, 0.0);
|
||||
b.recall_at_k_doc.clear();
|
||||
let v = regressions(&report(a, b), 0.03);
|
||||
assert_eq!(v.len(), 1, "{v:?}");
|
||||
assert!(v[0].contains("측정 불가"), "{v:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regressions_skip_metrics_never_measured_on_either_side() {
|
||||
// citation_coverage / refusal_correctness 는 --with-rag 없이는 양쪽 다 NaN 이다.
|
||||
// 잰 적이 없는 지표로 게이트를 실패시키면 안 된다.
|
||||
let r = report(agg(0.5, 0.95, 0.0), agg(0.5, 0.95, 0.0));
|
||||
assert!(regressions(&r, 0.0).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_chunker_version_from_snapshot() {
|
||||
let s = r#"{"config":{},"chunker_version":"slot@1"}"#;
|
||||
|
||||
@@ -29,7 +29,7 @@ mod variant;
|
||||
|
||||
pub use compare::{
|
||||
CompareOpts, CompareReport, ComparisonKind, QueryComparison, compare_runs,
|
||||
compare_runs_with_config, render_report_md,
|
||||
compare_runs_with_config, regressions, render_report_md,
|
||||
};
|
||||
pub use loader::load_golden_set;
|
||||
pub use metrics::{
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
//! "temperature": <float>,
|
||||
//! "seed": <u64>,
|
||||
//! "num_ctx": <usize>,
|
||||
//! "num_predict": <usize>,
|
||||
//! "stop": ["<str>", ...]
|
||||
//! }
|
||||
//! }
|
||||
@@ -177,6 +178,7 @@ impl LanguageModel for OllamaLanguageModel {
|
||||
temperature: effective_temperature,
|
||||
seed: effective_seed,
|
||||
num_ctx: self.context_tokens,
|
||||
num_predict: req.max_tokens,
|
||||
stop: &req.stop,
|
||||
},
|
||||
};
|
||||
@@ -233,6 +235,16 @@ struct OllamaOptions<'a> {
|
||||
temperature: f32,
|
||||
seed: u64,
|
||||
num_ctx: usize,
|
||||
/// Generation cap, from `GenerateRequest::max_tokens`.
|
||||
///
|
||||
/// Ollama defaults `num_predict` to -1 (unbounded) and shifts the
|
||||
/// context window when it fills, so a model that falls into a
|
||||
/// repetition loop streams forever. `request_timeout_secs` cannot
|
||||
/// save the caller: the connection keeps producing bytes, so no read
|
||||
/// ever times out. Seen in the v0.32 dogfood — one `eval` query held
|
||||
/// the run for 30+ minutes and had received 13 MB of token frames
|
||||
/// when it was killed.
|
||||
num_predict: usize,
|
||||
stop: &'a [String],
|
||||
}
|
||||
|
||||
@@ -513,6 +525,31 @@ fn truncate_body(s: &str, n: usize) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// v0.32 dogfood: `GenerateRequest::max_tokens` was computed by the RAG
|
||||
/// pipeline and then dropped on the floor — `OllamaOptions` never carried
|
||||
/// it. Ollama's default `num_predict` is -1 (unbounded), so a degenerate
|
||||
/// generation ran until something else killed it.
|
||||
#[test]
|
||||
fn request_body_carries_max_tokens_as_num_predict() {
|
||||
let stop: Vec<String> = vec!["</end>".into()];
|
||||
let body = OllamaRequest {
|
||||
model: "m",
|
||||
prompt: "p".into(),
|
||||
images: &[],
|
||||
stream: true,
|
||||
options: OllamaOptions {
|
||||
temperature: 0.0,
|
||||
seed: 7,
|
||||
num_ctx: 8192,
|
||||
num_predict: 1234,
|
||||
stop: &stop,
|
||||
},
|
||||
};
|
||||
let v = serde_json::to_value(&body).unwrap();
|
||||
assert_eq!(v["options"]["num_predict"], 1234);
|
||||
assert_eq!(v["options"]["num_ctx"], 8192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_trailing_newline_removes_lf_and_crlf() {
|
||||
assert_eq!(trim_trailing_newline(b"hi\n"), b"hi");
|
||||
|
||||
@@ -1868,17 +1868,38 @@ fn est_tokens(s: &str) -> usize {
|
||||
s.chars().count().div_ceil(4)
|
||||
}
|
||||
|
||||
/// Strict marker regex per design §1 / spec line 107: `[#1]` … `[#999]`.
|
||||
/// Matches without `#`, with whitespace, or with non-digit content are
|
||||
/// intentionally ignored (see test plan rows 5–6).
|
||||
/// Strict marker regex per design §1 / spec line 107: `[#1]` … `[#999]`,
|
||||
/// plus the grouped form `[#2, #10]` the model emits when one claim rests
|
||||
/// on several sources. Matches without `#`, with leading/trailing
|
||||
/// whitespace inside the brackets, or with non-digit content are still
|
||||
/// intentionally ignored (see test plan rows 5–6) — the strictness exists
|
||||
/// so Rust snippets like `vec![1]` in an answer are not read as citations.
|
||||
static MARKER_REGEX: OnceLock<Regex> = OnceLock::new();
|
||||
/// Pulls the individual `#N` out of a marker group matched by
|
||||
/// [`MARKER_REGEX`]. Only ever run on an already-validated span.
|
||||
static MARKER_NUM_REGEX: OnceLock<Regex> = OnceLock::new();
|
||||
static REFUSAL_PHRASE: OnceLock<Regex> = OnceLock::new();
|
||||
|
||||
/// Markers cited by the answer, in the order they appear.
|
||||
///
|
||||
/// Grouped markers matter beyond bookkeeping: `citations` is the
|
||||
/// intersection of this set with the packed entries, so a marker missed
|
||||
/// here is a source silently dropped from `answer.v1` while its `[#N]`
|
||||
/// stays visible in the answer text. Found in the v0.32 dogfood — the
|
||||
/// model wrote `[#2, #10]` and the reader had no way to resolve `[#2]`.
|
||||
fn extract_markers(s: &str) -> Vec<u32> {
|
||||
let re =
|
||||
MARKER_REGEX.get_or_init(|| Regex::new(r"\[#(\d{1,3})\]").expect("static regex compiles"));
|
||||
re.captures_iter(s)
|
||||
.filter_map(|c| c.get(1).and_then(|m| m.as_str().parse::<u32>().ok()))
|
||||
let group = MARKER_REGEX.get_or_init(|| {
|
||||
Regex::new(r"\[#\d{1,3}(?:\s*,\s*#\d{1,3})*\]").expect("static regex compiles")
|
||||
});
|
||||
let num =
|
||||
MARKER_NUM_REGEX.get_or_init(|| Regex::new(r"#(\d{1,3})").expect("static regex compiles"));
|
||||
group
|
||||
.find_iter(s)
|
||||
.flat_map(|g| {
|
||||
num.captures_iter(g.as_str())
|
||||
.filter_map(|c| c.get(1).and_then(|m| m.as_str().parse::<u32>().ok()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -1961,6 +1982,29 @@ mod tests {
|
||||
assert_send_sync::<RagPipeline>();
|
||||
}
|
||||
|
||||
/// v0.32 dogfood (28.4k-document KB): the model groups markers when a
|
||||
/// claim rests on several sources — `근거입니다 [#2, #10]`. The prompt
|
||||
/// only says "attribute with [#번호]", so grouping is not a violation.
|
||||
/// The single-marker-only regex dropped those, and since `citations`
|
||||
/// is built by intersecting the extracted set with the packed entries
|
||||
/// (`cited_set` below), the grouped sources vanished from `answer.v1`
|
||||
/// while their markers stayed visible in the answer text — a reader
|
||||
/// or agent sees `[#2]` with nothing to resolve it to.
|
||||
#[test]
|
||||
fn extract_markers_accepts_grouped_markers() {
|
||||
assert_eq!(extract_markers("근거입니다 [#2, #10]."), vec![2, 10]);
|
||||
assert_eq!(extract_markers("no spaces [#6,#7]"), vec![6, 7]);
|
||||
assert_eq!(extract_markers("three [#1, #2, #3]"), vec![1, 2, 3]);
|
||||
// Mixed with the single form, in document order.
|
||||
assert_eq!(
|
||||
extract_markers("a [#1]. b [#2, #10]. c [#9]."),
|
||||
vec![1, 2, 10, 9]
|
||||
);
|
||||
// Grouping must not loosen the strictness the single form has:
|
||||
// a bare number after the comma is not a marker.
|
||||
assert!(extract_markers("see [#1, 2]").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_markers_strict_regex() {
|
||||
// Valid markers.
|
||||
|
||||
@@ -21,6 +21,7 @@ kebab-store-sqlite = { path = "../kebab-store-sqlite" }
|
||||
# oss); kb is always-local for v1, so dragging in those SDKs would just
|
||||
# inflate the build.
|
||||
lancedb = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
arrow = { workspace = true }
|
||||
arrow-array = { workspace = true }
|
||||
arrow-schema = { workspace = true }
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use arrow_array::{Array, Float32Array, RecordBatch, StringArray};
|
||||
@@ -18,6 +19,7 @@ use kebab_core::{
|
||||
use kebab_store_sqlite::{EmbeddingRecordRow, SqliteStore};
|
||||
use lancedb::Connection;
|
||||
use lancedb::query::{ExecutableQuery, QueryBase};
|
||||
use lancedb::table::{CompactionOptions, OptimizeAction};
|
||||
use serde_json::json;
|
||||
use time::OffsetDateTime;
|
||||
use tokio::runtime::{Builder as RuntimeBuilder, Runtime};
|
||||
@@ -50,6 +52,26 @@ const INDEX_VERSION: &str = "v1";
|
||||
/// reaching into a private constant.
|
||||
pub const INDEX_VERSION_STR: &str = INDEX_VERSION;
|
||||
|
||||
/// Upserts between Lance compactions.
|
||||
///
|
||||
/// Every `upsert` is one `merge_insert`, which creates a new table
|
||||
/// version whose manifest lists **every** fragment in the table. kebab
|
||||
/// upserts once per document, so an N-document ingest ends up with N
|
||||
/// fragments and an N-entry manifest rewritten on every write: per-write
|
||||
/// cost grows linearly with corpus size, manifest bytes quadratically.
|
||||
///
|
||||
/// Measured on a 16.8k-document dogfood KB with this compaction absent:
|
||||
/// ingest decayed 30.7 → 4.3 documents/min, and `_versions/` held 12.2 GB
|
||||
/// of manifests against 1.7 GB of vectors. A single full compaction of
|
||||
/// that table took 102 s and returned it to 1.70 GB / 1 version.
|
||||
///
|
||||
/// The interval trades compaction work (each pass rewrites the table)
|
||||
/// against manifest size. At 512 the rewrite cost over a 30k-document
|
||||
/// ingest is roughly an hour, well under what the un-compacted manifests
|
||||
/// cost — and the tail of that ingest stays at full speed instead of
|
||||
/// running seven times slower.
|
||||
const COMPACT_EVERY_N_UPSERTS: u64 = 512;
|
||||
|
||||
/// Lance VectorStore.
|
||||
///
|
||||
/// Holds a single `lancedb::Connection` opened against
|
||||
@@ -79,6 +101,11 @@ pub struct LanceVectorStore {
|
||||
/// only — the `Connection` already knows it.
|
||||
#[allow(dead_code)]
|
||||
vector_dir: PathBuf,
|
||||
/// Upserts between compactions. See [`COMPACT_EVERY_N_UPSERTS`].
|
||||
compact_every: u64,
|
||||
/// Upserts since the last compaction. `upsert` takes `&self`, so the
|
||||
/// counter needs interior mutability.
|
||||
upserts_since_compact: AtomicU64,
|
||||
}
|
||||
|
||||
impl LanceVectorStore {
|
||||
@@ -94,6 +121,21 @@ impl LanceVectorStore {
|
||||
/// within a runtime"`. See the struct-level `# Async context`
|
||||
/// section.
|
||||
pub fn new(storage: &kebab_config::StorageCfg, sqlite: Arc<SqliteStore>) -> Result<Self> {
|
||||
Self::new_with_compact_interval(storage, sqlite, COMPACT_EVERY_N_UPSERTS)
|
||||
}
|
||||
|
||||
/// [`LanceVectorStore::new`] with an explicit compaction interval.
|
||||
///
|
||||
/// `#[doc(hidden)]` but public: the integration test drives the
|
||||
/// compaction path with a small interval instead of issuing
|
||||
/// [`COMPACT_EVERY_N_UPSERTS`] upserts. Not a config surface —
|
||||
/// production always goes through `new`.
|
||||
#[doc(hidden)]
|
||||
pub fn new_with_compact_interval(
|
||||
storage: &kebab_config::StorageCfg,
|
||||
sqlite: Arc<SqliteStore>,
|
||||
compact_every: u64,
|
||||
) -> Result<Self> {
|
||||
let data_dir = expand_path(&storage.data_dir, "");
|
||||
let vector_dir = expand_path(&storage.vector_dir, &data_dir.to_string_lossy());
|
||||
std::fs::create_dir_all(&vector_dir)
|
||||
@@ -125,6 +167,34 @@ impl LanceVectorStore {
|
||||
connection,
|
||||
sqlite,
|
||||
vector_dir,
|
||||
compact_every,
|
||||
upserts_since_compact: AtomicU64::new(0),
|
||||
})
|
||||
}
|
||||
|
||||
/// Compact the table's fragments and drop superseded versions.
|
||||
///
|
||||
/// `delete_unverified` is safe here: kebab's ingest is a single
|
||||
/// synchronous process and this runs between upserts, so there is no
|
||||
/// in-flight Lance transaction whose files could be reclaimed.
|
||||
fn compact_table(&self, table: &lancedb::Table) -> Result<()> {
|
||||
self.runtime.block_on(async {
|
||||
table
|
||||
.optimize(OptimizeAction::Compact {
|
||||
options: CompactionOptions::default(),
|
||||
remap_options: None,
|
||||
})
|
||||
.await
|
||||
.context("Lance compact")?;
|
||||
table
|
||||
.optimize(OptimizeAction::Prune {
|
||||
older_than: Some(chrono::Duration::zero()),
|
||||
delete_unverified: Some(true),
|
||||
error_if_tagged_old_versions: None,
|
||||
})
|
||||
.await
|
||||
.context("Lance prune")?;
|
||||
Result::<()>::Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -270,6 +340,28 @@ impl VectorStore for LanceVectorStore {
|
||||
rows = recs.len(),
|
||||
"upsert committed"
|
||||
);
|
||||
|
||||
// Fold the per-document fragments back together every so often.
|
||||
// Failure here is not fatal — the data is already committed and
|
||||
// the next interval retries — but it must be loud, because the
|
||||
// symptom of silently skipping it is a slow ingest much later.
|
||||
if self.upserts_since_compact.fetch_add(1, Ordering::Relaxed) + 1 >= self.compact_every {
|
||||
self.upserts_since_compact.store(0, Ordering::Relaxed);
|
||||
if let Err(e) = self.compact_table(&table) {
|
||||
tracing::warn!(
|
||||
target: "kebab-store-vector",
|
||||
table = %table_name,
|
||||
error = %e,
|
||||
"Lance compaction failed; ingest continues but writes will slow down"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
target: "kebab-store-vector",
|
||||
table = %table_name,
|
||||
"Lance compaction done"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,28 @@ impl TestEnv {
|
||||
}
|
||||
}
|
||||
|
||||
/// Same as [`TestEnv::new`] but with an explicit Lance compaction
|
||||
/// interval, so `tests/compaction.rs` can exercise the compaction
|
||||
/// path without driving the production `COMPACT_EVERY_N_UPSERTS`
|
||||
/// number of upserts.
|
||||
pub fn with_compact_interval(every: u64) -> Self {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let mut config = Config::defaults();
|
||||
config.storage.data_dir = temp.path().to_string_lossy().into_owned();
|
||||
let sqlite = SqliteStore::open(&config.storage).unwrap();
|
||||
sqlite.run_migrations().unwrap();
|
||||
let sqlite = Arc::new(sqlite);
|
||||
let vector =
|
||||
LanceVectorStore::new_with_compact_interval(&config.storage, sqlite.clone(), every)
|
||||
.unwrap();
|
||||
Self {
|
||||
temp,
|
||||
config,
|
||||
sqlite,
|
||||
vector,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_dir(&self) -> PathBuf {
|
||||
self.temp.path().to_path_buf()
|
||||
}
|
||||
|
||||
90
crates/kebab-store-vector/tests/compaction.rs
Normal file
90
crates/kebab-store-vector/tests/compaction.rs
Normal file
@@ -0,0 +1,90 @@
|
||||
//! Regression test for Lance version/fragment accumulation.
|
||||
//!
|
||||
//! Every `upsert` is one Lance `merge_insert`, and every `merge_insert`
|
||||
//! creates a new table version whose manifest lists **all** fragments in
|
||||
//! the table. kebab upserts once per ingested document, so without
|
||||
//! periodic compaction an N-document corpus leaves N fragments and the
|
||||
//! N-th write rewrites a manifest with N entries — write cost grows
|
||||
//! linearly with corpus size and manifest bytes grow quadratically.
|
||||
//!
|
||||
//! Measured on a 16.8k-document dogfood KB before the fix: ingest decayed
|
||||
//! from 30.7 to 4.3 documents/min, and `_versions/` held 12.2 GB of
|
||||
//! manifests against 1.7 GB of actual vectors. One full compaction took
|
||||
//! 102 s and brought the table back to 1.70 GB / 1 version.
|
||||
//!
|
||||
//! This test drives many more upserts than the compaction interval and
|
||||
//! asserts the on-disk version count stays bounded.
|
||||
//!
|
||||
//! `#[ignore]` + AVX gate per `tests/common/mod.rs` policy.
|
||||
|
||||
use kebab_core::VectorStore;
|
||||
|
||||
mod common;
|
||||
use common::{TestEnv, make_record, require_avx_or_panic};
|
||||
|
||||
const MODEL: &str = "compaction-model";
|
||||
|
||||
/// Count `*.manifest` files under the single Lance table directory.
|
||||
/// Lance keeps one manifest per surviving version, so this is a direct
|
||||
/// proxy for "how much history is still on disk".
|
||||
fn manifest_count(data_dir: &std::path::Path) -> usize {
|
||||
let lance_root = data_dir.join("lancedb");
|
||||
let mut n = 0;
|
||||
let mut stack = vec![lance_root];
|
||||
while let Some(dir) = stack.pop() {
|
||||
let Ok(entries) = std::fs::read_dir(&dir) else {
|
||||
continue;
|
||||
};
|
||||
for e in entries.flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
stack.push(p);
|
||||
} else if p.extension().is_some_and(|x| x == "manifest") {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "requires AVX-capable hardware (LanceDB)"]
|
||||
fn repeated_upserts_do_not_accumulate_lance_versions() {
|
||||
require_avx_or_panic();
|
||||
|
||||
// Compact every 8 upserts so the test stays quick; production uses
|
||||
// `COMPACT_EVERY_N_UPSERTS`.
|
||||
let env = TestEnv::with_compact_interval(8);
|
||||
env.seed_chunk(
|
||||
&format!("{:032x}", 0x1100u32),
|
||||
&format!("{:032x}", 0xd0c0u32),
|
||||
"note.md",
|
||||
"en",
|
||||
&[],
|
||||
"primary",
|
||||
);
|
||||
|
||||
let rec = make_record(0, 0, vec![1.0, 0.0, 0.0, 0.0], "hi", &[], MODEL);
|
||||
const UPSERTS: usize = 40;
|
||||
for _ in 0..UPSERTS {
|
||||
env.vector.upsert(std::slice::from_ref(&rec)).unwrap();
|
||||
}
|
||||
|
||||
let manifests = manifest_count(&env.data_dir());
|
||||
assert!(
|
||||
manifests <= 12,
|
||||
"Lance versions accumulated: {manifests} manifests after {UPSERTS} upserts \
|
||||
(compaction interval 8). Without compaction this grows one-per-upsert."
|
||||
);
|
||||
|
||||
// Compaction must not lose rows: the record is still searchable.
|
||||
let hits = env
|
||||
.vector
|
||||
.search(
|
||||
&[1.0, 0.0, 0.0, 0.0],
|
||||
5,
|
||||
&kebab_core::SearchFilters::default(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(hits.len(), 1, "compaction dropped the upserted row");
|
||||
}
|
||||
@@ -14,6 +14,67 @@ historical contract that was implemented; this file accumulates the
|
||||
deltas so phase 5+ readers can find the live behavior without diffing
|
||||
git history.
|
||||
|
||||
## 2026-08-16 — 28.4k 문서 도그푸딩: Lance 압축 부재 · 인용 마커 누락 · ollama 생성 무제한
|
||||
|
||||
나무위키 18,282 + Apache Jira 10,145 = **28,427 문서 / 600,808 청크** 코퍼스로 종단 도그푸딩. 기존 최대 규모(620 문서)의 46배라 그 규모에서는 보이지 않던 결함 3개가 드러났다. 셋 다 이 PR 에서 수정.
|
||||
|
||||
### 1. Lance fragment 무한 누적 — ingest 가 코퍼스 크기에 비례해 느려진다 (이슈 #230 제안 2)
|
||||
|
||||
`upsert` 가 asset 1건당 `merge_insert` 를 1회 호출하고 Lance 는 그때마다 새 테이블 버전 + fragment 를 만든다. manifest 는 **그 시점의 전 fragment 를 나열**하므로 N번째 쓰기가 N줄짜리 manifest 를 새로 쓴다 → 쓰기 비용이 문서 수에 비례, manifest 총량은 제곱. 코드베이스 전체에 `optimize` / `compact_files` / `cleanup_old_versions` 호출이 **0건**이었다.
|
||||
|
||||
| | 16,828 문서 시점 | 압축 후 (28,427 문서) |
|
||||
|---|---|---|
|
||||
| fragment / manifest | 16,814 | 336 |
|
||||
| `_versions/` | 12.2 GB | 6.6 MB |
|
||||
| 실제 벡터 데이터 | 1.7 GB | 1.7 GB |
|
||||
| ingest 속도 | 30.7 → **4.3 문서/분** (단조 하락) | **32~36 문서/분** (평평) |
|
||||
|
||||
이슈 #230 이 11,229 문서 시점에 잰 메타/실데이터 비율은 2.5배였는데, 16,828 문서에서는 **7.2배**였다 — 제곱 증가가 두 지점으로 확인됨.
|
||||
|
||||
수정: `COMPACT_EVERY_N_UPSERTS = 512` 마다 Compact + Prune. 기존 테이블 1회 압축은 **153초에 15 GB → 1.7 GB**(행 365,991 보존)로 끝났다. 즉 #230 본문의 "회복 수단이 `reset --vector-only`(전량 재임베딩) 밖에 없다" 는 사실이 아니다.
|
||||
|
||||
미해결(같은 이슈의 나머지): 삭제 경로 배치화(제안 1), geodatafusion(제안 3), doctor 지표(제안 4).
|
||||
|
||||
### 2. 묶음 인용 마커가 `answer.v1` 에서 조용히 사라짐
|
||||
|
||||
마커 추출 정규식이 `\[#(\d{1,3})\]` 라 **괄호 하나에 마커 하나**만 인정했다. 모델은 한 주장이 여러 근거에 걸리면 `[#2, #10]` 으로 묶는데, `SYSTEM_PROMPT_RAG_V4` 는 "`[#번호]` 로 귀속하라"고만 하고 한 괄호에 하나씩 쓰라고 지시하지 않으므로 지시 위반이 아니다.
|
||||
|
||||
`citations` 는 추출 마커와 packed entry 의 교집합이라(`cited_set`), 못 잡힌 마커는 근거가 멀쩡히 있는데도 배열에서 빠졌다. 본문에는 `[#2]` 가 남아 사용자도 agent 도 해소할 수 없는 인용이 된다. 부수적으로 `unknown_markers` / `grounded` 판정도 반쪽 마커 집합 위에서 계산됐다.
|
||||
|
||||
실측(28.4k KB, "Spark shuffle OOM" 질의): 본문 인용 `1,2,6,7,8,9,10` vs `citations` `1,8,9,10` → 3건 끊김. 수정 후 7/7 해소, `citation_coverage` 1.0.
|
||||
|
||||
기존 엄격함(`vec![1]` · `[1]` · `[ #1 ]` · `[#foo]` · `[#1234]` 불인정)은 유지 — 답변에 Rust 코드가 섞일 때의 오탐 방지가 원래 목적이고 기존 테스트가 그걸 고정한다.
|
||||
|
||||
### 3. ollama 요청이 `max_tokens` 를 안 보냄 — 반복 루프가 무한 스트림이 된다
|
||||
|
||||
`GenerateRequest::max_tokens` 를 RAG 파이프라인이 계산해 넘기는데 `OllamaOptions` 가 `temperature`/`seed`/`num_ctx`/`stop` 만 직렬화하고 **그 값을 버렸다**. ollama 기본 `num_predict` 는 -1(무제한)이고 컨텍스트가 차면 창을 밀어 계속 생성한다.
|
||||
|
||||
`request_timeout_secs` 로는 못 막는다 — 연결에 바이트가 계속 흐르므로 읽기 타임아웃이 안 터진다. `eval run --with-rag` 216 질의가 **1시간 45분에 21개**만 끝냈고, 붙잡고 있던 질의 하나가 **13 MB** 를 받은 상태였다(15초에 142 KB 유입 확인, ollama runner 198% CPU).
|
||||
|
||||
수정: `num_predict: req.max_tokens`. 같은 216 질의가 **31분**에 완료.
|
||||
|
||||
### 골든셋 + 회귀 게이트 (신규)
|
||||
|
||||
이 KB 전용 골든셋을 도그푸딩 store 에 만들었다 — 문서 42건 × 표현 5변형(abbr/ko/en/syn/para) + 거절 6 = **216 질의**. 정답(`expected_doc_ids`)은 검색이 아니라 SQLite 에서 `workspace_path → doc_id` 로 직접 뽑아 순환을 피했고, chunk 단위 대신 **문서 단위**로 잡았다(chunk_id 가 blake3(doc_id, chunker_version, block_ids, policy_hash) 파생이라 파일 1바이트 수정으로 전부 무효화됨).
|
||||
|
||||
베이스라인:
|
||||
|
||||
```
|
||||
recall@1 0.8381 · recall@5 0.9429 · recall@10 0.9524
|
||||
변형 일관성 42그룹 중 33개 완전 일치 · mean spread@10 0.214
|
||||
citation_coverage 1.0 · groundedness 0.3905 · refusal_correctness 0.1667
|
||||
```
|
||||
|
||||
실패한 9개 그룹은 **전부 para(내용 서술) 변형**이다. 7개는 top-50 에는 있고 top-10 밖(MisRanked, 랭킹 문제), 2개는 top-50 에도 없다(Missing, 어휘 격차).
|
||||
|
||||
`kebab eval compare --fail-under <허용치>` 를 추가했다. 이전에는 delta 만 출력하고 exit code 가 항상 0 이라 "회귀했는가"를 기계가 판정할 수 없었다. `empty_result_rate` 는 반대 방향으로 검사하고, **A 에서 재던 지표가 B 에서 NaN 이 되면 위반**으로 잡는다(골든셋이 ground truth 를 잃은 경우가 정확히 그 모양이다).
|
||||
|
||||
### 남은 문제 — `refusal_correctness 0.1667`
|
||||
|
||||
코퍼스에 없는 가짜 질의 6개 중 **1개만** 제대로 거절했다. "Who won the 2044 Interplanetary Curling Championship?" 에 **"T1이 우승했습니다 [#3]"** 라고 답하고 LoL 문서를 인용하면서 `grounded=True` 로 표시됐다.
|
||||
|
||||
원인은 `grounded` 가 "모델이 유효한 마커를 달았는가" 만 보기 때문이다(`grounded_unaware`). 지어낸 답도 마커만 달면 grounded 다. 이걸 막으라고 있는 `rag.nli_threshold` 는 기본값이 0(꺼짐)이라, **기본 설정에는 환각 방어가 사실상 없다.** NLI 를 켜고 같은 6개를 재측정하는 것이 다음 과제.
|
||||
|
||||
## 2026-06-27 — v0.32.0 ponytail-audit 정리 arc 도그푸딩 (chunker A/B byte-identical + GPU r9700)
|
||||
|
||||
over-engineering 감사(ponytail-audit) 후 4 PR(#219–#222) 로 표면·구조·crate 수 단순화. **능력 불변, 이 arc(#219–#222) 누적 −3400줄 / crate 22→20 / dep −1**(serde_yaml 제거; unsafe-libyaml 은 serde_yaml_ng 가 여전히 transitive 로 가져옴). 실엔진 종단 검증:
|
||||
|
||||
Reference in New Issue
Block a user