Pinecone에서 FAISS Chroma로 마이그레이션
개요
요약
관리형 벡터 DB인 Pinecone에서 오픈소스인 FAISS·Chroma로 옮기는 이유와 절차를 정리한다. 옮긴 뒤 새로 떠안는 운영 부담도 함께 짚는다. 비용 절감과 데이터 주권을 목표로 하는 팀이 실제로 부딪히는 함정과 검증 방법을 함께 다룬다.
목차
들어가며
RAG 파이프라인을 운영하다 보면 어느 순간 인프라 청구서가 눈에 들어온다. 나도 비슷했다. 처음엔 Pinecone의 시작 비용이 거의 무료였고 설정도 순식간이라 "일단 부어놓고 나중에 고민하자"는 결론에 쉽게 도달했다.
그런데 임베딩 차원이 1536으로 고정된 모델을 쓰면서 컬렉션이 천만 단위로 불어났다. 그러자 월 청구서가 팀의 다른 SaaS 비용을 다 합친 것에 가까워졌다.
그때부터 "Pinecone을 떠나야 하나"라는 질문이 시작됐다. 결론부터 말하면 모든 팀이 떠나야 하는 건 아니다. 다만 떠난다면 무엇을 잃고 무엇을 얻는지 알고 떠나야 한다. 이 글은 Pinecone에서 FAISS, Chroma로 옮기면서 부딪힌 함정과 검증 방법, 그리고 새로 떠안은 운영 부담을 정리한 기록이다.
왜 Pinecone을 떠나는가
가장 먼저 떠오르는 이유는 비용이다. Pinecone의 과금은 저장한 벡터 양과 읽기·쓰기 빈도에 비례한다. 임베딩이 천만 건을 넘고 RAG를 쓰는 사람이 늘면 청구서가 예상보다 빠르게 오른다. 초기 테스트 단계에서는 보이지 않던 곡선이다.
비용만큼 중요한 게 데이터 주권이다. 금융, 의료, 공공 분야를 상대하는 팀이라면 "사내 문서를 외부 서비스에 영구 저장해도 되는가"라는 질문이 보안 검토에서 반드시 나온다. 임베딩 자체는 원문이 아니지만, 임베딩에서 원문 일부를 복원하는 연구가 늘면서 "임베딩은 안전한 해시"라는 가정이 흔들리고 있다.
세 번째는 세밀한 제어다. RAG가 성숙해질수록 "이 쿼리는 검색 범위를 넓게, 저 컬렉션은 압축해서 메모리에 올리자" 같은 구체적인 요구가 생긴다. Pinecone은 이런 저수준 설정을 의도적으로 숨긴다. 편리하지만, 정확도 한계에 부딪힌 팀에는 답답한 벽이 된다.
마지막은 벤더 종속 회피다. 임베딩 모델은 반년에서 1년마다 더 좋은 것이 나온다. 모델이 바뀌면 벡터 차원과 거리 계산 방식이 달라지고 인덱스를 통째로 다시 만들어야 한다. 인프라 자체가 외부에 묶여 있으면 이 전환이 느려진다.
FAISS와 Chroma의 포지셔닝
먼저 두 도구의 정체를 정리해야 한다. FAISS와 Chroma는 같은 층위의 도구가 아니다.
FAISS는 Meta AI가 만든 ANN(근사 최근접 이웃, 정확도를 조금 포기하고 검색을 크게 빠르게 하는 방식) 검색 라이브러리다. 데이터베이스가 아니다. 인덱스를 메모리에 만들고 직접 검색하는 함수들의 묶음이다.
Chroma는 임베딩 작업에 특화된 데이터베이스다. 컬렉션, 메타데이터 필터, 저장, 서버 모드를 기본으로 제공한다. 내부적으로는 HNSW(그래프로 벡터를 연결해 빠르게 찾는 인덱스)를 쓰고 SQLite에 메타데이터와 문서를 보관한다.
| 항목 | Pinecone | FAISS | Chroma |
|---|---|---|---|
| 형태 | 관리형 서비스 | 라이브러리 | 임베디드·서버 DB |
| 운영 부담 | 거의 없음 | 직접 다 해야 함 | 중간 |
| 메타데이터 필터 | 강력 | 직접 구현 | 기본 제공 |
| 저장 | 자동 | 수동으로 파일 저장 | 자동(SQLite) |
| 인덱스 종류 | 숨겨짐 | 여러 종류 | HNSW 고정 |
| 분산 처리 | 자동 | 직접 설계 | 어려움 |
| 적정 규모 | 수억 이상 | 수십만에서 수억 | 수백만 이하 |
선택 기준은 단순하다. 운영 인력이 부족하고 메타데이터 필터가 자주 필요하면 Chroma. 인덱스를 직접 튜닝해야 하고 메모리와 속도를 극한까지 짜내야 하면 FAISS. 둘 다 한 대의 서버에서 도는 도구라는 점은 늘 기억해야 한다. 수억 벡터를 넘어가면 Milvus, Qdrant, Weaviate 같은 분산 솔루션을 다음 후보로 본다.
마이그레이션 절차
이전 작업의 첫 단계는 지금 Pinecone 인덱스의 정확한 모양을 아는 것이다. 차원 수, 거리 계산 방식, 메타데이터 구조, 네임스페이스 분포를 먼저 파악한다.
from pinecone import Pinecone
pc = Pinecone(api_key=PINECONE_API_KEY)
index = pc.Index("rag-prod")
stats = index.describe_index_stats()
print(stats["dimension"], stats["total_vector_count"])
for ns, info in stats["namespaces"].items():
print(ns, info["vector_count"])여기서 나온 dimension이 새 인덱스의 차원이 된다. 거리 계산 방식(cosine, dotproduct, euclidean)이 원래 인덱스와 다르면 검색 결과가 완전히 어긋나므로 반드시 확인한다.
두 번째 단계는 벡터를 꺼내는 것이다. Pinecone은 전체 벡터를 한 번에 내려받는 API를 제공하지 않는다. ID를 먼저 알아야 fetch가 가능하다는 점이 늘 함정이다. 보통 두 방법을 쓴다.
- 애플리케이션 DB에 원본 문서와 ID가 남아 있으면 그 ID 목록을 나눠 fetch한다.
- Pinecone에 ID 목록만 있다면 목록 조회 API로 페이지 단위로 모은 뒤 fetch한다.
추출은 반드시 다시 실행해도 안전하게 짠다. 천만 벡터를 옮기다 보면 네트워크가 끊기는 일은 반드시 일어난다. 배치 단위로 파일에 적어두고 이미 저장된 배치는 건너뛰게 만든다.
import pyarrow as pa
import pyarrow.parquet as pq
from pathlib import Path
BATCH = 1000
out_dir = Path("export/rag-prod")
out_dir.mkdir(parents=True, exist_ok=True)
def dump_batch(batch_idx, ids):
target = out_dir / f"batch-{batch_idx:06d}.parquet"
if target.exists():
return # 이미 저장된 배치는 건너뜀
res = index.fetch(ids=ids)
rows = []
for vid, v in res.vectors.items():
rows.append({
"id": vid,
"values": v.values,
"metadata": v.metadata or {},
})
table = pa.Table.from_pylist(rows)
pq.write_table(table, target)세 번째 단계는 목표 DB에 적재하는 것이다.
FAISS는 인덱스 종류를 직접 골라야 한다. cosine 유사도를 쓰던 인덱스를 옮길 때는, 벡터를 정규화한 뒤 내적을 쓰는 게 정석이다.
import faiss
import numpy as np
D = 1536
index = faiss.IndexFlatIP(D) # 정규화 + 내적으로 cosine을 흉내낸다
ids, vecs, metas = load_parquet_batches("export/rag-prod")
vecs = np.asarray(vecs, dtype="float32")
faiss.normalize_L2(vecs)
# FAISS는 정수 ID만 받으므로 IDMap2로 감싼다
index = faiss.IndexIDMap2(index)
index.add_with_ids(vecs, np.asarray([hash_id(i) for i in ids], dtype="int64"))
faiss.write_index(index, "rag-prod.faiss")
save_metadata_sidecar(ids, metas, "rag-prod-meta.parquet") # 메타데이터는 FAISS가 모른다여기서 두 가지가 항상 함정이다. FAISS는 문자열 ID를 모른다. 정수 ID로 매핑하고 원래 문자열 ID는 따로 저장해야 한다. FAISS는 메타데이터도 모른다. 필터링이 필요하면 검색 결과의 ID로 후처리하거나, 미리 메타별로 인덱스를 나눠야 한다.
Chroma는 훨씬 간단하다.
import chromadb
client = chromadb.PersistentClient(path="./chroma-rag-prod")
col = client.get_or_create_collection(
name="rag-prod",
metadata={"hnsw:space": "cosine"}, # 거리 계산 방식을 반드시 명시
)
for ids, vecs, metas, docs in iter_parquet_batches("export/rag-prod"):
col.add(ids=ids, embeddings=vecs, metadatas=metas, documents=docs)hnsw:space를 cosine으로 지정하지 않으면 기본값인 L2 거리가 쓰인다. 원래 cosine을 쓰던 인덱스라면 이 한 줄을 빼먹는 순간 결과가 통째로 어긋난다.
네 번째 단계는 가장 자주 빠뜨리는 검증이다. 같은 질문 500~1000개 정도를 옛 인덱스와 새 인덱스에 동시에 던지고 상위 결과가 얼마나 겹치는지(recall@k)와 순위가 얼마나 비슷한지를 잰다.
def recall_at_k(pinecone_ids, new_ids, k=10):
p = set(pinecone_ids[:k])
n = set(new_ids[:k])
return len(p & n) / k
samples = load_query_samples(1000)
scores = []
for q in samples:
pc_top = [m.id for m in index.query(vector=q, top_k=10).matches]
new_top = chroma_query(col, q, k=10)
scores.append(recall_at_k(pc_top, new_top))
print(f"mean recall@10 = {sum(scores)/len(scores):.3f}")내 경험상 FAISS의 정확 검색(Flat)은 거의 1.0에 가깝게 일치한다. 압축 인덱스로 옮기면 recall@10이 0.92에서 0.97 사이로 떨어진다. 이 수치가 실제 답변 품질에 어떻게 반영되는지 별도 평가로 한 번 더 확인해야 안심하고 전환할 수 있다.
운영 단계의 트레이드오프
이전이 끝나도 일은 아직 시작도 안 한 셈이다.
가장 먼저 부딪히는 건 메모리다. FAISS는 인덱스를 메모리에 통째로 올리는 게 기본이다. 1536차원 벡터 천만 개는 약 60GB다. 압축하면 줄어들지만 정확도도 같이 떨어진다. 디스크 기반 인덱스는 별도 설계가 필요해 운영 난이도가 한 단계 올라간다.
Chroma는 다른 종류의 한계가 있다. SQLite 기반 저장은 동시 접속에 약하다. 같은 폴더를 여러 프로세스에서 동시에 쓰는 걸 권장하지 않는다. 트래픽이 늘면 서버 모드로 분리하고 앞에 로드 밸런서를 둬야 한다.
재인덱싱 전략도 미리 준비해야 한다. 임베딩 모델이 바뀌면 차원도 바뀌므로 인덱스를 통째로 다시 만들어야 한다. 서비스를 멈추지 않으려면 옛 인덱스와 새 인덱스를 동시에 운용하면서 새 데이터는 양쪽에 다 넣고 읽기는 점진적으로 새 인덱스로 옮기는 방식이 필요하다. Pinecone이 자동으로 처리해주던 부분을 이제 직접 만들어야 한다.
백업과 복제도 통째로 떠안는다. FAISS는 파일로 저장해 클라우드 스토리지에 올리는 정도가 일반적이다. Chroma는 폴더 전체를 스냅샷으로 떠야 한다. 복제는 쓰기를 큐로 받아 여러 서버에 적용하는 방식을 직접 짜야 한다.
마지막은 관측이다. Pinecone 대시보드가 보여주던 응답 속도, 처리량, 인덱스 크기를 직접 만들어야 한다. 최소한 다음 네 가지는 지표로 잡는다.
| 지표 | 설명 | 기준값 예시 |
|---|---|---|
| 검색 응답 속도 | p50, p99 | p99 100ms 이내 |
| recall@k | 기준 인덱스와의 일치율 | 0.95 이상 |
| 인덱스 크기 | 메모리·디스크 사용량 | RAM의 70% 이하 |
| 쓰기 큐 적체 | 임베딩 처리 지연 | 1분 이내 소진 |
이 지표를 대시보드로 띄워두지 않으면 어느 날 갑자기 메모리가 가득 차고 나서야 문제를 알게 된다.
마치며
비용 절감만 보고 옮기면 운영 비용이 절감액을 넘는 함정에 빠지기 쉽다. 내가 가장 자주 본 실패 패턴은 이랬다. Pinecone 비용을 5분의 1로 줄였는데, 그 뒤 6개월간 엔지니어 한 명이 풀타임으로 인덱스를 돌보고 있었다.
선택 가이드를 단순히 정리하면 이렇다.
- 수백만 벡터 이하, 한 서버로 충분한 규모: Chroma가 가장 빠른 길이다. 메타데이터 필터와 저장이 그냥 된다.
- 수천만 벡터, 검색 속도가 핵심: FAISS와 별도 메타데이터 저장이 합리적이다. 인덱스를 직접 손에 쥘 수 있다.
- 수억 벡터, 여러 조직이 함께 씀, 높은 가용성 필요: Milvus, Qdrant, Weaviate 같은 분산 솔루션을 검토한다.
- 테스트 단계에서 recall, 응답 속도, 운영 인력 비용을 모두 재고 결정한다. 청구서만 보고 결정하면 후회한다.
참고 문헌
- FAISS: A library for efficient similarity search (GitHub)
- FAISS Wiki, Guidelines to choose an index
- Chroma 공식 문서
- Chroma GitHub 저장소
- Pinecone 공식 문서
- Pinecone Python Client (fetch API)
- Johnson et al., Billion-scale similarity search with GPUs (FAISS 논문)
- Malkov & Yashunin, Efficient and robust approximate nearest neighbor search using HNSW