fromis_9/backend/services/suggestions.js
caadiq 02fe9314e4 fix(suggestions): 동적 임계값으로 추천 검색어 필터링
- 최대 검색 횟수의 1% 또는 최소 10회 중 더 큰 값 적용
- GREATEST(MAX(count) * 0.01, 10) 사용
- 데이터가 적을 때도 오타 필터링 가능

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-13 22:49:35 +09:00

248 lines
7.5 KiB
JavaScript

import pool from "../lib/db.js";
import redis from "../lib/redis.js";
import Inko from "inko";
import { searchSchedules } from "./meilisearch.js";
const inko = new Inko();
// Redis 키 prefix
const SUGGESTION_PREFIX = "suggestions:";
const CACHE_TTL = 86400; // 24시간
// 추천 검색어로 노출되기 위한 최소 비율 (최대 검색 횟수 대비)
// 예: 0.01 = 최대 검색 횟수의 1% 이상만 노출
const MIN_COUNT_RATIO = 0.01;
// 최소 임계값 (데이터가 적을 때 오타 방지)
const MIN_COUNT_FLOOR = 10;
/**
* 영문만 포함된 검색어인지 확인
*/
function isEnglishOnly(text) {
const englishChars = text.match(/[a-zA-Z]/g) || [];
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
return englishChars.length > 0 && koreanChars.length === 0;
}
/**
* 일정 검색 결과가 있는지 확인 (Meilisearch)
*/
async function hasScheduleResults(query) {
try {
const result = await searchSchedules(query, { limit: 1 });
return result.hits.length > 0;
} catch (error) {
console.error("[SearchSuggestion] 검색 확인 오류:", error.message);
return false;
}
}
/**
* 영어 입력을 분석하여 실제 영어인지 한글 오타인지 판단
* 1. 영어로 일정 검색 → 결과 있으면 영어
* 2. 한글 변환 후 일정 검색 → 결과 있으면 한글
* 3. 둘 다 없으면 원본 유지
*/
async function resolveEnglishInput(query) {
const koreanQuery = inko.en2ko(query);
// 변환 결과가 같으면 변환 의미 없음
if (koreanQuery === query) {
return { resolved: query, type: "english" };
}
// 1. 영어로 검색
const hasEnglishResult = await hasScheduleResults(query);
if (hasEnglishResult) {
return { resolved: query, type: "english" };
}
// 2. 한글로 검색
const hasKoreanResult = await hasScheduleResults(koreanQuery);
if (hasKoreanResult) {
return { resolved: koreanQuery, type: "korean_typo" };
}
// 3. 둘 다 없으면 원본 유지
return { resolved: query, type: "unknown" };
}
/**
* 검색어 저장 (검색 실행 시 호출)
* - search_queries 테이블에 Unigram 저장
* - word_pairs 테이블에 Bi-gram 저장
* - Redis 캐시 업데이트
* - 영어 입력 시 일정 검색으로 언어 판단
*/
export async function saveSearchQuery(query) {
if (!query || query.trim().length === 0) return;
let normalizedQuery = query.trim().toLowerCase();
// 영문만 있는 경우 일정 검색으로 언어 판단
if (isEnglishOnly(normalizedQuery)) {
const { resolved, type } = await resolveEnglishInput(normalizedQuery);
if (type === "korean_typo") {
console.log(
`[SearchSuggestion] 한글 오타 감지: "${normalizedQuery}" → "${resolved}"`
);
}
normalizedQuery = resolved;
}
try {
// 1. Unigram 저장 (인기도)
await pool.query(
`INSERT INTO search_queries (query, count)
VALUES (?, 1)
ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`,
[normalizedQuery]
);
// 2. Bi-gram 저장 (다음 단어 예측)
const words = normalizedQuery.split(/\s+/).filter((w) => w.length > 0);
for (let i = 0; i < words.length - 1; i++) {
const word1 = words[i];
const word2 = words[i + 1];
// DB 저장
await pool.query(
`INSERT INTO word_pairs (word1, word2, count)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE count = count + 1`,
[word1, word2]
);
// Redis 캐시 업데이트 (Sorted Set)
await redis.zincrby(`${SUGGESTION_PREFIX}${word1}`, 1, word2);
}
console.log(`[SearchSuggestion] 검색어 저장: "${normalizedQuery}"`);
} catch (error) {
console.error("[SearchSuggestion] 검색어 저장 오류:", error.message);
}
}
/**
* 추천 검색어 조회
* - 입력이 공백으로 끝나면: 마지막 단어 기반 다음 단어 예측 (Bi-gram)
* - 그 외: prefix 매칭 (인기순)
* - 영어 입력 시: 일정 검색으로 영어/한글 판단
*/
export async function getSuggestions(query, limit = 10) {
if (!query || query.trim().length === 0) {
return [];
}
let searchQuery = query.toLowerCase();
let koreanQuery = null;
// 영문만 있는 경우, 한글 변환도 같이 검색
if (isEnglishOnly(searchQuery)) {
const converted = inko.en2ko(searchQuery);
if (converted !== searchQuery) {
koreanQuery = converted;
}
}
try {
const endsWithSpace = query.endsWith(" ");
const words = searchQuery
.trim()
.split(/\s+/)
.filter((w) => w.length > 0);
if (endsWithSpace && words.length > 0) {
// 다음 단어 예측 (Bi-gram)
const lastWord = words[words.length - 1];
return await getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
} else {
// prefix 매칭 (인기순) - 영어 원본 + 한글 변환 둘 다
return await getPrefixSuggestions(
searchQuery.trim(),
koreanQuery?.trim(),
limit
);
}
} catch (error) {
console.error("[SearchSuggestion] 추천 조회 오류:", error.message);
return [];
}
}
/**
* 다음 단어 예측 (Bi-gram 기반)
* 동적 임계값을 사용하므로 Redis 캐시를 사용하지 않음
*/
async function getNextWordSuggestions(lastWord, prefix, limit) {
try {
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]
);
// prefix + 다음 단어 조합으로 반환
return rows.map((r) => `${prefix} ${r.word2}`);
} catch (error) {
console.error("[SearchSuggestion] Bi-gram 조회 오류:", error.message);
return [];
}
}
/**
* Prefix 매칭 (인기순)
* @param {string} prefix - 원본 검색어 (영어 또는 한글)
* @param {string|null} koreanPrefix - 한글 변환된 검색어 (영어 입력의 경우)
* @param {number} limit - 결과 개수
*/
async function getPrefixSuggestions(prefix, koreanPrefix, limit) {
try {
let rows;
if (koreanPrefix) {
// 영어 원본과 한글 변환 둘 다 검색
[rows] = await pool.query(
`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}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
);
} else {
// 단일 검색
[rows] = await pool.query(
`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}%`, MIN_COUNT_RATIO, MIN_COUNT_FLOOR, limit]
);
}
return rows.map((r) => r.query);
} catch (error) {
console.error("[SearchSuggestion] Prefix 조회 오류:", error.message);
return [];
}
}
/**
* Redis 캐시 초기화 (필요시)
*/
export async function clearSuggestionCache() {
try {
const keys = await redis.keys(`${SUGGESTION_PREFIX}*`);
if (keys.length > 0) {
await redis.del(...keys);
console.log(`[SearchSuggestion] ${keys.length}개 캐시 삭제`);
}
} catch (error) {
console.error("[SearchSuggestion] 캐시 초기화 오류:", error.message);
}
}