Skip to content

임베딩 검색이 삭제된 문서의 청크를 돌려주던 문제 - #216

Merged
i3months merged 1 commit into
devfrom
fix/embedding-search-exclude-deleted
Aug 23, 2026
Merged

임베딩 검색이 삭제된 문서의 청크를 돌려주던 문제#216
i3months merged 1 commit into
devfrom
fix/embedding-search-exclude-deleted

Conversation

@i3months

Copy link
Copy Markdown
Member

문제

세션 생성 뒤 사용자가 워크스페이스에서 이력서를 지워도, 지운 이력서 본문이 RAG 로 되살아나 꼬리질문 생성과 채점 근거로 쓰인다.

경로:

  1. session_contexts 에는 문서 id 가 그대로 남는다 (soft delete 는 resumes·analyzed_documents 만 건드린다)
  2. SessionFollowupRequester·SessionFeedbackRequesterfindBySession_Id필터 없이 읽어 contextDocumentIds 로 싣는다
  3. AI 가 그 id 로 POST /api/internal/embeddings/search 호출
  4. 검색 SQL 에도 삭제 필터가 없다 → 지운 문서의 청크가 그대로 반환
  5. 그 청크가 꼬리질문 프롬프트와 correctness 평가 근거로 들어간다

document_embeddings 에는 청크 원문(chunk_text)이 저장되므로, 이력서에서 뽑은 문장이 그대로 돌아온다.

이건 이 저장소가 이미 세운 원칙에서 RAG 경로만 빠진 것이다. SessionQuestionsRequester.buildDocumentContexts 에는 같은 문제를 막아둔 주석이 이미 있다:

findById 대신 findActiveByIdAndOwner 를 쓴다 — 세션 생성 이후 사용자가 워크스페이스에서 이력서/레포/자소서를 삭제(soft delete)했을 수 있다. (…) "삭제"가 사용자 눈에는 지워졌지만 실제로는 계속 쓰이는 셈이라 삭제의 의미가 없어진다.

수정

검색 쿼리에서 막는다.

private static final String ACTIVE_DOC_JOIN =
    "JOIN analyzed_documents d ON d.id = e.document_id AND d.is_deleted = FALSE ";

호출부마다 거는 대신 쿼리에서 막은 이유: 3개 호출부(questions/followup/feedback) 중 어디도 삭제를 확인하지 않았다. 호출자가 늘 때마다 같은 실수를 반복할 수 있어서, 마지막 관문 한 곳에 둔다.

벡터 단독 경로와 하이브리드(RRF) 경로 모두 적용했다. 하이브리드는 벡터 CTE 와 full-text CTE 양쪽에 건다 — 한쪽만 막으면 본문 단어가 겹치는 순간 그대로 올라온다.

테스트 — #197 인프라

실제 Postgres(+pgvector)에서 세 경로를 각각 확인한다.

  • searchExcludesChunksOfDeletedDocuments — 벡터 단독, 문서 id 로 스코프
  • unscopedSearchAlsoExcludesDeletedDocumentsdocumentIds 미지정(전체 검색)
  • hybridSearchExcludesDeletedDocumentsqueryText 동반 RRF 경로

수정 전 코드에서 3개 모두 실패하는 것을 먼저 확인하고 고쳤다. 각 테스트는 지우기 전 상태(둘 다 조회됨)도 함께 단언한다 — 필터가 "아무것도 안 거르는" 상태와 구분되도록.

작업 중 밟은 함정 (주석으로 남김)

하이브리드 SQL 은 Java 텍스트 블록인데, 중간에 문자열을 이어붙여 블록을 쪼개면 .formatted 가 마지막 조각에만 걸린다. placeholder 가 밀려서 WHERE ... WHERE ... 인 SQL 이 만들어지고 런타임에야 터진다(실제로 그렇게 깨졌다). 한 블록 유지 + %s 인자 주입으로 바로잡고 그 자리에 주석을 남겼다.

함께 확인했고 이번 범위 밖으로 둔 것

POST /api/internal/embeddings/searchuserId 파라미터가 아예 없다. documentIds 가 비면 스펙상 "전체 검색" 이라 다른 사용자의 청크까지 대상이 된다.

지금은 유출이 없다 — AI 3개 호출부가 모두 빈 목록을 사전에 걸러 (none) 을 반환한다. 다만 방어가 전적으로 호출자에게 맡겨져 있고, 소유권 검증도 없다(남의 문서 id 를 넣으면 그대로 조회된다). 내부 API 라 X-Internal-API-Key 뒤에 있지만 계층 방어가 없는 셈이다.

이번 PR 은 삭제 필터에 집중했다. 사용자 스코프 추가는 계약 변경(요청에 userId 추가 + AI 호출부 4곳 수정)이라 분리하는 게 리뷰하기 낫다고 판단했다. 필요하면 후속으로 진행하겠다.

세션 생성 뒤 사용자가 워크스페이스에서 이력서를 지워도, session_contexts 에는
그 문서 id 가 남아 generate.followup·generate.feedback 페이로드로 계속 실려
나간다(SessionFollowupRequester/SessionFeedbackRequester 는 findBySession_Id 를
필터 없이 쓴다). 임베딩 검색에도 삭제 필터가 없어서, 지운 이력서 본문이 RAG 로
되살아나 꼬리질문 생성과 채점 근거로 그대로 쓰였다.

같은 문제를 SessionQuestionsRequester.buildDocumentContexts 는 이미
findActiveByIdAndOwner 로 막아두고 "삭제가 사용자 눈에는 지워졌지만 실제로는
계속 쓰이는 셈"이라고 적어놨다. RAG 경로만 그 원칙에서 빠져 있었다.

호출부마다 필터를 거는 대신 검색 쿼리에서 막는다 — 3개 호출부 중 어디도
삭제를 확인하지 않았고, 호출자가 늘 때마다 같은 실수를 반복할 수 있다.
벡터 단독·하이브리드(RRF) 두 경로 모두, 하이브리드는 벡터 CTE 와 full-text CTE
양쪽에 건다(한쪽만 막으면 본문 단어가 겹치는 순간 그대로 올라온다).

수정 과정에서 텍스트 블록을 쪼개 .formatted 가 마지막 조각에만 걸리는 함정을
밟았다 — placeholder 가 밀려 WHERE 가 두 번 나오는 SQL 이 만들어진다.
한 블록 유지 + %s 인자 주입으로 바로잡고 주석으로 남겼다.
@i3months
i3months merged commit 1e70ffc into dev Aug 23, 2026
5 checks passed
@i3months
i3months deleted the fix/embedding-search-exclude-deleted branch August 23, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant