Skip to content

[고객지원 챗봇 만들기] 김수민 제출합니다.#4

Open
boyekim wants to merge 7 commits into
cho-log:mainfrom
boyekim:boyekim
Open

[고객지원 챗봇 만들기] 김수민 제출합니다.#4
boyekim wants to merge 7 commits into
cho-log:mainfrom
boyekim:boyekim

Conversation

@boyekim
Copy link
Copy Markdown
Member

@boyekim boyekim commented May 20, 2026

구현 내용

  • Spring AI ChatClient를 사용해 /api/chat에서 사용자 질문에 대한 답변을 생성하도록 구현했습니다.
  • FAQ, 현행 정책, 상담 로그 데이터를 DocumentLoader에서 읽고 Spring AI Document로 변환했습니다.
  • FAQ는 ###, 정책은 ## 기준으로 chunking하고, 상담 로그는 JSONL을 ObjectMapper로 파싱해 agent_accuracy=correct인 데이터만 사용했습니다.
  • 응답에는 답변과 함께 promptTokens, completionTokens, totalTokens를 포함했습니다.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +67 to +74
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()
)
);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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()
            )
        );

Comment on lines +21 to +23
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");
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

데이터 디렉토리 경로가 하드코딩되어 있습니다. 운영 환경이나 테스트 환경에 따라 경로가 달라질 수 있으므로, RagProperties를 통해 application.yml에서 관리하도록 수정하는 것을 권장합니다.

)
);
} catch (IOException e) {
throw new UncheckedIOException("Failed to parse chatlog line in file: " + path.getFileName(), e);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

상담 로그 파일의 특정 라인이 유효하지 않은 JSON일 경우 UncheckedIOException이 발생하며 애플리케이션 시작이 중단됩니다. 데이터의 결함으로 인해 전체 시스템이 마비되지 않도록, 오류 발생 시 해당 라인을 건너뛰고 로그를 남기는 방식이 더 안전합니다.

Suggested change
throw new UncheckedIOException("Failed to parse chatlog line in file: " + path.getFileName(), e);
return null;


@PostConstruct
void loadFaqContext() {
vectorStore.add(documentLoader.load());
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

현재 구조는 애플리케이션이 시작될 때마다 모든 문서를 다시 읽고 임베딩하여 VectorStore에 추가합니다. 이는 시작 시간을 지연시키고 불필요한 API 비용을 발생시킵니다. SimpleVectorStoresave/load 기능을 사용하여 벡터 데이터를 파일로 캐싱하거나, 영구 저장소 기반의 VectorStore 도입을 고려해 보세요.

Copy link
Copy Markdown
Contributor

@jaeyeonling jaeyeonling left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

고생 많으셨습니다 : )

if (!"correct".equals(root.path("agent_accuracy").asText())) {
return null;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@PostConstruct
void loadFaqContext() {
    vectorStore.add(documentLoader.load());
}

이 구조의 트레이드오프:

  • ChatService답변 생성임베딩 초기화 두 책임을 가짐 (SRP 약함)
  • 테스트에서 ChatService 인스턴스 만들 때마다 vector add 실행 → OpenAI API 호출 비용 발생 가능
  • 임베딩 실패 시 ChatService Bean 생성 자체가 실패 → 서버 부팅 실패

"이 챗봇은 한 가지를 잘 하는가" 라는 질문을 ChatService에 던졌을 때 "답변 + 초기화 둘 다" 가 되는 게 분리가 필요한 신호일 수 있어요~

""")
.user("""
Customer question:
%s
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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이 다음 결정에 활용 가능한 형태로 만드는 사고 도 한 번 고민해보면 좋겠습니다!

Comment thread mission/wall-report.md
> 시간이 더 있었다면 시도해보고 싶은 개선점을 적어주세요.

-
- 임베딩과 벡터 검색의 원리가 궁금합니다. 제공되는 힌트를 보니까 cosine similarity, 벡터 차원 등의 키워드가 있던데 아직 잘 모릅니다...
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

해당 부분은 수학적인 개념이 깊게 필요한 부분이라 제가 설명드리기 어려울 것 같고... (저도 모름 ㅠㅠ)
제가 학습할 때 참고한 영상들 아래 링크로 올려둘게요~

Comment thread mission/wall-report.md
Comment on lines +70 to +71
- incorrect하다고 판단한 원인을 좀 더 알아보고 싶습니다. 로깅을 통해 어떤 chunk가 검색되었는지는 확인할 수 있지만, 오답이 검색 실패 때문인지, 검색된 문서를
충분히 활용하지 못한 답변 생성 문제인지 모르겠습니다. 그래서 더 어떤 시도를 해야하는지 답답했던 것 같습니다.(제가 늦게 참여해서 그런 걸까요..?)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"어떤 시도를 해야 할지 답답했다" 라는 것은 적용할 때 어떤 것을, 어떻게, 왜 하는지에 대한 명확한 기준이 없었기 때문일 확률이 높을 것 같은데요.
프롬프트, topK, chunking, chat log 데이터를 "하나씩 해봤는데 변화가 없었다"고 하셨는데 어떤 것이 문제인지 모르는 상태에서 적용하다 보면 효과가 있는 변경과 없는 변경이 서로 상쇄돼서 "변화 없음"으로 보일 수도 있어요.

먼저 "검색 실패인지 생성 실패인지 구분하는 방법" 자체가 해당 미션의 중요한 포인트기 때문에, 인지했다는 것 만으로도 가치가 있다 생각이 들고,
해당 영역을 어떻게 해볼 수 있을지를 더 깊게 학습하시면 좋을 것 같아요!

Comment thread mission/wall-report.md

> 추가로 궁금한 것

- 점수 계산은 어떤 것을 기준으로 하는지 궁금합니다. 점수가 높을 수록 실제 사용감이 좋아지는건지 궁금합니다.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이것이 보예가 설정하고 개선할 핵심입니다 ㅋㅋ
LLM 자체는 불확실성을 기반으로 하기 때문에 기존 개발과 같이 결정론적(deterministic) 사고를 하면 어려울 수 있어요.

측정과 개선 또한 보예의 가설을 세우고 측정하고 개선하는 방향으로 접근해보길 권장드려요!
내가 만든 챗봇의 목적을 정의하고, 목적을 기반으로 기준을 세우고, 그 기준으로 측정하며 개선 하기!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants