목차
- RAG 파이프라인을 직접 만들게 된 배경
- 청킹 전략이 답변 품질의 80%를 결정한다
- 벡터DB — Chroma, Qdrant, Pinecone을 돌려본 기록
- 임베딩 모델과 검색 정확도
- 답변 품질을 끌어올린 것들
- RAG 파이프라인 운영에서 마주친 것들
> Query: "배포 롤백 절차 알려줘"
> Retrieved: "신규 입사자는 첫 주에 Slack 채널에 가입하고..."
> Answer: "입사 첫 주에는 다음 절차를 따르세요..."
Python LangChain RAG 프로토타입을 시연하다 위 로그를 만났다. RAG 파이프라인 구축을 시작한 지 2주째, 벡터DB 비교에 매달리는 사이에 진짜 문제를 놓치고 있었다. 배포 롤백을 물었는데 온보딩 가이드를 답변으로 뱉은 거다.
LangChain으로 RAG 파이프라인을 구축하면서 각 단계의 선택지와 판단 근거를 다룬다. 청킹 전략, 벡터DB 선택, 답변 품질 개선까지.
RAG 파이프라인을 직접 만들게 된 배경
Confluence 기본 검색은 키워드 매칭이라 한계가 명확하다. “배포 롤백”을 검색하면 제목에 그 단어가 있는 문서만 나오고, 본문에 롤백 절차가 상세히 적힌 “장애 대응 프로세스” 같은 문서는 안 잡힌다. 위키가 수백 페이지를 넘어가면 이 문제가 심각해진다.
이 문제를 RAG로 풀기로 했다. Python 3.11, LangChain 0.3 환경이다.
RAG의 기본 구조 자체는 단순하다. 문서를 잘게 쪼개고(청킹), 벡터로 변환해서 저장하고(임베딩 + 벡터DB), 질문이 들어오면 관련 청크를 검색해서 LLM에 넘긴다. 개념은 간단한데, 각 단계의 선택이 최종 답변 품질에 직접 영향을 준다. 그 영향의 크기가 단계마다 상당히 다르다는 걸, 한참 뒤에야 알게 됐다.
처음에는 벡터DB부터 골랐다. Chroma, Pinecone, Qdrant를 놓고 2주를 비교했다. 결론부터 말하면 이건 순서가 틀렸다.
청킹 전략이 답변 품질의 80%를 결정한다
과장이 아니다. 같은 벡터DB, 같은 임베딩 모델을 쓰더라도 청킹 방식을 바꾸는 것만으로 답변 적중률이 써보면 2~3배 차이가 났다. 벡터DB를 Chroma에서 Qdrant로 바꿨을 때는 답변 품질에 유의미한 변화가 없었다. 그런데 chunk_size를 1000에서 500으로 줄이니 눈에 띄는 개선이 있었다.
LangChain에서 쓸 수 있는 청킹 방식은 크게 세 가지다.
RecursiveCharacterTextSplitter
LangChain 공식 문서에서 기본으로 추천하는 방식이다(출처: LangChain Text Splitters 문서, 2026-04 기준). 문자 수 기준으로 자르되, \n\n → \n → → “ 순서로 자연스러운 분리 지점을 찾아간다.
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 청크 하나의 최대 길이
chunk_overlap=50, # 청크 간 겹치는 부분
separators=["\n\n", "\n", " ", ""]
)
chunks = splitter.split_documents(documents)
# 800개 문서 기준 → 약 12,000개 청크가 생성됐다
chunk_size를 1000으로 잡았다가 500으로 줄인 게 첫 번째 전환점이었다. 사내 위키 문서는 한 섹션이 보통 200~400자 정도다. chunk_size가 1000이면 서로 다른 주제의 섹션이 하나의 청크에 섞인다. "배포 절차"와 "모니터링 설정"이 한 청크에 들어가면, 질문이 "배포" 관련이어도 "모니터링" 텍스트가 노이즈로 끼어든다. 데모에서 엉뚱한 답변이 나온 원인이 바로 이거였다.
chunk_overlap도 주의해야 한다. 처음에 200으로 줬더니 중복 청크가 많아져서 검색 결과에 비슷한 내용이 여러 번 올라왔다. 50으로 줄이니 중복이 사라졌다. 공식 문서에서는 chunk_size의 10~20% 정도를 권장하는데, 실제로 그 범위가 적당했다.
시맨틱 청킹
RecursiveCharacterTextSplitter의 한계는 의미 단위를 모른다는 거다. 500자에서 딱 잘리면 문장 중간에서 끊기기도 한다. langchain_experimental 패키지의 SemanticChunker는 임베딩 유사도를 기준으로 의미가 전환되는 지점에서 분할한다.
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
semantic_splitter = SemanticChunker(
embeddings,
breakpoint_threshold_type="percentile", # 유사도 하위 N%에서 분할
breakpoint_threshold_amount=70
)
semantic_chunks = semantic_splitter.split_documents(documents)
# 같은 800개 문서 → 약 9,500개 청크 (RecursiveCharacter보다 적다)
직접 해보니 시맨틱 청킹 쪽이 답변 정확도가 더 높았다. 한 문서 안에서 주제가 여러 번 바뀌는 경우에 차이가 뚜렷하다. 다만 단점이 명확하다. 청킹 과정 자체에서 임베딩 API를 호출하기 때문에 비용이 발생한다. 문서 800개에 text-embedding-3-small을 쓰면 대략 $0.3~0.5 정도인데, 문서가 자주 업데이트되면 매번 재청킹할 때마다 비용이 누적된다. 갱신 빈도가 높은 위키라면 고려해야 할 부분이다.
마크다운 헤더 기반 분할
사내 위키가 마크다운이나 유사한 구조화 포맷으로 되어 있다면 이 방식이 가장 자연스럽다. MarkdownHeaderTextSplitter는 ##, ### 같은 헤더를 기준으로 나누고, 헤더 정보를 메타데이터로 붙여준다. 나중에 답변에 "이건 [장애 대응 프로세스] 문서의 [롤백 절차] 섹션에서 가져왔다"는 출처를 달 수 있어서, 사내 도구에서는 이게 꽤 중요하다.
세 가지를 전부 돌려본 뒤 내린 결론은 이렇다. 문서가 잘 구조화되어 있으면 마크다운 헤더 기반으로 1차 분할하고, 긴 섹션은 RecursiveCharacterTextSplitter로 2차 분할하는 조합이 가장 나았다. 구조가 들쭉날쭉이면 시맨틱 청킹이 안전하다. 어떤 방식이든 chunk_size 300~500이 사내 위키 수준 문서에서는 무난했다.
벡터DB — Chroma, Qdrant, Pinecone을 돌려본 기록
먼저 말해두고 싶은 게 있다. 청킹을 잘 잡아두면 어떤 벡터DB를 써도 답변 품질 자체의 차이는 크지 않았다. 12,000개 청크에 같은 질문 20개를 돌렸을 때, 세 DB 모두 top-5 검색 결과가 거의 동일했다. 차이는 운영 측면에서 나온다.
비교 기준은 세 가지로 잡았다. 프로토타이핑 속도, 프로덕션 운영 부담, 월 비용. AWS 위에 올려야 한다는 제약이 있었고, 현재 문서 800개에서 내년 2,000개까지 늘어날 가능성을 고려했다.
Chroma
pip install chromadb 한 줄이면 끝난다. 별도 서버 없이 인메모리 또는 SQLite 기반으로 동작해서 아이디어 검증 단계에서는 이만한 게 없다. 12,000개 청크 검색이 돌려보니 즉시 끝났다.
프로덕션에 쓰기엔 불안한 부분이 있었다. 동시 접속 처리가 약하고, 데이터가 커지면 메모리 사용량 예측이 어렵다. Chroma 공식 문서(https://docs.trychroma.com/, 2026-04 기준)에서도 프로덕션에는 클라이언트-서버 모드를 권장하는데, 이걸 AWS에 올리려면 결국 별도 인프라 구성이 필요하다. 그 시점에서 "Qdrant를 쓰는 게 낫지 않나"라는 생각이 들었다. 프로토타입 전용이라고 보면 된다.
Pinecone
관리형 서비스라 인프라 걱정이 없다는 게 가장 큰 장점이다. Serverless 인덱스를 쓰면 사용량 기반 과금이고, API가 직관적이다. LangChain 연동도 매끄러워서 시작 속도만 놓고 보면 Chroma보다 빠를 수 있다.
비용이 걸렸다. 프로토타입 단계의 무료 티어로는 충분한데, 문서가 늘어나고 쿼리가 잦아지면 월 $70~100은 나온다(출처: Pinecone Pricing, 2026-04 기준). 사내 도구 하나에 매달 고정 비용이 발생하는 상황을 정당화하기가 애매했다. Qdrant를 EC2 t3.medium에 올리면 월 $30~40으로 같은 규모를 처리할 수 있었으니까.
Qdrant
최종 선택은 Qdrant였다(출처: Qdrant 공식 문서 https://qdrant.tech/documentation/, v1.13 기준).
셀프호스팅이 가능해서 AWS EC2 위에 Docker로 올릴 수 있었다. t3.medium 인스턴스면 20,000개 벡터는 거뜬하다. 메타데이터 필터링이 강력해서 부서별, 문서 유형별로 필터를 거는 게 쉬웠다. "개발팀 문서에서만 검색" 같은 기능은 사내 도구에서 거의 필수인데, Qdrant의 payload 필터링으로 간단히 구현했다.
다만 셀프호스팅은 운영 부담이 따른다. 백업 설정, 모니터링, 장애 대응을 직접 해야 한다. 팀에 인프라를 관리할 여력이 없으면 Pinecone이 나을 수 있다. 이건 기술의 우열이 아니라 팀 상황의 문제다.
임베딩 모델과 검색 정확도
이건 간단하게 간다. OpenAI text-embedding-3-small을 썼다. 차원 수 1536, 비용은 토큰당 $0.00002 수준이라 사내 위키 규모에서는 월 $1도 안 나온다.
text-embedding-3-large(차원 3072)도 돌려봤는데, 사내 위키 문서 수준에서는 검색 결과 차이를 못 느꼈다. 차원이 높으면 벡터DB 저장 용량과 검색 레이턴시에 영향을 주니까, small로 충분하면 large를 쓸 이유가 없다.
오픈소스 임베딩(BGE, multilingual-e5-large 등)은 검토만 하고 넘어갔다. 한국어 문서가 대부분이라 multilingual 모델이 유리할 수 있지만, 직접 서빙하려면 GPU 인스턴스를 별도로 띄우거나 CPU 추론을 감수해야 한다. OpenAI API가 안정적이고 비용도 수용 범위 내여서 굳이 바꿀 동기가 없었다.
답변 품질을 끌어올린 것들
청킹과 벡터DB를 세팅한 뒤에도 답변이 만족스럽지 않은 경우가 있었다. 네 가지를 시도했고, 세 가지에서 유의미한 개선이 나왔다.
검색 개수(k값) 조정
LangChain retriever의 기본 k값은 4다. 상위 4개 청크를 가져와서 LLM에 넘긴다.
6으로 올렸더니 커버리지가 좋아졌다. 배포 롤백 절차가 여러 문서에 나뉘어 있는 경우, k=4면 일부만 잡혔고 k=6이면 대부분 잡혔다. 반면 k를 10 이상으로 올리면 관련 없는 청크가 섞여서 답변이 오히려 흐려진다. 사내 위키 기준 k=5~7이 적정선이었다. 정답은 없고, 실제 질문 20개 정도를 만들어서 k값별로 답변 품질을 직접 비교하는 수밖에 없었다.
프롬프트 커스터마이징
검색된 청크를 LLM에 넘길 때 쓰는 프롬프트가 답변 품질에 상당한 영향을 준다. LangChain 기본 프롬프트는 영어 기준이라 한국어 문서에는 커스텀 프롬프트가 낫다.
from langchain_core.prompts import ChatPromptTemplate
rag_prompt = ChatPromptTemplate.from_template("""
아래 컨텍스트만 참고하여 질문에 답변하라.
컨텍스트에 없는 내용은 "해당 정보를 찾을 수 없다"고 답하라.
추측하지 말고, 컨텍스트에 있는 내용만 사용하라.
컨텍스트:
{context}
질문: {question}
""")
"컨텍스트에 없는 내용은 답하지 마라"를 명시하는 게 핵심이다. 이 한 줄이 없으면 LLM이 자기 지식으로 답변을 지어내는 할루시네이션이 발생한다. 사내 위키 검색에서 존재하지 않는 절차를 자신있게 알려주는 챗봇은 잘못된 검색보다 더 위험하다. 실제로 이 지시를 빠뜨렸을 때 없는 배포 스크립트 경로를 안내해주는 사고가 있었다.
리랭킹(Reranking)
나중에 붙인 건데 효과가 확실했다. 벡터 검색은 임베딩 코사인 유사도 기준이라, 키워드는 겹치지만 맥락이 다른 청크가 상위에 오르는 경우가 있다. 리랭커는 검색 결과를 질문 의도에 맞게 재정렬한다.
Cohere의 rerank API를 LangChain ContextualCompressionRetriever에 연결해서 사용했다. 리랭킹을 넣은 후 "배포 롤백 절차"를 다시 물어봤을 때, 정확한 문서를 참조한 답변이 나왔다. 팀 미팅에서 망했던 바로 그 질문이다. 2주 만에 제대로 된 답변을 봤을 때의 안도감은 컸다.
레이턴시가 단점이다. Cohere rerank 기준 100~200ms가 추가된다. 실시간 챗봇에서 체감될 수 있는 수준이지만, 답변 정확도 개선폭을 생각하면 감수할 만했다.
MultiQueryRetriever
사용자가 "배포 롤백"이라고 물어도 문서에는 "이전 버전 복원", "디플로이 되돌리기" 같은 표현이 쓰여 있을 수 있다. MultiQueryRetriever는 원래 질문을 LLM으로 3~5개 변형 질문으로 만들어서 각각 검색한 뒤 결과를 합친다.
로컬 테스트에서는 정확도가 올라가는 걸 확인했다. 다만 LLM 호출이 질문당 1회 추가되니까 비용과 레이턴시가 같이 늘어난다. 이건 아직 프로덕션에 안 넣었다. 비용 대비 효과를 좀 더 측정해야 하는 영역이다.
RAG 파이프라인 운영에서 마주친 것들
문서가 업데이트되면 해당 청크만 다시 임베딩해서 벡터DB에 넣어야 한다. 전체를 매번 재처리하면 비용과 시간이 낭비된다. LangChain의 indexing API에 record_manager를 쓰면 이미 인덱싱된 문서를 건너뛰고 변경분만 처리한다(출처: LangChain Indexing 문서, 2026-04 기준). 이걸 처음부터 알았으면 초기 세팅 시간을 상당히 줄였을 거다.
답변에 출처 표시를 빼먹으면 안 된다. 사내 도구이니 "이 답변은 [장애 대응 프로세스] 문서의 [롤백] 섹션에서 가져왔다" 같은 정보가 있어야 신뢰가 생긴다. 청킹할 때 메타데이터(문서 제목, URL, 섹션명)를 빠짐없이 넣어두는 게 나중에 큰 차이를 만든다. 이건 초기 구축 때 귀찮다고 넘기면, 나중에 전체 재인덱싱을 해야 하는 상황이 온다.
현재 Qdrant + RecursiveCharacterTextSplitter(chunk_size=500) + Cohere rerank 조합으로 운영 중이다. Python LangChain RAG 구축에서 시간 대비 효과가 가장 큰 투자는 청킹 전략이고, 가장 과대평가된 단계는 벡터DB 선택인 것 같다.
관련 글
- GPT-4o vs Claude vs Gemini — LLM 프롬프트 비용 최적화 실전 회고 – GPT-4o 하나로 시작한 사내 챗봇의 월 API 비용이 $420이었다. 3개월에 걸쳐 프롬프트 캐싱, 배치 API, 모델 라우팅을 적용해…
- FastAPI RAG 구현 — 스트리밍 응답까지
- Python LLM 비용 최적화