From 02fe9314e42b604bf58849916652e81ffefd5781 Mon Sep 17 00:00:00 2001 From: caadiq Date: Tue, 13 Jan 2026 22:41:15 +0900 Subject: [PATCH] =?UTF-8?q?fix(suggestions):=20=EB=8F=99=EC=A0=81=20?= =?UTF-8?q?=EC=9E=84=EA=B3=84=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EC=B2=9C=20=EA=B2=80=EC=83=89=EC=96=B4=20=ED=95=84=ED=84=B0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 최대 검색 횟수의 1% 또는 최소 10회 중 더 큰 값 적용 - GREATEST(MAX(count) * 0.01, 10) 사용 - 데이터가 적을 때도 오타 필터링 가능 Co-Authored-By: Claude Opus 4.5 --- backend/services/suggestions.js | 63 ++++++++++++++------------------- 1 file changed, 27 insertions(+), 36 deletions(-) diff --git a/backend/services/suggestions.js b/backend/services/suggestions.js index ba53c96..258eeed 100644 --- a/backend/services/suggestions.js +++ b/backend/services/suggestions.js @@ -9,6 +9,12 @@ const inko = new Inko(); const SUGGESTION_PREFIX = "suggestions:"; const CACHE_TTL = 86400; // 24시간 +// 추천 검색어로 노출되기 위한 최소 비율 (최대 검색 횟수 대비) +// 예: 0.01 = 최대 검색 횟수의 1% 이상만 노출 +const MIN_COUNT_RATIO = 0.01; +// 최소 임계값 (데이터가 적을 때 오타 방지) +const MIN_COUNT_FLOOR = 10; + /** * 영문만 포함된 검색어인지 확인 */ @@ -166,38 +172,21 @@ export async function getSuggestions(query, limit = 10) { /** * 다음 단어 예측 (Bi-gram 기반) + * 동적 임계값을 사용하므로 Redis 캐시를 사용하지 않음 */ async function getNextWordSuggestions(lastWord, prefix, limit) { try { - // 1. Redis 캐시 확인 - const cacheKey = `${SUGGESTION_PREFIX}${lastWord}`; - let nextWords = await redis.zrevrange(cacheKey, 0, limit - 1); + const [rows] = await pool.query( + `SELECT word2, count FROM word_pairs + WHERE word1 = ? + AND count >= GREATEST((SELECT MAX(count) * ? FROM word_pairs), ?) + ORDER BY count DESC + LIMIT ?`, + [lastWord, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit] + ); - // 2. 캐시 미스 시 DB 조회 후 Redis 채우기 - if (nextWords.length === 0) { - const [rows] = await pool.query( - `SELECT word2, count FROM word_pairs - WHERE word1 = ? - ORDER BY count DESC - LIMIT ?`, - [lastWord, limit * 2] // 여유있게 가져오기 - ); - - if (rows.length > 0) { - // Redis에 캐싱 - const multi = redis.multi(); - for (const row of rows) { - multi.zadd(cacheKey, row.count, row.word2); - } - multi.expire(cacheKey, CACHE_TTL); - await multi.exec(); - - nextWords = rows.map((r) => r.word2); - } - } - - // 3. prefix + 다음 단어 조합으로 반환 - return nextWords.slice(0, limit).map((word) => `${prefix} ${word}`); + // prefix + 다음 단어 조합으로 반환 + return rows.map((r) => `${prefix} ${r.word2}`); } catch (error) { console.error("[SearchSuggestion] Bi-gram 조회 오류:", error.message); return []; @@ -217,20 +206,22 @@ async function getPrefixSuggestions(prefix, koreanPrefix, limit) { if (koreanPrefix) { // 영어 원본과 한글 변환 둘 다 검색 [rows] = await pool.query( - `SELECT query FROM search_queries - WHERE query LIKE ? OR query LIKE ? - ORDER BY count DESC, last_searched_at DESC + `SELECT query FROM search_queries + WHERE (query LIKE ? OR query LIKE ?) + AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?) + ORDER BY count DESC, last_searched_at DESC LIMIT ?`, - [`${prefix}%`, `${koreanPrefix}%`, limit] + [`${prefix}%`, `${koreanPrefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit] ); } else { // 단일 검색 [rows] = await pool.query( - `SELECT query FROM search_queries - WHERE query LIKE ? - ORDER BY count DESC, last_searched_at DESC + `SELECT query FROM search_queries + WHERE query LIKE ? + AND count >= GREATEST((SELECT MAX(count) * ? FROM search_queries), ?) + ORDER BY count DESC, last_searched_at DESC LIMIT ?`, - [`${prefix}%`, limit] + [`${prefix}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit] ); }