diff --git a/backend/src/test/java/com/opensource/docgrid/e2e/LocalDocumentIndexingE2ETest.java b/backend/src/test/java/com/opensource/docgrid/e2e/LocalDocumentIndexingE2ETest.java index 11513c5d..41bbf934 100644 --- a/backend/src/test/java/com/opensource/docgrid/e2e/LocalDocumentIndexingE2ETest.java +++ b/backend/src/test/java/com/opensource/docgrid/e2e/LocalDocumentIndexingE2ETest.java @@ -141,8 +141,8 @@ void upload_indexesDocumentsAndSearchesRelatedContent() throws Exception { assertIndexedInvariants(pdfUpload, true); assertIndexedInvariants(docxUpload, false); - // 5. 실제 단건 Query Embedding과 pgvector 코사인 검색에서 관련 PDF가 먼저 반환된다. - assertSemanticSearchRanksPdfFirst(pdfUpload, docxUpload); + // 5. 실제 단건 Query Embedding과 pgvector 코사인 검색이 질의별로 관련 문서만 반환한다. + assertSemanticSearchAppliesRelevanceGuardrail(pdfUpload, docxUpload); // 6. 같은 ADMIN JWT로 실제 관리자 조회와 OpenAPI 노출·민감정보 제거 계약을 검증한다. assertAdminHttpContracts(accessToken, pdfUpload); @@ -240,37 +240,49 @@ private void assertEventOrder(Long jobId) { ); } - private void assertSemanticSearchRanksPdfFirst( + /** + * 질의와 관련된 문서만 검색되고, 관련 없는 문서는 최소 유사도에서 제외되는지 확인한다. + * + *

두 질의를 함께 검증한다. 한 질의만 보면 문서가 빠진 이유가 관련성 판정 때문인지 + * 인덱싱 누락 때문인지 구분할 수 없기 때문이다. + */ + private void assertSemanticSearchAppliesRelevanceGuardrail( UploadedDocument pdfUpload, UploadedDocument docxUpload ) { - float[] queryVector = embeddingClient.embed( - "How does pgvector HNSW improve cosine similarity search for embeddings?" - ); - assertThat(queryVector).hasSize(1024); - for (float value : queryVector) { - assertThat(Float.isFinite(value)).isTrue(); - } Long modelId = queryLong( "SELECT id FROM embedding_models WHERE is_active = TRUE AND is_searchable = TRUE" ); + List permittedIds = List.of(pdfUpload.documentId(), docxUpload.documentId()); - List candidates = vectorSearchQueryService.search( - queryVector, + // 1. pgvector 질의에는 관련 PDF만 남고 무관한 제빵 DOCX는 최소 유사도에서 제외된다. + List vectorQueryHits = searchByQuery( + "How does pgvector HNSW improve cosine similarity search for embeddings?", modelId, - List.of(pdfUpload.documentId(), docxUpload.documentId()), - 10 + permittedIds ); - assertThat(candidates).isNotEmpty(); - assertThat(candidates.get(0).documentId()).isEqualTo(pdfUpload.documentId()); - assertThat(candidates).anyMatch(candidate -> candidate.documentId().equals(docxUpload.documentId())); - assertThat(candidates.get(0).similarityScore()).isGreaterThan( - candidates.stream() - .filter(candidate -> candidate.documentId().equals(docxUpload.documentId())) - .findFirst() - .orElseThrow() - .similarityScore() + assertThat(vectorQueryHits).isNotEmpty(); + assertThat(vectorQueryHits.get(0).documentId()).isEqualTo(pdfUpload.documentId()); + assertThat(vectorQueryHits) + .allMatch(candidate -> candidate.documentId().equals(pdfUpload.documentId())); + + // 2. 같은 DOCX도 관련 질의에는 검색된다. 1의 제외가 인덱싱 누락이 아니라 관련성 판정임을 확인한다. + List bakingQueryHits = searchByQuery( + "How is bread dough fermented with yeast before baking?", + modelId, + permittedIds ); + assertThat(bakingQueryHits).isNotEmpty(); + assertThat(bakingQueryHits.get(0).documentId()).isEqualTo(docxUpload.documentId()); + } + + private List searchByQuery(String query, Long modelId, List permittedIds) { + float[] queryVector = embeddingClient.embed(query); + assertThat(queryVector).hasSize(1024); + for (float value : queryVector) { + assertThat(Float.isFinite(value)).isTrue(); + } + return vectorSearchQueryService.search(queryVector, modelId, permittedIds, 10); } private void assertAdminHttpContracts(String accessToken, UploadedDocument upload) {