규칙 기반 엔진의 가장 큰 약점은 “게임 관련 글 추천해줘” 같은 질문을 처리하지 못한다는 것이다. knowledgeBase에는 게임 서비스 페이지 URL만 있고 블로그 글 목록은 없다. 새 글을 쓸 때마다 JS를 수정할 수도 없다. 자동으로 블로그 DB를 검색해서 관련 글을 찾아주는 기능이 필요했다.
RAG(Retrieval-Augmented Generation)라는 거창한 이름을 붙였지만, 실체는 WordPress 내장 검색 기능(WP_Query)을 활용하는 것이다. 벡터 DB? 임베딩? 그런 거 없다. MySQL의 LIKE 검색으로 충분하다. 글이 수백 개 수준이면.
검색 의도 감지 — 언제 검색을 할 것인가
모든 메시지를 DB 검색하면 안 된다. “안녕”이라고 했는데 “안녕”이 포함된 블로그 글을 검색하면 말이 안 된다. 검색 의도가 있는 메시지만 처리해야 한다.
프론트엔드(JS)에서 1차 필터링을 한다:
// findResponse() 내부
var searchIntents = ["검색", "찾아", "추천", "관련 글", "관련된", "최근 글",
"최신 글", "올라온 글", "업데이트", "뭐 있어", "볼만한", "읽을만한"];
for (var si = 0; si < searchIntents.length; si++) {
if (msg.indexOf(searchIntents[si]) !== -1 && AI_ENGINE) {
return null; // 규칙 기반 스킵 → 서버로 전달
}
}
"찾아줘", "추천해줘", "관련 글" 같은 표현이 있으면 규칙 기반 매칭을 건너뛰고 서버로 보낸다. 서버(PHP)에서 한번 더 검색 트리거를 확인한다:
function nalkkul_chatbot_search_formatted($user_msg) {
// 검색 의도 감지
$search_triggers = ['검색', '찾아', '추천', '관련', '최근 글', '최신 글',
'올라온 글', '업데이트', '볼만한', '읽을만한'];
$is_search = false;
foreach ($search_triggers as $t) {
if (mb_strpos($user_msg, $t) !== false) { $is_search = true; break; }
}
if (!$is_search) return null;
// ...
}
이중 체크를 하는 이유: JS에서 검색 의도를 감지해도 AI 엔진이 없으면 서버로 안 보낸다. 반대로 AI 엔진이 있어서 서버로 왔는데 검색 의도가 아닌 일반 대화일 수도 있다. PHP에서 다시 확인해야 불필요한 DB 쿼리를 피할 수 있다.
불용어 제거 — 검색 키워드 추출
"게임 관련 글 찾아줘"라는 메시지에서 실제로 검색해야 할 키워드는 "게임"뿐이다. "관련", "글", "찾아줘"는 불용어다. 이걸 그대로 WP_Query에 넣으면 엉뚱한 결과가 나온다.
// 불용어 제거
$stopwords = ['검색', '찾아줘', '찾아', '알려줘', '알려', '추천해줘', '추천',
'해줘', '해주세요', '보여줘', '관련', '있어', '있나', '뭐야', '뭐가',
'어떤', '최근', '오늘', '글', '좀', '에', '은', '는', '이', '가',
'을', '를', '의', '로', '와', '과', '도', '에서', '대한',
'볼만한', '읽을만한'];
$kw = $user_msg;
foreach ($stopwords as $sw) { $kw = str_replace($sw, ' ', $kw); }
$kw = trim(preg_replace('/\s+/', ' ', $kw));
if (mb_strlen($kw) < 2) $kw = $user_msg;
모든 불용어를 공백으로 치환하고, 연속 공백을 정리한다. 결과가 2글자 미만이면 원본 메시지를 그대로 사용한다. "글 찾아줘" 같은 경우 불용어만 남으니까 원본 메시지로 폴백하는 안전장치다.
불용어 목록을 보면 한국어 조사까지 포함되어 있다. "게임에 대한 글을 찾아줘"에서 "에", "대한", "을", "찾아줘"를 빼면 "게임 글"이 되고, 공백 정리하면 "게임"만 남는다. 정확히 의도한 동작이다.
3단계 검색 전략
키워드를 뽑았으면 세 단계로 검색한다. 한 단계에서 결과가 나오면 다음 단계를 건너뛴다.
1단계: 키워드로 블로그 글 검색
$posts = get_posts([
'post_type' => 'post',
'post_status' => 'publish',
's' => $kw,
'posts_per_page' => 5,
]);
WordPress의 s 파라미터는 제목과 내용을 모두 검색한다. 5개로 제한한 이유: 채팅 창에 10개씩 나열하면 스크롤이 너무 길어진다. 사용자가 원하는 건 TOP 5면 충분하다.
2단계: 카테고리명으로 매칭
키워드 검색에서 결과가 없으면 카테고리명으로 매칭을 시도한다. "웹개발" 이라고 했을 때 "웹개발" 카테고리의 최신 글 5개를 보여준다.
if (empty($posts)) {
$categories = get_categories(['hide_empty' => true]);
foreach ($categories as $cat) {
if (mb_strpos($user_msg, $cat->name) !== false ||
mb_strpos($kw, $cat->name) !== false) {
$posts = get_posts([
'post_type' => 'post',
'post_status' => 'publish',
'category' => $cat->term_id,
'posts_per_page' => 5,
'orderby' => 'date',
'order' => 'DESC',
]);
break;
}
}
}
$user_msg와 $kw 둘 다 검사하는 이유: 불용어 제거 후 카테고리명이 날아갈 수도 있다. 원본 메시지에서는 확실히 카테고리명이 있으니까 둘 다 본다.
3단계: 페이지 검색
블로그 글에서도 카테고리에서도 결과가 없으면 마지막으로 서비스 페이지를 검색한다.
if (empty($posts)) {
$posts = get_posts([
'post_type' => 'page',
'post_status' => 'publish',
's' => $kw,
'posts_per_page' => 5,
]);
}
if (empty($posts)) return null;
모든 단계에서 결과가 없으면 null을 리턴한다. 그러면 AI에게 넘어간다.
AI를 거치지 않고 직접 포맷 — 핵심 결정
처음에는 검색 결과를 AI 컨텍스트에 넣어서 AI가 자연어로 답변하게 했다. 이론적으로는 맞다. 그런데 실전에서 문제가 터졌다.
Ollama의 3b 모델이 검색 결과를 무시했다. 시스템 프롬프트에 "아래 검색 결과를 안내하세요"라고 넣어도, 모델이 자기 마음대로 대답을 지어냈다. URL을 빠뜨리거나 제목을 변형하는 경우도 있었다. 검색 결과의 정확도가 곧 사용자 신뢰인데, AI가 여기서 환각을 일으키면 의미가 없다.
해결책: 검색 결과가 있으면 AI를 완전히 건너뛰고 PHP에서 직접 포맷해서 리턴한다.
function nalkkul_chatbot_ai() {
// ... nonce, rate limit 체크 ...
// RAG: 사용자 질문과 관련된 콘텐츠 검색
$search_result = nalkkul_chatbot_search_formatted($user_msg);
if ($search_result !== null) {
// 검색 결과가 있으면: AI 호출 없이 직접 반환 (빠르고 정확)
nalkkul_chatbot_log($user_msg, $search_result, 'search');
wp_send_json_success(['reply' => $search_result]);
return;
}
// 검색 결과 없으면: AI에게 일반 대화 위임
// ...
}
이 결정 하나가 챗봇의 품질을 크게 올렸다. 검색 결과는 항상 정확하고, 응답 시간은 0.3초 이내다. AI에게 넘기면 5~15초 기다려야 하고, 정확도도 보장 못 한다.
검색 결과 포맷팅
검색 결과를 사람이 읽기 편한 형태로 포맷한다:
if (empty($posts)) return null;
// 포맷팅
$reply = "관련 글을 찾았어요! 📚\n\n";
$idx = 1;
foreach ($posts as $p) {
$title = $p->post_title;
$url = urldecode(wp_make_link_relative(get_permalink($p->ID)));
$reply .= "{$idx}. {$title}\n→ {$url}\n\n";
$idx++;
}
$reply .= "원하는 글을 클릭해서 확인해보세요! 😊";
return $reply;
urldecode(wp_make_link_relative(get_permalink($p->ID))) — 이 체인이 중요하다. 하나씩 풀어보면:
get_permalink(): 전체 URL 반환 (https://nalkkul.com/some-post/)wp_make_link_relative(): 상대 경로로 변환 (/some-post/)urldecode(): URL 인코딩된 한글을 원래대로 복원
3번이 없으면 한글 제목의 슬러그가 /%EC%9A%B4%EC%84%B8/ 같은 형태로 표시된다. 채팅 창에서 이런 URL은 못생기기도 하고 사용자가 뭔지 알아볼 수 없다. urldecode()를 쓰면 /운세/ 같이 깔끔하게 나온다.
AI 컨텍스트용 검색 함수 — 별도 구현
검색 결과가 없어서 AI에게 넘어갈 때도, 사이트 콘텐츠를 컨텍스트로 제공하면 더 나은 답변을 받을 수 있다. 그래서 별도의 검색 함수가 있다:
function nalkkul_chatbot_search_content($user_msg) {
$search_triggers = ['검색', '찾아', '알려', '추천', '관련', '있어', '있나',
'뭐야', '뭐가', '어디', '어떤', '최근', '글', '포스트', '기사'];
$is_search = false;
foreach ($search_triggers as $trigger) {
if (mb_strpos($user_msg, $trigger) !== false) {
$is_search = true;
break;
}
}
if (!$is_search) return '';
// ... 불용어 제거 ...
// 1. WP_Query로 검색
$search_results = new WP_Query([
'post_type' => 'post',
'post_status' => 'publish',
's' => $clean_msg,
'posts_per_page' => 3,
'orderby' => 'relevance',
]);
if ($search_results->have_posts()) {
$context .= "아래 검색 결과를 모두 번호와 URL 포함하여 안내하세요:\n";
$idx = 1;
while ($search_results->have_posts()) {
$search_results->the_post();
$title = get_the_title();
$url = urldecode(wp_make_link_relative(get_permalink()));
$context .= "{$idx}. {$title} → {$url}\n";
$idx++;
}
wp_reset_postdata();
}
// ...
return $context;
}
두 함수의 차이를 정리하면:
| 함수 | 용도 | 결과 수 | AI 호출 |
|---|---|---|---|
| nalkkul_chatbot_search_formatted() | 직접 응답 | 5개 | 안 함 |
| nalkkul_chatbot_search_content() | AI 컨텍스트 | 3개 | 함 |
search_formatted는 사용자에게 직접 보여줄 텍스트를 만든다. search_content는 AI 시스템 프롬프트에 넣을 컨텍스트를 만든다. 결과 수가 다른 건 AI 컨텍스트에 너무 많이 넣으면 토큰을 낭비하기 때문이다.
동적 컨텍스트 캐싱
사이트 통계나 최신 글 목록 같은 정보는 매 요청마다 DB를 쿼리하면 낭비다. 30분 캐시를 적용했다:
function nalkkul_chatbot_get_dynamic_context() {
$cached = get_transient('nalkkul_chatbot_context');
if (false !== $cached) return $cached;
$context = "현재 날짜: " . date('Y년 m월 d일') . "\n\n";
// 오늘 발행된 글
$today_posts = get_posts([
'post_type' => 'post',
'post_status' => 'publish',
'date_query' => [['after' => date('Y-m-d 00:00:00'),
'before' => date('Y-m-d 23:59:59'),
'inclusive' => true]],
'posts_per_page' => 10,
]);
// ... 최근 글, 사이트 통계, 카테고리별 글 수 ...
set_transient('nalkkul_chatbot_context', $context, 30 * MINUTE_IN_SECONDS);
return $context;
}
set_transient으로 30분 캐시. "오늘 새로 올라온 글 뭐야?"라고 물으면 최대 30분 전 데이터로 답변할 수 있다. 실시간성보다 서버 부하 감소를 우선했다. 블로그 특성상 30분 딜레이는 문제가 되지 않는다.
겪은 문제와 해결 과정
문제 1: mb_strpos vs strpos. 처음에 strpos()를 썼다가 한글 매칭이 안 되는 경우가 있었다. 멀티바이트 문자열에서는 mb_strpos()를 써야 한다. PHP에서 한글 처리할 때 기본 중의 기본인데 실수했다.
문제 2: URL 인코딩. get_permalink()가 한글 슬러그를 URL 인코딩해서 돌려준다. 채팅 창에 %EC%9A%B4%EC%84%B8가 표시되면 사용자가 읽을 수 없다. urldecode()를 추가해서 해결. 프론트엔드 JS에서 이 URL을 클릭하면 브라우저가 알아서 다시 인코딩해준다.
문제 3: AI가 검색 결과를 무시. 앞에서도 말했지만 이게 제일 큰 문제였다. 시스템 프롬프트에 "반드시 검색 결과를 포함하여 답변하세요"라고 적어도 작은 모델은 무시한다. 결국 검색 결과가 있으면 AI를 아예 호출하지 않는 구조로 바꿨다. 가장 확실한 해결책이었다.
문제 4: wp_reset_postdata() 누락. WP_Query를 쓴 후 wp_reset_postdata()를 안 하면 이후의 워드프레스 루프가 깨진다. AJAX 핸들러에서는 큰 문제가 안 되지만, 습관적으로 넣어둬야 한다.
다음 단계
검색으로 해결할 수 없는 질문이 있다. "오늘 날씨 어때?", "재미있는 거 추천해줘", "이 사이트 누가 만들었어?" 같은 열린 질문. 여기서 비로소 AI가 필요해진다. 다음 글에서는 Ollama를 이용해서 로컬 AI를 연동하는 과정을 다룬다. 비용 0원으로 AI 챗봇을 돌리는 방법이다.
이 시리즈의 다른 글
- (1) 설계와 규칙 기반 엔진
- (2) 플로팅 UI와 대화 인터페이스
- (3) RAG 검색 연동으로 블로그 글 찾기 — 현재 글
- (4) Ollama 로컬 AI 연동
- (5) 대화 로그, 문의 접수, 운영 팁