S2 · 벡터 검색과 Pinecone 기초
Agentic RAG 실전 (14시간) · Day 1
S1에서 RAG의 첫 단계가 '의미가 가까운 문서 검색'이라 했다. 이 교시는 그 검색을 가능하게 하는 원리(임베딩·유사도)와 도구(Pinecone)를 다룬다.
목표
- 임베딩과 벡터 유사도, 의미 기반 검색이 되는 이유를 이해한다.
- 벡터 데이터베이스가 필요한 이유를 안다.
- Pinecone Serverless 인덱스를 만들고 벡터를 upsert/query 한다.
1. 임베딩과 유사도
임베딩(embedding) 은 텍스트를 의미를 담은 고차원 벡터로 변환한 것이다. 의미가 가까운 텍스트는 벡터 공간에서 가깝게 위치한다. 벡터를 이루는 숫자의 개수가 차원(dimension) 이며, 임베딩 모델이 결정한다.
- multilingual-e5-large(한국어) → 1024차원
- OpenAI text-embedding-3-small → 1536차원
가까움은 보통 코사인 유사도로 잰다(방향이 같을수록 1에 가깝고 크기는 무시). 검색은 질문을 임베딩한 뒤 저장된 벡터 중 코사인 유사도가 높은 것을 꺼내는 방식이다.
차원 일치 원칙: 임베딩 차원과 인덱스 dimension 이 같아야 한다. 모델을 바꾸면 인덱스도 그 차원으로 다시 만든다.
2. 벡터 데이터베이스와 Pinecone
문서가 수만~수백만 조각이면 매 질의마다 전수 비교는 느리다. 벡터 데이터베이스는 벡터를 미리 색인해 가장 가까운 것들을 빠르게(근사) 찾는다. 이 과정에서는 Pinecone을 쓴다.
- 인덱스(index): 벡터가 저장되는 단위(테이블에 해당).
- Serverless: 용량 사전 할당 없이 사용량 기반 과금.
- 네임스페이스(namespace): 한 인덱스 내부의 논리적 칸막이(테넌트·실험 분리).
- 메타데이터(metadata): 벡터에 함께 저장하는 부가 정보(원문·출처). 검색 후 표시나 필터에 사용.
3. 실행 준비
S0에서 구성한 프로젝트의 .venv 커널로 실행한다. 추가 설치는 없다. 아래 셀로 .env 의 키만 로드한다.
import os
from getpass import getpass
# S0 프로젝트 루트의 .env 에서 키 로드 (없으면 입력으로 폴백)
try:
from dotenv import load_dotenv
load_dotenv()
except ImportError:
pass
for name in ["OPENAI_API_KEY", "PINECONE_API_KEY"]:
if not os.environ.get(name):
os.environ[name] = getpass(f"{name}: ")
print("키:", {k: bool(os.environ.get(k)) for k in ["OPENAI_API_KEY", "PINECONE_API_KEY"]})
키: {'OPENAI_API_KEY': True, 'PINECONE_API_KEY': True}
4. Pinecone 연결
Pinecone 객체가 모든 작업의 진입점이다. API 키로 생성한 뒤 기존 인덱스 목록을 조회한다.
import os # 환경변수에서 키를 읽기 위해
from pinecone import Pinecone, ServerlessSpec # Pinecone 클라이언트와 Serverless 설정 클래스
pc = Pinecone(api_key=os.environ["PINECONE_API_KEY"]) # API 키로 클라이언트 생성
print("기존 인덱스:", [ix["name"] for ix in pc.list_indexes()]) # 계정에 있는 인덱스 이름 목록
기존 인덱스: []
5. Serverless 인덱스 생성
차원은 임베딩 모델에 맞춘다(multilingual-e5-large → 1024). metric 은 cosine, spec 은 서버리스(AWS 리전)로 둔다. 차원 불일치는 색인 실패로 이어지므로 모델 변경 시 인덱스를 재생성한다.
INDEX_NAME = "agentic-rag-lab" # 이 과정 전체에서 재사용할 인덱스 이름
EMBED_DIM = 1024 # multilingual-e5-large 의 임베딩 차원
existing = [ix["name"] for ix in pc.list_indexes()] # 현재 인덱스 목록을 다시 조회
if INDEX_NAME not in existing: # 같은 이름이 없을 때만 생성
pc.create_index(
name=INDEX_NAME, # 인덱스 이름
dimension=EMBED_DIM, # 벡터 차원 (임베딩과 반드시 일치)
metric="cosine", # 유사도 측정 방식: 코사인
spec=ServerlessSpec(cloud="aws", region="us-east-1"), # 서버리스: 클라우드/리전 지정
)
print(f"생성 완료: {INDEX_NAME} (dim={EMBED_DIM})")
else:
print(f"이미 존재: {INDEX_NAME}")
index = pc.Index(INDEX_NAME) # 인덱스 핸들 획득 (이후 upsert/query에 사용)
print(index.describe_index_stats()) # 벡터 수·차원 등 현재 상태 확인
생성 완료: agentic-rag-lab (dim=1024)
{'dimension': 1024,
'index_fullness': 0.0,
'metric': 'cosine',
'namespaces': {},
'total_vector_count': 0,
'vector_type': 'dense'}
6. 저수준 API — 직접 임베딩·upsert
LangChain 추상화(S3) 이전에 DB에 무엇이 저장되는지 직접 확인한다. Pinecone 호스팅 임베딩으로 문장을 1024차원 벡터로 만들어, id·원문(metadata)과 함께 demo 네임스페이스에 upsert 한다.
- input_type: 저장 문서는 passage, 검색 질의는 query(e5 계열에서 품질에 영향).
# Pinecone 호스팅 임베딩으로 텍스트 → 벡터 변환 (외부 임베딩 서버 불필요)
sentences = [
"연차 휴가는 15일입니다.",
"재택근무는 주 3일까지 가능합니다.",
"점심 메뉴로 김치찌개를 먹었습니다.",
]
# inference.embed: 문장 리스트를 임베딩 벡터 리스트로 변환
emb = pc.inference.embed(
model="multilingual-e5-large", # 한국어 지원 임베딩 모델 (1024차원)
inputs=sentences, # 임베딩할 입력 문장들
parameters={"input_type": "passage"}, # 'passage'=저장용 문서, 'query'=검색용 질의
)
vectors = [
{
"id": f"s{i}", # 각 벡터의 고유 ID
"values": emb[i].values, # 1024개 실수로 이루어진 임베딩 값
"metadata": {"text": sentences[i]}, # 원문을 메타데이터로 함께 저장 (검색 후 사람이 읽기 위함)
}
for i in range(len(sentences))
]
index.upsert(vectors=vectors, namespace="demo") # 'demo' 네임스페이스에 벡터 저장(upsert=삽입/갱신)
print("upsert 완료:", len(vectors), "개")
upsert 완료: 3 개
7. 검색(query)
질문도 같은 모델로 임베딩(query)한 뒤 가장 가까운 top_k 개를 받는다. include_metadata=True 로 원문도 함께 받는다.
question = "휴가가 며칠인지 궁금해요"
# 질의는 input_type='query' 로 임베딩 (저장용 passage와 구분)
q_emb = pc.inference.embed(
model="multilingual-e5-large",
inputs=[question],
parameters={"input_type": "query"},
)
res = index.query(
namespace="demo", # 검색할 네임스페이스
vector=q_emb[0].values, # 질의 임베딩 벡터
top_k=3, # 가장 가까운 3개를 반환
include_metadata=True, # 저장해 둔 원문(metadata)도 함께 받기
)
for match in res["matches"]: # 결과를 점수 높은 순으로 순회
print(round(match["score"], 4), "|", match["metadata"]["text"]) # 유사도 점수 + 원문
0.8324 | 연차 휴가는 15일입니다.
0.8185 | 재택근무는 주 3일까지 가능합니다.
0.7654 | 점심 메뉴로 김치찌개를 먹었습니다.
🔍 관찰 포인트
- "연차 휴가는 15일" 이 "휴가가 며칠" 질의에 상위로 잡힌다 — 의미 기반 검색의 특성.
- "점심 메뉴" 문장은 점수가 낮다.
- passage/query 구분이 검색 품질에 영향을 준다.
✏️ 미니 실습
- top_k 를 1·5로 바꿔 결과 변화를 본다.
- 새 네임스페이스에 다른 문장을 넣고 demo 와 분리되는지 확인한다.
- 질의를 영어로 바꿔도 한국어 문장이 검색되는지(다국어 임베딩) 확인한다.
# 미니 실습 공통 준비
# 아래 함수는 세 실습에서 공통으로 사용합니다.
# 검색 결과를 항상 점수(score) 높은 순으로 정렬해 보여 주기 위한 출력 함수입니다.
def print_matches(title, matches):
# 제목을 먼저 출력해 어떤 실험 결과인지 구분하기 쉽게 만듭니다.
print(f"\n{title}")
# 반환 결과를 score 기준 내림차순으로 다시 정렬해 보여 줍니다.
sorted_matches = sorted(matches, key=lambda item: item["score"], reverse=True)
# 정렬된 결과를 하나씩 출력합니다.
for match in sorted_matches:
# score 는 유사도 점수이고, metadata['text'] 는 저장해 둔 원문입니다.
print(round(match["score"], 4), "|", match["metadata"]["text"])
# 미니 실습 1. top_k 값 비교
# 같은 질의 벡터를 사용하되, 반환 개수(top_k)만 바꿔서 검색합니다.
for top_k in [1, 5]:
res = index.query(
namespace="demo", # 기존에 저장한 demo 네임스페이스에서 검색합니다.
vector=q_emb[0].values, # 바로 위 셀에서 만든 질의 임베딩 벡터를 재사용합니다.
top_k=top_k, # 이번 반복에서 사용할 반환 개수입니다.
include_metadata=True, # 사람이 읽을 수 있도록 원문 메타데이터를 함께 받습니다.
)
# top_k=5 로 요청해도 현재 demo 안에 문장이 3개뿐이면 3개만 반환됩니다.
print_matches(f"[top_k={top_k}] 검색 결과", res["matches"])
[top_k=1] 검색 결과
0.8324 | 연차 휴가는 15일입니다.
[top_k=5] 검색 결과
0.8324 | 연차 휴가는 15일입니다.
0.8185 | 재택근무는 주 3일까지 가능합니다.
0.7654 | 점심 메뉴로 김치찌개를 먹었습니다.
실습 1 해설
- top_k 는 "몇 개까지 보여 줄지"를 정하는 값입니다.
- top_k=1 에서는 가장 관련도 높은 문장 1개만 보입니다.
- top_k=5 로 늘려도 현재 demo 네임스페이스 안에는 문장이 3개뿐이므로 최대 3개까지만 반환됩니다.
- 즉, top_k 는 "원하는 최대 개수"이지 "항상 정확히 그 개수를 보장하는 값"은 아닙니다.
# 미니 실습 2. 네임스페이스 분리 확인
# 기존 demo 와 구분할 새 네임스페이스 이름입니다.
alt_namespace = "demo-alt"
# demo 와 다른 주제의 문장을 새 네임스페이스에 저장해 분리 여부를 확인합니다.
alt_sentences = [
"여름 세일은 다음 주 월요일부터 시작됩니다.",
"신규 회원은 첫 주문에 10퍼센트 할인을 받을 수 있습니다.",
"배송은 보통 이틀 안에 완료됩니다.",
]
# 새 문장도 저장용 문서이므로 input_type='passage' 로 임베딩합니다.
alt_emb = pc.inference.embed(
model="multilingual-e5-large",
inputs=alt_sentences,
parameters={"input_type": "passage"},
)
# Pinecone upsert 형식에 맞춰 id, values, metadata 를 묶습니다.
alt_vectors = [
{
"id": f"alt-{i}", # demo 쪽 id 와 겹치지 않도록 접두어를 붙입니다.
"values": alt_emb[i].values, # 새 문장의 임베딩 벡터입니다.
"metadata": {"text": alt_sentences[i]}, # 사람이 읽을 원문도 함께 저장합니다.
}
for i in range(len(alt_sentences))
]
# 새 벡터들을 demo-alt 네임스페이스에 저장합니다.
index.upsert(vectors=alt_vectors, namespace=alt_namespace)
# 네임스페이스 분리를 더 분명히 보기 위해 alt 문장과 관련 있는 새 질의를 만듭니다.
alt_question = "할인 혜택이 어떻게 되나요?"
# 이 질의는 검색용이므로 input_type='query' 로 임베딩합니다.
alt_q_emb = pc.inference.embed(
model="multilingual-e5-large",
inputs=[alt_question],
parameters={"input_type": "query"},
)
# 같은 질의를 demo 와 demo-alt 에 각각 보내면, 각 네임스페이스 안의 데이터만 검색됩니다.
for namespace in ["demo", alt_namespace]:
res = index.query(
namespace=namespace,
vector=alt_q_emb[0].values,
top_k=3,
include_metadata=True,
)
print_matches(f"[namespace={namespace}] '{alt_question}' 검색 결과", res["matches"])
# 필요하면 실습 후 아래 코드를 실행해 demo-alt 네임스페이스만 정리할 수 있습니다.
# index.delete(delete_all=True, namespace=alt_namespace)
[namespace=demo] '할인 혜택이 어떻게 되나요?' 검색 결과
0.7573 | 재택근무는 주 3일까지 가능합니다.
0.7424 | 연차 휴가는 15일입니다.
0.7277 | 점심 메뉴로 김치찌개를 먹었습니다.
[namespace=demo-alt] '할인 혜택이 어떻게 되나요?' 검색 결과
0.7985 | 신규 회원은 첫 주문에 10퍼센트 할인을 받을 수 있습니다.
0.7703 | 여름 세일은 다음 주 월요일부터 시작됩니다.
0.7555 | 배송은 보통 이틀 안에 완료됩니다.
실습 2 해설
- 네임스페이스(namespace)는 한 인덱스 안에서 데이터를 논리적으로 분리하는 칸막이 역할을 합니다.
- 같은 인덱스를 쓰더라도 demo 와 demo-alt 는 서로 다른 검색 공간처럼 동작합니다.
- 그래서 할인 관련 질의를 보내면 demo 에서는 원래 문장들만, demo-alt 에서는 새로 넣은 할인/세일 문장들만 검색됩니다.
- 실제 서비스에서는 사용자별 데이터 분리, 실험용 데이터 분리, 버전별 데이터 분리에 이 방식을 많이 사용합니다.
# 미니 실습 3. 영어 질의로 한국어 문장 검색
english_question = "How many vacation days do employees get?"
# 영어 질의도 같은 다국어(multilingual) 임베딩 모델로 query 형태로 변환합니다.
english_q_emb = pc.inference.embed(
model="multilingual-e5-large",
inputs=[english_question],
parameters={"input_type": "query"},
)
# 검색 대상은 다시 원래의 demo 네임스페이스로 지정합니다.
english_res = index.query(
namespace="demo",
vector=english_q_emb[0].values,
top_k=3,
include_metadata=True,
)
# 한국어 문장이 상위에 나온다면 다국어 임베딩이 작동하는 좋은 예시입니다.
print_matches(f"[영어 질의] '{english_question}' 검색 결과", english_res["matches"])
[영어 질의] 'How many vacation days do employees get?' 검색 결과
0.7999 | 연차 휴가는 15일입니다.
0.7684 | 재택근무는 주 3일까지 가능합니다.
0.6975 | 점심 메뉴로 김치찌개를 먹었습니다.
실습 3 해설
- multilingual-e5-large 는 다국어 임베딩 모델이므로 영어 질의와 한국어 문장을 같은 의미 공간에서 비교할 수 있습니다.
- 그래서 질문은 영어여도, 의미가 가장 가까운 한국어 문장이 상위에 검색될 수 있습니다.
- 이것이 다국어 검색의 핵심이며, 문서 언어와 사용자 질문 언어가 다를 때 특히 유용합니다.
- 다만 실제 서비스에서는 언어 혼합 데이터셋의 품질을 따로 점검해야 합니다.
# 실습 정리용 셀: demo-alt 네임스페이스 삭제
# 실습 2에서 만든 demo-alt 데이터만 지우고, 원래 demo 데이터는 그대로 남겨 둡니다.
index.delete(delete_all=True, namespace="demo-alt")
print("정리 완료: demo-alt 네임스페이스의 벡터를 삭제했습니다.")
정리 완료: demo-alt 네임스페이스의 벡터를 삭제했습니다.
정리
- 임베딩 → 유사도 → 벡터 DB 검색의 원리를 저수준 API로 확인했다.
- 차원 일치·네임스페이스·메타데이터·input_type 의 역할을 짚었다.
- 다음(S3): 이 과정을 LangChain으로 추상화하고, 긴 문서를 청킹해 색인하며 청크 크기 영향을 실험한다.
데모 벡터 삭제: index.delete(delete_all=True, namespace="demo")
'AI System > Agentic RAG: Agent 시스템을 위한 Vector RAG 설계' 카테고리의 다른 글
| d01 - 6. AgenticRAG 실습 (0) | 2026.06.15 |
|---|---|
| d01 - 5. LangGraph (0) | 2026.06.15 |
| d01 - 4. RAG 파이프라인 (0) | 2026.06.15 |
| d01 - 2. AgenticRAG (0) | 2026.06.15 |
| d01 - 1. 개발환경 구축 (0) | 2026.06.15 |