LLM 시맨틱 캐싱: 유사한 질문에 비용 없이 답변하기

AI 기술

LLM 캐싱시맨틱 캐시임베딩비용 최적화Redis

이 글은 누구를 위한 것인가

  • LLM API 호출 비용을 줄이려는 팀
  • 유사한 질문에 같은 답변을 재사용하려는 개발자
  • 캐시 히트율을 높여 응답 속도를 개선하려는 팀

들어가며

"배송은 언제 오나요?", "배송 예정일이 언제죠?", "언제 도착해요?" — 의미는 같지만 정확한 문자열은 다르다. 정확 매칭 캐시는 이 세 질문 모두 캐시 미스가 된다. 시맨틱 캐시는 임베딩 유사도로 의미가 같은 질문을 감지한다.

이 글은 bluefoxdev.kr의 LLM 시맨틱 캐싱 가이드 를 참고하여 작성했습니다.


1. 시맨틱 캐시 아키텍처

[2계층 캐시 구조]

L1: 정확 매칭 (해시 캐시)
  키: MD5(정규화된 질문)
  조회: O(1), 매우 빠름
  히트: 완전히 같은 질문

L2: 시맨틱 매칭 (벡터 캐시)
  키: 임베딩 벡터
  조회: ANN(근사 최근접 이웃)
  히트: 의미적으로 유사한 질문
  임계값: 코사인 유사도 > 0.92

LLM 호출 (캐시 미스)
  응답 생성 후 두 계층 모두 저장

[유사도 임계값 설정]
  > 0.95: 매우 엄격 (거의 같은 문장만)
  > 0.92: 권장 (의미적으로 동일한 문장)
  > 0.85: 느슨 (유사하지만 다를 수 있음)
  
  도메인마다 다름: A/B 테스트로 최적값 찾기

[TTL 전략]
  FAQ: 7일 (안정적 답변)
  제품 정보: 1일 (자주 변경)
  실시간 데이터: 캐시 안 함 (재고, 날씨)

2. 시맨틱 캐시 구현

import Anthropic from '@anthropic-ai/sdk';
import { Redis } from 'ioredis';
import crypto from 'crypto';

const client = new Anthropic();
const redis = new Redis(process.env.REDIS_URL!);

type Embedding = number[];

function cosineSimilarity(a: Embedding, b: Embedding): number {
  const dot = a.reduce((sum, val, i) => sum + val * b[i], 0);
  const normA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
  const normB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
  return dot / (normA * normB);
}

async function getEmbedding(text: string): Promise<Embedding> {
  const response = await client.messages.create({
    model: 'claude-haiku-4-5-20251001',
    max_tokens: 1,
    messages: [{ role: 'user', content: text }],
  });
  // 실제로는 Voyage AI 또는 OpenAI Embeddings API 사용
  return Array.from({ length: 1536 }, () => Math.random() - 0.5);
}

class SemanticCache {
  private readonly similarityThreshold = 0.92;
  private readonly maxCacheEntries = 10000;
  private readonly defaultTTL = 86400; // 24시간

  async get(question: string): Promise<string | null> {
    // L1: 정확 매칭
    const exactKey = `cache:exact:${crypto.createHash('md5').update(question.trim().toLowerCase()).digest('hex')}`;
    const exactHit = await redis.get(exactKey);
    if (exactHit) {
      await redis.incr('cache:stats:l1_hits');
      return exactHit;
    }

    // L2: 시맨틱 매칭
    const embedding = await getEmbedding(question);
    const semanticHit = await this.findSemanticMatch(embedding);
    if (semanticHit) {
      await redis.incr('cache:stats:l2_hits');
      // L1에도 저장 (다음 정확 매칭 시 빠르게)
      await redis.set(exactKey, semanticHit, 'EX', this.defaultTTL);
      return semanticHit;
    }

    await redis.incr('cache:stats:misses');
    return null;
  }

  async set(question: string, answer: string, ttl?: number): Promise<void> {
    const normalizedQ = question.trim().toLowerCase();
    const exactKey = `cache:exact:${crypto.createHash('md5').update(normalizedQ).digest('hex')}`;
    const embedding = await getEmbedding(question);
    const entryId = crypto.randomUUID();

    const effectiveTTL = ttl ?? this.defaultTTL;

    await Promise.all([
      // L1 저장
      redis.set(exactKey, answer, 'EX', effectiveTTL),
      // L2 저장 (벡터 + 답변)
      redis.set(`cache:vector:${entryId}`, JSON.stringify({
        question,
        answer,
        embedding,
        createdAt: Date.now(),
      }), 'EX', effectiveTTL),
      // 인덱스에 추가
      redis.sadd('cache:vector:index', entryId),
    ]);
  }

  private async findSemanticMatch(queryEmbedding: Embedding): Promise<string | null> {
    const entryIds = await redis.smembers('cache:vector:index');
    let bestMatch: string | null = null;
    let bestScore = this.similarityThreshold;

    // 배치 조회
    const entries = await Promise.all(
      entryIds.slice(0, 1000).map(id => redis.get(`cache:vector:${id}`))
    );

    for (const entry of entries) {
      if (!entry) continue;
      const { answer, embedding } = JSON.parse(entry);
      const score = cosineSimilarity(queryEmbedding, embedding);
      if (score > bestScore) {
        bestScore = score;
        bestMatch = answer;
      }
    }

    return bestMatch;
  }

  async getStats() {
    const [l1Hits, l2Hits, misses] = await Promise.all([
      redis.get('cache:stats:l1_hits'),
      redis.get('cache:stats:l2_hits'),
      redis.get('cache:stats:misses'),
    ]);
    const total = Number(l1Hits) + Number(l2Hits) + Number(misses);
    return {
      l1HitRate: `${((Number(l1Hits) / total) * 100).toFixed(1)}%`,
      l2HitRate: `${((Number(l2Hits) / total) * 100).toFixed(1)}%`,
      overallHitRate: `${(((Number(l1Hits) + Number(l2Hits)) / total) * 100).toFixed(1)}%`,
    };
  }
}

// LLM 호출 래퍼
const cache = new SemanticCache();

async function cachedLLMCall(question: string, systemPrompt: string): Promise<string> {
  const cached = await cache.get(question);
  if (cached) return cached;

  const response = await client.messages.create({
    model: 'claude-sonnet-4-6',
    max_tokens: 1024,
    system: systemPrompt,
    messages: [{ role: 'user', content: question }],
  });

  const answer = response.content[0].type === 'text' ? response.content[0].text : '';
  await cache.set(question, answer);
  return answer;
}

마무리

시맨틱 캐시의 핵심은 임계값 설정이다. 0.92는 대부분의 FAQ 시나리오에서 안전하다. 너무 낮으면 다른 질문에 잘못된 답변을 반환한다. 실시간 데이터(재고, 날씨)가 포함된 답변은 캐시하지 않는다. 캐시 히트율 30-50%만 달성해도 LLM API 비용을 크게 절감할 수 있다.