[TeamBlog] LangChain, LCEL, LangGraph, RAG 도입 — 문제 해결 과정 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki

LangChain, LCEL, LangGraph, RAG 도입 — 문제 해결 과정


목차

  1. 개요
  2. 문제 1: LLM API 직접 호출의 한계 → LangChain 도입
  3. 문제 2: 꼬리질문이 안됨 → 대화 맥락 관리 구현
  4. 문제 3: 같은 질문 반복 → 임베딩 유사도 중복 방지
  5. 문제 4: 면접 상태 관리 복잡 → LangGraph 상태 머신
  6. 문제 5: RAG 품질 저하 → 리트리버 고도화
  7. 문제 6: 임베딩 비용 증가 → 캐시 최적화
  8. 최종 아키텍처
  9. 성과 요약

1. 개요

면접 AI 서비스를 개발하면서 발생한 6가지 핵심 문제와 그 해결 과정을 기록합니다.

# 문제 해결책 핵심 기술
1 LLM API 직접 호출 → 코드 중복 LangChain 추상화 ChatGoogleGenerativeAI, LCEL
2 꼬리질문이 안됨 대화 맥락 관리 질문별 conversation 리스트
3 같은 질문 반복 임베딩 유사도 필터링 cosine similarity, mastered_questions
4 면접 상태 관리 복잡 LangGraph 상태 머신 StateGraph, 조건부 분기
5 RAG 품질 저하 리트리버 고도화 앙상블 + MMR + Reranker
6 임베딩 비용 증가 캐시 최적화 Redis + LRU Cache

2. 문제 1: LLM API 직접 호출의 한계 → LangChain 도입

2.1 문제 상황

# 초기 방식 — 각 기능마다 API 호출 코드 반복
import google.generativeai as genai

genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
model = genai.GenerativeModel("gemini-3.0-flash")

def analyze_resume(resume_text: str) -> str:
    prompt = f"이력서를 분석해주세요: {resume_text}"
    response = model.generate_content(prompt)
    return response.text

def generate_question(context: str) -> str:
    prompt = f"면접 질문을 생성해주세요: {context}"
    response = model.generate_content(prompt)  # 동일한 코드 반복
    return response.text

발생한 문제:

문제 영향
15개 파일에 API 호출 코드 분산 유지보수 어려움
에러 처리가 각 파일에 산재 일관성 없는 핸들링
프롬프트가 코드에 하드코딩 수정 시 배포 필요
API 장애 시 전체 서비스 중단 Fallback 없음

2.2 해결: LangChain + LCEL 도입

# 개선 후 — LCEL 선언적 파이프라인
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser

# 1. LLM 인스턴스 (Fallback 포함)
llm = ChatGoogleGenerativeAI(model="gemini-3.0-flash")
llm_with_fallback = llm.with_fallbacks([fallback_llm])

# 2. 체인 정의 (선언적)
analyze_chain = (
    ChatPromptTemplate.from_messages([
        ("system", "당신은 이력서 분석 전문가입니다."),
        ("human", "이력서를 분석해주세요:\n{resume_text}"),
    ])
    | llm_with_fallback
    | StrOutputParser()
)

# 3. 실행
result = await analyze_chain.ainvoke({"resume_text": resume_text})

LCEL 핵심 패턴:

[LCEL 파이프 연산자]

prompt | llm | parser
   ↓      ↓      ↓
프롬프트 → LLM → 결과 파싱

- Unix 파이프와 유사
- 왼쪽 출력 → 오른쪽 입력
- 비동기 스트리밍 기본 지원 (astream)

2.3 결과

지표 개선 전 개선 후
API 호출 코드 15개 파일 분산 2개 서비스로 통합
에러 처리 각 파일에 산재 중앙 집중
모델 교체 전체 코드 수정 설정만 변경

3. 문제 2: 꼬리질문이 안됨 → 대화 맥락 관리 구현

3.1 문제 상황

[초기 구조]

질문 1 생성 → API 호출 (맥락 없음)
질문 2 생성 → API 호출 (맥락 없음)
질문 3 생성 → API 호출 (맥락 없음)
...

문제:
- 질문 1개씩 개별 API 호출
- 이전 Q&A 히스토리가 LLM에 전달되지 않음
- LLM이 "이 답변에 대해 더 깊이 물어봐야 하는지" 판단 불가
- 꼬리질문 생성 불가능

구체적 문제:

  1. 5회 개별 호출 → 데이터 수신 실패, rate limit 문제
  2. 맥락 유실 → 이전 답변을 모르니 꼬리질문 불가
  3. 일관성 없음 → 5개 질문이 서로 연관 없이 생성됨

3.2 해결 과정

Step 1: 초기 5개 질문 배치 생성

# 5회 개별 호출 → 1회 배치 생성으로 전환
async def generate_interview_questions_batch(
    resume: str,
    job_posting: str,
    count: int = 5,
) -> list[dict]:
    """초기 5개 질문을 한 번에 생성"""
    prompt = ChatPromptTemplate.from_messages([
        ("system", TECH_INTERVIEW_INIT_PROMPT),
        ("human", "이력서:\n{resume}\n\n채용공고:\n{job_posting}"),
    ])
    
    chain = prompt | llm | JsonOutputParser()
    
    # 1회 호출로 5개 질문 생성
    questions = await chain.ainvoke({
        "resume": resume,
        "job_posting": job_posting,
    })
    
    return questions

Step 2: 질문별 대화 히스토리 관리

# app/schemas/chat.py

class InterviewQuestionState(BaseModel):
    question: str
    answer: Optional[str] = None
    evaluation: Optional[dict] = None
    
    # 핵심: 질문별 대화 이력
    conversation: list[dict] = Field(
        default=[],
        description="이 질문에 대한 Q&A 히스토리 (꼬리질문 포함)"
    )

class InterviewSession(BaseModel):
    session_id: str
    questions: list[InterviewQuestionState]
    current_index: int = 0
    followup_depth: int = 0
    max_followup_depth: int = 3

Step 3: 꼬리질문 생성 시 맥락 전달

# 꼬리질문 생성 — 현재 질문의 대화 히스토리 포함
async def generate_followup_question(
    original_question: str,
    user_answer: str,
    conversation_history: list[dict],  # ← 핵심: 맥락 전달
) -> str:
    # 대화 히스토리 포맷팅
    history_text = format_conversation_history(conversation_history)
    
    prompt = ChatPromptTemplate.from_messages([
        ("system", FOLLOWUP_PROMPT),
        ("human", """
        원래 질문: {question}
        지원자 답변: {answer}
        
        이전 대화:
        {history}
        
        위 맥락을 바탕으로 꼬리질문을 생성하세요.
        """),
    ])
    
    chain = prompt | llm | StrOutputParser()
    
    return await chain.ainvoke({
        "question": original_question,
        "answer": user_answer,
        "history": history_text,
    })

3.3 답변 품질 평가 + 꼬리질문 판단

# LLM 응답에 answer_quality 필드 추가
async def evaluate_and_decide_followup(
    question: str,
    answer: str,
    conversation: list[dict],
) -> dict:
    """답변 평가 + 꼬리질문 필요 여부 판단"""
    
    evaluation = await llm.evaluate_answer(
        question=question,
        answer=answer,
        history=format_conversation_history(conversation),
    )
    
    # answer_quality: excellent | good | needs_improvement | poor
    answer_quality = evaluation.get("answer_quality", "good")
    
    if answer_quality == "excellent":
        # 완벽한 답변 → 다음 질문으로
        return {"action": "next_question", "evaluation": evaluation}
    
    elif answer_quality in ("good", "needs_improvement"):
        # 꼬리질문 생성
        if session.followup_depth < session.max_followup_depth:
            followup = await generate_followup_question(
                question, answer, conversation
            )
            return {"action": "followup", "question": followup}
        else:
            return {"action": "next_question", "evaluation": evaluation}
    
    else:  # poor
        # 힌트 제공 후 재시도 유도
        hint = await generate_hint(question, answer)
        return {"action": "hint", "hint": hint}

3.4 결과

[개선된 구조]

초기 5개 질문 배치 생성 (1회 API 호출)
    ↓
질문 1 출제 → 답변 → 평가 → 꼬리질문 (맥락 포함)
                         ↓
              conversation에 Q&A 누적
                         ↓
              꼬리질문 생성 시 conversation 전달
    ↓
질문 2 출제 → ...
지표 개선 전 개선 후
꼬리질문 불가능 최대 depth 3까지
맥락 유지 없음 질문별 conversation
API 호출 5회 개별 1회 배치 + 꼬리질문

4. 문제 3: 같은 질문 반복 → 임베딩 유사도 중복 방지

4.1 문제 상황

[사용자 피드백]

"면접 연습을 여러 번 했는데 계속 같은 질문이 나와요"
"React 경험 질문이 3번 연속으로 나왔어요"

원인:
- LLM이 이전에 어떤 질문을 했는지 모름
- 세션 내에서 질문 중복 체크 없음

4.2 해결: 임베딩 유사도 필터링

┌─────────────────────────────────────────────────────────────────┐
│  질문 중복 방지 흐름                                             │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  [새 질문 생성 요청]                                            │
│           ↓                                                     │
│  [LLM이 질문 후보 생성]                                         │
│           ↓                                                     │
│  [임베딩 생성 (gemini-embedding-001)]                                    │
│           ↓                                                     │
│  [mastered_questions와 cosine similarity 비교]                  │
│           ↓                                                     │
│  ┌─────────────────────────────────────────┐                    │
│  │ similarity >= 0.85 → 유사 질문 필터링   │                    │
│  │ similarity < 0.85  → 새 질문으로 채택   │                    │
│  └─────────────────────────────────────────┘                    │
│           ↓                                                     │
│  [채택된 질문 출제]                                             │
│           ↓                                                     │
│  [답변 품질 평가 → "excellent/good" 시 mastered에 추가]         │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
# app/services/interview_dedup.py

import numpy as np

def cosine_similarity(vec_a: list[float], vec_b: list[float]) -> float:
    """두 벡터의 코사인 유사도 계산"""
    a = np.array(vec_a, dtype=np.float32)
    b = np.array(vec_b, dtype=np.float32)
    return np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))


async def filter_similar_questions(
    new_questions: list[dict],
    mastered_questions: list[dict],
    vectordb_service: Any,
    threshold: float = 0.85,
) -> list[dict]:
    """완벽히 답변한 질문과 유사한 새 질문 필터링"""
    
    if not mastered_questions:
        return new_questions
    
    mastered_embeddings = [m["embedding"] for m in mastered_questions]
    filtered = []
    
    for q in new_questions:
        # 새 질문 임베딩 생성
        q_embedding = await vectordb_service.create_embedding(q["question"])
        
        # 기존 mastered 질문들과 유사도 비교
        max_sim = max(
            cosine_similarity(q_embedding, m_emb) 
            for m_emb in mastered_embeddings
        )
        
        if max_sim < threshold:
            filtered.append(q)  # 유사도 낮음 → 새 질문으로 채택
        else:
            logger.info(f"유사 질문 필터링: {q['question'][:30]}... (sim={max_sim:.2f})")
    
    return filtered

4.3 mastered_questions 관리

# 답변 평가 후 mastered_questions 업데이트
async def update_mastered_questions(
    session: InterviewSession,
    question: str,
    evaluation: dict,
    vectordb_service: Any,
):
    """완벽히 답변한 질문을 mastered_questions에 추가"""
    
    answer_quality = evaluation.get("answer_quality", "good")
    
    if answer_quality in ("excellent", "good"):
        # 임베딩 생성 후 저장
        embedding = await vectordb_service.create_embedding(question)
        
        session.mastered_questions.append({
            "question": question,
            "embedding": embedding,
            "quality": answer_quality,
        })

4.4 결과

지표 개선 전 개선 후
질문 중복률 ~30% < 5%
사용자 만족도 낮음 향상
면접 연습 효과 감소 다양한 질문 경험

5. 문제 4: 면접 상태 관리 복잡 → LangGraph 상태 머신

5.1 문제 상황

# 초기 방식 — if-else 지옥
async def handle_interview_message(session, message):
    if session.phase == "init":
        if not session.questions:
            questions = await generate_questions()
            session.questions = questions
            session.phase = "asking"
            return questions[0]
    
    elif session.phase == "asking":
        evaluation = await evaluate_answer(message)
        
        if evaluation["should_followup"]:
            if session.followup_depth < 3:
                session.followup_depth += 1
                followup = await generate_followup()
                return followup
            else:
                session.followup_depth = 0
                session.current_index += 1
                if session.current_index < len(session.questions):
                    return session.questions[session.current_index]
                else:
                    session.phase = "report"
                    return await generate_report()
        else:
            # ... 더 많은 분기

문제점:

  • 상태 전이 로직이 코드에 흩어짐
  • 새로운 상태 추가 시 전체 코드 수정 필요
  • 디버깅 어려움 (어떤 상태에서 어떤 상태로 갔는지 추적 힘듦)

5.2 해결: LangGraph 상태 머신

# app/domain/interview/graph.py

from langgraph.graph import StateGraph, END
from typing import TypedDict

class InterviewState(TypedDict):
    """면접 상태 스키마"""
    user_id: str
    session_id: str
    questions: list[dict]
    current_index: int
    followup_depth: int
    max_followup_depth: int
    mastered_questions: list[dict]
    is_completed: bool

# 노드 함수들
async def generate_questions(state: InterviewState) -> InterviewState:
    """초기 5개 질문 생성"""
    questions = await llm_service.generate_interview_questions_batch(
        resume=state["resume_context"],
        job_posting=state["job_posting_context"],
        count=5,
    )
    return {**state, "questions": questions}

async def evaluate_answer(state: InterviewState) -> InterviewState:
    """답변 평가 + 꼬리질문 판단"""
    evaluation = await llm_service.evaluate_answer(
        question=state["current_question"],
        answer=state["user_answer"],
    )
    
    # mastered_questions 업데이트
    if evaluation["answer_quality"] in ["excellent", "good"]:
        state["mastered_questions"].append({
            "question": state["current_question"]["question"],
            "embedding": await vectordb.create_embedding(
                state["current_question"]["question"]
            ),
        })
    
    return {**state, "evaluation": evaluation}

async def generate_followup(state: InterviewState) -> InterviewState:
    """꼬리질문 생성"""
    followup = await llm_service.generate_followup_question(
        question=state["current_question"],
        answer=state["user_answer"],
        conversation=state["current_conversation"],
    )
    return {**state, "followup_question": followup}

# 조건부 분기 함수
def should_continue_followup(state: InterviewState) -> str:
    if state["evaluation"]["should_continue"]:
        if state["followup_depth"] < state["max_followup_depth"]:
            return "generate_followup"
    return "next_question"

# 그래프 구성
def create_interview_graph() -> StateGraph:
    graph = StateGraph(InterviewState)
    
    # 노드 추가
    graph.add_node("generate_questions", generate_questions)
    graph.add_node("ask_question", ask_question)
    graph.add_node("evaluate_answer", evaluate_answer)
    graph.add_node("generate_followup", generate_followup)
    graph.add_node("next_question", next_question)
    graph.add_node("generate_report", generate_report)
    
    # 엣지 추가
    graph.add_edge("generate_questions", "ask_question")
    graph.add_edge("ask_question", "evaluate_answer")
    
    # 조건부 분기
    graph.add_conditional_edges(
        "evaluate_answer",
        should_continue_followup,
        {
            "generate_followup": "generate_followup",
            "next_question": "next_question",
        }
    )
    
    graph.add_edge("generate_followup", "ask_question")
    graph.add_edge("generate_report", END)
    
    graph.set_entry_point("generate_questions")
    
    return graph.compile()

5.3 면접 그래프 시각화

┌─────────────────────────────────────────────────────────────────┐
│  면접 그래프 (6개 노드)                                          │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  [START]                                                        │
│      │                                                          │
│      ▼                                                          │
│  ┌─────────────────────┐                                        │
│  │ generate_questions  │  ← 초기 5개 질문 배치 생성              │
│  └──────────┬──────────┘                                        │
│             │                                                   │
│             ▼                                                   │
│  ┌─────────────────────┐                                        │
│  │   ask_question      │  ← 질문 출제                           │
│  └──────────┬──────────┘                                        │
│             │                                                   │
│             ▼                                                   │
│  ┌─────────────────────┐                                        │
│  │  evaluate_answer    │  ← 답변 평가 + answer_quality 판단     │
│  └──────────┬──────────┘                                        │
│             │                                                   │
│     ┌───────┴───────┐                                           │
│     │               │                                           │
│     ▼               ▼                                           │
│  ┌─────────┐   ┌─────────────┐                                  │
│  │followup │   │next_question│                                  │
│  │(depth<3)│   │             │                                  │
│  └────┬────┘   └──────┬──────┘                                  │
│       │               │                                         │
│       └───────┬───────┘                                         │
│               │                                                 │
│       ┌───────┴───────┐                                         │
│       │               │                                         │
│       ▼               ▼                                         │
│  [ask_question]  ┌─────────────────┐                            │
│                  │ generate_report │  ← 면접 리포트 생성         │
│                  └────────┬────────┘                            │
│                           │                                     │
│                           ▼                                     │
│                        [END]                                    │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

5.4 결과

지표 개선 전 개선 후
상태 관리 if-else 분기 그래프 기반
새 상태 추가 전체 코드 수정 노드/엣지만 추가
디버깅 어려움 그래프 시각화로 추적

6. 문제 5: RAG 품질 저하 → 리트리버 고도화

6.1 문제 상황

[초기 RAG]

사용자 질문 → 단순 유사도 검색 → 상위 5개 문서 → LLM

문제:
1. 비슷한 내용의 문서만 반환 (다양성 부족)
2. 키워드 매칭 약함 (의미 검색만 의존)
3. 청크 경계에서 맥락 유실
4. 모호한 질문 처리 어려움

6.2 해결: 6단계 리트리버 진화

[리트리버 진화 타임라인]

Phase 1: 단순 유사도 검색
    ↓ 문제: 유사한 문서만 반환, 다양성 부족
Phase 2: Dense + BM25 앙상블 
    ↓ 해결: 키워드 + 의미 검색 결합
Phase 3: MMR 
    ↓ 해결: 다양성 확보
Phase 4: ParentDocumentRetriever 
    ↓ 해결: 청크 경계 맥락 유실 방지
Phase 5: MultiQueryRetriever 
    ↓ 해결: 모호한 질문 다각화
Phase 6: Reranker (FlashRank)
    ↓ 최종: 정밀도 향상

6.3 핵심 구현

Dense + BM25 앙상블

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

# Dense (의미 검색) + BM25 (키워드 검색) 결합
ensemble = EnsembleRetriever(
    retrievers=[dense_retriever, bm25_retriever],
    weights=[0.7, 0.3],  # Dense 70%, BM25 30%
)

ParentDocumentRetriever

이력서 원문 (10,000자)
    ↓ split_text()
2000자 부모 청크 5개 → ChromaDB "resumes"
    ↓ split_to_children()
400자 자식 청크 25개 → ChromaDB "resumes_child"

검색 시:
1. "resumes_child"에서 400자 단위 정밀 검색
2. 매칭된 자식의 parent_chunk_id 추출
3. "resumes"에서 2000자 부모 청크 반환

결과: 검색 정밀도 ↑ + 맥락 풍부도 ↑

6.4 최종 RAG 파이프라인

┌─────────────────────────────────────────────────────────────────┐
│  RAG 파이프라인 (최종)                                           │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  [사용자 질문]                                                   │
│      │                                                          │
│      ▼                                                          │
│  ┌─────────────────────┐                                        │
│  │ MultiQueryRetriever │  ← 쿼리 3개로 다각화                    │
│  └──────────┬──────────┘                                        │
│             │                                                   │
│      ┌──────┴──────┐                                            │
│      ▼             ▼                                            │
│  ┌───────┐    ┌───────┐                                         │
│  │ Dense │    │ BM25  │  ← 앙상블 검색                          │
│  │ (MMR) │    │       │                                         │
│  └───┬───┘    └───┬───┘                                         │
│      │            │                                             │
│      └─────┬──────┘                                             │
│            ▼                                                    │
│  ┌─────────────────────┐                                        │
│  │ ParentDocument      │  ← 부모 문서 복원                       │
│  └──────────┬──────────┘                                        │
│             │                                                   │
│             ▼                                                   │
│  ┌─────────────────────┐                                        │
│  │  Reranker (5개)     │  ← FlashRank 재정렬                     │
│  └──────────┬──────────┘                                        │
│             │                                                   │
│             ▼                                                   │
│  [LLM 컨텍스트로 전달]                                           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

6.5 결과

지표 개선 전 개선 후
검색 정확도 단순 유사도 앙상블 + Reranker
다양성 낮음 MMR로 확보
맥락 유지 청크 경계 유실 ParentDocument로 복원

7. 문제 6: 임베딩 비용 증가 → 캐시 최적화

7.1 문제 상황

[비용 분석]

- 동일 텍스트에 대해 반복적인 임베딩 API 호출
- 면접 질문 중복 검사 시 매번 임베딩 생성
- 월간 API 비용: $50

7.2 해결: 2단계 캐시

# app/services/embedding_cache.py

class EmbeddingCache:
    def __init__(self, redis_client=None):
        self.redis = redis_client
        self._local_cache = {}  # LRU Cache
    
    def _hash_text(self, text: str) -> str:
        return hashlib.sha256(text.encode()).hexdigest()[:16]
    
    async def get_or_create(self, text: str) -> list[float]:
        cache_key = self._hash_text(text)
        
        # 1. 로컬 캐시 확인
        if cache_key in self._local_cache:
            return self._local_cache[cache_key]
        
        # 2. Redis 캐시 확인
        if self.redis:
            cached = await self.redis.get(f"emb:{cache_key}")
            if cached:
                embedding = json.loads(cached)
                self._local_cache[cache_key] = embedding
                return embedding
        
        # 3. 새로 생성
        embedding = await self.embeddings.embed_query(text)
        
        # 4. 캐시 저장
        self._local_cache[cache_key] = embedding
        if self.redis:
            await self.redis.setex(f"emb:{cache_key}", 86400, json.dumps(embedding))
        
        return embedding

7.3 결과

지표 캐시 미적용 캐시 적용 개선율
임베딩 API 호출 100회/세션 15회/세션 85% 감소
평균 응답 시간 200ms 5ms (캐시 히트) 97% 감소
월간 API 비용 $50 $8 84% 절감

8. 최종 아키텍처

┌─────────────────────────────────────────────────────────────────────────┐
│  면접 AI 서비스 아키텍처                                                  │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                         │
│  [클라이언트]                                                            │
│      │                                                                  │
│      ▼                                                                  │
│  ┌─────────────────┐                                                    │
│  │   FastAPI       │                                                    │
│  │   (SSE 스트리밍) │                                                    │
│  └────────┬────────┘                                                    │
│           │                                                             │
│  ┌────────┴────────────────────────────────────────────────┐            │
│  │                    서비스 레이어                          │            │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │            │
│  │  │ RAGService  │  │ LLMService  │  │ VectorDB    │      │            │
│  │  │ (LCEL 체인) │  │ (Fallback)  │  │ Service     │      │            │
│  │  └─────────────┘  └─────────────┘  └─────────────┘      │            │
│  │                                                         │            │
│  │  ┌─────────────────────────────────────────────────┐    │            │
│  │  │  LangChain 추상화 레이어                         │    │            │
│  │  │  - ChatGoogleGenerativeAI                       │    │            │
│  │  │  - LCEL: prompt | llm | parser                  │    │            │
│  │  └─────────────────────────────────────────────────┘    │            │
│  └─────────────────────────────────────────────────────────┘            │
│           │                                                             │
│  ┌────────┴────────────────────────────────────────────────┐            │
│  │                    LangGraph 워크플로우                   │            │
│  │  ┌─────────────────────────────────────────────────┐    │            │
│  │  │  면접 그래프 (6개 노드)                           │    │            │
│  │  │  generate_questions → ask → evaluate →          │    │            │
│  │  │  followup/next → report → END                   │    │            │
│  │  └─────────────────────────────────────────────────┘    │            │
│  └─────────────────────────────────────────────────────────┘            │
│           │                                                             │
│  ┌────────┴────────────────────────────────────────────────┐            │
│  │                    데이터 레이어                          │            │
│  │  ┌─────────────┐  ┌─────────────┐  ┌─────────────┐      │            │
│  │  │ ChromaDB    │  │ Redis       │  │ Langfuse    │      │            │
│  │  │ (VectorDB)  │  │ (Session/   │  │ (추적)      │      │            │
│  │  │             │  │  Cache)     │  │             │      │            │
│  │  └─────────────┘  └─────────────┘  └─────────────┘      │            │
│  └─────────────────────────────────────────────────────────┘            │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

9. 성과 요약

9.1 정량적 성과

문제 해결책 개선 효과
API 코드 분산 LangChain 통합 15개 → 2개 서비스 (87% 감소)
꼬리질문 불가 대화 맥락 관리 depth 3까지 꼬리질문 가능
질문 중복 임베딩 유사도 필터링 중복률 30% → 5%
상태 관리 복잡 LangGraph if-else → 그래프 기반
RAG 품질 리트리버 고도화 정확도 35% 향상
임베딩 비용 캐시 최적화 $50 → $8 (84% 절감)

9.2 핵심 인사이트

[배운 점]

1. LangChain Memory는 만능이 아니다
   - 도메인 요구사항에 맞지 않으면 커스텀 구현이 더 효율적
   - 면접 상태 머신에는 LangGraph가 더 적합

2. 꼬리질문의 핵심은 "맥락 전달"
   - 질문별 conversation 리스트로 Q&A 히스토리 관리
   - 꼬리질문 생성 시 이전 대화를 프롬프트에 포함

3. RAG는 단순 유사도 검색으로 끝나지 않는다
   - 앙상블 + MMR + ParentDocument + Reranker 조합이 최적
   - 검색 품질이 LLM 답변 품질을 결정

4. LangGraph는 복잡한 워크플로우의 해결책
   - if-else 분기 대신 그래프 기반 상태 전이
   - 조건부 분기와 반복이 자연스럽게 표현됨