[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 자동 적재 파이프라인
- ADR-057: 면접·채팅 대화 맥락 관리 전략 (세션 기반 유지, 버퍼 메모리 미도입)
- ADR-058: PDF 텍스트 추출 전략 — LangChain Document Loader 미도입, OCR 파이프라인 유지
- ADR-059: URL 기반 문서 입력 지원 — LangChain WebBaseLoader 도입 (첫 화면 + 채팅)
- ADR-060: 텍스트 분할 전략 도입 — RecursiveCharacterTextSplitter + pdfplumber 전환 로드맵
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/analyzeAPI: 면접 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.pyCLI 스크립트로 일괄 적재 - 별도의 새 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)을 선택합니다.
근거:
- 백엔드 조율 불필요: 기존
/evaluation/analyzeAPI 내부에서 fire-and-forget으로 저장하므로 백엔드 코드 변경이 필요 없음. - best-effort 설계: 저장 실패 시 로그만 남기고 평가 응답에는 영향 없음. 면접 데이터는 클라이언트에도 있으므로 유실 위험 낮음.
- 배치 CLI: 과거 데이터나 대량 적재가 필요할 때 JSON 파일을 준비해 서버에서 바로 실행 가능.
- 비용 관리: 임베딩 비용은 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 취소 가능. 프로덕션에서는 FastAPIBackgroundTasks도입 권장.- ChromaDB
add()사용 중이므로 동일 세션 재평가 시 중복 ID 에러 가능.upsert()전환 권장.
후속 작업:
-
asyncio.create_task()→ FastAPIBackgroundTasks전환 검토 - 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.history로 JSON 배열 통째로 전달 → 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 (현재 구조 유지)을 선택합니다.
근거:
- 면접 = 질문별 독립 평가: Q1 자기소개 답변이 Q3 팀워크 꼬리질문에 영향을 줄 필요가 없음. 각 질문은 해당 카테고리 역량을 독립적으로 평가하는 것이 면접 설계 의도.
- 일반 채팅은 이미 해결됨: ADR-053에서 구현한 클라이언트
history전달 방식이 Gemini·LCEL 양쪽에서 동작하며, 백엔드가 history를 관리하므로 AI 서버에서 별도 메모리 관리 불필요. - 백엔드 JSON 전달 = 사실상 버퍼 메모리: 백엔드가 대화 이력을 DB에 저장하고 매 요청마다
request.context.history로 JSON 배열을 통째로 전달한다. 이는ConversationBufferMemory가 하는 것과 동일한 역할. AI 서버에서 별도로 대화를 축적·관리할 필요가 없다. - 토큰 관리도 서버 단 슬라이싱으로 충분:
ConversationTokenBufferMemory의 토큰 상한 자동 관리는, 백엔드에서 최근 N턴만 보내거나 AI 서버에서 받은 history를 슬라이싱하면 동일하게 달성 가능. - 엔티티/KG 메모리는 과도한 설계:
EntityMemory와KGMemory는 매 턴마다 LLM 추가 호출이 필요하며, 면접 도우미 서비스에서 엔티티 추적이나 지식 그래프 구축은 불필요. - 벡터 스토어 검색 메모리는 ADR-056으로 이미 구현:
VectorStoreRetrieverMemory의 역할을 ChromaDBinterview_feedback컨렉션 +get_few_shot_for_personality()로 직접 구현 완료. - SQLite 이중 저장 불필요: 백엔드가 자체 DB에 대화 저장 중. AI 서버에서 별도 SQLite를 운영하면 이중 저장이며, stateless 설계에 반함.
- 휘발성 메모리는 이미 사용 중:
SessionStoreInMemory 백엔드가 이 역할을 수행. 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 파이프라인 유지)을 선택합니다.
근거:
- 한국 이력서는 대부분 디자인 PDF: Canva, PPT, 노션 Export 등으로 만든 이력서는 텍스트 레이어가 없는 경우가 대부분.
PyPDFLoader나pdfplumber로는 텍스트 추출 불가 → 어차피 OCR이 필수. - CLOVA OCR + Gemini Vision이 최적 조합: CLOVA OCR은 한국어 특화 API로 높은 정확도를 제공하며, Gemini Vision은 디자인이 복잡한 PDF에서도 텍스트를 추출할 수 있는 강력한 fallback.
- 웹 서비스 아키텍처와 일치: 현재 프로젝트는 LangChain 에이전트가 아닌 각 서비스가 독립적으로 동작하는 웹 서비스. S3 다운로드, OCR, VectorDB 저장이 각각 독립 서비스로 분리되어 있어 LangChain Loader의 통합 파이프라인이 맞지 않음.
- 불필요한 의존성 회피:
langchain-community와unstructured는 무거운 패키지로, 빌드 시간과 Docker 이미지 크기를 증가시킴. 현재langchain_core만 선택적으로 사용 중인 가벼운 구조 유지. - 청킹 이미 자체 처리:
vectordb_service에서 500 tokens / 50 overlap 청킹을 직접 처리 중이므로RecursiveCharacterTextSplitter도입 불필요. - Option 3 (하이브리드)도 현시점에서 불필요: 텍스트 기반 PDF 비율이 낮아 pdfplumber 분기 추가의 실익이 적음. 향후 채용공고가 텍스트 PDF 비율이 높아지면 재검토.
Consequences (결과)
긍정적 영향:
- 추가 의존성 없이 현재 파이프라인 유지 → Docker 이미지 경량화
- CLOVA OCR + Gemini Vision fallback으로 한국어 PDF 처리 품질 보장
- 독립 서비스 구조 유지로 유지보수 용이
부정적 영향 / 트레이드오프:
- 텍스트 기반 PDF(채용공고 등)에서도 OCR API를 호출하여 소량 비용 발생
pdfplumber가requirements.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 키) 또는 텍스트 직접 입력 두 가지만 지원한다. 그러나 실제 사용 시나리오에서는 다음과 같은 요구가 있다:
- 첫 화면 (문서 입력): 사용자가 채용공고 URL(사람인, 잡코리아, 원티드 등)이나 포트폴리오 URL(GitHub, 개인 블로그 등)을 붙여넣으면 해당 페이지 내용을 자동으로 읽어와 분석에 활용
- 채팅 중: 사용자가 참고 자료 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 (채택) ⭐
- 첫 화면:
DocumentInput에url필드 추가 →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)을 선택합니다.
근거:
- 기존 LangChain 사용과 일관성: 이미
langchain_core로 LCEL 체인(create_chain,create_chat_chain)을 사용 중이며,langchain-community는 이 생태계의 자연스러운 확장. ADR-058에서 "LangChain Document Loader 미도입"으로 결정한 것은 PDF 텍스트 추출에 한정된 것이며, 웹 URL 로딩은 별개의 요구사항. - 두 기능을 하나의 도구로 통일: 첫 화면(문서 입력)과 채팅 중(URL 참고) 모두
WebBaseLoader로 처리하여 코드 일관성 유지. - Document 객체 활용:
WebBaseLoader는Document객체를 반환하므로, 향후RecursiveCharacterTextSplitter등 LangChain의 청킹/임베딩 도구와 직접 연결 가능. - 확장성:
WebBaseLoader외에도AsyncHtmlLoader,PlaywrightURLLoader등 LangChain의 다른 Loader로 쉽게 교체 가능. SPA 사이트 대응이 필요해지면PlaywrightURLLoader로 전환. - Tavily 분리: Tavily는 채용 트렌드 검색(검색 + 요약) 용도로 별도 도입 예정. URL 텍스트 추출이라는 단순 기능에 유료 API를 사용할 필요 없음.
구현 요약 (3.model 기준)
| 구분 | 파일 | 내용 |
|---|---|---|
| 스키마 | app/schemas/text_extract.py |
DocumentInput에 url: 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.txt에langchain-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단계 로드맵)을 선택합니다.
근거:
- API 명세 일치: 기존 API 문서에 명시된 "500 tokens, 50 overlap"을 실제 구현으로 반영. chunk_size=2000자(≈500 토큰 한국어 기준), chunk_overlap=200자(≈50 토큰).
- 임베딩 제한 자동 준수: 2,000자 청크는 Gemini-embedding-001의 2,048 토큰 제한을 안전하게 하회. 긴 포트폴리오(10페이지+)도 자동 분할되어 임베딩 실패 방지.
- RecursiveCharacter가 최적: 8가지 분할 방식 중 한국어 이력서/채용공고의 섹션 구분(
\n\n,\n)과 가장 잘 맞음. SemanticChunker는 추가 비용 대비 현 단계에서 이점 제한적. - 기존 인프라 활용:
add_documents_batch()가 이미 구현되어 있어 배치 저장에 추가 개발 최소화.langchain도 이미 설치됨. - 단계별 위험 분산: Phase 1(청킹) → Phase 2(RAG 최적화) → Phase 3(pdfplumber 전환)으로 나누어 각 단계를 검증 후 진행.
- 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.py에chunk_size,chunk_overlap설정 추가 - Phase 2: RAG 설정값 상향 + 하드코딩 제거
- Phase 3:
pdf_extract_service.py신규 생성 + pdfplumber 우선 분기 - (선택) SemanticChunker 벤치마크 (Phase 3 이후)
- (선택) 기존 데이터 마이그레이션 스크립트 작성
이력
| 날짜 | 변경 내용 |
|---|---|
| 2026-02-18 | 초기 작성 (8가지 분할 방식 비교 분석, RecursiveCharacterTextSplitter 채택, 3단계 로드맵) |