diff --git a/app/core/config.py b/app/core/config.py index 28ce628..6e7d09d 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -38,6 +38,12 @@ class Settings(BaseSettings): chat_fallback_similarity_threshold: float = 0.35 + chat_query_rewrite_enabled: bool = True + chat_query_rewrite_model: str = "gpt-4o-mini" + chat_query_rewrite_temperature: float = 0.0 + chat_retrieval_method_query_expansion: str = "vector_query_expansion_fallback" + chat_retrieval_version_query_expansion: str = "query-expansion-fallback-v1" + @lru_cache def get_settings() -> Settings: diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 7ae5352..e143695 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -29,6 +29,7 @@ from app.services.embeddings import create_query_embedding from app.services.generator import generate_answer from app.services.validator import validate_question +from app.services.query_rewriter import expand_query_for_retrieval from app.repositories.regulation_chunk_repository import search_similar_chunks_all_dormitories @@ -156,9 +157,19 @@ def _answer_single_dormitory_chat( retrieval_method = settings.chat_retrieval_method_single retrieval_version = settings.chat_retrieval_version_single + rewritten_query = question try: - query_embedding = create_query_embedding(question) + retrieval_query = question + + if _should_pre_expand_query(question): + retrieval_query = expand_query_for_retrieval( + question=question, + dormitory=dormitory, + ) + rewritten_query = retrieval_query + + query_embedding = create_query_embedding(retrieval_query) chunks = search_similar_chunks( db=db, @@ -212,7 +223,7 @@ def _answer_single_dormitory_chat( # 검색 결과는 있었지만 답변 생성기가 "관련 정보를 찾을 수 없습니다."라고 한 경우 # 아직 fallback 검색을 하지 않은 상태라면 전체 생활관 검색으로 한 번 더 시도 if ( - answer_result.answer.strip() == settings.chat_no_answer_message + _is_no_answer(answer_result.answer) and retrieval_method != settings.chat_retrieval_method_fallback ): fallback_chunks = search_similar_chunks_all_dormitories( @@ -232,6 +243,62 @@ def _answer_single_dormitory_chat( dormitory=dormitory, is_fallback=True, ) + + + # 전체 생활관 fallback까지 했는데도 답변을 못 만들면 + # LLM query expansion으로 검색용 질의를 확장한 뒤 재검색 + if _is_no_answer(answer_result.answer): + expanded_query = expand_query_for_retrieval( + question=question, + dormitory=dormitory, + ) + + if expanded_query != question: + expanded_query_embedding = create_query_embedding(expanded_query) + rewritten_query = expanded_query + + # 1차: 확장 query로 사용자 dormitory + 공통 문서 검색 + expanded_chunks = search_similar_chunks( + db=db, + query_embedding=expanded_query_embedding, + dormitory=dormitory, + top_k=settings.chat_single_dormitory_top_k, + ) + + + if expanded_chunks: + chunks = expanded_chunks + retrieval_method = settings.chat_retrieval_method_query_expansion + retrieval_version = settings.chat_retrieval_version_query_expansion + + answer_result = generate_answer( + question, + chunks, + dormitory=dormitory, + is_fallback=False, + ) + + # 중요: + # 확장 query + 사용자 dormitory 검색으로도 답변이 부족하면 + # 확장 query + 전체 생활관 검색을 반드시 한 번 더 수행 + if _is_no_answer(answer_result.answer): + expanded_all_chunks = search_similar_chunks_all_dormitories( + db=db, + query_embedding=expanded_query_embedding, + top_k=settings.chat_fallback_top_k, + ) + + if expanded_all_chunks: + chunks = expanded_all_chunks + retrieval_method = settings.chat_retrieval_method_query_expansion + retrieval_version = settings.chat_retrieval_version_query_expansion + + answer_result = generate_answer( + question, + chunks, + dormitory=dormitory, + is_fallback=True, + ) except Exception as exc: _attach_chat_error_metadata( @@ -261,13 +328,22 @@ def _answer_single_dormitory_chat( db.commit() db.close() + final_answer_status = ChatAnswerStatus.SUCCESS + final_source_url = answer_result.source_url or "" + + if _is_no_answer(answer_result.answer): + final_answer_status = ChatAnswerStatus.NO_ANSWER + final_source_url = "" + + + return _finalize_chat_log_in_new_session( chat_log_id=chat_log_id, session_id=session_id, - answer_status=ChatAnswerStatus.SUCCESS, + answer_status=final_answer_status, answer=answer_result.answer, - source_url=answer_result.source_url or "", - rewritten_query=question, + source_url=final_source_url, + rewritten_query=rewritten_query, model_name=settings.chat_answer_model, prompt_version=settings.chat_prompt_version_single, retrieval_version=retrieval_version, @@ -285,8 +361,21 @@ def _answer_unspecified_dormitory_chat( started_at: float, ) -> ChatResponse: settings = get_settings() + retrieval_method = settings.chat_retrieval_method_grouped + retrieval_version = settings.chat_retrieval_version_grouped + rewritten_query = question + try: - query_embedding = create_query_embedding(question) + retrieval_query = question + + if _should_pre_expand_query(question): + retrieval_query = expand_query_for_retrieval( + question=question, + dormitory=None, + ) + rewritten_query = retrieval_query + + query_embedding = create_query_embedding(retrieval_query) except Exception as exc: _attach_chat_error_metadata( exc, @@ -294,6 +383,7 @@ def _answer_unspecified_dormitory_chat( occurred_step=STEP_RETRIEVAL, ) raise + try: chunks = search_similar_chunks_for_dormitories( db=db, @@ -317,19 +407,69 @@ def _answer_unspecified_dormitory_chat( answer_status=ChatAnswerStatus.NO_ANSWER, answer=settings.chat_no_answer_message, source_url="", - rewritten_query=question, + rewritten_query=rewritten_query, model_name=None, prompt_version=None, - retrieval_version=settings.chat_retrieval_version_grouped, + retrieval_version=retrieval_version, response_time_ms=_elapsed_ms(started_at), ) + try: + answer_result = generate_answer(question, chunks) + + # 비로그인/생활관 미지정 상태에서 원문 검색으로 답변을 못 만들면 + # query expansion으로 검색용 질의를 확장한 뒤 전체 생활관 대상으로 재검색 + if _is_no_answer(answer_result.answer): + expanded_query = expand_query_for_retrieval( + question=question, + dormitory=None, + ) + + if expanded_query != question: + expanded_query_embedding = create_query_embedding(expanded_query) + rewritten_query = expanded_query + + expanded_chunks = search_similar_chunks_for_dormitories( + db=db, + query_embedding=expanded_query_embedding, + dormitories=settings.chat_grouped_dormitories, + top_k=settings.chat_fallback_top_k, + ) + + rerank_keywords = _get_query_expansion_rerank_keywords( + question, + expanded_query, + ) + expanded_chunks = _rerank_chunks_by_keywords( + expanded_chunks, + rerank_keywords, + ) + + + if expanded_chunks: + chunks = expanded_chunks + retrieval_method = settings.chat_retrieval_method_query_expansion + retrieval_version = settings.chat_retrieval_version_query_expansion + + answer_result = generate_answer( + question, + chunks, + ) + + except Exception as exc: + _attach_chat_error_metadata( + exc, + error_type=ERROR_TYPE_LLM_API, + occurred_step=STEP_ANSWER_GENERATION, + ) + raise + try: create_chat_retrieval_results( db, chat_log_id=chat_log_id, retrieval_items=chunks, - retrieval_method=settings.chat_retrieval_method_grouped, + retrieval_method=retrieval_method, ) except Exception as exc: _attach_chat_error_metadata( @@ -338,27 +478,27 @@ def _answer_unspecified_dormitory_chat( occurred_step=STEP_RETRIEVAL, ) raise + db.commit() - try: - answer_result = generate_answer(question, chunks) - except Exception as exc: - _attach_chat_error_metadata( - exc, - error_type=ERROR_TYPE_LLM_API, - occurred_step=STEP_ANSWER_GENERATION, - ) - raise db.close() + + final_answer_status = ChatAnswerStatus.SUCCESS + final_source_url = answer_result.source_url or "" + + if _is_no_answer(answer_result.answer): + final_answer_status = ChatAnswerStatus.NO_ANSWER + final_source_url = "" + return _finalize_chat_log_in_new_session( chat_log_id=chat_log_id, session_id=session_id, - answer_status=ChatAnswerStatus.SUCCESS, + answer_status=final_answer_status, answer=answer_result.answer, - source_url=answer_result.source_url or "", - rewritten_query=question, + source_url=final_source_url, + rewritten_query=rewritten_query, model_name=settings.chat_answer_model, prompt_version=settings.chat_prompt_version_grouped, - retrieval_version=settings.chat_retrieval_version_grouped, + retrieval_version=retrieval_version, response_time_ms=_elapsed_ms(started_at), mark_retrieval_used=True, cited_regulation_chunk_ids=answer_result.cited_regulation_chunk_ids, @@ -532,3 +672,142 @@ def _should_fallback_retrieval(chunks: list[dict]) -> bool: return True return float(top_similarity) < settings.chat_fallback_similarity_threshold + +def _is_no_answer(answer: str) -> bool: + settings = get_settings() + return settings.chat_no_answer_message in answer.strip() + +def _get_query_expansion_rerank_keywords(question: str, expanded_query: str) -> list[str]: + text = f"{question} {expanded_query}".replace(" ", "") + + cooking_triggers = [ + "라면끓", + "끓여먹", + "끓여", + "취사", + "조리", + "요리", + "해먹", + "해먹어", + "해먹어도", + "음식해", + "음식해먹", + "전기포트", + "라면포트", + "에어프라이어", + "커피포트", + ] + + if any(trigger in text for trigger in cooking_triggers): + return [ + "반입금지 물품", + "반입금지", + "취사행위", + "취사", + "전열기구", + "전열기기", + "라면포트", + "전기포트", + "에어프라이어", + "커피포트", + "화재위험", + ] + + return [] + + +def _rerank_chunks_by_keywords( + chunks: list[dict], + keywords: list[str], +) -> list[dict]: + if not chunks or not keywords: + return chunks + + def keyword_score(chunk: dict) -> int: + content = (chunk.get("content") or "").lower() + return sum(1 for keyword in keywords if keyword.lower() in content) + + return sorted( + chunks, + key=lambda chunk: ( + keyword_score(chunk), + float(chunk.get("similarity") or 0.0), + ), + reverse=True, + ) + + +def _should_pre_expand_query(question: str) -> bool: + compact_question = question.replace(" ", "") + + curfew_triggers = [ + "통금", + "몇시까지들어", + "몇시까지입실", + "언제까지들어", + "새벽에들어", + "새벽에도들어", + "새벽2시에들어", + "새벽1시에들어", + "들어가도돼", + "출입가능", + "문닫", + "문열", + "폐문", + "개문", + ] + + if ( + any(trigger in compact_question for trigger in curfew_triggers) + and "휴게실" not in compact_question + ): + return True + + eating_place_triggers = [ + "먹을만한곳", + "먹을거", + "먹을것", + "뭐먹을", + "간단하게먹", + "식사해결", + "사먹을곳", + ] + + if any(trigger in compact_question for trigger in eating_place_triggers): + return True + + microwave_triggers = [ + "전자레인지", + "전자렌지", + "음식데워", + "데워먹", + "데워먹을", + ] + + if any(trigger in compact_question for trigger in microwave_triggers): + return True + + cooking_triggers = [ + "라면끓", + "라면먹", + "끓여먹", + "끓여", + "방에서라면", + "취사", + "조리", + "요리", + "해먹", + "해먹어", + "해먹어도", + "음식해", + "음식해먹", + "전기포트", + "라면포트", + "에어프라이어", + "커피포트", + ] + + if any(trigger in compact_question for trigger in cooking_triggers): + return True + + return False \ No newline at end of file diff --git a/app/services/generator.py b/app/services/generator.py index 054a9a9..0dfd33b 100644 --- a/app/services/generator.py +++ b/app/services/generator.py @@ -1,3 +1,4 @@ +import json import re from dataclasses import dataclass from typing import Optional @@ -58,13 +59,29 @@ def generate_answer( 너는 기숙사 안내 챗봇이다. 아래 제공된 정보를 기반으로만 질문에 답변해라. 모르는 내용은 추측하지 말고 모른다고 말해라. +참고 정보에 없는 일반 상식이나 추측으로 답하지 마라. +"일반적으로", "가능성이 높습니다", "확인하는 것이 좋습니다"처럼 근거 없는 표현을 사용하지 마라. 질문에서 생활관을 특정하지 않았고 참고 정보가 특정 생활관에만 해당하면, 해당 생활관 기준 답변임을 명확히 밝혀라. 질문에서 생활관을 특정하지 않았더라도 생활관별 구분을 강제로 만들지 말고, 가장 관련 있는 정보 중심으로 간결하게 답변해라. 참고 정보에 생활관 구분이 없거나 공통 규정으로 보이면 일반 답변으로 안내해라. {fallback_instruction} -답변에는 `[C1]`, `[C2]` 같은 내부 근거 라벨을 절대 출력하지 마라. -출처 문구는 서버가 별도로 붙이므로 답변 본문에는 출처 줄을 만들지 마라. -질문에 답할 정보가 충분하지 않으면 정확히 "{settings.chat_no_answer_message}"라고만 답해라. + +반드시 아래 JSON 형식으로만 출력해라. +설명 문장, 마크다운, 코드블록은 출력하지 마라. + +형식: +{{ + "answer": "사용자에게 보여줄 최종 답변", + "used_reference_index": 1 +}} + +규칙: +- "answer"에는 답변 본문만 작성해라. +- "answer"에는 출처 문구를 넣지 마라. +- "used_reference_index"에는 답변 작성에 가장 직접적으로 사용한 참고 정보 번호를 넣어라. +- 여러 참고 정보를 사용했다면 가장 핵심 근거가 되는 참고 정보 번호 하나만 넣어라. +- 질문에 답할 정보가 충분하지 않으면 "answer"는 정확히 "{settings.chat_no_answer_message}"로 작성하고, "used_reference_index"는 null로 작성해라. +- 참고 정보 번호는 [참고 정보 1], [참고 정보 2]의 숫자를 기준으로 한다. [질문] {question} @@ -81,14 +98,24 @@ def generate_answer( ] ) - raw_answer = response.choices[0].message.content.strip() - answer_without_labels = _strip_citation_labels(raw_answer) - source_chunk = _select_source_chunk(chunks) + raw_output = response.choices[0].message.content.strip() + parsed_answer, used_reference_index = _parse_generation_output( + raw_output, + no_answer_message=settings.chat_no_answer_message, + ) + + answer_without_labels = _strip_citation_labels(parsed_answer) + source_chunk = _select_source_chunk_by_reference_index( + chunks, + used_reference_index, + ) + answer = _format_answer_with_source( answer_without_labels, source_chunk=source_chunk, no_answer_message=settings.chat_no_answer_message, ) + cited_regulation_chunk_ids = _resolve_used_regulation_chunk_ids(source_chunk) source_url = _resolve_source_url(source_chunk) @@ -103,10 +130,6 @@ def _strip_citation_labels(answer: str) -> str: return re.sub(r"\s*\[C\d+\]", "", answer).strip() -def _select_source_chunk(chunks: list[dict]) -> Optional[dict]: - if not chunks: - return None - return chunks[0] def _format_answer_with_source( @@ -155,3 +178,68 @@ def _resolve_source_url(source_chunk: Optional[dict]) -> str: if source_chunk is None: return "" return source_chunk.get("source_url", "") or "" + + +def _parse_generation_output( + raw_output: str, + *, + no_answer_message: str, +) -> tuple[str, Optional[int]]: + try: + payload = json.loads(_strip_json_code_block(raw_output)) + except json.JSONDecodeError: + # 혹시 모델이 JSON 형식을 어기면 기존 방식처럼 전체 텍스트를 답변으로 사용 + return raw_output.strip(), None + + if not isinstance(payload, dict): + return no_answer_message, None + + answer = payload.get("answer") + if not isinstance(answer, str) or not answer.strip(): + return no_answer_message, None + + used_reference_index = payload.get("used_reference_index") + if used_reference_index is None: + return answer.strip(), None + + if isinstance(used_reference_index, int): + return answer.strip(), used_reference_index + + return answer.strip(), None + + +def _strip_json_code_block(raw_output: str) -> str: + text = raw_output.strip() + + # LLM이 ```json ... ``` 코드블록으로 감싼 경우 우선 제거 + if text.startswith("```json"): + text = text.removeprefix("```json").strip() + elif text.startswith("```"): + text = text.removeprefix("```").strip() + + if text.endswith("```"): + text = text.removesuffix("```").strip() + + # 앞뒤 설명이 섞여도 JSON 객체 부분만 추출 + match = re.search(r"\{.*\}", text, re.DOTALL) + if match: + return match.group(0).strip() + + return text + + +def _select_source_chunk_by_reference_index( + chunks: list[dict], + used_reference_index: Optional[int], +) -> Optional[dict]: + if not chunks: + return None + + if used_reference_index is None: + return None + + chunk_index = used_reference_index - 1 + if chunk_index < 0 or chunk_index >= len(chunks): + return None + + return chunks[chunk_index] diff --git a/app/services/query_rewriter.py b/app/services/query_rewriter.py new file mode 100644 index 0000000..3463da2 --- /dev/null +++ b/app/services/query_rewriter.py @@ -0,0 +1,124 @@ +"""사용자 질문을 RAG 검색에 적합한 검색용 질의로 확장하는 서비스 파일입니다.""" + +from typing import Optional + +from openai import OpenAI + +from app.core.config import get_settings + +settings = get_settings() +client = OpenAI(api_key=settings.openai_api_key) + + +def expand_query_for_retrieval( + question: str, + dormitory: Optional[str] = None, +) -> str: + """ + 원문 질문의 의미는 유지하면서 검색에 도움이 되는 키워드를 덧붙인 검색용 질의를 생성합니다. + + 주의: + - 답변 생성용 질문을 바꾸는 기능이 아닙니다. + - pgvector 검색에만 사용할 검색용 텍스트를 만드는 기능입니다. + - 실패하면 원문 질문을 그대로 반환합니다. + """ + + settings = get_settings() + + if not settings.chat_query_rewrite_enabled: + return question + + normalized_question = question.strip() + if not normalized_question: + return question + + dormitory_text = dormitory or "생활관 미지정" + + prompt = f""" +너는 가천대학교 기숙사 챗봇의 RAG 검색 질의를 보강하는 도우미다. + +목표: +사용자 질문의 의미를 바꾸지 말고, 벡터 검색에 도움이 되는 검색 키워드만 보강해라. + +규칙: +1. 새로운 사실을 만들지 마라. +2. 사용자의 의도를 바꾸지 마라. +3. 답변을 작성하지 마라. +4. 검색용 문장만 출력해라. +5. 원문 질문을 반드시 포함해라. +6. 기숙사 관련 동의어, 시설명, 규정명, 유의어, 생활관 약칭을 추가할 수 있다. +7. 출력은 1~2문장으로 짧게 작성해라. +8. 불필요한 설명, 따옴표, 번호 목록은 출력하지 마라. + +9. 사용자가 음료수, 물, 간식, 먹을 것, 마실 것, 살 곳, 사 먹을 곳, 구매할 곳을 물으면 편의점, 매점, 편의시설, 구매 위치 키워드를 포함해라. +10. 사용자가 방에서 라면, 조리, 끓여 먹기, 요리, 취사, 라면포트, 전기포트, 전열기구 사용 가능 여부를 물으면 반입금지 물품, 취사행위, 전열기구, 라면포트, 전기포트, 에어프라이어, 커피포트, 화재위험 키워드를 포함해라. +11. 사용자가 통금, 몇 시까지 들어가야 하는지, 문 닫는 시간, 새벽 출입, 출입 가능 시간을 물으면 폐문시간, 개문시간, 출입통제, 생활관 이용안내, 오전 1시, 오전 5시 키워드를 포함해라. +단, 사용자가 휴게실을 직접 언급하지 않았다면 휴게실 통금보다 생활관 출입 통금으로 해석해라. +12.사용자가 "먹을 거", "간단하게 먹을 곳", "먹을거 해결", "사먹을 곳"처럼 식사/간식 해결 장소를 물으면 학생식당, 학식, 편의점, 매점, 배달음식 수령 키워드를 함께 포함해라. +13.사용자가 전자레인지, 음식 데우기, 데워먹기, 휴게실 전자레인지 위치를 물으면 휴게실, 공용시설, 전자레인지, 음식 데우기, 정수기, 싱크대 키워드를 포함해라. + + +예시: +사용자 질문: 새벽 2시에 들어가도 돼? +검색용 질의: 새벽 2시에 들어가도 돼? 검색 키워드: 폐문시간, 개문시간, 출입통제, 출입 가능 시간, 생활관 이용안내, 오전 1시, 오전 5시 + +사용자 질문: 음료수 살만한 곳 있어? +검색용 질의: 음료수 살만한 곳 있어? 검색 키워드: 편의점, 매점, 편의시설, 음료 구매, 간식 구매, 생활용품 구매 위치 + +사용자 질문: 방에서 라면 먹어도 돼? +검색용 질의: 방에서 라면 먹어도 돼? 검색 키워드: 호실 내 취사, 라면포트, 전열기구, 반입금지 물품, 취사 금지 + +사용자 질문: 방에서 라면 끓여 먹어도 돼? +검색용 질의: 방에서 라면 끓여 먹어도 돼? 검색 키워드: 호실 내 취사, 취사행위, 반입금지 물품, 전열기구, 라면포트, 전기포트, 화재위험 + +사용자 질문: 와이파이 비밀번호 뭐야? +검색용 질의: 와이파이 비밀번호 뭐야? 검색 키워드: 무선인터넷, 네트워크, wifi, 인터넷 비밀번호 + +사용자 질문: 음료수 사 먹을만한 곳 있어? +검색용 질의: 음료수 사 먹을만한 곳 있어? 검색 키워드: 편의점, 매점, 편의시설, 음료 구매, 간식 구매, 물 구매, 생활용품 구매 위치 + +사용자 질문: 기숙사 통금 시간 언제야? +검색용 질의: 기숙사 통금 시간 언제야? 검색 키워드: 폐문시간, 개문시간, 출입통제, 출입 가능 시간, 생활관 이용안내, 오전 1시, 오전 5시 + +사용자 질문: 통금 언제야? +검색용 질의: 통금 언제야? 검색 키워드: 폐문시간, 개문시간, 출입통제, 출입 가능 시간, 생활관 이용안내, 오전 1시, 오전 5시 + +사용자 질문: 전자레인지 어디있어? +검색용 질의: 전자레인지 어디있어? 검색 키워드: 휴게실, 공용시설, 전자레인지, 음식 데우기, 편의시설 + +사용자 질문: 음식 데워먹을 수 있어? +검색용 질의: 음식 데워먹을 수 있어? 검색 키워드: 휴게실, 공용시설, 전자레인지, 음식 데우기, 편의시설 + +사용자 생활관: +{dormitory_text} + +사용자 질문: +{normalized_question} + +검색용 질의: +""" + + try: + response = client.chat.completions.create( + model=settings.chat_query_rewrite_model, + temperature=settings.chat_query_rewrite_temperature, + messages=[ + {"role": "user", "content": prompt}, + ], + timeout=settings.openai_timeout_seconds, + ) + + expanded_query = response.choices[0].message.content.strip() + + if not expanded_query: + return normalized_question + + # 안전장치: LLM 결과에 원문 질문이 빠지면 원문을 앞에 붙인다. + if normalized_question not in expanded_query: + expanded_query = f"{normalized_question}\n검색 키워드: {expanded_query}" + + return expanded_query + + except Exception: + # query expansion 실패가 챗봇 전체 실패로 이어지면 안 되므로 원문으로 fallback + return normalized_question \ No newline at end of file