Compare commits
3 Commits
v0.1.18
...
eb1eaa91a6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb1eaa91a6 | ||
|
|
9c2dc9f43a | ||
|
|
7779d5ddfd |
21
CHANGELOG.md
21
CHANGELOG.md
@@ -6,6 +6,27 @@
|
||||
|
||||
## 2026-06-15
|
||||
|
||||
### 🔍 #293 검색/벡터 결함 7건 (v0.1.19)
|
||||
- SearchController: q 빈값 400 가드 (`%%` 응답 폭발 차단)
|
||||
- SearchService: LIKE 와일드카드 escape (%, _, \), hybrid 모드에서 sem 결과에도 채널 부착
|
||||
- SearchService: ObjectMapper/TypeReference static 재사용, 알 수 없는 mode warn 로그
|
||||
- SearchService: maxDistance를 @Value("${app.search.max-distance:0.57}") 외부화 (env SEARCH_MAX_DISTANCE)
|
||||
- SearchMapper.xml: LIKE 절에 ESCAPE '\' 추가
|
||||
- VectorService: embeddings null/empty 가드 (NPE 차단)
|
||||
- 후속 분리: #331 (batch insert + 테스트)
|
||||
- Refs: #293 (close)
|
||||
|
||||
### 🛠 #304+#323 어드민 LLM 검증 UI + 공통 유틸 (v0.1.18)
|
||||
- 신규 frontend/src/lib/admin-utils.ts:
|
||||
- getAdminToken / authHeaders / consumeSseStream
|
||||
- api.ts: Restaurant 타입에 hidden/hidden_reason/verified_at + verify/setRestaurantHidden API 4개
|
||||
- RestaurantsPanel:
|
||||
- 헤더: "미검증 N건 + LLM 검증" 버튼
|
||||
- 테이블: 검증 컬럼 (숨김/OK/미검증 배지 + 클릭으로 토글)
|
||||
- colSpan 7로 수정
|
||||
- 후속 분리: #329 (admin 전체 파일 분리 + localStorage/SSE 11+곳 통일)
|
||||
- Refs: #304 #323 #322 (close)
|
||||
|
||||
### 🔧 #291+#292 백엔드 결함 일괄 수정 (v0.1.17)
|
||||
- ExtractorService: transcript null/blank 가드 (NPE 방지)
|
||||
- PipelineService.processExtract: 진입 시 status='processing' 명시 전이 (SSE/사용자 가시성)
|
||||
|
||||
@@ -2,7 +2,9 @@ package com.tasteby.controller;
|
||||
|
||||
import com.tasteby.domain.Restaurant;
|
||||
import com.tasteby.service.SearchService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@@ -21,7 +23,12 @@ public class SearchController {
|
||||
@RequestParam String q,
|
||||
@RequestParam(defaultValue = "keyword") String mode,
|
||||
@RequestParam(defaultValue = "20") int limit) {
|
||||
// #293 — q 빈값 가드: '%%' LIKE로 응답 폭발 차단
|
||||
if (q == null || q.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "검색어가 필요합니다");
|
||||
}
|
||||
if (limit > 100) limit = 100;
|
||||
return searchService.search(q, mode, limit);
|
||||
if (limit < 1) limit = 1;
|
||||
return searchService.search(q.trim(), mode, limit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
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.mapper.SearchMapper;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.*;
|
||||
@@ -12,12 +15,17 @@ import java.util.*;
|
||||
public class SearchService {
|
||||
|
||||
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 RestaurantService restaurantService;
|
||||
private final VectorService vectorService;
|
||||
private final CacheService cache;
|
||||
|
||||
@Value("${app.search.max-distance:0.57}")
|
||||
private double maxDistance;
|
||||
|
||||
public SearchService(SearchMapper searchMapper,
|
||||
RestaurantService restaurantService,
|
||||
VectorService vectorService,
|
||||
@@ -33,8 +41,8 @@ public class SearchService {
|
||||
String cached = cache.getRaw(key);
|
||||
if (cached != null) {
|
||||
try {
|
||||
var mapper = new com.fasterxml.jackson.databind.ObjectMapper();
|
||||
return mapper.readValue(cached, new com.fasterxml.jackson.core.type.TypeReference<List<Restaurant>>() {});
|
||||
// #293 — ObjectMapper 재사용 (필드 static)
|
||||
return JSON.readValue(cached, LIST_TYPE);
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
@@ -44,13 +52,20 @@ public class SearchService {
|
||||
case "hybrid" -> {
|
||||
var kw = keywordSearch(q, limit);
|
||||
var sem = semanticSearch(q, limit);
|
||||
// #293 — semantic 결과에도 channels 부착 (이전: keyword에만 부착되어 hybrid에서 sem 결과는 채널 누락)
|
||||
if (!sem.isEmpty()) attachChannels(sem);
|
||||
Set<String> seen = new HashSet<>();
|
||||
var merged = new ArrayList<Restaurant>();
|
||||
for (var r : kw) { 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;
|
||||
}
|
||||
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);
|
||||
@@ -58,7 +73,10 @@ public class SearchService {
|
||||
}
|
||||
|
||||
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);
|
||||
if (!results.isEmpty()) {
|
||||
attachChannels(results);
|
||||
@@ -68,7 +86,7 @@ public class SearchService {
|
||||
|
||||
private List<Restaurant> semanticSearch(String q, int limit) {
|
||||
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();
|
||||
|
||||
Set<String> seen = new LinkedHashSet<>();
|
||||
|
||||
@@ -27,12 +27,15 @@ public class VectorService {
|
||||
*/
|
||||
public List<Map<String, Object>> searchSimilar(String query, int topK, double maxDistance) {
|
||||
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
|
||||
float[] queryVec = new float[embeddings.getFirst().size()];
|
||||
float[] queryVec = new float[first.size()];
|
||||
for (int i = 0; i < queryVec.length; i++) {
|
||||
queryVec[i] = embeddings.getFirst().get(i).floatValue();
|
||||
queryVec[i] = first.get(i).floatValue();
|
||||
}
|
||||
|
||||
String sql = """
|
||||
|
||||
@@ -59,6 +59,11 @@ app:
|
||||
cache:
|
||||
ttl-seconds: 600
|
||||
|
||||
search:
|
||||
# #293 — 벡터 검색 cosine distance 임계값 (0.0=완전일치, 1.0=직교).
|
||||
# 0.57은 cohere embed-v4 한국어 시맨틱 적합도 기준 경험값.
|
||||
max-distance: ${SEARCH_MAX_DISTANCE:0.57}
|
||||
|
||||
daemon:
|
||||
# 인스턴스 차원 스케줄러 활성화. dev/prod가 같은 DB를 공유하므로
|
||||
# dev .env에 DAEMON_ENABLED=false를 설정해 dev 폴링을 끄고 prod만 동작시킨다.
|
||||
|
||||
@@ -30,12 +30,13 @@
|
||||
JOIN video_restaurants vr ON vr.restaurant_id = r.id
|
||||
JOIN videos v ON v.id = vr.video_id
|
||||
WHERE r.latitude IS NOT NULL
|
||||
AND (UPPER(r.name) LIKE UPPER(#{query})
|
||||
OR UPPER(r.address) LIKE UPPER(#{query})
|
||||
OR UPPER(r.region) LIKE UPPER(#{query})
|
||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query})
|
||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query})
|
||||
OR UPPER(v.title) LIKE UPPER(#{query}))
|
||||
<!-- #293 — ESCAPE 절로 사용자 입력의 %, _ 와일드카드 의도 우회 차단 -->
|
||||
AND (UPPER(r.name) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(r.address) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(r.region) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(r.cuisine_type) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(vr.foods_mentioned) LIKE UPPER(#{query}) ESCAPE '\'
|
||||
OR UPPER(v.title) LIKE UPPER(#{query}) ESCAPE '\')
|
||||
FETCH FIRST #{limit} ROWS ONLY
|
||||
</select>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user