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
7 changes: 5 additions & 2 deletions .github/workflows/backend-ci.yml
Original file line number Diff line number Diff line change
@@ -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
Expand Down
64 changes: 64 additions & 0 deletions docs/backend/implements/BI-43-2026-08-06-search-lexical-merge.md
Original file line number Diff line number Diff line change
@@ -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).
10 changes: 10 additions & 0 deletions docs/backend/worklog/2026-08-06-search-lexical-merge.md
Original file line number Diff line number Diff line change
@@ -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에 병합하지 않는 것이 트랙 전체의 제약이다.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.pinlog.pinlogback.domain.search;

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

/**
* 문자열 검색 병합(P49 §4·§5) 설정.
*
* <p>{@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
) {
}
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>범위를 {@code core.context.member_id}로 좁힌다 — 이 비정규화 컬럼의 존재 이유가 바로
* 「자연어 검색은 항상 본인 맥락 한정」이다({@code Context} 엔티티 주석). 이 필터는 검색 범위이지
* 인가가 아니다. 인가는 벡터 후보와 똑같이 {@code SearchRecordRepository.findVerified}의 Core
* 재검증이 맡고, 문자열 후보는 그 재검증을 우회하지 않는다.
*
* <p>매치는 부분일치다. 어절 시작 경계 요구는 실측에서 기대 정답 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)의 문자열 쪽 등가물이다.
*
* <p>바깥 정렬이 곧 문자열 목록의 순위다 — 최초 작성 시각({@code origin_created_at}, 목록
* 정렬·날짜 표시의 기준 컬럼) 내림차순. 실측(I54)은 코사인 내림차순을 썼지만 그 값은 FastAPI
* 밖으로 나오지 않아 여기서는 쓸 수 없고, 대신 이 도메인의 기존 정렬 기준을 따른다. 어떤
* 기준이든 <b>결정적</b>이어야 같은 질의가 같은 순서를 돌려준다.
*/
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<LexicalMatch> 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 문법 글자다. 질의에 섞여 오면 <b>글자</b>로 취급해야
* 한다 — 이스케이프가 빠지면 {@code 50%} 같은 질의가 「50으로 시작하는 모든 본문」에 매치되어
* 게이트가 정한 자격(부분일치)보다 넓은 후보가 들어온다.
*/
private static String escapeLike(String query) {
return query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
}
Loading
Loading