Skip to content

벡터 저장소부터 프롬프트 엔지니어링까지 — RAG를 떠받치는 기둥들

RAG 파이프라인을 설계하다 보면 결국 세 가지 질문에 부딪힌다. "문서를 어떻게 벡터로 바꾸지?", "그 벡터를 어디에 저장하고 어떻게 검색하지?", 그리고 "검색된 결과를 LLM에 어떤 방식으로 넘기지?" 이 글은 그 세 질문을 중심으로 풀어본다.

처음 RAG를 접했을 때 나는 임베딩과 벡터 저장소를 대충 골라서 연결하면 끝인 줄 알았다. 솔직히 말하면 삽질을 꽤 했다. 각 구성 요소가 왜 필요한지, 선택지마다 어떤 트레이드오프가 있는지를 이해하고 나서야 파이프라인이 제대로 돌아가기 시작했다.


RAG 파이프라인의 핵심 구성 요소

RAG 파이프라인은 크게 세 단계로 나뉜다. 인덱싱(문서를 벡터로 변환해서 저장), 검색(쿼리와 유사한 문서를 찾아오기), 생성(검색된 문서를 컨텍스트로 LLM에 전달). 단순해 보이지만, 각 단계에서 내리는 선택이 최종 응답 품질을 크게 좌우한다.

임베딩 모델 — 텍스트를 숫자로 바꾸는 첫 관문

임베딩은 텍스트를 고차원 벡터 공간의 한 점으로 변환하는 과정이다. 비슷한 의미의 텍스트는 가까운 위치에, 다른 의미의 텍스트는 먼 위치에 놓이게 된다. 말로 하면 간단한데, 어떤 모델을 쓰느냐에 따라 결과가 상당히 달라진다.

상용 모델 쪽에서는 OpenAI의 text-embedding-3-large(3072차원)와 text-embedding-3-small(1536차원)이 가장 널리 쓰인다. Cohere의 embed-v4는 MTEB 벤치마크에서 65.2로 OpenAI의 64.6을 근소하게 앞서고 있다. 하지만 벤치마크 점수가 실제 성능을 그대로 반영하지는 않는다는 점은 늘 염두에 둬야 한다.

오픈소스 쪽에서는 sentence-transformers 라이브러리가 사실상 표준이다. all-MiniLM-L6-v2는 가볍고 빨라서 프로토타이핑에 좋고, BGE-M3는 다국어 지원이 뛰어나서 한국어 데이터를 다룰 때 고려해볼 만하다. 비용이 0이라는 것도 큰 장점이지만 직접 호스팅해야 하는 부담이 있다.

한 가지 주의할 점. 차원이 높다고 무조건 좋은 건 아니다. 3072차원은 768차원에 비해 저장 공간이 4배 필요하고 검색 속도도 느려진다. 문서가 수천 건 수준이라면 768차원으로도 충분한 경우가 많았다. 내 경험상 차원 수보다는 데이터 도메인과 임베딩 모델의 궁합이 더 중요하다.

벡터 저장소 — 어디에 담을 것인가

임베딩이 만들어지면 어딘가에 저장하고 효율적으로 검색할 수 있어야 한다. 이걸 담당하는 게 벡터 저장소(Vector Store)인데, 선택지가 꽤 많다.

ChromaFAISSPineconeWeaviate
유형오픈소스오픈소스 (Meta)매니지드 서비스오픈소스/클라우드
특징파이썬 친화적, 셋업 간단GPU 가속, 원시 속도 최강서버리스, 운영 부담 제로하이브리드 검색, GraphQL
적합한 상황프로토타이핑, 소규모대규모 배치, 연구프로덕션 SaaS복합 검색 요구
약점대규모 프로덕션에 부적합DB가 아닌 라이브러리벤더 종속, 비용러닝 커브

처음에 나는 "일단 Chroma로 시작하자"는 접근을 했는데, 이건 나쁘지 않은 선택이었다. Chroma는 pip install chromadb 한 줄이면 바로 쓸 수 있고, LangChain과의 연동도 간단하다. 프로토타이핑 단계에서는 이만한 게 없다.

FAISS는 Meta가 만든 라이브러리로, 순수한 벡터 검색 속도에서는 타의 추종을 불허한다. GPU 가속을 쓰면 CPU 대비 5~10배 빠르다. 다만 FAISS는 데이터베이스가 아니라 라이브러리다. 메타데이터 필터링이나 CRUD 같은 기능이 없어서 직접 구현해야 한다. 연구나 배치 처리에는 좋지만, 실시간 서비스에 바로 쓰기에는 부족한 면이 있다.

Pinecone은 완전 매니지드 서비스라 인프라 걱정이 없다. 수십억 벡터에서도 50ms 이하 지연을 보장한다고 하는데, 그만큼 비용이 든다. 벤더 종속도 고려해야 한다. Weaviate는 하이브리드 검색에서 강점을 보인다. 벡터 검색과 키워드 검색을 한 쿼리에서 결합할 수 있는데, 이게 실무에서 꽤 유용하다.

정답은 없다. 프로토타이핑은 Chroma, 프로덕션 규모가 커지면 Pinecone이나 Weaviate로 마이그레이션하는 경로가 흔한 패턴이긴 하다.


유사도 측정 — 가깝다는 건 무슨 뜻인가

벡터 저장소에서 "비슷한 문서를 찾는다"고 할 때, "비슷하다"를 어떻게 정의하느냐가 검색 품질에 영향을 준다. 주로 쓰이는 메트릭은 세 가지다.

코사인 유사도(Cosine Similarity) 는 두 벡터 사이의 각도를 측정한다. 벡터의 크기(길이)는 무시하고 방향만 본다. 텍스트 검색에서 가장 보편적으로 쓰이는데, 문서 길이가 달라도 의미적 유사성을 잘 잡아내기 때문이다. 짧은 문장이든 긴 문단이든 방향이 비슷하면 높은 유사도를 반환한다.

유클리드 거리(Euclidean Distance) 는 두 점 사이의 직선 거리다. 벡터의 크기도 고려하기 때문에 클러스터링이나 이상 탐지에 더 적합하다. 단, 고차원에서는 거리 집중 현상(distance concentration)이 생겨서 모든 점 사이의 거리가 비슷해지는 문제가 있다.

내적(Dot Product) 은 방향과 크기를 모두 반영한다. 추천 시스템에서 많이 쓰이는데, 벡터의 크기가 "중요도"나 "인기도"를 나타내는 경우에 유리하다.

재미있는 점은, 벡터가 정규화(normalize)되어 있으면 코사인 유사도와 내적의 결과가 동일하다는 것이다. 대부분의 임베딩 모델이 정규화된 벡터를 출력하기 때문에, 실무에서는 코사인 유사도와 내적 중 어떤 걸 써도 결과가 같은 경우가 많다. 처음에 이걸 몰라서 "어떤 메트릭이 최선이지?"를 고민하느라 시간을 꽤 썼다.


벡터 저장소 검색 방식 비교

단순히 "가장 유사한 k개를 가져오는" 것 이상의 검색 전략이 있다. 각각의 장단점을 알아두면 상황에 맞게 선택할 수 있다.

가장 기본적인 방식이다. 쿼리 벡터와 코사인 유사도가 높은 상위 k개 문서를 반환한다. 직관적이고 빠르지만 한 가지 치명적인 약점이 있다. 상위 결과가 서로 너무 비슷할 수 있다는 것이다.

예를 들어 "좋은 휴가지 추천"을 검색하면 그리스 해변에 대한 문서 5개가 전부 올라올 수 있다. 의미적으로는 모두 관련이 있지만, 사용자가 원한 건 다양한 종류의 휴가지였을 것이다.

MMR (Maximum Marginal Relevance)

이 문제를 해결하기 위해 나온 것이 MMR이다. 핵심 아이디어는 간단하다. 검색 결과가 쿼리와 관련성이 높으면서도 서로 다양해야 한다는 것.

MMR은 이렇게 동작한다. 먼저 쿼리와 유사한 문서를 넉넉하게 가져온다(예: 20개). 그 다음, 하나씩 결과 목록에 추가하면서 "이미 선택된 문서들과 너무 비슷한 문서"에는 페널티를 준다. 수식으로 보면:

MMR = λ × 쿼리와의_유사도 - (1-λ) × 이미_선택된_문서와의_최대_유사도

λ를 1에 가깝게 하면 관련성 중심, 0에 가깝게 하면 다양성 중심이 된다. 보통 0.5~0.7 정도가 균형 잡힌 값이다.

RAG에서 MMR이 특히 유용한 이유가 있다. LLM에 비슷한 내용의 청크를 여러 개 넘기면 컨텍스트 윈도우만 낭비할 뿐이다. 다양한 관점의 청크를 넘겨야 LLM이 풍부한 답변을 생성할 수 있다. 내가 체감한 바로는 MMR을 적용하고 나서 답변 품질이 눈에 띄게 올라간 경험이 있다.

벡터 검색(시맨틱)과 키워드 검색(BM25)을 결합한 방식이다. 앞선 글에서도 언급했지만, 에러 코드나 제품명 같은 정확한 텍스트 매칭이 필요한 경우에는 키워드 검색이 훨씬 정확하다. 반대로 의미적으로 관련된 문서를 찾을 때는 벡터 검색이 강하다. 둘을 합치면 양쪽의 장점을 취할 수 있다.

최종_점수 = α × 시맨틱_유사도 + (1-α) × BM25_점수

α 값 조절이 관건인데, 이건 데이터 특성에 따라 다르다. 기술 문서처럼 고유 용어가 많은 경우 α를 낮춰서 키워드 매칭 비중을 높이는 게 좋고, 일반 텍스트에서는 α를 높여서 시맨틱 비중을 높이는 게 낫다. 솔직히 이건 실험 없이는 정답을 모른다.

Weaviate가 하이브리드 검색에서 특히 강한 이유는 벡터 검색, 키워드 매칭, 메타데이터 필터링을 단일 쿼리에서 결합할 수 있기 때문이다. Elasticsearch도 최근 벡터 검색을 지원하면서 하이브리드 검색의 선택지가 넓어졌다.


프롬프트 엔지니어링 기초 — LLM에게 말 거는 법

검색이 아무리 완벽해도 프롬프트가 엉망이면 답변도 엉망이다. RAG에서 Generation 단계의 품질은 결국 프롬프트에 달려 있다. 프롬프트 엔지니어링의 핵심 기법을 정리해본다.

역할 구분: system, user, assistant

대부분의 LLM API는 메시지를 세 가지 역할로 구분한다.

  • system: LLM의 전반적인 행동 방식을 지정한다. "너는 한국 세법 전문가야. 검색된 문서에 기반해서만 답변해" 같은 지시를 여기에 넣는다.
  • user: 사용자의 질문이나 요청. RAG에서는 검색된 컨텍스트와 함께 질문을 담는다.
  • assistant: LLM의 이전 응답. 멀티턴 대화에서 맥락을 유지하는 데 쓰인다.

RAG에서는 system 프롬프트에 "제공된 문서에만 기반해서 답변하세요. 문서에 없는 내용은 모른다고 하세요"를 명시하는 것만으로도 할루시네이션이 상당히 줄어든다. 이건 간단하지만 효과가 크다.

Zero-Shot 프롬프팅

예시 없이 지시만으로 작업을 수행하게 하는 방식이다. "다음 텍스트를 긍정/부정으로 분류해주세요"처럼 바로 본론으로 들어간다. 간단한 작업에서는 잘 동작하지만, 복잡한 포맷이나 특정 도메인 지식이 필요한 작업에서는 한계가 있다.

Few-Shot 프롬프팅

몇 가지 예시를 보여주고 패턴을 학습하게 하는 방식이다.

다음 리뷰의 감성을 분류하세요.

리뷰: "배송이 빠르고 포장이 깔끔했어요" → 긍정
리뷰: "제품 품질이 기대 이하네요" → 부정
리뷰: "가격 대비 괜찮은 편이에요" → ?

이렇게 2~3개의 예시만 넣어도 Zero-Shot보다 정확도가 확 올라가는 경우가 많다. 핵심은 예시의 개수보다 예시의 질이다. 다양한 케이스를 커버하는 좋은 예시 3개가 비슷한 예시 10개보다 낫다.

RAG 컨텍스트에서 Few-Shot을 쓸 때는 주의할 점이 있다. 예시가 컨텍스트 윈도우를 차지하기 때문에 검색된 문서를 넣을 공간이 줄어든다. 이 트레이드오프를 항상 고려해야 한다.

Chain-of-Thought (CoT)

"단계별로 생각해보세요"라는 한 마디가 놀라운 차이를 만든다. CoT는 LLM이 바로 답을 내놓는 대신 중간 추론 과정을 거치게 하는 기법이다.

Q: 회사 A의 2024년 매출이 100억이고 영업이익률이 12%라면,
   영업이익은 얼마인가?

# CoT 없이
A: 12억원입니다.

# CoT 적용
A: 매출이 100억원이고 영업이익률이 12%이므로,
   영업이익 = 100억 × 0.12 = 12억원입니다.

이 예시에서는 답이 같지만, 복잡한 추론이 필요한 질문에서는 CoT 유무에 따라 정답률 차이가 크다. 특히 수학적 계산, 논리적 추론, 다단계 분석에서 효과적이다.

Few-Shot과 CoT를 결합할 수도 있다. 예시 자체에 추론 과정을 포함시키는 건데, 이건 좀 더 복잡한 작업에서 확실히 효과가 있다.

RAG에서의 프롬프트 설계

RAG 파이프라인에서 프롬프트는 보통 이런 구조를 갖는다.

[System] 당신은 {도메인} 전문가입니다.
아래 제공된 참고 문서에만 기반하여 답변하세요.
문서에 없는 정보는 "해당 정보를 찾을 수 없습니다"라고 답하세요.

[User]
## 참고 문서
{검색된_청크_1}
{검색된_청크_2}
{검색된_청크_3}

## 질문
{사용자_질문}

이 템플릿이 단순해 보이지만, 몇 가지 디테일이 중요하다. "참고 문서에만 기반하여"라는 제약이 할루시네이션을 줄이고, 문서를 질문 앞에 배치하는 게 뒤에 배치하는 것보다 성능이 좋다는 연구 결과도 있다. 그리고 모른다고 답할 수 있는 탈출구를 주는 것도 중요하다. 이걸 안 넣으면 LLM은 어떻게든 답을 만들어내려고 한다.


아직 고민 중인 것들

여기까지 정리하면서도 확신이 없는 부분이 몇 가지 있다. 임베딩 모델을 프로젝트 도중에 바꿔야 할 때 기존 벡터를 전부 재생성하는 비용은 어떻게 관리하는 게 좋은지, 하이브리드 검색의 α 값을 자동으로 튜닝하는 실용적인 방법은 있는지, 프롬프트를 계속 다듬다 보면 어느 시점에서 "이만하면 됐다"고 판단해야 하는지.

이런 것들은 결국 자기 데이터와 유스케이스에서 직접 실험해보는 수밖에 없다는 게 지금까지의 결론이다. RAG는 한 번 구축하면 끝이 아니라 계속 실험하고 개선하는 살아있는 시스템이니까.

다음 글에서는 LangChain을 활용해서 이 구성 요소들을 실제로 연결하는 과정을 다뤄볼 예정이다.


참고자료

삽질 테크 블로그