From b1d2185873ef0772174ab110f38bc90e9adfbde1 Mon Sep 17 00:00:00 2001 From: colosair Date: Fri, 7 Aug 2026 19:08:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(S15P11A705):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EA=B2=B0=EA=B3=BC=20LLM=20=EA=B4=80=EB=A0=A8=EB=8F=84=20?= =?UTF-8?q?=EC=9E=AC=ED=8C=90=EC=A0=95=204=EB=B2=88=EC=A7=B8=20=EC=8B=A0?= =?UTF-8?q?=ED=98=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 배포 후 사용자가 발견한 검색 순위 오류(문장형 질의에 포함된 고유명사가 재작성·문자열 검색·키워드 재정렬 세 신호 모두의 사각지대에 걸려 관련 기록이 무관한 기록보다 낮은 순위로 나온 사례)를 교정한다. back이 3신호 병합까지 끝난 최종 후보(본문 포함)를 ai의 신규 엔드포인트에 보내 LLM 관련도 4단계로 재판정받고, 결과로 무관한 항목을 걸러내고 재정렬한다. - AiRelevanceJudgeClient — AiSearchClient를 본떴지만 실패 정책은 반대, 보조 신호라 실패를 삼키지 않고 그대로 던진다 - RecordSearchService.judgeRelevance() — mergeLexicalMatches()와 같은 강등 패턴(플래그 → 게이트 → try/catch 흡수), 실패 시 판정 이전 순서로 되돌아간다 - RelevanceJudgeProperties(pinlog.search.relevance-judge.enabled, 기본 false) · AiProperties.judge 타임아웃 · aiJudgeRestClient Bean - RelevanceJudgeSearchApiTests(켠 컨텍스트 4건) + RecordSearchApiTests 꺼짐 계약 1건, FastApiSearchStub이 /internal/v1/search/judge도 함께 받도록 확장 기본 꺼짐 — 켜기 전까지 기존 검색 동작에 영향 없음. ./gradlew clean check --no-daemon 통과. Co-Authored-By: Claude Fable 5 --- ...BI-44-2026-08-07-search-relevance-judge.md | 36 ++++ .../2026-08-07-search-relevance-judge.md | 18 ++ .../domain/ai/AiIntegrationConfig.java | 9 + .../pinlogback/domain/ai/AiProperties.java | 5 +- .../ai/client/AiRelevanceJudgeClient.java | 65 ++++++ .../ai/client/AiRelevanceJudgeRequest.java | 17 ++ .../ai/client/AiRelevanceJudgeResponse.java | 10 + .../domain/ai/client/RelevanceLabel.java | 13 ++ .../search/RelevanceJudgeProperties.java | 18 ++ .../search/service/RecordSearchService.java | 96 ++++++++- src/main/resources/application.yml | 11 + .../domain/ai/client/AiSearchClientTest.java | 2 +- .../AiPlaceSuggestionClientStubTests.java | 3 +- .../domain/search/FastApiSearchStub.java | 49 +++++ .../domain/search/RecordSearchApiTests.java | 22 ++ .../search/RelevanceJudgeSearchApiTests.java | 188 ++++++++++++++++++ 16 files changed, 554 insertions(+), 8 deletions(-) create mode 100644 docs/backend/implements/BI-44-2026-08-07-search-relevance-judge.md create mode 100644 docs/backend/worklog/2026-08-07-search-relevance-judge.md create mode 100644 src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeClient.java create mode 100644 src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeRequest.java create mode 100644 src/main/java/com/pinlog/pinlogback/domain/ai/client/AiRelevanceJudgeResponse.java create mode 100644 src/main/java/com/pinlog/pinlogback/domain/ai/client/RelevanceLabel.java create mode 100644 src/main/java/com/pinlog/pinlogback/domain/search/RelevanceJudgeProperties.java create mode 100644 src/test/java/com/pinlog/pinlogback/domain/search/RelevanceJudgeSearchApiTests.java diff --git a/docs/backend/implements/BI-44-2026-08-07-search-relevance-judge.md b/docs/backend/implements/BI-44-2026-08-07-search-relevance-judge.md new file mode 100644 index 00000000..9e2da2e9 --- /dev/null +++ b/docs/backend/implements/BI-44-2026-08-07-search-relevance-judge.md @@ -0,0 +1,36 @@ +# BI-44. 검색 결과 LLM 관련도 재판정(4번째 신호) 구현 + +- **상태**: ✅ 구현 완료. 기능 플래그는 꺼진 상태로 두었다. 켜는 결정은 별도다. +- **날짜**: 2026-08-07 +- **추적**: 티켓 미발급. 사용자가 실배포에서 발견한 검색 순위 오류를 직접 지시해 시작한 작업이다. 티켓이 발급되면 이 줄을 갱신한다. +- **관련**: 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..22b78d1d --- /dev/null +++ b/docs/backend/worklog/2026-08-07-search-relevance-judge.md @@ -0,0 +1,18 @@ +# 검색 4번째 신호(LLM 관련도 재판정)를 추가했다 + +- **날짜**: 2026-08-07 +- **관련**: [BI-44](../implements/BI-44-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 db661a44..04680014 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 @@ -15,12 +15,17 @@ 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.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; @@ -53,7 +58,7 @@ * 재검증하는 일이라 한 스냅샷으로 묶는다고 더 정확해지지 않는다. */ @Service -@EnableConfigurationProperties(LexicalSearchProperties.class) +@EnableConfigurationProperties({LexicalSearchProperties.class, RelevanceJudgeProperties.class}) public class RecordSearchService { private static final Logger log = LoggerFactory.getLogger(RecordSearchService.class); @@ -70,21 +75,26 @@ 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 RelevanceJudgeProperties relevanceJudgeProperties; - public RecordSearchService(AiSearchClient aiSearchClient, SearchRecordRepository searchRecordRepository, - ContextRepository contextRepository, ContextKeywordRepository contextKeywordRepository, - LexicalContextRepository lexicalContextRepository, LexicalSearchProperties lexicalProperties) { + public RecordSearchService(AiSearchClient aiSearchClient, AiRelevanceJudgeClient aiRelevanceJudgeClient, + SearchRecordRepository searchRecordRepository, ContextRepository contextRepository, + ContextKeywordRepository contextKeywordRepository, LexicalContextRepository lexicalContextRepository, + LexicalSearchProperties lexicalProperties, RelevanceJudgeProperties relevanceJudgeProperties) { this.aiSearchClient = aiSearchClient; + this.aiRelevanceJudgeClient = aiRelevanceJudgeClient; this.searchRecordRepository = searchRecordRepository; this.contextRepository = contextRepository; this.contextKeywordRepository = contextKeywordRepository; this.lexicalContextRepository = lexicalContextRepository; this.lexicalProperties = lexicalProperties; + this.relevanceJudgeProperties = relevanceJudgeProperties; } /** @@ -105,7 +115,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 = @@ -148,6 +162,78 @@ private List mergeLexicalMatches(long memberId, RecordSe return rrfMerge(vector, lexical, request.sizeOrDefault()); } + /** + * 검색 결과 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 479a61dc..98acf63d 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} # Feed 추천 정책값. 정본은 AI 파트가 소유한 docs/ai/spec/feed-scoring.md이며 여기서 임의로 # 바꾸지 않는다. 상수로 박지 않고 설정으로 두는 이유는 튜닝 대상이기 때문이다 — 재배포 없이 # 조정할 수 있어야 하고, 가중치를 바꿔도 순위가 안 바뀌는 회귀를 테스트가 잡을 수 있어야 한다. 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 d646f4fd..abecdb37 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(); @@ -36,6 +38,14 @@ final class FastApiSearchStub { record Match(long recordId, long contextId, double similarity) { } + /** {@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) { + } + /** * 요청 도착 시점의 관측 결과. * @@ -81,6 +91,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 { @@ -89,6 +102,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(); } @@ -116,6 +130,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) { @@ -155,6 +185,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}" 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 84c9c010..682b6f01 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(); + } + /** * {@code keywords}는 매칭 Context의 것이 아니라 Record의 활성 Context 전체 집계다 * (API 명세 6.1). 매칭 Context만 보면 같은 Record의 다른 Context가 가진 Keyword가 사라진다. 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(); + } +}