2026-03-18
RAG 챗봇의 검색 품질을 개선한 이야기 — 벡터만으론 부족했다
이 블로그에는 AI 챗봇이 있다. 블로그 글과 포트폴리오를 학습한 RAG(Retrieval-Augmented Generation) 기반 챗봇인데, 검색어를 임베딩해서 pgvector로 관련 청크를 찾고, 그 청크를 LLM에 넘겨서 답변을 생성하는 구조다.
처음 만들었을 때는 벡터 검색만으로 충분하다고 생각했다. "이 블로그는 뭘로 만들었어?" 같은 질문에는 의미 기반 검색이 잘 먹혔다. 그런데 운영하면서 약점이 보이기 시작했다.
벡터만으로 부족한 순간
"Kafka 설정 방법 알려줘"라고 물어보면, 벡터 검색은 Kafka와 의미적으로 가까운 청크를 찾는다. 메시지 큐, 이벤트 스트리밍 같은 주제의 글이 올라온다. 나쁘지 않다.
그런데 문제는, "Kafka"라는 단어가 정확히 제목에 들어있는 글이 1등이 아닐 때가 있다는 거다. 벡터 유사도는 의미적 거리를 측정하기 때문에, 비슷한 주제의 다른 글이 더 높은 점수를 받을 수 있다. 사용자가 "Kafka"를 정확히 입력했으면, Kafka 글이 1등이어야 직관적이다.
이건 벡터 검색의 근본적인 한계다. 의미적 유사성과 키워드 정확 매칭은 다른 축이다.
해결: 벡터 우선 + BM25 리랭킹
블로그 검색에서는 이미 pg_search(ParadeDB)의 BM25를 도입해서 하이브리드 검색을 하고 있었다. 그런데 챗봇 RAG에는 그걸 적용하지 않았다. 이유가 있었다.
챗봇 질문은 "Spring Boot에서 성능 최적화하려면 어떻게 해요?" 같은 자연어다. 이걸 그대로 BM25에 넣으면 "에서", "하려면", "어떻게" 같은 불필요한 단어가 BM25 점수를 오염시킨다. 블로그 검색처럼 병렬로 돌리면 노이즈가 너무 크다.
그래서 벡터 우선 + BM25 리랭킹 방식을 택했다.
// rag.ts — 하이브리드 검색
const [postRows, portfolioRows] = await Promise.all([
// Posts: 벡터 20개 후보 + BM25 리랭킹
bm25Query
? sql`
WITH vector_posts AS (
SELECT c.chunk_text, c.post_id, p.title, p.slug,
1 - (c.embedding <=> embedding::vector) AS vector_score
FROM post_chunks c
JOIN posts p ON p.id = c.post_id
WHERE p.status = 'published'
ORDER BY c.embedding <=> embedding::vector
LIMIT 20
),
bm25_posts AS (
SELECT p.id, paradedb.score(p.id) AS bm25_score
FROM posts p
WHERE p.id @@@ paradedb.parse(bm25Query)
LIMIT 10
)
SELECT vp.*, coalesce(bp.bm25_score, 0) AS bm25_score
FROM vector_posts vp
LEFT JOIN bm25_posts bp ON bp.id = vp.post_id
`
: sql`...벡터만...`,
// Portfolio: 벡터만 (BM25 인덱스 없음)
sql`...벡터 검색...`
]);핵심은 이렇다.
벡터 검색으로 후보 20개를 먼저 뽑는다. 이게 1차 필터다.
같은 질문으로 BM25 검색도 돌린다. 1글자 단어는 필터링해서 조사 노이즈를 줄인다.
벡터 후보에 BM25 점수를 LEFT JOIN으로 붙인다. BM25에 매칭되면 점수가 올라가고, 안 되면 벡터 점수만으로 평가한다.
BM25가 "보너스" 역할만 하기 때문에 자연어 노이즈의 영향이 적다. 벡터가 이미 관련 있는 후보를 잡아놓은 상태에서, BM25는 "이 중에서 키워드가 정확히 매칭되는 글의 순위를 올려줘"라고 하는 거다.
청킹 오버랩 200자
기존 청킹은 1000자 단위로 단락 기반 분리만 했다. 오버랩이 없었다.
문제는 청크 경계에서 문맥이 잘린다는 거다. "이 방식의 장점은"이라는 문장이 앞 청크에 들어가고, 실제 장점 설명은 다음 청크에 들어가는 식이다. RAG가 앞 청크를 선택하면 장점 설명이 빠지고, 뒤 청크를 선택하면 뭘 설명하는 건지 맥락이 없다.
200자 단락 오버랩을 적용했다. 다음 청크를 만들 때 이전 청크의 마지막 단락들(200자 이내)을 포함시킨다. 단락 경계를 깨지 않으므로 자연스럽다.
// 오버랩 로직 핵심
const currentParagraphs = current.split(/
+/);
overlapText = '';
for (let i = currentParagraphs.length - 1; i >= 0; i--) {
const candidate = currentParagraphs[i] + (overlapText ? '
' + overlapText : '');
if (candidate.length <= overlap) {
overlapText = candidate;
} else {
// 단락이 overlap보다 길면 마지막 overlap자를 사용
if (!overlapText) overlapText = currentParagraphs[i].slice(-overlap);
break;
}
}
current = overlapText ? overlapText + '
' + para : para;적용 후 청크 수가 185개에서 207개로 약 12% 늘었다. 비용 증가는 미미한데 검색 품질은 체감할 수 있을 정도로 좋아졌다.
시스템 프롬프트 분리
RAG 프롬프트도 정리했다. 기존에는 buildSystemPrompt() 함수 안에 ~150줄의 지시문이 인라인으로 들어있었다. 톤 설정, 보안 규칙, 채용 담당자 대응 방법 등이 전부 한 함수 안에.
이걸 src/data/chatbot-prompt.ts로 분리했다. 불변 지시문(톤, 보안, 규칙)은 상수로, 동적 부분(이력서, 검색된 청크)만 함수에서 조립한다. 프롬프트 내용 자체는 바꾸지 않았고, 구조만 분리했다.
// chatbot-prompt.ts — 불변 지시문
export const SYSTEM_INSTRUCTIONS = `당신은 백엔드 & DevOps 엔지니어 조용진의 블로그를 학습한 AI 어시스턴트입니다.
...
`;
// rag.ts — 동적 조립
export function buildSystemPrompt(chunks: string[]) {
return `${SYSTEM_INSTRUCTIONS}
[이력서 정보]
${resumeText}
[검색된 관련 글/포트폴리오]
${chunksText}`;
}이제 프롬프트를 수정할 때 150줄짜리 함수를 읽을 필요 없이, 프롬프트 파일만 열면 된다.
배치 임베딩
임베딩 재생성도 개선했다. 기존에는 청크 하나당 OpenAI API를 한 번 호출했다. 207개 청크 = 207번 API 호출. 느렸다.
OpenAI의 embeddings API는 여러 텍스트를 한 번에 받을 수 있다. 50개씩 묶어서 보내면 207번이 5번으로 줄어든다.
export async function getEmbeddings(texts: string[]): Promise<number[][]> {
const res = await openai.embeddings.create({
model: 'text-embedding-3-large',
input: texts,
dimensions: 2000,
});
return res.data.map(d => d.embedding);
}CLI 스크립트도 배치로 전환하고, 포트폴리오 재생성도 추가했다. 이전에는 posts와 notes만 CLI에서 재생성할 수 있었는데, portfolio가 빠져있었다.
결과
체감되는 변화 두 가지.
첫째, "Kafka 설정 방법" 같은 키워드 질문에서 정확한 글이 소스로 올라온다. 이전에는 의미적으로 비슷한 다른 글이 올라올 때가 있었다.
둘째, 긴 글의 중간 내용에 대한 질문에서 답변 품질이 좋아졌다. 오버랩 덕분에 청크 경계에서 잘리던 문맥이 보존된다.
아직 완벽하진 않다. 자연어 질문의 BM25 노이즈는 1글자 필터링으로 최소화했지만, 한국어 조사 처리는 여전히 한계가 있다. 나중에 글이 수백 개로 늘어나면 키워드 추출 방식(LLM 기반)으로 전환할 수도 있겠지만, 현재 규모에서는 이 정도면 충분하다.