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 (백로그)
설계서: 백엔드 - 검색/벡터 추천 (#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.
- Oracle 23ai VECTOR 타입 +
- 제약:
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):id32자 UUID(hex upper),restaurant_idFK,chunk_textCLOB,embeddingVECTOR.- 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 | 없음 | 단순 |
SearchService.search |
모드 분기 + 캐싱 | List<Restaurant> 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<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. 흐름 / 알고리즘
- 요청 진입: Controller 가
limit > 100이면 100 으로 clamp →SearchService.search(q, mode, limit)호출. - 캐시 조회: key=
search:q=<q>:m=<mode>:l=<limit>→cache.getRawhit 시 Jackson 역직렬화 후 즉시 반환. 직렬화 예외는 무시하고 본 로직 진행. - 모드 분기:
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 결과에만 적용됨.)
- 벡터 검색 내부 (
searchSimilar): a)OciGenAiService.embedTexts([query])→List<List<Double>>→ 첫 임베딩을float[]로 변환. b) 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. - 캐시 저장: 최종 결과를
cache.set(key, result)(Jackson JSON 직렬화, CacheService 가 TTL 관리 — #276). - 벡터 저장 (
saveRestaurantVectors): chunks 빈 리스트면 즉시 종료.embedTexts(chunks)후 chunks.size 만큼 반복하며 각 row 에 새 UUID + 변환된 float[] + chunk_text 를INSERT(단건 update). 예외는 호출자(PipelineService) 에서 warn 처리. - 채널 부착:
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→semanticSearchcatch → 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 의semanticSearchcatch → keyword 폴백. - 부분 결과: hybrid 에서 keyword 만 결과가 있고 semantic 이 throw 시, keyword 결과만 반환되도록 catch 위치는
semanticSearch내부 → 그대로 빈 리스트가 hybrid union 의 절반으로 사용됨 (장애 격리). - 안전 기본값: 모든 외부 실패는 keyword 결과 또는 빈 list 로 수렴; 500 응답을 피한다.
10. 테스트 계획
- 단위 — SearchService
- 캐시 hit → mapper/vector 미호출, 결과 동일.
- 캐시 miss + keyword 모드 →
keywordSearch1회,attachChannels호출. - semantic 모드 → vector 결과 K*3, dedup, 좌표 없는 식당 제외 검증.
- hybrid 중복 제거 순서 (kw 우선) 검증.
- vector 예외 → keyword 폴백.
- 단위 — VectorService
buildChunks입력 누락 필드 (region/cuisine/foods 등) 가 출력에서 자연스럽게 생략.saveRestaurantVectorsempty chunks → no-op.
- 통합 (Spring + Testcontainers Oracle 또는 in-memory mock)
restaurant_vectors에 샘플 데이터 삽입 →searchSimilar("족발", 10, 0.57)거리 정렬 검증.keywordSearchLIKE 매칭 + 채널 부착.
- 계약 테스트
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 와 합의 필요).