Rag 챗봇 고도화
RAG 챗봇 고도화: UX 개선과 응답 속도 최적화
이번 업데이트는 크게 세 방향으로 진행됐다.
사용자 경험 개선(간단히/자세히 답변, 도움말, 대화 저장)
챗봇 로직 개선(intent 분류, 영웅 인식, 카드 UI)
성능 개선(RAG 병렬화, LLM 호출 수 감소)
1. 간단히 / 자세히 답변 스타일
인게임 중에는 긴 답변을 읽기가 어렵기 때문에 답변 스타일을 선택할 수 있게 했다.
front에서 answer_style: "simple" | "detailed" 값을 함께 보내면 된다.
1
2
3
4
# views.py
answer_style = data.get("answer_style")
if answer_style not in ("simple", "detailed"):
answer_style = None
None이 들어오면 세션에 저장된 이전 선택값을 쓰고, 그것도 없으면 "detailed"(자세히)가 기본값이다.
두 스타일의 프롬프트 규칙은 다르게 작성했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if is_simple_style:
style_rules = (
"1. 문단 대신 핵심 아이디어당 한 줄, \\n으로 구분해라. "
"서두 없이 바로 본론부터 써라.\n"
"2. 추천 영웅이 있으면 '추천 영웅: 영웅1, 영웅2' 한 줄 뒤 "
"영웅마다 짧은 이유 1~2개를 적어라.\n"
"3. 스킬은 단축키를 괄호로 붙여라(예: 투창(우클릭)).\n"
"4. 마지막에 '바로 할 것 3가지'를 적어라."
)
else:
style_rules = (
"1. 첫 문장은 이번 질문의 핵심에 바로 답해라.\n"
"2. 영웅 교체를 추천할 때는 허용 목록 안에서만 골라라.\n"
...
)
“간단히” 스타일에서는 "- " 형태의 글머리 기호를 허용하고 나머지 마크다운은 금지하는 방식으로 차별화했다.
LLM 호출 수 감소
“간단히” 스타일에서는 judge_strategy_node를 건너뛴다.
judge_strategy_node는 recommendation_type, recommended_heroes, strategy_reason을 미리 뽑아서
generate_answer_node에 참고 정보로 넘겨주는 별도 LLM 호출이다.
“자세히” 스타일에서는 답변 품질을 높이기 위해 이 단계가 필요하지만,
“간단히” 스타일에서는 generate_answer_node가 컨텍스트만으로도 충분히 판단할 수 있다.
1
2
3
4
5
def route_after_retrieve(state):
...
if state.get("answer_style") == "simple":
return "generate_answer" # judge_strategy 건너뜀
return "judge_strategy"
후속 질문 생성(generate_suggested_questions_node)도 마찬가지다.
“간단히” 스타일에서는 답변 생성 시 후속 질문을 인라인으로 함께 받아오기 때문에,
이미 3개 이상 확보됐으면 별도 LLM 호출을 하지 않는다.
1
2
3
4
def route_after_generate(state):
if state.get("answer_style") == "simple" and len(state.get("suggested_questions") or []) >= 3:
return "format_response" # 후속 질문 생성 건너뜀
return "generate_suggested_questions"
결과적으로 “간단히” 스타일은 자세히 스타일보다 LLM 호출이 2번 적다.
2. 웰컴 화면 즉시 응답 (try_canned_shortcut)
웰컴 화면의 예시 질문 5개(카운터, 조합 추천, 맵 운영, 스탯 피드백, 영웅 유지)는
클릭할 때마다 매번 LLM 파이프라인 전체를 돌릴 필요가 없다.
미리 답을 써두고 그걸 바로 돌려주는 방식으로 처리한다.
1
2
3
4
5
6
# views.py
canned = try_canned_shortcut(message, role_filter, conversation_context, answer_style)
if canned["result"] is not None:
result = canned["result"] # 그래프 실행 없이 즉시 반환
else:
result = run_chatbot_graph(...)
try_canned_shortcut은 메시지를 보고 5가지 고정 주제 중 하나에 해당하는지 확인한다.
1
2
3
4
5
6
7
8
9
10
11
12
def match_canned_topic(message: str) -> Optional[str]:
# 겐지 카운터 질문인지 확인
if CANNED_COUNTER_HERO in text and any(word in text for word in CANNED_COUNTER_TRIGGER_WORDS):
return "counter_genji"
# 왕의 길 수비 운영인지 확인
if find_map(text) == CANNED_MAP_NAME and find_side(text) == CANNED_MAP_SIDE:
return "map_kings_row_defense"
# 솔저76 특정 스탯 피드백인지 확인 (수치까지 일치할 때만)
if find_first_hero(text) == CANNED_STAT_HERO and detect_stat_input(text):
if kills == CANNED_STAT_KILLS and deaths == CANNED_STAT_DEATHS and damage == CANNED_STAT_DAMAGE:
return "stat_soldier76"
...
비슷한 표현(“겐지 어떻게 잡아”, “겐지 카운터 뭐야”)도 같은 캐시를 쓰지만,
수치가 다른 스탯 질문이나 다른 영웅을 언급한 경우에는 그래프를 정상적으로 실행한다.
겐지 카운터처럼 역할 선택이 필요한 경우는 역할 질문을 먼저 보여주고, “전체”를 선택하면 미리 써둔 상성 카드를 돌려준다.
3. 상성 카드 & 추천 영웅 카드
답변과 함께 카드 형태의 데이터를 내려줬다.
서버에 올리고 난 후 피드백을 받았었는데 그 중 하나로 카드 형식으로 보여줬으면 한다는 얘기가 있었다.
상성 카드(matchup_card): 특정 영웅을 상대로 유리한 영웅(hard_heroes)과 불리한 영웅(easy_heroes) 목록
추천 영웅 카드(recommend_card): 상황에 맞는 추천 영웅 목록
두 카드 모두 format_response_node에서 응답에 포함된다.
1
2
3
4
5
6
7
8
return {
"result": {
"answer": answer,
"matchup_card": state.get("matchup_card"),
"recommend_card": state.get("recommend_card"),
...
}
}
카드 데이터가 있으면 프론트에서 UI로 렌더링하고, 없으면 텍스트 답변만 보여준다.
4. RAG 검색 병렬화
이전에는 여러 검색 쿼리를 순차적으로 실행했다.
임베딩 모델(BAAI/bge-m3, CPU) 계산이 매 쿼리마다 새로 필요하다 보니, 검색어 수가 늘수록 지연이 그대로 쌓였다.
검색어끼리는 서로 결과에 의존하지 않으므로 ThreadPoolExecutor로 병렬 실행한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 이전: 순차 실행
for query in queries:
docs = retrieve_documents(retriever, query)
...
# 현재: 병렬 실행
results_by_query: List[List[Any]] = [[] for _ in queries]
with ThreadPoolExecutor(max_workers=min(6, len(queries))) as executor:
future_to_idx = {
executor.submit(retrieve_documents, retriever, query): idx
for idx, query in enumerate(queries)
}
for future in as_completed(future_to_idx):
results_by_query[future_to_idx[future]] = future.result()
결과 순서는 기존과 동일하게 queries 순서를 유지해서, 중복 제거와 all_docs[:12] 절단 동작이 병렬화 전과 완전히 같다.
torch/HuggingFace 임베딩 연산은 GIL을 풀어주는 네이티브 연산이 대부분이라 스레드 병렬화 효과가 있었다.
5. intent 분류 개선
infer_intent_by_rule의 판단 순서와 기준을 전면 개편했다.
situation intent 추가
“파라가 계속 압박해” 처럼 인게임에서 겪고 있는 압박 상황을 토로하는 표현을 별도 intent(situation)로 분리했다.
1
2
3
4
5
6
7
8
9
10
_SITUATION_PATTERNS = [
re.compile(r"압박"),
re.compile(r"계속.{0,10}(물어|물고|죽어|죽는|터져|터지)"),
re.compile(r"뒤가\s*터"),
re.compile(r"못\s*막겠"),
re.compile(r"힘들어"),
]
def detect_situation(text: str) -> bool:
return any(pattern.search(text) for pattern in _SITUATION_PATTERNS)
이것을 detect_wants_to_keep_hero보다 먼저 확인한다.
“계속”이라는 흔한 단어가 두 함수에 모두 걸려서 상황 토로 문장이 stay로 잘못 분류되던 문제를 막기 위해서다.
counter 기준 명확화
이전에는 “카운터”, “견제”, “상대법” 등을 모두 counter로 처리했는데,
“겐지로 윈스턴 상대법 알려줘”처럼 자기 영웅을 유지한 채 상대법을 묻는 stay 질문에도 같은 단어가 등장한다.
1
2
3
4
5
6
# 이전: "상대법"이 있으면 counter
_COUNTER_LIST_WORDS = ["카운터", "견제", "상대법", ...]
# 현재: 카운터/상성 목록 요청만 counter
_COUNTER_LIST_WORDS = ["카운터", "상성", "상대하기 어려운", "상대하기 쉬운"]
_STAY_OPERATION_WORDS = ["상대법", "어떻게 상대", "어떻게 잡", "파훼", "대처", "견제"]
“겐지로 윈스턴 상대법”처럼 자기 영웅 + 상대법이 함께 있으면 stay로 분류하는 detect_stay_with_named_hero()도 추가했다.
swap 우선순위 상향
swap(“말고”, “바꾸”, “교체” 등) 감지를 1순위로 올렸다.
“계속”처럼 stay 신호로도 흔히 쓰이는 단어와 겹치지 않는 가장 구체적인 신호이기 때문이다.
6. 아군/상대 조합 인식 개선
아군 조합 추출 추가
이전에는 상대팀만 추출했는데, 아군 조합도 추출한다.
1
2
3
4
5
6
7
def extract_ally_team(text: str) -> List[str]:
patterns = [
r"우리팀\s*조합은\s*([가-힣A-Za-z0-9\.,\s]+)",
r"우리팀은\s*([가-힣A-Za-z0-9\.,\s]+)",
r"아군\s*조합은\s*([가-힣A-Za-z0-9\.,\s]+)",
...
]
아군 정보가 있으면 답변 프롬프트에도 포함해서 시너지를 고려한 조언이 가능하다.
상대/아군 교차 오염 방지
“상대팀은 A B C이고 우리팀은 D E F야”처럼 두 팀을 한 문장에 쓰면,
상대팀 캡처 그룹이 “우리팀” 마커까지 삼켜버려 아군 영웅이 상대팀에 섞이는 문제가 있었다.
1
2
3
4
5
6
7
def _truncate_before_markers(chunk: str, markers: List[str]) -> str:
cut = len(chunk)
for marker in markers:
idx = chunk.find(marker)
if idx != -1:
cut = min(cut, idx)
return chunk[:cut]
상대팀을 추출할 때는 아군 마커(우리팀, 아군) 이전까지만 잘라서 사용하고, 아군 추출할 때는 반대로 적용한다.
아군 조합으로 역할 추론
아군 4명을 알면 남은 한 자리가 어떤 역할인지 추론할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
TEAM_COMP_ROLE_QUOTA = {"tank": 1, "damage": 2, "support": 2}
def infer_missing_role_from_team_comp(ally_heroes: List[str]) -> Optional[str]:
role_counts = {"tank": 0, "damage": 0, "support": 0}
for hero in ally_heroes:
role = HERO_TO_ROLE.get(normalize_hero_name(hero))
if role in role_counts:
role_counts[role] += 1
open_roles = [
role for role, quota in TEAM_COMP_ROLE_QUOTA.items()
if role_counts[role] < quota
]
if len(open_roles) == 1:
return open_roles[0]
return None
탱커 1, 딜러 2, 힐러 2 구성에서 정확히 한 역할만 비어있으면 역할을 다시 묻지 않고 그 역할로 바로 확정한다.
영웅 별칭 추가
1
2
3
4
5
6
7
8
9
10
HERO_ALIASES = {
...
"솔져": "솔저76", # 오타 대응
"바스": "바스티온",
"라인": "라인하르트",
"정크": "정크랫",
"브리": "브리기테",
"위도우": "위도우메이커",
"호그": "로드호그",
}
사용자들이 실제로 쓰는 줄임말과 오타를 반영했다.
7. views.py 개선
역할 버튼 클릭 로그 저장 문제 수정
역할(전체/탱커/딜러/힐러) 버튼을 클릭하면 message가 빈 문자열로 들어온다.
기존에는 if message:로 이 경우를 걸러서 아예 저장하지 않았다.
이제는 역할 필터 클릭도 로그에 남긴다.
1
2
3
4
5
6
7
8
9
10
if message:
user_log_message = message
else:
role_label = ROLE_LABELS.get(role_filter, role_filter)
original_question = result.get("message") or ""
user_log_message = (
f"[역할 선택: {role_label}] {original_question}".strip()
if original_question
else f"[역할 선택: {role_label}]"
)
Admin에서 “사용자가 탱커 버튼을 눌러서 해당 답변이 나왔구나”를 바로 볼 수 있게 됐다.
log_session_id 분리
기존에는 Django의 session_key를 로그 식별자로 썼는데, 로그인/세션 만료 등으로 바뀔 수 있다.
이제는 로그 전용 UUID를 세션에 별도로 저장한다.
1
2
3
if not request.session.get("log_session_id"):
request.session["log_session_id"] = str(uuid.uuid4())
log_session_id = request.session["log_session_id"]
Django 세션이 갱신돼도 대화 묶음이 유지된다.
그래프 내부 에러 로그 저장
run_chatbot_graph 내부 노드에서 예외가 나면 파이썬 예외로 튀어오르지 않고 state["error"]에 담겨 정상 반환된다.
기존에는 이 경우를 따로 저장하지 않아서 Admin에서 실패를 알아챌 수 없었다.
1
2
3
4
5
6
if graph_error:
save_chat_log(
role="ERROR",
message=str(graph_error),
metadata={"source": "graph_error", ...},
)
8. Admin 페이지 개선
세션별 대화 전체보기
세션에 속한 모든 대화를 시간순으로 한 화면에 보여주는 session_transcript_view를 추가했다.
Admin의 세션 링크를 클릭하면 필터 목록 대신 이 화면으로 연결된다.
사용자가 어떤 흐름으로 질문했는지 한눈에 볼 수 있다.
카드, 예측 질문, 상대 조합 노출
AI 답변 상세 페이지에서 상성 카드(matchup_card), 추천 영웅 카드(recommend_card),
후속 질문(suggested_questions), 상대 조합 정보를 직접 볼 수 있도록 Admin fieldset을 개선했다.
마무리
이번 업데이트 변경 내용을 표로 정리하면 다음과 같다.
| 항목 | 내용 |
|---|---|
| 답변 스타일 | 간단히 / 자세히 선택 가능 |
| LLM 호출 수 | 간단히 스타일에서 최대 2회 감소 |
| 즉시 응답 | 웰컴 화면 예시 질문 5개 캐시 처리 |
| 카드 UI | 상성 카드, 추천 영웅 카드 응답 데이터 추가 |
| RAG 병렬화 | ThreadPoolExecutor로 검색 쿼리 동시 실행 |
| intent 분류 | situation 추가, counter/stay 기준 명확화 |
| 조합 인식 | 아군 조합 추출, 교차 오염 방지, 역할 추론 |
| 영웅 별칭 | 줄임말/오타 7개 추가 |
| 역할 버튼 로그 | 클릭 이벤트도 저장되도록 수정 |
| log_session_id | Django session_key와 분리 |
| 그래프 에러 | 내부 에러도 ERROR 로그로 저장 |
| Admin | 세션 전체보기, 카드/예측 질문 노출 |