From b019fff25deb85355bf7cbd28d52ccf1ab8d29d2 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 14:04:02 +0900 Subject: [PATCH 1/5] =?UTF-8?q?ci:=20backend-ci=20=ED=8A=B8=EB=A6=AC?= =?UTF-8?q?=EA=B1=B0=EC=97=90=20search-upgrade=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=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 검색 고도화 트랙(ai 레포 P49 §6)의 작업 PR은 base가 dev가 아니라 통합 브랜치라서, 트리거가 dev 한정이면 그 PR들이 CI 없이 병합된다. ai-ci가 같은 이유로 같은 트리거를 추가한 선례(ai cf07b15)를 따른다. Co-Authored-By: Claude Fable 5 --- .github/workflows/backend-ci.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) 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 From a5a164debd9af132208c12d0dd74a084869d4f74 Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 14:04:13 +0900 Subject: [PATCH 2/5] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20postgres=EC=9D=98=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=ED=95=9C=EB=8F=84=EB=A5=BC=20300=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=98=AC=EB=A6=B0=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spring 테스트 컨텍스트 캐시는 컨텍스트를 닫지 않고 쌓고, 컨텍스트마다 HikariPool(기본 10)이 공유 컨테이너 하나에 연결을 잡는다. 합이 postgres 기본 한도(100)를 넘으면 늦게 뜨는 컨텍스트가 "FATAL: sorry, too many clients already"로 죽는다 — 검색 쪽 컨텍스트가 하나 늘면서 실제로 넘었다 (clean check에서 OpenApi·Security·Flyway 계열 4클래스 기동 실패로 관측). 캐시 상한 축소는 기각한다: evict된 컨텍스트의 재기동 비용을 뒤 클래스가 갚고, 어느 클래스가 느려지는지가 실행 순서에 좌우된다. 이 값은 테스트 전용 컨테이너 설정이라 운영과 무관하다. Co-Authored-By: Claude Fable 5 --- .../integration/IntegrationContainerSupport.java | 8 ++++++++ 1 file changed, 8 insertions(+) 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(); } From b909227af3c9bfb267f2f1099bdee8c569bd29da Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 14:04:35 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=EB=8B=A8=EC=96=B4=ED=98=95=20?= =?UTF-8?q?=EC=A7=88=EC=9D=98=EC=9D=98=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=9D=84=20=EB=B2=A1=ED=84=B0=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=EC=99=80=20RRF(k=3D60)=EB=A1=9C=20=EB=B3=91=ED=95=A9?= =?UTF-8?q?=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검색 고도화 트랙 P49 작업 5(티켓 미발급 — 발급 시 병합 커밋에 키를 싣는다). 본문에 질의 문자열이 그대로 있는데 임베딩 유사도가 낮아 검색되지 않는 실패 사례(신한)를 회복한다. 규칙은 ai 파트 오프라인 실측(I54)의 확정안 그대로다: 단어형 한정·부분일치 게이트 + RRF(k=60) 병합 + similarity 원 코사인 유지 + 실패 시 벡터만. - LexicalContextRepository: core.context 본인 소유·활성 본문의 부분일치 매치. DISTINCT ON으로 Record당 대표 Context(최신 교체본) 하나. LIKE 특수문자 이스케이프 - LexicalSearchProperties: pinlog.search.lexical.enabled(기본 false — 꺼진 상태가 현행과 동일하다는 계약을 기본값 컨텍스트 테스트로 고정) · word-query-max-chars(ai와 같은 값 5) - RecordSearchService: distinctByRecord 직후 병합 단계. 단어형 판정은 ai와 등가(유니코드 공백 전체·코드 포인트 길이). 문자열 후보도 기존 Core 재검증에 그대로 합류. 조회 실패는 벡터 결과로 조용히 복귀. size 절단은 RRF 하위부터 한 번만 - 실측과 다르게 간 지점 셋(문자열 목록 순위 기준·문자열 단독 후보의 점수 항·similarity 0.0)은 컷 탈락 후보의 코사인이 FastAPI 밖으로 나오지 않는 제약에서 나왔다 — 근거와 트레이드오프는 구현 리포트(BI-42, 별도 커밋 예정)에 있다 검증: 신규 12건 RED(병합 요구 6건 실패 확인) → 구현 후 GREEN, 기본값 꺼짐 계약 1건 추가, clean check 전체 통과. Co-Authored-By: Claude Fable 5 --- .../search/LexicalSearchProperties.java | 22 ++ .../repository/LexicalContextRepository.java | 82 +++++ .../search/service/RecordSearchService.java | 179 ++++++++- src/main/resources/application.yml | 8 + .../domain/search/LexicalSearchApiTests.java | 345 ++++++++++++++++++ .../domain/search/RecordSearchApiTests.java | 21 ++ 6 files changed, 651 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/pinlog/pinlogback/domain/search/LexicalSearchProperties.java create mode 100644 src/main/java/com/pinlog/pinlogback/domain/search/repository/LexicalContextRepository.java create mode 100644 src/test/java/com/pinlog/pinlogback/domain/search/LexicalSearchApiTests.java 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 findMatches(long memberId, String query, int limit) { + return jdbc.query(MATCH_SQL, + Map.of("memberId", memberId, "pattern", "%" + escapeLike(query) + "%", "limit", limit), + (rows, i) -> new LexicalMatch(rows.getLong("record_id"), rows.getLong("context_id"))); + } + + /** + * {@code %}·{@code _}·{@code \}는 LIKE 문법 글자다. 질의에 섞여 오면 글자로 취급해야 + * 한다 — 이스케이프가 빠지면 {@code 50%} 같은 질의가 「50으로 시작하는 모든 본문」에 매치되어 + * 게이트가 정한 자격(부분일치)보다 넓은 후보가 들어온다. + */ + private static String escapeLike(String query) { + return query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_"); + } +} 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 c4480593..db661a44 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 @@ -1,12 +1,17 @@ package com.pinlog.pinlogback.domain.search.service; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.stereotype.Service; import com.pinlog.pinlogback.domain.ai.KeywordResponseStatus; @@ -15,11 +20,13 @@ 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.dto.MatchedContextResponse; import com.pinlog.pinlogback.domain.search.dto.RecordSearchItemResponse; import com.pinlog.pinlogback.domain.search.dto.RecordSearchRequest; import com.pinlog.pinlogback.domain.search.dto.RecordSearchResponse; import com.pinlog.pinlogback.domain.search.dto.SearchPlaceResponse; +import com.pinlog.pinlogback.domain.search.repository.LexicalContextRepository; import com.pinlog.pinlogback.domain.search.repository.SearchRecordRepository; import com.pinlog.pinlogback.domain.search.repository.VerifiedSearchRecord; import com.pinlog.pinlogback.global.response.BoundsResponse; @@ -27,35 +34,57 @@ /** * 개인 자연어 검색 유스케이스(API 명세 6.1, AI 설계 9장). * - *

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

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

    *
  1. FastAPI 호출 — Record 단위로 집계된 {@code (recordId, contextId, similarity)} 목록을 받는다
  2. - *
  3. Core 재검증 — 소유권·삭제·활성 Context·Place를 Spring이 다시 본다(9.5)
  4. + *
  5. 문자열 병합 — 단어형 질의면 본문 문자열 매치를 합쳐 RRF로 재정렬한다(P49 §4, 기본 꺼짐). + * 꺼져 있거나 실패하면 이 단계는 없던 것과 같다
  6. + *
  7. Core 재검증 — 소유권·삭제·활성 Context·Place를 Spring이 다시 본다(9.5). 문자열 후보도 + * 똑같이 지난다
  8. *
  9. 조립 — 본문·Keyword·판정 상태는 Core에서 조회해 붙인다. FastAPI는 본문을 주지 않는다
  10. *
  11. bounds 계산 — 재검증을 통과한 것들로만 계산한다
  12. *
* *

{@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 matches = distinctByRecord( - aiSearchClient.search(memberId, request.query(), request.sizeOrDefault())); + List matches = mergeLexicalMatches(memberId, request, + distinctByRecord(aiSearchClient.search(memberId, request.query(), request.sizeOrDefault()))); if (matches.isEmpty()) { return new RecordSearchResponse(null, List.of()); } @@ -89,6 +118,144 @@ public RecordSearchResponse search(long memberId, RecordSearchRequest request) { items); } + /** + * 문자열 검색을 벡터 결과에 병합한다(P49 §4, 규칙의 실측 근거는 ai 레포 I54). + * + *

이 메서드가 벡터 결과를 그대로 돌려주는 경로가 셋이다 — 플래그 꺼짐, 단어형이 아닌 질의 + * (게이트, P49 §5), 문자열 조회 실패. 어느 경로든 응답은 실패하지 않고 현행 검색과 같은 + * 동작으로 되돌아간다(P49 §4의 세 번째 원칙). 조회 실패를 오류로 올리지 않는 이유는 벡터 + * 검색이 이미 성공해 있기 때문이다 — 보조 신호의 장애가 주 결과를 지우면 안 된다. + */ + private List mergeLexicalMatches(long memberId, RecordSearchRequest request, + List vector) { + if (!lexicalProperties.enabled()) { + return vector; + } + String query = stripSpaces(request.query()); + if (!isWordQuery(query)) { + return vector; + } + 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; + } + if (lexical.isEmpty()) { + return vector; + } + return rrfMerge(vector, lexical, request.sizeOrDefault()); + } + + /** + * 단어형인가 — 공백이 없고 짧을 때만 그렇다. ai 레포 {@code SearchService._is_word_query}와 + * 같은 판정이어야 한다: 거기서 문장형으로 컷을 탄 질의가 여기서 단어형으로 문자열 경로를 타면 + * 「단어형 한정」 게이트(I54)가 두 파트에서 서로 다른 질의 집합에 걸린다. + * + *

글자 수는 코드 포인트로 센다({@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 rrfMerge(List vector, + List lexical, int limit) { + Map vectorByRecord = new LinkedHashMap<>(); + Map vectorRanks = new HashMap<>(); + for (AiSearchResponse.Match match : vector) { + vectorByRecord.put(match.recordId(), match); + vectorRanks.put(match.recordId(), vectorRanks.size() + 1); + } + Map lexicalRanks = new LinkedHashMap<>(); + Map lexicalContexts = new HashMap<>(); + for (LexicalContextRepository.LexicalMatch match : lexical) { + if (lexicalRanks.putIfAbsent(match.recordId(), lexicalRanks.size() + 1) == null) { + lexicalContexts.put(match.recordId(), match.contextId()); + } + } + + record Ranked(long recordId, double score, int vectorRank) { + } + List ranked = new ArrayList<>(); + for (Long recordId : union(vectorByRecord, lexicalRanks)) { + Integer vectorRank = vectorRanks.get(recordId); + Integer lexicalRank = lexicalRanks.get(recordId); + double score = (vectorRank == null ? 0.0 : 1.0 / (RRF_K + vectorRank)) + + (lexicalRank == null ? 0.0 : 1.0 / (RRF_K + lexicalRank)); + ranked.add(new Ranked(recordId, score, vectorRank == null ? Integer.MAX_VALUE : vectorRank)); + } + ranked.sort(Comparator.comparingDouble((Ranked r) -> -r.score()) + .thenComparingInt(Ranked::vectorRank) + .thenComparingLong(Ranked::recordId)); + + List merged = new ArrayList<>(); + 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)); + } + return List.copyOf(merged); + } + + /** 벡터 순서 먼저, 문자열 신규는 뒤에. 순서 자체는 정렬이 다시 정하므로 결정성만 맡는다. */ + private static List union(Map vectorByRecord, + Map lexicalRanks) { + List recordIds = new ArrayList<>(vectorByRecord.keySet()); + for (Long recordId : lexicalRanks.keySet()) { + if (!vectorByRecord.containsKey(recordId)) { + recordIds.add(recordId); + } + } + return recordIds; + } + /** * FastAPI가 준 순서(유사도 내림차순)를 유지하며 통과한 것만 담는다. * diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 76954875..28ae8879 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -170,6 +170,14 @@ pinlog: # 재스캔과 Finalizer가 각각 한 회차에 집는 상한. 후보 건수가 계속 이 값에 붙어 있으면 # FastAPI가 처리량을 못 따라가고 있다는 신호다(명세 8장). batch-size: 100 + # 문자열 검색 병합(P49 §4·§5, 병합 규칙의 실측 근거는 ai 레포 I54). 검증 게이트(P49 §7) 통과 + # 전에는 켜지 않는다 — 기본값 false가 곧 "현행과 동일한 검색"이다. + search: + lexical: + enabled: ${PINLOG_SEARCH_LEXICAL_ENABLED:false} + # 단어형 질의의 최대 글자 수. ai 레포 SEARCH_WORD_QUERY_MAX_CHARS와 같은 값·같은 의미여야 + # 한다 — 어긋나면 「단어형」의 정의가 파트마다 달라져 게이트(단어형 한정)가 절반만 켜진다. + word-query-max-chars: ${PINLOG_SEARCH_LEXICAL_WORD_QUERY_MAX_CHARS:5} # Feed 추천 정책값. 정본은 AI 파트가 소유한 docs/ai/spec/feed-scoring.md이며 여기서 임의로 # 바꾸지 않는다. 상수로 박지 않고 설정으로 두는 이유는 튜닝 대상이기 때문이다 — 재배포 없이 # 조정할 수 있어야 하고, 가중치를 바꿔도 순위가 안 바뀌는 회귀를 테스트가 잡을 수 있어야 한다. diff --git a/src/test/java/com/pinlog/pinlogback/domain/search/LexicalSearchApiTests.java b/src/test/java/com/pinlog/pinlogback/domain/search/LexicalSearchApiTests.java new file mode 100644 index 00000000..8d0c5c67 --- /dev/null +++ b/src/test/java/com/pinlog/pinlogback/domain/search/LexicalSearchApiTests.java @@ -0,0 +1,345 @@ +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; + +/** + * 문자열 검색 병합(P49 §4·§5, 병합 규칙의 실측 근거는 ai 레포 I54)의 계약을 고정한다. + * + *

이 클래스는 플래그를 컨텍스트에서 돈다. 기본값(끔)에서 현행과 동일하다는 계약은 + * {@link RecordSearchApiTests}가 기본값 컨텍스트에서 고정한다 — 같은 클래스에 두면 둘 중 하나의 + * 플래그 상태가 거짓이 된다. + * + *

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

    + *
  1. 게이트 — 단어형 질의(공백 없음·5자 이하)에서만 문자열 검색이 켜진다
  2. + *
  3. 병합 — 벡터 컷 통과자와 문자열 매치의 합집합을 RRF(k=60)로 재정렬하고, + * {@code size} 절단은 RRF 하위부터 한 번만 한다
  4. + *
  5. 계약 불변 — {@code similarity}는 원래 코사인 값을 유지한다. 문자열 단독 항목은 + * 코사인이 없으므로 0.0을 싣는다(front는 이 값을 UI에 노출하지 않는다)
  6. + *
  7. 재검증 불변 — 문자열 후보도 Core 재검증(소유권·삭제·활성 Context)을 그대로 지난다
  8. + *
+ */ +@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가 사라진다. From 07f39da8d2cf24e09a8c813ae58d3eee79f5c05a Mon Sep 17 00:00:00 2001 From: colosair Date: Thu, 6 Aug 2026 14:17:27 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EB=B3=91=ED=95=A9=EC=9D=98=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EB=A6=AC=ED=8F=AC=ED=8A=B8=20BI-42=EC=99=80=20?= =?UTF-8?q?=EC=9E=91=EC=97=85=20=EB=A1=9C=EA=B7=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 구현 커밋(b909227)이 예고한 기록이다. 실측(ai 레포 I54)의 계산을 런타임에 그대로 옮길 수 없던 세 지점(문자열 목록 순위 기준·문자열 단독 후보의 점수 항·similarity 대체값)의 판단과 근거, TDD 검증 경과, 실서버 E2E가 검증 게이트 몫으로 남는다는 경계를 담는다. Co-Authored-By: Claude Fable 5 --- .../BI-42-2026-08-06-search-lexical-merge.md | 64 +++++++++++++++++++ .../2026-08-06-search-lexical-merge.md | 10 +++ 2 files changed, 74 insertions(+) create mode 100644 docs/backend/implements/BI-42-2026-08-06-search-lexical-merge.md create mode 100644 docs/backend/worklog/2026-08-06-search-lexical-merge.md diff --git a/docs/backend/implements/BI-42-2026-08-06-search-lexical-merge.md b/docs/backend/implements/BI-42-2026-08-06-search-lexical-merge.md new file mode 100644 index 00000000..cb6dd934 --- /dev/null +++ b/docs/backend/implements/BI-42-2026-08-06-search-lexical-merge.md @@ -0,0 +1,64 @@ +# BI-42. 단어형 질의의 본문 문자열 검색과 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..2289cfe2 --- /dev/null +++ b/docs/backend/worklog/2026-08-06-search-lexical-merge.md @@ -0,0 +1,10 @@ +# 본문에 질의 문자열이 있는 기록을 검색 결과에 병합하는 경로를 기본 꺼짐으로 구현했다 + +- **날짜**: 2026-08-06 +- **관련**: [BI-42](../implements/BI-42-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-42에 적었다. + +기능 플래그 `pinlog.search.lexical.enabled`의 기본값은 꺼짐이다. 꺼진 상태의 응답이 현행과 동일하다는 것을 기본값 컨텍스트의 계약 테스트로 고정했다. 켜는 결정은 검색 고도화 트랙의 검증 게이트를 통과한 뒤에 한다. 작업은 dev가 아니라 통합 브랜치 `search-upgrade`를 기준으로 한 작업 브랜치에서 했다. 검증 전에는 dev에 병합하지 않는 것이 트랙 전체의 제약이다. From a5e76730381245422951f9c951b6ba1ba7b2f189 Mon Sep 17 00:00:00 2001 From: colosair Date: Fri, 7 Aug 2026 17:14:44 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=AC=B8=EC=9E=90=EC=97=B4=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EA=B5=AC=ED=98=84=20=EB=A6=AC=ED=8F=AC?= =?UTF-8?q?=ED=8A=B8=EB=A5=BC=20BI-43=EC=9C=BC=EB=A1=9C=20=EC=9E=AC?= =?UTF-8?q?=EB=B2=88=ED=98=B8=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dev에 먼저 머지된 PR #202가 BI-42를 `BI-42-2026-08-07-map-keyword-chips.md`로 확정했다. 이 브랜치의 `BI-42-2026-08-06-search-lexical-merge.md`와 번호가 겹치므로 뒤에 오는 이쪽을 BI-43으로 옮긴다. dev에 BI-43은 아직 없다. 두 파일은 날짜와 주제가 달라 파일명이 서로 다르다. git은 경로가 다른 파일을 같은 자원으로 보지 않으므로 병합할 때 충돌로 잡지 않고 둘 다 조용히 남긴다. 번호 중복은 사람이 읽을 때만 드러난다. dev의 직전 커밋 39cbc7f가 같은 사고를 BD-46에서 수습한 커밋이고, 이번에는 병합 전에 처리한다. implements 구역은 목록 표를 두지 않으므로(`docs/backend/implements/README.md`) 파일명과 H1, 작업 로그의 링크만 고치면 참조가 모두 맞는다. 브랜치 전체에서 옛 번호를 참조하는 곳이 남지 않았음을 확인했다. Co-Authored-By: Claude Fable 5 --- ...ical-merge.md => BI-43-2026-08-06-search-lexical-merge.md} | 2 +- docs/backend/worklog/2026-08-06-search-lexical-merge.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) rename docs/backend/implements/{BI-42-2026-08-06-search-lexical-merge.md => BI-43-2026-08-06-search-lexical-merge.md} (99%) diff --git a/docs/backend/implements/BI-42-2026-08-06-search-lexical-merge.md b/docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md similarity index 99% rename from docs/backend/implements/BI-42-2026-08-06-search-lexical-merge.md rename to docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md index cb6dd934..48aba8e2 100644 --- a/docs/backend/implements/BI-42-2026-08-06-search-lexical-merge.md +++ b/docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md @@ -1,4 +1,4 @@ -# BI-42. 단어형 질의의 본문 문자열 검색과 RRF 병합 구현 +# BI-43. 단어형 질의의 본문 문자열 검색과 RRF 병합 구현 - **상태**: ✅ 구현 완료. 기능 플래그는 꺼진 상태로 두었다. 켜는 결정은 검색 고도화 검증 게이트 통과 뒤의 일이다. - **날짜**: 2026-08-06 diff --git a/docs/backend/worklog/2026-08-06-search-lexical-merge.md b/docs/backend/worklog/2026-08-06-search-lexical-merge.md index 2289cfe2..df34ff54 100644 --- a/docs/backend/worklog/2026-08-06-search-lexical-merge.md +++ b/docs/backend/worklog/2026-08-06-search-lexical-merge.md @@ -1,10 +1,10 @@ # 본문에 질의 문자열이 있는 기록을 검색 결과에 병합하는 경로를 기본 꺼짐으로 구현했다 - **날짜**: 2026-08-06 -- **관련**: [BI-42](../implements/BI-42-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) +- **관련**: [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-42에 적었다. +규칙은 ai 파트의 오프라인 실측 리포트(I54)가 확정한 것을 그대로 구현했다. 단어형 질의에서만 본문 부분일치를 찾고, 벡터 검색 통과 후보와의 합집합을 순위 결합(RRF)으로 재정렬한다. 응답의 `similarity`는 원래 코사인 값을 유지한다. 문자열 조회가 실패하면 벡터 결과만 반환한다. 실측의 계산을 런타임에 그대로 옮길 수 없던 세 지점이 있었고, 각각의 판단과 근거는 BI-43에 적었다. 기능 플래그 `pinlog.search.lexical.enabled`의 기본값은 꺼짐이다. 꺼진 상태의 응답이 현행과 동일하다는 것을 기본값 컨텍스트의 계약 테스트로 고정했다. 켜는 결정은 검색 고도화 트랙의 검증 게이트를 통과한 뒤에 한다. 작업은 dev가 아니라 통합 브랜치 `search-upgrade`를 기준으로 한 작업 브랜치에서 했다. 검증 전에는 dev에 병합하지 않는 것이 트랙 전체의 제약이다.