- kiwi-nlp 기반 한국어 형태소 분석기 추가 - 추천 검색어 API 구현 (/api/schedules/suggestions) - Prefix 매칭, Bi-gram 다음 단어 예측 - 초성 검색 지원, 영문→한글 자동 변환 (Inko) - 사용자 사전 추가 (멤버/그룹명, 프로그램명 등) - DB 테이블: suggestion_queries, suggestion_word_pairs, suggestion_chosung Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
296 lines
8.1 KiB
JavaScript
296 lines
8.1 KiB
JavaScript
/**
|
|
* 추천 검색어 서비스
|
|
* - 형태소 분석으로 명사 추출
|
|
* - 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: 5,
|
|
// Redis 키 prefix
|
|
REDIS_PREFIX: 'suggest:',
|
|
// 캐시 TTL (초)
|
|
CACHE_TTL: {
|
|
PREFIX: 3600, // prefix 검색: 1시간
|
|
BIGRAM: 86400, // bi-gram: 24시간
|
|
POPULAR: 600, // 인기 검색어: 10분
|
|
MAX_COUNT: 3600, // 최대 횟수: 1시간
|
|
},
|
|
};
|
|
|
|
/**
|
|
* 추천 검색어 서비스 클래스
|
|
*/
|
|
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 매칭
|
|
*/
|
|
async getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
|
try {
|
|
let rows;
|
|
|
|
if (koreanPrefix) {
|
|
// 영어 + 한글 변환 둘 다 검색
|
|
[rows] = await this.db.query(
|
|
`SELECT query, count FROM suggestion_queries
|
|
WHERE query LIKE ? OR query LIKE ?
|
|
ORDER BY count DESC, last_searched_at DESC
|
|
LIMIT ?`,
|
|
[`${prefix}%`, `${koreanPrefix}%`, limit]
|
|
);
|
|
} else {
|
|
[rows] = await this.db.query(
|
|
`SELECT query, count FROM suggestion_queries
|
|
WHERE query LIKE ?
|
|
ORDER BY count DESC, last_searched_at DESC
|
|
LIMIT ?`,
|
|
[`${prefix}%`, limit]
|
|
);
|
|
}
|
|
|
|
return rows.map(r => r.query);
|
|
} catch (error) {
|
|
console.error('[Suggestion] Prefix 조회 오류:', error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 초성 검색
|
|
*/
|
|
async getChosungSuggestions(chosung, limit) {
|
|
try {
|
|
const [rows] = await this.db.query(
|
|
`SELECT word, count FROM suggestion_chosung
|
|
WHERE chosung LIKE ?
|
|
ORDER BY count DESC
|
|
LIMIT ?`,
|
|
[`${chosung}%`, limit]
|
|
);
|
|
|
|
return rows.map(r => r.word);
|
|
} catch (error) {
|
|
console.error('[Suggestion] 초성 검색 오류:', error.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 인기 검색어 조회
|
|
*/
|
|
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, count FROM suggestion_queries
|
|
ORDER BY count DESC
|
|
LIMIT ?`,
|
|
[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;
|