RAG LLM PDF 분석 서비스 'DocWeave' 개발 과정에서의 트러블슈팅
이번 글에서는 'DocWeave' 프로젝트를 진행하며 가장 고민했던 부분인 'LLM 응답의 신뢰성 확보'에 대해 이야기하고자 합니다.
우리는 흔히 LLM을 '똑똑한 AI'라고 생각하지만, 개발자의 관점에서 LLM은 '매우 불안정한 외부 API'일 뿐입니다. 같은 입력에도 매번 다른 출력을 내뱉을 수 있고, 때로는 제공된 문서를 무시하고 거짓된 응답(Hallucination)을 하기도 합니다.
이러한 불확실성을 통제하기 위해 DocWeave에 적용한 Validation(응답 검증) 로직과 그 구현 과정을 소개하고자 합니다.
RAG 아키텍처에서 LLM의 역할은 명확합니다. DB에서 찾아준 Context를 바탕으로 사용자의 질문에 답하는 것입니다. 하지만 실제 테스트를 진행해 보면 다음과 같은 문제들이 발생합니다.
서비스 품질을 위해 "잘못된 답변을 하느니, 차라리 답변을 거부하는 게 낫다"는 결론을 내렸습니다. 따라서 LLM이 생성한 답변을 사용자에게 보여주기 전에 검사하는 Guardrail(가드레일) 단계가 필수적이었습니다.
저는 Validation 로직을 규칙 기반(Rule-based)과 의미 기반(Semantic-based) 두 단계로 나누어 설계했습니다.
private boolean validateResponse(String context, String answer) {
// 1. 규칙 기반 필터링 (Fast Fail)
if (answer.contains("제공된 문서에서 해당 내용을 찾을 수 없습니다")) return true;
if (answer.length() < 5) return false;
try {
// 2. 의미 기반 필터링 (Semantic Check)
float[] contextVector = embeddingModel.embed(context);
float[] answerVector = embeddingModel.embed(answer);
double similarity = cosineSimilarity(contextVector, answerVector);
// 임계값(0.4) 이상일 때만 통과
return similarity >= SIMILARITY_THRESHOLD;
} catch (Exception e) {
log.error("Similarity Calculation Failed", e);
return true; // 에러 발생 시에는 관대하게 허용 (Fail Open)
}
}
가장 먼저 수행하는 것은 단순 문자열 검사입니다.
해당 과정에서는 연산 비용이 거의 들지 않으므로 가장 먼저 배치하여 불필요한 연산을 방지합니다.
규칙 기반 검사를 통과했다면, 이제 "이 답변이 정말로 제공된 문서(Context)에 기반한 것인가?"를 검증해야 합니다. 여기서 텍스트 임베딩(Embedding) 기술을 활용합니다.
// 코사인 유사도 계산 로직
private double cosineSimilarity(float[] v1, float[] v2) {
// ... (벡터 내적 및 정규화 연산) ...
return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB));
}
만약 LLM이 문서 내용을 무시하고 엉뚱한 소리를 했다면, 문서 벡터와 답변 벡터 사이의 거리가 멀어질 것입니다. 저는 여러 테스트를 통해 SIMILARITY_THRESHOLD = 0.4라는 임계값을 설정했습니다. 유사도가 0.4 미만이면 문서에 근거하지 않은 답변으로 간주하여 GuardrailException을 발생시킵니다.
// 1. LLM에게 답변 생성 요청
String rawAnswer = chatClient.prompt(prompt).call().content();
// 2. 가드레일 검증 적용
log.info("Validating answer quality for room: {}", roomId);
boolean isValid = validateResponse(context, rawAnswer);
if (!isValid) {
log.warn("Guardrail validation failed. Input: {}", requestDto.getMessage());
throw new GuardrailException(ErrorCode.GUARDRAIL_BLOCKED);
}
// 3. 검증 통과 시에만 DB 저장 및 반환
chatMessageRepository.save(...);
return ChatResponseDto.builder().answer(rawAnswer).build();
현재는 안정성을 최우선으로 하여 동기적으로 검증을 수행하고 있지만, 추후에는 검증 로직을 비동기로 처리하거나 경량화된 Cross-Encoder 모델을 도입하여 속도를 개선할 계획입니다.
LLM을 사용하는 서비스에서 "답변을 하는 것"보다 더 중요한 것은 "틀린 답변을 하지 않는 것"이라고 생각합니다. 현재의 검증 로직은 매우 간소화되어 있기 때문에, 향후 이를 더욱 고도화할 계획입니다.