[AI] 13. ADR 066‐070 ‐ 리트리버 전략 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki
ADR 066-070: 면접 질문 중복 방지 및 품질 개선
작성일: 2026-02-19
상태: 승인됨 (Accepted)
📚 목차
- ADR-066: 기술면접 질문 중복 방지 — 임베딩 유사도 + 답변 품질 평가
- ADR-067: LangChain 6대 모듈 도입 현황 — 4개 완전 도입 + Memory 의도적 미도입
- ADR-068: LangChain Callbacks 도입 — Langfuse CallbackHandler 전환 계획
- ADR-069: RAG 리트리버 확장 전략 — 희소·밀집·앙상블·문서 압축·재정렬
- ADR-070: LCEL 전환 비교 + 부모 문서 리트리버 + 다중 쿼리 리트리버 도입 계획
ADR-066: 기술면접 질문 중복 방지 — 임베딩 유사도 + 답변 품질 평가
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-02-19 |
| 결정자 | AI팀 |
| 관련 기능 | 기술면접, 꼬리질문 판단, VectorDB 임베딩 |
| 관련 ADR | ADR-065 (임베딩 캐시), ADR-054 (RAG 리트리버) |
🎯 컨텍스트 (Context)
현재 기술면접 시스템은 같은 채팅 세션 내에서 이미 완벽하게 답변된 주제와 유사한 질문이 다시 출제될 수 있는 문제가 있다.
현재 상태:
app/domain/interview/graph.py의evaluate_answer()에서 LLM이should_continue: true/false만 반환should_continue: false의 의미가 모호함:- ✅ "답변이 완벽해서 더 파고들 필요 없음" → 유사 질문 스킵 대상
- ❌ "답변을 못 해서 더 물어봐도 의미 없음" → 유사 질문 다시 출제 가능
- 두 경우가 동일하게
question["is_completed"] = True로 처리되어, 답변 품질에 따른 후속 처리 분기가 불가능
문제 시나리오:
질문 1: "React의 가상 DOM 동작 원리를 설명해주세요" → 완벽히 답변
질문 3: "React의 렌더링 최적화 방법을 설명해주세요" → 유사 주제 중복 출제
요구사항:
- 꼬리질문 판단 시 LLM이 답변 품질 등급을 함께 반환하도록 개선
- 같은 세션 내에서 "완벽/양호 답변"한 질문과 유사도가 높은 새 질문은 스킵
- "불량 답변"한 질문은 유사 질문이 다시 출제될 수 있도록 허용
- 적용 범위: 같은 채팅 세션(같은
session_id) 내에서만 동작
🏗️ 검토한 옵션
Option 1: LLM 답변 품질 등급 + 임베딩 유사도 필터링 (선택됨)
꼬리질문 판단 시 answer_quality 필드를 추가하고, 질문 출제 전 임베딩 유사도로 중복 필터링한다.
1단계: followup 프롬프트 응답에 answer_quality 추가
# 현재 LLM 응답 포맷
{ "should_continue": false }
# 개선된 포맷
{
"should_continue": false,
"answer_quality": "excellent", # excellent | good | poor | off_topic
"quality_reason": "핵심 개념을 정확히 설명하고 실무 경험까지 언급"
}
| 등급 | 의미 | 유사 질문 스킵 |
|---|---|---|
excellent |
핵심 + 심화까지 정확 | ✅ 스킵 |
good |
핵심은 답변, 부분적 부족 | ✅ 스킵 |
poor |
불완전하거나 부정확 | ❌ 유사 질문 허용 |
off_topic |
질문과 무관한 답변 | ❌ 유사 질문 허용 |
2단계: 세션 내 "마스터한 질문" 임베딩 수집
# InterviewSession 또는 세션 state에 추가
mastered_questions: list[dict] = []
# 형태: [{"question": "React 가상 DOM...", "embedding": [0.1, 0.2, ...]}]
answer_quality가excellent또는good이면 해당 질문을mastered_questions에 추가- 임베딩은
CacheBackedEmbeddings(ADR-065)로 생성 → 캐시 히트 시 API 비용 0
3단계: 질문 출제 전 유사도 필터링
import numpy as np
async def filter_similar_questions(
new_questions: list[dict],
mastered_questions: list[dict],
vectordb: VectorDBService,
threshold: float = 0.85,
) -> list[dict]:
"""마스터한 질문과 유사한 새 질문을 필터링"""
if not mastered_questions:
return new_questions
filtered = []
mastered_embeddings = [q["embedding"] for q in mastered_questions]
for q in new_questions:
q_embedding = await vectordb.create_embedding(q["question"])
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"질문 스킵 (유사도 {max_sim:.2f}): {q['question'][:50]}")
return filtered
flowchart TD
A[답변 수신] --> B[LLM 꼬리질문 판단]
B --> C{answer_quality?}
C -->|excellent/good| D[mastered_questions에 추가]
C -->|poor/off_topic| E[스킵 대상 아님]
D --> F[다음 질문 출제 전]
E --> F
F --> G{새 질문 임베딩 vs mastered 유사도}
G -->|> 0.85| H[질문 스킵 → 대체 질문]
G -->|< 0.85| I[정상 출제]
| 장점 | 단점 |
|---|---|
| 완벽 답변 주제의 불필요한 반복 제거 | followup 프롬프트 변경 필요 |
| 임베딩 캐시(ADR-065) 활용 → 추가 비용 최소 | 유사도 임계값 튜닝 필요 |
| 같은 세션 내에서만 동작 → 복잡도 낮음 | 세션 내 임베딩 비교 추가 연산 |
| 답변 품질에 따른 정밀한 분기 | LLM이 품질 등급을 정확히 판단하지 못할 수 있음 |
Option 2: 질문 텍스트 키워드 매칭만 사용
임베딩 없이 질문의 category, keywords 필드를 비교하여 중복을 판단한다.
| 장점 | 단점 |
|---|---|
| 구현 단순 | "React 가상 DOM" vs "React 렌더링 최적화"처럼 의미적 유사성 감지 불가 |
| 추가 API 비용 없음 | 카테고리가 같아도 질문 깊이가 다를 수 있음 |
Option 3: LLM에 이전 Q&A를 컨텍스트로 전달하여 중복 회피
질문 생성 시 이전 Q&A 전체를 프롬프트에 포함하고 "이미 답변된 주제는 피하라"고 지시한다.
| 장점 | 단점 |
|---|---|
| 별도 필터링 로직 불필요 | 프롬프트 길이 증가 → 토큰 비용 증가 |
| LLM이 자연스럽게 판단 | 판단이 불확실 (가끔 무시될 수 있음) |
| - | 5문 일괄 생성 시 적용 어려움 (이미 생성된 질문) |
결정 (Decision)
Option 1 (LLM 답변 품질 등급 + 임베딩 유사도 필터링)을 선택합니다.
근거:
- 정밀한 분류:
answer_quality등급으로 "잘 답해서 넘어감" vs "못 답해서 넘어감"을 명확히 구분. 기존should_continue단독 사용의 모호함을 해소. - 의미적 유사도: 임베딩 기반 cosine similarity로 "React 가상 DOM" ↔ "React 렌더링 최적화" 같은 의미적 유사성을 감지. 키워드 매칭(Option 2)으로는 불가능.
- 비용 효율:
CacheBackedEmbeddings(ADR-065) 덕분에 동일 질문 텍스트의 재임베딩 비용이 0. 세션 내 질문 수가 최대 5개라 비교 연산도 미미. - 범위 제한: 같은 세션(
session_id) 내에서만mastered_questions를 유지하므로, 세션 종료 시 자연스럽게 리셋되어 복잡한 영속 로직 불필요.
구현 요약 (3.model 기준)
| 구분 | 파일 | 내용 |
|---|---|---|
| 프롬프트 수정 | app/prompts/templates/interview/followup.md |
answer_quality + quality_reason 필드 추가 |
| 응답 파싱 | app/domain/interview/graph.py evaluate_answer() |
answer_quality 파싱 → excellent/good이면 mastered_questions에 추가 |
| 유사도 필터 | app/services/interview_dedup.py [NEW] |
filter_similar_questions() — 임베딩 cosine similarity 비교 |
| 세션 상태 | app/domain/interview/entities.py |
InterviewState에 mastered_questions 필드 추가 |
| 질문 필터링 | app/api/routes/v2/chat.py |
질문 출제 전 filter_similar_questions() 호출 |
Consequences (결과)
긍정적 영향:
- 같은 세션 내에서 이미 완벽히 답변한 주제의 중복 질문이 제거되어 면접 품질 향상
- 답변 품질 등급 데이터가 면접 리포트에도 활용 가능 (향후 확장)
- 임베딩 캐시(ADR-065)와 시너지: 질문 텍스트 임베딩 비용이 거의 0
부정적 영향 / 트레이드오프:
- followup 프롬프트 변경으로 LLM 응답 포맷이 복잡해짐. 파싱 실패 시 기본값(
good) 적용 필요. - cosine similarity 임계값(0.85)은 경험적 값. 너무 낮으면 다른 주제도 스킵되고, 너무 높으면 유사 질문이 통과될 수 있어 조정 필요.
후속 작업:
- followup 프롬프트에
answer_quality필드 추가 -
evaluate_answer()에서answer_quality파싱 로직 추가 -
InterviewState에mastered_questions필드 추가 -
filter_similar_questions()유틸 함수 구현 - 질문 출제 경로에서 필터링 호출 연동
- cosine similarity 임계값 튜닝 (0.80~0.90 범위 실험)
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-19 | 초기 작성 (답변 품질 등급 + 임베딩 유사도 중복 방지 결정) |
ADR-067: LangChain 6대 모듈 도입 현황 — 4개 완전 도입 + Memory 의도적 미도입
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | ✅ 승인됨 (Accepted) |
| 작성일 | 2026-02-20 |
| 결정자 | AI팀 |
| 관련 기능 | LLM 파이프라인 전체, 면접/채팅/RAG/평가 |
| 관련 ADR | ADR-054 (RAG 리트리버), ADR-065 (임베딩 캐시) |
🎯 컨텍스트 (Context)
LangChain은 6대 핵심 모듈(Model I/O, Data Connection, Chains, Agents, Memory, Callbacks)로 구성된 LLM 애플리케이션 프레임워크이다.
본 프로젝트(취업 준비 AI 서비스)에서 각 모듈의 도입 여부와 현황을 정리하여, 아키텍처 의사결정을 문서화한다.
🏗️ 모듈별 도입 현황
✅ 1. Model I/O — 완전 도입
LLM과의 입출력 인터페이스를 LangChain 추상화로 통일하였다.
| 컴포넌트 | 클래스/메서드 | 사용 파일 |
|---|---|---|
| LLM | ChatGoogleGenerativeAI |
langchain_wrapper.py — 멀티 API 키 + fallback 전략으로 인스턴스 생성 |
| Embeddings | GoogleGenerativeAIEmbeddings |
langchain_wrapper.py, vectordb_service.py — 임베딩 생성 |
| Prompt | ChatPromptTemplate.from_messages() |
langchain_wrapper.py(3곳), chains.py(4곳) — 시스템/휴먼 메시지 템플릿 |
| Output Parser | StrOutputParser |
langchain_wrapper.py, chains.py — 텍스트 출력 파싱 |
| Structured Output | llm.with_structured_output() |
langchain_wrapper.py — JSON 스키마 강제 응답 |
✅ 2. Data Connection — 완전 도입
외부 데이터 수집부터 벡터 저장소 검색까지 전 파이프라인을 LangChain으로 구축하였다.
| 컴포넌트 | 클래스/메서드 | 사용 파일 |
|---|---|---|
| Document Loader | WebBaseLoader |
web_loader_service.py — 채용공고 URL 크롤링 |
| Text Splitter | RecursiveCharacterTextSplitter |
text_splitter_service.py — 청크 분할 (chunk_size, separators 설정) |
| Vector Store | Chroma |
chains.py, _helpers.py — 컬렉션별 벡터DB 관리 |
| Embedding Cache | CacheBackedEmbeddings + LocalFileStore |
vectordb_service.py — ADR-065로 도입, 임베딩 캐싱 |
| Retriever | max_marginal_relevance_search() (MMR) |
chains.py — ADR-054로 도입, 다양성 확보 검색 |
✅ 3. Chains (LCEL) — 완전 도입
LangChain Expression Language(LCEL) 파이프 연산자를 활용하여 체인을 구성하였다.
| 패턴 | 사용 예 | 사용 파일 |
|---|---|---|
| LCEL 파이프 | prompt | llm | output_parser |
langchain_wrapper.py(3곳), chains.py(6곳) |
| 비동기 실행 | chain.ainvoke() |
langchain_wrapper.py, chains.py, rag_service.py — 총 10+회 |
| 스트리밍 | chain.astream() / llm.astream() |
langchain_wrapper.py, chains.py, rag_service.py — 총 5회 |
| Fallback | llm.with_fallbacks([...]) |
langchain_wrapper.py — 멀티 API 키 장애 대응 |
| 파라미터 바인딩 | llm.bind(**kwargs) |
langchain_wrapper.py — temperature, max_tokens 등 동적 설정 (6곳) |
✅ 4. Agents (LangGraph) — 완전 도입
LangGraph의 StateGraph를 활용하여 상태 기반 에이전트를 구현하였다.
| 그래프 | 노드 수 | 사용 파일 |
|---|---|---|
| 면접 그래프 | 6개 (generate_questions → ask_question → evaluate_answer → generate_followup → next_question → generate_report) |
app/domain/interview/graph.py |
| 토론 평가 그래프 | 6개 (load_gemini_result → gpt4o_analyze → compare_analyses → debate_round → synthesize_final → merge_results) |
app/domain/evaluation/debate_graph.py |
두 그래프 모두 add_node, add_edge, add_conditional_edges, END를 활용한 조건부 분기 로직을 포함한다.
⚠️ 5. Memory — 의도적 미도입
| 항목 | 내용 |
|---|---|
| 상태 | ❌ 미도입 (의도적) |
| LangChain Memory 클래스 사용 | 없음 (ConversationBufferMemory, ChatMessageHistory 등 미사용) |
미도입 사유:
-
세션 관리 요구사항 불일치: LangChain Memory는 단일 대화의 메시지 히스토리를 LLM 컨텍스트에 자동 주입하는 데 초점을 맞추고 있으나, 본 시스템은 면접/채팅/평가 등 모드별로 서로 다른 세션 라이프사이클을 관리해야 함.
-
커스텀 세션 스토어로 충분:
BaseSessionStore추상 클래스 +InMemorySessionStore/RedisSessionStore구현체로 세션 CRUD, TTL, 메타데이터 관리를 이미 구축. LangChain Memory 래핑 시 오히려 복잡도만 증가. -
면접 상태 머신과 비호환:
InterviewState(TypedDict)는questions,current_index,followup_depth,is_completed등 면접 고유 상태를 포함하며, LangChain Memory의 메시지 리스트 패러다임과 맞지 않음. -
성능/비용 최적화: 면접 리포트 생성 시 전체 Q&A를 한 번에 주입하므로, 매 턴마다 히스토리를 자동 주입하는 Memory 패턴은 불필요한 토큰 소비를 유발.
현재 대체 구현:
BaseSessionStore (추상)
├── InMemorySessionStore — 개발/테스트용
└── RedisSessionStore — 운영용 (TTL 자동 만료)
InterviewState (TypedDict)
├── questions: list[dict]
├── current_index: int
├── followup_depth: int
├── mastered_questions: list[dict] (ADR-066)
└── ...
⚠️ 6. Callbacks — 미도입 (전환 예정)
| 항목 | 내용 |
|---|---|
| 상태 | ❌ 미도입 → 도입 예정 (ADR-068 참조) |
| 현재 방식 | Langfuse SDK 직접 호출 (langfuse_client.py) |
| LangChain CallbackHandler 사용 | 없음 |
현재 langfuse_client.py에서 Langfuse SDK를 직접 사용하여 trace_llm_call(), create_generation(), observe_llm_call() 등의 래퍼 함수로 추적 중이다. LangChain의 Callbacks 모듈(CallbackHandler)은 사용하지 않고 있으며, 이를 도입하여 자동화된 추적으로 전환할 계획이다. 상세 계획은 ADR-068에 기술한다.
결정 (Decision)
LangChain 6대 모듈 중 4개(Model I/O, Data Connection, Chains, Agents)는 완전 도입을 유지한다.
- Model I/O: Google Gemini 기반 LLM/Embedding 통합
- Data Connection: 문서 로딩 → 분할 → 벡터 저장 → 검색 파이프라인
- Chains: LCEL 기반 비동기 체인 + 스트리밍 + fallback
- Agents: LangGraph StateGraph 기반 면접/토론 평가 워크플로우
Memory는 도입하지 않는다.
- 커스텀 세션 스토어 + TypedDict 기반 상태 관리가 도메인 요구사항에 더 적합
- LangChain Memory 래핑 시 복잡도 증가 대비 이점 없음
Callbacks는 ADR-068에 따라 Langfuse CallbackHandler로 전환 예정이다.
Consequences (결과)
긍정적 영향:
- LangChain 생태계의 핵심 기능을 최대한 활용하여 개발 생산성과 유지보수성 확보
- 모듈별 도입 근거가 명확히 문서화되어 팀 온보딩과 기술 의사결정에 기여
- Memory 미도입은 불필요한 추상화 레이어를 제거하여 코드 단순성 유지
부정적 영향 / 트레이드오프:
- Memory 미도입으로 LangChain 에코시스템의 일부 통합 기능(예: LangSmith 자동 히스토리 추적) 활용 불가
- Callbacks 미도입 상태에서는 LLM 호출 추적이 수동적이며, 누락 가능성 존재
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-20 | 초기 작성 (6대 모듈 도입 현황 정리, Memory 미도입 사유 문서화) |
ADR-068: LangChain Callbacks 도입 — Langfuse CallbackHandler 전환 계획
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | 📋 제안됨 (Proposed) |
| 작성일 | 2026-02-20 |
| 결정자 | AI팀 |
| 관련 기능 | LLM 호출 추적, 모니터링, 비용 분석 |
| 관련 ADR | ADR-067 (LangChain 모듈 현황), ADR-065 (임베딩 캐시) |
🎯 컨텍스트 (Context)
현재 시스템은 Langfuse SDK를 직접 호출하여 LLM 추적을 수행하고 있다. 이 방식은 동작하지만, 여러 한계가 있다.
현재 구현 (app/utils/langfuse_client.py):
# 직접 SDK 호출 방식
from langfuse import Langfuse
client = Langfuse(public_key=..., secret_key=..., host=...)
trace = client.create_trace_id()
generation = client.start_generation(name=..., model=..., input=..., output=...)
generation.end()
현재 방식의 문제점:
-
수동 계측 부담: 모든 LLM 호출 지점(
llm_service.py7곳,vllm_service.py,ocr_service.py)에서_langfuse_trace_and_generation()래퍼를 명시적으로 호출해야 함. 새로운 LLM 호출을 추가할 때 추적 코드 누락 위험. -
LCEL 체인 내부 불투명:
prompt | llm | parser체인에서 각 단계별 실행 시간, 토큰 사용량을 개별 추적할 수 없음. 체인 전체를 하나의 generation으로만 기록. -
LangGraph 노드 추적 불가: 면접 그래프(6노드), 토론 평가 그래프(6노드)의 노드별 실행 흐름과 소요 시간을 자동 추적할 수 없음.
-
코드 중복:
llm_service.py에 Langfuse 추적 로직이 7곳에 산재. 추적 포맷 변경 시 모든 지점을 수정해야 함.
🏗️ 검토한 옵션
Option 1: Langfuse CallbackHandler 도입 (선택됨)
LangChain의 Callbacks 모듈을 활용하여 CallbackHandler를 LLM/Chain/Agent에 자동 주입한다.
from langfuse.callback import CallbackHandler as LangfuseCallbackHandler
# 1) 핸들러 생성
langfuse_handler = LangfuseCallbackHandler(
public_key=settings.langfuse_public_key,
secret_key=settings.langfuse_secret_key,
host=settings.langfuse_host,
)
# 2) LCEL 체인에 자동 주입
chain = prompt | llm | parser
result = await chain.ainvoke(
{"input": user_message},
config={"callbacks": [langfuse_handler]},
)
# 3) LangGraph에도 동일하게 적용
app = workflow.compile()
result = await app.ainvoke(
initial_state,
config={"callbacks": [langfuse_handler]},
)
| 장점 | 단점 |
|---|---|
| 모든 LLM 호출이 자동 추적됨 (누락 불가) | langfuse 패키지의 LangChain 통합 의존성 추가 |
| LCEL 체인 단계별 (prompt → llm → parser) 개별 추적 | 기존 직접 SDK 코드 마이그레이션 필요 |
| LangGraph 노드별 실행 흐름 자동 시각화 | CallbackHandler 초기화 시 약간의 오버헤드 |
| 코드 중복 제거 (7곳 → 1곳 설정) | Langfuse 서버 장애 시 콜백 에러 핸들링 필요 |
trace_id, session_id, user_id 자동 전파 |
- |
Option 2: 현재 직접 SDK 방식 유지
기존 langfuse_client.py의 래퍼 함수를 계속 사용한다.
| 장점 | 단점 |
|---|---|
| 변경 없음, 안정적 | 수동 계측 부담 지속 |
| 세밀한 제어 가능 | 새 LLM 호출 추가 시 추적 누락 위험 |
| - | LCEL/LangGraph 내부 추적 불가 |
| - | 코드 중복 7곳 유지 |
Option 3: LangSmith 도입
LangChain의 공식 관측 플랫폼인 LangSmith를 사용한다.
| 장점 | 단점 |
|---|---|
| LangChain 네이티브 통합 | 외부 SaaS 의존 (Langfuse는 셀프호스팅 가능) |
| 풍부한 UI/대시보드 | 기존 Langfuse 인프라 폐기 필요 |
| - | 유료 플랜 필요 (프로덕션 규모) |
결정 (Decision)
Option 1 (Langfuse CallbackHandler 도입)을 선택한다.
근거:
- 자동 추적: CallbackHandler를
config에 전달하면 모든 LLM 호출, 체인 단계, 그래프 노드가 자동 추적됨. 수동 계측의 누락 위험 제거. - 기존 인프라 활용: 이미 운영 중인 Langfuse 서버를 그대로 사용. 추가 인프라 비용 없음.
- 점진적 마이그레이션: 기존 직접 SDK 코드를 즉시 삭제하지 않고, CallbackHandler와 병행 운영 후 안정화되면 제거.
- LangGraph 시각화: 면접 그래프(6노드)와 토론 평가 그래프(6노드)의 실행 흐름을 Langfuse 대시보드에서 trace로 시각화 가능.
구현 계획
Phase 1: CallbackHandler 팩토리 추가
| 구분 | 파일 | 내용 |
|---|---|---|
| 팩토리 함수 | app/utils/langfuse_client.py |
get_langfuse_callback_handler() 추가 — 세션별 trace_id, user_id 설정 |
# langfuse_client.py에 추가
def get_langfuse_callback_handler(
session_id: str | None = None,
user_id: str | None = None,
trace_name: str | None = None,
) -> Any | None:
"""LangChain CallbackHandler 인스턴스 반환"""
try:
from langfuse.callback import CallbackHandler as LangfuseCallbackHandler
except ImportError:
logger.warning("langfuse CallbackHandler not available")
return None
public_key = os.getenv("LANGFUSE_PUBLIC_KEY")
secret_key = os.getenv("LANGFUSE_SECRET_KEY")
host = os.getenv("LANGFUSE_HOST") or os.getenv("LANGFUSE_BASE_URL")
if not public_key or not secret_key:
return None
return LangfuseCallbackHandler(
public_key=public_key,
secret_key=secret_key,
host=host,
session_id=session_id,
user_id=user_id,
trace_name=trace_name or "langchain-trace",
)
Phase 2: LCEL 체인에 CallbackHandler 주입
| 구분 | 파일 | 내용 |
|---|---|---|
| 체인 실행 | app/domain/chat/chains.py |
ainvoke(), astream() 호출 시 config={"callbacks": [handler]} 전달 |
| 체인 생성 | app/infrastructure/llm/langchain_wrapper.py |
create_chain() 등에서 callbacks 옵션 지원 |
Phase 3: LangGraph에 CallbackHandler 주입
| 구분 | 파일 | 내용 |
|---|---|---|
| 면접 그래프 | app/domain/interview/graph.py |
app.ainvoke() 시 config={"callbacks": [handler]} 전달 |
| 토론 평가 | app/domain/evaluation/debate_graph.py |
app.ainvoke() 시 config={"callbacks": [handler]} 전달 |
Phase 4: 기존 직접 SDK 코드 정리
| 구분 | 파일 | 내용 |
|---|---|---|
| 중복 제거 | app/services/llm_service.py |
_langfuse_trace_and_generation() 7곳 호출 → CallbackHandler로 대체 후 제거 |
| 래퍼 정리 | app/utils/langfuse_client.py |
trace_llm_call(), create_generation() deprecated 처리 |
Consequences (결과)
긍정적 영향:
- LangChain 6대 모듈 중 5개 완전 도입 달성 (Memory만 의도적 미도입)
- 모든 LLM 호출이 자동 추적되어 비용 분석, 성능 모니터링, 디버깅 용이
- LCEL 체인 단계별/LangGraph 노드별 실행 흐름을 Langfuse 대시보드에서 시각화
llm_service.py의 추적 코드 중복 7곳 제거 → 유지보수성 향상
부정적 영향 / 트레이드오프:
langfuse패키지의 LangChain 통합 모듈 의존성 추가 (이미langfuse설치되어 있으므로 영향 최소)- 마이그레이션 기간 동안 직접 SDK + CallbackHandler 이중 추적 가능성 (Phase 4에서 해소)
- CallbackHandler의 에러가 LLM 호출 자체에 영향을 주지 않도록 에러 격리 필요
후속 작업:
-
langfuse_client.py에get_langfuse_callback_handler()팩토리 함수 추가 -
chains.py의ainvoke()/astream()호출에 callbacks config 전달 -
graph.py,debate_graph.py의app.ainvoke()에 callbacks config 전달 -
llm_service.py의 직접 Langfuse 추적 코드 제거 - Langfuse 대시보드에서 LCEL/LangGraph trace 시각화 확인
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-20 | 초기 작성 (Langfuse CallbackHandler 전환 계획 수립) |
ADR-069: RAG 리트리버 확장 전략 — 희소·밀집·앙상블·문서 압축·재정렬
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | 📋 제안됨 (Proposed) |
| 작성일 | 2026-02-20 |
| 결정자 | AI팀 |
| 관련 기능 | RAG, 채팅, 이력서·채용공고 검색, 면접 평가, 꼬리질문 |
| 관련 ADR | ADR-054 (RAG MMR), ADR-066 (질문 중복 방지) |
🎯 컨텍스트 (Context)
리트리버는 사용자 질문을 벡터로 변환한 뒤 코사인 유사성 또는 MMR(Maximal Marginal Relevance) 같은 수학적 방법으로 관련 문서를 검색한다. 현재는 **밀집 리트리버(Chroma + MMR)**만 사용 중이며, 키워드 기반 보완·검색 결과 압축·재정렬은 적용되지 않았다.
현재 상태:
app/domain/chat/chains.py: 컬렉션별max_marginal_relevance_search()(MMR), 단일 vectorstore 시similarity_score_threshold리트리버.app/services/vectordb_service.py,app/infrastructure/vectordb/chroma.py: Chroma 쿼리 시 L2 거리 기반 검색, 거리를1/(1+distance)로 유사도 변환.- 검색된 문서를 그대로 컨텍스트에 넣어 LLM에 전달 → 무관한 텍스트 포함 시 할루시네이션·토큰 낭비 가능성.
- 10개 이상 문서를 넣을 경우 모델 성능 저하가 보고됨 — 재정렬(rerank) 미적용.
요구사항:
- 이력서·채용공고 등 키워드(직무명, 스킬) 중요도가 높은 검색에 희소 리트리버(TF-IDF/BM25) 보완 검토.
- 검색 결과 중 질문과 무관한 부분 제거(문서 압축기)로 할루시네이션·토큰 절감.
- 희소 + 밀집 결합(앙상블 리트리버) 시 비율(예: 3:7) 설정 및 동적 실험(ConfigurableField).
- 긴 문맥 재정렬로 상위 N개만 선별해 컨텍스트 구성, 꼬리질문 등에서 더 많은 문서를 안정적으로 활용.
🔍 선택지 분석 (Options)
Option 1: 단계적 도입 (희소 → 앙상블 → 압축·재정렬) ⭐
우선순위를 두고 단계별로 도입한다.
| 단계 | 내용 | 적용 대상 |
|---|---|---|
| 1 | BM25(희소) 추가 — 이력서·채용공고 텍스트로 BM25 인덱스 구축, 키워드 매칭 보완 | 이력서/채용공고 평가·매칭 |
| 2 | 앙상블 리트리버 — LangChain EnsembleRetriever로 밀집(Chroma) + 희소(BM25) 결합, 가중치(예: 0.7/0.3) 설정. ConfigurableField로 invoke 시점에 가중치 변경 가능하게 하여 A/B 테스트 |
RAG 채팅 |
| 3 | 문서 압축기 — LLMChainExtractor 또는 유사 방식으로 검색 문서에서 질문 관련 문장만 추출 후 컨텍스트에 주입. 호출·토큰 증가 있으므로 상위 k 축소 후 적용 검토 |
RAG 채팅(선택 경로) |
| 4 | 긴 문맥 재정렬 — rerank(예: LangChain ContextualCompressionRetriever 또는 별도 reranker)로 검색 결과 순위 재조정 후 상위 N개만 context에 넣음. RunnableLambda로 question → retriever → rerank → context 흐름 구성, 꼬리질문 등에서 컨텍스트 품질 확보로 질문 수 확장 가능 |
꼬리질문·채팅 체인 |
| 장점 | 단점 |
|---|---|
| 키워드·문맥 둘 다 활용해 검색 품질 향상 | BM25 인덱스 구축·갱신 부담 |
| 압축·재정렬로 할루시네이션·성능 저하 완화 | 문서 압축 시 LLM 호출 추가 → 비용·지연 |
| ConfigurableField로 가중치 실험 용이 | 단계별 구현·검증 시간 필요 |
Option 2: 현행 유지 (밀집 리트리버 + MMR만)
기존 Chroma + MMR만 유지하고, 희소·앙상블·압축·재정렬을 도입하지 않는다.
| 장점 | 단점 |
|---|---|
| 변경 없음, 운영 단순 | 키워드 중요도가 높은 검색에서 한계 유지 |
| 추가 비용·지연 없음 | 10개 이상 문서 시 성능 저하·할루시네이션 리스크 |
| - | 꼬리질문 등에서 컨텍스트 효율화 기회 상실 |
Option 3: 재정렬·압축만 우선 도입
희소/앙상블 없이 문서 압축기와 재정렬만 먼저 적용한다.
| 장점 | 단점 |
|---|---|
| 밀집 리트리버만으로도 컨텍스트 품질·길이 제어 가능 | 키워드 보완 효과 없음 |
| 구현 범위가 상대적으로 작음 | 압축 시 LLM 호출 증가는 동일 |
| - | BM25 도입 시 앙상블로 확장하려면 추가 작업 필요 |
결정 (Decision)
Option 1 (단계적 도입)을 선택한다.
근거:
- 키워드·문맥 병행: 이력서·채용공고는 직무명·스킬 키워드가 중요하므로 희소 리트리버(BM25) 보완이 검색 정확도에 기여할 수 있음. 밀집만으로는 키워드 매칭이 약할 수 있음.
- 앙상블 비율 실험: ConfigurableField로 희소:밀집 가중치를 동적으로 바꿀 수 있으면, 서비스별·유저별 최적 비율을 실험하기 좋음.
- 할루시네이션·성능: 문서 압축과 재정렬로 "질문과 무관한 텍스트"를 줄이고 상위 N개만 넣으면, 할루시네이션 감소와 10개 이상 문서 시 성능 저하 완화에 도움이 됨.
- 꼬리질문 확장: 재정렬로 컨텍스트 품질을 높이면, 동일 토큰 예산으로 더 많은 꼬리질문을 허용하는 정책을 검토하기 쉬움. "꼬리질문 개수" 자체는 비즈니스 규칙으로 정하고, 재정렬은 그에 맞는 컨텍스트를 만드는 수단으로 둠.
구현 방향 (참고)
| 구분 | 내용 |
|---|---|
| 희소 리트리버 | LangChain BM25Retriever 또는 별도 TF-IDF/BM25 인덱스. 이력서·채용공고 텍스트(청크)로 구축. |
| 앙상블 | EnsembleRetriever(dense + sparse), 가중치 파라미터. ConfigurableField로 invoke() 시 가중치 주입. |
| 문서 압축기 | LLMChainExtractor 또는 ContextualCompressionRetriever + LLM. 상위 k 축소 후 적용 권장. |
| 재정렬 | retriever 출력 → rerank(예: rerank_documents 유틸 또는 서드파티 reranker) → 상위 N개만 context에 매핑. RunnableLambda로 question → retriever → rerank → context, 이후 context/question/language를 프롬프트 변수로 전달. |
| 적용 경로 | RAG 채팅(chains.py, rag_service.py), 꼬리질문 컨텍스트 구성, (선택) 면접 평가 시 참조 문서 검색. |
Consequences (결과)
긍정적 영향:
- 희소·밀집·앙상블로 키워드와 문맥을 함께 활용해 이력서·채용공고 검색 품질 향상 가능.
- 문서 압축·재정렬로 할루시네이션 감소, 긴 문맥 시 성능 저하 완화.
- ConfigurableField 기반 가중치로 다양한 설정 실험 가능.
- 꼬리질문 등에서 컨텍스트 품질 확보로 질문 수·깊이 확장 검토에 유리.
부정적 영향 / 트레이드오프:
- BM25 인덱스 구축·갱신 및 앙상블/압축/재정렬 파이프라인으로 복잡도·지연·비용 증가. 단계별 도입으로 완화.
- 문서 압축 시 LLM 호출 추가로 비용·지연이 늘 수 있음. k 축소·선택 경로만 적용으로 제어.
후속 작업:
- BM25(또는 TF-IDF) 리트리버 및 인덱스 구축 (이력서·채용공고 텍스트) —
rag_service.py에서 사용자 문서로 BM25 인덱스 구축,rag_bm25_max_docs상한 적용 - EnsembleRetriever + ConfigurableField 가중치 연동 —
retrieve_context(..., dense_weight=, sparse_weight=)로 invoke 시점 가중치 오버라이드 - 문서 압축기(LLMChainExtractor 등) 적용 경로·상위 k 결정 —
rag_use_compressor,rag_compressor_top_k설정, 병합·재정렬 후 압축 - 재정렬 단계 추가 및 question → retriever → rerank → context 체인 구성 —
_rerank()단계 명시, 흐름: question → retriever → RRF merge → rerank(top_k) → [compressor] → context - 꼬리질문 컨텍스트에 재정렬 반영 후 질문 수 정책 검토 — 아래 정책 노트 참고
꼬리질문 컨텍스트·질문 수 정책 (후속 반영)
- 채팅·꼬리질문 경로는 모두
RAGService.retrieve_context()를 사용하므로, 재정렬(rag_rerank_top_k) 및 선택적 압축이 이미 반영됨. - 질문 수 정책: 재정렬로 컨텍스트 품질이 올라갔으므로, 동일 토큰 예산으로 꼬리질문 개수·깊이를 늘리는 정책 검토가 가능함. 구체적 수치(예: 3뎁스 → 4뎁스)는 비즈니스·평가 지표에 따라 결정.
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-20 | 초기 작성 (희소·밀집·앙상블·문서 압축·재정렬 단계적 도입 전략 수립) |
| 2026-02-20 | 후속 작업 반영: BM25 상한, invoke 시 가중치, 문서 압축기, 재정렬 단계, 꼬리질문 정책 노트 |
ADR-070: LCEL 전환 비교 + 부모 문서 리트리버 + 다중 쿼리 리트리버 도입 계획
📋 메타데이터
| 항목 | 내용 |
|---|---|
| 상태 | 📋 제안됨 (Proposed) |
| 작성일 | 2026-02-20 |
| 결정자 | AI팀 |
| 관련 기능 | LLM 체인 구성, RAG 검색 파이프라인, 이력서·포트폴리오 맥락 유지 |
| 관련 ADR | ADR-052 (프롬프트 관리 진화), ADR-053 (채팅 history LCEL 경로), ADR-054 (RAG MMR), ADR-067 (LangChain 모듈 현황), ADR-069 (앙상블 리트리버) |
🎯 컨텍스트 (Context)
본 ADR은 세 가지 독립적이지만 상호 연관된 아키텍처 결정을 다룬다.
- LCEL 전환: 현재 프로젝트는 레거시
LLMChain클래스 대신 LCEL(prompt | llm | parser)을 사용하고 있으나, 이 전환에 대한 비교·선택 사유가 문서화되지 않았다. - 부모 문서 리트리버: 현재
chunk_size=2000으로 분할된 청크 단위로만 검색·반환하고 있어, 이력서·포트폴리오의 큰 맥락이 유실될 수 있다. - 다중 쿼리 리트리버: 사용자의 모호한 질문을 그대로 단일 쿼리로 검색하여, 관련 문서 누락 가능성이 있다.
Part 1: 레거시 LLMChain → LCEL 전환
배경
LangChain은 0.1.17 버전부터 LLMChain 클래스를 deprecated로 선언하고, LCEL(LangChain Expression Language)을 공식 권장 방식으로 전환하였다.
본 프로젝트는 초기부터 LCEL을 채택하여 LLMChain을 사용한 이력이 없으나, 이 선택의 사유와 비교가 문서화되지 않았다.
LLMChain vs LCEL 상세 비교
| 비교 항목 | LLMChain (레거시) | LCEL (현재 도입) |
|---|---|---|
| 문법 | chain = LLMChain(llm=llm, prompt=prompt) |
chain = prompt | llm | parser |
| 실행 | chain.run(input) 또는 chain.apredict(input) |
chain.ainvoke(input) |
| 스트리밍 | 네이티브 미지원, 별도 구현 필요 | chain.astream(input) 네이티브 지원 |
| 비동기 | .apredict() 제한적 비동기 |
ainvoke(), astream() 완전 비동기 |
| Fallback | 미지원 | llm.with_fallbacks([...]) 네이티브 |
| 파라미터 바인딩 | 인스턴스 생성 시 고정 | llm.bind(temperature=0.7) 동적 변경 |
| 타입 안전성 | dict 기반, 런타임 에러 | Runnable 프로토콜, IDE 자동완성 |
| 체인 합성 | 수동으로 체인 연결 구현 | | 연산자로 자유롭게 합성·교체 |
| 중간 결과 접근 | 어려움 | RunnablePassthrough, RunnableParallel 활용 |
| LangChain 공식 지위 | deprecated (0.1.17+) | 권장 방식 |
| Output Parser | 별도 후처리 필요 | 체인에 | parser 로 통합 |
코드 비교 — 동일 기능 구현
LLMChain (레거시 방식):
from langchain.chains import LLMChain
from langchain.prompts import ChatPromptTemplate
from langchain_google_genai import ChatGoogleGenerativeAI
# 1) 프롬프트 생성
prompt = ChatPromptTemplate.from_messages([
("system", "당신은 면접 전문가입니다."),
("human", "{question}"),
])
# 2) LLM 생성
llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash")
# 3) 체인 생성 — LLMChain 래퍼 필요
chain = LLMChain(llm=llm, prompt=prompt)
# 4) 실행 — 스트리밍 불가
result = chain.run(question="자기소개를 해주세요")
# 5) Fallback, 동적 temperature 변경 — 불가능
# 6) Output Parser — 별도 후처리 필요
LCEL (현재 프로젝트 방식):
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_google_genai import ChatGoogleGenerativeAI
# 1) 프롬프트 생성
prompt = ChatPromptTemplate.from_messages([
("system", "당신은 면접 전문가입니다."),
("human", "{question}"),
])
# 2) LLM 생성 + Fallback + 동적 바인딩
llm = ChatGoogleGenerativeAI(model="gemini-2.0-flash", google_api_key=key1)
llm = llm.with_fallbacks([
ChatGoogleGenerativeAI(model="gemini-2.0-flash", google_api_key=key2)
])
llm = llm.bind(temperature=0.7, max_output_tokens=2048)
# 3) 체인 생성 — 파이프 연산자로 합성
chain = prompt | llm | StrOutputParser()
# 4) 비동기 실행
result = await chain.ainvoke({"question": "자기소개를 해주세요"})
# 5) 스트리밍 실행
async for chunk in chain.astream({"question": "자기소개를 해주세요"}):
print(chunk, end="")
현재 코드에서의 LCEL 활용 현황
| 파일 | 메서드/위치 | LCEL 패턴 | 활용 기능 |
|---|---|---|---|
langchain_wrapper.py |
create_chain() |
prompt | llm | self._output_parser |
면접 질문 생성, RAG QnA |
langchain_wrapper.py |
create_chain_from_yaml() |
prompt | llm | self._output_parser |
YAML 기반 프롬프트 체인 |
langchain_wrapper.py |
create_chat_chain() |
prompt | llm | self._output_parser |
채팅 history + MessagesPlaceholder |
chains.py |
RAGChain (6곳) | _rag_prompt | llm | parser |
RAG 채팅, 분석, 팔로우업 |
rag_service.py |
면접/채팅 경로 | chain.ainvoke() 10+회, chain.astream() 5회 |
비동기 실행 + 스트리밍 |
langchain_wrapper.py |
LLM 초기화 | llm.with_fallbacks([...]) |
멀티 API 키 장애 대응 |
langchain_wrapper.py |
체인 생성 6곳 | llm.bind(temperature=, max_output_tokens=) |
동적 파라미터 설정 |
선택 사유 — 왜 LCEL인가
- LangChain 공식 deprecation: 0.1.17+에서
LLMChaindeprecated → 향후 제거 예정. LCEL이 공식 권장. - 스트리밍 필수: 면접 질문/채팅 응답을 실시간 스트리밍(
astream)해야 하며,LLMChain은 네이티브 스트리밍 미지원. - 멀티 API 키 Fallback:
with_fallbacks()로 API 키 1개 실패 시 자동 전환 —LLMChain에서는 불가능. - 동적 파라미터:
llm.bind(temperature=, max_output_tokens=)로 같은 LLM 인스턴스를 다른 설정으로 재사용 —LLMChain은 생성 시 고정. - 합성 용이성:
|연산자로 Prompt → LLM → Parser를 자유롭게 교체·확장 가능. 향후 MultiQueryRetriever, ParentDocumentRetriever 등과의 통합이 용이.
Part 2: 부모 문서 리트리버 (ParentDocumentRetriever) 도입 계획
배경
부모 문서 리트리버는 문서를 작은 자식 청크로 나눠서 검색 정확도를 높이고, 실제 반환은 **부모 문서(원본 또는 큰 청크)**로 하여 큰 맥락을 유지하는 리트리버이다.
원본 이력서 (부모 문서)
├── 자식 청크 1 (400자) ← 검색 대상
├── 자식 청크 2 (400자) ← 검색 대상
├── 자식 청크 3 (400자) ← 검색 대상
└── ...
검색: 자식 청크로 정밀 매칭 → 반환: 부모 문서 전체 → 큰 맥락 유지
현재 문제
| 항목 | 현재 상태 | 문제 |
|---|---|---|
| 청크 크기 | chunk_size=2000, chunk_overlap=200 |
2000자 청크만 반환 → 이력서 전체 맥락(프로젝트 간 관계, 기술 스택 전체 그림) 유실 |
| 검색 단위 | 2000자 청크로 검색 | 큰 청크는 여러 주제를 포함하여 검색 정확도 저하 가능 |
| 반환 단위 | 검색된 청크만 반환 | 이력서에서 벗어난 답변 생성 가능 (해당 청크에 없는 정보 누락) |
| 메타데이터 | parent_document_id, chunk_index, total_chunks 이미 존재 |
활용되지 않음 — 부모 문서 복원 로직 없음 |
도입 구조
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 자식 분할기 — 작은 청크로 검색 정확도 향상
child_splitter = RecursiveCharacterTextSplitter(
chunk_size=400, # 검색용 작은 청크
chunk_overlap=50,
)
# 부모 분할기 — 큰 청크 또는 원본 문서 유지
parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=2000, # 기존 청크 크기 유지 (반환용)
chunk_overlap=200,
)
# docstore — 부모 문서 저장소
docstore = InMemoryStore()
# ParentDocumentRetriever 구성
retriever = ParentDocumentRetriever(
vectorstore=chroma_vectorstore, # 자식 청크 벡터 저장
docstore=docstore, # 부모 문서 저장
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
# 문서 추가 — 자동으로 부모/자식 분할 및 저장
retriever.add_documents(documents)
# 검색 — 자식 청크로 매칭 후 부모 문서 반환
results = retriever.invoke("React 프로젝트 경험")
# → 이력서의 해당 프로젝트 전체 섹션이 반환됨 (400자가 아닌 2000자)
기대 효과
| 효과 | 설명 |
|---|---|
| 이력서 맥락 유지 | 자식 청크(400자)로 정밀 검색하되, 부모 문서(2000자)를 반환하여 프로젝트 전체 설명 포함 |
| 이력서에서 벗어나지 않은 답변 | 면접 질문 생성 시 이력서 전체 맥락을 참조하여 지원자 경험에 근거한 질문 생성 가능 |
| 검색 정확도 향상 | 작은 청크(400자)는 단일 주제를 다루므로 쿼리-문서 유사도가 높아짐 |
| 기존 인프라 활용 | text_splitter_service.py의 parent_document_id 메타데이터와 호환 |
구현 계획
| Phase | 파일 | 내용 |
|---|---|---|
| 1 | app/services/text_splitter_service.py |
child_splitter (chunk_size=400) 추가 |
| 2 | app/services/vectordb_service.py |
InMemoryStore + ParentDocumentRetriever 팩토리 |
| 3 | app/domain/chat/chains.py |
RAGChain에서 ParentDocumentRetriever 사용 경로 추가 |
| 4 | app/config/settings.py |
parent_chunk_size, child_chunk_size 설정 추가 |
Part 3: 다중 쿼리 리트리버 (MultiQueryRetriever) 도입 계획
배경
다중 쿼리 리트리버는 사용자의 불친절한/모호한 질문을 LLM으로 여러 관점의 쿼리로 변환한 뒤, 각 쿼리로 검색하여 **결과 합집합(deduplicate)**을 반환하는 후처리 리트리버이다.
사용자 질문: "이력서 좀 봐줘"
↓ LLM이 다중 쿼리 생성
쿼리 1: "지원자의 주요 기술 스택과 프레임워크 경험"
쿼리 2: "참여한 프로젝트명과 담당 역할 및 성과"
쿼리 3: "채용공고 요구사항과 일치하는 경력 사항"
↓ 각각 검색 → 합집합
더 포괄적인 컨텍스트 확보
현재 문제
| 항목 | 현재 상태 | 문제 |
|---|---|---|
| 쿼리 처리 | 사용자 질문 그대로 단일 쿼리로 검색 | "이력서 봐줘" → 모호한 쿼리로 관련 문서 누락 가능 |
| 검색 관점 | 단일 관점(문맥 유사도)만 사용 | 기술/경험/직무 등 다양한 관점의 문서를 놓칠 수 있음 |
| 쿼리 품질 | 사용자 입력 품질에 의존 | 불친절한 질문에 대한 검색 품질 저하 |
LCEL 체인에서의 MultiQueryRetriever 활용
from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import LineListOutputParser
# 1) 다중 쿼리 생성 프롬프트 — 질문 개수와 생성 방식 가이드
MULTI_QUERY_PROMPT = ChatPromptTemplate.from_messages([
("system", """당신은 AI 취업 코칭 서비스의 검색 쿼리 전문가입니다.
사용자의 질문을 3가지 다른 관점에서 검색 쿼리로 재작성하세요.
각 쿼리는 한 줄에 하나씩 출력하세요."""),
("human", """다음 질문을 3가지 관점에서 재작성하세요:
1. 기술 역량 중심 (사용 기술, 프레임워크, 언어 관점)
2. 경험/프로젝트 중심 (프로젝트명, 담당 역할, 성과 관점)
3. 직무 적합성 중심 (채용공고 요구사항, 자격 요건 관점)
원래 질문: {question}"""),
])
# 2) 쿼리 생성 체인 — LCEL 파이프로 구성
query_chain = MULTI_QUERY_PROMPT | llm | LineListOutputParser()
# 3) MultiQueryRetriever 구성
multi_query_retriever = MultiQueryRetriever(
retriever=base_retriever, # 기존 MMR/앙상블 리트리버
llm_chain=query_chain, # LCEL 체인 사용
parser_key="lines",
)
# 4) 실행 — 3개 쿼리 각각 검색 → 합집합(deduplicate) → 반환
results = await multi_query_retriever.ainvoke("이력서 좀 봐줘")
다중 쿼리 생성 예시
입력: "이력서 좀 봐줘"
| 관점 | 생성된 쿼리 |
|---|---|
| 기술 역량 | "지원자의 주요 프로그래밍 언어, 프레임워크, 클라우드 경험" |
| 경험/프로젝트 | "참여한 프로젝트 목록, 담당 역할, 정량적 성과" |
| 직무 적합성 | "채용공고 필수 요구사항과 지원자 경력 일치 여부" |
입력: "면접 준비 어떻게 해?"
| 관점 | 생성된 쿼리 |
|---|---|
| 기술 역량 | "지원자 기술 스택 기반 예상 기술 면접 질문" |
| 경험/프로젝트 | "프로젝트 경험 기반 행동 면접(STAR) 준비 포인트" |
| 직무 적합성 | "채용공고 직무 기술서 기반 면접 예상 질문" |
기대 효과
| 효과 | 설명 |
|---|---|
| 검색 커버리지 확대 | 단일 쿼리 → 3개 쿼리로 다양한 관점의 문서 포착 |
| 불친절한 질문 보완 | "이력서 봐줘" 같은 모호한 질문도 구체적 쿼리로 변환 |
| 기존 리트리버와 호환 | base_retriever에 MMR/앙상블 등 기존 리트리버 그대로 사용 |
| LCEL 체인 통합 | 쿼리 생성 프롬프트를 LCEL 체인으로 구성하여 일관된 아키텍처 유지 |
구현 계획
| Phase | 파일 | 내용 |
|---|---|---|
| 1 | app/prompts/templates/chat/multi_query.yaml |
다중 쿼리 생성 프롬프트 (3개 관점 + 가이드) |
| 2 | app/domain/chat/chains.py |
MultiQueryRetriever 팩토리 + RAGChain 통합 |
| 3 | app/services/rag_service.py |
다중 쿼리 경로 추가 (설정으로 on/off) |
| 4 | app/config/settings.py |
rag_use_multi_query, rag_multi_query_count 설정 |
결정 (Decision)
Part 1 — LCEL 전환
LCEL을 유지한다. LLMChain 도입은 하지 않는다.
근거:
- LangChain 공식 deprecation — 향후 LLMChain 제거 예정
- 스트리밍(
astream), Fallback(with_fallbacks), 동적 바인딩(bind) 등 현재 프로젝트에서 활발히 사용하는 기능이 LCEL에서만 지원 - 코드베이스 전체가 이미 LCEL 패턴으로 통일되어 있어 변경 비용 없음
Part 2 — 부모 문서 리트리버
도입을 제안한다.
근거:
- 이력서·포트폴리오의 큰 맥락 유지가 면접 질문 품질에 직접적 영향
- 자식 청크(400자)로 검색 정확도 향상 + 부모 문서(2000자)로 맥락 유지 — 양쪽 이점 확보
- 기존
parent_document_id메타데이터 인프라 활용 가능
Part 3 — 다중 쿼리 리트리버
도입을 제안한다.
근거:
- 사용자의 불친절한 질문을 LLM이 다양한 관점에서 재작성 → 검색 커버리지 확대
- LCEL 체인으로 쿼리 생성 프롬프트를 구성하여 질문 개수·방식을 유연하게 제어
- 기존 리트리버(MMR/앙상블)와 호환 —
base_retriever로 감싸기만 하면 됨
Consequences (결과)
긍정적 영향:
- LCEL 전환 사유가 명확히 문서화되어 팀 온보딩 시 "왜 LLMChain을 안 쓰는가" 질문에 대응 가능
- 부모 문서 리트리버로 이력서 맥락 유지 → 면접 질문이 이력서에서 벗어나지 않는 효과
- 다중 쿼리 리트리버로 모호한 질문에도 포괄적인 검색 결과 확보
- 세 가지 모두 LCEL 기반이므로 아키텍처 일관성 유지
부정적 영향 / 트레이드오프:
- 부모 문서 리트리버:
InMemoryStore에 부모 문서를 별도 저장하므로 메모리 사용량 증가. 문서가 많으면 Redis 기반 store 검토 필요. - 다중 쿼리 리트리버: 쿼리 생성에 LLM 호출 1회 추가 → 지연·비용 증가. 캐싱 또는 on/off 설정으로 제어 가능.
- 두 리트리버 동시 도입 시 파이프라인 복잡도 증가 — 단계별 도입으로 완화.
후속 작업:
-
text_splitter_service.py에 자식 분할기(chunk_size=400) 추가 -
vectordb_service.py에ParentDocumentRetriever+InMemoryStore팩토리 구성 -
multi_query.yaml프롬프트 템플릿 작성 (3개 관점 가이드) -
chains.py에MultiQueryRetriever경로 추가 -
settings.py에parent_chunk_size,child_chunk_size,rag_use_multi_query설정 추가 - RAG 채팅에서 부모 문서 리트리버 + 다중 쿼리 리트리버 통합 테스트
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-20 | 초기 작성 (LCEL 전환 비교, 부모 문서 리트리버·다중 쿼리 리트리버 도입 계획) |