logo

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

대화형 다이어그램을 불러오기 전에는 원문으로 표시됩니다.

실습 준비

!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 임베딩.

퀴즈

문제 1 / 5맞음: 0힌트: 0틀림: 0채점중: 0남음: 5

실습에서 Chroma 컬렉션에 청크를 추가할 때 함께 저장하지 않은 것은?

  • ○질문에 대한 최종 답변
  • ○청크의 원문
  • ○청크의 임베딩
  • ○청크가 속한 절 제목

퀴즈를 풀려면 대화형 기능을 불러와야 합니다.