Pinecone으로 벡터 검색 구현하기: 임베딩 저장, 메타데이터 필터, RAG와 문서 챗봇

이 글의 핵심

벡터 검색을 직접 운영하려면 인덱스 구축과 스케일링을 모두 챙겨야 하는데, Pinecone은 이를 관리형 서비스로 넘기는 선택지입니다. 문서를 인덱싱하고 메타데이터 필터로 검색 범위를 좁히는 흐름을 코드로 따라가고, Elasticsearch와의 비교, 무료 사용 범위, 프로덕션 도입 여부 같은 도입 전 질문에도 답합니다.

이 글의 핵심

Pinecone으로 벡터 검색을 구현하는 과정을 정리합니다. 인덱스 생성, 임베딩 저장, 유사도 검색, 메타데이터 필터링, RAG와 LangChain 통합까지 코드 순서대로 따라가면서, 각 단계에서 실제로 문제가 되는 제약과 설계 선택을 함께 설명합니다.

벡터 검색이 필요한 상황

키워드가 일치하지 않아도 찾아야 할 때

“비밀번호를 잊어버렸어요”라는 질문과 “계정 복구 절차” 문서는 겹치는 단어가 거의 없습니다. 키워드 검색(BM25)은 이런 경우 문서를 놓치기 쉽고, 임베딩 모델로 문장을 벡터로 바꿔 거리를 비교하면 의미가 가까운 문서를 찾을 수 있습니다. 반대로 제품 코드, 에러 코드처럼 정확히 일치해야 하는 문자열 검색은 여전히 키워드 검색이 더 정확하므로, 둘 중 하나로 모든 문제를 풀려고 하기보다 용도에 맞게 조합하는 편이 좋습니다.

비슷한 항목을 추천하고 싶을 때

상품 설명이나 사용자 행동을 벡터로 표현하면 “이 항목과 가까운 항목”을 찾는 것이 곧 추천이 됩니다. 규칙 기반 추천처럼 카테고리마다 조건을 손으로 관리하지 않아도 됩니다. 다만 추천 품질은 벡터 DB가 아니라 임베딩이 무엇을 표현하느냐에 달려 있습니다.

LLM에 사내 문서를 근거로 답하게 하고 싶을 때(RAG)

LLM은 학습하지 않은 사내 문서를 모르므로, 질문과 관련된 문서 조각을 찾아 프롬프트에 붙여 줘야 합니다. 이때 “관련 조각 찾기”를 담당하는 것이 벡터 검색입니다. RAG 전체 구조는 RAG 가이드에서 따로 다룹니다.


관리형 벡터 DB로서의 Pinecone

핵심 특징

Pinecone은 관리형(managed) 벡터 데이터베이스입니다. 벡터 검색 자체는 FAISS 같은 라이브러리로도 할 수 있지만, 라이브러리는 메모리 안의 인덱스일 뿐이라 영속화, 동시 쓰기, 복제, 확장을 직접 만들어야 합니다. Pinecone은 이 부분을 서비스로 제공합니다.

  • 근사 최근접 이웃(ANN) 검색: 모든 벡터와 비교하지 않고 근사 인덱스로 빠르게 상위 결과를 찾음
  • 확장성: 서버리스 인덱스는 저장량과 요청량에 따라 과금되고 용량 계획이 필요 없음
  • Metadata 필터링: 벡터와 함께 저장한 속성으로 검색 범위 제한
  • 관리형: 서버, 샤딩, 백업을 직접 운영하지 않음
  • 네임스페이스: 한 인덱스 안에서 테넌트별로 데이터를 분리

관리형의 대가도 분명합니다. 데이터가 외부 서비스에 저장되므로 사내 보안 정책상 허용되는지 먼저 확인해야 하고, 사용량이 커지면 비용이 선형으로 늘어납니다. 이미 PostgreSQL을 운영 중이고 벡터가 수백만 개 이하라면 pgvector로 충분한 경우도 많습니다. 선택지 비교는 pgvector·Qdrant 가이드와 벡터 DB 비교를 참고하세요.


설치와 클라이언트 초기화

설치

pip install pinecone openai

Python SDK 패키지 이름은 예전에 pinecone-client였지만 현재는 pinecone입니다. 오래된 튜토리얼을 따라 pinecone-client를 설치하면 구버전 API(pinecone.init())와 새 API(Pinecone() 클래스)가 섞여 AttributeError가 나기 쉬우니, 두 패키지가 함께 설치되어 있다면 pinecone-client를 제거하세요.

초기화

from pinecone import Pinecone
pc = Pinecone(api_key="your-api-key")
# Index 생성
pc.create_index(
    name="my-index",
    dimension=1536,  # OpenAI embedding dimension
    metric="cosine",
    spec={"serverless": {"cloud": "aws", "region": "us-east-1"}}
)
# Index 연결
index = pc.Index("my-index")

dimension, metric은 인덱스를 만든 뒤 바꿀 수 없습니다. 임베딩 모델을 바꾸려면 새 인덱스를 만들고 전체 문서를 다시 임베딩해야 하므로, 모델을 먼저 확정하고 인덱스를 만드는 순서가 중요합니다. 차원이 맞지 않는 벡터를 upsert하면 “Vector dimension 3072 does not match the dimension of the index 1536” 같은 오류가 납니다.

create_index는 같은 이름의 인덱스가 이미 있으면 409 Conflict 예외를 냅니다. 스크립트를 여러 번 실행하는 환경이라면 pc.has_index("my-index")로 먼저 확인하세요. 또 인덱스 생성 직후에는 준비 상태가 아닐 수 있어서 바로 upsert하면 실패할 수 있습니다. region은 애플리케이션 서버와 가까운 곳을 고르는 것이 검색 지연에 유리합니다. API 키는 예제처럼 코드에 적지 말고 PINECONE_API_KEY 환경 변수로 두면 Pinecone()이 자동으로 읽습니다.


OpenAI로 임베딩 만들기

OpenAI Embedding

from openai import OpenAI
client = OpenAI(api_key="your-api-key")
def get_embedding(text: str) -> list[float]:
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=text
    )
    return response.data[0].embedding
# 사용
embedding = get_embedding("Hello, world!")
print(len(embedding))  # 1536

임베딩은 텍스트를 고정 길이 숫자 배열로 바꾼 것이고, 같은 모델로 만든 벡터끼리만 거리 비교가 의미가 있습니다. 문서는 A 모델로, 질문은 B 모델로 임베딩하면 오류 없이 검색은 되지만 결과가 엉뚱하게 나옵니다. 이 문제는 예외가 나지 않아 발견이 늦으므로, 인덱싱과 검색 코드가 같은 get_embedding 함수를 쓰게 만드는 것이 가장 단순한 방어입니다.

text-embedding-3-small은 1536차원, text-embedding-3-large는 3072차원입니다. 3 시리즈 모델은 dimensions 파라미터로 더 짧은 벡터를 받을 수도 있는데, 이렇게 줄이면 저장 비용과 검색 속도가 좋아지는 대신 정확도가 조금 떨어집니다. 입력은 문자열 대신 문자열 리스트도 받으므로, 문서가 많을 때는 한 번에 여러 개를 보내 요청 수를 줄이는 편이 빠르고 레이트 리밋에도 덜 걸립니다.


Upsert로 벡터 저장하기

Upsert

# 단일 저장
index.upsert(
    vectors=[
        {
            "id": "doc1",
            "values": embedding,
            "metadata": {
                "title": "Document 1",
                "category": "tech",
                "date": "2024-01-01",
                "year": 2024
            }
        }
    ]
)
# 배치 저장
vectors = []
for i, doc in enumerate(documents):
    embedding = get_embedding(doc["text"])
    vectors.append({
        "id": f"doc{i}",
        "values": embedding,
        "metadata": doc["metadata"]
    })
index.upsert(vectors=vectors)

upsert는 이름 그대로 같은 id가 있으면 덮어쓰고 없으면 새로 넣습니다. 그래서 id를 결정하는 방식이 중요합니다. 위 배치 예제의 f"doc{i}"는 반복문 순서에 의존하므로, 다른 문서 묶음을 다시 인덱싱하면 기존 doc0, doc1을 조용히 덮어씁니다. 실무에서는 파일경로#청크번호나 원본 DB의 기본 키처럼 문서 내용에서 결정되는 id를 쓰면, 같은 문서를 다시 넣었을 때 중복 없이 갱신됩니다.

배치 저장도 한 번에 전부 보내면 안 됩니다. 요청 하나의 크기에 제한(레코드 1,000개 또는 2MB 내외)이 있어서, 문서가 수천 개면 요청 크기 초과 오류로 upsert 전체가 거부됩니다. 100개 정도씩 나눠서 upsert하는 것이 일반적입니다. 메타데이터에도 벡터당 40KB 제한이 있으므로 원문 전체를 넣기보다 청크 텍스트와 식별 정보 정도만 넣습니다.

서버리스 인덱스는 쓰기가 반영되기까지 짧은 지연이 있습니다. upsert 직후에 바로 query하면 방금 넣은 벡터가 결과에 안 나오는 경우가 있는데, 테스트 코드에서 이 때문에 간헐적으로 실패하는 일이 흔합니다. 처음 연동할 때 저도 “저장이 안 된다”고 생각해 코드를 한참 살폈는데, index.describe_index_stats()로 벡터 수가 늘어난 것을 확인한 뒤에야 반영 지연이라는 것을 알았습니다.


유사도 검색과 메타데이터 필터링

유사도 검색

query = "How to use Python?"
query_embedding = get_embedding(query)
results = index.query(
    vector=query_embedding,
    top_k=5,
    include_metadata=True
)
for match in results["matches"]:
    print(f"ID: {match['id']}")
    print(f"Score: {match['score']}")
    print(f"Metadata: {match['metadata']}")
    print()

top_k는 가까운 순으로 몇 개를 받을지 정하고, include_metadata=True를 주지 않으면 id와 점수만 돌아옵니다. 코사인 metric에서 score는 클수록 가깝습니다. 점수의 절대값은 임베딩 모델마다 분포가 달라서 “0.8 이상이면 관련 문서” 같은 기준을 다른 모델에 그대로 옮기면 맞지 않습니다. 임계값으로 결과를 자르려면 실제 질문 몇십 개로 점수 분포를 먼저 확인하세요.

Metadata 필터링

results = index.query(
    vector=query_embedding,
    top_k=5,
    filter={
        "category": {"$eq": "tech"},
        "year": {"$gte": 2024}
    },
    include_metadata=True
)

필터는 검색 후에 결과를 거르는 것이 아니라, 조건을 만족하는 벡터 안에서 상위 top_k를 찾습니다. 그래서 필터가 있어도 결과 개수가 줄지 않습니다. 여러 조건을 나열하면 AND로 묶이고, $or, $in, $ne 같은 연산자도 쓸 수 있습니다.

주의할 점은 $gt, $gte, $lt, $lte 같은 범위 연산자가 숫자 값에만 동작한다는 것입니다. "date": {"$gte": "2024-01-01"}처럼 문자열 날짜로 범위 필터를 걸면 원하는 대로 동작하지 않으므로, 예제처럼 year 정수를 따로 저장하거나 날짜를 Unix 타임스탬프로 저장해 비교합니다. 이 제약은 인덱싱 스키마를 정할 때 반영해야 나중에 재인덱싱을 피할 수 있습니다.

여러 고객사의 데이터를 한 인덱스에 담는다면 tenant_id 필터보다 네임스페이스(namespace="tenant-a")로 나누는 편이 안전합니다. 필터는 코드에서 빠뜨리면 다른 고객의 문서가 검색되지만, 네임스페이스는 지정한 공간 밖을 아예 검색하지 않습니다.


RAG: 문서 인덱싱과 답변 체인

문서 인덱싱

from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
# 문서 로드
loader = TextLoader("document.txt")
documents = loader.load()
# 청크 분할
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000,
    chunk_overlap=200
)
chunks = text_splitter.split_documents(documents)
# Pinecone에 저장
for i, chunk in enumerate(chunks):
    embedding = get_embedding(chunk.page_content)
    index.upsert(
        vectors=[{
            "id": f"chunk{i}",
            "values": embedding,
            "metadata": {
                "text": chunk.page_content,
                "source": chunk.metadata.get("source")
            }
        }]
    )

문서를 통째로 임베딩하지 않고 청크로 나누는 이유는 두 가지입니다. 긴 문서 전체를 벡터 하나로 만들면 여러 주제가 평균되어 어떤 질문과도 애매하게 가까워지고, LLM 프롬프트에 붙일 때도 필요한 부분만 넣어야 토큰 비용과 컨텍스트 길이를 감당할 수 있습니다. chunk_size=1000은 문자 수 기준이고, chunk_overlap=200은 문장이 청크 경계에서 잘려 의미가 끊기는 것을 줄이기 위한 겹침입니다. 청크가 너무 작으면 문맥이 부족하고, 너무 크면 검색 정밀도가 떨어지므로 문서 종류에 따라 조정합니다.

Pinecone은 벡터와 메타데이터만 저장하고 원문을 따로 보관하지 않으므로, 검색 결과로 LLM에 넘길 텍스트를 메타데이터 text에 함께 넣었습니다. 위 코드는 흐름을 보여 주기 위해 청크마다 임베딩 요청 1번, upsert 요청 1번을 보내는데, 청크가 수천 개면 매우 느립니다. 임베딩을 리스트로 묶어 요청하고 upsert도 100개 단위로 모아 보내는 것이 좋습니다. 여러 파일을 처리한다면 chunk{i} 대신 {source}#{i} 형태의 id를 써야 파일끼리 덮어쓰지 않습니다.

RAG Chain

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
def rag_query(question: str) -> str:
    # 1. 질문 임베딩
    query_embedding = get_embedding(question)
    # 2. 유사 문서 검색
    results = index.query(
        vector=query_embedding,
        top_k=3,
        include_metadata=True
    )
    # 3. 컨텍스트 구성
    context = "\n\n".join([
        match["metadata"]["text"]
        for match in results["matches"]
    ])
    # 4. LLM 호출
    template = ChatPromptTemplate.from_messages([
        ("system", "Answer the question based on the following context:\n\n{context}"),
        ("human", "{question}")
    ])
    llm = ChatOpenAI(model="gpt-4")
    prompt = template.format_messages(context=context, question=question)
    response = llm.invoke(prompt)
    return response.content
# 사용
answer = rag_query("What is LangChain?")
print(answer)

RAG의 네 단계(질문 임베딩 → 검색 → 컨텍스트 조립 → LLM 호출)를 프레임워크 없이 그대로 적은 코드입니다. 이렇게 직접 쓰면 각 단계에서 무엇이 오가는지 보이기 때문에, 답변이 이상할 때 검색 결과가 나빴는지 프롬프트가 문제였는지 나눠서 확인할 수 있습니다.

RAG 답변이 엉뚱할 때 가장 먼저 볼 곳은 LLM이 아니라 results["matches"]입니다. 경험상 대부분은 관련 청크가 top 3 안에 아예 들어오지 않은 경우이고, 이때 프롬프트를 아무리 다듬어도 나아지지 않습니다. 시스템 프롬프트에 “컨텍스트에 답이 없으면 모른다고 답하라”는 문장을 넣으면 없는 내용을 지어내는 경우를 줄일 수 있고, 출처(source)를 함께 출력하면 사용자가 답변을 검증할 수 있습니다.


LangChain과 연결하기

from langchain_pinecone import PineconeVectorStore
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings()
vectorstore = PineconeVectorStore.from_documents(
    documents=chunks,
    embedding=embeddings,
    index_name="my-index"
)
# 검색
docs = vectorstore.similarity_search("Python tutorial", k=3)
for doc in docs:
    print(doc.page_content)

LangChain의 Pinecone 연동은 예전에는 langchain.vectorstores.Pinecone이었지만 지금은 별도 패키지 langchain-pinecone의 PineconeVectorStore를 씁니다(pip install langchain-pinecone). 구버전 import 경로는 최신 LangChain에서 제거되어 ModuleNotFoundError가 납니다.

from_documents는 임베딩 생성, 배치 upsert, 텍스트를 메타데이터에 넣는 일을 한 번에 처리합니다. 이때 원문은 기본적으로 메타데이터의 text 키에 저장되는데, RAG 절처럼 직접 넣은 데이터와 같은 인덱스를 섞어 쓴다면 키 이름을 맞춰야 LangChain 쪽 검색 결과에서 page_content가 비지 않습니다. OpenAIEmbeddings()의 기본 모델이 인덱스를 만들 때 쓴 모델과 같은지도 확인해야 합니다.


문서 챗봇 만들기

from langchain.chains import RetrievalQA
from langchain_pinecone import PineconeVectorStore
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
# Vector Store
embeddings = OpenAIEmbeddings()
vectorstore = PineconeVectorStore.from_existing_index(
    index_name="my-index",
    embedding=embeddings
)
# QA Chain
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 chatbot(question: str) -> str:
    response = qa_chain.invoke({"query": question})
    return response["result"]
# 사용
print(chatbot("What is the pricing?"))
print(chatbot("How do I get started?"))

from_existing_index는 이미 채워진 인덱스에 연결만 하므로, 인덱싱 스크립트와 챗봇 서버를 분리할 수 있습니다. chain_type="stuff"는 검색된 문서를 전부 하나의 프롬프트에 채워 넣는 방식으로, 가장 단순하지만 k를 키우면 컨텍스트 길이를 넘을 수 있습니다. temperature=0은 같은 질문에 답이 흔들리지 않게 하려는 설정입니다.

RetrievalQA는 LangChain에서 레거시 체인으로 분류되어 새 코드에는 권장되지 않습니다. 최신 버전에서는 create_retrieval_chain이나 LCEL 파이프라인으로 같은 동작을 구성하고, 버전에 따라서는 langchain-classic 패키지를 따로 설치해야 import됩니다. 또 이 챗봇은 대화 기록을 기억하지 않아서 “그럼 가격은요?” 같은 후속 질문은 앞 문맥 없이 검색됩니다. 대화형으로 만들려면 이전 대화를 바탕으로 질문을 독립적인 문장으로 다시 쓴 뒤 검색하는 단계를 추가해야 합니다.


Pinecone 요약

  • 인덱스의 dimension과 metric은 바꿀 수 없으므로 임베딩 모델을 먼저 확정합니다.
  • 문서와 질문은 반드시 같은 임베딩 모델로 변환합니다.
  • id는 반복 순서가 아니라 문서에서 결정되는 값으로 정해야 재인덱싱 시 덮어쓰기와 중복을 피할 수 있습니다.
  • upsert는 배치로 나눠 보내고, 메타데이터는 벡터당 40KB 제한을 고려합니다.
  • 범위 필터($gte 등)는 숫자에만 동작하므로 날짜는 숫자로 저장합니다.
  • 멀티테넌트 데이터는 필터보다 네임스페이스로 분리합니다.
  • RAG 품질 문제는 먼저 검색 결과를 확인하고, 그다음 프롬프트를 봅니다.

같이 보면 좋은 글


자주 묻는 질문 (FAQ)

Q. Elasticsearch와 비교하면 어떤가요?

A. 목적이 다릅니다. Elasticsearch는 키워드 검색, 집계, 로그 분석에 강하고 최근 버전은 kNN 벡터 검색도 지원합니다. Pinecone은 벡터 검색만 하는 대신 운영을 신경 쓸 필요가 없습니다. 이미 Elasticsearch를 운영 중이고 키워드와 벡터 검색을 함께 써야 한다면 Elasticsearch 쪽이 구성 요소가 적고, 벡터 검색만 필요하고 인프라 운영을 줄이고 싶다면 Pinecone이 편합니다.

Q. 무료로 사용할 수 있나요?

A. 네, Starter 플랜이 무료로 제공됩니다. 다만 저장 용량, 읽기·쓰기 사용량, 인덱스 개수, 선택 가능한 리전에 제한이 있고 조건은 바뀌어 왔으므로, 프로젝트 규모를 정하기 전에 공식 가격 페이지에서 현재 한도를 확인하세요.

Q. 다른 임베딩 모델을 사용할 수 있나요?

A. 네, Pinecone은 벡터 값만 저장하므로 OpenAI 외에 Cohere, Hugging Face의 오픈소스 모델 등 무엇이든 쓸 수 있습니다. Pinecone 자체 호스팅 임베딩(Inference API)도 있습니다. 중요한 것은 인덱스 차원과 모델 출력 차원을 맞추고, 인덱싱과 검색에 같은 모델을 쓰는 것입니다.

Q. Pinecone을 도입하기 전에 무엇을 확인해야 하나요?

A. 관리형 서비스라 운영 부담은 적지만, 데이터가 외부에 저장되는 것이 보안 정책상 괜찮은지, 사용량이 커졌을 때 비용이 예산 안에 드는지, 리전이 서비스 위치와 맞는지는 도입 전에 확인해야 합니다.