Study/LangChain

4. LangChain 기본 활용 가이드 - 기본부터 체인 구성까지

bluebamus 2026. 2. 23.

04_LangChain_기본_활용_가이드.ipynb
0.17MB
04_LangChain_기본_활용_가이드_new.md
0.15MB

 

1. LangChain 핵심 개념

   - LangChain을 활용한 서비스를 구축할 때는 일련의 단계를 순서대로 거치게 된다. 아래에서는 일반적인 LangChain 서비스(특히 RAG 파이프라인) 구축 단계를 시각적으로 정리하고, 각 단계에서 자주 사용되는 함수와 클래스를 요약한다. 이어서 고급 서비스 구축 시 달라지는 부분과, 커스텀 기능(청킹, 리랭크, 도구 등)을 추가해야 하는 실무 시나리오를 다룬다.

 

   1) 일반적인 LangChain 서비스 구축 단계

      - LangChain 기반 서비스(주로 RAG)를 구축할 때 거치는 9단계를 블록 다이어그램과 플로우 차트로 정리한다.

[LangChain RAG 서비스 구축 - 블록 다이어그램]

┌─────────────────────────────────────────────────────────────────────────────┐
│                         준비 단계 (기반 설정)                               │
│                                                                             │
│  ┌────────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐   ┌──────┐  │
│  │ 단계 1     │ →  │ 단계 2   │ →  │ 단계 3   │ →  │ 단계 4   │ → │단계 5│  │
│  │ 모델 초기화│    │ 메시지   │    │ 프롬프트 │    │ 출력 파서 │  │ LCEL │   │
│  │            │    │ 구조 이해│    │ 템플릿   │    │ 설정      │  │ 체인 │  │
│  └────────────┘    └──────────┘    └──────────┘    └──────────┘   └──────┘  │
├─────────────────────────────────────────────────────────────────────────────┤
│                         데이터 단계 (검색 인프라)                           │
│                                                                             │ 
│  ┌────────────────────────────┐    ┌──────────────────────────────────────┐ │
│  │ 단계 6                     │ →  │ 단계 7                               │ │
│  │ 문서 로딩 + 텍스트 분할    │    │ 임베딩 → 벡터 DB 저장 → Retriever 생성│ │
│  └────────────────────────────┘    └──────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────────────────────┤
│                         조립 단계 (파이프라인 완성)                          │
│                                                                             │
│  ┌────────────────────────────┐    ┌──────────────────────────────────────┐ │
│  │ 단계 8                     │ →  │ 단계 9                               │ │
│  │ RAG 체인 조립              │    │ 검색 품질 최적화 (키워드 사전 등)     │ │
│  │ (Retrieval + Generation)   │    │                                      │ │
│  └────────────────────────────┘    └──────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────┘
[LangChain RAG 서비스 구축 - 플로우 차트]

[시작]
  │
  ▼
[단계 1] LLM 선택 및 초기화 (ChatOpenAI, ChatUpstage, ChatOllama)
  │
  ▼
[단계 2] 메시지 구조 이해 (SystemMessage, HumanMessage, AIMessage)
  │
  ▼
[단계 3] 프롬프트 템플릿 설계 (ChatPromptTemplate, FewShotPromptTemplate)
  │
  ▼
[단계 4] 출력 파서 선택 (StrOutputParser, JsonOutputParser, with_structured_output)
  │
  ▼
[단계 5] LCEL 체인 구성 (| 연산자, RunnablePassthrough, RunnableLambda)
  │
  ▼
[단계 6] 문서 로딩 + 텍스트 분할 (Loader → RecursiveCharacterTextSplitter)
  │
  ▼
[단계 7] 벡터 DB 구축 (OpenAIEmbeddings → Chroma/Pinecone → as_retriever)
  │
  ▼
[단계 8] RAG 체인 조립 (retriever + prompt + llm + parser)
  │
  ▼
[단계 9] 검색 품질 최적화 (키워드 사전 체인, 질의 변환)
  │
  ▼
[완료] 서비스 배포 및 모니터링


   2) 각 단계별 주요 함수 및 클래스 요약

단계 핵심 클래스 / 함수 간단 설명
1. 모델 초기화 ChatOpenAI(model, temperature) LLM 객체를 생성하고 파라미터를 설정한다
ChatUpstage(model) 한국어 특화 Solar 모델을 사용한다
ChatOllama(model) 로컬 환경에서 오픈소스 모델을 실행한다
2. 메시지 이해 SystemMessage(content) 모델의 역할과 행동 규칙을 지정한다
HumanMessage(content) 사용자의 질문을 전달한다
AIMessage(content) 모델의 응답을 표현하고 대화 히스토리에 포함한다
3. 프롬프트 관리 ChatPromptTemplate.from_messages() 역할별 메시지 기반 프롬프트를 정의한다
PromptTemplate.from_template() 문자열 기반 프롬프트를 정의한다
FewShotChatMessagePromptTemplate() 예시 기반 프롬프트를 구성한다
4. 출력 구조화 StrOutputParser() AIMessage에서 텍스트만 추출한다
JsonOutputParser(pydantic_object) JSON 형태로 출력을 파싱한다
llm.with_structured_output(Schema) 모델 네이티브 구조화 출력을 사용한다
5. 체인 구성 prompt | llm | parser LCEL 파이프 연산자로 체인을 조립한다
RunnablePassthrough() 입력을 그대로 통과시켜 딕셔너리 구성에 활용한다
RunnableLambda(fn) 커스텀 함수를 체인 중간에 삽입한다
6. 데이터 수집 Docx2txtLoader / PyPDFLoader 파일 포맷에 맞는 로더로 문서를 로딩한다
RecursiveCharacterTextSplitter(chunk_size, chunk_overlap) 문서를 검색 단위의 청크로 분할한다
7. 검색 시스템 OpenAIEmbeddings(model) 텍스트를 벡터로 변환하는 임베딩 모델을 초기화한다
Chroma.from_documents(docs, embedding) 벡터 DB를 생성하고 문서를 저장한다
db.as_retriever(search_kwargs) 벡터 DB를 Retriever 인터페이스로 변환한다
8. RAG 조립 create_retrieval_chain(retriever, combine_chain) 검색과 생성을 하나의 체인으로 연결한다
create_stuff_documents_chain(llm, prompt) 검색된 문서를 LLM에 전달하는 방식을 정의한다
9. 검색 최적화 dict_prompt | llm | StrOutputParser() 키워드 사전을 활용한 질의 변환 체인을 구성한다


   3) 고급 LangChain 서비스 구축 시 차이점

      - 고급 LangChain 서비스에서는 위 9단계 기본 구조에 추가적인 단계와 컴포넌트가 결합된다. 기본 구조 자체는 동일하지만, 각 단계에서 더 정교한 처리가 필요하며, 기본 흐름에 포함되지 않는 새로운 단계가 추가된다.

[고급 LangChain 서비스 - 추가되는 단계]

기본 흐름:
  질의 → 검색 → 생성 → 응답

고급 흐름:
  질의 → [질의 변환] → [멀티 검색] → [리랭킹] → [생성] → [검증] → 응답
           │              │              │             │
           ▼              ▼              ▼             ▼
        키워드 사전     앙상블 검색     CrossEncoder   Self-check
        Multi-Query    BM25 + 벡터     Cohere Rerank  환각 탐지
        HyDE           Parent Doc                     LLM 평가
구분 기본 서비스 고급 서비스
질의 처리 사용자 질의를 그대로 검색에 사용 키워드 사전 변환, Multi-Query 확장, HyDE 가설 문서 생성
검색 방식 단일 벡터 유사도 검색 (similarity) 앙상블 검색 (BM25 + 벡터), MMR 다양성 검색, Parent Document 검색
결과 후처리 검색 결과를 그대로 사용 리랭킹(CrossEncoder, Cohere)으로 검색 결과 재정렬
답변 생성 단일 LLM 호출 멀티스텝 생성, 에이전트 기반 도구 호출, 대화 히스토리 관리
품질 검증 없음 Self-check 체인, LLM 평가(환각/정확도/유용성), LangSmith 모니터링
분할 전략 고정 크기 청킹 시맨틱 청킹, 구조 기반 분할, 메타데이터 보강
단계 추가되는 주요 함수 / 클래스 설명
질의 변환 MultiQueryRetriever 하나의 질의를 여러 관점의 질의로 확장하여 검색 재현율을 높인다
앙상블 검색 EnsembleRetriever BM25(키워드)와 벡터(의미) 검색을 결합하여 상호 보완한다
리랭킹 CrossEncoderReranker 검색 결과를 질의와 함께 다시 평가하여 정밀도를 높인다
대화 관리 RunnableWithMessageHistory 대화 히스토리를 관리하여 멀티턴 대화를 지원한다
에이전트 create_react_agent, AgentExecutor LLM이 도구를 선택적으로 호출하며 단계별로 추론한다
평가 LangSmith evaluate() 답변의 정확도, 유용성, 환각 여부를 자동으로 평가한다


   4) 커스텀 기능 추가 시나리오

      - 실무에서는 기본 RAG 파이프라인으로는 해결되지 않는 요구사항이 빈번하게 발생한다. 아래에서는 커스텀 청킹, 리랭킹, 도구(Tools)를 추가해야 하는 대표적인 시나리오를 정리하고, 각 시나리오별로 요구 명세서, 설계 방향, 시각화를 제공한다.

 

      4.1) 시나리오 A: 커스텀 청킹 (Semantic Chunking)

         - 시나리오 설명: 법률 문서를 처리하는 RAG 시스템에서 고정 크기 청킹(RecursiveCharacterTextSplitter)을 사용하면 조항의 중간이 잘리거나, 관련 없는 조항이 하나의 청크에 섞이는 문제가 발생한다. 조항 단위의 의미적 분할이 필요하다.

         4.1.1) 요구 명세서:

항목 내용
목적 법률/규정 문서를 조항 단위로 의미적으로 분할한다
입력 법률 문서 (PDF/DOCX), 각 조항이 "제N조"로 시작하는 구조
출력 조항 단위로 분할된 Document 리스트, 각 Document에 조항 번호 메타데이터 포함
품질 기준 하나의 청크에 하나의 조항만 포함, 조항 내 내용이 잘리지 않음
제약 사항 조항 길이가 2000자를 초과하면 하위 항목(①②③) 단위로 재분할


         4.1.2) 설계 방향: 

            - 커스텀 청킹을 구현하려면 LangChain의 TextSplitter를 상속하거나, 전처리 단계에서 정규식 기반으로 문서를 분할한 뒤 Document 객체로 변환하는 방식을 사용한다. 핵심은 분할 기준을 "문자 수"가 아닌 "의미 단위(조항)"로 변경하는 것이다. 구현 시에는 먼저 정규식으로 "제N조" 패턴을 찾아 문서를 1차 분할하고, 각 분할 결과의 길이가 chunk_size를 초과하면 하위 항목(①②③) 기준으로 2차 분할을 수행한다. 분할된 각 청크에는 조항 번호, 원본 파일명, 페이지 번호 등의 메타데이터를 자동으로 부여한다.

[커스텀 청킹 - 플로우 차트]

[원본 법률 문서]
    │
    ▼
[1차 분할] 정규식으로 "제N조" 패턴 기준 분할
    │
    ├─ 제1조 (연차휴가) 전체 텍스트
    ├─ 제2조 (경조사 휴가) 전체 텍스트
    ├─ 제3조 (복리후생) 전체 텍스트
    └─ ...
    │
    ▼
[길이 검사] 각 조항이 chunk_size(2000자) 초과 여부 확인
    │
    ├─ 초과하지 않음 → 그대로 Document 객체 생성
    │
    └─ 초과함 → [2차 분할] 하위 항목(①②③) 기준 재분할
                    │
                    ├─ 제1조 ① 내용
                    ├─ 제1조 ② 내용
                    └─ 제1조 ③ 내용
    │
    ▼
[메타데이터 부여] 조항 번호, 파일명, 페이지 번호 자동 추가
    │
    ▼
[Document 리스트] 조항 단위로 분할된 최종 결과
# 커스텀 청킹 구현 예시 (설계 참고용)
import re
from langchain_core.documents import Document

def custom_legal_chunker(text: str, source: str, chunk_size: int = 2000) -> list[Document]:
    """법률 문서를 조항 단위로 분할하는 커스텀 청커"""
    # 1차 분할: "제N조" 패턴으로 조항 분리
    articles = re.split(r'(?=제\d+조)', text)
    articles = [a.strip() for a in articles if a.strip()]

    documents = []
    for article in articles:
        # 조항 번호 추출
        match = re.match(r'제(\d+)조', article)
        article_num = match.group(1) if match else "unknown"

        if len(article) <= chunk_size:
            # 청크 크기 이내이면 그대로 Document 생성
            documents.append(Document(
                page_content=article,
                metadata={"source": source, "article": f"제{article_num}조"}
            ))
        else:
            # 2차 분할: 하위 항목(①②③) 기준 재분할
            sub_items = re.split(r'(?=[①②③④⑤⑥⑦⑧⑨⑩])', article)
            for i, sub in enumerate(sub_items):
                if sub.strip():
                    documents.append(Document(
                        page_content=sub.strip(),
                        metadata={"source": source, "article": f"제{article_num}조", "sub_item": i}
                    ))

    return documents


            - 위 플로우 차트에서 볼 수 있듯이, 커스텀 청킹의 핵심은 "고정 크기 분할"에서 "의미 단위 분할"로 전환하는 것이다. 정규식 기반의 1차 분할로 조항 경계를 정확히 인식하고, 길이 초과 시에만 2차 분할을 적용함으로써 의미적 완결성을 유지하면서도 벡터 DB의 청크 크기 제한을 준수한다. 메타데이터에 조항 번호를 포함시키면 검색 결과에서 출처를 정확히 추적할 수 있어 답변의 신뢰성이 향상된다.

 

      4.2) 시나리오 B: 리랭킹 (Reranking)

         - 시나리오 설명: 벡터 유사도 검색만으로는 상위 K개 결과의 순서가 항상 최적이 아닌 경우가 있다. 특히 질의가 짧거나 모호할 때, 벡터 검색 1위 결과보다 3~4위 결과가 더 관련성이 높은 경우가 빈번하다. 리랭킹은 1차 검색 결과를 질의와 함께 다시 평가하여 순서를 재정렬하는 기법이다.

         4.2.1) 요구 명세서:

항목 내용
목적 벡터 검색 결과의 순서를 질의 관련성 기준으로 재정렬한다
입력 사용자 질의 (str), 벡터 검색 결과 상위 20개 (Document 리스트)
출력 관련성 순으로 재정렬된 상위 4개 Document 리스트
품질 기준 리랭킹 적용 후 상위 4개 결과의 관련성이 리랭킹 미적용 대비 향상됨
제약 사항 리랭킹 모델의 추가 지연 시간이 500ms 이내여야 한다


            - 설계 방향: 

               - 리랭킹을 구현하는 방법은 크게 두 가지이다. 첫째, CrossEncoder 모델을 사용하는 방법으로, 질의와 각 문서를 쌍으로 입력하여 관련성 점수를 직접 산출한다. CrossEncoder는 Bi-Encoder(일반 임베딩)보다 정확도가 높지만 속도가 느리므로, 1차 검색으로 후보를 축소한 뒤 적용한다. 둘째, Cohere Rerank API를 사용하는 방법으로, API 호출 한 번으로 리랭킹을 수행할 수 있어 구현이 간편하다. 구현 시에는 LCEL 체인의 retriever 뒤에 RunnableLambda로 리랭킹 로직을 삽입하면 된다.

[리랭킹 - 블록 다이어그램]

┌──────────┐    ┌──────────────────────────┐    ┌──────────────────┐    ┌────────────┐
│ 사용자   │    │ 1차 검색 (벡터 유사도)    │   │ 리랭킹           │    │ 최종 결과   │
│ 질의     │ →  │ 상위 20개 후보 반환       │ → │ CrossEncoder /   │ →  │ 상위 4개    │
│          │    │ (빠르지만 순서 최적 아님) │   │ Cohere Rerank    │    │ (정밀 순서) │
└──────────┘    └──────────────────────────┘    └──────────────────┘    └────────────┘
[리랭킹 - 플로우 차트]

[사용자 질의: "직장인 연말정산 방법"]
    │
    ▼
[벡터 검색] retriever.invoke(query) → 상위 20개 문서 반환
    │
    ├─ Doc1: 연말정산 개요 (유사도 0.89)
    ├─ Doc2: 소득공제 항목 (유사도 0.87)
    ├─ Doc3: 근로소득자 연말정산 절차 (유사도 0.85)  ← 실제로 가장 관련 높음
    ├─ Doc4: 세율 구간표 (유사도 0.84)
    └─ ... (16개 더)
    │
    ▼
[리랭킹] 질의 + 각 문서를 쌍으로 CrossEncoder에 입력
    │
    ├─ Doc3: 근로소득자 연말정산 절차 (리랭킹 점수 0.95) ← 1위로 상승
    ├─ Doc1: 연말정산 개요 (리랭킹 점수 0.91)
    ├─ Doc2: 소득공제 항목 (리랭킹 점수 0.88)
    └─ Doc4: 세율 구간표 (리랭킹 점수 0.72)
    │
    ▼
[상위 4개 반환] 리랭킹 점수 기준 정렬된 결과

 

# 리랭킹 체인 구현 예시 (설계 참고용)
from langchain_core.runnables import RunnableLambda

def rerank_documents(input_dict: dict) -> list:
    """CrossEncoder 기반 리랭킹"""
    query = input_dict["question"]
    docs = input_dict["documents"]

    # CrossEncoder로 (질의, 문서) 쌍의 관련성 점수 산출
    from sentence_transformers import CrossEncoder
    model = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

    pairs = [(query, doc.page_content) for doc in docs]
    scores = model.predict(pairs)

    # 점수 기준으로 정렬하여 상위 K개 반환
    scored_docs = sorted(zip(scores, docs), key=lambda x: x[0], reverse=True)
    return [doc for _, doc in scored_docs[:4]]

# LCEL 체인에 리랭킹 삽입
rag_chain_with_rerank = (
    {
        "question": RunnablePassthrough(),
        "documents": lambda x: retriever.invoke(x),  # 1차 검색: 상위 20개
    }
    | RunnableLambda(rerank_documents)  # 리랭킹: 상위 4개로 축소
    | RunnableLambda(lambda docs: "\n\n".join(d.page_content for d in docs))
    # 이후 prompt | llm | parser 연결
)

 

            - 리랭킹의 핵심 가치는 "검색 정밀도"를 높이는 데 있다. 벡터 검색은 대량의 문서에서 빠르게 후보를 추려내는 데 탁월하지만, 상위 결과의 세밀한 순서 결정은 CrossEncoder 같은 정밀 모델이 더 우수하다. 따라서 벡터 검색(Bi-Encoder)으로 넓은 후보(fetch_k=20)를 먼저 확보하고, 리랭킹(CrossEncoder)으로 최종 결과(k=4)를 선별하는 2단계 파이프라인이 실무에서 권장된다.

 

      4.3) 시나리오 C: 도구 (Tools) 통합

         - 시나리오 설명: RAG 시스템이 문서 검색만으로는 답변할 수 없는 질문을 받는 경우가 있다. 예를 들어 "오늘 날짜 기준 근속 연수에 따른 연차 일수를 계산해 주세요"와 같은 질문은 현재 날짜 조회, 수학 계산, DB 조회 등 외부 도구의 실행이 필요하다. LangChain의 Tool / Agent 시스템을 활용하면 LLM이 상황에 따라 적절한 도구를 선택하여 호출하고, 그 결과를 답변에 반영할 수 있다.

 

         4.3.1) 요구 명세서:

항목 내용
목적 LLM이 필요에 따라 외부 도구(계산기, 날짜 조회, DB 검색 등)를 호출하여 답변한다
입력 사용자 질의 (자연어), 사용 가능한 도구 목록
출력 도구 실행 결과를 반영한 최종 답변
품질 기준 도구가 필요한 질문에서 적절한 도구를 선택하고 올바른 인자를 전달한다
제약 사항 도구 호출 실패 시 사용자에게 안내 메시지를 반환한다


            - 설계 방향:

               - LangChain에서 도구를 정의하고 에이전트를 구성하는 과정은 다음과 같다. 먼저 각 도구의 기능을 Python 함수로 구현하고, `@tool` 데코레이터 또는 `StructuredTool`로 LangChain 도구 객체로 변환한다. 그다음 `create_react_agent`로 ReAct(Reasoning + Acting) 패턴의 에이전트를 생성하고, `AgentExecutor`로 실행 환경을 구성한다. 에이전트는 질의를 분석하여 도구 호출이 필요한지 판단하고, 필요하다면 적절한 도구를 선택하여 실행한 뒤, 그 결과를 종합하여 최종 답변을 생성한다.

                              ┌──────────────────────┐
                         ┌──→ │ Tool 1: 계산기       │
                         │    │ (수학 연산 수행)     │
                         │    └──────────────────────┘
┌──────────┐   ┌─────────┤    ┌──────────────────────┐    ┌──────────┐
│ 사용자   │ → │ LLM     │──→ │ Tool 2: 날짜 조회    │ →  │ 최종 답변 │
│ 질의     │   │ (ReAct  │    │ (현재 날짜/기간 계산)│    │           │
└──────────┘   │ Agent)  │    └──────────────────────┘    └──────────┘
               └─────────┤    ┌──────────────────────┐
                         │    │ Tool 3: 규정 검색    │
                         └──→ │ (벡터 DB RAG 검색)   │
                              └──────────────────────┘
[도구 통합 - 플로우 차트]

[사용자 질의: "입사 3년차 직원의 연차 일수를 계산해 주세요"]
    │
    ▼
[LLM 판단] 질의 분석 → 도구 호출 필요 여부 결정
    │
    ├─ "연차 규정 확인이 필요하다" → Tool 3 (규정 검색) 호출
    │       │
    │       ▼ 검색 결과: "제1조: 15일 기본 + 3년 이상 시 가산"
    │
    ├─ "근속 연수 기반 계산이 필요하다" → Tool 1 (계산기) 호출
    │       │
    │       ▼ 계산 결과: 15일 + 0일(가산) = 15일
    │
    ▼
[LLM 종합] 도구 실행 결과를 종합하여 최종 답변 생성
    │
    ▼
[최종 답변] "제1조에 따르면, 입사 3년차 직원의 연차 휴가는 15일입니다.
             3년 이상 근속 시 매 2년마다 1일의 가산 연차가 부여되므로,
             올해는 가산 대상이 아닙니다."
# 도구 통합 구현 예시 (설계 참고용)
from langchain_core.tools import tool
from langchain.agents import create_react_agent, AgentExecutor

@tool
def calculate(expression: str) -> str:
    """수학 계산을 수행합니다. 예: '15 + 1' → '16'"""
    try:
        return str(eval(expression))
    except Exception as e:
        return f"계산 오류: {e}"

@tool
def search_regulation(query: str) -> str:
    """사내 규정을 검색합니다."""
    docs = retriever.invoke(query)
    return "\n".join(doc.page_content for doc in docs)

# 도구 목록 정의
tools = [calculate, search_regulation]

# ReAct 에이전트 생성
agent = create_react_agent(llm, tools, prompt)
agent_executor = AgentExecutor(agent=agent, tools=tools, verbose=True)

# 실행
result = agent_executor.invoke({"input": "입사 3년차 직원의 연차 일수는?"})


            - 도구 통합의 설계에서 가장 중요한 점은 각 도구의 역할을 명확히 분리하고, 도구의 설명(description)을 LLM이 이해할 수 있도록 구체적으로 작성하는 것이다. LLM은 도구의 설명을 읽고 어떤 도구를 언제 사용할지 판단하므로, 모호한 설명은 잘못된 도구 선택으로 이어진다. 또한 에이전트가 도구를 반복 호출하는 무한 루프에 빠지지 않도록 `max_iterations` 파라미터를 설정하고, 도구 호출 실패 시의 예외 처리를 반드시 포함해야 한다.

 

   1.1. 배경 지식: LangChain이 필요한 이유

      - LLM을 활용한 애플리케이션을 직접 구현하면, 프롬프트 관리, API 호출, 출력 파싱, 체인 구성, 에러 처리 등 반복적인 코드를 매번 작성해야 한다. LangChain은 이러한 공통 패턴을 표준화된 인터페이스로 추상화하여 개발 생산성을 크게 높여주는 프레임워크이다.

직접 구현 시 문제점 LangChain의 해결 방식
모델마다 다른 API 인터페이스 통일된 Runnable 인터페이스로 추상화한다. OpenAI, Upstage, Ollama 등 어떤 모델이든 invoke(), stream(), batch() 등 동일한 메서드로 호출할 수 있으며, 모델 교체 시 import와 초기화 한 줄만 변경하면 나머지 코드는 그대로 유지된다.
프롬프트 하드코딩, 재사용 어려움 PromptTemplate과 ChatPromptTemplate으로 프롬프트를 변수화하여 관리한다. {변수명} 플레이스홀더를 사용해 동일 템플릿을 다양한 입력으로 재사용할 수 있고, partial_variables로 일부 변수를 미리 고정하거나 FewShotPromptTemplate으로 예시를 동적으로 삽입할 수 있다.
체인(파이프라인) 구성 시 복잡한 코드 LCEL(LangChain Expression Language)의 | 연산자로 컴포넌트를 Unix 파이프처럼 직관적으로 연결한다. prompt | llm | parser 형태로 체인을 선언적으로 구성하며, RunnableParallel로 병렬 실행, RunnableBranch로 조건 분기도 간결하게 표현할 수 있다.
출력 형식 파싱 로직 중복 StrOutputParser, JsonOutputParser, PydanticOutputParser 등 목적별 파서를 제공하여 LLM 출력을 문자열, JSON, Pydantic 객체 등 원하는 형태로 자동 변환한다. with_structured_output()을 사용하면 Function Calling을 통해 파싱 실패 없이 구조화된 출력을 보장할 수 있다.
벡터 DB 교체 시 대규모 코드 변경 추상화된 VectorStore 인터페이스를 통해 Chroma, Pinecone, FAISS 등 벡터 DB를 동일한 API(from_documents(), similarity_search(), as_retriever())로 사용한다. DB를 교체할 때 초기화 코드만 변경하면 검색 체인과 RAG 파이프라인 코드는 그대로 유지된다.
스트리밍/배치/비동기 처리 개별 구현 모든 Runnable 컴포넌트가 invoke(), stream(), batch(), ainvoke(), astream(), abatch() 메서드를 기본 내장하고 있어, 별도의 구현 없이 동일 체인에서 동기/비동기, 단건/배치, 스트리밍 호출을 자유롭게 전환할 수 있다.

 

      - LangChain은 LLM 기반 애플리케이션 개발을 위한 프레임워크이다. 각 컴포넌트를 모듈화하고, 체인(Chain)으로 조합하여 복잡한 워크플로를 구성한다.

 

   1.2. 핵심 모듈 구조

      - LangChain은 여러 패키지로 나뉘어 있으며, 각 패키지는 독립적으로 설치하고 사용할 수 있다. 이러한 모듈화 구조 덕분에 필요한 기능만 선택적으로 가져와 사용할 수 있다.

[LangChain 패키지 구조]

langchain-core        # 핵심 추상화 (Runnable, PromptTemplate, OutputParser 등)
                      # → 모든 LangChain 패키지의 기반이 되는 핵심 라이브러리
langchain             # 체인, 에이전트, 검색 로직
                      # → 고수준 API (create_retrieval_chain, RetrievalQA 등)
langchain-openai      # OpenAI 통합
                      # → ChatOpenAI, OpenAIEmbeddings 등 OpenAI 전용 클래스
langchain-community   # 커뮤니티 통합 (Chroma, BM25 등)
                      # → 서드파티 벡터 DB, 로더, LLM 통합
langchain-experimental # 실험적 기능 (SemanticChunker 등)
                      # → 아직 안정화되지 않은 최신 기능


      1) langchain-core는 LangChain 생태계 전체의 기반이 되는 핵심 라이브러리이다.

         - `Runnable` 인터페이스, `PromptTemplate`, `ChatPromptTemplate`, `StrOutputParser`, `JsonOutputParser`, `PydanticOutputParser`, `RunnablePassthrough`, `RunnableLambda`, `RunnableParallel`, `RunnableBranch` 등 모든 추상화 클래스와 LCEL 구성 요소가 이 패키지에 정의되어 있다. 다른 모든 LangChain 패키지는 langchain-core에 의존하며, `pip install langchain` 등 상위 패키지 설치 시 자동으로 함께 설치된다. 직접 `pip install langchain-core`를 실행할 필요는 거의 없다.

 

      2) langchain은 체인(Chain), 에이전트(Agent), 검색 로직 등 고수준 API를 제공하는 메인 패키지이다.

         - create_retrieval_chain`, `create_stuff_documents_chain`, `RetrievalQA`, `AgentExecutor`, `OutputFixingParser` 등 실무에서 바로 사용할 수 있는 조립 함수와 클래스가 포함되어 있다. langchain-core의 기본 빌딩 블록을 조합하여 복잡한 파이프라인을 간편하게 구성할 수 있도록 돕는 역할을 한다. LangChain Hub 연동(`hub.pull()`)도 이 패키지를 통해 제공된다.

      3) langchain-openai는 OpenAI API와의 공식 통합 패키지이다.

         - `ChatOpenAI` (LLM 호출), `OpenAIEmbeddings` (텍스트 임베딩) 등 OpenAI 전용 클래스를 제공한다. OpenAI의 Function Calling, JSON Mode, `with_structured_output()` 등 모델 네이티브 기능을 LangChain 인터페이스로 자연스럽게 활용할 수 있다. OpenAI 모델을 사용하는 프로젝트에서는 반드시 설치해야 하며, `pip install langchain-openai`로 개별 설치한다.

 

      4) langchain-community는 커뮤니티에서 개발하고 유지보수하는 서드파티 통합 패키지이다.

         - Chroma, FAISS 등 벡터 DB 연동, ChatOllama 등 로컬 LLM 연동, BM25Retriever 등 키워드 검색, Docx2txtLoader/PyPDFLoader/WebBaseLoader 등 문서 로더, HuggingFacePipeline 등 오픈소스 모델 통합이 모두 이 패키지에 포함되어 있다. 벡터 DB나 로더를 사용할 때 가장 자주 import하게 되는 패키지이며, 특정 기능을 사용할 때 해당 서드파티 라이브러리(예: `chromadb`, `pypdf`)를 추가로 설치해야 하는 경우가 많다.

 

      5) langchain-experimental은 아직 안정화되지 않은 실험적 기능을 모아놓은 패키지이다.

         - `SemanticChunker` (의미 기반 문서 분할), `GenerativeUIAgent` 등 최신 연구 결과를 반영한 기능이 포함되어 있다. 이 패키지의 API는 사전 공지 없이 변경될 수 있으므로, 프로덕션 환경에서 사용할 때는 버전을 고정하거나 해당 기능이 langchain 또는 langchain-core로 정식 편입될 때까지 기다리는 것이 안전하다.

 

      1.1.1. 패키지 간 의존 관계

┌─────────────────────────────────────────────────────────────────┐
│                    langchain-experimental                       │
│                  (SemanticChunker 등 실험 기능)                 │
└──────────────────────────┬──────────────────────────────────────┘
                           │ 의존
┌──────────────────────────▼──────────────────────────────────────┐
│                        langchain                                │
│              (체인, 에이전트, 검색 로직 등 고수준 API)           │
└──────────┬────────────────────────────────┬─────────────────────┘
           │ 의존                            │ 의존
┌──────────▼───────────┐          ┌──────────▼───────────────────┐
│   langchain-openai   │          │   langchain-community        │
│   langchain-upstage  │          │   (Chroma, Ollama, BM25 등)  │
│   langchain-pinecone │          │                              │
│   (공식 통합 패키지) │          │   (커뮤니티 통합 패키지)      │
└──────────┬───────────┘          └──────────┬───────────────────┘
           │ 의존                            │ 의존
┌──────────▼────────────────────────────────▼─────────────────────┐
│                      langchain-core                             │
│          (Runnable, PromptTemplate, OutputParser 등)            │
│                    모든 패키지의 기반 라이브러리                 │
└─────────────────────────────────────────────────────────────────┘


         - 실무 권장:

            - 프로젝트 시작 시 `langchain`, `langchain-openai`, `langchain-community`를 기본으로 설치하고, 필요에 따라 `langchain-pinecone`, `langchain-upstage` 등 추가 패키지를 설치한다. `langchain-core`는 다른 패키지 설치 시 자동으로 함께 설치된다.

 

   1.3. 핵심 추상화: Runnable 인터페이스

      - LangChain의 모든 컴포넌트는 `Runnable` 인터페이스를 구현한다. 이것이 LangChain 아키텍처의 핵심이다. `Runnable`은 "입력을 받아 처리한 뒤 출력을 반환하는 실행 가능한 단위"를 추상화한 인터페이스로, LangChain에서 사용하는 모든 구성 요소가 이 인터페이스를 따른다.

 

      - 구체적으로, `PromptTemplate`은 딕셔너리를 입력받아 포매팅된 프롬프트를 출력하고, `ChatOpenAI`는 프롬프트를 입력받아 `AIMessage`를 출력하며, `StrOutputParser`는 `AIMessage`를 입력받아 문자열을 출력한다. 이처럼 서로 다른 역할을 수행하는 컴포넌트들이 모두 동일한 `Runnable` 인터페이스를 구현하기 때문에, `|` 연산자로 자유롭게 조합할 수 있다. 이것이 LCEL(LangChain Expression Language)의 기반이며, LangChain이 유연한 파이프라인 구성을 가능하게 하는 근본 원리이다.

 

      -`Runnable` 인터페이스가 제공하는 핵심 메서드는 6가지이다. 이 6가지 메서드는 동기/비동기 축과 단건/배치/스트리밍 축의 조합으로 구성된다.

# Runnable의 핵심 메서드 (모든 LangChain 컴포넌트에서 사용 가능)
runnable.invoke(input)        # 단일 입력 처리 (가장 기본적인 호출)
runnable.batch([input1, ...]) # 배치 처리 (여러 입력을 한 번에 처리)
runnable.stream(input)        # 스트리밍 (토큰 단위로 점진적 출력)
runnable.ainvoke(input)       # 비동기 처리 (async/await 환경)
runnable.abatch([...])        # 비동기 배치 (여러 입력을 비동기로 처리)
runnable.astream(input)       # 비동기 스트리밍 (비동기 환경에서 스트리밍)

 

      -`invoke`는 가장 기본적인 호출 방식으로, 하나의 입력에 대해 하나의 출력을 동기적으로 반환한다. 

         - Jupyter Notebook이나 스크립트에서 개발하고 테스트할 때 가장 많이 사용하며, 디버깅 시에도 각 컴포넌트를 개별적으로 `invoke`하여 입출력을 확인하는 것이 일반적이다.

 

      -`batch`는 여러 입력을 리스트로 전달하여 한 번에 처리하는 방식이다.

          - 내부적으로 각 입력을 병렬로 처리하므로 개별 `invoke`를 반복 호출하는 것보다 훨씬 효율적이다. 평가(Evaluation) 시 수백 개의 질문을 일괄 처리하거나, 데이터 전처리에서 대량의 문서를 한 번에 임베딩할 때 사용한다. `max_concurrency` 파라미터로 동시 실행 수를 제한할 수 있어 API 호출 제한(rate limit)을 준수하는 데 유용하다.

 

      -`stream`은 LLM의 출력을 토큰 단위로 점진적으로 반환하는 방식이다.

          - 전체 응답이 완성될 때까지 기다리지 않고, 각 토큰이 생성될 때마다 즉시 사용자에게 전달할 수 있어 챗봇 UI에서 타이핑 효과를 구현할 때 필수적이다. 체인 전체에 `stream`을 적용하면 LLM 이전 단계(프롬프트 포매팅 등)는 한 번에 처리되고, LLM 출력부터 토큰 단위로 스트리밍이 시작된다.

 

      -`ainvoke`, `abatch`, `astream`은 각각 `invoke`, `batch`, `stream`의 비동기(async) 버전이다.

          - FastAPI, Streamlit 등 비동기 웹 프레임워크에서 사용하며, `await` 키워드와 함께 호출한다. 특히 `astream`은 SSE(Server-Sent Events)나 WebSocket을 통해 실시간 스트리밍 응답을 제공하는 프로덕션 환경에서 핵심적으로 사용된다.

 

         1.2.1. Runnable 메서드 사용 시나리오

메서드 사용 시나리오 예시
invoke 단일 질의 처리 사용자 질문 1건에 대한 답변 생성
batch 다수 질의 일괄 처리 평가 데이터셋의 100개 질문을 한 번에 처리
stream 실시간 응답 출력 챗봇 UI에서 타이핑 효과로 답변 표시
ainvoke 비동기 웹 서버 FastAPI, Streamlit 등에서 비동기 처리
abatch 비동기 대량 처리 비동기 환경에서 다수 질의 동시 처리
astream 비동기 스트리밍 SSE(Server-Sent Events)로 실시간 응답
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini")  # ChatOpenAI도 Runnable 인터페이스 구현

# invoke: 단일 호출
result = llm.invoke("안녕하세요")  # → AIMessage(content="안녕하세요! ...")

# batch: 배치 호출 (여러 질문을 한 번에 처리)
results = llm.batch(["질문1", "질문2", "질문3"])  # → [AIMessage, AIMessage, AIMessage]

# stream: 스트리밍 호출 (토큰 단위로 출력)
for chunk in llm.stream("긴 답변을 해주세요"):
    print(chunk.content, end="", flush=True)  # 토큰이 생성될 때마다 출력

 

            - 실무 권장: 

               - 개발 단계와 프로덕션 단계에서 사용해야 할 메서드가 다르다. 개발/디버깅 단계에서는 `invoke`를 기본으로 사용하여 각 컴포넌트의 입출력을 명확히 확인한다. 프로덕션 챗봇 UI에서는 반드시 `stream`(동기) 또는 `astream`(비동기)을 사용하여 사용자가 응답을 기다리는 시간을 줄인다. 스트리밍 없이 `invoke`를 사용하면 LLM이 전체 응답을 생성할 때까지(수 초) 사용자에게 아무것도 표시되지 않아 UX가 크게 저하된다. 평가(Evaluation) 파이프라인에서는 `batch`를 활용하여 수백~수천 개의 질문을 병렬로 처리하면 개별 `invoke` 대비 처리 시간을 크게 단축할 수 있다. 이때 `batch`의 `max_concurrency` 파라미터를 활용하면 OpenAI API의 분당 요청 제한(RPM)을 초과하지 않으면서 최대 처리량을 달성할 수 있다. FastAPI 등 비동기 웹 프레임워크를 사용하는 경우에는 `ainvoke`/`astream`을 사용하여 이벤트 루프를 차단하지 않도록 해야 하며, 동기 메서드(`invoke`)를 비동기 환경에서 호출하면 다른 요청의 처리가 지연될 수 있으므로 주의한다.

 

2. LLM 통합 (단계 1: 모델 선택과 초기화)

   2.1. 배경 지식: LLM API 호출 구조

      - LLM 서비스를 사용하려면 API 키를 발급받고, 해당 서비스의 엔드포인트에 HTTP 요청을 보내야 한다. LangChain은 이 과정을 추상화하여 `ChatOpenAI`, `ChatUpstage` 등의 클래스로 감싸 제공한다. 어떤 LLM 제공자를 사용하든 동일한 `invoke`, `stream` 등의 메서드로 호출할 수 있다.

[LLM 호출 흐름]

사용자 코드                LangChain 래퍼             LLM API 서비스
─────────               ──────────────            ───────────────
llm.invoke("질문")  →   ChatOpenAI 내부에서      →   OpenAI API 엔드포인트
                        메시지 구성, API 호출        POST /v1/chat/completions
                        응답 파싱                ←   JSON 응답
                    ←   AIMessage 객체 반환


   2.2. ChatOpenAI

      - 가장 널리 사용되는 OpenAI 모델 통합 클래스이다. `model`, `temperature`, `max_tokens` 등의 파라미터로 모델의 동작을 세밀하게 제어할 수 있다.

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(
    model="gpt-4o-mini",       # 사용할 모델명 (gpt-4o, gpt-4o-mini 등)
    temperature=0,              # 생성 다양성 (0: 결정적, 1: 다양한 출력)
    max_tokens=1000,           # 최대 생성 토큰 수 (응답 길이 제한)
    streaming=True,            # 스트리밍 활성화 여부 (True: 토큰 단위 출력)
    api_key="...",             # API 키 (환경변수 OPENAI_API_KEY 사용 권장)
    model_kwargs={             # 추가 파라미터 (OpenAI API에 직접 전달)
        "top_p": 0.95,         # 누적 확률 기반 샘플링 (nucleus sampling)
        "frequency_penalty": 0.1,  # 반복 단어 억제 페널티
    }
)

# 단순 호출 (문자열 입력 → AIMessage 반환)
response = llm.invoke("안녕하세요")
print(response.content)  # AIMessage의 content 필드에 실제 답변 텍스트가 담김


      - 파라미터 정의 기준:
         - `temperature`: 0에 가까울수록 동일 입력에 동일 출력(결정적), 1에 가까울수록 다양한 출력. RAG 파이프라인에서는 일반적으로 `0`을 사용하여 답변의 일관성을 보장한다.
         - `max_tokens`: 응답의 최대 길이를 제한한다. 너무 작으면 답변이 잘리고, 너무 크면 비용이 증가한다. 일반 Q&A는 500~1000, 긴 설명은 2000~4000 권장.
         - `top_p`: `temperature`와 함께 사용하며, 0.95는 상위 95% 확률의 토큰만 샘플링한다. 일반적으로 `temperature`와 `top_p` 중 하나만 조절한다.
         - `streaming`: `True`로 설정하면 `stream()` 호출 시 토큰 단위로 결과를 받을 수 있다. 챗봇 UI에서 타이핑 효과를 구현할 때 필수.

 

      2.1.1. API 키 관리 방법

# 방법 1: 환경변수 설정 (가장 권장되는 방법)
# .env 파일 또는 시스템 환경변수에 설정
# OPENAI_API_KEY=sk-...

from dotenv import load_dotenv  # python-dotenv 패키지 필요
load_dotenv()  # .env 파일에서 환경변수 로드

llm = ChatOpenAI(model="gpt-4o-mini")  # api_key 자동 인식

# 방법 2: 직접 전달 (테스트용, 코드에 키 노출 주의)
llm = ChatOpenAI(model="gpt-4o-mini", api_key="sk-...")

# 방법 3: os.environ 활용
import os
os.environ["OPENAI_API_KEY"] = "sk-..."
llm = ChatOpenAI(model="gpt-4o-mini")  # 환경변수에서 자동 인식


         - 실무 권장: API 키는 반드시 `.env` 파일이나 환경변수로 관리하고, 코드에 직접 하드코딩하지 않는다. `.env` 파일은 `.gitignore`에 추가하여 Git에 커밋되지 않도록 한다.

 

   2.3. 다중 LLM 제공자

      - LangChain의 통일된 인터페이스 덕분에 다양한 LLM 제공자를 동일한 방식으로 사용할 수 있다. 모델을 교체하더라도 나머지 코드(프롬프트, 체인 구성 등)를 변경할 필요가 없다.

# ──────────────────────────────────────────
# Upstage (한국어 특화 모델)
# ──────────────────────────────────────────
from langchain_upstage import ChatUpstage

llm_upstage = ChatUpstage(
    model="solar-pro"  # Upstage의 Solar 모델 (한국어 성능 우수)
)

# ──────────────────────────────────────────
# Ollama (로컬 실행, 인터넷 불필요)
# ──────────────────────────────────────────
from langchain_community.chat_models import ChatOllama

llm_ollama = ChatOllama(
    model="gemma2"  # 로컬에 설치된 Ollama 모델명 (llama3, mistral 등)
)

# ──────────────────────────────────────────
# HuggingFace (오픈소스 모델, 로컬 실행)
# ──────────────────────────────────────────
from langchain_community.llms import HuggingFacePipeline

llm_hf = HuggingFacePipeline.from_model_id(
    model_id="LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct",  # HuggingFace 모델 ID
    task="text-generation"  # 텍스트 생성 태스크 지정
)


      2.2.1. LLM 제공자 교체 시 코드 변경 범위

# LangChain의 핵심 장점: LLM을 교체해도 체인 코드는 동일
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 세법 전문가입니다."),
    ("human", "{question}"),
])

# 아래 한 줄만 변경하면 모델 교체 완료
llm = ChatOpenAI(model="gpt-4o-mini")           # OpenAI 사용 시
# llm = ChatUpstage(model="solar-pro")          # Upstage로 교체 시
# llm = ChatOllama(model="gemma2")              # Ollama로 교체 시

# 체인 구성 코드는 어떤 모델을 사용하든 동일
chain = prompt | llm | StrOutputParser()


   2.4. 모델 선택 기준

기준 gpt-4o gpt-4o-mini Ollama/Local Upstage
비용 높음 낮음 무료 중간
품질 최고 높음 중간 높음
속도 보통 빠름 가변 보통
한국어 우수 우수 가변 매우 우수
보안 클라우드 클라우드 로컬 클라우드
적합 용도 프로덕션 일반 사용 오프라인/보안 한국어 특화


      2.3.1. 상황별 모델 선택 가이드

상황 권장 모델 이유
빠른 프로토타이핑 gpt-4o-mini 낮은 비용, 빠른 응답, 충분한 품질
프로덕션 (범용) gpt-4o 최고 품질, 안정적 API
한국어 특화 서비스 Upstage solar-pro 한국어 이해도 최고, 문화적 맥락 이해
보안/오프라인 환경 Ollama (llama3, gemma2) 데이터가 외부로 전송되지 않음
비용 최소화 (대량 처리) gpt-4o-mini 또는 Ollama 저비용 또는 무료
최고 품질 (한국어 RAG) gpt-4o + Upstage 임베딩 생성은 GPT-4o, 검색은 한국어 특화 임베딩


         - 실무 권장: 개발 단계에서는 `gpt-4o-mini`로 빠르게 개발하고, 프로덕션 배포 시 `gpt-4o`로 교체하는 것이 일반적이다. 한국어 서비스라면 Upstage Solar를 반드시 벤치마크에 포함시킨다. 보안이 중요한 환경에서는 Ollama로 로컬 실행을 고려한다.

 

3. 메시지 시스템 (단계 2: 입출력 구조 이해)

   3.1. 배경 지식: Chat 모델의 메시지 기반 대화

      - OpenAI GPT-4, GPT-4o-mini 등 최신 LLM은 단순 텍스트 입출력이 아닌, 메시지(Message) 기반의 대화 구조를 사용한다. 각 메시지에는 역할(role)이 지정되어 있으며, 모델은 이 역할 정보를 기반으로 문맥을 이해한다.

[Chat 모델의 메시지 구조]

┌─────────────────────────────────────────────┐
│  SystemMessage (시스템 역할 지정)           │
│  "당신은 세법 전문가입니다."                │
├─────────────────────────────────────────────┤
│  HumanMessage (사용자 질문)                 │
│  "근로소득세 계산 방법을 알려주세요."        │
├─────────────────────────────────────────────┤
│  AIMessage (모델 응답)                      │
│  "근로소득세는 총급여에서 비과세소득을..."   │
├─────────────────────────────────────────────┤
│  HumanMessage (사용자 후속 질문)            │
│  "세율 구간도 알려주세요."                  │
├─────────────────────────────────────────────┤
│  AIMessage (모델 응답)                      │
│  "근로소득세 세율 구간은..."                │
└─────────────────────────────────────────────┘

→ 모델은 전체 메시지 히스토리를 참고하여 답변을 생성한다


   3.2. 메시지 타입

      - LangChain은 Chat 모델의 메시지 구조를 `SystemMessage`, `HumanMessage`, `AIMessage`, `ToolMessage` 등의 클래스로 표현한다. 각 클래스는 역할(role)을 명시적으로 구분한다.

from langchain_core.messages import (
    SystemMessage,    # 시스템 지시사항: 모델의 역할, 행동 규칙 정의
    HumanMessage,     # 사용자 입력: 사용자가 보낸 질문이나 요청
    AIMessage,        # AI 응답: 모델이 생성한 답변
    ToolMessage,      # 도구 실행 결과: 외부 도구(함수) 호출 결과
)

# 메시지 리스트 구성 (시스템 지시 + 사용자 질문)
messages = [
    SystemMessage(content="당신은 세법 전문가입니다."),        # 역할 부여
    HumanMessage(content="근로소득세 계산 방법을 알려주세요."),  # 사용자 질문
]

# LLM에 메시지 리스트 전달
response = llm.invoke(messages)
# → AIMessage(content="근로소득세는...")

print(response.content)   # 답변 텍스트만 추출
print(type(response))     # <class 'langchain_core.messages.AIMessage'>


      3.2.1. 메시지 타입별 역할과 사용 목적

메시지 타입 역할(role) 사용 목적 예시
SystemMessage system 모델의 페르소나, 행동 규칙, 응답 형식 지정 "당신은 세법 전문가입니다. 간결하게 답하세요."
HumanMessage user 사용자의 질문, 요청, 데이터 입력 "근로소득세란 무엇인가요?"
AIMessage assistant 모델이 생성한 응답 (대화 히스토리에 포함) "근로소득세는 개인의 근로소득에..."
ToolMessage tool 함수 호출(Function Calling) 결과 {"tax_amount": 5000000}


         - 실무 권장: `SystemMessage`에 모델의 역할과 응답 규칙을 명확히 지정하면 답변 품질이 크게 향상된다. RAG 파이프라인에서는 "주어진 컨텍스트만을 기반으로 답하세요. 컨텍스트에 없는 내용은 '정보 없음'으로 답하세요."와 같은 지시를 포함시키는 것이 일반적이다.

 

   3.3. 메시지 메타데이터

      - `AIMessage`에는 답변 텍스트(`content`) 외에도 토큰 사용량, 모델명, 종료 사유 등의 메타데이터가 포함된다. 이 정보는 비용 추적, 디버깅, 품질 모니터링에 활용된다.

msg = AIMessage(
    content="답변 내용",
    response_metadata={
        "token_usage": {
            "prompt_tokens": 50,       # 입력(프롬프트)에 사용된 토큰 수
            "completion_tokens": 100,  # 생성(응답)에 사용된 토큰 수
            "total_tokens": 150        # 전체 토큰 수 (비용 계산의 기준)
        },
        "model_name": "gpt-4o-mini",   # 실제 사용된 모델명
        "finish_reason": "stop"        # 종료 사유 (stop: 정상 완료, length: 토큰 제한 도달)
    }
)

# 토큰 사용량 확인 (비용 추적에 활용)
print(msg.response_metadata["token_usage"])
# → {"prompt_tokens": 50, "completion_tokens": 100, "total_tokens": 150}


      3.3.1. finish_reason 값별 의미

finish_reason 의미 대응 방법
stop 정상적으로 답변 완료 정상 처리
length max_tokens 제한에 도달하여 답변이 잘림 max_tokens 값 증가 또는 프롬프트 간소화
content_filter 콘텐츠 필터에 의해 차단 프롬프트 수정 또는 예외 처리
tool_calls 도구(함수) 호출을 요청 도구 실행 후 결과를 ToolMessage로 전달


         - 실무 권장: `finish_reason`이 `length`인 경우 답변이 중간에 잘린 것이므로, 프로덕션 환경에서는 이를 감지하여 `max_tokens`를 늘리거나 사용자에게 "답변이 길어 일부만 표시됩니다"와 같은 안내를 제공한다.

 

4. 프롬프트 템플릿 (단계 3: 프롬프트 관리)

   4.1. 배경 지식: 프롬프트 엔지니어링과 템플릿의 필요성

      - LLM에 보내는 프롬프트(지시문)의 품질이 답변 품질을 좌우한다. 동일한 질문이라도 프롬프트 구성에 따라 답변의 정확도, 형식, 길이가 크게 달라진다. 프롬프트를 코드에 하드코딩하면 다음과 같은 문제가 발생한다:
         - 프롬프트 수정 시 코드 전체를 검색해야 함

         - 동일 패턴의 프롬프트를 중복 작성

         - 변수 삽입 시 문자열 포매팅 로직이 산재

 

      - `PromptTemplate`은 프롬프트를 변수화된 템플릿으로 관리하여 재사용성과 유지보수성을 높인다.

[프롬프트 템플릿 동작 흐름]

템플릿 정의                    변수 주입                    최종 프롬프트
───────────                   ──────────                    ────────────
"질문에 {style}              style="전문가"                "질문에 전문가
 스타일로 답하세요.     →    question="소득세란?"    →     스타일로 답하세요.
 질문: {question}"                                         질문: 소득세란?"

 

      - LangChain은 용도에 따라 여러 종류의 프롬프트 템플릿을 제공한다:

템플릿 종류 용도 출력 형태
PromptTemplate 문자열 기반 프롬프트 단일 문자열
ChatPromptTemplate Chat 모델용 메시지 기반 프롬프트 메시지 리스트
FewShotPromptTemplate 예시 기반 프롬프트 (문자열) 단일 문자열
FewShotChatMessagePromptTemplate 예시 기반 프롬프트 (채팅) 메시지 리스트


   4.2. PromptTemplate (문자열 기반)

      - 가장 기본적인 프롬프트 템플릿이다. `{변수명}` 형태의 플레이스홀더를 사용하여 변수를 삽입한다.

from langchain_core.prompts import PromptTemplate

# ──────────────────────────────────────────
# 방법 1: from_template (간편 생성 - 변수를 자동 감지)
# ──────────────────────────────────────────
prompt = PromptTemplate.from_template(
    "다음 질문에 {style} 스타일로 답변하세요.\n질문: {question}"
    # → input_variables를 자동으로 ["style", "question"]으로 감지
)

# ──────────────────────────────────────────
# 방법 2: 명시적 정의 (input_variables를 직접 지정)
# ──────────────────────────────────────────
prompt = PromptTemplate(
    input_variables=["style", "question"],  # 필수 변수 명시
    template="다음 질문에 {style} 스타일로 답변하세요.\n질문: {question}"
)

# ──────────────────────────────────────────
# 템플릿 사용: format()으로 변수를 주입하여 최종 문자열 생성
# ──────────────────────────────────────────
formatted = prompt.format(style="전문가", question="소득세란?")
print(formatted)
# → "다음 질문에 전문가 스타일로 답변하세요.\n질문: 소득세란?"

# invoke()로 호출 시에도 동일하게 변수 딕셔너리 전달
result = prompt.invoke({"style": "전문가", "question": "소득세란?"})
# → StringPromptValue(text="다음 질문에 전문가 스타일로 답변하세요.\n질문: 소득세란?")

 

      - 파라미터 정의 기준:
         - `from_template`: 간편하게 사용할 때. 변수명은 템플릿 문자열에서 자동 추출된다.
         - 명시적 `PromptTemplate(...)`: 변수를 명확히 문서화하거나, `partial_variables`로 일부 변수를 미리 채워놓을 때 사용한다.

 

   4.3. ChatPromptTemplate (채팅 기반)

      - Chat 모델(GPT-4o, GPT-4o-mini 등)에 최적화된 프롬프트 템플릿이다. `PromptTemplate`이 단일 문자열을 생성하는 반면, `ChatPromptTemplate`은 역할(system/human/ai)이 지정된 메시지 리스트를 생성한다.

from langchain_core.prompts import ChatPromptTemplate

# from_messages()로 역할별 메시지 정의
prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 {domain} 전문가입니다."),  # 시스템 메시지 (역할 지정)
    ("human", "{question}"),                     # 사용자 메시지 (질문)
])

# format_messages()로 메시지 리스트 생성
messages = prompt.format_messages(domain="세법", question="소득세란?")
# → [SystemMessage(content="당신은 세법 전문가입니다."),
#    HumanMessage(content="소득세란?")]

# invoke()로도 동일하게 사용 가능
result = prompt.invoke({"domain": "세법", "question": "소득세란?"})
# → ChatPromptValue(messages=[SystemMessage(...), HumanMessage(...)])


      4.2.1. PromptTemplate vs ChatPromptTemplate 비교

기준 PromptTemplate ChatPromptTemplate
출력 형태 단일 문자열 메시지 리스트
역할 구분 없음 (하나의 텍스트) system/human/ai 역할 명시
적합 모델 텍스트 완성 모델 (레거시) Chat 모델 (GPT-4o 등 최신 모델)
LCEL 호환 가능 가능
권장 여부 특수한 경우에만 대부분의 경우 권장


         - 실무 권장: 최신 Chat 모델(GPT-4o, GPT-4o-mini 등)을 사용하는 경우 `ChatPromptTemplate`을 기본으로 사용한다. `PromptTemplate`은 `JsonOutputParser`의 `format_instructions`를 삽입하는 등 단일 문자열이 필요한 특수한 경우에만 사용한다.

 

   4.4. FewShotPromptTemplate (예시 기반)

      - 모델에게 입출력 예시(few-shot examples)를 제공하여 원하는 답변 형식과 스타일을 학습시키는 프롬프트 기법이다. 특히 특정 형식의 답변이 필요하거나, 도메인 용어를 정확히 사용해야 할 때 효과적이다.

from langchain_core.prompts import FewShotPromptTemplate, PromptTemplate

# 예시 데이터 정의 (질문-답변 쌍)
examples = [
    {"question": "소득세란?", "answer": "소득세는 개인의 소득에 부과되는 세금입니다."},
    {"question": "부가세란?", "answer": "부가가치세는 재화·용역의 공급에 부과되는 세금입니다."},
]

# 각 예시를 포매팅할 템플릿 정의
example_prompt = PromptTemplate(
    input_variables=["question", "answer"],
    template="질문: {question}\n답변: {answer}"
)

# FewShotPromptTemplate 구성
few_shot_prompt = FewShotPromptTemplate(
    examples=examples,               # 예시 데이터 리스트
    example_prompt=example_prompt,    # 각 예시를 포매팅할 템플릿
    prefix="다음 예시를 참고하여 질문에 답하세요.",  # 예시 앞에 붙는 접두사
    suffix="질문: {question}\n답변:",  # 예시 뒤에 붙는 접미사 (실제 질문)
    input_variables=["question"]       # 최종 프롬프트의 입력 변수
)

# 생성되는 최종 프롬프트:
# "다음 예시를 참고하여 질문에 답하세요.
#
#  질문: 소득세란?
#  답변: 소득세는 개인의 소득에 부과되는 세금입니다.
#
#  질문: 부가세란?
#  답변: 부가가치세는 재화·용역의 공급에 부과되는 세금입니다.
#
#  질문: 법인세란?
#  답변:"


      - 파라미터 정의 기준:

         - `examples`: 2~5개가 적정. 너무 많으면 토큰 비용 증가, 너무 적으면 패턴 학습 부족.
         - `prefix`: 모델에게 예시의 목적과 답변 규칙을 안내하는 텍스트.
         - `suffix`: 실제 사용자 질문이 들어가는 부분. `{question}`과 같은 변수를 포함해야 한다.

 

      4.4.1. FewShotChatMessagePromptTemplate (Chat 메시지 기반 Few-Shot)

         - Chat 모델에 최적화된 few-shot 프롬프트다. 문자열 대신 메시지 튜플로 예시를 구성하여 모델이 역할(human/ai)을 명확히 인식한다.

from langchain_core.prompts import ChatPromptTemplate, FewShotChatMessagePromptTemplate

# 예시를 메시지 형태로 정의 (human → ai 대화 쌍)
examples = [
    {"input": "소득세란?", "output": "소득세는 개인이 벌어들인 소득에 대해 부과되는 세금입니다."},
    {"input": "부가세란?", "output": "부가가치세는 재화나 용역의 거래 과정에서 발생하는 부가가치에 부과되는 세금입니다."},
]

# 각 예시의 포맷 (human → ai 대화 쌍으로 구성)
example_prompt = ChatPromptTemplate.from_messages([
    ("human", "{input}"),   # 사용자 질문 역할
    ("ai", "{output}"),     # AI 답변 역할
])

# FewShotChatMessagePromptTemplate 구성
few_shot_prompt = FewShotChatMessagePromptTemplate(
    example_prompt=example_prompt,  # 각 예시를 메시지로 포매팅할 템플릿
    examples=examples,              # 예시 데이터 리스트
)

# 최종 프롬프트에 few-shot 삽입
final_prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 세법 전문가입니다. 다음 예시를 참고하여 간결하게 답하세요."),
    few_shot_prompt,      # few-shot 예시가 여기에 자동 삽입됨
    ("human", "{input}"),  # 실제 사용자 질문
])

# 체인 구성 및 실행
chain = final_prompt | ChatOpenAI(model="gpt-4o-mini") | StrOutputParser()
result = chain.invoke({"input": "법인세란?"})
print(result)

 

         - FewShotPromptTemplate vs FewShotChatMessagePromptTemplate:

            - `FewShotPromptTemplate`: 문자열 기반, `PromptTemplate`과 함께 사용

            - `FewShotChatMessagePromptTemplate`: 메시지 기반, `ChatPromptTemplate`과 함께 사용 (Chat 모델 권장)

 

         4.4.2. Few-Shot 프롬프트 사용 시 고려사항

고려사항 설명
예시 수 2~5개가 적정. 0-shot(예시 없음)보다 품질 향상, 10개 이상은 토큰 낭비
예시 품질 예시의 답변이 원하는 형식과 품질을 정확히 반영해야 함
예시 다양성 다양한 유형의 질문-답변 쌍을 포함하여 모델의 일반화 능력 향상
예시 선택 고정 예시보다 사용자 질문과 유사한 예시를 동적으로 선택하면 더 효과적
토큰 비용 예시가 많을수록 입력 토큰이 증가하여 비용 상승


         - 실무 권장: 프로덕션 환경에서는 `ExampleSelector`를 사용하여 사용자 질문과 가장 유사한 예시를 동적으로 선택하는 것이 효과적이다. 고정된 예시 세트는 프로토타입 단계에서 사용한다.

 

   4.5. LangChain Hub 프롬프트

      - LangChain Hub는 커뮤니티에서 검증된 프롬프트 템플릿을 공유하는 저장소이다. 직접 프롬프트를 작성하는 대신, 이미 검증된 프롬프트를 가져와 사용할 수 있다.

from langchain import hub

# ──────────────────────────────────────────
# 검증된 프롬프트 가져오기 (LangSmith 계정 필요)
# ──────────────────────────────────────────
rag_prompt = hub.pull("rlm/rag-prompt")                    # 기본 RAG 프롬프트
retrieval_prompt = hub.pull("langchain-ai/retrieval-qa-chat")  # 현대적 검색 QA 프롬프트

# ──────────────────────────────────────────
# 주요 Hub 프롬프트 목록
# ──────────────────────────────────────────
# rlm/rag-prompt                    - 기본 RAG 프롬프트 (가장 널리 사용)
# langchain-ai/retrieval-qa-chat    - 현대적 검색 QA 프롬프트
# langchain-ai/rag-answer-vs-reference     - 답변 정확도 평가용
# langchain-ai/rag-answer-helpfulness      - 답변 유용성 평가용
# langchain-ai/rag-answer-hallucination    - 환각 탐지 평가용


      4.4.1. 주요 Hub 프롬프트 비교

프롬프트 용도 사용 시기
rlm/rag-prompt 기본 RAG 생성 빠른 프로토타이핑, 기본 RAG 구성
langchain-ai/retrieval-qa-chat 검색 기반 QA create_retrieval_chain과 함께 사용
langchain-ai/rag-answer-vs-reference 답변 정확도 평가 LangSmith 평가 파이프라인
langchain-ai/rag-answer-helpfulness 답변 유용성 평가 사용자 만족도 측정
langchain-ai/rag-answer-hallucination 환각 탐지 답변이 컨텍스트에 근거하는지 검증


         - 실무 권장: Hub 프롬프트는 좋은 시작점이지만, 프로덕션에서는 자체 도메인에 맞게 커스터마이징하는 것이 일반적이다. Hub 프롬프트를 기반으로 수정하면 처음부터 작성하는 것보다 효율적이다.

 

5. 출력 파서 (단계 4: 출력 구조화)

   5.1. 배경 지식: LLM 출력의 구조화가 필요한 이유

      - LLM의 출력은 기본적으로 자유 형식의 텍스트이다. 그러나 실무에서는 답변을 JSON, 리스트, Pydantic 객체 등 구조화된 형태로 받아야 하는 경우가 대부분이다. 예를 들어 세금 정보를 추출하여 DB에 저장하거나, API 응답으로 반환해야 할 때 자유 형식 텍스트는 사용하기 어렵다.

[출력 파서의 역할]

LLM 출력 (자유 형식)              출력 파서                 구조화된 데이터
─────────────────              ──────────              ────────────────
AIMessage(content=             StrOutputParser()   →   "소득세는..." (str)
  "소득세는..."
)                              JsonOutputParser()  →   {"tax_type": "소득세",
                                                        "rate": 6.0} (dict)

                               PydanticOutputParser →  TaxInfo(tax_type="소득세",
                                                              rate=6.0) (객체)


      5.1.1. 출력 파서 종류 한눈에 비교

파서 출력 형태 주요 용도 복잡도
StrOutputParser str 일반 텍스트 답변 낮음
JsonOutputParser dict JSON 형태 응답 중간
PydanticOutputParser Pydantic 객체 타입 검증이 필요한 구조화 데이터 중간
CommaSeparatedListOutputParser list[str] 쉼표 구분 리스트 낮음
OutputFixingParser 원본 파서의 출력 파싱 실패 자동 복구 높음
with_structured_output() Pydantic 객체 모델 네이티브 구조화 (권장) 낮음


   5.2. StrOutputParser

      - 가장 기본적인 출력 파서이다. `AIMessage` 객체에서 `content` 문자열만 추출한다. 대부분의 RAG 파이프라인에서 최종 답변을 문자열로 반환할 때 사용한다.

from langchain_core.output_parsers import StrOutputParser

parser = StrOutputParser()

# AIMessage 객체에서 content 문자열만 추출
# AIMessage(content="답변") → "답변" (문자열)

# LCEL 체인에서 가장 흔한 사용 패턴
chain = prompt | llm | StrOutputParser()
result = chain.invoke({"question": "소득세란?"})
print(type(result))  # <class 'str'>
print(result)        # "소득세는..." (순수 문자열)


      - 실무 권장:

         - RAG 파이프라인에서 최종 답변을 사용자에게 보여줄 때는 `StrOutputParser`로 충분하다. 후속 처리(DB 저장, API 응답 등)가 필요한 경우에만 `JsonOutputParser`나 `with_structured_output()`을 사용한다.

 

   5.3. JsonOutputParser

      - LLM의 출력을 JSON(딕셔너리) 형태로 파싱한다. Pydantic 모델로 스키마를 정의하면, 해당 스키마에 맞는 `format_instructions`(형식 지시사항)를 자동으로 생성하여 프롬프트에 삽입할 수 있다.

from langchain_core.output_parsers import JsonOutputParser
from pydantic import BaseModel, Field
# 참고: langchain_core.pydantic_v1은 deprecated. LangChain v0.3+에서는 pydantic v2 직접 사용

# Pydantic 모델로 출력 스키마 정의
class TaxInfo(BaseModel):
    tax_type: str = Field(description="세금 종류")      # 필드 설명이 format_instructions에 포함됨
    rate: float = Field(description="세율 (%)")
    description: str = Field(description="설명")

# JsonOutputParser에 Pydantic 모델 전달
parser = JsonOutputParser(pydantic_object=TaxInfo)

# format_instructions: LLM에게 "이 JSON 형식으로 응답하라"는 지시사항 자동 생성
prompt = PromptTemplate(
    template="다음 질문에 JSON으로 답하세요.\n{format_instructions}\n질문: {question}",
    input_variables=["question"],
    partial_variables={"format_instructions": parser.get_format_instructions()}
    # partial_variables: 템플릿 생성 시 미리 채워넣는 변수 (invoke 시 전달 불필요)
)

# 체인 구성 및 실행
chain = prompt | llm | parser
result = chain.invoke({"question": "소득세 정보"})
print(type(result))  # <class 'dict'>
print(result)
# → {"tax_type": "소득세", "rate": 6.0, "description": "..."}

 

      - 파라미터 정의 기준:

         - `pydantic_object`: 출력 스키마를 정의하는 Pydantic 모델. 각 필드의 `Field(description=...)`이 LLM에게 전달되는 형식 지시사항에 포함된다.

         - `partial_variables`: `format_instructions`처럼 invoke 시마다 변하지 않는 값을 미리 채워넣는 데 사용한다.

 

   5.4. PydanticOutputParser

      - `JsonOutputParser`와 유사하지만, 파싱 결과를 딕셔너리가 아닌 Pydantic 모델 인스턴스로 반환한다. 타입 검증이 자동으로 수행되므로 데이터 무결성이 보장된다.

from langchain_core.output_parsers import PydanticOutputParser

# TaxInfo Pydantic 모델 사용 (위에서 정의한 것과 동일)
parser = PydanticOutputParser(pydantic_object=TaxInfo)

# 자동으로 포맷 지시사항 생성 (LLM에게 JSON 형식을 안내)
instructions = parser.get_format_instructions()
# → "The output should be formatted as a JSON instance..."

# 파싱 결과는 TaxInfo 객체 (딕셔너리가 아님)
# result = parser.parse(llm_output)
# print(type(result))  # <class 'TaxInfo'>
# print(result.tax_type)  # "소득세" (속성 접근 가능)


      5.4.1. JsonOutputParser vs PydanticOutputParser 비교

기준 JsonOutputParser PydanticOutputParser
반환 타입 dict (딕셔너리) Pydantic 모델 인스턴스
타입 검증 없음 (JSON 파싱만 수행) Pydantic 모델의 타입 검증 자동 수행
필드 접근 result["tax_type"] result.tax_type
파싱 실패 시 JSON 파싱 에러 Pydantic 검증 에러 (더 상세한 에러 메시지)
사용 편의성 간단한 경우 적합 복잡한 스키마, 타입 안전성 필요 시 적합


   5.5. CommaSeparatedListOutputParser

      - LLM 출력을 쉼표로 구분된 리스트로 파싱한다. 간단한 열거형 답변이 필요할 때 사용한다.

from langchain_core.output_parsers import CommaSeparatedListOutputParser

parser = CommaSeparatedListOutputParser()

# LLM 출력 "소득세, 법인세, 부가세"를 리스트로 변환
# → ["소득세", "법인세", "부가세"]

# 포맷 지시사항 확인
print(parser.get_format_instructions())
# → "Your response should be a list of comma separated values, eg: `foo, bar, baz`"


   5.6. OutputFixingParser (파싱 오류 자동 수정)

      - LLM 출력이 예상 형식과 다를 때 LLM을 다시 호출하여 자동으로 파싱을 수정한다. 프로덕션 환경에서 안정성을 높이는 데 유용하다.

from langchain_core.output_parsers import PydanticOutputParser
from langchain.output_parsers import OutputFixingParser
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

class TaxInfo(BaseModel):
    tax_name: str = Field(description="세금 이름")
    rate: float = Field(description="세율 (퍼센트)")
    description: str = Field(description="간단한 설명")

# 기본 파서 정의
base_parser = PydanticOutputParser(pydantic_object=TaxInfo)

# OutputFixingParser로 감싸기 - 파싱 실패 시 LLM이 자동으로 수정 시도
fixing_parser = OutputFixingParser.from_llm(
    parser=base_parser,    # 기본 파서 (이 파서로 먼저 파싱 시도)
    llm=ChatOpenAI(model="gpt-4o-mini", temperature=0)  # 수정용 LLM
)

# 잘못된 형식의 출력도 자동 수정
malformed_output = '{"tax_name": "소득세", "rate": "약 6~45%", "description": "개인 소득에 부과"}'
# base_parser.parse(malformed_output)  # 이것은 rate가 float이 아니어서 실패
result = fixing_parser.parse(malformed_output)  # LLM이 "약 6~45%"를 적절한 float로 수정
print(result)  # TaxInfo(tax_name="소득세", rate=6.0, description="개인 소득에 부과")


      5.6.1. OutputFixingParser 동작 흐름

[OutputFixingParser 자동 수정 흐름]

1단계: 기본 파서로 파싱 시도
  malformed_output → base_parser.parse() → 실패 (ValidationError)

2단계: LLM에게 수정 요청 (자동)
  "다음 출력을 올바른 형식으로 수정하세요:
   원본: {"rate": "약 6~45%", ...}
   에러: rate 필드는 float이어야 합니다."
  → LLM 응답: {"rate": 6.0, ...}

3단계: 수정된 출력을 다시 파싱
  수정된 출력 → base_parser.parse() → 성공 → TaxInfo 객체 반환


         - 실무 권장:

            - `OutputFixingParser`는 파싱 실패 시 추가 LLM 호출이 발생하므로 비용이 증가한다. 프로덕션에서는 먼저 `with_structured_output()`이나 잘 설계된 프롬프트로 파싱 실패를 최소화하고, `OutputFixingParser`는 최후의 안전장치로 사용한다.

 

   5.7. with_structured_output() (모델 네이티브 구조화 출력)

      - LangChain v0.3+에서 권장하는 구조화 출력 방식이다. 별도의 파서 없이 LLM이 직접 Pydantic 모델에 맞는 출력을 생성한다. Function Calling을 지원하는 모델(GPT-4o, GPT-4o-mini 등)에서 사용 가능하다.

from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field
from typing import Optional

# 출력 스키마 정의 (Pydantic 모델)
class TaxAnalysis(BaseModel):
    """세금 분석 결과"""
    tax_type: str = Field(description="세금 종류")
    applicable_rate: float = Field(description="적용 세율 (%)")
    tax_amount: Optional[int] = Field(description="예상 세액 (원)", default=None)
    explanation: str = Field(description="계산 근거 설명")
    references: list[str] = Field(description="관련 법령 조항", default_factory=list)

# with_structured_output으로 LLM에 스키마 바인딩
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
structured_llm = llm.with_structured_output(TaxAnalysis)
# → 내부적으로 Function Calling을 사용하여 스키마에 맞는 출력 보장

# 호출 - 별도 파서 없이 바로 Pydantic 객체 반환
result = structured_llm.invoke("연봉 5000만원 직장인의 근로소득세를 분석해주세요.")
print(f"세금 종류: {result.tax_type}")        # 속성으로 직접 접근
print(f"적용 세율: {result.applicable_rate}%")
print(f"예상 세액: {result.tax_amount}원")
print(f"설명: {result.explanation}")
print(f"관련 법령: {result.references}")

# LCEL 체인에서 사용 (파서 없이 간결한 체인 구성)
from langchain_core.prompts import ChatPromptTemplate

prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 세무 전문가입니다. 정확한 세금 분석을 제공하세요."),
    ("human", "{question}")
])

# 파서 체인 없이 직접 구조화 출력 (prompt | structured_llm으로 완료)
analysis_chain = prompt | structured_llm
result = analysis_chain.invoke({"question": "법인세 과세표준 3억원인 경우의 세액은?"})
print(type(result))  # <class 'TaxAnalysis'> (Pydantic 객체)


      5.7.1. with_structured_output() 동작 원리

[with_structured_output() 내부 동작]

일반 파서 방식:
  prompt → LLM → "자유 형식 텍스트" → OutputParser → 구조화 데이터
                  (파싱 실패 가능)

with_structured_output() 방식:
  prompt → LLM (Function Calling으로 스키마 강제) → 구조화 데이터 직접 반환
           ↑ Pydantic 스키마가 Function 정의로 변환되어 LLM에 전달됨
           → 파싱 실패 가능성 극히 낮음


         - with_structured_output() vs OutputParser 비교:

방식 장점 단점
with_structured_output() 간결, 높은 정확도, 별도 파서 불필요 Function Calling 지원 모델만 가능
PydanticOutputParser 모든 LLM에서 사용 가능 format_instructions 필요, 파싱 실패 가능
OutputFixingParser 파싱 실패 자동 복구 추가 LLM 호출 비용


      5.7.2. 구조화 출력 방식 선택 가이드

상황 권장 방식 이유
GPT-4o/GPT-4o-mini 사용 with_structured_output() Function Calling 지원, 가장 간결하고 정확
Ollama/HuggingFace 로컬 모델 PydanticOutputParser Function Calling 미지원 모델에서도 사용 가능
파싱 실패 빈도가 높은 경우 OutputFixingParser 자동 재시도로 안정성 확보
단순 텍스트 답변만 필요 StrOutputParser 가장 간단, 추가 설정 불필요


         - 실무 권장: Function Calling을 지원하는 모델(GPT-4o, GPT-4o-mini)을 사용한다면 `with_structured_output()`이 가장 권장되는 방식이다. 코드가 간결하고, 파싱 실패 가능성이 극히 낮으며, 별도 파서를 체인에 추가할 필요가 없다.

 

6. LCEL (LangChain Expression Language) (단계 5: 체인 구성)

   6.1. 배경 지식: LCEL이 필요한 이유

      - LangChain의 초기 버전에서는 체인을 구성하기 위해 `LLMChain`, `SequentialChain` 등의 클래스를 사용해야 했다. 이 방식은 코드가 장황하고, 중간 단계를 디버깅하기 어려웠다. LCEL(LangChain Expression Language)은 이를 대체하는 선언적(declarative) 체인 구성 방식으로, `|` 연산자(파이프)로 컴포넌트를 직관적으로 연결한다.

[레거시 방식 vs LCEL 방식 비교]

레거시 방식 (LLMChain):
  chain = LLMChain(llm=llm, prompt=prompt, output_parser=parser)
  result = chain.run(question="소득세란?")
  # → 단일 체인만 구성 가능, 복잡한 파이프라인은 SequentialChain 필요

LCEL 방식 (파이프 연산자):
  chain = prompt | llm | parser
  result = chain.invoke({"question": "소득세란?"})
  # → 직관적, 유연한 조합, 스트리밍/배치/비동기 자동 지원


      6.1.1. LCEL의 핵심 장점

장점 설명
직관적 구문 | 연산자로 Unix 파이프처럼 데이터 흐름을 표현
자동 스트리밍 체인 전체에서 .stream() 호출 시 가능한 한 빨리 토큰 출력
자동 배치 .batch() 호출 시 각 컴포넌트에서 병렬 처리
자동 비동기 .ainvoke() 호출 시 비동기 실행
중간 단계 접근 RunnableLambda로 체인 중간 데이터를 검사하거나 변환 가능
타입 안전성 각 컴포넌트의 입출력 타입이 명확하여 디버깅 용이


   6.2. 파이프 연산자 (|)

      - LCEL의 핵심은 `|` 연산자로 Runnable을 연결하는 것이다. `|` 연산자로 연결된 체인은 내부적으로 `RunnableSequence` 객체가 된다.

from langchain_core.runnables import RunnableSequence

# 기본 체인 (| 연산자는 RunnableSequence를 생성)
chain = prompt | llm | StrOutputParser()
# 위와 동일: chain = RunnableSequence(first=prompt, middle=[llm], last=StrOutputParser())

# 동작 흐름:
# 1. prompt.invoke({"question": "..."}) → 포매팅된 프롬프트 (ChatPromptValue)
# 2. llm.invoke(formatted_prompt) → AIMessage (모델 응답)
# 3. StrOutputParser().invoke(ai_message) → str (문자열 추출)


      6.2.1. LCEL 데이터 흐름 상세 다이어그램

[LCEL 체인 데이터 흐름]

입력 딕셔너리                  prompt                        llm                    StrOutputParser
──────────────               ──────                       ─────                  ────────────────
{"question":          →   ChatPromptValue(           →   AIMessage(          →   "소득세는 개인의
 "소득세란?"}               messages=[                     content=                소득에 부과되는
                            SystemMessage(...),            "소득세는 개인의         세금입니다."
                            HumanMessage(                   소득에 부과되는
                              "소득세란?")                   세금입니다."             (str)
                           ])                            )
                           (프롬프트 객체)               (메시지 객체)

각 단계의 입출력 타입:
  dict → ChatPromptValue → AIMessage → str


         - 파라미터 정의 기준: `|` 연산자는 Python의 `__or__` 매직 메서드를 오버로딩한 것이다. 좌측 Runnable의 출력 타입이 우측 Runnable의 입력 타입과 호환되어야 한다. 예를 들어 `StrOutputParser`는 `AIMessage`를 입력으로 받으므로, 반드시 `llm` 뒤에 위치해야 한다.

 

   6.3. RunnablePassthrough

      - 입력을 그대로 통과시키는 Runnable이다. 주로 딕셔너리 구성에서 원본 입력을 유지하면서 다른 필드를 추가할 때 사용한다. RAG 체인에서 가장 빈번하게 사용되는 패턴이다.

from langchain_core.runnables import RunnablePassthrough

# ──────────────────────────────────────────
# 패턴 1: 단순 통과 (입력이 그대로 llm에 전달)
# ──────────────────────────────────────────
chain = RunnablePassthrough() | llm
# 입력이 그대로 llm에 전달됨

# ──────────────────────────────────────────
# 패턴 2: 딕셔너리와 함께 사용 (RAG에서 가장 흔한 패턴)
# ──────────────────────────────────────────
chain = (
    {
        "context": retriever,                   # retriever가 문서를 검색
        "question": RunnablePassthrough()       # 원본 질문을 그대로 전달
    }
    | prompt      # {"context": docs, "question": "..."} → 프롬프트 포매팅
    | llm         # 포매팅된 프롬프트 → AIMessage
    | StrOutputParser()  # AIMessage → str
)

# 동작 상세:
# input = "근로소득세란?"
# 딕셔너리 단계에서 두 Runnable이 병렬 실행:
#   retriever.invoke("근로소득세란?") → [Document(...), Document(...), ...]
#   RunnablePassthrough().invoke("근로소득세란?") → "근로소득세란?"
# 결과: {"context": [Document, ...], "question": "근로소득세란?"}
# → prompt.invoke({"context": ..., "question": ...}) → ChatPromptValue
# → llm.invoke(prompt_value) → AIMessage
# → StrOutputParser().invoke(ai_message) → str


      6.3.1. RunnablePassthrough vs RunnablePassthrough.assign() 비교

from langchain_core.runnables import RunnablePassthrough

# RunnablePassthrough(): 입력을 그대로 통과
pass_through = RunnablePassthrough()
pass_through.invoke("hello")  # → "hello"

# RunnablePassthrough.assign(): 기존 딕셔너리에 새 필드를 추가
# (입력이 딕셔너리일 때 사용)
chain = RunnablePassthrough.assign(
    context=retriever  # 기존 입력 딕셔너리에 "context" 필드를 추가
)
# 입력: {"question": "소득세란?"}
# 출력: {"question": "소득세란?", "context": [Document, ...]}
# → 기존 "question" 필드는 유지하면서 "context" 필드가 추가됨


         - 실무 권장: RAG 체인에서는 `{"context": retriever, "question": RunnablePassthrough()}` 패턴이 가장 표준적이다. 입력 딕셔너리에 필드를 추가해야 할 때는 `RunnablePassthrough.assign()`을 사용한다.

 

   6.4. RunnableLambda

      - 커스텀 함수를 Runnable로 변환한다. 체인 중간에 전처리, 후처리, 데이터 변환 등 임의의 로직을 삽입할 때 사용한다.

from langchain_core.runnables import RunnableLambda

def preprocess_query(query: str) -> str:
    """질의 전처리: 공백 제거, 소문자 변환"""
    return query.strip().lower()

def postprocess_answer(answer: str) -> dict:
    """답변 후처리: 메타데이터 추가"""
    return {
        "answer": answer,           # 원본 답변
        "length": len(answer),      # 답변 길이
        "has_code": "```" in answer  # 코드 포함 여부
    }

# RunnableLambda로 일반 함수를 체인에 삽입
chain = (
    RunnableLambda(preprocess_query)     # 1단계: 질의 전처리
    | prompt                              # 2단계: 프롬프트 포매팅
    | llm                                 # 3단계: LLM 호출
    | StrOutputParser()                   # 4단계: 문자열 추출
    | RunnableLambda(postprocess_answer)  # 5단계: 답변 후처리
)

# 실행
result = chain.invoke("  소득세 계산법은?  ")
# 1단계: "  소득세 계산법은?  " → "소득세 계산법은?"
# 2단계: "소득세 계산법은?" → ChatPromptValue(...)
# 3단계: ChatPromptValue → AIMessage("소득세는...")
# 4단계: AIMessage → "소득세는..."
# 5단계: "소득세는..." → {"answer": "소득세는...", "length": 42, "has_code": False}


      6.4.1. RunnableLambda 활용 패턴

# 패턴 1: 인라인 lambda (간단한 변환)
chain = prompt | llm | StrOutputParser() | RunnableLambda(lambda x: x.upper())

# 패턴 2: 조건부 로직
def route_by_length(answer: str) -> str:
    """답변 길이에 따라 후처리"""
    if len(answer) > 500:
        return answer[:500] + "\n\n(이하 생략...)"  # 긴 답변 자르기
    return answer

chain = prompt | llm | StrOutputParser() | RunnableLambda(route_by_length)

# 패턴 3: 로깅/디버깅
def log_step(x):
    """체인 중간 데이터를 로깅 (데이터는 그대로 통과)"""
    print(f"[LOG] 타입: {type(x).__name__}, 길이: {len(str(x))}")
    return x  # 반드시 입력을 그대로 반환해야 체인이 이어짐

chain = prompt | RunnableLambda(log_step) | llm | RunnableLambda(log_step) | StrOutputParser()


         - 실무 권장: 간단한 변환은 인라인 `lambda`를, 복잡한 로직은 명명된 함수를 `RunnableLambda`로 감싸서 사용한다. 디버깅 시에는 `RunnableLambda(log_step)`을 체인 중간에 삽입하여 데이터 흐름을 확인한다.

 

   6.5. RunnableParallel

      - 여러 Runnable을 병렬로 실행한다. 동일한 입력을 여러 Runnable에 동시에 전달하고, 각각의 결과를 딕셔너리로 합쳐 반환한다. LCEL에서 딕셔너리 `{}`를 사용하면 암시적으로 `RunnableParallel`이 생성된다.

from langchain_core.runnables import RunnableParallel, RunnablePassthrough, RunnableLambda

# ──────────────────────────────────────────
# 방법 1: 딕셔너리 (암시적 RunnableParallel) - 가장 흔한 방식
# ──────────────────────────────────────────
chain = {"context": retriever, "question": RunnablePassthrough()} | prompt
# 딕셔너리 {} 는 내부적으로 RunnableParallel로 변환됨
# retriever와 RunnablePassthrough()가 동일 입력으로 병렬 실행

# ──────────────────────────────────────────
# 방법 2: 명시적 RunnableParallel
# ──────────────────────────────────────────
parallel = RunnableParallel(
    context=retriever,                                             # 문서 검색
    question=RunnablePassthrough(),                                # 원본 질문 유지
    metadata=RunnableLambda(lambda x: {"timestamp": "2024-01-01"}) # 메타데이터 생성
)
# 입력: "근로소득세란?"
# 출력: {
#   "context": [Document, ...],        ← retriever 결과
#   "question": "근로소득세란?",         ← 원본 입력
#   "metadata": {"timestamp": "..."}   ← 메타데이터
# }


      6.5.1. RunnableParallel 동작 구조

[RunnableParallel 병렬 실행 구조]

                        ┌──→ retriever.invoke(input)        ──→ context
                        │
input ──→ RunnableParallel ──→ RunnablePassthrough(input)  ──→ question    ──→ 결합된 딕셔너리
                        │
                        └──→ RunnableLambda(input)          ──→ metadata

각 브랜치가 동일한 입력(input)을 받아 병렬로 실행되며,
결과는 키-값 쌍의 딕셔너리로 합쳐진다.


   6.6. RunnableBranch (조건부 분기)

      - 입력 조건에 따라 다른 체인을 실행하는 조건부 분기이다. 질의 유형별로 다른 처리 로직을 적용할 때 사용한다.

from langchain_core.runnables import RunnableBranch

# 질의 유형에 따른 분기 처리
branch_chain = RunnableBranch(
    # 조건-체인 쌍을 순서대로 정의 (위에서부터 조건 검사)
    (lambda x: "세금" in x["question"], tax_chain),      # 세금 관련 질문 → tax_chain
    (lambda x: "계산" in x["question"], calc_chain),      # 계산 관련 질문 → calc_chain
    general_chain  # 기본값: 어떤 조건에도 해당하지 않을 때 실행
)

# 동작:
# input = {"question": "세금 종류는?"}
# 1. "세금" in "세금 종류는?" → True → tax_chain 실행
#
# input = {"question": "이자 계산 방법은?"}
# 1. "세금" in "이자 계산 방법은?" → False
# 2. "계산" in "이자 계산 방법은?" → True → calc_chain 실행
#
# input = {"question": "날씨가 어때요?"}
# 1. "세금" in "날씨가 어때요?" → False
# 2. "계산" in "날씨가 어때요?" → False
# 3. 기본값 → general_chain 실행


      6.6.1. RunnableBranch 활용 예시: 도메인별 RAG 라우팅

from langchain_core.runnables import RunnableBranch, RunnableLambda
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 도메인별 특화 프롬프트와 체인 정의
tax_prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 세법 전문가입니다. 세금 관련 질문에 정확히 답하세요."),
    ("human", "{question}")
])
tax_chain = tax_prompt | llm | StrOutputParser()  # 세금 전용 체인

general_prompt = ChatPromptTemplate.from_messages([
    ("system", "당신은 도움이 되는 AI 어시스턴트입니다."),
    ("human", "{question}")
])
general_chain = general_prompt | llm | StrOutputParser()  # 범용 체인

# 분기 체인 구성
router_chain = RunnableBranch(
    (lambda x: any(kw in x["question"] for kw in ["세금", "세율", "소득세", "법인세"]),
     tax_chain),   # 세금 키워드 포함 → 세금 전용 체인
    general_chain  # 그 외 → 범용 체인
)

# 실행
result = router_chain.invoke({"question": "근로소득세 계산 방법은?"})  # → tax_chain 실행


         - 실무 권장: `RunnableBranch`는 간단한 조건 분기에 적합하다. 분기 조건이 복잡하거나, LLM이 분류해야 하는 경우에는 별도의 분류 체인(classifier chain)을 구성하여 분기 로직을 처리하는 것이 더 유연하다.

 

   6.7. 체인 디버깅

      - 체인이 복잡해지면 각 단계에서 데이터가 어떻게 변환되는지 확인하기 어렵다. LangChain은 다양한 디버깅 방법을 제공한다.

# ──────────────────────────────────────────
# 방법 1: RunnableLambda로 중간 단계 출력 (가장 실용적)
# ──────────────────────────────────────────
from langchain_core.runnables import RunnableLambda

def debug_step(x):
    """체인 중간 데이터를 출력하는 디버깅 함수"""
    print(f"[DEBUG] Type: {type(x).__name__}")
    print(f"[DEBUG] Value: {str(x)[:200]}")  # 값이 길면 200자까지만 출력
    print("-" * 50)
    return x  # 반드시 입력을 그대로 반환 (체인 연결 유지)

chain = (
    prompt
    | RunnableLambda(debug_step)  # 프롬프트 포매팅 결과 확인
    | llm
    | RunnableLambda(debug_step)  # LLM 출력 확인
    | StrOutputParser()
)

# ──────────────────────────────────────────
# 방법 2: with_config (콜백 핸들러 활용)
# ──────────────────────────────────────────
from langchain_core.callbacks import StdOutCallbackHandler

# StdOutCallbackHandler: 모든 단계의 시작/종료를 표준 출력에 출력
chain_with_debug = chain.with_config({"callbacks": [StdOutCallbackHandler()]})
result = chain_with_debug.invoke({"question": "소득세란?"})


      6.7.1. 디버깅 방법 비교

방법 장점 단점 사용 시기
RunnableLambda(debug_step) 원하는 위치에 삽입, 출력 형식 자유 체인 수정 필요 특정 단계의 데이터를 확인할 때
StdOutCallbackHandler 체인 수정 없이 전체 흐름 확인 출력이 장황할 수 있음 전체 체인 흐름을 파악할 때
LangSmith 연동 웹 UI에서 시각적 디버깅 설정 필요 프로덕션 모니터링, 팀 협업


         - 실무 권장: 개발 단계에서는 `RunnableLambda(debug_step)`으로 특정 단계를 확인하고, 프로덕션에서는 LangSmith를 연동하여 웹 UI에서 체인의 전체 실행 과정을 모니터링하는 것이 가장 효과적이다.

 

   6.8. LCEL 전체 구성 요소 요약

구성 요소 역할 주요 사용 패턴
| (파이프 연산자) 순차 연결 prompt | llm | parser
RunnablePassthrough 입력 그대로 통과 RAG 딕셔너리에서 질문 유지
RunnablePassthrough.assign() 딕셔너리에 필드 추가 기존 입력에 새 필드 합치기
RunnableLambda 커스텀 함수 → Runnable 전처리, 후처리, 디버깅
RunnableParallel / {} 병렬 실행 동시에 여러 작업 수행
RunnableBranch 조건부 분기 질의 유형별 다른 체인 실행
RunnableSequence 순차 실행 (|의 내부 구현) 직접 사용보다 |로 생성
[LCEL 체인 구성 전체 패턴 정리]

1. 기본 패턴:
   prompt | llm | StrOutputParser()

2. RAG 패턴:
   {"context": retriever, "question": RunnablePassthrough()} | prompt | llm | parser

3. 전처리 + RAG 패턴:
   RunnableLambda(preprocess)
   | {"context": retriever, "question": RunnablePassthrough()}
   | prompt | llm | parser

4. 조건 분기 패턴:
   RunnableBranch(
       (조건1, chain1),
       (조건2, chain2),
       default_chain
   )

5. 디버깅 패턴:
   prompt | RunnableLambda(debug) | llm | RunnableLambda(debug) | parser


      - 실무 권장: LCEL 체인을 구성할 때는 단순한 패턴(1번)부터 시작하여 동작을 확인한 후, 점진적으로 검색(2번), 전처리(3번), 분기(4번) 등을 추가한다. 한 번에 복잡한 체인을 구성하면 디버깅이 어려워지므로, 각 단계를 개별적으로 테스트하는 것이 중요하다.

 

7. 문서 로딩과 분할 (단계 6: 데이터 수집)

   - RAG 파이프라인에서 데이터 수집은 전체 품질의 기반이 되는 단계이다. 아무리 뛰어난 LLM과 검색 알고리즘을 사용하더라도, 원본 데이터의 로딩과 분할이 부실하면 최종 답변의 품질이 떨어진다. 이 단계에서는 다양한 포맷의 문서를 LangChain의 Document 객체로 변환하고, 검색에 최적화된 크기로 분할하는 방법을 다룬다.

 

   1) 배경 지식: Document 객체의 구조

      - LangChain에서 모든 문서 데이터는 `Document` 객체로 표현된다. 로더(Loader)가 반환하는 결과도, 텍스트 분할기(Splitter)가 처리하는 입력과 출력도 모두 `Document` 객체이다.

from langchain_core.documents import Document

# Document 객체의 기본 구조
doc = Document(
    page_content="문서의 실제 텍스트 내용",  # 텍스트 본문 (필수)
    metadata={                               # 메타데이터 (선택, 딕셔너리)
        "source": "파일 경로 또는 URL",
        "page": 0,
        "category": "세법"
    }
)


      - 핵심 포인트: `page_content`는 임베딩과 검색의 대상이 되는 텍스트이고, `metadata`는 검색 필터링이나 출처 추적에 활용된다. 메타데이터에 커스텀 필드를 추가하면 검색 시 필터 조건으로 사용할 수 있다.

 

   7.1. 다양한 로더 활용

      - LangChain은 파일 포맷별로 전문화된 로더를 제공한다. 각 로더는 해당 포맷의 파싱 로직을 내장하고 있어, 파일 경로만 지정하면 자동으로 텍스트를 추출하고 메타데이터를 생성한다.

 

      7.1.1. 로더 유형별 비교

로더 대상 포맷 반환 단위 자동 생성 메타데이터 설치 필요 패키지
Docx2txtLoader .docx 전체 문서 1개 source docx2txt
PyPDFLoader .pdf 페이지별 1개씩 source, page pypdf
WebBaseLoader URL (HTML) 페이지 1개 source, title beautifulsoup4
DirectoryLoader 디렉토리 파일별 1개씩 source 로더별 상이
CSVLoader .csv 행(row)별 1개씩 source, row (기본 포함)
TextLoader .txt 전체 문서 1개 source (기본 포함)
UnstructuredMarkdownLoader .md 전체 문서 1개 source unstructured
# ── DOCX 파일 로딩 ──
from langchain_community.document_loaders import Docx2txtLoader

loader = Docx2txtLoader("./doc.docx")  # Word 문서 경로 지정
docs = loader.load()                    # Document 리스트 반환 (보통 1개)
# docs[0].page_content → "문서 전체 텍스트..."
# docs[0].metadata → {"source": "./doc.docx"}

# ── PDF 파일 로딩 ──
from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("./doc.pdf")      # PDF 파일 경로 지정
docs = loader.load()                    # 페이지 수만큼 Document 리스트 반환
# docs[0].metadata → {"source": "./doc.pdf", "page": 0}  ← 페이지 번호 자동 포함

# ── 웹 페이지 로딩 ──
from langchain_community.document_loaders import WebBaseLoader

loader = WebBaseLoader("https://example.com")  # URL 지정
docs = loader.load()                            # HTML을 텍스트로 변환하여 반환
# docs[0].metadata → {"source": "https://example.com", "title": "페이지 제목"}

# ── 디렉토리 전체 로딩 ──
from langchain_community.document_loaders import DirectoryLoader

loader = DirectoryLoader(
    "./docs/",                  # 대상 디렉토리 경로
    glob="**/*.docx",           # 파일 패턴 (하위 폴더 포함)
    loader_cls=Docx2txtLoader,  # 각 파일에 사용할 로더 클래스
    show_progress=True          # 진행률 표시 (tqdm 필요)
)
docs = loader.load()  # 매칭되는 모든 파일을 로딩하여 Document 리스트 반환

# ── CSV 파일 로딩 ──
from langchain_community.document_loaders import CSVLoader

loader = CSVLoader(
    "./data.csv",                       # CSV 파일 경로
    csv_args={"delimiter": ","},        # CSV 파싱 옵션 (구분자 지정)
    source_column="source"              # 메타데이터의 source로 사용할 컬럼명
)
docs = loader.load()  # 각 행(row)이 하나의 Document로 변환


      7.1.2. 로더 선택 가이드

문서 포맷 확인
    │
    ├─ .docx  ──→  Docx2txtLoader
    ├─ .pdf   ──→  PyPDFLoader (페이지별) / PDFMinerLoader (정밀 파싱)
    ├─ .csv   ──→  CSVLoader
    ├─ .txt   ──→  TextLoader
    ├─ .md    ──→  UnstructuredMarkdownLoader
    ├─ URL    ──→  WebBaseLoader
    ├─ 디렉토리 ──→ DirectoryLoader (glob 패턴 + 로더 클래스 지정)
    └─ 기타   ──→  Document 직접 생성 (커스텀 파싱)


         - 실무 권장: 파일 기반 데이터는 LangChain 로더를 우선 사용한다. 로더가 지원하지 않는 데이터 소스(내부 DB, API 응답, 크롤링 결과 등)는 `Document` 객체를 직접 생성한다. `DirectoryLoader`는 대량의 파일을 한 번에 로딩할 때 유용하며, `show_progress=True`를 설정하면 진행 상황을 확인할 수 있다.

 

   7.2. 텍스트 분할

      - 로딩된 문서는 대부분 LLM의 컨텍스트 윈도우보다 길기 때문에, 검색 단위에 맞게 작은 청크(chunk)로 분할해야 한다. 청크가 너무 크면 검색 시 관련 없는 내용이 포함되고, 너무 작으면 의미 있는 문맥이 손실된다.

 

      7.2.1. 분할 방법 비교: load_and_split vs 개별 수행

방법 코드 장점 단점
load_and_split() 한 줄로 로딩+분할 간결한 코드 중간 검증 불가
개별 수행 load() → split_documents() 중간 결과 확인 가능 코드 2줄
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 텍스트 분할기 설정
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1500,     # 각 청크의 최대 문자 수
    chunk_overlap=200    # 인접 청크 간 중복 문자 수 (문맥 연속성 보장)
)

# ── 방법 1: load_and_split (로딩+분할을 한 번에 수행) ──
docs = loader.load_and_split(splitter)
# 내부적으로 loader.load() → splitter.split_documents()를 순차 실행
# 간단하지만 로딩 결과를 중간에 확인할 수 없음

# ── 방법 2: 개별 수행 (로딩과 분할을 분리) ──
documents = loader.load()                    # 1단계: 원본 문서 로딩
print(f"로딩된 문서 수: {len(documents)}")    # 로딩 결과 확인 가능
print(f"첫 문서 길이: {len(documents[0].page_content)}자")

docs = splitter.split_documents(documents)   # 2단계: 청크로 분할
print(f"분할된 청크 수: {len(docs)}")         # 분할 결과 확인 가능
print(f"첫 청크 길이: {len(docs[0].page_content)}자")

 

         - 파라미터 정의 기준: `chunk_size=1500`은 법률/기술 문서처럼 문맥이 중요한 경우에 적합하다. 일반 Q&A용이라면 500~1000을 권장한다. `chunk_overlap=200`은 `chunk_size`의 약 10~15%로, 인접 청크 간 문맥이 끊기지 않도록 보장한다.

 

      7.2.2. chunk_size와 chunk_overlap의 동작 원리

chunk_size = 1500, chunk_overlap = 200 인 경우:

원본 문서: [============================================전체 텍스트============================================]

청크 1: [===========1500자===========]
청크 2:                    [==200==[===========1500자===========]
청크 3:                                          [==200==[===========1500자===========]

         ←── 중복 영역(overlap) ──→
         이 영역이 문맥 연속성을 보장하여
         청크 경계에서 정보가 잘리는 것을 방지


      7.2.3. chunk_size 선정 가이드

chunk_size 장점 단점 적합한 경우
200~500 높은 검색 정밀도 문맥 손실 가능 FAQ, 짧은 정의, 용어집
500~1000 정밀도와 문맥의 균형 - 일반 문서, Q&A (가장 범용적)
1000~2000 풍부한 문맥 보존 검색 노이즈 증가 법률, 기술 문서, 계약서
2000+ 완전한 문맥 유지 관련 없는 내용 포함 장문 분석, 요약


         - 실무 권장: `RecursiveCharacterTextSplitter`(chunk_size=1000, chunk_overlap=150)로 시작한 뒤, 검색 품질 평가를 통해 최적값을 튜닝한다. 대부분의 경우 이 조합이 가장 안정적인 성능을 보인다.

 

      7.2.4. 분할 전략 선택 가이드

문서 유형 권장 분할기 권장 chunk_size 이유
일반 텍스트/보고서 RecursiveCharacterTextSplitter 500~1000 범용성, 안정적 성능
법률/계약서 RecursiveCharacterTextSplitter 1000~2000 문맥 보존 중요
마크다운/기술 문서 MarkdownHeaderTextSplitter + 크기 기반 재분할 섹션별 가변 구조 보존 + 크기 제어
CSV/로그 파일 CharacterTextSplitter (separator=\n) 행 단위 명확한 구분자
FAQ/용어집 RecursiveCharacterTextSplitter 200~500 개별 항목 단위


8. 벡터 스토어와 검색 (단계 7: 검색 시스템 구축)

   - 벡터 스토어는 텍스트를 임베딩 벡터로 변환하여 저장하고, 유사도 검색을 수행하는 핵심 인프라이다. 이 단계에서는 문서 청크를 벡터화하여 저장하고, 사용자 질의와 유사한 문서를 검색하는 시스템을 구축한다.

 

   1) 배경 지식: 벡터 검색의 동작 원리

[인덱싱 단계 - 오프라인]
문서 청크 → 임베딩 모델 → 벡터(숫자 배열) → 벡터 DB에 저장

[검색 단계 - 온라인]
사용자 질의 → 임베딩 모델 → 질의 벡터 → 벡터 DB에서 유사도 검색
                                              ↓
                                     코사인 유사도 계산
                                              ↓
                                     상위 K개 문서 반환


      - 벡터 검색은 텍스트의 의미적 유사성을 기반으로 동작한다. "소득세"와 "인컴택스"는 키워드가 다르지만, 임베딩 벡터 공간에서는 가까운 위치에 존재하므로 벡터 검색으로 찾을 수 있다.

 

   8.1. Chroma 벡터 스토어

      - Chroma는 로컬 환경에서 빠르게 사용할 수 있는 오픈소스 벡터 DB이다. 설치가 간단하고 별도 서버 없이 동작하여 개발과 프로토타입에 적합하다.

 

      8.1.1. Chroma 주요 파라미터

파라미터 설명 예시
documents 저장할 Document 리스트 docs
embedding 임베딩 모델 객체 OpenAIEmbeddings(...)
persist_directory 로컬 저장 경로 "./chroma_db"
collection_name 컬렉션(테이블) 이름 "my_collection"
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings

# 임베딩 모델 초기화
embedding = OpenAIEmbeddings(model="text-embedding-3-large")  # 3072차원, 최고 품질

# ── 벡터 스토어 생성 (문서를 임베딩하여 저장) ──
db = Chroma.from_documents(
    documents=docs,                        # 분할된 Document 리스트
    embedding=embedding,                   # 임베딩 모델
    persist_directory="./chroma_db",       # 로컬 디스크 저장 경로 (영속성)
    collection_name="my_collection"        # 컬렉션 이름 (용도별 구분)
)

# ── 기존 벡터 스토어 로딩 (이미 저장된 인덱스 재사용) ──
db = Chroma(
    persist_directory="./chroma_db",       # 저장된 경로와 동일해야 함
    embedding_function=embedding,          # ⚠️ 생성 시와 동일한 임베딩 모델 사용 필수
    collection_name="my_collection"        # 동일한 컬렉션 이름
)

# ── 유사도 검색 ──
results = db.similarity_search(
    "질문",                                # 검색할 질의 텍스트
    k=4                                    # 반환할 문서 수
)

# ── 유사도 점수와 함께 검색 ──
results_with_scores = db.similarity_search_with_score(
    "질문",                                # 검색할 질의 텍스트
    k=4                                    # 반환할 문서 수
)
# 반환: [(Document, score), ...] → score가 낮을수록 유사도 높음 (거리 기반)


         - 파라미터 정의 기준: `persist_directory`를 지정하면 벡터 인덱스가 로컬 디스크에 저장되어 프로그램 재시작 후에도 재사용할 수 있다. 지정하지 않으면 메모리에만 존재하여 프로세스 종료 시 삭제된다.

 

      8.1.2. Chroma 생성 vs 로딩 비교

작업 메서드 사용 시점 비용
생성 Chroma.from_documents() 최초 인덱스 생성, 인덱스 재구축 임베딩 API 비용 발생
로딩 Chroma(persist_directory=...) 기존 인덱스 재사용 비용 없음 (디스크 읽기만)


         - 실무 권장: 최초 1회만 `from_documents()`로 생성하고, 이후에는 `Chroma()`로 로딩하여 임베딩 API 비용을 절약한다. 문서가 변경되었을 때만 인덱스를 재구축한다.

 

   8.2. Pinecone 벡터 스토어

      - Pinecone은 완전 관리형 클라우드 벡터 DB로, 자동 확장과 고가용성을 제공한다. 프로덕션 환경에서 안정적인 서비스가 필요할 때 적합하다.

from langchain_pinecone import PineconeVectorStore

# ── Pinecone 벡터 스토어 생성 ──
db = PineconeVectorStore.from_documents(
    documents=docs,                        # 분할된 Document 리스트
    embedding=embedding,                   # 임베딩 모델
    index_name="my-index"                  # Pinecone 콘솔에서 생성한 인덱스 이름
)

# ── 기존 인덱스 연결 (이미 저장된 인덱스 재사용) ──
db = PineconeVectorStore.from_existing_index(
    index_name="my-index",                 # 기존 인덱스 이름
    embedding=embedding                    # 동일한 임베딩 모델
)


      - 파라미터 정의 기준: `index_name`은 Pinecone 콘솔에서 미리 생성해야 하며, 인덱스 생성 시 임베딩 모델의 차원 수(dimension)를 정확히 맞춰야 한다. 예: `text-embedding-3-large`는 3072, `text-embedding-3-small`은 1536.

 

      8.2.1. Chroma vs Pinecone 비교

기준 Chroma Pinecone
유형 로컬/오픈소스 클라우드/관리형
설정 간단 (pip install) 계정 생성 + API 키
비용 무료 사용량 기반 과금 (무료 티어 있음)
확장성 제한적 (단일 머신) 자동 확장
영속성 로컬 디스크 클라우드 자동 관리
적합 환경 개발/프로토타입/소규모 프로덕션/대규모/팀 협업
네트워크 불필요 (로컬) 필요 (클라우드)


   8.3. Retriever 인터페이스

      - Retriever는 벡터 스토어의 검색 기능을 LangChain 체인에서 사용할 수 있도록 추상화한 인터페이스이다. `db.as_retriever()`로 생성하며, `invoke()` 메서드로 검색을 실행한다.

 

      8.3.1. 검색 방식 비교

검색 방식 설명 장점 단점 적합한 경우
similarity 코사인 유사도 기반 빠르고 직관적 중복 결과 가능 일반 Q&A, 프로토타입
mmr 유사도 + 다양성 균형 중복 결과 감소 약간의 속도 저하 유사 문서가 많은 도메인
similarity_score_threshold 최소 유사도 필터링 품질 보장 결과 수 불확실 고품질 결과만 필요할 때
# ── 기본 유사도 검색 retriever ──
retriever = db.as_retriever(
    search_kwargs={"k": 4}                 # 상위 4개 문서 반환
)

# ── MMR (Maximal Marginal Relevance) retriever ──
# 유사도가 높으면서도 서로 다양한 문서를 선택
retriever = db.as_retriever(
    search_type="mmr",                     # MMR 검색 방식 지정
    search_kwargs={
        "k": 4,                            # 최종 반환 문서 수
        "fetch_k": 20                      # 1차로 가져올 후보 문서 수 (k의 3~5배)
    }
)

# ── 유사도 점수 임계값 retriever ──
retriever = db.as_retriever(
    search_type="similarity_score_threshold",
    search_kwargs={
        "score_threshold": 0.7,            # 최소 유사도 점수 (0~1)
        "k": 4                             # 최대 반환 문서 수
    }
)

# ── 검색 실행 ──
docs = retriever.invoke("근로소득세란?")   # Document 리스트 반환
for doc in docs:
    print(doc.page_content[:100])          # 각 문서의 내용 미리보기
    print(doc.metadata)                    # 메타데이터 (source, page 등)


         - 파라미터 정의 기준: MMR의 `fetch_k`는 `k`의 3~5배로 설정한다. 후보 풀이 충분해야 다양성을 확보할 수 있다. `lambda_mult`(기본값 0.5)를 추가하면 다양성(0에 가까울수록)과 유사도(1에 가까울수록)의 균형을 조절할 수 있다.

 

      8.3.2. Retriever의 위치: 전체 파이프라인에서의 역할

[문서 로딩] → [분할] → [임베딩] → [벡터 DB 저장]
                                        │
                                        ▼
                               db.as_retriever()  ← Retriever 생성
                                        │
                                        ▼
[사용자 질의] → retriever.invoke("질문") → [검색된 Document 리스트]
                                                    │
                                                    ▼
                                        [프롬프트에 컨텍스트로 삽입] → [LLM 답변 생성]


         - 실무 권장: 프로토타입에서는 기본 `similarity` 검색(k=4)으로 시작하고, 유사한 문서가 반복적으로 검색되는 문제가 발생하면 `mmr`로 전환한다. 법률/규정 문서처럼 유사한 조항이 많은 도메인에서는 MMR이 특히 효과적이다.

 

9. RAG 체인 구성 (단계 8: 파이프라인 조립)

   - RAG 체인은 검색(Retrieval)과 생성(Generation)을 하나의 파이프라인으로 연결하는 핵심 단계이다. 사용자 질의를 받아 관련 문서를 검색하고, 검색된 문서를 컨텍스트로 포함하여 LLM이 답변을 생성하는 전체 흐름을 조립한다.

 

   1) 배경 지식: RAG 체인의 3가지 접근 방식

      - LangChain에서 RAG 체인을 구성하는 방법은 크게 3가지가 있으며, 각각 적합한 사용 시점이 다르다.

┌─────────────────────────────────────────────────────────────────────┐
│                     RAG 체인 구성 방식                              │
├──────────────────┬──────────────────┬───────────────────────────────┤
│  RetrievalQA     │ create_retrieval │  LCEL 기반 커스텀 체인        │
│  (레거시)        │ _chain (현대적)  │  (가장 유연)                  │
├──────────────────┼──────────────────┼───────────────────────────────┤
│ • 간단한 설정    │ • 구조화된 출력   │ • 완전한 커스터마이징         │
│ • 빠른 프로토타입│ • 체인 조합 용이  │ • 세밀한 흐름 제어            │
│ • 커스텀 제한적  │ • 권장 방식       │ • 학습 곡선 있음              │
└──────────────────┴──────────────────┴───────────────────────────────┘
방식 복잡도 유연성 권장 용도
RetrievalQA 낮음 낮음 빠른 프로토타입, 레거시 코드 유지보수
create_retrieval_chain 중간 중간 일반적인 RAG 구현 (권장)
LCEL 커스텀 체인 높음 높음 세밀한 커스터마이징이 필요한 프로덕션


   9.1. RetrievalQA (레거시 방식)

      - LangChain 초기부터 제공된 방식으로, 간단한 설정으로 RAG를 빠르게 구현할 수 있다. 그러나 커스터마이징이 제한적이어서 현재는 `create_retrieval_chain`으로 대체되는 추세이다.

from langchain.chains import RetrievalQA

# RetrievalQA 체인 생성
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,                                   # LLM 객체 (ChatOpenAI 등)
    chain_type="stuff",                        # 문서 결합 방식 (stuff: 모두 하나로 합침)
    retriever=retriever,                       # Retriever 객체
    return_source_documents=True,              # 출처 문서 반환 여부
    chain_type_kwargs={"prompt": custom_prompt} # 커스텀 프롬프트 전달
)

# 체인 실행
result = qa_chain.invoke({"query": "근로소득세란?"})
print(result["result"])           # 생성된 답변 텍스트
print(result["source_documents"]) # 검색된 출처 Document 리스트


      - 파라미터 정의 기준: `chain_type`은 검색된 문서를 LLM에 전달하는 방식을 결정한다. `"stuff"`는 모든 문서를 하나의 프롬프트에 합치는 가장 단순한 방식이다. 문서가 많아 토큰 제한을 초과하면 `"map_reduce"`나 `"refine"`을 고려한다.

 

      9.1.1. chain_type 비교

chain_type 동작 방식 장점 단점 적합한 경우
stuff 모든 문서를 하나로 합쳐서 전달 간단, 빠름 토큰 제한 초과 가능 문서 수가 적을 때 (가장 일반적)
map_reduce 각 문서를 개별 처리 후 결합 대량 문서 처리 가능 느림, 비용 높음 문서가 매우 많을 때
refine 문서를 순차적으로 처리하며 답변 개선 정밀한 답변 가장 느림 높은 품질이 필요할 때


   9.2. create_retrieval_chain (현대적 방식)

      - LangChain에서 현재 권장하는 방식이다. 내부적으로 retriever와 document 결합 체인을 분리하여 더 유연한 구성이 가능하다.

from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain import hub

# ── 프롬프트 로딩 (LangChain Hub에서 검증된 프롬프트 가져오기) ──
prompt = hub.pull("langchain-ai/retrieval-qa-chat")

# ── 문서 결합 체인 생성 (검색된 문서를 LLM에 전달하는 방식 정의) ──
combine_chain = create_stuff_documents_chain(
    llm,      # LLM 객체
    prompt    # 프롬프트 (context와 input 변수를 포함해야 함)
)

# ── RAG 체인 조립 (retriever + 문서 결합 체인) ──
rag_chain = create_retrieval_chain(
    retriever,       # Retriever 객체 (검색 담당)
    combine_chain    # 문서 결합 체인 (생성 담당)
)

# ── 체인 실행 ──
result = rag_chain.invoke({"input": "근로소득세란?"})
print(result["answer"])   # 생성된 답변 텍스트
print(result["context"])  # 검색된 문서 리스트 (Document 객체)


      9.2.1. RetrievalQA vs create_retrieval_chain 비교

기준 RetrievalQA create_retrieval_chain
입력 키 "query" "input"
답변 키 "result" "answer"
출처 키 "source_documents" "context"
내부 구조 단일 체인 retriever + combine 분리
유연성 제한적 높음 (각 부분 교체 가능)
상태 레거시 현재 권장


         - 실무 권장: 신규 프로젝트에서는 `create_retrieval_chain`을 사용한다. 기존 `RetrievalQA` 코드는 동작에는 문제가 없지만, 향후 LangChain 업데이트에서 deprecated될 가능성이 있다.

 

   9.3. LCEL 기반 커스텀 RAG 체인

      - LCEL(LangChain Expression Language)을 사용하면 RAG 파이프라인의 모든 단계를 세밀하게 제어할 수 있다. 가장 유연한 방식이며, 프로덕션에서 커스텀 로직이 필요할 때 권장된다.

from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

# ── 프롬프트 정의 ──
prompt = ChatPromptTemplate.from_template("""
주어진 컨텍스트를 기반으로 질문에 답하세요.

컨텍스트:
{context}

질문: {question}

답변:
""")

# ── 검색 결과 포맷팅 함수 ──
def format_docs(docs):
    """검색된 Document 리스트를 하나의 문자열로 결합"""
    return "\n\n".join(doc.page_content for doc in docs)

# ── LCEL 기반 RAG 체인 조립 ──
rag_chain = (
    {
        # retriever로 검색 → format_docs로 텍스트 결합
        "context": retriever | format_docs,
        # 사용자 입력을 그대로 question에 전달
        "question": RunnablePassthrough()
    }
    | prompt              # 프롬프트에 context와 question 삽입
    | llm                 # LLM으로 답변 생성
    | StrOutputParser()   # AIMessage에서 텍스트만 추출
)

# ── 체인 실행 ──
answer = rag_chain.invoke("근로소득세란?")
print(answer)  # 문자열 답변 직접 반환


      9.3.1. LCEL RAG 체인의 데이터 흐름

입력: "근로소득세란?"
    │
    ├──→ retriever.invoke("근로소득세란?")
    │         │
    │         ▼
    │    [Document1, Document2, Document3, Document4]
    │         │
    │         ▼ format_docs()
    │    "문서1 내용\n\n문서2 내용\n\n문서3 내용\n\n문서4 내용"
    │         │
    │         ▼
    │    {"context": "결합된 문서 텍스트"}
    │
    └──→ RunnablePassthrough()
              │
              ▼
         {"question": "근로소득세란?"}
              │
              ▼  (두 딕셔너리 병합)
    {"context": "결합된 문서 텍스트", "question": "근로소득세란?"}
              │
              ▼  prompt.invoke()
    "주어진 컨텍스트를 기반으로 질문에 답하세요.\n\n컨텍스트:\n문서1 내용..."
              │
              ▼  llm.invoke()
    AIMessage(content="근로소득세란 근로를 제공하고 받는 소득에...")
              │
              ▼  StrOutputParser().invoke()
    "근로소득세란 근로를 제공하고 받는 소득에..."  ← 최종 문자열 답변


      9.3.2. 3가지 방식의 종합 비교

기준 RetrievalQA create_retrieval_chain LCEL 커스텀
코드량 최소 보통 많음
커스터마이징 제한적 중간 완전 자유
디버깅 어려움 보통 쉬움 (각 단계 확인)
스트리밍 제한적 지원 완전 지원
출처 추적 source_documents context 직접 구현 필요
학습 난이도 쉬움 보통 높음 (LCEL 이해 필요)
추천 상황 빠른 프로토타입 일반적인 RAG 프로덕션, 고급 커스텀


         - 실무 권장: 일반적인 RAG 구현에는 `create_retrieval_chain`을 사용하고, 키워드 사전 체인 통합, 멀티스텝 검증, 스트리밍 등 세밀한 제어가 필요하면 LCEL 커스텀 체인으로 전환한다. LCEL 체인은 각 단계에 `RunnableLambda(debug_step)`을 삽입하여 디버깅하기 용이하다.

 

10. 질의 최적화 체인 (단계 9: 검색 품질 개선)

   - RAG의 검색 품질은 사용자의 질의가 벡터 DB에 저장된 문서의 표현과 얼마나 잘 매칭되는지에 달려 있다. 사용자는 일상적인 표현을 사용하지만, 문서에는 전문 용어가 사용되는 경우가 많다. 질의 최적화는 이러한 표현 간극(vocabulary gap)을 줄여 검색 품질을 높이는 전략이다.

 

   1) 배경 지식: 왜 질의 최적화가 필요한가?

[문제 상황]
사용자 질의: "직장인 세금 계산"
벡터 DB 문서: "거주자의 근로소득세 산출 방법"

→ "직장인" ≠ "거주자/근로소득자" (동일한 개념이지만 다른 표현)
→ "세금 계산" ≠ "근로소득세 산출 방법" (일상 표현 vs 전문 용어)
→ 벡터 유사도가 낮아져 관련 문서를 놓칠 수 있음

[해결 방안: 키워드 사전 체인]
사용자 질의: "직장인 세금 계산"
    ↓ 키워드 사전 변환
변환된 질의: "거주자 근로소득자 근로소득세 산출 방법"
    ↓ 벡터 검색
→ 관련 문서와의 유사도가 높아져 정확한 검색 결과 반환


   10.1. 키워드 사전 체인

      - 키워드 사전은 사용자의 일상 표현을 도메인 전문 용어로 변환하는 가장 실용적인 방법이다. LLM을 활용하여 사전 기반의 용어 변환을 수행한다.

 

      10.1.1. 키워드 사전 체인의 구성 요소

구성 요소 역할 예시
사전(Dictionary) 용어 매핑 정의 "직장인 → 거주자, 근로소득자"
변환 프롬프트 LLM에게 변환 지시 "사전을 참고하여 질문을 변환하세요"
변환 체인 프롬프트 + LLM + 파서 dict_prompt | llm | StrOutputParser()
# ── 1. 키워드 사전 정의 ──
# 도메인 전문 용어와 일상 표현 간의 매핑을 정의
dictionary = """
직장인 → 거주자, 근로소득자
월급쟁이 → 근로소득자
연말정산 → 근로소득세 연말정산
세금 → 조세, 소득세
아르바이트 → 일용근로소득
프리랜서 → 사업소득자
"""

# ── 2. 사전 변환 프롬프트 ──
# LLM에게 사전을 참고하여 질의를 변환하도록 지시
dict_prompt = ChatPromptTemplate.from_template(
    "사전: {dictionary}\n질문: {question}\n변환된 질문:"
)

# ── 3. 사전 변환 체인 구성 ──
dict_chain = dict_prompt | llm | StrOutputParser()
# 입력: {"dictionary": 사전, "question": 원본 질문}
# 출력: 변환된 질문 문자열

# ── 4. RAG 체인에 통합 ──
# 사전 변환 체인의 출력을 RAG 체인의 입력으로 연결
final_chain = (
    {"query": dict_chain}     # 변환된 질문을 "query" 키로 전달
    | qa_chain                 # RetrievalQA 체인 실행
)

# ── 5. 실행 ──
result = final_chain.invoke({
    "dictionary": dictionary,             # 사전 전달
    "question": "직장인 세금 계산"         # 사용자의 원본 질문
})
# 내부 동작:
# 1) dict_chain이 "직장인 세금 계산" → "거주자 근로소득자의 근로소득세 산출 방법"으로 변환
# 2) qa_chain이 변환된 질문으로 벡터 검색 수행
# 3) 검색된 문서를 기반으로 LLM이 답변 생성


         - 파라미터 정의 기준: 사전의 크기는 도메인 용어 50~200개 정도가 적절하다. 너무 많으면 LLM이 변환 시 혼란을 겪을 수 있고, 너무 적으면 커버리지가 부족하다. 사전은 프롬프트에 삽입되므로 토큰 제한도 고려해야 한다.

 

      10.1.2. 키워드 사전 설계 가이드

설계 원칙 설명 예시
일상 표현 → 전문 용어 사용자가 쓸 법한 표현을 매핑 "월급 → 근로소득"
약어/줄임말 → 정식 명칭 약어를 풀어서 매핑 "연정 → 연말정산"
동의어 그룹 같은 의미의 다른 표현들 "직장인, 회사원, 샐러리맨 → 근로소득자"
도메인 특화 용어 해당 도메인의 핵심 용어 포함 "세금 감면 → 소득공제, 세액공제"


   10.2. LCEL 체인 통합 패턴

      - 키워드 변환, 검색, 생성을 하나의 LCEL 파이프라인으로 통합하는 고급 패턴이다. 이 방식은 RetrievalQA 없이 순수 LCEL만으로 전체 파이프라인을 구성한다.

from langchain_core.runnables import RunnableLambda

# ── 키워드 변환 → 검색 → 생성의 완전한 LCEL 파이프라인 ──
full_chain = (
    {
        # 1단계: 사용자 질의를 키워드 사전으로 변환
        "question": (
            {
                "dictionary": lambda _: dictionary,      # 사전을 고정값으로 전달
                "question": RunnablePassthrough()         # 사용자 입력 그대로 전달
            }
            | dict_prompt                                 # 변환 프롬프트 적용
            | llm                                         # LLM으로 변환 실행
            | StrOutputParser()                           # 문자열 추출
        ),
    }
    | {
        # 2단계: 변환된 질의로 벡터 검색 수행 + 결과 포맷팅
        # ⚠️ 주의: retriever.invoke()는 Document 리스트를 반환하므로
        # | 연산자가 아닌 RunnableLambda로 감싸서 format_docs를 적용해야 한다
        "context": RunnableLambda(
            lambda x: format_docs(retriever.invoke(x["question"]))
        ),
        "question": lambda x: x["question"]              # 변환된 질문을 그대로 전달
    }
    | prompt              # RAG 프롬프트에 context와 question 삽입
    | llm                 # LLM으로 최종 답변 생성
    | StrOutputParser()   # 문자열 답변 추출
)

# ── 실행 ──
answer = full_chain.invoke("직장인 세금 계산")


      10.2.1. LCEL 통합 파이프라인의 데이터 흐름

입력: "직장인 세금 계산"
    │
    ▼ [1단계: 키워드 변환]
    ├─ dictionary: "직장인 → 거주자, 근로소득자\n..."  (고정값)
    ├─ question: "직장인 세금 계산"                    (사용자 입력)
    │      │
    │      ▼ dict_prompt + llm + StrOutputParser
    │
    ▼ {"question": "거주자 근로소득자의 근로소득세 산출 방법"}
    │
    ▼ [2단계: 검색 + 포맷팅]
    ├─ context: retriever.invoke("거주자 근로소득자의...") → format_docs()
    │           → "문서1 내용\n\n문서2 내용\n\n..."
    ├─ question: "거주자 근로소득자의 근로소득세 산출 방법"
    │
    ▼ [3단계: 생성]
    prompt → llm → StrOutputParser()
    │
    ▼
    "근로소득세란 근로를 제공한 대가로 받는 소득에 부과되는..."  ← 최종 답변


      10.2.2. 키워드 사전 체인 적용 전후 비교

항목 사전 미적용 사전 적용
사용자 질의 "직장인 세금 계산" "직장인 세금 계산"
검색 질의 "직장인 세금 계산" (그대로) "거주자 근로소득자의 근로소득세 산출 방법"
검색 품질 관련 문서를 놓칠 가능성 전문 용어로 정확한 매칭
답변 품질 불완전할 수 있음 정확한 문서 기반 답변


         - 실무 권장: 키워드 사전은 도메인 전문가와 협업하여 구축하는 것이 가장 효과적이다. 초기에는 20~30개의 핵심 용어로 시작하고, 사용자 질의 로그를 분석하여 점진적으로 확장한다. LangSmith 등의 모니터링 도구를 활용하면 변환 전후의 검색 결과 차이를 추적할 수 있다.

 

11. LangChain 사용 단계 요약

   - 이 문서에서 다룬 LangChain 기반 RAG 파이프라인의 전체 단계를 정리한다. 각 단계는 순서대로 진행되며, 이전 단계의 출력이 다음 단계의 입력이 된다.

 

   1) 전체 파이프라인 아키텍처

┌───────────────────────────────────────────────────────────────────────┐
│                    LangChain RAG 파이프라인 전체 흐름                 │
│                                                                       │
│  [준비 단계: 1~5]                                                     │
│  ┌─────────┐   ┌──────────┐   ┌──────────┐   ┌─────────┐   ┌──────┐   │
│  │ 1.모델   │→ │ 2.메시지 │→  │ 3.프롬프트│→  │ 4.출력  │→  │5.체인│  │
│  │ 초기화   │  │ 이해     │   │ 관리      │   │ 구조화  │   │ 구성 │  │
│  └─────────┘   └──────────┘   └──────────┘   └─────────┘   └──────┘   │
│                                                                       │
│  [데이터 단계: 6~7]                                                   │
│  ┌───────────────────────┐   ┌──────────────────────────────┐         │
│  │ 6. 데이터 수집        │→  │ 7. 검색 시스템 구축           │        │
│  │ (로딩 + 분할)         │   │ (임베딩 + 벡터 DB + Retriever)│        │
│  └───────────────────────┘   └──────────────────────────────┘         │
│                                                                       │
│  [조립 단계: 8~9]                                                     │
│  ┌─────────────────────────┐   ┌──────────────────────────────┐       │
│  │ 8. RAG 파이프라인 조립  │→  │ 9. 검색 품질 최적화           │       │
│  │ (Retrieval + Generation)│   │ (키워드 사전 + 질의 변환)     │       │
│  └─────────────────────────┘   └──────────────────────────────┘       │
└───────────────────────────────────────────────────────────────────────┘


   2) 단계별 상세 요약

단계 목표 핵심 컴포넌트 주요 메서드/패턴 결과물 실무 포인트
1. 모델 초기화 LLM 설정 ChatOpenAI, ChatUpstage, ChatOllama ChatOpenAI(model="gpt-4o-mini", temperature=0) llm 객체 temperature=0으로 시작, 필요시 조절
2. 메시지 이해 I/O 구조 파악 SystemMessage, HumanMessage, AIMessage llm.invoke([SystemMessage(...), HumanMessage(...)]) 메시지 리스트 System 메시지로 역할/규칙 정의
3. 프롬프트 관리 재사용 가능한 프롬프트 PromptTemplate, ChatPromptTemplate ChatPromptTemplate.from_template("...") 프롬프트 객체 변수 기반 템플릿으로 재사용성 확보
4. 출력 구조화 출력 파싱 StrOutputParser, JsonOutputParser StrOutputParser() 파서 객체 대부분 StrOutputParser로 충분
5. 체인 구성 LCEL로 조합 | 연산자, RunnablePassthrough, RunnableLambda prompt | llm | parser 체인 객체 딕셔너리로 병렬 입력 구성
6. 데이터 수집 문서 로딩/분할 Docx2txtLoader, RecursiveCharacterTextSplitter loader.load() → splitter.split_documents() Document 리스트 chunk_size=1000, overlap=150으로 시작
7. 검색 시스템 벡터 DB 구축 OpenAIEmbeddings, Chroma, Retriever Chroma.from_documents() → db.as_retriever() 검색기 Chroma로 시작, 필요시 Pinecone 전환
8. RAG 조립 전체 파이프라인 create_retrieval_chain, LCEL create_retrieval_chain(retriever, combine_chain) RAG 체인 create_retrieval_chain 권장
9. 검색 최적화 품질 개선 키워드 사전, 질의 변환 체인 dict_prompt | llm | StrOutputParser() 최적화된 체인 도메인 전문가와 사전 공동 구축


   3) 단계별 코드 요약: 최소 RAG 구현

# ── 1단계: 모델 초기화 ──
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# ── 6단계: 데이터 수집 (로딩 + 분할) ──
from langchain_community.document_loaders import Docx2txtLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter

loader = Docx2txtLoader("./document.docx")           # 문서 로딩
splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000, chunk_overlap=150                # 청킹 설정
)
docs = loader.load_and_split(splitter)                # 로딩 + 분할

# ── 7단계: 검색 시스템 구축 ──
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

embedding = OpenAIEmbeddings(model="text-embedding-3-large")
db = Chroma.from_documents(docs, embedding,
                           persist_directory="./chroma_db")
retriever = db.as_retriever(search_kwargs={"k": 4})   # Retriever 생성

# ── 8단계: RAG 체인 조립 (LCEL 방식) ──
from langchain_core.runnables import RunnablePassthrough
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

prompt = ChatPromptTemplate.from_template("""
컨텍스트를 기반으로 질문에 답하세요.
컨텍스트: {context}
질문: {question}
답변:""")

def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)

rag_chain = (
    {"context": retriever | format_docs,
     "question": RunnablePassthrough()}
    | prompt | llm | StrOutputParser()
)

# ── 실행 ──
answer = rag_chain.invoke("근로소득세란?")
print(answer)


   4) 다음 단계로의 확장

      - 위의 기본 파이프라인을 기반으로 다음과 같은 고급 기능을 추가하여 품질을 개선할 수 있다.

확장 방향 적용 기법 기대 효과
검색 다양성 확보 MMR, Ensemble Retriever 중복 문서 제거, 키워드+의미 검색 결합
질의 최적화 키워드 사전, Multi-Query 표현 간극 해소, 검색 재현율 향상
답변 검증 Self-check, LLM Evaluation 환각 감소, 답변 신뢰성 향상
성능 모니터링 LangSmith 연동 각 단계 성능 추적, 병목 지점 파악
데이터 전처리 강화 시맨틱 청킹, 메타데이터 보강 검색 정밀도 향상


      - 실무 권장: 기본 파이프라인(1→6→7→8)을 먼저 완성하고, 검색 품질을 평가한 후 필요한 부분만 선택적으로 개선한다. 모든 고급 기능을 한 번에 적용하기보다는, 각 기능의 효과를 개별적으로 측정하면서 점진적으로 추가하는 것이 효율적이다.

 

12. 실무 프로젝트: 사내 규정 Q&A 챗봇 구축

   - 이 섹션에서는 본 문서에서 학습한 LangChain 핵심 기능을 모두 활용하여 사내 규정 문서 기반 Q&A 챗봇을 처음부터 끝까지 구현한다.

 

   1) 시나리오 및 요구 명세

      - 배경: 중소기업 인사팀에서 직원들의 반복적인 규정 관련 질문(연차, 복리후생, 경비 처리 등)에 자동 응답하는 챗봇을 구축한다.

항목 요구사항
대상 문서 사내 규정 문서 3종 (인사규정, 복리후생규정, 경비처리규정)
문서 형식 PDF 또는 텍스트 파일
질의 유형 "연차 며칠?", "출장 교통비 정산 방법은?" 등 짧은 자연어
응답 형식 2~3문장 간결 답변 + 관련 규정 조항 출처 표시
정확도 목표 관련 규정 검색 성공률 80% 이상
사용 기술 ChatOpenAI, PromptTemplate, LCEL, Chroma, StrOutputParser


   2) 전체 아키텍처

[사내 규정 Q&A 챗봇 파이프라인]

사내 규정 문서 (PDF/TXT)
    ↓
[1단계] 문서 로딩 (TextLoader/PyPDFLoader)
    ↓
[2단계] 텍스트 분할 (RecursiveCharacterTextSplitter)
    ↓
[3단계] 벡터 임베딩 + 저장 (OpenAIEmbeddings → Chroma)
    ↓
[4단계] 검색기 생성 (as_retriever)
    ↓
직원 질문 → [5단계] LCEL 체인 (검색 → 프롬프트 → LLM → 파서) → 답변


   3) 단계별 구현

# ============================================================
# 사내 규정 Q&A 챗봇 - 전체 구현
# ============================================================
# 사용 기술: ChatOpenAI, PromptTemplate, LCEL, Chroma, StrOutputParser
# 학습 포인트: 본 문서의 1~11장 핵심 기능을 모두 활용
# ============================================================

import os
from dotenv import load_dotenv

# 환경변수 로딩 (.env 파일에 OPENAI_API_KEY 설정 필요)
load_dotenv()
assert os.environ.get("OPENAI_API_KEY"), "OPENAI_API_KEY가 설정되지 않았습니다."

# ── [1단계] 문서 로딩 ──────────────────────────────────────
# TextLoader: 텍스트 파일을 Document 객체로 변환
# 실제 환경에서는 PyPDFLoader, Docx2txtLoader 등 형식에 맞는 로더 사용
from langchain_community.document_loaders import TextLoader

# 예시: 사내 규정 텍스트 파일 로딩
# 실습용으로 샘플 데이터를 직접 생성
sample_regulations = """
# 인사규정

제1조 (연차휴가)
① 1년간 80% 이상 출근한 직원에게는 15일의 유급 연차휴가를 부여한다.
② 3년 이상 근속 시 매 2년마다 1일의 가산 연차를 부여한다.
③ 연차는 발생일로부터 1년 이내에 사용해야 하며, 미사용 연차는 수당으로 보상한다.

제2조 (경조사 휴가)
① 본인 결혼: 5일, 자녀 결혼: 1일
② 배우자 출산: 10일 (유급)
③ 부모/배우자 사망: 5일, 조부모/형제자매 사망: 3일

제3조 (복리후생)
① 전 직원에게 연 120만원의 복지포인트를 지급한다.
② 자녀 학자금은 중학교~대학교까지 실비를 지원한다.
③ 건강검진은 연 1회 회사 부담으로 실시한다.

제4조 (경비 처리)
① 출장 교통비: 실비 정산 (대중교통 기준, 택시는 사전 승인 필요)
② 출장 숙박비: 1박당 10만원 한도 (서울/수도권 12만원)
③ 식비: 1일 3만원 한도
④ 모든 경비는 법인카드 사용을 원칙으로 하며, 영수증을 첨부해야 한다.
"""

# 샘플 데이터를 임시 파일로 저장 후 로딩
import tempfile
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False, encoding='utf-8') as f:
    f.write(sample_regulations)
    temp_path = f.name

loader = TextLoader(temp_path, encoding='utf-8')
documents = loader.load()
print(f"[1단계] 문서 로딩 완료: {len(documents)}개 문서, "
      f"총 {len(documents[0].page_content)}자")
# 실행 결과: [1단계] 문서 로딩 완료: 1개 문서, 총 612자


# ── [2단계] 텍스트 분할 ────────────────────────────────────
# RecursiveCharacterTextSplitter: 문단/줄/단어 경계에서 분할
# chunk_size=300: 규정 1~2개 조항이 하나의 청크에 포함되는 크기
# chunk_overlap=50: 조항 간 문맥 연결 보장
from langchain_text_splitters import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=300,      # 사내 규정은 짧으므로 작은 청크 크기
    chunk_overlap=50,    # 인접 조항 간 문맥 연결
    separators=["\n\n", "\n", " ", ""]  # 문단 → 줄 → 단어 순으로 분할 시도
)

chunks = splitter.split_documents(documents)
print(f"[2단계] 텍스트 분할 완료: {len(chunks)}개 청크")
for i, chunk in enumerate(chunks):
    print(f"  청크 {i+1}: {len(chunk.page_content)}자 - {chunk.page_content[:40]}...")
# 실행 결과:
# [2단계] 텍스트 분할 완료: 4개 청크
#   청크 1: 287자 - # 인사규정  제1조 (연차휴가) ① 1년간 80%...
#   청크 2: 245자 - 제2조 (경조사 휴가) ① 본인 결혼: 5일...
#   청크 3: 198자 - 제3조 (복리후생) ① 전 직원에게 연 120만원...
#   청크 4: 276자 - 제4조 (경비 처리) ① 출장 교통비: 실비 정산...


# ── [3단계] 벡터 임베딩 + 저장 ─────────────────────────────
# OpenAIEmbeddings: 텍스트를 벡터로 변환
# Chroma: 경량 벡터 DB (로컬 실행, 설치 간단)
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

embedding = OpenAIEmbeddings(model="text-embedding-3-small")
# text-embedding-3-small: 사내 규정은 짧은 텍스트이므로 소형 모델이면 충분

vectorstore = Chroma.from_documents(
    documents=chunks,
    embedding=embedding,
    collection_name="company_regulations"  # 컬렉션 이름 지정
)
print(f"[3단계] 벡터 DB 생성 완료: {vectorstore._collection.count()}개 벡터")
# 실행 결과: [3단계] 벡터 DB 생성 완료: 4개 벡터


# ── [4단계] 검색기 생성 ────────────────────────────────────
# as_retriever(): 벡터 DB를 LangChain Retriever 인터페이스로 변환
# k=2: 사내 규정은 문서가 적으므로 상위 2개면 충분
retriever = vectorstore.as_retriever(
    search_type="similarity",  # 코사인 유사도 기반 검색
    search_kwargs={"k": 2}     # 상위 2개 관련 문서 반환
)

# 검색 테스트
test_docs = retriever.invoke("연차 휴가 며칠?")
print(f"\n[4단계] 검색 테스트: '연차 휴가 며칠?'")
for i, doc in enumerate(test_docs):
    print(f"  결과 {i+1}: {doc.page_content[:60]}...")
# 실행 결과:
# [4단계] 검색 테스트: '연차 휴가 며칠?'
#   결과 1: # 인사규정  제1조 (연차휴가) ① 1년간 80% 이상 출근한 직원에게는...
#   결과 2: 제2조 (경조사 휴가) ① 본인 결혼: 5일, 자녀 결혼: 1일...


# ── [5단계] LCEL 체인 구성 ─────────────────────────────────
# 프롬프트 + 검색 + LLM + 파서를 파이프로 연결
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

# 프롬프트: 사내 규정 Q&A에 최적화
prompt = ChatPromptTemplate.from_template("""
당신은 사내 규정 안내 챗봇입니다. 다음 규칙을 준수하세요:

1. 제공된 규정 내용만을 기반으로 답변하세요
2. 관련 조항 번호를 반드시 포함하세요 (예: 제1조)
3. 2~3문장으로 간결하게 답변하세요
4. 규정에 없는 내용은 "해당 규정을 찾을 수 없습니다. 인사팀에 문의해 주세요."로 답하세요

관련 규정:
{context}

직원 질문: {question}

답변:""")

# 문서 포맷팅 함수
def format_docs(docs):
    """검색된 Document 리스트를 문자열로 결합"""
    return "\n\n".join(doc.page_content for doc in docs)

# LLM 설정 (gpt-4o-mini: 비용 효율 + 충분한 품질)
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

# LCEL 체인 조립
# RunnablePassthrough(): 원본 질문을 그대로 전달
# retriever | format_docs: 검색 → 문서 포맷팅을 파이프로 연결
qa_chain = (
    {
        "context": retriever | format_docs,  # 질문으로 검색 → 포맷팅
        "question": RunnablePassthrough()    # 원본 질문 그대로 전달
    }
    | prompt          # 프롬프트 템플릿에 context, question 삽입
    | llm             # LLM 호출 (gpt-4o-mini)
    | StrOutputParser()  # AIMessage → 문자열 변환
)
print("[5단계] LCEL 체인 구성 완료")


# ── [실행 및 테스트] ────────────────────────────────────────
test_questions = [
    "연차 휴가는 며칠인가요?",
    "출장 숙박비 한도가 얼마인가요?",
    "배우자 출산 휴가는 며칠인가요?",
    "회사에서 자녀 학자금을 지원해주나요?",
    "재택근무 규정이 있나요?",  # 규정에 없는 질문
]

print("\n" + "=" * 60)
print("사내 규정 Q&A 챗봇 테스트")
print("=" * 60)

for q in test_questions:
    answer = qa_chain.invoke(q)
    print(f"\nQ: {q}")
    print(f"A: {answer}")
    print("-" * 40)

# 임시 파일 정리
os.unlink(temp_path)


      - 예상 실행 결과:

==============================================================
사내 규정 Q&A 챗봇 테스트
==============================================================

Q: 연차 휴가는 며칠인가요?
A: 제1조에 따르면, 1년간 80% 이상 출근한 직원에게는 15일의 유급 연차휴가가 부여됩니다.
또한 3년 이상 근속 시 매 2년마다 1일의 가산 연차가 추가됩니다.
연차는 발생일로부터 1년 이내에 사용해야 합니다.
----------------------------------------

Q: 출장 숙박비 한도가 얼마인가요?
A: 제4조 제②항에 따르면, 출장 숙박비는 1박당 10만원이 한도이며,
서울/수도권의 경우 12만원까지 지원됩니다.
----------------------------------------

Q: 배우자 출산 휴가는 며칠인가요?
A: 제2조 제②항에 따르면, 배우자 출산 시 10일의 유급 휴가가 부여됩니다.
----------------------------------------

Q: 회사에서 자녀 학자금을 지원해주나요?
A: 제3조 제②항에 따르면, 자녀 학자금은 중학교부터 대학교까지
실비를 지원합니다.
----------------------------------------

Q: 재택근무 규정이 있나요?
A: 해당 규정을 찾을 수 없습니다. 인사팀에 문의해 주세요.
----------------------------------------


   4) 동작 흐름 요약

직원 질문 "연차 며칠?"
    → [RunnablePassthrough] 질문 그대로 전달
    → [retriever] Chroma에서 코사인 유사도 검색 (k=2)
    → [format_docs] 검색 결과를 문자열로 결합
    → [prompt] 규칙 + 규정 내용 + 질문을 프롬프트로 조합
    → [ChatOpenAI] gpt-4o-mini가 규정 기반 답변 생성
    → [StrOutputParser] 최종 문자열 응답 반환

비용: 약 $0.0002/질의 (gpt-4o-mini + text-embedding-3-small)


   5) 확장 과제

      - 이 기본 챗봇을 다음과 같이 확장하면서 LangChain의 고급 기능을 추가로 학습할 수 있다.

확장 과제 활용 기능 난이도
PDF 규정 문서 로딩 PyPDFLoader 낮음
대화 이력 유지 (멀티턴) ChatMessageHistory, RunnableWithMessageHistory 중간
답변에 출처 조항 번호 자동 추출 PydanticOutputParser + Structured Output 중간
검색 품질 평가 Retrieval Recall 측정 함수 구현 중간
Streamlit 웹 UI 연동 st.chat_message, 스트리밍 응답 높음



 

 

댓글