Files
joungmin 80b553ec19 docs: 현행화 17개 설계서 Approved + 후속 이슈 백로그 등록
Reviewer 결과 17 PASS / 1 REJECT (#267 admin 권한 critical).
- 17개 설계서를 Draft → Approved로 갱신
- #267(backend-user)은 critical 결함으로 06-Reviewer 유지
- 후속 17개 개선 이슈(#289~#305) 자동 등록 — 결함 124건 백로그 반영
  (critical 3 / major 46 / minor 75)
- docs/README.md에 18개 설계서 인덱스 추가
- CHANGELOG.md 2026-06-15 섹션 추가

Refs: #266 #268-#283 (현행화 완료) #267 (대기) #289-#305 (백로그)
2026-06-15 11:08:18 +09:00

202 lines
16 KiB
Markdown

<!-- 기능 설계서. 작성: [AI] Architect. 빈 섹션 금지 — 해당 없으면 "해당 없음" 명시. -->
# 설계서: 백엔드 - 검색/벡터 추천 (#271)
> **상태**: Approved <!-- Draft | Approved | Superseded -->
> **작성**: [AI] Architect · **최종수정**: 2026-06-15
> **추적성** — Redmine: #271 · 관련 ADR: 없음
> · 구현 파일: `backend-java/src/main/java/com/tasteby/service/SearchService.java`, `backend-java/src/main/java/com/tasteby/service/VectorService.java`, `backend-java/src/main/java/com/tasteby/controller/SearchController.java` · 테스트: TBD (현재 없음)
## 1. 목적 (Why)
사용자가 식당명/메뉴/지역 키워드로 빠르게 후보를 찾고, 의미 기반 추천(예: "혼술하기 좋은 이자카야")도 받을 수 있도록 키워드 SQL + Oracle 23ai VECTOR 코사인 거리 검색을 동일 엔드포인트로 제공한다.
## 2. 범위 (Scope)
- **포함**:
- `GET /api/search?q=&mode=&limit=` 단일 엔드포인트.
- 모드: `keyword`(기본, LIKE), `semantic`(벡터), `hybrid`(키워드 + 벡터 union).
- 결과 캐싱 (`CacheService`, key = `search:q=..:m=..:l=..`).
- 채널명 부착(`attachChannels`) — 검색 결과에 어떤 채널들이 다뤘는지 표시.
- 벡터 인덱스 운영용 API: 추출 파이프라인에서 호출되는 `saveRestaurantVectors`, 검색용 `searchSimilar`.
- **제외 (out of scope)**:
- 식당 상세/지도 노출 — #268/#278.
- 벡터 재생성 SSE (`rebuild-vectors`) — #269 (TODO).
- 사용자별 개인화 추천/로그.
- 채널 마스터 데이터 — #273.
- 임베딩 모델 학습/튜닝.
## 3. 인수조건 (Acceptance Criteria)
- [ ] `GET /api/search?q=족발&mode=keyword&limit=20``restaurants.name/foods_mentioned/...``%족발%` LIKE 매칭된 식당을 최대 limit(상한 100)개 반환하고, 각 결과의 `channels` 배열에 출연 채널명이 채워진다.
- [ ] `mode=semantic` 호출 시 OCI Embed 로 쿼리 임베딩 → `VECTOR_DISTANCE(... COSINE)``maxDistance ≤ 0.57` 인 chunk 상위 `max(30, limit*3)` 개를 가져와, restaurant_id 중복 제거 후 좌표 있는 식당만 limit 개 반환한다.
- [ ] `mode=hybrid` 는 키워드 결과 우선 + 의미 결과를 뒤에 union 하며, 동일 식당 중복 제거 후 limit 로 컷한다.
- [ ] 동일 (q, mode, limit) 두 번째 호출은 Redis 캐시에서 즉시 반환 (DB/OCI 호출 0회).
- [ ] semantic 호출 중 OCI 실패 시 keyword 결과로 자동 폴백하며 500 을 던지지 않는다.
- [ ] `VectorService.saveRestaurantVectors` 가 chunks 리스트를 96개 단위 배치 임베딩 후 한 INSERT/chunk 로 Oracle VECTOR 컬럼에 저장한다.
## 4. 컨텍스트 & 제약
- **의존성**:
- Oracle 23ai VECTOR 타입 + `VECTOR_DISTANCE(..., COSINE)` 함수.
- `OciGenAiService.embedTexts` (Cohere/embed-v4 등, 96 배치).
- `RestaurantService.findById` — semantic 결과 1차 행 조회.
- `CacheService` (Redis) — 직렬화는 Jackson ObjectMapper(local 인스턴스).
- `SearchMapper.keywordSearch`, `SearchMapper.findChannelsByRestaurantIds`.
- **제약**:
- `limit ≤ 100` (Controller 가드).
- 임베딩 비용 → 동일 쿼리 캐시 hit 시 0 호출, miss 시 1 호출.
- VECTOR 컬럼 바인딩은 `NamedParameterJdbcTemplate + float[]` 직접 바인딩 (MyBatis 미지원이라 JDBC 사용).
- hybrid mode 는 union 후 limit 만 적용 — 가중치 랭킹은 미구현 (단순 keyword 우선).
- **가정**:
- 검색 빈도는 식당 추출보다 훨씬 잦지만 임베딩 호출은 캐시로 대부분 흡수된다.
- 식당 1건당 vector chunk 1개 (`VectorService.buildChunks`) — 좌표 없는 식당은 semantic 결과에서 자동 제거.
- cosine distance 임계 0.57 은 운영 관측치 기반 (조정 가능).
## 5. 아키텍처 개요
- 모듈/파일:
- `controller/SearchController.java` — REST 엔드포인트, limit clamp.
- `service/SearchService.java` — 모드 분기, 캐시, 채널 부착.
- `service/VectorService.java` — 임베딩 + Oracle VECTOR 검색/저장 (JDBC).
- `mapper/SearchMapper.java` (+ XML) — `keywordSearch`, `findChannelsByRestaurantIds`.
- 협력: `OciGenAiService` (#270), `RestaurantService` (#268), `CacheService` (#276).
- I/O ↔ 순수 로직 경계:
- **I/O**: Oracle (LIKE + VECTOR), Redis 캐시, OCI Embed.
- **순수 로직**: 모드 분기, 중복 제거(LinkedHashSet), keyword 우선 union, `buildChunks` 텍스트 합성, 좌표 필터(`r.getLatitude() != null`).
```
┌────────────────────────┐
GET /api/search?q=&mode=&limit= │ SearchController │
────────────────────────────────▶ search(q, mode, limit) │
│ limit = min(limit,100) │
└──────────┬─────────────┘
┌──────────────────────────────────┐
│ SearchService.search │
│ cache.get("search:q=..:m=..") │ hit ──▶ return List<Restaurant>
└──────────┬───────────────────────┘ miss
┌────────────┬─────────┴────────────┬─────────────┐
▼ ▼ ▼ ▼
keywordSearch semanticSearch hybrid: cache.set
│ │ kw + sem union │
▼ ▼ │
SearchMapper VectorService.searchSimilar │
LIKE %q% ├── OciGenAiService.embedTexts(query) │
attachChannels │ → float[] qvec │
├── jdbc.query VECTOR_DISTANCE(... COSINE) │
│ ≤0.57, ORDER BY dist FETCH FIRST k │
└── RestaurantService.findById(rid) [coord!=null]
◀────────────── Response
```
## 6. 데이터 모델
- **입력**:
- 쿼리 파라미터: `q: string (필수)`, `mode ∈ {keyword, semantic, hybrid}`(기본 keyword), `limit: int (기본 20, 상한 100)`.
- **출력**: `List<Restaurant>` — id, name, address, region, latitude, longitude, cuisineType, priceRange, phone, website, googlePlaceId, businessStatus, rating, ratingCount, updatedAt, `channels: string[]` (검색 결과에만 채움), `foodsMentioned` (옵션).
- **벡터 인덱스 저장 (`restaurant_vectors`)**:
- `id` 32자 UUID(hex upper), `restaurant_id` FK, `chunk_text` CLOB, `embedding` VECTOR.
- chunk 본문 예시:
```
식당: <name>
지역: <region>
음식 종류: <cuisine_type>
메뉴: a, b, c
평가: <evaluation>
가격대: <price>
영상: <video_title>
```
- **캐시 키**: `search:q=<q>:m=<mode>:l=<limit>` (`CacheService.makeKey`). 값은 `List<Restaurant>` Jackson JSON.
- **검증 규칙**:
- `q` 가 비어 있으면 Controller 가드(현재 미존재) — 빈 문자열은 `%%` LIKE 로 모든 식당 매칭되므로 클라이언트에서 빈 쿼리 차단 권장. (Open Question 참조)
- limit > 100 → 100 으로 clamp.
- semantic 결과는 `latitude != null` 인 식당만 — 지도 노출 가능한 후보로 한정.
## 7. 함수 명세 (Function Specs)
| 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? |
|------|-----------|----------------|------|------|-----------|-------|
| `SearchController.search` | REST 엔드포인트 + limit clamp | `List<Restaurant> search(q, mode, limit)` | q, mode, limit | List<Restaurant> | 없음 | 단순 |
| `SearchService.search` | 모드 분기 + 캐싱 | `List<Restaurant> search(q, mode, limit)` | 동일 | 동일 | 캐시 직렬화 catch | **복잡** |
| `SearchService.keywordSearch` (private) | LIKE 검색 + 채널 부착 | `(q, limit)` | %q% pattern | List<Restaurant> | 빈 결과 빈 list | 단순 |
| `SearchService.semanticSearch` (private) | 벡터 검색 + 식당 조회 | `(q, limit)` | q, limit | List<Restaurant> | catch → keyword 폴백 | **복잡** |
| `SearchService.attachChannels` (private) | restaurant_id ↔ 채널명 부착 | `(restaurants)` | List | void (mutate) | 매핑 누락 시 빈 list | 단순 |
| `VectorService.searchSimilar` | OCI embed + Oracle VECTOR 질의 | `(query, topK, maxDistance)` | text, k, dist | `List<Map>` (restaurant_id, chunk_text, distance) | embed 실패 throw | **복잡** |
| `VectorService.saveRestaurantVectors` | chunk 배열 임베딩 + INSERT | `(restaurantId, chunks)` | rid, chunks | void | chunk 별 update 예외 throw | **복잡** |
| `VectorService.buildChunks` (static) | 식당 데이터 → 임베딩 텍스트 | `(name, data, videoTitle)` | name + Map + title | `List<String>`(1) | 없음 | 단순 |
> 복잡 표시 함수는 외부 I/O + 폴백 또는 비-MyBatis 경로(JDBC + VECTOR 바인딩). 별도 `fn-*.md` 우선순위: `searchSimilar`, `semanticSearch`.
## 8. 흐름 / 알고리즘
1. **요청 진입**: Controller 가 `limit > 100` 이면 100 으로 clamp → `SearchService.search(q, mode, limit)` 호출.
2. **캐시 조회**: key=`search:q=<q>:m=<mode>:l=<limit>` → `cache.getRaw` hit 시 Jackson 역직렬화 후 즉시 반환. 직렬화 예외는 무시하고 본 로직 진행.
3. **모드 분기**:
- `keyword` (default): `SearchMapper.keywordSearch("%q%", limit)` → 결과에 `attachChannels` 적용.
- `semantic`: `VectorService.searchSimilar(q, max(30, limit*3), 0.57)` → 결과의 `restaurant_id` 를 `LinkedHashSet` 으로 중복 제거 → `restaurantService.findById(rid)` 로 행 조회, `latitude != null` 인 것만 limit 개까지 누적.
- `hybrid`: keyword 결과 + semantic 결과를 순서대로 union (`HashSet seen` 으로 중복 제거), limit 초과시 subList(0, limit). (현재 채널 부착은 keyword 결과에만 적용됨.)
4. **벡터 검색 내부 (`searchSimilar`)**:
a) `OciGenAiService.embedTexts([query])` → `List<List<Double>>` → 첫 임베딩을 `float[]` 로 변환.
b) SQL:
```sql
SELECT rv.restaurant_id, rv.chunk_text,
VECTOR_DISTANCE(rv.embedding, :qvec, COSINE) AS dist
FROM restaurant_vectors rv
WHERE VECTOR_DISTANCE(rv.embedding, :qvec2, COSINE) <= :max_dist
ORDER BY dist
FETCH FIRST :k ROWS ONLY
```
`:qvec`/`:qvec2` 동일 배열 두 번 바인딩 (SELECT 와 WHERE 에 각각 사용).
c) RowMapper: `RESTAURANT_ID`, `CHUNK_TEXT`(CLOB → `JsonUtil.readClob`), `DIST`.
5. **캐시 저장**: 최종 결과를 `cache.set(key, result)` (Jackson JSON 직렬화, CacheService 가 TTL 관리 — #276).
6. **벡터 저장 (`saveRestaurantVectors`)**: chunks 빈 리스트면 즉시 종료. `embedTexts(chunks)` 후 chunks.size 만큼 반복하며 각 row 에 새 UUID + 변환된 float[] + chunk_text 를 `INSERT` (단건 update). 예외는 호출자(`PipelineService`) 에서 warn 처리.
7. **채널 부착**: `SearchMapper.findChannelsByRestaurantIds(ids)` → row 의 `restaurant_id`(소문자 또는 `RESTAURANT_ID` 대문자) 두 키 모두 지원 → `Map<restId, List<channelName>>` 구성 → 각 Restaurant 의 `channels` 필드 set (없으면 빈 리스트).
## 9. 엣지케이스 & 에러 처리
- **빈 쿼리**: `q=""` → keyword 는 모든 식당 매칭(LIKE `%%`), semantic 은 의미 약함. 현재 Controller 가드 없음 → Open Questions 참조.
- **특수문자/와일드카드**: q 에 `%`/`_` 가 있으면 LIKE 부작용. 현재 escape 미적용 (Open Question).
- **OCI Embed 미설정**: `IllegalStateException` → `semanticSearch` catch → keyword 폴백, 사용자에겐 200 응답.
- **임베딩 빈 결과**: `searchSimilar` 가 빈 list 반환 → semantic 결과 0 → `keywordSearch` 폴백은 발생하지 않음 (현재 코드: semantic 0이면 빈 결과 반환 가능). hybrid 모드는 키워드 결과로 채워짐.
- **좌표 없는 식당**: semantic 결과에서 자동 제외 (지도 무관 검색 시 누락 가능 — 의도).
- **캐시 직렬화 실패**: getRaw 후 `mapper.readValue` 예외는 무시 후 DB 재조회.
- **VECTOR 거리 임계 미달**: `WHERE dist <= 0.57` 로 0건 가능 → 빈 list. 임계 낮추거나 키워드 사용 권장.
- **채널 매핑 row 키 대소문자 차이**: row.getOrDefault 로 두 케이스 모두 처리 — Oracle 컬럼 대문자/lowerKeys 미적용 시 대비.
- **CLOB chunk_text**: `JsonUtil.readClob` 으로 안전 변환.
- **DB 연결 실패**: `jdbc.query` 예외 → SearchService 의 `semanticSearch` catch → keyword 폴백.
- **부분 결과**: hybrid 에서 keyword 만 결과가 있고 semantic 이 throw 시, keyword 결과만 반환되도록 catch 위치는 `semanticSearch` 내부 → 그대로 빈 리스트가 hybrid union 의 절반으로 사용됨 (장애 격리).
- **안전 기본값**: 모든 외부 실패는 keyword 결과 또는 빈 list 로 수렴; 500 응답을 피한다.
## 10. 테스트 계획
- **단위 — SearchService**
- 캐시 hit → mapper/vector 미호출, 결과 동일.
- 캐시 miss + keyword 모드 → `keywordSearch` 1회, `attachChannels` 호출.
- semantic 모드 → vector 결과 K*3, dedup, 좌표 없는 식당 제외 검증.
- hybrid 중복 제거 순서 (kw 우선) 검증.
- vector 예외 → keyword 폴백.
- **단위 — VectorService**
- `buildChunks` 입력 누락 필드 (region/cuisine/foods 등) 가 출력에서 자연스럽게 생략.
- `saveRestaurantVectors` empty chunks → no-op.
- **통합 (Spring + Testcontainers Oracle 또는 in-memory mock)**
- `restaurant_vectors` 에 샘플 데이터 삽입 → `searchSimilar("족발", 10, 0.57)` 거리 정렬 검증.
- `keywordSearch` LIKE 매칭 + 채널 부착.
- **계약 테스트**
- `GET /api/search?q=&limit=200` → limit clamp 100.
- mode 오타 → default(keyword).
- **드라이런/모킹**: OCI Embed → 고정 vector 반환 stub. Oracle VECTOR 함수 모킹 어려움 → Testcontainers 23ai (free profile) 사용 권장.
- **인수조건 매핑**: AC1↔keyword 통합, AC2↔searchSimilar 통합, AC3↔hybrid 단위, AC4↔캐시 단위, AC5↔폴백 단위, AC6↔saveRestaurantVectors 통합.
## 11. 리스크 & 대안 검토
- **Oracle 23ai VECTOR (선택)**: DB 내장이라 별도 인프라 불필요. 대안: pgvector, Pinecone, Qdrant — 운영 부담↑. → 트레이드오프: 23ai 라이선스/리전 의존.
- **NamedParameterJdbcTemplate 직접 사용**: MyBatis 가 VECTOR 직렬화 미지원 → JDBC 가 가장 단순. 대안: TypeHandler 작성 (구현 비용). 현 단계에서 단일 메서드라 직접 JDBC 유지.
- **단일 chunk/식당**: 비용 절감 + 단순. 대안: 메뉴/리뷰/장르 분리 chunk → recall↑, 비용/저장↑. 후속 ADR 후보.
- **hybrid union 단순 합치기**: 가중치 랭킹 부재 → semantic 일치 식당이 뒤에 묻힘. 대안: RRF (Reciprocal Rank Fusion) 또는 distance/score 정규화 후 정렬.
- **maxDistance=0.57 하드코딩**: 운영 관측치 변동 시 코드 수정 필요. 대안: 환경변수/설정으로 빼기.
- **캐시 무효화**: 식당/링크 변경 시 `CacheService.flush()` 전체 플러시 (현재 정책) → 콜드스타트 비용. 대안: 키 prefix 별 무효화.
- **빈 쿼리 가드 부재**: 운영 사고 시 모든 식당 반환 → 응답 크기 폭발. 트레이드오프: 가드 추가 (저비용).
## 12. 미해결 질문 (Open Questions)
- 빈 쿼리/공백 쿼리는 400 반환할지, 인기 식당 fallback 으로 응답할지.
- LIKE 와일드카드(`%`/`_`) escape 정책.
- hybrid 모드 랭킹 알고리즘 (RRF 도입 여부, semantic 가중치).
- semantic 모드에서 좌표 없는 식당도 노출할지 (검색 결과 vs 지도 마커 분리).
- 임계 `maxDistance=0.57` 의 모니터링/튜닝 방법 (사용자 클릭률 로그 필요).
- `restaurant_vectors` 중복(같은 식당 여러 chunk) 정책 — 현재 `saveRestaurantVectors` 가 추가만 함, 재추출 시 누적될 가능성.
- 검색 결과에 `foods_mentioned`/거리/평점 동시 노출 방식 (#278 와 합의 필요).