Skip to content

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

✨ Learn with Quiz
|

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

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

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을 재 놓는 것입니다. 이 두 가지가 없으면 이후의 모든 개선은 앞이 안 보이는 상태에서 하는 수리이고, 있으면 원인이 대체로 한 시간 안에 좁혀집니다.

When RAG Answers the Wrong Thing — a Debugging Procedure That Separates Retrieval Failure from Generation Failure

Introduction — the answer is wrong. Where do you look first?

When a RAG system answers something odd about content that is plainly in the documents, most teams fix the prompt. They rewrite "ground your answer only in the provided context" in a firmer tone. And it usually does not get better.

Of course it does not. In many cases the model did not disobey the instruction; the answer simply was not in the context it received. RAG is a pipeline with retrieval and generation wired in series, and when the front stage is wrong there is nothing to fix in the back stage.

This post lays out a debugging procedure that keeps the order. Split the cause first, check the most common culprit next, and build the measurement system last. Skip the order and you will spend six months fixing prompts.

Step 1 — retrieval failure or generation failure

The first thing to do is look at the retrieved chunks with your own eyes. Any debugging that skips this step is guesswork. Yet, remarkably, many pipelines never log the retrieval results.

If the logs are there, one experiment separates the causes.

def diagnose(question, gold_passage, retriever, generate):
    """compare feeding the gold chunk directly against feeding the retrieval result."""
    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)      # skip retrieval

    gold_in_ctx = gold_passage[:200] in ctx_retrieved

    if not gold_in_ctx and is_correct(ans_oracle):
        return "검색 실패"          # correct when handed the answer → front-stage problem
    if gold_in_ctx and not is_correct(ans_normal):
        return "생성 실패"          # wrong even when handed the answer → back-stage problem
    if not is_correct(ans_oracle):
        return "생성 실패 (근본)"    # cannot solve it even given only the gold document
    return "정상"

Run this diagnosis across the whole golden set and you get the ratio of the two failures. In my experience, early pipelines skew heavily toward retrieval failure, and within that, most of it comes from chunking.

Cases judged as generation failure split into three again. The answer was in the context but the model ignored it and answered from prior knowledge; the information was scattered across several chunks and the model failed to combine them; the contexts contradicted each other and the model picked the wrong side. All three call for different treatment, so do not lump them together.

The limits of "say you do not know when you do not know"

The first thing people try against generation failure is an instruction that blocks unsupported answers.

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

This instruction works. There are just two things to know when you use it.

First, demanding abstention forcefully also raises the rate at which the model abstains on questions it could have answered. That is a precision-recall trade, not a pure improvement. So do not report only "the hallucination rate fell" — report how much the answer rate fell alongside it.

Second, requiring citations gives no guarantee that the numbers the model wrote match the actual evidence. A citation number is merely a verifiable format; the verification has to be done by your code. Add a step that separately checks whether the cited chunk really contains the claim, and only then does it become a signal you can trust.

Chunking is half the cause

If the verdict is retrieval failure, look at the chunks before you swap the embedding model. Most of the time it ends right there.

The typical damage looks like this. A table gets cut through the middle so the header and the data land in different chunks. A code block is severed mid-function. A paragraph beginning "in cases matching the condition above" is separated from the paragraph before it and there is no way to tell what it refers to. A line holding only a clause number drifts away from its body text.

Fixed-length splitting knows nothing about any of this structure. So the first improvement is almost always splitting that respects document structure. Use heading boundaries for markdown, section tags for HTML, layout analysis results for PDF as the primary boundaries, and only cut inside them when the length overflows. Keep tables and code blocks whole as a single chunk each and treat them as exceptions to the length limit.

Size and overlap are the next question. The arithmetic itself is simple.

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배

Set the overlap to half the chunk size and storage doubles. The vector storage cost itself is trivial: 112 chunks of 1536-dimensional fp32 embeddings is a little over 690KB. The real price is that the candidate list fills up with nearly identical content. If you pull the top 5 and four of them are overlapping fragments of the same paragraph, your effective context is one. Ten to fifteen percent of the chunk size is enough overlap in most cases.

There is no correct answer for size, though there is a direction. Small chunks have higher retrieval precision and insufficient context; large chunks are the reverse. If your questions are single-fact lookups, go small; if they ask for procedures or explanations, go large. If you cannot settle on one, retrieving small and then expanding with neighboring chunks before handing them over is a practical compromise.

Embedding similarity is not semantic similarity

There is a typical pattern to how embedding retrieval fails.

The query contains an exact identifier. Error codes, product numbers, function names, employee IDs — in embedding space these tokens are nearly indistinguishable from other identifiers of similar shape. A vector is a device for compressing meaning, and the value of such a token lies precisely in the exact string.

Negation and conditions are flipped. "Conditions under which a refund is possible" and "conditions under which a refund is impossible" have very high cosine similarity. Embeddings capture topic, not logic.

The query and the document have different surface forms. A short question and a long descriptive paragraph come from different distributions to begin with. This is why some embedding models require separate query and document prefixes, and dropping that prefix costs visible performance.

The first and third are largely solved by mixing in lexical retrieval. BM25 is strong on exact string matches and assigns high weight to rare tokens. The most reliable way to merge the two result lists is reciprocal rank fusion, which uses only the ranks and never normalizes the scores.

def rrf(rankings, k=60, top_n=10):
    """rankings: per-retriever list of document IDs, in rank order. No need to align score scales."""
    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),     # lexical
    vector_search(query, k=50),   # semantic
])

The constant 60 is the value proposed in the original paper, and using it as-is in practice is generally fine. The advantage of this approach is that there is effectively only one value to tune; the disadvantage is that discarding score magnitudes means it cannot reflect a case where one retriever is overwhelmingly confident.

I will not claim hybrid always wins. If your domain has few proper nouns and identifiers and its sentences are mostly descriptive, pure vector retrieval may be better. But running two retrievers costs little and the implementation takes half a day, so trying it first and measuring is the reasonable order.

Reranking — when does it earn its keep

Reranking is the step where the first-stage retrieval pulls a generous candidate list and a more precise model reorders the top slice of it. A cross-encoder encodes the query and the document together and observes their interaction directly, so it is more accurate than making separate vectors for each.

The price is computation. Vector search encodes the query once, but a cross-encoder runs one forward pass per candidate.

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 varies enormously with model size, chunk length, hardware and whether you batch. The 1.2 milliseconds above is a value I put in for illustration, so be sure to measure and replace it in your own environment.

The criterion is this. Reranking only earns its keep when the first-stage retrieval does put the answer in the candidate list but ranks it low. That is, recall@50 is high while recall@5 is low. Conversely, if recall@50 itself is low, reranking can do nothing. You cannot promote something that is not there. So measure those two numbers before you even consider reranking, and keeping that order will spare you a pointless addition of latency.

Using it as a final filter before handing the whole context to the LLM is also valid. Dropping low-relevance chunks reduces input tokens and lowers the chance of the model being pulled toward irrelevant context.

Attaching context to chunks

If the text being embedded is only the chunk body, then that chunk is retrieved without knowing which part of which document it belongs to. A paragraph reading "the exceptions to clause 3 are as follows" has no way to be retrieved on its own.

The fix is simple. Prepend positional information to the text you embed.

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

# build both the embedding and the BM25 index over this text.
# but keep the same header in the context handed to the LLM so attribution is possible.

There are two reasons the effect is large. On the retrieval side, repeating the document title and section name in every chunk reinforces the topic signal. On the generation side, the model becomes able to tell documents apart, which reduces mistakes like blending the product A policy with the product B policy.

Going a step further, you can pre-generate one or two sentences summarizing the whole-document context for each chunk with an LLM and attach that. It is a trade: pay a per-document cost once at indexing time in exchange for retrieval quality. Note that this cost scales with the number of documents and is real money going out, so trying the simple header attachment first and only considering this when it falls short is the right order.

Metadata is both a retrieval target and a filter. Store date, version, department and document type as fields and you can cut off the "last year's policy got retrieved" class of failure with a filter. This does not work well as a ranking adjustment; only a filter makes it certain.

The golden set and recall@k — no improvement without measurement

Every change so far has to be reversible, and for that you need numbers. What you need is pairs of a question and the IDs of the gold evidence chunks.

The best way to build it is to pull from real user question logs. If you have no logs, generate questions from the documents with an LLM, but always have a human review them. Generated questions tend to copy the source wording verbatim, so an evaluation set made only of those is biased in favor of lexical retrieval. Be sure to mix in questions with rephrased wording, questions that require synthesizing several chunks, and questions the documents do not answer. That last kind is the only way to measure the ability to abstain.

def recall_at_k(dataset, retriever, k):
    """share of questions whose gold chunk landed in the top 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):
    """mean reciprocal rank of the first correct hit. Read alongside recall when rank matters."""
    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}")

Pull these two numbers per k and the diagnosis becomes mechanical. Low recall@50 means an index and chunking problem; high recall@50 with low recall@5 means a ranking problem; both high with wrong answers means a generation problem.

SymptomSuspected causeHow to checkFirst response
No evidence in the answer at allRetrieval failureGold chunk injection experimentChunking, hybrid
Gold chunk is not in the top ranksRanking problemCompare recall@50 with recall@5Reranking
Only one document cited repeatedlyExcessive overlapDuplication rate among top k chunksReduce overlap, diversify by document
Old version content citedMissing filterDate field of the cited chunkMetadata filter
Numbers inside tables are wrongTable splitInspect the chunk source directlyKeep tables whole as one chunk
Abstains on answerable questionsExcessive abstention instructionMeasure answer rate and accuracy togetherSoften the instruction, adjust the threshold

One caution. If this evaluation set is small, most of the differences you see in it are noise. Do not make a deployment decision on the result that recall rose from 0.70 to 0.73 across 30 questions. How to handle sample size and significance is covered in LLM evaluation without the vibes.

Closing — look at the retrieval results first

Half of RAG debugging ends at "actually read the retrieved chunks." Yet this is the step most often skipped, either because the pipeline was built without logging the context, or because fixing the prompt looks faster.

So two things are enough for today. Log the retrieved chunk IDs and their source text on every request, and build a 50-question golden set and measure recall@5 and recall@50. Without those two, every later improvement is repair work done blind; with them, the cause is usually narrowed down within an hour.