Compare commits

..

1 Commits

Author SHA1 Message Date
joungmin
9c2dc9f43a fix(search): #293 검색/벡터 결함 7건 일괄 수정
- SearchController: q 빈값 가드 (HTTP 400) — '%%' LIKE 응답 폭발 차단
- SearchService:
  - keywordSearch: LIKE 와일드카드 escape (%, _, \\)
  - hybrid 모드: semantic 결과에도 attachChannels 호출 (이전: keyword만)
  - ObjectMapper/TypeReference static 재사용 (캐시 hit 경로 GC 압박 완화)
  - 알 수 없는 mode → warn 로그 + keyword fallback (이전: silent)
  - maxDistance를 @Value("${app.search.max-distance:0.57}")로 외부화
- SearchMapper.xml: LIKE 절에 ESCAPE '\\' 추가
- VectorService.searchSimilar: embeddings/first list null/empty 가드 (NPE 방지)
- application.yml: app.search.max-distance (env SEARCH_MAX_DISTANCE) 추가

후속 분리: batch insert + 테스트 (별도 후속 이슈)

Refs: #293
2026-06-15 14:01:59 +09:00
5 changed files with 49 additions and 15 deletions

View File

@@ -2,7 +2,9 @@ package com.tasteby.controller;
import com.tasteby.domain.Restaurant; import com.tasteby.domain.Restaurant;
import com.tasteby.service.SearchService; import com.tasteby.service.SearchService;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List; import java.util.List;
@@ -21,7 +23,12 @@ public class SearchController {
@RequestParam String q, @RequestParam String q,
@RequestParam(defaultValue = "keyword") String mode, @RequestParam(defaultValue = "keyword") String mode,
@RequestParam(defaultValue = "20") int limit) { @RequestParam(defaultValue = "20") int limit) {
// #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단
if (q == null || q.isBlank()) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다");
}
if (limit > 100) limit = 100; if (limit > 100) limit = 100;
return searchService.search(q, mode, limit); if (limit < 1) limit = 1;
return searchService.search(q.trim(), mode, limit);
} }
} }

View File

@@ -1,9 +1,12 @@
package com.tasteby.service; package com.tasteby.service;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.tasteby.domain.Restaurant; import com.tasteby.domain.Restaurant;
import com.tasteby.mapper.SearchMapper; import com.tasteby.mapper.SearchMapper;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.*; import java.util.*;
@@ -12,12 +15,17 @@ import java.util.*;
public class SearchService { public class SearchService {
private static final Logger log = LoggerFactory.getLogger(SearchService.class); private static final Logger log = LoggerFactory.getLogger(SearchService.class);
private static final ObjectMapper JSON = new ObjectMapper();
private static final TypeReference<List<Restaurant>> LIST_TYPE = new TypeReference<>() {};
private final SearchMapper searchMapper; private final SearchMapper searchMapper;
private final RestaurantService restaurantService; private final RestaurantService restaurantService;
private final VectorService vectorService; private final VectorService vectorService;
private final CacheService cache; private final CacheService cache;
@Value("${app.search.max-distance:0.57}")
private double maxDistance;
public SearchService(SearchMapper searchMapper, public SearchService(SearchMapper searchMapper,
RestaurantService restaurantService, RestaurantService restaurantService,
VectorService vectorService, VectorService vectorService,
@@ -33,8 +41,8 @@ public class SearchService {
String cached = cache.getRaw(key); String cached = cache.getRaw(key);
if (cached != null) { if (cached != null) {
try { try {
var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); // #293 — ObjectMapper 재사용 (필드 static)
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {}); return JSON.readValue(cached, LIST_TYPE);
} catch (Exception ignored) {} } catch (Exception ignored) {}
} }
@@ -44,13 +52,20 @@ public class SearchService {
case "hybrid" -> { case "hybrid" -> {
var kw = keywordSearch(q, limit); var kw = keywordSearch(q, limit);
var sem = semanticSearch(q, limit); var sem = semanticSearch(q, limit);
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
if (!sem.isEmpty()) attachChannels(sem);
Set<String> seen = new HashSet<>(); Set<String> seen = new HashSet<>();
var merged = new ArrayList<Restaurant>(); var merged = new ArrayList<Restaurant>();
for (var r : kw) { if (seen.add(r.getId())) merged.add(r); } for (var r : kw) { if (seen.add(r.getId())) merged.add(r); }
for (var r : sem) { if (seen.add(r.getId())) merged.add(r); } for (var r : sem) { if (seen.add(r.getId())) merged.add(r); }
result = merged.size() > limit ? merged.subList(0, limit) : merged; result = merged.size() > limit ? merged.subList(0, limit) : merged;
} }
default -> result = keywordSearch(q, limit); case "keyword" -> result = keywordSearch(q, limit);
default -> {
// #293 — 알 수 없는 mode는 silent fallback 대신 경고 로그
log.warn("Unknown search mode '{}', falling back to keyword", mode);
result = keywordSearch(q, limit);
}
} }
cache.set(key, result); cache.set(key, result);
@@ -58,7 +73,10 @@ public class SearchService {
} }
private List<Restaurant> keywordSearch(String q, int limit) { private List<Restaurant> keywordSearch(String q, int limit) {
String pattern = "%" + q + "%"; // #293 — LIKE 와일드카드 escape: 사용자 입력의 %, _, \ 를 리터럴로 처리.
// SQL에서는 ESCAPE '\\' 절을 사용 (SearchMapper.xml).
String escaped = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
String pattern = "%" + escaped + "%";
List<Restaurant> results = searchMapper.keywordSearch(pattern, limit); List<Restaurant> results = searchMapper.keywordSearch(pattern, limit);
if (!results.isEmpty()) { if (!results.isEmpty()) {
attachChannels(results); attachChannels(results);
@@ -68,7 +86,7 @@ public class SearchService {
private List<Restaurant> semanticSearch(String q, int limit) { private List<Restaurant> semanticSearch(String q, int limit) {
try { try {
var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), 0.57); var similar = vectorService.searchSimilar(q, Math.max(30, limit * 3), maxDistance);
if (similar.isEmpty()) return List.of(); if (similar.isEmpty()) return List.of();
Set<String> seen = new LinkedHashSet<>(); Set<String> seen = new LinkedHashSet<>();

View File

@@ -27,12 +27,15 @@ public class VectorService {
*/ */
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) { public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
List<List<Double>> embeddings = genAi.embedTexts(List.of(query)); List<List<Double>> embeddings = genAi.embedTexts(List.of(query));
if (embeddings.isEmpty()) return List.of(); // #293 — embeddings 빈/null 가드 (NPE/IndexOutOfBoundsException 방지)
if (embeddings == null || embeddings.isEmpty()) return List.of();
List<Double> first = embeddings.getFirst();
if (first == null || first.isEmpty()) return List.of();
// Convert to float array for Oracle VECTOR type // Convert to float array for Oracle VECTOR type
float[] queryVec = new float[embeddings.getFirst().size()]; float[] queryVec = new float[first.size()];
for (int i = 0; i < queryVec.length; i++) { for (int i = 0; i < queryVec.length; i++) {
queryVec[i] = embeddings.getFirst().get(i).floatValue(); queryVec[i] = first.get(i).floatValue();
} }
String sql = """ String sql = """

View File

@@ -59,6 +59,11 @@ app:
cache: cache:
ttl-seconds: 600 ttl-seconds: 600
search:
# #293 — 벡터 검색 cosine distance 임계값 (0.0=완전일치, 1.0=직교).
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
max-distance: ${SEARCH_MAX_DISTANCE:0.57}
daemon: daemon:
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로 # 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다. # dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.

View File

@@ -30,12 +30,13 @@
JOIN video_restaurants vr ON vr.restaurant_id = r.id JOIN video_restaurants vr ON vr.restaurant_id = r.id
JOIN videos v ON v.id = vr.video_id JOIN videos v ON v.id = vr.video_id
WHERE r.latitude IS NOT NULL WHERE r.latitude IS NOT NULL
AND (UPPER(r.name) LIKE UPPER(#{query}) <!-- #293 — ESCAPE 절로 사용자 입력의 %, _ 와일드카드 의도 우회 차단 -->
OR UPPER(r.address) LIKE UPPER(#{query}) AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(r.region) LIKE UPPER(#{query}) OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(v.title) LIKE UPPER(#{query})) OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\'
OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\')
FETCH FIRST #{limit} ROWS ONLY FETCH FIRST #{limit} ROWS ONLY
</select> </select>