| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | |||
| 5 | 6 | 7 | 8 | 9 | 10 | 11 |
| 12 | 13 | 14 | 15 | 16 | 17 | 18 |
| 19 | 20 | 21 | 22 | 23 | 24 | 25 |
| 26 | 27 | 28 | 29 | 30 | 31 |
- 도메인설계
- 상태관리
- playwright
- Android
- 백엔드
- sqlite
- JWT
- python
- React
- php
- 인증
- 온디바이스AI
- electron
- Kotlin
- 메일자동화
- Vite
- next.js
- 보안
- FastAPI
- 아키텍처
- 마이그레이션
- 크롤링
- javascript
- 소셜로그인
- github actions
- typescript
- REST API
- 프론트엔드
- contextBridge
- 배포
- Today
- Total
HM소프트
교육 플랫폼 개발기 (2) — 한국어 RAG 챗봇과 법령 임포터의 검색 정밀도 본문

RAG 체감 품질은 LLM이 아니라 검색이 좌우한다. FULLTEXT를 버리고 조사를 떼는 한국어 토큰화와 IDF 가중 점수식을 직접 구현하며, relevance threshold로 잡음 청크를 거르고 거부 응답은 시작부만 검사한다. 법령 호·목 깊이 파싱과 단일/배열 정규화 버그까지 다룬다.
2편: 근거를 찾아주는 챗봇
1편에서 LMS 토대와 강사 권한 경계를 다뤘다. 이 플랫폼의 차별점은 AI 챗봇이다. 가맹·소상공인 산업 종사자가 "이 조항 위약금이 과한가요?" "정보공개서는 언제 줘야 하나요?" 같은 질문을 하면, 관련 법령과 강의 내용을 근거로 답한다. 즉 그냥 LLM에 던지는 게 아니라 RAG(Retrieval-Augmented Generation) — 먼저 지식베이스(KB)에서 근거를 찾아 LLM 컨텍스트에 넣고, "이 자료 안에서만 답하라"고 제약한다.
이 편의 핵심은 한 문장으로 요약된다. RAG의 체감 품질은 LLM이 아니라 "검색"이 결정한다. 무관한 조문을 근거로 넣으면 LLM은 그럴듯하게 헛소리를 한다. 그래서 이 글의 대부분은 한국어 검색 정밀도를 끌어올린 이야기다.

지식베이스 — 네 종류의 출처를 한 테이블에
KB는 kb_chunks 한 테이블에 모든 근거를 청크 단위로 담는다. source_type으로 출처 종류를 구분한다 — law(법령), vod(강의 자막/STT), material(교안), precedent(판례). 검색·LLM 컨텍스트에 적합한 크기로 쪼개기 위해, 긴 텍스트는 문장 경계를 보존하며 800자 한도로 청킹한다.
// 문장 경계를 보존하는 청킹
public static function chunk_text(string $text, int $size = 800): array {
if (mb_strlen($text) <= $size) return [$text];
// 문장부호(. 。 ! ?)와 줄바꿈을 경계로 분리 — 단어 중간에서 끊지 않는다
$sentences = preg_split('/(?<=[.。!?])\s+|(?<=\n)/u', $text, -1, PREG_SPLIT_NO_EMPTY);
$chunks = []; $current = '';
foreach ($sentences as $s) {
if (mb_strlen($current) + mb_strlen($s) > $size && $current !== '') {
$chunks[] = trim($current); $current = '';
}
$current .= $s . ' ';
}
if (trim($current) !== '') $chunks[] = trim($current);
return $chunks ?: [$text];
}
운영 환경에서 새 출처 타입(precedent)을 추가할 때, 이미 깔린 사이트의 source_type ENUM에는 그 값이 없었다. 1편에서 말한 "자가 회복 마이그레이션" 패턴을 여기서도 썼다 — SHOW COLUMNS로 현재 ENUM 정의를 읽어 'precedent'가 없을 때만 ALTER TABLE ... MODIFY로 기존 값을 전부 보존하며 새 값을 덧붙인다. 멱등하게.
검색 1 — 왜 FULLTEXT를 버렸나
처음엔 MariaDB의 FULLTEXT 인덱스(NATURAL LANGUAGE 모드)를 썼다. 결과는 실망스러웠다. 한국어는 띄어쓰기·조사 때문에 영어식 토큰화가 잘 안 맞는다. "위약금은"과 "위약금"이 다른 토큰으로 잡히고, "가맹사업법에서"는 통째로 한 단어가 된다.
그래서 검색을 직접 짰다. 첫 단계는 질문을 검색 토큰으로 분해하는 것. 구두점을 제거하고 공백으로 자른 뒤, 한국어 조사를 떼어낸 어간을 후보 토큰으로 추가한다. "위약금은" → 위약금은과 위약금 둘 다 토큰이 된다.

형태소 분석기를 붙이는 게 정석이지만, PHP·공용 호스팅 환경에서 외부 분석 엔진(예: 은전한닢) 의존을 더하는 건 배포 리스크가 컸다. 조사 휴리스틱은 완벽하진 않아도 "위약금은/위약금"을 같은 단어로 묶어주는 80% 효과를 의존성 0으로 얻는다. 도메인이 법령처럼 명사 중심이라 이 단순화가 잘 먹혔다.
검색 2 — IDF 가중치로 변별력 있는 단어를 띄우다
토큰 겹침만으로는 부족했다. 결정적인 문제는 "손해배상"처럼 여러 법령에 흔한 단어다. 사용자가 "가맹점 영업지역 손해배상"을 물으면, "손해배상"이 들어간 무관한 타 법령 조문이 상위에 떠버린다. 흔한 단어는 변별력이 없는데도 매칭 점수를 똑같이 받기 때문이다.
해법은 IDF(역문서빈도) 가중치다. 각 토큰이 몇 개 문서에 등장하는지(df)를 세서, 흔한 토큰은 가중치를 낮추고 드문(=변별력 높은) 토큰은 높인다. 구현은 두 단계다 — (1) 테이블을 1회 스캔해 토큰별 df를 구하고, (2) 그 df로 smoothed IDF 가중치를 만들어 점수식을 동적으로 조립한다.
// 토큰별 문서빈도(df) 1회 스캔
foreach ($tokens as $i => $t) {
$like = '%' . $wpdb->esc_like($t) . '%';
$df_parts[] = "SUM(CASE WHEN content LIKE %s OR title LIKE %s OR metadata LIKE %s THEN 1 ELSE 0 END) AS df{$i}";
$df_params[] = $like; $df_params[] = $like; $df_params[] = $like;
}
$df_row = $wpdb->get_row($wpdb->prepare("SELECT " . implode(', ', $df_parts) . " FROM $table" . $type_clause, ...$df_params), ARRAY_A);

설계에서 신경 쓴 두 가지가 있다. 첫째, 순수 IDF가 아니라 "기본 가중 + IDF 보너스"의 혼합 점수다. IDF만 쓰면 흔한 단어가 점수에 거의 기여하지 못해, 정작 그 단어가 핵심인 질문에서 재현율이 떨어진다. 그래서 모든 매칭에 기본점(재현율)을 주되, 드문 단어에 보너스(정밀도)를 얹었다. 둘째, 필드 가중치다. 제목(법령 조문 제목) 매칭이 본문보다, 본문이 메타(법령명)보다 높다.
여기서 미묘한 버그를 하나 잡았다. 점수식(SELECT)과 WHERE 절에 각각 LIKE 플레이스홀더가 들어가는데, 파라미터를 SQL 등장 순서(SELECT → WHERE → LIMIT)대로 정확히 합치지 않으면 바인딩이 어긋난다. $wpdb->prepare는 위치 기반이라, score용 파라미터 배열과 where용 파라미터 배열을 따로 모은 뒤 등장 순서대로 array_merge해야 한다. 이걸 놓쳐서 검색 결과가 미묘하게 틀어졌었다.

모든 토큰이 너무 흔하거나(전 문서 등장) 아예 부재하면 IDF로 변별이 안 된다. 이 경우엔 단순 존재 매칭으로 폴백해서, 적어도 빈손으로 돌아가지 않게 했다.

RAG 단계 — 잡음 청크를 컨텍스트에 넣지 않기
검색이 좋아도, 점수가 낮은 청크까지 LLM에 다 넣으면 도메인 밖 잡음이 답을 오염시킨다. 그래서 검색 결과에 relevance threshold를 적용했다. 절대 기준(약한 IDF 매칭 차단)과 상대 기준(top 점수의 25% 미만 제외)을 함께 쓴다.

시스템 프롬프트에서 LLM에 못을 박는다 — *"반드시 제공된 [참고 자료] 내에서만 답하라. 자료에 없으면 '학습 데이터에 포함되어 있지 않습니다'라고 답하라. 법령 인용 시 법률명·조항을 정확히 명시하라. 추측하지 마라."* 그리고 LLM이 그렇게 거부 응답을 시작하면, 출처를 0으로 강제한다. 답을 못 찾았는데 출처를 붙이면 사용자가 헷갈리기 때문이다.
여기서 한 가지 섬세한 판단이 있었다. 거부 신호는 응답 시작부 100자에서만 검사한다. 본문 중간의 "직접적인 규정은 없지만 유사하게..." 같은 안내는 정상 답변으로 보고 출처를 유지한다. 시작부만 보지 않으면 "없지만"이라는 단어 하나에 멀쩡한 답변의 출처가 다 날아간다.
// 거부 응답 감지는 시작부 100자만
$start = mb_substr($result['content'], 0, 100);
$starts_with_refusal = (
mb_strpos($start, '죄송하지만') !== false ||
mb_strpos($start, '학습 데이터에 포함') !== false ||
mb_strpos($start, '학습 데이터에 없') !== false
);
if ($starts_with_refusal) $sources = []; // 거부 시 출처 제거
미답변 질의 — 답 못 한 질문을 자산으로
RAG의 진짜 가치는 "못 답한 질문"에 있다. 챗봇이 거부한 질의를 그냥 버리지 않고, chat_history.is_unanswered 플래그로 기록했다. 같은 질문이 반복 거부되면 그건 KB에 그 주제가 비어 있다는 신호 — 다음에 어떤 법령·교안을 더 넣어야 하는지 알려주는 우선순위 목록이 된다. 운영자 화면에서 미답변 질의를 내용별로 묶어 횟수·최근 시각과 함께 보여준다.
// 미답변 질의 집계 — KB 보강 우선순위
public static function get_unanswered(int $limit = 50): array {
$sql = $wpdb->prepare(
"SELECT content AS question, COUNT(*) AS cnt, MAX(created_at) AS last_asked
FROM `{$table}` WHERE role = 'user' AND is_unanswered = 1
GROUP BY content ORDER BY last_asked DESC LIMIT %d", $limit);
return $wpdb->get_results($sql, ARRAY_A) ?: [];
}
법령 임포터 — 호(號)·목(目)까지 파고들기
KB의 법령은 국가 법령정보 Open API에서 임포트했다. 가맹사업법, 식품위생법, 근로기준법, 최저임금법 등을 조문 단위로 잘라 적재한다. 여기서 가장 까다로웠던 건 법령 문서의 계층 깊이였다. 한국 법령은 조 > 항 > 호 > 목으로 내려가는데, 정작 핵심 열거 내용("다음 각 호의...")은 호·목에 들어 있다. 처음엔 조문·항만 추출하고 호·목을 통째로 놓쳤다. 세부 조항에 실제 답이 있는데 말이다.

// 호·목 재귀 추출 — '다음 각 호'의 실제 내용
private static function append_subitems(array $parent, string &$content): void {
$hos = $parent['호'] ?? [];
if (isset($hos['호번호']) || isset($hos['호내용'])) $hos = [$hos]; // 단일 호 정규화
foreach ($hos as $ho) {
if (!empty($ho['호내용'])) $content .= self::strip_tags_keep_text($ho['호내용']) . "\n";
$moks = $ho['목'] ?? [];
if (isset($moks['목번호']) || isset($moks['목내용'])) $moks = [$moks];
foreach ($moks as $mok) {
if (!empty($mok['목내용'])) $content .= self::strip_tags_keep_text($mok['목내용']) . "\n";
}
}
}
이 임포터에서 잡은 파싱 버그가 두 개 더 있다. 첫째, 법령 API는 요소가 하나면 객체, 여럿이면 배열로 준다(항, 호, 목 전부). 단일 항을 배열로 감싸는 정규화를 빼먹으면 foreach가 키를 순회해버려 깨진다. 그래서 모든 계층에서 if (isset($x['번호'])) $x = [$x] 정규화를 일관되게 깔았다. 둘째, 조문 내용 자체가 문자열이 아니라 중첩 배열로 오는 경우가 있어서, 스칼라 값을 재귀로 펼쳐 문자열화하는 안전장치(array_walk_recursive)를 둬서 TypeError를 막았다.

"구조화된 문서를 파싱할 때 깊은 계층을 놓치지 말 것"과 "API가 단일/배열을 들쭉날쭉 주는 걸 항상 정규화할 것" — 이 두 교훈은 다른 프로젝트(판례 임포터, 3편)에서도 그대로 재사용됐다.
정리하며
- RAG 품질은 LLM이 아니라 검색이 좌우한다. FULLTEXT를 버리고 한국어 토큰화 + IDF 가중을 직접 구현했다.
- 토큰화는 조사를 떼어 어간을 추가하는 휴리스틱으로, 형태소 분석기 의존 없이 "위약금은/위약금"을 묶었다.
- 점수식은 기본 가중(재현율) + IDF 보너스(정밀도)의 혼합에 필드 가중치를 더했고,
prepare플레이스홀더 순서를 맞춰 바인딩 버그를 잡았다. - RAG는 relevance threshold로 잡음 청크를 거르고, 거부 응답은 시작부만 보고 출처를 비웠다.
- 미답변 질의를 플래그·집계해 KB 보강 우선순위로 삼았다.
- 법령 임포터는 호·목 깊이까지 추출하고, 단일/배열 정규화와 중첩 배열 방어로 파싱을 견고하게 했다.
다음 3편에서는 플랫폼이 매출과 신뢰를 만드는 부분 — 결제·영수증과 계약서 AI 검토·판례 검색을 다룬다.
'외주 개발일지' 카테고리의 다른 글
| 교육 플랫폼 개발기 (4) — 멘토링·알림 폴백·서버사이드 소셜 로그인·배포의 함정 (0) | 2026.06.23 |
|---|---|
| 교육 플랫폼 개발기 (3) — 결제 서버 검증, 영수증 PDF, 계약서 AI 검토 (0) | 2026.06.23 |
| 교육 플랫폼 개발기 (1) — WordPress 위에 LMS를 얹고 강사 권한 경계를 긋다 (0) | 2026.06.21 |
| 웹·앱 공용 풀스택 시스템 개발기 (2) — 알림(alerts) 시스템 설계 (1) | 2026.06.21 |
| 웹·앱 공용 풀스택 시스템 개발기 (1) — 공용 백엔드 API 재설계 (0) | 2026.06.21 |