RAG에서 검색된 문단에서 근거 있는 답변까지

이 글에는 짧은 영상이 있습니다:YouTubeTikTok

1. 개요

Retrieval-Augmented Generation (RAG)는 보통 “가장 가까운 top-k 개 문단 찾기” 단계에서 멈춰 그대로 prompt에 집어넣습니다. 이 방식은 문제가 있습니다. vector search는 항상 가장 가까운 무언가를 반환하는데, 심지어 어떤 문단도 질문에 제대로 답할 수 없을 때도 그렇습니다. 이 글에서는 그 다음 단계를 자세히 들어갑니다: hybrid search, reranking, 그리고 데이터가 충분하지 않을 때 거절하는 메커니즘을 통해 후보(candidates)를 신뢰할 수 있는 근거(evidence)로 변환하는 방법입니다. 다 읽으면 Spring Boot로 이 모든 단계를 다루는 작은 파이프라인을 만들 수 있고, 동작을 검증하는 테스트도 함께 나옵니다.

2. 문제점: 가장 가까운 것이 반드시 맞는 건 아닙니다

비행기 취소 시 환불 정책을 찾아주는 챗봇이 있다고 가정해 봅시다. vector search는 질문과 가장 가까운 30개 passage를 항상 반환할 텐데, collection에 취소된 항공편에 대한 문단이 없다면 어떻게 될까요? 시스템이 그 3개 문단을 그대로 LLM에 넘기면, 모델은 호텔 예약에 관한 일반적인 문단들을 바탕으로 “그럴듯한” 답변을 지어냅니다.

두 가지 문제를 해결해야 합니다:

  1. 선별: 수십 개 후보 중 어떤 문단이 실제로 질문에 도움이 되는가.
  2. 통제된 거절: 충분히 좋은 문단이 없으면, 추측하지 말고 “찾지 못했습니다”라고 말하기.

3. 전체 파이프라인

질문
  → Vector search (semantic)         ─┐
  → Keyword search (BM25/exact match) ├─→ 병합 (hybrid) → Rerank → 신뢰도 임계값 → 근거 → LLM

각 단계는 후보 개수를 줄이고 품질을 올립니다: 수십 개의 거친 후보에서 출처 표시가 있는 명확한 근거 몇 개로 좁혀집니다.

4. 기본 데이터 모델

public record Passage(
        String id,
        String text,
        String source,
        double score
) {
    public Passage withScore(double newScore) {
        return new Passage(id, text, source, newScore);
    }
}

Passage는 인덱싱된 텍스트 조각을 나타내고, source는 출처 표시용, score는 비교/정렬용입니다. withScore는 각 rerank 단계마다 원본을 변경하지 않고 새 점수로 복사본을 만드는 데 도움이 됩니다.

5. Hybrid search: semantic과 keyword 결합

Embedding은 의미 연결에 좋습니다 (예: “비행기 취소” ↔ “이동 중단”), 하지만 정책 코드 POL-2024-07이나 고유명사 같은 정확한 토큰은 놓치기 쉽습니다. Keyword search (BM25)는 이 약점을 보완합니다.

public interface VectorSearchClient {
    List<Passage> search(String query, int topK);
}

public interface KeywordSearchClient {
    List<Passage> search(String query, int topK);
}

@Service
public class HybridSearchService {

    private final VectorSearchClient vectorClient;
    private final KeywordSearchClient keywordClient;

    public HybridSearchService(VectorSearchClient vectorClient,
                                KeywordSearchClient keywordClient) {
        this.vectorClient = vectorClient;
        this.keywordClient = keywordClient;
    }

    public List<Passage> search(String query) {
        List<Passage> semantic = vectorClient.search(query, 20);
        List<Passage> keyword = keywordClient.search(query, 20);

        Map<String, Passage> merged = new LinkedHashMap<>();
        for (Passage p : semantic) {
            merged.put(p.id(), p);
        }
        for (Passage p : keyword) {
            merged.merge(p.id(), p, (existing, incoming) ->
                    existing.withScore(existing.score() + incoming.score()));
        }
        return new ArrayList<>(merged.values());
    }
}

한 passage가 두 소스 모두에서 나타나면 점수를 합산합니다 — 이건 그 문단이 의미로도, 정확한 키워드로도 일치한다는 강력한 신호입니다. 결과는 병합된 후보 리스트이며, 아직 최종 유용성으로 정렬되지 않았습니다.

6. Reranking: 쌍 비교 모델로 다시 필터링

vector search는 질문과 각 문단을 독립적으로 비교하므로 (bi-encoder), 깊은 관계를 “읽지” 못합니다. Reranker (보통 cross-encoder)는 질문과 문단을 동시에 받아 더 정확하게 채점하지만 훨씬 느립니다 — 따라서 이미 거친 필터링을 통과한 작은 후보 집합에만 적용합니다.

public interface Reranker {
    List<Passage> rerank(String query, List<Passage> candidates, int topN);
}

@Component
public class CrossEncoderReranker implements Reranker {

    private final CrossEncoderClient client; // 외부 rerank 모델 호출

    public CrossEncoderReranker(CrossEncoderClient client) {
        this.client = client;
    }

    @Override
    public List<Passage> rerank(String query, List<Passage> candidates, int topN) {
        return candidates.stream()
                .map(p -> p.withScore(client.relevanceScore(query, p.text())))
                .sorted(Comparator.comparingDouble(Passage::score).reversed())
                .limit(topN)
                .toList();
    }
}

client.relevanceScore는 질문과 각 문단 사이의 실제 관련도 점수 (예: 0–1)를 반환합니다. 내림차순으로 정렬한 후 상위 topN개만 유지합니다 — 보통 3~5개로, prompt에 넣기에 충분히 짧으면서 맥락을 희석하지 않습니다.

주의: 수천 개의 전체 passage를 rerank하지 마세요 — cross-encoder는 vector search보다 수십수백 배 느립니다. 입력 후보(2050)를 항상 제한한 후 rerank하세요.

7. LLM을 위한 근거와 출처 구성

rerank 후 몇 개의 고품질 문단이 생깁니다. 다음 단계는 이들을 명확하고 출처가 있는 context로 패킹하는 것입니다. LLM이 추측 대신 출처를 인용하면서 답할 수 있도록 말입니다.

@Component
public class EvidenceBuilder {

    public String build(List<Passage> topPassages) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < topPassages.size(); i++) {
            Passage p = topPassages.get(i);
            sb.append("[출처 %d - %s]%n%s%n%n"
                    .formatted(i + 1, p.source(), p.text()));
        }
        return sb.toString();
    }
}

각 문단에는 번호와 출처명 (source)이 붙습니다. system prompt에 삽입할 때 LLM에 이 문단들만 바탕으로 답하고 해당 출처 번호를 인용하도록 지시합니다 — 사용자가 추측 대신 근거를 확인할 수 있습니다.

[출처 1 - policy-2024-07.pdf]
직원은 악천후로 인해 항공편이 취소되면 발생한 호텔 비용의 100%를 환급받습니다...

[출처 2 - policy-2024-07.pdf]
적용 조건: 항공편이 반드시 취소되어야 하며, 6시간 미만의 지연에는 적용되지 않습니다...

8. 답변이 없는 경우 처리

이 부분을 가장 많이 간과합니다. 모든 collection은 “가장 가까운 이웃”을 갖고 있습니다 — 어떤 문단도 질문에 정말로 답하지 못할 때도 말입니다. 확인하지 않으면 LLM은 관계없는 context를 활용해 “그럴듯한” 답변을 추론합니다.

간단한 해결책: rerank 최고 점수를 기반으로 신뢰도 임계값(confidence threshold)을 설정합니다.

@Service
public class AnswerService {

    private static final double CONFIDENCE_THRESHOLD = 0.55;

    private final HybridSearchService searchService;
    private final Reranker reranker;
    private final EvidenceBuilder evidenceBuilder;
    private final LlmClient llmClient;

    public AnswerService(HybridSearchService searchService,
                          Reranker reranker,
                          EvidenceBuilder evidenceBuilder,
                          LlmClient llmClient) {
        this.searchService = searchService;
        this.reranker = reranker;
        this.evidenceBuilder = evidenceBuilder;
        this.llmClient = llmClient;
    }

    public String answer(String question) {
        List<Passage> candidates = searchService.search(question);
        List<Passage> topPassages = reranker.rerank(question, candidates, 4);

        boolean hasEvidence = !topPassages.isEmpty()
                && topPassages.get(0).score() >= CONFIDENCE_THRESHOLD;

        if (!hasEvidence) {
            return "이 질문에 명확하게 답변해 주는 정책을 찾지 못했습니다. "
                    + "정확한 정보는 인사 부서에 문의하세요.";
        }

        String context = evidenceBuilder.build(topPassages);
        return llmClient.generate(question, context);
    }
}

rerank 후 최고 점수 문단이 CONFIDENCE_THRESHOLD보다 낮으면, LLM을 호출해 근거 없는 답변을 만드는 대신 거절합니다. 구체적인 임계값은 실제 데이터로 조정해야 합니다 — 너무 낮으면 환각이, 너무 높으면 답할 수 있는 질문도 거절합니다.

9. 동작 검증 테스트

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter</artifactId>
    <scope>test</scope>
</dependency>
<dependency>
    <groupId>org.assertj</groupId>
    <artifactId>assertj-core</artifactId>
    <scope>test</scope>
</dependency>
class AnswerServiceTest {

    @Test
    void shouldAbstainWhenBestScoreBelowThreshold() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage weakMatch = new Passage("p1", "일반적인 호텔 예약 규정",
                "hotel-faq.pdf", 0.3);

        when(search.search(anyString())).thenReturn(List.of(weakMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(weakMatch));

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("비행기 취소 시 환불은 어떻게 되나요?");

        assertThat(result).contains("찾지 못했습니다");
        verifyNoInteractions(llm);
    }

    @Test
    void shouldAnswerWhenConfidenceIsHigh() {
        HybridSearchService search = mock(HybridSearchService.class);
        Reranker reranker = mock(Reranker.class);
        EvidenceBuilder builder = new EvidenceBuilder();
        LlmClient llm = mock(LlmClient.class);

        Passage strongMatch = new Passage("p2", "비행기가 취소되면 비용의 100%를 환급합니다",
                "policy-2024-07.pdf", 0.82);

        when(search.search(anyString())).thenReturn(List.of(strongMatch));
        when(reranker.rerank(anyString(), anyList(), anyInt()))
                .thenReturn(List.of(strongMatch));
        when(llm.generate(anyString(), anyString())).thenReturn("100% 환급을 받을 수 있습니다 [출처 1].");

        AnswerService service = new AnswerService(search, reranker, builder, llm);
        String result = service.answer("비행기 취소 시 환불은 어떻게 되나요?");

        assertThat(result).contains("출처 1");
        verify(llm).generate(anyString(), anyString());
    }
}

첫 번째 테스트는 점수가 낮을 때 거절하는 동작을 확인합니다 — LLM이 호출되지 않아 비용을 절약하고 환각을 피합니다. 두 번째 테스트는 강력한 근거가 있을 때 LLM이 호출되고 답변이 출처를 인용하는지 확인합니다.

10. 흔한 실수들

  • hybrid search를 건너뛰고 순수 vector만 사용: 정책 코드, 고유명사 같은 정확한 매칭을 놓치기 쉽습니다 — embedding이 이런 것들을 잘 구분하지 못합니다.
  • 전체 데이터셋을 rerank하기: 걸러진 부분 집합 대신 모든 데이터를 rerank하면 불필요하게 느리고 비쌉니다.
  • 신뢰도 임계값을 설정하지 않기: context가 무관해도 항상 답변하게 되고, LLM이 자신감 있게 거짓 정보를 제시하기 때문에 사용자는 잘 못 알아챕니다.
  • context가 너무 길기: 3~5개 rerank된 문단 대신 원본 30개를 모두 prompt에 집어넣으면 신호가 희석되고 토큰 비용이 늘어납니다.

참고: 도메인에 안정적인 어휘와 특수 키워드가 적다면 (예: 일반 대화형 채팅), hybrid search가 중복될 수 있습니다 — 순수 vector search도 충분하고 운영이 더 간단합니다.

11. 결론

RAG는 retrieval 단계가 올바르게 멈출 때만 신뢰할 수 있습니다: semantic과 keyword 결과를 병합하고, rerank해서 정말 유용한 문단을 선택하고, 근거가 부족하면 거절하기. 이 글의 작은 파이프라인 — HybridSearchService, Reranker, EvidenceBuilder, AnswerService — 은 실제 모델 (embedding, BM25 index, cross-encoder)을 연결할 수 있는 골격입니다. 그러면 글자 맞추는 기계가 아니라 출처를 인용하고 정직한 조수를 얻을 수 있습니다.

RAGLLMSpring BootsearchJava