
문서 채팅 앱 만들기: RAG가 실제로 작동하는 방식
요약
RAG(검색 증강 생성)의 핵심 원리와 이를 활용한 문서 채팅 앱 'Khoj'의 구축 과정을 설명합니다. 수집(Ingestion)과 쿼리(Query) 단계로 나누어 RAG 시스템이 어떻게 외부 데이터를 참조하여 LLM의 환각 현상을 해결하는지 다룹니다.
핵심 포인트
- RAG는 검색과 생성을 분리하여 LLM의 정보 공백을 해결함
- 수집 단계에서는 문서를 작은 청크로 나누어 임베딩함
- 청킹을 통해 검색의 정확도를 높이고 모호성을 줄임
- 사용자 질문 시 관련 청크를 컨텍스트로 제공하여 답변 생성
제가 처음 RAG를 접했을 때, 임베딩 (embeddings), 벡터 검색 (vector search), 코사인 유사도 (cosine similarity)와 같은 용어들 때문에 실제보다 더 위협적으로 느껴졌습니다.
하지만 전문 용어들을 걷어내고 나면, 핵심 아이디어는 매우 간단합니다. 그리고 직접 구축해 보는 것이 이를 이해하는 가장 빠른 방법입니다.
이 포스트에서는 제가 Khoj를 어떻게 구축했는지 설명합니다. Khoj는 어떤 PDF든 업로드하고 질문을 던지면, 문서의 실제 내용에 근거한 답변을 제공하는 문서 채팅 앱입니다. 이 글 전반에 걸쳐 이를 참조로 사용하겠지만, 여기서의 초점은 RAG가 어떻게 작동하는지, 그리고 각 구성 요소가 왜 존재하는지에 맞춰져 있습니다.
RAG가 해결하는 문제
대규모 언어 모델 (LLM)은 광범위한 데이터로 학습되었으며, 사용자의 특정 문서로 학습된 것이 아닙니다. LLM에게 한 번도 본 적 없는 내부 보고서, 계약서 또는 정책 문서에 대해 질문하면, 모델은 종종 자신감 있게 조작된 답변을 내놓을 수 있습니다. 즉, 실제로 알지 못하는 정보의 공백을 그럴듯하게 들리는 정보로 채워버리는 것입니다.
RAG는 이를 _검색 (retrieval)_과 _생성 (generation)_이라는 두 가지 관심사를 분리함으로써 해결합니다.
모델에게 가지고 있지 않을지도 모르는 정보를 회상하라고 요청하는 대신, 먼저 문서에서 관련 있는 부분을 검색한 다음, 이를 컨텍스트 (context)로서 모델에 전달합니다. 지시 사항은 다음과 같이 바뀝니다: "오직 이것에만 기반하여 답변하세요." 그러면 모델은 추측을 멈추고 실제 정보에 기반하여 추론하기 시작합니다.
이것이 전체 아이디어입니다. 이 포스트의 나머지 내용은 구현에 관한 것입니다.
두 단계
RAG 시스템에는 정신적 모델에서 별도로 구분해 두는 것이 좋은 두 가지 뚜렷한 단계가 있습니다.
수집 (Ingestion) 단계는 문서가 업로드될 때 한 번 실행됩니다. 검색을 위해 문서를 준비하는 과정입니다.
쿼리 (Query) 단계는 사용자의 모든 질문에 대해 실행됩니다. 관련 콘텐츠를 검색하고 답변을 생성합니다.
1단계 — 수집 (Ingestion)
왜 청킹 (Chunking)인가?
문서 전체를 하나의 벡터 (Vector)로 임베딩 (Embedding)할 수는 없습니다. 그 정도 규모에서는 임베딩이 모호하고 높은 수준의 표현 (High-level representation)만을 포착하게 되어, 이를 통해 검색할 경우 부정확한 결과가 반환됩니다.
대신, 문서를 각각 약 500자 정도의 더 작은 조각들로 나눕니다. 각 청크 (Chunk)는 고유한 임베딩 벡터를 가지며, 데이터베이스의 개별 행 (Row)으로 저장됩니다. 질문이 들어오면 시스템은 문서 전체가 아니라, 가장 관련성이 높은 세 개의 청크, 즉 중요한 부분들만을 찾아냅니다.
청크 간의 중첩 (Overlap, 이 경우에는 50자)은 중요합니다. 중첩이 없다면 청크 경계에 걸친 문장이 반으로 쪼개져 의미를 잃게 됩니다. 중첩을 통해 다음 청크가 이전 청크가 끝나기 직전에 시작되도록 하여, 해당 문맥 (Context)을 보존할 수 있습니다.
export function chunkText(text: string, chunkSize = 500, overlap = 50): string[] {
const chunks: string[] = []
let start = 0
...
임베딩 (Embedding)이란 무엇인가?
임베딩 (Embedding)은 텍스트 조각의 "의미"를 나타내는 숫자 리스트입니다. 유사한 의미를 가진 텍스트는 공간상에서 수치적으로 서로 가까운 벡터 (Vector)를 생성합니다.
이것이 의미론적 검색 (Semantic search)을 가능하게 하는 핵심입니다. 단순히 정확한 키워드 일치를 찾는 것이 아니라, 설령 완전히 다른 단어를 사용하더라도 질문과 "관련된" 내용을 담고 있는 청크를 찾아내는 것입니다.
// for document chunks
export async function embedText(text: string): Promise<number[]> {
const result = await ai.models.embedContent({
...
taskType의 구분을 주의 깊게 살펴볼 필요가 있습니다. RETRIEVAL_DOCUMENT와 RETRIEVAL_QUERY는 모델이 서로 다른 목적을 위해 출력 벡터를 최적화하도록 지시합니다. 하나는 '찾기 위한 것(being found)'을 위한 것이고, 다른 하나는 '찾는 것(finding)'을 위한 것입니다. 각 사례에 적절한 작업 유형(task type)을 사용하면 추가 비용 없이 검색 품질을 눈에 띄게 향상할 수 있습니다.
Postgres에 벡터 저장하기
각 청크(chunk)는 pgvector 확장 기능이 지원되는 vector(768) 컬럼을 가진 Postgres 테이블에 삽입됩니다:
create extension if not exists vector;
create table public.chunks (
...
ivfflat 인덱스는 유사도 검색(similarity search)을 빠르게 만듭니다. 이 인덱스가 없다면 모든 쿼리가 테이블 전체를 스캔해야 합니다. 인덱스를 사용하면 근사 최근접 이웃 (Approximate Nearest-Neighbor, ANN) 알고리즘을 사용하여 대규모 환경에서 훨씬 빠르게 검색할 수 있으며, 정밀도 측면에서의 손실은 무시할 수 있는 수준입니다.
2단계 — 쿼리 (Query)
질문 임베딩하기
사용자의 질문은 문서 청크에 사용되었던 것과 동일한 모델을 사용하여 임베딩됩니다. 이는 필수 사항입니다. 서로 다른 모델에서 생성된 임베딩은 서로 다른 벡터 공간 (vector spaces)에 존재하므로, 이들을 비교하는 것은 GPS 좌표와 마인크래프트 좌표를 비교하는 것과 같습니다. 즉, 이들은 완전히 다른 세계를 설명합니다.
벡터 검색
pgvector는 질문의 임베딩과 가장 유사한 임베딩을 가진 청크를 찾아냅니다:
create or replace function match_chunks(
query_embedding vector(768),
match_document_id uuid,
...
<=> 연산자는 pgvector의 코사인 거리 (cosine distance)입니다. 거리와 유사도는 역관계이므로, 1 - distance를 계산하면 0.0(완전히 무관함)에서 1.0(동일한 의미) 사이의 유사도 점수 (similarity score)를 얻을 수 있습니다.
모든 문장이 거대한 768차원 공간에 떠 있는 하나의 점이라고 상상해 보세요. 유사한 아이디어는 자연스럽게 서로 가까이 모이게 되고, 관련 없는 주제는 더 멀리 떨어지게 됩니다. 벡터 검색 (Vector search)은 단순히 "저장된 점들 중 이 새로운 점과 가장 가까운 점은 무엇인가?"라고 묻는 것과 같습니다.
유사도 임계값 (Similarity Threshold)
pgvector는 질문이 문서와 아무런 관련이 없는 경우에도 항상 결과를 반환합니다. 단지 찾을 수 있는 것 중 거리가 가장 가까운 청크 (chunks)를 반환할 뿐입니다. 임계값 (threshold)이 없다면, "해당 정보를 찾을 수 없습니다"와 같은 답변에 관련 없는 "출처 (sources)"를 첨부하게 되며, 이는 사용자에게 오해를 불러일으키고 혼란을 줄 수 있습니다.
const SIMILARITY_THRESHOLD = 0.5
const relevantChunks = chunks.filter(c => c.similarity >= SIMILARITY_THRESHOLD)
...
0.5라는 임계값은 합리적인 시작점입니다. 사용 중인 특정 콘텐츠와 임베딩 모델 (embedding model)에 따라 이를 조정해야 할 수도 있습니다.
프롬프트 구성하기 (Building the Prompt)
검색된 청크들은 컨텍스트 (context)로 조립되어 언어 모델 (language model)로 전달됩니다.
const context = relevantChunks
.map((c, i) => `[Chunk ${i + 1}]:\n${c.content}`)
.join('\n\n')
...
제공된 컨텍스트 내에서만 답변하라는 명시적인 지시가 환각 (hallucination)을 방지하는 핵심입니다. 모델은 더 이상 학습 데이터의 공백을 임의로 채우지 않고, 사용자가 제공한 콘텐츠를 읽고 그에 따라 추론하게 됩니다.
스트리밍 응답 (Streaming Responses)
스트리밍이 없다면 사용자는 질문을 제출한 뒤 전체 응답이 한꺼번에 나타날 때까지 몇 초 동안 기다려야 합니다. 스트리밍을 사용하면 단어들이 생성되는 즉시 화면에 나타납니다. 전체 응답 시간은 동일하지만, 사용자 경험 (experience)은 근본적으로 달라집니다.
여기에서 사용하는 방식은 서버 전송 이벤트 (Server-Sent Events, SSE)입니다. 서버는 줄바꿈으로 구분된 JSON 이벤트 스트림을 전송합니다.
data: {"type":"sources","sources":[...]}
data: {"type":"token","token":"The"}
...
세 가지 이벤트 유형이 있습니다: sources가 가장 먼저 도착하여 UI가 참조(references)를 즉시 표시할 수 있게 하고, token은 생성되는 각 단어를 전달하며, done은 스트림의 종료를 알립니다.
// server — 스트림 생성
const stream = new ReadableStream({
async start(controller) {
...
// client — 스트림 읽기
const reader = res.body!.getReader()
const decoder = new TextDecoder()
...
reader.read()는 브라우저의 기본 Web Streams API의 일부이며, 호출할 때마다 { done, value }를 반환합니다. 추가적인 라이브러리는 필요하지 않습니다.
마치며
RAG는 새로운 기술 카테고리가 아니라 하나의 패턴입니다. 관련 정보를 검색(Retrieve)한 다음, 해당 정보에 근거하여(grounded) 응답을 생성(Generate)하는 것입니다. 정교함은 청크 크기(chunk size), 중첩(overlap), 임베딩 품질(embedding quality), 유사도 임계값(similarity thresholds)과 같이 각 단계를 얼마나 잘 처리하느냐에 달려 있습니다.
이 요소들을 올바르게 설정하면, 언어 모델(language model)이 나머지를 처리합니다.
전체 구현 내용을 살펴보고 싶다면, Khoj의 소스 코드는 GitHub에서 확인할 수 있습니다. 라이브 데모는 khoj.shresthaprajwol.com.np에서 실행 중입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기

