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; + } +}