| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- DLT
- 코사인
- redis
- SaaS
- 객체지향적사고
- blockingqueue
- redisstreams
- 장애복구
- 레디스스트림
- rdb
- 테스트코드
- 레디스
- 배치처리
- 임베딩
- 마케팅 #퍼플카우 #새스고딘 #혁신 #독서 #이북
- 시맨틱캐싱
- god object
- 메세지브로커
- aof
- jedis
- 데이터유실방지
- retry
- 메시지브로커
- springboot
- OOP
- 백엔드
- Kafka
- 자연어캐싱
- 비동기처리
- redissearch
- Today
- Total
pandaterry's 개발로그
[Saas 개발로그] LLM 호출비 줄이는 법, Redis Search로 검증해봤습니다 본문

웹 백엔드를 운영하다 보면 캐시는 거의 본능처럼 사용하게 됩니다.
대부분은 Redis나 Memcached에 “정확히 일치하는” 키를 기반으로 결과를 저장하곤 합니다.
예를 들어, product:summary:2024-05 같은 키는 5월 상품 요약 데이터를 조회할 때 잘 맞는 구조입니다.
이렇게 명확히 정해진 키로 식별할 수 있는 요청은 캐시하기가 쉽습니다.
그런데 최근에는 자연어 기반 인터페이스가 많아지고 있습니다.
LLM을 활용해 데이터를 조회하거나, 자연어로 분석 보고서를 생성하는 시스템이 점점 늘어나고 있습니다.
문제는 이런 자연어 요청은 매번 다르다는 점입니다.
예를 들어 사용자가 이런 식으로 요청할 수 있습니다.
- “5월 매출 요약 보여줘”
- “지난달 실적 정리해줘”
- “이번 분기 매출 추이는?”
이 요청들은 단어는 다르지만 요청 의도는 거의 동일합니다.
그렇다면, 이들을 같은 요청으로 간주하고 캐싱할 수는 없을까요?
자연어 캐싱이 필요해졌던 배경
제가 개발 중인 시스템은 LLM을 통해 SQL을 생성하고, 이를 실행한 뒤 Excel 보고서로 다운로드하는 SaaS 형태의 백엔드입니다.
고객은 자연어로 요청을 보내고, 저희는 이를 SQL로 변환해 백엔드 데이터베이스를 조회한 뒤 최종 결과를 파일로 제공합니다.
SaaS 구조 특성상 사용량 측정이 중요한 과제입니다.
보고서 1건당 응답 행 수, 처리 비용, 트래픽 등을 측정해 요금제 제한을 걸어야 하기 때문입니다.
이 과정에서 Redis나 Kafka를 이용한 이벤트 유실 없는 로그 전송도 설계되어 있지만,
한편으로는 “같은 요청인데 매번 LLM을 호출하는 것이 과연 합리적인가?”라는 고민도 들었습니다.
LLM 호출 비용은 결코 싸지 않고, 또 GPT나 자체 LLM 처리 서버 구조에서는 요청이 많아질수록 처리 속도에도 영향을 줄 수 있기 때문입니다.
그러다가, 이 글을 본 겁니다. https://redis.io/blog/what-is-semantic-caching/
Redis - The Real-time Data Platform
Developers love Redis. Unlock the full potential of the Redis database with Redis Enterprise and start building blazing fast apps.
redis.io
그래서 실험을 해봤습니다. 시멘틱 캐싱을.....
실험 주제 정의
그래서 이번 글에서는 다음과 같은 궁금증을 실험적으로 풀어보고자 합니다.
“한국어 자연어 요청을 벡터화해서 Redis에 저장해두면,
다음에 유사한 질문이 들어왔을 때 캐시처럼 재사용할 수 있을까?”
조금 더 구체적으로 말하자면,
- 한글 자연어 질의들을 벡터 임베딩하고
- Redis 벡터 검색 기능을 통해 유사 쿼리를 찾아낸 뒤
- 찾은 쿼리들을 다른 유사도 검사기를 통해 일정 유사도 이상이면 LLM 호출 없이 캐시된 응답을 재사용할 수 있는지 실험합니다.
실험 환경 및 구성
- Redis Stack 7.2 이상 사용
- Jedis 5.2.0 이상 사용(이 버전부터 백터 검색을 지원한다고 합니다.)
- Redis 벡터 인덱스 설정 (HNSW 기반, COSINE 거리)
- LLM 임베딩 모델: text-embedding-3-small (OpenAI), 향후 국산 Ko-SBERT 대체 가능
- 질문 샘플: 유사도를 확인하기 위해 미리 정의한 자연어 질문 370개
- 실제 LLM 호출 여부 및 유사도 기반 캐시 적중률 측정
자연어 질의 샘플 370개
정말 SQL로서 요청할만한 질의들을 만들어봤습니다.
지역별 회원 수 알려줘
가장 유저 많은 지역은?
회원 수 많은 도시는?
어느 지역에 회원이 많을까?
지역별 사용자 통계 보여줘
도시별 가입자 수 조회
회원 분포가 가장 높은 지역
사용자 수가 많은 지역 순위
지역별 고객 수 통계
도시별 유저 분포 확인
월별 매출액 조회
매출이 가장 높은 달은?
월별 수익 통계 보여줘
어느 달에 매출이 많을까?
월별 판매액 분석
매출이 좋은 달 순위
월별 수익 분포 확인
매출 통계 월별로 보여줘
상품별 판매량 조회
가장 많이 팔린 상품은?
인기 상품 순위 보여줘
어떤 상품이 잘 팔릴까?
상품별 판매 통계
베스트셀러 상품 목록
....
....
이제 코드를 짜봅시다!
모든 코드보단 비즈니스 로직 위주로 어떤 시나리오로 작업을 했는지 소개합니다. 아키텍처나 OOP 등은 실험의 주제와 큰 연관이 없기에 약간 소홀히 했습니다.
RedisVectorStore
1536이라는 OpenAI 의 임베딩 모델과 같은 차원으로 벡터를 vector_index 라는 인덱스에 저장하기 위한 용도로 만든 클래스입니다.
가장 중요한 부분은 toBytes() 메서드입니다. little endian 설정을 하지 않으면 쿼리로서 검색이 작용하지 않으니 주의해주세요.
public class RedisVectorStore {
private static final Logger logger = LoggerFactory.getLogger(RedisVectorStore.class);
private static final String INDEX_NAME = "vector_index";
private static final int VECTOR_DIMENSION = 1536; // text-embedding-3-small 차원
private final UnifiedJedis jedis;
public RedisVectorStore(UnifiedJedis jedis) {
this.jedis = jedis;
}
/**
* HNSW 벡터 인덱스가 존재하지 않으면 생성
*/
public void initializeIndexIfNotExists() {
try {
// 인덱스 존재 여부 확인
try {
jedis.ftInfo(INDEX_NAME);
logger.info("Redis 벡터 인덱스가 이미 존재합니다: {}", INDEX_NAME);
return;
} catch (JedisDataException e) {
logger.debug("인덱스 존재 확인 중 오류 (정상): {}", e.getMessage());
}
logger.info("Redis 벡터 인덱스 생성 시작: {}", INDEX_NAME);
// Jedis 5.2.0 벡터 검색 스키마 생성[1]
SchemaField[] schema = {
TextField.of("text"),
VectorField.builder()
.fieldName("vector")
.algorithm(VectorField.VectorAlgorithm.HNSW)
.attributes(Map.of(
"TYPE", "FLOAT32",
"DIM", VECTOR_DIMENSION,
"DISTANCE_METRIC", "COSINE",
"M", 16,
"EF_CONSTRUCTION", 200
))
.build()
};
// 인덱스 생성[1]
jedis.ftCreate(INDEX_NAME,
FTCreateParams.createParams()
.addPrefix("vector:")
.on(IndexDataType.HASH),
schema
);
logger.info("Redis 벡터 인덱스 생성 완료: {} (차원: {})", INDEX_NAME, VECTOR_DIMENSION);
} catch (Exception e) {
if (e.getMessage() != null && e.getMessage().contains("Index already exists")) {
logger.info("인덱스가 이미 존재합니다: {}", INDEX_NAME);
} else {
logger.error("Redis 벡터 인덱스 생성 중 오류: {}", e.getMessage(), e);
throw new RuntimeException("벡터 인덱스 생성 실패", e);
}
}
}
/**
* 기존 인덱스를 삭제하고 새로 생성
*/
public void recreateIndex() {
try {
logger.info("기존 인덱스 삭제 시작: {}", INDEX_NAME);
try {
jedis.ftDropIndex(INDEX_NAME);
logger.info("기존 인덱스 삭제 완료: {}", INDEX_NAME);
} catch (JedisDataException e) {
logger.debug("인덱스 삭제 중 오류 (정상): {}", e.getMessage());
}
// 새 인덱스 생성
initializeIndexIfNotExists();
} catch (Exception e) {
logger.error("인덱스 재생성 중 오류: {}", e.getMessage(), e);
}
}
/**
* 모든 벡터 데이터 삭제
*/
public void clearAllData() {
try {
logger.info("모든 벡터 데이터 삭제 시작");
Set<String> keys = jedis.keys("vector:*");
if (keys != null && !keys.isEmpty()) {
jedis.del(keys.toArray(new String[0]));
logger.info("{}개의 벡터 데이터 삭제 완료", keys.size());
} else {
logger.info("삭제할 벡터 데이터가 없습니다");
}
} catch (Exception e) {
logger.error("데이터 삭제 중 오류: {}", e.getMessage(), e);
}
}
/**
* 벡터와 원본 텍스트를 Redis에 저장
*/
public void save(String id, float[] vector, String rawText) {
try {
String key = "vector:" + id;
byte[] vectorBytes = toBytes(vector);
// Hash 형태로 저장
jedis.hset(key.getBytes(), Map.of(
"text".getBytes(), rawText.getBytes(StandardCharsets.UTF_8),
"vector".getBytes(), vectorBytes
));
logger.debug("벡터 저장 완료: {} (텍스트: {})", id,
rawText.substring(0, Math.min(50, rawText.length())) + "...");
} catch (Exception e) {
logger.error("벡터 저장 중 오류: {}", e.getMessage(), e);
throw new RuntimeException("벡터 저장 실패: " + id, e);
}
}
/**
* 벡터 유사도 검색
*/
public SearchResult searchSimilarVectors(float[] queryVector, int k) {
try {
byte[] queryBytes = toBytes(queryVector);
// KNN 쿼리 생성[4]
Query query = new Query("*=>[KNN $K @vector $BLOB AS vector_score]")
.addParam("K", k)
.addParam("BLOB", queryBytes)
.setSortBy("vector_score", false)
.limit(0, k)
.returnFields("text", "vector_score")
.dialect(2);
SearchResult result = jedis.ftSearch(INDEX_NAME, query);
logger.debug("벡터 검색 완료: {}개 결과", result.getDocuments().size());
return result;
} catch (Exception e) {
logger.error("벡터 검색 중 오류: {}", e.getMessage(), e);
throw new RuntimeException("벡터 검색 실패", e);
}
}
/**
* 하이브리드 검색 (텍스트 필터 + 벡터 검색)
*/
public SearchResult hybridSearch(String textFilter, float[] queryVector, int k) {
try {
byte[] queryBytes = toBytes(queryVector);
// 하이브리드 쿼리[2]
Query query = new Query(String.format("(@text:%s)=>[KNN $K @vector $BLOB AS vector_score]", textFilter))
.addParam("K", k)
.addParam("BLOB", queryBytes)
.setSortBy("vector_score", false)
.limit(0, k)
.returnFields("text", "vector_score")
.dialect(2);
return jedis.ftSearch(INDEX_NAME, query);
} catch (Exception e) {
logger.error("하이브리드 검색 중 오류: {}", e.getMessage(), e);
throw new RuntimeException("하이브리드 검색 실패", e);
}
}
/**
* float 배열을 바이트 배열로 변환
*/
private byte[] toBytes(float[] vector) {
ByteBuffer buffer = ByteBuffer.allocate(vector.length * 4);
buffer.order(ByteOrder.LITTLE_ENDIAN); // 추가
for (float value : vector) {
buffer.putFloat(value);
}
return buffer.array();
}
/**
* 저장된 벡터 개수 조회
*/
public long getVectorCount() {
try {
Map<String, Object> info = jedis.ftInfo(INDEX_NAME);
Object numDocsObj = info.get("num_docs");
if (numDocsObj != null) {
return Long.parseLong(numDocsObj.toString());
}
return 0L;
} catch (Exception e) {
logger.error("벡터 개수 조회 중 오류: {}", e.getMessage());
return 0L;
}
}
}
JedisVectorSearchService
저장한 백터들을 대상으로 하여 같은 인덱스에서 K개만큼 유사도가 가장 높은 쿼리들을 뽑아내는 작업을 합니다. 이걸 전공에서 자주보았던 KNN이라고 하죠.
여기서도 저장할 때와 동시에 toBytes() 메서드에서 Little endian 영역을 주의하시면 됩니다.
public class JedisVectorSearchService {
private static final Logger logger = LoggerFactory.getLogger(JedisVectorSearchService.class);
private static final String INDEX_NAME = "vector_index";
private final UnifiedJedis jedis;
public JedisVectorSearchService(UnifiedJedis jedis) {
this.jedis = jedis;
}
/**
* 벡터 유사도 검색 (Top-K)
*
* @param queryVector 검색할 벡터
* @param topK 반환할 결과 개수
* @return 유사한 벡터들의 목록
*/
public List<RedisResult> searchTopK(float[] queryVector, int topK) {
try {
logger.info("벡터 검색 시작: Top-{}", topK);
byte[] vectorBytes = toBytes(queryVector);
Query query = new Query("*=>[KNN " + topK + " @vector $BLOB AS score]")
.addParam("BLOB", vectorBytes)
.setSortBy("score", true)
.returnFields("__key", "score", "text")
.dialect(2);
SearchResult result = jedis.ftSearch(INDEX_NAME, query);
List<RedisResult> results = new ArrayList<>();
for (var doc : result.getDocuments()) {
String key = doc.getId();
String text = doc.getString("text");
String scoreStr = doc.getString("score");
float score = Float.parseFloat(scoreStr);
// 키에서 ID 추출 (vector: 제거)
String id = key.replace("vector:", "");
RedisResult redisResult = new RedisResult(id, text, score);
results.add(redisResult);
logger.debug("검색 결과: {} (점수: {})", text, score);
}
logger.info("벡터 검색 완료: {}개 결과", results.size());
return results;
} catch (Exception e) {
logger.error("벡터 검색 중 오류: {}", e.getMessage(), e);
throw new RuntimeException("벡터 검색 실패", e);
}
}
/**
* 텍스트 필터와 함께 하는 하이브리드 검색
*/
public List<RedisResult> hybridSearch(String textFilter, float[] queryVector, int topK) {
try {
logger.info("하이브리드 검색 시작: '{}', Top-{}", textFilter, topK);
byte[] vectorBytes = toBytes(queryVector);
Query query = new Query(String.format("(@text:%s)=>[KNN %d @vector $BLOB AS score]", textFilter, topK))
.addParam("BLOB", vectorBytes)
.setSortBy("score", true)
.returnFields("__key", "score", "text")
.dialect(2);
SearchResult result = jedis.ftSearch(INDEX_NAME, query);
List<RedisResult> results = new ArrayList<>();
for (var doc : result.getDocuments()) {
String key = doc.getId();
String text = doc.getString("text");
String scoreStr = doc.getString("score");
float score = Float.parseFloat(scoreStr);
String id = key.replace("vector:", "");
RedisResult redisResult = new RedisResult(id, text, score);
results.add(redisResult);
}
logger.info("하이브리드 검색 완료: {}개 결과", results.size());
return results;
} catch (Exception e) {
logger.error("하이브리드 검색 중 오류: {}", e.getMessage(), e);
throw new RuntimeException("하이브리드 검색 실패", e);
}
}
/**
* 검색 인덱스 상태 확인
*/
public boolean isIndexAvailable() {
try {
jedis.ftInfo(INDEX_NAME);
return true;
} catch (JedisDataException e) {
logger.warn("인덱스가 존재하지 않습니다: {}", INDEX_NAME);
return false;
}
}
/**
* float 배열을 바이트 배열로 변환 (ByteBuffer 사용)
*/
private byte[] toBytes(float[] vector) {
ByteBuffer buffer = ByteBuffer.allocate(vector.length * 4);
buffer.order(ByteOrder.LITTLE_ENDIAN); // 핵심: Little Endian 설정
for (float v : vector) {
buffer.putFloat(v);
}
return buffer.array();
}
}
이제 Redis Search를 사용해서 저장하고 상위 k개의 유사도 높은 쿼리 가져오는 건 가능해졌습니다.
하지만 이 실험에서 가장 중요한 사안은 그래서
이 유사도가 정말 의미가 있는가?
입니다.
그래서 아래 Cosine 유사도와 Dot product 유사도 검증을 위한 평가기(Evaluator) 클래스를 추가로 만들었습니다.
CosineSimilarityEvaluator
코사인 유사도는 전공에서 질리도록 듣던 자주 사용되는 유사도 알고리즘인데, 쉽게 설명하면 단어들을 숫자로 바꾼 뒤, 두 숫자 줄 사이의 각도를 재서 각도가 작을수록 더 비슷하다고 하는 알고리즘입니다.
public class CosineSimilarityEvaluator {
private static final Logger logger = LoggerFactory.getLogger(CosineSimilarityEvaluator.class);
private final EmbeddingClient embeddingClient;
public CosineSimilarityEvaluator(EmbeddingClient embeddingClient) {
this.embeddingClient = embeddingClient;
}
/**
* 두 텍스트 간의 Cosine 유사도 계산
*
* @param query1 첫 번째 텍스트
* @param query2 두 번째 텍스트
* @return Cosine 유사도 점수 (0.0 ~ 1.0)
*/
public double compare(String query1, String query2) {
try {
logger.debug("Cosine 유사도 계산: '{}' vs '{}'",
query1.substring(0, Math.min(30, query1.length())),
query2.substring(0, Math.min(30, query2.length())));
// 두 텍스트를 벡터로 변환
float[] vector1 = embeddingClient.embed(query1);
float[] vector2 = embeddingClient.embed(query2);
// Cosine 유사도 계산
double similarity = calculateCosineSimilarity(vector1, vector2);
logger.debug("Cosine 유사도 결과: {:.4f}", similarity);
return similarity;
} catch (Exception e) {
logger.error("Cosine 유사도 계산 중 오류: {}", e.getMessage(), e);
return 0.0;
}
}
/**
* 두 벡터 간의 Cosine 유사도 계산
*
* @param vector1 첫 번째 벡터
* @param vector2 두 번째 벡터
* @return Cosine 유사도 점수
*/
private double calculateCosineSimilarity(float[] vector1, float[] vector2) {
if (vector1.length != vector2.length) {
throw new IllegalArgumentException("벡터 차원이 일치하지 않습니다: " +
vector1.length + " vs " + vector2.length);
}
double dotProduct = 0.0;
double norm1 = 0.0;
double norm2 = 0.0;
for (int i = 0; i < vector1.length; i++) {
dotProduct += vector1[i] * vector2[i];
norm1 += vector1[i] * vector1[i];
norm2 += vector2[i] * vector2[i];
}
norm1 = Math.sqrt(norm1);
norm2 = Math.sqrt(norm2);
if (norm1 == 0.0 || norm2 == 0.0) {
return 0.0;
}
return dotProduct / (norm1 * norm2);
}
}
DotProductSimilarityEvaluator
Dot Product 유사도는 두 벡터가 같은 방향으로 얼마나 강하게 뻗어 있는지를 수치로 보여줍니다.
예를 들어 "서울 회원 수 알려줘"와 "서울 지역 유저 수 보여줘"는 의미가 비슷하고 길이도 비슷하므로, 내적(dot product) 값이 크게 나옵니다.
public class DotProductSimilarityEvaluator {
private static final Logger logger = LoggerFactory.getLogger(DotProductSimilarityEvaluator.class);
private final EmbeddingClient embeddingClient;
public DotProductSimilarityEvaluator(EmbeddingClient embeddingClient) {
this.embeddingClient = embeddingClient;
}
/**
* 두 텍스트 간의 Dot Product 유사도 계산
*
* @param query1 첫 번째 텍스트
* @param query2 두 번째 텍스트
* @return 내적 유사도 점수 (값 범위는 정규화되지 않음)
*/
public double compare(String query1, String query2) {
try {
logger.debug("DotProduct 유사도 계산: '{}' vs '{}'",
query1.substring(0, Math.min(30, query1.length())),
query2.substring(0, Math.min(30, query2.length())));
float[] vector1 = embeddingClient.embed(query1);
float[] vector2 = embeddingClient.embed(query2);
double similarity = calculateDotProduct(vector1, vector2);
logger.debug("DotProduct 유사도 결과: {}", similarity);
return similarity;
} catch (Exception e) {
logger.error("DotProduct 유사도 계산 중 오류: {}", e.getMessage(), e);
return 0.0;
}
}
private double calculateDotProduct(float[] v1, float[] v2) {
if (v1.length != v2.length) {
throw new IllegalArgumentException("벡터 차원이 일치하지 않습니다: " + v1.length + " vs " + v2.length);
}
double dot = 0.0;
for (int i = 0; i < v1.length; i++) {
dot += v1[i] * v2[i];
}
return dot;
}
}
자, 이제 다 준비가 되었습니다.
이제 로그로 한번 확인해보겠습니다. 사실 처음엔 36개로 테스트해봤는데, 데이터 수가 적으면 적을수록 신뢰도가 낮기 때문에 과감하게 10배인 370개로 실험해봤습니다.
로그로 보는 실험
일단, 위 코드에서도 그렇듯이 로그로 하나하나 확인할 수 있게 달아놨습니다.
우선 인덱스를 생성하고 거기에 백터를 저장합니다. 위 370개를 저장해놓습니다.

이렇게 370개가 저장될때까지 지루하게 기다립니다.
...
...
그리고 사용자 입력창을 뜨게 했고, 그에 따라 제가 질의를 해봅니다. 캐싱이 되었으면 하는 질의를 해보는거죠.

자 이제 질의를 입력해봅니다.
가장 높은 매출을 기록했던 월은?

이미지를 보면 맨 아래 '개별 결과'에 top 5위까지의 redis search의 백터 검색으로 나온 유사도가 높은 결과물입니다.
그리고 각각에 대해 코사인 유사도와 dotProduct 유사도를 별도로 검증해봤습니다.
그래서 결론적으로!
신뢰도는 0.58이라 보통(0.6부터 높다고 해서ㅋㅋ)이라고 하네요. (기준 세우기 나름이라)
하지만 오차 범위가 그렇게 크지 않다는 점에서 유의미하다고 봅니다.
그래서 한번더 해봤습니다.
카테고리 매출의 순위

오 이건 그래도 redis search의 결과가 평가기를 통해 나온 유사도랑 확인해보니 0.75로 굉장히 유사도가 높은 것을 볼 수 있습니다.
결론
모든 질의가 항상 깔끔하게 매칭되진 않았습니다.
"가장 높은 매출을 기록했던 월은?"
이라는 질의에서도 보았듯이 상위 결과들의 Cosine 유사도가 약 0.58 수준으로, 보통 이하의 일관성을 보였습니다.
이러한 경우, 캐시 적중을 시도하더라도 LLM을 호출하는 편이 더 안정적일 수 있습니다.
적중 기준선 설정
실제로 서비스를 운영하려면 "이 정도 유사도 이상이면 캐시 재사용"이라는 기준이 있어야 합니다.
제가 실험한 환경에서는 아래와 같은 기준이 유효했습니다.
| 유사도 | 점수판단 | 기준조치 |
| 0.75 이상 | 거의 동일한 요청 | 캐시 즉시 재사용 |
| 0.5 ~ 0.75 | 비슷하지만 불완전 | 사용자의 추가 확인 필요 |
| 0.5 미만 | 유사도 불충분 | LLM 재호출 + 캐시 저장 시도 |
실무에서 어떻게 활용할 수 있을까?
이 실험은 단순한 Redis 벡터 검색을 넘어, 아래와 같은 현실적인 시사점을 제공합니다.
1. 비즈니스 데이터 질의에 적합
단순한 텍스트 응답보다는 SQL 기반 데이터 질의 결과 캐싱에 적합합니다.
자연어 요청은 다르지만, 실제로는 같은 SQL을 생성하는 경우가 많기 때문입니다.
2. 비용 절감 효과
LLM 호출은 단가가 높은 연산입니다.
특히 GPT-4 Turbo나 자체 호스팅 LLM을 사용한다면 자연어 요청을 필터링해서 캐시로 우회할 수 있는 구조는 서버 비용에 직접적인 영향을 줄 수 있습니다.
3. 한국어에서도 일정 수준 유효
Ko-SBERT가 아닌 OpenAI의 영어 모델(text-embedding-3-small)을 사용했음에도 의미 있는 유사도 결과가 확인되었습니다.
이는 향후 Ko-SBERT, KLUE 기반 모델로 대체한다면 보다 정확하고 일관된 캐싱 전략으로 확장 가능하다는 뜻이기도 합니다.
마무리: Semantic Cache, 단순하지만 강력한 접근
이번 실험을 통해 알게 된 사실은 간단합니다.
자연어는 달라도, 의미는 같을 수 있다.
그리고 의미가 같다면, 굳이 LLM을 매번 호출하지 않아도 됩니다.
Redis 벡터 인덱싱, KNN 검색, 평가기 기반 판단 등
이런 구조들을 적절히 조합하면, 기존 캐시의 한계를 넘어서는 새로운 캐싱 전략을 구축할 수 있습니다.
TODO 및 한계점
- 현재는 text-embedding-3-small 기반 실험이므로 한국어 특화 임베딩 모델 교체 실험 필요
- 실시간 사용자 피드백 기반 유사도 기준 동적 조정 시나리오 실험 필요
- 고도화 방향: LLM 결과 자체를 요약/클러스터링하여 캐시 키로 변환하는 방식 실험 예정
마무리
단순한 Redis 캐시는 “키 = 값”에 기반한 구조였습니다.
하지만 시맨틱 캐시는 “의미 = 값”이라는 전혀 다른 차원의 가능성을 열어줍니다.
이 글에서 살펴본 것처럼 기존의 캐싱 한계를 넘어, 자연어 기반 인터페이스에서도 효율적이고 경제적인 결과 재사용 구조를 만드는 것이 충분히 가능합니다.
특히 SaaS 구조나 LLM API 사용이 잦은 시스템이라면 Semantic Caching은 반드시 고려해야 할 전략 중 하나일겁니다.
'개발 > Saas 개발로그' 카테고리의 다른 글
| [Saas개발로그] JVM 2GB로 1,000만 행 Excel 처리는 가능한가? 직접 실험해봤습니다 (0) | 2025.06.14 |
|---|---|
| [Saas 개발로그] Redis Streams~ 너도 kafka처럼 복구해봐 (1) | 2025.06.13 |
| [Saas 개발로그] Redis로 Kafka 없이 유실 없는 메시지 큐를 만들 수 있을까? (1) | 2025.06.12 |
| [SaaS 개발로그] Redis Pub/Sub이면 충분한 줄 알았습니다 (0) | 2025.06.11 |