# 설계서: 백엔드 - 검색/벡터 추천 (#271) > **상태**: Approved > **작성**: [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 └──────────┬───────────────────────┘ 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` — 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 본문 예시: ``` 식당: 지역: 음식 종류: 메뉴: a, b, c 평가: 가격대: 영상: ``` - **캐시 키**: `search:q=:m=:l=` (`CacheService.makeKey`). 값은 `List` Jackson JSON. - **검증 규칙**: - `q` 가 비어 있으면 Controller 가드(현재 미존재) — 빈 문자열은 `%%` LIKE 로 모든 식당 매칭되므로 클라이언트에서 빈 쿼리 차단 권장. (Open Question 참조) - limit > 100 → 100 으로 clamp. - semantic 결과는 `latitude != null` 인 식당만 — 지도 노출 가능한 후보로 한정. ## 7. 함수 명세 (Function Specs) | 함수 | 책임(1줄) | 시그니처(잠정) | 입력 | 출력 | 에러/실패 | 복잡? | |------|-----------|----------------|------|------|-----------|-------| | `SearchController.search` | REST 엔드포인트 + limit clamp | `List search(q, mode, limit)` | q, mode, limit | List | 없음 | 단순 | | `SearchService.search` | 모드 분기 + 캐싱 | `List search(q, mode, limit)` | 동일 | 동일 | 캐시 직렬화 catch | **복잡** | | `SearchService.keywordSearch` (private) | LIKE 검색 + 채널 부착 | `(q, limit)` | %q% pattern | List | 빈 결과 빈 list | 단순 | | `SearchService.semanticSearch` (private) | 벡터 검색 + 식당 조회 | `(q, limit)` | q, limit | List | 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` (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`(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=:m=:l=` → `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>` → 첫 임베딩을 `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>` 구성 → 각 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 와 합의 필요).