From 3c038c76d4ba4d9e071625742ca66ec031b21c6c Mon Sep 17 00:00:00 2001 From: jmj Date: Mon, 24 Aug 2026 01:01:00 +0900 Subject: [PATCH] =?UTF-8?q?fix(backend):=20=EC=9E=84=EB=B2=A0=EB=94=A9=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=9D=B4=20=EC=82=AD=EC=A0=9C=EB=90=9C=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=EC=9D=98=20=EC=B2=AD=ED=81=AC=EB=A5=BC=20?= =?UTF-8?q?=EB=8F=8C=EB=A0=A4=EC=A3=BC=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 세션 생성 뒤 사용자가 워크스페이스에서 이력서를 지워도, 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 인자 주입으로 바로잡고 주석으로 남겼다. --- .../JdbcDocumentEmbeddingRepository.java | 54 +++++--- .../DocumentEmbeddingSearchTest.java | 119 ++++++++++++++++++ 2 files changed, 156 insertions(+), 17 deletions(-) create mode 100644 backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java diff --git a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java index 4766f25c..88eba719 100644 --- a/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java +++ b/backend/src/main/java/com/stackup/stackup/document/infrastructure/JdbcDocumentEmbeddingRepository.java @@ -28,6 +28,20 @@ ON CONFLICT (document_id, chunk_index) private static final String COUNT_SQL = "SELECT count(*) FROM document_embeddings WHERE document_id = ?"; + // 삭제된 문서의 청크는 검색에서 제외한다. + // + // 세션 생성 뒤 사용자가 워크스페이스에서 자료를 지워도 session_contexts 에는 그 문서 id 가 + // 남아 있고, generate.followup·generate.feedback 페이로드로 계속 실려 나간다 + // (SessionFollowupRequester/SessionFeedbackRequester 는 findBySession_Id 를 필터 없이 쓴다). + // 여기서 걸러주지 않으면 지운 이력서 본문이 꼬리질문·채점 근거로 되살아난다 — + // SessionQuestionsRequester.buildDocumentContexts 가 findActiveByIdAndOwner 로 막아둔 것과 + // 같은 문제가 RAG 경로에만 남아 있었다. + // + // 호출부마다 필터를 거는 대신 쿼리에서 막는 이유: 호출자가 늘 때마다 같은 실수를 반복할 수 + // 있고, 실제로 3개 호출부 중 어디도 삭제를 확인하지 않았다. 여기 한 곳이 마지막 관문이다. + private static final String ACTIVE_DOC_JOIN = + "JOIN analyzed_documents d ON d.id = e.document_id AND d.is_deleted = FALSE "; + private final JdbcTemplate jdbc; private final NamedParameterJdbcTemplate namedJdbc; @@ -82,15 +96,16 @@ public List search( private List searchVectorOnly( float[] queryEmbedding, List documentIds, boolean filterByDoc, int limit) { StringBuilder sql = new StringBuilder( - "SELECT document_id, chunk_index, chunk_text, (embedding <=> CAST(:qvec AS vector)) AS distance " - + "FROM document_embeddings "); + "SELECT e.document_id, e.chunk_index, e.chunk_text, " + + "(e.embedding <=> CAST(:qvec AS vector)) AS distance " + + "FROM document_embeddings e " + ACTIVE_DOC_JOIN); Map params = new HashMap<>(); params.put("qvec", toVectorLiteral(queryEmbedding)); if (filterByDoc) { - sql.append("WHERE document_id IN (:documentIds) "); + sql.append("WHERE e.document_id IN (:documentIds) "); params.put("documentIds", documentIds); } - sql.append("ORDER BY embedding <=> CAST(:qvec AS vector) LIMIT :limit"); + sql.append("ORDER BY e.embedding <=> CAST(:qvec AS vector) LIMIT :limit"); params.put("limit", limit); return namedJdbc.query(sql.toString(), params, ROW_MAPPER); @@ -105,28 +120,33 @@ private List searchHybrid( List documentIds, boolean filterByDoc, int limit) { - String docFilterVec = filterByDoc ? "WHERE document_id IN (:documentIds) " : ""; - String docFilterFts = filterByDoc ? "AND document_id IN (:documentIds) " : ""; + String docFilterVec = filterByDoc ? "WHERE e.document_id IN (:documentIds) " : ""; + String docFilterFts = filterByDoc ? "AND e.document_id IN (:documentIds) " : ""; + // 주의: 이 SQL 은 **한 개의** 텍스트 블록이어야 한다. 중간에 문자열을 이어붙여 블록을 + // 쪼개면 `.formatted` 가 마지막 조각에만 걸려 placeholder 가 밀린다(실제로 그렇게 깨졌다). + // 조각을 넣어야 하면 여기처럼 %%s 인자로 주입한다 — 순서는 등장 순. String sql = """ WITH v AS ( - SELECT document_id, chunk_index, chunk_text, - (embedding <=> CAST(:qvec AS vector)) AS distance, - ROW_NUMBER() OVER (ORDER BY embedding <=> CAST(:qvec AS vector)) AS rnk - FROM document_embeddings + SELECT e.document_id, e.chunk_index, e.chunk_text, + (e.embedding <=> CAST(:qvec AS vector)) AS distance, + ROW_NUMBER() OVER (ORDER BY e.embedding <=> CAST(:qvec AS vector)) AS rnk + FROM document_embeddings e + %s %s - ORDER BY embedding <=> CAST(:qvec AS vector) + ORDER BY e.embedding <=> CAST(:qvec AS vector) LIMIT :cand ), t AS ( - SELECT document_id, chunk_index, chunk_text, + SELECT e.document_id, e.chunk_index, e.chunk_text, ROW_NUMBER() OVER ( - ORDER BY ts_rank_cd(chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC + ORDER BY ts_rank_cd(e.chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC ) AS rnk - FROM document_embeddings - WHERE chunk_text_tsv @@ plainto_tsquery('simple', :qtext) + FROM document_embeddings e + %s + WHERE e.chunk_text_tsv @@ plainto_tsquery('simple', :qtext) %s - ORDER BY ts_rank_cd(chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC + ORDER BY ts_rank_cd(e.chunk_text_tsv, plainto_tsquery('simple', :qtext)) DESC LIMIT :cand ) SELECT COALESCE(v.document_id, t.document_id) AS document_id, @@ -139,7 +159,7 @@ SELECT COALESCE(v.document_id, t.document_id) AS document_id, ON v.document_id = t.document_id AND v.chunk_index = t.chunk_index ORDER BY rrf DESC LIMIT :limit - """.formatted(docFilterVec, docFilterFts); + """.formatted(ACTIVE_DOC_JOIN, docFilterVec, ACTIVE_DOC_JOIN, docFilterFts); Map params = new HashMap<>(); params.put("qvec", toVectorLiteral(queryEmbedding)); diff --git a/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java new file mode 100644 index 00000000..51d4781a --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/document/infrastructure/DocumentEmbeddingSearchTest.java @@ -0,0 +1,119 @@ +package com.stackup.stackup.document.infrastructure; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.stackup.stackup.document.domain.AnalyzedDocument; +import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository.EmbeddingChunk; +import com.stackup.stackup.document.domain.DocumentEmbeddingRepository.SearchHit; +import com.stackup.stackup.resume.domain.Resume; +import com.stackup.stackup.resume.domain.ResumeFileType; +import com.stackup.stackup.resume.domain.ResumeRepository; +import com.stackup.stackup.support.PostgresRepositoryTest; +import com.stackup.stackup.user.domain.User; +import com.stackup.stackup.user.domain.UserRepository; +import jakarta.persistence.EntityManager; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Import; + +/** + * 임베딩 검색은 삭제된 문서의 청크를 돌려주면 안 된다. + * + *

세션 생성 뒤 사용자가 워크스페이스에서 이력서를 지워도, 세션 컨텍스트에는 그 문서 id 가 + * 그대로 남아 `generate.followup`·`generate.feedback` 페이로드로 계속 실려 나간다 + * (`SessionFollowupRequester`/`SessionFeedbackRequester` 는 `findBySession_Id` 를 필터 없이 쓴다). + * 검색이 걸러주지 않으면 지운 이력서 본문이 꼬리질문·채점 근거로 되살아난다 — + * `SessionQuestionsRequester.buildDocumentContexts` 가 `findActiveByIdAndOwner` 로 막아둔 것과 + * 같은 문제가 RAG 경로에만 남아 있었다. + * + *

호출자마다 필터를 거는 대신 검색 쿼리에서 막는다 — 호출자가 늘어날 때마다 같은 실수를 + * 반복할 수 있고, 실제로 3개 호출부 중 어디도 삭제를 확인하지 않았다. + */ +@PostgresRepositoryTest +@Import(JdbcDocumentEmbeddingRepository.class) +class DocumentEmbeddingSearchTest { + + @Autowired UserRepository userRepository; + @Autowired ResumeRepository resumeRepository; + @Autowired AnalyzedDocumentRepository documentRepository; + @Autowired DocumentEmbeddingRepository embeddingRepository; + @Autowired EntityManager em; + + @Test + void searchExcludesChunksOfDeletedDocuments() { + AnalyzedDocument kept = document(97001L, "kept"); + AnalyzedDocument removed = document(97002L, "removed"); + + embeddingRepository.upsertAll(kept.getId(), "test-model", + List.of(new EmbeddingChunk(0, "살아있는 이력서 내용", vector(0.9f)))); + embeddingRepository.upsertAll(removed.getId(), "test-model", + List.of(new EmbeddingChunk(0, "지운 이력서 내용", vector(0.9f)))); + + // 지우기 전에는 둘 다 잡힌다 — 필터가 "아무것도 안 거르는" 상태와 구분되게. + assertThat(search(List.of(kept.getId(), removed.getId()))) + .extracting(SearchHit::documentId) + .containsExactlyInAnyOrder(kept.getId(), removed.getId()); + + removed.markDeleted(); + documentRepository.save(removed); + em.flush(); + + assertThat(search(List.of(kept.getId(), removed.getId()))) + .extracting(SearchHit::documentId) + .containsExactly(kept.getId()); + } + + // documentIds 를 안 주면 전체 검색이다 — 이 경로에서도 삭제 문서가 새면 안 된다. + @Test + void unscopedSearchAlsoExcludesDeletedDocuments() { + AnalyzedDocument removed = document(97003L, "removed-unscoped"); + embeddingRepository.upsertAll(removed.getId(), "test-model", + List.of(new EmbeddingChunk(0, "지운 문서 전체검색", vector(0.5f)))); + removed.markDeleted(); + documentRepository.save(removed); + em.flush(); + + assertThat(search(List.of())) + .extracting(SearchHit::documentId) + .doesNotContain(removed.getId()); + } + + // 하이브리드(queryText 동반) 경로도 같은 규약이어야 한다 — 벡터 CTE 만 막고 full-text CTE 를 + // 놓치면 본문 단어가 겹치는 순간 지운 문서가 그대로 올라온다. + @Test + void hybridSearchExcludesDeletedDocuments() { + AnalyzedDocument removed = document(97004L, "removed-hybrid"); + embeddingRepository.upsertAll(removed.getId(), "test-model", + List.of(new EmbeddingChunk(0, "쿠버네티스 운영 경험", vector(0.5f)))); + removed.markDeleted(); + documentRepository.save(removed); + em.flush(); + + List hits = embeddingRepository.search( + vector(0.5f), "쿠버네티스", List.of(removed.getId()), 5); + + assertThat(hits).isEmpty(); + } + + private List search(List documentIds) { + return embeddingRepository.search(vector(0.9f), null, documentIds, 10); + } + + private static float[] vector(float head) { + float[] v = new float[1536]; + v[0] = head; + return v; + } + + private AnalyzedDocument document(Long githubId, String name) { + User user = userRepository.save(User.createGithubUser(githubId, name, null, null, "t")); + Resume resume = resumeRepository.save( + Resume.create(user, name + ".pdf", "resumes/raw/x/" + name + ".pdf", ResumeFileType.PDF, 10L)); + AnalyzedDocument doc = documentRepository.save(AnalyzedDocument.forResume(resume)); + em.flush(); + return doc; + } +}