3737
3838_SELF_INTRO_CATEGORY = "SELF_INTRODUCTION"
3939_JOB_TAILORED_MODE = "JOB_TAILORED"
40+ # 답변별 코칭 RAG 는 해당 화제에 국한된 소수 청크만. 세션 공용 top_k 보다 작게.
41+ _COACHING_RAG_TOP_K = 3
42+ # 세션 RAG 질의 상한(문자). 답변 이어붙임이 임베딩 입력 한도를 넘지 않게.
43+ _SESSION_RAG_QUERY_MAX_CHARS = 2000
4044
4145
4246class FeedbackConsumer :
@@ -135,7 +139,7 @@ async def handle(self, message: AbstractIncomingMessage) -> None:
135139 ),
136140 self ._evaluate_self_intro (req , voice_analysis_summary ),
137141 self ._evaluate_job_fit (req , transcript , rag_context ),
138- self ._coach_answers (req , rag_context ),
142+ self ._coach_answers (req ),
139143 )
140144 )
141145 # 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지.
@@ -250,9 +254,13 @@ async def _evaluate_job_fit(
250254 ]
251255
252256 async def _coach_answers (
253- self , req : GenerateFeedbackRequest , rag_context : str
257+ self , req : GenerateFeedbackRequest
254258 ) -> list [AnswerCoachingItem ]:
255- """자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트."""
259+ """자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트.
260+
261+ 코칭 근거(RAG)는 **답변별로 개별 검색**한다 — 세션 공용 컨텍스트를 쓰면 마지막 답변
262+ 화제의 청크로 다른 답변의 모범답안이 오염(경험 날조)되므로, (질문+답변) 쌍으로 질의한다.
263+ """
256264 if self ._answer_coach is None :
257265 return []
258266 pairs = _collect_coachable_pairs (req .messages )
@@ -275,14 +283,21 @@ async def _one(
275283 ) -> AnswerCoachingItem | None :
276284 try :
277285 async with sem :
286+ # 이 (질문,답변)에 국한된 근거만 검색해 모범답안이 다른 화제로 새지 않게.
287+ pair_context = await self ._retrieve_context (
288+ f"{ question .content } \n { answer .content } " ,
289+ req .context_document_ids ,
290+ _COACHING_RAG_TOP_K ,
291+ req .session_id ,
292+ )
278293 res = await self ._answer_coach .coach (
279294 job_category = req .job_category ,
280295 mode = req .mode ,
281296 target_role = target_role ,
282297 question = question .content ,
283298 expected_signal = question .expected_signal or "" ,
284299 answer = answer .content ,
285- rag_context = rag_context ,
300+ rag_context = pair_context ,
286301 )
287302 except Exception as exc : # noqa: BLE001
288303 log .warning (
@@ -303,26 +318,31 @@ async def _one(
303318 return [it for it in items if it is not None ]
304319
305320 async def _build_rag_context (self , req : GenerateFeedbackRequest ) -> str :
306- if not self . _embedder or not req . context_document_ids :
307- return "(none)"
308- last_answer = next (
309- ( m . content for m in reversed ( req . messages ) if m . role == "INTERVIEWEE" ),
310- None ,
321+ # 세션 전체 채점(종합·패널·직무 적합도)용 컨텍스트. 마지막 답변 하나만 쓰면
322+ # 그 화제로 근거가 편향되므로, 세션의 모든 실질 답변(짧은 확인 제외)을 질의로 삼는다.
323+ query = _session_rag_query ( req . messages )
324+ return await self . _retrieve_context (
325+ query , req . context_document_ids , self . _rag_top_k , req . session_id
311326 )
312- if not last_answer :
327+
328+ async def _retrieve_context (
329+ self , query_text : str , document_ids : list [int ], top_k : int , session_id : int
330+ ) -> str :
331+ """query_text 로 pgvector 검색 → 청크를 컨텍스트 문자열로. 실패/무결과는 '(none)'."""
332+ if not self ._embedder or not document_ids or not query_text .strip ():
313333 return "(none)"
314334 try :
315335 query_vec = (
316- await self ._embedder .embed ([last_answer ], task_type = "RETRIEVAL_QUERY" )
336+ await self ._embedder .embed ([query_text ], task_type = "RETRIEVAL_QUERY" )
317337 )[0 ]
318338 hits = await self ._core .search_embeddings (
319339 query_embedding = query_vec ,
320- query_text = last_answer ,
321- document_ids = req . context_document_ids ,
322- top_k = self . _rag_top_k ,
340+ query_text = query_text ,
341+ document_ids = document_ids ,
342+ top_k = top_k ,
323343 )
324- except Exception as exc :
325- log .warn ("feedback.rag.failed" , error = str (exc ), session_id = req . session_id )
344+ except Exception as exc : # noqa: BLE001
345+ log .warn ("feedback.rag.failed" , error = str (exc ), session_id = session_id )
326346 return "(none)"
327347 if not hits :
328348 return "(none)"
@@ -331,6 +351,25 @@ async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str:
331351 )
332352
333353
354+ def _session_rag_query (messages : list [FeedbackMessageItem ]) -> str :
355+ """세션 채점 RAG 검색 질의 — 실질 INTERVIEWEE 답변(짧은 확인 제외)을 이어붙여 상한 절단.
356+ 마지막 답변 하나만 쓰던 편향을 없앤다. 전부 비면(레거시/전부 확인형) 마지막 답변 폴백."""
357+ answers = [
358+ (m .content or "" ).strip ()
359+ for m in messages
360+ if m .role == "INTERVIEWEE"
361+ and (m .content or "" ).strip ()
362+ and not _is_short_confirmation (m .content )
363+ ]
364+ joined = "\n " .join (answers )
365+ if not joined :
366+ last = next (
367+ (m .content for m in reversed (messages ) if m .role == "INTERVIEWEE" ), None
368+ )
369+ joined = (last or "" ).strip ()
370+ return joined [:_SESSION_RAG_QUERY_MAX_CHARS ]
371+
372+
334373def _collect_coachable_pairs (
335374 messages : list [FeedbackMessageItem ],
336375) -> list [tuple [FeedbackMessageItem , FeedbackMessageItem ]]:
0 commit comments