개요

< 11 >이 SSAFY < 29 >의 실습편이었듯, 이 편은 SSAFY < 30 >실습편이다.

거기서 RAG를 이론으로 훑었다. TF-IDF가 뭘 세는지, 밀집 검색이 어떻게 학습되는지, RAG가 깨지는 네 지점이 어디인지까지 갔다.

이번 편은 그걸 코드로 짠다. 그런데 순서가 재밌었다. 실습이 완성된 RAG를 보여주고 시작하는 게 아니라, 맨손으로 시작해서 한 번씩 깨지게 만들어놨다.

그냥 물어봤다        → 지어낸다
자료를 통째로 넣었다   → 맞히는데, 자료가 열 개면?
키워드로 찾았다       → 글자가 다르면 못 찾는다
의미로 찾았다         → 찾긴 찾는데 페이지가 통째로 온다
잘게 잘랐다          → 이제 좀 된다

RAG는 하나의 기법이 아니라 이 목록 전체였다. 각 단계가 앞 단계가 깨져서 생긴 것이다.

이번 글이 답할 질문 RAG의 부품들은 왜 하필 그렇게 생겼나? 각각이 무슨 문제를 막으려고 붙은 건가?

실습 자료는 SSAFY 교육 컨텐츠라 원본을 싣지 않는다. 개념만 가져와 예제는 전부 새로 짰다. 실습은 실제 서점 서비스 문서를 썼는데, 여기서는 있지도 않은 게임 「아르카 온라인」의 고객지원 문서를 지어내 썼다. 실존 서비스 정책을 내가 지어내면 그건 그냥 거짓말이 되기 때문이다.

가정한 문서는 네 개다.

data/시즌패스_환불규정.pdf
data/계정정지_해제절차.pdf
data/길드전_보상지급기준.pdf
data/아이템복구_신청방법.pdf

1. 그냥 물어봤다 — 모르는 건 지어낸다

첫 단계는 아무것도 안 붙이고 그냥 묻는 것이다.

from langchain_openai import ChatOpenAI
from langchain_core.messages import HumanMessage

llm = ChatOpenAI(model="gpt-5-mini", api_key=GMS_KEY, base_url=BASE_URL)

question = "아르카 온라인에서 시즌패스 환불은 어떤 조건에서 되나요?"
response = llm.invoke([HumanMessage(content=question)])
print(response.content)

당연히 못 맞힌다. 애초에 학습 데이터에 없는 정보라서 그렇다.

문제는 “모른다”고 하지 않고 그럴듯하게 지어낸다는 것이다. 이걸 환각(hallucination) 이라고 부른다. “일반적으로 7일 이내에…” 같은 말이 나오는데, 진짜 정책이 30일이면 고객한테 잘못된 안내를 한 게 된다.

LLM이 못 하는 게 세 가지로 갈린다.

못 하는 것
도메인 지식 회사 내부 문서는 학습에 안 들어갔다
최신 정보 학습을 끝낸 시점 이후는 모른다
모른다고 말하기 다음 단어를 이어 쓰게 학습돼서 일단 쓴다

세 번째가 제일 골치 아프다. 모르는 걸 모른다고 하면 그나마 낫다. 틀린 답을 자신 있게 하니까 쓸 수가 없다.


2. 자료를 통째로 넣었다 — 맞히는데, 어느 자료를 넣지

해결은 어이없을 만큼 단순하다. 자료를 프롬프트에 같이 넣으면 된다.

from langchain_community.document_loaders import PyMuPDFLoader
from langchain_core.prompts import ChatPromptTemplate

loader = PyMuPDFLoader("data/시즌패스_환불규정.pdf")
documents = loader.load()
doc_content = "\n".join([doc.page_content for doc in documents])

prompt_template = ChatPromptTemplate.from_messages([
    ("system", """당신은 게임 「아르카 온라인」의 고객지원 상담원이다.
아래 자료를 참고해 정확하게 답하라.
자료에 없는 내용은 "해당 정보는 제공된 자료에 없습니다"라고 답하라.

[참고 자료]
{context}"""),
    ("human", "{question}"),
])

messages = prompt_template.format_messages(context=doc_content, question=question)
response = llm.invoke(messages)

이러면 정확하게 답한다. 여기서 “자료에 없으면 없다고 하라” 한 줄이 중요하다. 이 문장이 없으면 자료를 줘도 모자란 부분을 지어내서 채운다.

{context}{question}나중에 값이 꽂힐 자리다. format_messages를 부르는 시점에 채워진다. 문자열을 f-string으로 직접 이어붙여도 되는데, 템플릿으로 두면 프롬프트와 데이터가 분리돼서 나중에 프롬프트만 고치기가 쉽다.

그런데 여기서 바로 다음 문제가 온다.

문서가 네 개인데 어느 걸 넣나? 네 개 다 넣으면 되지 않나 싶은데, 실제 서비스에서는 문서가 수백 개다. 전부 넣으면 토큰 제한에 걸리고, 넣을 수 있다 해도 돈이 든다. 입력 토큰만큼 요금이 나가니까.

자료를 넣으면 맞힌다는 건 1분 만에 확인된다. 진짜 문제는 “넣을 자료를 어떻게 고르느냐” 고, RAG의 나머지 전부가 이 질문에 대한 답이다.


3. 키워드로 찾았다 — 글자가 다르면 못 찾는다

제일 먼저 떠오르는 건 키워드 검색이다. 코드도 한 줄이다.

def keyword_search(documents, keyword):
    return [doc for doc in documents if keyword in doc.page_content]

in 하나로 끝난다. 빠르고 단순하고 공짜다. 그런데 딱 하나를 못 한다.

키워드 검색과 의미 검색

문서에 적힌 표현이 “즉시 환불”이라면, 사용자가 그 네 글자를 정확히 맞혀야만 찾아진다. “결제 취소”, “바로 환불”, “돈 돌려받기” 는 전부 0건이다.

사용자는 문서에 뭐라고 적혀 있는지 모른다. 알면 검색을 안 했겠지. 이게 키워드 검색의 근본 문제다.

더 쉬운 비유로

키워드 검색 — 책 뒤에 붙은 색인에서 단어를 찾는 것 의미 기반 검색 — 사서한테 “배송이 늦었는데 어쩌죠” 하고 물어보는 것

색인은 정확한 단어만 받는다. “총알배송”이 색인에 있으면 그 단어로만 찾을 수 있고, “빨리 오는 배송”이라고 물으면 색인은 아무 대답도 못 한다.

사서는 다르다. 내가 어떤 단어를 쓰든 무슨 말인지 알아듣고 관련 있는 서가로 데려간다. 그렇다면 컴퓨터를 사서처럼 만들려면 뭐가 필요할까. “말이 비슷하다”를 숫자로 잴 수 있어야 한다.

키워드 검색은 글자를 비교하고, 의미 검색은 뜻을 비교한다. 이 한 칸을 넘으려고 임베딩이 필요해진다.


4. 의미로 찾는다 — 임베딩과 벡터DB

여기가 두 번째로 걸린 곳이었다. 코드는 세 줄인데 세 줄이 각각 다른 일을 한다.

임베딩과 벡터 스토어

from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma

# ① 글을 숫자로 바꾸는 도구
embeddings = OpenAIEmbeddings(model="text-embedding-3-small",
                              api_key=GMS_KEY, base_url=BASE_URL)

# ② 문서를 전부 벡터로 바꿔서 담아둔다
vectorstore = Chroma.from_documents(
    documents=all_documents,
    embedding=embeddings,
    collection_name="arca_docs",
)

# ③ 질문이 오면 가까운 것 3개를 꺼내는 창구
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})

각각 이렇다.

하는 일 언제 도나
OpenAIEmbeddings 글 한 덩이를 숫자 1536개로 바꾼다 부를 때마다 API 호출
Chroma.from_documents 문서를 전부 벡터로 바꿔 저장한다 처음 한 번 (오래 걸린다)
as_retriever(k=3) 질문도 벡터로 바꿔 가까운 3개를 꺼낸다 질문마다

실습 코드에는 from langchain_community.vectorstores import Chroma로 되어 있는데, 지금은 langchain-chroma 패키지로 분리됐다. community 쪽 것도 아직 돌아가지만 경고가 뜨고, 새로 짤 때는 pip install langchain-chroma 후 위처럼 쓰는 게 맞다. < 11 >pretrained=True와 같은 종류의 변화다 — 라이브러리가 커지면서 붙어 있던 것들이 떨어져 나간다.

from_documents가 처음에 오래 걸리는 이유가 여기 있다. 문서 개수만큼 임베딩 API를 부른다. 문서 100개면 100번이다. 그래서 이건 한 번 만들어두고 재사용하는 물건이지, 질문마다 다시 만드는 게 아니다.

“가깝다”를 재는 자는 코사인 유사도다. < 11 >에서 CLIP이 그림과 문장을 비교할 때 쓴 그 식이 그대로 여기 온다.

$$ \cos(A, B) = \frac{A \cdot B}{\lVert A \rVert \lVert B \rVert} $$

두 벡터가 어느 방향을 보고 있느냐만 재고 길이는 안 본다. 그래서 긴 문서와 짧은 문서를 나란히 비교해도 길이 때문에 불리해지지 않는다.

걸리는 게 하나 더 있다

임베딩 모델에도 입력 상한이 있다. text-embedding-3-small은 8,191토큰이다. PDF 한 페이지가 이걸 넘으면 그 페이지는 그냥 거절당한다.

import tiktoken

MAX_TOKENS = 8000  # 상한보다 조금 낮게
enc = tiktoken.encoding_for_model("text-embedding-3-small")

for doc in all_documents:
    tokens = enc.encode(doc.page_content)
    if len(tokens) > MAX_TOKENS:
        doc.page_content = enc.decode(tokens[:MAX_TOKENS])

앞에서부터 잘라 버리는 방식이라 뒷부분은 통째로 사라진다. 임시방편이다. 애초에 페이지 하나를 통으로 넣는 게 문제라는 신호이기도 하고.

벡터DB는 마법이 아니라 미리 계산해둔 좌표 목록이다. 저장할 때 한 번 비싸고, 검색할 때 싸다.


5. 페이지가 통째로 온다 — 청킹

의미 검색이 되긴 되는데 결과가 이상하다. 찾아온 게 페이지 통째다.

“환불 조건” 하나 물었는데 그 페이지에 있는 결제 방법, 문의처, 약관 링크가 전부 딸려 온다. 필요한 두 줄이 스무 줄 사이에 묻힌다.

원인이 명확하다. 저장 단위가 페이지라서 검색 단위도 페이지다. 그래서 더 잘게 잘라 넣는다. 이걸 청킹이라고 한다.

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=300,       # 조각 하나의 최대 글자 수
    chunk_overlap=50,     # 조각끼리 겹치는 글자 수
    length_function=len,
    separators=["\n\n", "\n", ".", " ", ""],
)

chunked_documents = text_splitter.split_documents(all_documents)

Recursive가 이름에 붙은 이유는 끊는 자리를 순서대로 시도하기 때문이다. 먼저 빈 줄에서 끊어보고, 안 되면 줄바꿈, 그다음 마침표, 그다음 띄어쓰기, 최후엔 그냥 글자 수로 자른다. 말이 되는 자리에서 끊으려는 것이다.

overlap이 왜 필요한가

chunk_overlap이 처음엔 낭비처럼 보였다. 같은 글자를 두 번 저장하는 거니까.

청킹과 overlap

겹치지 않게 자르면 경계에 걸친 문장이 두 동강 난다. “7일 이내에 신청할 수” 와 “있다. 단, 보상을 한 번이라도” 로 갈라지면, 어느 쪽이 검색돼도 문장이 반쪽이다.

50자를 겹쳐두면 적어도 한쪽에는 온전한 문장이 들어간다. 저장 공간을 조금 더 쓰고 정확도를 사는 거래다.

크기는 문서 성격에 따라 다르다.

chunk_size 장점 단점 맞는 문서
100~200 딱 필요한 것만 온다 앞뒤 맥락이 없다 FAQ, 용어 정의
300~500 무난하다 최적값 찾기가 애매하다 정책·안내 문서
1000+ 맥락이 넉넉하다 잡음이 섞이고 비싸다 긴 설명, 이어지는 서술

chunk_overlap은 보통 chunk_size10~20% 로 잡는다. 300이면 30~60 사이다.

모델도 벡터DB도 그대로인데 자르는 크기 하나로 결과가 바뀐다. RAG에서 제일 손이 많이 가는 게 이 숫자 두 개다.


6. LangGraph로 묶는다 — State가 흐른다

부품이 다 모였으니 이어 붙인다. 여기가 첫 번째로 걸린 곳이었다.

LangGraph는 작업을 노드로 쪼개고, 노드끼리 상태(State) 를 주고받게 만드는 도구다.

from typing import TypedDict
from langgraph.graph import StateGraph, START, END

# 그래프 전체가 공유하는 데이터의 모양
class RAGState(TypedDict):
    question: str
    context: str
    answer: str

def retrieve(state: RAGState) -> RAGState:
    docs = retriever_chunked.invoke(state["question"])
    context = "\n\n".join([doc.page_content for doc in docs])
    return {"context": context}          # context 하나만 돌려준다

def generate(state: RAGState) -> RAGState:
    messages = rag_prompt.format_messages(
        context=state["context"], question=state["question"]
    )
    return {"answer": llm.invoke(messages).content}

workflow = StateGraph(RAGState)
workflow.add_node("retrieve", retrieve)
workflow.add_node("generate", generate)
workflow.add_edge(START, "retrieve")
workflow.add_edge("retrieve", "generate")
workflow.add_edge("generate", END)

rag_graph = workflow.compile()

여기서 한참 헤맸다. retrieve{"context": ...} 만 돌려주는데, 그럼 question은 사라지는 거 아닌가?

아니다.

LangGraph 상태 흐름

노드가 돌려준 dict는 상태를 통째로 교체하는 게 아니라 그 키만 덮어쓴다. 그래서 retrievecontext만 채워 넣어도 question은 그대로 남아 있고, generatestate["question"]을 꺼내 쓸 수 있다.

이걸 알고 나니 노드 짜는 게 편해졌다. 각 노드는 자기가 채울 칸만 신경 쓰면 된다.

시점 question context answer
invoke 직후 있음 비어 있음 비어 있음
retrieve 있음 채워짐 비어 있음
generate 있음 채워짐 채워짐

실행은 딕셔너리 하나 던지면 끝이다.

result = rag_graph.invoke({"question": "시즌패스 환불이 되나요?"})
print(result["answer"])

굳이 그래프로 만들 이유가 있나

노드 두 개를 순서대로 부르는 것뿐이면 함수 두 번 부르는 게 더 짧다. 실제로 지금 단계에서는 그렇다.

이렇게 만드는 이유는 다음에 낄 것들 때문이다.

  • 검색 결과가 부실하면 질문을 고쳐서 다시 검색하는 분기
  • 답을 쓴 뒤 근거가 맞는지 검사하고 아니면 되돌아가는 고리
  • 문서 검색 대신 계산기나 API를 부르는 다른 노드

이런 걸 함수 호출로 엮으면 if가 중첩되면서 금세 알아볼 수 없게 된다. 그래프로 두면 노드를 하나 만들고 선을 하나 긋는 일이 된다.

지금 두 노드짜리 그래프는 과한 게 맞다. 다만 여기서 노드와 엣지를 익혀두면 다음 편의 Agent가 그 위에 그냥 얹힌다.


정리

  • RAG는 하나의 기법이 아니라 무너진 것을 덧댄 목록이다. 환각 → 자료 넣기 → 자료 고르기 → 키워드의 한계 → 의미 검색 → 덩어리가 큼 → 청킹 순서로 하나씩 생겼다
  • 프롬프트에 “자료에 없으면 없다고 하라”를 반드시 넣는다. 자료를 줘도 모자란 부분은 지어내서 채운다
  • 키워드 검색은 사용자가 문서의 단어를 맞혀야 동작한다. “즉시 환불”로 적힌 문서를 “결제 취소”로는 절대 못 찾는다
  • 임베딩 세 줄은 각각 다른 일을 한다. 변환기(OpenAIEmbeddings) · 저장소(Chroma.from_documents) · 창구(as_retriever). 비싼 건 저장 단계고, 검색은 싸다
  • 임베딩 모델에도 입력 상한이 있다. text-embedding-3-small은 8,191토큰이고, 넘으면 잘라야 한다
  • 청킹은 chunk_sizechunk_overlap 두 숫자가 전부다. overlap은 경계에 걸친 문장이 두 동강 나는 걸 막고, 보통 chunk_size의 10~20%로 잡는다
  • LangGraph 노드는 자기가 채운 키만 돌려준다. 상태를 통째로 바꾸는 게 아니라 그 키만 덮어써서, 앞 노드가 넣어둔 값은 남는다
  • 노드 두 개짜리 그래프는 지금은 과하다. 분기·재검색·검증을 끼울 자리를 미리 만들어두는 것이다

참고 자료

한줄 평

  • RAG가 왜 이렇게 부품이 많나 했는데, 하나씩 깨져봤더니 전부 어디가 무너져서 붙은 것들이었다 -> 순서대로 깨져보는 게 완성본을 보는 것보다 훨씬 빨리 이해됐다!