본문 바로가기
글 목록

S3 Vectors RAG 개발기: 임베딩 모델 하나 차이로 검색 유사도가 50%가 된 날

운영 챗봇의 검색 점수가 에러 없이 전부 50%로 몰린 원인은 서버 .env에 남은 옛 임베딩 모델이었어요. 진단 과정과 모델·차원·인덱스를 한 세트로 고정한 방법을 정리했어요.

IntentCode··11분 읽기

3줄 요약

  • 운영 챗봇의 문서 검색 점수가 에러 없이 전부 50% 근처로 몰리는 일이 있었어요.
  • 서버 .env에 남은 옛 임베딩 모델 이름이 compose 기본값을 조용히 덮어쓴 탓이었어요.
  • 모델·차원·인덱스·접두어를 한 세트로 보고 값으로 고정해 재발을 막았어요.

임베딩 모델이 세 번 바뀐 챗봇

인텐트코드 홈페이지에는 회사소개서와 제품 안내를 검색해서 답하는 챗봇이 있어요. 구조는 흔한 RAG(검색 증강 생성, 먼저 관련 문서를 찾고 그 내용으로 답을 만드는 방식)예요. 질문을 임베딩(embedding, 글을 숫자 벡터로 바꾼 것)으로 바꾸고, 미리 적재해 둔 문서 벡터 가운데 가장 가까운 것을 찾아 Claude에게 건네요.

이 챗봇의 임베딩 모델은 반년 사이 세 번 바뀌었어요. 모델이 바뀔 때마다 벡터의 길이와 저장소 조건도 함께 움직였어요.

시기임베딩 모델차원벡터 저장소
2026년 4월Gemini 임베딩3072Postgres pgvector
2026년 5월Voyage AI voyage-3-large1024pgvector (S3 Vectors는 병행 시험)
2026년 10월Gemini gemini-embedding-23072Amazon S3 Vectors

5월에는 Gemini API를 쓸 수 없게 되면서 Voyage AI로 옮겼어요. 같은 달 S3 Vectors를 운영 경로와 따로 시험했는데, 64개 청크(chunk, 문서를 검색 단위로 자른 조각) 기준으로 recall@10 평균 98.8%, 지연 p50 71ms·p95 150ms가 나왔어요. 10월 21일 개편 때는 답변 모델을 Claude Haiku 5.5로, 임베딩을 gemini-embedding-2로, 검색 저장소를 S3 Vectors로 한꺼번에 바꿨어요.

차원도 따져 볼 점이에요. pgvector의 vector 타입은 HNSW·IVFFlat 인덱스를 2,000차원까지만 지원해요. 그래서 4월의 3072차원 시절에는 HNSW 인덱스를 쓰지 못했고, 1024차원인 Voyage로 옮기면서 다시 켰어요. S3 Vectors 인덱스는 1~4,096차원을 받으니 3072차원 벡터를 그대로 넣을 수 있어요.

증상: 에러 없이 틀리는 검색

배포를 마치고 운영 사이트에서 "회사 로드맵"을 물어봤어요. 로컬에서는 로드맵이 담긴 회사소개서 11쪽이 유사도 88%로 맨 위에 나왔던 질문이에요. 운영에서는 개인정보 원칙이 적힌 4쪽이 나왔고, 점수는 50%였어요.

더 이상한 건 운영의 결과가 하나같이 50% 근처였다는 점이에요. 4쪽, 7쪽, 12쪽이 비슷한 점수로 나란히 섰어요. 순위는 매겨져 있었지만 사실상 아무 의미가 없는 상태였어요.

로그에는 에러가 하나도 없었어요. 임베딩 호출도, 벡터 질의도 성공했고 결과도 요청한 개수만큼 돌아왔어요. 그래서 처음에는 적재가 덜 됐거나 청크를 잘못 나눈 건 아닌지부터 의심했어요.

진단: 50%라는 숫자를 읽는 법

S3 Vectors는 코사인 거리(cosine distance)를 돌려줘요. 0이면 같은 방향, 1이면 직각, 2면 정반대예요. 우리 코드는 이전 pgvector 시절과 같은 척도를 쓰려고 유사도 = 1 - 거리/2로 바꿔서 보여 줘요. 그래서 기존에 쓰던 임계값의 의미도 그대로 유지돼요.

이 척도에서 50%는 거리 1, 즉 질의 벡터와 문서 벡터가 거의 직각이라는 뜻이에요. 서로 아무 관련이 없는 벡터끼리 고차원 공간에서 만나면 대체로 이 근처가 나와요. 반면 같은 모델로 만든 벡터라면 로컬처럼 맞는 문서는 높게, 아닌 문서는 낮게 점수가 벌어져요.

그러니 모든 결과가 고르게 50%라는 건 "조금 덜 비슷하다"가 아니라 "서로 다른 공간에 있다"는 신호예요. 데이터를 다시 넣기 전에 질의 벡터를 만드는 쪽을 먼저 보기로 했어요.

로컬에서 다른 조건은 그대로 두고 질의 모델만 바꿔 재현했어요. 옛 모델 gemini-embedding-001로 질의하자 4쪽·7쪽·12쪽이 모두 50%로 나와 운영과 똑같았어요. gemini-embedding-2로 되돌리자 11쪽이 다시 88%로 올라왔어요. 원인의 범위가 한 줄짜리 설정으로 좁혀진 순간이었어요.

원인: 기본값은 기본값일 뿐이에요

운영 서버는 Lightsail 인스턴스 한 대에서 Docker Compose로 앱을 띄워요. 개편 직후 compose 파일은 임베딩 모델을 "없으면 이 값" 형태로 받고 있었어요.

diff
 environment:
-  GEMINI_EMBEDDING_MODEL: ${GEMINI_EMBEDDING_MODEL:-gemini-embedding-2}
+  # 임베딩 모델 · 차원 · 인덱스는 한 세트로 고정 (.env로 덮어쓰지 않음)
+  GEMINI_EMBEDDING_MODEL: gemini-embedding-2
+  GEMINI_EMBEDDING_DIM: "3072"
+  S3VECTORS_INDEX: knowledge-gemini

${변수:-기본값}은 변수가 비어 있을 때만 기본값을 써요. Docker 문서에 따르면 우선순위는 셸 환경 변수, --env-file로 넘긴 파일, 프로젝트 폴더의 .env 순서예요. 서버의 .env는 Git으로 관리하지 않는 파일인데, 여기에 예전에 쓰던 GEMINI_EMBEDDING_MODEL=gemini-embedding-001이 남아 있었어요.

결과적으로 문서는 gemini-embedding-2로 적재돼 있었고, 질의는 gemini-embedding-001로 만들어지고 있었어요. Google의 Gemini API 문서는 두 모델의 임베딩 공간이 호환되지 않으며, 모델을 올리면 기존 데이터를 전부 다시 임베딩해야 한다고 적고 있어요. 두 모델은 사용법도 달라요. gemini-embedding-001은 task_type 매개변수로 질의와 문서를 구분하고, gemini-embedding-2는 이 매개변수를 지원하지 않아 텍스트 접두어로 구분해요.

왜 아무것도 막아 주지 못했나

두 모델은 기본 출력 차원이 똑같이 3072예요. S3 Vectors 인덱스는 만들 때 정한 차원과 정확히 같은 길이의 벡터만 받는데, 길이가 같으니 잘못된 질의도 아무 문제 없이 통과했어요. 차원이 달랐다면 첫 질의에서 바로 실패했을 거예요.

검색이 맞으려면 질의와 문서 사이에 지켜야 할 약속이 여러 개 있어요. 그중 시스템이 대신 검사해 주는 건 일부뿐이에요.

약속바뀌면 생기는 일누가 막아 주나
벡터 차원질의·적재 실패S3 Vectors가 거부
거리 함수(cosine)점수 척도가 달라짐인덱스 생성 후 변경 불가
임베딩 모델점수가 고르게 무의미해짐아무도 없음
질의·문서 접두어검색 품질이 조용히 떨어짐아무도 없음
인덱스 이름다른 데이터를 검색아무도 없음

S3 Vectors 문서대로 인덱스는 만든 뒤 이름·차원·거리 함수·필터 불가 메타데이터 키를 바꿀 수 없어요. 하지만 벡터를 어떤 모델이 만들었는지는 인덱스가 알지 못해요. 표의 아래 세 줄은 앱이 스스로 지켜야 하는 약속이에요.

해결: 한 세트로 고정하고 한 번에 바꾸기

위 diff처럼 모델·차원·인덱스를 compose 파일에 기본값이 아닌 실제 값으로 적었어요. 이제 .env에 무엇이 남아 있어도 이 세 값은 덮어쓸 수 없어요. API 키처럼 환경마다 달라야 하는 값만 계속 변수로 받아요.

머지 전에는 docker compose config로 최종 설정을 렌더링해 확인했어요. 오래된 값이 들어간 .env로 렌더링해도 모델은 gemini-embedding-2, 인덱스는 knowledge-gemini로 유지됐어요. 머지 후에는 배포 워크플로가 서버에서 새 compose 파일을 받아 웹 컨테이너를 다시 만들어요.

접두어 처리도 모델 설정과 같은 파일에 모아 두었어요. Gemini API 문서가 안내하는 형식 그대로예요.

ts
// lib/rag/embed.ts — gemini-embedding-2는 task_type 대신 접두어로 질의/문서를 구분
const MODEL_ID = process.env.GEMINI_EMBEDDING_MODEL || 'gemini-embedding-2';
export const EMBEDDING_DIM = Number(process.env.GEMINI_EMBEDDING_DIM || 3072);

function asQuery(text: string): string {
  return `task: search result | query: ${text}`;
}

function asDocument(text: string, title?: string): string {
  return `title: ${title?.trim() || 'none'} | text: ${text}`;
}

다음에 모델을 바꿀 때 지킬 순서도 정리했어요.

  1. 새 모델의 차원으로 새 인덱스를 만들어요. 기존 인덱스는 건드리지 않아요.
  2. 새 모델과 그 모델의 접두어 규칙으로 전체 문서를 다시 적재해요.
  3. compose의 모델·차원·인덱스 값을 한 커밋에서 함께 바꿔 배포해요.
  4. 검색이 정상인 걸 확인한 뒤에 옛 인덱스를 지워요.

인덱스 이름에 모델 계열(knowledge-gemini)이 들어가 있으면 이 순서를 지키기가 더 쉬워요. 새 모델의 인덱스는 새 이름을 갖게 되니, 설정 값만 봐도 어느 공간을 검색하는지 알 수 있어요.

트러블슈팅: 비슷한 증상 세 가지

첫째, 모든 점수가 50% 근처로 고르게 몰려요. 이번과 같은 경우로, 질의 모델과 적재 모델이 다를 가능성이 가장 커요. 운영 컨테이너 안에서 실제로 쓰이는 모델 값을 확인하고, 로컬에서 질의 모델만 바꿔 재현해 보세요.

둘째, 질의나 적재가 차원 오류로 실패해요. 모델이 바뀌었거나 출력 차원 설정(outputDimensionality)이 인덱스 차원과 달라진 경우예요. 오히려 다행인 경우예요. 시스템이 바로 알려 주니까요.

셋째, 로컬은 멀쩡한데 운영만 이상해요. 코드보다 환경 차이를 먼저 보세요. Git에 없는 서버 .env, 배포 때 주입하는 값, 셸에 남은 변수가 모두 후보예요. docker compose config로 실제 적용된 값을 보면 빠르게 좁힐 수 있어요.

이번 일을 겪고 나니 차원 검사로는 잡을 수 없는 실수를 잡을 방법이 필요하다고 느꼈어요. 정답을 아는 질문 몇 개로 배포 직후 검색 공간을 점검하는 방법을 생각해 볼 만해요. 아래는 아직 적용하지 않은 스케치이고, 임계값은 우리 데이터로 다시 정해야 하는 예시 숫자예요.

ts
// 배포 직후 점검 스케치 — 정답을 아는 질문으로 임베딩 공간 확인
import { retrieveSimilar } from '@/lib/rag/retrieve';

const CANARIES = ['회사 로드맵', '개인정보 원칙'];

export async function checkEmbeddingSpace(): Promise<void> {
  for (const q of CANARIES) {
    const hits = await retrieveSimilar(q, { topK: 5 });
    const top = hits[0]?.similarity ?? 0;
    const spread = top - (hits.at(-1)?.similarity ?? 0);
    // 점수가 낮거나 고르게 몰리면 질의와 문서가 다른 공간에 있다는 신호
    if (top < 0.7 || spread < 0.05) {
      throw new Error(`embedding space check failed: "${q}" top=${top.toFixed(2)}`);
    }
  }
}

홈페이지 챗봇은 지금 이 고정된 설정으로 회사소개서와 제품 안내를 찾아 답하고 있어요. 비슷한 구성을 운영하고 있다면 서버의 .env를 한 번 열어 보길 권해요. 오래된 값 한 줄이 생각보다 오래 살아남아 있을 수 있어요.

참고 자료

새 글을 메일로 받아 보세요

제품을 만들며 배운 것을 주 2회 정리해요. 궁금한 건 도트에게 물어보세요.