회사 내부 문서가 Notion에 수백 페이지 쌓여 있었다. 검색해도 원하는 내용을 찾기 어려웠다. Notion 검색이 키워드 매칭 방식이라 "배포 절차가 뭐였지?"라고 검색하면 "배포"라는 단어가 들어간 모든 문서가 나왔다. 정작 필요한 건 "릴리즈 프로세스" 페이지에 있는데, 거기에는 "배포"라는 단어가 없었다.
RAG(Retrieval-Augmented Generation)를 써서 자연어로 질문하면 관련 문서를 찾아서 답변해주는 챗봇을 만들어보기로 했다. 프론트엔드 개발자가 백엔드도 AI도 잘 모르는 상태에서 시작한 프로젝트다.
RAG가 뭔가
LLM에게 질문할 때, 관련 문서를 먼저 찾아서(Retrieval) 프롬프트에 함께 넣어주는(Augmented) 방식으로 답변을 생성(Generation)하는 것이다.
1. 사용자 질문: "스테이징 배포는 어떻게 하나요?"
2. 검색: 질문과 관련된 문서 조각을 벡터 DB에서 찾음
3. 프롬프트 구성: "아래 문서를 참고해서 답변해줘: [관련 문서들]"
4. LLM이 문서를 기반으로 답변 생성
LLM이 학습하지 않은 내부 문서에 대해 답변할 수 있게 된다. 파인튜닝 없이.
임베딩과 벡터 DB
텍스트를 검색하려면 먼저 "의미"를 숫자로 바꿔야 한다. 이게 임베딩이다. "배포"와 "릴리즈"가 의미적으로 가깝다는 걸 임베딩 벡터 간 거리로 판단한다.
OpenAI의 text-embedding-3-small 모델을 사용했다:
import OpenAI from 'openai';
const openai = new OpenAI();
async function getEmbedding(text: string): Promise<number[]> {
const response = await openai.embeddings.create({
model: 'text-embedding-3-small',
input: text,
});
return response.data[0].embedding; // 1536차원 벡터
}
이 벡터를 저장하고 검색하는 게 벡터 DB의 역할이다. Pinecone, Weaviate, Chroma 등이 있는데, 빠르게 시작하려고 Supabase의 pgvector를 선택했다. PostgreSQL 확장이라 별도 인프라가 필요 없다.
-- Supabase에서 벡터 컬럼 생성
create table documents (
id bigserial primary key,
content text,
metadata jsonb,
embedding vector(1536)
);
-- 유사도 검색 함수
create function match_documents(
query_embedding vector(1536),
match_count int default 5
) returns table (id bigint, content text, similarity float)
language plpgsql as $$
begin
return query
select
documents.id,
documents.content,
1 - (documents.embedding <=> query_embedding) as similarity
from documents
order by documents.embedding <=> query_embedding
limit match_count;
end;
$$;청킹: 생각보다 중요했다
Notion 페이지를 통째로 임베딩하면 성능이 나쁘다. 한 페이지에 여러 주제가 섞여 있으면 임베딩 벡터가 어느 주제도 제대로 대표하지 못한다. 문서를 적절한 크기로 쪼개야 한다. 이걸 청킹이라고 한다.
처음에는 단순하게 500자씩 잘랐다. 결과가 별로였다. 문장 중간에서 잘리거나, 하나의 맥락이 두 청크에 걸치는 문제가 생겼다.
마크다운의 헤딩(##)을 기준으로 쪼개는 방식으로 바꿨다:
function chunkByHeading(markdown: string): string[] {
const sections = markdown.split(/(?=^##\s)/m);
return sections
.map((section) => section.trim())
.filter((section) => section.length > 50) // 너무 짧은 섹션 제외
.flatMap((section) => {
// 2000자 넘으면 문단 단위로 추가 분할
if (section.length > 2000) {
return splitByParagraph(section, 1000);
}
return [section];
});
}헤딩 기반으로 쪼개니까 각 청크가 하나의 주제를 담게 되어 검색 정확도가 체감할 수 있을 만큼 올라갔다.
검색 + 답변 생성
사용자 질문이 들어오면:
- 질문을 임베딩한다
- 벡터 DB에서 유사한 문서 청크를 찾는다
- 찾은 문서와 질문을 합쳐서 LLM에게 보낸다
async function askQuestion(question: string) {
// 1. 질문 임베딩
const queryEmbedding = await getEmbedding(question);
// 2. 유사 문서 검색
const { data: documents } = await supabase.rpc('match_documents', {
query_embedding: queryEmbedding,
match_count: 5,
});
// 3. 프롬프트 구성 + 답변 생성
const context = documents.map((doc) => doc.content).join('\n\n---\n\n');
const response = await anthropic.messages.create({
model: 'claude-sonnet-4-20250514',
messages: [
{
role: 'user',
content: `아래 문서를 참고해서 질문에 답변해줘.
문서에 없는 내용은 "문서에서 찾을 수 없습니다"라고 답해줘.
## 참고 문서
${context}
## 질문
${question}`,
},
],
});
return response;
}"문서에 없는 내용은 찾을 수 없다고 해줘" — 이 한 줄이 환각(hallucination)을 줄이는 데 효과적이다. 안 넣으면 LLM이 없는 내용을 그럴듯하게 지어낸다.
겪은 문제들
검색 정확도. 초반에는 관련 없는 문서가 자주 올라왔다. 청킹 전략을 바꾸고, 메타데이터(페이지 제목, 카테고리)를 청크에 포함시키니까 나아졌다. 청크 앞에 "문서: 릴리즈 프로세스 > 스테이징 배포" 같은 경로를 붙여주는 게 효과가 있었다.
Notion API 속도. 수백 페이지를 한 번에 가져오면 느리다. 변경된 페이지만 증분으로 가져오도록 last_edited_time 기준으로 필터링했다.
비용. 임베딩은 저렴하다. 수백 페이지 전체를 임베딩해도 몇 백 원이다. 비용이 드는 건 답변 생성(LLM 호출)인데, 내부 도구라 트래픽이 적어서 월 만 원대로 유지됐다.
프론트엔드 개발자라고 AI 파이프라인을 못 만드는 건 아니다. 벡터 DB도 결국 DB고, 임베딩도 API 호출이고, 청킹도 문자열 처리다. 개념이 생소해서 어려워 보이지, 실제 구현은 프론트엔드에서 하던 것과 크게 다르지 않았다.
