diff --git a/docs/backend/implements/BI-45-2026-08-07-search-relevance-judge.md b/docs/backend/implements/BI-45-2026-08-07-search-relevance-judge.md new file mode 100644 index 00000000..9e06e367 --- /dev/null +++ b/docs/backend/implements/BI-45-2026-08-07-search-relevance-judge.md @@ -0,0 +1,36 @@ +# BI-45. 검색 결과 LLM 관련도 재판정(4번째 신호) 구현 + +- **상태**: ✅ 구현 완료. 기능 플래그는 꺼진 상태로 두었다. 켜는 결정은 별도다. +- **날짜**: 2026-08-07 +- **추적**: S15P11A705-403([AI] 검색 결과 신뢰도 개선). 사용자가 실배포에서 발견한 검색 순위 오류를 직접 지시해 시작한 작업이다. +- **관련**: ai 레포 `S15P11A705-relevance-judge` 브랜치(`app/client/relevance_client.py` 등, `POST /internal/v1/search/judge` 신설) · `docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md`(같은 서비스의 앞선 신호) + +## 배경 + +배포 직후 사용자가 검색 결과 오류를 보고했다. 질의 "예전에 싸피 때 다녔던 헬스장 어디였지?"에서, 본문에 "싸피"가 그대로 있는 기록이 2위로 밀리고 그 단어가 없는 기록이 1위에 올랐다. + +원인은 기존 세 신호(재작성·문자열 검색·키워드 재정렬) 모두의 사각지대다. 이 질의는 문장형이라 재작성(6자 이하만 대상)과 문자열 검색(단어형만 대상)이 적용되지 않고, "싸피"는 키워드 목록에 없는 고유명사라 재정렬도 잡지 못한다. 남는 것은 순수 벡터 유사도뿐이고, 임베딩은 "본문에 그 단어가 정확히 있는가"보다 전체적인 의미 유사도를 본다. + +사용자가 4번째 신호를 직접 제안했다. 기존 파이프라인의 최종 후보를 LLM에게 질의와 함께 보여주고 관련도 4단계(`VERY_RELEVANT`~`NOT_RELEVANT`)로 재판정해, 무관한 것은 제거하고 나머지를 재정렬한다. RAG 분야의 "LLM reranker" 패턴이다. 검색 신호 3종 동결(ai 레포 P49) 위에 얹는 4번째 신호이므로 동결의 재론이 아니라 소유자(사용자)의 확장 결정으로 처리했다. + +## 아키텍처 결정 + +ai(FastAPI)는 Context 본문을 저장하지 않는다. 본문의 유일한 소유자는 back/Core DB다. 그래서 back이 3신호 병합까지 끝난 최종 후보(본문 포함)를 ai에 보내 판정만 받아오는 구조로 갔다. "FastAPI는 본문을 반환하지 않는다"는 공용 계약(05_AI_설계)은 ai→back 응답 방향의 조항이라 이 방향(back→ai 요청)을 막지 않는다 — `ContextProcessRequest.text`가 이미 같은 방향의 선례다. + +back이 직접 LLM을 호출하는 대안은 검토 후 배제했다. back에는 LLM 호출 인프라가 전무해 벤더 체인·구조화 출력 파싱·재시도 정책을 전부 새로 만들어야 하지만, ai에는 이미 그 인프라(`LLMClient`, 벤더 체인, 구조화 출력 파싱)가 있다. back 쪽은 `AiSearchClient`를 본뜬 클라이언트 하나만 추가하면 된다. + +## 산출 + +- **ai 레포**: `POST /internal/v1/search/judge` 신설. 요청 `{query, candidates: [{contextId, placeName, body}]}` → 응답 `{results: [{contextId, relevance}]}`. 기본 꺼짐(`SEARCH_RELEVANCE_JUDGE_ENABLED`). 상세는 그 레포 커밋(`S15P11A705-relevance-judge` 브랜치). +- **`AiRelevanceJudgeClient`** 신설(`domain/ai/client`). `AiSearchClient`를 본떴지만 실패 정책은 반대다 — 이 클라이언트는 보조 신호라 실패를 흡수하지 않고 그대로 던진다. 흡수는 호출부의 책임이다. +- **`RelevanceJudgeProperties`** 신설. `pinlog.search.relevance-judge.enabled`, 기본값 꺼짐. +- **`AiProperties`**에 `judge` 타임아웃 필드 추가(`connect-timeout: 1s`, `read-timeout: 10s`). 후보 최대 10건의 본문을 한 번의 LLM 호출로 판정하는 동기 경로라 `search`(5s)보다 길게 잡았다. +- **`AiIntegrationConfig`**에 `aiJudgeRestClient` Bean 추가 — `search`·`process`와 타임아웃이 달라 전용 인스턴스가 필요하다. +- **`RecordSearchService`** 수정. `matchedContexts` 빌드 직후, keyword 조회 직전에 `judgeRelevance()`를 넣었다 — 이 지점이 3신호 병합까지 끝난 진짜 최종 후보가 모이는 유일한 지점이다. 흐름과 실패 정책은 `mergeLexicalMatches()`(BI-43)와 같은 강등 패턴이다: 플래그 꺼짐·판정할 후보 없음·판정 호출 실패는 모두 원본 순서를 그대로 돌려준다. `NOT_RELEVANT`는 제거하고 나머지는 (등급 desc, 원 순서)로 안정 정렬한다. 판정이 누락된 항목은 제거하지 않고 `RELEVANT`와 동급으로 취급한다. 모든 후보가 `NOT_RELEVANT`면 빈 결과를 그대로 신뢰한다. +- **테스트**. 플래그를 켠 컨텍스트의 `RelevanceJudgeSearchApiTests` 4건 — 사용자 보고 실사례와 같은 모양(벡터 유사도가 낮아도 관련도가 높으면 순위가 오르는 것), `NOT_RELEVANT` 제거, 전부 무관일 때 빈 결과, 판정 호출 실패 시 강등. 기본값 컨텍스트의 `RecordSearchApiTests`에 꺼짐 계약 1건 추가. `FastApiSearchStub`을 확장해 같은 대역이 `/internal/v1/search`와 `/internal/v1/search/judge`를 함께 받게 했다 — 두 클라이언트가 같은 `pinlog.ai.base-url`을 보므로 대역도 하나여야 한다. + +## 검증 + +- `./gradlew clean check --no-daemon` 통과 (checkstyle 경고 2건은 테스트 메서드명의 "a/an" 관사 접두사 규칙 위반이라 수정 후 재통과). +- 사용자가 보고한 정확한 사례(피치플레이헬스 vs MH토탈휘트니스)는 시딩 데이터에 없어(사용자 개인 배포 데이터) 그 정확한 케이스로 로컬 재현은 불가능했다. 같은 실패 모양(본문에 질의어가 명시된 후보 vs 없는 후보)을 재현하는 테스트 픽스처로 대체 검증했다. +- 시연 DB·스냅샷 DB 반영, 플래그 활성화는 이번 범위 밖이다. 켜는 결정은 별도로 한다. diff --git a/docs/backend/worklog/2026-08-07-search-relevance-judge.md b/docs/backend/worklog/2026-08-07-search-relevance-judge.md new file mode 100644 index 00000000..8c45bb70 --- /dev/null +++ b/docs/backend/worklog/2026-08-07-search-relevance-judge.md @@ -0,0 +1,18 @@ +# 검색 4번째 신호(LLM 관련도 재판정)를 추가했다 + +- **날짜**: 2026-08-07 +- **관련**: [BI-45](../implements/BI-45-2026-08-07-search-relevance-judge.md) · ai 레포 `S15P11A705-relevance-judge` 브랜치 · [BI-43](../implements/BI-43-2026-08-06-search-lexical-merge.md)(앞선 세 신호) + +사용자가 실배포에서 발견한 검색 순위 오류(문장형 질의에 포함된 고유명사가 세 신호 모두의 사각지대에 걸려 관련 기록이 무관한 기록보다 낮은 순위로 나온 사례)를 교정하기 위해 4번째 검색 신호를 추가했다. ai가 후보의 LLM 관련도를 4단계로 재판정하고, back은 그 결과로 무관한 결과를 걸러내고 재정렬한다. + +구조는 back이 3신호 병합까지 끝난 최종 후보(본문 포함)를 ai의 신규 엔드포인트(`POST /internal/v1/search/judge`)에 보내는 방식이다. ai는 Context 본문을 저장하지 않으므로 back이 능동적으로 본문을 실어 보낸다 — "FastAPI는 본문을 반환하지 않는다"는 계약은 응답 방향의 조항이라 이 요청 방향을 막지 않는다. + +`AiRelevanceJudgeClient`를 `AiSearchClient`를 본떠 신설했지만 실패 정책은 반대로 했다 — 이 신호는 보조 신호라 클라이언트는 실패를 삼키지 않고 그대로 던지고, `RecordSearchService.judgeRelevance()`가 `mergeLexicalMatches()`(BI-43)와 같은 강등 패턴(플래그 → 게이트 → try/catch 흡수)으로 받는다. 실패해도 검색 자체는 성공하고 판정 이전 순서로 되돌아간다. + +TDD로 진행했다: `RelevanceJudgeSearchApiTests` 4건(사용자 보고 실사례와 같은 모양의 순위 역전 교정, `NOT_RELEVANT` 제거, 전부 무관 시 빈 결과, 판정 실패 시 강등)을 켠 컨텍스트에, `RecordSearchApiTests`에 꺼짐 계약 1건을 기본값 컨텍스트에 추가했다. `FastApiSearchStub`을 확장해 `/internal/v1/search`와 `/internal/v1/search/judge`를 같은 대역·같은 포트에서 받게 했다 — 두 클라이언트가 같은 `pinlog.ai.base-url`을 보기 때문이다. + +`AiProperties`에 `judge` 타임아웃 필드를 추가하면서 그 record를 직접 생성하는 기존 테스트 둘(`AiSearchClientTest`, `AiPlaceSuggestionClientStubTests`)이 컴파일 깨짐을 냈다 — 인자 하나를 추가해 고쳤다. + +checkstyle이 테스트 메서드명 둘(`aVeryRelevantJudgmentOutranksAHigherSimilarityMatch`, `aFailedJudgeCallFallsBackToThePreJudgeOrder`)의 "a/an" 관사 접두사를 위반으로 잡았다 — 관사를 뺀 이름으로 고쳤다. + +`./gradlew clean check --no-daemon`으로 전체 검증했다. ai 쪽 구현·검증(pytest 전량·ruff·문서 색인)은 그 레포 커밋 이력에 있다. 두 레포 모두 브랜치 커밋·push까지만 진행했고, dev 병합은 사용자 승인 후로 남겨 뒀다. diff --git a/src/main/java/com/pinlog/pinlogback/domain/ai/AiIntegrationConfig.java b/src/main/java/com/pinlog/pinlogback/domain/ai/AiIntegrationConfig.java index 59831447..54aab4cb 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/ai/AiIntegrationConfig.java +++ b/src/main/java/com/pinlog/pinlogback/domain/ai/AiIntegrationConfig.java @@ -55,6 +55,15 @@ public RestClient aiSearchRestClient(AiProperties properties) { return restClient(properties.baseUrl(), properties.search()); } + /** + * {@code judge}용 전용 인스턴스다. 후보 최대 10건의 본문을 한 번의 LLM 호출로 판정하는 동기 + * 경로라 {@code search}보다 응답이 느리다 — 같은 타임아웃을 쓰면 정상 판정이 잘린다. + */ + @Bean + public RestClient aiJudgeRestClient(AiProperties properties) { + return restClient(properties.baseUrl(), properties.judge()); + } + @Bean public RestClient aiPlaceSuggestionRestClient(AiProperties properties, AiPlaceSuggestionProperties placeSuggestionProperties) { diff --git a/src/main/java/com/pinlog/pinlogback/domain/ai/AiProperties.java b/src/main/java/com/pinlog/pinlogback/domain/ai/AiProperties.java index fbbac797..050fdfc5 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/ai/AiProperties.java +++ b/src/main/java/com/pinlog/pinlogback/domain/ai/AiProperties.java @@ -17,6 +17,8 @@ * {@code docs/backend/decisions/BD-39-embedding-profile-in-application-config.md} * @param process {@code POST /internal/v1/context/process} 타임아웃 * @param search {@code POST /internal/v1/search} 타임아웃 + * @param judge {@code POST /internal/v1/search/judge} 타임아웃(검색 4번째 신호). 후보 최대 + * 10건의 본문을 한 번의 LLM 호출로 판정하므로 {@code search}보다 길다 */ @ConfigurationProperties("pinlog.ai") public record AiProperties( @@ -24,7 +26,8 @@ public record AiProperties( String internalSecret, String embeddingProfile, Timeouts process, - Timeouts search + Timeouts search, + Timeouts judge ) { /** diff --git a/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeClient.java b/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeClient.java new file mode 100644 index 00000000..392df5c0 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeClient.java @@ -0,0 +1,65 @@ +package com.pinlog.pinlogback.domain.ai.client; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import com.pinlog.pinlogback.domain.ai.AiProperties; +import com.pinlog.pinlogback.global.web.TraceIdFilter; + +/** + * {@code POST /internal/v1/search/judge} 호출 — 검색 4번째 신호(LLM 관련도 재판정). + * + *
{@link AiSearchClient}와 실패 정책이 정반대다. 저쪽은 주 신호라 모든 실패를 예외로 올리지만, + * 이 클라이언트는 보조 신호다. 그래서 실패를 여기서 흡수하지 않고 그대로 던진다 — + * {@code RecordSearchService.judgeRelevance()}가 {@code mergeLexicalMatches()}와 같은 강등 + * 패턴(플래그 → 게이트 → try/catch 흡수)으로 받아, 실패하면 판정 이전 순서를 그대로 쓴다. + * + *
재시도하지 않는다 — 사용자 요청 경로이고, 판정 실패는 어차피 원 순서로 강등되므로 재시도로
+ * 얻는 값이 없다(오히려 요청 스레드만 더 붙잡는다).
+ */
+@Component
+public class AiRelevanceJudgeClient {
+
+ private static final String PATH = "/internal/v1/search/judge";
+ private static final String INTERNAL_SECRET_HEADER = "X-Internal-Secret";
+ private static final String REQUEST_ID_HEADER = "X-Request-Id";
+
+ private final RestClient restClient;
+ private final String internalSecret;
+
+ public AiRelevanceJudgeClient(@Qualifier("aiJudgeRestClient") RestClient aiJudgeRestClient,
+ AiProperties properties) {
+ this.restClient = aiJudgeRestClient;
+ this.internalSecret = Objects.requireNonNullElse(properties.internalSecret(), "");
+ }
+
+ /**
+ * @param candidates 3신호 병합까지 끝난 최종 후보(본문 포함)
+ * @throws org.springframework.web.client.RestClientException 호출이 실패했을 때. 여기서
+ * 삼키지 않는다 — 흡수는 호출부의 책임이다
+ */
+ public List {@code enabled}의 기본값이 {@code false}인 것은 {@link LexicalSearchProperties}와 같은
+ * 이유다 — 신규 신호는 끈 상태가 현행과 동일해야 하고, 켜는 것은 이 구현이 배포·관측된 뒤의
+ * 별도 결정이다.
+ *
+ * @param enabled 관련도 재판정을 켤지. 꺼져 있으면 ai 호출 자체가 없다
+ */
+@ConfigurationProperties("pinlog.search.relevance-judge")
+public record RelevanceJudgeProperties(
+ boolean enabled
+) {
+}
diff --git a/src/main/java/com/pinlog/pinlogback/domain/search/service/RecordSearchService.java b/src/main/java/com/pinlog/pinlogback/domain/search/service/RecordSearchService.java
index e7fe5ea6..41c1f378 100644
--- a/src/main/java/com/pinlog/pinlogback/domain/search/service/RecordSearchService.java
+++ b/src/main/java/com/pinlog/pinlogback/domain/search/service/RecordSearchService.java
@@ -16,13 +16,18 @@
import org.springframework.stereotype.Service;
import com.pinlog.pinlogback.domain.ai.KeywordResponseStatus;
+import com.pinlog.pinlogback.domain.ai.client.AiRelevanceJudgeClient;
+import com.pinlog.pinlogback.domain.ai.client.AiRelevanceJudgeRequest;
+import com.pinlog.pinlogback.domain.ai.client.AiRelevanceJudgeResponse;
import com.pinlog.pinlogback.domain.ai.client.AiSearchClient;
import com.pinlog.pinlogback.domain.ai.client.AiSearchResponse;
+import com.pinlog.pinlogback.domain.ai.client.RelevanceLabel;
import com.pinlog.pinlogback.domain.ai.repository.ContextKeywordRepository;
import com.pinlog.pinlogback.domain.record.entity.Context;
import com.pinlog.pinlogback.domain.record.repository.ContextRepository;
import com.pinlog.pinlogback.domain.search.ConfidenceGateProperties;
import com.pinlog.pinlogback.domain.search.LexicalSearchProperties;
+import com.pinlog.pinlogback.domain.search.RelevanceJudgeProperties;
import com.pinlog.pinlogback.domain.search.dto.MatchedContextResponse;
import com.pinlog.pinlogback.domain.search.dto.RecordSearchItemResponse;
import com.pinlog.pinlogback.domain.search.dto.RecordSearchRequest;
@@ -36,7 +41,7 @@
/**
* 개인 자연어 검색 유스케이스(API 명세 6.1, AI 설계 9장).
*
- * 흐름은 여섯이다. FastAPI가 준 것을 그대로 내보내는 단계가 없다는 점이 핵심이다.
+ * 흐름은 일곱이다. FastAPI가 준 것을 그대로 내보내는 단계가 없다는 점이 핵심이다.
*
* 실패 정책은 {@link #mergeLexicalMatches}와 같다: 플래그 꺼짐·판정할 후보 없음·판정 호출
+ * 실패는 모두 원본 순서를 그대로 돌려준다. 이 신호는 보조 신호이고, 이 신호의 장애가 검색
+ * 자체를 막으면 안 된다.
+ *
+ * {@code NOT_RELEVANT}는 제거하고 나머지는 (등급 desc, 원 순서)로 안정 정렬한다. 판정이
+ * 누락된 항목(ai가 응답에서 빠뜨렸거나 back이 알 수 없는 값이라 걸러졌을 때)은 제거하지 않고
+ * {@code RELEVANT}와 같은 우선순위로 원 순서 근처에 남긴다 — 판정 실패 하나가 결과를 지우면
+ * 안 된다는 원칙(§ {@link #assemble})을 이 신호에도 적용한다. 모든 후보가
+ * {@code NOT_RELEVANT}면 빈 목록을 그대로 신뢰한다 — 근거 없는 결과를 내보내지 않는다는
+ * 이 신호의 취지와 일치한다.
+ */
+ private List 이 클래스는 플래그를 켠 컨텍스트에서 돈다. 기본값(끔)에서 현행과 동일하다는 계약은
+ * {@link RecordSearchApiTests}가 기본값 컨텍스트에서 고정한다.
+ *
+ * 단언의 축은 셋이다.
+ *
+ *
*
@@ -58,7 +65,11 @@
* 재검증하는 일이라 한 스냅샷으로 묶는다고 더 정확해지지 않는다.
*/
@Service
-@EnableConfigurationProperties({LexicalSearchProperties.class, ConfidenceGateProperties.class})
+@EnableConfigurationProperties({
+ LexicalSearchProperties.class,
+ ConfidenceGateProperties.class,
+ RelevanceJudgeProperties.class,
+})
public class RecordSearchService {
private static final Logger log = LoggerFactory.getLogger(RecordSearchService.class);
@@ -75,24 +86,29 @@ public class RecordSearchService {
private static final double LEXICAL_ONLY_SIMILARITY = 0.0;
private final AiSearchClient aiSearchClient;
+ private final AiRelevanceJudgeClient aiRelevanceJudgeClient;
private final SearchRecordRepository searchRecordRepository;
private final ContextRepository contextRepository;
private final ContextKeywordRepository contextKeywordRepository;
private final LexicalContextRepository lexicalContextRepository;
private final LexicalSearchProperties lexicalProperties;
private final ConfidenceGateProperties gateProperties;
+ private final RelevanceJudgeProperties relevanceJudgeProperties;
- public RecordSearchService(AiSearchClient aiSearchClient, SearchRecordRepository searchRecordRepository,
- ContextRepository contextRepository, ContextKeywordRepository contextKeywordRepository,
- LexicalContextRepository lexicalContextRepository, LexicalSearchProperties lexicalProperties,
- ConfidenceGateProperties gateProperties) {
+ public RecordSearchService(AiSearchClient aiSearchClient, AiRelevanceJudgeClient aiRelevanceJudgeClient,
+ SearchRecordRepository searchRecordRepository, ContextRepository contextRepository,
+ ContextKeywordRepository contextKeywordRepository, LexicalContextRepository lexicalContextRepository,
+ LexicalSearchProperties lexicalProperties, ConfidenceGateProperties gateProperties,
+ RelevanceJudgeProperties relevanceJudgeProperties) {
this.aiSearchClient = aiSearchClient;
+ this.aiRelevanceJudgeClient = aiRelevanceJudgeClient;
this.searchRecordRepository = searchRecordRepository;
this.contextRepository = contextRepository;
this.contextKeywordRepository = contextKeywordRepository;
this.lexicalContextRepository = lexicalContextRepository;
this.lexicalProperties = lexicalProperties;
this.gateProperties = gateProperties;
+ this.relevanceJudgeProperties = relevanceJudgeProperties;
}
/**
@@ -116,7 +132,11 @@ public RecordSearchResponse search(long memberId, RecordSearchRequest request) {
matches.stream().map(AiSearchResponse.Match::contextId).toList(), memberId)
.stream()
.collect(Collectors.toMap(Context::getId, Function.identity()));
- List> results = new AtomicReference<>(List.of());
private final AtomicReference
> judgments = new AtomicReference<>(List.of());
+ private final AtomicReference
+ *
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@TestPropertySource(properties = "pinlog.search.relevance-judge.enabled=true")
+class RelevanceJudgeSearchApiTests extends IntegrationContainerSupport {
+
+ private static final String SEARCH_URL = "/v1/search/records";
+
+ /** Spring Context보다 먼저 떠야 {@code @DynamicPropertySource}가 포트를 알 수 있다. */
+ private static final FastApiSearchStub STUB = new FastApiSearchStub();
+
+ @DynamicPropertySource
+ static void aiServerPointsAtTheStub(DynamicPropertyRegistry registry) {
+ registry.add("pinlog.ai.base-url", STUB::baseUrl);
+ }
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private MemberRepository memberRepository;
+
+ @Autowired
+ private PlaceRepository placeRepository;
+
+ @Autowired
+ private RecordRepository recordRepository;
+
+ @Autowired
+ private ContextRepository contextRepository;
+
+ @AfterAll
+ static void stopStub() {
+ STUB.stop();
+ }
+
+ /**
+ * 사용자 보고 실사례와 같은 모양이다 — 벡터 유사도로는 뒤진 기록("싸피"가 본문에 그대로
+ * 있는 쪽)이 판정에서 {@code VERY_RELEVANT}를 받아 1위로 올라온다.
+ */
+ @Test
+ void veryRelevantJudgmentOutranksAHigherSimilarityMatch() throws Exception {
+ long me = newMemberId();
+ long higherSimilarity = newRecord(me, "judge-a", "37.5000000", "127.0000000");
+ long higherSimilarityContext = newContext(higherSimilarity, me, "군대 전역하고 다닌 헬스장");
+ long literalMatch = newRecord(me, "judge-b", "37.6000000", "127.1000000");
+ long literalMatchContext = newContext(literalMatch, me, "싸피 2학기 동안 다니던 헬스장");
+ STUB.willReturn(
+ new FastApiSearchStub.Match(higherSimilarity, higherSimilarityContext, 0.85),
+ new FastApiSearchStub.Match(literalMatch, literalMatchContext, 0.70));
+ STUB.willJudge(
+ new FastApiSearchStub.Judgment(higherSimilarityContext, "WEAKLY_RELEVANT"),
+ new FastApiSearchStub.Judgment(literalMatchContext, "VERY_RELEVANT"));
+
+ search(me, "싸피 다녔던 헬스장")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(2))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(literalMatch))
+ .andExpect(jsonPath("$.data.items[1].recordId").value(higherSimilarity));
+ }
+
+ /** {@code NOT_RELEVANT}는 결과에서 빠진다 — 근거 없는 결과를 내보내지 않는다는 취지다. */
+ @Test
+ void notRelevantResultsAreRemoved() throws Exception {
+ long me = newMemberId();
+ long relevant = newRecord(me, "judge-c", "37.5000000", "127.0000000");
+ long relevantContext = newContext(relevant, me, "관련 있는 기록");
+ long irrelevant = newRecord(me, "judge-d", "37.6000000", "127.1000000");
+ long irrelevantContext = newContext(irrelevant, me, "무관한 기록");
+ STUB.willReturn(
+ new FastApiSearchStub.Match(relevant, relevantContext, 0.80),
+ new FastApiSearchStub.Match(irrelevant, irrelevantContext, 0.75));
+ STUB.willJudge(
+ new FastApiSearchStub.Judgment(relevantContext, "RELEVANT"),
+ new FastApiSearchStub.Judgment(irrelevantContext, "NOT_RELEVANT"));
+
+ search(me, "질의")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(1))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(relevant));
+ }
+
+ /** 모든 후보가 {@code NOT_RELEVANT}면 빈 결과를 그대로 신뢰한다. */
+ @Test
+ void allNotRelevantYieldsAnEmptyResult() throws Exception {
+ long me = newMemberId();
+ long onlyMatch = newRecord(me, "judge-e", "37.5000000", "127.0000000");
+ long onlyMatchContext = newContext(onlyMatch, me, "무관한 기록");
+ STUB.willReturn(new FastApiSearchStub.Match(onlyMatch, onlyMatchContext, 0.80));
+ STUB.willJudge(new FastApiSearchStub.Judgment(onlyMatchContext, "NOT_RELEVANT"));
+
+ search(me, "질의")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items").isEmpty());
+ }
+
+ /**
+ * 판정 호출이 실패해도 검색 자체는 성공한다 — 판정 이전(벡터 유사도) 순서를 그대로 낸다.
+ * 이 신호는 보조 신호라 장애가 주 결과를 지우면 안 된다({@code mergeLexicalMatches}와 같은
+ * 강등 정책).
+ */
+ @Test
+ void failedJudgeCallFallsBackToThePreJudgeOrder() throws Exception {
+ long me = newMemberId();
+ long first = newRecord(me, "judge-f", "37.5000000", "127.0000000");
+ long firstContext = newContext(first, me, "첫 번째 기록");
+ long second = newRecord(me, "judge-g", "37.6000000", "127.1000000");
+ long secondContext = newContext(second, me, "두 번째 기록");
+ STUB.willReturn(
+ new FastApiSearchStub.Match(first, firstContext, 0.85),
+ new FastApiSearchStub.Match(second, secondContext, 0.70));
+ STUB.willFailJudge();
+
+ search(me, "질의")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(2))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(first))
+ .andExpect(jsonPath("$.data.items[1].recordId").value(second));
+ }
+
+ private ResultActions search(long memberId, String query) throws Exception {
+ return mockMvc.perform(post(SEARCH_URL).with(loginAs(memberId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"query\": \"" + query + "\"}"));
+ }
+
+ private long newMemberId() {
+ return memberRepository.save(Member.create()).getId();
+ }
+
+ private long newRecord(long memberId, String seed, String lat, String lng) {
+ String kakaoPlaceId = seed + "-" + java.util.UUID.randomUUID().toString().substring(0, 8);
+ Place place = placeRepository.save(Place.create(
+ kakaoPlaceId, "장소 " + seed, "주소 " + seed, null, null, null,
+ new BigDecimal(lat), new BigDecimal(lng)));
+ return recordRepository.save(Record.create(memberId, place.getId())).getId();
+ }
+
+ private long newContext(long recordId, long memberId, String body) {
+ return contextRepository.save(Context.create(recordId, memberId, body)).getId();
+ }
+}
diff --git a/src/test/java/com/pinlog/pinlogback/domain/search/SearchSignalCompositionApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/search/SearchSignalCompositionApiTests.java
new file mode 100644
index 00000000..aa44dcea
--- /dev/null
+++ b/src/test/java/com/pinlog/pinlogback/domain/search/SearchSignalCompositionApiTests.java
@@ -0,0 +1,129 @@
+package com.pinlog.pinlogback.domain.search;
+
+import static com.pinlog.pinlogback.support.AuthTestSupport.loginAs;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import java.math.BigDecimal;
+
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
+import org.springframework.http.MediaType;
+import org.springframework.test.context.DynamicPropertyRegistry;
+import org.springframework.test.context.DynamicPropertySource;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.ResultActions;
+
+import com.pinlog.pinlogback.domain.member.entity.Member;
+import com.pinlog.pinlogback.domain.member.repository.MemberRepository;
+import com.pinlog.pinlogback.domain.place.entity.Place;
+import com.pinlog.pinlogback.domain.place.repository.PlaceRepository;
+import com.pinlog.pinlogback.domain.record.entity.Context;
+import com.pinlog.pinlogback.domain.record.entity.Record;
+import com.pinlog.pinlogback.domain.record.repository.ContextRepository;
+import com.pinlog.pinlogback.domain.record.repository.RecordRepository;
+import com.pinlog.pinlogback.integration.IntegrationContainerSupport;
+
+/** 신뢰도 게이트와 관련도 재판정이 함께 켜졌을 때의 후보 순서를 고정한다. */
+@SpringBootTest
+@AutoConfigureMockMvc
+@TestPropertySource(properties = {
+ "pinlog.search.gate.enabled=true",
+ "pinlog.search.gate.similarity-threshold=0.35",
+ "pinlog.search.relevance-judge.enabled=true",
+})
+class SearchSignalCompositionApiTests extends IntegrationContainerSupport {
+
+ private static final String SEARCH_URL = "/v1/search/records";
+ private static final FastApiSearchStub STUB = new FastApiSearchStub();
+
+ @DynamicPropertySource
+ static void aiServerPointsAtTheStub(DynamicPropertyRegistry registry) {
+ registry.add("pinlog.ai.base-url", STUB::baseUrl);
+ }
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private MemberRepository memberRepository;
+
+ @Autowired
+ private PlaceRepository placeRepository;
+
+ @Autowired
+ private RecordRepository recordRepository;
+
+ @Autowired
+ private ContextRepository contextRepository;
+
+ @AfterAll
+ static void stopStub() {
+ STUB.stop();
+ }
+
+ @Test
+ void confidenceGateExcludesWeakCandidateBeforeTheJudgeCall() throws Exception {
+ long me = newMemberId();
+ long weak = newRecord(me, "composition-weak", "37.5000000", "127.0000000");
+ long weakContext = newContext(weak, me, "게이트에서 제외될 약한 후보");
+ long strong = newRecord(me, "composition-strong", "37.6000000", "127.1000000");
+ long strongContext = newContext(strong, me, "게이트를 통과할 강한 후보");
+ STUB.willReturn(
+ new FastApiSearchStub.Match(weak, weakContext, 0.20),
+ new FastApiSearchStub.Match(strong, strongContext, 0.80));
+ STUB.willJudge(new FastApiSearchStub.Judgment(strongContext, "RELEVANT"));
+
+ search(me, "질의")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(1))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(strong));
+ assertThat(STUB.lastJudgeCall().candidateContextIds()).containsExactly(strongContext);
+ }
+
+ @Test
+ void failedJudgeKeepsThePostGateCandidateOrder() throws Exception {
+ long me = newMemberId();
+ long weak = newRecord(me, "composition-fallback-weak", "37.5000000", "127.0000000");
+ long weakContext = newContext(weak, me, "게이트에서 제외될 약한 후보");
+ long strong = newRecord(me, "composition-fallback-strong", "37.6000000", "127.1000000");
+ long strongContext = newContext(strong, me, "게이트를 통과할 강한 후보");
+ STUB.willReturn(
+ new FastApiSearchStub.Match(weak, weakContext, 0.20),
+ new FastApiSearchStub.Match(strong, strongContext, 0.80));
+ STUB.willFailJudge();
+
+ search(me, "질의")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(1))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(strong));
+ }
+
+ private ResultActions search(long memberId, String query) throws Exception {
+ return mockMvc.perform(post(SEARCH_URL).with(loginAs(memberId))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"query\": \"" + query + "\"}"));
+ }
+
+ private long newMemberId() {
+ return memberRepository.save(Member.create()).getId();
+ }
+
+ private long newRecord(long memberId, String seed, String lat, String lng) {
+ String kakaoPlaceId = seed + "-" + java.util.UUID.randomUUID().toString().substring(0, 8);
+ Place place = placeRepository.save(Place.create(
+ kakaoPlaceId, "장소 " + seed, "주소 " + seed, null, null, null,
+ new BigDecimal(lat), new BigDecimal(lng)));
+ return recordRepository.save(Record.create(memberId, place.getId())).getId();
+ }
+
+ private long newContext(long recordId, long memberId, String body) {
+ return contextRepository.save(Context.create(recordId, memberId, body)).getId();
+ }
+}