
RAG는 쉽습니다. 유용한 RAG를 만드는 것이 어려운 부분입니다.
요약
단순한 RAG 구현을 넘어 실질적으로 유용한 RAG 시스템을 구축하기 위한 설계 원칙과 Pulse 프로젝트의 아키텍처를 소개합니다. 정확한 검색과 답변 불가능 시 '모릅니다'라고 답하는 신뢰성 있는 시스템 구축의 중요성을 강조합니다.
핵심 포인트
- 단순 RAG 구현과 유용한 RAG 시스템 구축 사이의 간극 존재
- 정확한 검색(Exact), 의미론적 검색(Semantic), 하이브리드 검색의 필요성
- 답변 근거가 없을 때 '모릅니다'라고 답변하는 신뢰성 확보가 핵심
- FastAPI, pgvector, Groq 등을 활용한 실제 서비스 아키텍처 사례
모두가 마치 설정창에 있는 버튼인 것처럼 "그냥 RAG를 추가하세요"라고 말합니다.
그렇지 않습니다. 제가 확인해 봤습니다. 매우 실망스럽더군요.
개요: 개인화된 뉴스 피드
Pulse는 개인용 AI 인텔리전스 피드로 시작되었습니다.
검색창이 붙어 있는 챗봇이 아닙니다. LLM이 본 적도 없는 기사를 자신 있게 설명하는 또 다른 앱도 아닙니다. 저는 더 유용한 것을 원했습니다:
- 기사 요약 및 분류
- 임베딩 (embeddings) 저장
- 정확한 검색 (exact search), 의미론적 검색 (semantic search), 그리고 하이브리드 검색 (hybrid search) 지원
- 나만의 코퍼스 (corpus)로부터 질문에 답변
- 사용된 기사 인용
- 코퍼스에 답이 없을 때는 "모릅니다"라고 말하기
마지막 부분이 중요합니다.
"모릅니다"라고 말할 수 없는 RAG 시스템은 지능적이지 않습니다. 그것은 그저 격식을 차려 입은 과신에 찬 자동 완성 기능일 뿐입니다.
단순한 버전은 다음과 같았습니다:
매우 깔끔하지만, 매우 불완전합니다.
유용한 버전에는 훨씬 더 많은 것이 필요했습니다.
실제 시스템 아키텍처
Pulse는 FastAPI 백엔드, pgvector가 포함된 PostgreSQL, 생성을 위한 Groq, 그리고 Expo Android 앱을 사용합니다.
높은 수준에서의 구조는 다음과 같습니다:
검색 (retrieval)을 위해 중요한 데이터베이스 컬럼은 다음과 같습니다:
class Article(Base):
title: Mapped[str]
summary: Mapped[str | None]
...
벡터 (vector) 컬럼은 pgvector를 사용합니다. pgvector는 코사인 거리 (cosine distance) 및 근사 인덱스 (approximate indexes)를 포함하여 Postgres 내부에서 벡터 유사도 검색 (vector similarity search)을 지원합니다: pgvector README
PostgreSQL은 또한 PostgreSQL 전문 검색 (full-text search) 문서에 설명된 전문 검색 기능을 제공합니다.
따라서 Pulse는 SQL 검색과 벡터 검색 중 하나를 선택하지 않습니다.
두 가지를 모두 사용합니다.
물론 한 가지 검색 모드만 사용하는 것은 너무 평온했기 때문입니다.
왜 "임베딩 (Embeddings)만 사용하기"로는 충분하지 않았는가
임베딩 (Embeddings)은 유용합니다. 하지만 마법은 아닙니다.
사용자가 다음과 같이 검색할 경우:
on-device foundation models
의미론적 검색 (semantic search)은 매우 훌륭합니다. 정확한 단어가 일치하지 않더라도 로컬 AI, 소형 모델 (small models), 모바일 추론 (mobile inference) 및 관련 주제에 대한 기사를 찾아낼 수 있습니다.
하지만 사용자가 다음과 같이 검색한다면:
Anthropic
정확한 검색 (exact search)이 종종 더 낫습니다. 단어 그 자체가 중요하기 때문입니다. Anthropic에 대한 시적인 해석은 필요하지 않습니다. Anthropic을 언급하는 기사가 필요할 뿐입니다.
이 지점이 순수 벡터 검색 (pure vector search)이 번거로워지는 부분입니다.
벡터 검색은 의미를 파악하는 데 능숙합니다. 전문 검색 (full-text search)은 정확한 언어를 다루는 데 능숙합니다. 유용한 제품은 대개 이 두 가지가 모두 필요합니다.
따라서 Pulse는 세 가지 모드를 지원합니다:
Exact -> PostgreSQL 전문 검색 (full-text search)
Semantic -> pgvector 코사인 유사도 (cosine similarity)
Hybrid -> 두 결과 집합을 병합 (merge both result sets)
검색 모드 1: 정확한 검색 (Exact Search)
정확한 검색은 PostgreSQL 전문 검색 (full-text search)을 사용합니다.
이 방식은 이름, 도구, 회사 및 문자 그대로 일치해야 하는 용어에 효과적입니다.
또한 빠르고 지루합니다.
하지만 지루함은 과소평가되어 있습니다. 많은 운영 환경의 시스템(production systems)은 흥미진진한 것들이 타임아웃(timing out)으로 바쁠 때, 그저 지루하게 잘 작동하는 것들입니다.
검색 모드 2: 의미론적 검색 (Semantic Search)
의미론적 검색은 쿼리 (query)를 임베딩하고 코사인 거리 (cosine distance)를 사용하여 기사 임베딩과 비교합니다.
query_embedding = await call_embedder(query_text)
distance = Article.embedding.cosine_distance(query_embedding)
...
검색 모드 3: 하이브리드 검색 (Hybrid Search)
하이브리드 검색 (Hybrid search)은 상호 순위 결합 (Reciprocal Rank Fusion, RRF)을 사용하여 정확한 검색 (exact search) 결과와 의미적 검색 (semantic search) 결과를 결합합니다.
아이디어는 간단합니다:
score = 1 / (k + rank)
만약 어떤 기사가 정확한 검색과 의미적 검색 모두에서 높은 순위를 차지한다면, 그 순위는 상승합니다. 만약 한 가지 검색에서만 높은 순위를 차지하더라도 여전히 기회가 있습니다.
우리는 두 결과 리스트를 병합합니다:
scores[article_id] += rrf_score(exact_rank)
scores[article_id] += rrf_score(semantic_rank)
이로 인해 하이브리드 방식이 기본값이 되었습니다.
왜일까요?
사용자들은 다음과 같이 생각하며 잠에서 깨지 않기 때문입니다:
“오늘은 코사인 유사도 (cosine similarity)에 의해 가장 잘 처리될 수 있는 쿼리를 구성해야지.”
사용자들은 단어를 입력합니다. 시스템은 이에 적응해야 합니다.
하이브리드 검색은 정확한 이름이 승리해야 할 때는 승리하게 해주면서도, 동시에 의미적 매칭 (semantic matches)을 통해 더 넓은 개념들을 포착할 수 있게 해줍니다.
질문 모드: 제동 장치가 있는 RAG (RAG With Brakes)
질문 (Ask) 모드는 검색 (retrieval)이 생성 (generation)으로 전환되는 단계입니다.
사용자가 다음과 같이 질문합니다:
AI 코딩 도구와 관련된 최근 테마는 무엇인가요?
Pulse는 다음과 같이 동작합니다:
여기서 거절 (rejection) 단계가 중요합니다.
만약 상위 검색된 기사들이 부실하다면, Pulse는 LLM을 호출하지 않습니다.
이것은 실패가 아닙니다.
이것은 제품이 책임감 있게 동작하는 것입니다.
만약 제가 다음과 같이 묻는다면:
뭄바이의 날씨는 어떤가요?
Pulse는 기상학적 팬픽션을 만들어내서는 안 됩니다.
대신 다음과 같이 말해야 합니다:
코퍼스 (corpus) 내에 충분한 관련 컨텍스트 (context)가 없습니다.
희망이 아닌 컨텍스트를 통한 프롬프팅 (Prompting With Context, Not Hope)
질문 (Ask) 프롬프트에는 통제된 컨텍스트만 포함됩니다:
Article ID
Title
Summary
...
가공되지 않은 HTML (raw HTML)이 아닙니다. 기사 본문 전체도 아닙니다. 데이터베이스 전체도 아닙니다. 마법의 주문처럼 “정확하게 답변해 주세요”라고 요청하는 것도 아닙니다.
단순화된 프롬프트 구조:
def build_ask_prompt(question, articles):
context = "\n\n".join(
f"[{article.id}]\n"
...
답변에는 기사 ID와 URL로 연결되는 인용 (citations)이 포함됩니다.
이를 통해 시스템의 근거를 확실히 유지 (grounded)할 수 있습니다.
완벽하지는 않습니다. LLM을 사용한다고 해서 완벽한 것은 없습니다. 하지만 모델이 사실과 동떨어져 자유롭게 날뛰게 하는 것보다는 훨씬 낫습니다.
개인화: 순위 매기기도 검색(Retrieval)입니다
검색만이 유일한 검색 문제가 아닙니다.
피드 자체가 검색입니다.
Pulse는 독서 행동으로부터 학습합니다:
- 짧은 읽기는 약한 신호입니다
- 더 긴 읽기는 더 강력한 신호입니다
- 읽은 카테고리는 카테고리 가중치를 업데이트합니다
- 기사 키워드는 관심사 용어를 업데이트합니다
- 북마크 및 숨겨진 기사는 표시되어야 할 것을 영향을 줍니다.
참여도 점수는 의도적으로 간단합니다:
def engagement_signal(duration_seconds: int):
if duration_seconds < 5:
return None
...
가짜 머신러닝식 절차는 없습니다. '신경망 선호 엔진' 같은 것은 없습니다. 왜냐하면 저는 한 기사를 14초 동안 읽었기 때문입니다.
카테고리 가중치는 지수 이동 평균(exponential moving average)을 사용합니다:
new_weight = old_weight + alpha * (signal - old_weight)
피드 점수는 다음 요소들을 결합합니다:
importance + category preference + recency + keyword overlap
학습 기능: RAG는 루프의 일부일 뿐이었습니다
기사가 정리되고, 요약되고, 임베딩되고, 순위가 매겨지면 다른 AI 기능들이 더 쉬워집니다.
Pulse는 동일한 풍부화된 코퍼스(enriched corpus)를 다음 용도로 사용합니다:
1. 일일 요약 (Daily Digest)
요약본은 최근의 중요도가 높은 풍부화된 기사들을 선택하고 Groq에게 세 단락 분량의 브리핑을 요청합니다.
이것은 단순한 요약(summarization)이 아닙니다. 이는 예정된 합성(scheduled synthesis)입니다.
2. 트렌드 (Trends)
트렌드 감지 기능은 최근 기사들로부터 풍부화된 엔티티(enriched entities)를 스캔합니다.
for entity in article.entities:
mentions[normalized_entity].add(article.id)
...
이를 통해 회사의 이름, 모델, 도구 또는 연구 주제와 같은 반복되는 주제들을 앱에 표시할 수 있습니다.
3. LangGraph 퀴즈 에이전트 (LangGraph Quiz Agent)
학습 유지(learning retention)를 위해 Pulse는 기사 요약본과 엔티티로부터 세 문제짜리 퀴즈를 생성합니다.
LangGraph는 다단계 에이전트 흐름 (multi-step agent flows)을 모델링하는 데 유용합니다.
Pulse는 다음을 위해 퀴즈 흐름을 사용합니다:
퀴즈 세션은 만료 시간과 함께 서버 측에 저장됩니다. 정답 키 (answer key)는 클라이언트로부터 신뢰하지 않습니다.
왜냐하면, 개인용 앱이라 할지라도 클라이언트가 스스로를 채점해서는 안 되기 때문입니다.
제품 규칙: 생성 전 검색 (Retrieval Before Generation)
가장 중요한 설계 규칙은 다음과 같았습니다:
먼저 검색하라. 그다음에 생성하라. 검색 결과가 부실하면 거절하라.
이 규칙은 모든 곳에 적용됩니다:
- 검색은 Groq 없이도 실행될 수 있습니다.
- 질문 모드 (Ask mode)는 할당량 (quota)을 소모하기 전에 관련 없는 질문을 거절합니다.
- 요약 (Digest)은 전체 데이터베이스가 아닌 선택된 기사들을 사용합니다.
- 퀴즈 생성은 풍부해진 (enriched) 기사들에 대해서만 작동합니다.
- 피드 순위 지정 (Feed ranking)은 실시간 모델 호출이 아닌 저장된 신호 (signals)를 사용합니다.
이를 통해 시스템은 더 저렴하고, 빠르며, 덜 황당해졌습니다.
LLM은 강력합니다. 하지만 비용이 많이 들고, 속도 제한 (rate-limited)이 있으며, 때로는 틀리는 것에 매우 확신을 갖기도 합니다.
따라서 Pulse는 LLM이 가치를 더하는 곳에만 사용하며, 그 주변에는 지루하지만 결정론적인 (deterministic) 코드를 배치합니다.
최종 형태
최종적인 RAG 아키텍처는 다음과 같았습니다:
이는 다음과 같은 방식보다 더 많은 작업이 필요합니다:
문서 (documents) -> 임베딩 (embeddings) -> 챗봇 (chatbot)
요점 (Takeaway)
입력 데이터가 깨끗하고, 쿼리 (query)가 친절하며, 아무도 이상한 것을 묻지 않는다면 RAG는 쉽습니다.
하지만 유용한 RAG는 다릅니다.
유용한 RAG에는 다음이 필요합니다:
- 깨끗한 소스 데이터 (clean source data)
- 검증된 보강 (validated enrichment)
- 정확한 검색 (exact search)
- 의미론적 검색 (semantic search)
- 하이브리드 랭킹 (hybrid ranking)
- 관련성 임계값 (relevance thresholds)
- 인용 (citations)
- 거절 경로 (refusal paths)
- 개인화 (personalization)
어려운 부분은 벡터를 데이터베이스에 넣는 것이 아닙니다.
어려운 부분은 벡터 결과가 충분히 좋지 않을 때를 결정하는 것입니다.
어려운 부분은 LLM을 호출하는 것이 아닙니다.
어려운 부분은 언제 호출하지 말아야 할지를 아는 것입니다.
그것이 Pulse를 유용하게 만든 점입니다.
모든 것에 답할 수 있기 때문이 아닙니다.
답할 수 없을 때를 알고 있기 때문입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기






