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
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -82,15 +96,16 @@ public List<SearchHit> search(
private List<SearchHit> searchVectorOnly(
float[] queryEmbedding, List<Long> 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<String, Object> 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);
Expand All @@ -105,28 +120,33 @@ private List<SearchHit> searchHybrid(
List<Long> 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,
Expand All @@ -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<String, Object> params = new HashMap<>();
params.put("qvec", toVectorLiteral(queryEmbedding));
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/**
* 임베딩 검색은 삭제된 문서의 청크를 돌려주면 안 된다.
*
* <p>세션 생성 뒤 사용자가 워크스페이스에서 이력서를 지워도, 세션 컨텍스트에는 그 문서 id 가
* 그대로 남아 `generate.followup`·`generate.feedback` 페이로드로 계속 실려 나간다
* (`SessionFollowupRequester`/`SessionFeedbackRequester` 는 `findBySession_Id` 를 필터 없이 쓴다).
* 검색이 걸러주지 않으면 지운 이력서 본문이 꼬리질문·채점 근거로 되살아난다 —
* `SessionQuestionsRequester.buildDocumentContexts` 가 `findActiveByIdAndOwner` 로 막아둔 것과
* 같은 문제가 RAG 경로에만 남아 있었다.
*
* <p>호출자마다 필터를 거는 대신 검색 쿼리에서 막는다 — 호출자가 늘어날 때마다 같은 실수를
* 반복할 수 있고, 실제로 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<SearchHit> hits = embeddingRepository.search(
vector(0.5f), "쿠버네티스", List.of(removed.getId()), 5);

assertThat(hits).isEmpty();
}

private List<SearchHit> search(List<Long> 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;
}
}
Loading