Rust로 직접 구축하며 배우는 RAG 내부 구조의 이해
요약
Rust 언어를 사용하여 RAG(Retrieval-Augmented Generation) 시스템의 핵심 구성 요소를 밑바닥부터 직접 구현하며 내부 구조를 심도 있게 학습하는 가이드입니다. 문서 로더, 청커, 벡터 저장소, gRPC API 등 전체 파이프라인의 작동 원리를 상세히 다룹니다.
핵심 포인트
- Rust를 활용한 고성능 AI 인프라 구축 방법론 제시
- 임베딩 및 의미론적 검색의 작동 원리 이해
- 벡터 데이터베이스와 ANN 인덱싱의 필요성 설명
- RAG 시스템의 주요 구성 요소별 역할 및 구현 방식
Qdrant, Rig, Tonic — 그리고 그 밑단에서 실제로 어떤 일이 일어나고 있는지에 대한 건강한 집착과 함께.
내가 이것을 만든 이유
몇 주 전, 저는 rust-dd 팀의 Our First Production-Ready RAG Dev Journey in Pure Rust를 접하게 되었고, 무언가 깨달음을 얻었습니다.
그 글을 읽으며 두 가지 사실을 깨달았습니다:
- Rust는 AI 시스템을 구축하기 위한 적합한 언어이다 — 빠르고, 안전하며, AI가 실제로 필요로 하는 종류의 인프라 작업을 위해 설계되었습니다.
- 저는 몇 달 동안 이런 것을 직접 만들어보고 싶었지만, 시작하지 않을 핑계만 계속 찾아왔습니다.
그래서 시작했습니다. 이 포스트는 그 결과물입니다. 🦀
대부분의 RAG 튜토리얼은 프레임워크, 네 개의 함수 호출, 그리고 작동하는 데모를 던져줍니다. 문서를 업로드하면 어딘가에서 벡터 (vectors)가 생성되고, LLM (Large Language Model)이 질문에 답하며, 여러분은 챗봇을 얻게 되지만 방금 무슨 일이 일어났는지에 대한 실질적인 이해는 없이 떠나게 됩니다.
이 포스트는 정반대의 접근 방식을 취합니다. 우리는 Rust를 사용하여 문서 로더 (document loader), 청커 (chunker), 벡터 저장소 (vector store), LLM 서비스, gRPC API, 그리고 터미널 채팅 클라이언트를 포함한 작지만 완전한 RAG 시스템을 구축할 것이며, 각 구성 요소가 왜 존재하는지 설명할 것입니다. 이 글을 마칠 때쯤 여러분은 다음을 이해하게 될 것입니다:
- 🧠 임베딩 (embeddings)이 작동하는 원리와 의미론적 검색 (semantic retrieval)이 실제로 어떻게 기능하는지
- 🗄️ 벡터 데이터베이스 (vector databases)가 존재하는 이유와 ANN 인덱싱 (ANN indexing)이 해결하는 문제
- ⚡ 검색 파이프라인 (retrieval pipelines)에서 비동기 런타임 (async runtimes)이 중요한 이유
- 🦀 왜 Rust가 AI 인프라 분야에서 진정으로 흥미로워지고 있는지
- 🔌 현대적인 AI 서비스 아키텍처에서 gRPC가 어떻게 부합하는지
전체 소스 코드는 github.com/Parikalp-Bhardwaj/qrag-rust에 있습니다. 클론(Clone)하여 함께 읽어보세요.
🤔 RAG가 존재하는 이유
LLM은 강력하지만, 여러분의 데이터에 대해 실제로 알고 있는 것은 아무것도 없습니다. LLM은 학습 과정에서 배운 패턴을 바탕으로 생성하며, 이는 다음을 의미합니다:
- 🚫 한 번도 본 적 없는 구체적인 사항을 질문받았을 때 발생하는 환각 (Hallucinations)
- 📅 오래된 정보 (Outdated information) — 학습 데이터 차단 시점(cutoff) 이후의 모든 정보는 보이지 않습니다.
- 🔒 비공개 지식에 대한 접근 불가 (No access to private knowledge) — 사용자의 문서, 코드베이스, 위키 등
- 📏 제한된 컨텍스트 창 (Limited context windows) — 모든 내용을 한꺼번에 붙여넣을 수 없습니다.
2023년에 학습된 모델은 귀하의 내부 API가 무엇을 하는지, 혹은 지난주 릴리스에서 무엇이 변경되었는지 말할 수 없습니다. 이것은 모델의 문제가 아니라, _검색 (retrieval)_의 문제입니다.
RAG는 생성(generation) 단계 이전에 검색 단계를 삽입함으로써 이를 해결합니다:
질의 (query)
→ 데이터에서 관련 컨텍스트(context) 검색
→ 프롬프트(prompt)에 컨텍스트 주입
...
이로 인해 엔지니어링의 초점이 이동합니다. LLM은 여러 구성 요소 중 하나가 되며, 시스템의 품질은 청킹 전략 (chunking strategy), 임베딩 품질 (embedding quality), 검색 정확도 (retrieval accuracy), 인덱싱 (indexing), 그리고 지연 시간 (latency)에 달려 있습니다. 이것이 대부분의 튜토리얼이 건너뛰는 영역입니다. 우리는 바로 그 영역을 깊이 파고들 것입니다.
🧩 구성 요소 (The building blocks)
이 프로젝트에서 대부분의 작업을 수행하는 네 가지 요소가 있습니다.
🔎 Qdrant — 벡터 데이터베이스 (the vector database)
일반적인 데이터베이스에서는 정확한 값으로 검색합니다:
SELECT * FROM documents WHERE title = 'Rust';
이는 키워드에는 작동하지만, 의미(meaning)에는 작동하지 않습니다. 다음을 고려해 보세요:
질의 (Query): "Rust는 어떻게 레이스 컨디션 (race conditions)을 방지하나요?"
관련 문서 (Relevant doc): "Rust는 소유권 (ownership)과 빌림 (borrowing)을 통해 메모리 안전성 (memory safety)과 두려움 없는 동시성 (fearless concurrency)을 제공합니다."
단어의 중복이 전혀 없습니다. SQL의 LIKE 연산자로는 이를 찾을 수 없습니다. 하지만 두 문장을 읽는 사람은 둘 다 같은 내용을 이야기하고 있다는 것을 압니다.
Qdrant는 문서를 의미의 수치적 지문인 **벡터 (vectors)**로 저장합니다. 의미론적으로 유사한 두 텍스트는 고차원 공간(high-dimensional space)에서 서로 가까이 위치하는 두 개의 벡터를 생성합니다. 검색은 "내 질의 벡터에 가장 가까운 벡터를 찾아라"가 됩니다. Qdrant의 포인트 (point)는 다음과 같이 생겼습니다:
Point {
id: "doc-1",
vector: [0.12, -0.44, 0.91, ...], // 1536 차원 (dimensions)
...
왜 전용 데이터베이스가 필요한가요? 단순하게 생각하면, 검색(Retrieval)이란 쿼리 벡터(query vector)를 저장된 모든 벡터와 비교하는 것을 의미하며, 이는 쿼리당 O(n)의 복잡도를 가집니다. 문서 100개 정도라면 괜찮지만, 1,000만 개라면 재앙이 될 것입니다. Qdrant는 모든 데이터를 스캔하지 않고도 유사한 일치 항목을 찾기 위해 근사 최근접 이웃 (Approximate Nearest Neighbor, ANN) 인덱싱(내부적으로는 HNSW 사용)을 활용합니다. 약간의 정확도를 희생하는 대신 극적인 속도 향상을 얻는 것입니다. 이것이 바로 벡터 데이터베이스(vector database)라는 카테고리가 존재하는 이유 전체입니다.
🦀 Rig — AI 애플리케이션 계층
Rig는 LLM 애플리케이션을 위한 Rust 프레임워크입니다. 이 프레임워크는 임베딩 API (embedding APIs), 완료 API (completion APIs), 벡터 저장소 연결 (vector store glue), 에이전트 구축 (agent construction)과 같이 지루하고 제공자별로 특화된 부분들을 처리합니다. Rig가 없다면 우리는 OpenRouter를 위한 HTTP 클라이언트를 직접 작성해야 했을 것입니다.
⚠️ 솔직한 주의사항: Rig는 상용구 코드(boilerplate)를 줄여주지만, 대신 생각해주지는 않습니다. 청킹 전략 (Chunking strategy), 검색 순위 지정 (retrieval ranking), 프롬프트 구성 (prompt construction) 등은 여전히 여러분의 문제이며, 실제로 답변의 품질을 결정하는 요소들입니다.
🚀 gRPC + Tonic — 서비스 계층
gRPC는 고성능 RPC 프레임워크입니다. JSON을 사용하는 POST /chat 대신, 프로토콜 버퍼 (Protocol Buffers)로 서비스를 정의하면 지원되는 모든 언어에서 강력한 타입이 지정된 (strongly-typed) 클라이언트와 서버를 얻을 수 있습니다. Tonic은 이를 구현한 Rust 라이브러리입니다.
REST 대신 gRPC를 사용하는 이유는 두 가지입니다:
- 타입이 지정된 계약 (Typed contracts).
.proto파일이 단일 진실 공급원 (single source of truth)이 됩니다. 클라이언트와 서버가 서로 어긋날 수 없습니다. - 백엔드 간 통신에 적합함 (Backend-to-backend fit). 실제 AI 인프라는
프론트엔드 → API 게이트웨이 → RAG 서비스 → 벡터 DB → LLM 제공자와 같은 구조를 가집니다. gRPC는 이러한 내부적인 단계(hops)를 위해 구축되었습니다.
⚡ Tokio — 비동기 런타임 (async runtime)
여기서 발생하는 모든 네트워크 호출은 비동기(async) 방식입니다 — Qdrant 쿼리, OpenRouter 임베딩, LLM 완료 호출 등 말이죠. Tokio를 사용하면 우리가 직접 스레드를 관리하지 않고도 작은 스레드 풀 위에서 수천 개의 호출을 동시에 실행할 수 있습니다. 이는 다른 모든 것의 밑바탕이 되는 조용한 기질(substrate) 역할을 합니다.
🏗️ 아키텍처 개요
시스템은 하나의 RagEngine을 공유하는 두 가지 단계로 구성됩니다.
인덱싱 (Indexing) (한 번 실행되거나 문서가 변경될 때마다 실행):
./docs → load → chunk → embed (OpenRouter) → store (Qdrant)
Query (질의) (누군가 질문할 때마다 실행):
question → embed → search Qdrant → top-k chunks → prompt → LLM → answer
양쪽 모두 동일한 임베딩 모델 (embedding model)을 사용해야 합니다. 이것이 기하학적 구조(geometry)를 작동하게 만드는 핵심입니다. 문서는 하나의 모델로 임베딩하고 질의는 다른 모델로 임베딩하면서, 그 거리(distances)가 의미를 갖기를 기대할 수는 없습니다.
📂 프로젝트 레이아웃 (Project layout)
qrag-rust/
├── Cargo.toml # crate 메타데이터 + 의존성 (dependencies)
├── build.rs # 빌드 시점에 .proto → Rust로 컴파일
...
각 파일은 하나의 역할만을 수행합니다. 이는 의도된 설계입니다. RAG 시스템은 빠르게 복잡해지며, 깔끔한 경계(clean seams)만이 유일한 방어책이기 때문입니다.
🛠️ 사전 요구 사항 (Prerequisites)
Rust
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source $HOME/.cargo/env
rustc --version
시스템 패키지 (System packages)
놀랍게도 많은 수의 Rust crate들이 내부적으로 네이티브 C/C++를 컴파일합니다. Ubuntu의 경우:
sudo apt update
sudo apt install -y \
build-essential pkg-config libssl-dev \
...
| 패키지 | 필요한 이유 |
|---|---|
build-essential | 네이티브 컴파일을 위한 GCC/G++ |
| ... |
protoc를 확인하세요:
protoc --version
Docker + Qdrant
sudo apt install -y docker.io docker-compose-plugin
sudo systemctl enable --now docker
docker compose up -d # 저장소의 docker-compose.yaml 사용
| 포트 | 제공 서비스 |
|---|---|
6333 | REST API + http://localhost:6333/dashboard에서의 대시보드 |
6334 | gRPC API — 우리의 Rust 클라이언트가 통신하는 방식 |
OpenRouter 키
우리는 OpenRouter를 사용하여 하나의 키로 임베딩 모델 (embedding model)과 완료 모델 (completion model)을 모두 사용할 수 있도록 합니다. 키를 생성한 후 다음을 진행하세요:
🔐 .env 및 Qdrant 설정
무엇인가를 실행하기 전에 두 가지가 준비되어야 합니다: API 키가 포함된 .env 파일과 실행 중인 Qdrant 컨테이너입니다.
1. .env 파일 생성
프로젝트 루트에 .env라는 이름의 파일을 생성하세요 (앞에 점이 붙은 숨김 파일임에 유의하세요):
# 필수 — OpenRouter API 키
OPENROUTER_API_KEY=sk-or-v1-paste-your-key-here
...
openrouter.ai/keys에서 키를 발급받으세요. 테스트하기에는 무료 티어(Free tier)로도 충분합니다.
각 변수의 역할:
| 변수 (Variable) | 기본값 (Default) | 제어 항목 (What it controls) |
|---|---|---|
OPENROUTER_API_KEY | (필수) | 임베딩 (embedding) 및 LLM 호출 모두에 대한 인증 수행 |
| ... |
⚠️ .env 파일을 절대로 git에 커밋하지 마세요. .gitignore에 추가하세요:
echo ".env" >> .gitignore
만약 이미 한 번이라도 커밋했다면, 키를 교체(rotate)하세요. git 히스토리는 잊지 않습니다. 대신 협업자들이 무엇을 설정해야 하는지 알 수 있도록 값이 비어 있는 .env.example 파일을 함께 배포하세요.
2. Docker로 Qdrant 시작하기
리포지토리에는 docker-compose.yaml 파일이 포함되어 있습니다:
services:
qdrant:
image: qdrant/qdrant:latest
...
다음 명령어로 시작하세요:
docker compose up -d
-d 플래그는 백그라운드에서 실행함을 의미합니다. 정상적으로 실행되었는지 확인하세요:
docker ps
# 목록에서 coderag-qdrant가 보여야 합니다
...
브라우저에서 대시보드를 여세요:
📖 모든 파일 살펴보기
이 부분은 대부분의 튜토리얼이 건너뛰는 구간입니다. 우리는 파일 하나하나를 위에서 아래로 훑으며, 각 파일이 무엇을 하는지, 그리고 왜 이런 구조로 설계되었는지 설명할 것입니다.
Cargo.toml — 매니페스트 (manifest)
[package]
name = "qrag-rust"
version = "0.1.0"
...
두 개의 [[bin]] 항목이 중요한 부분입니다. 기본적으로 Cargo는 src/main.rs를 자동으로 찾아내지만, src/bin/ 아래에 바이너리(binary)를 추가하는 순간 Cargo는 자동 검색을 중단하며, 두 항목을 모두 명시적으로 선언해야 합니다. 하나는 서버(server)이고, 다른 하나는 채팅 클라이언트(chat client)입니다. 둘 다 cargo build --release 실행 후 target/release/에 생성됩니다.
tonic-build는 런타임 의존성(runtime dependency)이 아닌 **빌드 의존성 (build-dependency)**입니다. 이는 .proto 파일을 Rust 코드로 변환하기 위해 컴파일 타임 (compile time)에만 실행됩니다.
build.rs — 컴파일 타임 코드 생성 (code generation)
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("proto/rag.proto")?;
Ok(())
...
단 세 줄이지만 효과는 매우 큽니다. cargo build를 실행할 때마다 이 스크립트가 가장 먼저 실행됩니다. 이 스크립트는 proto/rag.proto를 읽어 모든 메시지(message)와 서비스(service)에 대한 Rust 구조체(structs)와 트레이트(traits)를 생성하고, 이를 Cargo의 OUT_DIR에 작성합니다. 이후 main.rs에 있는 tonic::include_proto!("rag")가 생성된 코드를 우리 크레이트(crate)로 가져옵니다.
이것이 바로 생성된 프로토(proto) 코드를 git에 커밋하지 않는 이유입니다. 빌드할 때마다 매번 새로 생성되기 때문입니다.
proto/rag.proto — 서비스 계약 (service contract)
syntax = "proto3";
package rag;
...
이 파일은 API의 단일 진실 공급원 (single source of truth)입니다. 두 개의 RPC가 있습니다: Reindex는 ./docs로부터 벡터 인덱스 (vector index)를 다시 구축하며, AskQuestion은 실제 쿼리 엔드포인트 (query endpoint)입니다. 응답에는 정답뿐만 아니라 **그 정답을 생성하는 데 사용된 소스 청크 (source chunks)**가 포함됩니다. 이것이 RAG 시스템과 블랙박스 (black box)의 차이점입니다. 사용자가 근거 (grounding)를 확인할 수 있도록 항상 소스를 반환하십시오.
숫자(= 1, = 2)는 필드 태그 (field tags)입니다. 이는 프로토버프 (protobuf)가 네트워크 전송 시 필드를 식별하는 방식입니다. 한 번 할당되면 절대 변경하지 마십시오. 변경하는 즉시 모든 기존 클라이언트에 대해 즉각적인 브레이킹 체인지 (breaking change)가 발생합니다.
src/config.rs — 환경 기반 설정 (environment-driven config)
use std::env;
#[derive(Debug, Clone)]
...
합리적인 기본값과 함께 환경 변수 (environment variables)로부터 채워지는 일반적인 설정 구조체 (config struct)입니다. 각 설정은 이름과 기본값으로 문서화되어 있습니다. SERVER_ADDR와 PORT는 gRPC 서버가 바인딩 (bind)되는 위치를 설정합니다. QDRANT_URL은 Qdrant gRPC 포트를 가리킵니다 (주의: REST 포트인 6333이 아니라 6334입니다). QDRANT_COLLECTION은 컬렉션 (collection)의 이름을 지정합니다.
기본값은 로컬 개발 환경에서 즉시 작동합니다. 운영 환경 (production)에서의 재정의는 .env 파일이나 배포 환경을 통해 이루어집니다.
src/document_loader.rs — 디스크에서 파일 읽기
use anyhow::{Context, Result};
use std::fs;
use std::path::{Path, PathBuf};
...
이것은 진입로 (on-ramp) 역할을 합니다. 로더 (loader)는 ./docs를 순회하며 디렉토리와 알 수 없는 확장자를 건너뛰고, 지원되는 각 파일을 읽어 (path, content) 쌍으로 이루어진 Vec<Document>를 반환합니다.
주의 깊게 살펴볼 만한 두 가지 설계 선택 사항이 있습니다. **<P: AsRef<Path>>**는 &str, String, PathBuf, &Path와 같이 Path로 변환될 수 있는 모든 것에 대한 제네릭 (Generic)입니다. 호출자는 별도의 변환 없이 자신이 가진 무엇이든 전달할 수 있습니다. PDF 추출은 무거운 PDF 크레이트 (Crate)를 가져오는 대신 pdftotext (poppler-utils 제공)를 외부 프로세스로 실행합니다. 화려하지는 않지만, 수년간 검증된 방식이며 500줄에 달하는 의존성 (Dependency)을 피할 수 있습니다.
src/chunker.rs — 문서를 조각으로 분할하기
use crate::document_loader::Document;
#[derive(Debug, Clone)]
...
RAG에서 가장 과소평가된 단 하나의 결정입니다. 너무 크면 검색 (Retrieval) 결과가 모호해지고 (청크에 정답과 함께 너무 많은 노이즈가 포함됨), 너무 작으면 의미가 경계 너머로 파편화됩니다. 우리는 기술 문서에 적합한 지점인 청크당 120단어를 기준으로 단순한 **단어 수 기반 청커 (Word-count chunker)**를 사용합니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기