실무 Multi-Agent 오케스트레이션 13장 · 임베딩과 벡터 DB 5 / 7 ← 이전목차다음 → TechLead Cro

13장. 임베딩과 벡터 DB

따라하기 — 청크를 벡터 DB에 넣고 검색하기

출력 안내: 실행 코드는 답변·도구 결과·청크 본문을 글자 수로 자르지 않고 출력합니다. 아래의 기존 실행 예시는 일부 축약된 기록이며, 실제 실행 화면에서 전체 내용을 확인하세요.

목표

12장의 청크 12개를 임베딩해 Chroma에 저장하고, 질문 세 개로 검색이 되는지 확인합니다. 실행이 끝나면 프로젝트 폴더에 chroma_db/ 폴더가 생깁니다. 14장 이후의 실습은 모두 이 폴더를 불러 씁니다.


0. 실습 준비

VS Code에서 haru-market 폴더를 열고 터미널에서 환경을 켭니다.

$ conda activate myenv

이번 장은 새로 설치할 것이 없습니다. 다만 12장에서 만든 lesson12_rag_chunking.py가 폴더에 있어야 합니다. 이번 장의 파일이 그 파일의 청크를 가져다 씁니다.


1. 파일 만들기

haru-market 폴더 맨 위에 새 파일을 만듭니다.

lesson13_rag_embedding.py

아래 코드 전체를 복사해 붙여 넣고 저장합니다.

# -*- coding: utf-8 -*-
"""[13장] RAG 2 — 임베딩과 벡터 DB(Chroma) 인덱싱

임베딩: 문장을 숫자 벡터로 바꾼다. '의미가 비슷한 문장 = 가까운 벡터'.
  "반품 배송비 얼마?" 와 "단순 변심 반품 시 편도 3,000원" 은
  단어가 달라도 벡터 공간에서는 가깝다 → 키워드 검색이 못 하는 걸 한다.

오늘: 12장 청크 → Gemini 임베딩 → Chroma(로컬 파일)에 저장 → 유사도 검색 확인.

실행:  python lesson13_rag_embedding.py
"""
import os

from langchain_chroma import Chroma
from langchain_core.documents import Document
from langchain_google_genai import GoogleGenerativeAIEmbeddings

from config import EMBEDDING_MODEL, ROOT
from lesson12_rag_chunking import load_policy_chunks

CHROMA_DIR = str(ROOT / "chroma_db")
COLLECTION = "haru_policy"

# ── 1. 임베딩 모델 준비 + 벡터 구경 ──────────────────────────────────
embeddings = GoogleGenerativeAIEmbeddings(
    model=EMBEDDING_MODEL,                    # .env: models/gemini-embedding-001
    google_api_key=os.getenv("GOOGLE_API_KEY"),
)

sample_vec = embeddings.embed_query("반품 배송비는 얼마인가요?")
print(f"■ 임베딩 확인: '반품 배송비는 얼마인가요?'")
print(f"  차원 수: {len(sample_vec)}")
print(f"  앞 5개 값: {[round(v, 4) for v in sample_vec[:5]]}")
print("  → 문장 하나가 이 길이의 숫자 목록이 된다. 이 공간에서 '가까움'을 잰다.\n")

# ── 2. 청크 전체를 인덱싱 (배치로 임베딩 → Chroma 영속화) ─────────────
chunks = load_policy_chunks()
docs = [Document(page_content=c["text"], metadata=c["metadata"], id=c["id"])
        for c in chunks]

vectorstore = Chroma.from_documents(
    documents=docs,
    embedding=embeddings,
    collection_name=COLLECTION,
    persist_directory=CHROMA_DIR,       # 로컬 폴더에 저장 — 14장부터는 이 폴더를 불러 쓴다
)
print(f"■ 인덱싱 완료: {len(docs)}개 청크 → {CHROMA_DIR}")
print("  (임베딩 모델을 바꾸면 벡터 공간 자체가 달라진다 → 전체 재색인 필수)\n")

# ── 3. 유사도 검색 확인 — 아직 LLM 은 없다. '검색'만 본다 ─────────────
queries = [
    "교환 배송비 누가 내나요?",
    "환불 언제 들어와요?",
    "뜯었는데 반품 되나요?",
]
for q in queries:
    print(f"■ 검색: {q!r}")
    # 점수와 함께 상위 2개 (Chroma 는 거리 기반 — 작을수록 가깝다)
    results = vectorstore.similarity_search_with_score(q, k=2)
    for doc, score in results:
        preview = doc.page_content.replace("\n", " ")
        print(f"  (거리 {score:.3f}) [{doc.metadata.get('article', '')}] {preview}")
    print()

if __name__ == "__main__":
    print("""
──────────────────────────────────────────────────────────
확인할 것
  - 질문과 문서의 표현이 달라도 관련 청크가 찾아진다 (의미 검색)
  - chroma_db/ 폴더가 생겼다 — 14장부터는 재인덱싱 없이 이 폴더를 불러 쓴다
다음 장: 검색 결과를 근거로 '출처를 밝히며' 답하게 한다
──────────────────────────────────────────────────────────""")

2. 코드에서 볼 곳

파일은 세 부분으로 이루어져 있습니다.

부분 하는 일 임베딩 API 호출
1 문장 하나를 벡터로 바꿔 모양을 구경한다 1번
2 청크 12개를 색인한다 (chroma_db/에 저장) 청크 12개분
3 질문 세 개로 검색해 본다 질문마다 1번

LLM(gemini-3.8-flash)은 한 번도 부르지 않습니다. 이 파일에서 일하는 모델은 임베딩 모델뿐입니다.

먼저 맨 위의 import 한 줄을 봅니다.

from lesson12_rag_chunking import load_policy_chunks

12장에서 만든 함수를 가져옵니다. 12장의 파일은 화면 출력을 if __name__ == "__main__": 아래에 두었으므로, 여기서 가져와도 12장의 출력은 나오지 않습니다. 청크 목록만 받아 옵니다.

다음은 색인하는 부분입니다. id=c["id"]를 눈여겨봅니다.

chunks = load_policy_chunks()
docs = [Document(page_content=c["text"], metadata=c["metadata"], id=c["id"])
        for c in chunks]

vectorstore = Chroma.from_documents(
    documents=docs,
    embedding=embeddings,
    collection_name=COLLECTION,
    persist_directory=CHROMA_DIR,
)
코드 뜻
id=c["id"] 청크마다 policy-000 같은 고정된 이름을 줍니다. 다시 실행해도 중복되지 않는 이유입니다
embedding=embeddings 어떤 임베딩 모델로 벡터를 만들지
collection_name=COLLECTION 컬렉션 이름 haru_policy. 14장 이후에서 같은 이름으로 불러옵니다
persist_directory=CHROMA_DIR 저장할 폴더. config.py의 ROOT를 써서 어디서 실행해도 프로젝트 폴더 안의 chroma_db를 가리킵니다

3. 실행하기

실행하기 전에 짐작해 보세요.

  • "교환 배송비 누가 내나요?"를 검색하면 1위는 어느 조항일까요? 정책 문서의 조항 제목은 제2조(반품 사유별 기준), 제4조(교환), 제6조(환불 금액 산정), 제9조(분쟁 처리) 등입니다.
  • 이 파일을 한 번 더 실행하면 저장된 청크는 몇 개가 될까요?
$ python lesson13_rag_embedding.py

5초 안팎이면 끝납니다.

실행 결과 (저장 폴더의 경로는 본인 컴퓨터에 따라 다르게 나옵니다)

■ 임베딩 확인: '반품 배송비는 얼마인가요?'
  차원 수: 3072
  앞 5개 값: [0.0072, 0.0106, 0.0045, -0.0558, -0.0273]
  → 문장 하나가 이 길이의 숫자 목록이 된다. 이 공간에서 '가까움'을 잰다.

■ 인덱싱 완료: 12개 청크 → C:\Users\사용자명\Documents\haru-market\chroma_db
  (임베딩 모델을 바꾸면 벡터 공간 자체가 달라진다 → 전체 재색인 필수)

■ 검색: '교환 배송비 누가 내나요?'
  (거리 0.464) [제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ)] 서 제외된다. 제9조 (분쟁 처리) 반품·교환·환불에 관하여 회사와 고객 간 분쟁이 발생한 경우, 공정거래위원회가 고시한 「소...
  (거리 0.488) [제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간)] 행된다. 제4조 (교환) ① 동일 상품의 다른 색상·사이즈 교환은 수령일로부터 7일 이내 신청 가능하다. ② 단순 변심 교환의...

■ 검색: '환불 언제 들어와요?'
  (거리 0.528) [제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간)] 행된다. 제4조 (교환) ① 동일 상품의 다른 색상·사이즈 교환은 수령일로부터 7일 이내 신청 가능하다. ② 단순 변심 교환의...
  (거리 0.548) [제5조 (환불 처리 기간), 제6조 (환불 금액 산정), 제7조 (반품·교환 불가 사유)] 계좌이체·무통장 계좌 환급 검수 후 3일 이내 하루포인트·쿠폰 즉시 복원 검수 후 1일 이내 간편결제(하루페이 등) 결제 취소...

■ 검색: '뜯었는데 반품 되나요?'
  (거리 0.501) [제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ)] 서 제외된다. 제9조 (분쟁 처리) 반품·교환·환불에 관하여 회사와 고객 간 분쟁이 발생한 경우, 공정거래위원회가 고시한 「소...
  (거리 0.504) [제7조 (반품·교환 불가 사유), 제8조 (배송 지연 보상), 제9조 (분쟁 처리)] 제7조 (반품·교환 불가 사유) 다음 각 호에 해당하면 반품·교환이 제한된다. ① 고객의 사용·착용·세탁·수선으로 상품 가치가...
(이하 생략)

이 파일은 LLM을 부르지 않으므로 숫자까지 위와 같게 나옵니다.

실행이 끝나면 VS Code 탐색기에 chroma_db 폴더가 새로 보입니다. 안에는 chroma.sqlite3 파일과 이름이 긴 폴더 하나가 들어 있습니다. 직접 열어 볼 필요는 없습니다. Chroma가 관리하는 파일입니다.

이렇게 나오면 원인과 조치
ModuleNotFoundError: No module named 'lesson12_rag_chunking' 12장의 파일이 폴더에 없습니다. 12장 「따라하기」에서 파일을 먼저 만듭니다
429 RESOURCE_EXHAUSTED 짧은 시간에 요청이 몰렸습니다. 잠시 기다렸다가 다시 실행합니다. 다시 실행해도 중복해서 들어가지 않습니다

4. 무엇을 관찰했나

문장 하나가 숫자 3,072개가 되었다

차원 수: 3072. 질문 하나가 실수 3,072개의 목록이 되었습니다. 청크 12개도 같은 방식으로 3,072개짜리 벡터 12개가 되어 chroma_db/에 들어갔습니다.

거리는 0.46에서 0.55 사이였다

세 질문의 결과 여섯 개는 거리가 0.464부터 0.548까지입니다. 작을수록 가깝습니다. 1위와 2위의 차이는 0.003에서 0.024로 아주 작습니다. 순위가 한 끗 차이로 정해졌다는 뜻입니다. 그러니 1위 하나만 가져가는 것은 위험하고, 여러 개(k개)를 가져가야 합니다.

꼬리표와 청크 전체를 함께 읽는다

"교환 배송비 누가 내나요?"의 1위 미리보기는 "서 제외된다. 제9조 (분쟁 처리) …"로 시작합니다. 배송비를 물었는데 분쟁 처리 조항이 보입니다. 꼬리표와 본문 뒷부분까지 함께 확인합니다.

[제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ)]

끝에 자주 묻는 질문 (FAQ) 이 있습니다. 이 청크(policy-010)의 뒷부분입니다(실습문제 2에서 통째로 엽니다).

자주 묻는 질문 (FAQ)
“Q. 단순 변심인데 배송비는 누가 내나요?”
A. 편도 배송비 3,000원을 고객이 부담합니다. 교환은 왕복 6,000원입니다.
(중략)
“Q. 포장을 뜯었는데 반품 되나요?”
A. 단순 개봉은 반품 가능합니다. 다만 식품·위생 상품·속옷류는 개봉 시 반품이 제한됩니다.

"교환은 왕복 6,000원"과 "포장을 뜯었는데 반품 되나요?"가 모두 여기 있습니다. 첫째와 셋째 질문의 1위가 같은 청크인 이유입니다.

"환불 언제 들어와요?"의 1위도 같은 방법으로 읽습니다. 미리보기는 "제4조 (교환)"으로 시작하지만 꼬리표에 제5조 (환불 처리 기간) 이 있습니다. 그 청크(policy-007) 뒤쪽에 결제수단별 환불 기간 표가 들어 있습니다.

질문 1위 청크의 꼬리표에서 볼 곳 그 청크에 든 답
교환 배송비 누가 내나요? 자주 묻는 질문 (FAQ) 교환은 왕복 6,000원
환불 언제 들어와요? 제5조 (환불 처리 기간) 표 2 결제수단별 환불 소요 기간
뜯었는데 반품 되나요? 자주 묻는 질문 (FAQ) 단순 개봉은 반품 가능

세 질문 모두 1위 청크에 답이 있습니다.

검색이 고른 것은 임베딩 모델과 Chroma입니다. 그 결과를 사람이 읽을 수 있게 해 주는 꼬리표는 12장에서 우리가 붙인 것입니다.

문서에 없는 말로 물어도 찾아진다

"환불 언제 들어와요?"의 "들어와요"는 정책 문서 어디에도 없는 말입니다. 그래도 환불 기간이 적힌 청크가 1위와 2위로 올라왔습니다. 단어가 아니라 뜻이 가까웠기 때문입니다.

다시 실행해도 12개다

같은 명령으로 한 번 더 실행해 보세요. 출력은 처음과 똑같고, 저장된 청크도 12개 그대로입니다. Document에 넣은 id가 같아서 Chroma가 새로 추가하지 않고 덮어썼기 때문입니다.

다만 임베딩 API는 다시 호출됩니다. 문서가 바뀌지 않았다면 이 파일을 다시 실행할 이유가 없습니다.


5. 지금 폴더의 모습

haru-market/
├── config.py
├── data/
├── chroma_db/                    ← 이번 장 (실행하면 생깁니다)
├── (중략)
├── lesson12_rag_chunking.py
└── lesson13_rag_embedding.py     ← 이번 장

chroma_db/는 우리가 만든 파일이 아니라 실행 결과입니다. 지워도 이 파일을 다시 실행하면 똑같이 만들어집니다.


핵심 정리

  • 청크 12개가 3,072차원 벡터가 되어 chroma_db/ 에 저장되었습니다.
  • 검색 점수는 거리입니다. 이번 실행에서는 0.46~0.55 사이였고, 1위와 2위의 차이가 아주 작았습니다.
  • 세 질문 모두 1위 청크에 답이 있었습니다. 꼬리표와 청크 전체를 함께 읽습니다.
  • 다시 실행해도 12개입니다. id가 같으면 덮어씁니다.
  • 14장 이후는 모두 chroma_db/가 있어야 동작합니다. 폴더를 지웠다면 이 파일을 다시 실행합니다.
← 이전 절유사도 검색과 거리 읽기 — 점수는 작을수록 가깝다다음 절 →정리와 체크리스트
오명운 · macro@prag-ai.com