Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions docs/backend/implements/BI-45-2026-08-07-search-relevance-judge.md
Original file line number Diff line number Diff line change
@@ -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 반영, 플래그 활성화는 이번 범위 밖이다. 켜는 결정은 별도로 한다.
18 changes: 18 additions & 0 deletions docs/backend/worklog/2026-08-07-search-relevance-judge.md
Original file line number Diff line number Diff line change
@@ -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 병합은 사용자 승인 후로 남겨 뒀다.
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,17 @@
* {@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(
String baseUrl,
String internalSecret,
String embeddingProfile,
Timeouts process,
Timeouts search
Timeouts search,
Timeouts judge
) {

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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 관련도 재판정).
*
* <p>{@link AiSearchClient}와 실패 정책이 정반대다. 저쪽은 주 신호라 모든 실패를 예외로 올리지만,
* 이 클라이언트는 <b>보조 신호</b>다. 그래서 실패를 여기서 흡수하지 않고 그대로 던진다 —
* {@code RecordSearchService.judgeRelevance()}가 {@code mergeLexicalMatches()}와 같은 강등
* 패턴(플래그 → 게이트 → try/catch 흡수)으로 받아, 실패하면 판정 이전 순서를 그대로 쓴다.
*
* <p>재시도하지 않는다 — 사용자 요청 경로이고, 판정 실패는 어차피 원 순서로 강등되므로 재시도로
* 얻는 값이 없다(오히려 요청 스레드만 더 붙잡는다).
*/
@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 호출이 실패했을 때. <b>여기서
* 삼키지 않는다</b> — 흡수는 호출부의 책임이다
*/
public List<AiRelevanceJudgeResponse.Judgment> judge(String query,
List<AiRelevanceJudgeRequest.Candidate> 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();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.pinlog.pinlogback.domain.ai.client;

import java.util.List;

/**
* {@code POST /internal/v1/search/judge} 요청 본문. {@code candidates}는 3신호 병합까지 끝난
* <b>최종 후보</b>다 — 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<Candidate> candidates) {

public record Candidate(Long contextId, String placeName, String body) {
}
}
Original file line number Diff line number Diff line change
@@ -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<Judgment> results) {

public record Judgment(Long contextId, RelevanceLabel relevance) {
}
}
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package com.pinlog.pinlogback.domain.search;

import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* 검색 결과 LLM 관련도 재판정(4번째 신호) 설정.
*
* <p>{@code enabled}의 기본값이 {@code false}인 것은 {@link LexicalSearchProperties}와 같은
* 이유다 — 신규 신호는 끈 상태가 현행과 동일해야 하고, 켜는 것은 이 구현이 배포·관측된 뒤의
* 별도 결정이다.
*
* @param enabled 관련도 재판정을 켤지. 꺼져 있으면 ai 호출 자체가 없다
*/
@ConfigurationProperties("pinlog.search.relevance-judge")
public record RelevanceJudgeProperties(
boolean enabled
) {
}
Loading
Loading