주요 내용
# 벡터 데이터베이스를 사용한 프로덕션 RAG 애플리케이션 구축
## RAG란 무엇인가?
RAG를 처음 프로덕션에 올렸을 때, 개념 자체는 단순했지만 실제로 잘 동작하게 만드는 데 생각보다 시간이 걸렸습니다. 검색 증강 생성(RAG, Retrieval-Augmented Generation)은 LLM의 구조적 한계를 보완하는 패턴입니다—LLM은 학습 시점 이후의 최신 정보나 사내 전용 문서에 대해 답변하지 못하는데, RAG가 그 빈틈을 채웁니다. 사용자 질의와 관련된 문서를 먼저 검색하고, 그 내용을 LLM에 컨텍스트로 넣어 근거 있는 답변을 생성하는 방식입니다.
개념은 깔끔하지만, 프로덕션에서 안정적으로 동작하게 만들려면 청킹 전략, 벡터 데이터베이스 선택, 검색 품질 최적화, 평가 지표 설계까지 꽤 많은 결정이 따라옵니다. 이 글에서는 각 단계를 실제 코드와 함께 다룹니다.
—
## 벡터 데이터베이스 선택: Pinecone vs Weaviate
RAG 파이프라인의 핵심 인프라는 벡터 데이터베이스입니다. 프로덕션에서 가장 많이 쓰이는 두 옵션을 비교합니다.
### Pinecone
Pinecone은 완전 관리형(fully managed) 서비스입니다. 인프라 운영 부담 없이 벡터 검색을 바로 쓸 수 있습니다.
**장점:**
– 설정이 간단하고 스케일링이 자동으로 처리됩니다
– 낮은 지연 시간의 ANN(근사 최근접 이웃) 검색을 제공합니다
– 서버리스 플랜으로 소규모 프로젝트 진입 비용이 낮습니다
**단점:**
– 벤더 종속성이 생깁니다
– 데이터가 외부 서버에 저장되므로 규정 준수 이슈가 발생할 수 있습니다
### Weaviate
Weaviate는 오픈소스 벡터 데이터베이스로, 자체 호스팅 또는 클라우드 호스팅” rel=”nofollow sponsored” target=”_blank”>클라우드 호스팅” rel=”nofollow sponsored” target=”_blank”>클라우드 호스팅” rel=”nofollow sponsored” target=”_blank”>클라우드 호스팅” rel=”nofollow sponsored” target=”_blank”>클라우드 호스팅” rel=”nofollow sponsored” target=”_blank”>클라우드 호스팅” rel=”nofollow sponsored” target=”_blank”>클라우드 호스팅” rel=”nofollow sponsored” target=”_blank”>클라우드 호스팅” rel=”nofollow sponsored” target=”_blank”>클라우드 서비스 형태로 모두 사용할 수 있습니다.
**장점:**
– 온프레미스 배포하기” rel=”nofollow sponsored” target=”_blank”>배포하기” rel=”nofollow sponsored” target=”_blank”>배포하기” rel=”nofollow sponsored” target=”_blank”>배포하기” rel=”nofollow sponsored” target=”_blank”>배포하기” rel=”nofollow sponsored” target=”_blank”>배포가 가능해서 데이터 주권을 유지합니다
– 하이브리드 검색(벡터 + 키워드 BM25)을 기본으로 지원합니다
– REST, GraphQL, gRPC 등 다양한 쿼리 인터페이스를 제공합니다
**단점:**
– 자체 호스팅 시 운영 부담이 늘어납니다
– 초기 설정이 Pinecone보다 복잡합니다
제 경험상, 빠르게 MVP를 검증해야 할 때는 Pinecone이 훨씬 편했고, 데이터를 외부로 내보낼 수 없는 환경이라면 Weaviate 외에 선택지가 없었습니다. 팀의 운영 역량과 데이터 규정 요건을 함께 보고 결정하세요.
—
## RAG 파이프라인 단계별 구현
### 1단계: 문서 청킹(Chunking)
문서를 벡터로 변환하기 전에 적절한 크기로 분할해야 합니다. 청크 크기는 검색 품질에 직접 영향을 줍니다. 너무 작으면 문맥이 부족하고, 너무 크면 관련 없는 내용이 섞입니다.
“`python
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # 기본값은 문자(character) 단위
chunk_overlap=64,
separators=[“\n\n”, “\n”, “.”, ” “, “”]
)
docs = splitter.split_documents(raw_documents)
“`
> **주의:** `RecursiveCharacterTextSplitter`의 `chunk_size`는 기본적으로 **문자(character)** 단위입니다. 토큰 단위로 분할하려면 `length_function=tiktoken_len`처럼 토크나이저 기반 함수를 지정해야 합니다. 한국어 문서의 경우 문자 수와 토큰 수 사이 차이가 크므로 이 점에 유의하세요.
솔직히 이 부분에서 한 번 크게 데인 적이 있습니다. `chunk_size=512`로 설정해놓고 “토큰 기준으로 괜찮겠지”라고 넘어갔더니, 한국어 문서에서 실제 토큰 수가 예상보다 훨씬 적게 나와 검색 품질이 이상하게 낮았습니다. 처음부터 `tiktoken_len` 기반으로 잡았으면 시행착오를 많이 줄일 수 있었을 텐데요. 실무에서는 문자 기준 512~1024 범위에서 실험해 도메인에 맞는 최적값을 찾습니다. `chunk_overlap`을 64 정도로 설정하면 청크 경계에서 문맥이 잘리는 문제도 많이 줄어듭니다.
### 2단계: 임베딩 생성 및 인덱싱
청크를 고차원 벡터로 변환하고 데이터베이스에 저장합니다.
**Pinecone 사용 예:**
“`python
from pinecone import Pinecone
from openai import OpenAI
pc = Pinecone(api_key=”YOUR_PINECONE_KEY”)
index = pc.Index(“production-rag”)
openai_client = OpenAI(api_key=”YOUR_OPENAI_KEY”)
def embed_and_upsert(chunks: list[str], ids: list[str]):
response = openai_client.embeddings.create(
model=”text-embedding-3-small”,
input=chunks
)
vectors = [
{
“id”: ids[i],
“values”: r.embedding,
“metadata”: {“text”: chunks[i]}
}
for i, r in enumerate(response.data)
]
index.upsert(vectors=vectors)
“`
**Weaviate 사용 예:**
“`python
import weaviate
from weaviate.classes.config import Configure
client = weaviate.connect_to_local()
client.collections.create(
name=”Document”,
vectorizer_config=Configure.Vectorizer.text2vec_openai(
model=”text-embedding-3-small”
)
)
collection = client.collections.get(“Document”)
with collection.batch.dynamic() as batch:
for chunk in chunks:
batch.add_object({“content”: chunk})
“`
### 3단계: 검색 및 생성
사용자 쿼리가 들어오면 관련 청크를 검색하고 LLM에 전달합니다.
“`python
def retrieve_and_generate(query: str, top_k: int = 5) -> str:
# 쿼리 임베딩 생성
query_vector = openai_client.embeddings.create(
model=”text-embedding-3-small”,
input=query
).data[0].embedding
# 벡터 유사도 검색
results = index.query(
vector=query_vector,
top_k=top_k,
include_metadata=True
)
# 검색된 청크를 컨텍스트로 조합
context = “\n\n”.join(
match.metadata[“text”] for match in results.matches
)
# LLM 호출
response = openai_client.chat.completions.create(
model=”gpt-4o-mini”,
messages=[
{
“role”: “system”,
“content”: (
“주어진 컨텍스트만을 사용하여 질문에 답변하세요. ”
“컨텍스트에 없는 내용은 ‘제공된 정보에 없습니다’라고 답하세요.”
)
},
{
“role”: “user”,
“content”: f”컨텍스트:\n{context}\n\n질문: {query}”
}
]
)
return response.choices[0].message.content
“`
—
## 프로덕션 클라우드” rel=”nofollow sponsored” target=”_blank”>프로덕션 클라우드” rel=”nofollow sponsored” target=”_blank”>프로덕션 환경을 위한 최적화
### 순수 벡터 검색만으로는 부족합니다
벡터 검색은 의미론적 유사성은 잘 잡지만, 고유 명사나 제품명 같은 정확한 키워드 매칭에서는 BM25에 밀립니다. 두 방식을 결합하면 훨씬 안정적인 결과가 나옵니다.
Weaviate의 하이브리드 검색은 다음과 같이 씁니다:
“`python
results = collection.query.hybrid(
query=”벡터 데이터베이스 성능 최적화”,
alpha=0.7, # 0: BM25 키워드 중심, 1: 벡터 중심
limit=5
)
“`
`alpha` 값은 데이터 특성에 맞게 조정합니다. 기술 문서처럼 정확한 용어 매칭이 중요한 경우 0.5 이하로 낮추는 게 효과적입니다.
### 재순위화(Reranking)
1차 벡터 검색으로 후보 문서를 빠르게 좁히고, Cross-Encoder 모델로 재순위화하면 검색 품질이 눈에 띄게 올라갑니다.
“`python
from sentence_transformers import CrossEncoder
reranker = CrossEncoder(“cross-encoder/ms-marco-MiniLM-L-6-v2″)
def rerank(query: str, candidates: list[str], top_n: int = 3) -> list[str]:
pairs = [(query, c) for c in candidates]
scores = reranker.predict(pairs)
ranked = sorted(zip(scores, candidates), reverse=True)
return [doc for _, doc in ranked[:top_n]]
“`
초기 검색은 빠른 ANN으로 처리하고, 최종 선택만 Cross-Encoder로 정밀하게 다듬는 구조라 속도와 품질 모두 잡을 수 있습니다.
### 쿼리 결과 캐싱
반복되는 동일 쿼리에 대한 응답 비용과 지연을 줄이기 위해 결과를 캐싱합니다.
“`python
import hashlib
import redis
cache = redis.Redis(host=”localhost”, port=6379)
def cached_query(query: str) -> str:
key = hashlib.sha256(query.encode()).hexdigest()
cached = cache.get(key)
if cached:
return cached.decode()
result = retrieve_and_generate(query)
cache.setex(key, 3600, result) # TTL 1시간
return result
“`
이 구현은 쿼리 문자열이 동일할 때만 캐시를 쓰는 **정확 매칭 캐싱**입니다. 유사한 쿼리까지 캐시에서 처리하려면 쿼리 임베딩 간 유사도를 비교하는 벡터 기반 시맨틱 캐싱(예: GPTCache)을 별도로 구성해야 합니다.
—
## 평가와 모니터링
배포하고 나서 손 놓으면 안 됩니다. 프로덕션 AI 시스템은 품질 모니터링이 필수입니다. 주요 측정 지표는 다음과 같습니다.
| 지표 | 설명 |
|—|—|
| **Faithfulness** | LLM이 검색된 컨텍스트 범위 안에서만 답변하는지 |
| **Context Recall** | 정답에 필요한 청크가 실제로 검색되었는지 |
| **Answer Relevancy** | 생성된 답변이 질문과 관련이 있는지 |
| **지연 시간** | P50/P95 레이턴시 |
[RAGAS](https://github.com/explodinggradients/ragas) 프레임워크를 쓰면 이 지표들을 자동으로 측정할 수 있습니다.
“`python
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_recall
result = evaluate(
dataset=eval_dataset,
metrics=[faithfulness, answer_relevancy, context_recall]
)
print(result)
“`
평가 데이터셋은 실제 사용자 쿼리와 기대 답변으로 구성하는 게 가장 현실적입니다. 합성 데이터만으로는 실제 품질 저하를 감지하기 어렵습니다—이건 직접 겪어봐야 실감하게 됩니다.
—
## 마치며
기본 파이프라인을 구성한 이후가 진짜 시작입니다. 청킹 전략, 임베딩 모델 선택, 하이브리드 검색, 재순위화, 캐싱, 지속적인 품질 평가—각 단계에서 신중하게 결정해야 할 것들이 많습니다.
벡터 데이터베이스 선택에서는 Pinecone이 빠른 시작과 관리 편의성 면에서 유리하고, Weaviate는 데이터 주권과 장기적인 비용 효율이 중요한 환경에 더 맞습니다. 어느 쪽을 선택하든 이 글에서 다룬 파이프라인 구조는 동일하게 적용됩니다.
**다음 단계로 권장하는 순서:**
1. 도메인 문서 20~50개로 소규모 파이프라인을 먼저 구성합니다
2. 실제 사용자 쿼리 30개 이상으로 평가 데이터셋을 만들고 RAGAS로 기준선을 측정합니다
3. Context Recall이 낮으면 청킹을, Faithfulness가 낮으면 프롬프트를 먼저 조정합니다
4. 트래픽이 늘어나는 시점에 재순위화와 캐싱을 추가합니다
결론
구체적인 질문이나 막히는 부분이 있으면 댓글로 남겨주세요.