diff --git a/.github/workflows/backend-ci.yml b/.github/workflows/backend-ci.yml index 23555cf8..9894c276 100644 --- a/.github/workflows/backend-ci.yml +++ b/.github/workflows/backend-ci.yml @@ -1,10 +1,13 @@ name: backend-ci on: + # search-upgrade 는 검색 고도화 통합 브랜치다(ai 레포 P49 §6) — 그 트랙의 작업 PR은 base가 + # dev가 아니라 이 브랜치라서, 여기 없으면 그 PR들이 CI 없이 병합된다. ai-ci가 같은 이유로 + # 같은 트리거를 추가했다. pull_request: - branches: [dev] + branches: [dev, search-upgrade] push: - branches: [dev] + branches: [dev, search-upgrade] permissions: contents: read diff --git a/docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md b/docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md new file mode 100644 index 00000000..48aba8e2 --- /dev/null +++ b/docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md @@ -0,0 +1,64 @@ +# BI-43. 단어형 질의의 본문 문자열 검색과 RRF 병합 구현 + +- **상태**: ✅ 구현 완료. 기능 플래그는 꺼진 상태로 두었다. 켜는 결정은 검색 고도화 검증 게이트 통과 뒤의 일이다. +- **날짜**: 2026-08-06 +- **추적**: 티켓 미발급. 검색 고도화 트랙의 back 몫 작업이다(ai 레포 P49의 작업 5). 티켓이 발급되면 이 줄을 갱신한다. +- **관련**: ai 레포 `docs/proposals/P49-multi-signal-search.md`(검색 구조와 병합 방식의 설계 제안) · ai 레포 `docs/implements/2026-08-06-lexical-merge-rule.md`(병합 규칙을 확정한 오프라인 실측 리포트, 이하 I54) + +## 배경 + +현행 검색은 질의를 임베딩해 기록 본문과의 코사인 유사도로 후보를 찾고, 유사도가 기준에 못 미치는 후보를 제외한다. 이 방식은 본문에 질의 문자열이 글자 그대로 있는 기록을 놓칠 수 있다. 실제 실패 사례가 있다. 질의 `신한`은 정답 기록의 본문에 「신한」이 그대로 있는데도 임베딩 유사도가 낮아 검색 결과에 나오지 않았다. + +ai 파트가 이 사례를 유사도 기준 조정과 키워드 점수로 회복할 수 없음을 실측으로 확인했고, 본문 문자열 검색을 별도 경로로 추가하는 구조를 제안했다(P49). 본문 컬럼 `core.context.body`는 Spring 소유 스키마에 있고 FastAPI의 접근은 공용 계약이 금지한다. 그래서 문자열 검색과 결과 병합은 Spring이 수행한다. + +병합 규칙은 ai 파트가 오프라인 실측(I54)으로 확정했다. 규칙은 넷이다. + +1. 문자열 검색은 단어형 질의에서만 켠다. 단어형은 앞뒤 공백을 정리한 뒤 내부에 공백이 없고 5자 이하인 질의다. 매치는 부분일치로 판정한다. +2. 벡터 검색 통과 후보와 문자열 매치 후보의 합집합을 RRF로 재정렬한다. RRF는 순위 결합 방식이다. 후보가 각 목록에서 차지한 순위 r마다 1/(60+r)을 계산해 합산하고, 합산 점수가 큰 순서로 정렬한다. +3. 응답의 `similarity` 값은 원래 코사인 값을 유지한다. 병합 점수는 노출하지 않는다. +4. 문자열 검색이 실패하면 벡터 결과만 반환한다. 이 경우의 동작은 현행 검색과 같다. + +## 산출 + +- **`LexicalContextRepository`** 신설. `core.context`에서 요청자 소유이고 삭제되지 않은 본문에 질의 문자열이 부분일치하는 기록을 찾는다. 한 기록에서 여러 본문이 매치되면 최신 교체본 하나를 대표로 고른다. 질의에 섞인 LIKE 문법 글자 `%`·`_`·`\`는 이스케이프해 글자로 취급한다. +- **`LexicalSearchProperties`** 신설. `pinlog.search.lexical.enabled`는 문자열 검색 병합을 켜고 끄는 설정이고 기본값은 꺼짐이다. 꺼진 상태의 응답이 현행과 동일하다는 것이 검색 고도화 트랙의 안전 전제라서, 그 계약을 기본값 컨텍스트의 테스트로 고정했다. `word-query-max-chars`는 단어형 판정의 글자 수 상한이고 기본값은 5다. ai 서버의 같은 뜻의 설정 `SEARCH_WORD_QUERY_MAX_CHARS`와 값이 같아야 한다. 두 값이 어긋나면 단어형의 정의가 파트마다 달라진다. +- **`RecordSearchService`** 수정. FastAPI 응답을 기록 단위로 정리한 직후에 병합 단계를 넣었다. 단어형 질의면 문자열 매치를 조회해 RRF로 재정렬하고, 요청한 결과 개수만큼의 절단을 재정렬 뒤에 한 번만 적용한다. 문자열 후보의 기록 id와 본문 id는 기존 흐름에 그대로 합류한다. 그래서 소유권·삭제 여부·본문 존재를 다시 확인하는 Core 재검증이 벡터 후보와 문자열 후보에 동일하게 적용된다. +- **테스트**. 플래그를 켠 컨텍스트의 `LexicalSearchApiTests` 12건과, 기본값 컨텍스트의 `RecordSearchApiTests`에 추가한 꺼짐 계약 1건이다. 12건이 고정하는 계약은 네 축이다. 문자열 매치가 결과에 들어오는 것, RRF가 순서를 바꾸되 `similarity`는 원값으로 남는 것, 단어형이 아닌 질의에서 문자열 경로가 켜지지 않는 것, 문자열 후보도 재검증을 지나는 것이다. + +## 설계 판단 + +실측 리포트의 계산을 런타임에 그대로 옮길 수 없는 지점이 셋 있었다. 셋 모두 원인이 같다. 실측은 모든 후보의 코사인 값을 측정용 DB에서 직접 계산해 썼지만, 런타임의 Spring은 FastAPI가 반환한 후보의 코사인만 안다. 유사도 기준에서 탈락한 후보의 코사인은 FastAPI 밖으로 나오지 않는다. 세 판단 모두 현재 측정 결과를 바탕으로 한 설계 판단이며, 실제 효과는 검증 게이트의 실서버 확인이 필요하다. + +### 문자열 매치 목록의 순위 기준 + +실측은 문자열 매치 후보를 코사인 내림차순으로 줄 세워 RRF에 넣었다. 런타임은 그 코사인을 모른다. 대신 최초 작성 시각(`origin_created_at`) 내림차순으로 줄 세우고, 같은 시각이면 기록 id 내림차순으로 가른다. 이 컬럼을 고른 이유는 둘이다. 이 도메인에서 목록 정렬과 날짜 표시의 기준 컬럼이라 새 기준을 만들지 않는다. 그리고 같은 질의가 항상 같은 순서를 돌려주는 결정적 기준이다. 시연 데이터에서 문자열 매치는 질의당 소수라서, 이 기준 차이가 최종 순서를 바꾸는 경우는 드물다고 판단했다. + +### 문자열 단독 후보의 RRF 점수 항 + +실측의 RRF는 유사도 기준에서 탈락한 후보에도 탈락 전 벡터 순위 항을 더했다. 런타임은 그 순위를 모르므로, 문자열 매치로만 들어온 후보의 점수는 문자열 목록 순위 항 하나로 계산한다. 항이 하나 빠지면 그 후보의 순위는 실측보다 낮아지는 쪽으로만 움직인다. 탈락 후보의 벡터 순위는 항상 통과 후보보다 뒤라서 빠진 항의 값 자체도 작다. + +### 문자열 단독 항목의 `similarity` 값 + +응답의 `similarity`는 필수 숫자다. front의 응답 스키마가 숫자를 요구하고, null인 항목은 back의 방어 코드가 버린다. 문자열 매치로만 들어온 항목은 실을 코사인이 없으므로 0.0을 싣는다. front는 이 값을 화면에 노출하지 않는다(API 명세 6.1). 실제 코사인은 0.0이 나오지 않으므로, 이 값은 문자열 매치로만 들어온 항목을 로그에서 구분하는 표지도 된다. + +### 그 외 판단 + +- 문자열 매치 조회에 요청 결과 개수만큼의 LIMIT을 걸었다. 응답 상한이 그 개수라서 그보다 뒤의 문자열 후보는 최종 결과에 전부 들어갈 수 없다. 부분일치 매치라 이론상 후보가 넓어질 수 있는 것에 대한 방어이기도 하다. +- 단어형 판정을 ai 서버와 같은 결과가 나오게 구현했다. 공백 판정은 유니코드 공백 전체를 본다. `String.strip()`을 쓰지 않은 이유는 그 판정이 줄바꿈·탭은 공백으로 보지만 NBSP(U+00A0)는 보지 않아서다. ai의 판정(Python `str.isspace()`)은 NBSP를 공백으로 본다. 글자 수는 코드 포인트로 센다. +- 문자열 조회에서 예외가 나면 벡터 결과만 반환한다. 벡터 검색은 이미 성공해 있으므로, 보조 신호의 장애가 주 결과를 지우면 안 된다. 이 경로는 통합 테스트로 재현하지 못해 테스트가 없다. 코드 리뷰 대상으로 남긴다. + +## 검증 + +수행한 검증. + +- [x] 테스트 우선으로 진행했다. 신규 12건을 구현 전에 실행해, 병합 기능을 요구하는 6건이 실패하고 현행 동작으로도 성립하는 6건이 통과하는 것을 확인했다. 구현 후 12건 전부 통과했다. +- [x] `./gradlew clean check --no-daemon` 전체 통과. 기존 테스트의 회귀는 없다. + +수행하지 못한 검증. + +- [ ] 실서버 E2E. 실패 사례 `신한`의 회복과 관련 없는 질의의 무노출 유지는 이 리포트의 범위 밖이다. 검색 고도화 트랙의 검증 게이트(P49 §7)에서 통합 브랜치 빌드로 수행한다. + +## 미결 + +- back 티켓이 발급되면 이 문서의 추적 줄과 worklog 파일명을 갱신한다. +- 본문에 문자열은 있으나 기록의 주제가 아닌 경우와 동형어의 오탐 검증은 구현하지 않았다. 실측에서 그 유형의 사례를 잴 수 없었고, 운영 데이터에서 재평가하는 조건으로 남아 있다(P49 §9). diff --git a/docs/backend/worklog/2026-08-06-search-lexical-merge.md b/docs/backend/worklog/2026-08-06-search-lexical-merge.md new file mode 100644 index 00000000..df34ff54 --- /dev/null +++ b/docs/backend/worklog/2026-08-06-search-lexical-merge.md @@ -0,0 +1,10 @@ +# 본문에 질의 문자열이 있는 기록을 검색 결과에 병합하는 경로를 기본 꺼짐으로 구현했다 + +- **날짜**: 2026-08-06 +- **관련**: [BI-43](../implements/BI-43-2026-08-06-search-lexical-merge.md) · ai 레포 `docs/proposals/P49-multi-signal-search.md` · ai 레포 `docs/implements/2026-08-06-lexical-merge-rule.md`(I54) + +현행 검색은 임베딩 유사도만 쓰기 때문에, 본문에 질의 문자열이 글자 그대로 있는 기록을 놓칠 수 있다. 실패 사례 `신한`이 그 유형이다. 검색 고도화 트랙에서 이 유형의 회복은 back 몫으로 확정됐다. 본문 컬럼이 Spring 소유 스키마에 있고 FastAPI의 접근을 공용 계약이 금지하기 때문이다. + +규칙은 ai 파트의 오프라인 실측 리포트(I54)가 확정한 것을 그대로 구현했다. 단어형 질의에서만 본문 부분일치를 찾고, 벡터 검색 통과 후보와의 합집합을 순위 결합(RRF)으로 재정렬한다. 응답의 `similarity`는 원래 코사인 값을 유지한다. 문자열 조회가 실패하면 벡터 결과만 반환한다. 실측의 계산을 런타임에 그대로 옮길 수 없던 세 지점이 있었고, 각각의 판단과 근거는 BI-43에 적었다. + +기능 플래그 `pinlog.search.lexical.enabled`의 기본값은 꺼짐이다. 꺼진 상태의 응답이 현행과 동일하다는 것을 기본값 컨텍스트의 계약 테스트로 고정했다. 켜는 결정은 검색 고도화 트랙의 검증 게이트를 통과한 뒤에 한다. 작업은 dev가 아니라 통합 브랜치 `search-upgrade`를 기준으로 한 작업 브랜치에서 했다. 검증 전에는 dev에 병합하지 않는 것이 트랙 전체의 제약이다. diff --git a/src/main/java/com/pinlog/pinlogback/domain/search/LexicalSearchProperties.java b/src/main/java/com/pinlog/pinlogback/domain/search/LexicalSearchProperties.java new file mode 100644 index 00000000..9229c156 --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/search/LexicalSearchProperties.java @@ -0,0 +1,22 @@ +package com.pinlog.pinlogback.domain.search; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * 문자열 검색 병합(P49 §4·§5) 설정. + * + *
{@code enabled}의 기본값이 {@code false}인 것이 검색 고도화 트랙의 안전장치다 — 모든 신규 + * 신호는 끈 상태가 현행과 동일해야 하고(P49 §7 기준 4), 켜는 것은 검증 게이트 통과 뒤의 결정이다. + * 운영 중 문제가 나면 이 플래그로 즉시 현행 검색으로 되돌린다. + * + * @param enabled 문자열 검색 병합을 켤지. 꺼져 있으면 문자열 조회 자체가 없다 + * @param wordQueryMaxChars 단어형 질의의 최대 글자 수. ai 레포의 + * {@code SEARCH_WORD_QUERY_MAX_CHARS}와 같은 값·같은 의미여야 한다 — 두 값이 어긋나면 + * 「단어형」의 정의가 파트마다 달라져 게이트(단어형 한정, I54)가 절반만 켜진다 + */ +@ConfigurationProperties("pinlog.search.lexical") +public record LexicalSearchProperties( + boolean enabled, + int wordQueryMaxChars +) { +} diff --git a/src/main/java/com/pinlog/pinlogback/domain/search/repository/LexicalContextRepository.java b/src/main/java/com/pinlog/pinlogback/domain/search/repository/LexicalContextRepository.java new file mode 100644 index 00000000..cf349e1d --- /dev/null +++ b/src/main/java/com/pinlog/pinlogback/domain/search/repository/LexicalContextRepository.java @@ -0,0 +1,82 @@ +package com.pinlog.pinlogback.domain.search.repository; + +import java.util.List; +import java.util.Map; + +import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; +import org.springframework.stereotype.Repository; + +/** + * 본문에 질의 문자열이 그대로 있는 Record를 찾는다(P49 §3의 문자열 검색, 규칙의 실측 근거는 + * ai 레포 I54). + * + *
범위를 {@code core.context.member_id}로 좁힌다 — 이 비정규화 컬럼의 존재 이유가 바로 + * 「자연어 검색은 항상 본인 맥락 한정」이다({@code Context} 엔티티 주석). 이 필터는 검색 범위이지 + * 인가가 아니다. 인가는 벡터 후보와 똑같이 {@code SearchRecordRepository.findVerified}의 Core + * 재검증이 맡고, 문자열 후보는 그 재검증을 우회하지 않는다. + * + *
매치는 부분일치다. 어절 시작 경계 요구는 실측에서 기대 정답 6건을 잃는 손해만 관측되어 + * 채택되지 않았다(I54). 남는 오탐 유형(어절 중간 시작 일치·동형어)의 재평가는 운영 코퍼스 + * 조건으로 미뤄져 있다(P49 §9). + */ +@Repository +public class LexicalContextRepository { + + /** + * @param recordId 매치된 Record + * @param contextId 그 Record의 대표 매치 Context — {@code matchedContext}의 근거다 + */ + public record LexicalMatch(long recordId, long contextId) { + } + + /** + * 안쪽 {@code DISTINCT ON}이 Record당 대표 Context 하나를 고른다 — 교체 생성(BD-07)으로 구본과 + * 신본이 함께 매치되면 최신 것({@code created_at} 내림차순, 동시각이면 {@code id} 내림차순)이다. + * FastAPI가 Record별 최고 유사도 Context를 대표로 고르는 것(AI 설계 9.4)의 문자열 쪽 등가물이다. + * + *
바깥 정렬이 곧 문자열 목록의 순위다 — 최초 작성 시각({@code origin_created_at}, 목록
+ * 정렬·날짜 표시의 기준 컬럼) 내림차순. 실측(I54)은 코사인 내림차순을 썼지만 그 값은 FastAPI
+ * 밖으로 나오지 않아 여기서는 쓸 수 없고, 대신 이 도메인의 기존 정렬 기준을 따른다. 어떤
+ * 기준이든 결정적이어야 같은 질의가 같은 순서를 돌려준다.
+ */
+ private static final String MATCH_SQL = """
+ SELECT record_id, context_id FROM (
+ SELECT DISTINCT ON (ct.record_id)
+ ct.record_id AS record_id, ct.id AS context_id, ct.origin_created_at AS matched_at
+ FROM core.context ct
+ WHERE ct.member_id = :memberId
+ AND ct.deleted_at IS NULL
+ AND ct.body LIKE :pattern ESCAPE '\\'
+ ORDER BY ct.record_id, ct.created_at DESC, ct.id DESC
+ ) matched
+ ORDER BY matched_at DESC, record_id DESC
+ LIMIT :limit
+ """;
+
+ private final NamedParameterJdbcTemplate jdbc;
+
+ public LexicalContextRepository(NamedParameterJdbcTemplate jdbc) {
+ this.jdbc = jdbc;
+ }
+
+ /**
+ * @param query 앞뒤 공백을 정리한 단어형 질의. 단어형 판정은 호출부의 게이트가 이미 마쳤다
+ * @param limit 문자열 목록 순위 상위 몇 건까지 후보로 삼을지. 응답 상한이 {@code size}이므로
+ * 그보다 많은 후보는 애초에 최종 결과에 전부 들어갈 수 없다
+ * @return 문자열 목록 순위 순서의 매치 목록. 매치가 없으면 빈 목록
+ */
+ public List 흐름은 넷이다. FastAPI가 준 것을 그대로 내보내는 단계가 없다는 점이 핵심이다.
+ * 흐름은 다섯이다. FastAPI가 준 것을 그대로 내보내는 단계가 없다는 점이 핵심이다.
*
* {@code @Transactional}을 붙이지 않았다. 붙이면 FastAPI 호출(읽기 타임아웃 5s) 내내 DB
* 커넥션이 잡혀 있는다 — AI 파트 소유 명세 {@code docs/ai/spec/ai-integration.md} 4.1이 금지하는
- * 바로 그 형태다. 이 메서드의 DB 조회 넷은 서로 다른 스냅샷을 봐도 무방하다: 그 사이에 무엇이
+ * 바로 그 형태다. 이 메서드의 DB 조회들은 서로 다른 스냅샷을 봐도 무방하다: 그 사이에 무엇이
* 지워지든 결과는 "그 항목이 빠진다" 쪽으로만 움직이고, 애초에 검색은 움직이는 대상을 최선으로
* 재검증하는 일이라 한 스냅샷으로 묶는다고 더 정확해지지 않는다.
*/
@Service
+@EnableConfigurationProperties(LexicalSearchProperties.class)
public class RecordSearchService {
+ private static final Logger log = LoggerFactory.getLogger(RecordSearchService.class);
+
+ /** RRF 상수. 실측(ai 레포 I54)이 이 값으로 확정했고, 측정 도구와 같은 값이어야 비교가 성립한다. */
+ private static final double RRF_K = 60.0;
+
+ /**
+ * 문자열 단독 항목의 {@code similarity}. 이 항목은 벡터 컷을 통과하지 못해 코사인 값이 FastAPI
+ * 밖으로 나오지 않으므로 실을 원값이 없다. {@code null}은 응답 계약 위반이라(front 스키마가
+ * {@code number} 필수) 0.0을 싣는다 — front는 이 값을 UI에 노출하지 않고(API 명세 6.1), 0.0은
+ * 실제 코사인이 만들 수 없는 값이라 「문자열 매치로만 들어온 항목」의 표지 역할도 한다.
+ */
+ private static final double LEXICAL_ONLY_SIMILARITY = 0.0;
+
private final AiSearchClient aiSearchClient;
private final SearchRecordRepository searchRecordRepository;
private final ContextRepository contextRepository;
private final ContextKeywordRepository contextKeywordRepository;
+ private final LexicalContextRepository lexicalContextRepository;
+ private final LexicalSearchProperties lexicalProperties;
public RecordSearchService(AiSearchClient aiSearchClient, SearchRecordRepository searchRecordRepository,
- ContextRepository contextRepository, ContextKeywordRepository contextKeywordRepository) {
+ ContextRepository contextRepository, ContextKeywordRepository contextKeywordRepository,
+ LexicalContextRepository lexicalContextRepository, LexicalSearchProperties lexicalProperties) {
this.aiSearchClient = aiSearchClient;
this.searchRecordRepository = searchRecordRepository;
this.contextRepository = contextRepository;
this.contextKeywordRepository = contextKeywordRepository;
+ this.lexicalContextRepository = lexicalContextRepository;
+ this.lexicalProperties = lexicalProperties;
}
/**
@@ -64,8 +93,8 @@ public RecordSearchService(AiSearchClient aiSearchClient, SearchRecordRepository
* 빈 결과로 바꾸지 않는다 — 그러면 장애가 "일치하는 기록이 없음"으로 보인다
*/
public RecordSearchResponse search(long memberId, RecordSearchRequest request) {
- List 이 메서드가 벡터 결과를 그대로 돌려주는 경로가 셋이다 — 플래그 꺼짐, 단어형이 아닌 질의
+ * (게이트, P49 §5), 문자열 조회 실패. 어느 경로든 응답은 실패하지 않고 현행 검색과 같은
+ * 동작으로 되돌아간다(P49 §4의 세 번째 원칙). 조회 실패를 오류로 올리지 않는 이유는 벡터
+ * 검색이 이미 성공해 있기 때문이다 — 보조 신호의 장애가 주 결과를 지우면 안 된다.
+ */
+ private List 글자 수는 코드 포인트로 센다({@code String.length()}는 UTF-16 단위라 보충 평면 문자를 2로
+ * 센다). 공백 판정이 유니코드 공백 전체인 이유는 {@link #isQuerySpace(int)}에 있다.
+ */
+ private boolean isWordQuery(String query) {
+ return !query.isEmpty()
+ && query.codePointCount(0, query.length()) <= lexicalProperties.wordQueryMaxChars()
+ && query.codePoints().noneMatch(RecordSearchService::isQuerySpace);
+ }
+
+ /**
+ * 앞뒤 공백 정리. {@link String#strip()}을 쓰지 않는 이유는 그 판정({@code Character.isWhitespace})이
+ * NBSP(U+00A0)를 공백으로 보지 않아서다 — ai의 판정(Python {@code str.strip()}·{@code str.isspace()})은
+ * NBSP를 공백으로 보므로, 같은 질의가 두 파트에서 다르게 정리되면 안 된다.
+ */
+ private static String stripSpaces(String raw) {
+ int from = 0;
+ int to = raw.length();
+ while (from < to && isQuerySpace(raw.codePointAt(from))) {
+ from += Character.charCount(raw.codePointAt(from));
+ }
+ while (to > from && isQuerySpace(raw.codePointBefore(to))) {
+ to -= Character.charCount(raw.codePointBefore(to));
+ }
+ return raw.substring(from, to);
+ }
+
+ /**
+ * {@code isWhitespace}(탭·개행·전각 공백 등)와 {@code isSpaceChar}(NBSP 등 유니코드 공백 분류)의
+ * 합집합 — Python {@code str.isspace()}와 같은 범위를 덮는다. 좁게 잡으면 NBSP로 띄운 2어절
+ * 질의가 「공백 없음」으로 통과해 문장형 질의가 문자열 경로를 탄다(ai 쪽 같은 판정의 주석과
+ * 같은 근거).
+ */
+ private static boolean isQuerySpace(int codePoint) {
+ return Character.isWhitespace(codePoint) || Character.isSpaceChar(codePoint);
+ }
+
+ /**
+ * 벡터 컷 통과자와 문자열 매치의 합집합을 RRF(k=60) 순위로 재정렬한다 — 실측으로 확정된
+ * 규칙이다(I54: 뒤에 추가하는 방식보다 정답 순위를 확실히 올렸다). 최종 절단은 RRF 순위 기준으로
+ * {@code limit}을 한 번만 적용한다(P49 §4-4 — 합집합이 {@code limit}을 넘으면 RRF
+ * 하위부터 잘린다는 것이 계약이다).
+ *
+ * 점수는 {@code Σ 1/(k + 목록 내 순위)}다. 벡터 목록의 순위는 FastAPI가 준 순서(유사도
+ * 내림차순), 문자열 목록의 순위는 {@code LexicalContextRepository}가 정한 순서다. 동점이면
+ * 벡터 순위가 있는 쪽·그 순위가 높은 쪽이 먼저이고 그다음 {@code recordId} 오름차순이다 —
+ * 측정 도구(ai 레포 {@code lexical_sweep.py})의 동점 규칙과 같아야 실측과 구현이 같은 순서를
+ * 낸다.
+ *
+ * 벡터에도 있는 Record는 원래 {@code Match}를 그대로 쓴다 — {@code similarity}는 원래
+ * 코사인 값을 유지하고 병합 점수는 노출하지 않는다(P49 §4-5). 문자열 단독 Record는 매치된
+ * Context를 대표로 {@code Match}를 새로 만든다.
+ */
+ private List 이 클래스는 플래그를 켠 컨텍스트에서 돈다. 기본값(끔)에서 현행과 동일하다는 계약은
+ * {@link RecordSearchApiTests}가 기본값 컨텍스트에서 고정한다 — 같은 클래스에 두면 둘 중 하나의
+ * 플래그 상태가 거짓이 된다.
+ *
+ * 단언의 축은 넷이다.
+ *
+ *
*
*
*
+ *
+ */
+@SpringBootTest
+@AutoConfigureMockMvc
+@TestPropertySource(properties = "pinlog.search.lexical.enabled=true")
+class LexicalSearchApiTests 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();
+ }
+
+ /**
+ * I54의 회복 사례(`신한`) 형태다. 벡터 컷을 통과하지 못한 Record라도 본문에 질의 문자열이
+ * 그대로 있으면 결과에 들어와야 한다. 문자열 단독 항목의 {@code similarity}는 0.0이고
+ * {@code matchedContext}는 매치된 Context다.
+ */
+ @Test
+ void wordQueryAddsARecordWhoseBodyContainsTheQueryString() throws Exception {
+ long me = newMemberId();
+ long vectorOnly = newRecord(me, "lex-vec", "37.5000000", "127.0000000");
+ long vectorContext = newContext(vectorOnly, me, "벡터로만 잡히는 기록");
+ long lexicalOnly = newRecord(me, "lex-str", "37.6000000", "127.1000000");
+ long lexicalContext = newContext(lexicalOnly, me, "신한은행 앞 골목의 가게");
+ STUB.willReturn(new FastApiSearchStub.Match(vectorOnly, vectorContext, 0.82));
+
+ search(me, "신한")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(2))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(vectorOnly))
+ .andExpect(jsonPath("$.data.items[0].similarity").value(0.82))
+ .andExpect(jsonPath("$.data.items[1].recordId").value(lexicalOnly))
+ .andExpect(jsonPath("$.data.items[1].similarity").value(0.0))
+ .andExpect(jsonPath("$.data.items[1].matchedContext.contextId").value(lexicalContext));
+ }
+
+ /**
+ * 병합이 순서를 실제로 바꾸는 것과 {@code similarity}가 원값으로 남는 것을 함께
+ * 고정한다. 두 신호에 모두 잡힌 Record(RRF 점수 {@code 1/62 + 1/61})가 벡터 1위
+ * ({@code 1/61})를 앞선다. 순서가 바뀌어도 값은 병합 점수가 아니라 코사인이다(P49 §4-5).
+ */
+ @Test
+ void recordMatchedByBothSignalsRisesAboveAVectorOnlyOne() throws Exception {
+ long me = newMemberId();
+ long vectorFirst = newRecord(me, "lex-both-a", "37.5000000", "127.0000000");
+ long vectorFirstContext = newContext(vectorFirst, me, "벡터 1위 기록");
+ long both = newRecord(me, "lex-both-b", "37.6000000", "127.1000000");
+ long bothContext = newContext(both, me, "신한카드 할인 받은 가게");
+ STUB.willReturn(
+ new FastApiSearchStub.Match(vectorFirst, vectorFirstContext, 0.90),
+ new FastApiSearchStub.Match(both, bothContext, 0.80));
+
+ search(me, "신한")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(2))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(both))
+ .andExpect(jsonPath("$.data.items[0].similarity").value(0.80))
+ .andExpect(jsonPath("$.data.items[1].recordId").value(vectorFirst))
+ .andExpect(jsonPath("$.data.items[1].similarity").value(0.90));
+ }
+
+ /**
+ * 문장형 질의의 조사·부사가 만드는 우연한 일치를 막는 것이 단어형 한정의 목적이다(P49 §5).
+ * 본문에 질의가 그대로 있어도 공백이 있으면 문자열 경로 자체가 꺼져 있어야 한다.
+ */
+ @Test
+ void sentenceQueriesDoNotTriggerLexicalSearch() throws Exception {
+ long me = newMemberId();
+ long vectorOnly = newRecord(me, "lex-sent", "37.5000000", "127.0000000");
+ long vectorContext = newContext(vectorOnly, me, "벡터로만 잡히는 기록");
+ long lexicalOnly = newRecord(me, "lex-sent-b", "37.6000000", "127.1000000");
+ newContext(lexicalOnly, me, "신한 은행 바로 앞");
+ STUB.willReturn(new FastApiSearchStub.Match(vectorOnly, vectorContext, 0.82));
+
+ search(me, "신한 은행")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(1))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(vectorOnly));
+ }
+
+ /** 단어형 경계는 5자다(ai 레포 {@code SEARCH_WORD_QUERY_MAX_CHARS}와 같은 값·같은 의미). */
+ @Test
+ void queriesOverTheWordLengthLimitDoNotTriggerLexicalSearch() throws Exception {
+ long me = newMemberId();
+ long lexicalOnly = newRecord(me, "lex-long", "37.6000000", "127.1000000");
+ newContext(lexicalOnly, me, "신한은행지점 방문 기록");
+ STUB.willReturn();
+
+ search(me, "신한은행지점")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items").isEmpty());
+ }
+
+ /**
+ * 전각 공백(U+3000)·NBSP(U+00A0)로 띄운 2어절 질의가 「공백 없음」으로 통과하면 문장형
+ * 질의가 문자열 경로를 타게 된다 — ai의 단어형 판정({@code str.isspace()})과 같은 방향으로
+ * 유니코드 공백 전체를 공백으로 본다.
+ */
+ @Test
+ void exoticSpacesAlsoMakeAQuerySentenceForm() throws Exception {
+ long me = newMemberId();
+ long lexicalOnly = newRecord(me, "lex-nbsp", "37.6000000", "127.1000000");
+ newContext(lexicalOnly, me, "신 한 그리고 신 한");
+ STUB.willReturn();
+
+ search(me, "신 한")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items").isEmpty());
+ search(me, "신 한")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items").isEmpty());
+ }
+
+ /**
+ * 문자열 검색의 범위 필터는 {@code core.context.member_id}다. 남의 본문에 질의가 있어도
+ * 후보조차 되면 안 된다 — 재검증이 걸러 주기를 기대하는 것과 쿼리가 애초에 좁히는 것은
+ * 방어 층이 다르다.
+ */
+ @Test
+ void someoneElsesBodyMatchIsNeverAdded() throws Exception {
+ long me = newMemberId();
+ long stranger = newMemberId();
+ long strangerRecord = newRecord(stranger, "lex-other", "37.6000000", "127.1000000");
+ newContext(strangerRecord, stranger, "신한은행에서 환전한 기록");
+ STUB.willReturn();
+
+ search(me, "신한")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items").isEmpty());
+ }
+
+ /** 지운 Context의 본문은 매치 대상이 아니다({@code deleted_at IS NULL}). */
+ @Test
+ void deletedContextsAreNeverLexicallyMatched() throws Exception {
+ long me = newMemberId();
+ long recordId = newRecord(me, "lex-delctx", "37.6000000", "127.1000000");
+ newContext(recordId, me, "남아 있는 무관한 본문");
+ long deleted = newContext(recordId, me, "신한은행 들렀던 기록");
+ contextRepository.deleteById(deleted);
+ STUB.willReturn();
+
+ search(me, "신한")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items").isEmpty());
+ }
+
+ /**
+ * 문자열 후보도 Core 재검증(AI 설계 9.5)을 그대로 지난다. Record가 지워졌는데 Context 행이
+ * 남아 있는 짧은 창에서, 문자열 매치가 재검증을 우회해 지운 기록을 되살리면 안 된다.
+ */
+ @Test
+ void lexicallyMatchedButDeletedRecordIsStillDropped() throws Exception {
+ long me = newMemberId();
+ long recordId = newRecord(me, "lex-delrec", "37.6000000", "127.1000000");
+ newContext(recordId, me, "신한은행 옆 카페");
+ recordRepository.deleteById(recordId);
+ STUB.willReturn();
+
+ search(me, "신한")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items").isEmpty());
+ }
+
+ /**
+ * {@code %}·{@code _}는 LIKE 와일드카드가 아니라 질의의 글자다. 이스케이프가 빠지면
+ * {@code 50%}가 「50으로 시작하는 모든 본문」에 매치되어, 게이트(부분일치)가 정한 자격보다
+ * 넓은 후보가 들어온다.
+ */
+ @Test
+ void likeWildcardsAreLiteralCharacters() throws Exception {
+ long me = newMemberId();
+ long literal = newRecord(me, "lex-like-a", "37.5000000", "127.0000000");
+ long literalContext = newContext(literal, me, "할인 50% 쿠폰 받은 곳");
+ long decoy = newRecord(me, "lex-like-b", "37.6000000", "127.1000000");
+ newContext(decoy, me, "50점 만점의 가게");
+ STUB.willReturn();
+
+ search(me, "50%")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(1))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(literal))
+ .andExpect(jsonPath("$.data.items[0].matchedContext.contextId").value(literalContext));
+ }
+
+ /**
+ * 최종 절단은 RRF 순위 기준으로 {@code size}를 한 번만 적용한다(P49 §4-4). 벡터
+ * 2위({@code 1/62})가 문자열 1위({@code 1/61})에 밀려 잘린다 — 「벡터 결과를 먼저 채우고
+ * 남는 자리에 문자열을 넣는」 구현이면 이 테스트가 깨진다.
+ */
+ @Test
+ void theMergedListIsCutOnceBySizeAtTheRrfTail() throws Exception {
+ long me = newMemberId();
+ long vectorFirst = newRecord(me, "lex-cut-a", "37.5000000", "127.0000000");
+ long vectorFirstContext = newContext(vectorFirst, me, "벡터 1위");
+ long vectorSecond = newRecord(me, "lex-cut-b", "37.6000000", "127.1000000");
+ long vectorSecondContext = newContext(vectorSecond, me, "벡터 2위");
+ long lexicalOnly = newRecord(me, "lex-cut-c", "37.7000000", "127.2000000");
+ newContext(lexicalOnly, me, "신한은행 골목 안쪽");
+ STUB.willReturn(
+ new FastApiSearchStub.Match(vectorFirst, vectorFirstContext, 0.90),
+ new FastApiSearchStub.Match(vectorSecond, vectorSecondContext, 0.80));
+
+ mockMvc.perform(post(SEARCH_URL).with(loginAs(me))
+ .contentType(MediaType.APPLICATION_JSON)
+ .content("{\"query\": \"신한\", \"size\": 2}"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(2))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(vectorFirst))
+ .andExpect(jsonPath("$.data.items[1].recordId").value(lexicalOnly));
+ }
+
+ /**
+ * 한 Record 안에서 여러 Context가 매치되면 최신 것 하나가 대표다. Record 단위 응답(AI 설계
+ * 9.4)의 문자열 쪽 등가물이고, 교체 생성(BD-07)이 만드는 구본·신본 동시 매치에서 신본을
+ * 고른다.
+ */
+ @Test
+ void severalMatchingContextsOfARecordCollapseIntoTheLatestOne() throws Exception {
+ long me = newMemberId();
+ long recordId = newRecord(me, "lex-dup", "37.6000000", "127.1000000");
+ newContext(recordId, me, "신한은행 처음 갔던 기록");
+ long latest = newContext(recordId, me, "신한은행 다시 갔던 기록");
+ STUB.willReturn();
+
+ search(me, "신한")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(1))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(recordId))
+ .andExpect(jsonPath("$.data.items[0].matchedContext.contextId").value(latest));
+ }
+
+ /**
+ * I54가 관측한 무결과 회복이다 — 벡터가 0건이어도 문자열 매치가 있으면 결과가 생긴다.
+ * 빈 벡터 결과에서 병합 경로가 일찍 반환해 버리는 구현이면 이 테스트가 깨진다.
+ */
+ @Test
+ void lexicalMatchRecoversAnOtherwiseEmptyResult() throws Exception {
+ long me = newMemberId();
+ long lexicalOnly = newRecord(me, "lex-empty", "37.6000000", "127.1000000");
+ long lexicalContext = 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(lexicalContext))
+ .andExpect(jsonPath("$.data.bounds.swLat").value(37.6));
+ }
+
+ 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/RecordSearchApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/search/RecordSearchApiTests.java
index 436530b5..84c9c010 100644
--- a/src/test/java/com/pinlog/pinlogback/domain/search/RecordSearchApiTests.java
+++ b/src/test/java/com/pinlog/pinlogback/domain/search/RecordSearchApiTests.java
@@ -410,6 +410,27 @@ void noMatchIsAnEmptyItemListWithNullBounds() throws Exception {
.andExpect(jsonPath("$.data.bounds").value(Matchers.nullValue()));
}
+ /**
+ * 문자열 검색 병합(P49 §4)은 기본값이 꺼짐이고, 꺼진 상태의 응답은 현행과 완전히 같아야
+ * 한다(P49 §7 기준 4 — 「모든 플래그를 끄면 현행과 동일한 응답」). 이 클래스는 기본값 컨텍스트에서
+ * 돌므로 그 계약을 여기서 고정한다 — 켠 상태의 병합 계약은 {@link LexicalSearchApiTests}가 맡는다.
+ * 이 테스트가 깨졌다면 기본값이 켜졌거나, 꺼진 플래그가 문자열 경로를 완전히 막지 못하는 것이다.
+ */
+ @Test
+ void lexicalMergeIsOffByDefaultSoABodyMatchAddsNothing() throws Exception {
+ long me = newMemberId();
+ long vectorOnly = newRecord(me, "search-lexoff", "37.5000000", "127.0000000");
+ long vectorContext = newContext(vectorOnly, me, "벡터로만 잡히는 기록");
+ long bodyMatch = newRecord(me, "search-lexoff-b", "37.6000000", "127.1000000");
+ newContext(bodyMatch, me, "신한은행 앞 골목의 가게");
+ STUB.willReturn(new FastApiSearchStub.Match(vectorOnly, vectorContext, 0.82));
+
+ search(me, "신한")
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.data.items.length()").value(1))
+ .andExpect(jsonPath("$.data.items[0].recordId").value(vectorOnly));
+ }
+
/**
* {@code keywords}는 매칭 Context의 것이 아니라 Record의 활성 Context 전체 집계다
* (API 명세 6.1). 매칭 Context만 보면 같은 Record의 다른 Context가 가진 Keyword가 사라진다.
diff --git a/src/test/java/com/pinlog/pinlogback/integration/IntegrationContainerSupport.java b/src/test/java/com/pinlog/pinlogback/integration/IntegrationContainerSupport.java
index db010c7a..cda284a3 100644
--- a/src/test/java/com/pinlog/pinlogback/integration/IntegrationContainerSupport.java
+++ b/src/test/java/com/pinlog/pinlogback/integration/IntegrationContainerSupport.java
@@ -78,6 +78,14 @@ public abstract class IntegrationContainerSupport {
// 호출이 연결 타임아웃으로 실패한다(BT-01).
//
// 여기서 수동으로 시작하면 컨테이너 하나가 실행 내내 살아 있고, 정리는 JVM 종료 시 Ryuk가 한다.
+ //
+ // max_connections를 올리는 이유: Spring 테스트 컨텍스트 캐시는 컨텍스트를 닫지 않고 쌓는데,
+ // 컨텍스트마다 HikariPool(기본 10)이 이 컨테이너 하나에 연결을 잡는다. 합이 postgres 기본
+ // 한도(100)를 넘으면 늦게 뜨는 컨텍스트가 "FATAL: sorry, too many clients already"로 죽는다 —
+ // 검색 쪽 컨텍스트가 하나 늘면서(LexicalSearchApiTests) 실제로 넘었다. 캐시 상한을 줄이는
+ // 대안은 기각했다: evict된 컨텍스트를 쓰는 뒤 클래스가 재기동 비용을 갚고, 어떤 클래스가
+ // 느려지는지가 실행 순서에 따라 달라진다. 이 값은 테스트 전용 컨테이너 설정이라 운영과 무관하다.
+ POSTGRES.setCommand("postgres", "-c", "max_connections=300");
POSTGRES.start();
REDIS.start();
}