
우리 서비스에는 LLM을 활용한 법률 챗봇이 있다.
법률 도메인이라는 특성상 질문 유형이 어느 정도 한정적일 것이라 예상했지만, 실제 운영 데이터를 확인해보니 유사한 질문과 답변이 중복으로 여러 개 저장되고 있었고 이로 인해 LLM 호출 비용도 꾸준히 증가하고 있었다.
초기 MVP 단계에서는 매번 LLM을 호출해 답변을 생성하는 방식이었지만,
사용자 유입이 증가할 경우 비용 부담이 커지고 응답 속도도 느려질 것이 명확했다.
따라서, 불필요한 LLM 호출을 줄이고 빠른 응답을 제공하기 위해 "캐싱"을 도입하기로 결정했다.
단순한 문자열 기반의 캐싱이 아니라, 질문을 벡터화하여 의미적으로 유사한 질문을 빠르게 찾아주는 방식을 선택했다.
이 문제를 해결하기 위해 다음과 같은 검색 전략을 설계했다.
우리는 단순한 문자열 기반 캐싱이 아니라, 의미 기반 검색을 수행해야 했다.
이를 위해 임베딩 벡터를 활용한 벡터DB 기반 캐싱을 설계했다.
mxbai-embed-large 모델을 선택했는가?✔️ 초기에는 nomic-embed-text와 비교 테스트를 진행했으나,
✔️ 법률 도메인 특성상 보다 정확한 유사도 검색이 필요하여 mxbai-embed-large 모델을 선택했다.
✔️ 또한, 유사도 임계치를 0.9로 높게 설정하여 정확도가 높은 결과만 반환하도록 최적화했다.
// 질문을 임베딩하여 Redis에 저장
public void saveAiChat(String query, String response) {
String normalizedQuery = normalizeTextForRedis(query);
String queryHash = hashQuery(query);
List<Double> embedding = generateEmbeddingAsList(normalizedQuery);
byte[] queryVectorBinary = convertToFloat32Binary(embedding);
String base64Vector = Base64.getEncoder().encodeToString(queryVectorBinary);
String key = "ai_chat:" + System.currentTimeMillis();
Map<String, String> chatData = new HashMap<>();
chatData.put("query", normalizedQuery);
chatData.put("query_hash", queryHash);
chatData.put("response", response);
chatData.put("query_vector", base64Vector);
redisModulesCommands.hset(key, chatData);
redisModulesCommands.expire(key, 86400); // 24시간 TTL 설정
}
✔ 주요 포인트
✔️ 질문을 임베딩하여 Redis에 저장
✔️ query_vector 필드를 활용한 벡터 기반 검색
✔️ MD5 해시 기반의 빠른 문자열 매칭 검색도 함께 적용
CREATE TABLE llm_chat_result (
id SERIAL PRIMARY KEY,
query TEXT,
response TEXT,
query_embedding VECTOR(1024) -- 벡터 저장
);
CREATE INDEX ON llm_chat_result USING hnsw (query_embedding vector_l2_ops);
✔ 주요 포인트
✔️ gvector 확장을 활용하여 벡터 저장
✔️ HNSW 인덱스를 적용하여 검색 최적화
✔️ 벡터 유사도 검색을 통해 기존 질문 재활용
public String findAiChat(String query) {
// 1. RedisSearch에서 검색
Optional<Map<String, String>> redisResult = findSimilarByVector(query);
if (redisResult.isPresent()) {
return redisResult.get().get("response");
}
// 2. PostgreSQL에서 검색
Optional<String> dbResult = findInPostgres(query);
if (dbResult.isPresent()) {
return dbResult.get();
}
// 3. LLM 호출
return callLlmAndCache(query);
}

결론:
✔️ 즉시 응답 가능 (Redis 캐싱 덕분에 100ms 내 응답)
✔️ 비용 절감 (반복되는 질문에 대한 LLM 호출 방지)
✔️ 확장성 강화 (pgvector를 활용한 유사 질문 검색 최적화)
✔️ 법률 도메인 특성상 질문이 유사하게 반복되므로, 벡터 기반 캐싱이 효과적
✔️ 임베딩 모델을 적절히 선택하는 것이 중요 (mxbai-embed-large vs nomic-embed-text)
✔️ 향후 FAISS/Pinecone 같은 대형 벡터 스토어 도입을 고려할 수 있음
이번 개선 작업을 통해,
LLM 챗봇의 비용을 절감하고, 응답 속도를 획기적으로 줄이는 데 성공했다.
이제 남은 과제는 더 정밀한 임베딩 모델 선택과, 벡터DB의 확장성 테스트가 될 것이다.



(아니 벨로그 마크다운 왜이래.... 이모티콘을 쓰게 만드네...)