
왜 정석적인거에 집착하는가
잘 모르겠다..
일단 main.py부터 시작해야지 하는데 이거 맞나 싶어서 제미나이한테 물어봄
파일명은 기존의 main.py 보다는 app.py이가 낫다고 해서 바꾸었다.
왜냐면....
실제 서비스 수전의 RAG 어플리케이션을 구축할 때, 메인 파일은 단순히 코드를 실행하는 곳이 아니라
전체 파이프라인의 오케스트레이션을 담당해야 한다.,
스프링의 service 계층이나 Controller에서 비즈니스 로직을 조율하는 느낌으로 접근하면 이해가 빠르실 거라 는데
난 아닌던데;;
정석적인 RAG 서비스의 메인 파일에 포함되어야 할 핵심 구성 요소는 다음과 같다.
1. 환경 설정 및 의존성 주입(Configuration & DI)
가장 먼저 서비스에 필요한 환경 변수와 외부 모듈을 초기화한다.
- API Key 관리 : dotenv 등을 활용한 환경 변수 로드
기존의 코드 :
내가 왜 이렇게 짰었는지 히스토리 복기가 필요함.
# RAG 전체 흐름 : PDFLoader → Chunker → Embedder → Retriever → Generator
# app.py
# 역할: 각 클래스를 조립해서 전체 RAG 파이프라인을 실행한다.
from service.rag.ingestion.index_builder import IndexBuilder
#from service.rag.components.vectorstore.faiss.vector_store import VectorStore
from service.rag.components.vectorstore.chroma.vector_store import VectorStore # faiss → chroma
from service.rag.components.embedding.embedder import Embedder
from service.rag.rag_pipeline import RAGPipeline
#1. 데이터 로드 → 청킹 → 임베딩 → 벡터DB 저장 → 검색 → 답변 생성
print("=" * 100)
print("1주차 개발 : RAG 파이프라인 시작")
print("=" * 100)
# 1. 인덱스 생성
# RAG에서 인덱스 생성 = 데이터를 검색 가능하게 준비하는 과정
# 1.1 ├ PDF 읽기
# 1.2 ├ Chunk 생성
# 1.3 ├ Embedding 생성
# 1.4 └ VectorDB 저장
store = VectorStore()
embedder = Embedder()
# ── 목표 1. 벡터 DB 저장 데이터 확인 ──────────────────────────
print("\n[1] 벡터 DB 저장 현황")
store.print_documents()
# ── 목표 2. Retrieval + 유사도 점수 출력 ──────────────────────
print("\n[2] 검색 시작 (종료: exit)")
while True:
query = input("\n질문: ").strip()
if query.lower() == "exit":
break
query_vector = embedder.embed([query])[0]
results = store.search(query_vector, k=3)
print(f"\n{'='*60}")
print(f" 검색 결과 top-{len(results)}")
print(f"{'='*60}")
for i, r in enumerate(results):
print(f"\n[{i+1}] PAGE {r['page']} | CHUNK {r['chunk']}")
print(f" 거리(distance) : {r['distance']:.4f}") # 낮을수록 유사
print(f" 유사도(1-dist) : {1 - r['distance']:.4f}") # 높을수록 유사
print(f" 내용 : {r['text'][:200]}")
print("-" * 60)
# 1주차 내용은 위에까지. 아래는 무시할 것.
# 1️⃣ 벡터 DB 내용 확인
# 2. RAG 파이프라인 생성
# 2.1 ├ query
# 2.2 ├ embedding
# 2.3 ├ vector search
# 2.4 ├ context 생성
# 2.5 └ LLM 호출
#rag = RAGPipeline(embedder, store)
# 3. 질문 반복
#while True:
#query = input("질문: ")
#answer = rag.ask(query)
#print("답변:", answer)
🛠 리팩토링 포인트
1. 반복문(while true) 제거 : 서비스는 요청(Request)이 올 때만 동작해야 한다.
2. 초기화 로직 분리 : 모델 로드와 DB 연결은 서버가 뜰 때 딱 한 번만 수행해야 한다(Bean 생성과 유사).
3. API 엔드포인트 생성 : input() 대신 HTTP POST 요청으로 질문 받도록 수정해야 함.
좋은 질문입니다! 자바 개발자로서 GET과 POST의 차이를 API 설계의 관점에서 왜 그렇게 추천했는지 이유를 설명해 드릴게요.
결론부터 말씀드리면, **"AI에게 질문을 던지는 행위는 단순 조회가 아니라, 데이터를 기반으로 새로운 정보를 생성하는 과정이기 때문"**입니다.
1. HTTP 표준 규격 관점 (GET vs POST)
HTTP 표준에서 GET은 '리소스의 상태를 변경하지 않는 단순 조회(Read)'에 사용됩니다. 반면 POST는 '서버에 데이터를 제출하여 새로운 데이터를 생성(Create)하거나 처리(Process)'할 때 사용합니다.
- GET을 쓰면 안 되는 이유:
- 길이 제한: 브라우저나 서버마다 URL 길이에 제한이 있습니다. 질문(Query)이 길어지거나 프롬프트 엔지니어링으로 입력값이 복잡해지면 URL이 터집니다.
- 보안: 질문 내용이 URL에 노출됩니다. 만약 사용자가 실수로 민감한 정보를 질문에 포함하면 서버 로그에 그대로 다 남습니다. (매우 위험!)
- 캐싱 문제: GET은 브라우저나 중간 서버에서 결과를 캐싱해버릴 위험이 있습니다. 똑같은 질문을 했는데, 예전 질문에 대한 답변이 캐시에서 그대로 튀어나올 수 있습니다.
2. RAG 서비스의 실제 동작 과정
RAG는 그냥 DB에서 값을 꺼내오는 게 아닙니다.
- 사용자의 질문을 받는다.
- 질문을 벡터화(Embedding) 한다.
- 벡터 DB에서 **유사한 문서(Context)**를 찾는다.
- 문서와 질문을 합쳐서 LLM(ChatGPT 등)에게 프롬프트를 전송한다.
- LLM이 답변을 생성한다.
이 과정은 서버 입장에서 엄청나게 많은 계산을 수행하고, 새로운 답변을 "생성"하는 작업입니다. 따라서 **"데이터를 처리하고 결과를 가져오는 POST"**가 명백히 더 적합합니다.
3. 스프링 개발자의 관점
자바 스프링으로 API를 짤 때를 생각해보세요.
- @GetMapping("/ask?query=...") -> 왠지 모르게 불안하지 않으신가요? 보통 검색 조건이 아주 간단할 때만 사용하죠.
- @PostMapping("/ask") -> RequestBody에 JSON으로 깔끔하게 담아서 보내는 게 훨씬 익숙하고 유지보수하기 편합니다.
앞으로의 확장성: 나중에 RAG 서비스를 만들면 질문뿐만 아니라 다음과 같은 추가 설정값들을 같이 보내야 할 때가 옵니다.
- temperature (답변의 창의성 조절)
- model_version (모델 선택)
- conversation_id (대화 맥락 유지)
이런 것들을 URL 뒤에 다 붙이는 건 불가능합니다. POST 방식을 써서 JSON 객체로 받아야 확장이 자유롭습니다.
정리하자면, **"단순 조회라면 GET이지만, LLM을 거쳐 새로운 답변을 생성하는 RAG 서비스라면 POST가 표준이자 정석"**이라고 보시면 됩니다! 납득이 가시나요?
질문 : 왜 요청을 POST로?
결론부터 말하자면, AI에게 질문을 던지는 행위는 단순 조회가 아니라, 데이터를 기반으로 새로운 정보를 생성하는 과정이기 때문이다,
1. HTTP 표준 규격 관점(GET vs POST)
HTTP 표준에서 GET은 '리소스의 상태를 변경하지 않는 단순 조회(Read)'에 사용된다.
반면 POST는 '서버에 데이터를 제출하여 새로운 데이터를 생성(Create)하거나 처리(Process)'할때 사용한다.
- GET을 쓰면 안 되는 이유 :
1) 길이 제한 : 브라우저나 서버마다 URL 길이에 제한이 있다. 질문(Query : 쿼리 스트링(Query String))이 길어지거나
프롬프트 엔지니어링으로 입력값이 복잡해지면 URL이 터지거나, 너무 긴 질문은 잘릴 수 있다. 이와 달리 POST는
HTTP Body에 데이터를 담으므로 사실상 크기 제한이 없고, JSON 구조로 복잡한 파라미터를 깔끔하게 보낼 수 있다.
2) 보안(Security) : 질문 내용이 URL에 노출된다. 만약 사용자가 실수로 민감한 정보를 질문에 포함하면 서버 로그에
그대로 다 남는다. 이는 매우 위험한 것으로, 브라우저 히스토리, 서버 액세스 로그, 프록시 서버 등에 기록이 다 남는다.
반면, POST의 경우 데이터가 Body에 숨겨져 전송되므로(HTTPS 암호화 포함), 로그에 질문 내용이 무방비하게
노출되는 것을 방지할 수 있다.
3) 캐싱 문제 : GET은 브라우저나 중간 서버에서 결과를 캐싱해버릴 위험이 있다. 똑같은 질문을 했는데,
예전 질문에 대한 답변이 캐시에서 그대로 튀어나올 수 있다.
자바 개발자를 위한 비유 / 스프링 개발자의 관점
스프링부트로 비즈
백엔드 개발자의 날카로운 질문이네요! 보통 조회(Read) 작업은 GET을 쓰는 게 정석이지만, **LLM/RAG 서비스에서는 POST를 사용하는 것이 사실상의 표준(Standard)**입니다.
그 이유는 크게 3가지로 요약할 수 있습니다.
1. 질문(Query)의 길이와 데이터 복잡성
RAG 서비스에서 사용자의 질문은 단순한 키워드가 아닙니다. 때로는 수천 자의 텍스트를 붙여넣기도 하고, 특정 옵션(필터링, 모델 선택, 파라미터 등)을 함께 보냅니다.
- GET: URL의 쿼리 스트링(Query String)은 브라우저나 서버마다 길이 제한이 있습니다. 너무 긴 질문은 잘릴 수 있죠.
- POST: HTTP Body에 데이터를 담으므로 사실상 크기 제한이 없고, JSON 구조로 복잡한 파라미터를 깔끔하게 보낼 수 있습니다.
2. 검색을 넘어선 '생성' 작업 (Side Effect)
RAG는 단순히 DB를 조회하는 행위에 그치지 않습니다.
- GET은 멱등성(Idempotent)을 가집니다. 즉, 여러 번 호출해도 서버 상태가 변하지 않아야 하죠.
- 하지만 RAG 서비스는 내부적으로 **LLM 토큰을 소모(비용 발생)**하고, 대화 이력을 **DB에 저장(State 변경)**하거나 로그를 남기는 등 무거운 비즈니스 로직이 수반됩니다.
- "답변을 생성한다"는 행위 자체가 서버 자원을 적극적으로 사용하는 'Action'에 가깝기 때문에 POST가 더 적합합니다.
3. 보안 (Security)
사용자의 질문에는 개인정보나 민감한 비즈니스 데이터가 포함될 수 있습니다.
- GET: URL에 질문 내용이 그대로 노출됩니다. 이는 브라우저 히스토리, 서버 액세스 로그, 프록시 서버 등에 기록이 다 남는다는 뜻이죠.
- POST: 데이터가 Body에 숨겨져 전송되므로(HTTPS 암호화 포함), 로그에 질문 내용이 무방비하게 노출되는 것을 방지할 수 있습니다.
💡 자바 개발자를 위한 비유
스프링부트로 비즈니스 로직을 짤 때, 검색 필터 조건이 10개가 넘어가면 GET /search?a=1&b=2... 보다는 POST /search에 SearchRequestDTO를 바인딩해서 처리하는 것이 훨씬 깔끔하고 안전한 것과 같은 이치입니다.
실제로 OpenAI의 ChatGPT API나 LangChain 기반의 서비스들도 대부분 /chat/completions 같은 엔드포인트를 POST로 제공하고 있습니다.
이제 왜 POST로 설계하는 것이 "정석"에 가까운지 감이 오시나요? 현재 코드에서 input()으로 받던 질문을 POST Body로 받게만 바꿔도 서비스다운 면모가 갖춰질 겁니다!
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
from service.rag.components.vectorstore.chroma.vector_store import VectorStore
from service.rag.components.embedding.embedder import Embedder
# 1. 초기화 (Spring의 ApplicationContext 로드 시점과 유사)
app = FastAPI()
# 전역 변수로 관리 (실제로는 DI 컨테이너를 쓰는 게 좋지만 우선은 이렇게 시작!)
store = VectorStore()
embedder = Embedder()
# 2. DTO 정의 (Request Body 데이터 구조)
class QuestionRequest(BaseModel):
query: str
# 3. API 엔드포인트 (Controller 역할)
@app.post("/ask")
async def ask_question(request: QuestionRequest):
query = request.query.strip()
if not query:
raise HTTPException(status_code=400, detail="질문을 입력해주세요.")
try:
# 4. 비즈니스 로직 (현재는 검색 단계까지 구현됨)
# 질문을 벡터로 변환
query_vector = embedder.embed([query])[0]
# 벡터 DB 검색
results = store.search(query_vector, k=3)
# 5. 결과 반환 (JSON 형태로 자동 변환됨)
return {
"status": "success",
"results": [
{
"page": r['page'],
"chunk": r['chunk'],
"similarity": 1 - r['distance'],
"content": r['text']
} for r in results
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# 서버 실행 가이드: 터미널에서 `uvicorn app:app --reload` 실행
💡 무엇이 달라졌는가?
- 실행 방식 : python app.py가 아니라 uvicorn app : app 명렁어로 서버를 띄운다.
- 통신 : 이제 터미널에서 직접 치는게 아니라, 포스트맨(Postman)이나 프론트엔드에서 http://localhost:8000/ask로
데이터를 주고 받을 수 있다.
- 상태관리 : 서버가 떠 있는 동안 store와 embedder 객체가 메모리에 유지되므로 매번 모델을 새로 로드할 필요가 없어
훨씬 빠르다.
1. RAGPipeline : 현재 app.py에 있는 검색 로직을 service/rag/rag_pipeline.py 안으로 옮긴다.(service 레이어 생성)
2. Controller 단순화 : app.py에서는 pipeline.ask(query) 한 줄만 호출하도록 만든다.
3. Config 주입 : config.py의 값들을 VectorStore(config.TOP_K) 이런 식으로 생성자 주입(DI) 하듯이 전달한다.
이렇게 하면 나중에 VectorStore를 Chroma에서 FAISS로 바꿔도 app.py는 건드릴 필요가 없는 결합도가 낮은 구조가
된다.
리팩토링 2
리팩토링 전 코드
#component = 시스템을 구성하는 하나의 부품
# 텍스트를 벡터로 변경하는 클래스
from sentence_transformers import SentenceTransformer
class Embedder:
#def __init__(self, model_name="all-MiniLM-L6-v2"):
def __init__(self, model_name="BAAI/bge-m3"):
self.model = SentenceTransformer(model_name)
def embed(self, text: str):
return self.model.encode(text)
리팩토링 후 코드
#component = 시스템을 구성하는 하나의 부품
# 텍스트를 벡터로 변경하는 클래스
from sentence_transformers import SentenceTransformer
from typing import List
import numpy as np
class Embedder:
#def __init__(self, model_name="all-MiniLM-L6-v2"):
# 성희가 물어봤던 부분이었나? 왜 이 모델을 썼냐고 했었던 것 같은데.
# BAAI/bge-m3 모델은 현재 오픈소스 임베딩 모델 중 한국어 성능이 가장 좋은 녀석 중 하나래.
# 1) 다국어 지원 : 한국어 처리가 매우 매끄러움.
# 2) 멀티 기능 : 나중에 'Dense Retrieval' 외에 'Sparse Retrieval(키워드 기반)' 기능까지 확장할 수 있는 모델이다.
def __init__(self, model_name="BAAI/bge-m3"):
# 모델 로딩은 무거운 작업이라 생성자에서 하는 게 정석이라 한다.
# 당연한건데 이해 못했었음. 메서드 호출될 때마다 생성하면 비효율적.
# 이 클래스가 로드되는 시점 한번만 실행.
self.model = SentenceTransformer(model_name)
# 1. 여러 개를 한 번에 변환할 때 (Batch 처리용)
def embed_documents(self, texts: List[str]) -> np.ndarray:
return self.model.encode(texts)
# 2. 질문 하나만 변환할 때 (단일 처리용)
def embed_query(self, text: str) -> np.ndarray:
# 내부적으로 리스트로 감싸서 보내고, 결과에서 0번째를 꺼내서 반환
return self.model.encode([text])[0]
📂 RAG 시스템의 두 가지 라이프사이클
: RAG 시스템은 크게 두 가지 시점으로 나뉘어 동작할 수 있다.
1. Ingestion(데이터 구축 시점)
- 대상 : PDF, 위키, 사내 문서 등 방대한 텍스트 리스트
- 도구 : embed_documents(List<String>)
- 이유 : 수만 개의 문장 조각을 하나씩 for문 돌려서 모델에 넣으면 오버헤드가 크다.
리스트로 묶어 배치(Batch)로 던저야 GPU나 CPU 연산 효율을 극대화 할 수 있다.
2. Retrieval(실시간 검색 시점)
- 대상 : 사용자가 채팅창에 입력한 질문 1개
- 도구 : embed_query(String)
- 이유 : 사용자는 한 번에 질문 한번만 던진다. 굳이 리스트로 감싸서 넘길 필요 없이 바로 문자열을 받아 처리하고, 결과물도
리스트가 아닌 단일 벡터로 받아야 바로 DB 검색에 쓸 수 있다.
'스터디 > RAG' 카테고리의 다른 글
| [코드리뷰] VectorStore - 리팩토링2 (0) | 2026.04.20 |
|---|---|
| [FastAPI] Spring Boot 개발자가 이해하는 FastAPI 구조와 용어 매핑 (1) | 2026.04.19 |
| fitz 라이브러리와 pdfplumber 라이브러리를 통해 추출한 텍스트 비교 (0) | 2026.03.17 |
| 2주차 스터디 준비 - github에서 이슈생성 (0) | 2026.03.15 |
| [RAG/팀] 1주차 (0) | 2026.03.05 |