이번 시간에는 질문이 비슷하면 저장해 둔 답을 돌려주는 시맨틱 캐시에서, "얼마나 비슷해야 같은 질문인가"를 임베딩 모델 e5-small과 Redis로 측정하며 배워가는 시간입니다.
같은 뜻 질문과 말만 비슷한 다른 뜻 질문을 직접 만들어 기준값마다 적중과 잘못된 적중을 세고, Redis 라이브러리의 기본값으로 조회하면 어떻게 되는지도 재 볼게요.
LLM 앞에 질문 캐시를 붙이면 같은 질문이 다시 들어왔을 때 모델을 부르지 않고 저장해 둔 답을 바로 돌려줄 수 있어요. 그런데 사용자는 같은 질문을 같은 글자로 묻지 않죠.
그래서 질문을 임베딩해 "충분히 비슷하면 같은 질문"으로 보는 시맨틱 캐시를 씁니다. 문제는 이 "충분히 비슷하면"이 어디까지냐는 거예요.
예를 들어서 캐시에 "NGINX 설정에 keepalive를 쓰지 않았는데도 upstream 연결이 재사용되나요?"라는 질문과 그 답이 들어 있다고 해볼게요. 여기에 다음 질문이 들어옵니다.
질문: NGINX 설정에 keepalive를 썼는데도 upstream 연결이 재사용되지 않는 이유는?
가장 가까운 캐시 질문: NGINX 설정에 keepalive를 쓰지 않았는데도 upstream 연결이 재사용되나요?
코사인 유사도: 0.994
돌려준 답: 설정 파일에 `keepalive`가 한 줄도 없는데 연결을 재사용한 것입니다.
질문은 정반대인데 유사도는 0.994예요. 이 실습의 같은 뜻 질문 60개 가운데 이보다 높은 것은 하나도 없었습니다.
실습 준비
이 글의 명령은 모두 실습 코드의 semantic-cache/ 디렉터리에서 실행합니다.
compose.yaml: Redis 8.10, Qdrant 1.19, Python 클라이언트 컨테이너 정의requirements.txt: 클라이언트 패키지sc.py:info,embed,dist,sweep,rvl,stores,lat등 실습 명령을 모은 스크립트(stores,lat는 이 글에서는 쓰지 않아요)seeds.jsonl: 캐시 질문 30개pairs.jsonl: 들어온 질문 158개
Redis, Qdrant, Python 클라이언트 컨테이너를 띄울게요. Redis는 redis:8.10.2 이미지를 씁니다. Qdrant는 함께 뜨지만 이 글에서는 쓰지 않아요.
Redis 8부터는 공식 이미지에 검색과 벡터 인덱스 기능(Redis Query Engine)이 들어 있어요. 그래서 따로 모듈을 붙이지 않아도 FT.CREATE로 벡터 인덱스를 만들 수 있습니다.
패키지 버전은 requirements.txt에 고정했어요. 임베딩 패키지와 함께 Redis 클라이언트 redis, Redis 공식 벡터 라이브러리 redisvl도 넣었습니다.
실습 코드는 sc.py 하나에 모았고, python sc.py <명령> 형태로 실행해요. 파일 끝의 두 줄이 첫 인자와 이름이 같은 함수를 찾아 나머지 인자를 넘겨 부르는 구조예요. 예를 들어 python sc.py dist e5s는 dist("e5s")를 실행합니다(e5s는 아래에서 정하는 모델 키입니다).
처음 실행할 info는 Redis에 접속해 서버 버전, 올라와 있는 모듈, 만들어 둔 검색 인덱스 목록을 출력해요. 접속 주소는 compose 파일에서 넘긴 REDIS_URL을 씁니다.
명령을 짧게 쓰려고 셸 함수 두 개를 만들어 놓을게요. 아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
dc() { docker compose -p scache "$@"; }
py() { dc exec -T client python sc.py "$@"; }
dc up -d
dc exec -T client pip install --no-cache-dir -q -r requirements.txt
py info
redis_version 8.10.2
module vectorset 1
module search 81001
module timeseries 81001
module bf 81001
module ReJSON 81000
FT._LIST []
INFO server, MODULE LIST, FT._LIST 결과를 보면 8.10.2에 search 모듈이 올라와 있고, 아직 만든 인덱스는 없어요.
질문 쌍 만들기
캐시에 미리 넣어 둘 질문(이하 캐시 질문)은 이 사이트 글 30편에서 한 편에 하나씩 만든 기술 질문 30개예요.
질문마다 그 글에서 답의 근거가 되는 정답 문장(gold_passage)이 붙어 있어서, 이 글에서는 LLM이 만든 답 대신 이 문장을 캐시에 저장할 답으로 씁니다. 이 글은 어느 질문의 답이 나갔는지만 세니까 답 문장이 무엇이든 결과는 같아요. 이 질문 30개를 만든 과정이 더 궁금하면 청크 크기를 비교한 글을 참고하세요.
여기에 들어올 질문 158개(pairs.jsonl)는 직접 썼어요. 종류와 작성 규칙은 이렇습니다.
- 같은 뜻 60개: 캐시 질문마다 2개씩 썼습니다. 하나는 어순, 존댓말, 동의어를 바꾼 것이고, 다른 하나는 "producer retry로 같은 batch가 두 번 오면"처럼 영어 단어를 섞은 것입니다. 정답은 원래 캐시 질문입니다.
- 다른 뜻 58개: 캐시 질문마다 2개씩, 말은 거의 같지만 캐시에 있는 답을 보여 주면 틀린 답이 되는 질문입니다. 네 유형을 고르게 섞었습니다.
- 반대 방향(14개): 켜기와 끄기, 먼저와 나중, 커지는 이유와 줄이는 방법. 예: "Kafka로 이벤트를 먼저 보낸 다음 주문을 DB에 커밋하면 어떤 문제가 생기나요?"
- 숫자나 이름만 다름(16개): 오류 코드, 설정 이름, 설정 값. 예: "binlog_format을 STATEMENT로 두면 Binlog에 무엇이 기록되나요?"
- 대상만 다름(14개): Pub/Sub와 Streams, CPU와 메모리, 메타데이터 락과 행 잠금. 예: "Redis Streams로 채팅 서버를 연결했을 때 구독이 잠깐 끊긴 동안 보낸 메시지는 어떻게 되나요?"
- 묻는 범위가 다름(14개): "왜 생기나요"와 "어떻게 고치나요"처럼 같은 주제의 다른 질문. 예: "Kafka lag 숫자가 계속 커지면 어떻게 줄여야 하나요?"
- 관계없는 질문 30개: CSS, iOS, Git 같은 이 사이트 밖의 개발 질문 15개와 "김치찌개를 맛있게 끓이는 순서는?" 같은 일상 질문 15개입니다.
- 짧은 후속 질문 10개: "그럼 기본값은요?", "그거 끄면 어떻게 돼요?"처럼 앞 대화 없이는 무엇을 묻는지 정해지지 않는 질문입니다.
정답은 이렇게 정했어요.
- 다른 뜻 질문은 원칙적으로 "미스여야 함"입니다. 다만 다른 캐시 질문의 답이 맞는 경우는 그 질문을 정답으로 적었습니다. 예를 들어 "프로듀서가 같은 결제 메시지를 두 번 보내도 브로커에 한 번만 저장되게 하려면?"은 결제 캐시 질문에서 만들었지만, 답은 멱등 프로듀서를 묻는 다른 캐시 질문의 답입니다. 이런 질문이 3개입니다.
- 관계없는 질문과 후속 질문은 모두 "미스여야 함"입니다.
실습 출력에서 seed는 캐시 질문, probe는 들어온 질문을 가리켜요. 종류는 같은 뜻 same, 다른 뜻 diff(반대 방향 opposite, 숫자나 이름만 다름 number, 대상만 다름 target, 묻는 범위가 다름 scope), 관계없음 unrelated, 후속 followup으로 찍힙니다.
실습 1: 유사도 분포 보기
먼저 기본 모델 intfloat/multilingual-e5-small로 볼게요. 모델은 처음 불러올 때 Hugging Face에서 hf 볼륨으로 받아요.
질문끼리 비교하니까 캐시 질문과 들어온 질문 양쪽 모두 query: 접두어를 붙였어요. 임베딩 거리와 정규화가 검색 순위에 주는 영향이 더 궁금하면 임베딩 거리를 다룬 글을 참고하세요. e5-small의 모델 키는 sc.py의 MODELS에 e5s로 정해 두었습니다.
embed는 질문 188개(캐시 질문 30개, 들어온 질문 158개)를 임베딩해 파일로 저장하고, 질문을 하나씩 임베딩하는 시간도 잽니다.
dist는 들어온 질문마다 가장 가까운 캐시 질문과의 코사인 유사도를 구해 종류별 분포를 출력해요. 벡터가 정규화돼 있으니 내적이 곧 코사인입니다. 저장소 없이 numpy로 전부 비교한 값이고, 뒤에서 RedisVL 결과를 이 값과 맞춰 볼 거예요.
py embed e5s
py dist e5s
출력은 다른 뜻 질문 예시 두 줄까지만 옮겼어요(발췌).
e5s intfloat/multilingual-e5-small 384차원 질문 188개 일괄 3.5초 하나씩 중앙값 22.7ms p99 42.9ms
[e5s] 가장 가까운 seed와의 코사인 유사도
same 60개 최소 0.879 10% 0.920 중앙값 0.958 90% 0.988 최대 0.993
diff 58개 최소 0.840 10% 0.899 중앙값 0.943 90% 0.979 최대 0.994
diff/opposite 14개 최소 0.931 10% 0.934 중앙값 0.965 90% 0.990 최대 0.994
diff/number 16개 최소 0.861 10% 0.896 중앙값 0.938 90% 0.958 최대 0.970
diff/target 14개 최소 0.840 10% 0.889 중앙값 0.932 90% 0.965 최대 0.980
diff/scope 14개 최소 0.924 10% 0.939 중앙값 0.951 90% 0.969 최대 0.987
unrelated 30개 최소 0.793 10% 0.797 중앙값 0.820 90% 0.845 최대 0.867
followup 10개 최소 0.816 10% 0.817 중앙값 0.832 90% 0.849 최대 0.862
same 중 가장 가까운 seed가 원래 질문인 것 59/60
0.879 producer retry로 같은 batch가 두 번 오면 broker가 duplicate를 어떻게 막나요? -> seed 4 (원래 seed 27, 유사도 0.867)
same 중앙값(0.958) 이상인 diff 17개
0.994 [opposite] NGINX 설정에 keepalive를 썼는데도 upstream 연결이 재사용되지 않는 이유는? -> seed 13 NGINX 설정에 keepalive를 쓰지 않았는데도 upstream 연결이 재사용되나요?
0.992 [opposite] MySQL에서 잠금 대기 시간 초과 오류가 나면 트랜잭션 전체를 취소하게 하려면 어떻게 하나요? -> seed 30 MySQL에서 잠금 대기 시간 초과 오류가 나면 트랜잭션 전체가 취소되나요?
종류별로 읽어 보면 이렇습니다.
- 관계없는 질문: 0.793~0.867입니다. 이 모델은 관계없는 문장끼리도 유사도가 높게 나오는 편이라, "김치찌개를 맛있게 끓이는 순서는?" 같은 질문이 기술 질문과 0.8을 넘깁니다. 같은 뜻 질문의 최솟값(0.879)보다는 낮습니다.
- 같은 뜻 질문: 중앙값 0.958, 10% 백분위 0.920입니다. 60개 중 59개는 가장 가까운 캐시 질문이 원래 질문이었습니다. 영어를 섞은 "producer retry로..." 하나는 원래 질문(0.867)보다 PostgreSQL VACUUM 질문(0.879)에 더 가까웠습니다.
- 다른 뜻 질문: 중앙값 0.943으로 같은 뜻 질문과 0.015밖에 차이가 나지 않습니다. 최댓값 0.994는 같은 뜻 질문의 최댓값 0.993보다 높습니다. 58개 중 17개가 같은 뜻 질문의 중앙값보다 높습니다.
- 유형별: 반대 방향 유형이 중앙값 0.965로 가장 높고 최솟값도 0.931입니다. 단어 한두 개만 바꾸거나 순서만 바꾼 질문이라 문장 대부분이 겹칩니다. 숫자나 이름만 다른 유형(중앙값 0.938)과 대상만 다른 유형(0.932)이 그다음입니다.
실습 2: 기준값별 적중과 잘못된 적중
이제 기준값을 정해 분류해 볼게요. 들어온 질문 하나는 기준값 t에서 네 가지 중 하나가 됩니다.
- 올바른 적중: 가장 가까운 캐시 질문이 정답이고 유사도가 t 이상
- 잘못된 적중: 유사도가 t 이상인데, 정답이 "미스여야 함"이거나 다른 캐시 질문
- 놓친 적중: 정답 캐시 질문이 있는데 적중하지 못함
- 올바른 미스: 정답이 "미스여야 함"이고 유사도가 t 미만
이 분류는 sc.py의 classify가 맡아요.
sweep은 0.80부터 0.99까지 0.01 간격으로 분류해 전체를 out/sweep-e5s.csv로 남기고, 화면에는 고른 기준값만 출력합니다. 적중률은 같은 뜻 질문 60개 가운데 올바르게 적중한 비율이에요.
py sweep e5s 0.80 0.99 0.85,0.88,0.90,0.92,0.95,0.98
[e5s] 기준값별 결과 (적중률은 같은 뜻 60개 기준)
0.85 적중 59/60 (98.3%) 잘못된 적중 62 (같은 뜻이 다른 seed로 1, 다른 뜻 57, 관계없음 3, 후속 1)
0.88 적중 59/60 (98.3%) 잘못된 적중 56 (같은 뜻이 다른 seed로 0, 다른 뜻 56, 관계없음 0, 후속 0)
0.90 적중 58/60 (96.7%) 잘못된 적중 51 (같은 뜻이 다른 seed로 0, 다른 뜻 51, 관계없음 0, 후속 0)
0.92 적중 53/60 (88.3%) 잘못된 적중 47 (같은 뜻이 다른 seed로 0, 다른 뜻 47, 관계없음 0, 후속 0)
0.95 적중 41/60 (68.3%) 잘못된 적중 26 (같은 뜻이 다른 seed로 0, 다른 뜻 26, 관계없음 0, 후속 0)
0.98 적중 15/60 (25.0%) 잘못된 적중 5 (같은 뜻이 다른 seed로 0, 다른 뜻 5, 관계없음 0, 후속 0)
훑은 범위에서 잘못된 적중이 0인 기준값 없음
잘못 붙는 probe의 최고 유사도 0.9944, 이보다 높은 같은 뜻 적중 0/60
diff/opposite 최고 0.994 NGINX 설정에 keepalive를 썼는데도 upstream 연결이 재사용되지 않는 이유는? -> seed 13
diff/number 최고 0.970 binlog_format을 STATEMENT로 두면 Binlog에 무엇이 기록되나요? -> seed 24
diff/target 최고 0.980 Redis Streams로 채팅 서버를 연결했을 때 구독이 잠깐 끊긴 동안 보낸 메시지는 어떻게 되나요? -> seed 11
diff/scope 최고 0.987 Redis에서 오래 걸리는 명령이 있어도 다른 클라이언트 요청이 느려지지 않게 하려면? -> seed 2
기준값을 내리면 적중률과 잘못된 적중이 함께 올라가요. 출력을 보면 이렇습니다.
- 관계없는 질문과 후속 질문은 기준값으로 걸러집니다. 0.87부터 둘 다 잘못된 적중이 0입니다. 같은 뜻 질문이 다른 캐시 질문에 붙는 경우(앞의 "producer retry..." 하나)도 0.88부터 사라집니다.
- 다른 뜻 질문은 걸러지지 않습니다. 같은 뜻 질문 58개(96.7%)가 적중하는 0.90에서 다른 뜻 질문도 58개 중 51개가 적중합니다. 0.95로 올리면 적중률이 68.3%로 떨어지는데 잘못된 적중은 26개가 남고, 0.98에서도 5개가 남습니다.
- 0.80~0.99 어디에도 잘못된 적중이 0인 기준값은 없습니다. 잘못 붙는 질문의 최고 유사도가 0.9944이고, 이보다 높은 같은 뜻 질문은 0개입니다. 이 질문셋과 이 모델에서는 잘못된 적중을 0으로 만드는 기준값이 곧 적중률 0인 기준값입니다.
그러니 "유사도 0.9 이상이면 같은 질문으로 봐도 된다"는 이 모델에서는 맞지 않고, "기준값을 높이면 잘못된 적중이 사라진다"도 맞지 않아요. 여기서 눈여겨볼 건 기준값을 올리면 잘못된 적중보다 적중률이 먼저 무너진다는 점이에요.
실습 3: 라이브러리 기본값 그대로 쓰면
RedisVL의 SemanticCache는 Redis로 시맨틱 캐시를 만드는 공식 라이브러리예요. 기준값을 정하지 않고 쓰면 어떤 값으로 동작하는지 확인해 볼게요. 0.27.2의 생성자 시그니처와 docstring에 적힌 내용은 이렇습니다.
distance_threshold: 기본값 0.1입니다. 단위는 유사도가 아니라 Redis COSINE 거리(0~2)이고, 작을수록 엄격합니다.ttl: 기본값None으로, 만료 시간 없이 저장합니다.vectorizer: 넘기지 않으면 Hugging Face의redis/langcache-embed-v1모델을 받아 씁니다.
기본 vectorizer를 쓰면 모델이 바뀌어 앞의 결과와 비교할 수 없어요. 그래서 앞에서 계산해 둔 e5-small 벡터를 돌려주는 CustomVectorizer를 넘겼습니다. 이 vectorizer는 질문 문장으로 미리 계산한 벡터를 찾아 돌려줘요.
distance_threshold는 지정하지 않았어요. rvl은 기본값과 인덱스의 벡터 필드를 찍고, 캐시 질문 30개를 store로 넣은 뒤 들어온 질문 158개를 check로 조회합니다.
py rvl e5s
출력에서 앞부분과 잘못된 적중 몇 줄만 옮겨 볼게요.
distance_threshold 기본값 0.1 ttl 기본값 None
인덱스 vector 필드 [('prompt_vector', <VectorIndexAlgorithm.FLAT: 'FLAT'>, <VectorDistanceMetric.COSINE: 'COSINE'>)]
RedisVL 기본값으로 조회: 적중 58/60 잘못된 적중 51 (같은 뜻이 다른 seed로 0, 다른 뜻 51, 관계없음 0, 후속 0)
numpy 유사도 0.90 분류와 같은 probe 158 / 158
잘못된 적중 0.924 [diff/scope] Kafka lag 숫자가 계속 커지면 어떻게 줄여야 하나요? -> seed 1 답: lag는 메시지 개수입니다. 몇 초 늦었는지는 메시지가 얼마나 빨리 들어
잘못된 적중 0.934 [diff/number] 컨테이너가 종료 코드 143으로 죽었을 때 원인을 어떻게 확인하나요? -> seed 5 답: 메모리 때문에 끝났는지는 `OOMKilled` 값과 `oom` 이벤트로
잘못된 적중 0.994 [diff/opposite] NGINX 설정에 keepalive를 썼는데도 upstream 연결이 재사용되지 않는 이유는? -> seed 13 답: 설정 파일에 `keepalive`가 한 줄도 없는데 연결을 재사용한 것입
잘못된 적중 0.970 [diff/number] binlog_format을 STATEMENT로 두면 Binlog에 무엇이 기록되나요? -> seed 24 답: `ROW`(기본값): 바뀐 행 자체를 기록합니다.
- 기본값: 출력한 값도 거리 0.1, TTL 없음입니다. 인덱스는 FLAT(전수 비교)에 COSINE 거리입니다.
- 결과: 적중 58/60, 잘못된 적중 51개입니다. 앞의
sweep에서 유사도 0.90으로 분류한 결과와 158개 질문 모두 같습니다. 거리 0.1은 유사도 0.9와 같은 기준입니다. - 잘못 나간 답: Kafka lag를 줄이는 방법을 물었는데 "lag는 메시지 개수입니다"가, 컨테이너 종료 코드 143의 원인을 물었는데 137과 OOM을 가리는 방법이 나갑니다.
기본값 0.1은 어떤 모델을 쓸지 모르는 채로 정해진 값이에요. vectorizer를 넘기지 않을 때 쓰는 모델은 redis/langcache-embed-v1이고, 그 모델에 맞춘 값이 e5-small에도 맞는다는 근거는 없습니다.
이 질문셋에서는 같은 뜻 질문 대부분을 적중시키면서 다른 뜻 질문도 대부분 적중시키는 값이었어요.
정리
범위: 임베딩 모델 e5-small, Redis 8.10.2, RedisVL 0.27.2, 캐시 질문 30개와 들어온 질문 158개(같은 뜻 60, 다른 뜻 58, 관계없음 30, 후속 10), CPU만 쓰는 컨테이너.
- e5-small에서 "keepalive를 썼는데도"와 "쓰지 않았는데도" 질문의 유사도는 0.994였고, 같은 뜻 질문 60개 중 이보다 높은 것은 없었어요. 유사도 숫자만으로 같은 질문인지 판단하지 마세요.
- e5-small의 기준값을 0.95에서 0.90으로 내리자 적중이 41개에서 58개로, 잘못된 적중이 26개에서 51개로 함께 늘었고, 0.80~0.99 어디에도 잘못된 적중이 0인 기준값은 없었어요. 기준값은 같은 뜻과 다른 뜻 질문을 함께 담은 질문셋으로 적중과 잘못된 적중을 같이 재서 골라 주세요.
- RedisVL
SemanticCache의 기본distance_threshold0.1은 유사도 0.90 분류와 158개 모두 같았고 잘못된 적중 51개를 냈어요. 라이브러리 기본값을 그대로 쓰지 말고 쓰는 모델로 기준값을 다시 재 보세요. - 이 글에 싣지 않은 추가 측정에서 같은 질문셋을 다른 임베딩 모델로 재면 잘못된 적중이 0이 되는 가장 낮은 기준값이
intfloat/multilingual-e5-large0.99(적중 12/60),BAAI/bge-m30.97(적중 11/60)이었고 e5-small은 0.99로도 부족했어요. 모델을 바꾸면 기준값을 질문셋으로 다시 훑어 주세요.