40줄의 Python 코드로 LLM 앱을 위한 시맨틱 캐시(Semantic Cache)를 구축하고 비용을 절반으로 절감하는 방법
요약
LLM API 호출 비용을 절감하기 위해 의미론적 유사성을 기반으로 하는 시맨틱 캐시(Semantic Cache) 구축 방법을 소개합니다. 단순 문자열 매칭의 한계를 극복하기 위해 임베딩과 벡터 비교를 활용하는 4단계 가이드를 제공합니다.
핵심 포인트
- 문자열 기반 캐시는 의미가 같은 질문도 매칭하지 못해 비용 낭비를 초래함
- 임베딩을 활용해 쿼리의 의미적 유사성을 계산하여 캐시 적중률을 높임
- SentenceTransformer와 벡터 비교를 통해 간단한 프로토타입 구현 가능
- 운영 환경에서는 Qdrant나 Milvus 같은 전문 벡터 스토어 사용 권장
만약 여러분이 모든 사용자 요청마다 LLM API를 호출하고 있다면, 거의 확실하게 동일한 질문에 대해 중복 비용을 지불하고 있는 것입니다. 이는 사용자가 어리석어서가 아니라, 인간은 같은 질문을 수십 가지의 다른 방식으로 던지며, 일반적인 캐시(Cache)는 정확히 일치하는 문자열만 매칭하기 때문입니다.
"how do I reset my password"와 "reset password help pls"는 dict나 Redis GET 연산 관점에서는 완전히 무관한 데이터입니다. 하지만 사용자들에게 이들은 동일한 요청입니다. 매번 발생하는 불일치는 불필요하게 발생한, 비용을 전액 지불해야 하는 모델 호출(Model call)이 됩니다.
이를 해결해 봅시다. 이 포스트를 마칠 때쯤 여러분은 작동하는 시맨틱 캐시(Semantic Cache)를 갖게 될 것이며, 이것이 정확히 어디서 실패하는지, 그리고 어떻게 수정하는지도 알게 될 것입니다.
구체적인 문제 상황
import redis
import hashlib
...
이 방식은 데모에서는 잘 작동합니다. 하지만 실제 운영 환경(Production)에서 실제 사용자 트래픽에 대한 캐시 적중률(Cache hit rate)은 0에 가까울 것입니다. 왜냐하면 hashlib.sha256은 "reset my password"와 "help me reset password"가 같은 의미라는 것을 모르기 때문입니다. 여러분은 누군가 정확히 똑같은 요청을 두 번 복사해서 붙여넣을 때만 도움이 되는 캐시를 만든 셈인데, 사람이 직접 입력하는 데이터에서는 그런 일이 거의 일어나지 않습니다.
1단계: 해시(Hash) 대신 임베딩(Embed) 사용하기
정확한 문자열을 매칭하는 대신, 우리는 '의미'를 매칭합니다. 쿼리를 벡터(Vector)로 변환하고, 이미 답변했던 쿼리들의 벡터와 비교하는 방식입니다.
from sentence_transformers import SentenceTransformer
import numpy as np
...
normalize_embeddings=True 설정이 중요합니다. 이는 나중에 추가적인 정규화(Normalization) 단계 없이 단순한 내적(Dot product)을 코사인 유사도(Cosine similarity)로 사용할 수 있음을 의미합니다.
2단계: 최소 기능의 벡터 스토어(Vector Store)
이 패턴을 학습하기 위해 반드시 Qdrant나 Milvus가 필요한 것은 아닙니다 (물론 확장성을 위해 운영 환경에서는 필요할 것입니다. 이에 대해서는 아래에서 더 자세히 다루겠습니다). 작동하는 프로토타입을 위해서는 인메모리(In-memory) 리스트만으로도 충분합니다.
cache_store = [] # dict 리스트: {embedding, query, response}
def find_match(query_embedding, threshold=0.92):
...
3단계: 통합하기
def semantic_cached_call(prompt: str) -> str:
query_embedding = embed(prompt)
match = find_match(query_embedding)
...
이것이 전부입니다. 의역된 테스트 쿼리(paraphrased test queries) 배치를 대상으로 실행해 보면, 문자열 캐시(string cache)라면 완전히 놓쳤을 항목들에 대해 즉시 캐시 히트(cache hits)가 발생하는 것을 확인할 수 있습니다.
4단계: 이 방식이 실패하는 지점 (그럼에도 불구하고 이것이 필요한 이유)
다음 코드를 실행해 보세요:
print(semantic_cached_call("How do I cancel my subscription?"))
print(semantic_cached_call("How do I reactivate my subscription?"))
사용 중인 임베딩 모델(embedding model)과 임계값(threshold)에 따라, 두 번째 쿼리가 cancel(취소) 답변을 제공받을 실질적인 가능성이 있습니다. 왜냐하면 "cancel"과 "reactivate"는 문장의 나머지 거의 모든 단어를 공유하며, 임베딩 모델은 지시 동사를 구별하기보다는 공유된 어휘(vocabulary)를 기반으로 클러스터링(cluster)하는 경향이 있기 때문입니다. 이것은 시맨틱 캐싱(semantic caching)에서 발생하는 가장 흔한 실패 모드이며, 가드레일(guard rail) 없이 위의 40줄짜리 버전을 바로 배포할 수 없는 이유이기도 합니다.
해결책은 다음과 같습니다. 유사도 매칭(similarity match)이 이루어진 _후_에, 결과를 신뢰하기 _전_에 알려진 극성 쌍(polarity pairs)에 대해 가벼운 거부 규칙(rejection rule)을 적용하는 것입니다:
OPPOSING_PAIRS = [
("cancel", "reactivate"), ("enable", "disable"),
("add", "remove"), ("increase", "decrease"),
...
이 방법이 모든 예외 케이스(edge case)를 잡아내지는 못할 것입니다. 이를 위해서는 적절한 교차 인코더 재순위화(cross-encoder rerank) 단계나 작은 의도 분류기(intent classifier)가 필요하겠지만, 복잡성을 거의 추가하지 않고도 가장 당혹스러운 종류의 버그를 제거할 수 있으며, 주말 프로젝트나 내부 도구용으로는 충분히 좋은 지점입니다.
5단계: 프로덕션 환경에 적합하게 만들기 (Production-Ready)
인메모리(in-memory) 리스트는 데모용으로는 작동합니다. 실제 트래픽을 처리하려면 이를 실제 벡터 데이터베이스(vector database)로 교체하세요. 쿼리 패턴은 동일하며, 단지 for entry in cache_store를 ANN 인덱스(ANN index)로 바꾸는 것뿐입니다:
# Qdrant를 예시로 사용
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct, VectorParams, Distance
...
로직은 동일하지만, 이제 밀리초 미만(sub-millisecond)의 조회 속도로 수백만 개의 캐시된 항목까지 확장 가능한 인덱스를 기반으로 작동하게 됩니다.
배포 전 주의해야 할 흔한 실수들
실수 1: 모든 쿼리 유형에 하나의 전역 임계값(threshold)을 적용하는 것. "API 사용법은 무엇인가요?"라는 질문에 맞춰 조정된 임계값은 "환불 자격이 어떻게 되나요?"와 같은 질문에는 너무 느슨할 수 있습니다. 앱이 여러 도메인에 걸쳐 있다면, 서로 다른 임계값을 사용하거나 민감한 카테고리는 캐싱에서 완전히 제외하십시오.
실수 2: 모든 것을 영원히 캐싱하는 것. 캐시된 답변은 기반이 되는 사실(가격, 정책, 기능 가용성 등)이 변경되는 즉시 쓸모없게 됩니다. 모든 캐시 항목에 created_at을 추가하고, 영구적이지 않은 정보에 대해서는 공격적으로 만료(expire) 처리를 하십시오.
실수 3: 유사도 점수(similarity score)만 신뢰하는 것. 위에서 보여준 것처럼, 높은 유사도가 반드시 동일한 의도(intent)를 의미하지는 않습니다. 캐시된 응답을 제공하기 전에 항상 벡터 검색(vector search)과 함께 최소 하나 이상의 가벼운 가드레일(guard rail)을 병행하십시오.
실수 4: 카테고리별 적중률(hit rate)을 측정하지 않는 것. 전체 적중률은 허영 지표(vanity metric)에 불과합니다. 고객 지원 봇의 경우 "어떻게 하면..."과 같은 질문에는 70%의 적중률을 보일 수 있지만, 계정 관련 질문에는 0%에 가까울 수 있습니다. 카테고리별로 구분하여 어떤 것이 캐싱할 가치가 있는지 결정해야 합니다.
직접 시도해 보세요
위의 40줄 버전을 가져와서 현재 사용 중인 LLM에 연결하고, 실제 트래픽이 발생하는 하루 동안 캐시 적중(hit) 대 미적중(miss)을 기록해 보세요. 반복적인 사용자 의도(고객 지원, FAQ, 내부 도구 등)가 있는 앱이라면 최소 30~40%의 적중률을 발견할 것이라고 진심으로 확신합니다. 그리고 그 적중 건수 하나하나가 여러분이 비용을 지불하거나 기다릴 필요가 없었던 완전한 LLM 호출입니다.
가드레일과 무효화(invalidation) 로직이 내장된 더 완전한 참조 구현체가 필요하다면, 제가 Remem (pip install remem-ai)이라는 오픈 소스 패키지로 만들어 두었으니, 직접 버전을 구축한 후 더 까다로운 엣지 케이스(edge cases)에 대해 비교해 보고 싶을 때 살펴보시기 바랍니다.
이제 여러분의 차례입니다: 40줄 버전을 구축하여 여러분의 로그나 의역된 테스트 세트에 실행해 보고, 댓글로 적중률을 알려주세요. 30~40%라는 수치가 제 트래픽 패턴 외의 환경에서도 유지되는지 알고 싶습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기