ChromaDB로 로컬 벡터 검색 만들기: 임베딩 전략, 메타데이터 필터, LangChain RAG

이 글의 핵심

임베딩 모델을 바꿨더니 검색 결과가 엉뚱해지는 문제는 벡터가 어디서 만들어지고 어떤 공간에 저장되는지 이해하지 못했을 때 자주 생깁니다. 메타데이터 필터가 벡터 검색 이후 어떤 제약으로 작동하는지, 여러 프로세스가 같은 저장소를 쓸 때의 주의점, 운영 전에 확인할 트러블슈팅 항목을 함께 다룹니다.

이 글의 핵심

ChromaDB는 임베딩 벡터를 저장하고 유사도 검색(근사 최근접 이웃, ANN) 과 메타데이터 필터를 결합해 문서를 꺼내는 데 초점을 둔 오픈소스 벡터 저장소입니다. 이 글은 API 사용법만 나열하지 않으며, 내부적으로 어떤 단계가 일어나는지, 프로덕션에서 무엇을 직접 책임져야 하는지, 장애 시 어디부터 볼지를 한 흐름으로 정리합니다.


내부 동작과 메커니즘

임베딩은 어디서 생성되는가

문서 문자열을 collection.add(documents=[...])만 넘기면 Chroma는 기본 임베딩 함수로 벡터를 만듭니다. 반대로 embedding_function을 넘기면 외부 API(예: OpenAI)·로컬 모델이 벡터를 만들고, Chroma는 고차원 벡터와 메타데이터·ID를 함께 보관합니다. 즉 “의미 검색의 품질”은 대부분 임베딩 모델·전처리에 달려 있으며, Chroma는 색인·검색·필터링 인프라에 가깝습니다.

근사 최근접 이웃(ANN)과 HNSW

정확한 k-NN(전수 비교)은 벡터 수가 많아지면 비용이 폭증합니다. Chroma는 설정에 따라 그래프 기반 ANN(예: HNSW 계열) 을 사용해 정확도와 속도의 타협을 봅니다. metadata의 hnsw:* 옵션은 그래프 구축·탐색 특성과 연결되므로, 동일 데이터라도 파라미터를 바꾸면 최근접 이웃 결과가 미세하게 달라질 수 있습니다. 재현 가능한 평가를 위해서는 모델 버전·거리 척도·HNSW 설정을 실험 설정에 고정해야 합니다.

메타데이터 필터와 “벡터 검색 이후의 제약”

where·where_document는 ANN 후보를 좁히거나 후처리 필터로 사용됩니다. 필터 표현식이 복잡할수록 인덱스 설계·스키마 일관성이 중요해집니다. 운영에서는 필드 타입 불일치(문자열 vs 숫자)나 누락된 키가 조용히 결과 집합을 비우는 흔한 원인입니다.

영속화(PersistentClient)의 의미

PersistentClient(path=...)는 디스크에 컬렉션 상태를 유지합니다. 다만 동시에 여러 프로세스가 동일 경로를 쓰면 잠금·손상 문제가 날 수 있어, 단일 워커 전제 또는 서버 모드로 역할을 분리하는 것이 일반적입니다.


설치 및 기본 사용

pip install chromadb
import chromadb

client = chromadb.Client()
collection = client.create_collection(name="my_collection")
collection.add(
    documents=["This is document 1", "This is document 2"],
    metadatas=[{"source": "doc1"}, {"source": "doc2"}],
    ids=["id1", "id2"],
)
results = collection.query(query_texts=["document about Python"], n_results=2)
print(results)

chromadb.Client()는 메모리에만 데이터를 두는 임시 클라이언트라서, 프로세스가 끝나면 컬렉션이 모두 사라집니다. 실험용으로는 편하지만, “어제 넣은 문서가 없다”는 질문의 가장 흔한 원인이기도 합니다. 디스크에 남기려면 뒤에서 설명할 PersistentClient를 써야 합니다. 또 create_collection은 같은 이름의 컬렉션이 이미 있으면 에러를 내므로, 스크립트를 여러 번 실행할 때는 get_or_create_collection이 편합니다.

documents만 넘겼으므로 Chroma는 기본 임베딩 함수로 벡터를 만듭니다. 기본값은 all-MiniLM-L6-v2 문장 임베딩 모델(384차원)을 ONNX 런타임으로 돌리는 방식이고, 처음 실행할 때 모델 파일을 내려받기 때문에 첫 add가 유난히 오래 걸리거나, 인터넷이 막힌 서버에서는 다운로드 단계에서 실패합니다. 이 모델은 영어 위주로 학습되어 한국어 문서의 검색 품질이 낮은 편이라, 한국어 데이터라면 다국어 임베딩 모델을 명시적으로 지정하는 것이 좋습니다.

query 결과는 문서·거리·메타데이터를 리스트의 리스트 형태로 돌려줍니다. 바깥 리스트가 질의(query_texts에 넣은 문장) 단위이고, 안쪽 리스트가 그 질의의 상위 결과라서 질의가 하나여도 [0]으로 한 번 꺼내야 합니다. 아래 예시처럼 키 이름으로 접근합니다.

for i, doc in enumerate(results["documents"][0]):
    print(f"{i+1}. {doc}")
    print(f"   Distance: {results['distances'][0][i]}")

임베딩 전략

기본 임베딩과 거리 척도

collection = client.create_collection(
    name="my_collection",
    metadata={"hnsw:space": "cosine"},
)
collection.add(
    documents=["Python is great", "JavaScript is popular"],
    ids=["id1", "id2"],
)

코사인·L2·내적 등은 임베딩 모델 학습 시 가정한 유사도와 맞아야 합니다. 예를 들어 정규화된 벡터라면 코사인과 내적이 동치에 가까워지는 등, 모델 카드의 권장 거리를 따르는 것이 안전합니다.

지정하지 않았을 때 Chroma의 기본 거리 척도는 제곱 L2 거리입니다. 코사인을 전제로 만든 임베딩을 기본 설정 컬렉션에 넣어도 에러는 나지 않고 순위만 미묘하게 달라지므로, 이 차이를 알아채기 어렵습니다. 또 거리 척도는 컬렉션을 만들 때 정해지고 나중에 바꿀 수 없습니다. 척도를 바꾸려면 새 컬렉션을 만들어 데이터를 다시 넣어야 합니다. 결과의 distances 값은 “거리”이므로 작을수록 가깝다는 점도 헷갈리기 쉬운데, 코사인 공간에서 반환되는 값은 유사도가 아니라 1 - 코사인 유사도입니다. 버전 1.x 이후에는 metadata={"hnsw:space": ...} 대신 configuration 인자로 인덱스 설정을 넘기는 방식이 도입되었으므로, 사용하는 버전의 문서를 확인하는 것이 좋습니다.

커스텀 임베딩(운영 권장)

from chromadb.utils import embedding_functions

openai_ef = embedding_functions.OpenAIEmbeddingFunction(
    api_key="your-api-key",
    model_name="text-embedding-3-small",
)
collection = client.create_collection(
    name="my_collection",
    embedding_function=openai_ef,
)

프로덕션에서는 API 키를 코드에 박지 않고 환경 변수·시크릿 매니저로 주입하며, 모델 이름을 설정으로 고정해 배포마다 임베딩 공간이 바뀌지 않게 합니다.

임베딩 모델을 바꾸면 벡터 차원도 달라지는 경우가 많습니다. 기본 모델은 384차원, text-embedding-3-small은 1536차원이라, 기본 모델로 만든 컬렉션에 OpenAI 임베딩을 넣거나 반대로 질의하면 “Embedding dimension 1536 does not match collection dimensionality 384” 같은 에러가 납니다. 차원이 같은 모델끼리 바꾸면 에러조차 나지 않고 결과만 엉망이 되므로 더 위험합니다. 제가 이 조합에서 가장 자주 본 실수는 컬렉션을 만들 때는 embedding_function을 넘겼는데, 다른 스크립트에서 get_collection으로 다시 열 때는 넘기지 않은 경우입니다. 구버전 Chroma는 임베딩 함수 설정을 저장하지 않아서, 이때 질의는 기본 모델로 임베딩되고 저장된 벡터와 다른 공간끼리 비교됩니다. 컬렉션을 열 때마다 같은 임베딩 함수를 넘기는 헬퍼 함수를 하나 두면 이런 실수를 막을 수 있습니다.


검색과 메타데이터

results = collection.query(
    query_texts=["Python tutorial"],
    n_results=5,
    where={"category": "programming"},
    where_document={"$contains": "beginner"},
)

where는 구조화 필터, where_document는 문서 본문 조건입니다. RAG 품질 이슈의 상당수는 청크 경계·메타데이터 누락에서 오므로, 인덱싱 파이프라인에서 문서 ID·출처·버전을 메타데이터에 넣는 패턴이 흔합니다.

where에 조건을 여러 개 쓰려면 {"category": "programming", "level": "beginner"}처럼 키를 나열하는 것이 아니라 {"$and": [{"category": "programming"}, {"level": "beginner"}]}처럼 논리 연산자로 감싸야 하고, 그렇지 않으면 “Expected where to have exactly one operator” 류의 검증 에러가 납니다. 비교 연산자는 $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin을 지원합니다. 메타데이터 값은 문자열·숫자·불리언 같은 스칼라만 허용되어 리스트나 중첩 딕셔너리를 넣으면 add에서 에러가 나므로, 태그처럼 여러 값을 가진 필드는 문자열로 합치거나 필드를 나눠야 합니다. 필터가 너무 좁으면 n_results=5를 요청해도 그보다 적은 결과가 돌아올 수 있다는 점도 결과 처리 코드에서 고려해야 합니다.


LangChain 통합

from langchain.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain.document_loaders import TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

loader = TextLoader("document.txt")
documents = loader.load()
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000)
chunks = text_splitter.split_documents(documents)

embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embeddings,
    persist_directory="./chroma_db",
)

docs = vectorstore.similarity_search("Python tutorial", k=3)
for doc in docs:
    print(doc.page_content)

LangChain은 문서 분할·로더·임베딩을 담당하며, Chroma는 저장·검색을 담당합니다. 버전 업그레이드 시 LangChain과 Chroma 클라이언트 메이저 버전 호환표를 반드시 확인합니다.

위 예제의 from langchain.vectorstores import Chroma와 langchain.document_loaders 경로는 LangChain 0.1 시절의 import이며, 최신 버전에서는 폐기 경고를 내거나 제거되었습니다. 현재는 pip install langchain-chroma 후 from langchain_chroma import Chroma를, 로더는 langchain_community.document_loaders를, 분할기는 langchain_text_splitters를 씁니다. 또 예전 예제에 자주 나오는 vectorstore.persist() 호출은 Chroma 0.4 이후 자동 저장으로 바뀌면서 필요 없어졌습니다. chunk_size=1000은 문자 수 기준이고 오버랩(chunk_overlap)을 지정하지 않으면 기본값이 적용되므로, 문장이 청크 경계에서 잘려 검색에 걸리지 않는 문제가 보이면 오버랩과 구분자 설정부터 확인해 볼 만합니다.


RAG 챗봇 예시

from langchain.chains import RetrievalQA
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4", temperature=0)
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
)

def ask(question: str) -> str:
    response = qa_chain.invoke({"query": question})
    return response["result"]

print(ask("What is Python?"))

RetrievalQA의 반환 형식은 래퍼 버전에 따라 다를 수 있어, 프로덕션에서는 Pydantic으로 응답 스키마를 고정하는 편이 안전합니다. RetrievalQA 자체도 최신 LangChain에서는 폐기 예정(deprecated)으로 표시되어 있고, create_retrieval_chain이나 LCEL로 검색기와 프롬프트를 직접 연결하는 방식이 권장됩니다.

chain_type="stuff"는 검색된 청크 k개를 그대로 프롬프트에 이어 붙이는 가장 단순한 방식입니다. 청크 크기 1000자에 k=3이면 대략 3000자가 프롬프트에 들어가므로, k를 늘리거나 청크를 키우면 토큰 비용과 지연이 함께 늘고 모델의 컨텍스트 한도에 걸릴 수 있습니다. 반대로 답이 엉뚱할 때는 LLM보다 검색 단계를 먼저 의심하는 편이 빠릅니다. vectorstore.similarity_search_with_score로 질문마다 어떤 청크가 어떤 거리로 뽑혔는지 로그에 남겨 보면, 관련 문서가 아예 검색되지 않은 것인지, 검색은 됐는데 모델이 무시한 것인지 구분할 수 있습니다.


Persistent Storage

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_or_create_collection(name="my_collection")
collection.add(documents=["Document 1", "Document 2"], ids=["id1", "id2"])

client = chromadb.PersistentClient(path="./chroma_db")
collection = client.get_collection(name="my_collection")

백업 시에는 디렉터리 스냅샷과 함께 임베딩 모델 버전·청킹 파라미터를 메타데이터로 남겨야 동일 검색 결과를 재현할 수 있습니다.


프로덕션 패턴

  • 단일 임베딩 모델·단일 거리 척도: 실험실에서 바꾼 모델을 프로덕션에 그대로 옮기면 기존 벡터와 공존 불가인 경우가 많아, 마이그레이션(재임베딩) 계획이 필요합니다.
  • 청크 전략: RecursiveCharacterTextSplitter 한 가지에 의존하기보다 문서 유형별로 구분자·크기·오버랩을 조정합니다.
  • 멱등성: 동일 문서를 다시 넣을 때 ID 전략(소스 해시 등)으로 중복을 막습니다.
  • 관측: 검색 단계에서 top-k 거리 분포·필터 탈락률을 로깅하면 품질 저하를 조기에 감지할 수 있습니다.
  • 보안: 로컬이라도 민감 문서는 암호화 저장소 + 접근 통제와 함께 다루고, 클라우드 Chroma를 쓸 때는 네트워크·인증·감사 로그를 검토합니다.

트러블슈팅

증상흔한 원인점검
검색 결과가 항상 엉뚱함임베딩 모델 변경, 거리 척도 불일치모델·hnsw:space·벡터 재생성 여부
필터 후 결과 없음메타데이터 타입·키 누락인덱싱 시 샘플 레코드 스키마 검증
느린 쿼리ANN 파라미터·데이터 규모인덱스 설정, n_results·청크 수
프로세스 간 손상/락동일 경로 다중 쓰기단일 프로세스 직렬화 또는 서버 분리
LangChain과 버전 충돌클라이언트 API 변경호환 버전 핀(pin)

정리 및 체크리스트

  • ChromaDB: 임베딩 벡터 + 메타데이터 + ANN 검색을 묶는 벡터 저장소.
  • 품질: 임베딩·청킹·거리 척도가 검색 품질을 결정하며, Chroma는 인프라.
  • 운영: 영속 경로 동시성, 모델 버전 고정, 백업 시 재현 메타데이터.

구현 체크리스트

  • 임베딩 모델·거리 척도 확정
  • 컬렉션 메타데이터 스키마 정의
  • 청킹·ID·재색인 정책
  • 로컬/서버 배포 모드 결정
  • 백업·마이그레이션 절차

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. 임베딩 모델을 바꿨더니 검색 결과가 엉뚱해졌습니다. 왜 그런가요?

A. 컬렉션에 저장된 벡터는 저장할 때 쓴 임베딩 모델의 공간에 있어서, 질의만 새 모델로 임베딩하면 서로 다른 공간의 벡터를 비교하게 됩니다. 에러 없이 결과만 조용히 나빠지므로 알아채기 어렵습니다. 모델을 바꾸면 기존 문서를 새 모델로 다시 임베딩해 새 컬렉션에 넣고, 컬렉션 메타데이터의 거리 척도(hnsw:space)도 모델 권장값과 맞는지 함께 확인하세요.