RAG 파이프라인 만들기
고객 안내서를 청크로 나눠 임베딩하고 Chroma에 저장한 뒤, 검색한 청크를 출처 번호와 함께 프롬프트에 넣어 답변을 생성한다.
- 앞 문서들에서 만든 부품을 하나로 연결: 청킹 → 임베딩 → 저장 → 검색 → 프롬프트 구성 → 생성.
- 저장소: 벡터 데이터베이스 Chroma. 임베딩과 원문을 함께 저장하고, 질문 벡터와 가까운 청크를 찾아 줌.
Mermaid 다이어그램 원문
flowchart LR
subgraph 색인
A[안내서] --> B[청크] --> C[임베딩] --> D[(Chroma)]
end
subgraph 질의
Q[질문] --> E[임베딩] --> F[검색]
D --> F
F --> G[번호를 붙인 청크 + 질문] --> H[LLM] --> I[출처 번호가 달린 답변]
end대화형 다이어그램을 불러오기 전에는 원문으로 표시됩니다.
실습 준비
- 실습 자료 다운로드: mindmall_guide.py. 현재 폴더에 업로드. 안내서 원문이 문자열 변수
guide에 들어 있음. - API 키를
openrouter-key.txt에 저장하고, 현재 폴더에 업로드.
!pip install -q openai chromadb
from pathlib import Path
import numpy as np
from openai import OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=Path("openrouter-key.txt").read_text().strip(),
)
model = "openai/gpt-4.1-mini"
embedding_model = "openai/text-embedding-3-small"
색인 1: 청크로 나누기
- 키워드 검색과 벡터 검색에서 만든
chunk_with_heading함수를 그대로 사용. 문단 단위로 나누고 앞에 절 제목을 붙임. - 절 제목은 답변의 출처를 보여 줄 때도 쓰도록 따로 기록.
def chunk_with_heading(text): # 청크마다 절 제목과 본문을 담은 사전
chunks = []
for section in text.split("\n## ")[1:]:
title, body = section.split("\n", 1)
chunks += [{"section": title, "text": f"[{title}] {p.strip()}"}
for p in body.split("\n\n") if p.strip()]
return chunks
from mindmall_guide import guide
chunks = chunk_with_heading(guide)
len(chunks)
실행 결과
22
색인 2: 임베딩해 Chroma에 저장하기
def embed(texts):
response = client.embeddings.create(model=embedding_model, input=texts)
return [d.embedding for d in response.data]
- 컬렉션(collection): Chroma에서 청크를 모아 두는 단위. 관계형 데이터베이스의 테이블에 해당.
space: cosine: 코사인 거리(1 − 코사인 유사도)로 가까운 청크를 찾음. 거리는 작을수록 비슷함.chromadb.Client()는 메모리에만 저장. 런타임이 끝나면 사라짐. 파일로 남기려면chromadb.PersistentClient(path="폴더").
import chromadb
chroma = chromadb.Client()
collection = chroma.get_or_create_collection(
name="mindmall",
configuration={"hnsw": {"space": "cosine"}}, # 코사인 거리 사용
)
texts = [c["text"] for c in chunks]
collection.add(
ids=[f"chunk-{i}" for i in range(len(chunks))], # 청크마다 고유한 id
documents=texts, # 원문
embeddings=embed(texts), # 임베딩
metadatas=[{"section": c["section"]} for c in chunks], # 부가 정보
)
collection.count()
실행 결과
22
- 여기까지가 색인. 안내서가 바뀌지 않으면 다시 실행할 필요 없음.
질의 1: 검색하기
def retrieve(question, k=3): # 가까운 청크 k개의 원문·절 제목·거리
r = collection.query(query_embeddings=embed([question]), n_results=k)
return [{"text": doc, "section": meta["section"], "distance": round(dist, 3)} # 질문이 하나이므로 [0]
for doc, meta, dist in zip(r["documents"][0], r["metadatas"][0], r["distances"][0])]
for hit in retrieve("골드 회원인데 제주도로 받으면 배송비가 있나요?"):
print(hit["distance"], hit["text"][:60])
실행 결과
0.355 [3. 배송] 배송비는 3,000원이다. 한 주문의 결제 금액이 5만 원 이상이면 배송비가 무료다. 제주도와
0.463 [1. 회원 등급] 골드 회원은 모든 주문에 무료 배송을 받는다. 실버 회원은 매월 무료 배송 쿠폰 2장을
0.571 [2. 적립금] 결제 금액의 1%를 적립금으로 준다. 실버 회원은 2%, 골드 회원은 3%를 준다. 적립금은
질의 2: 프롬프트 만들기
- 검색한 청크에
[1],[2]처럼 번호를 붙여 넣음. 모델이 답변에 번호를 달게 하면 어느 청크를 근거로 했는지 확인 가능. - 자료에 없는 내용은 모른다고 답하게 지시. 검색이 실패했을 때 모델이 지어내는 것을 줄임.
def build_prompt(question, hits):
sources = "\n\n".join(f"[{i}] {hit['text']}" for i, hit in enumerate(hits, start=1))
return f"""당신은 마인드몰 고객센터 직원입니다. 아래 자료만 근거로 답하세요.
- 답변의 각 문장 끝에 근거가 된 자료 번호를 [1]처럼 다세요.
- 자료에 없는 내용은 추측하지 말고 "안내서에서 찾을 수 없습니다"라고 답하세요.
[자료]
{sources}
[질문]
{question}"""
- 위 검색 결과로 만들면
[1]배송,[2]회원 등급,[3]적립금 청크 뒤에 질문이 오는 프롬프트가 됨.print(build_prompt(question, retrieve(question)))로 확인 가능.
질의 3: 답변 생성하기
def answer(question, k=3): # 검색 → 프롬프트 구성 → 생성 → 출처 목록 붙이기
hits = retrieve(question, k)
response = client.chat.completions.create(
model=model, messages=[{"role": "user", "content": build_prompt(question, hits)}])
sources = "\n".join(f"[{i}] {hit['section']}" for i, hit in enumerate(hits, start=1))
return f"{response.choices[0].message.content}\n\n출처\n{sources}"
print(answer("골드 회원인데 제주도로 받으면 배송비가 있나요?"))
실행 결과
골드 회원이라도 제주도와 도서산간 지역은 결제 금액과 관계없이 3,000원의 추가 배송비가 붙습니다[1].
골드 회원은 모든 주문에 무료 배송을 받는다고 되어 있지만, 추가 배송비에 대한 예외는 명확히 안내되어 있으므로 추가 배송비는 내야 합니다[1][2].
출처
[1] 3. 배송
[2] 1. 회원 등급
[3] 2. 적립금
- 확인: 골드 회원도 제주도 추가 배송비 3,000원을 낸다는 규정을 반영했는가? 답변에 단 번호가 실제로 그 내용을 담은 청크를 가리키는가?
- 계산이 필요한 질문(예: 실버 회원의 적립금)도 해 볼 것. 모델이 계산한 금액은 따로 검산.
안내서에 없는 내용.
print(answer("해외로도 배송해 주나요?"))
실행 결과
안내서에서 찾을 수 없습니다[1][2][3].
출처
[1] 3. 배송
[2] 3. 배송
[3] 3. 배송
- 검색은 항상 무언가를 돌려줌. 관련 없는 배송 청크가 검색되어도, 지침 덕분에 그 내용으로 지어내지 않고 모른다고 답함.
- 모른다는 답에도 출처 번호를 단 것은 지침을 기계적으로 따른 결과. 지침에 "모른다고 답할 때는 번호를 달지 마세요"를 추가하는 식으로 다듬을 수 있음.
- 실행마다 표현은 달라질 수 있음. 모른다고 답하는지, 근거 없는 내용을 덧붙이지 않는지 확인.
검색 결과 수 정하기
k가 작으면: 필요한 청크를 놓칠 수 있음(재현도 하락).k가 크면: 관련 없는 청크가 섞이고 토큰 비용이 늘어남(정밀도 하락). 긴 자료의 중간에 있는 내용은 잘 반영되지 않는 문제도 있음(긴 컨텍스트와 웹 검색에서 다룸).- 흔한 방법: 검색은 넉넉히 하고, 순위를 다시 매겨 상위 몇 개만 프롬프트에 넣기. 다음 문서의 rerank.
참고: Chroma 문서, OpenRouter 임베딩.
퀴즈
실습에서 Chroma 컬렉션에 청크를 추가할 때 함께 저장하지 않은 것은?
퀴즈를 풀려면 대화형 기능을 불러와야 합니다.