diff --git a/ai/src/ai_server/core/client.py b/ai/src/ai_server/core/client.py index b97f8dab..511fc3ff 100644 --- a/ai/src/ai_server/core/client.py +++ b/ai/src/ai_server/core/client.py @@ -56,6 +56,7 @@ async def upsert_embeddings( async def search_embeddings( self, *, + user_id: int, query_embedding: list[float], query_text: str | None = None, document_ids: list[int] | None = None, @@ -254,14 +255,19 @@ async def _do_upsert( async def search_embeddings( self, *, + user_id: int, query_embedding: list[float], query_text: str | None = None, document_ids: list[int] | None = None, top_k: int = 5, ) -> list[EmbeddingSearchHit]: """임베딩 검색. query_text 가 주어지면 Core 가 벡터+BM25 RRF 하이브리드로, - 없으면 pgvector cosine 단독으로 topK 반환. 실패 시 빈 리스트 (RAG 보강용이므로 fatal 아님).""" + 없으면 pgvector cosine 단독으로 topK 반환. 실패 시 빈 리스트 (RAG 보강용이므로 fatal 아님). + + user_id 는 필수 — Core 가 검색 범위를 이 사용자 소유 문서로 제한한다. + envelope.context.user_id 를 그대로 넘긴다.""" body: dict = { + "userId": user_id, "queryEmbedding": query_embedding, "documentIds": list(document_ids or []), "topK": top_k, diff --git a/ai/src/ai_server/messaging/consumers/feedback_consumer.py b/ai/src/ai_server/messaging/consumers/feedback_consumer.py index 3c7b534b..7174b225 100644 --- a/ai/src/ai_server/messaging/consumers/feedback_consumer.py +++ b/ai/src/ai_server/messaging/consumers/feedback_consumer.py @@ -149,7 +149,8 @@ async def _process( ) transcript = _build_transcript(req.messages) score_basis = _build_score_basis(req.messages) - rag_context = await self._build_rag_context(req) + user_id = envelope.context.user_id + rag_context = await self._build_rag_context(req, user_id) voice_analysis_summary = _build_voice_analysis_summary( req.voice_analysis_summary ) @@ -208,7 +209,7 @@ async def _tracked(coro: Awaitable[T]) -> T: _tracked(self._evaluate_self_intro(req, voice_analysis_summary)), _tracked(self._evaluate_job_fit(req, transcript, rag_context)), _tracked(self._evaluate_personality(req)), - _tracked(self._coach_answers(req)), + _tracked(self._coach_answers(req, user_id)), ) # 빈 평가위원 항목(점수·내용 모두 없음)은 표시하지 않는다 — LLM 부분 응답이 빈 패널로 새는 것 방지. extras = [self_intro_item, *job_fit_items, personality_item] @@ -475,7 +476,7 @@ async def _evaluate_personality( return _to_panel_item(PERSONALITY_EVALUATOR_LABEL, PERSONALITY_DIMENSION, ev) async def _coach_answers( - self, req: GenerateFeedbackRequest + self, req: GenerateFeedbackRequest, user_id: int | None ) -> list[AnswerCoachingItem]: """자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트. @@ -510,6 +511,7 @@ async def _one( req.context_document_ids, _COACHING_RAG_TOP_K, req.session_id, + user_id, ) res = await self._answer_coach.coach( job_category=req.job_category, @@ -538,25 +540,38 @@ async def _one( items = await asyncio.gather(*(_one(q, a) for q, a in pairs)) return [it for it in items if it is not None] - async def _build_rag_context(self, req: GenerateFeedbackRequest) -> str: + async def _build_rag_context( + self, req: GenerateFeedbackRequest, user_id: int | None + ) -> str: # 세션 전체 채점(종합·패널·직무 적합도)용 컨텍스트. 마지막 답변 하나만 쓰면 # 그 화제로 근거가 편향되므로, 세션의 모든 실질 답변(짧은 확인 제외)을 질의로 삼는다. query = _session_rag_query(req.messages) return await self._retrieve_context( - query, req.context_document_ids, self._rag_top_k, req.session_id + query, req.context_document_ids, self._rag_top_k, req.session_id, user_id ) async def _retrieve_context( - self, query_text: str, document_ids: list[int], top_k: int, session_id: int + self, + query_text: str, + document_ids: list[int], + top_k: int, + session_id: int, + user_id: int | None, ) -> str: - """query_text 로 pgvector 검색 → 청크를 컨텍스트 문자열로. 실패/무결과는 '(none)'.""" + """query_text 로 pgvector 검색 → 청크를 컨텍스트 문자열로. 실패/무결과는 '(none)'. + + user_id 는 Core 가 검색 범위를 소유 문서로 제한하는 데 쓴다 — 없으면 검색하지 않는다.""" if not self._embedder or not document_ids or not query_text.strip(): return "(none)" + if user_id is None: + log.warning("feedback.rag.skipped_no_user", session_id=session_id) + return "(none)" try: query_vec = ( await self._embedder.embed([query_text], task_type="RETRIEVAL_QUERY") )[0] hits = await self._core.search_embeddings( + user_id=user_id, query_embedding=query_vec, query_text=query_text, document_ids=document_ids, diff --git a/ai/src/ai_server/messaging/consumers/followup_consumer.py b/ai/src/ai_server/messaging/consumers/followup_consumer.py index e5a1ac2e..95998968 100644 --- a/ai/src/ai_server/messaging/consumers/followup_consumer.py +++ b/ai/src/ai_server/messaging/consumers/followup_consumer.py @@ -95,7 +95,7 @@ async def _process( trace_id=envelope.trace_id, ) - rag_context = await self._build_rag_context(req) + rag_context = await self._build_rag_context(req, envelope.context.user_id) if self._streaming is not None and self._notifier is not None: result = await self._stream_followup(req, rag_context, envelope.trace_id) else: @@ -239,12 +239,18 @@ async def _synth_segment( error=str(exc), ) - async def _build_rag_context(self, req: GenerateFollowupRequest) -> str: + async def _build_rag_context( + self, req: GenerateFollowupRequest, user_id: int | None + ) -> str: + # user_id 는 Core 가 검색 범위를 소유 문서로 제한하는 데 쓴다 — 없으면 검색하지 않는다. if not self._core or not self._embedder or not req.context_document_ids: return "(none)" + if user_id is None: + log.warning("followup.rag.skipped_no_user", session_id=req.session_id) + return "(none)" try: return await asyncio.wait_for( - self._do_build_rag_context(req), timeout=self._rag_timeout_sec + self._do_build_rag_context(req, user_id), timeout=self._rag_timeout_sec ) except asyncio.TimeoutError: log.warning( @@ -254,13 +260,16 @@ async def _build_rag_context(self, req: GenerateFollowupRequest) -> str: ) return "(none)" - async def _do_build_rag_context(self, req: GenerateFollowupRequest) -> str: + async def _do_build_rag_context( + self, req: GenerateFollowupRequest, user_id: int + ) -> str: query = f"{req.previous_question}\n\n{req.answer_text}" try: query_vec = ( await self._embedder.embed([query], task_type="RETRIEVAL_QUERY") )[0] hits = await self._core.search_embeddings( + user_id=user_id, query_embedding=query_vec, query_text=query, document_ids=req.context_document_ids, diff --git a/ai/src/ai_server/messaging/consumers/questions_consumer.py b/ai/src/ai_server/messaging/consumers/questions_consumer.py index 294df353..dbaa87e0 100644 --- a/ai/src/ai_server/messaging/consumers/questions_consumer.py +++ b/ai/src/ai_server/messaging/consumers/questions_consumer.py @@ -98,7 +98,7 @@ async def _process( message="면접 자료를 정리하고 있어요.", trace_id=envelope.trace_id, ) - context_text = await self._build_context(req) + context_text = await self._build_context(req, envelope.context.user_id) await self._emit_progress( session_id=req.session_id, phase="GENERATING", @@ -160,9 +160,12 @@ async def _emit_progress( trace_id=trace_id, ) - async def _build_context(self, req: GenerateQuestionsRequest) -> str: + async def _build_context( + self, req: GenerateQuestionsRequest, user_id: int | None + ) -> str: base_context = _build_context(req.documents) - if not self._core or not self._embedder: + # user_id 는 Core 가 검색 범위를 소유 문서로 제한하는 데 쓴다 — 없으면 검색하지 않는다. + if not self._core or not self._embedder or user_id is None: return base_context document_ids = [d.document_id for d in req.documents] @@ -173,7 +176,7 @@ async def _build_context(self, req: GenerateQuestionsRequest) -> str: try: return await asyncio.wait_for( - self._do_build_context_rag(req, document_ids, base_context), + self._do_build_context_rag(req, document_ids, base_context, user_id), timeout=self._rag_timeout_sec, ) except asyncio.TimeoutError: @@ -189,6 +192,7 @@ async def _do_build_context_rag( req: GenerateQuestionsRequest, document_ids: list[int], base_context: str, + user_id: int, ) -> str: query = _build_initial_rag_query(req) try: @@ -196,6 +200,7 @@ async def _do_build_context_rag( await self._embedder.embed([query], task_type="RETRIEVAL_QUERY") )[0] hits = await self._core.search_embeddings( + user_id=user_id, query_embedding=query_vec, query_text=query, document_ids=document_ids, diff --git a/ai/tests/test_core_client.py b/ai/tests/test_core_client.py index 02977b2e..54eae1b4 100644 --- a/ai/tests/test_core_client.py +++ b/ai/tests/test_core_client.py @@ -287,6 +287,7 @@ async def test_search_embeddings_uses_latest_core_contract() -> None: core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) hits = await core.search_embeddings( + user_id=7, query_embedding=[0.1, 0.2], document_ids=[7, 8], top_k=3, @@ -303,6 +304,8 @@ async def test_search_embeddings_uses_latest_core_contract() -> None: client.post.assert_awaited_once_with( "/api/internal/embeddings/search", json={ + # userId 는 Core 가 검색 범위를 소유 문서로 제한하는 데 쓴다 — 빠지면 400. + "userId": 7, "queryEmbedding": [0.1, 0.2], "documentIds": [7, 8], "topK": 3, @@ -316,6 +319,7 @@ async def test_search_embeddings_includes_query_text_for_hybrid() -> None: core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) await core.search_embeddings( + user_id=7, query_embedding=[0.1], query_text="gRPC 동시성 처리", document_ids=[7], @@ -333,7 +337,7 @@ async def test_search_embeddings_non_2xx_returns_empty(status: int) -> None: client = _make_post_client(status=status, text="bad") core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) - assert await core.search_embeddings(query_embedding=[0.1]) == [] + assert await core.search_embeddings(user_id=7, query_embedding=[0.1]) == [] @pytest.mark.asyncio @@ -341,4 +345,4 @@ async def test_search_embeddings_http_error_returns_empty() -> None: client = _make_post_client(raise_exc=httpx.ConnectError("dns fail")) core = HttpCoreClient(base_url="http://core:38010", api_key="k", client=client) - assert await core.search_embeddings(query_embedding=[0.1]) == [] + assert await core.search_embeddings(user_id=7, query_embedding=[0.1]) == [] diff --git a/ai/tests/test_followup_consumer.py b/ai/tests/test_followup_consumer.py index 8f67756b..97986ddd 100644 --- a/ai/tests/test_followup_consumer.py +++ b/ai/tests/test_followup_consumer.py @@ -687,7 +687,7 @@ async def test_rag_searches_top_k_directly(): ) req = _make_req() - result = await consumer._build_rag_context(req) + result = await consumer._build_rag_context(req, 7) # 청크 텍스트가 결과에 포함돼야 한다 assert "이 청크가 반환돼야 한다" in result @@ -731,6 +731,6 @@ async def _slow_embed(texts, *, task_type=""): ) req = _make_req() - result = await consumer._build_rag_context(req) + result = await consumer._build_rag_context(req, 7) assert result == "(none)" diff --git a/backend/openapi.json b/backend/openapi.json index ee5af73a..25e0dfcb 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -1307,7 +1307,7 @@ "post" : { "tags" : [ "Internal: Embedding Search" ], "summary" : "pgvector cosine topK 검색", - "description" : "queryEmbedding 으로 가장 가까운 청크 topK 반환. documentIds 가 비어 있으면 전체.", + "description" : "queryEmbedding 으로 가장 가까운 청크 topK 반환. **검색 범위는 항상 userId 소유 문서로 제한**된다 — documentIds 를 주면 그 중 소유한 것만, 비어 있으면 소유 문서 전체.", "operationId" : "internalSearchEmbeddings", "requestBody" : { "content" : { @@ -1331,7 +1331,7 @@ } }, "400" : { - "description" : "queryEmbedding 누락", + "description" : "userId 또는 queryEmbedding 누락", "content" : { "*/*" : { "schema" : { @@ -3695,6 +3695,10 @@ "SearchRequest" : { "type" : "object", "properties" : { + "userId" : { + "type" : "integer", + "format" : "int64" + }, "queryEmbedding" : { "type" : "array", "items" : { @@ -3717,7 +3721,7 @@ "format" : "int32" } }, - "required" : [ "queryEmbedding" ] + "required" : [ "queryEmbedding", "userId" ] }, "SearchResponse" : { "type" : "object", diff --git a/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java b/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java index 606c1a8c..996efecd 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/DocumentEmbeddingService.java @@ -30,9 +30,28 @@ public int upsert(EmbeddingUpsertCommand command) { return embeddingRepository.upsertAll(command.documentId(), command.model(), mapped); } + /** + * 임베딩 검색 — **항상 요청자 소유 문서로 제한한다.** + * + *
이전에는 documentIds 가 비면 전체 사용자의 청크가 대상이었고 소유권 검증도 없었다. + * 유출이 없었던 건 AI 호출부 3곳이 모두 빈 목록을 사전에 걸러줬기 때문인데, 방어가 + * 전적으로 호출자에게 있었다 — 호출부가 하나 늘거나 가드를 빠뜨리면 남의 이력서 청크가 + * 프롬프트로 들어간다. 여기서 스코프를 확정해 호출자와 무관하게 불가능하게 만든다. + * + *
documentIds 를 주면 그 중 소유한 것만 남기고(요청한 id 를 그대로 믿지 않는다),
+ * 비어 있으면 소유 문서 전체가 대상이다. 교집합이 비면 검색하지 않고 빈 결과 —
+ * 빈 목록을 그대로 넘기면 다시 전체 검색이 된다.
+ */
public List