본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 05. 31. 03:57

LangChain 없이 처음부터 직접 구축한 RAG 파이프라인 — FastAPI와 FAISS만 사용

요약

LangChain과 같은 프레임워크 없이 FastAPI와 FAISS만을 사용하여 RAG 파이프라인을 밑바닥부터 직접 구축하는 방법을 설명합니다. 텍스트 추출, 청킹, 임베딩, 벡터 저장 및 쿼리 과정을 상세히 다룹니다.

핵심 포인트

  • 프레임워크 없이 FastAPI와 FAISS로 RAG 구현 가능
  • pypdf를 활용한 텍스트 추출 및 문맥 유지를 위한 청킹 전략
  • MiniLM-L6-v2 모델을 이용한 로컬 임베딩 및 벡터 정규화
  • FAISS IndexFlatIP를 활용한 효율적인 벡터 검색

제가 찾은 대부분의 RAG 튜토리얼은 "pip install langchain을 하면 끝납니다"라고 하거나, 50페이지 분량의 학술 논문이었습니다. 저는 그 중간 단계, 즉 모든 코드를 이해하고 면접에서 실제로 설명할 수 있는 파이프라인을 원했습니다.

그래서 처음부터 직접 구축했습니다. LangChain도, LlamaIndex도, 다른 프레임워크도 사용하지 않았습니다. 오직 FastAPI, FAISS, sentence-transformers, 그리고 LLM API만을 사용했습니다.

제가 무엇을 만들었는지, 무엇이 작동했고, 무엇이 실패했는지 소개합니다.

Demo of the RAG pipeline in action

아키텍처 (The architecture)

PDF --> 텍스트 추출 (pypdf) --> 청킹 (chunk) (500자, 50자 중첩) --> 임베딩 (embed) (MiniLM-L6-v2)
                                                                        |
                                                                        v
...

총 5개의 Python 파일, 약 300줄의 코드:

파일역할
main.pyFastAPI 앱, 3개의 엔드포인트 (endpoints), 프롬프트 엔지니어링 (prompt engineering)
...

업로드 작동 방식

/upload로 PDF를 POST할 때 세 가지 작업이 일어납니다:

1. 텍스트 추출 (Text extraction) — pypdf가 각 페이지를 읽고 원문 텍스트를 반환합니다. 추출 가능한 텍스트가 없는 페이지(스캔된 이미지)는 건너뜁니다.

2. 청킹 (Chunking) — 각 페이지는 50자의 중첩 (overlap)을 포함하여 약 500자 단위의 청크 (chunks)로 분할됩니다. 중첩은 청크 경계에서 문맥을 잃는 것을 방지합니다.

CHUNK_SIZE = 500
CHUNK_OVERLAP = 50

...

3. 임베딩 (Embedding) — 각 청크는 all-MiniLM-L6-v2를 사용하여 384차원의 벡터 (vector)로 임베딩됩니다. 이는 CPU에서 로컬로 실행되므로 API 호출이 필요하지 않습니다. 벡터는 내적 (inner product)을 코사인 유사도 (cosine similarity)로 사용할 수 있도록 정규화(normalized)됩니다.

def embed_texts(texts):
    model = get_embed_model()  # 지연 로딩되는 싱글톤 (lazy-loaded singleton)
    vectors = model.encode(
...

벡터와 청크 메타데이터는 FAISS의 IndexFlatIP 인덱스에 저장됩니다. 이는 브루트 포스 (brute-force) 방식의 완전 탐색으로, 약 10만 개의 벡터까지는 문제없이 작동합니다.

쿼리 작동 방식

/query로 질문을 POST할 때:

  1. 질문은 **동일한 모델 (same model)**을 사용하여 임베딩 (embedding)됩니다.
  2. FAISS가 코사인 유사도 (cosine similarity)를 통해 가장 유사한 상위 k개의 청크 (top-k chunks)를 찾습니다.
  3. 청크들은 [Chunk 3 | Page 2]와 같은 라벨이 붙은 프롬프트 (prompt) 형식으로 구성됩니다.
  4. LLM이 해당 청크들에 근거하여 답변을 생성합니다.
  5. 답변과 출처 청크가 모두 반환됩니다.

시스템 프롬프트 (system prompt)는 의도적으로 엄격하게 설정되었습니다:

제공된 문서 컨텍스트 (document context) 내에서만 엄격하게 질문에 답변하는 신중한 어시스턴트입니다.

...

교체 가능한 LLM 제공업체 (Swappable LLM providers)

제가 만족하는 부분 중 하나는 단일 환경 변수 (environment variable)를 통해 LLM을 교체할 수 있다는 점입니다:

LLM_PROVIDER=groq      # 또는 openai, 또는 anthropic

세 제공업체 모두 동일한 인터페이스 (interface)를 공유합니다:

class LLMClient(ABC):
    @abstractmethod
    def generate(self, system: str, user: str) -> str: ...

선택한 제공업체의 API 키만 있으면 됩니다. 저는 개발을 위해 Groq와 Llama 3.3 70B를 사용했는데, 속도가 빠르고 무료 티어 (free-tier) 친화적이기 때문입니다.

테스트 결과: 성공한 것과 실패한 것

저는 가상의 5페이지 분량 회사 문서를 만들고 파이프라인 (pipeline)에 19개의 질문을 던졌습니다. 질문은 단순 조회부터 다단계 추론 (multi-hop reasoning), 그리고 부정 테스트 (negative tests, 문서가 답할 수 없는 질문)까지 다양했습니다.

잘 작동한 부분:

  • 직접 조회: "Magpie-7의 권장 소비자 가격은 무엇인가요?" — 정확히 맞춤
  • 테이블 데이터: "Standard 티어에는 무엇이 포함되어 있나요?" — 정확함
  • 부정 테스트: "Zentara의 주식 티커 (stock ticker)는 무엇인가요?" — "문서에 없습니다"라고 정확히 답변
  • 다단계 추론 (Multi-hop): "1시간 SLA 지원을 원한다면 비용이 얼마인가요?" — 가격 테이블의 정보를 결합하여 답변

실패한 부분:

  • "CEO는 누구인가요?" — 찾지 못함
  • "Zentara의 직원 수는 몇 명인가요?" — 찾지 못함

두 답변 모두 1페이지의 밀집된 "Company snapshot" 테이블에 있었습니다: CEO, CTO, 본사 (HQ), 직원 수, 매출 등이 모두 한데 모여 있었습니다.

실패 원인 (그리고 배운 점)

문제는 LLM이 아니라 **리트리버 (retriever)**였습니다. 회사 스냅샷 테이블에는 8개 이상의 서로 다른 사실들이 하나의 청크 (chunk)에 빽빽하게 들어가 있었습니다. 해당 청크의 임베딩 (embedding)은 그 모든 주제의 모호한 평균값이 되어버렸고

저는 Santanu Mohanta입니다 — LinkedIn을 통해 저와 연결되거나 GitHub에서 저의 다른 프로젝트들을 확인하실 수 있습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0