From 717258f105d4e6d0ebf1c9b5035678ac418dba65 Mon Sep 17 00:00:00 2001 From: kimssirr Date: Sun, 3 May 2026 19:04:18 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EB=B2=A1=ED=84=B0,=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=ED=95=98=EC=9D=B4=EB=B8=8C=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=EA=B2=80=EC=83=89=20=EA=B5=AC=ED=98=84=201?= =?UTF-8?q?=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/config.py | 13 +- .../regulation_chunk_repository.py | 280 +++++++++++++++++- app/services/chat_service.py | 22 +- tests/test_chat_service.py | 19 +- tests/test_regulation_chunk_repository.py | 74 +++++ 5 files changed, 380 insertions(+), 28 deletions(-) diff --git a/app/core/config.py b/app/core/config.py index 28ce628..3913307 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -20,10 +20,10 @@ class Settings(BaseSettings): notice_summary_model: str = "gpt-4o-mini" chat_prompt_version_single: str = "chat-answer-source-v1" chat_prompt_version_grouped: str = "chat-answer-unspecified-dormitory-source-v1" - chat_retrieval_version_single: str = "dormitory-search-v1" - chat_retrieval_version_grouped: str = "dormitory-search-unspecified-v1" - chat_retrieval_method_single: str = "vector_dormitory_top_k" - chat_retrieval_method_grouped: str = "vector_unspecified_dormitory_top_k" + chat_retrieval_version_single: str = "hybrid-dormitory-search-v1" + chat_retrieval_version_grouped: str = "hybrid-dormitory-search-unspecified-v1" + chat_retrieval_method_single: str = "hybrid_dormitory_top_k" + chat_retrieval_method_grouped: str = "hybrid_unspecified_dormitory_top_k" chat_no_answer_message: str = "관련 정보를 찾을 수 없습니다." chat_invalid_question_message: str = "기숙사 관련 질문을 입력해주세요." chat_single_dormitory_top_k: int = 3 @@ -33,8 +33,8 @@ class Settings(BaseSettings): chat_session_timeout_minutes: int = 30 chat_fallback_top_k: int = 5 - chat_retrieval_method_fallback: str = "vector_all_dormitories_fallback" - chat_retrieval_version_fallback: str = "dormitory-search-fallback-v1" + chat_retrieval_method_fallback: str = "hybrid_all_dormitories_fallback" + chat_retrieval_version_fallback: str = "hybrid-dormitory-search-fallback-v1" chat_fallback_similarity_threshold: float = 0.35 @@ -50,4 +50,3 @@ def get_settings() -> Settings: Settings.model_config = SettingsConfigDict(extra="ignore") - diff --git a/app/repositories/regulation_chunk_repository.py b/app/repositories/regulation_chunk_repository.py index a0aa7be..80cfd0b 100644 --- a/app/repositories/regulation_chunk_repository.py +++ b/app/repositories/regulation_chunk_repository.py @@ -261,4 +261,282 @@ def search_similar_chunks_all_dormitories( "similarity": float(row.similarity), } for row in result - ] \ No newline at end of file + ] + + +def search_hybrid_chunks( + db: Session, + query_text: str, + query_embedding: list[float], + dormitory: str, + top_k: int = 3, + candidate_k: int = 20, + vector_weight: float = 0.7, + keyword_weight: float = 0.3, +): + """단일 생활관과 공통 문서를 대상으로 하이브리드 검색합니다.""" + + return _search_hybrid_chunks( + db=db, + query_text=query_text, + query_embedding=query_embedding, + top_k=top_k, + candidate_k=candidate_k, + vector_weight=vector_weight, + keyword_weight=keyword_weight, + filter_sql="(rd.dormitory = :dormitory OR rd.dormitory IS NULL)", + params={"dormitory": dormitory}, + ) + + +def search_hybrid_chunks_for_dormitories( + db: Session, + query_text: str, + query_embedding: list[float], + dormitories: list[str], + top_k: int = 3, + candidate_k: int = 20, + vector_weight: float = 0.7, + keyword_weight: float = 0.3, +): + """여러 생활관과 공통 문서를 대상으로 하이브리드 검색합니다.""" + + return _search_hybrid_chunks( + db=db, + query_text=query_text, + query_embedding=query_embedding, + top_k=top_k, + candidate_k=candidate_k, + vector_weight=vector_weight, + keyword_weight=keyword_weight, + filter_sql="(rd.dormitory = ANY(:dormitories) OR rd.dormitory IS NULL)", + params={"dormitories": dormitories}, + ) + + +def search_hybrid_chunks_all_dormitories( + db: Session, + query_text: str, + query_embedding: list[float], + top_k: int = 5, + candidate_k: int = 30, + vector_weight: float = 0.7, + keyword_weight: float = 0.3, +): + """생활관 필터 없이 전체 활성 regulation_chunk를 대상으로 하이브리드 검색합니다.""" + + return _search_hybrid_chunks( + db=db, + query_text=query_text, + query_embedding=query_embedding, + top_k=top_k, + candidate_k=candidate_k, + vector_weight=vector_weight, + keyword_weight=keyword_weight, + filter_sql="TRUE", + params={}, + ) + + +def _search_hybrid_chunks( + db: Session, + *, + query_text: str, + query_embedding: list[float], + top_k: int, + candidate_k: int, + vector_weight: float, + keyword_weight: float, + filter_sql: str, + params: dict, +): + """ + 벡터 유사도와 키워드 점수를 가중합해 검색합니다. + + 반환값의 similarity는 최종 결합 점수이며, 벡터 단독 점수는 vector_similarity에 남깁니다. + """ + + embedding_str = "[" + ",".join(map(str, query_embedding)) + "]" + + sql = text( + f""" + WITH vector_search AS ( + SELECT + rc.regulation_chunk_id, + rd.document_id, + rd.document_version, + rc.chunk_id, + COALESCE(rc.chunk_text, rd.content, '') AS content, + rd.source, + rd.source_url, + rd.dormitory, + 1 - (rc.embedding <=> CAST(:embedding AS vector)) AS vector_similarity, + NULL::float AS keyword_score, + ROW_NUMBER() OVER ( + ORDER BY rc.embedding <=> CAST(:embedding AS vector) + ) AS vector_rank, + NULL::bigint AS keyword_rank + FROM regulation_chunk rc + JOIN regulation_document rd + ON rd.regulation_document_id = rc.regulation_document_id + WHERE {filter_sql} + AND rc.is_active = TRUE + AND rd.is_active = TRUE + AND rc.embedding IS NOT NULL + ORDER BY rc.embedding <=> CAST(:embedding AS vector) + LIMIT :candidate_k + ), + + keyword_search AS ( + SELECT + rc.regulation_chunk_id, + rd.document_id, + rd.document_version, + rc.chunk_id, + COALESCE(rc.chunk_text, rd.content, '') AS content, + rd.source, + rd.source_url, + rd.dormitory, + 1 - (rc.embedding <=> CAST(:embedding AS vector)) AS vector_similarity, + ts_rank_cd( + to_tsvector( + 'simple', + COALESCE(rc.chunk_text, '') || ' ' || + COALESCE(rd.content, '') || ' ' || + COALESCE(rc.keywords::text, '') + ), + websearch_to_tsquery('simple', :query_text) + ) AS keyword_score, + NULL::bigint AS vector_rank, + ROW_NUMBER() OVER ( + ORDER BY ts_rank_cd( + to_tsvector( + 'simple', + COALESCE(rc.chunk_text, '') || ' ' || + COALESCE(rd.content, '') || ' ' || + COALESCE(rc.keywords::text, '') + ), + websearch_to_tsquery('simple', :query_text) + ) DESC + ) AS keyword_rank + FROM regulation_chunk rc + JOIN regulation_document rd + ON rd.regulation_document_id = rc.regulation_document_id + WHERE {filter_sql} + AND rc.is_active = TRUE + AND rd.is_active = TRUE + AND rc.embedding IS NOT NULL + AND to_tsvector( + 'simple', + COALESCE(rc.chunk_text, '') || ' ' || + COALESCE(rd.content, '') || ' ' || + COALESCE(rc.keywords::text, '') + ) @@ websearch_to_tsquery('simple', :query_text) + ORDER BY keyword_score DESC + LIMIT :candidate_k + ), + + combined AS ( + SELECT * FROM vector_search + UNION ALL + SELECT * FROM keyword_search + ), + + dedup AS ( + SELECT + regulation_chunk_id, + MAX(document_id) AS document_id, + MAX(document_version) AS document_version, + MAX(chunk_id) AS chunk_id, + MAX(content) AS content, + MAX(source) AS source, + MAX(source_url) AS source_url, + MAX(dormitory) AS dormitory, + MAX(vector_similarity) AS vector_similarity, + MAX(keyword_score) AS keyword_score, + MIN(vector_rank) AS vector_rank, + MIN(keyword_rank) AS keyword_rank + FROM combined + GROUP BY regulation_chunk_id + ), + + scored AS ( + SELECT + *, + COALESCE(keyword_score / NULLIF(MAX(keyword_score) OVER (), 0), 0) AS normalized_keyword_score, + CASE + WHEN keyword_score IS NULL OR keyword_score = 0 THEN :vector_weight + ELSE :vector_weight + :keyword_weight + END AS available_weight, + ( + (:vector_weight * COALESCE(vector_similarity, 0)) + + ( + :keyword_weight * + COALESCE(keyword_score / NULLIF(MAX(keyword_score) OVER (), 0), 0) + ) + ) / + CASE + WHEN keyword_score IS NULL OR keyword_score = 0 THEN :vector_weight + ELSE :vector_weight + :keyword_weight + END AS hybrid_score + FROM dedup + ) + + SELECT + regulation_chunk_id, + document_id, + document_version, + chunk_id, + content, + source, + source_url, + dormitory, + vector_similarity, + keyword_score, + normalized_keyword_score, + vector_rank, + keyword_rank, + hybrid_score + FROM scored + ORDER BY hybrid_score DESC + LIMIT :top_k + """ + ) + + result = db.execute( + sql, + { + "embedding": embedding_str, + "query_text": query_text.strip(), + "top_k": top_k, + "candidate_k": candidate_k, + "vector_weight": vector_weight, + "keyword_weight": keyword_weight, + **params, + }, + ).mappings().all() + + return [ + { + "regulation_chunk_id": row.regulation_chunk_id, + "document_id": row.document_id, + "document_version": row.document_version, + "chunk_id": row.chunk_id, + "content": row.content, + "source": row.source, + "source_url": row.source_url, + "retrieval_group": row.dormitory, + "similarity": float(row.hybrid_score), + "vector_similarity": float(row.vector_similarity) if row.vector_similarity is not None else None, + "keyword_score": float(row.keyword_score) if row.keyword_score is not None else None, + "normalized_keyword_score": ( + float(row.normalized_keyword_score) + if row.normalized_keyword_score is not None + else None + ), + "vector_rank": int(row.vector_rank) if row.vector_rank is not None else None, + "keyword_rank": int(row.keyword_rank) if row.keyword_rank is not None else None, + "hybrid_score": float(row.hybrid_score), + } + for row in result + ] diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 7ae5352..a1e58d5 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -22,8 +22,9 @@ from app.repositories.chat_log_repository import update_chat_log_result from app.repositories.chat_retrieval_result_repository import create_chat_retrieval_results from app.repositories.chat_retrieval_result_repository import mark_chat_retrieval_results_used_in_answer -from app.repositories.regulation_chunk_repository import search_similar_chunks -from app.repositories.regulation_chunk_repository import search_similar_chunks_for_dormitories +from app.repositories.regulation_chunk_repository import search_hybrid_chunks +from app.repositories.regulation_chunk_repository import search_hybrid_chunks_all_dormitories +from app.repositories.regulation_chunk_repository import search_hybrid_chunks_for_dormitories from app.schemas.chat import ChatRequest from app.schemas.chat import ChatResponse from app.services.embeddings import create_query_embedding @@ -31,8 +32,6 @@ from app.services.validator import validate_question -from app.repositories.regulation_chunk_repository import search_similar_chunks_all_dormitories - ERROR_TYPE_TIMEOUT = "TIMEOUT" ERROR_TYPE_LLM_API = "LLM_API_ERROR" ERROR_TYPE_RETRIEVAL = "RETRIEVAL_ERROR" @@ -160,17 +159,22 @@ def _answer_single_dormitory_chat( try: query_embedding = create_query_embedding(question) - chunks = search_similar_chunks( + chunks = search_hybrid_chunks( db=db, + query_text=question, query_embedding=query_embedding, dormitory=dormitory, top_k=settings.chat_single_dormitory_top_k, + candidate_k=20, + vector_weight=0.7, + keyword_weight=0.3, ) # 1차 검색 결과가 없거나 유사도가 낮으면 전체 생활관 fallback 검색 if _should_fallback_retrieval(chunks): - chunks = search_similar_chunks_all_dormitories( + chunks = search_hybrid_chunks_all_dormitories( db=db, + query_text=question, query_embedding=query_embedding, top_k=settings.chat_fallback_top_k, ) @@ -215,8 +219,9 @@ def _answer_single_dormitory_chat( answer_result.answer.strip() == settings.chat_no_answer_message and retrieval_method != settings.chat_retrieval_method_fallback ): - fallback_chunks = search_similar_chunks_all_dormitories( + fallback_chunks = search_hybrid_chunks_all_dormitories( db=db, + query_text=question, query_embedding=query_embedding, top_k=settings.chat_fallback_top_k, ) @@ -295,8 +300,9 @@ def _answer_unspecified_dormitory_chat( ) raise try: - chunks = search_similar_chunks_for_dormitories( + chunks = search_hybrid_chunks_for_dormitories( db=db, + query_text=question, query_embedding=query_embedding, dormitories=settings.chat_grouped_dormitories, top_k=settings.chat_grouped_dormitory_top_k, diff --git a/tests/test_chat_service.py b/tests/test_chat_service.py index d8c06d3..bc999b2 100644 --- a/tests/test_chat_service.py +++ b/tests/test_chat_service.py @@ -87,7 +87,7 @@ def test_answer_chat_question_returns_success_for_single_dormitory(monkeypatch: monkeypatch.setattr(chat_service, "create_query_embedding", lambda *_args, **_kwargs: [0.1, 0.2, 0.3]) monkeypatch.setattr( chat_service, - "search_similar_chunks", + "search_hybrid_chunks", lambda *_args, **_kwargs: [ { "regulation_chunk_id": 1001, @@ -181,7 +181,7 @@ def test_answer_chat_question_uses_top_scored_chunks_when_dormitory_is_missing( monkeypatch.setattr(chat_service, "validate_question", lambda *_args, **_kwargs: (True, "택배는 어디서 받나요?")) monkeypatch.setattr(chat_service, "create_query_embedding", lambda *_args, **_kwargs: [0.1, 0.2, 0.3]) - def fake_search_similar_chunks_for_dormitories(*_args, **kwargs): + def fake_search_hybrid_chunks_for_dormitories(*_args, **kwargs): multi_search_calls.append(kwargs) return [ { @@ -218,13 +218,8 @@ def fake_generate_answer(_question, chunks): monkeypatch.setattr( chat_service, - "search_similar_chunks", - lambda *_args, **_kwargs: pytest.fail("unspecified dormitory should use a single multi-dormitory query"), - ) - monkeypatch.setattr( - chat_service, - "search_similar_chunks_for_dormitories", - fake_search_similar_chunks_for_dormitories, + "search_hybrid_chunks_for_dormitories", + fake_search_hybrid_chunks_for_dormitories, ) monkeypatch.setattr(chat_service, "generate_answer", fake_generate_answer) monkeypatch.setattr( @@ -340,7 +335,7 @@ def test_answer_chat_question_marks_error_when_generation_fails(monkeypatch: pyt monkeypatch.setattr(chat_service, "create_query_embedding", lambda *_args, **_kwargs: [0.1, 0.2, 0.3]) monkeypatch.setattr( chat_service, - "search_similar_chunks", + "search_hybrid_chunks", lambda *_args, **_kwargs: [ { "regulation_chunk_id": 1001, @@ -388,14 +383,14 @@ def raise_generation_error(*_args, **_kwargs): assert chat_log.answer_status == ChatAnswerStatus.ERROR assert chat_log.rewritten_query == "외박 신청은 어디서 하나요?" assert chat_log.answer == "" - assert retrieval_calls["chat_log_id"] == 501 + assert retrieval_calls == {} assert error_log_calls["chat_log_id"] == 501 assert error_log_calls["session_id"] == "session-123" assert error_log_calls["error_type"] == chat_service.ERROR_TYPE_LLM_API assert error_log_calls["occurred_step"] == chat_service.STEP_ANSWER_GENERATION assert error_log_calls["error_message"] == "llm failed" assert error_log_calls["error_detail"] == "RuntimeError: llm failed" - assert db.commit_count == 2 + assert db.commit_count == 1 assert db.close_count == 0 assert finalize_db.commit_count == 1 assert db.flush_count == 0 diff --git a/tests/test_regulation_chunk_repository.py b/tests/test_regulation_chunk_repository.py index fe6acbd..b4d7e39 100644 --- a/tests/test_regulation_chunk_repository.py +++ b/tests/test_regulation_chunk_repository.py @@ -94,3 +94,77 @@ def execute(self, statement): assert result == 3 assert len(executed_statements) == 1 + + +def test_search_hybrid_chunks_maps_hybrid_score_to_similarity() -> None: + executed_params: list[dict] = [] + + class MappingResult: + def mappings(self): + return self + + def all(self): + return [ + SimpleNamespace( + regulation_chunk_id=1001, + document_id="dorm-rule", + document_version="v1", + chunk_id="chunk-1", + content="외박 신청은 포털에서 가능합니다.", + source="생활관 규정집", + source_url="https://example.com/rules/1", + dormitory="제1학생생활관", + vector_similarity=0.82, + keyword_score=0.4, + normalized_keyword_score=1.0, + vector_rank=2, + keyword_rank=1, + hybrid_score=0.91, + ) + ] + + class HybridSession: + def execute(self, _statement, params): + executed_params.append(params) + return MappingResult() + + result = regulation_chunk_repository.search_hybrid_chunks( + db=HybridSession(), + query_text=" 외박 신청 ", + query_embedding=[0.1, 0.2, 0.3], + dormitory="제1학생생활관", + top_k=3, + ) + + assert executed_params[0]["query_text"] == "외박 신청" + assert executed_params[0]["dormitory"] == "제1학생생활관" + assert result[0]["similarity"] == 0.91 + assert result[0]["vector_similarity"] == 0.82 + assert result[0]["keyword_score"] == 0.4 + assert result[0]["normalized_keyword_score"] == 1.0 + + +def test_search_hybrid_chunks_for_dormitories_passes_dormitory_list() -> None: + executed_params: list[dict] = [] + + class EmptyMappingResult: + def mappings(self): + return self + + def all(self): + return [] + + class HybridSession: + def execute(self, _statement, params): + executed_params.append(params) + return EmptyMappingResult() + + result = regulation_chunk_repository.search_hybrid_chunks_for_dormitories( + db=HybridSession(), + query_text="택배", + query_embedding=[0.1, 0.2, 0.3], + dormitories=["제1학생생활관", "제2학생생활관"], + ) + + assert result == [] + assert executed_params[0]["dormitories"] == ["제1학생생활관", "제2학생생활관"] From 22e8d562604e6d092fde6d21c00d1df81242373a Mon Sep 17 00:00:00 2001 From: kimssirr Date: Fri, 1 May 2026 10:00:00 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EC=A7=88=EB=AC=B8=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5=20=EA=B2=80=EC=83=89=EA=B3=BC=20=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=EA=B7=BC=EA=B1=B0=20=EC=B2=98=EB=A6=AC=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/config.py | 6 + app/services/chat_service.py | 368 ++++++++++++++++++++++++++++++--- app/services/generator.py | 108 +++++++++- app/services/query_rewriter.py | 129 ++++++++++++ tests/test_chat_service.py | 95 +++++++++ 5 files changed, 666 insertions(+), 40 deletions(-) create mode 100644 app/services/query_rewriter.py diff --git a/app/core/config.py b/app/core/config.py index 3913307..48a6a39 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 = "hybrid_query_expansion_fallback" + chat_retrieval_version_query_expansion: str = "hybrid-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 a1e58d5..a795456 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -30,6 +30,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 ERROR_TYPE_TIMEOUT = "TIMEOUT" @@ -155,18 +156,27 @@ 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_hybrid_chunks( db=db, - query_text=question, + query_text=retrieval_query, query_embedding=query_embedding, dormitory=dormitory, top_k=settings.chat_single_dormitory_top_k, candidate_k=20, - vector_weight=0.7, keyword_weight=0.3, ) @@ -174,7 +184,7 @@ def _answer_single_dormitory_chat( if _should_fallback_retrieval(chunks): chunks = search_hybrid_chunks_all_dormitories( db=db, - query_text=question, + query_text=retrieval_query, query_embedding=query_embedding, top_k=settings.chat_fallback_top_k, ) @@ -216,12 +226,12 @@ 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_hybrid_chunks_all_dormitories( db=db, - query_text=question, + query_text=retrieval_query, query_embedding=query_embedding, top_k=settings.chat_fallback_top_k, ) @@ -237,6 +247,64 @@ 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_hybrid_chunks( + db=db, + query_text=expanded_query, + 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_hybrid_chunks_all_dormitories( + db=db, + query_text=expanded_query, + 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( @@ -266,13 +334,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, @@ -290,8 +367,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, @@ -299,10 +389,11 @@ def _answer_unspecified_dormitory_chat( occurred_step=STEP_RETRIEVAL, ) raise + try: chunks = search_hybrid_chunks_for_dormitories( db=db, - query_text=question, + query_text=retrieval_query, query_embedding=query_embedding, dormitories=settings.chat_grouped_dormitories, top_k=settings.chat_grouped_dormitory_top_k, @@ -323,19 +414,70 @@ 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_hybrid_chunks_for_dormitories( + db=db, + query_text=expanded_query, + 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( @@ -344,27 +486,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, @@ -533,8 +675,174 @@ def _should_fallback_retrieval(chunks: list[dict]) -> bool: if not chunks: return True - top_similarity = chunks[0].get("similarity") - if top_similarity is None: + top_vector_score = chunks[0].get("vector_score") + if top_vector_score is None: + top_vector_score = chunks[0].get("vector_similarity") + if top_vector_score is None: + top_vector_score = chunks[0].get("similarity") + if top_vector_score is None: + return True + + return float(top_vector_score) < 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 + + atm_triggers = [ + "atm", + "atm기", + "에이티엠", + "현금인출", + "현금뽑", + "은행", + "자동화기기", + "현금자동입출금기", + ] + + if any(trigger in compact_question.lower() for trigger in atm_triggers): + return True + + dormitory_alias_triggers = [ + "1긱", + "2긱", + "3긱", + ] + + if any(trigger in compact_question for trigger in dormitory_alias_triggers): + return True + + cooking_triggers = [ + "라면끓", + "라면먹", + "끓여먹", + "끓여", + "방에서라면", + "취사", + "조리", + "요리", + "해먹", + "해먹어", + "해먹어도", + "음식해", + "음식해먹", + "전기포트", + "라면포트", + "에어프라이어", + "커피포트", + ] + + if any(trigger in compact_question for trigger in cooking_triggers): return True - return float(top_similarity) < settings.chat_fallback_similarity_threshold + return False 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..0b29bb6 --- /dev/null +++ b/app/services/query_rewriter.py @@ -0,0 +1,129 @@ +"""사용자 질문을 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.사용자가 전자레인지, 음식 데우기, 데워먹기, 휴게실 전자레인지 위치를 물으면 휴게실, 공용시설, 전자레인지, 음식 데우기, 정수기, 싱크대 키워드를 포함해라. +14. 사용자가 ATM, atm, 에이티엠, 현금 인출, 은행 자동화기기 위치를 물으면 ATM, 현금자동입출금기, 자동화기기, 현금 인출, 은행, 편의시설 키워드를 포함해라. +15. 사용자가 1긱, 2긱, 3긱처럼 생활관 약칭을 쓰면 각각 제1학생생활관/1관, 제2학생생활관/2관, 제3학생생활관/3관 키워드를 포함해라. + + +예시: +사용자 질문: 새벽 2시에 들어가도 돼? +검색용 질의: 새벽 2시에 들어가도 돼? 검색 키워드: 폐문시간, 개문시간, 출입통제, 출입 가능 시간, 생활관 이용안내, 오전 1시, 오전 5시 + +사용자 질문: 음료수 살만한 곳 있어? +검색용 질의: 음료수 살만한 곳 있어? 검색 키워드: 편의점, 매점, 편의시설, 음료 구매, 간식 구매, 생활용품 구매 위치 + +사용자 질문: 방에서 라면 먹어도 돼? +검색용 질의: 방에서 라면 먹어도 돼? 검색 키워드: 호실 내 취사, 라면포트, 전열기구, 반입금지 물품, 취사 금지 + +사용자 질문: 방에서 라면 끓여 먹어도 돼? +검색용 질의: 방에서 라면 끓여 먹어도 돼? 검색 키워드: 호실 내 취사, 취사행위, 반입금지 물품, 전열기구, 라면포트, 전기포트, 화재위험 + +사용자 질문: 와이파이 비밀번호 뭐야? +검색용 질의: 와이파이 비밀번호 뭐야? 검색 키워드: 무선인터넷, 네트워크, wifi, 인터넷 비밀번호 + +사용자 질문: 음료수 사 먹을만한 곳 있어? +검색용 질의: 음료수 사 먹을만한 곳 있어? 검색 키워드: 편의점, 매점, 편의시설, 음료 구매, 간식 구매, 물 구매, 생활용품 구매 위치 + +사용자 질문: 기숙사 통금 시간 언제야? +검색용 질의: 기숙사 통금 시간 언제야? 검색 키워드: 폐문시간, 개문시간, 출입통제, 출입 가능 시간, 생활관 이용안내, 오전 1시, 오전 5시 + +사용자 질문: 통금 언제야? +검색용 질의: 통금 언제야? 검색 키워드: 폐문시간, 개문시간, 출입통제, 출입 가능 시간, 생활관 이용안내, 오전 1시, 오전 5시 + +사용자 질문: 전자레인지 어디있어? +검색용 질의: 전자레인지 어디있어? 검색 키워드: 휴게실, 공용시설, 전자레인지, 음식 데우기, 편의시설 + +사용자 질문: 음식 데워먹을 수 있어? +검색용 질의: 음식 데워먹을 수 있어? 검색 키워드: 휴게실, 공용시설, 전자레인지, 음식 데우기, 편의시설 + +사용자 질문: 2긱에 atm기 있어? +검색용 질의: 2긱에 atm기 있어? 검색 키워드: 제2학생생활관, 2관, ATM, 현금자동입출금기, 자동화기기, 현금 인출, 은행, 편의시설 + +사용자 생활관: +{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 diff --git a/tests/test_chat_service.py b/tests/test_chat_service.py index bc999b2..7fb41fd 100644 --- a/tests/test_chat_service.py +++ b/tests/test_chat_service.py @@ -161,6 +161,76 @@ def test_answer_chat_question_returns_success_for_single_dormitory(monkeypatch: assert db.rollback_count == 0 +def test_answer_chat_question_uses_expanded_query_for_embedding_and_keywords( + monkeypatch: pytest.MonkeyPatch, +) -> None: + db = FakeSession() + finalize_db = FakeSession() + chat_session = _build_chat_session() + chat_log = _build_chat_log() + expanded_query = "2긱에 atm기 있어? 검색 키워드: 제2학생생활관, 2관, ATM, 현금자동입출금기, 자동화기기" + embedded_texts: list[str] = [] + search_calls: list[dict] = [] + + monkeypatch.setattr(chat_service, "get_chat_session", lambda *_args, **_kwargs: chat_session) + monkeypatch.setattr(chat_service, "create_chat_log", lambda *_args, **_kwargs: chat_log) + monkeypatch.setattr(chat_service, "get_chat_log_by_id", lambda *_args, **_kwargs: chat_log) + monkeypatch.setattr(chat_service, "touch_chat_session_activity", lambda *_args, **_kwargs: chat_session) + monkeypatch.setattr(chat_service, "get_session_factory", lambda: (lambda: finalize_db)) + monkeypatch.setattr(chat_service, "validate_question", lambda *_args, **_kwargs: (True, "2긱에 atm기 있어?")) + monkeypatch.setattr( + chat_service, + "expand_query_for_retrieval", + lambda *_args, **_kwargs: expanded_query, + ) + monkeypatch.setattr( + chat_service, + "create_query_embedding", + lambda text: embedded_texts.append(text) or [0.1, 0.2, 0.3], + ) + + def fake_search_hybrid_chunks(*_args, **kwargs): + search_calls.append(kwargs) + return [ + { + "regulation_chunk_id": 1003, + "document_id": "facility_usage_017", + "document_version": "v1", + "chunk_id": "chunk-1", + "content": "제2학생생활관 ATM은 지하 편의시설 근처에 있습니다.", + "source": "생활관 시설 안내", + "source_url": "https://example.com/rules/3", + "similarity": 0.9, + } + ] + + monkeypatch.setattr(chat_service, "search_hybrid_chunks", fake_search_hybrid_chunks) + monkeypatch.setattr( + chat_service, + "generate_answer", + lambda *_args, **_kwargs: AnswerGenerationResult( + answer="제2학생생활관 ATM은 지하 편의시설 근처에 있습니다.", + source_url="https://example.com/rules/3", + cited_regulation_chunk_ids=[1003], + ), + ) + monkeypatch.setattr(chat_service, "create_chat_retrieval_results", lambda *_args, **_kwargs: []) + monkeypatch.setattr(chat_service, "mark_chat_retrieval_results_used_in_answer", lambda *_args, **_kwargs: []) + + chat_service.answer_chat_question( + db, + ChatRequest( + session_id="session-123", + question="2긱에 atm기 있어?", + dormitory="제2학생생활관", + ), + ) + + assert embedded_texts == [expanded_query] + assert search_calls[0]["query_text"] == expanded_query + assert chat_log.rewritten_query == expanded_query + + def test_answer_chat_question_uses_top_scored_chunks_when_dormitory_is_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -430,3 +500,28 @@ def test_build_chat_error_metadata_defaults_timeout_error() -> None: assert result.error_type == chat_service.ERROR_TYPE_TIMEOUT assert result.occurred_step is None assert result.error_message == "request timed out" + + +def test_should_fallback_retrieval_uses_vector_score_before_hybrid_similarity() -> None: + chunks = [ + { + "similarity": 0.9, + "vector_score": 0.2, + } + ] + + assert chat_service._should_fallback_retrieval(chunks) is True + + +def test_should_fallback_retrieval_falls_back_to_similarity_for_legacy_results() -> None: + chunks = [ + { + "similarity": 0.9, + } + ] + + assert chat_service._should_fallback_retrieval(chunks) is False + + +def test_should_pre_expand_query_for_atm_and_dormitory_alias() -> None: + assert chat_service._should_pre_expand_query("2긱에 atm기 있어?") is True From 567ec7400298c169770112fd1fa487776099f7e1 Mon Sep 17 00:00:00 2001 From: kimssirr Date: Sun, 3 May 2026 10:00:00 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=ED=95=98=EC=9D=B4=EB=B8=8C=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=EC=A0=90=EC=88=98=EC=99=80=20=EB=B2=A1=ED=84=B0=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20fallback=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../regulation_chunk_repository.py | 26 +++++-------------- tests/test_regulation_chunk_repository.py | 6 +++-- 2 files changed, 11 insertions(+), 21 deletions(-) diff --git a/app/repositories/regulation_chunk_repository.py b/app/repositories/regulation_chunk_repository.py index 80cfd0b..22bb337 100644 --- a/app/repositories/regulation_chunk_repository.py +++ b/app/repositories/regulation_chunk_repository.py @@ -271,7 +271,6 @@ def search_hybrid_chunks( dormitory: str, top_k: int = 3, candidate_k: int = 20, - vector_weight: float = 0.7, keyword_weight: float = 0.3, ): """단일 생활관과 공통 문서를 대상으로 하이브리드 검색합니다.""" @@ -282,7 +281,6 @@ def search_hybrid_chunks( query_embedding=query_embedding, top_k=top_k, candidate_k=candidate_k, - vector_weight=vector_weight, keyword_weight=keyword_weight, filter_sql="(rd.dormitory = :dormitory OR rd.dormitory IS NULL)", params={"dormitory": dormitory}, @@ -296,7 +294,6 @@ def search_hybrid_chunks_for_dormitories( dormitories: list[str], top_k: int = 3, candidate_k: int = 20, - vector_weight: float = 0.7, keyword_weight: float = 0.3, ): """여러 생활관과 공통 문서를 대상으로 하이브리드 검색합니다.""" @@ -307,7 +304,6 @@ def search_hybrid_chunks_for_dormitories( query_embedding=query_embedding, top_k=top_k, candidate_k=candidate_k, - vector_weight=vector_weight, keyword_weight=keyword_weight, filter_sql="(rd.dormitory = ANY(:dormitories) OR rd.dormitory IS NULL)", params={"dormitories": dormitories}, @@ -320,7 +316,6 @@ def search_hybrid_chunks_all_dormitories( query_embedding: list[float], top_k: int = 5, candidate_k: int = 30, - vector_weight: float = 0.7, keyword_weight: float = 0.3, ): """생활관 필터 없이 전체 활성 regulation_chunk를 대상으로 하이브리드 검색합니다.""" @@ -331,7 +326,6 @@ def search_hybrid_chunks_all_dormitories( query_embedding=query_embedding, top_k=top_k, candidate_k=candidate_k, - vector_weight=vector_weight, keyword_weight=keyword_weight, filter_sql="TRUE", params={}, @@ -345,7 +339,6 @@ def _search_hybrid_chunks( query_embedding: list[float], top_k: int, candidate_k: int, - vector_weight: float, keyword_weight: float, filter_sql: str, params: dict, @@ -463,22 +456,16 @@ def _search_hybrid_chunks( scored AS ( SELECT *, + LEAST(1, GREATEST(0, COALESCE(vector_similarity, 0))) AS vector_score, COALESCE(keyword_score / NULLIF(MAX(keyword_score) OVER (), 0), 0) AS normalized_keyword_score, - CASE - WHEN keyword_score IS NULL OR keyword_score = 0 THEN :vector_weight - ELSE :vector_weight + :keyword_weight - END AS available_weight, ( - (:vector_weight * COALESCE(vector_similarity, 0)) + + LEAST(1, GREATEST(0, COALESCE(vector_similarity, 0))) + ( :keyword_weight * - COALESCE(keyword_score / NULLIF(MAX(keyword_score) OVER (), 0), 0) + COALESCE(keyword_score / NULLIF(MAX(keyword_score) OVER (), 0), 0) * + (1 - LEAST(1, GREATEST(0, COALESCE(vector_similarity, 0)))) ) - ) / - CASE - WHEN keyword_score IS NULL OR keyword_score = 0 THEN :vector_weight - ELSE :vector_weight + :keyword_weight - END AS hybrid_score + ) AS hybrid_score FROM dedup ) @@ -492,6 +479,7 @@ def _search_hybrid_chunks( source_url, dormitory, vector_similarity, + vector_score, keyword_score, normalized_keyword_score, vector_rank, @@ -510,7 +498,6 @@ def _search_hybrid_chunks( "query_text": query_text.strip(), "top_k": top_k, "candidate_k": candidate_k, - "vector_weight": vector_weight, "keyword_weight": keyword_weight, **params, }, @@ -528,6 +515,7 @@ def _search_hybrid_chunks( "retrieval_group": row.dormitory, "similarity": float(row.hybrid_score), "vector_similarity": float(row.vector_similarity) if row.vector_similarity is not None else None, + "vector_score": float(row.vector_score) if row.vector_score is not None else None, "keyword_score": float(row.keyword_score) if row.keyword_score is not None else None, "normalized_keyword_score": ( float(row.normalized_keyword_score) diff --git a/tests/test_regulation_chunk_repository.py b/tests/test_regulation_chunk_repository.py index b4d7e39..5ba7ccd 100644 --- a/tests/test_regulation_chunk_repository.py +++ b/tests/test_regulation_chunk_repository.py @@ -115,11 +115,12 @@ def all(self): source_url="https://example.com/rules/1", dormitory="제1학생생활관", vector_similarity=0.82, + vector_score=0.82, keyword_score=0.4, normalized_keyword_score=1.0, vector_rank=2, keyword_rank=1, - hybrid_score=0.91, + hybrid_score=0.874, ) ] @@ -138,8 +139,9 @@ def execute(self, _statement, params): assert executed_params[0]["query_text"] == "외박 신청" assert executed_params[0]["dormitory"] == "제1학생생활관" - assert result[0]["similarity"] == 0.91 + assert result[0]["similarity"] == 0.874 assert result[0]["vector_similarity"] == 0.82 + assert result[0]["vector_score"] == 0.82 assert result[0]["keyword_score"] == 0.4 assert result[0]["normalized_keyword_score"] == 1.0 From 309490d7f69e3a431cd52c554141b7fdcadd4c6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=82=AC=EB=9D=BC?= Date: Sun, 3 May 2026 21:33:28 +0900 Subject: [PATCH 4/7] Update chat_service.py --- app/services/chat_service.py | 1 - 1 file changed, 1 deletion(-) diff --git a/app/services/chat_service.py b/app/services/chat_service.py index a795456..718eef5 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -332,7 +332,6 @@ def _answer_single_dormitory_chat( raise db.commit() - db.close() final_answer_status = ChatAnswerStatus.SUCCESS final_source_url = answer_result.source_url or "" From 4dbf8da1c5c577cfb2ca509d75fec0323a9f58b0 Mon Sep 17 00:00:00 2001 From: kimssirr Date: Sun, 3 May 2026 11:00:00 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C?= =?UTF-8?q?=20=EA=B2=80=EC=83=89=20=ED=91=9C=ED=98=84=20=EB=B3=B4=EA=B0=95?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/services/chat_service.py | 23 ----------------------- app/services/query_rewriter.py | 7 ------- tests/test_chat_service.py | 16 ++++++++-------- 3 files changed, 8 insertions(+), 38 deletions(-) diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 718eef5..7287564 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -798,29 +798,6 @@ def _should_pre_expand_query(question: str) -> bool: if any(trigger in compact_question for trigger in microwave_triggers): return True - atm_triggers = [ - "atm", - "atm기", - "에이티엠", - "현금인출", - "현금뽑", - "은행", - "자동화기기", - "현금자동입출금기", - ] - - if any(trigger in compact_question.lower() for trigger in atm_triggers): - return True - - dormitory_alias_triggers = [ - "1긱", - "2긱", - "3긱", - ] - - if any(trigger in compact_question for trigger in dormitory_alias_triggers): - return True - cooking_triggers = [ "라면끓", "라면먹", diff --git a/app/services/query_rewriter.py b/app/services/query_rewriter.py index 0b29bb6..cb12426 100644 --- a/app/services/query_rewriter.py +++ b/app/services/query_rewriter.py @@ -56,10 +56,6 @@ def expand_query_for_retrieval( 단, 사용자가 휴게실을 직접 언급하지 않았다면 휴게실 통금보다 생활관 출입 통금으로 해석해라. 12.사용자가 "먹을 거", "간단하게 먹을 곳", "먹을거 해결", "사먹을 곳"처럼 식사/간식 해결 장소를 물으면 학생식당, 학식, 편의점, 매점, 배달음식 수령 키워드를 함께 포함해라. 13.사용자가 전자레인지, 음식 데우기, 데워먹기, 휴게실 전자레인지 위치를 물으면 휴게실, 공용시설, 전자레인지, 음식 데우기, 정수기, 싱크대 키워드를 포함해라. -14. 사용자가 ATM, atm, 에이티엠, 현금 인출, 은행 자동화기기 위치를 물으면 ATM, 현금자동입출금기, 자동화기기, 현금 인출, 은행, 편의시설 키워드를 포함해라. -15. 사용자가 1긱, 2긱, 3긱처럼 생활관 약칭을 쓰면 각각 제1학생생활관/1관, 제2학생생활관/2관, 제3학생생활관/3관 키워드를 포함해라. - - 예시: 사용자 질문: 새벽 2시에 들어가도 돼? 검색용 질의: 새벽 2시에 들어가도 돼? 검색 키워드: 폐문시간, 개문시간, 출입통제, 출입 가능 시간, 생활관 이용안내, 오전 1시, 오전 5시 @@ -91,9 +87,6 @@ def expand_query_for_retrieval( 사용자 질문: 음식 데워먹을 수 있어? 검색용 질의: 음식 데워먹을 수 있어? 검색 키워드: 휴게실, 공용시설, 전자레인지, 음식 데우기, 편의시설 -사용자 질문: 2긱에 atm기 있어? -검색용 질의: 2긱에 atm기 있어? 검색 키워드: 제2학생생활관, 2관, ATM, 현금자동입출금기, 자동화기기, 현금 인출, 은행, 편의시설 - 사용자 생활관: {dormitory_text} diff --git a/tests/test_chat_service.py b/tests/test_chat_service.py index 7fb41fd..07e7aa9 100644 --- a/tests/test_chat_service.py +++ b/tests/test_chat_service.py @@ -168,7 +168,7 @@ def test_answer_chat_question_uses_expanded_query_for_embedding_and_keywords( finalize_db = FakeSession() chat_session = _build_chat_session() chat_log = _build_chat_log() - expanded_query = "2긱에 atm기 있어? 검색 키워드: 제2학생생활관, 2관, ATM, 현금자동입출금기, 자동화기기" + expanded_query = "전자레인지 어디있어? 검색 키워드: 휴게실, 공용시설, 전자레인지, 음식 데우기, 편의시설" embedded_texts: list[str] = [] search_calls: list[dict] = [] @@ -177,7 +177,7 @@ def test_answer_chat_question_uses_expanded_query_for_embedding_and_keywords( monkeypatch.setattr(chat_service, "get_chat_log_by_id", lambda *_args, **_kwargs: chat_log) monkeypatch.setattr(chat_service, "touch_chat_session_activity", lambda *_args, **_kwargs: chat_session) monkeypatch.setattr(chat_service, "get_session_factory", lambda: (lambda: finalize_db)) - monkeypatch.setattr(chat_service, "validate_question", lambda *_args, **_kwargs: (True, "2긱에 atm기 있어?")) + monkeypatch.setattr(chat_service, "validate_question", lambda *_args, **_kwargs: (True, "전자레인지 어디있어?")) monkeypatch.setattr( chat_service, "expand_query_for_retrieval", @@ -194,10 +194,10 @@ def fake_search_hybrid_chunks(*_args, **kwargs): return [ { "regulation_chunk_id": 1003, - "document_id": "facility_usage_017", + "document_id": "facility_usage_011", "document_version": "v1", "chunk_id": "chunk-1", - "content": "제2학생생활관 ATM은 지하 편의시설 근처에 있습니다.", + "content": "전자레인지는 휴게실 공용시설에 있습니다.", "source": "생활관 시설 안내", "source_url": "https://example.com/rules/3", "similarity": 0.9, @@ -209,7 +209,7 @@ def fake_search_hybrid_chunks(*_args, **kwargs): chat_service, "generate_answer", lambda *_args, **_kwargs: AnswerGenerationResult( - answer="제2학생생활관 ATM은 지하 편의시설 근처에 있습니다.", + answer="전자레인지는 휴게실 공용시설에 있습니다.", source_url="https://example.com/rules/3", cited_regulation_chunk_ids=[1003], ), @@ -221,7 +221,7 @@ def fake_search_hybrid_chunks(*_args, **kwargs): db, ChatRequest( session_id="session-123", - question="2긱에 atm기 있어?", + question="전자레인지 어디있어?", dormitory="제2학생생활관", ), ) @@ -523,5 +523,5 @@ def test_should_fallback_retrieval_falls_back_to_similarity_for_legacy_results() assert chat_service._should_fallback_retrieval(chunks) is False -def test_should_pre_expand_query_for_atm_and_dormitory_alias() -> None: - assert chat_service._should_pre_expand_query("2긱에 atm기 있어?") is True +def test_should_pre_expand_query_for_microwave_question() -> None: + assert chat_service._should_pre_expand_query("전자레인지 어디있어?") is True From a78676ad6be8116141063ea180498261dd3ae150 Mon Sep 17 00:00:00 2001 From: kimssirr Date: Sun, 3 May 2026 12:00:00 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20=ED=95=98=EC=9D=B4=EB=B8=8C=EB=A6=AC?= =?UTF-8?q?=EB=93=9C=20=EA=B2=80=EC=83=89=20=EC=99=B8=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/core/config.py | 6 - app/services/chat_service.py | 335 +++------------------------------ app/services/generator.py | 108 +---------- app/services/query_rewriter.py | 122 ------------ tests/test_chat_service.py | 74 -------- 5 files changed, 37 insertions(+), 608 deletions(-) delete mode 100644 app/services/query_rewriter.py diff --git a/app/core/config.py b/app/core/config.py index 48a6a39..3913307 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -38,12 +38,6 @@ 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 = "hybrid_query_expansion_fallback" - chat_retrieval_version_query_expansion: str = "hybrid-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 7287564..105e979 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -30,7 +30,6 @@ 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 ERROR_TYPE_TIMEOUT = "TIMEOUT" @@ -156,23 +155,13 @@ def _answer_single_dormitory_chat( retrieval_method = settings.chat_retrieval_method_single retrieval_version = settings.chat_retrieval_version_single - rewritten_query = question try: - 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) + query_embedding = create_query_embedding(question) chunks = search_hybrid_chunks( db=db, - query_text=retrieval_query, + query_text=question, query_embedding=query_embedding, dormitory=dormitory, top_k=settings.chat_single_dormitory_top_k, @@ -184,7 +173,7 @@ def _answer_single_dormitory_chat( if _should_fallback_retrieval(chunks): chunks = search_hybrid_chunks_all_dormitories( db=db, - query_text=retrieval_query, + query_text=question, query_embedding=query_embedding, top_k=settings.chat_fallback_top_k, ) @@ -226,12 +215,12 @@ def _answer_single_dormitory_chat( # 검색 결과는 있었지만 답변 생성기가 "관련 정보를 찾을 수 없습니다."라고 한 경우 # 아직 fallback 검색을 하지 않은 상태라면 전체 생활관 검색으로 한 번 더 시도 if ( - _is_no_answer(answer_result.answer) + answer_result.answer.strip() == settings.chat_no_answer_message and retrieval_method != settings.chat_retrieval_method_fallback ): fallback_chunks = search_hybrid_chunks_all_dormitories( db=db, - query_text=retrieval_query, + query_text=question, query_embedding=query_embedding, top_k=settings.chat_fallback_top_k, ) @@ -247,64 +236,6 @@ 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_hybrid_chunks( - db=db, - query_text=expanded_query, - 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_hybrid_chunks_all_dormitories( - db=db, - query_text=expanded_query, - 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( @@ -332,23 +263,15 @@ def _answer_single_dormitory_chat( raise db.commit() - - 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 = "" - - + db.close() return _finalize_chat_log_in_new_session( chat_log_id=chat_log_id, session_id=session_id, - answer_status=final_answer_status, + answer_status=ChatAnswerStatus.SUCCESS, answer=answer_result.answer, - source_url=final_source_url, - rewritten_query=rewritten_query, + source_url=answer_result.source_url or "", + rewritten_query=question, model_name=settings.chat_answer_model, prompt_version=settings.chat_prompt_version_single, retrieval_version=retrieval_version, @@ -366,21 +289,8 @@ 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: - 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) + query_embedding = create_query_embedding(question) except Exception as exc: _attach_chat_error_metadata( exc, @@ -388,11 +298,10 @@ def _answer_unspecified_dormitory_chat( occurred_step=STEP_RETRIEVAL, ) raise - try: chunks = search_hybrid_chunks_for_dormitories( db=db, - query_text=retrieval_query, + query_text=question, query_embedding=query_embedding, dormitories=settings.chat_grouped_dormitories, top_k=settings.chat_grouped_dormitory_top_k, @@ -413,70 +322,19 @@ def _answer_unspecified_dormitory_chat( answer_status=ChatAnswerStatus.NO_ANSWER, answer=settings.chat_no_answer_message, source_url="", - rewritten_query=rewritten_query, + rewritten_query=question, model_name=None, prompt_version=None, - retrieval_version=retrieval_version, + retrieval_version=settings.chat_retrieval_version_grouped, 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_hybrid_chunks_for_dormitories( - db=db, - query_text=expanded_query, - 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=retrieval_method, + retrieval_method=settings.chat_retrieval_method_grouped, ) except Exception as exc: _attach_chat_error_metadata( @@ -485,27 +343,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=final_answer_status, + answer_status=ChatAnswerStatus.SUCCESS, answer=answer_result.answer, - source_url=final_source_url, - rewritten_query=rewritten_query, + source_url=answer_result.source_url or "", + rewritten_query=question, model_name=settings.chat_answer_model, prompt_version=settings.chat_prompt_version_grouped, - retrieval_version=retrieval_version, + retrieval_version=settings.chat_retrieval_version_grouped, response_time_ms=_elapsed_ms(started_at), mark_retrieval_used=True, cited_regulation_chunk_ids=answer_result.cited_regulation_chunk_ids, @@ -683,142 +541,3 @@ def _should_fallback_retrieval(chunks: list[dict]) -> bool: return True return float(top_vector_score) < 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 diff --git a/app/services/generator.py b/app/services/generator.py index 0dfd33b..054a9a9 100644 --- a/app/services/generator.py +++ b/app/services/generator.py @@ -1,4 +1,3 @@ -import json import re from dataclasses import dataclass from typing import Optional @@ -59,29 +58,13 @@ def generate_answer( 너는 기숙사 안내 챗봇이다. 아래 제공된 정보를 기반으로만 질문에 답변해라. 모르는 내용은 추측하지 말고 모른다고 말해라. -참고 정보에 없는 일반 상식이나 추측으로 답하지 마라. -"일반적으로", "가능성이 높습니다", "확인하는 것이 좋습니다"처럼 근거 없는 표현을 사용하지 마라. 질문에서 생활관을 특정하지 않았고 참고 정보가 특정 생활관에만 해당하면, 해당 생활관 기준 답변임을 명확히 밝혀라. 질문에서 생활관을 특정하지 않았더라도 생활관별 구분을 강제로 만들지 말고, 가장 관련 있는 정보 중심으로 간결하게 답변해라. 참고 정보에 생활관 구분이 없거나 공통 규정으로 보이면 일반 답변으로 안내해라. {fallback_instruction} - -반드시 아래 JSON 형식으로만 출력해라. -설명 문장, 마크다운, 코드블록은 출력하지 마라. - -형식: -{{ - "answer": "사용자에게 보여줄 최종 답변", - "used_reference_index": 1 -}} - -규칙: -- "answer"에는 답변 본문만 작성해라. -- "answer"에는 출처 문구를 넣지 마라. -- "used_reference_index"에는 답변 작성에 가장 직접적으로 사용한 참고 정보 번호를 넣어라. -- 여러 참고 정보를 사용했다면 가장 핵심 근거가 되는 참고 정보 번호 하나만 넣어라. -- 질문에 답할 정보가 충분하지 않으면 "answer"는 정확히 "{settings.chat_no_answer_message}"로 작성하고, "used_reference_index"는 null로 작성해라. -- 참고 정보 번호는 [참고 정보 1], [참고 정보 2]의 숫자를 기준으로 한다. +답변에는 `[C1]`, `[C2]` 같은 내부 근거 라벨을 절대 출력하지 마라. +출처 문구는 서버가 별도로 붙이므로 답변 본문에는 출처 줄을 만들지 마라. +질문에 답할 정보가 충분하지 않으면 정확히 "{settings.chat_no_answer_message}"라고만 답해라. [질문] {question} @@ -98,24 +81,14 @@ def generate_answer( ] ) - 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, - ) - + raw_answer = response.choices[0].message.content.strip() + answer_without_labels = _strip_citation_labels(raw_answer) + source_chunk = _select_source_chunk(chunks) 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) @@ -130,6 +103,10 @@ 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( @@ -178,68 +155,3 @@ 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 deleted file mode 100644 index cb12426..0000000 --- a/app/services/query_rewriter.py +++ /dev/null @@ -1,122 +0,0 @@ -"""사용자 질문을 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 diff --git a/tests/test_chat_service.py b/tests/test_chat_service.py index 07e7aa9..9bcd781 100644 --- a/tests/test_chat_service.py +++ b/tests/test_chat_service.py @@ -161,76 +161,6 @@ def test_answer_chat_question_returns_success_for_single_dormitory(monkeypatch: assert db.rollback_count == 0 -def test_answer_chat_question_uses_expanded_query_for_embedding_and_keywords( - monkeypatch: pytest.MonkeyPatch, -) -> None: - db = FakeSession() - finalize_db = FakeSession() - chat_session = _build_chat_session() - chat_log = _build_chat_log() - expanded_query = "전자레인지 어디있어? 검색 키워드: 휴게실, 공용시설, 전자레인지, 음식 데우기, 편의시설" - embedded_texts: list[str] = [] - search_calls: list[dict] = [] - - monkeypatch.setattr(chat_service, "get_chat_session", lambda *_args, **_kwargs: chat_session) - monkeypatch.setattr(chat_service, "create_chat_log", lambda *_args, **_kwargs: chat_log) - monkeypatch.setattr(chat_service, "get_chat_log_by_id", lambda *_args, **_kwargs: chat_log) - monkeypatch.setattr(chat_service, "touch_chat_session_activity", lambda *_args, **_kwargs: chat_session) - monkeypatch.setattr(chat_service, "get_session_factory", lambda: (lambda: finalize_db)) - monkeypatch.setattr(chat_service, "validate_question", lambda *_args, **_kwargs: (True, "전자레인지 어디있어?")) - monkeypatch.setattr( - chat_service, - "expand_query_for_retrieval", - lambda *_args, **_kwargs: expanded_query, - ) - monkeypatch.setattr( - chat_service, - "create_query_embedding", - lambda text: embedded_texts.append(text) or [0.1, 0.2, 0.3], - ) - - def fake_search_hybrid_chunks(*_args, **kwargs): - search_calls.append(kwargs) - return [ - { - "regulation_chunk_id": 1003, - "document_id": "facility_usage_011", - "document_version": "v1", - "chunk_id": "chunk-1", - "content": "전자레인지는 휴게실 공용시설에 있습니다.", - "source": "생활관 시설 안내", - "source_url": "https://example.com/rules/3", - "similarity": 0.9, - } - ] - - monkeypatch.setattr(chat_service, "search_hybrid_chunks", fake_search_hybrid_chunks) - monkeypatch.setattr( - chat_service, - "generate_answer", - lambda *_args, **_kwargs: AnswerGenerationResult( - answer="전자레인지는 휴게실 공용시설에 있습니다.", - source_url="https://example.com/rules/3", - cited_regulation_chunk_ids=[1003], - ), - ) - monkeypatch.setattr(chat_service, "create_chat_retrieval_results", lambda *_args, **_kwargs: []) - monkeypatch.setattr(chat_service, "mark_chat_retrieval_results_used_in_answer", lambda *_args, **_kwargs: []) - - chat_service.answer_chat_question( - db, - ChatRequest( - session_id="session-123", - question="전자레인지 어디있어?", - dormitory="제2학생생활관", - ), - ) - - assert embedded_texts == [expanded_query] - assert search_calls[0]["query_text"] == expanded_query - assert chat_log.rewritten_query == expanded_query - - def test_answer_chat_question_uses_top_scored_chunks_when_dormitory_is_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -521,7 +451,3 @@ def test_should_fallback_retrieval_falls_back_to_similarity_for_legacy_results() ] assert chat_service._should_fallback_retrieval(chunks) is False - - -def test_should_pre_expand_query_for_microwave_question() -> None: - assert chat_service._should_pre_expand_query("전자레인지 어디있어?") is True From aeeffccbb06d1fff590a20d15c9ddf91b9f4d71b Mon Sep 17 00:00:00 2001 From: kimssirr Date: Sun, 3 May 2026 22:21:26 +0900 Subject: [PATCH 7/7] fix: keep chat db session open for finalization --- app/services/chat_service.py | 3 --- tests/test_chat_service.py | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/app/services/chat_service.py b/app/services/chat_service.py index 053b3bc..6d6d247 100644 --- a/app/services/chat_service.py +++ b/app/services/chat_service.py @@ -330,7 +330,6 @@ def _answer_single_dormitory_chat( raise db.commit() - db.close() final_answer_status = ChatAnswerStatus.SUCCESS final_source_url = answer_result.source_url or "" @@ -485,7 +484,6 @@ def _answer_unspecified_dormitory_chat( raise db.commit() - db.close() final_answer_status = ChatAnswerStatus.SUCCESS final_source_url = answer_result.source_url or "" @@ -821,4 +819,3 @@ def _should_pre_expand_query(question: str) -> bool: return True return False - diff --git a/tests/test_chat_service.py b/tests/test_chat_service.py index 9bcd781..1c98792 100644 --- a/tests/test_chat_service.py +++ b/tests/test_chat_service.py @@ -154,7 +154,7 @@ def test_answer_chat_question_returns_success_for_single_dormitory(monkeypatch: assert retrieval_calls["mark_used_chat_log_id"] == 501 assert retrieval_calls["cited_regulation_chunk_ids"] == [1001] assert db.commit_count == 2 - assert db.close_count == 1 + assert db.close_count == 0 assert finalize_db.commit_count == 1 assert db.flush_count == 0 assert finalize_db.flush_count == 1