Kmemo: 잘못된 답변 제공을 거부하는 LLM 호출용 시맨틱 캐시 (Semantic Cache)
요약
Kmemo는 시맨틱 캐시 사용 시 발생할 수 있는 잘못된 답변 제공 문제를 해결하는 라이브러리입니다. 유사도 기반의 임계값 설정만으로는 구분하기 어려운 미세한 차이를 10개의 가드(Guards) 체인을 통해 검증하여 정확도를 높입니다.
핵심 포인트
- 시맨틱 캐시의 유사도 기반 오류(잘못된 답변 수락) 문제 해결
- 10개의 가드 체인을 통한 숫자, 단위, 엔티티 등 세부 차이 검증
- 비용 비대칭성을 고려하여 잘못된 수락보다 안전한 거절을 우선시
- 임베딩 모델에 종속되지 않는 유연한 구현 방식 제공
정확히 일치하는 방식의 캐시(Exact-match cache)는 이미 "python list reverse"에 대해 답변한 적이 있더라도 "how do I reverse a list in Python"이라는 질문에는 대응하지 못합니다. 하지만 시맨틱 캐시(Semantic cache)는 다릅니다. 프롬프트를 임베딩(Embedding)하여 이전에 본 가장 유사한 프롬프트를 찾아내고, 모델을 호출하는 대신 그 답변을 다시 재생(Replay)합니다. API 호출은 줄어들고, 지연 시간(Latency)은 낮아지며, 답변은 동일하게 유지됩니다.
하지만 잘못된 답변을 전달하는 문제가 발생할 수 있습니다.
"Convert 100 USD to EUR"
"Convert 250 USD to EUR" 코사인 유사도 (Cosine similarity): ~0.99
모든 주류 임베딩 모델(Embedding model)은 이 쌍에 대해 약 0.99의 점수를 부여합니다. 유사도 축에서 이 '아슬아슬한 차이'는 대부분의 진정한 의미적 유사 문장(Paraphrase)보다 더 가깝게 위치하기 때문에, 임계값(Threshold)만으로는 이를 구분할 수 없습니다. 임계값을 높이면 이 오류를 잡기 전에 실제 유효한 히트(Hit)들을 놓치게 됩니다.
따라서 임계값에만 의존하여 구축된 캐시는 누군가에게 250달러가 92유로라고 말해버릴 것입니다. 오류 없이, 로그에도 남지 않은 채 아주 빠르게 말이죠.
Kmemo가 이를 해결하는 방법
Kmemo는 이를 예외적인 사례가 아닌 핵심적인 문제로 다룹니다. 유사도는 첫 번째 필터일 뿐입니다. 유사도 검사를 통과한 후보들은 두 답변이 반드시 달라야 한다는 구체적인 증거를 찾기 위해 10개의 가드(Guards) 체인에 의해 텍스트로 읽힙니다. 이 가드들은 숫자의 바뀜, 단위 불일치, 엔티티(Entity) 차이, 시간 참조의 차이, 부정(Negation), 반의어 반전, 비교 역전, 또는 다른 종류의 답변 요구 등을 확인합니다.
기본 설정은 비용의 비대칭성(Cost asymmetry)을 따릅니다. 잘못된 거절(Rejection)은 API 호출 한 번의 비용만 발생하지만, 잘못된 수락(Acceptance)은 잘못된 답변을 제공하는 결과를 초래합니다. 따라서 가드들은 추측하기보다는 판단을 유보합니다.
빠른 시작 (Quick start)
JDK 17 이상이 필요합니다.
dependencies {
implementation("io.github.nacode-studios:kmemo-core:1.0.0")
}
kmemo-core는 kotlinx-coroutines-core만을 유일한 의존성으로 선언합니다. 사용자는 String에서 FloatArray로 변환하는 임의의 함수를 통해 임베딩 소스를 가져오기만 하면 됩니다. Kmemo는 자체 임베딩 모델을 포함하지 않으며 특정 제공업체의 SDK에 의존하지 않습니다.
val cache = SemanticCache(
embedder = Embedder { text -> openAi.embed(text) },
store = InMemoryStore(maxEntries = 10_000, ttl = 1.hours),
...
getOrPut은 프롬프트(prompt)를 한 번 임베딩(embed)한 후, 조회(lookup)와 쓰기(write) 모두에 해당 벡터를 재사용합니다. 동일한 질문을 하는 동시 호출자들은 병합(coalesced)됩니다. 즉, 첫 번째 호출자가 계산을 수행하면 나머지 호출자들은 대기하다가 그 답변을 제공받습니다.
모든 미스(miss)는 그 이유를 알려줍니다
히트율(hit rate)이 4%인 캐시는 미스(miss)가 발생한 원인을 알지 못하면 튜닝할 수 없습니다. 임계값 미스(threshold miss)와 가드 거부(guard rejection)에 대한 해결책이 서로 반대이기 때문입니다.
when (val result = cache.lookup(prompt)) {
is CacheLookup.Hit -> result.response
is CacheLookup.Miss -> when (result.reason) {
...
또한 cache.explain(prompt)라는 읽기 전용 컴패니언(companion) 함수가 있어, 모든 가드(guard)의 판결이 포함된 모든 후보를 보여줍니다. 이는 기대했던 히트(hit)가 발생하지 않았을 때 찾아보게 되는 도구입니다.
스코프 (Scopes)
정답의 형태를 결정짓는 모든 요소는 스코프(scope)에 포함되어야 합니다: 모델(model), 온도(temperature), 시스템 프롬프트(system prompt), 테넌트(tenant), 언어(language). 이를 제외하면 캐시는 한 모델의 답변을 다른 모델의 호출자에게 제공하게 됩니다.
cache.getOrPut(prompt, scope = "gpt-4o|t=0.0|v3") { llm.complete(it) }
엄격함의 정도 선택하기
SemanticCache(embedder) // MatchGuards.standard()
SemanticCache(embedder, guards = MatchGuards.strict()) // 히트율을 희생하여 마진(margin)을 확보
SemanticCache(embedder, guards = MatchGuards.none()) // 단순 유사도 기반의 베이스라인
가드(guards)는 영어 이외의 언어에서도 작동합니다. 이탈리아어, 스페인어, 독일어, 프랑스어를 위한 큐레이션된 팩(packs)이 제공되며, 각 언어는 현지화된 근접 미스 코퍼스(near-miss corpus)를 기준으로 측정되었습니다.
SemanticCache(embedder, guards = MatchGuards.standard(Locale.ITALIAN))
어휘적 가드(lexical guards)가 볼 수 없는 것
근접 미스(near misses)의 약 3분의 1은 세상에 대한 지식(world knowledge)을 필요로 합니다. 강아지의 구충을 하는 것은 성견의 구충과 같지 않습니다. 에탄올의 끓는점은 메탄올의 끓는점과 다릅니다. 그 어떤 토큰 비교(token comparison)로도 이러한 차이를 잡아낼 수는 없습니다.
이를 위해 일반적으로 비용이 저렴한 모델 호출인 선택적 Verifier (검증기)가 존재합니다. 이는 이미 임계값(threshold)과 모든 가드(guard)를 통과한 후보군에 대해서만 실행되므로, 일반적인 경우에는 비용이 들지 않으며, '실패 시 차단(fails closed)' 방식으로 동작합니다. 즉, 타임아웃이나 에러가 발생하면 확인되지 않은 내용을 제공하는 대신 거부합니다.
수치 (The numbers)
가드(guards)는 어떤 가드도 튜닝되지 않은 블라인드 검증 분할(blind validation split)이 포함된 세 개의 레이블링된 코퍼스(labelled corpora)를 대상으로 평가되며, 모든 빌드 시 CI 회귀 게이트(regression gate)로 실행됩니다.
블라인드 분할 결과, 유사하지만 틀린 답변(near misses)은 67%의 확률로 거부되었고, 의역(paraphrases)은 88%의 확률로 유지되었습니다.
두 수치 모두 100%는 아니며, 저는 이를 마케팅용 주장보다는 있는 그대로 발표하는 쪽을 택하겠습니다. 통과되는 유사 답변(near misses)은 대부분 Verifier가 커버하는 세상 지식(world-knowledge) 사례들입니다. 직접 재현해 보세요:
./gradlew :kmemo-core:test --tests '*CorpusTest*'
오염 없이 블라인드 분할을 확장하는 방법은 docs/CORPUS.md에 기술되어 있습니다.
임계값을 복사하지 말고 교정(Calibrate)하세요
ThresholdCalibrator는 사용 중인 임베딩 모델(embedding model)에 적합한 임계값을 측정합니다. 블로그 포스트에서 발견한 값은 다른 누군가의 모델에 맞춰 튜닝된 값입니다.
스토어, 회복 탄력성, 관찰 가능성 (Stores, resilience, observability)
Embedder와 CacheStore는 단일 메서드 심(seam)으로 구현되어 있어, 매칭 로직을 수정하지 않고도 인메모리(in-memory)에서 시작하여 벡터 데이터베이스(vector database)로 전환할 수 있습니다. Redis (RediSearch KNN) 및 Postgres (pgvector) 스토어가 제공되며, 정확한 스캔(exact scan)의 확장성이 한계에 도달할 때를 대비한 선택적 인프로세스(in-process) HNSW 스토어도 포함되어 있습니다.
임베더(embedder)는 조회할 때마다 네트워크 호출이 발생하므로, Kmemo는 사용자가 그 실패를 제어할 수 있게 합니다:
val cache = SemanticCache(
embedder = myEmbedder.retrying(maxAttempts = 4),
embedFailurePolicy = EmbedFailurePolicy.FALL_BACK_TO_COMPUTE,
...
val metrics = KmemoMetrics().also { it.bindTo(meterRegistry) } // kmemo-micrometer
val cache = SemanticCache(embedder, listeners = listOf(metrics, Slf4jCacheListener()))
통합 (Integrations)
SemanticCache빈 (bean)을 자동 설정하는 Spring Boot starterChatClient를 위한 Spring AI 캐싱Advisor- LangChain4j 캐싱
ChatModel래퍼 (wrapper) - Ktor 서버 플러그인
examples/는 API 키가 필요 없는 실행 가능한 데모입니다. Redis 저장소를 위한 docker-compose와 함께, 가드 (guard)가 실제 발생할 뻔한 오류(near miss)를 잡아내는 모습을 보여줍니다.
사용해 보기
- 리포지토리 (Repo): https://github.com/NaCode-Studios/Kmemo
- Maven Central:
io.github.nacode-studios:kmemo-core:1.0.0 - API 문서: https://nacode-studios.github.io/Kmemo/
- Apache-2.0
1.0은 SemVer (유의적 버전)에 따라 안정적인 버전입니다. 만약 프로덕션 환경에서 시맨틱 캐시 (semantic cache)를 운영하다가 제가 미처 생각하지 못한 잘못된 히트 (false hit)가 발생했다면, 꼭 알려주시기 바랍니다. 문제를 일으킨 쌍 (pair)과 함께 이슈 (issue)를 생성해 주세요.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기