derivation_cache 히트가 재임베딩보다 느림 — 청크별 개별 조회 + 히트마다 touch 쓰기 #231

Closed
opened 2026-08-05 05:58:13 +00:00 by altair823 · 2 comments
Owner

증상

v0.28 → v0.32 업그레이드 재색인(chunker_version cascade 로 전 문서가 full re-process)에서, 임베딩 캐시가 히트하는데도 캐시를 우회하고 전량 재임베딩하는 편이 더 빠르다.

derivation_cache 는 설계상 "순수 성능 레이어 — 손상/삭제되어도 정확성 영향 없음"(migrations/V012__derivation_cache.sql:11)인데, 현재는 성능을 악화시키고 있다.

원인

embed_with_cache(crates/kebab-app/src/ingest.rs:1008-1060)가 청크 text 마다 SQLite 를 개별 왕복한다.

for (i, text) in texts.iter().enumerate() {
    let key = kebab_core::derivation_cache_key("embedding", text, version_key);
    let cached = sqlite
        .derivation_cache_get(&key)?                  // ← 청크당 쿼리 1회
        .and_then(|p| crate::derivation_payload::decode_embedding(&p));
    ...
}

여섯 가지가 겹친다.

1. 조회 배칭 없음

청크 1건당 derivation_cache_get 1회. 52,000 chunk 코퍼스면 조회 52,000회. 반면 미스는 emb.embed(&miss_inputs) 한 번으로 배치 처리된다.

2. prepare_cached 미사용

derivation_cache_get(crates/kebab-store-sqlite/src/derivation_cache.rs:24)은 SQL 리터럴을 conn.query_row 에 넘긴다. rusqlite 의 query_row 는 내부적으로 prepare(문장 캐시 안 함)를 호출하므로 매 조회마다 SQL 파싱이 일어난다. kebab-store-sqlite 전체에 prepare_cached 사용처가 0건이다.

3. 단일 Mutex<Connection> 직렬화

lock_conn()(crates/kebab-store-sqlite/src/store.rs:165)은 전역 뮤텍스다. 캐시 조회 52,000회가 전부 이 락을 잡았다 놓는다. 같은 락 위에서 chunk INSERT/DELETE 가 chunks_ad/chunks_ai 트리거를 통해 FTS5 전체 스캔(#229)을 돌리고 있어 두 문제가 서로를 증폭한다.

4. 히트가 쓰기를 유발한다 ← 핵심

히트 키는 emb_touch_keys 에 누적됐다가 derivation_cache_touch(derivation_cache.rs:65)로 last_used_at 을 UPDATE 한다. 트랜잭션 1개로 묶여 있긴 하나 키마다 stmt.execute 이고, 읽기 전용이어야 할 캐시 히트 경로가 쓰기 트래픽을 만든다.

5. put 이 미스마다 개별 커밋

derivation_cache_put(derivation_cache.rs:45)은 명시 트랜잭션 밖에서 conn.execute 를 호출한다 → 행마다 암묵 트랜잭션 = WAL 커밋 1회. synchronous = NORMAL(store.rs:1179)이라 fsync 는 아니지만 프레임 쓰기 + wal-index 갱신은 매번 발생한다.

6. 4 KB BLOB overflow page

1024-dim f32 payload = 4,096 B. SQLite 기본 page_size 4096 이라 payload 마다 overflow page 로 넘어간다. 52,000행이면 200 MB+ 이고 조회마다 overflow chain 을 따라간다.

왜 히트가 미스보다 느린가

캐시 미스 캐시 히트
SQLite 읽기 1회 (조회 실패) 1회 (4 KB BLOB)
SQLite 쓰기 put 1회 touch UPDATE 1회
임베딩 계산 배치 1회 · GPU · 락 밖 없음
전역 뮤텍스 점유 횟수 청크당 2회 청크당 2회

히트가 미스보다 SQLite 작업을 덜 하지 않는다. 미스가 추가로 내는 비용은 임베딩 호출인데 그건 GPU 에서 배치로, 뮤텍스를 잡지 않고 돈다. 결과적으로 캐시는 "병렬 GPU 배치 1회"를 "직렬 SQLite 왕복 N회"로 바꿔치기하는 셈이고, 코퍼스가 크고 SQLite 락이 이미 #229 로 포화된 상황에서는 순손실이 된다.

실측

⚠️ 정량 실측 보완 필요. 보고 환경(약 12,000 doc / 52,000 chunk, ollama + snowflake-arctic-embed2 1024-dim)에서 "캐시 히트 쪽이 더 느리다"가 관측되었으나 수치 미기록. 아래 표를 채울 것.

조건 chunk/초 총 소요
캐시 히트 경로
캐시 우회(전량 재임베딩)

제안

  1. 조회 배칭. WHERE cache_key IN (?,?,…) 로 청크 배치 단위 1회 조회 후 HashMap 으로 되돌림. 쿼리 수가 N → N/배치크기.
  2. prepare_cached 도입. 최소 derivation_cache_get / _put / _touch. 크레이트 전역 적용도 별도 이득이 있다.
  3. touch 를 히트 경로에서 분리. TTL GC 는 last_used_at 의 초 단위 정밀도가 필요 없다. (a) run 종료 시 1회 배치, (b) 확률적 샘플링, (c) created_at 기반 TTL 로 바꿔 touch 제거. (c) 가 가장 단순하고 히트 경로를 완전한 읽기 전용으로 만든다.
  4. put 배치 트랜잭션. 미스 벡터를 모아 트랜잭션 1개로 커밋.
  5. 캐시 계측 노출. 현재 hit/miss 카운터가 내부 변수에만 있다. ndjson 로그와 AssetTimingscache_hit / cache_miss / cache_ms 를 additive 로 실어 "캐시가 이득인지"를 사용자가 판단할 수 있게 한다.
  6. (검토) 캐시 우회 스위치. 1~4 로 해결되면 불필요하다 — 플래그부터 만들지 말 것.

관련

  • #229 chunks_fts 삭제 FTS5 전체 스캔 — 같은 Mutex<Connection> 을 공유하므로 상호 증폭. #229 를 먼저 고치면 본 이슈의 체감도 함께 줄어든다.
  • #230 Lance fragment 무한 누적
  • 발단: v0.30.0 md-heading-v2(58386c4) / v0.30.1 pdf-page-v1.2(ed8ab7c) 청커 버전 cascade 로 전 문서 full re-process 가 발생하면서 캐시 경로가 전 코퍼스 규모로 노출됨.
## 증상 v0.28 → v0.32 업그레이드 재색인(chunker_version cascade 로 전 문서가 full re-process)에서, **임베딩 캐시가 히트하는데도 캐시를 우회하고 전량 재임베딩하는 편이 더 빠르다.** `derivation_cache` 는 설계상 "순수 성능 레이어 — 손상/삭제되어도 정확성 영향 없음"(`migrations/V012__derivation_cache.sql:11`)인데, 현재는 성능을 **악화**시키고 있다. ## 원인 `embed_with_cache`(`crates/kebab-app/src/ingest.rs:1008-1060`)가 청크 text 마다 SQLite 를 개별 왕복한다. ```rust for (i, text) in texts.iter().enumerate() { let key = kebab_core::derivation_cache_key("embedding", text, version_key); let cached = sqlite .derivation_cache_get(&key)? // ← 청크당 쿼리 1회 .and_then(|p| crate::derivation_payload::decode_embedding(&p)); ... } ``` 여섯 가지가 겹친다. ### 1. 조회 배칭 없음 청크 1건당 `derivation_cache_get` 1회. 52,000 chunk 코퍼스면 조회 52,000회. 반면 미스는 `emb.embed(&miss_inputs)` **한 번**으로 배치 처리된다. ### 2. `prepare_cached` 미사용 `derivation_cache_get`(`crates/kebab-store-sqlite/src/derivation_cache.rs:24`)은 SQL 리터럴을 `conn.query_row` 에 넘긴다. rusqlite 의 `query_row` 는 내부적으로 `prepare`(문장 캐시 안 함)를 호출하므로 **매 조회마다 SQL 파싱**이 일어난다. `kebab-store-sqlite` 전체에 `prepare_cached` 사용처가 0건이다. ### 3. 단일 `Mutex<Connection>` 직렬화 `lock_conn()`(`crates/kebab-store-sqlite/src/store.rs:165`)은 전역 뮤텍스다. 캐시 조회 52,000회가 전부 이 락을 잡았다 놓는다. 같은 락 위에서 chunk INSERT/DELETE 가 `chunks_ad`/`chunks_ai` 트리거를 통해 FTS5 전체 스캔(#229)을 돌리고 있어 **두 문제가 서로를 증폭**한다. ### 4. 히트가 쓰기를 유발한다 ← 핵심 히트 키는 `emb_touch_keys` 에 누적됐다가 `derivation_cache_touch`(`derivation_cache.rs:65`)로 `last_used_at` 을 UPDATE 한다. 트랜잭션 1개로 묶여 있긴 하나 키마다 `stmt.execute` 이고, **읽기 전용이어야 할 캐시 히트 경로가 쓰기 트래픽을 만든다.** ### 5. put 이 미스마다 개별 커밋 `derivation_cache_put`(`derivation_cache.rs:45`)은 명시 트랜잭션 밖에서 `conn.execute` 를 호출한다 → 행마다 암묵 트랜잭션 = WAL 커밋 1회. `synchronous = NORMAL`(`store.rs:1179`)이라 fsync 는 아니지만 프레임 쓰기 + wal-index 갱신은 매번 발생한다. ### 6. 4 KB BLOB overflow page 1024-dim f32 payload = 4,096 B. SQLite 기본 `page_size` 4096 이라 payload 마다 overflow page 로 넘어간다. 52,000행이면 200 MB+ 이고 조회마다 overflow chain 을 따라간다. ## 왜 히트가 미스보다 느린가 | | 캐시 미스 | 캐시 히트 | |---|---|---| | SQLite 읽기 | 1회 (조회 실패) | 1회 (4 KB BLOB) | | SQLite 쓰기 | `put` 1회 | `touch` UPDATE 1회 | | 임베딩 계산 | **배치 1회 · GPU · 락 밖** | 없음 | | 전역 뮤텍스 점유 횟수 | 청크당 2회 | 청크당 2회 | 즉 **히트가 미스보다 SQLite 작업을 덜 하지 않는다.** 미스가 추가로 내는 비용은 임베딩 호출인데 그건 GPU 에서 **배치로, 뮤텍스를 잡지 않고** 돈다. 결과적으로 캐시는 "병렬 GPU 배치 1회"를 "직렬 SQLite 왕복 N회"로 바꿔치기하는 셈이고, 코퍼스가 크고 SQLite 락이 이미 #229 로 포화된 상황에서는 순손실이 된다. ## 실측 ⚠️ **정량 실측 보완 필요.** 보고 환경(약 12,000 doc / 52,000 chunk, ollama + snowflake-arctic-embed2 1024-dim)에서 "캐시 히트 쪽이 더 느리다"가 관측되었으나 수치 미기록. 아래 표를 채울 것. | 조건 | chunk/초 | 총 소요 | |---|---|---| | 캐시 히트 경로 | | | | 캐시 우회(전량 재임베딩) | | | ## 제안 1. **조회 배칭.** `WHERE cache_key IN (?,?,…)` 로 청크 배치 단위 1회 조회 후 `HashMap` 으로 되돌림. 쿼리 수가 N → N/배치크기. 2. **`prepare_cached` 도입.** 최소 `derivation_cache_get` / `_put` / `_touch`. 크레이트 전역 적용도 별도 이득이 있다. 3. **touch 를 히트 경로에서 분리.** TTL GC 는 `last_used_at` 의 초 단위 정밀도가 필요 없다. (a) run 종료 시 1회 배치, (b) 확률적 샘플링, (c) `created_at` 기반 TTL 로 바꿔 touch 제거. **(c) 가 가장 단순**하고 히트 경로를 완전한 읽기 전용으로 만든다. 4. **put 배치 트랜잭션.** 미스 벡터를 모아 트랜잭션 1개로 커밋. 5. **캐시 계측 노출.** 현재 hit/miss 카운터가 내부 변수에만 있다. ndjson 로그와 `AssetTimings` 에 `cache_hit` / `cache_miss` / `cache_ms` 를 additive 로 실어 "캐시가 이득인지"를 사용자가 판단할 수 있게 한다. 6. (검토) **캐시 우회 스위치.** 1~4 로 해결되면 불필요하다 — 플래그부터 만들지 말 것. ## 관련 - #229 `chunks_fts` 삭제 FTS5 전체 스캔 — 같은 `Mutex<Connection>` 을 공유하므로 상호 증폭. #229 를 먼저 고치면 본 이슈의 체감도 함께 줄어든다. - #230 Lance fragment 무한 누적 - 발단: v0.30.0 `md-heading-v2`(`58386c4`) / v0.30.1 `pdf-page-v1.2`(`ed8ab7c`) 청커 버전 cascade 로 전 문서 full re-process 가 발생하면서 캐시 경로가 전 코퍼스 규모로 노출됨.
Author
Owner

PR #237 을 머지했다. 이슈가 비워 둔 실측 표를 채웠는데 가설이 재현되지 않는다. 이슈는 열어 둔다 — 닫을지는 아래를 보고 판단해 주면 좋겠다.

실측

나무위키 1,157 파일(확장자 있는 792개가 색인, chunk 16,379개), ollama + snowflake-arctic-embed2 1024-dim, --force-reingest 로 전 문서 재처리. 캐시 우회는 derivation_cache 테이블을 비워 전량 미스로 만들었다.

조건 총 소요
캐시 히트 경로 139.1초
캐시 우회(전량 재임베딩) 1179.6초

캐시 히트가 8.5배 빠르다.

이 PR 이 추가한 계측으로 내역을 뜯어보면 (자산 처리 1,584건 / 히트 32,758 / 미스 0, run 141.3초):

구간 소요 비중
embed_ms — 벡터 phase 전체 75.2초 53%
store_ms — SQLite 문서·청크 기록 57.0초 40%
chunk_ms 4.4초 3%
↳ 그중 캐시 경로 0.6~2.1초 0.4~1.5%

이슈가 지목한 여섯 원인이 전부 합쳐 run 의 1.5% 이하다.

다만 보고가 틀렸다고 단정하지 않는다

보고 이후 #229#230 이 머지됐다. 이 이슈 본문 스스로 #229 를 "같은 Mutex<Connection> 을 공유하므로 상호 증폭" 이라고 적었고, "#229 를 먼저 고치면 본 이슈의 체감도 함께 줄어든다" 고 예측했다. 원 보고 환경에서는 캐시 조회 52,000회가 전부 그 뮤텍스를 잡았다 놓는데 같은 뮤텍스 위에서 chunk 삭제가 FTS5 전체 스캔을 돌리고 있었다.

그러니 이 실측은 "#229 가 머지된 뒤에는 재현되지 않는다" 로 읽는 것이 맞다. 측정 머신이 61 GB RAM 이라 DB 가 통째로 페이지 캐시에 올라간다는 점도 밝혀 둔다.

반영한 것 / 안 한 것

제안 1(조회 배칭) · 2(prepare_cached) · 4(put 배치 트랜잭션) 는 왕복이 줄 뿐 잃는 게 없어 넣었다. 다만 A/B 벽시계가 139.1초 → 139.3초로 측정 오차 안이다. 이 코퍼스에서는 체감이 없다.

제안 5(계측 노출)가 실질 산출물이다. asset_timingscache_hit / cache_miss / cache_ms 를 additive 로 실어, 사용자가 자기 코퍼스에서 직접 판단할 수 있게 했다. cache_ms 는 touch 도 포함한다 — 이슈의 가장 날카로운 지적이 "읽기 전용이어야 할 히트 경로가 쓰기를 만든다" 인데 touch 를 빼고 재는 지표로는 그 주장을 검증할 수 없다.

제안 3(touch 분리)은 안 했다. 캐시 경로 전체가 1.5% 이하라 touch 만 떼어낼 이유가 없고, 권한 (c)안(created_at 기반 TTL)은 LRU 를 age 기반 축출로 바꾸는 의미 변경이다. 제안 6(우회 스위치)도 안 했다 — 이슈 스스로 "플래그부터 만들지 말 것" 이라고 적었다. 원인 6(4 KB BLOB overflow)은 page_size 변경이 기존 DB 에 VACUUM 을 요구해서 남겼다.

닫을지 판단할 거리

메모리를 제한한 환경(예: cgroup 으로 페이지 캐시를 좁힌 상태)에서 다시 재 보면 원 보고에 가까운 그림이 나올 수 있다. 그게 필요하면 이슈를 열어 두고, 아니면 위 근거로 닫으면 된다.

PR #237 을 머지했다. 이슈가 비워 둔 실측 표를 채웠는데 **가설이 재현되지 않는다.** 이슈는 열어 둔다 — 닫을지는 아래를 보고 판단해 주면 좋겠다. ## 실측 나무위키 1,157 파일(확장자 있는 792개가 색인, chunk 16,379개), ollama + snowflake-arctic-embed2 1024-dim, `--force-reingest` 로 전 문서 재처리. 캐시 우회는 `derivation_cache` 테이블을 비워 전량 미스로 만들었다. | 조건 | 총 소요 | |---|---| | 캐시 히트 경로 | **139.1초** | | 캐시 우회(전량 재임베딩) | **1179.6초** | 캐시 히트가 8.5배 빠르다. 이 PR 이 추가한 계측으로 내역을 뜯어보면 (자산 처리 1,584건 / 히트 32,758 / 미스 0, run 141.3초): | 구간 | 소요 | 비중 | |---|---|---| | `embed_ms` — 벡터 phase 전체 | 75.2초 | 53% | | `store_ms` — SQLite 문서·청크 기록 | 57.0초 | 40% | | `chunk_ms` | 4.4초 | 3% | | ↳ 그중 **캐시 경로** | **0.6~2.1초** | **0.4~1.5%** | 이슈가 지목한 여섯 원인이 전부 합쳐 run 의 1.5% 이하다. ## 다만 보고가 틀렸다고 단정하지 않는다 보고 이후 #229 와 #230 이 머지됐다. **이 이슈 본문 스스로 #229 를 "같은 `Mutex<Connection>` 을 공유하므로 상호 증폭" 이라고 적었고, "#229 를 먼저 고치면 본 이슈의 체감도 함께 줄어든다" 고 예측했다.** 원 보고 환경에서는 캐시 조회 52,000회가 전부 그 뮤텍스를 잡았다 놓는데 같은 뮤텍스 위에서 chunk 삭제가 FTS5 전체 스캔을 돌리고 있었다. 그러니 이 실측은 **"#229 가 머지된 뒤에는 재현되지 않는다"** 로 읽는 것이 맞다. 측정 머신이 61 GB RAM 이라 DB 가 통째로 페이지 캐시에 올라간다는 점도 밝혀 둔다. ## 반영한 것 / 안 한 것 제안 1(조회 배칭) · 2(prepare_cached) · 4(put 배치 트랜잭션) 는 왕복이 줄 뿐 잃는 게 없어 넣었다. 다만 **A/B 벽시계가 139.1초 → 139.3초로 측정 오차 안**이다. 이 코퍼스에서는 체감이 없다. 제안 5(계측 노출)가 실질 산출물이다. `asset_timings` 에 `cache_hit` / `cache_miss` / `cache_ms` 를 additive 로 실어, 사용자가 자기 코퍼스에서 직접 판단할 수 있게 했다. `cache_ms` 는 touch 도 포함한다 — 이슈의 가장 날카로운 지적이 "읽기 전용이어야 할 히트 경로가 쓰기를 만든다" 인데 touch 를 빼고 재는 지표로는 그 주장을 검증할 수 없다. 제안 3(touch 분리)은 안 했다. 캐시 경로 전체가 1.5% 이하라 touch 만 떼어낼 이유가 없고, 권한 (c)안(`created_at` 기반 TTL)은 LRU 를 age 기반 축출로 바꾸는 **의미 변경**이다. 제안 6(우회 스위치)도 안 했다 — 이슈 스스로 "플래그부터 만들지 말 것" 이라고 적었다. 원인 6(4 KB BLOB overflow)은 `page_size` 변경이 기존 DB 에 VACUUM 을 요구해서 남겼다. ## 닫을지 판단할 거리 메모리를 제한한 환경(예: cgroup 으로 페이지 캐시를 좁힌 상태)에서 다시 재 보면 원 보고에 가까운 그림이 나올 수 있다. 그게 필요하면 이슈를 열어 두고, 아니면 위 근거로 닫으면 된다.
Author
Owner

실측 근거로 닫는다. 상세는 위 코멘트에 있다.

요약하면, 이슈가 비워 둔 표를 채운 결과 가설이 재현되지 않았다 — 캐시 히트 경로 139초, 캐시 우회 1,180초로 히트가 8.5배 빠르고, 계측을 붙여 뜯어보니 캐시 경로 전체가 run 의 1.5% 이하다.

다만 이 이슈가 틀렸다는 뜻이 아니다. 보고 이후 #229 가 머지됐고, 이 이슈 본문 스스로 #229 와 같은 Mutex<Connection> 을 공유해 상호 증폭한다고 적었으며 '#229 를 먼저 고치면 체감이 줄어든다' 고 예측했다. 그 예측이 맞았다고 읽는 것이 정확하다.

제안 중 조회 배칭·prepare_cached·put 배치 트랜잭션(1·2·4)은 왕복이 줄 뿐 잃는 게 없어 반영했다. 다만 벽시계 차이는 139.1초 → 139.3초로 측정 오차 안이라 이 코퍼스에서는 체감이 없다는 것도 함께 적어 둔다.

실질적인 산출물은 제안 5(계측 노출)다. asset_timingscache_hit / cache_miss / cache_ms 가 실려서, 이제 사용자가 자기 코퍼스에서 캐시가 이득인지 직접 판단할 수 있다. 다른 환경(메모리가 좁아 페이지 캐시가 안 도는 머신, 더 큰 코퍼스)에서 이 지표가 캐시 경로를 유의미하게 가리키면 그때 다시 열면 된다.

제안 3(touch 분리)과 6(우회 스위치)은 근거가 없어 안 했다. 전자는 LRU 를 age 기반 축출로 바꾸는 의미 변경이고, 후자는 이슈 스스로 '플래그부터 만들지 말 것' 이라고 적었다.

실측 근거로 닫는다. 상세는 위 코멘트에 있다. 요약하면, 이슈가 비워 둔 표를 채운 결과 **가설이 재현되지 않았다** — 캐시 히트 경로 139초, 캐시 우회 1,180초로 히트가 8.5배 빠르고, 계측을 붙여 뜯어보니 캐시 경로 전체가 run 의 1.5% 이하다. 다만 이 이슈가 틀렸다는 뜻이 아니다. 보고 이후 #229 가 머지됐고, **이 이슈 본문 스스로 #229 와 같은 `Mutex<Connection>` 을 공유해 상호 증폭한다고 적었으며 '#229 를 먼저 고치면 체감이 줄어든다' 고 예측했다.** 그 예측이 맞았다고 읽는 것이 정확하다. 제안 중 조회 배칭·`prepare_cached`·put 배치 트랜잭션(1·2·4)은 왕복이 줄 뿐 잃는 게 없어 반영했다. 다만 벽시계 차이는 139.1초 → 139.3초로 측정 오차 안이라 이 코퍼스에서는 체감이 없다는 것도 함께 적어 둔다. 실질적인 산출물은 제안 5(계측 노출)다. `asset_timings` 에 `cache_hit` / `cache_miss` / `cache_ms` 가 실려서, 이제 사용자가 **자기 코퍼스에서** 캐시가 이득인지 직접 판단할 수 있다. 다른 환경(메모리가 좁아 페이지 캐시가 안 도는 머신, 더 큰 코퍼스)에서 이 지표가 캐시 경로를 유의미하게 가리키면 그때 다시 열면 된다. 제안 3(touch 분리)과 6(우회 스위치)은 근거가 없어 안 했다. 전자는 LRU 를 age 기반 축출로 바꾸는 의미 변경이고, 후자는 이슈 스스로 '플래그부터 만들지 말 것' 이라고 적었다.
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: altair823-org/kebab#231