[고객지원 챗봇 만들기] 김준영 제출합니다.#2
Conversation
- 우선 vip 베송 기준에 대한 질문에 답할 수 있게끔만 구현하여 구조를 잡는다.
- QuestionAnswerAdvisor 사용을 위한 의존성 추가
- spring ai가 OpenAiEmbeddingModel을 스프링 빈으로 관리한다
- md 파일을 Document로 변환하기 위한 라이브러리
- MarkdownReader에서 classpath로 쉽게 읽어오기 위해
- MarkdownReader로 md를 변환한 Document를 벡터 저장소에 저장. - 애플리케이션 실행하면 빈 생성 과정에서 SimpleVectorStore가 각 document에 대해 EmbeddingModel 호출하는 것 확인 가능
- 기존에는 string 반환해 plain/text
- 컨텍스트에 포함할 문서 개수를 늘려 핵심 사실을 포함할 수 있도록 수정
- 참고한 k개 문서 정보 로그로 확인 가능
- 8개(topK) 문서 중 deprecated 문서가 섞여들어가 컨텍스트가 오염되고, 이로 인해 불필요한 정보 포함 및 정작 중요한 정보는 활용 못하는 문제 해결
There was a problem hiding this comment.
Code Review
This pull request implements a Spring AI-based chatbot system using Retrieval-Augmented Generation (RAG). Key additions include a ChatbotController that utilizes a QuestionAnswerAdvisor and SimpleVectorStore, a MarkdownReader for ingesting FAQ and policy documents from the classpath, and a comprehensive knowledge base consisting of various markdown and chat log files. Feedback was provided regarding the /debug endpoint in ChatbotController, which currently returns a hardcoded string instead of the actual AI-generated content.
- 자주 묻는 질문 채팅창 내로 이동 - 기본 인사 및 안내 제공
| .map(filename -> { | ||
| try { | ||
| return resolver.getResources("classpath:data/**/" + filename)[0]; | ||
| } catch (IOException e) { |
There was a problem hiding this comment.
이 부분이 핵심인 것 같아서 자세히 남길게요.
return documents.stream()
.map(document -> document.getMetadata().get("filename").toString())
.distinct()
.map(filename -> {
try {
return resolver.getResources("classpath:data/**/" + filename)[0];
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.map(TextReader::new)
.flatMap(reader -> reader.get().stream())
.toList();흐름:
- similaritySearch가 top-4 청크 를 반환
- 청크에서 filename 추출 → distinct
- 해당 파일을 classpath에서 다시 통째로 읽음
- 파일 전체를 LLM 컨텍스트에 보냄
즉 검색은 "어떤 파일이 관련 있는가" 까지만 알려주고, LLM 입력은 4개 파일 전체가 됩니다.
문제:
- 청킹 효과 무효화 — splitter로 작은 청크를 만든 의미가 사라짐
- 토큰 폭발 —
return-policy-v3.md60줄 × 4파일 = 200+줄을 매번 LLM에 보냄 - 위치 효과 손실 — LLM은 입력의 시작과 끝을 더 강하게 참조하는데 (중간이 묻힘), 정책 본문 절반이 중간에 묻힐 가능성
- 비용 — input token 청구 폭증
에 대해 고민해보면 좋겠습니다!
| VectorStore vectorStore, | ||
| MarkdownReader markdownReader, | ||
| ChatClient.Builder chatClientBuilder | ||
| ) { |
There was a problem hiding this comment.
public ChatbotService(...) {
...
vectorStore.add(markdownReader.loadAll());
...
}생성자에서 임베딩 호출 부수효과가 있어요. 트레이드오프:
- 부팅 지연 — 첫 ChatbotService Bean 생성 시점에 모든 임베딩 호출
- 테스트 어려움 —
new ChatbotService(...)가 매번 임베딩 호출 → OpenAI quota 소모 - 부팅 실패 가능성 — 임베딩 API 일시 장애 시 서버가 못 뜸
- 책임 분리 약함 — 답변 생성 과 임베딩 초기화 두 책임
분리 방향)
- (a)
VectorStoreInitializer implements ApplicationRunner로 별도 컴포넌트 - (b)
@Bean ApplicationRunner로 Config에서 분리
"생성과 초기화를 같은 자리에 두지 마라" 라는 원칙, 한 번 고민해보면 좋겠습니다~
| } catch (IOException e) { | ||
| throw new RuntimeException(e); | ||
| } | ||
| }) |
There was a problem hiding this comment.
정보 없는 throw 안티패턴이 보이네요.
throw new RuntimeException(e);운영 시점에 이 코드가 발화하면 아래와 같은 문제가 있습니다.
- 어떤 filename이 실패했는지 메시지에 없음 → 디버깅 위해 stack trace + 코드 추적 필요
- 사용자에게는 500 에러만 가고 왜 실패했는지 알 길 없음
최소한 아래와 같이 정보를 담아주면 좋겠습니다.
throw new IllegalStateException("문서 로드 실패: " + filename, e);에러 메시지도 의미 있는 정보 여야 한다는 원칙, 한 번 참고해주세요~
| %s | ||
| """.formatted(request.question(), context)) | ||
| .call() | ||
| .chatResponse(); |
There was a problem hiding this comment.
system prompt 너무 잘 짜셨네요 👍 특히 아래 부분이 인상적이에요.
- 내용이 충돌하는 경우 다음 우선순위를 따라 답변 합니다.
- 질문 도메인과 가장 근접한 내용
- 더 구체적인 상황을 다루는 내용
- 더 최신 버전의 내용
이 규칙은 코드 가 아니라 프롬프트 에 표현한 점이 좋습니다 — 정책 결정을 LLM과 공유하는 자연스러운 자리예요.
다만 위치가 인라인 String이라:
- 운영자가 우선순위를 수정하려면 컴파일·빌드·배포
- 우선순위 v1 vs v2 비교가 자연스럽지 않음
- diff에서 "왜 바꿨는지" 안 보임
외부 .st 분리하는 구조로 개선해보면 어떨까요?
@Value("classpath:prompts/faq-system.st")
private Resource systemPrompt;|
|
||
| @GetMapping({"/", "/chat"}) | ||
| public String chat(Model model) { | ||
| model.addAttribute("pageTitle", "초록 고객지원 챗봇"); |
There was a problem hiding this comment.
웹 UI를 직접 구현하신 부분 너무 좋네요 👍
다만 initialMessage / quickPrompts 가 컨트롤러에 인라인 String이라, 아래와 같은 확장 시 복붙이 발생할 수 있어요.
- 다른 채널(예: 슬랙 봇)에서 같은 "환영 메시지"를 쓰려면 코드 복붙
- 이메일에서 "운영시간 안내" 를 같이 보내려면 또 복붙
- i18n (영어 페이지) 추가 시 또 복붙
챗봇 본체를 채널-무관 인터페이스로 두고, 채널별 어댑터가 각자의 환영 메시지를 가져가는 형태로 한 번 그려보면 어떨까요? 예시)
interface ChatChannel {
String welcomeMessage();
List<String> quickPrompts();
void send(String userId, String message);
}| SearchRequest searchRequest = getSearchRequest(request.question(), 4); | ||
| List<Document> documents = vectorStore.similaritySearch(searchRequest); | ||
|
|
||
| // 증강 & 생성 |
There was a problem hiding this comment.
String answer = chatResponse.getResult().getOutput().getText();
Usage usage = chatResponse.getMetadata().getUsage();아래 케이스에서 NPE → 사용자에게 500이 갈 가능성이 있어요.
chatResponse가 null인 경우 (rate limit, network error)getResult()가 null인 경우getUsage()가 null인 경우 (provider별 가능)
방어적 코드 추가도 좋고, 더 나아가 "LLM 호출 자체가 실패했을 때 어떤 응답을 사용자에게 보낼지" 를 명시적으로 분기하는 것도 한 번 고민해보면 좋겠습니다. "일시적 장애로 답변이 어렵습니다" 같은 명시적 응답이 운영자 alert 잡는 데도 도움이 될 수 있어요!
진행도 공유 차원에서 pr 미리 만들어두었습니다!
챗봇 사용
애플리케이션 실행 후 챗봇 url로 접속합니다.
