fromis_9/backend/src/services/suggestions/index.js

303 lines
8.7 KiB
JavaScript
Raw Normal View History

/**
* 추천 검색어 서비스
* - 형태소 분석으로 명사 추출
* - Bi-gram 기반 다음 단어 예측
* - 초성 검색 지원
* - 영어 오타 감지 (Inko)
*/
import Inko from 'inko';
import { extractNouns, initMorpheme, isReady } from './morpheme.js';
import { getChosung, isChosungOnly, isChosungMatch } from './chosung.js';
const inko = new Inko();
// 설정
const CONFIG = {
// 추천 검색어 최소 검색 횟수 비율 (최대 대비)
MIN_COUNT_RATIO: 0.01,
// 최소 임계값 (데이터 적을 때 오타 방지)
MIN_COUNT_FLOOR: 10,
// Redis 키 prefix
REDIS_PREFIX: 'suggest:',
// 캐시 TTL (초)
CACHE_TTL: {
PREFIX: 3600, // prefix 검색: 1시간
BIGRAM: 86400, // bi-gram: 24시간
POPULAR: 600, // 인기 검색어: 10분
},
};
/**
* 추천 검색어 서비스 클래스
*/
export class SuggestionService {
constructor(db, redis) {
this.db = db;
this.redis = redis;
}
/**
* 서비스 초기화 (형태소 분석기 로드)
*/
async initialize() {
try {
await initMorpheme();
console.log('[Suggestion] 서비스 초기화 완료');
} catch (error) {
console.error('[Suggestion] 서비스 초기화 실패:', error.message);
}
}
/**
* 영문만 포함된 검색어인지 확인
*/
isEnglishOnly(text) {
const englishChars = text.match(/[a-zA-Z]/g) || [];
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
return englishChars.length > 0 && koreanChars.length === 0;
}
/**
* 영어 입력을 한글로 변환 (오타 감지)
*/
convertEnglishToKorean(text) {
const converted = inko.en2ko(text);
return converted !== text ? converted : null;
}
/**
* 검색어 저장 (검색 실행 호출)
* - 형태소 분석으로 명사 추출
* - Unigram + Bi-gram 저장
*/
async saveSearchQuery(query) {
if (!query || query.trim().length === 0) return;
let normalizedQuery = query.trim().toLowerCase();
// 영어 입력 → 한글 변환 시도
if (this.isEnglishOnly(normalizedQuery)) {
const korean = this.convertEnglishToKorean(normalizedQuery);
if (korean) {
console.log(`[Suggestion] 한글 변환: "${normalizedQuery}" → "${korean}"`);
normalizedQuery = korean;
}
}
try {
// 1. 전체 검색어 저장 (Unigram)
await this.db.query(
`INSERT INTO suggestion_queries (query, count)
VALUES (?, 1)
ON DUPLICATE KEY UPDATE count = count + 1, last_searched_at = CURRENT_TIMESTAMP`,
[normalizedQuery]
);
// 2. 형태소 분석으로 명사 추출
let nouns;
if (isReady()) {
nouns = await extractNouns(normalizedQuery);
} else {
// fallback: 공백 분리
nouns = normalizedQuery.split(/\s+/).filter(w => w.length > 0);
}
// 3. Bi-gram 저장 (명사 쌍)
for (let i = 0; i < nouns.length - 1; i++) {
const word1 = nouns[i].toLowerCase();
const word2 = nouns[i + 1].toLowerCase();
await this.db.query(
`INSERT INTO suggestion_word_pairs (word1, word2, count)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE count = count + 1`,
[word1, word2]
);
// Redis 캐시 업데이트
await this.redis.zincrby(`${CONFIG.REDIS_PREFIX}bigram:${word1}`, 1, word2);
}
// 4. 초성 인덱스 저장
for (const noun of nouns) {
const chosung = getChosung(noun);
if (chosung.length >= 2) {
await this.db.query(
`INSERT INTO suggestion_chosung (chosung, word, count)
VALUES (?, ?, 1)
ON DUPLICATE KEY UPDATE count = count + 1`,
[chosung, noun.toLowerCase()]
);
}
}
console.log(`[Suggestion] 저장: "${normalizedQuery}" → 명사: [${nouns.join(', ')}]`);
} catch (error) {
console.error('[Suggestion] 저장 오류:', error.message);
}
}
/**
* 추천 검색어 조회
*/
async getSuggestions(query, limit = 10) {
if (!query || query.trim().length === 0) {
return [];
}
let searchQuery = query.toLowerCase();
let koreanQuery = null;
// 영어 입력 → 한글 변환
if (this.isEnglishOnly(searchQuery)) {
koreanQuery = this.convertEnglishToKorean(searchQuery);
}
try {
// 초성 검색 모드
if (isChosungOnly(searchQuery.replace(/\s/g, ''))) {
return await this.getChosungSuggestions(searchQuery.replace(/\s/g, ''), limit);
}
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 this.getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
} else {
// Prefix 매칭
return await this.getPrefixSuggestions(searchQuery.trim(), koreanQuery?.trim(), limit);
}
} catch (error) {
console.error('[Suggestion] 조회 오류:', error.message);
return [];
}
}
/**
* 다음 단어 예측 (Bi-gram)
*/
async getNextWordSuggestions(lastWord, prefix, limit) {
try {
// Redis 캐시 확인
const cacheKey = `${CONFIG.REDIS_PREFIX}bigram:${lastWord}`;
const cached = await this.redis.zrevrange(cacheKey, 0, limit - 1);
if (cached && cached.length > 0) {
return cached.map(word => `${prefix} ${word}`);
}
// DB 조회
const [rows] = await this.db.query(
`SELECT word2, count FROM suggestion_word_pairs
WHERE word1 = ?
ORDER BY count DESC
LIMIT ?`,
[lastWord, limit]
);
return rows.map(r => `${prefix} ${r.word2}`);
} catch (error) {
console.error('[Suggestion] Bi-gram 조회 오류:', error.message);
return [];
}
}
/**
* Prefix 매칭
* - GREATEST() 동적 임계값 적용: MAX(count) * 1% 또는 최소 10
*/
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
try {
let rows;
if (koreanPrefix) {
// 영어 + 한글 변환 둘 다 검색
[rows] = await this.db.query(
`SELECT query FROM suggestion_queries
WHERE (query LIKE ? OR query LIKE ?)
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
ORDER BY count DESC, last_searched_at DESC
LIMIT ?`,
[`${prefix}%`, `${koreanPrefix}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
);
} else {
[rows] = await this.db.query(
`SELECT query FROM suggestion_queries
WHERE query LIKE ?
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
ORDER BY count DESC, last_searched_at DESC
LIMIT ?`,
[`${prefix}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
);
}
return rows.map(r => r.query);
} catch (error) {
console.error('[Suggestion] Prefix 조회 오류:', error.message);
return [];
}
}
/**
* 초성 검색
* - GREATEST() 동적 임계값 적용
*/
async getChosungSuggestions(chosung, limit) {
try {
const [rows] = await this.db.query(
`SELECT word FROM suggestion_chosung
WHERE chosung LIKE ?
AND count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_chosung), ?)
ORDER BY count DESC
LIMIT ?`,
[`${chosung}%`, CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
);
return rows.map(r => r.word);
} catch (error) {
console.error('[Suggestion] 초성 검색 오류:', error.message);
return [];
}
}
/**
* 인기 검색어 조회
* - GREATEST() 동적 임계값 적용
*/
async getPopularQueries(limit = 10) {
try {
// Redis 캐시 확인
const cacheKey = `${CONFIG.REDIS_PREFIX}popular`;
const cached = await this.redis.get(cacheKey);
if (cached) {
return JSON.parse(cached);
}
// DB 조회 (동적 임계값 이상만)
const [rows] = await this.db.query(
`SELECT query FROM suggestion_queries
WHERE count >= GREATEST((SELECT MAX(count) * ? FROM suggestion_queries), ?)
ORDER BY count DESC
LIMIT ?`,
[CONFIG.MIN_COUNT_RATIO, CONFIG.MIN_COUNT_FLOOR, limit]
);
const result = rows.map(r => r.query);
// 캐시 저장
await this.redis.setex(cacheKey, CONFIG.CACHE_TTL.POPULAR, JSON.stringify(result));
return result;
} catch (error) {
console.error('[Suggestion] 인기 검색어 조회 오류:', error.message);
return [];
}
}
}
export default SuggestionService;