벡터 재활용
지난 시간
저번 시간에 벡터를 재사용한다고 했었죠.
우리가 매번 “경제”를 검색할때 마다, API를 호출해서, 요청 보내고, 받고 하면 느리겠죠?
실제로 측정에서도 API 호출 시간이 제일 길었고요.
이유는 간단합니다. 일단 api를 호출안해도 되고, 또 호출 안하니깐 돈이 안들죠.
그러면 어떻게?
일단 2가지 방법을 고려해봤습니다.
첫번째는 램처럼 애플리케이션 단에다가 저장해버리는거고, 두번째는 하드디스크에 저장하듯, DB에다가 저장해버리는거에요. 장단점도 비슷합니다. 애플리케이션 메모리에다가 저장하면 빨라요. DB를 드나들지 않고 바로 찾아주니깐요.
근데, 램이랑 똑같이 서버 재부팅 하면 다 사라집니다. DB에다가 저장하면 재부팅해도 그대로지만, DB왔다갔다 하는 시간이 걸리고요.
그래서 저는 두가지 다 써보려고 합니다.
메모리 -> 미스 -> DB -> 미스 -> Voyage API
이런 방식으로 구현해 볼까 합니다.
캐시 히트 와 미스란 뭘까요? 캐시 히트(찾음), 캐시 미스(못찾음) 위에서 봤듯 히트가 많을 수록 성능이 빨라지겠죠. 단계가 줄어드니깐요.
일단 먼저 DB 캐시를 만들어 보려고 합니다.
정규화
ai 투자 == AI 투자 == Ai 투자 == ai투자 == AI투자 == Ai투자
우리가 보기에는 다 똑같잖아요? 근데 컴퓨터는 각각 다른 걸로 봅니다.
그래서 해줘야 할건 정규화라고 똑같이 다듬어 줄건데,
NFC 정규화 → 앞뒤 공백 제거 → 연속 공백 한 칸으로 → 소문자 이런 순서로 할겁니다.
코드로 보면
1
2
3
4
Normalizer.normalize(query, Normalizer.Form.NFC)
.trim()
.replaceAll("\\s+", " ")
.toLowerCase();
이렇습니다.
NFC 정규화?
NFC 정규화는 뭘까요? 우선 한글을 저장 하는 방식에는 두가지가 있습니다.
일단 “뉴스”라는 단어를 저장한다고 칩시다.
| 저장형태 | “뉴스”.length() | |
|---|---|---|
| NFC (완성형) | 뉴 + 스 | 2 |
| NFD (조합형) | ㄴㅠ + ㅅㅡ | 4 |
화면에는 똑같이 뉴스로 보이는데, 자바가 보면 길이도 다르고 문자열로 다르다고 판단합니다.
맥에서 파일 시스템이 NFD를 쓰는데, 파일명이나 텍스트를 복사해서 검색창에 넣으면 NFD로 들어올 수 있어요.
그러면 DB에 이미 “뉴스”가 있는데, “뉴스”를 못 찾는 참사가 발생합니다.
심지어 콘솔로 봐도 문자열을 “뉴스” 그대로 나와서 엥? 소리가 나올 수 있습니다. 그래서 NFC로 먼저 통일하고 시작하는게 좋습니다.
DB 벡터 캐시 테이블 구조
| 컬럼 | 용도 |
|---|---|
| id | PK (대리키) |
| 정규화된 검색어 | 캐시 키 |
| 모델 이름 | 캐시 키 |
| 벡터 | 캐시 값 |
| 생성 시각 | 운영/디버깅용 |
검색어, 모델에 PK가 아닌 복합 유니크 제약을 걸었는데, 이유가 있습니다.
voyage-5 라는 모델이 만약 새로 나와서, 그걸로 바꾸고 검색을 한다고 가정을 해봅시다.
기존에 있던 뉴스들은 새 모델로 다시 임베딩이 되는데, 문제는 캐시 테이블에는 이전 모델이 만든 검색어 벡터가 그대로 있습니다.
그래서 다른 벡터끼리 유사도를 계산하게 되는데, 이게 무슨 소리냐고요?
이전 모델에서 “뉴스”를 검색하면 벡터값이 {1.0f , 1.0f}가 나왔는데, 새 모델에서는 “뉴스” 검색하니 벡터값이 {0.0f , -0.1f}가 나온거에요. 그래서 만약 캐시 히트가 나면 {0.0f , -0.1f}에 해당하는 값들을 가져와야 하는데, {1.0f , 1.0f}을 가져와 버리니, 이상한 검색 결과가 나와버립니다.
그래서 키에다가 모델 이름을 넣어서, 모델이 바뀌면, DB에서 아, 얘는 모델이 다르네? 하면서 미스가 나고, 새 모델로 다시 캐시값을 만들어서 넣어줘요.
유니크 제약은 두 요청이 동시에 미스를 내버리면, 양쪽 다 저장을 하려고 합니다.
제약이 없으면 같은 검색어 행이 두개나 생기겠죠?
그리고 결과가 2개라 Optional이 담지 못하고 터집니다. 그래서 하나만 들어가게 제약을 둔겁니다.
왜 PK로 안 씀?? (클릭하여 펼치기)
PK로 안한 이유는 코드가 좀 더 단순해서 사용했습니다. (검색어, 모델)이 복합 PK가 이론상 정답이지만, JPA에서 쓰려면 별도 클래스를 만들고 하기 때문에 대리키 + 유니크 제약으로 같은 보장을 더 단순한 코드로 그냥 했습니다.
…
구현
어떻게 할까 머리를 굴려 보다가, 플래그를 세워서 캐시 히트/미스로 하려고 했는데, AI한테 뺀찌 먹었습니다.
방향은 맞는데, 자바 클래스가 있다고 합니다. Optional<> 이라고. 값과 값이 있음/없음을 같이 전달하는 클래스 입니다.
제가 하려던 flag와 비슷하죠. 값이 있니? 없니? 로 물어보고, 있으면 이렇게, 없으면 저렇게를 한번 거치고 값을 뽑아야 합니다.
메서드가 QueryVectorCache를 반환한다고 합시다. 그러면 QueryVectorCache를 부르는 쪽은 항상 값이 있다고 믿습니다. 캐시 미스가 나면? null을 주는데 여기서 문제가 생깁니다.
조용히 퍼져요. null값을 들고 이리저리 돌아다니다가, null이 있으면 문제가 생기는 곳에서 터집니다.
이러면 범인이 사건 현장에 나타나지 않아서, 검거가 힘들겠죠.
근데 Optional은 값과 있음/없음을 같이 전달해 줍니다. 값이 있으면 무조건 이렇게/ 없으면 저렇게를 해야합니다. 값의 유무를 확인하는걸 건너뛰는게 구조적으로 어렵습니다. 플래그는 쉬운데 말이죠.
1
2
3
4
class FlagTest {
boolean found;
QueryVectorCache value;
}
1
2
FlagTest r = search(...);
r.value.getEmbedding(); // found를 안 봤는데도 통과
이렇게요. 그래서 Optional를 사용해서 미연에 방지하는겁니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public float[] getVector(String query) {
String norm = normalizeQuery(query);
Optional<QueryVectorCache> cached = queryVectorCacheRepository.findByNormalizedQueryAndModel(norm, voyageModel);
float[] normTovector;
if (cached.isPresent()){
QueryVectorCache cache = cached.get();
return cache.getEmbedding();
} else {
normTovector = embeddingClient.embedQuery(norm);
QueryVectorCache cache = new QueryVectorCache(norm, voyageModel, normTovector, LocalDateTime.now());
queryVectorCacheRepository.save(cache);
}
return normTovector;
}
이렇게, 1단계 DB에서 구현이 끝났습니다. 자주 쓰는 검색어들 미리 선별해서 넣어둬도 괜찮지 않을까 생각이 드네요.
알아보니깐 이게 캐시 워밍이라고 하네요. 제가 천재같은 발상을 한줄 알았는데, 아니었습니다. 아쉽네요.
아래 측정 값은, 재측정에서 달라졌습니다.재측정
측정
| 구분 | 중앙값 | p95 |
|---|---|---|
| 캐시 도입 전 (지난 글, 300건) | 196.8 ms | 233.8 ms |
| 캐시 히트 | 28.2 ms | 44.8 ms |
| 캐시 미스 | 286.0 ms | 323.3 ms |
캐시 히트가 되면 많이 빨라집니다.
조건이 좀 많긴 합니다. 전건이 캐시 히트가 났고, HTML 자체가 아무것도 없기도 하고, 받는 데이터 자체도 10건이 전부라 응답 자체가 2 ~ 5kb밖에 되지 않고, 거기다가 바로 옆에 랩탑한테 보내는 로컬 네트워크 환경이고, 나중에 JS나 이것저것 프론트에 넣고 좀 멀리 있는 서버에 호스팅 하고 그러면 더 느려질 수 있겠죠.
다만 중복되지 않도록, 이상한 무작위 검색어 asbonesd이런 검색어 한 550개를 AI 시켜서 검색시켰는데, 토큰 수가 검색어랑은 좀 달라서, API 시간 차이가 미세하게 있을 수 있습니다.
근데 이상하지 않나요? 캐시 미스나니깐, 더 느려지네요? 일이 늘어나서 느려질 수 있다고 느끼긴 했는데, 막상 거진 100ms 가량이 늘어나니깐, 뭔가 잘못된거 같아서 API 시간 측정을 다시 해봤습니다.
550번의 API 호출 시간 중앙값이 197.1ms 정도 나왔습니다. 저번 시간에 300건 측정했을때 대략 197.8 ms 나왔는데, 사실상 같다고 봐도 무방하겠죠.
| 구간 | 소요 시간 |
|---|---|
| 정규화 + 캐시 조회 | 12.1 ms |
| Voyage API 왕복 | 197.1 ms |
| 캐시 저장 | 77.7 ms |
- 측정 지점: NewsService.search() 진입 ~ getVector() 반환 / ~ 결과 변환 완료
- 참고 : 중앙값은 더할 수 있는 값이 아니라, 구간별 중앙값의 합은 전체 중앙값과 정확히 일치하지 않습니다. 대략적인 비중을 보는 용도로만 봐주세요.
그러면 진짜 어디서 늘었냐? 캐시 저장이에요.
그런데 생각해보면, 여러분은 캐시 저장되는걸 기다릴 필요가 없잖아요?
벡터값은 이미 가지고 있으니, DB에 검색을 해서 먼저 검색 결과를 돌려준 다음, 벡터를 저장해도 별 상관 없겠죠? 이거는 먼 미래에 개선 사항으로 남겨뒀습니다.
다음 시간
캐시 히트율에 따라서 효율을 계산할 수 있어서 해봤는데, 평균 응답 = 캐시 히트 × 히트율 + 캐시 미스 × (1 − 히트율) 이니깐,
1
2
3
평균 응답 = 28.2 × 히트율 + 286.0 × (1 − 히트율)
286.0 − 257.8 × 히트율 = 196.8
히트율 = 89.2 ÷ 257.8 = 0.346
이렇게 되겠네요. 대략 34.6%가 손익 분기점입니다. 그렇다면 캐시 히트를 올리면 올릴수록 더 효율이 좋아지겠죠?
NewsService는 getVector()를 부를 뿐, 그 벡터가 캐시에서 왔는지 API에서 왔는지 모릅니다.
다음 시간에는 메모리에다가 한번 벡터를 저장해 봅시다.