chore: PR #237 회차 1 리뷰 반영 — 계측 정밀도와 서술 정정

리뷰 두 건이 머지 가능으로 결론냈지만, 이 PR 의 핵심이 "실측 근거" 인데 그
근거 쪽에 문제가 있다는 지적이 나왔다. 그쪽을 우선 고친다.

1) cache_ms 가 체계적으로 과소계상돼 있었다 (MEDIUM)

   문서 하나당 `as_millis()` 절삭 지점이 3곳(조회 / 삽입 / touch)이었고,
   각 구간이 대개 1 ms 미만이라 값이 통째로 사라졌다. 실제로 문서 1,584건
   중 **1,422건(90%)이 0 으로 찍혔다**. 초안이 "캐시 경로 전체 0.6초" 를
   점추정으로 적고 그 숫자를 근거로 제안 3·6 을 기각했는데, 0.6초는
   하한이었다.

   내부 누적을 마이크로초로 바꿔 절삭을 emit 시점 1회로 줄였다. 재측정한
   구간은 **0.6~2.2초 (run 141.3초의 0.4~1.6%)** 다. 결론은 구간 어느
   쪽에서도 같지만, 점추정으로 적어 둘 값은 아니었다.

   히트 payload 를 `Vec<f32>` 로 되돌리는 디코드 비용도 캐시 경로에
   계상했다. SQL 경계에서 멈추는 지표는 캐시를 실제보다 싸 보이게 한다.

2) embed_ms 를 "Lance upsert" 로만 라벨했다 (MEDIUM)

   `t_embed` 스팬은 orphan purge + 캐시 경로 + 임베더 + 레코드 구성 +
   Lance upsert + touch 를 전부 감싼다. 코드 주석 자신이 "purge + upsert"
   라고 적고 있는데 HOTFIXES 가 더 좁게 적었다.

   그리고 cache_ms 는 embed_ms 의 **부분집합**이지 별도 가산 항목이 아니다.
   스키마 설명이 "embedder 호출 제외 — that is embed_ms" 라 두 값이 겹치지
   않는 것처럼 읽혔고, 외부 소비자가 phase 를 합산하면 이중 계상한다.
   "included in embed_ms" 를 명시했다.

3) CacheStats 가 embed_with_cache 의 doc 블록을 가로챘다 (MEDIUM)

   구조체를 doc 블록과 `fn` 사이에 끼워 넣어서, 함수 설명 전체가 구조체의
   문서가 되고 함수는 문서가 하나도 없는 상태였다. 구조체를 위로 올렸다.

4) 계측의 사각지대를 명시했다 (MEDIUM/LOW)

   - code 자산은 `asset_timings` 를 아예 emit 하지 않는다(이 PR 이전부터의
     공백). 채우려면 code 경로에 parse/chunk/store 타이머를 새로 깔아야 해서
     #231 범위 밖이다. 스키마와 DOGFOOD 에 적었다.
   - `cache_*` 는 임베딩 kind 만 센다. 같은 테이블을 쓰는 OCR·caption 파생은
     단건 API 라 안 잡히고, 이미지 위주 코퍼스에서는 캐시가 한 일을 과소
     표현한다.

5) README 미갱신 (MEDIUM)

   `⏱` 줄에 `cache 히트/전체 소요` 세그먼트가 추가됐는데 README 의 ingest
   설명이 phase 목록만 적고 있었다.

미반영: `get_many` 의 `prepare_cached` 가 배치 크기마다 SQL 문자열이 달라져
사실상 캐시 미스라는 지적 — 정확하지만 누수도 정확성 문제도 없고, 버킷
패딩은 1% 짜리에 낼 복잡도가 아니다. `put_many` 시그니처의 불필요한 할당,
자산 단위 피크 메모리 2배(자산 단위로 유계) 도 같은 이유로 남긴다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017c9JwQq8ZkGvYjpKXMiDhF
This commit is contained in:
2026-08-17 00:08:10 +09:00
parent 74e28293ba
commit 40042c809b
6 changed files with 81 additions and 51 deletions

View File

@@ -81,7 +81,7 @@ Markdown · PDF · 이미지(OCR + caption) · 소스코드(Rust/Python/TS/JS/Go
| 명령 | 동작 |
|------|------|
| `kebab init` | XDG 경로에 데이터 디렉토리 + config.toml 생성 |
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 미지원 확장자는 자동 skip. 진행바는 현재 **파일명** · 느린 **phase(ocr/caption/embed)+모델명** · **경과초**`(Ns)` · 문서별 청크 수 · phase별 소요시간(parse/chunk/ocr/caption/embed/store)을 표시하고, 종료 시 **최장 소요 파일 top-5** 를 요약한다 (`--json``asset_phase`/`asset_chunked`/`asset_timings` 이벤트로, 사람용 요약은 미출력) |
| `kebab ingest [<path>]` | 워크스페이스 스캔 후 새/변경 문서 색인 (idempotent · incremental, `--force-reingest` 로 강제 재처리). 미지원 확장자는 자동 skip. 진행바는 현재 **파일명** · 느린 **phase(ocr/caption/embed)+모델명** · **경과초**`(Ns)` · 문서별 청크 수 · phase별 소요시간(parse/chunk/ocr/caption/embed/store)과 임베딩 캐시 적중(`cache 히트/전체 소요`)을 표시하고, 종료 시 **최장 소요 파일 top-5** 를 요약한다 (`--json``asset_phase`/`asset_chunked`/`asset_timings` 이벤트로, 사람용 요약은 미출력) |
| `kebab ingest-file <path>` | 단일 파일 ingest (workspace 외부 가능 — `_external/` 로 deterministic copy) |
| `kebab ingest-stdin --title <T>` | stdin 의 markdown 본문 ingest |
| `kebab search --mode {lexical,vector,hybrid} "<query>" [flags]` | 검색 (default hybrid = RRF fusion, citation 포함). 출처 필터 `--source <id>` (`[[workspace.sources]]` id) · `--source-type {markdown,note,paper,reference,inbox}` (둘 다 repeatable/comma-sep, OR). 그 외 필터/budget flag 는 `--help` |

View File

@@ -1010,6 +1010,30 @@ fn unsupported_media_warning(path: &str) -> String {
}
}
/// What one asset's trip through the derivation cache cost and produced.
///
/// Carried as a struct rather than four out-params: they are read and
/// reported together (the `asset_timings` event wants all four), and a
/// caller that updates three of them and forgets the fourth would report
/// a cache that looks free.
#[derive(Default)]
pub(crate) struct CacheStats {
pub hit: usize,
pub miss: usize,
/// Keys that hit, for the batched `last_used_at` bump the caller runs
/// after the vector upsert.
pub touch_keys: Vec<String>,
/// Lookup + decode + insert + touch, in **microseconds**. Not the
/// embedder call the misses trigger.
///
/// Microseconds because a per-asset millisecond truncation loses the
/// measurement entirely: a document's cache work is routinely
/// sub-millisecond, so accumulating `as_millis()` per span reported
/// zero for most assets and made the total a lower bound rather than
/// a figure. The wire event converts to ms at emit.
pub us: u64,
}
/// Embed `texts` with the derivation cache (design 2026-05-31 §3.4).
///
/// 1) 각 text 의 embedding cache_key 계산 → **한 번의 배치 조회**로 히트/미스 분리.
@@ -1025,23 +1049,6 @@ fn unsupported_media_warning(path: &str) -> String {
/// GPU 에서 배치로, 전역 뮤텍스를 잡지 않고 돌기 때문이다. 결과적으로 캐시가
/// "병렬 GPU 배치 1회" 를 "직렬 SQLite 왕복 N회" 로 바꿔치기하는 형태였다.
/// 조회와 삽입을 배치 단위로 접어 그 비대칭을 없앤다.
/// What one asset's trip through the derivation cache cost and produced.
///
/// Carried as a struct rather than four out-params: they are read and
/// reported together (the `asset_timings` event wants all four), and a
/// caller that updates three of them and forgets the fourth would report
/// a cache that looks free.
#[derive(Default)]
pub(crate) struct CacheStats {
pub hit: usize,
pub miss: usize,
/// Keys that hit, for the batched `last_used_at` bump the caller runs
/// after the vector upsert.
pub touch_keys: Vec<String>,
/// Lookup + insert + touch. Not the embedder call the misses trigger.
pub ms: u64,
}
fn embed_with_cache(
emb: &dyn Embedder,
sqlite: &kebab_store_sqlite::SqliteStore,
@@ -1055,12 +1062,16 @@ fn embed_with_cache(
.map(|text| kebab_core::derivation_cache_key("embedding", text, version_key))
.collect();
let cached = sqlite.derivation_cache_get_many(&keys)?;
stats.ms += u64::try_from(t_cache.elapsed().as_millis()).unwrap_or(u64::MAX);
stats.us += u64::try_from(t_cache.elapsed().as_micros()).unwrap_or(u64::MAX);
let mut out: Vec<Option<Vec<f32>>> = Vec::with_capacity(texts.len());
let mut miss_indices: Vec<usize> = Vec::new();
let mut miss_inputs: Vec<EmbeddingInput<'_>> = Vec::new();
// Decoding a hit's payload back into `Vec<f32>` is part of what the
// cache costs, so it is inside the timer too — a metric that stopped
// at the SQL boundary would flatter the cache.
let t_decode = std::time::Instant::now();
for (i, text) in texts.iter().enumerate() {
// 히트 = 캐시에 있고 payload 가 정상 디코드되는 경우. 손상 payload 는
// 미스로 강등(재계산, 정확성 우선 §3.5).
@@ -1081,6 +1092,7 @@ fn embed_with_cache(
out.push(None);
}
}
stats.us += u64::try_from(t_decode.elapsed().as_micros()).unwrap_or(u64::MAX);
if !miss_inputs.is_empty() {
let miss_vectors = emb.embed(&miss_inputs)?;
@@ -1095,7 +1107,7 @@ fn embed_with_cache(
}
let t_put = std::time::Instant::now();
sqlite.derivation_cache_put_many(&puts)?;
stats.ms += u64::try_from(t_put.elapsed().as_millis()).unwrap_or(u64::MAX);
stats.us += u64::try_from(t_put.elapsed().as_micros()).unwrap_or(u64::MAX);
}
Ok(out
@@ -1449,7 +1461,7 @@ fn ingest_one_asset(
{
let t_touch = std::time::Instant::now();
app.sqlite.derivation_cache_touch(&emb_cache.touch_keys)?;
emb_cache.ms += u64::try_from(t_touch.elapsed().as_millis()).unwrap_or(u64::MAX);
emb_cache.us += u64::try_from(t_touch.elapsed().as_micros()).unwrap_or(u64::MAX);
}
}
}
@@ -1472,7 +1484,7 @@ fn ingest_one_asset(
caption_ms: 0,
cache_hit: u32::try_from(emb_cache.hit).unwrap_or(u32::MAX),
cache_miss: u32::try_from(emb_cache.miss).unwrap_or(u32::MAX),
cache_ms: emb_cache.ms,
cache_ms: emb_cache.us / 1_000,
},
);
@@ -1482,10 +1494,10 @@ fn ingest_one_asset(
tracing::info!(
target: "kebab-app",
doc = %canonical.doc_id.0,
"derivation cache: embedding hit={} miss={} in {}ms",
"derivation cache: embedding hit={} miss={} in {}us",
emb_cache.hit,
emb_cache.miss,
emb_cache.ms
emb_cache.us
);
}
@@ -1861,7 +1873,7 @@ fn ingest_one_image_asset(
{
let t_touch = std::time::Instant::now();
app.sqlite.derivation_cache_touch(&emb_cache.touch_keys)?;
emb_cache.ms += u64::try_from(t_touch.elapsed().as_millis()).unwrap_or(u64::MAX);
emb_cache.us += u64::try_from(t_touch.elapsed().as_micros()).unwrap_or(u64::MAX);
}
}
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
@@ -1883,7 +1895,7 @@ fn ingest_one_image_asset(
caption_ms,
cache_hit: u32::try_from(emb_cache.hit).unwrap_or(u32::MAX),
cache_miss: u32::try_from(emb_cache.miss).unwrap_or(u32::MAX),
cache_ms: emb_cache.ms,
cache_ms: emb_cache.us / 1_000,
},
);
@@ -1893,10 +1905,10 @@ fn ingest_one_image_asset(
tracing::info!(
target: "kebab-app",
doc = %canonical.doc_id.0,
"derivation cache: embedding hit={} miss={} in {}ms",
"derivation cache: embedding hit={} miss={} in {}us",
emb_cache.hit,
emb_cache.miss,
emb_cache.ms
emb_cache.us
);
}
@@ -2755,7 +2767,7 @@ fn ingest_one_pdf_asset(
{
let t_touch = std::time::Instant::now();
app.sqlite.derivation_cache_touch(&emb_cache.touch_keys)?;
emb_cache.ms += u64::try_from(t_touch.elapsed().as_millis()).unwrap_or(u64::MAX);
emb_cache.us += u64::try_from(t_touch.elapsed().as_micros()).unwrap_or(u64::MAX);
}
}
let embed_ms = u64::try_from(t_embed.elapsed().as_millis()).unwrap_or(u64::MAX);
@@ -2777,7 +2789,7 @@ fn ingest_one_pdf_asset(
caption_ms: 0,
cache_hit: u32::try_from(emb_cache.hit).unwrap_or(u32::MAX),
cache_miss: u32::try_from(emb_cache.miss).unwrap_or(u32::MAX),
cache_ms: emb_cache.ms,
cache_ms: emb_cache.us / 1_000,
},
);
@@ -2787,10 +2799,10 @@ fn ingest_one_pdf_asset(
tracing::info!(
target: "kebab-app",
doc = %canonical.doc_id.0,
"derivation cache: embedding hit={} miss={} in {}ms",
"derivation cache: embedding hit={} miss={} in {}us",
emb_cache.hit,
emb_cache.miss,
emb_cache.ms
emb_cache.us
);
}
@@ -3104,7 +3116,7 @@ fn ingest_one_code_asset(
{
let t_touch = std::time::Instant::now();
app.sqlite.derivation_cache_touch(&emb_cache.touch_keys)?;
emb_cache.ms += u64::try_from(t_touch.elapsed().as_millis()).unwrap_or(u64::MAX);
emb_cache.us += u64::try_from(t_touch.elapsed().as_micros()).unwrap_or(u64::MAX);
}
}
@@ -3114,10 +3126,10 @@ fn ingest_one_code_asset(
tracing::info!(
target: "kebab-app",
doc = %canonical.doc_id.0,
"derivation cache: embedding hit={} miss={} in {}ms",
"derivation cache: embedding hit={} miss={} in {}us",
emb_cache.hit,
emb_cache.miss,
emb_cache.ms
emb_cache.us
);
}

View File

@@ -140,21 +140,31 @@ pub enum IngestEvent {
#[serde(default)]
caption_ms: u64,
/// v0.32.1 (additive, issue #231): derivation-cache outcome for
/// this asset's chunks, and the wall-clock the cache path itself
/// this asset's **embedding** chunks, and what the cache path
/// cost. Without these the only way to tell whether the cache is
/// paying for itself was a `tracing::info!` on stderr, which is
/// gone the moment the run ends — so "is the cache a win on my
/// corpus" had no answer a user could look up.
///
/// Scope: embeddings only. The OCR and caption derivations share
/// the same table but go through the single-key API and are not
/// counted here, so an image-heavy corpus under-reports what the
/// cache actually did.
#[serde(default)]
cache_hit: u32,
#[serde(default)]
cache_miss: u32,
/// Lookup, insert, and the `last_used_at` touch that every hit
/// triggers — everything the cache costs except the embedder call
/// the misses go on to make, which `embed_ms` already covers.
/// Lookup, payload decode, insert, and the `last_used_at` touch
/// that every hit triggers — everything the cache costs except
/// the embedder call the misses go on to make.
///
/// The touch is counted deliberately: issue #231's sharpest claim
/// is that a read-only cache hit generates write traffic, and a
/// metric that left it out could not test that claim.
///
/// **Included in `embed_ms`, not additional to it.** The embed
/// timer spans the whole vector phase, cache work included, so
/// summing the phase fields double-counts this one.
#[serde(default)]
cache_ms: u64,
},

View File

@@ -278,6 +278,7 @@ echo "# stdin content" | "$RELEASE_BIN" ingest-stdin --title "from stdin" --conf
- sweep 이 끝난 뒤 진행바가 asset 분모·라벨로 돌아오는가 (TTY). sweep 이 같은 바를 빌려 쓰므로 복구가 빠지면 색인 구간 내내 `sweep [..] 4213/21` 로 그려진다.
- ndjson 로그에 `purge` 줄과 `sweep_summary` 가 남는가 (이슈 #228 이전에는 이 구간이 0바이트였다). purge 실패 시 `purge_failed`.
- sweep 중 Ctrl-C 한 번에 실제로 멈추는가. `sweep_completed.checked` 가 예고한 `total` 이 아니라 실제 검사한 수로 나와야 한다.
- `asset_timings``cache_hit` / `cache_miss` / `cache_ms` (v0.32.1, issue #231). warm 재색인이면 `cache_miss == 0` 이어야 한다. `cache_ms`**`embed_ms` 에 포함된** 값이라 phase 를 합산할 때 이중 계상하지 말 것. 임베딩 kind 만 세므로 이미지·PDF 위주 코퍼스에서는 캐시가 한 일을 과소 표현한다. code 자산은 `asset_timings` 자체를 emit 하지 않는다.
---

View File

@@ -225,17 +225,17 @@
"cache_hit": {
"type": "integer",
"minimum": 0,
"description": "asset_timings: chunks whose embedding came from the derivation cache."
"description": "asset_timings: chunks whose embedding came from the derivation cache. Embeddings only — the OCR and caption derivations share the table but are not counted. Emitted on the markdown / image / PDF paths; the code path does not emit asset_timings at all."
},
"cache_miss": {
"type": "integer",
"minimum": 0,
"description": "asset_timings: chunks whose embedding had to be computed."
"description": "asset_timings: chunks whose embedding had to be computed. Same scope as cache_hit."
},
"cache_ms": {
"type": "integer",
"minimum": 0,
"description": "asset_timings: wall-clock the derivation-cache lookup and insert cost, excluding the embedder call the misses trigger (that is `embed_ms`)."
"description": "asset_timings: what the derivation-cache path cost — lookup, payload decode, insert, and the last_used_at touch — excluding the embedder call the misses trigger. INCLUDED IN embed_ms rather than additional to it: the embed timer spans the whole vector phase, so summing the phase fields double-counts this one."
}
}
}

View File

@@ -29,16 +29,23 @@ git history.
**캐시 히트가 8.5배 빠르다.** 이슈의 가설은 이 환경에서 재현되지 않는다.
새로 넣은 `asset_timings` 계측으로 139초의 내역을 뜯어보면 더 분명하다 (히트 32,758 / 미스 0):
새로 넣은 `asset_timings` 계측으로 내역을 뜯어보면 더 분명하다 (문서 1,584건 / 히트 32,758 / 미스 0, run 141.3초):
| 구간 | 소요 | 비중 |
|---|---|---|
| Lance upsert + 레코드 구성 | 74.6초 | 53% |
| SQLite 문서·청크 기록 | 56.7초 | 41% |
| chunk | 4.4초 | 3% |
| **캐시 경로 전체**(조회+삽입+touch) | **0.6초** | **0.4%** |
| `embed_ms` — 벡터 phase 전체 | 75.2초 | 53% |
| `store_ms` SQLite 문서·청크 기록 | 57.0초 | 40% |
| `chunk_ms` | 4.4초 | 3% |
| `parse_ms` | 0.1초 | 0% |
| ↳ 그중 **캐시 경로**(키 계산+조회+디코드+삽입+touch) | **0.6~2.2초** | **0.4~1.6%** |
즉 이슈가 지목한 여섯 원인이 전부 합쳐서 run 의 0.4% 다. 조회 배칭(원인 1)으로 아낄 수 있는 것은 그 안의 일부다.
두 가지를 정확히 적어 둔다. 리뷰에서 지적받아 고친 것들이다.
`embed_ms` 는 "Lance upsert" 가 아니다. `t_embed` 스팬은 orphan purge(`purge_vector_orphans_for_workspace_path`) + 캐시 경로 + 임베더 호출 + 레코드 구성 + Lance upsert + touch 를 전부 감싼다 — 코드 주석 자신이 "purge + upsert" 라고 적고 있다.
캐시 경로는 **`embed_ms` 안에 포함된 부분집합**이지 별도 가산 항목이 아니다. 그리고 0.6초는 **하한**이다. `cache_ms` 는 문서당 한 번 ms 로 절삭되는데 문서 하나의 캐시 작업이 대개 1 ms 미만이라 **1,584건 중 1,422건(90%)이 0 으로 찍혔다**. 절삭 상한이 문서당 1 ms 이므로 참값은 0.6~2.2초 구간이다. 초안은 이걸 0.6초 점추정으로 적었고, 그 숫자를 근거로 제안 3·6 을 기각했다 — 구간으로 고쳐 적는다. 결론(캐시 경로가 run 의 몇 %)은 구간 어느 쪽에서도 같다.
내부 누적은 마이크로초로 바꿔 절삭 지점을 문서당 3곳에서 1곳(emit 시점)으로 줄였고, 히트 payload 를 `Vec<f32>` 로 되돌리는 디코드 비용도 캐시 경로에 계상했다 — SQL 경계에서 멈추는 지표는 캐시를 실제보다 싸 보이게 한다.
### 왜 원 보고가 틀렸다고 단정하지 않는가
@@ -54,13 +61,13 @@ git history.
- `derivation_cache_put_many` — 미스 벡터를 한 트랜잭션에. 기존 단건 `put` 은 명시 트랜잭션 밖이라 행마다 암묵 커밋이었다.
- `prepare_cached` — get/put/touch 셋 다. `query_row` 는 호출마다 SQL 을 다시 파싱한다.
제안 5(계측 노출)가 이번의 실질적 산출물이다. `asset_timings``cache_hit` / `cache_miss` / `cache_ms` 를 additive 로 실었다. 이전에는 hit/miss 가 `tracing::info!` 로 stderr 에만 나가서 run 이 끝나면 사라졌고, "내 코퍼스에서 캐시가 이득인가" 를 사용자가 확인할 방법이 없었다. `cache_ms` 에는 **touch 도 포함**한다 — 이슈의 가장 날카로운 지적이 "읽기 전용이어야 할 히트 경로가 쓰기를 만든다" 인데, touch 를 빼고 재는 지표로는 그 주장을 검증할 수 없다.
제안 5(계측 노출)가 이번의 실질적 산출물이다. `asset_timings``cache_hit` / `cache_miss` / `cache_ms` 를 additive 로 실었다. 두 가지 한계를 문서·스키마에 명시했다 — **임베딩 kind 만** 센다(같은 테이블을 쓰는 OCR·caption 파생은 단건 API 라 안 잡힌다), 그리고 **code 자산은 `asset_timings` 자체를 emit 하지 않는다**(이 PR 이전부터의 공백이고, 채우려면 code 경로에 parse/chunk/store 타이머를 새로 깔아야 해서 #231 범위 밖이다). 이전에는 hit/miss 가 `tracing::info!` 로 stderr 에만 나가서 run 이 끝나면 사라졌고, "내 코퍼스에서 캐시가 이득인가" 를 사용자가 확인할 방법이 없었다. `cache_ms` 에는 **touch 도 포함**한다 — 이슈의 가장 날카로운 지적이 "읽기 전용이어야 할 히트 경로가 쓰기를 만든다" 인데, touch 를 빼고 재는 지표로는 그 주장을 검증할 수 없다.
### 반영하지 않은 것
- 제안 3(touch 를 히트 경로에서 분리). 실측상 캐시 경로 전체가 0.6초라 touch 만 떼어낼 이유가 없고, 이슈가 권한 (c)안(`created_at` 기반 TTL)은 LRU 를 age 기반 축출로 바꾸는 의미 변경이다. 근거 없이 할 변경이 아니다.
- 제안 3(touch 를 히트 경로에서 분리). 실측상 캐시 경로 전체가 0.6~2.2초라 touch 만 떼어낼 이유가 없고, 이슈가 권한 (c)안(`created_at` 기반 TTL)은 LRU 를 age 기반 축출로 바꾸는 의미 변경이다. 근거 없이 할 변경이 아니다.
- 제안 6(캐시 우회 스위치). 이슈 스스로 "1~4 로 해결되면 불필요 — 플래그부터 만들지 말 것" 이라고 적었다.
- 원인 6(4 KB BLOB overflow page). `page_size` 변경은 기존 DB 에서 VACUUM 을 요구하는데, kebab 은 VACUUM 을 실행하지 않는다(#229 항목 참조). 0.4% 를 줄이자고 낼 비용이 아니다.
- 원인 6(4 KB BLOB overflow page). `page_size` 변경은 기존 DB 에서 VACUUM 을 요구하는데, kebab 은 VACUUM 을 실행하지 않는다(#229 항목 참조). 1% 남짓을 줄이자고 낼 비용이 아니다.
### 곁다리: 내가 만든 flaky test