[AI] 13. ADR 066‐070 ‐ 리트리버 전략 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki

ADR 066-070: 면접 질문 중복 방지 및 품질 개선

작성일: 2026-02-19
상태: 승인됨 (Accepted)


📚 목차


ADR-066: 기술면접 질문 중복 방지 — 임베딩 유사도 + 답변 품질 평가

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-02-19
결정자 AI팀
관련 기능 기술면접, 꼬리질문 판단, VectorDB 임베딩
관련 ADR ADR-065 (임베딩 캐시), ADR-054 (RAG 리트리버)

🎯 컨텍스트 (Context)

현재 기술면접 시스템은 같은 채팅 세션 내에서 이미 완벽하게 답변된 주제와 유사한 질문이 다시 출제될 수 있는 문제가 있다.

현재 상태:

  • app/domain/interview/graph.pyevaluate_answer()에서 LLM이 should_continue: true/false만 반환
  • should_continue: false의 의미가 모호함:
    • ✅ "답변이 완벽해서 더 파고들 필요 없음" → 유사 질문 스킵 대상
    • ❌ "답변을 못 해서 더 물어봐도 의미 없음" → 유사 질문 다시 출제 가능
  • 두 경우가 동일하게 question["is_completed"] = True로 처리되어, 답변 품질에 따른 후속 처리 분기가 불가능

문제 시나리오:

질문 1: "React의 가상 DOM 동작 원리를 설명해주세요" → 완벽히 답변
질문 3: "React의 렌더링 최적화 방법을 설명해주세요" → 유사 주제 중복 출제

요구사항:

  1. 꼬리질문 판단 시 LLM이 답변 품질 등급을 함께 반환하도록 개선
  2. 같은 세션 내에서 "완벽/양호 답변"한 질문과 유사도가 높은 새 질문은 스킵
  3. "불량 답변"한 질문은 유사 질문이 다시 출제될 수 있도록 허용
  4. 적용 범위: 같은 채팅 세션(같은 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_qualityexcellent 또는 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 답변 품질 등급 + 임베딩 유사도 필터링)을 선택합니다.

근거:

  1. 정밀한 분류: answer_quality 등급으로 "잘 답해서 넘어감" vs "못 답해서 넘어감"을 명확히 구분. 기존 should_continue 단독 사용의 모호함을 해소.
  2. 의미적 유사도: 임베딩 기반 cosine similarity로 "React 가상 DOM" ↔ "React 렌더링 최적화" 같은 의미적 유사성을 감지. 키워드 매칭(Option 2)으로는 불가능.
  3. 비용 효율: CacheBackedEmbeddings(ADR-065) 덕분에 동일 질문 텍스트의 재임베딩 비용이 0. 세션 내 질문 수가 최대 5개라 비교 연산도 미미.
  4. 범위 제한: 같은 세션(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 InterviewStatemastered_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 파싱 로직 추가
  • InterviewStatemastered_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_questionsask_questionevaluate_answergenerate_followupnext_questiongenerate_report) app/domain/interview/graph.py
토론 평가 그래프 6개 (load_gemini_resultgpt4o_analyzecompare_analysesdebate_roundsynthesize_finalmerge_results) app/domain/evaluation/debate_graph.py

두 그래프 모두 add_node, add_edge, add_conditional_edges, END를 활용한 조건부 분기 로직을 포함한다.

⚠️ 5. Memory — 의도적 미도입

항목 내용
상태 ❌ 미도입 (의도적)
LangChain Memory 클래스 사용 없음 (ConversationBufferMemory, ChatMessageHistory 등 미사용)

미도입 사유:

  1. 세션 관리 요구사항 불일치: LangChain Memory는 단일 대화의 메시지 히스토리를 LLM 컨텍스트에 자동 주입하는 데 초점을 맞추고 있으나, 본 시스템은 면접/채팅/평가 등 모드별로 서로 다른 세션 라이프사이클을 관리해야 함.

  2. 커스텀 세션 스토어로 충분: BaseSessionStore 추상 클래스 + InMemorySessionStore / RedisSessionStore 구현체로 세션 CRUD, TTL, 메타데이터 관리를 이미 구축. LangChain Memory 래핑 시 오히려 복잡도만 증가.

  3. 면접 상태 머신과 비호환: InterviewState(TypedDict)questions, current_index, followup_depth, is_completed 등 면접 고유 상태를 포함하며, LangChain Memory의 메시지 리스트 패러다임과 맞지 않음.

  4. 성능/비용 최적화: 면접 리포트 생성 시 전체 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()

현재 방식의 문제점:

  1. 수동 계측 부담: 모든 LLM 호출 지점(llm_service.py 7곳, vllm_service.py, ocr_service.py)에서 _langfuse_trace_and_generation() 래퍼를 명시적으로 호출해야 함. 새로운 LLM 호출을 추가할 때 추적 코드 누락 위험.

  2. LCEL 체인 내부 불투명: prompt | llm | parser 체인에서 각 단계별 실행 시간, 토큰 사용량을 개별 추적할 수 없음. 체인 전체를 하나의 generation으로만 기록.

  3. LangGraph 노드 추적 불가: 면접 그래프(6노드), 토론 평가 그래프(6노드)의 노드별 실행 흐름과 소요 시간을 자동 추적할 수 없음.

  4. 코드 중복: 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 도입)을 선택한다.

근거:

  1. 자동 추적: CallbackHandler를 config에 전달하면 모든 LLM 호출, 체인 단계, 그래프 노드가 자동 추적됨. 수동 계측의 누락 위험 제거.
  2. 기존 인프라 활용: 이미 운영 중인 Langfuse 서버를 그대로 사용. 추가 인프라 비용 없음.
  3. 점진적 마이그레이션: 기존 직접 SDK 코드를 즉시 삭제하지 않고, CallbackHandler와 병행 운영 후 안정화되면 제거.
  4. 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.pyget_langfuse_callback_handler() 팩토리 함수 추가
  • chains.pyainvoke()/astream() 호출에 callbacks config 전달
  • graph.py, debate_graph.pyapp.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 (단계적 도입)을 선택한다.

근거:

  1. 키워드·문맥 병행: 이력서·채용공고는 직무명·스킬 키워드가 중요하므로 희소 리트리버(BM25) 보완이 검색 정확도에 기여할 수 있음. 밀집만으로는 키워드 매칭이 약할 수 있음.
  2. 앙상블 비율 실험: ConfigurableField로 희소:밀집 가중치를 동적으로 바꿀 수 있으면, 서비스별·유저별 최적 비율을 실험하기 좋음.
  3. 할루시네이션·성능: 문서 압축과 재정렬로 "질문과 무관한 텍스트"를 줄이고 상위 N개만 넣으면, 할루시네이션 감소와 10개 이상 문서 시 성능 저하 완화에 도움이 됨.
  4. 꼬리질문 확장: 재정렬로 컨텍스트 품질을 높이면, 동일 토큰 예산으로 더 많은 꼬리질문을 허용하는 정책을 검토하기 쉬움. "꼬리질문 개수" 자체는 비즈니스 규칙으로 정하고, 재정렬은 그에 맞는 컨텍스트를 만드는 수단으로 둠.

구현 방향 (참고)

구분 내용
희소 리트리버 LangChain BM25Retriever 또는 별도 TF-IDF/BM25 인덱스. 이력서·채용공고 텍스트(청크)로 구축.
앙상블 EnsembleRetriever(dense + sparse), 가중치 파라미터. ConfigurableFieldinvoke() 시 가중치 주입.
문서 압축기 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은 세 가지 독립적이지만 상호 연관된 아키텍처 결정을 다룬다.

  1. LCEL 전환: 현재 프로젝트는 레거시 LLMChain 클래스 대신 LCEL(prompt | llm | parser)을 사용하고 있으나, 이 전환에 대한 비교·선택 사유가 문서화되지 않았다.
  2. 부모 문서 리트리버: 현재 chunk_size=2000으로 분할된 청크 단위로만 검색·반환하고 있어, 이력서·포트폴리오의 큰 맥락이 유실될 수 있다.
  3. 다중 쿼리 리트리버: 사용자의 모호한 질문을 그대로 단일 쿼리로 검색하여, 관련 문서 누락 가능성이 있다.

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인가

  1. LangChain 공식 deprecation: 0.1.17+에서 LLMChain deprecated → 향후 제거 예정. LCEL이 공식 권장.
  2. 스트리밍 필수: 면접 질문/채팅 응답을 실시간 스트리밍(astream)해야 하며, LLMChain은 네이티브 스트리밍 미지원.
  3. 멀티 API 키 Fallback: with_fallbacks()로 API 키 1개 실패 시 자동 전환 — LLMChain에서는 불가능.
  4. 동적 파라미터: llm.bind(temperature=, max_output_tokens=)로 같은 LLM 인스턴스를 다른 설정으로 재사용 — LLMChain은 생성 시 고정.
  5. 합성 용이성: | 연산자로 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.pyparent_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 도입은 하지 않는다.

근거:

  1. LangChain 공식 deprecation — 향후 LLMChain 제거 예정
  2. 스트리밍(astream), Fallback(with_fallbacks), 동적 바인딩(bind) 등 현재 프로젝트에서 활발히 사용하는 기능이 LCEL에서만 지원
  3. 코드베이스 전체가 이미 LCEL 패턴으로 통일되어 있어 변경 비용 없음

Part 2 — 부모 문서 리트리버

도입을 제안한다.

근거:

  1. 이력서·포트폴리오의 큰 맥락 유지가 면접 질문 품질에 직접적 영향
  2. 자식 청크(400자)로 검색 정확도 향상 + 부모 문서(2000자)로 맥락 유지 — 양쪽 이점 확보
  3. 기존 parent_document_id 메타데이터 인프라 활용 가능

Part 3 — 다중 쿼리 리트리버

도입을 제안한다.

근거:

  1. 사용자의 불친절한 질문을 LLM이 다양한 관점에서 재작성 → 검색 커버리지 확대
  2. LCEL 체인으로 쿼리 생성 프롬프트를 구성하여 질문 개수·방식을 유연하게 제어
  3. 기존 리트리버(MMR/앙상블)와 호환 — base_retriever로 감싸기만 하면 됨

Consequences (결과)

긍정적 영향:

  • LCEL 전환 사유가 명확히 문서화되어 팀 온보딩 시 "왜 LLMChain을 안 쓰는가" 질문에 대응 가능
  • 부모 문서 리트리버로 이력서 맥락 유지 → 면접 질문이 이력서에서 벗어나지 않는 효과
  • 다중 쿼리 리트리버로 모호한 질문에도 포괄적인 검색 결과 확보
  • 세 가지 모두 LCEL 기반이므로 아키텍처 일관성 유지

부정적 영향 / 트레이드오프:

  • 부모 문서 리트리버: InMemoryStore에 부모 문서를 별도 저장하므로 메모리 사용량 증가. 문서가 많으면 Redis 기반 store 검토 필요.
  • 다중 쿼리 리트리버: 쿼리 생성에 LLM 호출 1회 추가 → 지연·비용 증가. 캐싱 또는 on/off 설정으로 제어 가능.
  • 두 리트리버 동시 도입 시 파이프라인 복잡도 증가 — 단계별 도입으로 완화.

후속 작업:

  • text_splitter_service.py에 자식 분할기(chunk_size=400) 추가
  • vectordb_service.pyParentDocumentRetriever + InMemoryStore 팩토리 구성
  • multi_query.yaml 프롬프트 템플릿 작성 (3개 관점 가이드)
  • chains.pyMultiQueryRetriever 경로 추가
  • settings.pyparent_chunk_size, child_chunk_size, rag_use_multi_query 설정 추가
  • RAG 채팅에서 부모 문서 리트리버 + 다중 쿼리 리트리버 통합 테스트

이력

날짜 변경 내용
2026-02-20 초기 작성 (LCEL 전환 비교, 부모 문서 리트리버·다중 쿼리 리트리버 도입 계획)