RAG에서 검색된 문단에서 근거 있는 답변까지
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에 넘기면, 모델은 호텔 예약에 관한 일반적인 문단들을 바탕으로 “그럴듯한” 답변을 지어냅니다.
두 가지 문제를 해결해야 합니다:
- 선별: 수십 개 후보 중 어떤 문단이 실제로 질문에 도움이 되는가.
- 통제된 거절: 충분히 좋은 문단이 없으면, 추측하지 말고 “찾지 못했습니다”라고 말하기.
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)을 연결할 수 있는 골격입니다. 그러면 글자 맞추는 기계가 아니라 출처를 인용하고 정직한 조수를 얻을 수 있습니다.