13장. 임베딩과 벡터 DB
실습문제와 해답
코드를 직접 돌려 보며 13장 내용을 손에 익힙니다. 먼저 스스로 해 본 뒤 해답을 펼쳐 보세요. 해답은 새 파일로 만들어 실행합니다. 세 문제 모두 LLM은 부르지 않고 임베딩 모델만 씁니다.
문제 1 — 코사인 유사도를 직접 재 보기
새 파일 ex13_cosine.py를 만들고, "반품 배송비는 얼마인가요?"를 기준으로 아래 네 문장과의 코사인 유사도를 재어 출력하세요.
- "반품 배송비는 얼마인가요?" (같은 문장)
- "환불할 때 택배비 누가 내요?"
- "포인트는 언제 적립되나요?"
- "오늘 점심 뭐 먹지?"
- 힌트: 코사인 유사도는
두 벡터의 내적 / (크기 × 크기)입니다. 내적은 같은 자리 숫자끼리 곱해 모두 더한 값이고, 크기는 각 숫자를 제곱해 더한 뒤 제곱근을 씌운 값입니다. - 실행하기 전에 네 값의 순서를 짐작해 보세요.
해답 보기
import math
import os
from langchain_google_genai import GoogleGenerativeAIEmbeddings
from config import EMBEDDING_MODEL
embeddings = GoogleGenerativeAIEmbeddings(
model=EMBEDDING_MODEL, google_api_key=os.getenv("GOOGLE_API_KEY"))
def cos(a, b):
"""코사인 유사도 = 내적 / (크기 x 크기)"""
dot = sum(x * y for x, y in zip(a, b))
na = math.sqrt(sum(x * x for x in a))
nb = math.sqrt(sum(y * y for y in b))
return dot / (na * nb)
base = "반품 배송비는 얼마인가요?"
others = [
"반품 배송비는 얼마인가요?", # 같은 문장
"환불할 때 택배비 누가 내요?", # 뜻은 비슷, 단어는 다름
"포인트는 언제 적립되나요?", # 같은 쇼핑몰 이야기, 다른 주제
"오늘 점심 뭐 먹지?", # 상관없는 문장
]
base_vec = embeddings.embed_query(base)
for text in others:
sim = cos(base_vec, embeddings.embed_query(text))
print(f"유사도 {sim:.3f} | {base!r} vs {text!r}")
$ python ex13_cosine.py
실행 결과
유사도 1.000 | '반품 배송비는 얼마인가요?' vs '반품 배송비는 얼마인가요?'
유사도 0.872 | '반품 배송비는 얼마인가요?' vs '환불할 때 택배비 누가 내요?'
유사도 0.573 | '반품 배송비는 얼마인가요?' vs '포인트는 언제 적립되나요?'
유사도 0.538 | '반품 배송비는 얼마인가요?' vs '오늘 점심 뭐 먹지?'
해설 — 세 가지를 봅니다.
첫째, 둘째 줄입니다. 두 문장은 겹치는 단어가 없는데 0.872입니다. 임베딩이 글자가 아니라 뜻을 담는다는 것을 숫자로 확인했습니다.
둘째, 셋째와 넷째 줄의 차이가 0.035밖에 되지 않습니다. 같은 쇼핑몰의 포인트 이야기와 점심 이야기가 거의 같은 거리에 있습니다. "같은 분야"라는 것만으로는 가까워지지 않고, 묻는 내용이 같아야 가까워집니다.
셋째, 상관없는 문장도 0.538입니다. 0이 아닙니다. 이 모델의 유사도는 대략 0.5에서 1 사이에서 움직입니다. 그래서 "0.6이면 꽤 비슷하다"고 읽으면 틀립니다. 값 하나가 아니라 후보끼리의 순서를 봅니다.
문제 2 — 색인 없이 불러와서, 1위 청크를 통째로 열어 보기
lesson13_rag_embedding.py는 실행할 때마다 청킹과 색인을 다시 합니다. 이번에는 이미 만들어진 chroma_db/를 불러오기만 하는 파일 ex13_load_only.py를 만드세요.
Chroma(collection_name=..., embedding_function=..., persist_directory=...)로 불러옵니다.from_documents는 쓰지 않습니다.- 저장된 청크 수를 출력합니다.
vectorstore.get()["ids"]의 길이를 세면 됩니다. - "뜯었는데 반품 되나요?"를
k=4로 검색해 거리, id, 꼬리표를 출력합니다. - 1위 청크의 본문 전체를 출력합니다.
해답 보기
import os
from langchain_chroma import Chroma
from langchain_google_genai import GoogleGenerativeAIEmbeddings
from config import EMBEDDING_MODEL, ROOT
embeddings = GoogleGenerativeAIEmbeddings(
model=EMBEDDING_MODEL, google_api_key=os.getenv("GOOGLE_API_KEY"))
# 저장된 chroma_db 를 불러오기만 한다. 청킹도 인덱싱도 하지 않는다.
vectorstore = Chroma(
collection_name="haru_policy",
embedding_function=embeddings,
persist_directory=str(ROOT / "chroma_db"),
)
print(f"저장된 청크 수: {len(vectorstore.get()['ids'])}")
question = "뜯었는데 반품 되나요?"
results = vectorstore.similarity_search_with_score(question, k=4)
print(f"\n검색: {question!r}")
for doc, score in results:
print(f" (거리 {score:.3f}) {doc.id} [{doc.metadata.get('article', '')}]")
top_doc, top_score = results[0]
print(f"\n1위 청크 {top_doc.id} 의 전체 내용")
print("-" * 60)
print(top_doc.page_content)
$ python ex13_load_only.py
실행 결과
저장된 청크 수: 12
검색: '뜯었는데 반품 되나요?'
(거리 0.501) policy-010 [제8조 (배송 지연 보상), 제9조 (분쟁 처리), 자주 묻는 질문 (FAQ)]
(거리 0.504) policy-009 [제7조 (반품·교환 불가 사유), 제8조 (배송 지연 보상), 제9조 (분쟁 처리)]
(거리 0.527) policy-006 [제1조 (용어의 정의), 제2조 (반품 사유별 기준), 제3조 (반품 신청 절차), 제4조 (교환)]
(거리 0.551) policy-007 [제3조 (반품 신청 절차), 제4조 (교환), 제5조 (환불 처리 기간)]
1위 청크 policy-010 의 전체 내용
------------------------------------------------------------
서 제외된다.
제9조 (분쟁 처리)
반품·교환·환불에 관하여 회사와 고객 간 분쟁이 발생한 경우, 공정거래위원회가 고시한 「소비자분쟁해결기준」을 따른다. 협
의가 이루어지지 않으면 소비자분쟁조정위원회의 조정을 신청할 수 있다.
자주 묻는 질문 (FAQ)
“Q. 단순 변심인데 배송비는 누가 내나요?”
A. 편도 배송비 3,000원을 고객이 부담합니다. 교환은 왕복 6,000원입니다.
“Q. 불량품인데도 제가 배송비를 내야 하나요?”
A. 아닙니다. 하자·오배송은 회사가 배송비를 전액 부담합니다.
“Q. 환불은 며칠 걸리나요?”
A. 회수·검수 후 3영업일 이내 처리되며, 카드 취소는 카드사 사정으로 3~5일이 더 걸릴 수 있습니다.
“Q. 포장을 뜯었는데 반품 되나요?”
A. 단순 개봉은 반품 가능합니다. 다만 식품·위생 상품·속옷류는 개봉 시 반품이 제한됩니다.
본 정책은 2026-06-15부터 시행되며, 시행 전 주문에는 종전 정책(v2.3)이 적용됩니다.
부칙 — 개정 이력
(이하 생략)
해설 — 세 가지를 봅니다.
첫째, 이 파일은 lesson12_rag_chunking을 가져오지 않고, 청킹도 색인도 하지 않습니다. 저장된 폴더를 여는 것만으로 검색이 됩니다. 임베딩 API는 질문 하나에만 호출되었습니다. 14장부터의 파일이 모두 이 방식입니다.
둘째, 1위 청크의 꼬리표 끝에 "자주 묻는 질문 (FAQ)" 이 있고, 본문에 "포장을 뜯었는데 반품 되나요?"라는 FAQ가 그대로 들어 있습니다. 질문과 거의 같은 문장이 든 청크가 1위로 올라온 것입니다.
셋째, 2위(policy-009)의 꼬리표에는 제7조 (반품·교환 불가 사유)가 있습니다. 제7조에는 "뜯었다"는 말이 없고 "포장을 개봉한 경우"라고 적혀 있습니다. 단어가 다른데도 거리 0.504로 1위와 거의 같은 자리에 올라왔습니다. 이쪽이 뜻으로 찾은 결과입니다.
문제 3 — 메타데이터로 문서를 골라 검색하기
청크에는 벡터와 본문 말고 메타데이터도 함께 저장되어 있습니다. 그 메타데이터로 검색 범위를 좁힐 수 있습니다. 새 파일 ex13_filter.py를 만들고, "배송비를 안 내도 되는 경우가 있나요?"를 세 가지로 검색해 거리, id, 문서 제목을 출력하세요.
- [1] 전체에서
k=3 - [2] 멤버십정책 안에서만
k=3 -
[3] 반품교환환불정책 안에서만
k=3 -
힌트:
similarity_search_with_score(질문, k=3, filter={"doc_title": "멤버십정책"}).filter에 메타데이터의 키와 값을 주면 그 값을 가진 청크 안에서만 가까운 것을 찾습니다. - 실행하기 전에 짐작해 보세요. [1]의 세 개는 한 문서에서만 나올까요?
해답 보기
import os
from langchain_chroma import Chroma
from langchain_google_genai import GoogleGenerativeAIEmbeddings
from config import EMBEDDING_MODEL, ROOT
embeddings = GoogleGenerativeAIEmbeddings(
model=EMBEDDING_MODEL, google_api_key=os.getenv("GOOGLE_API_KEY"))
vectorstore = Chroma(
collection_name="haru_policy",
embedding_function=embeddings,
persist_directory=str(ROOT / "chroma_db"),
)
question = "배송비를 안 내도 되는 경우가 있나요?"
def show(title: str, results) -> None:
print(f"\n{title}")
for doc, score in results:
print(f" (거리 {score:.3f}) {doc.id} {doc.metadata['doc_title']}")
print(f"검색: {question!r}")
show("[1] 전체에서 검색",
vectorstore.similarity_search_with_score(question, k=3))
show("[2] 멤버십정책 안에서만 검색",
vectorstore.similarity_search_with_score(
question, k=3, filter={"doc_title": "멤버십정책"}))
show("[3] 반품교환환불정책 안에서만 검색",
vectorstore.similarity_search_with_score(
question, k=3, filter={"doc_title": "반품교환환불정책"}))
$ python ex13_filter.py
실행 결과
검색: '배송비를 안 내도 되는 경우가 있나요?'
[1] 전체에서 검색
(거리 0.594) policy-010 반품교환환불정책
(거리 0.608) policy-003 멤버십정책
(거리 0.611) policy-001 멤버십정책
[2] 멤버십정책 안에서만 검색
(거리 0.608) policy-003 멤버십정책
(거리 0.611) policy-001 멤버십정책
(거리 0.697) policy-004 멤버십정책
[3] 반품교환환불정책 안에서만 검색
(거리 0.594) policy-010 반품교환환불정책
(거리 0.623) policy-008 반품교환환불정책
(거리 0.659) policy-009 반품교환환불정책
해설 — 세 가지를 봅니다.
첫째, [1]에는 두 문서가 섞여 나왔습니다. 배송비를 내지 않는 경우는 두 문서에 모두 있습니다. 반품 정책에는 "하자·오배송은 회사가 전액 부담"이, 멤버십 정책에는 "VIP 단순 변심 반품 배송비 면제 월 1회"가 있습니다. 검색은 문서를 가리지 않고 가까운 순서로 골랐습니다.
둘째, [2]와 [3]은 한 문서의 청크만 돌려줬습니다. filter가 범위를 좁혔습니다. [2]의 1, 2위는 [1]의 2, 3위와 거리까지 같습니다. 거리를 다시 계산한 것이 아니라 후보를 걸러낸 것이기 때문입니다.
셋째, [2]의 3위는 거리가 0.697로 앞의 둘보다 뚝 떨어집니다. 범위를 좁히면 그 안에서 k개를 채워야 하므로 덜 가까운 청크도 올라옵니다.
이렇게 걸러낼 수 있는 것은 12장에서 청크마다 doc_title을 붙여 두었기 때문입니다. 메타데이터는 답변의 출처 표기(14장)에만 쓰이는 것이 아니라 검색 범위를 정하는 데도 쓰입니다. 예를 들어 멤버십 문의만 받는 창구라면 멤버십 정책 안에서만 찾게 할 수 있습니다.