RAG 앱의 완성 형태
이전 장에서는 문서를 임베딩하고, 벡터스토어에 저장하고, 리트리버로 관련 문서를 검색하는 흐름을 만들었다. 이제 그 검색 결과를 LLM에 전달해 실제 답변을 생성할 차례다.
RAG 애플리케이션의 기본 흐름은 단순하다.
사용자가 질문한다.
리트리버가 질문과 관련 있는 문서 조각을 찾는다.
검색된 문서를 프롬프트의 문맥으로 넣는다.
LLM이 문맥을 근거로 답변한다.
앱이 답변과 출처를 함께 보여 준다.
여기서 중요한 점은 LLM이 모든 것을 알고 있다고 가정하지 않는 것이다. LangChain 문서에서도 검색은 모델의 고정된 지식과 제한된 컨텍스트를 보완하기 위해 실행 시점에 외부 지식을 가져오는 단계로 설명된다.1 즉, RAG의 핵심은 "모델이 잘 말하게 하기"보다 "모델이 참고할 재료를 정확히 주기"에 있다.
문서 기반 챗봇은 LangChain을 배우는 가장 실용적인 첫 프로젝트다. 프롬프트, 모델, 리트리버, LCEL, 출력 처리, 출처 표시가 모두 들어가기 때문이다. 동시에 서비스에서 자주 부딪히는 문제도 드러난다. 검색 결과가 부족하면 답변이 흔들리고, 출처가 없으면 사용자가 믿기 어렵고, 문맥 밖의 질문에 모델이 추측할 수 있다.
AI 생성 이미지
기본 RAG 체인 만들기
아래 예제는 이전 장에서 만든 Chroma 벡터스토어가 ./chroma_db에 저장되어 있다고 가정한다. 경로가 다르면 persist_directory만 바꾸면 된다.
from dotenv import load_dotenv
from operator import itemgetter
from langchain_chroma import Chroma
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
load_dotenv()
embeddings = OpenAIEmbeddings(model='text-embedding-3-small')
vectorstore = Chroma(
persist_directory='./chroma_db',
embedding_function=embeddings,
)
retriever = vectorstore.as_retriever(
search_kwargs={'k': 4}
)
llm = ChatOpenAI(
model='gpt-4o-mini',
temperature=0,
)
def format_docs(docs):
blocks = []
for i, doc in enumerate(docs, start=1):
source = doc.metadata.get('source', 'unknown')
page = doc.metadata.get('page')
label = f'{source} p.{page}' if page is not None else source
blocks.append(f'[{i}] 출처: {label}' + chr(10) + doc.page_content)
return (chr(10) * 2).join(blocks)
prompt = ChatPromptTemplate.from_messages([
('system', '''당신은 문서 기반 질의응답 도우미입니다.
아래 문맥에 있는 내용만 근거로 답하세요.
문맥에서 확인할 수 없는 내용은 추측하지 말고 "제공된 문서에서 확인할 수 없습니다."라고 답하세요.
답변에는 가능한 한 사용한 문서 번호를 함께 표시하세요. 예: [1], [2]
서로 충돌하는 내용이 있으면 충돌한다고 말하세요.'''),
('human', '''질문:
{question}
문맥:
{context}
답변:''')
])
rag_chain = (
RunnablePassthrough.assign(
docs=itemgetter('question') | retriever
)
.assign(
context=itemgetter('docs') | format_docs
)
.assign(
answer=prompt | llm | StrOutputParser()
)
)
result = rag_chain.invoke({
'question': '휴가 신청 절차를 알려줘.'
})
print(result['answer'])이 체인에서 입력은 {'question': '...'} 형태의 딕셔너리다. 첫 번째 assign은 질문을 리트리버에 보내 docs를 만든다. 두 번째 assign은 검색된 문서 목록을 하나의 문자열 context로 바꾼다. 마지막 assign은 question과 context를 프롬프트에 넣고 모델 응답을 answer로 저장한다.
중요한 장점은 중간 결과가 사라지지 않는다는 점이다. 최종 result에는 question, docs, context, answer가 함께 남는다. 따라서 답변만 출력하지 않고, 어떤 문서가 검색되었는지도 확인할 수 있다.
출처를 함께 표시하기
RAG 답변은 출처가 있어야 한다. 사용자는 답변이 그럴듯한지보다 "어떤 문서를 근거로 했는지"를 확인하고 싶어 한다. 특히 사내 규정, 계약서, 정책 문서처럼 책임이 따르는 정보는 출처가 없으면 활용하기 어렵다.
출처 표시는 두 단계로 나누는 것이 좋다.
첫째, 프롬프트 안의 문맥에 문서 번호와 출처를 넣는다. 그래야 모델이 답변 중에 [1], [2]처럼 근거 번호를 언급할 수 있다.
둘째, 최종 출처 목록은 모델에게 맡기지 말고 애플리케이션 코드가 metadata에서 직접 만든다. 모델은 파일명이나 페이지를 잘못 말할 수 있기 때문이다. 출처는 검증 가능한 데이터에서 꺼내야 한다.
def collect_sources(docs):
sources = []
seen = set()
for doc in docs:
source = doc.metadata.get('source', 'unknown')
page = doc.metadata.get('page')
key = (source, page)
if key in seen:
continue
seen.add(key)
sources.append({
'source': source,
'page': page,
})
return sources
result = rag_chain.invoke({
'question': '재택근무 신청 조건은 무엇인가요?'
})
print(result['answer'])
print()
print('출처:')
for source in collect_sources(result['docs']):
print('-', source)이 방식은 답변과 출처를 분리한다. 답변 본문에는 모델이 문서 번호를 표시하고, 화면 하단에는 코드가 만든 실제 출처 목록을 보여 준다. 웹 앱이라면 출처를 클릭했을 때 원문 문서나 PDF 페이지로 이동하게 만들 수 있다.
모르는 내용은 모른다고 답하게 하기
RAG를 붙였다고 환각이 자동으로 사라지지는 않는다. 검색된 문서가 질문과 약하게 관련되어 있거나, 문서에 답이 없는데도 모델이 일반 지식으로 답할 수 있다. 그래서 최소한 세 가지 장치를 넣어야 한다.
첫째, 시스템 프롬프트에 "문맥에 있는 내용만 근거로 답하라"고 명시한다. 둘째, 검색 결과가 비어 있으면 모델을 호출하지 않고 바로 실패 응답을 반환한다. 셋째, 운영 단계에서는 검색 결과와 답변을 로그로 남겨 사람이 검토할 수 있어야 한다.
위에서 만든 rag_chain은 검색과 답변 생성을 한 번에 실행한다. 그래서 "문서가 하나도 없으면 모델을 호출하지 않겠다"는 정책을 적용하려면 검색 단계와 답변 생성 단계를 조금 나누는 편이 안전하다.
retrieve_chain = RunnablePassthrough.assign(
docs=itemgetter('question') | retriever
).assign(
context=itemgetter('docs') | format_docs
)
answer_chain = prompt | llm | StrOutputParser()
def ask(question):
retrieved = retrieve_chain.invoke({'question': question})
if not retrieved['docs']:
return {
'answer': '제공된 문서에서 확인할 수 없습니다.',
'sources': [],
}
answer = answer_chain.invoke(retrieved)
return {
'answer': answer,
'sources': collect_sources(retrieved['docs']),
}
response = ask('우리 회사의 해외 법인 설립 절차는 어떻게 되나요?')
print(response['answer'])이렇게 나누면 검색 결과가 없을 때는 LLM을 호출하지 않는다. 비용을 줄일 수 있고, 빈 문맥을 받은 모델이 일반 지식으로 그럴듯한 답을 만드는 일도 줄일 수 있다.
검색 결과가 있다고 해서 항상 충분한 것은 아니다. 벡터스토어가 점수를 제공한다면 상위 문서의 유사도 점수를 함께 확인하고, 일정 기준보다 낮으면 답변을 보류할 수 있다. 다만 점수의 의미는 벡터스토어와 검색 방식마다 다르다. 0.7 이상이면 안전하다 같은 보편적인 기준은 없다. 실제 질문 세트로 실험하면서 기준을 정해야 한다.
애매한 질문은 검색 전에 다시 쓰기
사용자의 질문은 검색에 적합하지 않은 경우가 많다.
예를 들어 대화에서 사용자가 이렇게 물을 수 있다.
"그건 어떻게 신청해?"
"앞에서 말한 제도는 누구에게 적용돼?"
"비용 처리는?"
사람은 대화 흐름을 보고 이해하지만, 리트리버는 현재 질문 문자열만 보고 문서를 찾는다. 이럴 때는 질문 재작성 단계를 추가한다. 질문 재작성은 대화 맥락을 반영해 검색에 적합한 독립 질문을 만드는 과정이다. LangChain의 RAG 설명에서도 고급 검색 흐름에는 질의 개선, 검색 검증, 답변 검증 같은 단계가 포함될 수 있다고 설명한다.1
rewrite_prompt = ChatPromptTemplate.from_messages([
('system', '''사용자의 현재 질문을 문서 검색에 적합한 독립적인 질문으로 바꾸세요.
원래 의미를 바꾸지 마세요.
대화 기록이 없어도 이해되는 한 문장으로 작성하세요.'''),
('human', '''대화 기록:
{chat_history}
현재 질문:
{question}
검색 질문:''')
])
rewrite_chain = rewrite_prompt | llm | StrOutputParser()
rag_with_rewrite = (
RunnablePassthrough.assign(
search_query=rewrite_chain
)
.assign(
docs=itemgetter('search_query') | retriever
)
.assign(
context=itemgetter('docs') | format_docs
)
.assign(
answer=prompt | llm | StrOutputParser()
)
)
result = rag_with_rewrite.invoke({
'chat_history': '사용자: 재택근무 제도가 궁금합니다. AI: 재택근무는 부서장 승인 후 사용할 수 있습니다.',
'question': '그건 어떻게 신청해?',
})
print('검색 질문:', result['search_query'])
print(result['answer'])여기서 search_query는 검색용 질문이고, question은 사용자가 실제로 한 질문이다. 둘을 구분하는 것이 좋다. 답변은 사용자 질문에 맞춰야 하지만, 검색은 더 명확한 문장으로 수행하는 편이 정확할 수 있다.
대화 기록 자체를 장기적으로 관리하는 방법은 다음 장에서 다룬다. 지금은 "검색 전에 질문을 정리할 수 있다"는 구조만 이해하면 된다.
긴 문서에는 압축과 재랭킹을 검토하기
기본 RAG가 항상 충분한 것은 아니다. 검색된 청크 안에 관련 없는 문장이 너무 많으면 모델이 엉뚱한 부분에 집중할 수 있다. 반대로 청크가 너무 작으면 답변에 필요한 주변 맥락이 빠질 수 있다.
이때 사용할 수 있는 대표적인 개선 방법이 문맥 압축과 재랭킹이다.
문맥 압축은 검색된 문서에서 질문과 관련 있는 부분만 남기는 과정이다. 긴 규정 문서에서 한두 문단만 필요한 경우에 유용하다. 토큰 비용을 줄이고, 모델이 읽어야 할 잡음을 줄일 수 있다.
재랭킹은 1차 검색으로 후보 문서를 넉넉히 가져온 뒤, 더 정교한 모델로 질문과 문서의 관련도를 다시 매겨 상위 문서만 남기는 방식이다. 예를 들어 벡터 검색으로 20개를 가져오고, 재랭커가 그중 가장 관련 있는 4개를 고르는 식이다. 고급 RAG에서는 질문 확장, 문맥 압축, 부모 문서 검색, 재랭킹 같은 기법을 상황에 따라 조합한다.2
실무에서는 다음 순서로 접근하는 것이 좋다.
먼저 청크 크기, overlap,
k값을 조정한다.검색 결과를 직접 읽어 보고 관련 없는 문서가 많은지 확인한다.
질문 표현이 문제라면 질문 재작성이나 다중 질의를 검토한다.
문서가 너무 길고 잡음이 많다면 문맥 압축을 넣는다.
후보는 맞는데 순서가 자주 틀리면 재랭킹을 넣는다.
처음부터 모든 기법을 넣으면 디버깅이 어려워진다. RAG 개선은 한 번에 하나씩 바꿔야 한다. 그래야 어떤 변경이 품질을 올렸는지 알 수 있다.
답변 검증 단계를 추가하기
중요한 업무용 RAG라면 답변 생성 후 검증 단계를 추가할 수 있다. 검증 단계의 목적은 모델의 답변이 검색된 문맥에 의해 뒷받침되는지 확인하는 것이다.
간단한 방식은 별도의 검증 프롬프트를 두는 것이다.
validation_prompt = ChatPromptTemplate.from_messages([
('system', '''당신은 RAG 답변 검증자입니다.
질문, 문맥, 답변을 비교하세요.
답변이 문맥으로 충분히 뒷받침되면 supported를 true로 판단하세요.
문맥에 없는 주장이나 과장이 있으면 supported를 false로 판단하세요.'''),
('human', '''질문:
{question}
문맥:
{context}
답변:
{answer}
JSON 형식으로만 답하세요:
{{"supported": true, "reason": "짧은 이유"}}''')
])
validation_chain = validation_prompt | llm | StrOutputParser()
result = rag_chain.invoke({'question': '경조사 휴가 기준을 알려줘.'})
validation = validation_chain.invoke(result)
print(result['answer'])
print(validation)이 검증도 완벽하지는 않다. 검증자 역시 LLM이기 때문이다. 하지만 로그와 함께 사용하면 도움이 된다. 운영 환경에서는 질문, 재작성된 검색 질문, 검색된 문서 ID, 답변, 검증 결과, 사용자 피드백을 함께 저장하는 것이 좋다. 이후 LangSmith 같은 관찰 도구를 붙이면 어떤 질문에서 검색이 실패하는지 추적하기 쉬워진다.
RAG 체인을 설계할 때의 기준
좋은 RAG 앱은 답변이 길거나 화려한 앱이 아니다. 사용자가 확인할 수 있는 근거를 제공하고, 모르는 내용은 멈출 줄 아는 앱이다.
설계할 때는 다음 기준을 확인하자.
답변에 사용할 문서만 프롬프트에 넣고 있는가?
검색된 문서의 출처를 metadata로 보존하고 있는가?
출처 목록을 모델이 아니라 코드가 만들고 있는가?
문맥에 없는 질문에 대해 모른다고 답하는가?
애매한 질문을 검색 가능한 질문으로 바꾸는 단계가 있는가?
검색 결과와 최종 답변을 나중에 검토할 수 있는가?
이 장에서 만든 RAG 체인은 문서 기반 챗봇의 기본 골격이다. 다음 장에서는 여기에 대화 기록과 상태 관리를 더한다. 사용자가 한 번 묻고 끝나는 앱이 아니라, 이전 대화를 기억하며 자연스럽게 이어지는 챗봇으로 확장할 것이다.
참고
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.