LangChain vs LlamaIndex 실전 아키텍처
두 프레임워크의 설계 철학 차이부터 실제 RAG·에이전트 아키텍처, 흔한 함정과 프로덕션 베스트 프랙티스까지
LLM 앱을 만들다 보면 거의 반드시 마주치는 갈림길이 있다. "LangChain을 쓸까, LlamaIndex를 쓸까?" 검색해 보면 "둘 다 좋아요", "섞어 쓰세요" 같은 맥 빠지는 답이 대부분이다. 하지만 실제로 두 프레임워크는 태어난 목적이 다르고, 그 차이가 코드 구조·디버깅 난이도·확장 방향에 그대로 드러난다. 잘못 고르면 "되긴 되는데 왜 되는지 모르겠고, 고치려면 라이브러리 내부를 뜯어야 하는" 상태에 빠진다.
핵심을 한 줄로 정리하면 이렇다. **LangChain은 "LLM을 중심으로 한 범용 오케스트레이션 프레임워크"**이고, **LlamaIndex는 "데이터를 LLM에 연결하는 것(특히 RAG)에 최적화된 데이터 프레임워크"**다. LangChain은 체인·에이전트·도구·메모리·그래프(LangGraph)까지 폭이 넓고, LlamaIndex는 문서 적재→인덱싱→검색→합성으로 이어지는 RAG 파이프라인의 깊이가 강점이다.
이 가이드는 다음을 다룬다.
| 다루는 내용 | 왜 중요한가 |
|---|---|
| 설계 철학·추상화 비교 | 어느 쪽이 내 문제에 자연스러운지 판단 기준 |
| 최소 RAG를 양쪽으로 구현 | 같은 문제를 두 코드로 보면 차이가 체감됨 |
| 검색/인덱싱 심화 (LlamaIndex 강점) | RAG 품질의 80%는 retrieval에서 갈림 |
| 에이전트·도구·그래프 (LangChain/LangGraph 강점) | 멀티스텝·분기·상태 관리가 필요한 워크플로 |
| 관측성·평가·프로덕션 | "데모는 되는데 운영이 안 되는" 구간 |
| 흔한 함정 모음 | 실제로 시간을 가장 많이 잡아먹는 곳 |
전제: Python 3.9+ 환경, OpenAI 혹은 Anthropic 같은 상용 LLM API를 쓴다고 가정한다. 코드는 2024~2025 기준의 모듈 분리 구조(langchain-core, langchain-openai, llama-index-core 등)를 따른다. 버전은 빠르게 바뀌므로, 정확한 핀(pin) 버전은 본인 환경의 pip show로 확인하고 마이그레이션 노트를 항상 같이 보길 권한다.
1. 설계 철학: 오케스트레이터 vs 데이터 프레임워크
두 프레임워크의 차이를 이해하려면 "이게 무엇의 추상화인가"를 봐야 한다.
LangChain — LLM 호출을 합성(compose)하는 오케스트레이터
LangChain의 1차 추상화는 Runnable이다. 프롬프트, 모델, 파서, 리트리버, 함수까지 전부 Runnable로 통일되어 있고, 파이프 연산자(|)로 합성한다. 이걸 LCEL(LangChain Expression Language)이라고 부른다.
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from langchain_core.output_parsers import StrOutputParser
prompt = ChatPromptTemplate.from_template("{topic}를 한 문장으로 설명해줘")
model = ChatOpenAI(model="gpt-4o-mini")
chain = prompt | model | StrOutputParser() # 전부 Runnable, | 로 합성
chain.invoke({"topic": "벡터 데이터베이스"})
중요한 건, LangChain은 RAG를 "여러 기능 중 하나"로 본다는 점이다. 에이전트, 도구 호출, 메모리, 스트리밍, 병렬 실행, 분기, 라우팅이 모두 1급 시민이다. 폭이 넓은 대신, RAG에 특화된 편의 기능은 직접 조립해야 한다.
LlamaIndex — 데이터를 LLM에 연결하는 데이터 프레임워크
LlamaIndex의 1차 추상화는 Document → Node → Index → Retriever → QueryEngine으로 이어지는 데이터 파이프라인이다. "문서를 어떻게 자르고, 어떻게 인덱싱하고, 어떻게 검색해서, 어떻게 답을 합성하는가"가 핵심 관심사다.
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader
documents = SimpleDirectoryReader("./data").load_data() # 적재
index = VectorStoreIndex.from_documents(documents) # 청킹+임베딩+인덱싱
query_engine = index.as_query_engine() # 검색+합성 엔진
response = query_engine.query("이 문서의 핵심 주장은?")
위 5줄에 LangChain이라면 수동으로 조립해야 할 "로더 → 스플리터 → 임베딩 → 벡터스토어 → 리트리버 → 프롬프트 → 합성"이 전부 합리적 기본값으로 들어 있다. RAG를 빨리, 잘 만드는 데에는 LlamaIndex의 추상화가 더 자연스럽다.
판단 기준 한 줄
- 문제의 중심이 "내 데이터에 대한 질의응답/검색"이면 → LlamaIndex가 자연스럽다
- 문제의 중심이 "여러 LLM 단계·도구·분기·상태를 조율"이면 → LangChain(특히 LangGraph)이 자연스럽다
- 둘 다면? → LlamaIndex로 retrieval 레이어를 만들고, LangGraph로 오케스트레이션을 감싸는 조합이 실전에서 흔하다 (8장 참고)
2. 설치와 패키지 구조: 모놀리식의 종말을 이해하라
두 프레임워크 모두 과거의 거대한 단일 패키지에서 코어 + 통합(integration) 분리 구조로 전환했다. 이 구조를 모르면 설치부터 ImportError로 시간을 날린다.
LangChain의 패키지 분리
pip install langchain-core # Runnable, 프롬프트, 기본 추상화 (의존성 가벼움)
pip install langchain-openai # OpenAI 통합 (ChatOpenAI, OpenAIEmbeddings)
pip install langchain-anthropic # Anthropic 통합 (ChatAnthropic)
pip install langchain-community # 서드파티 로더/벡터스토어 다수
pip install langgraph # 상태 그래프 (에이전트/워크플로)
langchain-core: 안정적 추상화. 직접 import 대상.langchain(메타 패키지): 체인/에이전트 헬퍼. 점점 LangGraph로 이동 중.langchain-community: 통합이 많지만 품질 편차가 크다. 핵심 통합은 전용 패키지(langchain-openai등)로 빠져나가는 추세.- 핵심 함정: 같은 클래스가 여러 경로에서 import 가능해 보이지만, deprecated 경로를 쓰면 경고/동작 차이가 난다. 항상 전용 통합 패키지에서 import하라 (
from langchain_openai import ChatOpenAI, NOTfrom langchain.chat_models import ...).
LlamaIndex의 패키지 분리
pip install llama-index-core # 코어
pip install llama-index-llms-openai # OpenAI LLM
pip install llama-index-embeddings-openai # OpenAI 임베딩
pip install llama-index-vector-stores-chroma # Chroma 벡터스토어
pip install llama-index-readers-file # 파일 로더
# 또는 한 번에 (배치 묶음): pip install llama-index (편의 메타패키지)
- import 경로가 패키지 구조와 1:1로 맞는다:
from llama_index.llms.openai import OpenAI,from llama_index.vector_stores.chroma import ChromaVectorStore. llama-index메타패키지는 자주 쓰는 통합을 묶어 주지만, 프로덕션에서는 필요한 것만 골라 설치해 의존성을 줄이는 편이 낫다.
베스트 프랙티스
1. 버전을 반드시 핀하라. requirements.txt 또는 pyproject에 정확한 버전 명시.
예: langchain-core==<확인한 버전> (런타임에 pip show 로 확인)
2. 코어와 통합 패키지의 버전 호환을 맞춰라. 코어만 올리면 통합이 깨질 수 있다.
3. CI에서 import smoke test를 둬라. (아래)
# tests/test_imports.py — 패키지 분리 깨짐을 조기 발견
def test_core_imports():
from langchain_openai import ChatOpenAI # noqa
from llama_index.core import VectorStoreIndex # noqa
버전 마이그레이션은 두 프레임워크 공통으로 가장 흔한 운영 비용이다. 업그레이드 전에는 반드시 각 프로젝트의 마이그레이션 가이드를 읽고, 작은 통합 테스트로 회귀를 잡아라.
3. 같은 RAG를 양쪽으로 구현해 비교하기
동일한 문제 — "./data 폴더의 문서로 질의응답" — 를 두 프레임워크로 짜 보면 추상화 수준 차이가 손에 잡힌다.
LlamaIndex 버전 (영속 저장 + 재로드까지)
from llama_index.core import (
VectorStoreIndex, SimpleDirectoryReader,
StorageContext, load_index_from_storage, Settings,
)
from llama_index.llms.openai import OpenAI
from llama_index.embeddings.openai import OpenAIEmbedding
# 전역 기본값 설정 (LlamaIndex 0.10+ 패턴: ServiceContext 폐기, Settings 사용)
Settings.llm = OpenAI(model="gpt-4o-mini")
Settings.embed_model = OpenAIEmbedding(model="text-embedding-3-small")
import os
if not os.path.exists("./storage"):
docs = SimpleDirectoryReader("./data").load_data()
index = VectorStoreIndex.from_documents(docs)
index.storage_context.persist("./storage") # 디스크 영속
else:
sc = StorageContext.from_defaults(persist_dir="./storage")
index = load_index_from_storage(sc) # 재빌드 없이 로드
qe = index.as_query_engine(similarity_top_k=4)
print(qe.query("환불 정책 요약해줘"))
주목: 청킹·임베딩·합성 프롬프트·출처(node) 추적이 전부 기본 제공된다. response.source_nodes로 어떤 청크가 근거였는지 바로 볼 수 있다.
LangChain 버전 (LCEL로 명시 조립)
from langchain_community.document_loaders import DirectoryLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
docs = DirectoryLoader("./data", glob="**/*.txt").load()
splits = RecursiveCharacterTextSplitter(
chunk_size=1000, chunk_overlap=150
).split_documents(docs)
vectorstore = Chroma.from_documents(
splits, OpenAIEmbeddings(model="text-embedding-3-small"),
persist_directory="./chroma_db",
)
retriever = vectorstore.as_retriever(search_kwargs={"k": 4})
prompt = ChatPromptTemplate.from_template(
"아래 맥락만 근거로 답하라. 모르면 모른다고 하라.\n\n맥락:\n{context}\n\n질문: {question}"
)
def format_docs(docs):
return "\n\n".join(d.page_content for d in docs)
rag_chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt | ChatOpenAI(model="gpt-4o-mini") | StrOutputParser()
)
print(rag_chain.invoke("환불 정책 요약해줘"))
무엇이 다른가
| 항목 | LlamaIndex | LangChain |
|---|---|---|
| 코드 길이 | 짧다 (기본값 풍부) | 길다 (명시 조립) |
| 제어 granularity | 옵션으로 조정 | 단계마다 손댈 수 있음 |
| 출처 추적 | source_nodes 기본 | 직접 통과시켜야 함 |
| 프롬프트 가시성 | 숨어 있음(커스터마이즈 가능) | 코드에 노출 |
| 학습 곡선(RAG) | 완만 | 다소 가파름 |
언제 어느 쪽? 표준 RAG를 빠르게 세우고 retrieval 튜닝에 집중하려면 LlamaIndex. 검색 결과를 다른 LLM 단계와 비표준적으로 엮거나, 프롬프트/파서를 완전 통제하려면 LangChain의 명시성이 유리하다.
4. 인덱싱·검색 심화 (LlamaIndex가 빛나는 곳)
RAG 품질은 대부분 retrieval 단계에서 갈린다. LlamaIndex는 이 영역의 도구가 깊다.
노드 파서/청킹 전략
from llama_index.core.node_parser import (
SentenceSplitter, # 문장 경계 보존 청킹
SentenceWindowNodeParser, # 문장 단위 인덱싱 + 주변 윈도우 보강
)
# 문장 윈도우: 작은 단위로 검색하되, 합성 시엔 앞뒤 맥락을 붙여 줌
parser = SentenceWindowNodeParser.from_defaults(
window_size=3,
window_metadata_key="window",
original_text_metadata_key="original_text",
)
청크가 너무 크면 노이즈가 끼고, 너무 작으면 맥락이 끊긴다. "작게 검색하고 크게 합성한다"(small-to-big)는 LlamaIndex가 sentence-window, auto-merging 같은 패턴으로 잘 지원한다.
하이브리드 검색 + 리랭킹 (실전 품질의 핵심)
from llama_index.core.postprocessor import SentenceTransformerRerank
rerank = SentenceTransformerRerank(
model="cross-encoder/ms-marco-MiniLM-L-6-v2", top_n=3
)
# 벡터 검색으로 top_k=20을 넓게 가져온 뒤, 리랭커로 top_n=3만 남김
qe = index.as_query_engine(
similarity_top_k=20,
node_postprocessors=[rerank],
)
패턴: 넓게 회수(high recall) → 리랭킹으로 정밀화(high precision). 단순 top-k만 키우면 컨텍스트에 노이즈가 늘어 오히려 답이 나빠진다. 리랭커를 붙이는 게 거의 항상 이득이다.
메타데이터 필터링
from llama_index.core.vector_stores import MetadataFilters, MetadataFilter
filters = MetadataFilters(filters=[
MetadataFilter(key="category", value="billing"),
MetadataFilter(key="lang", value="ko"),
])
qe = index.as_query_engine(filters=filters)
적재 시 노드 메타데이터(doc.metadata = {...})를 잘 심어 두면, 검색 공간을 미리 좁혀 정확도와 비용을 동시에 잡는다.
LangChain 쪽 동급 기능
LangChain도 가능은 하다. MultiQueryRetriever(질문을 여러 변형으로 확장), ContextualCompressionRetriever(검색 후 압축/리랭킹), EnsembleRetriever(BM25 + 벡터 하이브리드) 등이 있다.
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
bm25 = BM25Retriever.from_documents(splits); bm25.k = 5
vect = vectorstore.as_retriever(search_kwargs={"k": 5})
hybrid = EnsembleRetriever(retrievers=[bm25, vect], weights=[0.4, 0.6])
결론: 두 프레임워크 모두 고급 retrieval이 되지만, "기본값의 품질"과 "패턴의 완성도"는 LlamaIndex가 앞선다. RAG 품질이 제품의 생명선이라면 LlamaIndex를 retrieval 엔진으로 두는 선택이 합리적이다.
함정: 리랭커 모델(cross-encoder)은 별도 다운로드/추론 비용이 든다. 서버리스/저메모리 환경에서는 로컬 cross-encoder 대신 호스팅 리랭킹 API를 쓰는 편이 운영이 단순하다.
5. 에이전트·도구·상태 (LangChain/LangGraph가 빛나는 곳)
"검색만"이 아니라 "여러 도구를 부르고, 결과에 따라 분기하고, 상태를 들고 다니는" 워크플로가 되면 무게중심이 LangChain — 특히 LangGraph — 로 넘어간다.
왜 LangGraph인가: 초기 LangChain의 AgentExecutor는 내부 루프가 블랙박스라 디버깅·중단·재개·휴먼인더루프가 어려웠다. LangGraph는 워크플로를 **명시적 상태 그래프(노드 + 엣지 + 공유 state)**로 모델링해, 각 스텝을 들여다보고 제어할 수 있게 한다.
from langgraph.graph import StateGraph, START, END
from typing import TypedDict, Annotated
import operator
class State(TypedDict):
question: str
context: str
answer: str
steps: Annotated[list, operator.add] # 누적 병합 (reducer)
def retrieve(state: State):
docs = retriever.invoke(state["question"])
return {"context": format_docs(docs), "steps": ["retrieve"]}
def generate(state: State):
ans = rag_chain_core.invoke(state) # context+question -> answer
return {"answer": ans, "steps": ["generate"]}
def needs_more(state: State):
return "retrieve" if len(state["context"]) < 200 else "generate"
g = StateGraph(State)
g.add_node("retrieve", retrieve)
g.add_node("generate", generate)
g.add_edge(START, "retrieve")
g.add_conditional_edges("retrieve", needs_more, {"retrieve": "retrieve", "generate": "generate"})
g.add_edge("generate", END)
app = g.compile()
app.invoke({"question": "환불은 며칠 걸려?", "steps": []})
LangGraph의 실전 강점
- 명시적 제어 흐름: 분기/루프/재시도가 그래프로 보인다.
- 체크포인팅 + 휴먼인더루프:
checkpointer로 상태를 저장해 중단/재개, 사람 승인 후 재개가 가능하다. - 멀티 에이전트: 서브그래프로 "supervisor가 워커를 라우팅"하는 구조를 깔끔히 만든다.
- 스트리밍: 노드별 중간 결과/토큰 스트리밍이 1급 지원.
도구 호출 (tool calling)
from langchain_core.tools import tool
@tool
def get_order_status(order_id: str) -> str:
"""주문 ID로 배송 상태를 조회한다."""
return db.lookup(order_id)
llm_with_tools = ChatOpenAI(model="gpt-4o-mini").bind_tools([get_order_status])
@tool 데코레이터가 함수 시그니처/독스트링에서 스키마를 자동 생성한다. LangGraph의 prebuilt(예: ReAct 스타일 에이전트)와 결합하면 도구 루프를 빠르게 세울 수 있다.
LlamaIndex의 에이전트는? LlamaIndex에도 FunctionAgent/워크플로 등 에이전트 기능이 있고, 특히 여러 인덱스를 도구로 노출하는 RAG 에이전트(예: 문서별 query engine을 tool로 묶어 라우팅)에 강하다. 다만 임의의 비-RAG 도구·복잡한 분기·멀티 에이전트 오케스트레이션의 성숙도와 제어력은 LangGraph가 더 깊다.
판단 기준: 워크플로가 "검색 도구들 사이의 라우팅" 수준이면 LlamaIndex로 충분. "외부 시스템 호출 + 조건 분기 + 상태 + 사람 승인 + 멀티 에이전트"면 LangGraph.
6. 프롬프트·합성·출력 제어
RAG에서 검색만큼 중요한 게 합성(synthesis) 단계의 프롬프트와 출력 구조다.
LlamaIndex — response_mode와 프롬프트 템플릿
LlamaIndex는 여러 청크를 어떻게 LLM에 먹일지 response_mode로 제어한다.
qe = index.as_query_engine(
response_mode="compact", # 청크를 컨텍스트 한도까지 묶어 호출 (기본·효율적)
# "refine" : 청크마다 순차 호출하며 답을 점진 개선 (정확하지만 호출 多)
# "tree_summarize": 계층적 요약 (긴 문서 요약에 적합)
)
기본 합성 프롬프트를 바꾸려면:
from llama_index.core import PromptTemplate
qa_tmpl = PromptTemplate(
"맥락 정보:\n{context_str}\n\n"
"위 맥락만 근거로, 한국어로 간결히 답하라. 추측 금지.\n"
"질문: {query_str}\n답:"
)
qe.update_prompts({"response_synthesizer:text_qa_template": qa_tmpl})
함정: LlamaIndex의 기본 프롬프트는 보이지 않게 동작하므로, 출력 톤·언어·환각 억제가 마음에 안 들면 "내부 프롬프트가 영어 기본일 수 있다"는 점을 의심하고 위처럼 명시 교체하라.
구조화 출력 (둘 다 지원, 방식이 다름)
LangChain — Pydantic 스키마로 구조화:
from pydantic import BaseModel, Field
class Answer(BaseModel):
summary: str = Field(description="한 문장 요약")
confidence: float = Field(description="0~1")
sources: list[str]
structured_llm = ChatOpenAI(model="gpt-4o-mini").with_structured_output(Answer)
structured_llm.invoke("환불 정책을 요약하고 출처를 들어라")
LlamaIndex — 출력 클래스 지정:
from llama_index.core.program import LLMTextCompletionProgram
program = LLMTextCompletionProgram.from_defaults(
output_cls=Answer,
prompt_template_str="질문에 답하라: {query}",
)
result: Answer = program(query="환불 정책 요약")
베스트 프랙티스
- 환각을 줄이려면 합성 프롬프트에 "맥락에 없으면 모른다고 답하라"를 명시하고, 가능한 한 출처(node/문서 id)를 함께 반환시켜라.
- 구조화 출력은 가능하면 모델의 네이티브 함수/JSON 모드(
with_structured_output, tool calling)를 쓰는 게 정규식 파싱보다 안정적이다. - 한국어 서비스라면 기본 프롬프트 언어를 반드시 점검하라. 영어 기본 프롬프트 + 한국어 질의 조합에서 답변 톤이 어색해지는 경우가 잦다.
7. 관측성·평가·디버깅
데모와 프로덕션을 가르는 건 "왜 이 답이 나왔는지 추적 가능한가"다.
트레이싱
- LangChain/LangGraph → LangSmith: 환경변수만으로 모든 체인/그래프 실행이 트레이스로 캡처된다.
export LANGCHAIN_TRACING_V2=true
export LANGCHAIN_API_KEY=<key>
export LANGCHAIN_PROJECT=my-rag
이후 별도 코드 없이 invoke만 해도 단계별 입력/출력/토큰/지연이 기록된다. 분기·도구 호출이 많은 LangGraph 디버깅에 특히 강력하다.
- LlamaIndex → 콜백/인스트루먼테이션 + 서드파티: LlamaIndex는 instrumentation 이벤트를 노출하고, 여러 관측 도구(예: Arize Phoenix 등 OpenTelemetry 기반)와 연동된다. LangSmith로도 LlamaIndex 실행을 트레이스할 수 있다(콜백 핸들러 연결). 벤더 종속을 피하려면 OpenTelemetry 기반 도구를 권장.
평가(eval) — 추측 말고 측정하라
RAG의 두 축을 분리해 측정해야 한다.
| 축 | 무엇을 보나 | 대표 지표 |
|---|---|---|
| Retrieval | 관련 청크를 가져왔나 | hit rate, MRR, context recall |
| Generation | 가져온 맥락에 충실히 답했나 | faithfulness(환각), answer relevancy |
# LlamaIndex 내장 평가기 예시
from llama_index.core.evaluation import FaithfulnessEvaluator
ev = FaithfulnessEvaluator(llm=Settings.llm)
resp = qe.query("환불은 며칠?")
result = ev.evaluate_response(response=resp) # 맥락 대비 환각 여부
print(result.passing, result.score)
LangChain은 LangSmith의 데이터셋 + 평가기로 회귀 테스트를 돌린다. 프레임워크 무관하게 RAGAS 같은 전용 평가 라이브러리를 붙여 faithfulness/context-recall을 정량화하는 것도 표준 관행이다.
디버깅 베스트 프랙티스
1. 항상 source_nodes / retrieved docs를 로깅하라. 답이 틀리면
대개 "검색이 틀렸나" vs "합성이 틀렸나"부터 갈라야 한다.
2. 검색만 따로 떼어 평가하라 (LLM 없이 retriever만 호출).
3. 골든 질문셋 20~50개를 CI에 박아 회귀를 자동 검출하라.
4. 토큰/지연/비용을 트레이스로 모니터링 — top_k를 키울 때
비용이 선형 이상으로 뛰는 지점을 본다.
핵심: "왜 틀렸지"를 retrieval/generation 둘로 분해하지 않으면 끝없이 프롬프트만 만지게 된다.
8. 둘을 함께 쓰는 실전 하이브리드 아키텍처
현장에서 가장 실용적인 답은 종종 "둘 중 하나"가 아니라 **"각자의 강점 레이어를 합치는 것"**이다.
패턴 A — LlamaIndex로 retrieval, LangGraph로 오케스트레이션
RAG 품질은 LlamaIndex의 인덱싱/리랭킹으로 끌어올리고, 멀티스텝 워크플로·도구·분기·휴먼인더루프는 LangGraph로 감싼다. LlamaIndex의 query engine을 LangChain 도구로 감싸면 된다.
from langchain_core.tools import tool
# LlamaIndex로 만든 고품질 query engine을 LangChain 도구로 노출
@tool
def search_docs(query: str) -> str:
"""사내 문서에서 관련 내용을 검색해 답한다."""
resp = qe.query(query) # qe = LlamaIndex query engine
cites = [n.node.node_id for n in resp.source_nodes]
return f"{resp}\n\n[출처: {', '.join(cites)}]"
# 이 도구를 LangGraph 에이전트/노드에서 호출
llm_with_tools = ChatOpenAI(model="gpt-4o-mini").bind_tools([search_docs])
이러면 "검색 정밀도"와 "흐름 제어"를 각각 가장 잘하는 도구로 분담한다.
패턴 B — LangChain 리트리버 인터페이스로 통합
LlamaIndex 인덱스를 LangChain의 BaseRetriever로 어댑트해, 기존 LCEL 체인에 그대로 끼우는 방식도 가능하다(커뮤니티 통합/얇은 래퍼 사용). 기존 LangChain 코드베이스에 LlamaIndex 검색만 이식할 때 유용하다.
경계를 명확히 하라 (아키텍처 규칙)
┌─────────────────────────────────────────────┐
│ Orchestration Layer (LangGraph) │ ← 분기/도구/상태/HITL
│ - 사용자 의도 라우팅 │
│ - 도구 호출 / 외부 시스템 │
├─────────────────────────────────────────────┤
│ Retrieval Layer (LlamaIndex) │ ← 인덱싱/검색/리랭킹/합성
│ - 청킹 전략, 하이브리드 검색, 리랭커 │
├─────────────────────────────────────────────┤
│ Storage (Vector DB + Metadata + Cache) │
└─────────────────────────────────────────────┘
함정 / 주의
- 두 프레임워크의
Document·Node객체 모델이 다르다. 경계에서 명시적 변환을 두고, 양쪽 객체를 섞어 흘리지 마라. - 의존성 충돌(특히 pydantic 메이저 버전, 공통 하위 라이브러리)에 주의. 가상환경에서 두 패키지군 버전 호환을 먼저 검증하고 핀하라.
- 하이브리드는 강력하지만 표면적이 늘어난다. **"정말 둘 다 필요한가"**를 먼저 자문하라. 단순 RAG라면 한쪽으로 충분하다.
9. 흔한 함정 모음 (실전 디버깅 체크리스트)
실제로 시간을 가장 많이 잡아먹는 지점들이다. 증상 → 원인 → 해결 형태로 정리한다.
[공통] 버전·import 경로 함정
- 증상:
ImportError,DeprecationWarning, 같은 클래스가 여러 경로. - 원인: 모놀리식 → 분리 패키지 전환 과도기. deprecated 경로 잔존.
- 해결: 전용 통합 패키지에서만 import. 버전 핀. CI에 import smoke test.
[공통] 인덱스 재빌드 비용 폭발
- 증상: 매 요청/재시작마다 임베딩을 다시 만들어 비용·지연 급증.
- 원인: 영속 저장 미사용. 메모리 인덱스를 매번 재생성.
- 해결: LlamaIndex는
persist/load_index_from_storage, LangChain은 벡터스토어persist_directory로 디스크 영속. 운영은 관리형 벡터 DB.
[LlamaIndex] 숨은 기본 프롬프트가 영어/원하지 않는 톤
- 증상: 한국어 질의인데 답 구조·톤이 어색, 환각 억제 안 됨.
- 원인: 내부 합성 프롬프트가 보이지 않게 적용됨.
- 해결:
query_engine.get_prompts()로 확인 후update_prompts로 교체. "맥락에 없으면 모른다" 명시.
[LlamaIndex] ServiceContext 코드 따라 했는데 안 됨
- 증상: 오래된 튜토리얼의
ServiceContext가 동작 안 함. - 원인: 0.10+에서
ServiceContext폐기, 전역Settings로 대체. - 해결:
Settings.llm,Settings.embed_model사용. 항상 최신 문서 기준 코드 확인.
[LangChain] AgentExecutor 무한 루프/블랙박스
- 증상: 에이전트가 같은 도구를 반복 호출, 왜 그러는지 안 보임.
- 원인: 구형 AgentExecutor의 불투명 루프.
- 해결: LangGraph로 마이그레이션해 분기·재시도·최대 스텝을 명시 제어.
recursion_limit설정.
[LangChain] 출처가 사라짐
- 증상: 답은 나오는데 어떤 문서가 근거였는지 모름.
- 원인: LCEL 체인에서 retrieved docs를 통과시키지 않음.
- 해결:
RunnableParallel로{answer, context}를 함께 반환해 출처 보존.
[공통] top_k만 키워서 답이 더 나빠짐
- 증상: 컨텍스트 늘렸는데 환각·산만함 증가.
- 원인: recall은 올랐으나 precision 하락, 노이즈 유입.
- 해결: 넓게 회수 후 리랭커로 정밀화. 컨텍스트는 양보다 질.
[공통] 비동기/스트리밍 혼선
- 증상:
ainvoke/astream안에서 동기 블로킹 호출로 이벤트 루프 정지. - 원인: 동기/비동기 API 혼용.
- 해결: 경로를 async로 일관. LangGraph/LlamaIndex 모두 async 인터페이스를 끝까지 사용.
[공통] 평가 없이 프롬프트만 튜닝
- 증상: 고쳐도 나아졌는지 모르고 회귀 발생.
- 원인: 골든셋·지표 부재.
- 해결: retrieval/generation 분리 평가 + CI 회귀 테스트.
10. 의사결정 가이드와 마이그레이션 전략
정리. "무엇을 언제"를 한 화면에 모은다.
빠른 결정 표
| 상황 | 1순위 | 이유 |
|---|---|---|
| 내 문서 Q&A / 챗봇 빠르게 | LlamaIndex | RAG 기본값·출처추적이 풍부 |
| 검색 품질이 제품 생명선 | LlamaIndex | 청킹/하이브리드/리랭킹 성숙 |
| 멀티스텝·도구·분기·상태 | LangGraph | 명시적 그래프·체크포인팅·HITL |
| 멀티 에이전트 오케스트레이션 | LangGraph | supervisor/서브그래프 패턴 |
| 둘 다 (검색 + 복잡 워크플로) | 하이브리드 | LlamaIndex 검색 + LangGraph 흐름 |
| 기존 LangChain 코드에 검색만 강화 | LangChain + LlamaIndex 리트리버 | 점진적 이식 |
| 프로토타입 최단 시간 | 둘 다 가능 | 문제 형태에 따라 |
팀 관점 추가 고려
- 관측성을 LangSmith로 표준화할 거면 LangChain 생태계가 매끄럽다. 벤더 중립을 원하면 OpenTelemetry 기반(예: Phoenix) + LlamaIndex 조합.
- 두 프레임워크 모두 추상화를 끝까지 신뢰하지 말고, 핵심 경로(검색·합성·도구 호출)는 직접 들여다볼 수 있게 로깅/트레이싱을 깔아라.
마이그레이션·교체 전략
1. retrieval 레이어를 인터페이스로 격리하라.
- search(query) -> [{text, score, source}] 같은 자체 인터페이스를 두고
내부 구현(LangChain/LlamaIndex)을 갈아끼울 수 있게 한다.
2. 프롬프트·합성 로직을 프레임워크 밖 함수로 빼라.
- 프레임워크 교체 시 재사용 가능.
3. 골든 질문셋 + 평가 지표를 먼저 만들어라.
- 교체 전후 품질 회귀를 정량 비교. "느낌"으로 바꾸지 마라.
4. 한 번에 통째로 갈지 말고, 레이어 단위로 점진 교체.
- 예: 검색만 LlamaIndex로, 오케스트레이션은 그대로.
마지막 원칙: 프레임워크는 수단이다. 두 라이브러리 모두 빠르게 진화하므로, 버전을 핀하고, 마이그레이션 노트를 읽고, 자체 평가셋으로 회귀를 막는 운영 습관이 "LangChain이냐 LlamaIndex냐"보다 제품 수명에 더 큰 영향을 준다. 작게 시작해 측정 가능한 채로 키워라.