[AI] 11. ADR 056‐060 ‐ RAG 확장 - 100-hours-a-week/9-team-Devths-WIKI GitHub Wiki

ADR 056-060: 면접 데이터 적재·대화 맥락 관리 전략

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


📚 목차


ADR-056: 모의면접 결과 VectorDB 자동 적재 파이프라인

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-02-17
결정자 AI팀
관련 기능 면접 평가, VectorDB, RAG, 배치 적재
관련 ADR ADR-054 (RAG 리트리버 확장 + MMR), ADR-055 (캐시·질문 이력 저장소 전략)
관련 PR PR #131

🎯 컨텍스트 (Context)

모의면접 완료 후 생성되는 Q&A + 평가 데이터가 현재는 클라이언트 응답으로만 반환되고, 서버 측에는 저장되지 않는다. 이 데이터를 VectorDB에 적재하면 향후 RAG 검색 시 유사 질문·답변·피드백을 참고해 더 정확한 면접 준비 가이드를 제공할 수 있다.

현재 상태:

  • /evaluation/analyze API: 면접 Q&A를 입력받아 Gemini/GPT-4o로 평가 리포트를 SSE 스트리밍 반환
  • VectorDB interview_feedback 컬렉션: 외부 면접 데이터셋(interview_dataset_valid.json)만 임베딩되어 있음
  • 사용자 실제 면접 결과는 서버에 저장되지 않음
flowchart LR
    A["사용자 면접"] --> B["/evaluation/analyze"]
    B --> C["Gemini 리포트"]
    C --> D["SSE 응답 (클라이언트에만 반환)"]
    C -. "❌ 저장 없음" .-> E["VectorDB"]

문제점:

  • 사용자 면접 결과가 RAG에 활용되지 않아, 유사 질문·피드백 기반 추천이 불가
  • 과거 면접 데이터를 일괄 적재할 방법이 없음

요구사항:

  • 평가 완료 시 Q&A 데이터를 VectorDB interview_feedback 컬렉션에 자동 저장
  • 기존 API에 영향 없이 (SSE 응답 지연/실패 없이) best-effort로 저장
  • 과거 면접 데이터를 일괄 적재할 수 있는 CLI 도구 제공
  • 외부 데이터셋과 사용자 면접 결과를 메타데이터로 구분 (source 필드)

🔍 선택지 분석 (Options)

Option 1: 평가 API 내 비동기 자동 저장 + 배치 CLI (채택) ⭐

  • 평가 리포트 생성 완료 후 asyncio.create_task()로 비동기 VectorDB 적재 (fire-and-forget)
  • 과거 데이터는 scripts/ingest_interview_results.py CLI 스크립트로 일괄 적재
  • 별도의 새 API 엔드포인트 없음 → 백엔드 변경 불필요
flowchart LR
    A["면접 세션"] --> B["/evaluation/analyze"]
    B --> C["Gemini 리포트"]
    C --> D["SSE 응답"]
    C --> E["asyncio.create_task\n(best-effort)"]
    E --> F["InterviewIngestionService"]
    F --> G["VectorDB\ninterview_feedback"]
    H["과거 데이터 JSON"] --> I["ingest_interview_results.py"]
    I --> F
장점 단점
기존 API 변경 없음, 백엔드 조율 불필요 asyncio.create_task()가 SSE generator 안에서 호출 시 task 취소 가능성 (best-effort이므로 허용)
저장 실패 시 평가 응답에 영향 없음 매 평가 시 임베딩 API 호출 → 소량 비용 발생
CLI로 과거 데이터 일괄 적재 가능 -

Option 2: 별도 수동 트리거 API 추가

  • POST /ai/evaluation/ingest 엔드포인트를 추가해 백엔드에서 평가 완료 후 호출
장점 단점
저장 시점을 백엔드가 명시적으로 제어 백엔드에 새 API 호출 로직 추가 필요 → 팀 간 조율 필요
재시도 로직 구현 가능 별도 엔드포인트 유지보수 부담

Option 3: 평가 결과를 DB에 저장 후 별도 배치 잡으로 VectorDB 동기화

  • 평가 결과를 RDB에 먼저 저장하고, 주기적 배치 잡이 RDB → VectorDB로 동기화
장점 단점
데이터 유실 방지 (RDB 기록) 아키텍처 복잡도 증가 (RDB + 배치 스케줄러 + VectorDB)
배치 잡으로 일괄 처리 가능 현재 인프라 대비 과도한 설계

결정 (Decision)

Option 1 (평가 API 내 비동기 자동 저장 + 배치 CLI)을 선택합니다.

근거:

  1. 백엔드 조율 불필요: 기존 /evaluation/analyze API 내부에서 fire-and-forget으로 저장하므로 백엔드 코드 변경이 필요 없음.
  2. best-effort 설계: 저장 실패 시 로그만 남기고 평가 응답에는 영향 없음. 면접 데이터는 클라이언트에도 있으므로 유실 위험 낮음.
  3. 배치 CLI: 과거 데이터나 대량 적재가 필요할 때 JSON 파일을 준비해 서버에서 바로 실행 가능.
  4. 비용 관리: 임베딩 비용은 Q&A당 소량이며, 면접 빈도 대비 허용 가능한 수준.

구현 요약 (3.model 기준)

구분 파일 내용
스키마 app/schemas/ingestion.py InterviewResultDocument (Q&A + 점수/판정), InterviewIngestionInput (세션 단위 입력)
서비스 app/services/interview_ingestion_service.py Q&A → 텍스트 문서 변환, VectorDBService.add_texts() 호출, ID 생성(user_{id}_session_{id}_q{idx})
API 연동 app/api/routes/v2/evaluation.py _generate_analyze_stream() 완료 후 asyncio.create_task(_ingest_interview_qa_best_effort(request))
배치 CLI scripts/ingest_interview_results.py JSON 파일 → InterviewIngestionInput 파싱 → ingest_session() 호출
보안 evaluation.py L80 # SAST: request 기반 값은 로그에 넣지 않음 — Log Injection 방지

문서 포맷 예시:

[면접 질문] React의 Virtual DOM이 무엇인가요?
[지원자 답변] 실제 DOM과 비교해서 변경 사항만 반영하는 ...
[점수] 4/5
[판정] 적절
[평가 근거] 핵심 개념을 잘 이해하고 있음

메타데이터 구분:

필드 외부 데이터셋 사용자 면접 결과
source (없음) user_interview
user_id 0 (공용) 실제 사용자 ID
session_id (없음) 면접 세션 ID

Consequences (결과)

긍정적 영향:

  • 사용자 면접 결과가 VectorDB에 축적되어 RAG 기반 유사 피드백 검색 가능.
  • 향후 "비슷한 질문에 다른 지원자는 어떻게 답했는지" 등의 기능 구현 기반 마련.
  • 외부 데이터셋과 사용자 데이터가 동일 컬렉션에 source 메타데이터로 구분되어 통합 검색 가능.

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

  • asyncio.create_task()가 SSE generator 내부에서 호출되므로 커넥션 종료 시 task 취소 가능. 프로덕션에서는 FastAPI BackgroundTasks 도입 권장.
  • ChromaDB add() 사용 중이므로 동일 세션 재평가 시 중복 ID 에러 가능. upsert() 전환 권장.

후속 작업:

  • asyncio.create_task() → FastAPI BackgroundTasks 전환 검토
  • ChromaDB add()upsert() 전환 (재평가 시 안전한 덮어쓰기)
  • 적재된 사용자 면접 데이터를 RAG 검색에 활용하는 로직 구현
  • (선택) 적재 데이터 품질 모니터링 및 정제 파이프라인

이력

날짜 변경 내용
2026-02-17 초기 작성 (PR #131 기반, Option 1 비동기 자동 저장 + 배치 CLI 채택)

ADR-057: 면접·채팅 대화 맥락 관리 전략 (세션 기반 유지, 버퍼 메모리 미도입)

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-02-17
결정자 AI팀
관련 기능 면접 Q&A, 꼬리질문, 일반 채팅, 대화 맥락 유지
관련 ADR ADR-053 (채팅 history 반영), ADR-055 (캐시·질문 이력 저장소)

🎯 컨텍스트 (Context)

면접 모드와 일반 채팅 모드에서 대화 맥락(history)을 어떻게 관리할지 결정이 필요하다. LangChain이 제공하는 메모리 모듈(ConversationBufferMemory, ConversationTokenBufferMemory, ConversationSummaryMemory)을 도입하면 AI 서버가 자체적으로 대화 이력을 축적·관리할 수 있지만, 현재 구조에서 실제 필요한지 검토한다.

현재 상태:

모드 대화 맥락 관리 방식
면접 Q&A InterviewSession.questions[i].conversation 리스트에 질문별 대화 이력 저장. 세션은 SessionStore(Redis/InMemory)에 JSON으로 관리
꼬리질문 format_conversation_history(current_q.conversation)현재 질문의 대화만 프롬프트에 삽입, history=[]로 LLM 호출
일반 채팅 클라이언트(백엔드)가 request.context.historyJSON 배열 통째로 전달 → Gemini contents / LCEL messages에 반영 (ADR-053)

핵심 구조 — 백엔드가 history를 JSON으로 통째로 전달:

flowchart LR
    A["사용자"] --> B["백엔드"]
    B -->|"request.context.history\n(JSON 배열 통째 전달)"| C["AI 서버"]
    C -->|"history → Gemini contents\nhistory → LCEL messages"| D["LLM"]
    D --> C --> B --> A
flowchart TB
    subgraph 면접모드["면접 모드 — 질문별 독립 맥락"]
        A["Q1 대화"] --> B["Q1.conversation"]
        C["Q2 대화"] --> D["Q2.conversation"]
        E["Q3 대화"] --> F["Q3.conversation"]
        B -. "독립" .- D
        D -. "독립" .- F
    end
    subgraph 일반채팅["일반 채팅 — 백엔드 JSON history"]
        G["턴 1"] --> H["history 리스트"]
        I["턴 2"] --> H
        J["턴 3"] --> H
        H --> K["LLM 호출 시 전체 전달"]
    end

검토 사항:

  • 면접에서 Q1 답변이 Q3 꼬리질문에 영향을 줘야 하는가?
  • 일반 채팅에서 LangChain 버퍼 메모리가 현재 history 전달 방식보다 나은가?
  • 백엔드가 이미 JSON으로 history를 통째로 주는데, AI 서버에서 별도 메모리 축적이 필요한가?

🔍 선택지 분석 (Options)

Option 1: 현재 구조 유지 (세션 기반 + 클라이언트 history) — 채택 ⭐

  • 면접: InterviewSession.conversation으로 질문별 독립 맥락 관리
  • 일반 채팅: 클라이언트가 history 리스트를 매 요청에 전달
  • LangChain ConversationBufferMemory 미도입
장점 단점
면접은 질문별 독립 평가에 적합 면접에서 질문 간 맥락 연결 불가 (Q1 답변 → Q3 참조 불가)
토큰 절약 (질문별 이력만 전달) -
구현 복잡도 낮음, 추가 의존성 없음 -
일반 채팅은 클라이언트 history로 충분히 맥락 유지 서버 독립적 history 관리 불가 (클라이언트 의존)

Option 2: LangChain ConversationBufferMemory 도입

  • AI 서버가 세션별 ConversationBufferMemory를 관리
  • 모든 턴이 자동으로 메모리에 축적되어 LLM 호출 시 자동 삽입
from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory()
memory.save_context({"input": "자기소개 해보세요"}, {"output": "저는..."})
memory.save_context({"input": "지원 이유?"}, {"output": "이 회사는..."})
# 이후 모든 호출에 전체 대화 자동 포함
장점 단점
질문 간 맥락 연결 가능 ("아까 말한 프로젝트 더 설명해주세요") 대화 길어질수록 토큰 비용 급증 (5질문 × 3 depth = 최대 15턴 누적)
서버 독립적 history 관리 면접은 질문별 독립 평가가 목적 → 전체 맥락 불필요
LangChain 생태계 활용 추가 메모리 관리 복잡도 (세션 만료, 저장소 연동)
- 백엔드가 이미 JSON history를 통째로 전달 → 서버에서 중복 축적 불필요

Option 3: ConversationTokenBufferMemory (토큰 상한 메모리)

  • max_token_limit을 설정하면 오래된 턴부터 자동 삭제하여 토큰 사용량 제한
  • BufferMemory와 동일하지만 토큰 기준으로 history를 잘라냄
from langchain.memory import ConversationTokenBufferMemory

memory = ConversationTokenBufferMemory(
    llm=llm,
    max_token_limit=2000,  # 2000토큰 초과 시 오래된 턴 자동 삭제
)
장점 단점
토큰 상한 자동 관리로 비용 예측 가능 백엔드에서 전달할 history 개수를 제한하면 동일 효과 (서버 단 슬라이싱)
긴 대화에서 OOM/토큰 초과 방지 여전히 AI 서버에서 별도 메모리 축적 필요 → 백엔드 JSON 전달과 이중 관리
- 세션 만료·저장소 연동 등 추가 복잡도

Option 4: ConversationSummaryMemory (요약 메모리)

  • 대화가 길어지면 이전 턴을 LLM으로 요약하여 토큰 절약
장점 단점
긴 대화에서도 토큰 사용량 제한 요약 시 LLM 추가 호출 → 비용·지연 발생
- 면접에서는 질문별 독립 평가가 목적이므로 과도한 구조
- 백엔드가 history를 관리하므로 AI 서버에서 요약할 필요 없음

Option 5: ConversationEntityMemory (엔티티 메모리)

  • 대화에서 "사람", "회사", "기술" 등 엔티티를 추출하여 별도 저장
  • "김철수는 React 개발자"를 기억하여 이후 대화에 활용
장점 단점
엔티티 기반 맥락 추적 가능 매 턴마다 LLM 추가 호출 (엔티티 추출용) → 비용·지연
- 면접은 질문별 독립 평가라 엔티티 추적 불필요
- 일반 채팅은 백엔드 history에 전체 대화가 있어 LLM이 알아서 맥락 파악

Option 6: ConversationKGMemory (지식 그래프 메모리)

  • 대화에서 (주체, 관계, 객체) 트리플을 추출해 지식 그래프 구축
  • 예: 김철수 → 근무 → 네이버
장점 단점
복잡한 관계망 추적 가능 매 턴 LLM 호출 + 그래프 저장소 필요 → 과도한 설계
- 면접 도우미 서비스에서 지식 그래프 수준의 관계 추적은 불필요
- RAG(VectorDB) 유사 검색이 더 적합

Option 7: VectorStoreRetrieverMemory (벡터 스토어 검색 메모리)

  • 과거 대화를 벡터 임베딩 → 현재 질문과 유사한 과거 대화를 검색해 컨텍스트로 제공
장점 단점
유사 대화 검색으로 장기 기억 효과 ADR-056에서 ChromaDB로 이미 구현
- get_few_shot_for_personality()가 동일 역할 수행 중
- LangChain 모듈 없이 직접 ChromaDB로 구현 완료

Option 8: LCEL 체인에 메모리 추가 / SQLite 저장 / 휘발성 메모리

LCEL 체인 메모리: chain = prompt | llm | memory 식으로 파이프라인 연결

장점 단점
LCEL 체인에 메모리 자동 연결 ADR-053에서 history → messages 매핑으로 이미 구현
- 백엔드 JSON 전달과 이중 관리

SQLite 저장 (SQLChatMessageHistory): AI 서버에서 대화를 SQLite에 저장

장점 단점
서버 재시작 후에도 대화 유지 백엔드가 자체 DB(MySQL/PostgreSQL)에 대화 저장 중 → 이중 저장
- AI 서버는 stateless가 적합 (수평 확장성)

휘발성 메모리 (일반 변수 / InMemory): Python 딕셔너리에 대화 저장

장점 단점
가장 빠르고 간단 서버 재시작 시 소멸
현재 SessionStore InMemory로 이미 사용 중 다중 인스턴스 시 공유 불가 → Redis 확장 (ADR-055)

정리: 휘발성 메모리는 현재 SessionStore의 InMemory 백엔드로 이미 사용 중이며, Redis는 이의 영속성 확장 버전입니다 (ADR-055 참조).

LangChain 메모리 및 저장 방식 전체 비교

메모리/저장 방식 용도 필요? 현재 구조에서의 대응
ConversationBufferMemory 전체 대화 축적 백엔드 JSON history로 대체
ConversationTokenBufferMemory 토큰 상한 자동 관리 서버 단 슬라이싱으로 충분
ConversationSummaryMemory 오래된 대화 요약 추가 LLM 호출 비용, 백엔드가 관리 주체
ConversationEntityMemory 엔티티 추출·추적 매 턴 LLM 추가 호출, 면접/채팅에 불필요
ConversationKGMemory 지식 그래프 구축 과도한 설계, RAG로 충분
VectorStoreRetrieverMemory 유사 대화 검색 ADR-056 ChromaDB로 이미 구현
LCEL 체인 메모리 체인에 메모리 자동 연결 ADR-053 history 매핑으로 이미 구현
SQLite 저장 AI 서버 자체 대화 저장 백엔드 DB에서 담당, 이중 저장 불필요
휘발성 메모리 (InMemory) 빠른 세션 저장 SessionStore InMemory로 이미 사용 중
Redis 영속성 있는 세션 저장 ✅(선택) InMemory 확장 옵션 (ADR-055)

결정 (Decision)

Option 1 (현재 구조 유지)을 선택합니다.

근거:

  1. 면접 = 질문별 독립 평가: Q1 자기소개 답변이 Q3 팀워크 꼬리질문에 영향을 줄 필요가 없음. 각 질문은 해당 카테고리 역량을 독립적으로 평가하는 것이 면접 설계 의도.
  2. 일반 채팅은 이미 해결됨: ADR-053에서 구현한 클라이언트 history 전달 방식이 Gemini·LCEL 양쪽에서 동작하며, 백엔드가 history를 관리하므로 AI 서버에서 별도 메모리 관리 불필요.
  3. 백엔드 JSON 전달 = 사실상 버퍼 메모리: 백엔드가 대화 이력을 DB에 저장하고 매 요청마다 request.context.history로 JSON 배열을 통째로 전달한다. 이는 ConversationBufferMemory가 하는 것과 동일한 역할. AI 서버에서 별도로 대화를 축적·관리할 필요가 없다.
  4. 토큰 관리도 서버 단 슬라이싱으로 충분: ConversationTokenBufferMemory의 토큰 상한 자동 관리는, 백엔드에서 최근 N턴만 보내거나 AI 서버에서 받은 history를 슬라이싱하면 동일하게 달성 가능.
  5. 엔티티/KG 메모리는 과도한 설계: EntityMemoryKGMemory는 매 턴마다 LLM 추가 호출이 필요하며, 면접 도우미 서비스에서 엔티티 추적이나 지식 그래프 구축은 불필요.
  6. 벡터 스토어 검색 메모리는 ADR-056으로 이미 구현: VectorStoreRetrieverMemory의 역할을 ChromaDB interview_feedback 컨렉션 + get_few_shot_for_personality()로 직접 구현 완료.
  7. SQLite 이중 저장 불필요: 백엔드가 자체 DB에 대화 저장 중. AI 서버에서 별도 SQLite를 운영하면 이중 저장이며, stateless 설계에 반함.
  8. 휘발성 메모리는 이미 사용 중: SessionStore InMemory 백엔드가 이 역할을 수행. Redis는 이의 영속성 확장 버전 (ADR-055).

Consequences (결과)

긍정적 영향:

  • 면접 모드에서 토큰 사용량 최소화 (질문별 이력만 전달)
  • 추가 의존성·복잡도 없이 현재 구조 유지
  • 일반 채팅은 ADR-053 기반 history 전달로 맥락 유지 가능

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

  • 면접에서 "아까 자기소개에서 언급한 프로젝트를 더 설명해달라" 같은 질문 간 맥락 참조는 불가. 현재 면접 설계(카테고리별 독립 평가)에서는 문제 없음.
  • 일반 채팅의 history 관리가 클라이언트에 의존. 클라이언트가 history를 보내지 않으면 맥락 유실.

후속 작업:

  • (선택) 일반 채팅에서 history 최근 N턴 또는 토큰 상한 제한 도입 (ADR-053 후속)
  • (선택) 면접 종료 후 전체 Q&A를 요약해 평가 리포트에 반영하는 기능 검토

이력

날짜 변경 내용
2026-02-17 초기 작성 (면접 세션 기반 유지, LangChain 메모리 전체 분석 후 미도입 결정)

ADR-058: PDF 텍스트 추출 전략 — LangChain Document Loader 미도입, OCR 파이프라인 유지

📋 메타데이터

항목 내용
상태 ✅ 승인됨 (Accepted)
작성일 2026-02-17
결정자 AI팀
관련 기능 이력서/포트폴리오 OCR, 채용공고 OCR, VectorDB 임베딩, RAG
관련 ADR ADR-042 (OCR 전략), ADR-054 (RAG 리트리버 확장)

🎯 컨텍스트 (Context)

이력서/포트폴리오와 채용공고를 PDF 파일로 업로드 받을 때, LangChain Document Loader(PyPDFLoader, UnstructuredPDFLoader 등)를 도입하여 문서 처리 파이프라인을 LangChain 생태계로 통합할 필요가 있는지 검토한다.

현재 상태:

flowchart LR
    A["S3에서 PDF 다운로드"] --> B["pdf2image\n(PDF → PIL Image)"]
    B --> C{"CLOVA OCR\n설정 여부"}
    C -->|"설정됨"| D["CLOVA OCR API\n(Primary)"]
    C -->|"미설정"| E["Gemini Vision\n(Fallback)"]
    D --> F{"품질 검증\n(50자 이상, 한글 10%↑)"}
    F -->|"통과"| G["텍스트 반환"]
    F -->|"실패"| E
    E --> G
    G --> H["VectorDB 저장\n(ChromaDB)"]

현재 코드 경로:

파일 역할
app/services/ocr_service.py CLOVA OCR → Gemini Vision fallback, 품질 검증
app/services/llm_service.py Gemini Vision 텍스트 추출 (extract_text_from_file)
app/api/routes/v2/text_extract.py POST /ai/text/extract 엔드포인트
app/services/vectordb_service.py ChromaDB 저장 + 청킹 (500 tokens, 50 overlap)

의존성 현황:

패키지 설치 여부 사용 여부
pdf2image ✅ (PDF → 이미지 변환)
pdfplumber ❌ (설치만, 미사용)
langchain 주석 처리 (requirements.txt)
langchain-community 미설치

문제 제기:

  • LangChain Document Loader를 도입하면 PyPDFLoader → RecursiveCharacterTextSplitter → ChromaDB 전체를 LangChain으로 통합할 수 있지 않은가?
  • 현재 자체 OCR 파이프라인이 LangChain Loader보다 나은 점이 있는가?

🔍 선택지 분석 (Options)

Option 1: 현재 OCR 파이프라인 유지 (채택) ⭐

  • pdf2image → CLOVA OCR (Primary) → Gemini Vision (Fallback) 유지
  • LangChain Document Loader 미도입
  • 청킹은 vectordb_service에서 자체 처리
# app/services/ocr_service.py — 현재 구조
async def extract_text(self, file_url, file_type="pdf", ...):
    if CLOVA_AVAILABLE:
        result = await self._try_clova_ocr(file_url, file_type, ...)
        if result.get("success"):
            should_fallback, reason = self._should_fallback(result)
            if not should_fallback:
                return result  # CLOVA 성공
    # Gemini Fallback
    return await self.llm_service.extract_text_from_file(file_url, ...)
장점 단점
한국어 이력서 최적화: CLOVA OCR은 한국어 특화, Gemini Vision은 디자인 PDF 대응 텍스트 기반 PDF에도 OCR을 거쳐 API 비용 발생
스캔/이미지/디자인 PDF 대응: 텍스트 레이어 없는 PDF에서도 정상 동작 pdfplumber가 설치만 되고 미사용 (불필요한 의존성)
품질 검증 로직: 텍스트 길이, 한글 비율 기반 자동 fallback -
추가 의존성 없음: langchain-community, unstructured 등 무거운 패키지 불필요 -
현재 아키텍처(웹 서비스)에 적합: 각 서비스가 독립적으로 동작 -

Option 2: LangChain Document Loader 도입

  • PyPDFLoader 또는 UnstructuredPDFLoader로 PDF 텍스트 추출
  • RecursiveCharacterTextSplitter로 청킹
  • LangChain 생태계로 파이프라인 통합
# LangChain Document Loader 방식 (가상)
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

loader = PyPDFLoader("/tmp/resume.pdf")
docs = loader.load()  # 텍스트 기반 PDF에서만 동작
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_documents(docs)
장점 단점
LangChain 생태계 통합 (Loader → Splitter → VectorStore 체인) 스캔/이미지/디자인 PDF에서 텍스트 추출 불가 → 결국 OCR 필요
텍스트 기반 PDF에서 빠르고 정확한 추출 한국 이력서 대부분이 디자인 PDF → 실질적 이점 제한적
RecursiveCharacterTextSplitter로 표준 청킹 추가 의존성: langchain-community, unstructured, pypdf
- 현재 아키텍처와 불일치: 웹 서비스 구조에서 LangChain 에이전트 파이프라인은 과도
- 현재 청킹이 vectordb_service에서 이미 동작 중 → 이중 구현

Option 3: 하이브리드 방식 (pdfplumber + OCR fallback)

  • 텍스트 기반 PDF는 pdfplumber로 직접 텍스트 추출 (빠르고 비용 없음)
  • 텍스트 추출 실패 또는 품질 부족 시 기존 OCR 파이프라인으로 fallback
# 하이브리드 방식 (가상)
import pdfplumber

def extract_text_hybrid(pdf_bytes):
    text = ""
    with pdfplumber.open(io.BytesIO(pdf_bytes)) as pdf:
        for page in pdf.pages:
            text += page.extract_text() or ""
    if len(text) > 50 and korean_ratio(text) > 0.1:
        return text  # pdfplumber 성공
    # OCR fallback
    return await ocr_pipeline(pdf_bytes)
장점 단점
텍스트 기반 PDF에서 OCR API 호출 비용 절감 pdfplumber가 이미 설치되어 있으나, 추가 분기 로직 필요
현재 OCR fallback과 호환 가능 한국 이력서의 대부분이 디자인 PDF → pdfplumber 성공률 낮을 것으로 예상
LangChain 의존성 추가 없음 두 경로 유지보수 부담

결정 (Decision)

Option 1 (현재 OCR 파이프라인 유지)을 선택합니다.

근거:

  1. 한국 이력서는 대부분 디자인 PDF: Canva, PPT, 노션 Export 등으로 만든 이력서는 텍스트 레이어가 없는 경우가 대부분. PyPDFLoaderpdfplumber로는 텍스트 추출 불가 → 어차피 OCR이 필수.
  2. CLOVA OCR + Gemini Vision이 최적 조합: CLOVA OCR은 한국어 특화 API로 높은 정확도를 제공하며, Gemini Vision은 디자인이 복잡한 PDF에서도 텍스트를 추출할 수 있는 강력한 fallback.
  3. 웹 서비스 아키텍처와 일치: 현재 프로젝트는 LangChain 에이전트가 아닌 각 서비스가 독립적으로 동작하는 웹 서비스. S3 다운로드, OCR, VectorDB 저장이 각각 독립 서비스로 분리되어 있어 LangChain Loader의 통합 파이프라인이 맞지 않음.
  4. 불필요한 의존성 회피: langchain-communityunstructured는 무거운 패키지로, 빌드 시간과 Docker 이미지 크기를 증가시킴. 현재 langchain_core만 선택적으로 사용 중인 가벼운 구조 유지.
  5. 청킹 이미 자체 처리: vectordb_service에서 500 tokens / 50 overlap 청킹을 직접 처리 중이므로 RecursiveCharacterTextSplitter 도입 불필요.
  6. Option 3 (하이브리드)도 현시점에서 불필요: 텍스트 기반 PDF 비율이 낮아 pdfplumber 분기 추가의 실익이 적음. 향후 채용공고가 텍스트 PDF 비율이 높아지면 재검토.

Consequences (결과)

긍정적 영향:

  • 추가 의존성 없이 현재 파이프라인 유지 → Docker 이미지 경량화
  • CLOVA OCR + Gemini Vision fallback으로 한국어 PDF 처리 품질 보장
  • 독립 서비스 구조 유지로 유지보수 용이

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

  • 텍스트 기반 PDF(채용공고 등)에서도 OCR API를 호출하여 소량 비용 발생
  • pdfplumberrequirements.txt에 설치되어 있으나 미사용 → 불필요한 의존성으로 정리 필요

후속 작업:

  • pdfplumber 의존성 제거 검토 (사용하지 않으므로 requirements.txt에서 삭제)
  • (선택) 채용공고 텍스트 PDF 비율이 높아지면 pdfplumber 1차 시도 → OCR fallback 하이브리드 방식 재검토
  • (선택) OCR 비용 모니터링 후 비용 절감이 필요하면 하이브리드 방식 도입

이력

날짜 변경 내용
2026-02-17 초기 작성 (LangChain Document Loader 미도입, 기존 OCR 파이프라인 유지 결정)

ADR-059: URL 기반 문서 입력 지원 — LangChain WebBaseLoader 도입 (첫 화면 + 채팅)

📋 메타데이터

항목 내용
상태 제안됨 (Proposed)
작성일 2026-02-17
결정자 AI팀
관련 기능 이력서/채용공고 URL 입력, 채팅 중 URL 읽기, RAG
관련 ADR ADR-058 (PDF 텍스트 추출 전략), ADR-053 (채팅 history 반영)

🎯 컨텍스트 (Context)

현재 이력서/포트폴리오와 채용공고를 입력받는 방식은 PDF 파일 업로드(S3 키) 또는 텍스트 직접 입력 두 가지만 지원한다. 그러나 실제 사용 시나리오에서는 다음과 같은 요구가 있다:

  1. 첫 화면 (문서 입력): 사용자가 채용공고 URL(사람인, 잡코리아, 원티드 등)이나 포트폴리오 URL(GitHub, 개인 블로그 등)을 붙여넣으면 해당 페이지 내용을 자동으로 읽어와 분석에 활용
  2. 채팅 중: 사용자가 참고 자료 URL(기술 블로그, 공식 문서 등)이나 추가 채용공고 URL을 보내면 해당 페이지 내용을 읽어와 답변에 반영

현재 상태:

flowchart LR
    subgraph 현재["현재 — 2가지 입력만 지원"]
        A["PDF 파일 (S3 키)"] --> C["OCR → 텍스트"]
        B["텍스트 직접 입력"] --> C
        C --> D["VectorDB 저장"]
    end
    subgraph 목표["목표 — URL 입력 추가"]
        E["PDF 파일 (S3 키)"] --> H["OCR → 텍스트"]
        F["텍스트 직접 입력"] --> H
        G["URL 입력 🆕"] --> I["WebBaseLoader → 텍스트"]
        I --> H
        H --> J["VectorDB 저장"]
    end

현재 코드 경로:

파일 현재 역할
app/schemas/text_extract.py DocumentInput: s3_key 또는 text 2-way 입력
app/api/routes/v2/text_extract.py extract_document(): s3_key → OCR, text → 직접 사용
app/api/routes/v2/chat.py user_message를 문자열로만 처리, URL 감지 없음

현재 의존성:

패키지 설치 여부 용도
langchain_core LCEL 체인, 프롬프트 템플릿
langchain-community WebBaseLoader 포함
beautifulsoup4 HTML 파싱 (WebBaseLoader 의존)
httpx HTTP 클라이언트

문제점:

  • 채용공고 URL을 직접 붙여넣을 수 없어 사용자가 수동으로 텍스트를 복사·붙여넣기해야 함
  • 채팅 중 참고 URL을 보내도 AI가 해당 페이지에 접근하지 못해 유용한 답변을 생성할 수 없음

요구사항:

  • 첫 화면에서 이력서/포트폴리오/채용공고를 URL로도 입력할 수 있어야 함
  • 채팅 중 URL이 포함된 메시지를 보내면 해당 페이지 내용을 읽어와 답변에 반영
  • 기존 PDF/텍스트 입력 방식과 호환 유지
  • Tavily는 이번 범위에 포함하지 않음 (채용 트렌드 검색 용도로 별도 ADR에서 도입 예정)

🔍 선택지 분석 (Options)

Option 1: LangChain WebBaseLoader (채택) ⭐

  • 첫 화면: DocumentInputurl 필드 추가 → WebBaseLoader(url).load() → 텍스트 추출 → VectorDB 저장
  • 채팅 중: 메시지에서 URL 감지 → WebBaseLoader로 내용 fetch → 컨텍스트로 주입
# 첫 화면: text_extract.py에서 URL 입력 처리
from langchain_community.document_loaders import WebBaseLoader

async def extract_from_url(url: str) -> str:
    loader = WebBaseLoader(url)
    docs = loader.load()
    return "\n".join(doc.page_content for doc in docs)

# extract_document() 내부
if doc_input.url:
    extracted_text = await extract_from_url(doc_input.url)
# 채팅 중: chat.py에서 URL 감지 및 내용 주입
import re
from langchain_community.document_loaders import WebBaseLoader

url_pattern = re.compile(r'https?://\S+')
urls = url_pattern.findall(user_message)
if urls:
    loader = WebBaseLoader(urls[0])
    docs = loader.load()
    web_context = docs[0].page_content[:3000]  # 토큰 제한
    user_message = f"참고 URL 내용:\n{web_context}\n\n사용자 질문: {user_message}"
장점 단점
langchain_core 이미 사용 중 → langchain-community 추가는 자연스러운 확장 langchain-community 패키지 추가 (빌드 시간 약간 증가)
Document 객체 반환 → 청킹/임베딩 체인 연결 용이 JavaScript 렌더링 사이트(SPA)에서는 내용 추출 제한적
beautifulsoup4 기반으로 HTML 파싱 안정적 beautifulsoup4 의존성 추가
채팅/첫화면 모두 동일한 도구로 통일 -
LangChain 생태계의 다른 Loader 확장 가능 -

Option 2: httpx + trafilatura 직접 구현

  • httpx(이미 설치)로 HTML fetch → trafilatura로 본문만 깨끗하게 추출
  • LangChain 의존성 추가 없이 독립 구현
import httpx
import trafilatura

async def fetch_url_text(url: str) -> str:
    async with httpx.AsyncClient() as client:
        response = await client.get(url, timeout=15.0)
        response.raise_for_status()
    return trafilatura.extract(response.text) or ""
장점 단점
trafilatura만 추가 → 가장 경량 LangChain Document 객체가 아닌 plain text 반환 → 별도 Document 래핑 필요
본문 추출 품질이 높음 (광고·네비게이션 자동 제거) LangChain 생태계와 분리 → 추후 Loader 확장 시 불일치
비동기 httpx 활용 가능 trafilatura는 내부적으로 동기 → run_in_executor 래핑 필요

Option 3: Tavily Extract API

  • Tavily API의 Extract 기능으로 URL 내용 추출
  • API 키 필요, 호출당 비용 발생
from tavily import TavilyClient

client = TavilyClient(api_key=os.getenv("TAVILY_API_KEY"))
result = client.extract(urls=[url])
text = result["results"][0]["raw_content"]
장점 단점
JavaScript 렌더링 사이트도 대응 가능 API 키 필요 + 호출당 비용 발생
깨끗한 텍스트 추출 외부 API 의존 → 네트워크 지연, 장애 시 기능 중단
- Tavily는 채용 트렌드 검색 용도로 별도 도입 예정 → 이번 범위에서 분리

결정 (Decision)

Option 1 (LangChain WebBaseLoader)을 선택합니다.

근거:

  1. 기존 LangChain 사용과 일관성: 이미 langchain_core로 LCEL 체인(create_chain, create_chat_chain)을 사용 중이며, langchain-community는 이 생태계의 자연스러운 확장. ADR-058에서 "LangChain Document Loader 미도입"으로 결정한 것은 PDF 텍스트 추출에 한정된 것이며, 웹 URL 로딩은 별개의 요구사항.
  2. 두 기능을 하나의 도구로 통일: 첫 화면(문서 입력)과 채팅 중(URL 참고) 모두 WebBaseLoader로 처리하여 코드 일관성 유지.
  3. Document 객체 활용: WebBaseLoaderDocument 객체를 반환하므로, 향후 RecursiveCharacterTextSplitter 등 LangChain의 청킹/임베딩 도구와 직접 연결 가능.
  4. 확장성: WebBaseLoader 외에도 AsyncHtmlLoader, PlaywrightURLLoader 등 LangChain의 다른 Loader로 쉽게 교체 가능. SPA 사이트 대응이 필요해지면 PlaywrightURLLoader로 전환.
  5. Tavily 분리: Tavily는 채용 트렌드 검색(검색 + 요약) 용도로 별도 도입 예정. URL 텍스트 추출이라는 단순 기능에 유료 API를 사용할 필요 없음.

구현 요약 (3.model 기준)

구분 파일 내용
스키마 app/schemas/text_extract.py DocumentInputurl: str | None 필드 추가, validate_input_source 3-way 검증 (s3_key / text / url 중 하나만)
첫 화면 라우트 app/api/routes/v2/text_extract.py extract_document()elif doc_input.url: 분기 → WebBaseLoader(url).load() → 텍스트 추출
채팅 라우트 app/api/routes/v2/chat.py NORMAL 모드에서 user_message URL 감지 → WebBaseLoader → 컨텍스트 주입
서비스 app/services/web_loader_service.py (신규) WebBaseLoader 래핑, 타임아웃/에러 처리, 텍스트 길이 제한
의존성 requirements-serving.txt langchain-community, beautifulsoup4 추가

첫 화면 처리 흐름:

flowchart LR
    A["DocumentInput\n(url 필드)"] --> B["WebBaseLoader"]
    B --> C["Document 객체"]
    C --> D["page_content → 텍스트"]
    D --> E["VectorDB 저장\n(resume/job_posting)"]
    D --> F["분석 리포트 생성"]

채팅 중 처리 흐름:

flowchart LR
    A["사용자 메시지\n(URL 포함)"] --> B["URL 감지\n(regex)"]
    B --> C["WebBaseLoader"]
    C --> D["웹 내용 추출\n(최대 3000자)"]
    D --> E["컨텍스트로 주입"]
    E --> F["RAG + LLM 응답"]

Consequences (결과)

긍정적 영향:

  • 사용자가 채용공고 URL을 직접 붙여넣어 분석 가능 → UX 개선
  • 채팅 중 참고 URL을 보내면 AI가 해당 내용을 반영한 답변 생성 가능
  • LangChain 생태계 확장으로 향후 다양한 Loader 활용 기반 마련

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

  • langchain-community + beautifulsoup4 의존성 추가 → Docker 이미지 크기 약간 증가
  • JavaScript 렌더링(SPA) 사이트에서는 내용 추출이 제한적 (사람인, 잡코리아 등 일부 페이지). 필요 시 PlaywrightURLLoader로 전환 검토
  • 채팅 중 URL fetch 시 응답 지연 발생 가능 (네트워크 요청 추가)

후속 작업:

  • app/services/web_loader_service.py 구현 (WebBaseLoader 래핑, 타임아웃/에러 처리)
  • DocumentInput 스키마에 url 필드 추가 및 검증 로직 수정
  • text_extract.py 라우트에 URL 입력 분기 추가
  • chat.py 라우트에 URL 감지 및 컨텍스트 주입 로직 추가
  • requirements-serving.txtlangchain-community, beautifulsoup4 추가
  • (선택) SPA 사이트 대응이 필요하면 PlaywrightURLLoader 전환 검토
  • (별도 ADR) Tavily API를 활용한 채용 트렌드 검색 기능 도입

이력

날짜 변경 내용
2026-02-17 초기 작성 (LangChain WebBaseLoader 도입, 첫 화면 URL 입력 + 채팅 중 URL 읽기)

ADR-060: 텍스트 분할 전략 도입 — RecursiveCharacterTextSplitter + pdfplumber 전환 로드맵

📋 메타데이터

항목 내용
상태 제안됨 (Proposed)
작성일 2026-02-18
결정자 AI팀
관련 기능 텍스트 청킹, VectorDB 임베딩, RAG 검색, PDF 텍스트 추출
관련 ADR ADR-058 (OCR 파이프라인 유지), ADR-054 (RAG 리트리버 확장 + MMR)

🎯 컨텍스트 (Context)

OCR로 추출한 텍스트를 VectorDB에 저장할 때 청킹(chunking) 없이 전체 텍스트를 단일 문서로 저장하고 있다. API 문서에는 "500 tokens, 50 overlap" 청킹이 명시되어 있으나 실제 구현은 안 되어 있다. 또한 이력서/포트폴리오가 10페이지 이상으로 길어질 수 있으며, 추후 OCR을 걷어내고 PDF 직접 읽기로 전환하려는 계획이 있다.

현재 상태:

flowchart LR
    A["PDF/이미지/텍스트/URL"] --> B["OCR/WebLoader"]
    B --> C["전체 텍스트\n(단일 문자열)"]
    C --> D["vectordb.add_document()\n1개 임베딩"]
    D --> E["ChromaDB\n단일 문서"]

현재 코드 경로:

파일 역할 문제점
app/api/routes/v2/text_extract.py L277-288 add_document(text=전체텍스트) 단일 호출 청킹 없이 전체 텍스트를 한 번에 저장
app/services/vectordb_service.py L160-206 add_document() — 전체 텍스트 → 단일 임베딩 2,048 토큰 초과 시 임베딩 실패 위험
app/services/vectordb_service.py L208-254 add_documents_batch() — 배치 저장 구현 완료되어 있으나 호출되는 곳 없음

의존성 현황:

패키지 설치 여부 사용 여부
langchain ✅ (LCEL 체인)
langchain-community ✅ (WebBaseLoader, ADR-059)
pdfplumber ❌ (설치만, 미사용)
RecursiveCharacterTextSplitter ✅ (langchain 내장) ❌ (미사용)

문제점:

  • 임베딩 제한 위반: Gemini-embedding-001 최대 2,048 토큰(약 8,192자). 10페이지 포트폴리오(15,000자+)는 임베딩 실패
  • 검색 품질 저하: 전체 이력서 1개 임베딩 → 과도하게 generic한 벡터 → "프로젝트 경험" 등 특정 섹션 검색 불가
  • pdfplumber 낭비: 텍스트 레이어 있는 PDF에도 OCR API 호출 → 불필요한 비용 발생
  • API 명세 불일치: 문서에 "500 tokens, 50 overlap" 명시, 실제 미구현

요구사항:

  • 문서 텍스트를 적절한 청크로 분할하여 VectorDB에 저장
  • 10페이지 이상 이력서/포트폴리오 지원
  • 추후 OCR을 걷어내고 pdfplumber로 PDF 직접 읽기 전환
  • 기존 RAG 검색 호환성 유지

🔍 선택지 분석 (Options)

8가지 텍스트 분할 방식 비교

# 방식 적합도 판단 근거
1 CharacterTextSplitter 고정 길이 절단. 문장/단어 중간에서 잘릴 위험. 한국어에서 형태소 단위 분리 불가
2 RecursiveCharacterTextSplitter 채택 구분자 계층(\n\n\n. ) 기반 자연 분할. 한국어 이력서/채용공고의 섹션 구분과 잘 맞음. LangChain 기본 제공, 가장 범용적
3 TokenTextSplitter △ 보조 토큰 기반 정확한 분할이나 의미 경계 무시. RecursiveCharacter의 length_function으로 토큰 카운터 연결하는 보조 활용이 적절
4 SemanticChunker △ Phase 3 임베딩 기반 의미 변화 감지로 최고 품질. 단, 추가 임베딩 API 호출 비용 + 속도 저하 + 비결정적 청크 크기. pdfplumber 안정화 후 검토
5 Language (코드) 프로그래밍 언어 구문 기반. 주 대상이 한국어 텍스트이므로 부적합
6 MarkdownHeaderTextSplitter △ 조건부 마크다운 포트폴리오 직접 입력 시에만 유용. OCR/pdfplumber 출력은 마크다운 아님
7 HTMLHeaderTextSplitter WebBaseLoader가 이미 텍스트 추출 완료 후 전달하므로 HTML 태그 없음
8 RecursiveJsonSplitter 프로젝트 문서가 JSON이 아님

Option 1: RecursiveCharacterTextSplitter 도입 + pdfplumber 전환 로드맵 (채택) ⭐

3단계 로드맵:

Phase 1 — 텍스트 분할 도입 (즉시):

  • TextSplitterService 신규 생성 (RecursiveCharacterTextSplitter 래핑)
  • text_extract.py에서 add_document()split_text() + add_documents_batch() 교체
  • chunk_size=2000자(≈500 토큰), chunk_overlap=200자(≈50 토큰)
# app/services/text_splitter_service.py
from langchain.text_splitter import RecursiveCharacterTextSplitter

class TextSplitterService:
    def __init__(self, chunk_size=2000, chunk_overlap=200):
        self._splitter = RecursiveCharacterTextSplitter(
            chunk_size=chunk_size,
            chunk_overlap=chunk_overlap,
            separators=["\n\n", "\n", ". ", " ", ""],
        )

    def split_text(self, text, document_id, ...) -> list[TextChunk]:
        chunks = self._splitter.split_text(text)
        return [TextChunk(id=f"{document_id}_chunk_{i:03d}", text=c, ...) for i, c in enumerate(chunks)]

Phase 2 — RAG 최적화 (Phase 1 후):

  • rag_max_context_length: 4000 → 6000 상향
  • retrieval_k: 3 → 5 (청크 단위 검색이므로 더 많은 결과 필요)
  • retrieve_all_documents()에 청크 정렬 로직 추가

Phase 3 — pdfplumber 전환 (Phase 2 후):

  • PDFExtractService 신규 생성 (pdfplumber 래핑)
  • PDF → pdfplumber 우선 시도 → 텍스트 부족 시 기존 OCR 폴백
  • 이미지 파일은 기존 OCR 유지
flowchart TB
    subgraph Phase3["Phase 3 — pdfplumber 우선 + OCR 폴백"]
        A["PDF 파일"] --> B{"pdfplumber\n텍스트 추출"}
        B -->|"텍스트 충분\n(≥50자/페이지)"| C["TextSplitter\n→ 청크 분할"]
        B -->|"텍스트 부족\n(스캔 PDF)"| D["기존 OCR\n(CLOVA → Gemini)"]
        D --> C
        C --> E["add_documents_batch()\n→ ChromaDB"]
    end
    subgraph Phase1["Phase 1 — 청킹 도입"]
        F["전체 텍스트"] --> G["TextSplitterService\n(RecursiveCharacter)"]
        G --> H["[청크1, 청크2, ..., 청크N]"]
        H --> I["add_documents_batch()\n→ ChromaDB"]
    end
장점 단점
API 명세("500 tokens, 50 overlap")와 정확히 일치 Phase 3까지 완료해야 전체 효과 발휘
Gemini 임베딩 2,048 토큰 제한 자동 준수 기존 단일 문서 데이터와 신규 청크 데이터 공존 기간 발생
RAG 검색 정밀도 향상 (섹션별 관련 청크 반환) 청크 수 증가 → ChromaDB 저장 용량 증가 (미미)
add_documents_batch() 기존 구현 활용 -
pdfplumber로 OCR 비용 절감 (Phase 3) -
단계별 도입으로 위험 분산 -

Option 2: 청킹 없이 현재 구조 유지

장점 단점
변경 없음, 안정성 유지 임베딩 제한(2,048 토큰) 위반 위험 그대로
- 긴 문서 지원 불가
- 검색 품질 개선 불가
- API 명세와 실제 동작 불일치 지속

Option 3: SemanticChunker 즉시 도입

장점 단점
의미 단위 최고 품질 분할 매 문서마다 추가 임베딩 API 호출 → 비용 증가
- OCR 출력 텍스트 품질이 낮으면 의미 분할 정확도 저하
- 비결정적 청크 크기 → 일부 청크가 토큰 제한 초과 가능
- RecursiveCharacter 대비 복잡도 대폭 증가

결정 (Decision)

Option 1 (RecursiveCharacterTextSplitter + 3단계 로드맵)을 선택합니다.

근거:

  1. API 명세 일치: 기존 API 문서에 명시된 "500 tokens, 50 overlap"을 실제 구현으로 반영. chunk_size=2000자(≈500 토큰 한국어 기준), chunk_overlap=200자(≈50 토큰).
  2. 임베딩 제한 자동 준수: 2,000자 청크는 Gemini-embedding-001의 2,048 토큰 제한을 안전하게 하회. 긴 포트폴리오(10페이지+)도 자동 분할되어 임베딩 실패 방지.
  3. RecursiveCharacter가 최적: 8가지 분할 방식 중 한국어 이력서/채용공고의 섹션 구분(\n\n, \n)과 가장 잘 맞음. SemanticChunker는 추가 비용 대비 현 단계에서 이점 제한적.
  4. 기존 인프라 활용: add_documents_batch()가 이미 구현되어 있어 배치 저장에 추가 개발 최소화. langchain도 이미 설치됨.
  5. 단계별 위험 분산: Phase 1(청킹) → Phase 2(RAG 최적화) → Phase 3(pdfplumber 전환)으로 나누어 각 단계를 검증 후 진행.
  6. pdfplumber 활용 계획: ADR-058에서 "현시점에서 불필요"로 결정한 pdfplumber를 Phase 3에서 OCR 폴백 구조로 재활용. 텍스트 기반 PDF에서 OCR API 비용 절감.

구현 요약 (3.model 기준)

Phase 1 — 텍스트 분할 도입:

구분 파일 내용
서비스 (신규) app/services/text_splitter_service.py TextSplitterService 클래스, TextChunk dataclass, RecursiveCharacterTextSplitter 래핑
라우트 (수정) app/api/routes/v2/text_extract.py L277-288 add_document()split_text() + add_documents_batch() 교체
설정 (수정) app/config/settings.py chunk_size: int = 2000, chunk_overlap: int = 200 추가

Phase 2 — RAG 최적화:

구분 파일 내용
설정 (수정) app/config/settings.py rag_max_context_length 4000→6000, rag_retrieval_k 3→5, rag_fetch_k 20→30
서비스 (수정) app/services/rag_service.py L191 하드코딩 → settings 참조, 청크 정렬 로직 추가
체인 (수정) app/domain/chat/chains.py + app/api/routes/v2/_helpers.py RAGChain 생성 시 settings 주입

Phase 3 — pdfplumber 전환:

구분 파일 내용
서비스 (신규) app/services/pdf_extract_service.py PDFExtractService, pdfplumber 래핑, 스캔 PDF 감지
라우트 (수정) app/api/routes/v2/text_extract.py PDF 처리 분기: pdfplumber 우선 → OCR 폴백

Consequences (결과)

긍정적 영향:

  • Gemini 임베딩 토큰 제한 자동 준수 → 긴 문서 임베딩 실패 방지
  • RAG 검색 정밀도 향상 (전체 문서 대신 관련 섹션 청크 반환)
  • API 명세("500 tokens, 50 overlap")와 실제 구현 일치
  • Phase 3에서 pdfplumber 도입 시 텍스트 기반 PDF의 OCR API 비용 절감
  • 기존 add_documents_batch() 활용으로 추가 인프라 개발 최소화

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

  • 기존 단일 문서 데이터와 신규 청크 데이터가 ChromaDB에 공존 (마이그레이션 없이 자연 교체)
  • 청크 수 증가로 ChromaDB 저장 용량 증가 (이력서/채용공고 규모에서 미미)
  • Phase 3의 pdfplumber는 스캔/디자인 PDF에서 동작하지 않아 OCR 폴백 필수 유지

후속 작업:

  • Phase 1: text_splitter_service.py 신규 생성 + text_extract.py 수정
  • Phase 1: settings.pychunk_size, chunk_overlap 설정 추가
  • Phase 2: RAG 설정값 상향 + 하드코딩 제거
  • Phase 3: pdf_extract_service.py 신규 생성 + pdfplumber 우선 분기
  • (선택) SemanticChunker 벤치마크 (Phase 3 이후)
  • (선택) 기존 데이터 마이그레이션 스크립트 작성

이력

날짜 변경 내용
2026-02-18 초기 작성 (8가지 분할 방식 비교 분석, RecursiveCharacterTextSplitter 채택, 3단계 로드맵)