From f4ed9cec98e382a451f18561eed747c879f2f6bf Mon Sep 17 00:00:00 2001 From: devxb Date: Sun, 29 Mar 2026 16:17:28 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20ES=20=EC=97=B0=EA=B2=B0=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=ED=95=B4=EB=8F=84=20=EC=84=9C=EB=B2=84=EB=9C=A8?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../similarity/EsKnnTextSimilarityChecker.kt | 16 ++-- .../quiz/infra/similarity/QuizSimilarity.kt | 2 +- .../EsKnnTextSimilarityCheckerTest.kt | 89 +++++++++++++++++++ 3 files changed, 101 insertions(+), 6 deletions(-) create mode 100644 src/test/kotlin/org/gitanimals/quiz/infra/similarity/EsKnnTextSimilarityCheckerTest.kt diff --git a/src/main/kotlin/org/gitanimals/quiz/infra/similarity/EsKnnTextSimilarityChecker.kt b/src/main/kotlin/org/gitanimals/quiz/infra/similarity/EsKnnTextSimilarityChecker.kt index b2791b9..824194b 100644 --- a/src/main/kotlin/org/gitanimals/quiz/infra/similarity/EsKnnTextSimilarityChecker.kt +++ b/src/main/kotlin/org/gitanimals/quiz/infra/similarity/EsKnnTextSimilarityChecker.kt @@ -29,11 +29,17 @@ class EsKnnTextSimilarityChecker( it.numCandidates(MAX_RETURN_KNN_SIZE * 5) }.build() - val searchHits = elasticSearchOperations.search(knnQuery, QuizSimilarity::class.java) - - return SimilarityResponse( - searchHits.searchHits.map { it.content.quizId } - ) + return runCatching { + elasticSearchOperations.search(knnQuery, QuizSimilarity::class.java) + }.onFailure { + logger.warn("[EsKnnTextSimilarityChecker] Elasticsearch similarity search failed. return empty result.", it) + }.map { searchHits -> + SimilarityResponse( + searchHits.searchHits.map { it.content.quizId } + ) + }.getOrElse { + SimilarityResponse(emptyList()) + } } companion object { diff --git a/src/main/kotlin/org/gitanimals/quiz/infra/similarity/QuizSimilarity.kt b/src/main/kotlin/org/gitanimals/quiz/infra/similarity/QuizSimilarity.kt index 19972b9..c049776 100644 --- a/src/main/kotlin/org/gitanimals/quiz/infra/similarity/QuizSimilarity.kt +++ b/src/main/kotlin/org/gitanimals/quiz/infra/similarity/QuizSimilarity.kt @@ -8,7 +8,7 @@ import org.springframework.data.elasticsearch.annotations.FieldType private const val OPEN_AI_SMALL_DIMS = 1536 -@Document(indexName = "quiz_similarity", createIndex = true) +@Document(indexName = "quiz_similarity", createIndex = false) class QuizSimilarity( @Id val id: Long, diff --git a/src/test/kotlin/org/gitanimals/quiz/infra/similarity/EsKnnTextSimilarityCheckerTest.kt b/src/test/kotlin/org/gitanimals/quiz/infra/similarity/EsKnnTextSimilarityCheckerTest.kt new file mode 100644 index 0000000..e534486 --- /dev/null +++ b/src/test/kotlin/org/gitanimals/quiz/infra/similarity/EsKnnTextSimilarityCheckerTest.kt @@ -0,0 +1,89 @@ +package org.gitanimals.quiz.infra.similarity + +import io.kotest.core.spec.style.DescribeSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import org.springframework.data.elasticsearch.client.elc.NativeQuery +import org.springframework.data.elasticsearch.core.ElasticsearchOperations +import org.springframework.data.elasticsearch.core.SearchHit +import org.springframework.data.elasticsearch.core.SearchHits + +internal class EsKnnTextSimilarityCheckerTest : DescribeSpec({ + + describe("getSimilarity 메소드는") { + context("Elasticsearch 조회에 성공하면") { + val elasticSearchOperations = mockk() + val tokenizer = mockk() + val checker = EsKnnTextSimilarityChecker(elasticSearchOperations, tokenizer) + val quizSimilarity = QuizSimilarity( + id = 1L, + quizId = 10L, + vector = listOf(0.1f, 0.2f), + ) + val searchHits = mockk>() + + every { tokenizer.embed(any()) } returns embeddingResponse() + every { + elasticSearchOperations.search(any(), QuizSimilarity::class.java) + } returns searchHits + every { searchHits.searchHits } returns listOf( + SearchHit( + null, + null, + null, + 1.0f, + null, + null, + null, + null, + null, + null, + quizSimilarity, + ) + ) + + it("유사한 퀴즈 아이디를 반환한다.") { + val result = checker.getSimilarity("quiz") + + result.similarityQuizIds shouldBe listOf(10L) + } + } + + context("Elasticsearch 조회에 실패하면") { + val elasticSearchOperations = mockk() + val tokenizer = mockk() + val checker = EsKnnTextSimilarityChecker(elasticSearchOperations, tokenizer) + + every { tokenizer.embed(any()) } returns embeddingResponse() + every { + elasticSearchOperations.search(any(), QuizSimilarity::class.java) + } throws IllegalStateException("Elastic down") + + it("빈 결과를 반환한다.") { + val result = checker.getSimilarity("quiz") + + result.similarityQuizIds shouldBe emptyList() + } + } + } +}) { + + companion object { + private fun embeddingResponse(): Tokenizer.Response { + return Tokenizer.Response( + usage = Tokenizer.Response.Usage( + promptToken = 1, + totalToken = 1, + ), + model = "text-embedding-3-small", + data = listOf( + Tokenizer.Response.Data( + `object` = "embedding", + embedding = listOf(0.1f, 0.2f), + ) + ), + ) + } + } +}