Skip to content
Published on

RAG가 엉뚱한 답을 할 때 — 검색 실패와 생성 실패를 가르는 디버깅 절차

공유하기
Authors

들어가며 — 답이 틀렸습니다. 어디부터 보십니까

RAG 시스템이 문서에 분명히 있는 내용을 두고 엉뚱한 답을 하면, 대부분의 팀이 프롬프트를 고칩니다. "제공된 문맥에만 근거하세요"를 더 강한 어조로 다시 씁니다. 그리고 대체로 나아지지 않습니다.

당연합니다. 모델은 지시를 어긴 것이 아니라, 받은 문맥 안에 정답이 없었을 뿐인 경우가 많기 때문입니다. RAG는 검색과 생성이 직렬로 붙은 파이프라인이고, 앞단이 틀리면 뒷단은 손댈 데가 없습니다.

이 글은 순서를 지키는 디버깅 절차를 정리합니다. 원인을 먼저 가르고, 가장 흔한 범인부터 확인하고, 마지막에 측정 체계를 세웁니다. 순서를 지키지 않으면 프롬프트만 6개월 고치게 됩니다.

1단계 — 검색 실패인가, 생성 실패인가

가장 먼저 할 일은 검색된 청크를 눈으로 보는 것입니다. 이 단계를 건너뛰고 진행하는 디버깅은 전부 추측입니다. 그런데 놀랍게도 많은 파이프라인이 검색 결과를 로그에 남기지 않습니다.

로그가 있다면 다음 실험 하나로 원인이 갈립니다.

def diagnose(question, gold_passage, retriever, generate):
    """정답 청크를 직접 넣었을 때와 검색 결과로 넣었을 때를 비교한다."""
    retrieved = retriever(question, k=5)
    ctx_retrieved = "\n\n".join(c.text for c in retrieved)

    ans_normal = generate(question, ctx_retrieved)
    ans_oracle = generate(question, gold_passage)      # 검색을 건너뛴다

    gold_in_ctx = gold_passage[:200] in ctx_retrieved

    if not gold_in_ctx and is_correct(ans_oracle):
        return "검색 실패"          # 정답만 주면 맞힌다 → 앞단 문제
    if gold_in_ctx and not is_correct(ans_normal):
        return "생성 실패"          # 정답을 줬는데도 틀린다 → 뒷단 문제
    if not is_correct(ans_oracle):
        return "생성 실패 (근본)"    # 정답 문서만 줘도 못 푼다
    return "정상"

이 진단을 골든 셋 전체에 돌리면 두 실패의 비율이 나옵니다. 제 경험상 초기 파이프라인에서는 검색 실패 쪽이 훨씬 많고, 그 안에서도 대부분이 청킹에서 옵니다.

생성 실패로 판정된 경우는 다시 셋으로 나뉩니다. 문맥에 답이 있는데 무시하고 사전 지식으로 답한 경우, 여러 청크에 흩어진 정보를 종합하지 못한 경우, 그리고 문맥이 서로 모순될 때 잘못된 쪽을 고른 경우입니다. 셋의 처방이 전부 다르므로 뭉뚱그리면 안 됩니다.

"모르면 모른다고 하세요"의 한계

생성 실패 대응으로 가장 먼저 시도하는 것이 근거 없는 답변을 막는 지시입니다.

SYSTEM = """제공된 문맥만 근거로 답하십시오.
문맥에 답이 없으면 정확히 "문서에서 찾을 수 없습니다"라고만 출력하십시오.
각 주장 끝에 근거 청크 번호를 대괄호로 표기하십시오."""

이 지시는 효과가 있습니다. 다만 두 가지를 알고 쓰셔야 합니다.

첫째, 기권을 강하게 요구하면 답할 수 있는 질문에도 기권하는 비율이 함께 올라갑니다. 이건 정밀도와 재현율의 교환이지 순수한 개선이 아닙니다. 그러니 "환각률이 떨어졌다"만 보고하지 말고 "답변률이 얼마나 떨어졌는지"를 같이 봐야 합니다.

둘째, 근거 표기를 요구해도 모델이 표기한 번호가 실제 근거와 일치한다는 보장은 없습니다. 인용 번호는 검증 가능한 형식일 뿐이고, 검증은 여러분 코드가 해야 합니다. 인용된 청크에 해당 주장이 실제로 있는지 별도로 확인하는 단계를 붙이면 그제야 신뢰할 수 있는 신호가 됩니다.

청킹이 원인의 절반입니다

검색 실패로 판정됐다면 임베딩 모델을 바꾸기 전에 청크를 먼저 보십시오. 대부분 여기서 끝납니다.

전형적인 파괴 사례는 이렇습니다. 표가 중간에서 잘려 헤더와 데이터가 다른 청크로 갈라집니다. 코드 블록이 함수 중간에서 끊깁니다. "위 조건에 해당하는 경우"로 시작하는 문단이 앞 문단과 분리되어 무엇을 가리키는지 알 수 없게 됩니다. 조항 번호만 있는 줄과 본문이 따로 놉니다.

고정 길이 분할은 이런 구조를 전혀 모릅니다. 그래서 첫 번째 개선은 거의 항상 문서 구조를 존중하는 분할입니다. 마크다운이면 헤딩 경계, HTML이면 섹션 태그, PDF면 레이아웃 분석 결과를 우선 경계로 쓰고, 그 안에서 길이가 넘칠 때만 자릅니다. 표와 코드 블록은 통째로 하나의 청크로 두고 길이 제한의 예외로 처리합니다.

크기와 겹침은 그다음 문제입니다. 계산 자체는 단순합니다.

import math

def chunk_stats(doc_tokens, size, overlap):
    stride = size - overlap
    n = 1 if doc_tokens <= size else math.ceil((doc_tokens - overlap) / stride)
    stored = n * size
    return n, stored / doc_tokens

for size, ov in [(256, 32), (512, 64), (1024, 128), (512, 256)]:
    n, infl = chunk_stats(50_000, size, ov)
    print(f"size={size:4} overlap={ov:3} → 청크 {n:3}개, 저장 토큰 {infl:.2f}배")
# size= 256 overlap= 32 → 청크 224개, 저장 토큰 1.15배
# size= 512 overlap= 64 → 청크 112개, 저장 토큰 1.15배
# size=1024 overlap=128 → 청크  56개, 저장 토큰 1.15배
# size= 512 overlap=256 → 청크 195개, 저장 토큰 2.00배

겹침을 크기의 절반으로 잡으면 저장량이 두 배가 됩니다. 벡터 저장 비용 자체는 대수롭지 않습니다. 청크 112개에 1536차원 fp32 임베딩이면 690KB 남짓입니다. 진짜 대가는 후보 목록이 거의 같은 내용으로 채워진다는 점입니다. 상위 5개를 뽑았는데 4개가 같은 문단의 겹친 조각이면 실효 문맥은 하나뿐입니다. 겹침은 크기의 10~15퍼센트면 충분한 경우가 대부분입니다.

크기에는 정답이 없습니다. 다만 방향은 있습니다. 작은 청크는 검색 정밀도가 높고 문맥이 부족하며, 큰 청크는 반대입니다. 질문이 단일 사실 조회형이면 작게, 절차나 설명을 요구하면 크게 가는 편이 맞습니다. 하나로 못 정하겠으면 작게 검색하고 이웃 청크를 함께 확장해서 넘기는 방식이 실용적인 절충입니다.

임베딩 유사도는 의미 유사도가 아닙니다

임베딩 검색이 실패하는 전형적인 패턴이 있습니다.

질의에 정확한 식별자가 들어 있는 경우입니다. 오류 코드, 제품 번호, 함수 이름, 사번 같은 토큰은 임베딩 공간에서 비슷한 형태의 다른 식별자와 거의 구분되지 않습니다. 벡터는 의미를 압축하는 장치이고, 이런 토큰의 가치는 정확히 그 문자열이라는 데 있기 때문입니다.

부정과 조건이 뒤집힌 경우입니다. "환불이 가능한 조건"과 "환불이 불가능한 조건"은 코사인 유사도가 매우 높습니다. 임베딩은 주제를 잡지 논리를 잡지 않습니다.

질의와 문서의 표현 형태가 다른 경우입니다. 짧은 질문과 긴 서술형 문단은 애초에 분포가 다릅니다. 질의용과 문서용 프리픽스를 따로 요구하는 임베딩 모델이 있는 이유가 이것이고, 그 프리픽스를 빠뜨리면 성능이 눈에 띄게 떨어집니다.

첫 번째와 세 번째는 어휘 검색을 섞으면 상당 부분 해결됩니다. BM25는 정확한 문자열 일치에 강하고, 희귀한 토큰에 높은 가중치를 줍니다. 두 결과를 합치는 가장 무난한 방법은 점수를 정규화하지 않고 순위만 쓰는 역순위 융합입니다.

def rrf(rankings, k=60, top_n=10):
    """rankings: 검색기별 문서 ID 리스트(순위순). 점수 스케일을 맞출 필요가 없다."""
    scores = {}
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)[:top_n]

hits = rrf([
    bm25_search(query, k=50),     # 어휘
    vector_search(query, k=50),   # 의미
])

상수 60은 원 논문에서 제안된 값이고, 실무에서 그대로 써도 대체로 무난합니다. 이 방식의 장점은 튜닝할 값이 사실상 하나뿐이라는 것이고, 단점은 점수 크기를 버리기 때문에 한 검색기가 압도적으로 확신하는 경우를 반영하지 못한다는 것입니다.

하이브리드가 항상 이긴다고 말하지는 않겠습니다. 도메인에 고유명사와 식별자가 적고 문장이 서술형 위주라면 순수 벡터 검색이 더 나을 수 있습니다. 다만 두 검색기를 동시에 돌리는 비용이 낮고 구현이 반나절이므로, 먼저 시도해서 재 보는 편이 합리적인 순서입니다.

재순위화 — 언제 값을 하는가

재순위화는 1차 검색으로 후보를 넉넉히 뽑고, 그중 상위 일부를 정밀한 모델로 다시 줄 세우는 단계입니다. 교차 인코더는 질의와 문서를 한 번에 인코딩해 상호작용을 직접 보므로, 각자 따로 벡터를 만드는 방식보다 정확합니다.

대가는 계산량입니다. 벡터 검색은 질의를 한 번 인코딩하면 되지만, 교차 인코더는 후보 수만큼 순전파를 돌립니다.

def rerank_budget(n_candidates, ms_per_pair, retrieval_ms):
    total = retrieval_ms + n_candidates * ms_per_pair
    return total, total / retrieval_ms

for n in (20, 50, 100):
    total, ratio = rerank_budget(n, ms_per_pair=1.2, retrieval_ms=15)
    print(f"후보 {n:3}개 → {total:5.0f}ms (검색만 대비 {ratio:.1f}배)")
# 후보  20개 →    39ms (검색만 대비 2.6배)
# 후보  50개 →    75ms (검색만 대비 5.0배)
# 후보 100개 →   135ms (검색만 대비 9.0배)

ms_per_pair는 모델 크기, 청크 길이, 하드웨어, 배치 처리 여부에 따라 크게 달라집니다. 위 1.2밀리초는 설명용으로 넣은 값이니 반드시 본인 환경에서 측정해서 바꾸십시오.

판단 기준은 이렇습니다. 재순위화는 1차 검색이 정답을 후보 안에 넣어 주지만 순위가 낮을 때만 값을 합니다. 즉 recall@50은 높은데 recall@5가 낮은 상황입니다. 반대로 recall@50 자체가 낮으면 재순위화는 아무것도 못 합니다. 없는 것을 위로 올릴 수는 없기 때문입니다. 그래서 재순위화를 검토하기 전에 두 숫자를 먼저 재야 하고, 이 순서를 지키면 쓸데없는 지연 시간 추가를 피할 수 있습니다.

전체 문맥을 LLM에 넘기기 전 마지막 필터로 쓰는 경우도 유효합니다. 관련도가 낮은 청크를 버리면 입력 토큰이 줄고, 모델이 무관한 문맥에 이끌릴 확률도 줄어듭니다.

청크에 문맥을 붙이기

임베딩되는 텍스트가 청크 본문뿐이라면, 그 청크는 자기가 어느 문서의 어느 부분인지 모르는 상태로 검색됩니다. "3항의 예외는 다음과 같다"라는 문단은 그 자체로 검색될 방법이 없습니다.

해법은 단순합니다. 임베딩 대상 텍스트 앞에 위치 정보를 붙입니다.

def contextualize(chunk, doc):
    header = " > ".join(filter(None, [doc.title, chunk.section, chunk.subsection]))
    return f"[{header}]\n{chunk.text}"

# 임베딩과 BM25 색인 모두 이 텍스트를 대상으로 만든다.
# 단, LLM에 넘기는 문맥에도 같은 헤더를 유지해야 출처 표기가 가능하다.

효과가 큰 이유는 두 가지입니다. 검색 측면에서는 문서 제목과 섹션명이 청크마다 반복되면서 주제 신호가 보강됩니다. 생성 측면에서는 모델이 여러 문서를 구분할 수 있게 되어, "A 제품 정책"과 "B 제품 정책"을 섞는 실수가 줄어듭니다.

한 걸음 더 나가면 청크마다 문서 전체 맥락을 요약한 한두 문장을 LLM으로 미리 생성해 붙이는 방법도 있습니다. 색인 시점에 문서당 한 번 비용을 치르는 대신 검색 품질을 올리는 거래입니다. 다만 이 비용은 문서 수에 비례해 실제로 나가는 돈이니, 앞의 단순한 헤더 부착을 먼저 해 보고 부족할 때 검토하는 편이 순서상 맞습니다.

메타데이터는 검색 대상이자 필터입니다. 날짜, 버전, 부서, 문서 종류를 필드로 저장해 두면 "작년 정책이 검색되는" 종류의 실패를 필터로 잘라낼 수 있습니다. 이건 순위 조정으로는 잘 안 되고 필터로만 확실하게 됩니다.

골든 셋과 recall@k — 측정 없이 개선은 없습니다

여기까지의 모든 변경은 되돌릴 수 있어야 하고, 그러려면 숫자가 필요합니다. 필요한 것은 질문과 정답 근거 청크 ID의 쌍입니다.

만드는 방법은 실제 사용자 질문 로그에서 뽑는 것이 가장 좋습니다. 로그가 없으면 문서에서 LLM으로 질문을 생성하되, 반드시 사람이 검수하십시오. 생성된 질문은 원문 표현을 그대로 베끼는 경향이 있어서, 그런 질문만으로 만든 평가셋은 어휘 검색에 유리하게 편향됩니다. 표현을 바꾼 질문, 여러 청크를 종합해야 하는 질문, 그리고 문서에 답이 없는 질문을 반드시 섞으십시오. 마지막 종류가 기권 능력을 재는 유일한 방법입니다.

def recall_at_k(dataset, retriever, k):
    """정답 청크가 상위 k 안에 들어온 질문의 비율"""
    hit = 0
    for q, gold_ids in dataset:
        got = [c.id for c in retriever(q, k=k)]
        if any(g in got for g in gold_ids):
            hit += 1
    return hit / len(dataset)

def mrr(dataset, retriever, k):
    """첫 정답의 순위 역수 평균. 순위가 중요할 때 recall과 함께 본다."""
    total = 0.0
    for q, gold_ids in dataset:
        got = [c.id for c in retriever(q, k=k)]
        for rank, cid in enumerate(got, start=1):
            if cid in gold_ids:
                total += 1.0 / rank
                break
    return total / len(dataset)

for k in (1, 5, 20, 50):
    print(f"recall@{k:2} = {recall_at_k(golden, hybrid_retriever, k):.3f}")

이 두 숫자를 k별로 뽑아 놓으면 진단이 기계적으로 됩니다. recall@50이 낮으면 색인과 청킹 문제, recall@50은 높은데 recall@5가 낮으면 순위 문제, 둘 다 높은데 답이 틀리면 생성 문제입니다.

증상의심 원인확인 방법우선 대응
답에 근거가 아예 없음검색 실패정답 청크 주입 실험청킹, 하이브리드
정답 청크가 상위권에 없음순위 문제recall@50과 recall@5 비교재순위화
문서 하나만 반복 인용겹침 과다상위 k 청크의 중복률겹침 축소, 문서 단위 다양화
옛 버전 내용을 인용필터 부재인용 청크의 날짜 필드메타데이터 필터
표 안 숫자가 틀림표 분할청크 원문 직접 확인표를 통째 청크로
있는 답에 기권기권 지시 과잉답변률과 정확도 동시 측정지시 완화, 임계값 조정

주의할 점 하나. 이 평가셋의 크기가 작으면 여기서 나온 차이는 대부분 잡음입니다. 30개 질문에서 recall이 0.70에서 0.73으로 올랐다는 결과로 배포 결정을 내리면 안 됩니다. 표본 크기와 유의성을 다루는 방법은 감으로 하지 않는 LLM 평가 편에 정리했습니다.

마치며 — 검색 결과를 먼저 보십시오

RAG 디버깅의 절반은 "검색된 청크를 실제로 읽어 보기"에서 끝납니다. 그런데 이 단계가 가장 자주 생략됩니다. 파이프라인이 문맥을 로그에 남기지 않게 짜여 있거나, 프롬프트를 고치는 편이 더 빨라 보이기 때문입니다.

그러니 오늘 할 일은 두 가지면 충분합니다. 요청마다 검색된 청크 ID와 원문을 로그에 남기고, 질문 50개짜리 골든 셋을 만들어 recall@5와 recall@50을 재 놓는 것입니다. 이 두 가지가 없으면 이후의 모든 개선은 앞이 안 보이는 상태에서 하는 수리이고, 있으면 원인이 대체로 한 시간 안에 좁혀집니다.