From 8a739d4d0de3aee879c149639578331182177624 Mon Sep 17 00:00:00 2001 From: ghkim1632 Date: Fri, 7 Aug 2026 18:52:09 +0900 Subject: [PATCH] =?UTF-8?q?feat(S15P11A705-400):=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EC=A1=B0=EB=A6=BD=20=EB=8B=A8=EA=B3=84?= =?UTF-8?q?=EC=97=90=20=EA=B2=B0=ED=95=A9=20=EC=8B=A0=EB=A2=B0=EB=8F=84=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=EB=A5=BC=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit S1(벡터)·S2(문자열)·S3(키워드) 세 신호 중 S1 하나뿐이고 유사도가 임계값(기본 0.35, ai 레포 S15P11A705-401 재측정 채택값) 미만인 결과를 응답에서 뺀다. S2·S3 중 하나라도 있으면 유사도와 무관하게 남긴다. 게이트는 문자열 병합 직후·Core 재검증 이전에 건다 — 세 신호가 전부 갖춰지는 가장 이른 지점이라 DTO에 신호를 얹지 않고 불필요한 Core 조회도 만들지 않는다 (BD-52). AiSearchResponse.Match에 keywordMatched 필드를 추가해 ai 레포가 이미 계산해 보내는 S3 신호를 받는다. 기본값은 꺼짐 — 검증 게이트 통과 전에는 현행과 동일하다. --- ...e-gate-filters-before-core-revalidation.md | 67 ++++++ .../BI-44-2026-08-07-confidence-gate.md | 41 ++++ ...26-08-07-S15P11A705-400-confidence-gate.md | 11 + .../domain/ai/client/AiSearchResponse.java | 7 +- .../search/ConfidenceGateProperties.java | 23 ++ .../search/service/RecordSearchService.java | 80 +++++-- src/main/resources/application.yml | 7 + .../domain/search/ConfidenceGateApiTests.java | 201 ++++++++++++++++++ .../domain/search/FastApiSearchStub.java | 17 +- .../domain/search/RecordSearchApiTests.java | 18 ++ 10 files changed, 454 insertions(+), 18 deletions(-) create mode 100644 docs/backend/decisions/BD-52-confidence-gate-filters-before-core-revalidation.md create mode 100644 docs/backend/implements/BI-44-2026-08-07-confidence-gate.md create mode 100644 docs/backend/worklog/2026-08-07-S15P11A705-400-confidence-gate.md create mode 100644 src/main/java/com/pinlog/pinlogback/domain/search/ConfidenceGateProperties.java create mode 100644 src/test/java/com/pinlog/pinlogback/domain/search/ConfidenceGateApiTests.java diff --git a/docs/backend/decisions/BD-52-confidence-gate-filters-before-core-revalidation.md b/docs/backend/decisions/BD-52-confidence-gate-filters-before-core-revalidation.md new file mode 100644 index 00000000..f3815035 --- /dev/null +++ b/docs/backend/decisions/BD-52-confidence-gate-filters-before-core-revalidation.md @@ -0,0 +1,67 @@ +# BD-52. 결합 신뢰도 게이트는 문자열 병합 직후·Core 재검증 이전에 건다 + +- **상태**: Accepted +- **날짜**: 2026-08-07 +- **관련**: `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4 · ai 레포 + [P49](../../ai/proposals/P49-multi-signal-search.md) §4-5(similarity 비노출 계약) · 오프라인 + 재측정 [S15P11A705-401](../../ai/implements/2026-08-07-gate-threshold-remeasure.md) +- **번호**: [BD-45](BD-45-worklog-per-entry-files.md)의 규칙대로 `dev` 머지 순서가 번호를 확정한다. + 머지 시점에 52가 이미 다른 결정에 쓰였다면 [BD-50](BD-50-datasource-redis-config-follows-infra-env-vars.md)의 + 선례대로 번호만 옮기고 내용은 그대로 둔다. + +## 맥락 + +`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4는 검색 결과의 낮은 연관도 노출을 줄이기 위해 +새 게이트를 제안했다 — S1(벡터)·S2(문자열)·S3(키워드) 세 신호 중 S1 하나뿐이고 유사도가 낮으면 +결과에서 뺀다. 그 문서는 "응답을 최종 조립하는 단계"에 판단 함수를 두라고 적었지만, 실제 +`RecordSearchService.search()`의 흐름(문자열 병합 → Core 재검증 → 조립 → bounds 계산)을 보면 +"최종 조립"이 정확히 어느 지점을 가리키는지 자명하지 않다. + +세 신호가 전부 갖춰지는 가장 이른 지점은 **문자열 병합 직후**다. + +- S1(벡터 유사도)은 FastAPI 응답에 항상 있다. +- S2(문자열 매치)는 `mergeLexicalMatches`가 병합하는 순간 결정된다 — 그 이전엔 아직 문자열 + 후보 목록조차 없다. +- S3(키워드 매치)는 ai 레포가 이미 응답 필드(`keywordMatched`, S15P11A705-399)로 실어 보낸다 — + Spring이 계산하지 않는다. + +## 선택지 + +| 안 | 장점 | 단점 | +|---|---|---| +| (a) 문서 표현 그대로 "응답 조립(`assemble`) 이후"에 게이트를 건다 | 문서가 말한 "최종"이라는 표현에 더 가깝다 | `RecordSearchItemResponse`는 `keywordMatched`·문자열 매치 여부를 들고 있지 않다 — 그 정보를 조립까지 끌고 가려면 DTO를 늘리거나 별도 Map을 나란히 들고 다녀야 한다. 게다가 이미 뺄 것이 정해진 Record까지 Core 재검증(소유권·삭제 조회)을 거치는 낭비가 생긴다 | +| **(b) `mergeLexicalMatches` 직후·`searchRecordRepository.findVerified` 이전에 게이트를 건다** | 세 신호가 전부 갖춰지는 가장 이른 지점이라 추가 상태를 끌고 다닐 필요가 없다. 게이트가 뺄 Record는 애초에 Core 조회 대상에서 빠지므로 DB 조회가 준다 | "응답 조립"이라는 문서 표현과 정확히 같은 자리는 아니다 — 다만 사용자에게 보이는 최종 결과는 (a)와 완전히 같다 | + +## 결정 + +**(b)를 골랐다.** + +1. **사용자 관점에서 (a)·(b)는 같은 응답을 만든다.** 게이트는 "보여줄지 말지"만 정하고 그 + 판단에 필요한 정보(유사도·S2·S3)는 이미 병합 단계에서 전부 확정돼 있다. 그 뒤 어느 단계에서 + 걸러도 최종 응답은 같다 — 그래서 "최종 조립 단계"라는 문서의 표현은 결과적 위치이지 구현 + 위치의 강제가 아니라고 읽었다. +2. **불필요한 DB 조회를 만들지 않는다.** 게이트가 뺄 Record까지 Core에 소유권·삭제 여부를 + 묻는 것은 버릴 조회다. `mergeLexicalMatches` 직후에 걸면 그 조회 자체가 없다. +3. **새 필드를 DTO에 얹지 않는다.** `RecordSearchItemResponse`는 API 응답 계약이다. + `keywordMatched`·문자열 매치 여부는 게이트 판단에만 쓰고 클라이언트에 노출하지 않는데(문서 + §4.1이 명시한 원칙 — "보여줄지 말지"는 완전히 새로운 마지막 단계이지 기존 필드의 의미를 + 바꾸는 것이 아니다), (a)를 택하면 그 신호를 조립 단계까지 옮기기 위한 임시 구조가 + 필요해진다. (b)는 `AiSearchResponse.Match`(이미 `keywordMatched`를 들고 있다)와 병합 + 단계의 지역 변수(`lexicalMatchedRecordIds`)만으로 끝난다. + +## 결과 + +- **이 결정으로 감수하는 것** + - 게이트는 `List` 단위로 판단한다 — `RecordSearchItemResponse`가 + 만들어지기 전이다. 나중에 게이트 판단이 Core 데이터(예: Place 카테고리)를 필요로 하게 + 되면 이 위치를 다시 바꿔야 한다. + - `mergeLexicalMatches`의 반환 타입이 `List`에서 `LexicalMergeResult`(병합 목록 + + 문자열 매치 Record id 집합)로 바뀌었다. 이 메서드를 호출하는 곳이 늘면 그 신호도 함께 + 옮겨야 한다는 것을 기억해야 한다. +- **재검토 트리거** + - 게이트 판단이 벡터·문자열·키워드 외의 신호(예: Place 메타데이터)를 쓰게 되면, 그 신호가 + 준비되는 시점에 맞춰 게이트 위치를 다시 정한다. + - 문서(`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`)가 가리켰던 + `.claude/handoff/SEARCH-UPGRADE-HANDOFF.md`는 이 레포·`ai`·`docs` 어디에도 없었다 — 이 + 결정과 구현은 그 문서 대신 P49 제안서와 `search-upgrade` 브랜치의 실제 코드를 근거로 + 삼았다는 것을 남겨 둔다. diff --git a/docs/backend/implements/BI-44-2026-08-07-confidence-gate.md b/docs/backend/implements/BI-44-2026-08-07-confidence-gate.md new file mode 100644 index 00000000..0b720c47 --- /dev/null +++ b/docs/backend/implements/BI-44-2026-08-07-confidence-gate.md @@ -0,0 +1,41 @@ +# BI-44. 결합 신뢰도 게이트 구현 + +- **상태**: ✅ 구현 완료. 기능 플래그는 꺼진 상태로 두었다. 켜는 결정은 검색 고도화 검증 게이트 통과 뒤의 일이다. +- **날짜**: 2026-08-07 +- **추적**: S15P11A705-400 +- **관련**: `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`(중앙 조정 세션 인계 문서) §4 · ai 레포 `docs/proposals/P49-multi-signal-search.md` · ai 레포 `docs/implements/2026-08-07-gate-threshold-remeasure.md`(임계값 오프라인 재측정, S15P11A705-401) · [BD-52](../decisions/BD-52-confidence-gate-filters-before-core-revalidation.md)(게이트 위치 결정) + +## 배경 + +현행 검색은 유사도 하한(τ_abs·r)으로 관련 없는 결과를 자르지만, 무관한 질의의 최고 유사도가 실제 정답의 유사도보다 높게 나오는 역전이 실측으로 확인됐다(중앙 조정 세션 §2.2). 임계값 하나로는 잡음을 다 자르면서 정답을 다 살릴 수 없다. + +인계 문서 §4가 제안한 게이트는 벡터 유사도 하나만으로 판정하지 않는다. 컷을 통과한 결과마다 근거 신호 세 가지 — S1(벡터 컷 통과)·S2(문자열 매치)·S3(키워드 매치) — 를 세고, S1 하나뿐이고 유사도가 낮으면 그 결과를 응답에서 뺀다. S2나 S3가 하나라도 있으면 유사도가 낮아도 남긴다 — 문자열이나 키워드로 뒷받침되는 결과는 벡터 유사도의 역전 문제에서 자유롭기 때문이다. + +## 산출 + +- **`ConfidenceGateProperties`** 신설. `pinlog.search.gate.enabled`는 게이트를 켜고 끄는 설정이고 기본값은 꺼짐이다. `similarity-threshold`는 S1 단독 결과를 제외하는 유사도 하한이고 기본값은 0.35 — ai 레포가 이 용도로 별도 오프라인 재측정한 채택값이다(S15P11A705-401). +- **`AiSearchResponse.Match`에 `keywordMatched` 필드 추가.** ai 레포가 검색 응답 스키마에 이미 실어 보내는 값이다(ai 레포 S15P11A705-399). 재정렬이 이미 계산하지만 순서에만 쓰고 버리던 신호를 back까지 살렸다. +- **`RecordSearchService` 수정.** + - `mergeLexicalMatches`가 병합된 목록뿐 아니라 문자열로 매치된 Record id 집합(S2 신호)도 함께 돌려주도록 반환 타입을 `LexicalMergeResult`로 바꿨다. 그 신호는 `rrfMerge` 안에서만 알고 버려지던 것이었다. + - `applyConfidenceGate`를 신설해 문자열 병합 직후·Core 재검증 이전에 게이트를 건다(위치 근거는 BD-52). S2·S3 중 하나라도 있으면 유사도와 무관하게 통과시키고, 아니면 유사도가 `similarityThreshold` 미만인 것만 뺀다. + - 문자열 단독 항목(`similarity == 0.0`)은 그 자체로 S2 신호가 있는 Record이므로 이 규칙에서 별도 분기 없이 항상 살아남는다. +- **테스트**. 플래그를 켠 컨텍스트의 `ConfidenceGateApiTests` 6건과, 기본값 컨텍스트의 `RecordSearchApiTests`에 추가한 꺼짐 계약 1건이다. 6건이 고정하는 계약은 넷이다. 유사도가 낮고 다른 신호도 없는 결과가 빠지는 것, 유사도가 임계값과 같으면(`>=`) 남는 것, 유사도가 낮아도 키워드 매치가 있으면 남는 것, 문자열 단독 항목은 게이트 판정 대상이 아니라는 것이다. + +## 설계 판단 + +### 게이트 위치 — 응답 조립 이전 + +인계 문서는 "응답을 최종 조립하는 단계"에 게이트를 두라고 적었다. 실제로는 문자열 병합 직후·Core 재검증 이전에 걸었다 — 세 신호가 전부 갖춰지는 가장 이른 지점이고, 사용자가 보는 최종 응답은 어느 지점에서 걸러도 같다. 게이트가 뺄 Record까지 Core에 소유권·삭제 여부를 묻는 조회를 만들지 않는 이점도 있다. 자세한 선택지 비교는 BD-52에 있다. + +### 임계값 0.35의 출처 — 재사용이 아니라 재측정 + +인계 문서는 threshold 후보로 0.35를 들면서 그 값이 `SEARCH_KEYWORD_RERANK_FLOOR`(질의-Preset 코사인이 같은 의미인지 판단하는, 전혀 다른 용도의 값)에서 가져온 것이라 "이 재사용이 실제로 타당한지는 아직 검증되지 않았다"고 스스로 명시했다. ai 파트가 이 프로젝트의 기존 관행(같은 숫자라도 구조가 바뀌면 재측정한다, ai 레포 `app/core/config.py`의 `SEARCH_KEYWORD_RERANK_FLOOR` 주석 참고)을 따라 이 용도로 별도 오프라인 재측정을 했고(S15P11A705-401), 그 결과 0.35가 정답 손실 없이 무관 노출을 크게 줄이는 값임을 확인했다. back은 그 채택값을 설정 기본값으로만 가져왔다 — 값을 back이 직접 정하지 않았다. + +### `keywordMatched`의 null 방어 + +ai 스키마는 이 필드를 항상 보내지만(필수, 기본값 없음), `AiSearchResponse.Match`는 다른 필드(`recordId`·`contextId`·`similarity`)와 마찬가지로 상대 응답을 신뢰하지 않는다는 이 클래스의 기존 원칙을 따른다. 다만 급이 다르다 — `recordId`가 없으면 그 Match 전체를 버리지만(`distinctByRecord`), `keywordMatched`가 없거나 `null`이면 그 Match를 버리지 않고 신호 없음(false)으로만 본다. S3는 판정을 보강하는 신호이지 Record를 식별하는 값이 아니기 때문이다. + +## 남은 것 + +- 이 구현은 오프라인 재측정(S15P11A705-401)이 재구성한 S2·S3 신호를 실서버에서 그대로 재현한다는 전제 위에 있다. `lexical_sweep.py`류의 실서버 on/off 대조가 아직 이 게이트를 대상으로 돈 적은 없다 — 검증 게이트 통과 전에 필요하다. +- `.claude/handoff/SEARCH-UPGRADE-HANDOFF.md`(인계 문서가 가리킨 위치)는 `ai`·`back`·`docs` 어디에도 없었다. 이 구현은 그 문서 대신 P49 제안서와 `search-upgrade` 브랜치의 실제 코드를 근거로 삼았다(BD-52에도 같은 내용을 남겼다). diff --git a/docs/backend/worklog/2026-08-07-S15P11A705-400-confidence-gate.md b/docs/backend/worklog/2026-08-07-S15P11A705-400-confidence-gate.md new file mode 100644 index 00000000..69d59ef2 --- /dev/null +++ b/docs/backend/worklog/2026-08-07-S15P11A705-400-confidence-gate.md @@ -0,0 +1,11 @@ +# 결합 신뢰도 게이트를 문자열 병합 직후에 걸었다 + +- **날짜**: 2026-08-07 +- **추적**: S15P11A705-400 +- **관련**: [BD-52](../decisions/BD-52-confidence-gate-filters-before-core-revalidation.md) · [BI-44](../implements/BI-44-2026-08-07-confidence-gate.md) · `OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md` §4 + +인계 문서가 "응답 조립 이후"에 게이트를 두라고 적었지만, 실제 코드를 보니 S1·S2·S3 세 신호가 전부 갖춰지는 가장 이른 지점은 `mergeLexicalMatches` 직후였다. 조립까지 신호를 끌고 가려면 `RecordSearchItemResponse`를 늘리거나 별도 Map을 나란히 들고 다녀야 했는데, 그러면 클라이언트에 노출하지 않기로 한 신호(키워드 매치 여부)가 DTO 경계를 넘나드는 임시 구조가 생긴다. 사용자에게 보이는 최종 응답은 어느 지점에서 걸러도 같으므로, 신호가 자연스럽게 모이는 지점으로 옮겼다 — 게이트가 뺄 Record를 Core 재검증 대상에서 아예 빼는 부수 이득도 있었다. + +임계값 0.35는 back이 정하지 않았다. 인계 문서 스스로 "다른 용도(키워드 재정렬 floor)에서 가져온 값이라 이 용도로는 검증된 적이 없다"고 밝혔고, ai 파트가 별도로 오프라인 재측정해(S15P11A705-401) 같은 값을 다시 채택했다. back은 그 결과를 설정 기본값으로 가져오기만 했다. + +작업 중 `.claude/handoff/SEARCH-UPGRADE-HANDOFF.md`(인계 문서가 근거로 가리킨 파일)를 찾아봤는데 `ai`·`back`·`docs` 어디에도 없었다. 대신 ai 레포 `search-upgrade` 브랜치의 P49 제안서와 실제 코드(`RecordSearchService.rrfMerge`, `keywordMatched` 필드)를 직접 확인해 근거로 삼았다 — 이 사실을 BD-52와 BI-44에도 남겼다. diff --git a/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiSearchResponse.java b/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiSearchResponse.java index 4b2995be..288753e2 100644 --- a/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiSearchResponse.java +++ b/src/main/java/com/pinlog/pinlogback/domain/ai/client/AiSearchResponse.java @@ -17,7 +17,12 @@ public record AiSearchResponse(List results) { * @param recordId 매칭된 Record. {@code ai} 스키마의 비정규화 값이므로 Spring이 재검증한다 * @param contextId 그 Record에서 가장 유사한 Context. {@code matchedContext}의 근거다 * @param similarity {@code 1 - cosine distance}. 정렬 근거이자 응답 필드다(API 명세 6.1) + * @param keywordMatched 키워드 재정렬(ai 레포 {@code SearchService._rerank_by_keyword})이 이미 + * 계산하는 Preset 매치 여부(ai 레포 {@code S15P11A705-399}). 결합 신뢰도 게이트(S15P11A705-400)의 + * S3 신호로 쓴다 — 결과를 보여줄지 정하는 데만 쓰고 응답에는 노출하지 않는다. 계약상 항상 + * 오지만, 누락·{@code null}은 신호 없음(false)으로 본다({@link RecordSearchService}의 다른 + * null 방어와 같은 원칙 — 상대 응답의 결함이 우리 500으로 나타나면 안 된다) */ - public record Match(Long recordId, Long contextId, Double similarity) { + public record Match(Long recordId, Long contextId, Double similarity, Boolean keywordMatched) { } } diff --git a/src/main/java/com/pinlog/pinlogback/domain/search/ConfidenceGateProperties.java b/src/main/java/com/pinlog/pinlogback/domain/search/ConfidenceGateProperties.java new file mode 100644 index 00000000..d6b1116c --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/search/ConfidenceGateProperties.java @@ -0,0 +1,23 @@ +package com.pinlog.pinlogback.domain.search; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 결합 신뢰도 게이트(S15P11A705-400, {@code OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md} §4) 설정. + * + *

{@code enabled}의 기본값이 {@code false}인 것이 이 트랙의 다른 신호(문자열 병합·키워드 + * 재정렬)와 같은 안전장치다 — 새 신호는 끈 상태가 현행과 동일해야 하고, 켜는 것은 검증 게이트 + * 통과 뒤의 결정이다. + * + * @param enabled 게이트를 켤지. 꺼져 있으면 어떤 결과도 신뢰도만으로 제외되지 않는다 + * @param similarityThreshold S1(벡터) 신호 하나뿐인 결과를 제외하는 유사도 하한. 오프라인 + * 재측정(ai 레포 {@code docs/implements/2026-08-07-gate-threshold-remeasure.md}, + * S15P11A705-401)이 0.35를 채택했다 — 다른 검색 신호(키워드 재정렬 floor)에서 값만 재사용한 + * 것이 아니라 이 용도로 별도로 다시 쟀다 + */ +@ConfigurationProperties("pinlog.search.gate") +public record ConfidenceGateProperties( + boolean enabled, + double similarityThreshold +) { +} 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..e7fe5ea6 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 @@ -6,6 +6,7 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.function.Function; import java.util.stream.Collectors; @@ -20,6 +21,7 @@ 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.dto.MatchedContextResponse; import com.pinlog.pinlogback.domain.search.dto.RecordSearchItemResponse; @@ -34,12 +36,15 @@ /** * 개인 자연어 검색 유스케이스(API 명세 6.1, AI 설계 9장). * - *

흐름은 다섯이다. FastAPI가 준 것을 그대로 내보내는 단계가 없다는 점이 핵심이다. + *

흐름은 여섯이다. FastAPI가 준 것을 그대로 내보내는 단계가 없다는 점이 핵심이다. * *

    - *
  1. FastAPI 호출 — Record 단위로 집계된 {@code (recordId, contextId, similarity)} 목록을 받는다
  2. + *
  3. FastAPI 호출 — Record 단위로 집계된 + * {@code (recordId, contextId, similarity, keywordMatched)} 목록을 받는다
  4. *
  5. 문자열 병합 — 단어형 질의면 본문 문자열 매치를 합쳐 RRF로 재정렬한다(P49 §4, 기본 꺼짐). * 꺼져 있거나 실패하면 이 단계는 없던 것과 같다
  6. + *
  7. 결합 신뢰도 게이트 — S1(벡터)·S2(문자열)·S3(키워드) 중 S1 하나뿐이고 유사도가 낮은 결과를 + * 뺀다(S15P11A705-400, 기본 꺼짐). 문자열·키워드 재정렬과는 독립된 마지막 판단이다
  8. *
  9. Core 재검증 — 소유권·삭제·활성 Context·Place를 Spring이 다시 본다(9.5). 문자열 후보도 * 똑같이 지난다
  10. *
  11. 조립 — 본문·Keyword·판정 상태는 Core에서 조회해 붙인다. FastAPI는 본문을 주지 않는다
  12. @@ -53,7 +58,7 @@ * 재검증하는 일이라 한 스냅샷으로 묶는다고 더 정확해지지 않는다. */ @Service -@EnableConfigurationProperties(LexicalSearchProperties.class) +@EnableConfigurationProperties({LexicalSearchProperties.class, ConfidenceGateProperties.class}) public class RecordSearchService { private static final Logger log = LoggerFactory.getLogger(RecordSearchService.class); @@ -75,16 +80,19 @@ public class RecordSearchService { private final ContextKeywordRepository contextKeywordRepository; private final LexicalContextRepository lexicalContextRepository; private final LexicalSearchProperties lexicalProperties; + private final ConfidenceGateProperties gateProperties; public RecordSearchService(AiSearchClient aiSearchClient, SearchRecordRepository searchRecordRepository, ContextRepository contextRepository, ContextKeywordRepository contextKeywordRepository, - LexicalContextRepository lexicalContextRepository, LexicalSearchProperties lexicalProperties) { + LexicalContextRepository lexicalContextRepository, LexicalSearchProperties lexicalProperties, + ConfidenceGateProperties gateProperties) { this.aiSearchClient = aiSearchClient; this.searchRecordRepository = searchRecordRepository; this.contextRepository = contextRepository; this.contextKeywordRepository = contextKeywordRepository; this.lexicalContextRepository = lexicalContextRepository; this.lexicalProperties = lexicalProperties; + this.gateProperties = gateProperties; } /** @@ -93,8 +101,11 @@ public RecordSearchService(AiSearchClient aiSearchClient, SearchRecordRepository * 빈 결과로 바꾸지 않는다 — 그러면 장애가 "일치하는 기록이 없음"으로 보인다 */ public RecordSearchResponse search(long memberId, RecordSearchRequest request) { - List matches = mergeLexicalMatches(memberId, request, - distinctByRecord(aiSearchClient.search(memberId, request.query(), request.sizeOrDefault()))); + List vector = + distinctByRecord(aiSearchClient.search(memberId, request.query(), request.sizeOrDefault())); + LexicalMergeResult lexicalResult = mergeLexicalMatches(memberId, request, vector); + List matches = + applyConfidenceGate(lexicalResult.matches(), lexicalResult.lexicalMatchedRecordIds()); if (matches.isEmpty()) { return new RecordSearchResponse(null, List.of()); } @@ -118,6 +129,18 @@ public RecordSearchResponse search(long memberId, RecordSearchRequest request) { items); } + /** + * {@link #mergeLexicalMatches}의 반환값. 병합된 목록과 함께 어느 Record가 문자열로 + * 매치됐는지(S2 신호)를 실어 보낸다 — {@link #rrfMerge}가 병합 도중에만 알고 버리던 정보를 + * 결합 신뢰도 게이트(S15P11A705-400)가 쓸 수 있게 한다. + * + * @param matches 병합된(또는 병합이 생략된) 목록 + * @param lexicalMatchedRecordIds 문자열 매치가 있었던 Record id. 병합이 생략된 모든 경로에서는 + * 빈 집합이다 — 그 경로들에서는 문자열 신호 자체가 계산되지 않았기 때문이다 + */ + private record LexicalMergeResult(List matches, Set lexicalMatchedRecordIds) { + } + /** * 문자열 검색을 벡터 결과에 병합한다(P49 §4, 규칙의 실측 근거는 ai 레포 I54). * @@ -126,26 +149,57 @@ public RecordSearchResponse search(long memberId, RecordSearchRequest request) { * 동작으로 되돌아간다(P49 §4의 세 번째 원칙). 조회 실패를 오류로 올리지 않는 이유는 벡터 * 검색이 이미 성공해 있기 때문이다 — 보조 신호의 장애가 주 결과를 지우면 안 된다. */ - private List mergeLexicalMatches(long memberId, RecordSearchRequest request, + private LexicalMergeResult mergeLexicalMatches(long memberId, RecordSearchRequest request, List vector) { if (!lexicalProperties.enabled()) { - return vector; + return new LexicalMergeResult(vector, Set.of()); } String query = stripSpaces(request.query()); if (!isWordQuery(query)) { - return vector; + return new LexicalMergeResult(vector, Set.of()); } List lexical; try { lexical = lexicalContextRepository.findMatches(memberId, query, request.sizeOrDefault()); } catch (RuntimeException e) { log.warn("lexical search failed; returning vector-only results", e); - return vector; + return new LexicalMergeResult(vector, Set.of()); } if (lexical.isEmpty()) { - return vector; + return new LexicalMergeResult(vector, Set.of()); + } + Set lexicalMatchedRecordIds = lexical.stream() + .map(LexicalContextRepository.LexicalMatch::recordId) + .collect(Collectors.toUnmodifiableSet()); + return new LexicalMergeResult( + rrfMerge(vector, lexical, request.sizeOrDefault()), lexicalMatchedRecordIds); + } + + /** + * 결합 신뢰도 게이트(S15P11A705-400, {@code OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md} §4). + * + *

    S1(벡터)·S2(문자열)·S3(키워드) 세 신호 중 S1 하나뿐이고 그 유사도가 + * {@code similarityThreshold} 미만이면 결과에서 뺀다. S2·S3 중 하나라도 있으면 유사도와 무관하게 + * 남긴다 — 문자열이든 키워드든 벡터 유사도가 못 잡는 근거가 따로 있다는 뜻이기 때문이다. + * + *

    문자열 단독 항목({@code similarity == 0.0})은 애초에 {@code lexicalMatchedRecordIds}에 + * 있으므로 이 규칙에서 자동으로 살아남는다 — 별도 분기가 필요 없다. + * + *

    기존 재정렬·병합 계약(정렬은 후보를 추가·제거하지 않는다, P49 §4)은 이 게이트의 계약이 + * 아니다 — 그 계약은 {@link #rrfMerge} 이전 단계인 키워드 재정렬(ai 레포 소관)의 것이고, 이 + * 게이트는 그 뒤에 오는 별도의 마지막 단계다. + */ + private List applyConfidenceGate( + List matches, Set lexicalMatchedRecordIds) { + if (!gateProperties.enabled()) { + return matches; } - return rrfMerge(vector, lexical, request.sizeOrDefault()); + double threshold = gateProperties.similarityThreshold(); + return matches.stream() + .filter(match -> lexicalMatchedRecordIds.contains(match.recordId()) + || Boolean.TRUE.equals(match.keywordMatched()) + || match.similarity() >= threshold) + .toList(); } /** @@ -239,7 +293,7 @@ record Ranked(long recordId, double score, int vectorRank) { for (Ranked entry : ranked.subList(0, Math.min(limit, ranked.size()))) { AiSearchResponse.Match fromVector = vectorByRecord.get(entry.recordId()); merged.add(fromVector != null ? fromVector : new AiSearchResponse.Match( - entry.recordId(), lexicalContexts.get(entry.recordId()), LEXICAL_ONLY_SIMILARITY)); + entry.recordId(), lexicalContexts.get(entry.recordId()), LEXICAL_ONLY_SIMILARITY, false)); } return List.copyOf(merged); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 479a61dc..4a56973a 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -178,6 +178,13 @@ pinlog: # 단어형 질의의 최대 글자 수. ai 레포 SEARCH_WORD_QUERY_MAX_CHARS와 같은 값·같은 의미여야 # 한다 — 어긋나면 「단어형」의 정의가 파트마다 달라져 게이트(단어형 한정)가 절반만 켜진다. word-query-max-chars: ${PINLOG_SEARCH_LEXICAL_WORD_QUERY_MAX_CHARS:5} + # 결합 신뢰도 게이트(S15P11A705-400, BD-52). 벡터 신호 하나뿐이고 유사도가 낮은 결과를 + # 응답에서 뺀다. 기본값 false가 곧 "현행과 동일한 검색"이다. + gate: + enabled: ${PINLOG_SEARCH_GATE_ENABLED:false} + # ai 레포 오프라인 재측정 채택값(S15P11A705-401, + # docs/implements/2026-08-07-gate-threshold-remeasure.md). + similarity-threshold: ${PINLOG_SEARCH_GATE_SIMILARITY_THRESHOLD:0.35} # Feed 추천 정책값. 정본은 AI 파트가 소유한 docs/ai/spec/feed-scoring.md이며 여기서 임의로 # 바꾸지 않는다. 상수로 박지 않고 설정으로 두는 이유는 튜닝 대상이기 때문이다 — 재배포 없이 # 조정할 수 있어야 하고, 가중치를 바꿔도 순위가 안 바뀌는 회귀를 테스트가 잡을 수 있어야 한다. diff --git a/src/test/java/com/pinlog/pinlogback/domain/search/ConfidenceGateApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/search/ConfidenceGateApiTests.java new file mode 100644 index 00000000..011a9889 --- /dev/null +++ b/src/test/java/com/pinlog/pinlogback/domain/search/ConfidenceGateApiTests.java @@ -0,0 +1,201 @@ +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.Record; +import com.pinlog.pinlogback.domain.record.repository.ContextRepository; +import com.pinlog.pinlogback.domain.record.repository.RecordRepository; +import com.pinlog.pinlogback.integration.IntegrationContainerSupport; + +/** + * 결합 신뢰도 게이트(S15P11A705-400, BD-52, + * {@code OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md} §4)의 계약을 고정한다. + * + *

    이 클래스는 게이트와 문자열 병합을 둘 다 켠 컨텍스트에서 돈다 — S2(문자열) 신호가 + * 게이트를 통과시키는지 보려면 문자열 병합도 켜져 있어야 한다. 기본값(둘 다 꺼짐)에서 게이트가 + * 아무것도 지우지 않는다는 계약은 {@link RecordSearchApiTests}가 기본값 컨텍스트에서 고정한다. + * + *

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

      + *
    1. S1 단독·약함 — 유사도가 임계값 미만이고 문자열·키워드 신호가 없으면 뺀다
    2. + *
    3. 경계 — 유사도가 임계값과 같으면 남긴다({@code >=})
    4. + *
    5. S3(키워드)가 있으면 살아남는다 — 유사도가 낮아도 키워드 매치가 있으면 뺴지 않는다
    6. + *
    7. S2(문자열)가 있으면 살아남는다 — 문자열 단독 항목({@code similarity == 0.0})은 + * 그 자체로 S2가 있으므로 항상 살아남는다
    8. + *
    + */ +@SpringBootTest +@AutoConfigureMockMvc +@TestPropertySource(properties = { + "pinlog.search.gate.enabled=true", + "pinlog.search.gate.similarity-threshold=0.35", + "pinlog.search.lexical.enabled=true", +}) +class ConfidenceGateApiTests 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(); + } + + @Test + void weakVectorOnlyResultWithNoOtherSignalIsDropped() throws Exception { + long me = newMemberId(); + long weak = newRecord(me, "gate-weak", "37.5000000", "127.0000000"); + long weakContext = newContext(weak, me, "약한 유사도로만 걸린 기록"); + STUB.willReturn(new FastApiSearchStub.Match(weak, weakContext, 0.20)); + + search(me, "질의") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items").isEmpty()); + } + + @Test + void resultAtExactlyTheThresholdIsKept() throws Exception { + long me = newMemberId(); + long atThreshold = newRecord(me, "gate-exact", "37.5000000", "127.0000000"); + long context = newContext(atThreshold, me, "정확히 임계값인 기록"); + STUB.willReturn(new FastApiSearchStub.Match(atThreshold, context, 0.35)); + + search(me, "질의") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].recordId").value(atThreshold)); + } + + @Test + void strongResultAboveTheThresholdIsKept() throws Exception { + long me = newMemberId(); + long strong = newRecord(me, "gate-strong", "37.5000000", "127.0000000"); + long context = newContext(strong, me, "충분히 유사한 기록"); + STUB.willReturn(new FastApiSearchStub.Match(strong, context, 0.80)); + + search(me, "질의") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].recordId").value(strong)); + } + + /** S3 — 유사도가 임계값 미만이어도 키워드 매치가 있으면 뺴지 않는다. */ + @Test + void weakResultWithKeywordMatchIsKept() throws Exception { + long me = newMemberId(); + long weakButMatched = newRecord(me, "gate-kw", "37.5000000", "127.0000000"); + long context = newContext(weakButMatched, me, "키워드로 살아남는 기록"); + STUB.willReturn(new FastApiSearchStub.Match(weakButMatched, context, 0.20, true)); + + search(me, "질의") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].recordId").value(weakButMatched)); + } + + /** + * S2 — 문자열 단독 항목({@code similarity == 0.0})은 벡터 유사도 판정 대상이 아니다. 문자열 + * 병합이 이미 자격을 정했으므로(P49 §5) 게이트가 그 판단을 다시 덮으면 안 된다. + */ + @Test + void lexicalOnlyResultIsNeverDroppedByTheGate() throws Exception { + long me = newMemberId(); + long lexicalOnly = newRecord(me, "gate-lex", "37.6000000", "127.1000000"); + long context = newContext(lexicalOnly, me, "신한은행 앞 골목의 가게"); + STUB.willReturn(); + + search(me, "신한") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].recordId").value(lexicalOnly)) + .andExpect(jsonPath("$.data.items[0].similarity").value(0.0)) + .andExpect(jsonPath("$.data.items[0].matchedContext.contextId").value(context)); + } + + /** 섞인 응답 — 약하고 무근거인 것만 빠지고, 나머지는 근거별로 각각 남는다. bounds도 남은 것만 반영한다. */ + @Test + void onlyTheWeakAndUnsupportedResultIsDroppedFromAMixedResponse() throws Exception { + long me = newMemberId(); + long strong = newRecord(me, "gate-mix-strong", "37.5000000", "127.0000000"); + long strongContext = newContext(strong, me, "충분히 유사한 기록"); + long weakUnsupported = newRecord(me, "gate-mix-weak", "37.9000000", "127.9000000"); + long weakContext = newContext(weakUnsupported, me, "약하고 근거 없는 기록"); + STUB.willReturn( + new FastApiSearchStub.Match(strong, strongContext, 0.80), + new FastApiSearchStub.Match(weakUnsupported, weakContext, 0.10)); + + search(me, "질의") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].recordId").value(strong)) + .andExpect(jsonPath("$.data.bounds.swLat").value(37.5)) + .andExpect(jsonPath("$.data.bounds.neLat").value(37.5)); + } + + 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(com.pinlog.pinlogback.domain.record.entity.Context.create( + recordId, memberId, body)).getId(); + } +} 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..3c41df9e 100644 --- a/src/test/java/com/pinlog/pinlogback/domain/search/FastApiSearchStub.java +++ b/src/test/java/com/pinlog/pinlogback/domain/search/FastApiSearchStub.java @@ -32,8 +32,17 @@ final class FastApiSearchStub { private static final JsonMapper JSON = JsonMapper.builder().build(); - /** FastAPI가 Record 단위로 집계해 돌려주는 한 건(AI 설계 9.4). 본문은 돌려주지 않는다. */ - record Match(long recordId, long contextId, double similarity) { + /** + * FastAPI가 Record 단위로 집계해 돌려주는 한 건(AI 설계 9.4). 본문은 돌려주지 않는다. + * + *

    {@code keywordMatched}는 ai 레포 키워드 재정렬의 매치 여부(S15P11A705-399)다. 3-인자 + * 생성자는 그 신호가 없는(false) 기존 호출부 전부를 그대로 둔다 — 결합 신뢰도 게이트 + * (S15P11A705-400)를 재는 테스트만 4-인자로 명시한다. + */ + record Match(long recordId, long contextId, double similarity, boolean keywordMatched) { + Match(long recordId, long contextId, double similarity) { + this(recordId, contextId, similarity, false); + } } /** @@ -157,8 +166,8 @@ private void handle(HttpExchange exchange) throws IOException { private String resultsJson() { String items = results.get().stream() - .map(match -> "{\"recordId\":%d,\"contextId\":%d,\"similarity\":%s}" - .formatted(match.recordId(), match.contextId(), match.similarity())) + .map(match -> "{\"recordId\":%d,\"contextId\":%d,\"similarity\":%s,\"keywordMatched\":%s}" + .formatted(match.recordId(), match.contextId(), match.similarity(), match.keywordMatched())) .collect(Collectors.joining(",")); return "{\"results\":[" + items + "]}"; } 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..bb69dde5 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,24 @@ void lexicalMergeIsOffByDefaultSoABodyMatchAddsNothing() throws Exception { .andExpect(jsonPath("$.data.items[0].recordId").value(vectorOnly)); } + /** + * 결합 신뢰도 게이트(S15P11A705-400, BD-52)는 기본값이 꺼짐이고, 꺼진 상태의 응답은 + * 현행과 완전히 같아야 한다. 유사도가 매우 낮아도 게이트가 꺼져 있으면 지워지지 않는다 — + * 켠 상태의 게이트 계약은 {@link ConfidenceGateApiTests}가 맡는다. + */ + @Test + void confidenceGateIsOffByDefaultSoAWeakResultIsStillReturned() throws Exception { + long me = newMemberId(); + long weak = newRecord(me, "search-gateoff", "37.5000000", "127.0000000"); + long weakContext = newContext(weak, me, "유사도가 매우 낮은 기록"); + STUB.willReturn(new FastApiSearchStub.Match(weak, weakContext, 0.05)); + + search(me, "질의") + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items.length()").value(1)) + .andExpect(jsonPath("$.data.items[0].recordId").value(weak)); + } + /** * {@code keywords}는 매칭 Context의 것이 아니라 Record의 활성 Context 전체 집계다 * (API 명세 6.1). 매칭 Context만 보면 같은 Record의 다른 Context가 가진 Keyword가 사라진다.