에이전트·RAG

AGENT / 15번째 글

RAG의 구조: Naive에서 Modular까지

검색 결과를 프롬프트에 끼워 넣는 최소 형태에서 시작해, 그 형태가 무너지는 자리마다 무엇이 덧붙어 세 세대가 되었는지를 한 줄기로 따라간다. 인덱싱 설계와 단계별 평가까지 함께 다룬다.

PALDYN Team50 MIN READ

지난 글에서 PostgreSQL 확장인 pgvector로 벡터를 저장하고 유사도 검색을 돌려 봤다. 검색 결과가 화면에 뜨는 것으로 그 글은 끝났지만, 실무에서 그 결과가 향하는 곳은 사람의 눈이 아니라 LLM의 프롬프트다. RAG(Retrieval-Augmented Generation, 검색 증강 생성)는 LLM이 답을 만들기 전에 외부 문서를 검색해 그 내용을 프롬프트에 끼워 넣는 구조다. 2020년 「Retrieval-Augmented Generation for Knowledge-Intensive NLP Tasks」 논문에서 이름이 붙었고, 지금은 LLM을 업무에 붙일 때 가장 먼저 꺼내는 구조가 됐다.

이 글은 그 구조를 한 줄기로 훑는다. 「질문 → 검색 → 생성」 세 칸짜리 최소 형태에서 시작해, 그 형태가 실제로 어디서 무너지는지 보고, 무너지는 자리마다 무엇이 덧붙어 왔는지를 따라간다. 흔히 이 흐름을 세 세대로 나눠 부른다 — 1세대 Naive RAG, 2세대 Advanced RAG, 3세대 Modular RAG다. 세대는 서로를 갈아 치우지 않는다. 뒤 세대는 앞 세대를 감싸고 있고, 어디서 멈출지는 다루는 문서의 양과 질문의 복잡도가 정한다.

RAG의 역할

LLM의 한계

최신 LLM은 언어를 이해하고 만들어 내는 일에서는 흠잡을 데가 별로 없다. 문제는 언어가 아니라 지식이 어디에 들어 있는가다. 모델이 아는 것은 학습 때 가중치에 눌러 담긴 것뿐이고, 여기서 네 가지 한계가 한꺼번에 나온다.

첫째는 지식 컷오프(knowledge cutoff)다. 모델이 학습 데이터를 모은 시점을 뜻하고, 그 뒤에 일어난 일은 가중치 어디에도 없다. 어제 올라온 사내 공지, 지난주에 바뀐 요금제, 오늘 새벽에 나온 논문은 모델이 알 방법이 없다. 그리고 이 선은 모델을 다시 학습시키지 않는 한 시간이 갈수록 뒤로 밀린다.

둘째는 환각(hallucination)이다. LLM은 「모릅니다」라고 말하는 것보다 그럴듯한 문장을 이어 붙이는 쪽으로 최적화되어 있다. 특히 구체적인 수치, 날짜, 사람 이름, 법 조항처럼 정밀해야 하는 자리에서 확신에 찬 오답이 나온다. 문장의 형태가 완벽해서 읽는 쪽이 틀렸다는 것을 알아채기도 어렵다.

셋째는 사내 지식의 부재다. 공개 데이터로 학습한 모델에게 우리 회사의 내부 규정, 미공개 설계 문서, 지난 분기 회의록이 있을 리 없다. 대부분의 업무 질문이 정확히 이 영역에 있다는 것이 문제다.

넷째는 출처의 불투명성이다. 모델이 어떤 근거로 그 답을 냈는지 추적할 방법이 없다. 법률·의료·금융처럼 틀린 답의 대가가 큰 영역에서는 답이 맞았는지보다 왜 그렇게 답했는지 보일 수 있는가가 먼저다.

넷은 서로 다른 증상처럼 보이지만 원인이 하나다. 답에 필요한 정보가 가중치 안에만 있다는 것이다. RAG는 그 정보를 가중치 밖으로 꺼내 놓는다. 꺼내 놓고 나면 넷이 함께 풀린다 — 문서를 갈아 끼우면 컷오프가 사라지고, 근거를 프롬프트에 함께 넣으면 환각이 줄고, 사내 문서를 넣으면 사내 지식이 생기고, 검색된 청크에 붙어 있던 메타데이터가 그대로 출처가 된다.

LLM 단독 vs RAG 비교

파인튜닝과의 차이

같은 목적으로 자주 비교되는 것이 파인튜닝(fine-tuning)이다. 도메인 데이터로 모델의 가중치를 다시 조정하는 방법이고, RAG와는 정보를 넣는 자리부터 다르다. RAG는 정보를 프롬프트에 넣고 파인튜닝은 가중치에 넣는다.

구분 RAG 파인튜닝
지식 업데이트 문서만 갈아 끼우면 즉시 재학습 필요
학습 비용 없음 GPU 시간이 든다
출처 제공 가능 불가능
새 도메인 적응 문서를 넣는 즉시 데이터 수집과 학습이 먼저
잘 맞는 자리 최신 정보, 근거가 필요한 답 특정 말투·출력 형식

가르는 질문은 하나로 줄어든다. 모델에게 없는 것이 지식인가 말투인가. 「우리 제품 A의 보증 기간이 몇 개월인가」는 지식이라 RAG다. 「모든 답변을 상담원 어조의 세 문단으로 내라」는 형식이라 파인튜닝 쪽이 낫다. 여기서 흔히 하는 실수가 형식 문제를 파인튜닝으로 풀어야 할 자리에 문서를 더 넣는 것, 반대로 지식 문제를 프롬프트가 아니라 재학습으로 풀려는 것이다.

둘은 배타적이지도 않다. 도메인 말투로 파인튜닝한 모델 위에 RAG를 얹는 조합이 실제로 가장 좋은 결과를 낸다. 다만 처음 시작이라면 순서는 명확하다 — RAG가 훨씬 빠르고 싸고, 무엇보다 틀렸을 때 어디가 틀렸는지 볼 수 있다. 두 방법의 비교는 RAG와 파인튜닝의 선택에서 따로 더 다룬다.

인덱싱과 쿼리 파이프라인

RAG 시스템은 하나의 흐름처럼 보이지만 실제로는 서로 다른 시각에 도는 두 개의 파이프라인이다. 이 둘을 갈라 놓고 보는 것이 이 글 전체의 뼈대가 된다.

오프라인 인덱싱 파이프라인은 문서를 검색 가능한 상태로 미리 만들어 두는 쪽이다. 문서를 수집하고, 청킹(chunking, 긴 문서를 검색 단위로 쪼개는 일)으로 조각을 내고, 각 조각을 임베딩 모델로 벡터로 바꾸고, 벡터와 원문과 메타데이터를 벡터 DB에 함께 저장한다. 한 번 돌려 두면 문서가 바뀔 때까지 다시 돌 일이 없다.

온라인 쿼리 파이프라인은 질문이 들어올 때마다 도는 쪽이다. 질문을 같은 임베딩 모델로 벡터로 바꾸고, 벡터 DB에서 가장 가까운 청크 Top-K를 뽑고, 그 청크들을 컨텍스트로 삼아 프롬프트를 조립하고, LLM에게 넘긴다.

두 파이프라인 사이에는 반드시 지켜야 하는 약속이 하나 있다. 질문을 벡터로 만드는 모델과 문서를 벡터로 만든 모델이 같아야 한다. 다르면 두 벡터가 애초에 다른 공간에 놓이므로 코사인 유사도가 아무 의미도 갖지 못한다. 그런데 차원 수만 맞으면 계산은 그냥 되고 오류도 안 난다 — 검색 결과가 그럴듯한 순서로 나오는데 실제로는 아무 상관 없는 청크들인, 찾기 가장 나쁜 종류의 고장이다. 임베딩 모델을 바꾸면 인덱스 전체를 다시 만들어야 한다는 것도 같은 이유다.

숫자를 한 번 넣어 보면 규모 감이 잡힌다. 200쪽짜리 사내 매뉴얼이 페이지당 2,000자면 전체 40만 자다. 청크를 500자로 자르고 앞뒤를 50자씩 겹치면 청크 하나가 450자씩 전진하므로 조각은 889개가 나온다. 질의할 때 Top-K를 4로 두면 프롬프트에 들어가는 것은 2,000자, 전체 문서의 0.5%다. 나머지 99.5%를 버리는 이 선택이 답변 품질의 상한을 정한다. RAG를 손보는 일의 대부분은 결국 그 0.5%를 제대로 고르는 일이다.

RAG 전체 파이프라인

1세대 Naive RAG

RAG 아키텍처 발전 단계

질문·검색·생성

1세대는 방금 본 쿼리 파이프라인을 그대로 코드로 옮긴 것이다. 질문을 그대로 임베딩하고, 그대로 검색하고, 검색 결과를 그대로 프롬프트에 붙인다. 「그대로」가 세 번 나오는 것이 이 세대의 정의다.

def naive_rag(question: str, vectorstore, llm) -> str:
    docs = vectorstore.similarity_search(question, k=3)
    context = "\n\n".join(d.page_content for d in docs)
    prompt = f"Context:\n{context}\n\nQuestion: {question}"
    return llm.invoke(prompt).content

다섯 줄이 전부다. 이 단순함을 얕보면 안 된다. 개념 증명 단계에서 이 다섯 줄이 「우리 문서로 답이 나오긴 하는가」를 하루 만에 확인해 주고, 그 답이 어느 정도 쓸 만한지가 이후 투자 규모를 정한다. 뒤에 나올 모든 장치는 이 다섯 줄의 어느 자리가 부족한지를 확인한 다음에 붙이는 것이다.

첫 파이프라인의 전체 코드

인덱싱까지 포함한 형태를 LangChain으로 세워 본다. 문서 로더, 분할기, 임베딩, 벡터 저장소, 체인이 각각 한 덩어리씩 맡는다.

from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import FAISS
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

# ── 인덱싱 ───────────────────────────────────────────────
documents = PyPDFLoader("company_handbook.pdf").load()
splitter = RecursiveCharacterTextSplitter(
    chunk_size=500, chunk_overlap=50,
    separators=["\n\n", "\n", ".", " "],
)
chunks = splitter.split_documents(documents)

embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = FAISS.from_documents(chunks, embeddings)
vectorstore.save_local("faiss_index")

# ── 쿼리 ─────────────────────────────────────────────────
prompt = PromptTemplate(
    input_variables=["context", "question"],
    template="""다음 문서 내용을 참고하여 질문에 답하세요.
문서에 없는 내용은 "문서에서 찾을 수 없습니다"라고 답하세요.

문서:
{context}

질문: {question}
답변:""",
)

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
    chain_type_kwargs={"prompt": prompt},
    return_source_documents=True,
)

result = qa_chain.invoke({"query": "연차 휴가는 며칠인가요?"})
print(result["result"])
for doc in result["source_documents"]:
    print(doc.metadata.get("source"), doc.metadata.get("page"))

눈여겨볼 것은 return_source_documents=True 한 줄이다. 이 인자가 없으면 답변 문자열만 돌아오고, 그 순간 앞에서 본 네 번째 한계인 출처 불투명성이 그대로 돌아온다. 검색된 청크에는 파일 이름과 페이지 번호가 메타데이터로 붙어 있으므로, 답변 아래에 「사규 3장 12쪽」을 함께 찍어 주는 데 드는 비용은 이 인자 하나뿐이다. 근거를 보여 줄 수 있다는 것이 RAG의 가장 큰 실무적 이점인데, 기본값을 그대로 쓰다 그 이점을 버리는 코드를 자주 본다.

한 가지 주의할 점이 있다. LangChain은 체인을 조립하는 방식을 여러 차례 바꿔 왔고, 그때마다 import 경로가 함께 움직였다. 1.0에서는 RetrievalQA 같은 고수준 체인과 뒤에서 쓸 앙상블 검색기가 langchain-classic이라는 별도 패키지로 빠져서 langchain_classic.chains·langchain_classic.retrievers에서 불러오게 됐다. 위 코드는 흐름을 보여 주기 위한 것이고, 실제로 붙여 넣기 전에는 설치한 버전의 문서를 확인하는 편이 낫다. 반면 로드 → 분할 → 임베딩 → 저장 → 검색 → 생성이라는 단계의 배열 자체는 라이브러리가 바뀌어도 그대로다. 외울 것은 이쪽이다.

근거 강제 프롬프트

검색이 잘 돼도 마지막 한 걸음에서 무너지는 경우가 있다. LLM이 컨텍스트를 참고 자료로만 보고 자기가 아는 것을 섞어 답하는 경우다. 프롬프트가 그것을 막는다.

RAG_PROMPT = """당신은 주어진 문서를 바탕으로 질문에 답하는
전문 어시스턴트입니다.

규칙:
1. 반드시 아래 문서 내용에 근거해서만 답변하세요.
2. 문서에 없는 내용을 추측하거나 창작하지 마세요.
3. 답변에 근거한 문서 번호([문서 1], [문서 2] 등)를 명시하세요.
4. 확실하지 않으면 "문서에서 명확한 정보를 찾지 못했습니다"
   라고 답하세요.

참조 문서:
{context}

질문: {question}

답변 (근거 문서 번호 포함):"""

핵심은 네 번째 규칙, 「문서에 없으면 모른다고 하라」는 명시적 허가다. 이 문장이 없으면 모델은 빈칸을 자기 사전 지식으로 메우려 하고, 그러면 애써 검색한 의미가 사라진다. 세 번째 규칙도 장식이 아니다. 문서 번호를 답변 안에 적게 하면 사람이 그 자리에서 대조할 수 있고, 모델 스스로도 근거 없이 쓴 문장을 만들기 어려워진다.

컨텍스트에 번호를 매겨 넣는 것도 이 규칙과 한 쌍이다. 청크들을 그냥 이어 붙이면 모델이 가리킬 이름이 없다. [문서 1], [문서 2]처럼 머리표를 붙여 넣어야 세 번째 규칙이 지켜질 수 있다. 프롬프트 설계 전반은 프롬프트 엔지니어링에서 따로 다뤘다.

Naive RAG의 한계

여기까지가 1세대다. 그리고 실제 사용자를 붙이면 보통 며칠 안에 세 가지 증상이 나타난다.

첫째는 검색 정밀도다. 사용자는 검색어를 쓰지 않고 말을 한다. 「지난번에 얘기한 그 기능 있잖아요」에는 검색에 쓸 만한 명사가 하나도 없고, 「그거 얼마예요」는 앞 대화 없이는 무엇의 가격인지조차 알 수 없다. 질문을 그대로 임베딩하는 1세대는 이런 질문 앞에서 그냥 진다.

둘째는 중복 컨텍스트다. 같은 내용이 여러 문서에 조금씩 다른 문장으로 실려 있으면 벡터 검색은 그 셋을 나란히 뽑아 온다. 유사도만 보고 고르니 당연한 결과인데, Top-K가 4일 때 그중 셋이 같은 말이면 실제로 확보한 정보는 두 조각뿐이다. 프롬프트 자리를 그만큼 낭비한 셈이다.

셋째는 Lost in the Middle이라 불리는 현상이다. LLM이 긴 컨텍스트를 읽을 때 앞머리와 끝의 정보는 잘 쓰지만 가운데 놓인 정보는 흘리는 경향이 관찰됐다. 그래서 Top-K를 10, 20으로 올려도 답이 나아지지 않고 오히려 나빠지는 구간이 생긴다. 정답 청크가 컨텍스트 한복판에 놓이면 모델이 그것을 못 본 것처럼 답하기 때문이다. 더 많이 넣는 것이 답이 아니라는 사실이 2세대의 출발점이 된다.

2세대 Advanced RAG

2세대 Advanced RAG는 파이프라인의 뼈대를 바꾸지 않는다. 검색이라는 한 칸의 앞과 뒤에 처리 단계를 덧붙일 뿐이다. 앞에 붙는 것을 사전 검색(pre-retrieval), 뒤에 붙는 것을 사후 검색(post-retrieval)이라 부른다.

쿼리 변환

앞쪽에서 하는 일은 하나로 요약된다 — 사용자가 던진 문장과 검색에 좋은 문장은 다르다. 그 사이를 메우는 방법이 셋이다.

쿼리 재작성(query rewriting)은 모호한 질문을 LLM으로 다시 쓰는 것이다. 대화 맥락을 붙여 「지난번 그 기능」을 「2월에 출시한 자동 백업 기능」으로 바꾸면 검색이 비로소 걸린다. 대화형 RAG에서는 사실상 필수인데, 두 번째 질문부터는 거의 언제나 앞 대화에 의존하기 때문이다.

HyDE(Hypothetical Document Embeddings)는 방향을 뒤집는다. 질문과 문서는 문장의 결이 다르다 — 질문은 짧고 의문형이고, 문서는 길고 서술형이다. 그래서 질문 벡터와 문서 벡터는 내용이 맞아도 거리가 멀 수 있다. HyDE는 LLM에게 「이 질문에 대한 답이 있다면 이렇게 생겼을 것」이라는 가상의 문서를 두세 문장으로 짓게 하고, 그 가상 문서를 검색어로 쓴다. 내용이 틀려도 상관없다. 찾으려는 것은 사실이 아니라 비슷한 결의 문장이기 때문이다.

쿼리 분해(query decomposition)는 복합 질문을 나눈다. 「A 제품과 B 제품의 보증 기간 차이는」은 한 번의 검색으로 답할 수 없다. A의 보증 기간과 B의 보증 기간을 따로 찾아 와야 하고, 그러려면 질문을 둘로 쪼개 각각 검색한 뒤 결과를 합쳐야 한다.

하이브리드 검색

검색 단계 자체에도 손댈 곳이 있다. 벡터 검색은 의미가 비슷한 것을 찾는 데 강하지만 정확히 그 글자를 찾는 데는 약하다. 제품 코드 KX-350, 오류 번호 E-4012, 사람 이름 같은 것은 임베딩 공간에서 별다른 의미를 갖지 못해서, 비슷한 코드가 잔뜩 있는 문서 더미에서는 엉뚱한 것이 먼저 올라온다.

BM25는 그 반대편에 있는 고전적인 검색 방식이다. 단어가 문서에 몇 번 나오는지와 그 단어가 전체 문서에서 얼마나 드문지로 점수를 매긴다. 의미는 전혀 모르지만 글자가 정확히 일치하면 확실하게 잡아낸다. 둘의 약점이 서로 겹치지 않으므로 하이브리드 검색은 둘을 함께 돌리고 결과를 섞는다.

섞는 방식에 함정이 하나 있다. 두 점수의 눈금이 다르다는 것이다. 코사인 유사도는 대체로 −1-1 부터 11 사이지만 BM25 점수에는 정해진 상한이 없다. 그래서 두 점수를 그냥 더하면 BM25 쪽이 결과를 통째로 지배하거나 반대로 묻힌다. 실무에서 자주 쓰는 우회로가 점수 대신 순위를 섞는 것이고, 대표적인 것이 상호 순위 융합이다.

RRF(d)=∑iwik+ri(d)\mathrm{RRF}(d) = \sum_{i} \frac{w_i}{k + r_i(d)}

ri(d)r_i(d) 는 ii 번째 검색기가 문서 dd 에게 매긴 순위이고 kk 는 상위권의 영향력을 누그러뜨리는 상수다. 점수의 눈금이 사라지고 순위만 남으므로 어떤 검색기든 같은 규칙으로 합칠 수 있다. 가중치를 6 대 4쯤에서 시작해 도메인에 맞춰 옮기는 것이 보통인데, 코드나 식별자가 많은 문서일수록 BM25 쪽 몫을 올린다. 융합 방식과 가중치 조정은 하이브리드 검색 튜닝에서 더 깊이 다룬다.

하이브리드 검색과 순위 융합

재순위와 컨텍스트 압축

뒤쪽에 붙는 것은 재순위(reranking)다. 벡터 검색은 질문 벡터와 청크 벡터를 각각 따로 만들어 거리를 재므로 빠르지만 거칠다. 재순위는 질문과 청크를 한 쌍으로 묶어 모델에 함께 넣고 관련도를 직접 점수 매긴다. 이런 모델을 cross-encoder라 부른다. 정확한 대신 느려서 모든 문서에 쓸 수 없고, 그래서 역할이 정해진다 — 벡터 검색으로 20개쯤 넉넉히 뽑아 놓고 그중 상위 3개만 남기는 좁히기 단계다. 전용 모델 대신 LLM에게 「이 청크가 이 질문에 도움이 되는가」를 직접 묻는 방식도 쓰인다. 기준을 말로 적을 수 있다는 것이 장점이고, 후보 하나마다 모델 호출이 붙는다는 것이 값이다.

이 구성이 앞 절의 Lost in the Middle을 직접 겨냥한다. 컨텍스트에 넣는 개수를 20에서 3으로 줄이면 정답이 가운데 묻힐 자리 자체가 없어지고, 남은 셋 중 가장 관련 있는 것을 맨 앞에 두면 모델이 가장 잘 읽는 자리에 정답이 놓인다.

컨텍스트 압축은 한 걸음 더 간다. 남긴 청크에서도 질문과 관련 있는 문장만 뽑아내 프롬프트를 줄인다. 500자 청크에서 실제로 답에 쓰이는 것이 두 문장뿐이라면 나머지는 토큰을 먹으면서 모델의 주의를 분산시킬 뿐이다.

from langchain.retrievers import (EnsembleRetriever,
                                  ContextualCompressionRetriever)
from langchain.retrievers.document_compressors import LLMChainExtractor
from langchain_community.retrievers import BM25Retriever

bm25 = BM25Retriever.from_documents(chunks)
bm25.k = 4
vector = vectorstore.as_retriever(search_kwargs={"k": 4})

ensemble = EnsembleRetriever(retrievers=[bm25, vector], weights=[0.4, 0.6])

compressor = LLMChainExtractor.from_llm(llm)      # 질문 관련 문장만 추출
retriever = ContextualCompressionRetriever(
    base_compressor=compressor, base_retriever=ensemble,
)

HYDE = "다음 질문에 대한 가상의 답변 문서를 2~3 문장으로 작성하세요.\n질문: {q}\n답변:"

def hyde_search(question: str) -> list:
    hypothetical = llm.invoke(HYDE.format(q=question)).content
    return retriever.invoke(hypothetical)

import를 빼면 열 줄 남짓인데 그 안에 세 가지가 겹쳐 있다. 하이브리드 검색, 컨텍스트 압축, HyDE다. 그리고 셋 다 LLM 호출을 하나씩 늘린다는 점이 값이다. 압축기는 청크마다 모델을 부르고 HyDE는 검색 전에 한 번 더 부른다. 질문 하나에 모델을 세 번 부르면 응답 시간도 비용도 세 배가 된다. 2세대의 장치는 공짜가 아니고, 그래서 세 개를 한꺼번에 켜는 대신 하나씩 켜 보고 지표가 실제로 오르는 것만 남기는 순서가 맞다. 재순위 모델을 고르는 기준은 재순위, 질문을 고쳐 쓰는 방법의 갈래는 쿼리 재작성에 따로 정리해 뒀다.

인덱싱 설계

지금까지는 쿼리 쪽 이야기였다. 그런데 검색이 아무리 좋아도 인덱스에 없는 것은 찾을 수 없다. 표가 줄글로 뭉개져 저장됐다면 어떤 재순위 모델도 그 표의 숫자를 되살리지 못한다. 인덱싱은 검색 품질의 출발점이 아니라 상한이다.

RAG 인덱싱 파이프라인 상세

구조 보존 파싱

문서 파싱을 「텍스트 추출」로 이해하면 첫 단추가 어긋난다. PDF에서 글자만 뽑아내면 제목이 본문과 구별되지 않고, 표는 셀 경계가 사라져 숫자들이 한 줄로 늘어서고, 두 단 편집된 문서는 왼쪽 단과 오른쪽 단이 번갈아 섞인다. 이렇게 만들어진 텍스트를 청킹하면 애초에 뜻이 통하지 않는 조각이 나온다.

그래서 파싱 단계에서 해야 할 일은 요소의 종류를 알아내 표시해 두는 것이다. unstructured 같은 라이브러리는 문서를 제목·본문·표·목록 같은 요소로 갈라 주고, 요소마다 그 종류를 함께 돌려준다. 표는 표대로 따로 다루고, 제목은 그 아래 본문의 소속을 알려 주는 이름표로 쓰면 된다. 처리 중에 붙는 군더더기 — 여러 칸으로 벌어진 공백, 유니코드 따옴표, 열 글자도 안 되는 부스러기 조각 — 를 걷어 내는 것도 이 단계의 일이다.

PDF는 페이지 단위 정보를 함께 남길 수 있다는 점에서 특별하다. PyMuPDF 같은 도구로 페이지별로 텍스트를 뽑으면 각 조각이 몇 쪽에서 왔는지가 그대로 남고, 그 값이 나중에 답변 아래 찍히는 출처가 된다. 파싱 도구를 고르는 기준과 읽기 순서가 꼬이는 문제는 문서 파싱에서 자세히 다뤘다.

메타데이터 필터

메타데이터는 출처 표기용 부속물처럼 보이지만, 실제로는 검색 범위를 좁히는 손잡이다. 유사도만으로 고르는 검색에 조건을 하나 걸면 후보 집합 자체가 줄어들고, 줄어든 만큼 정밀도가 오른다.

갈래 담는 것 쓰이는 자리
출처 파일명, 페이지, 절 제목, 문서 종류 답변에 근거 표기
시간 인덱싱 시각, 문서 버전 오래된 규정 걸러 내기
접근 제어 공개 등급, 소속 부서 권한 없는 문서 차단
품질 문서 내 청크 순번, 전체 청크 수, 글자 수 앞뒤 청크 이어 붙이기

시간과 접근 제어 두 줄이 특히 중요하다. 규정이 개정됐는데 옛 버전이 인덱스에 남아 있으면 검색은 그 둘을 구별하지 못하고, 모델은 어느 쪽이든 문서에 있으니 그대로 답한다. 접근 제어는 더 무겁다 — 인사 문서가 전사 챗봇에서 검색되는 사고는 필터 한 줄이 빠져서 생긴다. filter={"doc_type": "policy", "department": "HR"}처럼 조건을 얹는 일 자체는 벡터 DB 대부분이 지원하고, 필터를 검색 전에 거는지 후에 거는지에 따라 성능과 결과가 달라진다. 그 차이는 메타데이터 필터링에 정리돼 있다.

청크 크기와 겹침

인덱싱에서 마지막이자 가장 되돌리기 어려운 결정이 청크의 크기다. 검색이 돌려주는 최소 단위가 곧 청크이므로, 이 결정은 모델이 볼 수 있는 정보의 모양 자체를 정한다.

작게 자르면 검색은 정확해진다. 조각 하나가 한 가지 이야기만 담고 있으니 벡터가 또렷하다. 대신 답에 필요한 문맥이 잘려 나간다 — 「이 경우 3일 이내에 신청한다」만 검색되고 「이 경우」가 무엇인지는 앞 조각에 남는 식이다. 크게 자르면 반대가 된다. 문맥은 살아 있지만 조각 하나에 여러 주제가 섞여 벡터가 흐려지고, 검색이 걸려도 관련 없는 내용이 함께 딸려 온다.

그래서 청크 크기는 성능 손잡이가 아니라 정밀도와 문맥 사이의 교환이다. 겹침(overlap)은 그 교환을 조금 무르게 만드는 장치다. 앞 조각의 끝 몇 십 자를 다음 조각의 앞에 다시 넣어 두면 경계에 걸친 문장이 어느 한쪽에서는 온전히 살아난다. 500자에 50자 겹침이면 저장할 조각 수가 약 11% 늘어나는 대가로 경계 손실을 줄이는 셈이다.

3세대 Modular RAG

2세대까지는 파이프라인이 한 줄이었다. 검색 앞뒤로 상자가 늘어났을 뿐 흐름은 언제나 처음부터 끝까지 같은 순서로 지나간다. 3세대 Modular RAG는 그 전제를 놓는다. 각 단계를 갈아 끼울 수 있는 모듈로 만들고, 어떤 순서로 지날지를 질문마다 다르게 정한다.

모듈 구성

모듈의 구성은 시스템마다 다르지만 자주 나오는 자리가 여섯이다.

  • Router: 질문의 종류를 보고 어느 경로로 보낼지 정한다. 사내 규정 질문은 벡터 검색으로, 매출 숫자 질문은 SQL로, 잡담은 검색 없이 곧장 생성으로 보낸다.
  • Retriever: 검색을 맡는다. 벡터, BM25, 지식 그래프, 관계형 DB 등 여러 벌을 둘 수 있다.
  • Reranker: cross-encoder로 후보의 순서를 다시 매긴다.
  • Generator: 최종 답변을 만든다. 질문 난이도에 따라 큰 모델과 작은 모델을 갈아 끼울 수 있는 자리이기도 하다.
  • Memory: 대화 이력을 들고 있다가 쿼리 재작성에 넘긴다.
  • Evaluator: 나온 답을 채점하고, 기준에 못 미치면 다시 검색하도록 되돌린다.

여섯 중 앞뒤를 여는 것이 Router와 Evaluator다. Router는 파이프라인을 갈래로 만들고 Evaluator는 파이프라인을 고리로 만든다. 앞선 두 세대에 없던 것이 정확히 이 둘이다. 예를 들어 「지난달 매출은」 같은 질문에 문서 검색을 돌리는 것은 처음부터 헛수고인데, Router가 있으면 그 질문은 애초에 검색 쪽으로 가지 않는다. Evaluator는 반대쪽 끝에서, 검색된 근거로 답이 안 되면 쿼리를 바꿔 한 번 더 돌게 한다.

그래프 파이프라인

갈래와 고리가 생기면 코드를 함수 호출의 나열로 쓰기 어려워진다. 그래서 이 세대의 구현은 대개 상태와 노드로 이뤄진 그래프로 표현한다. 상태 객체 하나가 파이프라인을 따라 흐르고, 각 노드는 그 상태에서 필요한 것을 읽어 자기 몫을 채워 넣는다.

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

class RAGState(TypedDict):
    question: str
    rewritten: str
    docs: list
    answer: str
    sources: list

def rewrite(state: RAGState) -> RAGState:
    q = llm.invoke(f"검색에 맞게 다시 쓰세요:\n{state['question']}").content
    return {"rewritten": q}

def retrieve(state: RAGState) -> RAGState:
    return {"docs": ensemble.invoke(state["rewritten"])}

def rerank(state: RAGState) -> RAGState:
    from sentence_transformers import CrossEncoder
    model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L6-v2")
    pairs = [[state["question"], d.page_content] for d in state["docs"]]
    ranked = sorted(zip(state["docs"], model.predict(pairs)),
                    key=lambda x: x[1], reverse=True)
    return {"docs": [d for d, _ in ranked[:3]]}

def generate(state: RAGState) -> RAGState:
    context = "\n\n".join(d.page_content for d in state["docs"])
    answer = llm.invoke(f"Context:\n{context}\n\nQ: {state['question']}").content
    return {"answer": answer,
            "sources": [d.metadata.get("source") for d in state["docs"]]}

workflow = StateGraph(RAGState)
for name, fn in [("rewrite", rewrite), ("retrieve", retrieve),
                 ("rerank", rerank), ("generate", generate)]:
    workflow.add_node(name, fn)
workflow.set_entry_point("rewrite")
workflow.add_edge("rewrite", "retrieve")
workflow.add_edge("retrieve", "rerank")
workflow.add_edge("rerank", "generate")
workflow.add_edge("generate", END)

app = workflow.compile()

지금 그린 그래프는 선 하나짜리라 2세대와 결과가 같다. 값은 모양을 바꾸기 쉬워졌다는 것에 있다. 재순위 모델을 바꾸려면 rerank 함수 하나만 손대면 되고, 재순위를 아예 빼려면 간선 두 개를 고쳐 retrieve에서 generate로 잇는다. 평가 노드를 넣어 generate 뒤에서 조건에 따라 retrieve로 되돌리는 것도 간선 하나를 조건부로 바꾸는 일이다. 모듈마다 A/B 테스트를 붙일 수 있는 것도 경계가 함수로 갈라져 있기 때문이다.

또 하나 중요한 것이 상태 객체다. 질문 원문과 재작성된 질문이 question과 rewritten으로 따로 남아 있다는 점을 보라. 재순위는 원문으로 채점하고 검색은 재작성문으로 돌리는데, 상태를 덮어썼다면 이 구분이 불가능하다. 어디서 무엇이 잘못됐는지 추적할 때도 각 단계의 중간 산물이 그대로 남아 있는 편이 훨씬 낫다.

세대 선택 기준

세대가 올라갈수록 좋아지는 것이 아니라, 감당해야 할 복잡도가 올라간다. 모듈이 여섯이면 고장 날 자리도 여섯이고 관측해야 할 지표도 여섯 벌이다. 문서가 수백 조각뿐인 사내 FAQ에 Router와 Evaluator를 다는 것은 유지비만 늘리는 선택이다.

이런 자리 이런 조건
Naive 개념 증명, 내부 데모 청크 1,000개 이하, 질문이 단순
Advanced 실제 서비스 배포 질문이 다양하고 검색 품질이 성과에 직결
Modular 대규모 엔터프라이즈 데이터 소스가 여럿, 모듈별 최적화와 상시 모니터링

순서도 정해져 있다. Naive로 세워 보고, 지표를 재서 어디가 부족한지 확인한 다음, 그 자리에만 2세대 장치를 붙인다. 검색 정밀도가 문제인데 재순위를 다는 것도, 컨텍스트가 넘치는데 Top-K를 올리는 것도 지표를 안 보고 고치기 때문에 생기는 일이다. 그러면 다음 절이 남는다 — 무엇을 어떻게 재는가.

단계별 평가

검색과 생성의 분리

RAG를 평가할 때 가장 흔한 실수는 최종 답변만 보고 좋다·나쁘다를 매기는 것이다. 답이 틀렸다는 사실은 알겠는데 어디를 고쳐야 하는지는 하나도 알 수 없다. 정답 문서가 애초에 검색되지 않은 것인지, 검색은 됐는데 모델이 흘린 것인지, 둘은 완전히 다른 고장이고 처방도 정반대다.

그래서 RAG의 평가는 최소한 두 토막으로 갈라야 한다. 검색이 정답 근거를 가져왔는가와 답변이 그 근거를 벗어나지 않았는가다. 앞이 나쁘면 인덱싱과 검색을 고치고, 뒤가 나쁘면 프롬프트와 생성 모델을 고친다. 앞이 좋은데 뒤가 나쁘면 아무리 좋은 임베딩 모델을 사도 소용이 없고, 그 반대도 마찬가지다.

이 분리는 개발 순서도 정해 준다. 검색부터 고정하고 생성을 손대야 변화의 원인이 하나로 남는다. 둘을 동시에 바꾸면 지표가 올랐을 때 무엇 덕분인지 알 수 없고, 다음에 같은 개선을 재현할 수도 없다.

평가 지표와 ragas

두 토막을 실제 숫자로 만든 것이 다음 넷이다.

지표 재는 것 나쁠 때 고칠 자리
충실도(Faithfulness) 답변이 검색된 근거 안에 머무는가 프롬프트, 생성 모델
답변 관련성 답변이 질문에 답하고 있는가 프롬프트, 질문 재작성
컨텍스트 정밀도 검색된 것 중 쓸모 있는 비율 재순위, 청크 크기
컨텍스트 재현율 필요한 근거가 검색에 다 들어왔는가 Top-K, 하이브리드 검색

뒤의 둘을 숫자로 따라가면 뜻이 분명해진다. Top-K를 5로 두었는데 그중 실제로 답에 쓰인 것이 2개면 컨텍스트 정밀도는 2/5=0.42/5 = 0.4 다. 나머지 셋은 토큰을 먹으면서 모델의 주의를 흩는 자리다. 정답을 구성하는 데 문장 셋이 필요한데 검색에 둘만 들어왔다면 재현율은 2/3≈0.672/3 \approx 0.67 이고, 이 경우 모델은 남은 하나를 지어낼 수밖에 없다. 재현율이 낮으면 환각은 모델의 잘못이 아니다.

ragas 라이브러리가 이 넷을 자동으로 계산해 준다. 질문·검색된 컨텍스트·답변·정답 네 칸을 갖는 표본을 모아 넘기면 된다.

from ragas import EvaluationDataset, evaluate
from ragas.embeddings import LangchainEmbeddingsWrapper
from ragas.llms import LangchainLLMWrapper
from ragas.metrics import (Faithfulness, ResponseRelevancy,
                           LLMContextPrecisionWithReference, LLMContextRecall)

dataset = EvaluationDataset.from_list([
    {"user_input": "연차는 며칠인가요?",
     "retrieved_contexts": retrieved_chunks_1,
     "response": rag_answer_1,
     "reference": correct_answer_1},
    {"user_input": "재택근무 정책은?",
     "retrieved_contexts": retrieved_chunks_2,
     "response": rag_answer_2,
     "reference": correct_answer_2},
])

result = evaluate(
    dataset,
    metrics=[Faithfulness(), ResponseRelevancy(),
             LLMContextPrecisionWithReference(), LLMContextRecall()],
    llm=LangchainLLMWrapper(llm),
    embeddings=LangchainEmbeddingsWrapper(embeddings),
)
print(result)

채점하는 것도 LLM이라 평가기에 쓸 모델을 따로 넘긴다는 점을 눈여겨볼 만하다. 답변 관련성은 임베딩 유사도까지 쓰므로 인덱싱에 썼던 임베딩 모델도 함께 들어간다. 지표 클래스와 모델 어댑터의 이름·위치는 ragas도 손보는 중이라 여기서도 설치한 버전을 확인하는 편이 낫다. 그리고 준비물 중 진짜 비용이 드는 것은 코드가 아니라 reference, 곧 정답이다. 도메인을 아는 사람이 질문과 정답을 손으로 만들어야 하고, 서른 개만 있어도 시작할 수 있다. 이 데이터셋이 없으면 어떤 개선도 「그런 것 같다」로 끝난다. 평가 세트를 만드는 방법과 지표를 읽는 요령은 RAG 평가에서 따로 다뤘다.

증상별 처방

현장에서 마주치는 증상은 대개 넷 중 하나이고, 앞에서 본 지표가 그 넷을 갈라 준다.

증상 흔한 원인 먼저 볼 곳
엉뚱한 청크가 검색된다 청크가 너무 크거나 임베딩이 도메인과 안 맞음 청크 크기를 줄이고 임베딩 모델을 교체
여러 청크에 흩어진 정보를 못 합친다 답에 필요한 근거가 조각마다 나뉘어 있음 Top-K를 올리거나 부모-자식 청킹
컨텍스트를 무시하고 아는 대로 답한다 프롬프트의 근거 의존 지시가 약함 프롬프트 강화, 근거 없으면 거절하도록
너무 느리다 질의마다 임베딩·압축 API를 반복 호출 임베딩 캐시, 배치 처리, 로컬 모델

두 번째 줄의 부모-자식 청킹(parent-child chunking)은 검색과 컨텍스트의 단위를 갈라 두는 방법이다. 검색은 작은 자식 청크로 정확하게 하고, 프롬프트에 넣을 때는 그 청크가 속한 큰 부모 조각을 대신 넣는다. 앞에서 본 「정밀도와 문맥 사이의 교환」을 양쪽 다 가져가려는 시도다.

넷째 줄의 마지막 항목은 임베딩 모델을 API 대신 로컬에서 돌리는 선택이다. 한국어를 포함한 다국어 검색에서 자주 쓰이는 오픈 모델로 BGE 계열이 있다. 어떤 임베딩 모델이 어떤 문서에 맞는지는 임베딩 모델 선택에 정리돼 있다.

지연 단축

마지막으로 속도다. 2세대 장치를 붙일수록 질문 하나당 모델 호출이 늘고, 사용자가 기다리는 시간은 그 합이다. 손잡이는 넷이다.

인덱싱 캐싱은 같은 문서를 두 번 임베딩하지 않게 한다. 문서 내용의 해시를 키로 두고 이미 인덱싱한 것은 건너뛰면, 문서 몇 개만 바뀐 야간 재인덱싱이 몇 시간에서 몇 분으로 줄어든다. 비동기 검색은 하이브리드 검색처럼 여러 검색기를 돌릴 때 값을 한다. BM25와 벡터 검색은 서로를 기다릴 이유가 없으므로 asyncio로 함께 던지면 둘 중 느린 쪽 시간만 든다.

스트리밍 응답은 총 시간을 줄이지는 않지만 체감을 크게 바꾼다. 답변을 다 만들 때까지 빈 화면을 보여 주는 대신 토큰이 나오는 대로 흘려보내면, 사용자가 기다리는 것은 전체 생성 시간이 아니라 첫 글자까지의 시간이 된다. 검색과 재순위가 생성보다 앞에 있으므로 그 앞 단계를 줄이는 일이 곧 첫 글자를 당기는 일이라는 점도 함께 기억해 둔다. 청크 캐시는 자주 나오는 질문의 검색 결과를 메모리에 들고 있는 방법이다. 사내 챗봇처럼 질문이 몰리는 곳에서는 상위 몇 십 개 질문이 트래픽의 큰 몫을 차지하는 경우가 많다. 응답 시간과 비용을 함께 손보는 방법은 비용과 지연 튜닝에서 더 다룬다.

세 세대를 지나오며 붙인 장치는 많았지만, 그 전부가 앞에서 계산한 하나의 숫자로 돌아간다. 40만 자 중 프롬프트에 들어가는 2,000자를 어떻게 고를 것인가. 재작성도 하이브리드도 재순위도 그 2,000자를 고르는 방법이었다. 그런데 아직 손대지 않은 자리가 하나 남아 있다 — 고를 대상인 조각 자체를 어떻게 만들 것인가다. 500자로 자를지 200자로 자를지, 문장 경계에서 끊을지 의미가 바뀌는 자리에서 끊을지에 따라 검색이 걸리는 방식이 통째로 달라진다. 다음 글에서 그 결정을 정면으로 다룬다.


읽어주셔서 감사합니다. 😊

LATEST

에이전트·RAG의 최신 글

에이전트·RAG2026.08.23

에이전트는 어디서 어긋나기 시작하는가

에이전트가 이상한 답을 낼 때 증상은 마지막에 보이지만 어긋난 자리는 그보다 앞입니다. 한 걸음이 무너지는 여섯 자리를 나누고, 증상에서 원인을 거슬러 찾는 방법과 자리별 처방을 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트가 무엇을 했는지 나중에 알 수 있게 만들기

에이전트는 같은 입력에도 다른 경로로 갑니다. 그래서 로그 몇 줄로는 왜 그렇게 됐는지 복원이 안 됩니다. 트레이스를 어떻게 나누고 구간마다 무엇을 붙이며 어떤 지표를 볼지 정리합니다.

11 MIN
에이전트·RAG2026.08.23

에이전트 비용은 걸음 수보다 빨리 늘어난다

걸음이 두 배면 비용은 두 배가 아니라 서너 배입니다. 왜 그렇게 되는지, 어디서 새는지, 상한을 몇 겹으로 어떻게 거는지와 실제로 효과가 큰 순서대로의 대응을 정리합니다.

13 MIN