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