[고객지원 챗봇 만들기] 김수민 제출합니다.#4
Conversation
There was a problem hiding this comment.
Code Review
This pull request implements a RAG-based chatbot using Spring AI, featuring document loading and chunking for FAQs, policies, and chat logs, alongside a REST API for chat interactions. It also refactors Gradle environment variable loading and updates the Python evaluation script for detailed result tracking. Review feedback identifies a potential NullPointerException in response handling, suggests externalizing hardcoded file paths to configuration properties, and recommends implementing vector data caching to reduce application startup latency and API costs.
| return new ChatAnswerResponse( | ||
| response.getResult().getOutput().getText(), | ||
| new ChatAnswerResponse.TokenUsage( | ||
| usage == null || usage.getPromptTokens() == null ? 0 : usage.getPromptTokens(), | ||
| usage == null || usage.getCompletionTokens() == null ? 0 : usage.getCompletionTokens(), | ||
| usage == null || usage.getTotalTokens() == null ? 0 : usage.getTotalTokens() | ||
| ) | ||
| ); |
There was a problem hiding this comment.
LLM 응답 결과가 비어있을 경우 response.getResult()가 null을 반환하여 NullPointerException이 발생할 수 있습니다. 응답 존재 여부를 확인한 후 안전하게 텍스트를 추출하도록 개선이 필요합니다.
String answer = (response.getResult() != null && response.getResult().getOutput() != null)
? response.getResult().getOutput().getText()
: "고객센터에 문의해주세요.";
return new ChatAnswerResponse(
answer,
new ChatAnswerResponse.TokenUsage(
usage == null || usage.getPromptTokens() == null ? 0 : usage.getPromptTokens(),
usage == null || usage.getCompletionTokens() == null ? 0 : usage.getCompletionTokens(),
usage == null || usage.getTotalTokens() == null ? 0 : usage.getTotalTokens()
)
);| private static final Path FAQ_DIRECTORY = Path.of("data/layer1_faq"); | ||
| private static final Path CURRENT_POLICY_DIRECTORY = Path.of("data/layer2_policies/current"); | ||
| private static final Path CHATLOG_DIRECTORY = Path.of("data/layer3_chatlogs"); |
| ) | ||
| ); | ||
| } catch (IOException e) { | ||
| throw new UncheckedIOException("Failed to parse chatlog line in file: " + path.getFileName(), e); |
There was a problem hiding this comment.
|
|
||
| @PostConstruct | ||
| void loadFaqContext() { | ||
| vectorStore.add(documentLoader.load()); |
| if (!"correct".equals(root.path("agent_accuracy").asText())) { | ||
| return null; | ||
| } | ||
|
|
There was a problem hiding this comment.
return new Document(
"# Source Type: CHATLOG\n# Source: %s\n%s".formatted(path.getFileName(), line),
...
);line이 JSONL 원본 한 줄 통째인데, 즉 LLM 컨텍스트에 이렇게 들어가게 됩니다.
# Source Type: CHATLOG
# Source: ch_001.jsonl
{"conversation_id":"...","agent_accuracy":"correct","primary_intent":"refund","turns":[{"role":"customer","text":"..."}]}
문제:
- 토큰 낭비 — JSON 구문 토큰(
{"role":등)이 다 비용 - 메타 누출 —
conversation_id등 운영용 식별자가 LLM 입력에 노출 - 검색 품질 저하 — 임베딩 모델이 JSON 구문에 점수 분산
- LLM 혼란 — 자연어 답변을 만들어야 하는데 입력이 JSON
자연어 형태로 청크화하면 위 문제들이 한 번에 해결됩니다. 추가로 LLM 호출 직전에 user message를 log로 한 번 찍어보면 실제로 어떤 토큰이 들어가는지 확인할 수 있어요~
| @Component | ||
| @ConfigurationProperties(prefix = "app.rag") | ||
| public class RagProperties { | ||
|
|
There was a problem hiding this comment.
topK/splitRegex를 @ConfigurationProperties로 외부화하신 부분 너무 좋네요 👍
설정 가능한 변수 로 보는 시각이 깔려 있다는 신호로 보여요.
다만 한 칸 더 가본다면:
private int topK = 5; // 모든 layer 공통FAQ 4 / 정책 3 / 챗로그 2 같은 layer별 차등 topK 는 표현 못 합니다. 정책 문서가 챗로그 노이즈에 묻히지 않게 하려면 차등이 필요해요.
그리고 splitRegex 의 기본값이 코드에 박혀 있어서 application.yml에 명시 안 하면 변경이 보이지 않는 구조입니다. yml에 명시적으로 두는 게 "의도된 설계" 가 더 또렷이 보일 수 있다는 점도 같이 고민해보면 좋겠습니다~
| private final RagProperties ragProperties; | ||
| private final DocumentLoader documentLoader; | ||
|
|
||
| @PostConstruct |
There was a problem hiding this comment.
@PostConstruct
void loadFaqContext() {
vectorStore.add(documentLoader.load());
}이 구조의 트레이드오프:
ChatService가 답변 생성 과 임베딩 초기화 두 책임을 가짐 (SRP 약함)- 테스트에서
ChatService인스턴스 만들 때마다 vector add 실행 → OpenAI API 호출 비용 발생 가능 - 임베딩 실패 시
ChatServiceBean 생성 자체가 실패 → 서버 부팅 실패
"이 챗봇은 한 가지를 잘 하는가" 라는 질문을 ChatService에 던졌을 때 "답변 + 초기화 둘 다" 가 되는 게 분리가 필요한 신호일 수 있어요~
| """) | ||
| .user(""" | ||
| Customer question: | ||
| %s |
There was a problem hiding this comment.
system prompt가 ChatService에 인라인이네요. "고객센터에 문의해주세요" 라는 거절 규칙이 들어 있는 만큼 외부 파일로 분리하면 좋겠습니다.
외부화 시 장점:
- 운영자가 거절 문구를 PR로 수정 가능
- 톤 변경 (A/B) 자연스러움
- prompt 자체에 대한 회귀 테스트 가능 (
evaluate.py가 일부 역할)
별도로 — Customer question / Support context 가 영어인 게 의도된 것인지 한 번 확인해보세요. LLM은 한국어 user question + 영어 label을 어떻게 해석하는지에 따라 출력 언어가 흔들리는 경우가 있어요. 호출 직전에 advisor로 실제 입력을 log로 찍어보면 확인할 수 있습니다~
| new ChatAnswerResponse.TokenUsage( | ||
| usage == null || usage.getPromptTokens() == null ? 0 : usage.getPromptTokens(), | ||
| usage == null || usage.getCompletionTokens() == null ? 0 : usage.getCompletionTokens(), | ||
| usage == null || usage.getTotalTokens() == null ? 0 : usage.getTotalTokens() |
There was a problem hiding this comment.
Usage null 방어 좋네요 👍
usage == null || usage.getPromptTokens() == null ? 0 : usage.getPromptTokens()한 칸 더 가본다면, 아래 케이스도 같이 다뤄볼 수 있어요.
chatResponse자체가 null인 경우 (rate limit, network) — 현재는response.getResult().getOutput().getText()에서 NPE- vector store 검색 실패 —
similaritySearch가 예외 던지면 그대로 사용자에게 500 getResult()가 null인 경우
사용자에게 "일시 장애로 답변이 어렵습니다" 메시지를 명시적으로 돌려주는 분기 하나 추가하면, 운영자가 alert 잡는 데도 도움이 될 수 있어요. 더 나아가 에러를 LLM이 다음 결정에 활용 가능한 형태로 만드는 사고 도 한 번 고민해보면 좋겠습니다!
| > 시간이 더 있었다면 시도해보고 싶은 개선점을 적어주세요. | ||
|
|
||
| - | ||
| - 임베딩과 벡터 검색의 원리가 궁금합니다. 제공되는 힌트를 보니까 cosine similarity, 벡터 차원 등의 키워드가 있던데 아직 잘 모릅니다... |
There was a problem hiding this comment.
해당 부분은 수학적인 개념이 깊게 필요한 부분이라 제가 설명드리기 어려울 것 같고... (저도 모름 ㅠㅠ)
제가 학습할 때 참고한 영상들 아래 링크로 올려둘게요~
- 3Blue1Brown — Dot products and duality (10분 영상) https://www.youtube.com/watch?v=LyGKycYT2v0 (내적을 "한 벡터를 다른 벡터 위에 투영한 길이"로 시각화. 수식 없이 기하학적 직관부터 잡아준다.)
- betterexplained — Vector Calculus: Understanding the Dot Product https://betterexplained.com/articles/vector-calculus-understanding-the-dot-product/ ("두 벡터가 얼마나 같은 방향으로 일하는가(work)"라는 물리적 직관. 그림 중심 설명.)
- Khan Academy — Dot Product (선형대수 코스) https://www.khanacademy.org/math/linear-algebra/vectors-and-spaces/dot-cross-products/v/vector-dot-product-and-vector-length (공식 유도 + 연습 문제. dot(a,b) = |a||b|cos(θ)의 관계를 단계별로 증명.)
- 3Blue1Brown — Essence of Linear Algebra (Chapter 1-3) https://www.youtube.com/playlist?list=PLZHQObOWTQDPD3MizzM2xVFitgF8hE_ab (벡터가 뭔지, 벡터의 덧셈/스칼라곱이 기하학적으로 뭘 의미하는지. 처음 3개 영상(30분)이면 충분. 선형대수 직관을 잡는 최고의 자료.)
- Vicki Boykis — What are embeddings? https://vickiboykis.com/what_are_embeddings/ (ML 엔지니어가 쓴 임베딩 deep dive. "벡터 공간에 텍스트를 매핑한다"는 것이 실제로 무슨 뜻인지, 역사와 함께 설명.)
- Normconf — WTF are embeddings? (Josh Starmer) https://www.youtube.com/watch?v=wjZofJX0v4M (임베딩 벡터가 무엇이고, 두 벡터의 "가까움"이 왜 유사도인지를 코사인 유사도 중심으로 설명. 수식 최소화, 그림 중심.)
- StatQuest — Cosine Similarity (6분 영상) https://www.youtube.com/watch?v=e9U0QAFbfLI (2D 좌표에서 각도가 줄어들수록 유사도가 올라가는 것을 시각적으로 보여줌. 유클리드 거리와의 차이도 다룸.)
- Wikipedia — Cosine similarity (수식 섹션만) https://en.wikipedia.org/wiki/Cosine_similarity (공식 정의, 각도와 유사도의 관계, 다른 거리 측정과의 비교.)
- Pinecone — Chunking Strategies https://www.pinecone.io/learn/chunking-strategies (고정 크기, 구분자, 시맨틱 청킹을 비교. 그림과 코드 예시 포함.)
- LangChain — Text Splitters https://python.langchain.com/docs/concepts/text_splitters/ (Python이지만 청킹 전략의 분류와 trade-off를 가장 체계적으로 설명. Spring AI의 TokenTextSplitter와 동일한 개념.)
| - incorrect하다고 판단한 원인을 좀 더 알아보고 싶습니다. 로깅을 통해 어떤 chunk가 검색되었는지는 확인할 수 있지만, 오답이 검색 실패 때문인지, 검색된 문서를 | ||
| 충분히 활용하지 못한 답변 생성 문제인지 모르겠습니다. 그래서 더 어떤 시도를 해야하는지 답답했던 것 같습니다.(제가 늦게 참여해서 그런 걸까요..?) |
There was a problem hiding this comment.
"어떤 시도를 해야 할지 답답했다" 라는 것은 적용할 때 어떤 것을, 어떻게, 왜 하는지에 대한 명확한 기준이 없었기 때문일 확률이 높을 것 같은데요.
프롬프트, topK, chunking, chat log 데이터를 "하나씩 해봤는데 변화가 없었다"고 하셨는데 어떤 것이 문제인지 모르는 상태에서 적용하다 보면 효과가 있는 변경과 없는 변경이 서로 상쇄돼서 "변화 없음"으로 보일 수도 있어요.
먼저 "검색 실패인지 생성 실패인지 구분하는 방법" 자체가 해당 미션의 중요한 포인트기 때문에, 인지했다는 것 만으로도 가치가 있다 생각이 들고,
해당 영역을 어떻게 해볼 수 있을지를 더 깊게 학습하시면 좋을 것 같아요!
|
|
||
| > 추가로 궁금한 것 | ||
|
|
||
| - 점수 계산은 어떤 것을 기준으로 하는지 궁금합니다. 점수가 높을 수록 실제 사용감이 좋아지는건지 궁금합니다. |
There was a problem hiding this comment.
이것이 보예가 설정하고 개선할 핵심입니다 ㅋㅋ
LLM 자체는 불확실성을 기반으로 하기 때문에 기존 개발과 같이 결정론적(deterministic) 사고를 하면 어려울 수 있어요.
측정과 개선 또한 보예의 가설을 세우고 측정하고 개선하는 방향으로 접근해보길 권장드려요!
내가 만든 챗봇의 목적을 정의하고, 목적을 기반으로 기준을 세우고, 그 기준으로 측정하며 개선 하기!
구현 내용
ChatClient를 사용해/api/chat에서 사용자 질문에 대한 답변을 생성하도록 구현했습니다.DocumentLoader에서 읽고 Spring AIDocument로 변환했습니다.###, 정책은##기준으로 chunking하고, 상담 로그는 JSONL을ObjectMapper로 파싱해agent_accuracy=correct인 데이터만 사용했습니다.promptTokens,completionTokens,totalTokens를 포함했습니다.