From 026efa4eb6e249e0e3ab4fbe4e1aba08581f5220 Mon Sep 17 00:00:00 2001 From: jmj Date: Thu, 2 Jul 2026 15:45:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai):=20=ED=94=BC=EB=93=9C=EB=B0=B1/?= =?UTF-8?q?=EC=BD=94=EC=B9=AD=20RAG=20=EC=9E=AC=EC=A7=88=EC=9D=98=20(L8)?= =?UTF-8?q?=20=E2=80=94=20=EB=A7=88=EC=A7=80=EB=A7=89=EB=8B=B5=EB=B3=80=20?= =?UTF-8?q?=ED=8E=B8=ED=96=A5=C2=B7=EC=BD=94=EC=B9=AD=20=EB=82=A0=EC=A1=B0?= =?UTF-8?q?=20=EC=99=84=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 면접 품질 감사 L8. 기존엔 _build_rag_context 가 '마지막 INTERVIEWEE 답변' 하나만 검색해 그 컨텍스트를 세션 전체 채점(종합·패널·직무적합도) + 모든 답변 코칭에 공유 → 마지막 화제로 correctness 편향, 코칭 모범답안이 무관 청크로 경험 날조. - 검색 로직을 _retrieve_context(query, docIds, top_k)로 공용화. - 세션 채점: _session_rag_query(모든 실질 답변, 짧은 확인 제외, 2000자 상한)로 질의 → 마지막 답변 편향 제거(전부 확인형이면 마지막 답변 폴백). - 코칭: _coach_answers 가 (질문+답변) 쌍마다 개별 RAG(top_k=3)로 검색 → 모범답안이 해당 화제 근거에만 걸림. 동시성 세마포어는 그대로. - 실패/무결과/임베더 없음은 기존과 동일하게 '(none)' 폴백. - _session_rag_query 단위 테스트 5건 추가. 전체 305 passed, flake8 clean. 주: 채점/코칭 근거 분포가 바뀌는 변경 — 실제 품질은 라이브 before/after 로 확인 권장. Co-Authored-By: Claude Opus 4.8 --- .../messaging/consumers/feedback_consumer.py | 71 ++++++++++++++----- ai/tests/test_feedback_rag_query.py | 57 +++++++++++++++ 2 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 ai/tests/test_feedback_rag_query.py diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index 3e6fd031..4a75dc13 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -37,6 +37,10 @@ _SELF_INTRO_CATEGORY = "SELF_INTRODUCTION" _JOB_TAILORED_MODE = "JOB_TAILORED" +# 답변별 코칭 RAG 는 해당 화제에 국한된 소수 청크만. 세션 공용 top_k 보다 작게. +_COACHING_RAG_TOP_K = 3 +# 세션 RAG 질의 상한(문자). 답변 이어붙임이 임베딩 입력 한도를 넘지 않게. +_SESSION_RAG_QUERY_MAX_CHARS = 2000 class FeedbackConsumer: @@ -135,7 +139,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None: ), self._evaluate_self_intro(req, voice_analysis_summary), self._evaluate_job_fit(req, transcript, rag_context), - self._coach_answers(req, rag_context), + self._coach_answers(req), ) ) # 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지. @@ -250,9 +254,13 @@ async def _evaluate_job_fit( ] async def _coach_answers( - self, req: GenerateFeedbackRequest, rag_context: str + self, req: GenerateFeedbackRequest ) -> list[AnswerCoachingItem]: - """자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트.""" + """자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트. + + 코칭 근거(RAG)는 **답변별로 개별 검색**한다 — 세션 공용 컨텍스트를 쓰면 마지막 답변 + 화제의 청크로 다른 답변의 모범답안이 오염(경험 날조)되므로, (질문+답변) 쌍으로 질의한다. + """ if self._answer_coach is None: return [] pairs = _collect_coachable_pairs(req.messages) @@ -275,6 +283,13 @@ async def _one( ) -> AnswerCoachingItem | None: try: async with sem: + # 이 (질문,답변)에 국한된 근거만 검색해 모범답안이 다른 화제로 새지 않게. + pair_context = await self._retrieve_context( + f"{question.content}\n{answer.content}", + req.context_document_ids, + _COACHING_RAG_TOP_K, + req.session_id, + ) res = await self._answer_coach.coach( job_category=req.job_category, mode=req.mode, @@ -282,7 +297,7 @@ async def _one( question=question.content, expected_signal=question.expected_signal or "", answer=answer.content, - rag_context=rag_context, + rag_context=pair_context, ) except Exception as exc: # noqa: BLE001 log.warning( @@ -303,26 +318,31 @@ async def _one( return [it for it in items if it is not None] async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: - if not self._embedder or not req.context_document_ids: - return "(none)" - last_answer = next( - (m.content for m in reversed(req.messages) if m.role == "INTERVIEWEE"), - None, + # 세션 전체 채점(종합·패널·직무 적합도)용 컨텍스트. 마지막 답변 하나만 쓰면 + # 그 화제로 근거가 편향되므로, 세션의 모든 실질 답변(짧은 확인 제외)을 질의로 삼는다. + query = _session_rag_query(req.messages) + return await self._retrieve_context( + query, req.context_document_ids, self._rag_top_k, req.session_id ) - if not last_answer: + + async def _retrieve_context( + self, query_text: str, document_ids: list[int], top_k: int, session_id: int + ) -> str: + """query_text 로 pgvector 검색 → 청크를 컨텍스트 문자열로. 실패/무결과는 '(none)'.""" + if not self._embedder or not document_ids or not query_text.strip(): return "(none)" try: query_vec = ( - await self._embedder.embed([last_answer], task_type="RETRIEVAL_QUERY") + await self._embedder.embed([query_text], task_type="RETRIEVAL_QUERY") )[0] hits = await self._core.search_embeddings( query_embedding=query_vec, - query_text=last_answer, - document_ids=req.context_document_ids, - top_k=self._rag_top_k, + query_text=query_text, + document_ids=document_ids, + top_k=top_k, ) - except Exception as exc: - log.warn("feedback.rag.failed", error=str(exc), session_id=req.session_id) + except Exception as exc: # noqa: BLE001 + log.warn("feedback.rag.failed", error=str(exc), session_id=session_id) return "(none)" if not hits: return "(none)" @@ -331,6 +351,25 @@ async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: ) +def _session_rag_query(messages: list[FeedbackMessageItem]) -> str: + """세션 채점 RAG 검색 질의 — 실질 INTERVIEWEE 답변(짧은 확인 제외)을 이어붙여 상한 절단. + 마지막 답변 하나만 쓰던 편향을 없앤다. 전부 비면(레거시/전부 확인형) 마지막 답변 폴백.""" + answers = [ + (m.content or "").strip() + for m in messages + if m.role == "INTERVIEWEE" + and (m.content or "").strip() + and not _is_short_confirmation(m.content) + ] + joined = "\n".join(answers) + if not joined: + last = next( + (m.content for m in reversed(messages) if m.role == "INTERVIEWEE"), None + ) + joined = (last or "").strip() + return joined[:_SESSION_RAG_QUERY_MAX_CHARS] + + def _collect_coachable_pairs( messages: list[FeedbackMessageItem], ) -> list[tuple[FeedbackMessageItem, FeedbackMessageItem]]: diff --git a/ai/tests/test_feedback_rag_query.py b/ai/tests/test_feedback_rag_query.py new file mode 100644 index 00000000..992c55b6 --- /dev/null +++ b/ai/tests/test_feedback_rag_query.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from ai_server.messaging.consumers.feedback_consumer import _session_rag_query +from ai_server.model.messages.feedback import FeedbackMessageItem + + +def _msg(mid: int, role: str, content: str, seq: int | None = None) -> FeedbackMessageItem: + return FeedbackMessageItem(id=mid, sequence_number=seq or mid, role=role, content=content) + + +def test_joins_substantive_answers_excludes_short_confirmations() -> None: + msgs = [ + _msg(1, "INTERVIEWER", "인덱스를 어떻게 튜닝했나요?"), + _msg(2, "INTERVIEWEE", "복합 인덱스를 user_id, created_at 으로 잡아 풀스캔을 제거했습니다."), + _msg(3, "INTERVIEWER", "그럼 커버링 인덱스였나요?"), + _msg(4, "INTERVIEWEE", "네 맞습니다."), # 짧은 확인 → 제외 + _msg(5, "INTERVIEWER", "캐시는요?"), + _msg(6, "INTERVIEWEE", "Redis 로 조회 캐시를 붙여 p95 를 40ms 로 낮췄습니다."), + ] + q = _session_rag_query(msgs) + assert "복합 인덱스" in q + assert "Redis" in q + assert "네 맞습니다" not in q # 짧은 확인 답변은 질의에서 빠진다 + + +def test_not_only_last_answer() -> None: + # 마지막 답변 하나만 쓰던 편향 제거 — 앞선 실질 답변도 질의에 포함. + msgs = [ + _msg(1, "INTERVIEWER", "Q1"), + _msg(2, "INTERVIEWEE", "쿼리 튜닝으로 75% 단축했습니다."), + _msg(3, "INTERVIEWER", "Q2"), + _msg(4, "INTERVIEWEE", "마지막으로 모니터링을 붙였습니다."), + ] + q = _session_rag_query(msgs) + assert "쿼리 튜닝" in q and "모니터링" in q + + +def test_all_short_falls_back_to_last_answer() -> None: + msgs = [ + _msg(1, "INTERVIEWER", "Q"), + _msg(2, "INTERVIEWEE", "네 맞습니다."), + _msg(3, "INTERVIEWER", "Q2"), + _msg(4, "INTERVIEWEE", "아니요."), + ] + q = _session_rag_query(msgs) + assert q == "아니요." # 전부 확인형이면 마지막 답변 폴백 + + +def test_no_interviewee_is_empty() -> None: + msgs = [_msg(1, "INTERVIEWER", "Q"), _msg(2, "SYSTEM", "S")] + assert _session_rag_query(msgs) == "" + + +def test_caps_length() -> None: + long = "가" * 5000 + msgs = [_msg(1, "INTERVIEWEE", long)] + assert len(_session_rag_query(msgs)) == 2000