feat(Suggestions): 일정 검색 기반 영어/한글 오타 판단
- 영어 입력 시 Meilisearch로 일정 검색하여 언어 판단 - 영어로 결과 있으면 영어, 한글 변환으로 결과 있으면 한글 오타 - 휴리스틱 대신 실제 데이터 기반 판단으로 정확도 향상 - fm1.24는 영어, vmfhaltmskdls는 프로미스나인으로 변환
This commit is contained in:
parent
3d7e8e1c2f
commit
9d04c0de91
1 changed files with 94 additions and 15 deletions
|
|
@ -1,6 +1,7 @@
|
||||||
import pool from "../lib/db.js";
|
import pool from "../lib/db.js";
|
||||||
import redis from "../lib/redis.js";
|
import redis from "../lib/redis.js";
|
||||||
import Inko from "inko";
|
import Inko from "inko";
|
||||||
|
import { searchSchedules } from "./meilisearch.js";
|
||||||
|
|
||||||
const inko = new Inko();
|
const inko = new Inko();
|
||||||
|
|
||||||
|
|
@ -9,24 +10,79 @@ const SUGGESTION_PREFIX = "suggestions:";
|
||||||
const CACHE_TTL = 86400; // 24시간
|
const CACHE_TTL = 86400; // 24시간
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 영문 자판으로 입력된 검색어인지 확인
|
* 영문만 포함된 검색어인지 확인
|
||||||
*/
|
*/
|
||||||
function isEnglishKeyboard(text) {
|
function isEnglishOnly(text) {
|
||||||
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
const englishChars = text.match(/[a-zA-Z]/g) || [];
|
||||||
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
const koreanChars = text.match(/[가-힣ㄱ-ㅎㅏ-ㅣ]/g) || [];
|
||||||
return englishChars.length > 0 && koreanChars.length === 0;
|
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 저장
|
* - search_queries 테이블에 Unigram 저장
|
||||||
* - word_pairs 테이블에 Bi-gram 저장
|
* - word_pairs 테이블에 Bi-gram 저장
|
||||||
* - Redis 캐시 업데이트
|
* - Redis 캐시 업데이트
|
||||||
|
* - 영어 입력 시 일정 검색으로 언어 판단
|
||||||
*/
|
*/
|
||||||
export async function saveSearchQuery(query) {
|
export async function saveSearchQuery(query) {
|
||||||
if (!query || query.trim().length === 0) return;
|
if (!query || query.trim().length === 0) return;
|
||||||
|
|
||||||
const normalizedQuery = query.trim().toLowerCase();
|
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 {
|
try {
|
||||||
// 1. Unigram 저장 (인기도)
|
// 1. Unigram 저장 (인기도)
|
||||||
|
|
@ -65,6 +121,7 @@ export async function saveSearchQuery(query) {
|
||||||
* 추천 검색어 조회
|
* 추천 검색어 조회
|
||||||
* - 입력이 공백으로 끝나면: 마지막 단어 기반 다음 단어 예측 (Bi-gram)
|
* - 입력이 공백으로 끝나면: 마지막 단어 기반 다음 단어 예측 (Bi-gram)
|
||||||
* - 그 외: prefix 매칭 (인기순)
|
* - 그 외: prefix 매칭 (인기순)
|
||||||
|
* - 영어 입력 시: 일정 검색으로 영어/한글 판단
|
||||||
*/
|
*/
|
||||||
export async function getSuggestions(query, limit = 10) {
|
export async function getSuggestions(query, limit = 10) {
|
||||||
if (!query || query.trim().length === 0) {
|
if (!query || query.trim().length === 0) {
|
||||||
|
|
@ -72,12 +129,13 @@ export async function getSuggestions(query, limit = 10) {
|
||||||
}
|
}
|
||||||
|
|
||||||
let searchQuery = query.toLowerCase();
|
let searchQuery = query.toLowerCase();
|
||||||
|
let koreanQuery = null;
|
||||||
|
|
||||||
// 영문 자판 -> 한글 변환 시도
|
// 영문만 있는 경우, 한글 변환도 같이 검색
|
||||||
if (isEnglishKeyboard(searchQuery)) {
|
if (isEnglishOnly(searchQuery)) {
|
||||||
const koreanQuery = inko.en2ko(searchQuery);
|
const converted = inko.en2ko(searchQuery);
|
||||||
if (koreanQuery !== searchQuery) {
|
if (converted !== searchQuery) {
|
||||||
searchQuery = koreanQuery;
|
koreanQuery = converted;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -93,8 +151,12 @@ export async function getSuggestions(query, limit = 10) {
|
||||||
const lastWord = words[words.length - 1];
|
const lastWord = words[words.length - 1];
|
||||||
return await getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
|
return await getNextWordSuggestions(lastWord, searchQuery.trim(), limit);
|
||||||
} else {
|
} else {
|
||||||
// prefix 매칭 (인기순)
|
// prefix 매칭 (인기순) - 영어 원본 + 한글 변환 둘 다
|
||||||
return await getPrefixSuggestions(searchQuery.trim(), limit);
|
return await getPrefixSuggestions(
|
||||||
|
searchQuery.trim(),
|
||||||
|
koreanQuery?.trim(),
|
||||||
|
limit
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[SearchSuggestion] 추천 조회 오류:", error.message);
|
console.error("[SearchSuggestion] 추천 조회 오류:", error.message);
|
||||||
|
|
@ -144,16 +206,33 @@ async function getNextWordSuggestions(lastWord, prefix, limit) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prefix 매칭 (인기순)
|
* Prefix 매칭 (인기순)
|
||||||
|
* @param {string} prefix - 원본 검색어 (영어 또는 한글)
|
||||||
|
* @param {string|null} koreanPrefix - 한글 변환된 검색어 (영어 입력의 경우)
|
||||||
|
* @param {number} limit - 결과 개수
|
||||||
*/
|
*/
|
||||||
async function getPrefixSuggestions(prefix, limit) {
|
async function getPrefixSuggestions(prefix, koreanPrefix, limit) {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
let rows;
|
||||||
|
|
||||||
|
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
|
||||||
|
LIMIT ?`,
|
||||||
|
[`${prefix}%`, `${koreanPrefix}%`, limit]
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// 단일 검색
|
||||||
|
[rows] = await pool.query(
|
||||||
`SELECT query FROM search_queries
|
`SELECT query FROM search_queries
|
||||||
WHERE query LIKE ?
|
WHERE query LIKE ?
|
||||||
ORDER BY count DESC, last_searched_at DESC
|
ORDER BY count DESC, last_searched_at DESC
|
||||||
LIMIT ?`,
|
LIMIT ?`,
|
||||||
[`${prefix}%`, limit]
|
[`${prefix}%`, limit]
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return rows.map((r) => r.query);
|
return rows.map((r) => r.query);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue