derivation_cache 히트가 재임베딩보다 느림 — 청크별 개별 조회 + 히트마다 touch 쓰기 #231
Reference in New Issue
Block a user
Delete Branch "%!s()"
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?
증상
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 를 개별 왕복한다.여섯 가지가 겹친다.
1. 조회 배칭 없음
청크 1건당
derivation_cache_get1회. 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_size4096 이라 payload 마다 overflow page 로 넘어간다. 52,000행이면 200 MB+ 이고 조회마다 overflow chain 을 따라간다.왜 히트가 미스보다 느린가
put1회touchUPDATE 1회즉 히트가 미스보다 SQLite 작업을 덜 하지 않는다. 미스가 추가로 내는 비용은 임베딩 호출인데 그건 GPU 에서 배치로, 뮤텍스를 잡지 않고 돈다. 결과적으로 캐시는 "병렬 GPU 배치 1회"를 "직렬 SQLite 왕복 N회"로 바꿔치기하는 셈이고, 코퍼스가 크고 SQLite 락이 이미 #229 로 포화된 상황에서는 순손실이 된다.
실측
⚠️ 정량 실측 보완 필요. 보고 환경(약 12,000 doc / 52,000 chunk, ollama + snowflake-arctic-embed2 1024-dim)에서 "캐시 히트 쪽이 더 느리다"가 관측되었으나 수치 미기록. 아래 표를 채울 것.
제안
WHERE cache_key IN (?,?,…)로 청크 배치 단위 1회 조회 후HashMap으로 되돌림. 쿼리 수가 N → N/배치크기.prepare_cached도입. 최소derivation_cache_get/_put/_touch. 크레이트 전역 적용도 별도 이득이 있다.last_used_at의 초 단위 정밀도가 필요 없다. (a) run 종료 시 1회 배치, (b) 확률적 샘플링, (c)created_at기반 TTL 로 바꿔 touch 제거. (c) 가 가장 단순하고 히트 경로를 완전한 읽기 전용으로 만든다.AssetTimings에cache_hit/cache_miss/cache_ms를 additive 로 실어 "캐시가 이득인지"를 사용자가 판단할 수 있게 한다.관련
chunks_fts삭제 FTS5 전체 스캔 — 같은Mutex<Connection>을 공유하므로 상호 증폭. #229 를 먼저 고치면 본 이슈의 체감도 함께 줄어든다.md-heading-v2(58386c4) / v0.30.1pdf-page-v1.2(ed8ab7c) 청커 버전 cascade 로 전 문서 full re-process 가 발생하면서 캐시 경로가 전 코퍼스 규모로 노출됨.PR #237 을 머지했다. 이슈가 비워 둔 실측 표를 채웠는데 가설이 재현되지 않는다. 이슈는 열어 둔다 — 닫을지는 아래를 보고 판단해 주면 좋겠다.
실측
나무위키 1,157 파일(확장자 있는 792개가 색인, chunk 16,379개), ollama + snowflake-arctic-embed2 1024-dim,
--force-reingest로 전 문서 재처리. 캐시 우회는derivation_cache테이블을 비워 전량 미스로 만들었다.캐시 히트가 8.5배 빠르다.
이 PR 이 추가한 계측으로 내역을 뜯어보면 (자산 처리 1,584건 / 히트 32,758 / 미스 0, run 141.3초):
embed_ms— 벡터 phase 전체store_ms— SQLite 문서·청크 기록chunk_ms이슈가 지목한 여섯 원인이 전부 합쳐 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 으로 페이지 캐시를 좁힌 상태)에서 다시 재 보면 원 보고에 가까운 그림이 나올 수 있다. 그게 필요하면 이슈를 열어 두고, 아니면 위 근거로 닫으면 된다.
실측 근거로 닫는다. 상세는 위 코멘트에 있다.
요약하면, 이슈가 비워 둔 표를 채운 결과 가설이 재현되지 않았다 — 캐시 히트 경로 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 기반 축출로 바꾸는 의미 변경이고, 후자는 이슈 스스로 '플래그부터 만들지 말 것' 이라고 적었다.