Skip to main content

기본 RAG가 "동작"한 다음에 생기는 문제

지난 장에서 우리는 기본 RAG 파이프라인을 만들었습니다. 문서를 청크로 나누고, 임베딩하고, 벡터스토어에 저장한 뒤, 사용자의 질문과 가까운 청크를 찾아 모델에게 넘겼습니다. 이 흐름은 RAG의 뼈대입니다. LangChain 공식 문서에서도 RAG를 크게 인덱싱, 그리고 런타임의 검색과 생성으로 나누어 설명합니다.1

그런데 실서비스에서는 "검색이 된다"와 "쓸 만한 답변이 나온다" 사이에 꽤 넓은 강이 있습니다. 데모에서는 잘 맞던 질문이 실제 사용자 앞에서는 갑자기 흔들립니다.

사용자는 문서에 있는 표현 그대로 질문하지 않습니다. "환불 정책"이라고 쓰인 문서를 두고 "돈 돌려받을 수 있어?"라고 묻습니다. 이전 대화에서 "그 기능"이라고 말하기도 합니다. 사내 문서에는 "SSO", "통합 로그인", "Single Sign-On"이 섞여 있고, 어떤 문서는 최신 버전이지만 어떤 문서는 2년 전 초안입니다. 벡터 검색은 의미가 비슷한 문서를 잘 찾지만, 제품명, 오류 코드, 조항 번호, 날짜처럼 정확한 키워드가 중요한 질문에서는 BM25 같은 키워드 검색보다 약할 때가 있습니다.

그래서 고급 RAG의 목표는 단순히 더 많은 문서를 가져오는 것이 아닙니다. 정확히 말하면 다음 세 가지를 동시에 만족시키는 것입니다.

  1. 답변에 필요한 근거를 놓치지 않는다.

  2. 모델에게 넘기는 컨텍스트에서 잡음을 줄인다.

  3. 비용과 지연 시간을 감당 가능한 수준으로 유지한다.

이 장에서는 기본 벡터 검색을 실제 서비스용 검색 시스템으로 키우는 방법을 다룹니다. 질문을 다시 쓰고, 여러 검색 전략을 합치고, 검색 결과를 재정렬하고, 대화 이력을 반영하고, 마지막에는 RAGAS와 자체 평가셋으로 품질을 측정하는 루프까지 만들 것입니다.

고급 RAG 패턴과 품질 개선

검색 실패를 먼저 분류하자

RAG 품질을 개선하려면 "답이 틀렸다"에서 멈추면 안 됩니다. 어느 단계에서 실패했는지 나눠야 합니다. RAG는 한 덩어리의 마법이 아니라 여러 단계의 파이프라인이기 때문입니다.

가장 흔한 실패는 검색 실패입니다. 답변에 필요한 문서가 지식 베이스에 있는데도 top-k 검색 결과에 들어오지 않는 경우입니다. 사용자의 질문 표현과 문서 표현이 다르거나, 청크가 너무 작아 의미가 끊겼거나, 임베딩 모델이 도메인 용어를 잘 표현하지 못할 때 발생합니다.

두 번째는 검색은 됐지만 컨텍스트가 나쁜 경우입니다. 관련 문서가 섞여 있긴 하지만, 함께 들어온 문서 중 절반 이상이 무관한 내용입니다. 모델은 주어진 컨텍스트를 모두 읽고 답해야 하므로, 불필요한 문서가 많으면 답변이 흐려집니다.

세 번째는 생성 실패입니다. 필요한 근거가 컨텍스트에 있는데도 모델이 잘못 요약하거나, 근거 없는 문장을 덧붙이거나, 질문에 직접 답하지 않고 장황하게 설명하는 경우입니다.

네 번째는 운영 실패입니다. 답은 괜찮지만 너무 느리거나 비쌉니다. MultiQueryRetriever를 무턱대고 붙여 검색을 여섯 번 하고, 리랭커까지 매번 호출하고, top-k를 20으로 올리면 품질은 조금 좋아질 수 있지만 응답 시간과 비용이 금방 튀어 오릅니다.

따라서 고급 RAG 개선은 보통 다음 순서로 진행하는 것이 좋습니다.

질문 분석 → 검색 후보 확장 → 필터링/다양화 → 재정렬/압축 → 답변 생성 → 평가

이제 각 단계에서 어떤 도구를 쓸 수 있는지 살펴보겠습니다.

질문 재작성: 검색하기 좋은 질문으로 바꾸기

사용자의 질문은 사람이 이해하기에는 충분하지만 검색기에게는 불친절할 수 있습니다.

예를 들어 사용자가 이렇게 묻습니다.

그거 엔터프라이즈 요금제에서도 돼?

사람은 대화 맥락을 보고 "그거"가 SAML SSO 설정이라는 것을 알 수 있습니다. 하지만 검색기는 현재 문장만 받으면 무엇을 찾아야 할지 모릅니다. 이때 필요한 것이 질문 재작성, 즉 query rewriting입니다.

질문 재작성은 사용자의 원래 질문을 검색에 적합한 독립 질의로 바꾸는 단계입니다.

원래 질문:
그거 엔터프라이즈 요금제에서도 돼?

재작성된 검색 질의:
엔터프라이즈 요금제에서 SAML SSO 설정을 사용할 수 있는가?

LangChain에서는 LCEL로 간단한 재작성 체인을 만들 수 있습니다.

from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

rewrite_prompt = ChatPromptTemplate.from_messages([
    ("system", "사용자 질문을 문서 검색에 적합한 구체적인 한국어 검색 질의로 바꾸세요. 답변하지 말고 질의만 출력하세요."),
    ("human", "질문: {question}")
])

query_rewriter = rewrite_prompt | llm | StrOutputParser()

search_query = query_rewriter.invoke({
    "question": "그거 엔터프라이즈 요금제에서도 돼?"
})

docs = retriever.invoke(search_query)

질문 재작성은 특히 짧은 질문, 모호한 질문, 대명사가 많은 질문, 도메인 용어와 일반 표현이 섞인 질문에 효과적입니다. 다만 모든 질문을 LLM으로 재작성하면 호출이 한 번 늘어납니다. 운영 환경에서는 "질문이 너무 짧거나 대명사가 포함된 경우에만 재작성"처럼 조건을 두는 편이 좋습니다.

MultiQueryRetriever: 한 번 묻지 말고 여러 관점에서 묻기

벡터 검색은 질문 표현에 민감합니다. "캐시 무효화 전략"이라는 문서를 찾고 싶은데 사용자가 "저장된 응답을 언제 지워야 해?"라고 물으면, 임베딩 모델이 잘 연결해 줄 수도 있지만 놓칠 수도 있습니다. 이때는 하나의 질문을 여러 표현으로 확장해 검색 재현율을 높일 수 있습니다.

LangChain의 MultiQueryRetriever는 LLM을 사용해 사용자 질문의 여러 변형을 만들고, 각 질의로 검색한 결과를 합친 뒤 중복을 제거하는 방식입니다. LangChain의 retriever는 문자열 질의를 받아 Document 목록을 반환하는 일반 인터페이스이므로, 벡터스토어뿐 아니라 다양한 검색 방식으로 확장할 수 있습니다.2

개념적으로는 다음과 같습니다.

사용자 질문:
로그인이 자꾸 풀리는 이유가 뭐야?

생성된 검색 질의:
- 세션 만료로 사용자가 로그아웃되는 원인
- 로그인 토큰 갱신 실패 문제
- 쿠키 설정 때문에 인증 상태가 유지되지 않는 경우
- SSO 환경에서 세션이 끊기는 문제

예시는 다음처럼 구성할 수 있습니다.

from langchain.retrievers.multi_query import MultiQueryRetriever

multi_retriever = MultiQueryRetriever.from_llm(
    retriever=vectorstore.as_retriever(search_kwargs={"k": 4}),
    llm=llm,
)

docs = multi_retriever.invoke("로그인이 자꾸 풀리는 이유가 뭐야?")

MultiQueryRetriever는 놓친 문서를 다시 잡는 데 강합니다. 하지만 검색 횟수가 늘어나므로 지연 시간이 증가합니다. 따라서 운영에서는 다음 기준으로 적용 여부를 판단하는 것이 좋습니다.

  • 질문이 짧거나 모호하면 MultiQuery를 사용한다.

  • 정확한 오류 코드나 문서 ID가 포함된 질문은 일반 검색 또는 하이브리드 검색을 우선한다.

  • 자주 들어오는 질문은 생성된 다중 질의를 캐싱한다.

여기서 중요한 점은 "많이 검색하면 무조건 좋다"가 아니라는 것입니다. 후보 문서를 넓게 가져온 다음에는 반드시 정밀도를 높이는 단계가 필요합니다. 그 역할을 하는 것이 MMR, 메타데이터 필터링, 리랭킹, Contextual Compression입니다.

하이브리드 검색: 벡터 검색과 BM25를 함께 쓰기

벡터 검색은 의미를 찾는 데 강합니다. BM25는 전통적인 키워드 검색 알고리즘으로, 특정 단어가 문서에 얼마나 중요하게 등장하는지 계산합니다. 둘은 경쟁 관계라기보다 보완 관계입니다.

예를 들어 다음 질문을 봅시다.

ERR_AUTH_4017 오류는 어떻게 해결해?

이 질문에서는 ERR_AUTH_4017이라는 정확한 문자열이 매우 중요합니다. 벡터 검색만 사용하면 "인증 오류", "로그인 실패"와 의미가 가까운 문서가 먼저 나올 수 있습니다. 하지만 실제 정답은 오류 코드가 정확히 포함된 릴리스 노트나 장애 대응 문서에 있을 가능성이 큽니다. 이때 BM25가 강합니다.

반대로 이런 질문은 벡터 검색이 유리합니다.

사용자가 결제 도중 이탈하지 않게 하려면 어떤 설정을 봐야 해?

문서에는 "결제 전환율 개선", "checkout recovery", "abandoned payment" 같은 표현이 있을 수 있습니다. 단어가 정확히 일치하지 않아도 의미가 가까운 문서를 찾아야 하므로 벡터 검색이 필요합니다.

하이브리드 검색은 두 검색 결과를 합칩니다. 간단한 구현은 BM25 결과와 벡터 검색 결과를 각각 가져와 중복을 제거한 뒤 리랭커에 넘기는 방식입니다.

from langchain_community.retrievers import BM25Retriever

bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5

vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

def hybrid_retrieve(query: str):
    keyword_docs = bm25_retriever.invoke(query)
    vector_docs = vector_retriever.invoke(query)

    seen = set()
    merged = []
    for doc in keyword_docs + vector_docs:
        key = (doc.metadata.get("source"), doc.page_content[:120])
        if key not in seen:
            seen.add(key)
            merged.append(doc)
    return merged

실무에서는 단순 병합보다 Reciprocal Rank Fusion, 줄여서 RRF 같은 순위 결합 기법을 쓰기도 합니다. RRF는 서로 다른 검색기의 점수 스케일이 달라도 "각 검색기에서 몇 등으로 나왔는가"를 기준으로 결과를 합칩니다. 벡터 점수와 BM25 점수를 억지로 같은 단위로 맞추지 않아도 된다는 장점이 있습니다.

메타데이터 필터링: 검색 범위를 줄이는 가장 현실적인 방법

고급 RAG에서 메타데이터는 장식이 아닙니다. 검색 품질, 보안, 비용을 동시에 좌우하는 핵심 설계 요소입니다.

문서 청크에는 본문뿐 아니라 다음과 같은 정보가 붙어 있어야 합니다.

  • source: 원본 URL, 파일 경로, 문서 ID

  • created_at 또는 updated_at: 작성일과 갱신일

  • product: 제품명 또는 모듈명

  • version: 문서가 설명하는 버전

  • department: 작성 부서

  • access_level: 접근 권한

  • doc_type: 가이드, FAQ, 정책, 릴리스 노트, 장애 보고서 등

사용자가 "최신 API 인증 방식 알려줘"라고 물으면, 오래된 문서까지 검색할 필요가 없습니다. updated_at 기준으로 최근 문서에 가중치를 주거나, 아예 특정 날짜 이후 문서만 검색할 수 있습니다.

retriever = vectorstore.as_retriever(
    search_kwargs={
        "k": 5,
        "filter": {
            "product": "payments",
            "doc_type": {"$in": ["guide", "faq"]},
            "access_level": {"$lte": user.access_level},
        },
    }
)

필터 문법은 벡터스토어마다 다릅니다. Chroma, Pinecone, Weaviate, Qdrant, Elasticsearch는 모두 메타데이터 필터를 지원하지만 세부 연산자와 인덱싱 방식이 다릅니다. 따라서 애플리케이션 코드에서는 "검색 조건 객체"를 따로 만들고, 벡터스토어별 변환 레이어를 두는 편이 안전합니다.

메타데이터 필터링은 특히 권한 제어에서 중요합니다. RAG 시스템이 사용자가 볼 수 없는 문서를 검색해 모델에게 넘기면, 모델이 그 내용을 요약해 버릴 수 있습니다. 따라서 접근 권한 필터는 생성 단계가 아니라 검색 단계에서 먼저 적용해야 합니다.

MMR: 비슷한 문서만 줄줄이 나오는 문제 줄이기

기본 top-k 검색은 점수가 높은 문서를 순서대로 가져옵니다. 그런데 상위 5개가 모두 같은 문서의 비슷한 청크라면 어떨까요? 모델은 다양한 근거를 얻지 못하고 같은 내용을 반복해서 읽게 됩니다.

이때 사용하는 전략이 MMR, 즉 Maximal Marginal Relevance입니다. MMR은 관련성뿐 아니라 다양성도 함께 고려합니다. 질문과 가까우면서도 이미 선택된 문서와 너무 비슷하지 않은 청크를 고릅니다.

retriever = vectorstore.as_retriever(
    search_type="mmr",
    search_kwargs={
        "k": 5,
        "fetch_k": 20,
        "lambda_mult": 0.5,
    },
)

여기서 fetch_k는 먼저 넓게 가져올 후보 수이고, k는 최종으로 반환할 문서 수입니다. lambda_mult는 관련성과 다양성 사이의 균형을 조절합니다. 값이 높을수록 질문과의 유사성을 더 중시하고, 낮을수록 문서 간 다양성을 더 중시합니다.

MMR은 FAQ, 정책 문서, 제품 가이드처럼 비슷한 표현이 반복되는 문서 묶음에서 특히 유용합니다. 다만 답이 하나의 정확한 조항에 있는 경우에는 다양성보다 정확성이 중요하므로 일반 similarity 검색이나 키워드 검색이 더 나을 수 있습니다.

Parent Document Retriever: 작은 청크로 찾고 큰 문맥으로 답하기

청킹에는 오래된 딜레마가 있습니다. 청크가 작으면 검색은 정밀해지지만 문맥이 부족합니다. 청크가 크면 문맥은 풍부하지만 검색 점수가 흐려질 수 있습니다.

이 문제를 해결하는 대표적인 방식이 Parent Document Retriever입니다. 아이디어는 단순합니다.

검색할 때는 작은 child chunk를 사용한다.
모델에게 넘길 때는 그 child가 속한 큰 parent chunk를 넘긴다.

예를 들어 400자짜리 작은 청크로 검색하면 사용자의 질문과 정확히 맞는 위치를 찾기 쉽습니다. 하지만 답변을 만들 때는 해당 청크 주변의 2,000자짜리 부모 문서를 넘겨 더 넓은 문맥을 제공합니다.

이 방식은 법률 문서, 기술 매뉴얼, 연구 보고서처럼 앞뒤 문맥이 중요한 자료에 좋습니다. 단, 부모 문서가 너무 크면 다시 잡음이 늘어납니다. "검색은 작게, 생성은 적당히 크게"가 핵심입니다.

리랭커: 많이 찾고, 똑똑하게 다시 줄 세우기

초기 검색기는 보통 빠른 검색을 위해 설계됩니다. 벡터 검색은 대규모 문서에서 후보를 빠르게 찾는 데 좋지만, "질문과 문서가 정말로 답변 관계인가?"를 세밀하게 판단하지는 못합니다.

리랭커는 이 두 번째 판단을 담당합니다. 먼저 벡터 검색이나 하이브리드 검색으로 후보를 20개 정도 가져온 뒤, 리랭커가 질문과 각 문서를 함께 보고 관련도를 다시 매깁니다.

1차 검색: 빠르게 후보 20개 찾기
2차 리랭킹: 질문-문서 쌍을 정밀 평가해 상위 5개 선택

리랭커에는 Cohere Rerank 같은 API 기반 모델, BGE reranker 같은 오픈소스 cross-encoder 모델, 또는 LLM 기반 점수화 방식을 사용할 수 있습니다. Cross-encoder는 질문과 문서를 함께 입력으로 넣어 판단하기 때문에 일반 임베딩 검색보다 정밀하지만 느립니다.

실무 기준은 분명합니다. 전체 문서에 리랭커를 적용하지 말고, 1차 검색 결과에만 적용하세요.

candidate_docs = hybrid_retrieve(question)[:20]
reranked_docs = reranker.compress_documents(candidate_docs, question)
final_docs = reranked_docs[:5]

리랭커는 "검색 결과는 많지만 상위 문서 정밀도가 낮은 상황"에서 효과가 큽니다. 반대로 후보 검색 자체가 필요한 문서를 전혀 못 가져오고 있다면 리랭커를 붙여도 해결되지 않습니다. 이 경우에는 질문 재작성, MultiQuery, 하이브리드 검색, 청킹 전략부터 봐야 합니다.

Contextual Compression: 모델에게 필요한 부분만 넘기기

검색된 문서가 관련은 있지만 너무 길 때가 있습니다. 문서 전체를 모델에게 넘기면 비용이 늘고, 모델이 엉뚱한 문장에 주의를 빼앗길 수 있습니다. 이때 사용하는 것이 Contextual Compression입니다.

LangChain의 Contextual Compression Retriever는 기본 검색기가 가져온 문서를 그대로 반환하지 않고, 주어진 질문과 관련 있는 문서 또는 문서 일부만 남기도록 압축합니다. 공식 예제에서도 기본 검색 결과에 무관한 문장이 섞일 수 있으며, 압축 검색기는 질문에 맞는 정보만 반환하도록 돕는다는 아이디어를 설명합니다.3

개념은 다음과 같습니다.

질문:
KYC 검증 실패 시 재시도 제한은 몇 번인가?

검색된 문서:
KYC 개요, 지원 국가, 검증 절차, 실패 코드, 재시도 제한, 관리자 설정 ...

압축된 컨텍스트:
KYC 검증 실패 시 사용자는 24시간 동안 최대 3회까지 재시도할 수 있다.

예시 코드는 다음과 비슷한 구조가 됩니다.

from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import EmbeddingsFilter

embeddings_filter = EmbeddingsFilter(
    embeddings=embeddings,
    similarity_threshold=0.75,
)

compression_retriever = ContextualCompressionRetriever(
    base_retriever=vectorstore.as_retriever(search_kwargs={"k": 10}),
    base_compressor=embeddings_filter,
)

docs = compression_retriever.invoke("KYC 검증 실패 시 재시도 제한은 몇 번인가?")

압축 방식은 여러 가지입니다. 임베딩 기반 필터는 빠르고 저렴하지만 문장 단위의 미묘한 판단은 약할 수 있습니다. LLM 기반 extractor는 더 정교하지만 비용과 지연 시간이 증가합니다. 따라서 운영에서는 "저렴한 필터 → 필요한 경우에만 LLM 압축"처럼 단계적으로 쓰는 편이 좋습니다.

대화형 RAG: 후속 질문은 독립 질문이 아니다

대화형 RAG에서 가장 흔한 실수는 마지막 사용자 메시지만 검색하는 것입니다.

다음 대화를 보겠습니다.

사용자: SAML SSO 설정 방법 알려줘.
AI: 관리자 콘솔에서 SSO 공급자 정보를 등록하고 메타데이터 XML을 업로드하면 됩니다.
사용자: 그거 엔터프라이즈 플랜에서만 돼?

마지막 질문인 "그거 엔터프라이즈 플랜에서만 돼?"만 검색하면 검색기는 그거가 무엇인지 모릅니다. 따라서 대화형 RAG에서는 현재 질문을 검색하기 전에 대화 이력을 반영해 독립적인 질문으로 바꿔야 합니다.

LangChain의 create_history_aware_retriever는 이 패턴을 위한 헬퍼입니다. 대화 기록이 없으면 입력을 그대로 검색기에 넘기고, 대화 기록이 있으면 LLM과 프롬프트를 사용해 검색 질의를 만든 뒤 검색기에 전달합니다.4

from langchain.chains import create_history_aware_retriever
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

contextualize_q_prompt = ChatPromptTemplate.from_messages([
    ("system", "대화 기록과 최신 질문을 보고, 검색에 사용할 독립적인 질문으로 바꾸세요. 답변하지 말고 질문만 출력하세요."),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

history_aware_retriever = create_history_aware_retriever(
    llm,
    retriever,
    contextualize_q_prompt,
)

이제 이 검색기를 기존 RAG 체인에 연결하면 됩니다.

from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain

qa_prompt = ChatPromptTemplate.from_messages([
    ("system", "다음 컨텍스트만 사용해 답하세요. 모르면 모른다고 말하세요.nn{context}"),
    MessagesPlaceholder("chat_history"),
    ("human", "{input}"),
])

question_answer_chain = create_stuff_documents_chain(llm, qa_prompt)
conversational_rag_chain = create_retrieval_chain(
    history_aware_retriever,
    question_answer_chain,
)

여기서 중요한 설계 원칙이 있습니다. 대화 기록 전체를 무조건 검색 질의 생성에 넣지 마세요. 대화가 길어질수록 비용이 늘고, 오래된 주제가 현재 질문을 오염시킬 수 있습니다. 보통은 최근 N개 메시지, 요약된 대화 상태, 사용자 프로필 중 검색에 필요한 것만 넣는 방식이 좋습니다.

고급 RAG 패턴을 목적별로 구분하기

RAG 관련 자료를 보다 보면 citation-aware RAG, corrective RAG, self-RAG, agentic RAG 같은 이름이 많이 나옵니다. 이름만 보면 복잡해 보이지만, 결국 목적은 몇 가지로 정리됩니다. Hugging Face의 Advanced RAG 자료에서도 기본 RAG를 넘어 쿼리 확장, 다중 검색, 재순위화, 동적 프롬프팅처럼 모듈을 추가해 품질을 높이는 흐름을 설명합니다.5

패턴핵심 목적적합한 상황주의할 점
Citation-aware RAG답변 문장마다 근거 출처를 연결법무, 정책, 고객지원처럼 근거 표시가 중요한 서비스출처가 "검색된 문서"가 아니라 "실제로 답변에 사용된 문장"이어야 함
Multi-source RAG여러 저장소에서 검색제품 문서, 티켓, DB, 웹 문서가 분리된 환경저장소별 신뢰도와 최신성을 구분해야 함
Contextual RAG청크에 주변 맥락을 보강짧은 청크만으로 의미가 부족한 기술 문서맥락을 너무 많이 붙이면 검색 잡음 증가
Fusion RAG여러 질의·검색기의 결과를 결합리서치, 조사, 복잡한 질문병합 후 리랭킹 없이는 잡음이 늘 수 있음
Adaptive RAG질문 난이도에 따라 경로 선택단순 FAQ와 복잡한 분석 질문이 섞인 서비스라우터 평가가 부정확하면 오히려 품질 저하
Corrective RAG검색 결과가 부족하면 재검색 또는 외부 검색내부 문서 누락이 잦은 환경외부 검색 사용 시 신뢰도와 보안 검토 필요
Self-RAG모델이 검색 필요성·근거 충분성을 스스로 점검고정 파이프라인으로 처리하기 어려운 질문LLM 호출이 늘어 비용과 지연 시간이 커짐
Agentic RAG에이전트가 검색 도구를 필요할 때 호출여러 도구, 여러 단계 추론, 탐색형 작업반복 호출, 도구 루프, 비용 폭증을 제어해야 함

이 패턴들은 서로 배타적이지 않습니다. 예를 들어 실서비스 고객지원 봇은 history-aware retriever로 질문을 재작성하고, 하이브리드 검색으로 후보를 찾고, 리랭커로 상위 문서를 고르고, citation-aware 방식으로 답변에 출처를 붙일 수 있습니다. 여기에 검색 결과가 약하면 corrective RAG처럼 재검색을 수행할 수도 있습니다.

LangChain 공식 문서도 RAG 아키텍처를 고정된 2-step RAG, 에이전트가 검색 여부를 결정하는 Agentic RAG, 그리고 검증 단계를 포함하는 Hybrid RAG로 나눠 설명합니다.6 즉, 운영 시스템에서는 "항상 검색 후 답변"만이 정답이 아닙니다. 어떤 질문은 검색이 필요 없고, 어떤 질문은 검색을 두 번 해야 하며, 어떤 질문은 SQL이나 외부 API 호출이 필요합니다.

Citation-aware RAG: 답변보다 근거가 먼저다

RAG를 서비스에 붙이면 사용자는 곧 이렇게 묻습니다.

그 답은 어디에 근거한 거야?

Citation-aware RAG는 답변에 출처를 붙이는 패턴입니다. 단순히 검색된 문서 목록을 아래에 보여 주는 것과는 다릅니다. 진짜 citation-aware RAG는 답변의 각 주장과 근거 청크를 연결해야 합니다.

가장 단순한 구현은 컨텍스트를 만들 때 청크마다 ID를 붙이는 것입니다.

def format_docs_with_ids(docs):
    lines = []
    for i, doc in enumerate(docs, start=1):
        source = doc.metadata.get("source", "unknown")
        lines.append(
            f"[문서 {i}] source={source}n{doc.page_content}"
        )
    return "nn".join(lines)

프롬프트에서는 다음처럼 요구합니다.

답변의 각 핵심 문장 끝에 근거 문서 번호를 붙이세요.
예: 비밀번호는 90일마다 변경해야 합니다. [문서 2]
컨텍스트에 없는 내용은 추측하지 마세요.

이 방식만으로도 출처 표시의 기본은 만들 수 있습니다. 하지만 더 정확하게 하려면 답변 생성 후 "각 문장이 어느 문서에서 지지되는가"를 별도 검증 체인으로 확인할 수 있습니다. 규제 산업에서는 이 후처리 검증이 매우 중요합니다.

Multi-source RAG: 모든 지식을 한 벡터스토어에 넣지 않기

많은 팀이 처음에는 모든 문서를 하나의 벡터스토어에 넣습니다. 작게 시작할 때는 괜찮습니다. 하지만 데이터가 늘면 문제가 생깁니다.

제품 매뉴얼, 장애 티켓, 고객 문의, 릴리스 노트, 사내 정책은 성격이 다릅니다. 최신성, 신뢰도, 문체, 접근 권한도 다릅니다. 이를 모두 한 인덱스에 섞으면 검색 결과가 혼탁해집니다.

Multi-source RAG는 저장소를 나누고, 질문에 따라 적절한 저장소를 선택하거나 여러 저장소를 동시에 검색합니다.

retrievers = {
    "docs": product_docs_retriever,
    "tickets": support_ticket_retriever,
    "policies": policy_retriever,
}

def route_query(question: str):
    if "정책" in question or "권한" in question:
        return [retrievers["policies"]]
    if "장애" in question or "오류" in question:
        return [retrievers["docs"], retrievers["tickets"]]
    return [retrievers["docs"]]

더 정교하게는 LLM 라우터를 사용할 수 있습니다. 다만 라우터가 틀릴 수 있으므로, 중요한 질문에서는 "주 저장소 + 보조 저장소" 구조를 쓰는 것이 안전합니다.

Adaptive·Corrective·Self RAG: 실패를 감지하고 다시 시도하기

기본 RAG는 검색 결과가 좋든 나쁘든 그대로 답변을 생성합니다. 실서비스에서는 이 태도가 위험합니다. 검색 결과가 약하면 답변을 멈추거나, 다시 검색하거나, 다른 도구를 사용해야 합니다.

Adaptive RAG는 질문의 난이도와 유형에 따라 경로를 고릅니다. 단순 정의 질문은 기본 RAG로 빠르게 처리하고, 비교·분석 질문은 MultiQuery와 리랭킹을 사용하고, 최신 정보 질문은 웹 검색 도구로 라우팅하는 식입니다.

Corrective RAG는 검색 결과를 평가한 뒤 부족하면 수정 행동을 합니다.

검색 → 관련성 평가 → 충분하면 답변
                 → 부족하면 질문 재작성 후 재검색
                 → 그래도 부족하면 모른다고 답하거나 외부 도구 호출

Self-RAG는 모델이 답변 과정에서 스스로 "검색이 필요한가?", "근거가 충분한가?", "답변이 컨텍스트에 충실한가?"를 점검하는 패턴입니다. 강력하지만 LLM 호출이 많아질 수 있습니다. 따라서 모든 트래픽에 적용하기보다는 고위험 질문, 낮은 검색 점수, 불만족 피드백이 들어온 질문에 제한적으로 적용하는 것이 현실적입니다.

벡터스토어별 튜닝 기준

ChromaDB, Pinecone, Weaviate, FAISS는 모두 벡터 검색을 지원하지만 운영 성격이 다릅니다. Chroma는 로컬 개발과 소규모 서비스에서 시작하기 좋고, Pinecone은 관리형 클라우드로 확장과 운영 편의성이 강하며, Weaviate는 하이브리드 검색과 스키마 기반 구성이 강점입니다. FAISS는 빠른 로컬 유사도 검색 라이브러리로, 직접 운영할 수 있는 환경에서 비용을 줄이기 좋습니다.

도구 선택보다 더 중요한 것은 튜닝 항목입니다.

첫째, 청킹 전략입니다. 청크가 작으면 검색 정밀도는 좋아질 수 있지만 벡터 수가 늘어 저장 비용과 검색 비용이 증가합니다. 청크가 크면 벡터 수는 줄지만 검색 결과에 불필요한 문장이 섞입니다. 일반 문서는 500~1,000 토큰 또는 800~1,500자 근처에서 시작하고, API 문서나 FAQ는 더 작게, 긴 보고서는 parent-child 구조를 고려하는 것이 좋습니다.

둘째, 임베딩 모델입니다. 영어 중심 문서, 한국어 문서, 코드 문서, 법률 문서는 잘 맞는 임베딩 모델이 다를 수 있습니다. 모델 차원 수가 크면 표현력은 좋아질 수 있지만 저장 비용과 검색 비용이 늘어납니다. 운영에서는 최소 두세 개 모델을 같은 평가셋으로 비교해야 합니다.

셋째, top-k와 score threshold입니다. k를 크게 하면 재현율은 올라가지만 모델 입력 토큰이 늘고 잡음도 늘어납니다. k=4에서 시작해 실패 사례를 보며 6, 8로 늘려 보는 식이 좋습니다. score threshold를 설정하면 약한 검색 결과를 버릴 수 있지만, 임베딩 모델과 벡터스토어마다 점수 의미가 달라 절대값을 맹신하면 안 됩니다.

넷째, 필터 조건입니다. 제품, 버전, 권한, 날짜 필터는 검색 후보 자체를 줄입니다. 이는 품질뿐 아니라 속도와 비용에도 직접 영향을 줍니다. 특히 멀티테넌트 서비스에서는 tenant ID 필터를 절대 빠뜨리면 안 됩니다.

다섯째, 인덱스 구성입니다. 대규모 벡터 검색은 보통 근사 최근접 탐색을 사용합니다. 정확도를 조금 양보하고 속도를 얻는 방식입니다. Pinecone, Weaviate 같은 관리형 서비스에서는 인덱스 타입, replica, pod 또는 serverless 설정이 비용과 지연 시간에 영향을 줍니다. FAISS를 직접 쓴다면 Flat, IVF, HNSW 같은 인덱스 선택이 중요해집니다.

여섯째, 캐싱입니다. 같은 문서의 임베딩은 다시 만들지 않아야 하고, 자주 들어오는 질문의 검색 결과도 짧은 TTL로 캐싱할 수 있습니다. 질문 재작성 결과, MultiQuery 결과, 리랭킹 결과 역시 캐싱 후보입니다. 다만 권한과 문서 갱신 시점이 다른 사용자에게 캐시가 잘못 공유되지 않도록 캐시 키에 사용자 권한과 문서 버전을 포함해야 합니다.

비용과 품질의 균형 잡기

고급 RAG 기법은 대부분 비용을 추가합니다. 질문 재작성은 LLM 호출을 한 번 늘립니다. MultiQuery는 검색 횟수를 늘립니다. 리랭커는 후보 문서마다 추가 계산을 합니다. Contextual Compression은 압축 모델 호출을 추가할 수 있습니다.

따라서 모든 질문에 모든 기법을 붙이면 안 됩니다. 실무에서는 단계적 방어선을 둡니다.

1단계: 기본 검색 + 메타데이터 필터
2단계: 검색 점수가 낮으면 질문 재작성
3단계: 여전히 부족하면 MultiQuery 또는 하이브리드 검색
4단계: 후보가 많으면 리랭킹
5단계: 컨텍스트가 길면 압축
6단계: 근거가 부족하면 모른다고 답변 또는 재검색

이렇게 하면 쉬운 질문은 빠르고 싸게 처리하고, 어려운 질문에만 비용을 집중할 수 있습니다. 좋은 RAG 시스템은 항상 최고급 경로를 타는 시스템이 아니라, 질문에 맞는 만큼만 똑똑해지는 시스템입니다.

RAGAS와 자체 평가셋으로 개선 루프 만들기

감으로 RAG를 개선하면 금방 한계가 옵니다. 프롬프트를 바꿨더니 어떤 질문은 좋아지고 어떤 질문은 나빠집니다. top-k를 늘렸더니 정답률은 조금 오르지만 비용이 증가합니다. 리랭커를 붙였더니 평균 품질은 좋아졌지만 지연 시간이 두 배가 됩니다.

그래서 평가셋이 필요합니다. RAGAS는 RAG 평가에 자주 쓰이는 프레임워크이며, 대표적으로 faithfulness, answer relevancy, context precision, context recall 같은 지표를 제공합니다. RAGAS 문서와 관련 자료에서는 LangChain QA 체인과 결합해 검색 컨텍스트와 생성 답변을 평가하는 흐름을 설명합니다.7

각 지표를 쉽게 풀면 이렇습니다.

지표묻는 질문주로 보는 단계
Faithfulness답변이 제공된 컨텍스트에 근거하는가?생성
Answer relevancy답변이 사용자 질문에 직접 답하는가?생성
Context precision검색된 컨텍스트 중 쓸모 있는 것이 상위에 있는가?검색
Context recall정답에 필요한 근거를 검색 결과가 충분히 포함하는가?검색
Citation accuracy표시한 출처가 실제 답변 문장을 지지하는가?생성·후처리
Latency사용자가 기다릴 만한 시간 안에 끝나는가?운영
Token cost질문당 비용이 목표 범위 안에 있는가?운영

평가셋은 처음부터 거창할 필요 없습니다. 서비스에 중요한 질문 30개로 시작해도 됩니다. 각 질문에는 최소한 다음 정보를 넣습니다.

{
  "question": "엔터프라이즈 플랜에서 SAML SSO를 사용할 수 있나요?",
  "ground_truth": "엔터프라이즈 플랜에서 SAML SSO를 사용할 수 있으며, 관리자 권한이 필요합니다.",
  "expected_sources": ["docs/sso/enterprise.md"],
  "tags": ["pricing", "sso", "permission"]
}

평가 실행 시에는 답변만 저장하지 말고 중간 결과도 저장해야 합니다.

result = rag_chain.invoke({"input": question})

record = {
    "question": question,
    "answer": result["answer"],
    "contexts": [doc.page_content for doc in result["context"]],
    "sources": [doc.metadata.get("source") for doc in result["context"]],
    "latency_ms": latency_ms,
    "prompt_tokens": prompt_tokens,
    "completion_tokens": completion_tokens,
}

실패 분석은 태그별로 해야 합니다. 전체 평균이 0.82라고 해서 안심하면 안 됩니다. "요금제 질문"은 잘 맞지만 "오류 코드 질문"은 계속 실패할 수 있습니다. 이 경우 전체 프롬프트를 고칠 것이 아니라 하이브리드 검색과 BM25 가중치를 조정해야 합니다.

반대로 context recall은 높은데 faithfulness가 낮다면 검색기는 충분히 잘하고 있습니다. 이때는 리트리버가 아니라 프롬프트, 출력 형식, 근거 인용 검증을 고쳐야 합니다.

실패 사례별 처방전

RAG 품질 개선은 의사의 진단과 비슷합니다. 증상만 보고 약을 고르면 안 됩니다.

증상가능성 높은 원인먼저 시도할 개선
정답 문서가 검색 결과에 없음질문 표현 불일치, 청킹 문제, 임베딩 부적합질문 재작성, MultiQuery, 청크 재설계, 임베딩 모델 교체
비슷한 청크만 여러 개 검색됨top-k similarity의 중복MMR, parent-child, 중복 제거
오류 코드·제품명이 잘 안 잡힘벡터 검색의 정확 키워드 약점BM25 결합, 하이브리드 검색, 메타데이터 필터
오래된 문서가 답변에 사용됨날짜 필터 부재, 최신성 가중치 없음updated_at 필터, recency boost, 문서 폐기 정책
답변은 맞지만 출처가 틀림출처 후처리 부재citation-aware 프롬프트, 문장-근거 검증
답변이 장황하고 비용이 큼컨텍스트 과다, top-k 과다Contextual Compression, 리랭킹 후 top-k 축소
후속 질문에서 엉뚱한 검색대화 이력 미반영history-aware retriever, 질문 재작성
특정 사용자에게 보이면 안 되는 문서가 검색됨권한 필터 부재access_level, tenant_id 필터를 검색 단계에 강제

이 표를 팀의 운영 문서에 넣어 두면 좋습니다. 장애가 발생했을 때 "모델을 더 큰 것으로 바꾸자"는 막연한 대응을 줄이고, 파이프라인의 어느 부분을 고쳐야 할지 빠르게 좁힐 수 있습니다.

실전 기본 조합: 운영형 RAG 파이프라인

이제 지금까지의 내용을 하나의 흐름으로 묶어 보겠습니다. 모든 기능을 항상 켜는 것이 아니라, 단계적으로 적용하는 운영형 구조입니다.

사용자 질문
  ↓
대화 이력 기반 질문 재작성
  ↓
메타데이터 필터 생성
  ↓
하이브리드 검색 또는 MultiQuery 검색
  ↓
MMR로 다양성 확보
  ↓
리랭커로 상위 문서 재정렬
  ↓
Contextual Compression으로 컨텍스트 축소
  ↓
근거 인용 포함 답변 생성
  ↓
평가 로그 저장

코드로 완전히 구현하면 길어지지만, 핵심 골격은 다음처럼 생각할 수 있습니다.

def advanced_rag(question: str, chat_history: list, user: User):
    standalone_question = rewrite_with_history(question, chat_history)

    filters = build_metadata_filter(
        question=standalone_question,
        user=user,
    )

    candidates = hybrid_retrieve(
        query=standalone_question,
        filters=filters,
        k=20,
    )

    diverse_candidates = apply_mmr(
        query=standalone_question,
        docs=candidates,
        k=10,
    )

    reranked_docs = rerank(
        query=standalone_question,
        docs=diverse_candidates,
        top_n=5,
    )

    compressed_context = compress_context(
        query=standalone_question,
        docs=reranked_docs,
    )

    answer = generate_answer_with_citations(
        question=question,
        context=compressed_context,
        chat_history=chat_history,
    )

    log_for_evaluation(
        question=question,
        rewritten_question=standalone_question,
        docs=reranked_docs,
        answer=answer,
    )

    return answer

이 구조는 복잡해 보이지만, 각 단계는 독립적으로 교체할 수 있습니다. 이것이 LangChain을 쓰는 장점입니다. 기본 retriever를 MultiQueryRetriever로 바꾸거나, Chroma를 Pinecone으로 바꾸거나, 리랭커를 추가해도 전체 애플리케이션 구조를 다시 만들 필요가 없습니다.

고급 RAG의 핵심은 "더하기"가 아니라 "고르기"다

고급 RAG를 처음 접하면 모든 기법을 한꺼번에 붙이고 싶어집니다. 하지만 좋은 RAG 시스템은 기능이 많은 시스템이 아니라, 실패 유형에 맞는 처방을 고르는 시스템입니다.

검색 결과가 부족하면 후보를 넓혀야 합니다. 이때는 질문 재작성, MultiQuery, 하이브리드 검색이 도움이 됩니다. 후보는 충분하지만 잡음이 많다면 리랭커와 Contextual Compression이 필요합니다. 후속 질문이 흔들린다면 history-aware retriever가 먼저입니다. 근거 신뢰가 중요하다면 citation-aware RAG와 출처 검증이 필수입니다. 질문 유형이 다양하다면 adaptive RAG나 agentic RAG로 넘어갈 수 있습니다.

다음 장에서는 RAG가 문서를 읽고 답하는 수준을 넘어, 실제 행동을 수행하는 애플리케이션으로 확장됩니다. 검색기가 문서를 찾는 도구였다면, 앞으로는 계산기, 데이터베이스, 외부 API, 업무 시스템이 모두 도구가 됩니다. RAG가 "근거를 가져오는 능력"이라면, 도구 호출은 "세상에 영향을 주는 능력"입니다.

참고

1Build a RAG agent with LangChain

2Retriever integrations - Docs by LangChain

3Contextual Compression Retriever

5한국어 Advanced RAG 구현: Hugging Face와 LangChain 활용한 쿡북

6Retrieval - Docs by LangChain

7Langchain - Ragas

책 목록

LangChain 실전 활용법: LLM 앱부터 RAG·에이전트·운영까지

전체 목차 보기

이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.

틸노트는 사용자가 AI를 활용해 노트를 쉽게 작성하거나 편집할 수 있는 서비스입니다.

AI 스퀘어 단톡방에 함께해요

AI 활용과 틸노트 사용 팁을 나누는 오픈채팅방입니다. 입장 비밀번호는 tilnote입니다.

참여하기