LangChain, LCEL, LangGraph, RAG 도입 — 문제 해결 과정
목차
- 개요
- 문제 1: LLM API 직접 호출의 한계 → LangChain 도입
- 문제 2: 꼬리질문이 안됨 → 대화 맥락 관리 구현
- 문제 3: 같은 질문 반복 → 임베딩 유사도 중복 방지
- 문제 4: 면접 상태 관리 복잡 → LangGraph 상태 머신
- 문제 5: RAG 품질 저하 → 리트리버 고도화
- 문제 6: 임베딩 비용 증가 → 캐시 최적화
- 최종 아키텍처
- 성과 요약
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이 "이 답변에 대해 더 깊이 물어봐야 하는지" 판단 불가
- 꼬리질문 생성 불가능
구체적 문제:
- 5회 개별 호출 → 데이터 수신 실패, rate limit 문제
- 맥락 유실 → 이전 답변을 모르니 꼬리질문 불가
- 일관성 없음 → 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 분기 대신 그래프 기반 상태 전이
- 조건부 분기와 반복이 자연스럽게 표현됨