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 judge(String query, + List candidates) { + AiRelevanceJudgeRequest request = new AiRelevanceJudgeRequest(query, candidates); + AiRelevanceJudgeResponse response = restClient.post() + .uri(PATH) + .header(INTERNAL_SECRET_HEADER, internalSecret) + .header(REQUEST_ID_HEADER, currentRequestId()) + .body(request) + .retrieve() + .body(AiRelevanceJudgeResponse.class); + return response == null || response.results() == null ? List.of() : response.results(); + } + + /** 요청 스레드에서 동기로 도므로 MDC에 traceId가 있다. 없으면 새로 만든다. */ + private String currentRequestId() { + String traceId = MDC.get(TraceIdFilter.TRACE_ID); + return traceId != null ? traceId : UUID.randomUUID().toString(); + } +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeRequest.java b/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeRequest.java new file mode 100644 index 00000000..5e401732 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeRequest.java @@ -0,0 +1,17 @@ +package com.pinlog.pinlogback.domain.ai.client; + +import java.util.List; + +/** + * {@code POST /internal/v1/search/judge} 요청 본문. {@code candidates}는 3신호 병합까지 끝난 + * 최종 후보다 — ai(FastAPI)는 본문을 저장하지 않으므로({@code ai.context_embedding}· + * {@code ai.context_keyword} 어디에도 텍스트 컬럼이 없다) back이 능동적으로 본문을 실어 보낸다. + * "FastAPI는 본문을 반환하지 않는다"(05_AI_설계 L626·L634·L933)는 ai→back 응답 방향의 조항이라 + * 이 방향(back→ai 요청)을 막지 않는다 — {@code ContextProcessRequest.text}가 이미 같은 방향의 + * 선례다. + */ +public record AiRelevanceJudgeRequest(String query, List candidates) { + + public record Candidate(Long contextId, String placeName, String body) { + } +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeResponse.java b/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeResponse.java new file mode 100644 index 00000000..b65a6eb4 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeResponse.java @@ -0,0 +1,10 @@ +package com.pinlog.pinlogback.domain.ai.client; + +import java.util.List; + +/** {@code POST /internal/v1/search/judge} 응답 본문. */ +public record AiRelevanceJudgeResponse(List results) { + + public record Judgment(Long contextId, RelevanceLabel relevance) { + } +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/ai/client/RelevanceLabel.java b/src/main/java/com/pinlog/pinlogback/domain/ai/client/RelevanceLabel.java new file mode 100644 index 00000000..2f4b378d --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/ai/client/RelevanceLabel.java @@ -0,0 +1,13 @@ +package com.pinlog.pinlogback.domain.ai.client; + +/** + * 검색 후보 LLM 관련도 재판정 4단계(검색 4번째 신호). ai 레포 + * {@code app/schema/relevance.py::RelevanceLabel}과 이름·순서가 같아야 한다 — 어긋나면 등급 + * 문자열은 역직렬화되지만 back의 정렬 우선순위(가장 높은 등급부터)가 그 파트의 의도와 달라진다. + */ +public enum RelevanceLabel { + VERY_RELEVANT, + RELEVANT, + WEAKLY_RELEVANT, + NOT_RELEVANT +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/search/RelevanceJudgeProperties.java b/src/main/java/com/pinlog/pinlogback/domain/search/RelevanceJudgeProperties.java new file mode 100644 index 00000000..1f11a40c --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/search/RelevanceJudgeProperties.java @@ -0,0 +1,18 @@ +package com.pinlog.pinlogback.domain.search; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 검색 결과 LLM 관련도 재판정(4번째 신호) 설정. + * + *

{@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가 준 것을 그대로 내보내는 단계가 없다는 점이 핵심이다. * *

    *
  1. FastAPI 호출 — Record 단위로 집계된 @@ -47,6 +52,8 @@ * 뺀다(S15P11A705-400, 기본 꺼짐). 문자열·키워드 재정렬과는 독립된 마지막 판단이다
  2. *
  3. Core 재검증 — 소유권·삭제·활성 Context·Place를 Spring이 다시 본다(9.5). 문자열 후보도 * 똑같이 지난다
  4. + *
  5. 관련도 재판정 — 게이트를 통과하고 Core 재검증된 후보만 LLM이 관련도 순으로 재정렬한다 + * (기본 꺼짐). 호출 실패는 게이트 적용 후 순서를 유지한다
  6. *
  7. 조립 — 본문·Keyword·판정 상태는 Core에서 조회해 붙인다. FastAPI는 본문을 주지 않는다
  8. *
  9. bounds 계산 — 재검증을 통과한 것들로만 계산한다
  10. *
@@ -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 verifiedRecordIds = List.copyOf(verified.keySet()); + + matches = judgeRelevance(matches, verified, matchedContexts, request.query()); + + List verifiedRecordIds = + matches.stream().map(AiSearchResponse.Match::recordId).distinct().toList(); Map> keywords = contextKeywordRepository.findKeywordsForOwner(verifiedRecordIds, memberId); Map keywordStatuses = @@ -202,6 +222,78 @@ private List applyConfidenceGate( .toList(); } + /** + * 검색 결과 LLM 관련도 재판정(4번째 신호). 문장형 질의에 포함된 고유명사처럼 재작성·문자열 + * 검색·재정렬 세 신호 모두의 사각지대를(예: "싸피 다녔던 헬스장"에서 본문에 "싸피"가 그대로 + * 있는 기록이 벡터 유사도만으로는 밀리는 경우) 이 신호가 메운다. 신호는 {@code matches}· + * {@code verified}·{@code matchedContexts}가 전부 갖춰진 뒤 마지막에 붙는다 — 그래야 + * 3신호 병합까지 끝난 진짜 최종 후보를 LLM이 본다. + * + *

실패 정책은 {@link #mergeLexicalMatches}와 같다: 플래그 꺼짐·판정할 후보 없음·판정 호출 + * 실패는 모두 원본 순서를 그대로 돌려준다. 이 신호는 보조 신호이고, 이 신호의 장애가 검색 + * 자체를 막으면 안 된다. + * + *

{@code NOT_RELEVANT}는 제거하고 나머지는 (등급 desc, 원 순서)로 안정 정렬한다. 판정이 + * 누락된 항목(ai가 응답에서 빠뜨렸거나 back이 알 수 없는 값이라 걸러졌을 때)은 제거하지 않고 + * {@code RELEVANT}와 같은 우선순위로 원 순서 근처에 남긴다 — 판정 실패 하나가 결과를 지우면 + * 안 된다는 원칙(§ {@link #assemble})을 이 신호에도 적용한다. 모든 후보가 + * {@code NOT_RELEVANT}면 빈 목록을 그대로 신뢰한다 — 근거 없는 결과를 내보내지 않는다는 + * 이 신호의 취지와 일치한다. + */ + private List judgeRelevance(List matches, + Map verified, Map matchedContexts, String query) { + if (!relevanceJudgeProperties.enabled()) { + return matches; + } + List candidates = new ArrayList<>(); + for (AiSearchResponse.Match match : matches) { + VerifiedSearchRecord record = verified.get(match.recordId()); + Context context = matchedContexts.get(match.contextId()); + if (record == null || context == null) { + continue; + } + candidates.add(new AiRelevanceJudgeRequest.Candidate( + match.contextId(), record.placeName(), context.getBody())); + } + if (candidates.isEmpty()) { + return matches; + } + List judgments; + try { + judgments = aiRelevanceJudgeClient.judge(query, candidates); + } catch (RuntimeException e) { + log.warn("relevance judge failed; returning pre-judge results", e); + return matches; + } + Map byContext = new HashMap<>(); + for (AiRelevanceJudgeResponse.Judgment judgment : judgments) { + if (judgment != null && judgment.contextId() != null && judgment.relevance() != null) { + byContext.put(judgment.contextId(), judgment.relevance()); + } + } + List kept = new ArrayList<>(); + for (AiSearchResponse.Match match : matches) { + if (byContext.get(match.contextId()) != RelevanceLabel.NOT_RELEVANT) { + kept.add(match); + } + } + kept.sort(Comparator.comparingInt(m -> relevanceRank(byContext.get(m.contextId())))); + return List.copyOf(kept); + } + + /** 판정 누락(다른 판정도 실을 게 없는 {@code null})은 {@code RELEVANT}와 동급으로 취급한다. */ + private static int relevanceRank(RelevanceLabel label) { + if (label == null) { + return 1; + } + return switch (label) { + case VERY_RELEVANT -> 0; + case RELEVANT -> 1; + case WEAKLY_RELEVANT -> 2; + case NOT_RELEVANT -> 3; + }; + } + /** * 단어형인가 — 공백이 없고 짧을 때만 그렇다. ai 레포 {@code SearchService._is_word_query}와 * 같은 판정이어야 한다: 거기서 문장형으로 컷을 탄 질의가 여기서 단어형으로 문자열 경로를 타면 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 4a56973a..1ded3929 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -146,6 +146,12 @@ pinlog: # 스레드 점유만 늘린다(AI 파트 소유 명세 docs/ai/spec/ai-integration.md 3장). connect-timeout: 1s read-timeout: 5s + judge: + # 검색 4번째 신호(관련도 재판정). 후보 최대 10건의 본문을 한 번의 LLM 호출로 판정하는 + # 동기 경로라 search보다 길다. 초과·실패는 예외가 아니라 강등이다 — 판정 이전 순서로 + # 되돌아간다(RecordSearchService.judgeRelevance). + connect-timeout: 1s + read-timeout: 10s place-suggestion: connect-timeout: 1s read-timeout: 35s @@ -178,6 +184,11 @@ pinlog: # 단어형 질의의 최대 글자 수. ai 레포 SEARCH_WORD_QUERY_MAX_CHARS와 같은 값·같은 의미여야 # 한다 — 어긋나면 「단어형」의 정의가 파트마다 달라져 게이트(단어형 한정)가 절반만 켜진다. word-query-max-chars: ${PINLOG_SEARCH_LEXICAL_WORD_QUERY_MAX_CHARS:5} + # 검색 결과 LLM 관련도 재판정(4번째 신호). 사용자 보고 실사례(문장형 질의에 포함된 고유명사가 + # 세 신호 모두의 사각지대에 걸려 관련 기록이 무관한 기록보다 낮은 순위로 나온 사례)를 교정한다. + # 기본값 false가 곧 "현행과 동일한 검색"이다 — 켜는 것은 이 구현이 배포·관측된 뒤의 결정이다. + relevance-judge: + enabled: ${PINLOG_SEARCH_RELEVANCE_JUDGE_ENABLED:false} # 결합 신뢰도 게이트(S15P11A705-400, BD-52). 벡터 신호 하나뿐이고 유사도가 낮은 결과를 # 응답에서 뺀다. 기본값 false가 곧 "현행과 동일한 검색"이다. gate: diff --git a/src/test/java/com/pinlog/pinlogback/domain/ai/client/AiSearchClientTest.java b/src/test/java/com/pinlog/pinlogback/domain/ai/client/AiSearchClientTest.java index 3b89b59a..8910ba0f 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/ai/client/AiSearchClientTest.java +++ b/src/test/java/com/pinlog/pinlogback/domain/ai/client/AiSearchClientTest.java @@ -67,7 +67,7 @@ private AiSearchClient newClient(String embeddingProfile, MockEnvironment enviro AiProperties.Timeouts timeouts = new AiProperties.Timeouts(Duration.ofSeconds(1), Duration.ofSeconds(5)); AiProperties properties = new AiProperties( - "http://localhost:8000", "test-internal-secret", embeddingProfile, timeouts, timeouts); + "http://localhost:8000", "test-internal-secret", embeddingProfile, timeouts, timeouts, timeouts); return new AiSearchClient( RestClient.create(), JsonMapper.builder().build(), properties, environment); } diff --git a/src/test/java/com/pinlog/pinlogback/domain/place/AiPlaceSuggestionClientStubTests.java b/src/test/java/com/pinlog/pinlogback/domain/place/AiPlaceSuggestionClientStubTests.java index ecd239a2..697a4c4a 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/place/AiPlaceSuggestionClientStubTests.java +++ b/src/test/java/com/pinlog/pinlogback/domain/place/AiPlaceSuggestionClientStubTests.java @@ -105,7 +105,8 @@ private AiPlaceSuggestionClient newClient() { .build(); AiProperties.Timeouts existingTimeouts = new AiProperties.Timeouts(connectTimeout, readTimeout); AiProperties properties = new AiProperties( - STUB.baseUrl(), INTERNAL_SECRET, "test-profile", existingTimeouts, existingTimeouts); + STUB.baseUrl(), INTERNAL_SECRET, "test-profile", existingTimeouts, existingTimeouts, + existingTimeouts); AiPlaceSuggestionProperties placeProperties = new AiPlaceSuggestionProperties(connectTimeout, readTimeout, 1); return new AiPlaceSuggestionClient( diff --git a/src/test/java/com/pinlog/pinlogback/domain/search/FastApiSearchStub.java b/src/test/java/com/pinlog/pinlogback/domain/search/FastApiSearchStub.java index 3c41df9e..39869f03 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/search/FastApiSearchStub.java +++ b/src/test/java/com/pinlog/pinlogback/domain/search/FastApiSearchStub.java @@ -29,6 +29,8 @@ final class FastApiSearchStub { static final String PATH = "/internal/v1/search"; + /** 검색 4번째 신호(관련도 재판정)의 경로. 같은 대역이 같은 포트에서 함께 받는다. */ + static final String JUDGE_PATH = "/internal/v1/search/judge"; private static final JsonMapper JSON = JsonMapper.builder().build(); @@ -45,6 +47,14 @@ record Match(long recordId, long contextId, double similarity, boolean keywordMa } } + /** {@code POST /internal/v1/search/judge} 응답 한 건. */ + record Judgment(long contextId, String relevance) { + } + + /** {@code /internal/v1/search/judge}로 도착한 요청의 관측 결과. */ + record JudgeReceived(String query, java.util.List candidateContextIds, String internalSecret) { + } + /** * 요청 도착 시점의 관측 결과. * @@ -90,6 +100,9 @@ enum Mode { private final AtomicReference mode = new AtomicReference<>(Mode.RESULTS); private final AtomicReference> results = new AtomicReference<>(List.of()); private final AtomicReference received = new AtomicReference<>(); + private final AtomicReference> judgments = new AtomicReference<>(List.of()); + private final AtomicReference judgeFails = new AtomicReference<>(false); + private final AtomicReference judgeReceived = new AtomicReference<>(); FastApiSearchStub() { try { @@ -98,6 +111,7 @@ enum Mode { throw new IllegalStateException("FastAPI 검색 대역을 띄우지 못했다", e); } server.createContext(PATH, this::handle); + server.createContext(JUDGE_PATH, this::handleJudge); server.setExecutor(Executors.newFixedThreadPool(2)); server.start(); } @@ -125,6 +139,22 @@ Received lastCall() { return received.get(); } + /** 관련도 재판정 응답을 갈아 끼운다. contextId별 4단계 라벨. */ + void willJudge(Judgment... items) { + judgeFails.set(false); + judgments.set(List.of(items)); + } + + /** 관련도 재판정 호출이 5xx로 실패하는 상황을 재현한다 — 강등 계약 검증용. */ + void willFailJudge() { + judgeFails.set(true); + } + + /** 판정 호출이 없었으면 {@code null}. */ + JudgeReceived lastJudgeCall() { + return judgeReceived.get(); + } + void stop() { server.stop(0); if (server.getExecutor() instanceof ExecutorService executor) { @@ -164,6 +194,25 @@ private void handle(HttpExchange exchange) throws IOException { } } + private void handleJudge(HttpExchange exchange) throws IOException { + JsonNode body = JSON.readTree(exchange.getRequestBody().readAllBytes()); + List candidateIds = new java.util.ArrayList<>(); + body.path("candidates").forEach(c -> candidateIds.add(c.path("contextId").asLong())); + judgeReceived.set(new JudgeReceived( + body.path("query").asString(""), + List.copyOf(candidateIds), + exchange.getRequestHeaders().getFirst("X-Internal-Secret"))); + + if (Boolean.TRUE.equals(judgeFails.get())) { + respond(exchange, 503, ""); + return; + } + String items = judgments.get().stream() + .map(j -> "{\"contextId\":%d,\"relevance\":\"%s\"}".formatted(j.contextId(), j.relevance())) + .collect(Collectors.joining(",")); + respond(exchange, 200, "{\"results\":[" + items + "]}"); + } + private String resultsJson() { String items = results.get().stream() .map(match -> "{\"recordId\":%d,\"contextId\":%d,\"similarity\":%s,\"keywordMatched\":%s}" diff --git a/src/test/java/com/pinlog/pinlogback/domain/search/RecordSearchApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/search/RecordSearchApiTests.java index bb69dde5..f8eb4ce6 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/search/RecordSearchApiTests.java +++ b/src/test/java/com/pinlog/pinlogback/domain/search/RecordSearchApiTests.java @@ -431,6 +431,28 @@ void lexicalMergeIsOffByDefaultSoABodyMatchAddsNothing() throws Exception { .andExpect(jsonPath("$.data.items[0].recordId").value(vectorOnly)); } + /** + * 관련도 재판정(4번째 신호)도 기본값이 꺼짐이다. 대역이 결과를 지우는 판정 + * ({@code NOT_RELEVANT})을 돌려주도록 프로그래밍해도, 플래그가 꺼져 있으면 판정 호출 자체가 + * 없어 그 판정이 응답에 아무 영향도 못 준다 — 이 테스트가 깨졌다면 기본값이 켜졌거나 + * 판정 호출이 게이트 없이 나가고 있는 것이다. 켠 상태의 계약은 + * {@link RelevanceJudgeSearchApiTests}가 맡는다. + */ + @Test + void relevanceJudgeIsOffByDefaultSoAStubbedNotRelevantJudgmentIsIgnored() throws Exception { + long me = newMemberId(); + long onlyMatch = newRecord(me, "search-judgeoff", "37.5000000", "127.0000000"); + long onlyMatchContext = newContext(onlyMatch, me, "벡터로만 잡히는 기록"); + STUB.willReturn(new FastApiSearchStub.Match(onlyMatch, onlyMatchContext, 0.82)); + STUB.willJudge(new FastApiSearchStub.Judgment(onlyMatchContext, "NOT_RELEVANT")); + + search(me, "질의") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].recordId").value(onlyMatch)); + assertThat(STUB.lastJudgeCall()).isNull(); + } + /** * 결합 신뢰도 게이트(S15P11A705-400, BD-52)는 기본값이 꺼짐이고, 꺼진 상태의 응답은 * 현행과 완전히 같아야 한다. 유사도가 매우 낮아도 게이트가 꺼져 있으면 지워지지 않는다 — diff --git a/src/test/java/com/pinlog/pinlogback/domain/search/RelevanceJudgeSearchApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/search/RelevanceJudgeSearchApiTests.java new file mode 100644 index 00000000..54154029 --- /dev/null +++ b/src/test/java/com/pinlog/pinlogback/domain/search/RelevanceJudgeSearchApiTests.java @@ -0,0 +1,188 @@ +package com.pinlog.pinlogback.domain.search; + +import static com.pinlog.pinlogback.support.AuthTestSupport.loginAs; +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; + +/** + * 검색 결과 LLM 관련도 재판정(4번째 신호)의 계약을 고정한다. 배포 후 사용자가 보고한 실사례 + * ("싸피 다녔던 헬스장" 질의에서 본문에 "싸피"가 있는 기록이 없는 기록보다 낮은 순위로 나온 것)를 + * 이 신호가 교정한다는 것이 목적이다. + * + *

이 클래스는 플래그를 컨텍스트에서 돈다. 기본값(끔)에서 현행과 동일하다는 계약은 + * {@link RecordSearchApiTests}가 기본값 컨텍스트에서 고정한다. + * + *

단언의 축은 셋이다. + * + *

    + *
  1. 필터·재정렬 — {@code NOT_RELEVANT}는 제거되고 나머지는 등급 desc로 재배치된다
  2. + *
  3. 강등 — 판정 호출이 실패하면 판정 이전 순서를 그대로 반환한다(검색 자체는 성공)
  4. + *
  5. 전부 무관 — 모든 후보가 {@code NOT_RELEVANT}면 빈 결과를 그대로 신뢰한다
  6. + *
+ */ +@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(); + } +}