6개월 전, 팀에서 고객 지원 봇을 프로덕션에 올렸다. 그 봇은 자신 있게 사용자들에게 “반품 기간은 60일입니다”라고 답했다. 실제로는 30일이었다. 2023년 제품 문서로 파인튜닝한 모델이었고, 정책이 바뀐 걸 아무도 — 나 포함해서 — 확인하지 않았다. 지원 티켓 300개가 쏟아졌고, 동료 한 명이 분기 리뷰에서 곤란한 상황에 처했다.
그 뒤로 RAG로 새로 만들었고, 지금은 잘 돌아가고 있다.
이 경험 때문에, “RAG와 파인튜닝은 케이스 바이 케이스”로 끝나는 글을 읽을 때마다 좀 짜증이 난다. 어떤 걸 선택하느냐에는 실제 결과가 따른다. 비교표보다 그 결과에서 배운 것들이 훨씬 쓸모 있다.
두 제품에서 세 개의 LLM 기능을 만들고, 18개월 동안 이 주제에 매달린 경험에서 쓴다.
파인튜닝은 보기보다 정당화하기 어렵다
파인튜닝은 모델에게 특정 방식으로 동작하게 하고 싶을 때 자연스러운 선택처럼 보인다. 내 데이터로 학습시키고, 도메인 지식을 구워 넣으면 된다고. 나도 처음엔 그렇게 생각했다.
사내 코드 리뷰 어시스턴트를 만들 때가 딱 그런 케이스였다. 18개월치 PR 코멘트가 있었다 — 시니어 엔지니어들의 노하우가 담긴 것들. 이게 좋은 학습 데이터가 될 거라고 확신했다.
OpenAI 파인튜닝 API(당시엔 gpt-3.5-turbo, 2024년 중반)로 네 번쯤 학습을 돌리고 나서야 결과물이 팀의 리뷰 스타일에 맞아오기 시작했다. 짧고 직접적인 코멘트. 불필요한 칭찬 없이. 사내 스타일 가이드 링크 포함. 팀 반응도 나쁘지 않았다.
그런데 거기서 신규 엔지니어 세 명이 들어오고 스타일 가이드도 업데이트됐다. 갑자기 파인튜닝된 모델이 우리가 명시적으로 폐기한 낡은 관행을 가르치기 시작했다. 재학습에는 돈이 들고, 며칠의 이터레이션이 필요하다. 더 불편한 건, 새 학습 데이터를 큐레이팅하는 것 자체가 공짜가 아니라는 점이다.
Here is the thing: 파인튜닝은 모델에게 어떻게 답할 것인가를 가르치는 거지, 무엇을 알 것인가가 아니다. “특정 톤으로 말하기”나 “항상 이 JSON 필드로 출력하기” 같은 용도라면 복잡도가 정당화된다. 하지만 “제품에 대해 정확하게 답하기”라면, 데이터 노화라는 오르막길을 계속 올라가야 한다.
One thing I noticed: 평가 문제가 생각보다 심각했다. 파인튜닝된 모델이 실제로 나아졌는지 어떻게 판단하나? 평가 인프라에 학습 자체만큼의 시간을 썼다. 그게 없으면 기본적으로 감으로 진행하는 거다.
여기서 배운 것: 파인튜닝이 효과적인 건 문제가 ‘동작 방식’에 관한 것일 때다 — 일관된 포맷, 톤, 태스크 구조. 그리고 그 동작이 자주 변하지 않을 때. 정보가 계속 업데이트되는 용도에는 처음부터 RAG가 맞다.
RAG도 만병통치약은 아니었다
반품 정책 사태 이후, RAG가 모든 것의 답이라는 확신을 가지게 됐다. 솔직히 조금 부끄럽지만, 두 달 동안 모든 아키텍처 논의에서 “그냥 RAG 쓰면 되지”를 외쳤다 — RAG가 진짜로 힘든 케이스를 만나기 전까지.
계약서 요약 툴을 만들고 있었다. 계약서를 청크로 나눠 벡터 스토어에 저장하고, 관련 조항을 검색해서 모델에게 요약시키는 구조. 간단해 보였다. 검색 부분은 잘 됐다. 문제는, 법적 문서에는 복잡한 상호 참조가 있다는 거다 — “제4.2조(b)에 정의된 바에 따라” 같은 식으로. 우리 청킹 전략이 그 정의를 참조하는 조항과 분리시켜버렸다. 모델은 불완전한 컨텍스트로 답하고 있었는데, 그게 불완전한지조차 인식하지 못했다.
RAG 품질은 청킹과 검색 전략에 달려 있다. 대부분의 입문 튜토리얼에서 이 부분을 충분히 다루지 않는다. 결국 큰 청크, 오버랩, Cohere rerank API를 사용한 리랭킹 단계가 있는 하이브리드 방식으로 이동했다. 많이 나아지긴 했는데, 거기까지 가는 데 몇 주가 걸렸다.
한국어 문서 작업이라면 임베딩 모델 선택에도 신경 써야 한다. text-embedding-3-small은 한국어에서도 어느 정도 잘 작동하지만, 한국어 문서 비중이 높은 프로젝트라면 intfloat/multilingual-e5-large를 직접 비교해볼 가치가 있다. 내가 두 모델을 같은 데이터셋으로 직접 벤치마킹한 건 아니라서 — 유스케이스에 따라 다를 수 있다.
레이턴시도 고려해야 한다. 모든 쿼리가 벡터 DB를 찌르고, 유사도 검색을 하고, 청크를 가져오고, 컨텍스트 윈도우에 쑤셔 넣고, 그다음에 LLM 추론이 돌아간다. 내 환경(Qdrant + GPT-4o)에서는 모델 추론 시간에 더해 800ms~1.2초가 추가됐다. 비동기 태스크면 괜찮다. 인터랙티브하게 느껴져야 하는 것이라면, 체감이 된다.
# 처음에 쓰던 검색 로직 — 리랭킹 없는 버전
from qdrant_client import QdrantClient
from openai import OpenAI
qdrant = QdrantClient(url="http://localhost:6333")
client = OpenAI()
def retrieve_and_answer(query: str, top_k: int = 5) -> str:
# 쿼리 임베딩 생성
query_embedding = client.embeddings.create(
input=query,
model="text-embedding-3-small"
).data[0].embedding
# 단순하게 상위 K개 가져오기 — 리랭킹 없음
results = qdrant.search(
collection_name="contracts",
query_vector=query_embedding,
limit=top_k
# score_threshold 없었던 게 실수였다 — 관련 없는 청크들이 들어왔음
)
# 가져온 청크로 컨텍스트 구성
context = "\n\n---\n\n".join(
r.payload["text"] for r in results
)
# 모델이 볼 수 있는 건 여기 넣은 컨텍스트뿐
# 중요한 상호 참조가 청크 경계에서 잘렸다면, 그건 보이지 않는다
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{
"role": "system",
"content": "제공된 컨텍스트만을 바탕으로 답하세요."
},
{
"role": "user",
"content": f"컨텍스트:\n{context}\n\n질문: {query}"
}
]
)
return response.choices[0].message.content
이 단순한 limit=5 검색을 후보 20개 검색 후 상위 5개로 리랭킹하는 2단계 방식으로 바꿨다. 그 변경 하나가 다른 어떤 시도보다 답변 품질을 많이 올렸다.
여기서 배운 것: RAG는 데이터가 변하는 경우, 출처 추적이 필요한 경우, 대규모 문서 코퍼스를 다루는 경우에 강하다. 단, 검색 파이프라인은 진짜 엔지니어링 작업이다. 처음부터 공수를 계획에 넣어야 한다.
둘을 함께 쓰는 게 의미 있는 케이스
두 방식 모두에서 실패를 경험한 뒤, 함께 쓰는 팀들의 케이스를 주목하기 시작했다. 거기서 납득이 가는 패턴이 보였다.
파인튜닝으로 동작 방식과 스타일을, RAG로 지식을 담당하게 한다. 모델이 ‘무엇을 아는가’와 ‘어떻게 답하는가’를 분리하는 개념이다.
구체적인 예: 사내 플랫폼 문서 어시스턴트를 만들었다. 모델에게 필요한 게 세 가지였다 — (a) 항상 특정 구조화된 포맷으로 답하기, (b) 스프린트마다 바뀌는 문서를 따라가기, (c) 환각하는 것보다 “모르겠습니다”라고 솔직하게 말하기.
적절한 불확실성 표현이 담긴 정형화된 답변 예시들로 작은 모델을 파인튜닝했다. 그걸 쿼리 시점에 문서를 검색하는 RAG 파이프라인에 연결했다.
결과는 둘 중 하나만 썼을 때보다 좋았다. API 스펙을 환각하지 않게 됐다(컨텍스트에 정확한 정보가 있으니까). 출력 포맷이 일관성 있어졌다(학습으로 심었으니까). 프롬프트만으로는 안정화하기 어려웠던 “모르겠습니다” 동작이, 파인튜닝 후에는 훨씬 신뢰할 수 있게 됐다.
# 조합한 구현 — 단순화한 버전
def answer_with_finetuned_rag(query: str) -> str:
# 관련 문서 검색
docs = retrieve_docs(query, top_k=5)
context = format_context(docs)
# 파인튜닝된 모델로 일관성 있는 답변 생성
# ft:gpt-3.5-turbo-0125:our-org:docs-assistant:abc123 — 우리 팀 체크포인트
response = client.chat.completions.create(
model="ft:gpt-3.5-turbo-0125:our-org:docs-assistant:abc123",
messages=[
{
"role": "system",
"content": (
"당신은 문서 어시스턴트입니다. "
"제공된 컨텍스트에서만 답하세요. "
"컨텍스트에 답이 없으면 그렇다고 명시하세요."
)
},
{
"role": "user",
"content": f"문서 컨텍스트:\n{context}\n\n질문: {query}"
}
],
temperature=0.2 # 포맷 일관성을 위해 낮게
)
return response.choices[0].message.content
이게 항상 정당화된다고 말하는 건 아니다. 파인튜닝된 모델을 운영한다는 건 그 체크포인트를 내가 관리해야 한다는 뜻이다. OpenAI가 베이스 모델을 폐기하면 재학습이 필요하다. 그건 실제 운영 오버헤드다.
여기서 배운 것: 조합이 복잡도를 정당화하는 건, 프롬프트로는 해결하기 어려운 명확한 동작 문제가 있고, 동시에 지식 베이스가 동적으로 업데이트되는 — 이 두 가지가 모두 성립할 때뿐이다. 하나라도 해당 안 되면 더 단순한 방식을 선택하는 게 맞다.
실제로 어떤 걸 써야 하나
다 겪고 나서, 지금 내 판단 프레임워크는 꽤 단순해졌다. 세 가지 질문을 순서대로 답한다.
1. 재학습 사이클보다 빠르게 데이터가 바뀌나?
그렇다면 RAG가 필요하다. 이건 대부분의 프로덕트 유스케이스에 해당된다 — 지원 봇, 문서 어시스턴트, 팀이 계속 업데이트하는 콘텐츠 DB를 다루는 것들 전부.
2. 프롬프트로는 고칠 수 없는 일관된 동작 문제가 있나?
여러 시스템 프롬프트 변형과 퓨샷 예시를 충분히 시도한 뒤에도 출력이 안정되지 않는다는 뜻이다. 그렇다면 파인튜닝을 고려할 만하다. 진지한 프롬프트 엔지니어링을 아직 안 해봤다면, 그것부터 하라 — 이터레이션이 빠르고 비용이 낮다.
3. 성능 차이가 운영 비용에 값하나?
오래된 베이스 체크포인트로 파인튜닝한 모델은 최신 플래그십 모델보다 토큰당 단가가 낮을 수 있다. 하루에 수백만 건의 API 호출이 있다면, 재학습 비용을 감안해도 경제적으로 맞을 수 있다. 사내 툴을 만드는 대부분의 팀에게는 해당하지 않는다.
솔직히 말한다. 내가 실제로 본 대부분의 애플리케이션에서 — 이건 일반론이 아니라, 실제로 접한 프로덕트 이야기다 — RAG가 맞는 출발점이다. 데이터 신선도 문제 하나만으로 지식 검색 유스케이스에서 파인튜닝은 대부분 탈락한다. 파인튜닝에 손대기 전에 청킹 전략, 임베딩 모델 선택, 검색 품질에 먼저 공을 들여야 한다.
예외는, 프롬프트로는 지정하기 어려운 동작이 필요할 때다 — 강하게 제약된 출력 스키마, 특수한 추론 패턴, 확실하게 안정시켜야 하는 톤 요구사항. 그런 경우엔 파인튜닝이 의미가 있다.
팀 누군가가 “문서를 모델에 파인튜닝하자”고 말하면 — 이걸 한 번 이상 들었다 — “업데이트되면 어떻게 할 거야?”라고 물어봐라. 대부분 그 질문에서 정리가 된다.