Vector DB

Pinecone에서 FAISS/Chroma로: 벡터 DB 마이그레이션 실전 가이드

관리형 벡터 DB인 Pinecone에서 오픈소스 대안인 FAISS, Chroma로 마이그레이션하는 동기, 아키텍처 차이, 데이터 이전 절차, 그리고 운영 단계의 트레이드오프를 정리한다. 비용 절감과 데이터 주권 확보를 목표로 하는 팀이 실제로 부딪히는 함정과 검증 전략을 함께 다룬다.

2026.06.04 · 7분 읽기 · 1,719 단어
📊 슬라이드

들어가며

RAG 파이프라인을 운영하다 보면 어느 순간 인프라 청구서가 눈에 들어오기 시작한다. 나도 비슷한 경로를 거쳤다. 초기엔 Pinecone의 시작 비용이 무료에 가까웠고, 환경 설정에 들이는 시간이 워낙 짧아 "일단 Pinecone에 부어놓고 나중에 고민하자"는 결론에 쉽게 도달했다. 그러나 임베딩 차원이 1536으로 고정된 OpenAI text-embedding-3-small을 쓰면서 컬렉션이 천만 단위로 불어나자, 월 청구서가 팀 내 다른 SaaS를 모두 합친 액수에 근접하기 시작했다.

그 시점부터 "Pinecone을 떠나야 하나"라는 질문이 본격적으로 등장했다. 결론부터 말하면, 모든 팀이 떠나야 하는 것은 아니다. 다만 떠나야 한다면 무엇을 잃고 무엇을 얻는지 분명히 알고 떠나야 한다. 이 글은 Pinecone에서 FAISS, Chroma로 이전하면서 내가 부딪혔던 함정과 검증 전략, 그리고 운영 단계에서 새로 떠안게 된 부담을 정리한 기록이다.

왜 Pinecone을 떠나는가

가장 먼저 떠오르는 이유는 당연히 비용이다. Pinecone의 과금 모델은 podded 인덱스든 serverless든 본질적으로 "저장한 벡터의 양과 읽기/쓰기 빈도"에 비례한다. 임베딩이 천만 건을 넘어가고 RAG가 사내 사용자에게 일반화되면서 read units가 같이 따라 늘어나면, 청구서가 선형 이상으로 보이는 구간이 등장한다. 초기 POC 단계에서는 보이지 않던 비용 곡선이다.

비용 못지않게 중요한 것은 데이터 주권이다. 금융, 의료, 공공 영역의 고객사를 상대하는 팀이라면 "사내 문서 임베딩을 외부 SaaS에 영구 저장해도 되는가"라는 질문이 보안 검토에서 반드시 나온다. 임베딩 자체는 원문이 아니지만, 임베딩 역추적(embedding inversion) 공격 연구가 활발해지면서 "임베딩 = 안전한 해시"라는 가정이 흔들리고 있다. 규제 환경에서는 온프레미스 또는 VPC 내부에서 인덱스를 직접 통제하고 싶다는 요구가 강해진다.

세 번째는 세밀한 제어 욕구다. RAG 파이프라인이 성숙해질수록 "이 쿼리는 HNSW의 efSearch를 64로 두고, 저쪽 컬렉션은 IVF-PQ로 압축해 RAM에 올리자" 같은 구체적 요구가 생긴다. Pinecone은 이런 저수준 튜닝을 의도적으로 숨기는 추상화 계층을 제공한다. 편의성이 강점이지만, 정확도 한계에 부딪힌 팀에게는 답답한 벽이 된다.

마지막으로 벤더 락인 회피다. 임베딩 모델은 6개월~1년 주기로 더 좋은 것이 나온다. text-embedding-ada-002에서 3-large로, BGE에서 BGE-M3로 바뀌는 동안 벡터 차원과 거리 척도가 변하고, 인덱스를 통째로 다시 만들어야 하는 일이 반복된다. 이때 인프라 자체가 외부 종속되어 있으면 모델 교체 의사결정이 느려진다.

FAISS와 Chroma의 포지셔닝 비교

먼저 두 도구의 정체성을 정리해야 한다. FAISS와 Chroma는 같은 층위의 도구가 아니다.

FAISS는 Meta AI Research에서 만든 ANN(Approximate Nearest Neighbor) 검색 라이브러리다. 데이터베이스가 아니다. 인덱스 객체를 메모리에 만들고 직접 검색하는 C++/Python API의 묶음이다.

Chroma는 임베딩 워크로드에 특화된 데이터베이스다. 컬렉션, 메타데이터 필터, 영속화, 클라이언트-서버 모드를 기본 제공한다. 내부적으로는 HNSWlib을 인덱스로 쓰고, SQLite에 메타데이터와 문서를 보관한다.

이 차이가 선택을 가른다. 아래 표가 내가 이전 결정을 내릴 때 책상에 붙여두고 본 비교다.

항목 Pinecone FAISS Chroma
형태 관리형 SaaS 라이브러리 임베디드/서버 DB
운영 부담 거의 없음 직접 모든 것 중간
메타데이터 필터링 강력 직접 구현 기본 제공
영속화 자동 write_index로 수동 자동(SQLite)
인덱스 종류 추상화됨 Flat/IVF/HNSW/PQ 등 풍부 HNSW(고정)
분산/샤딩 자동 직접 설계 어려움
메모리 모델 추상화됨 전량 RAM 상주가 기본 RAM + SQLite
최적 규모 수억~ 수십만~수억(샤딩 시) ~수백만

선택 기준은 단순하게 잡았다. 운영 인력이 부족하고, 메타데이터 필터링과 컬렉션 추상화가 자주 필요하면 Chroma. 인덱스 알고리즘을 직접 튜닝해야 하고, 메모리/속도 한계를 끝까지 짜내야 하면 FAISS. 둘 다 단일 노드 중심 도구라는 점은 항상 머리에 둬야 한다. 수억 벡터를 넘어가면 Milvus, Qdrant, Weaviate 같은 분산 솔루션이 다음 후보로 들어온다.

마이그레이션 절차

이전 작업의 첫 단계는 의외로 현재 Pinecone 인덱스의 정확한 모양을 아는 것이다. 차원 수, 거리 측정법, 메타데이터 스키마, 네임스페이스 분포를 먼저 파악해야 한다. describe_index_stats가 출발점이다.

from pinecone import Pinecone

pc = Pinecone(api_key=PINECONE_API_KEY)
index = pc.Index("rag-prod")

stats = index.describe_index_stats()
# 핵심: dimension, total_vector_count, namespaces별 분포
print(stats["dimension"], stats["total_vector_count"])
for ns, info in stats["namespaces"].items():
    print(ns, info["vector_count"])

여기서 나오는 dimension이 곧 새 인덱스의 차원이 된다. 차원 일치 확인하기 — 너무 당연해서 빠뜨리기 쉽지만, 거리 측정법(cosine/dotproduct/euclidean)이 Pinecone과 새 인덱스에서 다르면 top-k가 완전히 어긋난다. cosine을 쓰던 인덱스를 FAISS의 IndexFlatL2로 옮기면 결과가 완전히 달라진다는 얘기다.

두 번째 단계는 벡터를 배출하는 것이다. Pinecone은 "전체 벡터를 한 번에 내려받는" 단일 API를 제공하지 않는다. 따라서 ID를 알아야 fetch가 가능하다는 점이 항상 함정이다. 보통은 두 가지 방법을 쓴다.

  1. 애플리케이션 DB(예: Postgres)에 원본 문서와 ID가 남아 있다면, 그 ID 목록을 batch로 잘라 index.fetch(ids=[...])로 가져온다.
  2. Pinecone에 ID 목록만 있다면 list_paginated API로 ID를 페이지네이션해 끌어모은 뒤 fetch한다.

추출은 반드시 멱등하게 짠다. 천만 벡터를 한 번에 옮기다 보면 네트워크 단절은 통계적으로 보장된 실패 모드다. 배치 단위로 parquet에 적어두고, 이미 적힌 배치는 건너뛰는 식으로 구현했다.

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와 Chroma 각각의 코드를 정리해본다.

FAISS는 인덱스 종류를 직접 선택해야 한다. cosine 유사도를 쓰던 Pinecone 인덱스를 옮길 때는, 벡터를 L2 정규화한 뒤 내적(IndexFlatIP)을 쓰는 것이 정석이다.

import faiss
import numpy as np

D = 1536
# cosine을 흉내내려면 정규화 + 내적
index = faiss.IndexFlatIP(D)
# 1억 단위로 늘어날 가능성이 있으면 HNSW가 현실적
# index = faiss.IndexHNSWFlat(D, 32)  # M=32

ids, vecs, metas = load_parquet_batches("export/rag-prod")
vecs = np.asarray(vecs, dtype="float32")
faiss.normalize_L2(vecs)  # cosine 흉내의 핵심

# 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")
# 메타데이터는 별도 저장 — FAISS 자체는 메타를 모른다
save_metadata_sidecar(ids, metas, "rag-prod-meta.parquet")

여기서 두 가지가 항상 함정이다. 첫째, FAISS는 문자열 ID를 모른다. IndexIDMap2로 정수 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가 적용된다. Pinecone에서 cosine을 쓰던 인덱스라면 이 한 줄이 빠질 때 결과가 통째로 어긋난다.

네 번째이자 가장 자주 건너뛰는 단계가 검증이다. 동일한 쿼리 셋(500~1000개 정도면 충분하다)을 Pinecone과 새 인덱스에 동시에 던지고, top-k의 교집합 비율 — 즉 recall@k — 와 순위 상관계수(Spearman ρ)를 측정한다.

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에 가깝게 일치하지만(완전 검색이라 당연하다), HNSW나 IVF로 옮기면 recall@10이 0.92~0.97 구간으로 떨어진다. 이 수치가 RAG 응답 품질에 어떻게 반영되는지를 실제 답변 평가셋으로 한 번 더 확인해야 안심하고 컷오버할 수 있다.

운영 단계의 트레이드오프

이전이 끝나도 일은 시작도 안 한 셈이다. 가장 먼저 부딪히는 것은 메모리다. FAISS는 인덱스를 메모리에 통째로 올리는 것이 기본이다. 1536차원 float32 벡터 천만 개는 단순 계산으로 약 60GB다. HNSW는 그래프 오버헤드까지 더해진다. 압축(IndexPQ, IndexIVFPQ)으로 줄일 수 있지만 recall이 같이 떨어진다. 디스크 기반 인덱스(OnDiskInvertedLists)는 별도 설계가 필요하고, 운영 난이도가 한 단계 올라간다.

Chroma는 다른 종류의 한계가 있다. SQLite 기반 영속 계층은 동시성에 약하다. PersistentClient는 같은 디렉터리를 여러 프로세스에서 동시에 쓰는 것을 권장하지 않는다. 트래픽이 늘어나면 chromadb 서버 모드로 분리하고, 그 앞에 로드 밸런서를 둬야 한다. 이 시점이 되면 "그냥 Pinecone 쓸걸"이라는 생각이 한 번씩 든다.

재인덱싱 전략도 미리 준비해야 한다. 임베딩 모델을 ada-002에서 3-large로 바꾸면 차원이 1536에서 3072로 바뀌므로 인덱스를 통째로 다시 만들어야 한다. 무중단으로 가려면 듀얼 인덱스(v1, v2)를 동시에 운용하면서, 신규 데이터는 양쪽에 모두 적재하고 읽기는 v1에서 점진적으로 v2로 옮기는 패턴이 필요하다. Pinecone이 자동으로 처리해주던 것을 직접 만들어야 하는 영역이다.

백업과 복제도 통째로 떠안는다. FAISS는 write_index로 파일을 떨궈 S3에 올리는 정도가 일반적이다. Chroma는 디렉터리 전체를 스냅샷으로 떠야 한다. 복제는 사실상 "쓰기를 모두 큐로 받아 여러 노드에 적용"하는 패턴을 직접 짜야 한다. Pinecone의 가용성을 일부라도 흉내내려면 운영 코드가 꽤 늘어난다.

마지막으로 관측성이다. Pinecone 대시보드가 보여주던 latency, throughput, index size 그래프를 직접 만들어야 한다. 나는 다음 네 가지를 최소 지표로 잡았다.

지표 설명 임계값 예시
query latency p50/p99 검색 응답 시간 p99 < 100ms
recall@k 기준 인덱스 대비 일치율 ≥ 0.95
index size 메모리/디스크 사용량 RAM의 70% 이하
write backlog 임베딩 큐 적체 1분 이내 소진

이걸 Prometheus + Grafana로 띄워두지 않으면 어느 날 갑자기 RAM이 터지고서야 문제를 알게 된다.

마치며

비용 절감만 보고 이전하면 운영 비용이 청구서 절감액을 초과하는 함정에 빠지기 쉽다. 내가 본 가장 흔한 실패 패턴은 "Pinecone 월 비용을 1/5로 줄였는데, 그 후 6개월간 인프라 엔지니어 1명이 풀타임으로 인덱스를 돌보고 있더라"였다.

선택 가이드를 단순화하면 이렇다.

  • 수백만 벡터 이하, 단일 노드로 충분한 워크로드 → Chroma가 가장 빠른 이전 경로다. 메타데이터 필터링과 영속화가 그냥 된다.
  • 수천만 벡터, 검색 속도가 핵심, 메타데이터는 단순 → FAISS + 메타데이터 사이드카가 합리적이다. 인덱스 알고리즘을 손에 쥘 수 있다.
  • 수억 벡터, 멀티 테넌시, 고가용성 요구 → Milvus, Qdrant, Weaviate 같은 분산 솔루션을 다음 후보로 검토하라. FAISS 샤딩을 직접 짜는 것보다 그쪽이 빠르다.
  • POC 단계에서 recall, latency, 운영 인력 비용을 모두 측정한 뒤 결정하라. 청구서만 보고 결정하면 후회한다.

다음에 다룰 것은 임베딩 모델을 교체할 때의 무중단 듀얼 인덱스 전환 패턴이다. 이게 사실 마이그레이션보다 더 자주 마주치는 운영 이슈인데, 정리된 글이 거의 없어서 직접 써볼 생각이다.

참고 문헌