fix(suggestions): 동적 임계값으로 추천 검색어 필터링

- 최대 검색 횟수의 1% 또는 최소 10회 중 더 큰 값 적용
- GREATEST(MAX(count) * 0.01, 10) 사용
- 데이터가 적을 때도 오타 필터링 가능

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
caadiq 2026-01-13 22:41:15 +09:00
parent 88f15a3ec1
commit 02fe9314e4

View file

@ -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]
);
}