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
Original file line number Diff line number Diff line change
@@ -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<AiSearchResponse.Match>` 단위로 판단한다 — `RecordSearchItemResponse`가
만들어지기 전이다. 나중에 게이트 판단이 Core 데이터(예: Place 카테고리)를 필요로 하게
되면 이 위치를 다시 바꿔야 한다.
- `mergeLexicalMatches`의 반환 타입이 `List<Match>`에서 `LexicalMergeResult`(병합 목록 +
문자열 매치 Record id 집합)로 바뀌었다. 이 메서드를 호출하는 곳이 늘면 그 신호도 함께
옮겨야 한다는 것을 기억해야 한다.
- **재검토 트리거**
- 게이트 판단이 벡터·문자열·키워드 외의 신호(예: Place 메타데이터)를 쓰게 되면, 그 신호가
준비되는 시점에 맞춰 게이트 위치를 다시 정한다.
- 문서(`OFFTOPIC-CONFIDENCE-GATE-HANDOFF-DRAFT.md`)가 가리켰던
`.claude/handoff/SEARCH-UPGRADE-HANDOFF.md`는 이 레포·`ai`·`docs` 어디에도 없었다 — 이
결정과 구현은 그 문서 대신 P49 제안서와 `search-upgrade` 브랜치의 실제 코드를 근거로
삼았다는 것을 남겨 둔다.
41 changes: 41 additions & 0 deletions docs/backend/implements/BI-44-2026-08-07-confidence-gate.md
Original file line number Diff line number Diff line change
@@ -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에도 같은 내용을 남겼다).
11 changes: 11 additions & 0 deletions docs/backend/worklog/2026-08-07-S15P11A705-400-confidence-gate.md
Original file line number Diff line number Diff line change
@@ -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에도 남겼다.
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,12 @@ public record AiSearchResponse(List<Match> 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) {
}
}
Original file line number Diff line number Diff line change
@@ -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) 설정.
*
* <p>{@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
) {
}
Loading
Loading