Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion ai/src/ai_server/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
29 changes: 22 additions & 7 deletions ai/src/ai_server/messaging/consumers/feedback_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]:
"""자기소개 제외 답변마다 모범 답안·리라이트·코칭을 병렬 생성 → 메시지별 복기 리스트.

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 13 additions & 4 deletions ai/src/ai_server/messaging/consumers/followup_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
13 changes: 9 additions & 4 deletions ai/src/ai_server/messaging/consumers/questions_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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]
Expand All @@ -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:
Expand All @@ -189,13 +192,15 @@ 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:
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=document_ids,
Expand Down
8 changes: 6 additions & 2 deletions ai/tests/test_core_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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],
Expand All @@ -333,12 +337,12 @@ 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
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]) == []
4 changes: 2 additions & 2 deletions ai/tests/test_followup_consumer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)"
10 changes: 7 additions & 3 deletions backend/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand All @@ -1331,7 +1331,7 @@
}
},
"400" : {
"description" : "queryEmbedding 누락",
"description" : "userId 또는 queryEmbedding 누락",
"content" : {
"*/*" : {
"schema" : {
Expand Down Expand Up @@ -3695,6 +3695,10 @@
"SearchRequest" : {
"type" : "object",
"properties" : {
"userId" : {
"type" : "integer",
"format" : "int64"
},
"queryEmbedding" : {
"type" : "array",
"items" : {
Expand All @@ -3717,7 +3721,7 @@
"format" : "int32"
}
},
"required" : [ "queryEmbedding" ]
"required" : [ "queryEmbedding", "userId" ]
},
"SearchResponse" : {
"type" : "object",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,28 @@ public int upsert(EmbeddingUpsertCommand command) {
return embeddingRepository.upsertAll(command.documentId(), command.model(), mapped);
}

/**
* 임베딩 검색 — **항상 요청자 소유 문서로 제한한다.**
*
* <p>이전에는 documentIds 가 비면 전체 사용자의 청크가 대상이었고 소유권 검증도 없었다.
* 유출이 없었던 건 AI 호출부 3곳이 모두 빈 목록을 사전에 걸러줬기 때문인데, 방어가
* 전적으로 호출자에게 있었다 — 호출부가 하나 늘거나 가드를 빠뜨리면 남의 이력서 청크가
* 프롬프트로 들어간다. 여기서 스코프를 확정해 호출자와 무관하게 불가능하게 만든다.
*
* <p>documentIds 를 주면 그 중 소유한 것만 남기고(요청한 id 를 그대로 믿지 않는다),
* 비어 있으면 소유 문서 전체가 대상이다. 교집합이 비면 검색하지 않고 빈 결과 —
* 빈 목록을 그대로 넘기면 다시 전체 검색이 된다.
*/
public List<DocumentEmbeddingRepository.SearchHit> search(
float[] queryEmbedding, String queryText, List<Long> documentIds, int topK
Long userId, float[] queryEmbedding, String queryText, List<Long> documentIds, int topK
) {
return embeddingRepository.search(queryEmbedding, queryText, documentIds, topK);
List<Long> owned = documentRepository.findActiveIdsByOwner(userId);
List<Long> scoped = documentIds == null || documentIds.isEmpty()
? owned
: documentIds.stream().filter(owned::contains).toList();
if (scoped.isEmpty()) {
return List.of();
}
return embeddingRepository.search(queryEmbedding, queryText, scoped, topK);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,20 @@ Optional<AnalyzedDocument> findByIdAndResume_User_IdOrIdAndRepository_User_Id(
""")
Optional<AnalyzedDocument> findActiveByIdAndOwner(@Param("id") Long id, @Param("userId") Long userId);

// 임베딩 검색 스코프 확정용 — 엔티티가 아니라 id 만 필요하다.
@Query("""
SELECT d.id FROM AnalyzedDocument d
LEFT JOIN d.resume rs
LEFT JOIN rs.user ru
LEFT JOIN d.repository rp
LEFT JOIN rp.user pu
LEFT JOIN d.coverLetter cl
LEFT JOIN cl.user cu
WHERE d.deleted = false
AND (ru.id = :userId OR pu.id = :userId OR cu.id = :userId)
""")
List<Long> findActiveIdsByOwner(@Param("userId") Long userId);

@Query("""
SELECT d FROM AnalyzedDocument d
WHERE d.coverLetter.id = :coverLetterId
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,16 +28,17 @@ public class InternalEmbeddingSearchController {
@Operation(
operationId = "internalSearchEmbeddings",
summary = "pgvector cosine topK 검색",
description = "queryEmbedding 으로 가장 가까운 청크 topK 반환. documentIds 비어 있으면 전체."
description = "queryEmbedding 으로 가장 가까운 청크 topK 반환. **검색 범위는 항상 userId 소유 문서로 제한**된다 — documentIds 를 주면 그 중 소유한 것만, 비어 있으면 소유 문서 전체."
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "검색 결과"),
@ApiResponse(responseCode = "400", description = "queryEmbedding 누락"),
@ApiResponse(responseCode = "400", description = "userId 또는 queryEmbedding 누락"),
@ApiResponse(responseCode = "401", description = "X-Internal-API-Key 인증 실패")
})
@PostMapping("/search")
public SearchResponse search(@Valid @RequestBody SearchRequest request) {
List<SearchHit> hits = embeddingService.search(
request.userId(),
request.queryEmbedding(),
request.queryText(),
request.documentIds() == null ? List.of() : request.documentIds(),
Expand All @@ -47,9 +48,12 @@ public SearchResponse search(@Valid @RequestBody SearchRequest request) {
}

public record SearchRequest(
// 검색 범위를 확정하는 값 — 없으면 스코프를 정할 수 없으므로 필수.
@NotNull Long userId,
@NotNull float[] queryEmbedding,
// 선택. 주어지면 벡터 + full-text(BM25) RRF 하이브리드 검색.
String queryText,
// 선택. 주면 소유 문서와의 교집합으로 좁힌다. 비면 소유 문서 전체.
List<Long> documentIds,
@Positive Integer topK
) {
Expand Down
Loading
Loading