TL;DR — RAG는 로드 → 분할 → 임베딩/저장 → 검색 → 생성의 파이프라인이며 앞 셋은 사전 1회(인덱싱), 뒤 둘은 매 질문(질의)으로 시점이 다릅니다. 1.0에서는 분할이
langchain-text-splitters 별도 패키지로,
고급 Retriever(MultiQuery·Ensemble·ContextualCompression·SelfQuery) 다수가
langchain-classic으로 이동했습니다. 생성 단계는 검색을 항상 하는 2-step LCEL과, LLM이 검색 시점을 스스로 정하는 Agentic RAG 두 갈래로 갈립니다. 옛 RetrievalQA는 이 둘로 대체됩니다.
이 글은 RAG를 한 번쯤 만들어 봤지만 검색 품질에서 막힌 개발자가 LangChain 1.0 기준으로 검색 단계를 끌어올리는 선택지(고급 Retriever·증분 인덱싱·하이브리드 검색)를 이해하도록 돕습니다. 나아가 2-step LCEL과 Agentic RAG 중 무엇을 언제 쓸지의 기준을 얻게 합니다.
이 글에서 다루는 것
- RAG 5단계 파이프라인과 인덱싱 vs 질의 분리
- 로드·분할·임베딩/벡터스토어·기본 검색(similarity/MMR)
- 고급 Retriever 4종과 ParentDocumentRetriever
- 증분 인덱싱(SQLRecordManager+index)과 RAG 두 갈래(2-step LCEL vs Agentic)
이 글에서 다루지 않는 것
- create_agent 자체의 상세 (4편에서)
- 관측·평가(LangSmith)와 RAG 품질 측정 (6편에서)
- 특정 벡터DB의 운영·튜닝 상세
1. 왜 RAG인가 · 파이프라인 개요
LLM은 학습 시점 이후의 사실이나 사내 문서를 모릅니다. 그렇다고 모델을 다시 학습시키는 건 비싸고 느립니다. RAG(Retrieval-Augmented Generation)는 그 사이를 메우는 방법입니다. 질문과 관련된 외부 문서를 먼저 검색해 프롬프트에 끼워 넣은 뒤 그 문맥만 근거로 답하게 합니다. 목적은 두 가지로 요약됩니다 — 환각 감소와 출처 추적입니다.
RAG를 도서관 사서에 비유하면 이해가 쉽습니다. 사서는 모든 책을 외우지 않습니다. 대신 질문을 듣고(질의) → 색인에서 관련 책을 찾아(검색) → 그 책을 근거로 답합니다(생성). 그런데 책을 찾으려면 누군가 먼저 책을 분류하고 색인 카드를 만들어(인덱싱) 두었어야 합니다. 이 "미리 한 번 해두는 인덱싱"과 "질문마다 매번 하는 질의"가 시점이 다르다는 점이 RAG 설계의 출발점입니다.
전체 파이프라인은 다섯 단계입니다. 앞 셋은 사전에 1회, 뒤 둘은 매 질문마다 돕니다. 이 경계를 그림으로 먼저 봅니다.
이 글은 이 다섯 단계를 순서대로 따라가되, 단순히 "되게 만드는" 수준을 넘어 production 품질을 가르는 지점(분할 균형·하이브리드 검색·증분 인덱싱·생성 갈래 선택)에 무게를 둡니다. 앞 편(04 create_agent)에서 본 에이전트가 RAG와 만나는 지점이 바로 8절의 Agentic RAG입니다.
2. 로드 — 출처가 무엇이든 Document로 표준화
첫 단계는 PDF·웹페이지·DB 등 제각각인 원본을 하나의 형식으로 표준화하는 것입니다. LangChain은 문서 로더로 이를 처리합니다. 결과는 출처와 무관하게 모두 Document 객체로 나옵니다. 로더는 1.0에서 대부분 langchain-community에 있습니다.
Document는 본문인 page_content와 부가 정보인 metadata 두 부분으로 이뤄집니다. 이렇게 한 형식으로 통일해 두면 이후 분할·검색 단계가 PDF인지 웹인지 신경 쓰지 않고 동일하게 동작합니다. 특히 metadata 보존이 출처 추적의 토대입니다 — 나중에 "이 답이 어느 문서 몇 페이지에서 왔는지"를 인용으로 돌려주려면(8절) 여기서 출처 정보가 보존돼야 합니다.
3. 분할 — 청크 크기로 정확도와 맥락을 저울질
문서 전체를 통째로 임베딩하면 검색이 부정확해집니다. 그래서 적당한 크기의 청크(chunk)로 쪼갭니다. 1.0에서 분할 도구는 langchain-text-splitters 별도 패키지에 있습니다(옛 langchain.text_splitter 경로는 이동됨).
RecursiveCharacterTextSplitter는 문단·줄·단어 같은 구분자 목록을 큰 단위부터 차례로 시도해, 의미 단위를 최대한 보존하며 쪼갭니다(권장). 반면 CharacterTextSplitter는 단일 구분자만 씁니다. 핵심 파라미터인 chunk_size와 chunk_overlap은 임베딩 정확도와 맥락 보존을 저울질하는 손잡이입니다. 청크가 작으면 검색은 정밀해지지만 맥락이 잘립니다. 반대로 크면 맥락은 풍부하지만 의미가 흐려집니다. 위 값은 출발점일 뿐, 문서 성격에 맞춰 조정해야 합니다. 이 트레이드오프를 구조로 해결하는 방법이 5절의 ParentDocumentRetriever입니다.
📐 데이터과학 메모 · 문자 vs 토큰 — chunk_size=1000은 토큰이 아니라 문자 수 기준입니다(기본 length_function=len). 임베딩 모델의 토큰 한도·비용에 맞추려면 RecursiveCharacterTextSplitter.from_tiktoken_encoder(...)로 토큰 기준 분할을 씁니다. 특히 한글은 토큰당 문자 수가 영어와 달라, 문자 길이만 믿으면 임베딩 입력 한도를 넘기기 쉽습니다.
4. 임베딩 & 벡터스토어 생태계
청크를 검색 가능하게 만들려면 임베딩(텍스트 → 벡터) 후 벡터스토어에 저장합니다. 임베딩 초기화도 모델과 마찬가지로 프로바이더 무관 진입점인 init_embeddings를 씁니다(1편의 init_chat_model과 같은 패턴).
임베딩 선택의 두 손잡이 — text-embedding-3 계열은 dimensions 인자로 차원(기본 1536)을 줄여 저장·검색 비용을 낮출 수 있습니다. 단 벡터스토어 인덱스의 차원과 유사도 metric(보통 코사인)은 임베딩과 반드시 일치시켜야 합니다. 어긋나면 인덱싱 자체가 깨집니다.
벡터스토어는 용도에 따라 계층이 나뉩니다. 프로토타이핑이면 메모리 내장형으로 충분합니다. 운영 규모에서는 전용 파트너 패키지를 씁니다. 무엇을 쓰든 표준 인터페이스를 공유한다는 점이 LangChain의 이점입니다.
| 계층 | 예 | 패키지 |
|---|---|---|
| 프로토타이핑 | InMemoryVectorStore | langchain-core |
| 커뮤니티 | Chroma · FAISS | langchain-community |
| 전용 파트너 | Pinecone · Qdrant · Milvus · PGVector · Redis | langchain-pinecone · langchain-qdrant · langchain-milvus · langchain-postgres · langchain-redis |
어느 벡터스토어든 .as_retriever() · add_documents · similarity_search · max_marginal_relevance_search 같은 동일한 메서드를 제공합니다. 덕분에 프로토타입은 InMemory로 시작합니다. 운영에서는 코드를 거의 바꾸지 않고 전용 스토어로 갈아탈 수 있습니다. 이 표준 인터페이스 위에서 다음 절의 검색 전략이 동작합니다.
5. 기본 검색(similarity · MMR)과 ParentDocumentRetriever
검색의 기본형은 벡터스토어를 retriever로 바꿔 질문을 넣는 것입니다. 이때 search_type으로 전략을 고릅니다. similarity는 가장 유사한 청크를 그대로, mmr(Maximal Marginal Relevance)은 유사하면서도 서로 겹치지 않게 다양성을 확보합니다.
k는 최종 반환 개수, fetch_k는 다양성 계산 전 후보 개수, lambda_mult은 유사도와 다양성의 가중치입니다(1.0=순수 유사도, 0.0=다양성 최대, 0.5=절충). 호출은 .invoke로 합니다 — 옛 자료의 get_relevant_documents는 폐기됐습니다. retriever 역시 1편에서 본 Runnable이라 LCEL 파이프에 그대로 끼워 넣을 수 있는데, 그 점이 8절의 2-step RAG에서 빛을 발합니다.
3절에서 본 청크 크기의 딜레마 — 작으면 정밀하지만 맥락 손실, 크면 맥락은 풍부하지만 의미 부정확 — 를 구조로 푸는 것이 ParentDocumentRetriever입니다. 핵심 아이디어는 분리입니다. 검색은 작은 청크로 정밀하게 수행합니다. 그리고 모델에 넘길 때는 그 청크가 속한 큰 부모 문서를 반환합니다. 부모 문서는 별도의 docstore(InMemoryStore)에 보관합니다.
이렇게 하면 "정밀 검색"과 "충분한 맥락"을 동시에 얻습니다. 다만 1.0에서 이 retriever는 langchain-classic으로 이동했으므로 별도 설치가 필요합니다. 또 한 가지 주의점은 이름입니다 — 여기서 부모 문서를 담는 InMemoryStore(docstore)는 3편 LangGraph의 장기 메모리 Store와는 다른 개념입니다. 이름만 비슷할 뿐 역할이 다릅니다.
6. 고급 Retriever — production 품질의 핵심
RAG의 답변 품질은 대부분 검색 품질에서 결정됩니다. 기본 유사도 검색만으로 부족할 때 쓰는 네 가지 고급 Retriever가 있습니다. 각각 재현율·정밀도·필터링 중 노리는 지점이 다릅니다.
| 기법 | 효과 | 핵심 동작 |
|---|---|---|
| MultiQueryRetriever | 재현율 ↑ | LLM이 다관점 질의로 확장 → 결과 합집합 |
| EnsembleRetriever | 하이브리드 | BM25(키워드) + dense(의미)를 가중 RRF로 결합 |
| ContextualCompression + Reranker | 정밀도 ↑ | 넓게 recall → cross-encoder로 재순위 |
| SelfQueryRetriever | 메타 필터 | 자연어 → 메타데이터 필터 자동 변환 |
실무에서 가장 효과가 큰 것은 EnsembleRetriever의 하이브리드 검색입니다. 의미 기반 dense 검색은 "휴가 며칠?"을 잘 잡습니다. 다만 정확한 제품 코드나 약어 같은 키워드는 놓치기 쉽습니다. 그 빈틈을 BM25(키워드 검색)가 메웁니다. 그렇게 모인 두 결과를 RRF(Reciprocal Rank Fusion)로 합칩니다. 마무리로 ContextualCompression + Reranker를 얹으면, 넓게 회수한 뒤 cross-encoder로 다시 정렬해 상위 정밀도를 끌어올립니다. 실무에서 reranker 자리에는 보통 Cohere CohereRerank나 오픈소스 cross-encoder(CrossEncoderReranker+BGE 계열)를 끼웁니다. 또 EnsembleRetriever의 RRF(Reciprocal Rank Fusion)는 점수가 아닌 순위로 합치므로, dense·BM25의 점수 스케일이 달라도 안전하게 섞입니다.
여기서 import 경로가 중요합니다. 이 네 Retriever는 1.0에서 대부분 langchain-classic으로 옮겨졌고, 하이브리드의 한 축인 BM25Retriever는 langchain-community에 있습니다. 옛 자료의 langchain.retrievers 경로 그대로 쓰면 import가 안 되는 이유가 이것입니다. 모든 기법을 한꺼번에 쓸 필요는 없습니다 — 검색이 못 잡는 게 키워드인지, 후보가 넓어 정밀도가 낮은지, 메타데이터 조건인지 진단한 뒤 맞는 도구를 더하는 게 순서입니다.
7. 증분 인덱싱 — 매번 전체 재임베딩을 피한다
문서는 한 번 넣고 끝이 아니라 계속 추가·수정·삭제됩니다. 그때마다 전체를 다시 임베딩하면 비용도 시간도 낭비입니다. 증분 인덱싱은 콘텐츠 해시로 무엇이 새로 생기고 바뀌고 사라졌는지를 추적해, 변경분만 반영합니다.
SQLRecordManager가 각 청크의 해시를 기록해 두고, index()가 이전 상태와 비교해 중복은 건너뛰고 변경·삭제만 처리합니다. cleanup 모드는 "incremental"(같은 source의 옛 버전만 정리)과 "full"(이번에 없는 것 전부 삭제)로 나뉩니다. 이는 데이터 신선도와 임베딩 비용을 동시에 잡는 운영의 기본기입니다. 이 API들도 langchain-classic에 있다는 점을 기억해 둡니다.
8. RAG 구성 — 2-step LCEL vs Agentic RAG
검색까지 준비됐으면 마지막은 생성입니다. 여기서 길이 두 갈래로 갈립니다. 항상 검색하는 고정 파이프라인(2-step LCEL)이냐, LLM이 검색 시점을 스스로 판단하는 에이전트(Agentic RAG)냐입니다. 둘을 나란히 봅니다.
2-step LCEL은 1편·2편에서 본 파이프(|) 그대로입니다 — retriever로 문맥을 만들고 프롬프트·모델·파서를 잇습니다. 검색이 항상 일어나므로 단순하고 빠릅니다. 반면 Agentic RAG는 4편에서 본 create_agent에 retriever를 도구로 넘깁니다. 핵심은 create_retriever_tool이 core(비폐기)라는 점입니다. 어떤 길을 택할지는 아래 기준으로 갈립니다.
| 기준 | 2-step RAG | Agentic RAG |
|---|---|---|
| 검색 | 항상 강제 | 필요할 때만(생략/반복/조합) |
| 지연 · 비용 | 낮음 | 높음(판단 단계 추가) |
| 리스크 | 적음 | 도구 선택 오류 → description이 중요 |
여기에 두 가지 실전 보강이 따라옵니다. 첫째, 대화형 RAG(멀티턴)입니다. "그건 며칠인데?" 같은 후속 질문은 대명사 해소가 안 되면 검색이 빗나갑니다. create_history_aware_retriever(langchain-classic)로 대화 맥락을 반영해 질문을 먼저 독립형으로 재작성한 뒤 검색합니다. 둘째, 출처/인용 반환입니다.
RAG의 두 목적 중 하나가 출처 추적이었던 만큼, 답변과 함께 근거를 돌려줘야 신뢰할 수 있습니다. 1편에서 본 with_structured_output으로 답과 인용을 한 스키마로 강제하는 것이 현행 방식입니다.
2절에서 metadata를 보존하라고 한 이유가 여기서 회수됩니다 — citations에 담길 source id가 그 metadata에서 옵니다. 이 방식이 레거시의 RetrievalQA(return_source_documents=True)보다 권장됩니다.
🕰️ 레거시 RAG — 옛 자료의 RetrievalQA는 0.1.17에서 deprecated되어 langchain-classic으로 이동했고 입력/출력 키도 다릅니다. 후계인 create_retrieval_chain(+create_stuff_documents_chain, 둘 다 classic)은 입력키 input, 출력키 answer/context를 씁니다. 신규 코드는 위 2-step LCEL 또는 Agentic RAG를 쓰는 게 맞습니다.
시리즈: 01 시작하기 → 02 LCEL & Runnable → 03 LangGraph → 04 create_agent → 05 현대 RAG → 06 LangSmith
9. 정리 · 다음 편
현대 RAG는 "되게 만드는 것"과 "잘 되게 만드는 것" 사이의 거리로 정의됩니다. 그 거리를 메우는 지점은 대부분 검색 단계에 있습니다 — 청크 분리(ParentDocument), 하이브리드(BM25+dense), 재순위(Reranker), 그리고 인덱싱 비용을 관리하는 증분 인덱싱. 생성 단계에서는 항상 검색하는 2-step LCEL과 스스로 판단하는 Agentic RAG 중 요구에 맞는 길을 고르면 됩니다. 그런데 "잘 되고 있는지"는 어떻게 알 수 있을까요. 검색 품질은 context precision/recall, 생성이 근거에 충실한지는 faithfulness 같은 지표(RAGAS·LangSmith evaluator)로 정량화합니다. 다음 편에서는 검색·생성·비용을 추적하고 품질을 측정하는 LangSmith — 트레이싱·평가·비용 관리를 봅니다.
- langchain-ai/langchain — 공식 저장소(소스·릴리스·CHANGELOG)
- LangChain — Retrieval(검색) 가이드
- LangChain — Structured output(구조화 출력)
'LangChain' 카테고리의 다른 글
| LLM 앱 운영 5종 세트 — 트레이싱·Async 콜백·LLM-as-judge·usage_metadata·캐싱 (0) | 2026.07.01 |
|---|---|
| create_agent & 미들웨어 — LangChain 1.0 에이전트 표준 (0) | 2026.06.29 |
| LangGraph 입문 — LCEL로 안 되던 순환·기억·사람 개입을 그래프로 풀기 (0) | 2026.06.26 |
| 파이프(|) 하나로 invoke·batch·stream이 공짜로 따라오는 이유 — LangChain Runnable (0) | 2026.06.24 |
| LangChain 1.0 시작하기 (0) | 2026.06.21 |