Post

Rag 챗봇 고도화

Rag 챗봇 고도화

RAG 챗봇 고도화: 더 빠르고, 더 쓰기 편하게

이번 업데이트의 목표는 한 줄로 말하면 “답변을 더 빠르게, 더 읽기 편하게” 다.

크게 세 방향으로 진행됐다.


  • 사용자 경험(UX): 답변을 간단히/자세히 중에 고를 수 있게 하고, 도움말과 대화 저장 기능을 추가했다.

  • 챗봇 로직: 질문 의도를 더 정확히 파악하고, 영웅 이름을 잘 알아듣고, 답을 카드 형태로 보여준다.

  • 성능: 검색을 병렬로 처리하고, LLM(AI 모델) 호출 횟수를 줄여서 속도를 개선했다.


image






1. 답변 스타일: “간단히” vs “자세히”



게임을 하는 도중에는 긴 글을 읽기가 어렵다. 그래서 사용자가 원하는 답변 길이를 직접 고를 수 있게 했다.


  • 간단히: 핵심만 짧게, 한 줄씩

  • 자세히: 이유와 배경까지


프론트에서 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) 이전에 골랐던 스타일을 그대로 쓰고, 그것도 없으면 기본값 “자세히” 로 답한다.


두 스타일은 AI에게 주는 지시문(프롬프트)이 서로 다르다.

예를 들어 “간단히”는 서두 없이 바로 본론부터 쓰고 스킬은 단축키를 괄호로 붙이고 마지막에 “바로 할 것 3가지”로 정리하도록 했다.



“간단히” — AI 호출 줄이기

“간단히”는 단순히 답만 짧은 게 아니라 AI를 부르는 횟수 자체가 적다.

그래서 응답도 더 빠르다. 두 군데를 건너뛴다.


① 전략 판단 단계를 건너뜀 (judge_strategy_node)

원래는 답을 만들기 전에 “어떤 영웅을 추천할지, 그 이유는 뭔지”를 미리 뽑아두는 별도의 AI 호출이 있었다.

“자세히”에서는 답 품질을 위해 이 단계가 필요하지만, “간단히”에서는 없어도 충분히 답할 수 있어서 건너뛰었다.

1
2
3
4
def route_after_retrieve(state):
    if state.get("answer_style") == "simple":
        return "generate_answer"   # 전략 판단 건너뜀
    return "judge_strategy"


② 후속 질문 생성을 건너뜀 (generate_suggested_questions_node)

“간단히”는 답을 만들 때 추천 질문까지 한 번에 받아온다.

그래서 이미 3개 이상 확보됐으면 후속 질문을 위한 AI 호출을 또 하지 않는다.

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"

결과적으로 “간단히”는 “자세히”보다 AI 호출이 2번 적다.






2. 예시 질문은 즉시 응답 (try_canned_shortcut)

메인 화면에는 예시 질문 5개(카운터, 조합 추천, 맵 운영, 스탯 피드백, 영웅 유지)가 있다.

이 버튼들은 답이 정해져 있기 때문에 누를 때마다 매번 AI 전체 과정을 돌릴 필요가 없다.

그래서 미리 답을 써두고 클릭하면 그대로 바로 돌려주는 방식으로 처리했다.

AI를 활용을 하지 않다보니 응답이 바로 나온다.




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
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"
    # 솔저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"
    ...

여기서 중요한 규칙이 하나 있다.

표현이 조금 달라도 뜻이 같으면 같은 캐시를 쓴다. (ex. “겐지 어떻게 잡아”, “겐지 카운터 뭐야” → 둘 다 겐지 카운터 캐시)

하지만 스탯 수치가 다르거나, 다른 영웅을 물으면 미리 써둔 답이 안 맞으니 그때는 AI를 활용한다.


겐지 카운터처럼 역할별로 답이 갈리는 경우는 먼저 “어떤 역할이 궁금하세요?”(전체/탱커/딜러/힐러)를 물어보고

사용자가 “전체”를 누르면 그때 미리 써둔 상성 카드를 돌려준다. (질문 → 역할 선택 → 캐시 답변, 이렇게 2단계다.)






3. 상성 카드 & 추천 영웅 카드

서버에 올린 뒤 받은 피드백 중에 “카드처럼 보여줬으면 한다”는 의견이 있었다.

답변 텍스트만 주는 대신 정보를 카드 형태로 함께 답하게 했다.


  • 상성 카드(matchup_card): 특정 영웅을 상대로 유리한 영웅과 불리한 영웅 목록

  • 추천 영웅 카드(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)이 CPU에서 돌아 계산이 느리다는 점이다.

검색어마다 이 느린 계산을 새로 하다 보니, 검색어가 늘어날수록 대기 시간이 그대로 더해졌다.

ex. 검색어 하나에 1초라면, 3개는 3초, 5개는 5초… 이렇게 쌓인다.

검색어끼리는 서로 결과에 의존하지 않으므로 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](앞 12개만 사용)

이 두 동작이 병렬 전과 동일하게 작동한다. → 속도만 빨라지고 답의 내용은 그대로다.

torch/HuggingFace 임베딩 연산은 GIL을 풀어주는 네이티브 연산이 대부분이라 스레드 병렬화 효과가 있었다.






5. intent 분류 개선

챗봇이 답을 잘하려면 “사용자가 뭘 원하는지” 를 먼저 정확히 알아야 한다.

그래서 infer_intent_by_rule의 판단 순서와 기준을 전면 개편했다.

(같은 문장도 무엇을 먼저 검사하느냐에 따라 결과가 달라지기 때문이다.)


① 상황 토로(situation) 의도 추가

“파라가 계속 압박해”처럼 게임 중 겪는 어려움을 토로하는 표현을 별도 의도(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)보다 먼저 확인한다.

“계속”이라는 흔한 단어가 두 함수에 다 걸려서, 상황 토로 문장이 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순위로 올렸다.

이유는 “계속”처럼 여러 의도에 두루 쓰여 헷갈리는 단어와 달리

“바꾸/교체”는 딱 봐도 ‘영웅을 바꾸고 싶다’는 뜻이라 헷갈릴 일이 거의 없는 가장 구체적인 신호이기 때문이다.

확실한 것부터 먼저 걸러냈다.






6. 우리팀/상대 조합 인식 개선

우리팀 조합 추출 추가

이전에는 상대팀만 추출했는데 이제 우리팀 조합도 추출한다.


1
2
3
4
5
6
def extract_ally_team(text: str) -> List[str]:
    patterns = [
        r"우리팀\s*조합은\s*([가-힣A-Za-z0-9\.,\s]+)",
        r"우리팀은\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, 힐러 2로 구성되기 때문이다.

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
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에서 실패를 알 수 없었다.

이제는 ERROR 로그로 남긴다.

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 병렬화검색을 동시 실행해 속도 개선 (결과는 동일)
intent 분류situation 추가, counter/stay 기준·순서 정리
조합 인식우리팀 조합 추출, 팀 섞임 방지, 빈 역할 자동 추론
영웅 별칭줄임말/오타 7개 추가
역할 버튼 로그클릭 이벤트도 저장
log_session_idDjango session_key와 분리해 대화 묶음 유지
그래프 에러내부 에러도 ERROR 로그로 저장
Admin세션 전체보기, 카드·후속 질문 노출
This post is licensed under CC BY 4.0 by the author.