왜 세 가지를 전부 써봤냐면
우리 팀은 5명짜리 스타트업인데, 작년 말부터 사내 지식 베이스 Q&A 시스템을 만들고 있었다. Confluence 페이지, Notion 문서, PDF 파일을 합쳐서 약 5만 개 문서 규모였다. 처음엔 그냥 LangChain으로 빠르게 만들자는 생각이었다 — 유튜브 튜토리얼마다 다 LangChain을 쓰니까 별 고민을 안 했다.
근데 첫 주에 문제가 생겼다. 응답이 너무 느리고, 메모리 사용이 이상하게 튀었다. 그래서 어쩌다 보니 LlamaIndex도 써보고, 마지막엔 Haystack까지 건드리게 됐다. 2주 동안 세 개를 다 써본 셈인데, 이 경험을 정리해두면 비슷한 상황에 있는 분들한테 도움이 될 것 같아서 적는다.
미리 말해두자면 — 나는 ML 엔지니어가 아니다. 백엔드 개발자가 RAG 시스템 만들다가 발견한 것들이다.
LangChain 0.3.12: 뭐든 할 수 있다는 게 오히려 발목을 잡는다
LangChain 0.3.12는 생태계가 넓다. 거의 모든 LLM provider, 벡터 DB, 문서 로더를 지원한다. 처음 세팅할 때 이 풍부한 인테그레이션이 정말 좋았다.
from langchain_community.document_loaders import ConfluenceLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Qdrant
from langchain.chains import RetrievalQA
# 기본 RAG 파이프라인 — 30분 안에 돌아가는 버전
loader = ConfluenceLoader(
url="https://yourteam.atlassian.net/wiki",
username="[email protected]",
api_key=os.environ["CONFLUENCE_API_KEY"],
space_key="TEAM"
)
docs = loader.load()
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200
)
chunks = splitter.split_documents(docs)
vectorstore = Qdrant.from_documents(
chunks,
OpenAIEmbeddings(model="text-embedding-3-small"),
url="http://localhost:6333",
collection_name="knowledge_base"
)
qa_chain = RetrievalQA.from_chain_type(
llm=ChatOpenAI(model="gpt-4o-mini"),
retriever=vectorstore.as_retriever(search_kwargs={"k": 5})
)
이 코드, 30분 안에 돌아간다. 진짜로. 그게 LangChain의 가장 큰 장점이다.
그런데 거기서 문제가 시작됐다. 응답 시간이 평균 4.2초였는데, 내부 도구라서 처음엔 그냥 넘어갔다. 근데 팀원들이 “너무 느리다”고 하기 시작했고, 나도 점점 느끼기 시작했다.
profiling을 해보니 문제가 여러 곳에 있었다. LCEL(LangChain Expression Language)로 체인을 구성하면 내부적으로 객체 생성이 많이 일어나는데, 이게 생각보다 overhead가 컸다. 특히 retriever 호출마다 새 객체가 생성되는 패턴이 있어서, 트래픽이 조금만 몰려도 메모리가 눈에 띄게 올라갔다.
더 짜증났던 건 디버깅이었다. LangChain은 추상화 레이어가 많아서, 뭔가 잘못됐을 때 어디서 실패했는지 추적하기가 어렵다. LangSmith를 붙이면 나아지긴 하는데, 그것도 추가 설정이고 — 모든 게 “추가 설정”이다.
버전 간 하위 호환성 문제는 여전하다. langchain-community가 분리된 이후로 import 구조가 계속 바뀌는데, 예전 튜토리얼 따라 하다가 DeprecationWarning 폭탄 맞는 경험은 이제 LangChain 입문 의식처럼 느껴질 정도다.
프로토타입은 빠르게 만들 수 있다. 프로덕션 안정성은 직접 보강해야 한다 — 이게 내가 2주 후에 내린 결론이다.
LlamaIndex 0.11.4: 검색 파이프라인만큼은 확실히 잘 만들었다
LlamaIndex는 처음부터 방향이 다르다. LangChain이 “우리가 다 연결해줄게”라면, LlamaIndex는 “우리는 데이터 인덱싱이랑 검색에 집중할게”라는 느낌이다.
실제로 써보니 이 철학이 코드에서 드러난다. 쿼리 파이프라인이 훨씬 세밀하게 제어된다. hybrid search(키워드 + 벡터), reranking, 쿼리 분해 같은 걸 쓸 때 LlamaIndex가 훨씬 자연스럽다.
나한테 특히 의미 있었던 건 QueryFusionRetriever였다.
from llama_index.core import VectorStoreIndex, StorageContext
from llama_index.core.retrievers import QueryFusionRetriever
from llama_index.core.query_engine import RetrieverQueryEngine
from llama_index.vector_stores.qdrant import QdrantVectorStore
from llama_index.embeddings.openai import OpenAIEmbedding
import qdrant_client
client = qdrant_client.QdrantClient(url="http://localhost:6333")
vector_store = QdrantVectorStore(client=client, collection_name="knowledge_base")
storage_context = StorageContext.from_defaults(vector_store=vector_store)
index = VectorStoreIndex.from_documents(
documents,
storage_context=storage_context,
embed_model=OpenAIEmbedding(model="text-embedding-3-small")
)
# 쿼리 변형 + 결과 합치기 — LangChain에선 직접 구현해야 했던 것
retriever = QueryFusionRetriever(
[index.as_retriever(similarity_top_k=5)],
similarity_top_k=5,
num_queries=4, # 원본 쿼리 포함 4개 변형 생성
mode="reciprocal_rerank",
use_async=True # 이게 핵심이었다
)
query_engine = RetrieverQueryEngine.from_args(retriever)
response = query_engine.query("우리 팀 온보딩 프로세스가 어떻게 돼?")
여기서 진짜 놀란 게 있었다. use_async=True 하나 켰더니 응답 시간이 4.2초에서 1.8초로 줄었다. 나는 당연히 임베딩 모델이 병목일 거라고 생각했는데 — 알고 보니 retriever가 순차적으로 실행되던 게 문제였다. 당연히 알고 있어야 할 것인데, 이걸 처음 발견했을 때 “내가 이걸 왜 지금 알았지”라는 생각이 들었다. LangChain에서는 이게 기본값이 아니었고, 내가 딱히 찾아보지도 않았다.
단점도 있다. LLM 통합이 LangChain보다 얇고, 에이전트 기능이나 복잡한 체인을 구현하려면 LangChain 생태계가 훨씬 넓다. 문서가 빠르게 업데이트되는데 예제 코드가 버전이랑 안 맞는 경우도 종종 있다 — GitHub issue 뒤지는 시간이 꽤 걸렸다.
검색 품질이 중요한 RAG라면, 특히 LLM 연동 복잡도가 낮을 때, LlamaIndex가 LangChain보다 낫다는 게 내 결론이다.
Haystack 2.9: 가장 덜 유명한데 가장 프로덕션 친화적이었다
솔직히 Haystack은 기대를 안 하고 시작했다. 세 개 중 가장 덜 알려진 편이고, 커뮤니티도 작다. 근데 써보고 나서 이게 왜 덜 유명한지 이해가 안 될 정도였다.
독일 스타트업 deepset이 만들었고, 엔터프라이즈 용도를 처음부터 염두에 두고 설계했다는 게 코드에서 느껴진다. 가장 인상적인 건 파이프라인 직렬화다. Python 코드 대신 YAML로 파이프라인 전체를 정의한다.
components:
- name: retriever
type: haystack.components.retrievers.InMemoryEmbeddingRetriever
init_parameters:
top_k: 5
- name: prompt_builder
type: haystack.components.builders.PromptBuilder
init_parameters:
template: |
Context: {% for doc in documents %}{{ doc.content }}{% endfor %}
Question: {{ query }}
Answer:
- name: llm
type: haystack.components.generators.OpenAIGenerator
init_parameters:
model: gpt-4o-mini
connections:
- sender: retriever.documents
receiver: prompt_builder.documents
- sender: prompt_builder.prompt
receiver: llm.prompt
이게 별 것 아닌 것처럼 보이는데, CI/CD에 파이프라인 변경 이력을 남길 수 있다는 게 실제로 엄청 실용적이다. LangChain이나 LlamaIndex는 파이프라인이 Python 코드 자체라서 설정값이랑 로직이 섞인다. “지난달에 왜 이 프롬프트를 바꿨지?”를 git blame으로 추적할 수 있다는 게 — 작은 것 같지만 6개월 뒤에 진짜 고맙다.
에러 처리도 명시적이다. 컴포넌트마다 입출력 타입이 명확히 정의돼 있어서, 타입이 안 맞으면 런타임이 아니라 파이프라인 빌드 시점에 잡힌다. 이게 별거 아닌 것 같아도, 새벽 2시에 프로덕션 장애 대응할 때는 굉장히 소중하다.
모니터링 통합도 잘 돼 있다. Prometheus 메트릭이 기본으로 노출되고, OpenTelemetry 지원도 있어서 기존 모니터링 스택에 끼워 넣기가 편하다. LangChain에서 이걸 구현하려면 직접 미들웨어를 짜야 했다.
아쉬운 점은 러닝 커브다. 컴포넌트 기반 설계가 익숙해지면 강력한데, 처음에 “이걸 왜 이렇게 써야 하지?”라는 생각이 여러 번 들었다. 커뮤니티도 LangChain에 비하면 작아서 Stack Overflow에 물어볼 수가 없고, 공식 Discord에 가야 한다. 거기 사람들은 친절한데 응답이 항상 빠르진 않다. Confluence 같은 특정 소스에 대한 커뮤니티 인테그레이션도 LangChain보다 적다 — 내 경우엔 직접 커스텀 컴포넌트를 만들었는데, 시간이 걸렸다.
팀 규모가 크거나 장기 운영이 중요하다면, 이 초기 러닝 커브는 충분히 감수할 만하다.
실제로 재봤을 때 나온 숫자들
2주 테스트에서 나온 수치들이다. 환경은 Qdrant 로컬, OpenAI text-embedding-3-small, LLM은 gpt-4o-mini, 쿼리 100개 기준이다.
| LangChain 0.3.12 | LlamaIndex 0.11.4 | Haystack 2.9.0 | |
|---|---|---|---|
| 평균 응답시간 | 4.2초 | 1.8초 | 2.1초 |
| p95 응답시간 | 7.8초 | 3.2초 | 3.5초 |
| 메모리 피크 (100 req/min) | 2.4 GB | 1.1 GB | 0.9 GB |
| 체감 검색 정확도 | 중 | 상 | 상 |
| 초기 세팅 시간 | 30분 | 1시간 | 3시간 |
체감 검색 정확도는 팀원 3명이 직접 평가한 거라 완전히 객관적이진 않다. 그리고 이 수치가 모든 환경에서 재현된다고 보장할 수 없다 — 문서 특성이나 쿼리 패턴에 따라 달라질 수 있다.
LangChain의 응답시간과 메모리가 특히 나빴던 건 내가 LCEL을 제대로 최적화 안 했을 수도 있다. 공정하게 비교하려고 공식 문서 따라했는데, 최적화 여지가 있을 수 있다는 점은 인정한다. 다만 “공식 문서 따라 했을 때의 기본 성능”이라는 기준 자체가 의미 있다고 생각한다.
내 선택과 그 이유
결론부터.
5만 개 이하 문서, 작은 팀, 빠르게 출시해야 하는 상황이면 LlamaIndex를 쓴다. 검색 품질이 좋고, 비동기 처리가 자연스럽고, 러닝 커브가 적당하다. LangChain만큼 생태계가 넓진 않지만, RAG에 필요한 건 거의 다 있다.
문서가 많고, 팀이 성장 중이고, 6개월 이상 운영을 생각한다면 Haystack이다. 초기에 시간이 더 들지만, 파이프라인 버전 관리와 모니터링 통합이 나중에 확실히 보답한다. 우리 팀은 결국 이쪽으로 마이그레이션했다.
LangChain은 순수 RAG 단독으로는 잘 안 쓸 것 같다. 에이전트 구현이나 LLM 조합이 복잡한 경우엔 아직 LangChain이 생태계 면에서 우위가 있다. 근데 문서 검색 + 응답 생성 파이프라인만 본다면, 다른 두 개 중 하나가 낫다.
금요일 오후에 Haystack으로 마이그레이션 배포를 했다가 YAML 파이프라인 설정 하나를 잘못 써서 30분 동안 에러를 봤다. 덕분에 주말 내내 마음 한 구석이 불편했는데 — 역설적으로, 그 에러가 명확하게 떠 있었던 덕분에 빠르게 고칠 수 있었다. LangChain이었으면 스택트레이스 뒤지는 데 훨씬 오래 걸렸을 거다.
세 개를 다 써보고 나서 드는 생각은, 어떤 프레임워크를 쓰든 임베딩 모델 선택이랑 청킹 전략이 프레임워크 선택보다 검색 품질에 더 큰 영향을 미친다는 거다. 이건 처음에 생각 못 했던 부분인데, 세 개를 테스트하면서 오히려 이걸 더 많이 배웠다. 프레임워크 비교를 하러 갔다가 엉뚱한 걸 배운 셈이다.