RAG &
VECTOR DB
검색 증강 생성 완전 가이드
데이터 수집 → 임베딩 → 벡터 DB → 배포
이 칼럼에서 다루는 내용
데이터 수집
YouTube API
임베딩
텍스트 → 벡터
벡터 DB
Turso · Chroma
RAG 체인
LangChain
배포
Vercel · Streamlit
비용 최적화
Gemini 무료 티어
⚠️ 면책고지: 이 칼럼은 2026년 3월 기준 기술 스택을 참고하여 작성되었습니다. AI 기술, API 비용, 서비스 정책은 빠르게 변화하므로 공식 문서를 반드시 확인하세요. 실제 프로덕션 배포 전에 비용과 보안을 충분히 검토하세요.
RAG란 무엇인가 — 검색 증강 생성
AI가 모르는 정보를 실시간으로 찾아서 답하게 만드는 핵심 기술. RAG(Retrieval-Augmented Generation)의 개념과 왜 필요한지부터 시작합니다.
RAG = 검색 증강 생성이란?
RAG(Retrieval-Augmented Generation)는 AI 언어 모델이 답변을 생성할 때 외부 지식 데이터베이스에서 관련 정보를 검색하여 활용하는 기술입니다. 일반 LLM(대형 언어 모델)은 학습 데이터의 한계로 최신 정보가 없거나 특정 도메인의 전문 지식이 부족합니다. RAG는 이 문제를 해결하기 위해 모델이 답변하기 전에 실시간으로 관련 문서를 검색해서 참고하게 만듭니다.
원본 문서 → 전처리 → 청킹 → 임베딩 → 벡터 DB 저장 → 검색 + LLM 생성
이 칼럼의 파이프라인은 가장 기본 형태인 Naive RAG(임베딩 유사도 검색 후 생성)에 해당합니다. Reranker·HyDE·멀티홉 검색 같은 Advanced RAG는 다루지 않으며, 다른 글이나 문서에서 만나면 "그건 이 칼럼보다 한 단계 더 나간 구성"이라고 이해하면 됩니다.
왜 RAG를 써야 하는가?
❌ 문제: LLM의 지식 한계
GPT·Claude 같은 LLM은 학습 마감일(cutoff) 이후의 정보를 전혀 모릅니다. 사내 문서·사규·제품 스펙은 당연히 없습니다.
✅ RAG로 해결
최신 문서를 벡터 DB에 넣으면 모델 재학습 없이 즉시 최신 정보로 답변합니다.
❌ 문제: 파인튜닝은 너무 비싸다
도메인 지식을 모델에 주입하려면 파인튜닝이 필요하고, 비용·시간·GPU가 대규모로 필요합니다. 데이터가 바뀔 때마다 반복해야 합니다.
✅ RAG로 해결
벡터 DB에 문서만 추가·삭제하면 끝. 모델은 그대로 두고 지식만 교체할 수 있습니다.
❌ 문제: 환각(Hallucination)
LLM은 모르는 내용을 그럴듯하게 지어냅니다. 출처 없는 답변은 신뢰할 수 없고, 기업 서비스에서는 치명적입니다.
✅ RAG로 해결
검색된 실제 문서를 컨텍스트로 제공하므로 "이 문서 기반으로 답변"이 가능해지고 출처 추적도 됩니다.
기존 데이터베이스 vs RAG 벡터 DB — 데이터 생김새 비교
RAG의 핵심은 데이터를 저장하는 방식 자체가 다르다는 점입니다. 기존 DB는 "정확한 값"을 찾고, 벡터 DB는 "의미가 비슷한 것"을 찾습니다.
| 구분 | PostgreSQL / SQLite | JSON (파일/NoSQL) | 벡터 DB (RAG) |
|---|---|---|---|
| 저장 단위 | 행(Row) — 정형화된 컬럼 | 키-값 오브젝트 | 청크(Chunk) + 벡터 배열 |
| 데이터 타입 | INT, VARCHAR, TIMESTAMP… | string, number, array… | float32[] (수백~수천 차원) |
| 검색 방식 | WHERE, JOIN, INDEX (정확 일치/범위) | 키 탐색, 필터 | 코사인 유사도 / 내적 (의미 유사도) |
| 질문 예시 | "id = 42인 사용자 이름은?" | "category가 news인 항목 전부" | "환불 정책이 궁금해요" → 유사 청크 반환 |
| 스키마 | 엄격한 고정 스키마 | 유연한 스키마 | id, vector, metadata(JSON) |
| 주요 용도 | 트랜잭션, 정형 데이터 | 설정, 반정형 데이터 | 시맨틱 검색, RAG, 추천 |
id | name | age | created_at ----|--------|-----|------------ 1 | 김철수 | 32 | 2024-01-15 2 | 이영희 | 28 | 2024-01-16 3 | 박지성 | 35 | 2024-01-17 -- 검색 SELECT * FROM users WHERE age > 30; -- 결과: id=1, id=3
정확한 값으로 정확히 일치하는 행을 찾음
{
"id": "doc_001",
"title": "환불 정책",
"category": "policy",
"content": "구매 후 7일 이내...",
"tags": ["환불", "정책"]
}
// 검색
db.find({
category: "policy"
})
// 키값 정확 일치만 가능키-값 일치 탐색. 의미 검색 불가
{
"id": "chunk_042",
"text": "환불은 7일 이내 가능...",
"vector": [
0.0312, -0.1847, 0.9023,
0.4411, -0.7782, 0.1105,
... (총 1536차원)
],
"metadata": {
"source": "policy.pdf",
"page": 3,
"date": "2024-01"
}
}텍스트 + 의미 벡터 + 메타데이터를 함께 저장
벡터의 진짜 모습 — 숫자 배열의 실체
임베딩 벡터는 사람이 읽을 수 없는 수백~수천 개의 소수(float32)로 이루어진 배열입니다. 컴퓨터 내부에서는 이 소수들이 32비트 이진수로 저장됩니다. 하나씩 뜯어봅니다.
입력 텍스트:
"환불은 7일 이내 신청 가능합니다"
↓ 임베딩 모델 (text-embedding-3-small)
출력 벡터 (1536차원 중 앞 10개):
[
0.03124, // 차원 0
-0.18472, // 차원 1
0.90231, // 차원 2
0.44118, // 차원 3
-0.77823, // 차원 4
0.11053, // 차원 5
-0.55601, // 차원 6
0.29847, // 차원 7
0.67392, // 차원 8
-0.04219, // 차원 9
... // (1526개 더)
]0.03124 를 32비트 이진수로:
[부호 1bit][지수 8bit][가수 23bit]
0 01111010 00000000000000000000000
↑ ↑ ↑
양수 지수값 소수 정밀도
2진수 전체:
00111101000000000000000000000000
16진수:
0x3D000000
─────────────────────────────────
1536개 float32 × 4byte
= 6,144 bytes ≈ 6KB / 청크 1개🗺️ 벡터 공간에서 의미가 비슷하면 거리가 가깝다
의미 공간 (1536차원을 2차원으로 축소해서 표현) ^ | [반품 절차 안내]● ●[교환 신청 방법] | | ●[환불은 7일 이내...] ← 질문과 가장 가까움 ✅ | | ★ "환불 어떻게 해요?" (질문 벡터) | | ●[배송 조회 방법] | | ●[회사 연혁 소개] ●[채용 공고] | └─────────────────────────────────────────────→ 거리가 가까울수록 의미가 유사 → 검색 결과로 반환
실제로는 1536차원이지만, 위처럼 의미가 비슷한 텍스트끼리 벡터 공간에서 가까운 위치에 클러스터됩니다. "환불 어떻게 해요?"라는 질문은 "반품"·"교환"·"환불 7일 이내" 같은 청크와 가깝고, "채용 공고"와는 멀리 배치됩니다.
벡터 DB 검색 로직 — 코사인 유사도
벡터 DB가 "의미가 비슷한 청크"를 찾을 때 사용하는 핵심 수식은 코사인 유사도(Cosine Similarity)입니다. 두 벡터가 이루는 각도가 작을수록(방향이 같을수록) 의미가 유사하다고 판단합니다.
📐 코사인 유사도 수식
A · B
cos(θ) = ──────
|A| |B|
A · B = Σ (Aᵢ × Bᵢ) ← 내적 (dot product)
|A| = √Σ Aᵢ² ← 벡터 A의 크기
|B| = √Σ Bᵢ² ← 벡터 B의 크기
결과 범위: -1 ~ 1
1.0 → 완전히 같은 방향 (매우 유사)
0.0 → 직각 (관련 없음)
-1.0 → 반대 방향 (반대 의미)# Python 예시
import numpy as np
def cosine_similarity(a, b):
return np.dot(a, b) / (
np.linalg.norm(a) * np.linalg.norm(b)
)
query_vec = embed("환불 어떻게 해요?")
chunk_vec = embed("환불은 7일 이내 가능")
score = cosine_similarity(query_vec, chunk_vec)
# → 0.91 (매우 유사)🔎 벡터 검색 전체 흐름
- 1질문 임베딩: 사용자 질문 "환불 어떻게 해요?"를 임베딩 모델로 변환 → 1536차원 float32 벡터 생성
- 2ANN 검색: 벡터 DB에서 저장된 모든 청크 벡터와 코사인 유사도 계산 (실제로는 HNSW·IVF 같은 근사 알고리즘으로 빠르게)
- 3Top-K 반환: 유사도 상위 K개(보통 3~5개) 청크를 점수와 함께 반환. 예: score=0.91, 0.87, 0.82
- 4컨텍스트 조립: 반환된 청크 텍스트를 LLM 프롬프트에 삽입: "다음 문서를 참고해서 답하세요: [청크1][청크2]..."
- 5LLM 생성: LLM이 검색된 실제 문서 내용을 근거로 정확한 답변 생성. 출처도 함께 반환 가능
⚡ 실제 벡터 DB는 전부 비교하지 않는다 — ANN (근사 최근접 탐색)
청크가 100만 개라면 모든 벡터와 코사인 유사도를 계산하면 너무 느립니다. 실제 벡터 DB는 ANN(Approximate Nearest Neighbor) 알고리즘을 사용해 정확도를 약간 희생하고 속도를 극적으로 높입니다.
HNSW
계층적 그래프 구조. Qdrant·Weaviate 기본값. 높은 정확도 + 빠른 속도
IVF
벡터를 클러스터로 묶어 해당 클러스터만 탐색. Faiss·Pinecone 사용
LSH
해시 함수로 유사 벡터를 같은 버킷에 배치. 초대용량에 유리
RAG 데이터 구축의 핵심 단계
데이터 수집
사내 문서, PDF, 웹페이지, DB, FAQ 등 도메인에 맞는 원천 데이터를 수집합니다.
데이터 전처리
수집한 데이터를 정제하고 노이즈(불필요한 특수문자, 중복 내용 등)를 제거합니다.
청킹(Chunking)
긴 문서를 AI가 처리하기 적합한 크기(예: 문자 기준 500 전후 또는 토큰 기준 설정)로 나눕니다.
임베딩(Embedding)
각 청크를 텍스트 의미가 담긴 숫자 배열(벡터)로 변환합니다.
벡터 DB 저장
생성된 벡터를 Pinecone, Qdrant, Chroma, Turso 등의 벡터 DB에 저장합니다.
검색 + 생성
질문을 벡터로 변환 → 유사 청크 검색 → LLM에 컨텍스트 제공 → 최종 답변 생성.
⚠️ RAG 구축 시 중요한 포인트
- •청크 크기가 너무 크면 검색 정확도가 낮아지고, 너무 작으면 문맥이 끊깁니다.
- •데이터 품질이 낮으면 답변도 낮아집니다. "Garbage in, Garbage out" 원칙.
- •메타데이터(출처, 날짜, 카테고리 등)를 함께 저장하면 검색 필터링이 용이합니다.
- •도메인 특화 임베딩 모델을 사용하면 검색 품질이 향상됩니다.
핵심 한 줄 요약
RAG는 AI가 올바른 정보를 찾아 정확하게 답할 수 있도록 지식 창고를 설계하고 채우는 작업입니다.
NotebookLM vs 직접 RAG 구축 — 무엇이 다른가
유튜버가 영상 300개·댓글 3,000개 데이터를 활용하려면? Google NotebookLM으로 충분할까, 아니면 직접 RAG를 구축해야 할까? 두 방식의 내부 구조부터 뜯어봅니다.
내부에서 무슨 일이 벌어지는가 — 구조 비교
파일 업로드 (PDF/구글 독스/텍스트)
↓
[Google 내부 임베딩 파이프라인]
- 청킹: Google이 결정 (제어 불가)
- 임베딩: Google 전용 모델 (비공개)
- 저장소: Google 서버 (접근 불가)
↓
[질문 입력]
↓
[Google 내부 검색 로직]
- 검색 파라미터: 비공개
- top-k 값: 비공개
- 필터링 방식: 비공개
↓
[Gemini 기반 답변 생성]
↓
답변 출력
❌ 벡터값 확인 불가
❌ 검색 로직 수정 불가
❌ 외부 API 연동 불가
❌ 자동화 불가데이터 수집 (YouTube API 자동화)
↓
[내가 설계한 파이프라인]
- 청킹: chunk_size=500, overlap=50
- 임베딩: text-embedding-3-small
- 저장소: 내 Turso / Chroma DB
↓
[질문 입력]
↓
[내가 설계한 검색 로직]
- 검색 방식: cosine similarity
- top_k: 5 (내가 설정)
- 메타 필터: type='video'
↓
[내가 선택한 LLM + 내 프롬프트]
↓
답변 출력 + 출처 URL 자동 첨부
✅ 벡터값 직접 확인·수정 가능
✅ 검색 파라미터 자유롭게 조정
✅ Slack·Discord 등 연동 가능
✅ 새 영상 업로드 시 자동 임베딩NotebookLM이 막히는 순간 — 실제 시나리오
📓 NotebookLM
매번 파일을 수동으로 다시 업로드해야 합니다. 300개 영상이라면 주기적인 수동 작업이 영구적으로 반복됩니다.
🔧 직접 RAG 구축
YouTube API webhook 또는 cron job으로 새 영상이 올라오면 자동으로 임베딩 → DB 저장. 한 번 설정하면 끝.
📓 NotebookLM
대화 창에서 직접 물어보는 것만 가능. 500개, 1,000개 규모가 되면 답변 품질이 불안정해집니다.
🔧 직접 RAG 구축
"기획 중복 체크 API"를 만들어 영상 기획 노션/시트에 버튼 하나로 연동. 자동으로 유사 영상 목록 반환.
📓 NotebookLM
업로드 가능한 파일 용량 제한이 있습니다. 대용량 댓글 데이터 전체를 한 번에 넣기 어렵습니다.
🔧 직접 RAG 구축
댓글 수 제한 없이 전부 벡터화. "가장 많이 요청된 주제 TOP 10"을 자동으로 집계하는 분석 API 구현 가능.
📓 NotebookLM
답변 스타일을 내 말투로 고정할 수 없습니다. 프롬프트에서 "내 스타일로 써줘"라고 해도 한계가 있습니다.
🔧 직접 RAG 구축
내 영상 300개 대본을 학습 데이터로 쓰고, 시스템 프롬프트에 "아래 대본들의 말투와 구성 방식을 따라라"를 고정. 일관된 내 스타일 유지.
항목별 비교 — 한눈에 정리
| 항목 | NotebookLM | 직접 RAG 구축 |
|---|---|---|
| 데이터 용량 | 파일 수·크기 제한 있음 | 제한 없음 (DB 용량만큼) |
| 신규 데이터 추가 | 수동 업로드 (반복 작업) | 자동화 가능 (API + cron) |
| 검색 로직 제어 | 불가 (Google 내부 처리) | chunk_size·top_k·필터 등 직접 설정 |
| 외부 서비스 연동 | 불가 | Slack·Discord·Notion·앱 등 API 연동 가능 |
| 말투·스타일 학습 | 프롬프트로 일부만 가능 | 시스템 프롬프트 + 예시 대본으로 일관성 유지 |
| 출처 URL 자동 첨부 | 제한적 | 메타데이터(video_id)로 타임스탬프 링크 자동 생성 |
| 비용 | 무료 (유료 플랜 있음) | API 비용 소량 (임베딩 약 $0.1~2/초기 1회) |
| 구축 난이도 | 매우 쉬움 (파일 업로드만) | 개발 필요 (이 칼럼이 그 가이드) |
| 데이터 보안·소유권 | Google 서버에 저장 | 내 서버·DB에 완전 소유 |
어떤 걸 선택해야 할까? — 의사결정 가이드
내 데이터로 AI를 활용하고 싶다
│
▼
자동화가 필요한가?
(새 데이터 자동 추가, API 연동 등)
│
┌───────┴───────┐
아니오 예
│ │
▼ ▼
문서가 50개 👉 직접 RAG 구축
미만인가? │
│ → 이 칼럼을 따라하세요
┌───┴───┐
예 아니오
│ │
▼ ▼
NotebookLM 데이터 소유권이
으로 충분 중요한가?
│
┌─────┴─────┐
아니오 예
│ │
▼ ▼
용량 제한 👉 직접 RAG 구축
확인 후 │
NotebookLM → 내 서버·DB에
시도 가능 완전 소유·통제📓 NotebookLM으로 충분한 경우
- ✓문서 수가 적고 (10~50개 수준) 자주 바뀌지 않는다
- ✓개인 공부·리서치 용도로 가끔씩만 사용한다
- ✓외부 서비스 연동이나 자동화가 전혀 필요 없다
- ✓개발 역량 없이 바로 사용하고 싶다
🔧 직접 RAG 구축이 필요한 경우
- ✓데이터가 계속 늘어나고 자동으로 반영되어야 한다
- ✓다른 서비스(Slack, 앱, 웹사이트)와 연동해야 한다
- ✓내 말투·스타일이 일관되게 반영된 결과가 필요하다
- ✓데이터를 내 서버에서 완전히 소유·통제해야 한다
유튜버가 직접 RAG를 구축하면 가능한 것들
나만의 YouTube 전략 AI
내 채널 데이터 기반으로 "조회수 잘 나왔던 주제 패턴이 뭐야?"에 실제 데이터로 답변합니다.
→ 메타데이터의 view_count를 필터로 써서 상위 20% 영상만 검색 범위로 지정 가능
대본 자동 생성 — 내 말투 그대로
영상 300개 대본을 학습시켜 내 특유의 말투, 구성 방식이 반영된 대본이 생성됩니다.
→ 시스템 프롬프트에 유사 대본 3~5개를 컨텍스트로 주입해 스타일 일관성 유지
댓글 자동 분류 및 답변
3,000개 댓글을 긍정/질문/개선 요청 유형으로 자동 분류하고 답변 초안을 생성합니다.
→ metadata의 type: "comment" 필터 + like_count 가중치로 핵심 댓글만 추출
콘텐츠 중복 방지
"이 주제 전에 다룬 적 있어?" 300개 영상을 전부 뒤져 겹치는 내용을 알려줍니다.
→ 코사인 유사도 0.85 이상이면 "유사 영상 있음" 경고, 해당 영상 링크 반환
시청자 니즈 기반 기획
댓글 3,000개에서 "가장 많이 요청된 영상 주제 TOP 10"을 자동으로 추출합니다.
→ "영상 만들어 주세요", "다뤄주세요" 패턴 검색 후 클러스터링으로 주제 집계
타임스탬프 기반 검색
"2024년 3월에 올린 영상 3분에 무슨 말 했지?"를 정확히 찾아줍니다.
→ SRT 자막 파싱 + metadata에 start_time 저장 → 답변에 ?t=195 타임스탬프 링크 자동 첨부
추천 구성 스택 — 전체 아키텍처
┌─────────────────────────────────────────────────────────────┐
│ 데이터 수집 레이어 │
│ │
│ YouTube Data API v3 │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 영상 정보 │ │ 댓글 수집 │ │ 대본 .txt 파일 │ │
│ │ title, desc │ │ 3,000개 │ │ 직접 보유분 │ │
│ │ view_count │ │ 감정/요청 등 │ │ │ │
│ └──────┬───────┘ └──────┬───────┘ └────────┬─────────┘ │
└─────────┼────────────────┼───────────────────┼─────────────┘
└───────────────┬┘ │
↓ ↓
┌─────────────────────────────────────────────────────────────┐
│ 전처리 & 임베딩 레이어 │
│ │
│ clean_text() → RecursiveCharacterTextSplitter(size=500) │
│ ↓ │
│ Gemini text-embedding-004 (무료 1500req/일) │
│ 또는 OpenAI text-embedding-3-small ($0.02/1M) │
│ ↓ │
│ float32[] 벡터 (768 or 1536차원) │
└─────────────────────────┬───────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 저장 레이어 │
│ │
│ 로컬 개발 │ Vercel 스택 │ 무료 온라인 │
│ Chroma DB │ Turso DB │ Qdrant Cloud │
│ (./chroma_db 폴더) │ (SQL + 벡터 통합) │ (1GB 무료) │
└─────────────────────────┬───────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ 검색 & 생성 레이어 │
│ │
│ 질문 입력 → 질문 임베딩 → cosine similarity 검색 │
│ → top-k=5 청크 반환 → LLM 프롬프트 조립 │
│ → GPT-4o-mini / Gemini Flash → 최종 답변 │
│ ↓ │
│ 출처: metadata의 video_id → youtube.com/watch?v=...&t=195 │
└─────────────────────────┬───────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ UI / 연동 레이어 │
│ │
│ 로컬 테스트 │ 기존 스택 통합 │ 메신저 연동 │
│ Streamlit │ Next.js + Vercel │ Slack Bot │
│ (streamlit run) │ (App Router API) │ Discord Bot │
└─────────────────────────────────────────────────────────────┘📌 목적별 추천 스택 조합
🧪 로컬 입문·테스트
- 수집: YouTube Data API
- 임베딩: Gemini 무료 티어
- 벡터 DB: Chroma (로컬 파일)
- UI: Streamlit
- 비용: 거의 $0
🚀 기존 Next.js 스택에 통합
- 수집: YouTube Data API + cron
- 임베딩: OpenAI text-embedding-3-small
- 벡터 DB: Turso (SQL + 벡터 통합)
- UI: Vercel 서버리스 API + React
- 비용: ~$1~5/월
🌐 무료 온라인 배포
- 수집: YouTube Data API
- 임베딩: Gemini 무료 티어
- 벡터 DB: Qdrant Cloud (1GB 무료)
- UI: Streamlit Cloud (무료)
- 비용: $0 (무료 플랜)
환경 세팅 A to Z — 제로부터 시작하기
Python 설치부터 API 키 발급·검증까지, RAG 구축에 필요한 환경을 처음부터 단계별로 설정합니다. 흔히 막히는 에러와 해결법도 함께 정리했습니다.
전체 파이프라인 한눈에 보기
[1단계] 환경 세팅 ← 지금 이 섹션
└─ Python 설치 → 가상환경 → 패키지 설치 → API 키 발급·검증
[2단계] 데이터 수집 ← 섹션 3
└─ YouTube API로 영상·댓글·커뮤니티·자막 자동 수집
[3단계] 데이터 전처리 ← 섹션 4
└─ 노이즈 제거 → 정제 → 메타데이터 붙이기
[4단계] 청킹 (Chunking) ← 섹션 5
└─ 긴 텍스트를 청크 단위로 분할 (문자 수 기준 500)
[5단계] 임베딩 + 벡터 DB ← 섹션 6~9
└─ 텍스트 → 숫자 벡터 → Chroma / Turso DB에 저장
[6단계] 검색 + 생성 ← 섹션 10~11
└─ 질문 벡터 → 유사 청크 검색 → LLM → 최종 답변
[7단계] UI 구축·배포 ← 섹션 12~13
└─ Streamlit / Next.js 웹 인터페이스 + 배포STEP 1 — Python 설치 및 가상환경
Python 3.12 이상을 권장합니다(3.10+도 대부분 동작). python.org에서 설치 후 아래 명령어로 버전을 확인합니다.가상환경은 필수입니다 — 전역(global) Python에 패키지를 설치하면 프로젝트 간 충돌이 발생합니다.
# 버전 확인 python3 --version # Python 3.12.x # 프로젝트 폴더 생성 mkdir my_rag_project cd my_rag_project # 가상환경 생성 python3 -m venv venv # 활성화 source venv/bin/activate # 프롬프트가 (venv)로 바뀌면 성공
# 버전 확인 python --version # Python 3.12.x # 프로젝트 폴더 생성 mkdir my_rag_project cd my_rag_project # 가상환경 생성 python -m venv venv # 활성화 venv\Scripts\Activate.ps1 # 프롬프트가 (venv)로 바뀌면 성공
# 버전 확인 python --version # Python 3.12.x # 프로젝트 폴더 생성 mkdir my_rag_project cd my_rag_project # 가상환경 생성 python -m venv venv # 활성화 venv\Scripts\activate.bat # 프롬프트가 (venv)로 바뀌면 성공
Windows PowerShell 실행 정책 에러가 나는 경우: PowerShell을 관리자 권한으로 열고Set-ExecutionPolicy RemoteSigned -Scope CurrentUser를 한 번 실행한 뒤 다시 시도하세요.
STEP 2 — 필요 패키지 설치 (역할 설명 포함)
# (venv) 가 앞에 붙은 상태에서 실행 # ── RAG 핵심 ────────────────────────────────────────────── pip install langchain # RAG 체인·파이프라인 프레임워크 pip install langchain-community # Chroma·FAISS 등 커뮤니티 통합 pip install langchain-openai # OpenAI 임베딩·LLM LangChain 래퍼 # ── 벡터 DB ─────────────────────────────────────────────── pip install chromadb # 로컬 벡터 DB (파일로 저장) # ── LLM / 임베딩 API ────────────────────────────────────── pip install openai # GPT-4o, text-embedding-3-small # ── 데이터 수집 ─────────────────────────────────────────── pip install google-api-python-client # YouTube Data API v3 클라이언트 pip install yt-dlp # 자막(SRT) 자동 다운로드 # ── UI ──────────────────────────────────────────────────── pip install streamlit # 웹 채팅 인터페이스 # ── 유틸리티 ────────────────────────────────────────────── pip install python-dotenv # .env 파일 로드 pip install tiktoken # 토큰 수 계산 (OpenAI 호환) pip install pandas # 데이터 분석·CSV 처리
📄 requirements.txt 생성 — 팀원·배포 환경 재현용
# 설치 완료 후 현재 환경을 파일로 저장 pip freeze > requirements.txt # 다른 환경에서 동일하게 설치 pip install -r requirements.txt # requirements.txt 예시 (버전은 설치 시점에 따라 다름) # langchain==0.3.x # langchain-community==0.3.x # chromadb==0.5.x # openai==1.x.x # streamlit==1.x.x
STEP 3 — API 키 발급
🤖 OpenAI API 키 발급
💳 소액 크레딧($5)을 먼저 충전해야 API 호출이 가능합니다. 영상 300개 임베딩 비용은 약 $0.1~0.5 수준입니다.
▶️ YouTube Data API 키 발급
🆓 YouTube API는 하루 10,000 유닛 무료입니다. 영상 300개 전체 수집은 약 900~1,200 유닛 소모 (1회 충분).
STEP 4 — .env 파일 작성 + .gitignore 설정
⚠️ .env 파일은 절대 GitHub에 올리지 마세요! API 키가 노출되면 타인이 과금을 유발하거나 계정이 정지될 수 있습니다.
.env
# OpenAI OPENAI_API_KEY=sk-proj-xxxxxxxxxxxxxxxx # YouTube Data API v3 YOUTUBE_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxxxxxx # (선택) Gemini — 무료 임베딩 사용 시 GOOGLE_API_KEY=AIzaxxxxxxxxxxxxxxxxxxxxxxxx # (선택) Turso — Vercel 스택 사용 시 TURSO_DATABASE_URL=libsql://내DB명.turso.io TURSO_AUTH_TOKEN=eyJhbGciOi...
.gitignore
# API 키 — 절대 커밋 금지 .env .env.local .env.* # 가상환경 — 용량 크고 OS마다 다름 venv/ .venv/ env/ # 벡터 DB — 재생성 가능, 용량 큼 chroma_db/ # 수집 원본 데이터 — 개인정보 포함 가능 data/raw/ # Python 캐시 __pycache__/ *.pyc *.pyo .pytest_cache/ # macOS 시스템 파일 .DS_Store
STEP 5 — 설치 검증 (환경이 제대로 됐는지 확인)
패키지 설치와 API 키 설정이 끝나면 아래 검증 스크립트를 실행합니다. 모두 ✅가 나와야 다음 단계로 넘어갈 수 있습니다.
# 0_check_env.py — 환경 검증 스크립트
import sys
def check(label: str, fn):
try:
result = fn()
print(f" ✅ {label}: {result}")
return True
except Exception as e:
print(f" ❌ {label}: {e}")
return False
print("\n🔍 환경 검증 시작...\n")
# 1. Python 버전
check("Python 버전", lambda: sys.version.split()[0])
# 2. 필수 패키지 임포트
import importlib
for pkg in ["langchain", "chromadb", "openai", "streamlit", "dotenv"]:
check(f"패키지 {pkg}", lambda p=pkg: importlib.import_module(p) and "OK")
# 3. .env 로드 + API 키 존재 확인
from dotenv import load_dotenv
import os
load_dotenv()
check("OPENAI_API_KEY 존재",
lambda: "설정됨 (" + os.getenv("OPENAI_API_KEY","")[:8] + "...)"
if os.getenv("OPENAI_API_KEY") else (_ for _ in ()).throw(Exception("키 없음")))
check("YOUTUBE_API_KEY 존재",
lambda: "설정됨 (" + os.getenv("YOUTUBE_API_KEY","")[:8] + "...)"
if os.getenv("YOUTUBE_API_KEY") else (_ for _ in ()).throw(Exception("키 없음")))
# 4. OpenAI API 실제 연결 테스트 (소량 토큰 소모)
from openai import OpenAI
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
check("OpenAI API 연결",
lambda: client.embeddings.create(
model="text-embedding-3-small", input="테스트"
).data[0].embedding[:3])
print("\n검증 완료! 모두 ✅이면 다음 단계로 진행하세요.")# 실행 python 0_check_env.py # 정상 출력 예시 🔍 환경 검증 시작... ✅ Python 버전: 3.12.3 ✅ 패키지 langchain: OK ✅ 패키지 chromadb: OK ✅ 패키지 openai: OK ✅ 패키지 streamlit: OK ✅ 패키지 dotenv: OK ✅ OPENAI_API_KEY 존재: 설정됨 (sk-proj-...) ✅ YOUTUBE_API_KEY 존재: 설정됨 (AIzaSyB...) ✅ OpenAI API 연결: [0.0312, -0.1847, 0.9023] 검증 완료! 모두 ✅이면 다음 단계로 진행하세요.
흔한 설치 에러 3종 — 해결법
❌ ModuleNotFoundError: No module named 'langchain'
원인: 가상환경이 활성화되지 않은 상태에서 pip install을 했거나, 다른 Python 인터프리터로 실행 중입니다.
# 가상환경 활성화 확인 — 프롬프트에 (venv)가 있어야 함 source venv/bin/activate # macOS/Linux venv\Scripts\activate.bat # Windows # 활성화된 상태에서 다시 설치 pip install langchain langchain-community langchain-openai
❌ openai.AuthenticationError: Incorrect API key provided
원인: .env 파일의 키가 잘못됐거나, load_dotenv()를 호출하지 않았거나, .env 파일 위치가 다릅니다.
# .env 파일이 스크립트와 같은 폴더에 있는지 확인 ls -la | grep .env # macOS/Linux dir | findstr .env # Windows # 키 앞뒤 공백·따옴표 없는지 확인 # 올바른 형식: OPENAI_API_KEY=sk-proj-xxxxxxxx # 잘못된 형식 (따옴표, 공백 금지): OPENAI_API_KEY = "sk-proj-xxxxxxxx"
❌ ERROR: Could not build wheels for chromadb
원인: C++ 빌드 도구가 없거나 Python 버전이 너무 낮습니다 (3.8 이하).
# Python 버전 확인 (3.10 이상 필요) python --version # macOS — Xcode 커맨드라인 도구 설치 xcode-select --install # Windows — Visual C++ Build Tools 설치 후 재시도 # https://visualstudio.microsoft.com/visual-cpp-build-tools/ # 또는 chromadb 최신 버전 시도 pip install --upgrade chromadb
최종 프로젝트 폴더 구조
my_rag_project/ ├── .env ← API 키 (절대 공개 금지) ├── .gitignore ← .env·venv·chroma_db 제외 ├── requirements.txt ← pip freeze > requirements.txt │ ├── 0_check_env.py ← 환경 검증 스크립트 (이 섹션) ├── 1_collect_data.py ← 영상·댓글·커뮤니티 수집 ├── 1b_collect_community.py ← 커뮤니티 게시글 수집 ├── 1c_collect_subtitles.py ← 자막(SRT) 자동 다운로드 ├── 2_preprocess.py ← 전처리 ├── 3_embed_store.py ← 임베딩 + 벡터 DB 저장 ├── 4_rag_chain.py ← RAG 검색+생성 로직 ├── 5_app.py ← Streamlit UI │ ├── data/ │ ├── raw/ │ │ ├── videos.json ← 영상 정보 │ │ ├── comments.json ← 댓글 │ │ ├── community.json ← 커뮤니티 게시글 │ │ ├── subtitles/ ← SRT 자막 파일들 │ │ └── scripts/ ← 직접 보유 대본 .txt │ └── processed/ │ └── all_data.json ← 전처리·통합된 데이터 │ └── chroma_db/ ← 벡터 DB (자동 생성, git 제외)
✅ 환경 세팅 완료 체크리스트
데이터 수집 — YouTube API — 영상·댓글·커뮤니티·자막
YouTube Data API를 활용해 내 채널의 영상 정보, 댓글, 커뮤니티 게시글, 자막을 자동으로 수집합니다. 무엇을 수집할 수 있고 무엇이 불가능한지, API 할당량 구조까지 실제 기준으로 정리합니다.
YouTube API로 수집 가능한 것 vs 불가능한 것
⚠️ API 할당량(Quota) — 반드시 알아야 할 비용 구조
YouTube Data API v3는 하루 10,000 유닛(unit)이 무료 기본 할당량입니다. 각 API 호출은 유닛을 소모하므로 300개 영상 전체 수집 시 할당량을 미리 계산해야 합니다.
| API 메서드 | 소모 유닛 | 300개 영상 기준 | 비고 |
|---|---|---|---|
| search.list | 100 유닛/호출 | 600 유닛 (6회 × 50개) | 영상 ID 수집용 |
| videos.list | 1 유닛/호출 | 6 유닛 (6회 × 50개 배치) | 영상 상세 정보 |
| commentThreads.list | 1 유닛/호출 | 300 유닛 (영상당 1회) | 댓글 1페이지 |
| activities.list | 1 유닛/호출 | 수십 유닛 | 커뮤니티 게시글 |
| 합계 (1회 전체 수집) | — | 약 900~1,200 유닛 | 하루 10,000 유닛 이내 ✅ |
💡 댓글이 많은 영상에서 여러 페이지를 가져올 경우 유닛 소모가 늘어납니다. 초기 1회 전체 수집은 하루 할당량 안에서 가능하지만, 이후 증분 수집(새 영상만 추가)으로 전환하면 일일 유닛 소모를 10~50 유닛 수준으로 줄일 수 있습니다.
🔑 내 채널 ID 찾는 방법
방법 1 — YouTube Studio에서 직접 확인
YouTube Studio → 설정 → 채널 → 고급 설정
→ 채널 ID: UCxxxxxxxxxxxxxxxxxxxxxxxxx
방법 2 — 채널 URL에서 추출
https://www.youtube.com/channel/UCxxxxxxxxxxxxxxxxxxxxxxxxx
↑ 이 부분이 채널 ID
방법 3 — @핸들로 API 조회
GET https://www.googleapis.com/youtube/v3/channels
?part=id&forHandle=@큐레이터단비&key=YOUR_API_KEY
→ 응답의 items[0].id 값 사용채널 URL이 youtube.com/@핸들명 형식이라면 방법 3으로 채널 ID(UC로 시작하는 24자리)를 먼저 얻어야 합니다.
📌 수집 데이터 범위 — RAG에 넣을 원천 데이터
영상 정보
- ·제목
- ·설명란 (대본 대용)
- ·조회수·좋아요
- ·게시일
댓글
- ·댓글 내용
- ·작성자
- ·좋아요 수
- ·게시일
커뮤니티 게시글
- ·게시글 텍스트
- ·이미지 설명
- ·좋아요 수
- ·게시일
자막·대본 (별도)
- ·SRT 파일 (yt-dlp)
- ·Whisper AI 변환
- ·.txt 직접 보유
- ·타임스탬프 포함
1_collect_data.py — 영상·댓글 수집 (에러 핸들링 강화)
# 1_collect_data.py
import os, json, time
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from dotenv import load_dotenv
load_dotenv()
YOUTUBE_API_KEY = os.getenv("YOUTUBE_API_KEY")
CHANNEL_ID = "UCxxxxxxxxxxxxxxxxxxxxxxxx" # 내 채널 ID
youtube = build("youtube", "v3", developerKey=YOUTUBE_API_KEY)
# ─── 1. 채널의 모든 영상 ID 수집 ───────────────────────────
def get_all_video_ids(channel_id: str) -> list[str]:
"""search.list API → 유닛 소모 100/호출. 50개씩 페이징."""
video_ids = []
next_page_token = None
while True:
try:
res = youtube.search().list(
channelId=channel_id,
part="id",
type="video",
maxResults=50,
pageToken=next_page_token,
).execute()
except HttpError as e:
print(f"[ERROR] search.list 실패: {e}")
break
for item in res.get("items", []):
vid = item.get("id", {}).get("videoId")
if vid:
video_ids.append(vid)
next_page_token = res.get("nextPageToken")
if not next_page_token:
break
time.sleep(0.5) # 초당 호출 제한 방지
print(f"수집된 영상 ID: {len(video_ids)}개")
return video_ids
# ─── 2. 영상 상세 정보 수집 (50개 배치) ─────────────────────
def get_video_details(video_ids: list[str]) -> list[dict]:
"""videos.list API → 유닛 소모 1/호출. 50개 배치 처리."""
videos = []
for i in range(0, len(video_ids), 50):
batch = video_ids[i : i + 50]
try:
res = youtube.videos().list(
part="snippet,statistics",
id=",".join(batch),
).execute()
except HttpError as e:
print(f"[ERROR] videos.list 배치 {i//50+1} 실패: {e}")
continue
for item in res.get("items", []):
snippet = item.get("snippet", {})
stats = item.get("statistics", {})
videos.append({
"video_id": item["id"],
"title": snippet.get("title", ""),
"description": snippet.get("description", ""),
"published_at": snippet.get("publishedAt", ""),
"view_count": int(stats.get("viewCount", 0)),
"like_count": int(stats.get("likeCount", 0)),
"comment_count":int(stats.get("commentCount",0)),
"type": "video",
})
time.sleep(0.2)
print(f"수집된 영상 상세: {len(videos)}개")
return videos
# ─── 3. 댓글 수집 ────────────────────────────────────────────
def get_comments(
video_ids: list[str],
max_per_video: int = 20,
) -> list[dict]:
"""
commentThreads.list → 유닛 소모 1/호출.
댓글 비활성화 영상은 HttpError 403으로 처리 후 스킵.
"""
all_comments = []
for idx, video_id in enumerate(video_ids):
try:
res = youtube.commentThreads().list(
part="snippet",
videoId=video_id,
maxResults=max_per_video,
order="relevance", # 인기순 (최신순: "time")
).execute()
except HttpError as e:
# 댓글 사용 중지 영상 → 조용히 스킵
if e.resp.status in (403, 404):
continue
print(f"[ERROR] 댓글 수집 실패 ({video_id}): {e}")
continue
for item in res.get("items", []):
c = item["snippet"]["topLevelComment"]["snippet"]
all_comments.append({
"video_id": video_id,
"comment": c.get("textDisplay", ""),
"author": c.get("authorDisplayName", ""),
"like_count": int(c.get("likeCount", 0)),
"published_at": c.get("publishedAt", ""),
"type": "comment",
})
if (idx + 1) % 50 == 0:
print(f" 댓글 수집 진행 중... {idx+1}/{len(video_ids)}")
time.sleep(0.3)
print(f"수집된 댓글: {len(all_comments)}개")
return all_comments
# ─── 4. 실행 및 저장 ─────────────────────────────────────────
if __name__ == "__main__":
os.makedirs("data/raw", exist_ok=True)
# (1) 영상 ID 수집
video_ids = get_all_video_ids(CHANNEL_ID)
# (2) 영상 상세 정보 저장
videos = get_video_details(video_ids)
with open("data/raw/videos.json", "w", encoding="utf-8") as f:
json.dump(videos, f, ensure_ascii=False, indent=2)
print("→ data/raw/videos.json 저장 완료")
# (3) 댓글 저장
comments = get_comments(video_ids)
with open("data/raw/comments.json", "w", encoding="utf-8") as f:
json.dump(comments, f, ensure_ascii=False, indent=2)
print("→ data/raw/comments.json 저장 완료")
print(f"\n✅ 수집 완료: 영상 {len(videos)}개 | 댓글 {len(comments)}개")1b_collect_community.py — 커뮤니티 게시글 수집
커뮤니티 탭 게시글은 activities API로 수집합니다. 단, 이미지 전용 게시글은 텍스트가 없거나 매우 짧을 수 있으므로len(text) < 20 이하는 필터링합니다.
# 1b_collect_community.py
import os, json, time
from googleapiclient.discovery import build
from googleapiclient.errors import HttpError
from dotenv import load_dotenv
load_dotenv()
youtube = build("youtube", "v3", developerKey=os.getenv("YOUTUBE_API_KEY"))
CHANNEL_ID = "UCxxxxxxxxxxxxxxxxxxxxxxxx"
def get_community_posts(channel_id: str) -> list[dict]:
"""
activities.list API로 커뮤니티 게시글 수집.
eventType="social"이 커뮤니티 포스트에 해당.
"""
posts = []
next_page_token = None
while True:
try:
res = youtube.activities().list(
part="snippet,contentDetails",
channelId=channel_id,
maxResults=50,
pageToken=next_page_token,
).execute()
except HttpError as e:
print(f"[ERROR] activities.list 실패: {e}")
break
for item in res.get("items", []):
snippet = item.get("snippet", {})
# 커뮤니티 게시글만 필터
if snippet.get("type") != "social":
continue
text = snippet.get("description", "").strip()
if len(text) < 20: # 너무 짧은 게시글 스킵
continue
posts.append({
"post_id": item["id"],
"text": text,
"published_at": snippet.get("publishedAt", ""),
"type": "community",
})
next_page_token = res.get("nextPageToken")
if not next_page_token:
break
time.sleep(0.3)
print(f"수집된 커뮤니티 게시글: {len(posts)}개")
return posts
if __name__ == "__main__":
os.makedirs("data/raw", exist_ok=True)
posts = get_community_posts(CHANNEL_ID)
with open("data/raw/community.json", "w", encoding="utf-8") as f:
json.dump(posts, f, ensure_ascii=False, indent=2)
print("→ data/raw/community.json 저장 완료")1c_collect_subtitles.py — 자막 자동 수집 (yt-dlp)
YouTube Data API는 자막을 제공하지 않습니다.yt-dlp로 자동 생성 자막(ASR) 또는 수동 업로드 자막을 SRT 파일로 일괄 다운로드할 수 있습니다. 이 파일들은 이후 타임스탬프 RAG(section-15)에서 그대로 활용됩니다.
# 먼저 설치
# pip install yt-dlp
# 1c_collect_subtitles.py
import os, json, subprocess
from pathlib import Path
SUBTITLES_DIR = Path("data/raw/subtitles")
SUBTITLES_DIR.mkdir(parents=True, exist_ok=True)
def download_subtitles_for_video(video_id: str, lang: str = "ko") -> bool:
"""
단일 영상의 자막을 SRT로 다운로드.
--write-subs : 수동 업로드 자막
--write-auto-subs : 자동 생성 자막 (없을 때 폴백)
--skip-download : 영상 파일은 받지 않음 (자막만)
"""
url = f"https://www.youtube.com/watch?v={video_id}"
output_template = str(SUBTITLES_DIR / f"{video_id}.%(ext)s")
cmd = [
"yt-dlp",
"--write-subs",
"--write-auto-subs",
"--sub-langs", lang,
"--sub-format", "srt",
"--convert-subs", "srt",
"--skip-download",
"-o", output_template,
url,
]
result = subprocess.run(cmd, capture_output=True, text=True)
# SRT 파일이 생성됐는지 확인
srt_files = list(SUBTITLES_DIR.glob(f"{video_id}*.srt"))
return len(srt_files) > 0
def batch_download_subtitles(video_ids: list[str], lang: str = "ko"):
"""영상 ID 목록 전체의 자막을 순차 다운로드."""
success, fail = 0, 0
for idx, video_id in enumerate(video_ids):
ok = download_subtitles_for_video(video_id, lang)
if ok:
success += 1
else:
fail += 1 # 자막이 없는 영상은 건너뜀 (정상)
if (idx + 1) % 20 == 0:
print(f" 자막 다운로드 진행 중... {idx+1}/{len(video_ids)}")
import time; time.sleep(1.0) # yt-dlp 과호출 방지
print(f"\n✅ 자막 다운로드 완료: 성공 {success}개 | 자막 없음 {fail}개")
print(f" 저장 위치: {SUBTITLES_DIR}/")
if __name__ == "__main__":
# videos.json에서 video_id 목록 로드
with open("data/raw/videos.json", encoding="utf-8") as f:
videos = json.load(f)
video_ids = [v["video_id"] for v in videos]
batch_download_subtitles(video_ids, lang="ko")
# 영어 자막도 필요하면: batch_download_subtitles(video_ids, lang="en")자막이 없는 영상은 건너뜁니다. 자막을 직접 만들고 싶다면openai-whisper로 영상 오디오를 텍스트로 변환할 수 있습니다 (별도 GPU 권장 / Whisper large-v3 기준 분당 약 10초 처리). 자막 파일 활용 방법은 섹션 15 — 타임스탬프 RAG에서 다룹니다.
수집 결과 — 실제 데이터 생김새
{
"video_id": "dQw4w9WgXcQ",
"title": "썸네일 클릭률 높이는 법",
"description": "안녕하세요 오늘은...",
"published_at": "2024-03-15T09:00:00Z",
"view_count": 150000,
"like_count": 4200,
"comment_count": 312,
"type": "video"
}{
"video_id": "dQw4w9WgXcQ",
"comment": "이 방법 써봤는데
진짜 CTR 2배 됐어요!",
"author": "시청자닉네임",
"like_count": 87,
"published_at": "2024-03-16T14:23:00Z",
"type": "comment"
}{
"post_id": "Ugkx...",
"text": "다음 영상 주제 투표!
①썸네일 A/B 테스트 방법
②쇼츠 알고리즘 분석
어떤 게 더 궁금하세요?",
"published_at": "2024-03-20T08:00:00Z",
"type": "community"
}# 수집 완료 후 폴더 구조
data/ ├── raw/ │ ├── videos.json ← 영상 정보 │ ├── comments.json ← 댓글 │ ├── community.json ← 커뮤니티 게시글 │ ├── subtitles/ │ │ ├── dQw4w9WgXcQ.ko.srt │ │ ├── aBcDeFgHiJk.ko.srt │ │ └── ... │ └── scripts/ ← 직접 보유 대본 │ ├── 영상001_대본.txt │ └── 영상002_대본.txt └── processed/ ← 전처리 후 저장 (다음 단계)
💡 대본 직접 추가 방법
YouTube API는 영상 대본을 직접 제공하지 않습니다. 직접 보유한 대본이 있다면data/raw/scripts/폴더에 .txt 형식으로 넣으면 다음 전처리 단계에서 자동으로 읽어옵니다.
# 파일명 규칙 예시 영상001_썸네일클릭률.txt 영상002_알고리즘분석.txt # → metadata의 filename으로 저장됨
♻️ 증분 수집 — 새 영상만 추가하는 방법
최초 1회 전체 수집 후에는 매번 전체를 다시 받을 필요가 없습니다. 이미 수집한 video_id 목록과 비교해서 새 영상만 추가하면 API 할당량을 크게 아낄 수 있습니다.
# 증분 수집 핵심 로직
import json
# 기존에 수집된 video_id 목록 로드
with open("data/raw/videos.json", encoding="utf-8") as f:
existing = {v["video_id"] for v in json.load(f)}
# 현재 채널에서 전체 ID 조회
all_ids = get_all_video_ids(CHANNEL_ID)
# 새로운 영상만 필터링
new_ids = [vid for vid in all_ids if vid not in existing]
print(f"새 영상 {len(new_ids)}개 발견")
if new_ids:
new_videos = get_video_details(new_ids)
new_comments = get_comments(new_ids)
# 기존 데이터에 append 후 저장
# → 임베딩도 새 항목만 처리 (비용 최소화)데이터 전처리 — 품질이 곧 답변 품질
수집한 원본 데이터를 정제하고 통일된 형식으로 만드는 단계입니다. "Garbage in, Garbage out" — 전처리 품질이 최종 답변 품질을 결정합니다. 데이터 타입마다 노이즈의 종류가 다르고, 정제 전략도 달라집니다.
전처리 전 vs 후 — 실제 데이터 비교
전처리하지 않은 원본 텍스트를 그대로 임베딩하면 노이즈가 벡터에 섞여 유사도 계산이 왜곡됩니다. 아래는 실제 YouTube 영상 설명란과 댓글의 원본/정제 후 차이입니다.
❌ 전처리 전 (원본)
안녕하세요! 🎬✨ 오늘은 썸네일 클릭률(CTR) 높이는 법을 알려드립니다!! 👇 아래 링크 확인해주세요👇 https://youtu.be/abc123 https://link.tree/danbi 📧 비즈니스 문의: danbi@email.com #유튜브 #썸네일 #CTR #크리에이터 #유튜버팁 ───────────────────── 🔔 구독·좋아요·알림설정!
✅ 전처리 후
안녕하세요 오늘은 썸네일 클릭률 CTR 높이는 법을 알려드립니다 비즈니스 문의 danbi@email.com
→ URL·이모지·해시태그·구분선·CTA 문구 제거 → 핵심 내용만 남음
❌ 전처리 전 (원본)
ㅋㅋㅋㅋ 진짜 ㅇㅈ ㅠㅠ 이방법 써봤는데 CTR 2배됨ㄷㄷ
👍👍👍 @큐레이터단비 감사해요!!!! http://spam.link/buy✅ 전처리 후
진짜 이방법 써봤는데 CTR 2배됨 큐레이터단비 감사해요
→ 반복 이모지·스팸 URL·과도한 줄임말 처리 → 의미 있는 피드백만 남음
❌ 전처리 전 (원본)
📢📢 [공지] 다음영상 주제 투표!!!!! 🗳️🗳️ ①썸네일 A/B테스트 ②쇼츠알고리즘 ③수익화조건 댓글로 번호 달아주세요~ ❤️❤️❤️❤️❤️❤️
✅ 전처리 후
공지 다음영상 주제 투표 썸네일 A,B테스트 쇼츠알고리즘 수익화조건 댓글로 번호 달아주세요
→ 이모지·느낌표·공백 정제 → 투표 내용 자체는 보존
데이터 타입별 정제 전략
| 데이터 타입 | 주요 노이즈 | 정제 전략 | 최소 길이 |
|---|---|---|---|
| 영상 설명란 | URL·이모지·해시태그·CTA 문구·구분선 | HTML 제거 → URL 제거 → 이모지 제거 → 해시태그 제거 | 30자 |
| 댓글 | 스팸 URL·반복 이모지·극도로 짧은 반응 | 위와 동일 + like_count 0 이하 스팸 필터 가능 | 10자 |
| 커뮤니티 게시글 | 이모지 과다·느낌표 과다 | 이모지 제거 → 반복 구두점 정리 | 20자 |
| 대본 .txt | 타임코드 텍스트·방송 큐 표시·무의미한 메모 | 타임코드 패턴([00:00]) 제거 → 최소 정제만 | 50자 |
| SRT 자막 | 타임스탬프 헤더·숫자 인덱스 | 타임스탬프는 메타데이터로 분리 → 텍스트만 추출 | 5자/세그먼트 |
clean_text() — 정규식 한 줄씩 해설
import re
def clean_text(text: str) -> str:
if not text or not text.strip():
return ""
# 1. HTML 태그 제거 (<br>, <a href="...">, <strong> 등)
text = re.sub(r'<[^>]+>', ' ', text)
# 2. URL 제거 (http://, https://, www. 로 시작하는 모든 URL)
text = re.sub(r'https?://\S+|www\.\S+', '', text)
# 3. 이메일 주소 제거
text = re.sub(r'[\w.+-]+@[\w-]+\.[\w.-]+', '', text)
# 4. 이모지 제거 (유니코드 이모지 범위 전체)
text = re.sub(
r'[\U0001F600-\U0001F64F' # 얼굴 이모지
r'\U0001F300-\U0001F5FF' # 기호·픽토그램
r'\U0001F680-\U0001F6FF' # 교통·지도
r'\U0001F1E0-\U0001F1FF' # 국기
r'\u2600-\u26FF' # 기타 기호
r'\u2700-\u27BF]+', # 딩뱃 기호
'', text
)
# 5. 해시태그 제거 (#유튜브, #CTR 등)
text = re.sub(r'#\w+', '', text)
# 6. 반복 구두점 정리 (!!!! → !, ㅋㅋㅋ → ㅋ)
text = re.sub(r'([!?.,ㅋㅎ])\1{2,}', r'\1', text)
# 7. 허용 문자만 유지 (한글·영문·숫자·기본 문장부호)
text = re.sub(r'[^\w\s가-힣.,!?()\"\'%-]', ' ', text)
# 8. 연속 공백·줄바꿈 정리
text = re.sub(r'\s+', ' ', text).strip()
return text이모지 제거가 필요한 이유
이모지는 임베딩 모델이 유니코드 코드포인트로 처리해 의미 없는 차원을 낭비합니다. "👍👍👍 좋아요" → "좋아요"가 훨씬 깔끔한 벡터를 만듭니다.
해시태그 제거가 필요한 이유
"#유튜브 #썸네일 #CTR"이 남아있으면 모든 영상 청크가 이 키워드를 포함해 검색 시 불필요하게 상위에 올라옵니다.
반복 구두점 정리
"진짜!!!!!!!" 와 "진짜!"는 같은 의미지만 토큰 낭비가 다릅니다. 청크 크기(500자) 안에 실제 내용을 더 많이 담으려면 반복은 줄여야 합니다.
URL·이메일은 항상 제거?
비즈니스 문의 이메일은 RAG 검색에 불필요합니다. 단, "링크 참고" 같은 문맥이 중요하다면 URL 도메인만 남기는 방식으로 조정 가능합니다.
2_preprocess.py — 전체 코드 (커뮤니티·자막 처리 포함)
# 2_preprocess.py
import json, re, os, glob
from pathlib import Path
# ─── 공통 정제 함수 ─────────────────────────────────────────
def clean_text(text: str) -> str:
if not text or not text.strip():
return ""
text = re.sub(r'<[^>]+>', ' ', text) # HTML 태그
text = re.sub(r'https?://\S+|www\.\S+', '', text) # URL
text = re.sub(r'[\w.+-]+@[\w-]+\.[\w.-]+', '', text) # 이메일
text = re.sub( # 이모지
r'[\U0001F600-\U0001F64F\U0001F300-\U0001F5FF'
r'\U0001F680-\U0001F6FF\U0001F1E0-\U0001F1FF'
r'\u2600-\u26FF\u2700-\u27BF]+', '', text)
text = re.sub(r'#\w+', '', text) # 해시태그
text = re.sub(r'([!?.,ㅋㅎ])\1{2,}', r'\1', text) # 반복 구두점
text = re.sub(r'[^\w\s가-힣.,!?()\"\'%-]', ' ', text) # 허용 문자
text = re.sub(r'\s+', ' ', text).strip() # 공백 정리
return text
# ─── 1. 영상 설명란 처리 ───────────────────────────────────
def process_videos() -> list[dict]:
path = Path("data/raw/videos.json")
if not path.exists():
print("⚠️ data/raw/videos.json 없음, 스킵")
return []
with open(path, encoding="utf-8") as f:
videos = json.load(f)
processed = []
for v in videos:
title = clean_text(v.get("title", ""))
description = clean_text(v.get("description", ""))
if len(description) < 30: # 설명이 너무 짧으면 스킵
continue
processed.append({
"id": f"video_{v['video_id']}",
"content": f"[영상 제목] {title}\n\n[내용]\n{description}",
"metadata": {
"type": "video",
"video_id": v["video_id"],
"title": title,
"view_count": int(v.get("view_count", 0)),
"like_count": int(v.get("like_count", 0)),
"published_at": v.get("published_at", ""),
},
})
print(f"영상 처리 완료: {len(processed)}개")
return processed
# ─── 2. 댓글 처리 ─────────────────────────────────────────
def process_comments(min_likes: int = 0) -> list[dict]:
"""
min_likes: 이 값 이상의 좋아요를 받은 댓글만 포함.
기본값 0 = 전부 포함. 스팸이 많으면 1~2로 올리세요.
"""
path = Path("data/raw/comments.json")
if not path.exists():
print("⚠️ data/raw/comments.json 없음, 스킵")
return []
with open(path, encoding="utf-8") as f:
comments = json.load(f)
processed, skipped = [], 0
for i, c in enumerate(comments):
comment = clean_text(c.get("comment", ""))
like_count = int(c.get("like_count", 0))
if len(comment) < 10: # 너무 짧은 댓글
skipped += 1
continue
if like_count < min_likes: # 좋아요 필터
skipped += 1
continue
processed.append({
"id": f"comment_{i}",
"content": f"[댓글] {comment}",
"metadata": {
"type": "comment",
"video_id": c.get("video_id", ""),
"like_count": like_count,
"published_at": c.get("published_at", ""),
},
})
print(f"댓글 처리 완료: {len(processed)}개 (스킵 {skipped}개)")
return processed
# ─── 3. 커뮤니티 게시글 처리 ───────────────────────────────
def process_community() -> list[dict]:
path = Path("data/raw/community.json")
if not path.exists():
print("⚠️ data/raw/community.json 없음, 스킵")
return []
with open(path, encoding="utf-8") as f:
posts = json.load(f)
processed = []
for p in posts:
text = clean_text(p.get("text", ""))
if len(text) < 20:
continue
processed.append({
"id": f"community_{p.get('post_id', len(processed))}",
"content": f"[커뮤니티] {text}",
"metadata": {
"type": "community",
"post_id": p.get("post_id", ""),
"published_at": p.get("published_at", ""),
},
})
print(f"커뮤니티 처리 완료: {len(processed)}개")
return processed
# ─── 4. 직접 보유 대본 .txt 처리 ──────────────────────────
def process_scripts() -> list[dict]:
script_files = list(Path("data/raw/scripts").glob("*.txt"))
if not script_files:
print("⚠️ data/raw/scripts/*.txt 없음, 스킵")
return []
# 타임코드 패턴 제거 — [00:00], (00:00:00) 형식
TIMECODE_RE = re.compile(r'\[?\d{1,2}:\d{2}(?::\d{2})?\]?')
processed = []
for filepath in script_files:
filename = filepath.stem # 확장자 제외 파일명
raw = filepath.read_text(encoding="utf-8")
raw = TIMECODE_RE.sub('', raw) # 타임코드 제거
content = clean_text(raw)
if len(content) < 50:
continue
processed.append({
"id": f"script_{filename}",
"content": f"[대본: {filename}]\n\n{content}",
"metadata": {
"type": "script",
"filename": filename,
},
})
print(f"대본 처리 완료: {len(processed)}개")
return processed
# ─── 5. SRT 자막 처리 (타임스탬프 메타데이터 보존) ──────────
def process_subtitles() -> list[dict]:
srt_files = list(Path("data/raw/subtitles").glob("*.srt"))
if not srt_files:
print("⚠️ data/raw/subtitles/*.srt 없음, 스킵")
return []
SRT_BLOCK_RE = re.compile(
r'\d+\n' # 인덱스 번호
r'(\d{2}:\d{2}:\d{2},\d{3})' # 시작 시간
r' --> '
r'(\d{2}:\d{2}:\d{2},\d{3})\n' # 종료 시간
r'((?:.+\n?)+)', # 자막 텍스트
re.MULTILINE
)
processed = []
for srt_path in srt_files:
video_id = srt_path.stem.split('.')[0] # "dQw4w9WgXcQ.ko" → "dQw4w9WgXcQ"
raw_srt = srt_path.read_text(encoding="utf-8", errors="ignore")
segments = SRT_BLOCK_RE.findall(raw_srt)
# 10개 세그먼트씩 묶어서 하나의 청크로 만들기
CHUNK_SIZE = 10
for i in range(0, len(segments), CHUNK_SIZE):
batch = segments[i : i + CHUNK_SIZE]
start_time = batch[0][0] # 첫 세그먼트 시작 시간
end_time = batch[-1][1] # 마지막 세그먼트 종료 시간
text = ' '.join(
clean_text(seg[2].replace('\n', ' '))
for seg in batch
if clean_text(seg[2]) # 빈 세그먼트 스킵
)
if len(text) < 10:
continue
processed.append({
"id": f"subtitle_{video_id}_{i // CHUNK_SIZE}",
"content": f"[자막 {start_time}~{end_time}] {text}",
"metadata": {
"type": "subtitle",
"video_id": video_id,
"start_time": start_time, # → 타임스탬프 링크용
"end_time": end_time,
"chunk_index": i // CHUNK_SIZE,
},
})
print(f"자막 처리 완료: {len(processed)}개 청크")
return processed
# ─── 실행 ──────────────────────────────────────────────────
if __name__ == "__main__":
Path("data/processed").mkdir(parents=True, exist_ok=True)
all_data: list[dict] = []
all_data.extend(process_videos())
all_data.extend(process_comments(min_likes=0)) # 스팸 많으면 min_likes=1
all_data.extend(process_community())
all_data.extend(process_scripts())
all_data.extend(process_subtitles())
out_path = Path("data/processed/all_data.json")
with open(out_path, "w", encoding="utf-8") as f:
json.dump(all_data, f, ensure_ascii=False, indent=2)
# 타입별 통계 출력
from collections import Counter
counts = Counter(d["metadata"]["type"] for d in all_data)
print(f"\n✅ 전처리 완료!")
print(f" 총 {len(all_data)}개 문서")
for t, n in counts.items():
print(f" · {t}: {n}개")전처리 결과 구조 — 타입별 실제 출력
모든 데이터 타입이 id · content · metadata의 통일된 구조로 변환됩니다. 이 구조가 다음 단계(청킹 → 임베딩)의 입력 형식입니다.
{
"id": "video_dQw4w9WgXcQ",
"content": "[영상 제목] 썸네일 클릭률 높이는 법\n\n[내용]\n안녕하세요 오늘은...",
"metadata": {
"type": "video",
"video_id": "dQw4w9WgXcQ",
"title": "썸네일 클릭률 높이는 법",
"view_count": 150000,
"like_count": 4200,
"published_at": "2024-03-15T09:00:00Z"
}
}{
"id": "comment_142",
"content": "[댓글] 이방법 써봤는데 CTR 2배됨 감사해요",
"metadata": {
"type": "comment",
"video_id": "dQw4w9WgXcQ",
"like_count": 87,
"published_at": "2024-03-16T14:23:00Z"
}
}{
"id": "community_Ugkx...",
"content": "[커뮤니티] 다음영상 주제 투표 썸네일 A,B테스트 쇼츠알고리즘 수익화조건",
"metadata": {
"type": "community",
"post_id": "Ugkx...",
"published_at": "2024-03-20T08:00:00Z"
}
}{
"id": "subtitle_dQw4w9WgXcQ_3",
"content": "[자막 00:03:15,000~00:04:05,000] 수익화 조건은 구독자 1000명 이상 시청시간 4000시간 이상입니다",
"metadata": {
"type": "subtitle",
"video_id": "dQw4w9WgXcQ",
"start_time": "00:03:15,000",
"end_time": "00:04:05,000",
"chunk_index": 3
}
}💡 metadata 설계가 중요한 이유 — 검색 필터링
메타데이터를 풍부하게 저장해두면 벡터 유사도 검색에SQL 조건 필터를 함께 적용할 수 있습니다. 단순 시맨틱 검색보다 훨씬 정밀한 결과를 얻을 수 있습니다.
# 메타데이터 필터 활용 예시 (LangChain Chroma)
# ① 영상 타입만 검색
retriever = vectordb.as_retriever(
search_kwargs={
"k": 5,
"filter": {"type": "video"} # comment·subtitle 제외
}
)
# ② 조회수 상위 영상만 검색 (Turso SQL + 벡터)
# SELECT ... FROM rag_documents
# WHERE type = 'video' AND view_count > 50000
# ORDER BY vector_distance_cos(...) LIMIT 5
# ③ 특정 영상의 자막만 검색 (타임스탬프 RAG)
# filter: {"type": "subtitle", "video_id": "dQw4w9WgXcQ"}
# → 해당 영상의 특정 구간 발화 내용 검색# python 2_preprocess.py 실행 결과 예시
영상 처리 완료: 287개 댓글 처리 완료: 2,814개 (스킵 186개) 커뮤니티 처리 완료: 43개 대본 처리 완료: 12개 자막 처리 완료: 1,840개 청크 ✅ 전처리 완료! 총 4,996개 문서 · video: 287개 · comment: 2,814개 · community: 43개 · script: 12개 · subtitle: 1,840개
📌 전처리 핵심 포인트
- •각 타입마다 노이즈의 종류가 다릅니다 — 영상 설명·댓글·자막을 같은 로직으로만 처리하면 안 됩니다.
- •metadata를 풍부하게 저장할수록 나중에 검색 필터를 더 정밀하게 쓸 수 있습니다 (view_count, start_time 등).
- •SRT 자막의 start_time은 타임스탬프 링크(?t=195) 자동 생성의 핵심 — 반드시 메타데이터로 보존하세요.
- •댓글 스팸이 많다면 min_likes=1 이상으로 설정해 좋아요 없는 댓글을 필터링하세요.
- •전처리 후 all_data.json의 총 문서 수와 타입별 분포를 확인하는 습관을 들이세요.
청킹 (Chunking) — 텍스트 분할 전략
긴 텍스트를 AI가 처리하기 적합한 작은 단위로 나누는 청킹. 크기와 오버랩 설정이 검색 정확도를 결정합니다.
01. 왜 청킹 크기가 RAG 품질을 결정하는가
❌ 청크 너무 크면
- • 검색 정확도 저하 — 관련 없는 내용 혼입
- • LLM 컨텍스트 토큰 낭비
- • 벡터가 여러 주제를 뭉뚱그려 표현
- • 비용 증가 (긴 프롬프트)
❌ 청크 너무 작으면
- • 문맥 단절 — 앞뒤 맥락 없이 단어만 남음
- • 벡터 DB 행 수 폭증 (비용·속도 저하)
- • 단편적 답변 생성
- • 오버랩 없으면 경계에서 정보 소실
✅ 권장 균형점
- 문자 기준: 400~600자 / 오버랩 50자
- 토큰 기준: 256~512토큰 / 오버랩 10~20%
- • 데이터 타입마다 최적값이 다름
- • 실험·평가 후 최종 결정 권장
# 동일 텍스트를 크기별로 분할했을 때 청크 수 변화
원문 텍스트: "유튜브 알고리즘은 시청 지속시간을 가장 중요한
지표로 봅니다. 썸네일 클릭률(CTR)은 7~10%가 평균이며,
이를 높이려면 대비가 강한 색상과 짧은 텍스트가 필요합니다.
댓글 참여도도 알고리즘에 영향을 줍니다."
chunk_size=1000 → [전체가 1청크]
├─ 벡터 1개가 '알고리즘+CTR+댓글' 모두를 표현 → 검색 희석
chunk_size=500 → [2청크] ← ✅ 권장
├─ 청크1: 알고리즘 / 시청 지속시간 주제
└─ 청크2: CTR / 색상 / 댓글 주제
chunk_size=100 → [7~8청크]
├─ "유튜브 알고리즘은" (문장 잘림)
├─ "시청 지속시간을 가장" (맥락 없음)
└─ ...📌 문자(Character) 기준 vs 토큰(Token) 기준 — 핵심 차이
| 항목 | 문자(char) 기준 | 토큰(token) 기준 |
|---|---|---|
| 사용 클래스 | RecursiveCharacterTextSplitter | from_tiktoken_encoder() |
| chunk_size=500 의미 | 최대 500글자 | 최대 500 BPE 토큰 |
| 영어 500자 ≈ | 약 100~130 토큰 | 500 토큰 |
| 한국어 500자 ≈ | 약 250~400 토큰 | 500 토큰 |
| 비용 계산 정확도 | 부정확 (토큰 수 가변) | 정확 |
| LLM 컨텍스트 제어 | 간접적 | 직접적 |
| 추천 상황 | 빠른 프로토타이핑 | GPT-4o 프로덕션 |
# 한국어 토큰 수 직접 확인하기
import tiktoken
enc = tiktoken.encoding_for_model("gpt-4o")
text_ko = "유튜브 썸네일 클릭률을 높이는 방법"
text_en = "How to increase YouTube thumbnail CTR"
print(len(enc.encode(text_ko))) # → 17 토큰 (한국어는 1자≈0.7~1.5토큰)
print(len(enc.encode(text_en))) # → 8 토큰 (영어는 1단어≈1~2토큰)
print(len(text_ko)) # → 18 문자
print(len(text_en)) # → 38 문자02. 청킹 전략 4종 비교 — 인터랙티브 가이드
RecursiveCharacter
기본 권장구분자 우선순위(\n\n → \n → . → 공백)에 따라 재귀적으로 분할. 문단·문장 경계를 최대한 존중하면서 고정 크기에 근접하게 나눕니다. LangChain 기본값이며 대부분의 텍스트에 가장 안정적입니다.
✅ 장점
- • 문장·문단 경계를 최대한 보존
- • 대부분의 텍스트 유형에 무난하게 동작
- • 오버랩 조정만으로 맥락 보완 가능
⚠️ 단점
- • 의미 단위를 완벽히 보장하지는 않음
- • 고정 크기 기준이라 맥락이 잘릴 수 있음
from langchain.text_splitter import (
RecursiveCharacterTextSplitter
)
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 문자(char) 기준 최대 크기
chunk_overlap=50, # 앞·뒤 청크와 겹치는 문자 수
separators=[ # 이 순서로 분할 시도
"\n\n", # 1순위: 빈 줄 (문단 구분)
"\n", # 2순위: 줄바꿈
".", # 3순위: 마침표
" " # 4순위: 공백
]
)
chunks = splitter.split_documents(documents)
print(f"청크 수: {len(chunks)}") # 예: 4,996개03. 오버랩(Overlap) — 맥락 연결의 핵심
오버랩은 앞 청크의 끝부분을 다음 청크의 시작 부분에 중복으로 포함시켜 경계에서 맥락이 단절되는 문제를 방지합니다. 업계 표준은 청크 크기의 10~20%입니다 (500자 청크라면 50~100자 오버랩).
# chunk_size=20자, chunk_overlap=5자 예시 (개념 시각화)
원문: "썸네일 클릭률을 높이려면 대비가 강한 색상을 써야 합니다"
└─────────────────────────────────────────────────────┘
(42자)
chunk_overlap=0 (오버랩 없음):
청크1: "썸네일 클릭률을 높이려" ← 문장이 잘림
청크2: "면 대비가 강한 색상을" ← 앞 맥락 없음
청크3: "써야 합니다"
chunk_overlap=5 (5자 겹침):
청크1: "썸네일 클릭률을 높이려"
청크2: "높이려면 대비가 강한 색상을" ← "높이려면" 5자 재포함 ✅
청크3: "강한 색상을 써야 합니다" ← "강한 색상을" 재포함 ✅오버랩 = 0
위험청크 경계에서 문장이 완전히 잘립니다. 단어·문장이 반쪽만 남아 벡터 품질이 떨어집니다.
오버랩 = 10~20%
권장앞 청크의 끝부분이 다음 청크에 재포함되어 맥락을 자연스럽게 이어줍니다. 업계 표준입니다.
오버랩 > 50%
과잉중복 청크가 너무 많아 벡터 DB 크기와 비용이 급증합니다. 중복 결과가 검색에서 자주 반환됩니다.
04. 데이터 타입별 최적 청킹 설정
유튜브 RAG에서 수집한 4가지 데이터 타입은 특성이 달라 청킹 전략을 각각 다르게 적용해야 합니다.
| 데이터 타입 | 청크 크기 | 오버랩 | 분할 방식 | 이유 |
|---|---|---|---|---|
| 📹 영상 설명란 | 400~600자 | 50자 | RecursiveCharacter | 문단 단위 설명, 평균 길이 300~1,000자. 문장 경계 보존 중요. |
| 💬 댓글 | 200~300자 | 30자 | RecursiveCharacter | 댓글 하나가 이미 짧은 경우가 많음. 과도한 분할 주의. |
| 📝 자막 (SRT) | 10세그먼트 묶음 | 2세그먼트 | 커스텀 (시간 기반) | start_time 메타데이터 보존이 핵심. 세그먼트 단위로 묶은 뒤 RecursiveCharacter 적용. |
| 📄 대본 (Script) | 500~800자 | 80자 | RecursiveCharacter | 긴 호흡의 설명이 많아 청크 크기를 크게 설정. 오버랩도 넓게. |
# 📹 영상 설명란 Document 예시
Document(
page_content="썸네일 클릭률을 높이는 핵심은...",
metadata={"type":"video","video_id":"abc123",
"view_count":128400}
)# 💬 댓글 Document 예시
Document(
page_content="이 방법 써봤는데 구독자가 실제로...",
metadata={"type":"comment","likes":42,
"video_id":"abc123"}
)# 📝 자막 (SRT) Document 예시
Document(
page_content="오늘은 썸네일 CTR에 대해...",
metadata={"type":"subtitle",
"start_time":"00:02:34",
"video_id":"abc123"}
)# 📄 대본 (Script) Document 예시
Document(
page_content="안녕하세요, 오늘 주제는 알고리즘...",
metadata={"type":"script",
"filename":"ep42_algo.txt"}
)05. 3_embed_store.py — 청킹 + 임베딩 + 벡터 DB 저장 (완성 코드)
# 3_embed_store.py ─ 청킹 + 임베딩 + Chroma 저장
import json, os, time
from dotenv import load_dotenv
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.schema import Document
load_dotenv()
# ────────────────────────────────────────────────
# 1. 전처리 데이터 로드
# ────────────────────────────────────────────────
with open("data/processed/all_data.json", "r", encoding="utf-8") as f:
all_data = json.load(f)
print(f"로드된 문서 수: {len(all_data)}개")
# ────────────────────────────────────────────────
# 2. LangChain Document 변환
# ────────────────────────────────────────────────
documents = [
Document(page_content=item["content"], metadata=item["metadata"])
for item in all_data
]
# ────────────────────────────────────────────────
# 3. 데이터 타입별 청킹 설정 분기
# ────────────────────────────────────────────────
def make_splitter(data_type: str) -> RecursiveCharacterTextSplitter:
"""데이터 타입에 따라 최적 청킹 파라미터 반환"""
settings = {
"video": {"chunk_size": 500, "chunk_overlap": 50},
"comment": {"chunk_size": 250, "chunk_overlap": 30},
"subtitle": {"chunk_size": 400, "chunk_overlap": 50},
"script": {"chunk_size": 700, "chunk_overlap": 80},
}
cfg = settings.get(data_type, {"chunk_size": 500, "chunk_overlap": 50})
return RecursiveCharacterTextSplitter(
chunk_size=cfg["chunk_size"],
chunk_overlap=cfg["chunk_overlap"],
separators=["\n\n", "\n", ".", " "]
)
# 타입별 분할
type_groups: dict[str, list[Document]] = {}
for doc in documents:
dtype = doc.metadata.get("type", "video")
type_groups.setdefault(dtype, []).append(doc)
all_chunks: list[Document] = []
for dtype, docs in type_groups.items():
splitter = make_splitter(dtype)
chunks = splitter.split_documents(docs)
# chunk_index 메타데이터 추가 (순서 추적)
for idx, chunk in enumerate(chunks):
chunk.metadata["chunk_index"] = idx
all_chunks.extend(chunks)
print(f" {dtype:10s}: {len(docs):5d}개 문서 → {len(chunks):6d}개 청크")
print(f"\n전체 청크 수: {len(all_chunks)}개")
# ────────────────────────────────────────────────
# 4. 임베딩 모델 (text-embedding-3-small, 1536차원)
# ────────────────────────────────────────────────
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small" # $0.02 / 1M tokens
)
# ────────────────────────────────────────────────
# 5. Chroma 벡터 DB 저장 (배치 처리)
# ────────────────────────────────────────────────
BATCH_SIZE = 500 # 한 번에 임베딩할 청크 수
print("\n벡터 DB 저장 시작...")
vectordb = None
for i in range(0, len(all_chunks), BATCH_SIZE):
batch = all_chunks[i : i + BATCH_SIZE]
print(f" 배치 {i//BATCH_SIZE + 1}: {i}~{i+len(batch)}번 청크 임베딩 중...")
if vectordb is None:
# 첫 배치: 새 컬렉션 생성
vectordb = Chroma.from_documents(
documents=batch,
embedding=embeddings,
persist_directory="./chroma_db",
collection_name="youtube_rag",
)
else:
# 이후 배치: 기존 컬렉션에 추가
vectordb.add_documents(batch)
time.sleep(0.5) # API 레이트리밋 방지
total = vectordb._collection.count()
print(f"\n✅ 저장 완료: {total}개 벡터 (chroma_db/ 폴더)")
# ────────────────────────────────────────────────
# 6. 저장 결과 검증 — 샘플 검색 테스트
# ────────────────────────────────────────────────
print("\n검색 테스트:")
test_results = vectordb.similarity_search_with_score(
"썸네일 클릭률을 높이는 방법",
k=3
)
for doc, score in test_results:
similarity = 1 - score # Chroma는 distance 반환 → 유사도로 변환
dtype = doc.metadata.get("type", "?")
title = doc.metadata.get("title", "제목없음")[:30]
print(f" [{similarity:.4f}] ({dtype}) {title} — {doc.page_content[:60]}...")# 실행 결과 예시
로드된 문서 수: 4,996개
video : 287개 문서 → 891개 청크
comment : 2814개 문서 → 2841개 청크 (짧은 댓글은 1:1)
subtitle: 1840개 문서 → 2203개 청크
script : 55개 문서 → 312개 청크
전체 청크 수: 6,247개
벡터 DB 저장 시작...
배치 1: 0~500번 청크 임베딩 중...
배치 2: 500~1000번 청크 임베딩 중...
...
배치 13: 6000~6247번 청크 임베딩 중...
✅ 저장 완료: 6247개 벡터 (chroma_db/ 폴더)
검색 테스트:
[0.8921] (subtitle) 유튜브 썸네일 완벽 가이드 — 썸네일 클릭률을 높이려면 대비가 강한 색상과...
[0.8734] (video) CTR 높이는 5가지 전략 — 유튜브에서 클릭률이 높은 썸네일의 공통점은...
[0.8512] (script) 알고리즘 완전 정복 — 썸네일과 제목의 조합이 초기 CTR을 결정합니다...⚠️ separators 복사 시 주의: Python 파일에 붙여넣을 때 \n은 이스케이프 문자(줄바꿈)이어야 합니다. JSX 코드 블록에서 복사하면 이중 역슬래시가 포함될 수 있으므로 \\n\\n→ \n\n으로 교체 후 사용하세요.
🧠 SemanticChunker 심화: 위 방식은 재귀 문자 분할(고정 크기 근접)입니다. 문단·문장 의미 경계에서 정확히 나누는 SemanticChunker는 검색 정확도가 높지만 임베딩 API를 청킹 단계에서도 호출하므로 비용이 2배가 됩니다. 품질을 우선한다면 전략 탭의 SemanticChunker 코드를 참고하세요.
06. Chroma 저장 구조 + 비용 예상
📦 Chroma 저장 파일 구조
chroma_db/
├── chroma.sqlite3 ← 메타데이터, 원본 텍스트
│ (id, content, metadata 등)
└── [uuid 폴더]/ ← 컬렉션당 1개
├── data_level0.bin ← HNSW 벡터 인덱스 (바이너리)
├── header.bin ← 인덱스 헤더
└── length.bin ← 청크 길이 정보
# 6,247 벡터 (1536차원 float32) 기준
# data_level0.bin ≈ 6247 × 1536 × 4byte ≈ 38 MB💰 임베딩 비용 계산
* 영상 300개 + 댓글 3,000개 + 자막 기준. Gemini text-embedding-004 사용 시 무료 (일 1,500 요청 한도).
✅ 청킹 단계 완료 체크리스트
임베딩 모델의 원리 — 텍스트를 숫자로
임베딩 모델은 텍스트의 의미를 숫자 벡터로 변환합니다. "의미가 비슷한 것끼리 비슷한 숫자가 나온다" — 이것이 RAG 검색의 핵심 원리입니다. 이 섹션에서는 모델이 내부적으로 어떻게 동작하는지, 실제 API를 어떻게 호출하는지, 어떤 모델을 골라야 하는지를 다룹니다.
임베딩의 핵심 원리
"썸네일 클릭률 높이는 법" → [0.12, -0.34, 0.56 ...]
"유튜브 썸네일 CTR 향상" → [0.11, -0.33, 0.57 ...] ← 거의 같은 숫자!
cosine similarity ≈ 0.97 → 검색 시 함께 반환 ✅
"강아지 산책 방법" → [0.87, 0.91, -0.23 ...] ← 완전히 다른 숫자
cosine similarity ≈ 0.12 → 검색 결과에 포함되지 않음 ❌
임베딩 모델 내부에서 무슨 일이 일어나는가
입력 텍스트: "썸네일 클릭률 높이는 법"
│
▼
┌───────────────────────────┐
│ 토크나이저 (Tokenizer) │
│ "썸네일" → [42301] │
│ "클릭" → [28934] │
│ "률" → [9823] │
│ "높이는" → [51204] │
│ "법" → [7201] │
└──────────────┬────────────┘
│ 토큰 ID 시퀀스
▼
┌───────────────────────────┐
│ Transformer Encoder │
│ (BERT 계열, 수억 파라미터) │
│ │
│ 각 토큰 → 768/1536차원 │
│ 문맥 정보를 반영한 벡터 │
│ │
│ [CLS] 토큰 최종 hidden │
│ state를 전체 문장 표현으로 │
└──────────────┬────────────┘
│
▼
┌───────────────────────────┐
│ Pooling Layer │
│ (Mean Pooling 또는 │
│ CLS Pooling) │
│ → 단일 벡터로 압축 │
└──────────────┬────────────┘
│
▼
출력: [0.0312, -0.1847, 0.9023, ...] ← 1536차원 float32 벡터
↑ 이 숫자들이 "썸네일 클릭률"의 의미를 인코딩한 것Transformer Encoder
BERT·RoBERTa 계열 구조. 문장 전체를 한 번에 읽어 각 단어의 문맥 의미를 파악합니다. LLM의 Decoder와 달리 "생성"은 하지 않습니다.
Pooling
문장의 모든 토큰 벡터를 하나의 벡터로 요약합니다. Mean Pooling(평균)이 일반적이며, text-embedding-3-small은 이 방식을 사용합니다.
L2 정규화
최종 벡터를 단위 구(unit sphere) 위에 올립니다. 이렇게 하면 내적(dot product)과 코사인 유사도가 동일한 값이 되어 계산이 효율적입니다.
임베딩 모델 vs LLM — 역할 구분
⚠️ 흔한 오해: ChatGPT·Claude·Gemini가 임베딩도 해주는 것이 아닙니다! 임베딩 모델과 LLM은 완전히 다른 두 가지 모델입니다. RAG에서는 둘 다 씁니다.
| 구분 | 임베딩 모델 | LLM (GPT-4o · Claude · Gemini) |
|---|---|---|
| 하는 일 | 텍스트 → 숫자 벡터 변환 (의미 인코딩) | 벡터를 읽는 것이 아닌, 텍스트를 읽고 자연어로 답변 생성 |
| 아키텍처 | Transformer Encoder (BERT 계열) | Transformer Decoder (GPT 계열) — 토큰을 순차 생성 |
| 출력 형태 | float32[] 벡터 1개 | 자연어 텍스트 토큰 스트림 |
| 연산 횟수 | 1회 forward pass → 완료 | 답변 길이만큼 N번 반복 (autoregressive) |
| 모델 크기 | 수억 파라미터 (가벼움) | 수백억~수천억 파라미터 (매우 무거움) |
| 1M 토큰 비용 | $0.02 (text-embedding-3-small) | $2.50~$15 (GPT-4o 기준) |
| RAG에서 역할 | 문서·질문을 벡터화 → DB 저장·검색 | 검색된 청크를 읽고 최종 답변 생성 |
RAG에서 두 모델을 쓰는 순서: 임베딩 모델로 문서를 전부 벡터화해서 DB에 저장 → 질문도 같은 임베딩 모델로 벡터화 → 코사인 유사도로 유사 청크 검색 → 검색된 청크 텍스트 + 질문을 LLM에 전달 → LLM이 자연어 답변 생성.임베딩 모델은 저장/검색 전용, LLM은 생성 전용입니다.
실제 임베딩 API 호출 코드
from openai import OpenAI
import os
from dotenv import load_dotenv
load_dotenv()
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def embed_text(text: str) -> list[float]:
"""단일 텍스트 → 1536차원 벡터"""
res = client.embeddings.create(
model="text-embedding-3-small",
input=text,
)
return res.data[0].embedding
def embed_batch(texts: list[str]) -> list[list[float]]:
"""여러 텍스트 한 번에 → API 호출 횟수 절감"""
res = client.embeddings.create(
model="text-embedding-3-small",
input=texts, # 최대 2048개 동시 처리
)
# 입력 순서대로 정렬해서 반환
return [e.embedding for e in
sorted(res.data, key=lambda x: x.index)]
# 사용 예시
vec = embed_text("썸네일 클릭률 높이는 법")
print(f"차원 수: {len(vec)}") # 1536
print(f"첫 3개: {vec[:3]}") # [0.031, -0.184, 0.902]import google.generativeai as genai
import os
from dotenv import load_dotenv
load_dotenv()
genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
def embed_document(text: str) -> list[float]:
"""DB에 저장할 문서 임베딩 (retrieval_document)"""
res = genai.embed_content(
model="models/text-embedding-004",
content=text,
task_type="retrieval_document",
# ↑ 저장용: 문서 의미를 최적화
)
return res["embedding"] # 768차원
def embed_query(text: str) -> list[float]:
"""검색 질문 임베딩 (retrieval_query)"""
res = genai.embed_content(
model="models/text-embedding-004",
content=text,
task_type="retrieval_query",
# ↑ 검색용: 질문 의미를 최적화
# ⚠️ 저장·검색에 다른 task_type을 써야 품질↑
)
return res["embedding"] # 768차원⚠️ Gemini의 task_type — 저장용과 검색용을 반드시 구분하세요
retrieval_documentDB에 저장하는 청크 임베딩 시 사용. 문서의 내용을 검색 최적화된 방식으로 인코딩합니다.
retrieval_query사용자 질문을 임베딩할 때 사용. 질문과 문서가 잘 매칭되도록 별도 최적화됩니다.
semantic_similarity두 문장의 일반적인 유사도 측정용. RAG 검색보다는 중복 탐지·클러스터링에 적합.
classification텍스트 분류 작업용. 댓글 감정 분류 등에 사용. RAG 검색에는 부적합.
배치 임베딩 — 청크 5,000개를 효율적으로 처리하기
전처리 결과가 5,000개 청크라면 하나씩 API를 호출하면 5,000번 호출이 필요합니다. 배치 처리를 사용하면 API 호출 횟수를 50~100배 줄이고 속도도 대폭 빨라집니다.
# embed_utils.py — 배치 임베딩 유틸리티
import time
from openai import OpenAI
import os
client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
def embed_chunks_batch(
texts: list[str],
model: str = "text-embedding-3-small",
batch_size: int = 100, # OpenAI: 최대 2048, 안정적으로는 100
sleep_sec: float = 0.1, # 분당 요청 제한(RPM) 방지용 대기
) -> list[list[float]]:
"""
대량 청크를 배치로 나눠 임베딩.
실패한 배치는 재시도 후 빈 벡터로 채움.
"""
all_embeddings: list[list[float]] = []
for i in range(0, len(texts), batch_size):
batch = texts[i : i + batch_size]
try:
res = client.embeddings.create(
model=model,
input=batch,
)
# 인덱스 순서대로 정렬
batch_vecs = [
e.embedding
for e in sorted(res.data, key=lambda x: x.index)
]
all_embeddings.extend(batch_vecs)
except Exception as e:
print(f"[ERROR] 배치 {i//batch_size+1} 실패: {e}")
# 실패한 배치는 빈 벡터로 채워 인덱스 유지
all_embeddings.extend([[] for _ in batch])
# 진행 상황 출력
done = min(i + batch_size, len(texts))
print(f" 임베딩 진행: {done}/{len(texts)}", end="\r")
time.sleep(sleep_sec)
print(f"\n✅ 임베딩 완료: {len(all_embeddings)}개")
return all_embeddings
# 사용 예시
import json
with open("data/processed/all_data.json", encoding="utf-8") as f:
all_data = json.load(f)
texts = [d["content"] for d in all_data]
vectors = embed_chunks_batch(texts, batch_size=100)
# 빈 벡터(실패) 개수 확인
failed = sum(1 for v in vectors if not v)
print(f"성공: {len(vectors)-failed}개 | 실패: {failed}개")💰 실제 임베딩 비용 계산 (영상 300개 + 댓글 3,000개 기준)
| 데이터 | 청크 수 | 평균 토큰 | OpenAI ($0.02/1M) | Gemini (무료) |
|---|---|---|---|---|
| 영상 설명란 | ~1,000개 | 250토큰 | ~$0.005 | $0 |
| 댓글 | ~2,800개 | 40토큰 | ~$0.002 | $0 |
| 커뮤니티 | ~43개 | 80토큰 | <$0.001 | $0 |
| 자막 청크 | ~1,800개 | 120토큰 | ~$0.004 | $0 |
| 합계 (1회) | ~5,643개 | — | ~$0.01~0.05 | $0 (1500req/일) |
* Gemini 무료 티어: 1,500 req/일. 5,643개 청크를 배치 없이 1개씩 호출하면 4일 필요. 배치 API가 없으므로 time.sleep(0.05)로 속도 조절 권장.
데이터 종류별 임베딩 처리 방법
텍스트 / PDF
모델: text-embedding-3-small (OpenAI)
→ 1536차원 벡터
💡 이 칼럼의 기본 선택. 한국어 포함 다국어 지원.
이미지 (썸네일 분석)
모델: CLIP (OpenAI), Gemini Embedding 2
→ 512~1408차원 벡터
💡 텍스트-이미지 교차 검색 가능. "밝은 색 썸네일" 같은 쿼리에 유리.
오디오 (음성)
모델: Whisper → 텍스트 변환 후 텍스트 임베딩
→ 텍스트로 변환 후 처리
💡 Whisper large-v3 기준 분당 ~10초 처리. GPU 있으면 로컬 실행 가능.
영상 (자막 방식)
모델: yt-dlp SRT 추출 → 텍스트 임베딩
→ 자막 텍스트 + 타임스탬프
💡 이 칼럼의 권장 방식. 타임스탬프 메타데이터로 ?t=195 링크 자동 생성.
어떤 임베딩 모델을 선택해야 하는가
가장 중요한 원칙: 저장(embed_document)과 검색(embed_query)에 반드시 같은 모델을 써야 합니다. 다른 모델로 만든 벡터끼리는 거리 비교가 의미 없습니다. 모델을 바꾸면 전체 데이터를 재임베딩해야 합니다.
# ❌ 절대 안 되는 패턴 저장: text-embedding-3-small로 임베딩 → DB 저장 검색: text-embedding-3-large로 임베딩 → 코사인 유사도 계산 → 두 모델의 벡터 공간이 달라서 유사도 결과가 완전히 무의미 # ✅ 올바른 패턴 저장: text-embedding-3-small → DB 저장 검색: text-embedding-3-small (동일 모델) → 코사인 유사도 계산
| 상황 | 추천 모델 | 이유 |
|---|---|---|
| 비용 0원으로 빠르게 시작 | Gemini text-embedding-004 | 1,500 req/일 무료, 768차원, 한국어 우수 |
| 정확도·속도 균형 | text-embedding-3-small (OpenAI) | 1,536차원, $0.02/1M, 가장 무난한 선택 |
| 최고 정확도 | text-embedding-3-large (OpenAI) | 3,072차원, $0.13/1M, 정밀도 중시 |
| 한국어 특화 | Gemini Embedding 2 | 다국어 1위, 크로스링구얼 검색 강점 |
| 셀프호스팅 (서버 직접) | BGE-M3 (BAAI) | 오픈소스, 568M 파라미터, dense+sparse 지원 |
| 이미 Turso·Vercel 스택 | text-embedding-3-small | float32 저장·vector32() 함수와 바로 호환 |
🎯 비유로 이해하는 임베딩 모델의 전체 역할
임베딩 모델 = 도서관 사서
책(텍스트)을 의미별로 분류해 번호표(벡터)를 붙입니다. 빠르고 저렴합니다.
벡터 DB = 도서관 서가
번호표 순서대로 책이 꽂혀 있어서 비슷한 번호끼리 가까운 곳에 있습니다.
LLM = 전문 컨설턴트
사서가 꺼내온 책을 읽고 고객 질문에 맞춤 설명을 해줍니다. 무겁고 비쌉니다.
벡터 DB 종류 비교 — 무엇을 선택할까
일반 DB(PostgreSQL)는 정확한 값을 찾는 데 최적화되어 있습니다. 반면 벡터 DB는 "의미가 비슷한 것"을 찾는 데 특화되어 있습니다. RAG의 핵심 검색 엔진은 반드시 벡터 DB(또는 벡터 확장)를 사용해야 합니다.
01PostgreSQL vs 벡터 DB — 검색 방식의 근본 차이
PostgreSQL
키워드 검색-- 정확한 키워드 일치 검색
SELECT title, content
FROM documents
WHERE title LIKE '%썸네일%'
OR content LIKE '%클릭률%'
ORDER BY created_at DESC
LIMIT 5;- ✅정확한 숫자·날짜 필터링 (view_count > 10000, date = '2024-01')
- ✅테이블 JOIN · 트랜잭션 완벽 지원
- ❌"썸네일 CTR 향상" ≠ "클릭률 높이는 법" — 같은 의미여도 못 찾음
- ❌자연어 질문에 완전히 무력화
벡터 DB
의미 검색 (Semantic)-- 의미적 유사도 기반 검색 (코사인 유사도)
SELECT id, content, metadata,
1 - (embedding <=> query_vec) AS similarity
FROM documents
ORDER BY embedding <=> query_vec -- ANN 탐색
LIMIT 5;
-- query_vec = embed("썸네일 클릭률 높이는 법")
-- "유튜브 CTR 향상 전략" 도 높은 유사도로 반환 ✅- ✅의미가 비슷한 문서를 자동으로 탐지 (동의어·유의어)
- ✅자연어 질문 직접 검색 지원
- ❌정확한 문자열 매칭은 상대적으로 취약
- ⚡해결책: 하이브리드 검색 (벡터 + 키워드 결합)
💡 직관적 비유: PostgreSQL은 도서관 책 제목 색인처럼 정확한 단어를 찾습니다. 벡터 DB는 사서가 "이것과 비슷한 내용의 책"을 직관적으로 추천해주는 것과 같습니다. RAG에서는 사용자의 질문이 정확한 키워드를 포함하지 않아도 관련 문서를 찾아야 하기 때문에 벡터 DB가 필수입니다.
02주요 벡터 DB 한눈에 비교
| 이름 | 형태 | 저장 위치 | 핵심 특징 | 비용 | 추천 상황 |
|---|---|---|---|---|---|
| 🐘 pgvector | PostgreSQL 확장 | 셀프호스팅 / Supabase | 기존 SQL + 벡터 동시 사용 가능. IVFFlat·HNSW 인덱스 지원 | 무료 (Supabase 무료 티어 있음) | 이미 PostgreSQL 사용 중인 프로젝트 |
| 🎨 Chroma추천 | 오픈소스 벡터 DB | 로컬 / 도커 | Python 3줄로 시작 가능. 파일 기반 저장. LangChain 완벽 통합 | 완전 무료 | 로컬 개발·프로토타이핑 입문 |
| ⚡ FAISS | 인메모리 라이브러리 | 로컬 메모리 | Meta 개발. 초고속 ANN 검색. GPU 지원. 서버 없음 (라이브러리) | 완전 무료 | 대규모 오프라인 배치 처리·연구 |
| 🔷 Weaviate | 클라우드/셀프호스팅 | Weaviate Cloud / 도커 | GraphQL API. BM25 하이브리드 내장. 멀티모달 지원(텍스트+이미지) | 무료 샌드박스 / 유료 프로덕션 | 멀티모달·엔터프라이즈 프로덕션 |
| 📌 Pinecone | 완전 관리형 클라우드 | Pinecone Cloud | 설정 불필요, 자동 스케일링. 가장 단순한 API. 서버리스 지원 | 무료 1개 인덱스 / 유료 $0.096/hr~ | 빠른 MVP·스타트업 프로덕션 |
| 🎯 Qdrant추천 | 오픈소스 / 클라우드 | Qdrant Cloud / 셀프호스팅 | Rust 구현으로 초고성능. 페이로드 필터링 강력. 무료 1GB 클라우드 | 클라우드 무료 1GB / 셀프호스팅 무료 | 무료 온라인 배포·고성능 필요 |
| 🗃️ Turso추천 | SQLite 기반 엣지 DB | Turso Cloud (엣지) | libSQL(SQLite 포크) + 벡터 확장. Vercel 엣지와 찰떡궁합. 초저지연 | 무료 500 DB / 유료 $29/mo~ | Next.js + Vercel 기존 스택 |
# Chroma / Qdrant 실제 레코드 구조
{
"id": "chunk_0042",
"content": "썸네일 클릭률을 높이려면 대비가 강한 색상과 큰 텍스트를 써야 합니다.",
"embedding": [ # float32 × 1536차원 ≈ 6KB
0.03124, -0.17832, 0.29441, 0.00823,
-0.11209, 0.44821, -0.02341, 0.38821,
... # 실제로는 1536개 숫자
],
"metadata": {
"type": "subtitle", # video | comment | subtitle | script
"video_id": "dQw4w9WgXcY",
"title": "유튜브 썸네일 완벽 가이드",
"view_count": 128400,
"published_at": "2024-03-15",
"start_time": "00:02:34" # 자막 타임스탬프
}
}03하이브리드 검색 — 벡터 + 키워드
프로덕션 환경에서는 순수 벡터 검색만으로는 부족한 경우가 많습니다. 예를 들어 사용자가 "2024년 1월 영상"처럼 정확한 날짜를 요구하거나 "MKBHD" 같은 고유명사를 검색할 때는 키워드 검색이 훨씬 정확합니다. BM25(키워드) + 코사인 유사도(벡터)를 결합한 하이브리드 검색이 업계 표준으로 자리잡았습니다.
# 하이브리드 검색 흐름 (ASCII)
사용자 질문
"2024년 썸네일 CTR 높이는 법"
│
┌────┴────┐
│ │
▼ ▼
BM25 검색 벡터 검색
(키워드) (코사인 유사도)
│ │
▼ ▼
score₁ score₂
│ │
└────┬────┘
│ RRF 또는 가중 합산
▼ (α=0.7 벡터, β=0.3 BM25)
통합 랭킹 결과 top-K
│
▼
LLM에 컨텍스트로 전달# LangChain EnsembleRetriever (하이브리드)
from langchain.retrievers import (
BM25Retriever,
EnsembleRetriever
)
from langchain_community.vectorstores import Chroma
# 벡터 검색 리트리버 (코사인 유사도)
vector_retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": 5}
)
# BM25 키워드 리트리버
bm25_retriever = BM25Retriever.from_documents(
docs, k=5
)
# 하이브리드: 벡터 70% + BM25 30%
hybrid = EnsembleRetriever(
retrievers=[vector_retriever, bm25_retriever],
weights=[0.7, 0.3] # 가중치 조정 가능
)
results = hybrid.invoke(
"2024년 썸네일 클릭률 높이는 법"
)
# → 키워드 "2024년" + 의미 "CTR 향상" 동시 반영Qdrant
내장 하이브리드
Weaviate
BM25 내장
Elasticsearch
업계 표준
Chroma + LangChain
EnsembleRetriever
04ANN 인덱스 알고리즘 — 수백만 벡터도 빠르게 찾는 법
전체 벡터를 하나하나 비교하는 방식(브루트 포스)은 벡터 수가 늘어날수록 O(N)으로 느려집니다. 실제 서비스에서는 근사 최근접 이웃(ANN) 알고리즘을 사용해 일부 정확도를 포기하는 대신 검색 속도를 수백 배 높입니다.
HNSW
Hierarchical Navigable Small World
계층형 그래프 구조. 현재 가장 많이 사용되는 ANN 알고리즘으로 Qdrant·pgvector·Weaviate 모두 지원. 검색 속도와 정확도 균형이 가장 뛰어남.
검색 속도 ★★★★★ / 정확도 ★★★★☆
Qdrant, pgvector, Weaviate 기본값IVF
Inverted File Index
벡터 공간을 클러스터로 분할 후 가장 가까운 클러스터만 탐색. 학습(train) 단계 필요. FAISS에서 주로 사용되며 대규모 배치 처리에 유리.
검색 속도 ★★★★☆ / 정확도 ★★★☆☆
FAISS, 대규모 오프라인 처리LSH
Locality Sensitive Hashing
해시 함수로 유사한 벡터를 같은 버킷에 배치. 메모리 효율 최고. 정확도가 가장 낮지만 초고속 처리가 필요한 스트리밍 환경에서 활용.
검색 속도 ★★★★★ / 정확도 ★★☆☆☆
실시간 스트리밍, 대용량 해시 검색# HNSW 검색 과정 (개념도)
Layer 2 (최상위, 드문 연결) ●━━━━━━━━━━━━━━━━━━━━━●━━━━━━● Layer 1 (중간 밀도) ●━━━━●━━━━━━●━━━━●━━━●━━━━━━● Layer 0 (최하위, 전체 벡터) ●━●━●━●━●━●━●━●━●━●━●━●━●━● 검색: 상위 레이어에서 빠르게 영역 좁힘 → 하위 레이어에서 정밀 탐색 시간복잡도: O(log N) ← 브루트포스 O(N) 대비 수백 배 빠름
05상황별 추천 — 지금 내 상황에 맞는 벡터 DB는?
로컬 개발 입문
→ Chroma
- ✓Python 3줄로 바로 시작
- ✓파일 저장, 서버 불필요
- ✓LangChain 완벽 통합
- ✓무료, 초기화 없음
import chromadb client = chromadb.PersistentClient( path="./chroma_db" ) col = client.get_or_create_collection( "rag_docs" )
Next.js + Vercel 스택
→ Turso
- ✓Vercel 엣지와 최적 연동
- ✓libSQL (SQLite 기반)
- ✓기존 SQL 문법 그대로
- ✓무료 500 DB 제공
import { createClient } from "@libsql/client";
const db = createClient({
url: process.env.TURSO_URL,
authToken: process.env.TURSO_TOKEN
});
await db.execute(`SELECT ... `);무료 온라인 배포
→ Qdrant Cloud
- ✓무료 1GB 클러스터 제공
- ✓Rust 기반 초고성능
- ✓REST + gRPC API
- ✓강력한 메타데이터 필터
from qdrant_client import QdrantClient
client = QdrantClient(
url=os.getenv("QDRANT_URL"),
api_key=os.getenv("QDRANT_KEY")
)
# 의미 검색
client.search(
collection_name="rag_docs",
query_vector=query_vec, limit=5
)이미 PostgreSQL 사용
→ pgvector
- ✓기존 DB에 확장 하나만 추가
- ✓SQL + 벡터 동시 쿼리
- ✓Supabase 무료 지원
- ✓HNSW 인덱스 지원
-- PostgreSQL에 확장 추가 CREATE EXTENSION vector; -- 벡터 컬럼 추가 ALTER TABLE docs ADD COLUMN embedding vector(1536); -- 코사인 유사도 검색 SELECT * FROM docs ORDER BY embedding <=> $1 LIMIT 5;
06메타데이터 필터 — 벡터 + 조건 검색 결합
벡터 검색과 메타데이터 조건을 동시에 적용하면 훨씬 정밀한 결과를 얻을 수 있습니다. 예를 들어 "썸네일 관련 내용 중에서 조회수 1만 이상 영상의 자막만" 같은 복합 조건이 가능합니다.
# Chroma — 메타데이터 필터 + 벡터 검색
results = collection.query(
query_embeddings=[query_vec],
n_results=5,
where={ # 메타데이터 필터
"$and": [
{"type": {"$eq": "subtitle"}}, # 자막만
{"view_count": {"$gte": 10000}}, # 조회수 1만+
]
},
include=["documents", "metadatas", "distances"]
)
# 결과: 의미적으로 유사하면서 조건도 충족
for doc, meta, dist in zip(
results["documents"][0],
results["metadatas"][0],
results["distances"][0]
):
sim = 1 - dist # 코사인 유사도로 변환
print(f"[{sim:.3f}] {meta['title']} — {doc[:80]}")# Qdrant — 강력한 페이로드 필터
from qdrant_client.models import Filter, FieldCondition, Range, MatchValue
results = client.search(
collection_name="rag_docs",
query_vector=query_vec,
query_filter=Filter(
must=[
FieldCondition(
key="type",
match=MatchValue(value="subtitle")
),
FieldCondition(
key="view_count",
range=Range(gte=10000) # 1만 이상
)
]
),
limit=5,
with_payload=True # 메타데이터 함께 반환
)
for r in results:
print(f"score={r.score:.4f}",
r.payload["title"],
r.payload.get("start_time", ""))🎯 벡터 DB 최종 선택 가이드
지금 상황은?
│
├─ 로컬에서 Python 테스트만 할 것이다
│ └─ ✅ Chroma (pip install chromadb, 3줄 시작)
│
├─ Next.js + Vercel로 웹 서비스를 만든다
│ └─ ✅ Turso (기존 SQL 스택 그대로)
│
├─ 무료로 온라인 배포가 필요하다
│ └─ ✅ Qdrant Cloud Free (1GB 무료 클러스터)
│
├─ 이미 PostgreSQL DB가 있다
│ └─ ✅ pgvector (CREATE EXTENSION vector; 한 줄)
│
├─ 빠른 MVP, 설정 없이 바로 프로덕션
│ └─ ✅ Pinecone (완전 관리형, 자동 스케일링)
│
└─ 대규모 오프라인 배치 / GPU 처리
└─ ✅ FAISS (Meta 제작, 메모리 기반 초고속)📌 핵심 원칙 1
임베딩 저장 시 모델과 검색 시 모델은 반드시 동일해야 합니다. 모델을 바꾸면 전체 재임베딩이 필요합니다.
📌 핵심 원칙 2
처음에는 Chroma로 로컬 테스트 → 배포 시 Qdrant Cloud 또는 Turso로 마이그레이션하는 전략이 가장 효율적입니다.
📌 핵심 원칙 3
메타데이터(type, view_count, date)를 풍부하게 저장해두면 나중에 필터 검색으로 응답 품질을 크게 높일 수 있습니다.
Turso 벡터 DB 완전 가이드 — SQL + 벡터 통합
Turso는 벡터 DB 기능이 네이티브로 내장된 SQLite 기반 DB입니다. CLI 세팅부터 SQL 문법, Next.js·Python 연동 코드, 무료 플랜 한도까지 한 번에 정리합니다.
Turso의 핵심 강점
일반 데이터 + 벡터 통합
하나의 DB에서 SQL + 벡터 검색을 동시에. 별도 벡터 DB 인프라 불필요.
SQLite 호환 — libSQL
SQLite 문법 그대로. 기존 SQLite 쿼리·도구 재사용 가능.
Vercel·Next.js 공식 통합
Vercel 마켓플레이스에서 원클릭 연결. 기존 스택에 바로 추가.
엣지 복제
전 세계 지역에 DB를 복제해 지연 시간 최소화. 글로벌 서비스에 적합.
Python·JS 모두 지원
libsql-experimental(Python), @libsql/client(JS) 공식 SDK 제공.
무료 플랜으로 시작
개인 RAG 프로젝트는 무료 플랜으로 충분. 상세 한도는 아래 표 참고.
STEP 1 — Turso CLI 설치 및 DB 생성
아래 CLI 명령어와 대시보드 UI는 Turso 업데이트에 따라 변경될 수 있습니다. 최신 설치 방법은 docs.turso.tech/cli/installation 에서 확인하세요.
# ── 1. Turso CLI 설치 ──────────────────────────────── # macOS / Linux curl -sSfL https://get.tur.so/install.sh | bash # Windows (PowerShell) # → WSL 사용 권장, 또는 turso.tech 공식 문서 확인 # ── 2. 로그인 ───────────────────────────────────────── turso auth login # → 브라우저에서 GitHub/Google 인증 # ── 3. DB 생성 ──────────────────────────────────────── turso db create my-rag-db # → 가장 가까운 지역에 자동 생성 # → 특정 지역 지정: turso db create my-rag-db --location nrt (도쿄) # ── 4. 연결 URL 확인 ────────────────────────────────── turso db show my-rag-db # → URL: libsql://my-rag-db-[username].turso.io # ── 5. 인증 토큰 발급 ───────────────────────────────── turso db tokens create my-rag-db # → eyJhbGciOi... 형태의 JWT 토큰 출력 # → 이 토큰을 .env.local의 TURSO_AUTH_TOKEN에 저장 # ── 6. CLI로 직접 쿼리 실행 (선택) ─────────────────── turso db shell my-rag-db # → 대화형 SQL 쉘 진입 # 예: > SELECT COUNT(*) FROM rag_documents;
STEP 2 — 권장 테이블 스키마 생성
-- 권장 스키마 (model · fingerprint · chunk_index 포함) CREATE TABLE IF NOT EXISTS rag_documents ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, content TEXT NOT NULL, type TEXT NOT NULL, -- 'video' | 'comment' | 'script' source_id TEXT, chunk_index INTEGER DEFAULT 0, -- 같은 원본의 몇 번째 청크 model TEXT NOT NULL, -- 'text-embedding-3-small' 등 fingerprint TEXT, -- content SHA-256 해시 (변경 감지) created_at DATETIME DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, embedding BLOB -- float32 벡터 (바이너리 BLOB) ); -- UPSERT를 위한 유니크 제약 (source_id + chunk_index 조합이 기준키) CREATE UNIQUE INDEX IF NOT EXISTS idx_source_chunk ON rag_documents(source_id, chunk_index); -- 메타데이터 필터 가속용 인덱스 CREATE INDEX IF NOT EXISTS idx_type ON rag_documents(type); CREATE INDEX IF NOT EXISTS idx_model ON rag_documents(model); CREATE INDEX IF NOT EXISTS idx_fingerprint ON rag_documents(fingerprint);
model 컬럼이 필수인 이유: 임베딩 모델이 다르면 벡터 공간 자체가 달라 코사인 유사도 계산이 무의미해집니다. 검색 SQL에서 WHERE model = ?를 항상 포함해야 모델 혼재 시 오동작을 막을 수 있습니다. (자세한 내용은 DB 저장 형태 섹션 참고)
STEP 3 — Turso 벡터 SQL 문법
-- ① 벡터 저장 — vector32()에 JSON 배열 문자열 전달
INSERT INTO rag_documents
(title, content, type, source_id, chunk_index, model, fingerprint, embedding)
VALUES (
'유튜브 팁 영상 1',
'썸네일 클릭률을 높이는 방법...',
'video',
'dQw4w9WgXcQ',
0,
'text-embedding-3-small',
'a3f2c1d9...',
vector32('[0.12, -0.34, 0.56, ...]') -- JSON 배열 문자열 형식 필수!
);
-- ② 유사도 검색 — 반드시 같은 model로 필터!
SELECT title, content, type, source_id,
vector_distance_cos(embedding, vector32('[0.10, -0.30, ...]')) AS distance
FROM rag_documents
WHERE model = 'text-embedding-3-small' -- ← 이 줄 없으면 모델 혼재 시 오동작
ORDER BY distance
LIMIT 5;
-- ③ 메타데이터 필터 + 벡터 검색 동시에
SELECT title, content,
vector_distance_cos(embedding, vector32('[...]')) AS distance
FROM rag_documents
WHERE model = 'text-embedding-3-small'
AND type = 'video' -- SQL 필터와 벡터 검색 동시 사용
ORDER BY distance
LIMIT 5;
-- ④ UPSERT — 같은 source_id + chunk_index면 업데이트
INSERT INTO rag_documents
(title, content, type, source_id, chunk_index, model, fingerprint, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, vector32(?))
ON CONFLICT (source_id, chunk_index) DO UPDATE SET
content = excluded.content,
model = excluded.model,
fingerprint = excluded.fingerprint,
embedding = excluded.embedding,
updated_at = CURRENT_TIMESTAMP;⚠️ vector32() 입력 형식 주의사항
❌ 잘못된 형식
-- Python list 그대로 전달하면 오류
vector32([0.12, -0.34, 0.56])
-- 중괄호 배열도 오류
vector32('{0.12, -0.34, 0.56}')✅ 올바른 형식
-- JSON 배열 문자열로 전달
vector32('[0.12, -0.34, 0.56]')
-- Python에서 변환
import json
vec_str = json.dumps(vector_list)
# → '[0.12, -0.34, 0.56]'STEP 4a — Next.js 연결 코드 (싱글턴 패턴)
Next.js는 개발 환경에서 Hot Reload 시 모듈이 재실행됩니다.createClient()를 매번 호출하면 연결이 과다 생성됩니다. 글로벌 싱글턴으로 한 번만 생성하는 패턴을 사용하세요.
// lib/turso.ts — 싱글턴 패턴
import { createClient, type Client } from "@libsql/client";
// Next.js 개발 환경 Hot Reload 시 중복 생성 방지
declare global {
// eslint-disable-next-line no-var
var _tursoClient: Client | undefined;
}
function createTursoClient(): Client {
if (!process.env.TURSO_DATABASE_URL) {
throw new Error("TURSO_DATABASE_URL 환경변수가 설정되지 않았습니다.");
}
if (!process.env.TURSO_AUTH_TOKEN) {
throw new Error("TURSO_AUTH_TOKEN 환경변수가 설정되지 않았습니다.");
}
return createClient({
url: process.env.TURSO_DATABASE_URL,
authToken: process.env.TURSO_AUTH_TOKEN,
});
}
// 개발: 글로벌에 캐시 / 프로덕션: 매 인스턴스 1개
export const turso: Client =
process.env.NODE_ENV === "production"
? createTursoClient()
: (globalThis._tursoClient ??= createTursoClient());📄 lib/init-db.ts — 테이블 초기화
// lib/init-db.ts
import { turso } from "./turso";
export async function initDB(): Promise<void> {
// 트랜잭션으로 묶어 실행 — 일부 실패 시 전체 롤백
await turso.batch([
{
sql: `CREATE TABLE IF NOT EXISTS rag_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL,
type TEXT NOT NULL,
source_id TEXT,
chunk_index INTEGER DEFAULT 0,
model TEXT NOT NULL,
fingerprint TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
embedding BLOB
)`,
args: [],
},
{
sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_source_chunk
ON rag_documents(source_id, chunk_index)`,
args: [],
},
{
sql: `CREATE INDEX IF NOT EXISTS idx_type
ON rag_documents(type)`,
args: [],
},
{
sql: `CREATE INDEX IF NOT EXISTS idx_model
ON rag_documents(model)`,
args: [],
},
], "write");
console.log("Turso DB 초기화 완료");
}
// 사용 예: npm run init-db 스크립트 또는 앱 최초 실행 시 1회 호출
// scripts/init-db.ts:
// import { initDB } from "../lib/init-db";
// initDB().then(() => process.exit(0));STEP 4b — Python 연동 코드 (LangChain 파이프라인)
# pip install libsql-experimental python-dotenv openai
import json, os, hashlib
import libsql_experimental as libsql
from openai import OpenAI
from dotenv import load_dotenv
load_dotenv()
# ── 연결 ──────────────────────────────────────────────
conn = libsql.connect(
database=os.getenv("TURSO_DATABASE_URL"),
auth_token=os.getenv("TURSO_AUTH_TOKEN"),
)
openai_client = OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
EMBEDDING_MODEL = "text-embedding-3-small"
# ── 임베딩 함수 ────────────────────────────────────────
def embed(text: str) -> list[float]:
res = openai_client.embeddings.create(
model=EMBEDDING_MODEL,
input=text.strip()[:8000],
)
return res.data[0].embedding
def fingerprint(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
# ── 저장 (UPSERT) ──────────────────────────────────────
def upsert_chunk(
title: str,
content: str,
content_type: str,
source_id: str,
chunk_index: int,
) -> None:
fp = fingerprint(content)
# 기존 fingerprint 확인 → 변경 없으면 스킵
cursor = conn.execute(
"SELECT fingerprint FROM rag_documents WHERE source_id = ? AND chunk_index = ?",
(source_id, chunk_index)
)
row = cursor.fetchone()
if row and row[0] == fp:
print(f" 스킵 (변경 없음): {source_id}:{chunk_index}")
return
vec = embed(content)
vec_str = json.dumps(vec) # ← vector32()에 JSON 문자열 전달 필수
conn.execute(
"""INSERT INTO rag_documents
(title, content, type, source_id, chunk_index, model, fingerprint, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, vector32(?))
ON CONFLICT (source_id, chunk_index) DO UPDATE SET
content = excluded.content,
model = excluded.model,
fingerprint = excluded.fingerprint,
embedding = excluded.embedding,
updated_at = CURRENT_TIMESTAMP""",
(title, content, content_type, source_id, chunk_index,
EMBEDDING_MODEL, fp, vec_str)
)
conn.commit()
print(f" 저장 완료: {source_id}:{chunk_index}")
# ── 벡터 검색 ─────────────────────────────────────────
def search(query: str, top_k: int = 5, type_filter: str | None = None) -> list[dict]:
query_vec = embed(query)
vec_str = json.dumps(query_vec)
if type_filter:
cursor = conn.execute(
"""SELECT title, content, type, source_id,
vector_distance_cos(embedding, vector32(?)) AS distance
FROM rag_documents
WHERE model = ? AND type = ?
ORDER BY distance
LIMIT ?""",
(vec_str, EMBEDDING_MODEL, type_filter, top_k)
)
else:
cursor = conn.execute(
"""SELECT title, content, type, source_id,
vector_distance_cos(embedding, vector32(?)) AS distance
FROM rag_documents
WHERE model = ?
ORDER BY distance
LIMIT ?""",
(vec_str, EMBEDDING_MODEL, top_k)
)
cols = [d[0] for d in cursor.description]
return [dict(zip(cols, row)) for row in cursor.fetchall()]
# ── 사용 예시 ──────────────────────────────────────────
if __name__ == "__main__":
# 저장
upsert_chunk(
title="썸네일 팁 영상",
content="썸네일 클릭률을 높이는 방법...",
content_type="video",
source_id="dQw4w9WgXcQ",
chunk_index=0,
)
# 검색
results = search("썸네일 클릭률 높이는 방법", top_k=5)
for r in results:
print(f" [{r['distance']:.3f}] {r['title']}: {r['content'][:60]}...")📦 환경변수 설정 (.env.local · Vercel 대시보드)
# .env.local (로컬 개발 — Git에 절대 커밋 금지) TURSO_DATABASE_URL=libsql://my-rag-db-[username].turso.io TURSO_AUTH_TOKEN=eyJhbGciOi... OPENAI_API_KEY=sk-... # Vercel 대시보드 설정 경로: # 프로젝트 → Settings → Environment Variables # 위 3개를 Production / Preview / Development 환경에 동일하게 추가
토큰은 만료 기간을 설정할 수 있습니다. turso db tokens create my-rag-db --expiration 90d로 90일 유효 토큰을 발급할 수 있습니다. 프로덕션에서는 만료 기간을 설정하고 주기적으로 갱신하는 것을 권장합니다.
🖥️ DB 뷰어로 데이터 확인하기
Turso CLI Shell
터미널에서 바로 SQL 실행
turso db shell my-rag-db별도 설치 없이 바로 사용
TablePlus
GUI 뷰어 — libSQL 드라이버 지원
libsql://... + JWT 토큰embedding 컬럼은 <BLOB>으로 표시됨 (정상)
Turso 대시보드
웹 UI에서 테이블·쿼리 확인
app.turso.tech회원가입 후 내 DB 목록에서 접근
✅ Turso 무료 플랜으로 개인 RAG가 충분한가?
아래 수치는 2026년 3월 기준 참고값입니다. turso.tech/pricing 에서 최신 한도를 반드시 확인하세요.
저장 용량
9GB
유튜브 채널 전체 ≈ 20~30MB
✅ 무료로 충분
DB 개수
500개
프로젝트당 1개만 사용
✅ 무료로 충분
월 읽기
10억 row
검색 수천 회도 여유
✅ 무료로 충분
월 쓰기
2,500만 row
증분 동기화 시 충분
✅ 무료로 충분
DB 저장 형태 — 원본 텍스트 vs 벡터
실제로 Turso DB에 어떤 데이터가 어떤 형태로 저장되는지를 행(row) 단위로 시각화합니다. INSERT·SELECT SQL, DB 뷰어에서 보이는 모습, 용량 계산 근거까지 전부 확인합니다.
권장 테이블 스키마 — 실무 컬럼 포함
-- Turso (libSQL) 기준
CREATE TABLE rag_documents (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL, -- 원본 텍스트 (사람이 읽을 수 있음)
type TEXT NOT NULL, -- 'video' | 'comment' | 'script'
source_id TEXT, -- 원본 리소스 식별자 (영상 ID 등)
chunk_index INTEGER DEFAULT 0, -- 같은 원본의 몇 번째 청크인지
model TEXT NOT NULL, -- 임베딩 모델명 ('text-embedding-3-small' 등)
fingerprint TEXT, -- content의 SHA-256 해시 (변경 감지용)
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
embedding BLOB -- float32 벡터 (바이너리, 모델마다 크기 다름)
);
-- 벡터 유사도 검색용 인덱스 (Turso 전용)
-- 일반 SQL 인덱스는 벡터 컬럼에 효과 없음. Turso는 내부 ANN 인덱스를 자동 관리.
-- 텍스트 검색 가속용 일반 인덱스는 아래처럼 따로 생성
CREATE INDEX idx_type ON rag_documents(type);
CREATE INDEX idx_source_id ON rag_documents(source_id);
CREATE INDEX idx_fingerprint ON rag_documents(fingerprint);
CREATE INDEX idx_model ON rag_documents(model);⚠️ model과 fingerprint 컬럼이 중요한 이유
model: 임베딩 모델이 달라지면 벡터 공간 자체가 바뀝니다. 어떤 모델로 만든 벡터인지 기록해두지 않으면 모델 교체 시 어떤 행을 재임베딩해야 하는지 알 수 없습니다.
fingerprint: content의 SHA-256 해시입니다. 새 데이터를 저장할 때 해시가 같으면 재임베딩을 건너뜁니다. API 비용을 크게 절약합니다. (자세한 내용은 비용 최적화 섹션 참고)
실제 한 행(row)의 데이터 생김새
Turso DB 한 행(row) 구조
| 컬럼 | 실제 값 예시 | 형태 / 설명 |
|---|---|---|
| id | 1 | 숫자 — 사람이 읽을 수 있음 |
| title | "썸네일 클릭률 높이는 법" | 텍스트 그대로 |
| content | "썸네일에 숫자를 넣으면..." | 텍스트 그대로 (청크 원문) |
| type | "video" | "video" | "comment" | "script" |
| source_id | "dQw4w9WgXcQ" | 텍스트 그대로 (영상 ID 등) |
| chunk_index | 0 | 숫자 — 같은 원본의 몇 번째 청크 |
| model | "text-embedding-3-small" | 텍스트 — 임베딩 생성에 사용한 모델명 |
| fingerprint | "a3f2c1d9e4b7..." | 텍스트 — content의 SHA-256 해시 64자 |
| created_at | "2026-03-22 09:00:00" | 날짜 그대로 |
| updated_at | "2026-03-22 09:00:00" | 날짜 그대로 |
| embedding | 3c8b1e40 2d7a4b3f ad91cc28 ... (6144 bytes) | ⚠️ 바이너리 BLOB — DB 뷰어에서 이상한 문자로 표시됨 |
대본 하나가 DB에 저장되는 전체 과정
[원본 대본] script_001.txt (4,000자)
─────────────────────────────────────────────────
"안녕하세요 오늘은 썸네일 클릭률을 높이는 방법...
첫 번째로는 숫자를 활용하는 방법입니다...
두 번째로는 얼굴 표정입니다. 놀란 표정이나...
세 번째로는 색상 대비입니다..."
─────────────────────────────────────────────────
↓ ① fingerprint 생성
SHA-256("안녕하세요 오늘은...") = "a3f2c1d9..."
→ 기존 행과 해시 비교 → 변경된 경우만 아래로 진행
↓ ② 청킹 (RecursiveCharacterTextSplitter, 500자 단위)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ 청크 0 │ │ 청크 1 │ │ 청크 2 │
│ "안녕하세요 │ │ "두 번째로는 │ │ "세 번째로는 │
│ 오늘은 썸네 │ │ 얼굴 표정..."│ │ 색상 대비..."│
│ 일 클릭률..."│ │ │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
↓ ③ 임베딩 API 호출 (text-embedding-3-small → 1536차원)
청크 0 → [0.031, -0.184, 0.902, ...] (float32 × 1536)
청크 1 → [0.117, -0.392, 0.541, ...] (float32 × 1536)
청크 2 → [0.284, -0.071, 0.739, ...] (float32 × 1536)
↓ ④ UPSERT (중복 실행해도 안전)
┌───┬───────────────┬──────────────────┬───────┬──────────────┬─────────────┐
│id │title │content │chunk │fingerprint │embedding │
├───┼───────────────┼──────────────────┼───────┼──────────────┼─────────────┤
│ 1 │script_001 │안녕하세요 오늘.. │ 0 │ a3f2c1d9... │ [바이너리] │
│ 2 │script_001 │두 번째로는 얼.. │ 1 │ b9e4a2f1... │ [바이너리] │
│ 3 │script_001 │세 번째로는 색.. │ 2 │ c7d8e3a0... │ [바이너리] │
└───┴───────────────┴──────────────────┴───────┴──────────────┴─────────────┘
↑ 사람이 읽을 수 있음 ↑ 이상한 문자 (정상)실제 INSERT / UPSERT SQL 코드
-- ① 기본 INSERT (신규 행 추가)
INSERT INTO rag_documents
(title, content, type, source_id, chunk_index, model, fingerprint, embedding)
VALUES (
'script_001',
'안녕하세요 오늘은 썸네일 클릭률을 높이는 방법...',
'script',
'script_001',
0,
'text-embedding-3-small',
'a3f2c1d9e4b7...', -- SHA-256 해시
vector32('[0.031, -0.184, 0.902, ...]') -- Turso 벡터 리터럴
);
-- ② UPSERT — 중복 실행해도 안전 (멱등성 보장)
-- source_id + chunk_index 조합이 같으면 UPDATE, 없으면 INSERT
INSERT INTO rag_documents
(title, content, type, source_id, chunk_index, model, fingerprint, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, vector32(?))
ON CONFLICT (source_id, chunk_index) DO UPDATE SET
title = excluded.title,
content = excluded.content,
model = excluded.model,
fingerprint = excluded.fingerprint,
embedding = excluded.embedding,
updated_at = CURRENT_TIMESTAMP;
-- UPSERT를 위한 유니크 제약 추가 필요
-- CREATE UNIQUE INDEX idx_source_chunk ON rag_documents(source_id, chunk_index);
-- ③ fingerprint 비교 — 변경된 행만 골라내기
SELECT id, source_id, chunk_index, fingerprint
FROM rag_documents
WHERE source_id = 'script_001'
ORDER BY chunk_index;
-- Python에서 새 fingerprint와 비교 → 다른 것만 재임베딩# 저장 후 유사도 검색 — 사용 예시
-- 질문 벡터와 가장 유사한 청크 5개 검색
SELECT
id,
title,
content,
type,
source_id,
chunk_index,
model,
vector_distance_cos(embedding, vector32(?)) AS distance
FROM rag_documents
WHERE model = 'text-embedding-3-small' -- 반드시 같은 모델로 검색!
ORDER BY distance
LIMIT 5;
-- 타입 필터 + 벡터 검색 동시에
SELECT title, content,
vector_distance_cos(embedding, vector32(?)) AS distance
FROM rag_documents
WHERE type = 'video'
AND model = 'text-embedding-3-small'
ORDER BY distance
LIMIT 5;DB 뷰어(TablePlus · DBeaver)에서 보이는 실제 모습
id │ content
────┼──────────────────────────────────
1 │ 안녕하세요 오늘은 썸네일 클릭률을
│ 높이는 방법에 대해 알아보겠습니다.
│ 첫 번째로는 숫자를 활용하는...
────┼──────────────────────────────────
2 │ 두 번째로는 얼굴 표정입니다.
│ 놀란 표정이나 강렬한 시선이
│ 클릭률을 높이는 데 효과적...
────┼──────────────────────────────────
3 │ 세 번째로는 색상 대비입니다.
│ 배경과 텍스트의 색상 대비를...언제든 읽고 수정·삭제 가능. 원본 데이터 보존됨.
id │ embedding
────┼──────────────────────────────────
1 │ <BLOB> 6144 bytes
│ 3c8b1e40 2d7a4b3f ad91cc28
│ f03e5d12 88c4a719 e25fb603
│ ▒▒▒Ý╗?´ûÌ(≡>]¶ˆÄ§↑_¶♣...
────┼──────────────────────────────────
2 │ <BLOB> 6144 bytes
│ 1f4a9c02 e83b6d71 5a029e4f
│ ╠Ö╔²ñ▓▄☺>├♀ÿñ♠⌂ã╦×↓...
────┼──────────────────────────────────
3 │ <BLOB> 6144 bytes
│ 7d2e0b85 c14f3a96 08fe7c3d
│ }. τ.Å≡:û♣ω▀}╣═Ñl½...이상한 문자로 표시됨 — 완전히 정상입니다. float32 배열이 바이너리로 저장된 것.
DB 용량 계산 근거 — 왜 텍스트보다 벡터가 훨씬 크나
📏 벡터 1개 크기 계산
float32 = 4 bytes (IEEE 754 32비트) text-embedding-3-small → 1536차원 1536 × 4 bytes = 6,144 bytes ≈ 6 KB text-embedding-3-large → 3072차원 3072 × 4 bytes = 12,288 bytes ≈ 12 KB Gemini text-embedding-004 → 768차원 768 × 4 bytes = 3,072 bytes ≈ 3 KB BGE-M3 (dense) → 1024차원 1024 × 4 bytes = 4,096 bytes ≈ 4 KB
💡 차원 압축(MRL)을 지원하는 모델은 저장 시 더 낮은 차원으로 잘라 용량을 절약할 수 있습니다.
📊 300개 대본 기준 총 용량
가정: 300개 대본 × 평균 4,000자 = 120만자
1자 ≈ 3bytes(UTF-8 한글) → 약 3.6MB 원본
청킹: 500자 단위 → 약 2,400개 청크
(단, 댓글·커뮤니티 포함 시 청크 수 더 많음)
──── 1536차원 기준 ────────────────────────
원본 텍스트 : 3.6 MB
벡터 데이터 : 2,400 × 6KB = 14.4 MB
메타데이터 : 0.5 MB
인덱스 오버헤드: 3.0 MB
─────────────────────────────────────────
합계 : 약 21~22 MB
──── 768차원(Gemini) 기준 ─────────────────
벡터 데이터 : 2,400 × 3KB = 7.2 MB
합계 : 약 14~15 MBTurso 무료 플랜 9GB 이내 — 여유롭게 수용 가능합니다.
Chroma vs Turso — 저장 파일 구조 비교
chroma_db/
├── chroma.sqlite3 ← 텍스트·메타데이터
│ (SQLite, 사람이 열 수 있음)
└── [uuid]/
├── data_level0.bin ← HNSW 벡터 인덱스
│ (바이너리, 읽기 불가)
├── header.bin
├── length.bin
└── link_lists.bin
접근 방법:
Python: Chroma(persist_directory="./chroma_db")
뷰어: DB Browser for SQLite로 .sqlite3 열기
주의: 서버 재시작 시 파일 경로 유지 필요
(Streamlit Cloud는 재시작 시 소멸!)Turso 내부 (libSQL, SQLite 호환):
rag_documents 테이블
├── content, title, type ... (SQLite 행)
└── embedding BLOB (벡터, 바이너리)
접근 방법:
JS: @libsql/client
Python: libsql-experimental
뷰어: TablePlus + libSQL 드라이버
(embedding 컬럼은 <BLOB>으로 표시)
장점: 서버리스·항상 켜짐·Vercel 공식 통합
전 세계 엣지 복제 지원
주의: vector32() 함수로 벡터 삽입 필요데이터 라이프사이클 — 수정·삭제·재임베딩
-- ① 원본 내용 수정 시: content 업데이트 + 재임베딩 필요
UPDATE rag_documents
SET content = '수정된 내용...',
fingerprint = '새 SHA-256 해시',
embedding = vector32('[새 벡터...]'),
updated_at = CURRENT_TIMESTAMP
WHERE source_id = 'script_001' AND chunk_index = 0;
-- ② 특정 소스 전체 삭제 (영상 삭제 시)
DELETE FROM rag_documents
WHERE source_id = 'dQw4w9WgXcQ';
-- ③ 임베딩 모델 교체 시 전체 재임베딩
-- → 새 모델로 생성된 벡터로 일괄 업데이트
UPDATE rag_documents
SET embedding = vector32(?),
model = 'text-embedding-3-large',
updated_at = CURRENT_TIMESTAMP
WHERE id = ?;
-- ④ 특정 모델의 행만 조회 (재임베딩 대상 확인)
SELECT COUNT(*) FROM rag_documents
WHERE model != 'text-embedding-3-small';
-- → 0이면 전체 동일 모델. 0이 아니면 혼재 상태 (주의!)⚠️ 모델 혼재 시 검색 결과가 엉망이 됩니다
text-embedding-3-small로 만든 벡터와 text-embedding-3-large로 만든 벡터는 서로 다른 수학적 공간에 존재합니다. 같은 테이블에 혼재하면 유사도 계산 자체가 무의미해집니다. 모델을 바꿀 때는 반드시 전체 행을 새 모델로 재임베딩해야 합니다.model 컬럼으로 항상 어떤 모델을 썼는지 추적하세요.
💡 핵심 정리 — 4가지만 기억하세요
content는 항상 읽을 수 있다
embedding만 바이너리입니다. 원본 텍스트는 절대 사라지지 않으며 DB 뷰어로 언제든 확인·수정 가능합니다.
model 컬럼을 반드시 저장하라
모델이 달라지면 벡터 공간이 바뀝니다. 어떤 모델로 만든 벡터인지 기록하지 않으면 재임베딩 시 큰 혼란이 옵니다.
fingerprint로 비용을 절약하라
content의 SHA-256 해시를 저장해두면 변경된 청크만 재임베딩할 수 있습니다. 전체 재실행 비용을 90% 이상 줄입니다.
UPSERT로 멱등성을 보장하라
INSERT ON CONFLICT DO UPDATE를 사용하면 스크립트를 여러 번 실행해도 데이터가 중복·손상되지 않습니다.
검색 + 생성 — RAG 체인 코드 — 실전 구현
질문이 들어왔을 때 유사한 청크를 검색하고, 그것을 바탕으로 LLM이 답변을 생성하는 RAG 체인을 완성합니다. 기초 RetrievalQA부터 최신 LCEL 패턴, 대화 히스토리, 스트리밍까지 단계별로 다룹니다.
RAG 체인 동작 흐름
질문: "썸네일 클릭률 높이는 방법 알려줘"
↓
1. 질문을 임베딩 벡터로 변환
[0.10, -0.30, 0.55, ...]
↓
2. Chroma에서 가장 유사한 청크 5개 검색
청크 1: "썸네일에 숫자를 넣으면 클릭률이..." (유사도: 0.91)
청크 2: "얼굴 표정, 특히 놀란 표정이..." (유사도: 0.87)
청크 3: "색상 대비를 강하게 하면..." (유사도: 0.82)
청크 4: "텍스트 크기는 전체의 30% 이하로..." (유사도: 0.79)
청크 5: "A/B 테스트로 2주간 비교한 결과..." (유사도: 0.74)
↓
3. (선택) Reranker로 순위 재정렬
Cross-Encoder가 질문-청크 쌍을 정밀 평가 → 순서 재조정
↓
4. 프롬프트 조립
[시스템]: 당신은 내 YouTube 채널 전문 어시스턴트...
[컨텍스트]: 청크 1 + 청크 2 + ... + 청크 5
[히스토리]: (대화 이력, Memory 사용 시)
[질문]: 썸네일 클릭률 높이는 방법 알려줘
↓
5. LLM(GPT-4o-mini 등) 답변 생성 (스트리밍 or 일반)
"내 채널 데이터를 보면, 썸네일에 숫자를 넣었을 때..."⚠️ 기존 코드의 "거리(distance)"는 낮을수록 가깝습니다(코사인 거리). Chroma의 similarity_search_with_score는 반환값이 거리인지 유사도인지 설정에 따라 다릅니다. 혼동을 막기 위해 위 흐름도는 유사도(높을수록 유사) 기준으로 표시했습니다.
STEP 1 — 기초: RetrievalQA (개념 이해용)
LangChain 0.2+에서 RetrievalQA는 deprecated 경고가 납니다. 동작은 하지만 새 프로젝트에는 STEP 2의 LCEL 패턴을 사용하세요. 아래는 RAG 흐름을 이해하기 위한 개념 예제입니다.
# 4_rag_chain.py (개념 이해용 — RetrievalQA)
import os
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate
load_dotenv()
# 1. 저장된 벡터 DB 로드
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectordb = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
collection_name="youtube_rag"
)
print(f"벡터 DB 로드 완료. 총 {vectordb._collection.count()}개 청크")
# 2. 검색기 설정
retriever = vectordb.as_retriever(
search_type="similarity", # 코사인 유사도 기반
search_kwargs={
"k": 5, # 상위 5개 청크 반환
# "filter": {"type": "video"} # 메타데이터 필터 (선택)
}
)
# 3. 맞춤 프롬프트
CUSTOM_PROMPT = PromptTemplate(
template="""당신은 내 YouTube 채널(큐레이터 단비)의 전문 어시스턴트입니다.
아래 제공된 채널 데이터(영상 대본, 댓글 등)를 기반으로만 답변하세요.
데이터에 없는 내용은 솔직하게 "해당 데이터에서 찾을 수 없습니다"라고 말하세요.
채널 데이터:
{context}
질문: {question}
답변:""",
input_variables=["context", "question"]
)
# 4. RAG 체인 생성
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
chain_type_kwargs={"prompt": CUSTOM_PROMPT},
return_source_documents=True
)
# 5. 질의 테스트
def ask(question: str):
result = qa_chain.invoke({"query": question})
print("\n💬 질문:", question)
print("\n🤖 답변:", result["result"])
print("\n📄 참고 소스:")
for i, doc in enumerate(result["source_documents"]):
print(
f" [{i+1}] {doc.metadata.get('title', '제목없음')}"
f" | 유형: {doc.metadata.get('type', '?')}"
f" | 조회수: {doc.metadata.get('view_count', 0):,}"
)
if __name__ == "__main__":
ask("썸네일 클릭률 높이는 방법이 뭐야?")
ask("댓글에서 시청자들이 가장 많이 요청한 주제는?")STEP 2 — 권장: LCEL 패턴 (LangChain 0.2+ 정식 방식)
LCEL(LangChain Expression Language)은 | 연산자로 컴포넌트를 조합합니다. 스트리밍·배치·비동기를 자동으로 지원하며, 디버깅과 교체가 쉽습니다. 아래는 출처 문서까지 함께 반환하는 완성형 코드입니다.
# 4_rag_chain_lcel.py (권장 — LCEL 완전 구현)
import os
from operator import itemgetter
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.output_parsers import StrOutputParser
load_dotenv()
# ── 벡터 DB & 검색기 ──────────────────────────────────
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectordb = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
collection_name="youtube_rag"
)
retriever = vectordb.as_retriever(search_kwargs={"k": 5})
# ── 컨텍스트 포맷터 ────────────────────────────────────
def format_docs(docs) -> str:
"""검색된 문서 목록을 프롬프트용 문자열로 변환"""
return "\n\n---\n\n".join(
f"[{i+1}] ({doc.metadata.get('type','?')}) "
f"{doc.metadata.get('title','?')}:\n{doc.page_content}"
for i, doc in enumerate(docs)
)
# ── 프롬프트 ───────────────────────────────────────────
prompt = ChatPromptTemplate.from_messages([
("system",
"""당신은 내 YouTube 채널(큐레이터 단비)의 전문 어시스턴트입니다.
반드시 아래 채널 데이터만 근거로 답변하세요.
근거 없는 내용은 '데이터에서 찾을 수 없습니다'라고 말하세요.
채널 데이터:
{context}"""),
("human", "{question}"),
])
# ── LLM ────────────────────────────────────────────────
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)
# ── 체인 조합 (출처 문서 함께 반환) ───────────────────
rag_chain_with_sources = (
{
"context": retriever | RunnableLambda(format_docs),
"question": RunnablePassthrough(),
# 출처 문서를 별도로 보존
"_docs": retriever,
}
| RunnablePassthrough.assign(
answer=prompt | llm | StrOutputParser()
)
)
def ask(question: str):
result = rag_chain_with_sources.invoke(question)
print("\n💬 질문:", question)
print("\n🤖 답변:", result["answer"])
print("\n📄 참고 소스:")
for i, doc in enumerate(result["_docs"]):
print(
f" [{i+1}] {doc.metadata.get('title','?')}"
f" | 유형: {doc.metadata.get('type','?')}"
)
if __name__ == "__main__":
ask("썸네일 클릭률 높이는 방법이 뭐야?")STEP 3 — 스트리밍 응답 (실무 필수)
LCEL 체인은 .stream()을 호출하는 것만으로 스트리밍이 활성화됩니다. Streamlit·FastAPI·Vercel AI SDK와 조합할 때 사용자 경험이 크게 향상됩니다.
# ── 스트리밍 버전 (LCEL) ──────────────────────────────
# 단순 체인 (출처 반환 없이 답변만 스트리밍)
simple_chain = (
{
"context": retriever | RunnableLambda(format_docs),
"question": RunnablePassthrough(),
}
| prompt
| llm
| StrOutputParser()
)
# 스트리밍 출력
print("🤖 답변: ", end="", flush=True)
for chunk in simple_chain.stream("썸네일 클릭률 높이는 방법?"):
print(chunk, end="", flush=True)
print() # 줄바꿈
# ── Streamlit 스트리밍 연동 ───────────────────────────
# import streamlit as st
# with st.chat_message("assistant"):
# response = st.write_stream(
# simple_chain.stream(user_question)
# )
# ── FastAPI + SSE 스트리밍 연동 ──────────────────────
# from fastapi.responses import StreamingResponse
# async def stream_answer(question: str):
# async def generator():
# async for chunk in simple_chain.astream(question):
# yield f"data: {chunk}\n\n"
# return StreamingResponse(generator(), media_type="text/event-stream")STEP 4 — 대화 히스토리 (멀티턴 대화)
기본 RAG는 매 질문이 독립적입니다. "아까 말한 거 더 자세히 알려줘" 같은 후속 질문을 처리하려면 대화 히스토리(Memory)가 필요합니다. LangChain의 create_history_aware_retriever를 사용하면 히스토리를 반영해 검색 쿼리 자체를 재작성합니다.
# 4_rag_chain_memory.py (멀티턴 대화 — 히스토리 반영)
from langchain.chains import create_history_aware_retriever, create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import MessagesPlaceholder
from langchain_core.messages import HumanMessage, AIMessage
# ── 1. 히스토리를 반영한 검색 쿼리 재작성 프롬프트 ────
contextualize_q_prompt = ChatPromptTemplate.from_messages([
("system",
"""대화 히스토리와 최신 질문이 주어집니다.
히스토리 없이도 이해 가능한 독립적인 질문으로 재작성하세요.
질문을 답변하지 말고 필요하면 재작성, 아니면 그대로 반환하세요."""),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
history_aware_retriever = create_history_aware_retriever(
llm, retriever, contextualize_q_prompt
)
# ── 2. 답변 생성 프롬프트 (히스토리 포함) ────────────
qa_prompt = ChatPromptTemplate.from_messages([
("system",
"""당신은 내 YouTube 채널 전문 어시스턴트입니다.
아래 채널 데이터만 근거로 답변하세요.
{context}"""),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
question_answer_chain = create_stuff_documents_chain(llm, qa_prompt)
rag_chain = create_retrieval_chain(
history_aware_retriever, question_answer_chain
)
# ── 3. 대화 루프 ──────────────────────────────────────
chat_history = []
def chat(question: str) -> str:
result = rag_chain.invoke({
"input": question,
"chat_history": chat_history,
})
# 히스토리 업데이트
chat_history.extend([
HumanMessage(content=question),
AIMessage(content=result["answer"]),
])
return result["answer"]
# 사용 예시
print(chat("썸네일 클릭률 높이는 방법이 뭐야?"))
print(chat("그 중에서 가장 효과적인 방법 하나만 뽑아줘")) # 후속 질문
print(chat("그게 실제 내 채널 데이터에 있어?")) # 히스토리 참조STEP 5 — 메타데이터 필터링 검색
# 메타데이터 필터로 검색 범위를 좁힐 수 있습니다
# Chroma의 where 필터 (메타데이터 기반)
# 영상 데이터만 검색
video_retriever = vectordb.as_retriever(
search_kwargs={
"k": 5,
"filter": {"type": "video"} # video | comment | script
}
)
# 특정 조회수 이상 영상만 검색 (Chroma where 문법)
popular_retriever = vectordb.as_retriever(
search_kwargs={
"k": 5,
"filter": {
"$and": [
{"type": {"$eq": "video"}},
{"view_count": {"$gte": 10000}}
]
}
}
)
# 동적으로 필터를 바꾸는 함수형 검색기
def get_filtered_retriever(content_type: str | None = None, min_views: int = 0):
filters = {}
conditions = []
if content_type:
conditions.append({"type": {"$eq": content_type}})
if min_views > 0:
conditions.append({"view_count": {"$gte": min_views}})
if len(conditions) > 1:
filters = {"$and": conditions}
elif len(conditions) == 1:
filters = conditions[0]
return vectordb.as_retriever(
search_kwargs={"k": 5, "filter": filters} if filters else {"k": 5}
)에러 처리 — 프로덕션 필수
import time
from openai import RateLimitError, APIError
def ask_with_retry(question: str, max_retries: int = 3) -> str:
"""API 실패 시 지수 백오프로 재시도"""
for attempt in range(max_retries):
try:
result = rag_chain_with_sources.invoke(question)
return result["answer"]
except RateLimitError:
if attempt < max_retries - 1:
wait = 2 ** attempt # 1초 → 2초 → 4초
print(f"Rate limit 초과. {wait}초 후 재시도 ({attempt+1}/{max_retries})")
time.sleep(wait)
else:
return "⚠️ 요청 한도 초과입니다. 잠시 후 다시 시도해주세요."
except APIError as e:
print(f"API 오류: {e}")
return "⚠️ AI 서비스 오류가 발생했습니다. 잠시 후 다시 시도해주세요."
except Exception as e:
print(f"예상치 못한 오류: {e}")
return "⚠️ 오류가 발생했습니다."
return "⚠️ 재시도 한도를 초과했습니다."
# 검색 결과가 없을 때 처리
def ask_safe(question: str) -> dict:
docs = retriever.invoke(question)
if not docs:
return {
"answer": "관련 데이터를 찾지 못했습니다. 다른 키워드로 질문해보세요.",
"sources": []
}
# 유사도가 낮은 결과만 있을 때 경고
# (with_score 사용 시: score < 임계값이면 신뢰도 낮음 표시)
answer = ask_with_retry(question)
return {"answer": answer, "sources": docs}실무 적용 심화 옵션
🔁 Reranker (검색 후 재정렬)
검색된 상위 k개를 LLM에 넣기 전, Cross-Encoder Reranker로 순위를 다시 매깁니다. Bi-Encoder 벡터 검색은 빠르지만 정밀도가 낮고, Cross-Encoder는 느리지만 정확합니다. 두 단계를 조합하는 것이 최선입니다.
# pip install cohere
import cohere
co = cohere.Client(os.getenv("COHERE_API_KEY"))
def rerank(query: str, docs: list, top_n: int = 3):
results = co.rerank(
query=query,
documents=[d.page_content for d in docs],
top_n=top_n,
model="rerank-multilingual-v3.0"
)
return [docs[r.index] for r in results.results]
# 체인에 적용
reranked_docs = rerank(question, retriever.invoke(question))🧪 HyDE (가상 문서 임베딩)
Hypothetical Document Embedding: 질문을 LLM으로 먼저 가상 답변으로 만든 뒤, 그 가상 답변의 임베딩으로 검색합니다. 질문이 짧거나 추상적일 때 검색 품질을 높여줍니다.
from langchain.chains import HypotheticalDocumentEmbedder
# 질문 → 가상 답변 생성 → 가상 답변으로 검색
hyde_embeddings = HypotheticalDocumentEmbedder.from_llm(
llm=llm,
base_embeddings=embeddings,
custom_prompt=PromptTemplate(
input_variables=["question"],
template="다음 질문에 대한 상세 답변을 작성하세요:\n{question}\n답변:"
)
)
hyde_retriever = vectordb.as_retriever(
search_kwargs={"k": 5}
)
# hyde_embeddings로 검색 쿼리 생성 후 검색chain_type 옵션 비교
| 타입 | 동작 | 추천 상황 |
|---|---|---|
| stuff | 청크 전부를 하나로 합침 | 청크 수 적고 짧을 때 (기본) |
| map_reduce | 각 청크 개별 요약 후 병합 | 청크 많고 문서 길 때 |
| refine | 이전 답변 + 새 청크 반복 정제 | 고품질 단일 답변 필요 시 |
| map_rerank | 각 청크로 답변 생성 후 최고점 선택 | 정확도 최우선 |
temperature 용도별 권장값
0.0사실 기반 Q&A
환각 최소화, 가장 보수적
0.3일반 어시스턴트 (권장)
사실성과 자연스러움 균형
0.5콘텐츠 아이디어
창의적 제안 허용
0.7+대본·카피 생성
창의적 글쓰기, 다양한 표현
📊 패턴별 선택 기준 요약
| 패턴 | 구현 난이도 | 대화 히스토리 | 스트리밍 | 추천 대상 |
|---|---|---|---|---|
| RetrievalQA | ⭐ 쉬움 | ❌ | ❌ | 개념 학습·빠른 프로토타입 |
| LCEL 기본 | ⭐⭐ 보통 | ❌ | ✅ | 개인 프로젝트·Streamlit |
| LCEL + 히스토리 | ⭐⭐⭐ 보통 | ✅ | ✅ | 챗봇·멀티턴 서비스 |
| LCEL + Reranker | ⭐⭐⭐ 높음 | ✅ | ✅ | 검색 품질 중요한 서비스 |
| LCEL + HyDE | ⭐⭐⭐ 높음 | 선택 | ✅ | 짧은/추상적 질문이 많을 때 |
💡 구현 순서 권장 로드맵
- 1RetrievalQA로 빠르게 동작 확인 → 개념 이해
- 2LCEL 기본 패턴으로 마이그레이션 → 스트리밍 연동
- 3메타데이터 필터 추가 → 검색 정밀도 향상
- 4에러 처리·재시도 로직 추가 → 안정성 확보
- 5대화 히스토리 추가 → 멀티턴 대화 지원
- 6Reranker 실험 → 답변 품질 측정 후 도입 여부 결정
Vercel 서버리스 RAG 구조 — Next.js + Turso
기존 Next.js/Vercel 스택에 RAG를 통합하는 프로덕션 수준의 완성형 구조입니다. 별도의 서버나 추가 인프라 없이 Vercel 서버리스 함수로 처리하며, 스트리밍 응답과 데이터 동기화 API까지 포함합니다.
전체 폴더 구조
my-nextjs-app/ ├── app/ │ ├── api/ │ │ └── rag/ │ │ ├── ask/ │ │ │ └── route.ts ← 질문 처리 API (스트리밍 지원) │ │ └── sync/ │ │ └── route.ts ← 데이터 임베딩 동기화 API │ └── my-rag-ui/ │ └── page.tsx ← 채팅 UI (스트리밍 렌더링) ├── lib/ │ ├── turso.ts ← Turso DB 클라이언트 (싱글턴) │ └── rag/ │ ├── constants.ts ← 모델명·차원·TOP_K 상수 │ ├── embed.ts ← 임베딩 함수 │ ├── search.ts ← 벡터 검색 함수 │ └── generate.ts ← LLM 스트리밍 생성 함수 └── .env.local ← API 키 (절대 공개 금지)
lib/rag/constants.ts — 상수 한 곳에서 관리
모델명을 여러 파일에 하드코딩하면 모델을 교체할 때 모든 파일을 찾아서 고쳐야 합니다. 상수 파일 하나에서만 관리하면 한 줄 수정으로 전체 교체가 됩니다.
// lib/rag/constants.ts export const EMBEDDING_MODEL = "text-embedding-3-small" as const; export const EMBEDDING_DIMENSIONS = 1536 as const; // 모델 교체 시 여기도 수정 export const LLM_MODEL = "gpt-4o-mini" as const; export const TOP_K = 5 as const; // 검색 청크 수 export const MIN_SIMILARITY_THRESHOLD = 0.3 as const; // 코사인 거리 상한 (이보다 크면 관련 없음) // 임베딩 모델 교체 체크리스트: // 1. EMBEDDING_MODEL 문자열 수정 // 2. EMBEDDING_DIMENSIONS 숫자 수정 // 3. DB의 기존 행을 새 모델로 전체 재임베딩 (sync API 실행) // 4. 두 모델의 벡터가 절대 혼재하지 않도록 주의
lib/rag/ — 모듈별 구현 코드
📄 lib/rag/embed.ts
// lib/rag/embed.ts
import OpenAI from "openai";
import { EMBEDDING_MODEL } from "./constants";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
/** 단일 텍스트 임베딩 (검색 쿼리용) */
export async function embedQuery(query: string): Promise<number[]> {
const res = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: query.trim().slice(0, 8000), // 8K 토큰 상한 초과 방지
});
return res.data[0].embedding;
}
/** 배치 임베딩 (문서 저장용 — API 호출 횟수 절감) */
export async function embedBatch(texts: string[]): Promise<number[][]> {
if (texts.length === 0) return [];
const res = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: texts.map((t) => t.trim().slice(0, 8000)),
});
return res.data
.sort((a, b) => a.index - b.index)
.map((item) => item.embedding);
}📄 lib/rag/search.ts
// lib/rag/search.ts
import { turso } from "@/lib/turso";
import { EMBEDDING_MODEL, TOP_K, MIN_SIMILARITY_THRESHOLD } from "./constants";
export type RagDoc = {
id: number;
title: string;
content: string;
type: string;
source_id: string;
distance: number;
};
export async function searchSimilarDocs(
queryVector: number[],
topK = TOP_K,
typeFilter?: string // 'video' | 'comment' | 'script' | undefined(전체)
): Promise<RagDoc[]> {
const vecStr = JSON.stringify(queryVector);
// WHERE model = ? 로 반드시 같은 모델의 벡터만 검색
// 모델이 다른 벡터와 비교하면 유사도 수치가 무의미해짐
const sql = typeFilter
? `SELECT id, title, content, type, source_id,
vector_distance_cos(embedding, vector32(?)) AS distance
FROM rag_documents
WHERE model = ? AND type = ?
ORDER BY distance
LIMIT ?`
: `SELECT id, title, content, type, source_id,
vector_distance_cos(embedding, vector32(?)) AS distance
FROM rag_documents
WHERE model = ?
ORDER BY distance
LIMIT ?`;
const args = typeFilter
? [vecStr, EMBEDDING_MODEL, typeFilter, topK]
: [vecStr, EMBEDDING_MODEL, topK];
const { rows } = await turso.execute({ sql, args });
// 유사도 임계값 필터 — 관련 없는 결과 제거
return (rows as RagDoc[]).filter(
(row) => row.distance <= MIN_SIMILARITY_THRESHOLD
);
}📄 lib/rag/generate.ts
// lib/rag/generate.ts
import OpenAI from "openai";
import { LLM_MODEL } from "./constants";
import type { RagDoc } from "./search";
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY! });
function buildContext(docs: RagDoc[]): string {
if (docs.length === 0) return "관련 데이터 없음";
return docs
.map((d, i) => `[${i + 1}] (${d.type}) ${d.title}:\n${d.content}`)
.join("\n\n---\n\n");
}
const SYSTEM_PROMPT = (context: string) =>
`당신은 내 YouTube 채널 전문 어시스턴트입니다.
반드시 아래 채널 데이터만 근거로 답변하세요.
데이터에 없는 내용은 솔직하게 "해당 데이터에서 찾을 수 없습니다"라고 말하세요.
채널 데이터:
${context}`;
/** 일반 응답 (전체 텍스트 반환) */
export async function generateAnswer(
query: string,
docs: RagDoc[]
): Promise<string> {
const completion = await openai.chat.completions.create({
model: LLM_MODEL,
temperature: 0.3,
messages: [
{ role: "system", content: SYSTEM_PROMPT(buildContext(docs)) },
{ role: "user", content: query },
],
});
return completion.choices[0].message.content ?? "";
}
/** 스트리밍 응답 (ReadableStream 반환) */
export async function generateAnswerStream(
query: string,
docs: RagDoc[]
): Promise<ReadableStream<string>> {
const stream = await openai.chat.completions.create({
model: LLM_MODEL,
temperature: 0.3,
stream: true,
messages: [
{ role: "system", content: SYSTEM_PROMPT(buildContext(docs)) },
{ role: "user", content: query },
],
});
return new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content ?? "";
if (text) controller.enqueue(text);
}
controller.close();
},
});
}app/api/rag/ask/route.ts — 스트리밍 + 에러 처리
스트리밍 응답을 사용하면 LLM이 토큰을 생성하는 즉시 클라이언트로 전달합니다. 전체 답변이 완성될 때까지 기다리지 않아도 되므로 체감 응답 속도가 크게 향상됩니다. Vercel 타임아웃(기본 10초) 문제도 스트리밍으로 완화할 수 있습니다.
// app/api/rag/ask/route.ts
import { NextRequest } from "next/server";
import { embedQuery } from "@/lib/rag/embed";
import { searchSimilarDocs } from "@/lib/rag/search";
import { generateAnswerStream } from "@/lib/rag/generate";
// Edge Runtime — 스트리밍에 최적화, 타임아웃 제약 없음
// (단, Edge에서는 Node.js 전용 API 사용 불가)
export const runtime = "edge";
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const query: string = body?.query ?? "";
const typeFilter: string | undefined = body?.typeFilter; // 선택적 타입 필터
if (!query.trim()) {
return new Response(
JSON.stringify({ error: "query가 비어있습니다." }),
{ status: 400, headers: { "Content-Type": "application/json" } }
);
}
// 1. 질문 임베딩
const queryVec = await embedQuery(query);
// 2. 벡터 검색
const docs = await searchSimilarDocs(queryVec, 5, typeFilter);
// 3. 검색 결과 없을 때 조기 반환
if (docs.length === 0) {
const fallback = "관련 채널 데이터를 찾지 못했습니다. 다른 키워드로 질문해 보세요.";
return new Response(fallback, {
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
// 4. 출처 메타데이터를 헤더에 포함 (스트리밍 중 body에 넣기 어려움)
const sourceMeta = JSON.stringify(
docs.map((d) => ({ title: d.title, type: d.type, source_id: d.source_id }))
);
// 5. LLM 스트리밍 응답 반환
const stream = await generateAnswerStream(query, docs);
return new Response(stream, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"X-Rag-Sources": encodeURIComponent(sourceMeta), // 출처 헤더로 전달
"X-Rag-Doc-Count": String(docs.length),
},
});
} catch (err) {
console.error("[RAG /ask] error:", err);
return new Response(
JSON.stringify({ error: "서버 오류가 발생했습니다. 잠시 후 다시 시도해 주세요." }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
}app/api/rag/sync/route.ts — 데이터 임베딩 동기화
새 영상이나 댓글이 추가될 때마다 전체를 재임베딩하면 비용이 낭비됩니다. fingerprint로 변경된 데이터만 감지해서 재임베딩합니다. Vercel Cron Jobs나 GitHub Actions로 주기적으로 호출하면 자동화됩니다.
// app/api/rag/sync/route.ts
import { NextRequest, NextResponse } from "next/server";
import crypto from "crypto";
import { turso } from "@/lib/turso";
import { embedBatch } from "@/lib/rag/embed";
import { EMBEDDING_MODEL } from "@/lib/rag/constants";
// SYNC_SECRET으로 무단 접근 차단 (Vercel 환경변수에 설정)
function isAuthorized(req: NextRequest): boolean {
const secret = req.headers.get("x-sync-secret");
return secret === process.env.SYNC_SECRET;
}
function fingerprint(text: string): string {
return crypto.createHash("sha256").update(text).digest("hex");
}
type SyncItem = {
title: string;
content: string;
type: "video" | "comment" | "script";
source_id: string;
chunk_index: number;
};
export async function POST(req: NextRequest) {
if (!isAuthorized(req)) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const { items }: { items: SyncItem[] } = await req.json();
if (!items?.length) {
return NextResponse.json({ error: "items가 비어있습니다." }, { status: 400 });
}
// 1. 기존 fingerprint 조회 (변경 감지)
const sourceIds = [...new Set(items.map((i) => i.source_id))];
const { rows: existingRows } = await turso.execute({
sql: `SELECT source_id, chunk_index, fingerprint
FROM rag_documents
WHERE source_id IN (${sourceIds.map(() => "?").join(",")})`,
args: sourceIds,
});
type FpRow = { source_id: string; chunk_index: number; fingerprint: string };
const existingFpMap = new Map(
(existingRows as FpRow[]).map((r) => [`${r.source_id}:${r.chunk_index}`, r.fingerprint])
);
// 2. 변경된 항목만 필터링
const toEmbed = items.filter((item) => {
const key = `${item.source_id}:${item.chunk_index}`;
const newFp = fingerprint(item.content);
return existingFpMap.get(key) !== newFp;
});
if (toEmbed.length === 0) {
return NextResponse.json({ synced: 0, skipped: items.length, message: "변경 없음" });
}
// 3. 배치 임베딩 (변경된 것만)
const vectors = await embedBatch(toEmbed.map((i) => i.content));
// 4. UPSERT — 중복 실행 안전
let synced = 0;
for (let i = 0; i < toEmbed.length; i++) {
const item = toEmbed[i];
const vec = vectors[i];
const fp = fingerprint(item.content);
await turso.execute({
sql: `INSERT INTO rag_documents
(title, content, type, source_id, chunk_index, model, fingerprint, embedding)
VALUES (?, ?, ?, ?, ?, ?, ?, vector32(?))
ON CONFLICT (source_id, chunk_index) DO UPDATE SET
title = excluded.title,
content = excluded.content,
model = excluded.model,
fingerprint = excluded.fingerprint,
embedding = excluded.embedding,
updated_at = CURRENT_TIMESTAMP`,
args: [
item.title, item.content, item.type,
item.source_id, item.chunk_index,
EMBEDDING_MODEL, fp,
JSON.stringify(vec),
],
});
synced++;
}
return NextResponse.json({
synced,
skipped: items.length - synced,
model: EMBEDDING_MODEL,
});
}
// Vercel Cron 설정 예시 (vercel.json)
// {
// "crons": [{ "path": "/api/rag/sync", "schedule": "0 3 * * *" }]
// }
// → 매일 새벽 3시 자동 동기화 (POST + x-sync-secret 헤더 필요)app/my-rag-ui/page.tsx — 스트리밍 채팅 UI
// app/my-rag-ui/page.tsx
"use client";
import { useState, useRef } from "react";
type Source = { title: string; type: string; source_id: string };
export default function MyRagPage() {
const [query, setQuery] = useState("");
const [answer, setAnswer] = useState("");
const [sources, setSources] = useState<Source[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const abortRef = useRef<AbortController | null>(null);
const handleAsk = async () => {
if (!query.trim() || loading) return;
// 이전 요청 취소
abortRef.current?.abort();
abortRef.current = new AbortController();
setLoading(true);
setAnswer("");
setSources([]);
setError("");
try {
const res = await fetch("/api/rag/ask", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query }),
signal: abortRef.current.signal,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error ?? "서버 오류");
}
// 출처 헤더 파싱
const sourceMeta = res.headers.get("X-Rag-Sources");
if (sourceMeta) {
setSources(JSON.parse(decodeURIComponent(sourceMeta)));
}
// 스트리밍 텍스트 읽기
const reader = res.body?.getReader();
const decoder = new TextDecoder();
if (!reader) throw new Error("스트림을 읽을 수 없습니다.");
while (true) {
const { done, value } = await reader.read();
if (done) break;
setAnswer((prev) => prev + decoder.decode(value, { stream: true }));
}
} catch (e: unknown) {
if (e instanceof Error && e.name === "AbortError") return; // 취소는 무시
setError(e instanceof Error ? e.message : "알 수 없는 오류가 발생했습니다.");
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) handleAsk();
};
return (
<div className="max-w-2xl mx-auto p-4 space-y-4">
<h1 className="text-2xl font-bold">내 채널 AI 어시스턴트</h1>
<textarea
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="내 채널에 대해 뭐든 물어보세요... (Ctrl+Enter로 전송)"
className="w-full border rounded-lg p-3 min-h-[100px] resize-none focus:outline-none focus:ring-2 focus:ring-violet-400"
/>
<div className="flex gap-2">
<button
onClick={handleAsk}
disabled={loading || !query.trim()}
className="flex-1 px-6 py-2.5 bg-violet-600 hover:bg-violet-700 text-white rounded-lg disabled:opacity-50 transition-colors font-bold"
>
{loading ? "답변 생성 중..." : "질문하기"}
</button>
{loading && (
<button
onClick={() => abortRef.current?.abort()}
className="px-4 py-2.5 bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 rounded-lg text-sm"
>
중단
</button>
)}
</div>
{error && (
<div className="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg text-sm text-red-700 dark:text-red-300">
⚠️ {error}
</div>
)}
{answer && (
<div className="space-y-3">
<div className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 whitespace-pre-wrap text-sm leading-relaxed">
{answer}
{loading && <span className="inline-block w-1 h-4 bg-violet-500 animate-pulse ml-1 align-middle" />}
</div>
{sources.length > 0 && (
<details className="text-xs">
<summary className="cursor-pointer text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 select-none">
📄 참고한 데이터 {sources.length}개 보기
</summary>
<ul className="mt-2 space-y-1 pl-2">
{sources.map((s, i) => (
<li key={i} className="text-gray-500 dark:text-gray-400">
[{i + 1}] ({s.type}) {s.title}
</li>
))}
</ul>
</details>
)}
</div>
)}
</div>
);
}Vercel 서버리스 제약 및 대응 전략
| 제약 | Hobby | Pro | 대응 전략 |
|---|---|---|---|
| 실행 시간 (Node.js) | 10초 | 30초 | Edge Runtime 사용 → 제한 없음 (단, Node.js API 불가) |
| 실행 시간 (Edge) | 제한 없음 | 제한 없음 | export const runtime = "edge" 선언 — 스트리밍에 최적 |
| 응답 크기 | 4.5MB | 4.5MB | 스트리밍으로 청크 단위 전송 → 사실상 제한 없음 |
| 동시 함수 수 | 1000 | 1000 | 일반 개인 서비스에서 문제 안 됨 |
| 콜드 스타트 | 있음 | 있음 (감소) | Edge Runtime이 Node.js보다 콜드 스타트 훨씬 빠름 |
* Vercel 플랜별 제한은 변경될 수 있습니다. Vercel 공식 문서에서 최신 정보를 확인하세요.
Vercel 환경변수 설정
# .env.local (로컬 개발용 — 절대 Git에 커밋하지 마세요) OPENAI_API_KEY=sk-... TURSO_DATABASE_URL=libsql://내DB명.turso.io TURSO_AUTH_TOKEN=eyJhbGciOi... SYNC_SECRET=임의의긴무작위문자열 # sync API 보호용 # Vercel 대시보드 설정 경로: # 프로젝트 → Settings → Environment Variables # 위 4개를 동일하게 추가 (Production / Preview / Development 환경 선택)
🏗️ 완성형 아키텍처 한눈에 보기
[사용자 질문]
│
▼
[Edge Function: /api/rag/ask]
├─ embedQuery() → OpenAI Embeddings API
├─ searchSimilarDocs() → Turso vector_distance_cos (WHERE model = ?)
├─ generateAnswerStream() → OpenAI Chat API (stream: true)
└─ ReadableStream → [브라우저: 토큰 단위 스트리밍 렌더링]
[데이터 동기화: /api/rag/sync] ← Vercel Cron / GitHub Actions 호출
├─ fingerprint 비교 → 변경된 항목만 선별
├─ embedBatch() → OpenAI Embeddings API (배치)
└─ UPSERT → Turso (ON CONFLICT DO UPDATE)⚡ Edge Runtime
타임아웃 없음, 빠른 콜드 스타트, 스트리밍 최적화
💾 fingerprint 증분 동기화
변경된 데이터만 재임베딩 → API 비용 최소화
🔒 SYNC_SECRET 보호
동기화 API에 시크릿 헤더 인증으로 무단 접근 차단
Streamlit RAG 채팅 UI
LCEL + StreamingStreamlit의 st.write_stream과 LangChain LCEL을 결합해 ChatGPT 스타일의 스트리밍 RAG 채팅 UI를 구현합니다. 대화 히스토리, 메타데이터 필터링, 지수 백오프 재시도까지 포함합니다.
📐 데이터 흐름
실시간 스트리밍
st.write_stream() + chain.stream()으로 토큰 단위 출력. 사용자 대기 시간 최소화.
멀티턴 대화 히스토리
MessagesPlaceholder로 LCEL 프롬프트에 히스토리 주입. 최근 10턴 자동 트리밍.
MMR 검색 + 메타 필터
Maximum Marginal Relevance로 다양성 확보. 소스 타입별 메타데이터 동적 필터링.
자동 재시도 (지수 백오프)
API 오류 시 1s→2s→4s 간격으로 최대 3회 재시도. 세션 에러 카운터로 반복 실패 감지.
@st.cache_resource
Chroma DB·임베딩·LLM을 앱 재실행 시에도 재생성 없이 재사용. 첫 요청만 느림.
소스 문서 표시
expander로 참고 문서 제목·타임스탬프·미리보기 표시. 답변 신뢰성 향상.
# 5_app.py · Streamlit RAG Chat UI (LCEL + Streaming + History)
# ─────────────────────────────────────────────────────────────────
# 설치: pip install streamlit langchain langchain-openai
# langchain-community chromadb python-dotenv
# 실행: streamlit run 5_app.py
# ─────────────────────────────────────────────────────────────────
import streamlit as st
from dotenv import load_dotenv
from operator import itemgetter
from typing import Iterator
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough, RunnableLambda
from langchain_core.messages import HumanMessage, AIMessage
load_dotenv()
# ── 페이지 설정 ────────────────────────────────────────────────
st.set_page_config(
page_title="AI-Vive RAG Chat",
page_icon="🤖",
layout="wide",
)
# ── 세션 상태 초기화 ───────────────────────────────────────────
if "messages" not in st.session_state:
st.session_state.messages = [] # UI 표시용 메시지 리스트
if "chat_history" not in st.session_state:
st.session_state.chat_history = [] # LangChain 대화 히스토리
if "error_count" not in st.session_state:
st.session_state.error_count = 0
# ── RAG 체인 로드 (캐시 필수!) ─────────────────────────────────
@st.cache_resource(show_spinner="🔄 RAG 체인 초기화 중...")
def load_rag_chain():
"""
@st.cache_resource: 앱 재실행 시에도 체인을 재생성하지 않음.
Chroma DB 재로딩 방지 → 첫 요청만 느리고 이후 빠름.
"""
# 1) 임베딩 모델
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# 2) Chroma 벡터스토어 (기존 DB 로드)
vectorstore = Chroma(
collection_name="ai_vive_rag",
embedding_function=embeddings,
persist_directory="./chroma_db",
)
# 3) 리트리버 — 메타데이터 필터링 지원
retriever = vectorstore.as_retriever(
search_type="mmr", # Maximum Marginal Relevance
search_kwargs={
"k": 5, # 최종 반환 문서 수
"fetch_k": 20, # MMR 후보군
"lambda_mult": 0.7, # 다양성 vs 관련성 균형
# 메타데이터 필터 예시 (필요 시 동적으로 변경)
# "filter": {"source": "youtube"},
},
)
# 4) LLM (스트리밍 활성화)
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0.3,
streaming=True, # ← 스트리밍 핵심 설정
max_retries=3, # 내장 재시도 (지수 백오프)
request_timeout=30,
)
# 5) LCEL 프롬프트 — 대화 히스토리 포함
prompt = ChatPromptTemplate.from_messages([
("system",
"당신은 YouTube 영상 내용을 기반으로 답변하는 AI 어시스턴트입니다.\n"
"아래 컨텍스트를 참고해 질문에 답하세요.\n"
"모르면 '해당 영상에서 찾을 수 없습니다'라고 솔직히 말하세요.\n\n"
"컨텍스트:\n{context}"),
MessagesPlaceholder(variable_name="chat_history"), # ← 대화 히스토리
("human", "{question}"),
])
# 6) 컨텍스트 포맷팅 헬퍼
def format_docs(docs):
return "\n\n".join(
f"[출처: {d.metadata.get('title', '알 수 없음')} "
f"({d.metadata.get('timestamp', '')})]\n{d.page_content}"
for d in docs
)
# 7) LCEL 체인 구성
# 질문 → 검색 → 프롬프트 조립 → LLM → 파서
rag_chain = (
RunnablePassthrough.assign(
context=itemgetter("question") | retriever | format_docs
)
| prompt
| llm
| StrOutputParser()
)
return rag_chain, retriever
# ── 스트리밍 제너레이터 ────────────────────────────────────────
def stream_response(chain, question: str, history: list) -> Iterator[str]:
"""
chain.stream() 으로 토큰을 실시간 yield.
st.write_stream() 과 연동하여 ChatGPT 스타일 출력.
"""
yield from chain.stream({
"question": question,
"chat_history": history,
})
# ── 소스 문서 표시 헬퍼 ───────────────────────────────────────
def show_sources(retriever, question: str):
"""질문과 관련된 소스 문서를 expander로 표시."""
with st.expander("📚 참고 소스 문서", expanded=False):
docs = retriever.invoke(question)
for i, doc in enumerate(docs, 1):
meta = doc.metadata
st.markdown(
f"**[{i}] {meta.get('title', '제목 없음')}** "
f"`{meta.get('source', '')}` "
f"*{meta.get('timestamp', '')}*"
)
st.caption(doc.page_content[:300] + "…")
st.divider()
# ════════════════════════════════════════════════════════════════
# 메인 UI
# ════════════════════════════════════════════════════════════════
st.title("🤖 AI-Vive RAG 채팅")
st.caption("YouTube 영상 기반 대화형 AI · LCEL + Streaming + Multi-turn")
# 사이드바: 설정
with st.sidebar:
st.header("⚙️ 설정")
# 메타데이터 필터
source_filter = st.selectbox(
"소스 필터",
["전체", "YouTube 영상", "문서", "노트"],
index=0,
)
# 검색 문서 수
top_k = st.slider("검색 문서 수 (k)", 1, 10, 5)
# 대화 초기화
if st.button("🗑️ 대화 초기화", use_container_width=True):
st.session_state.messages = []
st.session_state.chat_history = []
st.session_state.error_count = 0
st.rerun()
st.divider()
st.markdown("**모델 정보**")
st.code("LLM: gpt-4o-mini\nEmbed: text-embedding-3-small\nDB: Chroma (local)")
# 체인 로드
try:
rag_chain, retriever = load_rag_chain()
except Exception as e:
st.error(f"❌ RAG 체인 초기화 실패: {e}")
st.stop()
# 이전 메시지 표시
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
st.markdown(msg["content"])
# 사용자 입력
if prompt_input := st.chat_input("YouTube 영상에 대해 질문하세요..."):
# 1) 사용자 메시지 표시
st.session_state.messages.append({"role": "user", "content": prompt_input})
with st.chat_message("user"):
st.markdown(prompt_input)
# 2) AI 응답 (스트리밍)
with st.chat_message("assistant"):
try:
# 스트리밍 응답 출력
response = st.write_stream(
stream_response(
rag_chain,
prompt_input,
st.session_state.chat_history,
)
)
# 에러 카운터 초기화
st.session_state.error_count = 0
except Exception as e:
st.session_state.error_count += 1
if st.session_state.error_count >= 3:
response = "⚠️ 반복 오류 발생. 잠시 후 다시 시도하거나 대화를 초기화해 주세요."
else:
response = f"⚠️ 오류 발생 ({st.session_state.error_count}/3): {str(e)[:100]}"
st.warning(response)
# 3) 소스 문서 표시
show_sources(retriever, prompt_input)
# 4) 대화 히스토리 업데이트
st.session_state.messages.append({"role": "assistant", "content": response})
st.session_state.chat_history.extend([
HumanMessage(content=prompt_input),
AIMessage(content=response),
])
# 5) 히스토리 길이 제한 (최근 10턴 = 메시지 20개)
if len(st.session_state.chat_history) > 20:
st.session_state.chat_history = st.session_state.chat_history[-20:]
📊 이전 코드 vs 강화 버전 비교
| 항목 | 이전 (RetrievalQA) | 강화 버전 (LCEL) |
|---|---|---|
| LLM 호출 방식 | RetrievalQA.from_chain_type() | LCEL pipe (|) 체인 |
| 스트리밍 | ❌ 미지원 (invoke 블로킹) | ✅ chain.stream() + st.write_stream() |
| 대화 히스토리 | ❌ 없음 (단일 Q&A) | ✅ MessagesPlaceholder + HumanMessage/AIMessage |
| 검색 전략 | similarity (기본) | MMR (다양성 + 관련성 균형) |
| 메타데이터 필터 | ❌ 미지원 | ✅ Chroma filter + 동적 소스 선택 |
| 에러 처리 | ❌ 없음 | ✅ try/except + 에러 카운터 + 재시도 |
| 히스토리 관리 | ❌ 무제한 메모리 누수 | ✅ 최근 10턴 트리밍 / 토큰 기반 트리밍 |
| 캐시 | @st.cache_resource 있음 | @st.cache_resource + retriever 분리 |
| 소스 표시 | st.expander (기본) | 제목·타임스탬프·미리보기 구조화 |
📦 설치
pip install streamlit \ langchain langchain-openai \ langchain-community chromadb \ python-dotenv
🚀 실행
# 1) 환경변수 설정 echo "OPENAI_API_KEY=sk-..." > .env # 2) 실행 streamlit run 5_app.py # 3) 브라우저에서 확인 # → http://localhost:8501
⚠️ 중요 사항
- @st.cache_resource 필수 — 없으면 매 메시지마다 Chroma DB를 재로딩해 응답이 수 초씩 지연됩니다.
- streaming=True — ChatOpenAI 초기화 시 반드시 설정해야 chain.stream()이 토큰 단위로 동작합니다.
- 히스토리 트리밍 — 트리밍 없이 대화를 계속하면 컨텍스트 창을 초과해 API 오류가 발생합니다. 최근 10턴(20메시지)을 권장합니다.
- Chroma 로컬 DB 경로 —
persist_directory가 이전 단계에서 저장한 경로와 일치해야 합니다.
배포 옵션 비교 — 어디에 올릴까
만들어진 RAG 앱을 어디에 배포할지 결정합니다. 개인 유튜버 무료 배포부터 프로덕션 서비스까지 — 상황별 최적 스택과 단계별 배포 가이드를 제공합니다.
01. 배포 플랫폼 5종 인터랙티브 비교
Streamlit Cloud
Community Cloud
완전 무료
GitHub 계정만 있으면 됨
무료 추천✅ 장점
- ✓GitHub 연동, 3클릭 배포
- ✓Python 앱 기본 지원
- ✓secrets.toml로 API 키 관리
- ✓커스텀 도메인 지원
⚠️ 단점 / 주의사항
- ✗15분 비활성 시 슬립 (무료 플랜)
- ✗RAM 1GB 제한
- ✗재시작 시 로컬 파일 초기화
- ✗Chroma 로컬 저장 불가 → Qdrant 필요
추천 상황
개인 유튜버 RAG, Python 앱 무료 배포
02. 전체 플랫폼 비교표
| 플랫폼 | 비용 | 슬립 | Python 지원 | 벡터 DB 연동 | 난이도 | 추천 상황 |
|---|---|---|---|---|---|---|
| 🎈 Streamlit Cloud추천 | 무료 | ✅ 있음 (15분) | ✅ 기본 지원 | Qdrant / Pinecone | ⭐ 매우 쉬움 | 개인·무료 |
| 🌊 Render | 무료/$7~ | ✅ 있음 (무료) | ✅ 지원 | Qdrant / pgvector | ⭐⭐ 쉬움 | 무료 영구 티어 |
| 🚂 Railway | $1~5/월 | ❌ 없음 | ✅ 지원 | Qdrant 자체 배포 | ⭐⭐ 쉬움 | 항상 켜짐 |
| ▲ Vercel추천 | 무료/Pro | ❌ 없음 | ❌ JS/TS 전용 | Turso / pgvector | ⭐⭐⭐ 보통 | Next.js 스택 |
| ☁️ AWS EC2 | $4~20/월 | ❌ 없음 | ✅ 완전 지원 | Chroma 로컬 가능 | ⭐⭐⭐⭐ 어려움 | 프로덕션·보안 |
⚠️ 요금은 수시 변경됩니다. 배포 전 각 플랫폼 공식 사이트에서 최신 요금을 반드시 확인하세요.
03. 상황별 배포 시나리오 — 스택 + 단계별 가이드
개인 유튜버 · 무료 배포
📦 권장 스택
앱 서버
Streamlit Cloud (무료 · GitHub 연동)
벡터 DB
Qdrant Cloud (무료 1GB · 신용카드 불필요)
임베딩
OpenAI text-embedding-3-small ($0.02/1M)
LLM
GPT-4o mini 또는 Gemini 2.0 Flash
총 비용
최초 임베딩 ~$0.05 / 이후 질문당 ~$0.01
✅ 장점
• 완전 무료
• 설정 15분 내 완료
• 항상 켜진 Qdrant Cloud
⚠️ 한계
• Streamlit 앱 15분 슬립
• RAM 1GB 제한
🗺️ 배포 단계
- 1
Qdrant Cloud 계정 생성 → 무료 클러스터 생성 (신용카드 불필요)
- 2
로컬에서 벡터 임베딩 → Qdrant Cloud에 업로드
- 3
GitHub에 앱 코드 push (secrets.toml 제외)
- 4
share.streamlit.io → GitHub 연동 → Deploy
- 5
Streamlit 대시보드에서 Secrets 설정 (API 키)
04. Qdrant Cloud 셋업 — 클러스터 생성 → 벡터 업로드
계정 생성
cloud.qdrant.io 접속 → Google / GitHub / 이메일로 무료 가입. 신용카드 불필요.
클러스터 생성
Create Free Cluster → 클러스터명 입력 → 클라우드 제공자(GCP/AWS) → 지역 선택 → Create.
API 키 발급
클러스터 생성 직후 API Key 팝업 표시. 반드시 복사 후 .env에 저장 (다시 볼 수 없음!).
엔드포인트 확인
클러스터 대시보드에서 Cluster URL 복사: https://xxxx.qdrant.io:6333 형태.
# migrate_to_qdrant_cloud.py — 로컬 Chroma → Qdrant Cloud 마이그레이션
"""
로컬 개발 시 Chroma로 저장한 벡터를
Streamlit Cloud 배포를 위해 Qdrant Cloud로 옮기는 스크립트.
1회만 실행하면 됩니다.
"""
import os, json
from dotenv import load_dotenv
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from qdrant_client import QdrantClient
from qdrant_client.models import (
Distance, VectorParams, PointStruct
)
load_dotenv()
# ── 1. 로컬 Chroma에서 모든 청크 읽기 ──
print("로컬 Chroma에서 데이터 읽는 중...")
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
local_chroma = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
collection_name="youtube_rag",
)
# Chroma 내부 컬렉션 직접 접근
col = local_chroma._collection
data = col.get(include=["documents", "metadatas", "embeddings"])
ids = data["ids"]
docs = data["documents"]
metas = data["metadatas"]
vecs = data["embeddings"] # List[List[float]]
TOTAL = len(ids)
print(f" 읽기 완료: {TOTAL}개 벡터")
# ── 2. Qdrant Cloud 연결 ──
print("Qdrant Cloud 연결 중...")
client = QdrantClient(
url=os.getenv("QDRANT_URL"), # https://xxxx.qdrant.io:6333
api_key=os.getenv("QDRANT_API_KEY"),
)
COLLECTION = "youtube_rag"
DIM = len(vecs[0]) # 1536 (text-embedding-3-small)
# ── 3. 컬렉션 생성 (없으면) ──
existing = [c.name for c in client.get_collections().collections]
if COLLECTION not in existing:
client.create_collection(
collection_name=COLLECTION,
vectors_config=VectorParams(
size=DIM,
distance=Distance.COSINE,
),
)
print(f" 컬렉션 '{COLLECTION}' 생성 완료 (dim={DIM})")
else:
print(f" 컬렉션 '{COLLECTION}' 이미 존재 — 추가 업서트")
# ── 4. 배치 업로드 (500개씩) ──
BATCH = 500
print(f"업로드 시작 ({TOTAL}개, 배치={BATCH})...")
for start in range(0, TOTAL, BATCH):
end = min(start + BATCH, TOTAL)
points = [
PointStruct(
id=i, # 정수 ID
vector=vecs[start + i],
payload={
"doc_id": ids[start + i],
"content": docs[start + i],
**metas[start + i], # type, video_id, title 등 메타
},
)
for i in range(end - start)
]
client.upsert(collection_name=COLLECTION, points=points)
print(f" [{end}/{TOTAL}] 업로드 완료")
count = client.count(collection_name=COLLECTION).count
print(f"\n✅ 마이그레이션 완료: Qdrant Cloud '{COLLECTION}' — {count}개 벡터")# qdrant_test.py — 업로드 후 검색 테스트
from qdrant_client import QdrantClient
from langchain_openai import OpenAIEmbeddings
from langchain_qdrant import QdrantVectorStore
import os
from dotenv import load_dotenv
load_dotenv()
client = QdrantClient(
url=os.getenv("QDRANT_URL"),
api_key=os.getenv("QDRANT_API_KEY"),
)
# 컬렉션 상태 확인
info = client.get_collection("youtube_rag")
print(f"벡터 수: {info.points_count}") # 예: 6247
print(f"차원: {info.config.params.vectors.size}") # 1536
# 검색 테스트
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectordb = QdrantVectorStore(
client=client,
collection_name="youtube_rag",
embedding=embeddings,
)
results = vectordb.similarity_search("썸네일 CTR 높이는 법", k=3)
for r in results:
print(f"[{r.metadata.get('type')}] {r.page_content[:80]}")05. Streamlit Cloud 배포 완전 가이드 (무료 · 15분 완성)
# requirements.txt (루트 폴더에 생성)
# RAG 파이프라인 핵심 패키지 langchain>=0.1.0 langchain-openai>=0.0.5 langchain-community>=0.0.20 langchain-qdrant>=0.1.0 # Qdrant Cloud 연동 openai>=1.0.0 qdrant-client>=1.7.0 streamlit>=1.30.0 python-dotenv>=1.0.0 tiktoken>=0.5.0 pandas>=2.0.0 requests>=2.31.0 # chromadb 제외 — Streamlit Cloud는 로컬 파일 불가
# .streamlit/secrets.toml (⚠️ .gitignore 필수!)
# Streamlit Cloud 대시보드에서 직접 입력 # (로컬 개발용으로만 이 파일 사용) OPENAI_API_KEY = "sk-proj-xxxx" YOUTUBE_API_KEY = "AIzaxxxxxxxx" QDRANT_URL = "https://xxxx.qdrant.io:6333" QDRANT_API_KEY = "xxxxxxxxxx" GOOGLE_API_KEY = "AIzaxxxxxxx" # Gemini 사용 시 # 코드에서 접근: # st.secrets["OPENAI_API_KEY"] # ← os.environ 대신 st.secrets 사용!
# .gitignore — 반드시 포함해야 할 항목
.env .streamlit/secrets.toml # ← 절대 GitHub에 올리지 말 것! venv/ __pycache__/ chroma_db/ data/raw/ *.mp3 .DS_Store
# 5_app.py — Streamlit Cloud + Qdrant Cloud 완성 코드
import streamlit as st
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_qdrant import QdrantVectorStore
from langchain.chains import RetrievalQA
from qdrant_client import QdrantClient
openai_key = st.secrets["OPENAI_API_KEY"]
qdrant_url = st.secrets["QDRANT_URL"]
qdrant_key = st.secrets["QDRANT_API_KEY"]
@st.cache_resource # 재시작 시 재연결 방지
def get_vectordb():
emb = OpenAIEmbeddings(model="text-embedding-3-small", api_key=openai_key)
client = QdrantClient(url=qdrant_url, api_key=qdrant_key)
return QdrantVectorStore(client=client, collection_name="youtube_rag", embedding=emb)
@st.cache_resource
def get_qa_chain():
llm = ChatOpenAI(model="gpt-4o-mini", api_key=openai_key, temperature=0.3)
return RetrievalQA.from_chain_type(
llm=llm,
retriever=get_vectordb().as_retriever(search_kwargs={"k": 5}),
return_source_documents=True,
)
st.title("🎬 내 유튜브 채널 AI 검색")
st.caption("채널 영상·댓글·자막에서 의미 검색")
query = st.text_input("질문을 입력하세요", placeholder="구독자 늘리는 방법이 뭐라고 했어?")
if query:
with st.spinner("검색 중..."):
result = get_qa_chain().invoke({"query": query})
st.markdown("### 💬 답변")
st.write(result["result"])
with st.expander("📄 참고 문서 보기"):
for doc in result["source_documents"]:
meta = doc.metadata
st.markdown(f"**{meta.get('type','?')}** | {meta.get('title','')[:40]}")
if meta.get("youtube_url"):
st.markdown(f"[▶ {meta.get('start_time','')[:8]} 에서 보기]({meta['youtube_url']})")
st.caption(doc.page_content[:200] + "...")
st.divider()🚀 Streamlit Cloud 배포 4단계
GitHub Push
5_app.py, requirements.txt, .gitignore를 GitHub 저장소에 push
share.streamlit.io
New app → GitHub 저장소 선택 → Main file: 5_app.py 지정
Secrets 설정
앱 설정 → Secrets 탭 → TOML 형식으로 API 키 직접 입력
Deploy!
Deploy 클릭 → 2~5분 빌드 → 공개 URL 자동 생성
06. Railway / Render — Dockerfile 배포 (항상 켜짐)
Streamlit Cloud의 15분 슬립이 불편하다면 Railway 또는 Render에 Dockerfile로 배포해 항상 켜진 서버를 운용할 수 있습니다. Chroma 로컬 저장도 가능하지만 영구 볼륨이 없는 무료 플랜은 재배포 시 초기화되므로 Qdrant Cloud를 함께 사용하는 것을 권장합니다.
# Dockerfile (루트 폴더)
FROM python:3.12-slim
# 작업 디렉터리
WORKDIR /app
# 의존성 먼저 복사 (캐시 활용)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# 앱 코드 복사
COPY . .
# Streamlit 기본 포트
EXPOSE 8501
# 헬스체크 (Railway/Render 모니터링용)
HEALTHCHECK CMD curl -f http://localhost:8501/_stcore/health || exit 1
# 앱 실행
# --server.headless: 브라우저 자동 열기 비활성
# --server.address: 0.0.0.0 으로 외부 접근 허용
CMD ["streamlit", "run", "5_app.py",
"--server.port=8501",
"--server.address=0.0.0.0",
"--server.headless=true",
"--browser.gatherUsageStats=false"]# .dockerignore
.env .git venv/ __pycache__/ chroma_db/ data/raw/ *.mp3 .DS_Store *.pyc
🚂 Railway 배포 방법
- 1railway.app → New Project → Deploy from GitHub
- 2저장소 선택 → Dockerfile 자동 감지
- 3Settings → Variables → 환경 변수 추가
- 4Deploy → 공개 URL 자동 생성
- 5Settings → Networking → Custom Domain 설정 (선택)
🌊 Render 배포 방법
- 1render.com → New → Web Service
- 2GitHub 저장소 연결 → Docker 런타임 선택
- 3Environment Variables 탭에서 API 키 추가
- 4Create Web Service → 3~5분 빌드
- 5무료 플랜: 15분 슬립 / $7/월: 항상 켜짐
07. Vercel + Turso 통합 — Next.js RAG API Route
# Turso에 벡터 저장 (Python 스크립트)
import libsql_client, json, os
from dotenv import load_dotenv
load_dotenv()
client = libsql_client.create_client_sync(
url=os.getenv("TURSO_DATABASE_URL"),
auth_token=os.getenv("TURSO_AUTH_TOKEN"),
)
client.execute("""
CREATE TABLE IF NOT EXISTS rag_docs (
id TEXT PRIMARY KEY,
content TEXT NOT NULL,
meta TEXT,
vec F32_BLOB(1536)
)
""")
client.execute(
"CREATE INDEX IF NOT EXISTS vec_idx "
"ON rag_docs (libsql_vector_idx(vec))"
)
for chunk in all_chunks:
vec_str = json.dumps(chunk["embedding"])
client.execute(
"INSERT OR REPLACE INTO rag_docs VALUES(?,?,?,vector(?))",
[chunk["id"], chunk["content"],
json.dumps(chunk["metadata"]), vec_str]
)# app/api/rag/route.ts — Next.js Route Handler
import { createClient } from "@libsql/client";
import OpenAI from "openai";
const db = createClient({
url: process.env.TURSO_DATABASE_URL!,
authToken: process.env.TURSO_AUTH_TOKEN!,
});
const openai = new OpenAI();
export async function POST(req: Request) {
const { query } = await req.json();
const { data } = await openai.embeddings.create({
model: "text-embedding-3-small", input: query,
});
const vec = JSON.stringify(data[0].embedding);
const { rows } = await db.execute({
sql: `SELECT id, content, meta,
vector_distance_cos(vec, vector(?)) AS dist
FROM rag_docs ORDER BY dist LIMIT 5`,
args: [vec],
});
const context = rows.map(r => r.content as string).join("\n\n");
const chat = await openai.chat.completions.create({
model: "gpt-4o-mini",
messages: [
{ role:"system", content:`문서를 참고해 답하라:\n${context}` },
{ role:"user", content: query },
],
});
return Response.json({
answer: chat.choices[0].message.content,
sources: rows.map(r => ({
content: r.content,
meta: JSON.parse(r.meta as string),
})),
});
}08. 환경별 설정 자동 전환 — 로컬 / Streamlit Cloud / Railway
로컬 개발(Chroma)과 클라우드 배포(Qdrant Cloud)에서 코드를 두 벌 관리하는 대신, 환경 변수 하나로 벡터 DB 연결을 자동으로 전환하는 패턴입니다. RAG_ENV=local|cloud만 설정하면 나머지는 자동으로 처리됩니다.
# config.py — 환경 자동 감지 + 벡터 DB 전환
"""
환경별 설정 자동 전환 모듈.
사용:
from config import get_vectordb, get_llm
환경 변수 설정:
로컬: RAG_ENV=local (기본값)
Streamlit: RAG_ENV=cloud (Streamlit secrets)
Railway/Render: RAG_ENV=cloud (환경 변수)
"""
import os
# ── Streamlit Cloud와 일반 환경 통합 시크릿 로더 ──
def _get_secret(key: str) -> str:
"""st.secrets → os.environ 순으로 시크릿 조회"""
try:
import streamlit as st
return st.secrets[key]
except Exception:
val = os.getenv(key, "")
if not val:
raise EnvironmentError(f"시크릿 '{key}' 없음. .env 또는 환경 변수를 확인하세요.")
return val
def get_vectordb():
"""환경에 따라 Chroma(로컬) 또는 Qdrant Cloud 반환"""
from langchain_openai import OpenAIEmbeddings
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small",
api_key=_get_secret("OPENAI_API_KEY"),
)
env = os.getenv("RAG_ENV", "local")
if env == "local":
# 로컬 개발: Chroma 파일 저장
from langchain_community.vectorstores import Chroma
return Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
collection_name="youtube_rag",
)
else:
# 클라우드 배포: Qdrant Cloud
from qdrant_client import QdrantClient
from langchain_qdrant import QdrantVectorStore
client = QdrantClient(
url=_get_secret("QDRANT_URL"),
api_key=_get_secret("QDRANT_API_KEY"),
)
return QdrantVectorStore(
client=client,
collection_name="youtube_rag",
embedding=embeddings,
)
def get_llm(temperature: float = 0.3):
"""OpenAI GPT-4o mini LLM 반환"""
from langchain_openai import ChatOpenAI
return ChatOpenAI(
model="gpt-4o-mini",
api_key=_get_secret("OPENAI_API_KEY"),
temperature=temperature,
)
# ── 사용 예시 (5_app.py에서) ──
# from config import get_vectordb, get_llm
# vectordb = get_vectordb() # 환경에 따라 자동 전환
# llm = get_llm()로컬 개발
RAG_ENV=local
→ Chroma (./chroma_db/)
Streamlit Cloud
RAG_ENV=cloud (secrets)
→ Qdrant Cloud
Railway / Render
RAG_ENV=cloud (환경 변수)
→ Qdrant Cloud
09. 배포 후 데이터 업데이트 전략
# 신규 영상만 증분 임베딩 → Qdrant Cloud 추가
# incremental_update.py
from qdrant_client import QdrantClient
from qdrant_client.models import PointStruct
import os, json
from dotenv import load_dotenv
load_dotenv()
client = QdrantClient(
url=os.getenv("QDRANT_URL"),
api_key=os.getenv("QDRANT_API_KEY"),
)
def get_existing_video_ids() -> set[str]:
"""Qdrant에 이미 있는 video_id 목록"""
existing = set()
offset = None
while True:
res, offset = client.scroll(
collection_name="youtube_rag",
scroll_filter=None,
limit=1000,
offset=offset,
with_payload=["video_id"],
)
for point in res:
if vid := point.payload.get("video_id"):
existing.add(vid)
if offset is None:
break
return existing
def add_new_chunks(new_chunks: list[dict]):
"""새 청크만 Qdrant Cloud에 추가 (중복 없이)"""
existing_ids = get_existing_video_ids()
to_add = [
c for c in new_chunks
if c["metadata"].get("video_id") not in existing_ids
]
if not to_add:
print("추가할 신규 청크 없음")
return
points = [
PointStruct(id=i, vector=c["embedding"],
payload={"content": c["content"], **c["metadata"]})
for i, c in enumerate(to_add)
]
client.upsert(collection_name="youtube_rag", points=points)
print(f"✅ {len(to_add)}개 청크 추가 완료")📅 업데이트 주기 권장
# GitHub Actions 자동 업데이트 (선택)
# .github/workflows/update_rag.yml
name: Weekly RAG Update
on:
schedule:
- cron: '0 2 * * 1' # 매주 월요일 02:00
workflow_dispatch: # 수동 실행 가능
jobs:
update:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.12' }
- run: pip install -r requirements.txt
- run: python incremental_update.py
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
QDRANT_URL: ${{ secrets.QDRANT_URL }}
QDRANT_API_KEY: ${{ secrets.QDRANT_API_KEY }}10. 배포 방법 선택 가이드 + 최종 체크리스트
# 배포 플랫폼 선택 의사결정 트리
나의 상황은?
│
├─ Python 앱 (Streamlit) + 완전 무료
│ └─ ✅ Streamlit Cloud + Qdrant Cloud
│ (무료 · 15분 완성 · 슬립 있음)
│
├─ Python 앱 + 슬립 없이 항상 켜짐
│ ├─ 저비용 → Railway Hobby ($5/월)
│ └─ 안정성 → Render ($7/월~)
│ + Dockerfile 필요
│
├─ Next.js 앱 + 기존 Vercel 스택
│ └─ ✅ Vercel + Turso
│ (기존 스택, JS/TS만 가능)
│
├─ 프로덕션 · 보안 · 대용량
│ └─ ✅ AWS EC2 + Qdrant 셀프호스팅
│
└─ 로컬 Chroma 파일 유지 필요
└─ Railway / Render / AWS EC2
(영구 볼륨 또는 유료 플랜)✅ 배포 전 최종 체크리스트
API 키 보안
.env와 secrets.toml은 절대 GitHub에 올리지 마세요. Streamlit 대시보드·Railway 환경 변수·Vercel Environment Variables에서 직접 입력하세요.
Chroma vs Qdrant
Streamlit Cloud·Railway 무료 플랜은 재시작 시 로컬 파일이 사라집니다. Qdrant Cloud 또는 유료 플랜의 영구 볼륨을 사용하세요.
요금 변경 주의
Railway, Render 등 호스팅 요금은 수시로 변경됩니다. 이 문서의 가격은 참고용이며, 배포 전 반드시 공식 사이트에서 최신 요금을 확인하세요.
비용 최적화 전략 — 거의 공짜로 운영
임베딩·LLM·DB 세 가지 비용 구조를 이해하고, OpenRouter 임베딩 모델·무료 티어·증분 임베딩· 최신 LLM 선택으로 RAG 운영 비용을 최소화하는 전략을 정리합니다.
RAG 운영 비용은 3가지로 나뉜다
임베딩 비용
문서를 벡터로 변환할 때 발생. 최초 세팅 1회 + 새 데이터 추가 시만 발생. 전체 비용의 10~20%.
초기 1회 + 증분LLM 답변 생성 비용
사용자 질문에 답할 때마다 발생. 사용량에 비례해 누적. 전체 비용의 70~80%로 가장 큰 비중.
질문마다 발생DB 저장 비용
Turso 무료 플랜 9GB로 개인 유튜버 수준은 충분히 수용. 별도 비용 없음.
무료 (Turso 기준)임베딩 모델 비용 비교 — OpenRouter 기준 전수록
🧭 용도별 빠른 선택
범용 RAG · 빠른 시작
→ text-embedding-3-small
$0.02/M · 생태계 표준
다국어 · 한국어↔영어
→ Qwen3 Embed 8B
$0.01/M · MTEB 다국어 1위
코드 RAG · 코딩 에이전트
→ Codestral Embed 2505
$0.15/M · SWE-Bench 1위
고정밀 영어 RAG
→ text-embedding-3-large
$0.13/M · 3072차원
비용·성능 균형
→ Qwen3 Embed 4B
$0.02/M · 2560차원
초경량 · 로컬 가능
→ Qwen3 Embed 0.6B
가격 확인 · 1024차원 · OSS
퍼플렉시티 · 초저가
→ pplx-embed-v1-0.6B
$0.004/M · 32K ctx
퍼플렉시티 · 고품질
→ pplx-embed-v1-4B
$0.03/M · 32K ctx
"300영상 1회 비용" 기준: 영상 300개 × 평균 대본 4,000자 → 약 600만 토큰 (한글 1자 ≈ 1~2토큰). 가격은 변동됩니다 — 공식 사이트에서 최신 가격을 반드시 확인하세요.
| 모델 | 제공사 | 컨텍스트 | 1M 토큰 비용 | 300영상 1회 | 차원 (최대) | OpenRouter 사용량 | 특징 |
|---|---|---|---|---|---|---|---|
| Qwen3 Embedding 8B다국어 1위 | Qwen/Alibaba | 32K | $0.01 | ~$0.06 | 4096 (MRL 32~4096) | 51.5B 토큰 · Health #21 · Legal #22 | MTEB 다국어 1위 (70.58점). 100+언어·코드. 한국어↔영어 최강. |
| Qwen3 Embedding 4B균형 | Qwen/Alibaba | 33K | $0.02 | ~$0.12 | 2560 (MRL 32~2560) | 5.63B 토큰 | 8B 경량화. 비용·성능 균형. 다국어·코드 지원. |
| Qwen3 Embedding 0.6B초경량 OSS | Qwen/Alibaba | 8K¹ | 확인 필요 | — | 1024 (MRL 32~1024) | 미공개 | 초소형·MTEB 상위권. Apache 2.0 오픈소스. 로컬 셀프호스팅 가능. |
| Text Embedding Ada 002레거시 | OpenAI | 8K | $0.10 | ~$0.60 | 1536 | 630M 토큰 | 레거시. 신규는 text-embedding-3-small 권장. |
| Text Embedding 3 Small범용 표준 | OpenAI | 8K | $0.02 | ~$0.12 | 1536 (MRL 256~1536) | 40.6B 토큰 · Health #44 · Legal #30 | LangChain·LlamaIndex 기본값. 범용 RAG 생태계 표준. |
| Text Embedding 3 LargeOpenAI 최고 | OpenAI | 8K | $0.13 | ~$0.78 | 3072 (MRL 256~3072) | 11.9B 토큰 | OpenAI 최고 품질. 영어·비영어 SOTA. 고정밀 도메인용. |
| Codestral Embed 2505코드 1위 | Mistral AI | 8K | $0.15 | ~$0.90 | 최대 3072 (int8 지원) | 482M 토큰 | 코드 전용 세계 최초. SWE-Bench·CodeSearchNet 1위. 256d+int8도 경쟁사 최고 성능 상회. |
| pplx-embed-v1-0.6B퍼플렉시티 | Perplexity | 32K | $0.004 | ~$0.02 | 1024 (OpenRouter) | 46.1M 토큰 | 웹·초저지연 임베딩. pplx-embed-v1 경량. |
| pplx-embed-v1-4B퍼플렉시티 | Perplexity | 32K | $0.03 | ~$0.18 | 1024 (OpenRouter) | 38.7M 토큰 | 검색 품질 최대. pplx-embed-v1 대형. |
| BGE-M3 (로컬)로컬 무료 | 자체 호스팅 | 8K | 무료 | $0 | 1024 / 768 | — | GPU/CPU 서버 필요. Vercel 실행 불가. 완전 무료. |
| Gemini text-embedding-004Google 무료 | 2K | 무료 티어 | $0 | 768 | — | 1,500 req/일 무료. task_type 구분 필수. 유료 시 $0.20/M. | |
| voyage-3 (Voyage AI) | Voyage AI | 32K | $0.06 | ~$0.36 | 1024 | — | 법률·금융 도메인 특화. MRL 지원. |
¹ Qwen3 0.6B — HuggingFace 공식 스펙 32K, OpenRouter 등재 8K로 상이할 수 있음. openrouter.ai/qwen/qwen3-embedding-0.6b 에서 최신 가격·스펙 확인 필요. Perplexity pplx-embed-v1 — openrouter.ai/perplexity 에서 차원·단가 확인. MRL = Matryoshka Representation Learning: 최대 차원에서 소수 차원까지 점진적 압축 가능. 최신 가격: openrouter.ai/models / platform.openai.com/docs/pricing / docs.voyageai.com
LLM 답변 생성 비용 비교 — 운영 비용의 핵심
RAG에서 LLM은 질문마다 호출됩니다. 임베딩은 최초 1회지만 LLM 비용은 계속 누적되므로 모델 선택이 장기 운영 비용을 결정합니다.
¹ 질문 1회 기준: 입력 ~2,000토큰 (청크 5개+프롬프트+질문) + 출력 ~300토큰. OpenAI·Anthropic·xAI·Google 텍스트 행은 2026-02~03 공개 단가 기준. Grok 4는 요청 128K 토큰 초과 시 단가가 달라질 수 있음. Nano Banana(이미지)는 이미지·오디오 토큰 별도 과금. 가격 수시 변동.
아래 표는 OpenAI(GPT-5.1~5.4 계열) · Anthropic(Claude 전 라인) · Google(Gemini 2.5~3.1) · xAI(Grok)를 OpenRouter 공개 단가 기준으로 넓게 수록한 것입니다. 이미지 전용·실험 모델은 특징 칸에 별도 과금을 적었습니다.
정보 최신화🟢 Google Gemini
| 모델 | 입력 1M | 출력 1M | 질문 1회¹ | 100회/월 | 특징 |
|---|---|---|---|---|---|
| Gemma 3n 2B (free) | 무료 | 무료 | $0 | $0 | 8K ctx. 초경량·멀티모달. OpenRouter 무료 호스트. |
| Gemini 2.5 Flash-Lite추천 | $0.10 | $0.40 | ~$0.0003 | ~$0.03 | 1.05M ctx. GA. Reasoning 토글. |
| Gemini 2.5 Flash-Lite Preview (09-2025) | $0.10 | $0.40 | ~$0.0003 | ~$0.03 | 1.05M ctx. Preview 스냅샷. 단가는 GA Flash-Lite와 동일. |
| Gemini 2.5 Flash | $0.30 | $2.50 | ~$0.001 | ~$0.14 | 1.05M ctx. 추론·코딩·수학. 워크호스. |
| Gemini 2.5 Pro | $1.25 | $10.00 | ~$0.006 | ~$0.55 | 1.05M ctx. SOTA급 추론·LMArena 상위권. |
| Nano Banana (2.5 Flash Image) | $0.30 | $2.50 | ~$0.001 | ~$0.14 | 33K ctx. 이미지 생성·편집. 이미지 토큰 $30/M 등 별도. |
| Gemini 3 Flash Preview | $0.50 | $3.00 | ~$0.002 | ~$0.19 | 1.05M ctx. 3세대 Flash. thinking 레벨. 오디오 $1/M. |
| Gemini 3 Pro Preview | $2.00 | $12.00 | ~$0.008 | ~$0.76 | 1.05M ctx. 3세대 Pro. 일부 종료 예정일 OpenRouter 확인. 오디오 $2/M. |
| Gemini 3.1 Flash Lite Preview | $0.25 | $1.50 | ~$0.001 | ~$0.10 | 1.05M ctx. 3.1 Flash-Lite. 3 Flash 대비 저가. 오디오 $0.50/M. |
| Gemini 3.1 Pro Preview | $2.00 | $12.00 | ~$0.008 | ~$0.76 | 1.05M ctx. 3.1 Pro. SWE·에이전트 강화. 오디오 $2/M. |
| Gemini 3.1 Pro Preview (Custom Tools) | $2.00 | $12.00 | ~$0.008 | ~$0.76 | 3.1 Pro 변형. 도구 선택·멀티툴 안정화. |
| Nano Banana 2 (3.1 Flash Image Preview) | $0.50 | $3.00 | ~$0.002 | ~$0.19 | 66K ctx. 이미지 생성·편집. 이미지 토큰 $60/M 별도. |
| Nano Banana Pro (3 Pro Image Preview) | $2.00 | $12.00 | ~$0.008 | ~$0.76 | 66K ctx. 최고 이미지 품질. 이미지 $120/M·오디오 $2/M 별도. |
정보 최신화🔵 Anthropic Claude
| 모델 | 입력 1M | 출력 1M | 질문 1회¹ | 100회/월 | 특징 |
|---|---|---|---|---|---|
| Claude 3 Haiku | $0.25 | $1.25 | ~$0.001 | ~$0.09 | 200K ctx. 초경량·즉시 응답. 입문·저부하. |
| Claude 3.5 Haiku | $0.80 | $4.00 | ~$0.003 | ~$0.32 | 200K ctx. 3 Haiku 대비 속도·코딩 강화. |
| Claude Haiku 4.5 | $1.00 | $5.00 | ~$0.004 | ~$0.35 | 200K ctx. 속도·비용 균형. 코딩·에이전트. |
| Claude Sonnet 4.6기본 추천 | $3.00 | $15.00 | ~$0.011 | ~$1.05 | 1M ctx. Sonnet 최신. 코딩·에이전트. |
| Claude Sonnet 4.5 | $3.00 | $15.00 | ~$0.011 | ~$1.05 | 1M ctx. Sonnet 4.5. 4.6과 동일 단가. |
| Claude Sonnet 4 | $3.00 | $15.00 | ~$0.011 | ~$1.05 | 200K ctx(OpenRouter). 이전 세대 Sonnet. |
| Claude 3.5 Sonnet | $6.00 | $30.00 | ~$0.021 | ~$2.10 | 200K ctx. 레거시 고가 티어. |
| Claude 3.7 Sonnet | $3.00 | $15.00 | ~$0.011 | ~$1.05 | 200K ctx. 하이브리드 추론. 2026-05 단종 예정. |
| Claude Opus 4.6 | $5.00 | $25.00 | ~$0.018 | ~$1.75 | 1M ctx. 최상급·장기 작업. |
| Claude Opus 4.5 | $5.00 | $25.00 | ~$0.018 | ~$1.75 | 200K ctx. 추론·에이전트·멀티모달. |
| Claude Opus 4.1 | $15.00 | $75.00 | ~$0.053 | ~$5.25 | 200K ctx. Opus 4.1. 고난이도·고비용. |
| Claude Opus 4 | $15.00 | $75.00 | ~$0.053 | ~$5.25 | 200K ctx. Opus 4 초기. 장기 에이전트. |
정보 최신화🔴 OpenAI GPT
| 모델 | 입력 1M | 출력 1M | 질문 1회¹ | 100회/월 | 특징 |
|---|---|---|---|---|---|
| GPT-5.4 nano초저가 | $0.20 | $1.25 | ~$0.0008 | ~$0.08 | 400K ctx. 초경량·고볼륨 RAG. 텍스트·이미지 입력. |
| GPT-5.4 mini | $0.75 | $4.50 | ~$0.003 | ~$0.29 | 400K ctx. 처리량·비용 균형. 채팅·코딩·에이전트. |
| GPT-5.4 | $2.50 | $15.00 | ~$0.01 | ~$0.95 | 1M+ ctx. Codex·GPT 통합 프론티어. 멀티모달·도구. |
| GPT-5.4 Pro | $30.00 | $180.00 | ~$0.11 | ~$11 | 1M+ ctx. 최고 추론·에이전트. 고난이도·고비용. |
| GPT-5.3 Chat | $1.75 | $14.00 | ~$0.008 | ~$0.77 | 400K ctx. 5.3 대화형. 에이전트·장문. |
| GPT-5.3 Codex | $1.75 | $14.00 | ~$0.008 | ~$0.77 | 400K ctx. 코딩·에이전트. 5.3 Chat과 동일 단가. |
| GPT-5.2 | $1.75 | $14.00 | ~$0.008 | ~$0.77 | 400K ctx. 5.2 플래그십. 적응형 추론. |
| GPT-5.2-Codex | $1.75 | $14.00 | ~$0.008 | ~$0.77 | 400K ctx. 소프트웨어 엔지니어링·리팩터. |
| GPT-5.2 Pro | $21.00 | $168.00 | ~$0.092 | ~$9.2 | 400K ctx. 5.2 고성능 티어. 고난이도·고비용. |
| GPT-5.1 | $1.25 | $10.00 | ~$0.006 | ~$0.55 | 400K ctx. 5.1 플래그십. |
| GPT-5.1 Chat | $1.25 | $10.00 | ~$0.006 | ~$0.55 | 400K ctx. 5.1 대화형. |
| GPT-5.1 Codex | $1.25 | $10.00 | ~$0.006 | ~$0.55 | 400K ctx. 코딩·에이전트. |
| GPT-5.1 Codex Mini | $0.25 | $2.00 | ~$0.001 | ~$0.11 | 400K ctx. Codex 경량. |
| GPT-5.1 Codex Max | $1.25 | $10.00 | ~$0.006 | ~$0.55 | 400K ctx. 장기 에이전트 코딩. 단가는 5.1 Codex와 동일. |
| GPT-5 | $1.25 | $10.00 | ~$0.006 | ~$0.55 | 400K ctx. GPT-5 기본. |
| GPT-5 Mini | $0.25 | $2.00 | ~$0.001 | ~$0.11 | 400K ctx. 경량. |
| GPT-5 Nano최저가 | $0.05 | $0.40 | ~$0.0002 | ~$0.02 | 400K ctx. 초경량. |
| GPT-5 Chat | $1.25 | $10.00 | ~$0.006 | ~$0.55 | 400K ctx. 대화형. |
| GPT-5 Pro | $15.00 | $120.00 | ~$0.067 | ~$6.7 | 400K ctx. GPT-5 Pro. |
| GPT-4o | $2.50 | $10.00 | ~$0.007 | ~$0.73 | 128K ctx. 레거시·멀티모달. 신규는 GPT-5.4 계열 권장. |
정보 최신화🟡 xAI Grok
| 모델 | 입력 1M | 출력 1M | 질문 1회¹ | 100회/월 | 특징 |
|---|---|---|---|---|---|
| Grok 4.1 Fast최저가 | $0.20 | $0.50 | ~$0.0006 | ~$0.06 | 2M ctx. 에이전트 도구·딥리서치. reasoning on/off. |
| Grok 4 Fast | $0.20 | $0.50 | ~$0.0006 | ~$0.06 | 2M ctx. 멀티모달·비용 효율. non-/reasoning. |
| Grok Code Fast 1 | $0.20 | $1.50 | ~$0.001 | ~$0.09 | 256K ctx. 코딩·에이전트. 추론 트레이스. |
| Grok 4.20 Beta | $2.00 | $6.00 | ~$0.006 | ~$0.58 | 2M ctx. 2026-03 플래그십. 속도·도구·저환각. |
| Grok 4.20 Multi-Agent Beta | $2.00 | $6.00 | ~$0.006 | ~$0.58 | 2M ctx. 멀티 에이전트 병렬·협업 연구. |
| Grok 4 | $3.00 | $15.00 | ~$0.011 | ~$1.05 | 256K ctx. 추론 고정. 요청 128K 초과 시 요금↑. |
| Grok 3 | $3.00 | $15.00 | ~$0.011 | ~$1.05 | 131K ctx. 전 세대 플래그십. 도메인 지식. |
| Grok 3 Mini | $0.30 | $0.50 | ~$0.0008 | ~$0.08 | 131K ctx. 경량·추론 트레이스. 비용 효율. |
* 단가·모델명은 OpenRouter(openrouter.ai) 기준으로 정리했습니다. 공식: Anthropic platform.claude.com/docs/en/about-claude/pricing / Google ai.google.dev/gemini-api/docs/pricing / OpenAI openai.com/api/pricing / xAI docs.x.ai/developers/models
Gemini API 무료 티어로 임베딩
Gemini 임베딩 모델명은 업데이트됩니다. 최신 모델은 ai.google.dev/gemini-api/docs/models에서 확인하세요. 무료 티어 한도(req/분, req/일)도 변경될 수 있습니다.
# pip install google-generativeai python-dotenv
import google.generativeai as genai, os
genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
GEMINI_EMBED_MODEL = "models/text-embedding-004" # 안정 버전 · 768차원
# 최신 실험 모델 (성능 더 높음, 모델명 변경 가능):
# GEMINI_EMBED_MODEL = "models/gemini-embedding-exp-03-07"
def embed_document(text: str) -> list[float]:
"""DB 저장용 — retrieval_document 필수"""
return genai.embed_content(
model=GEMINI_EMBED_MODEL,
content=text.strip(),
task_type="retrieval_document" # ← 저장 시 반드시 이 값
)["embedding"]
def embed_query(query: str) -> list[float]:
"""검색 쿼리용 — retrieval_query 필수"""
return genai.embed_content(
model=GEMINI_EMBED_MODEL,
content=query.strip(),
task_type="retrieval_query" # ← 검색 시 반드시 이 값
)["embedding"]
# ⚠️ task_type 혼용 시 검색 품질 급락
# 저장: retrieval_document / 검색: retrieval_query 반드시 구분OpenRouter 통합 임베딩 코드 — 6종 모델 한 번에
OpenRouter는 OpenAI 호환 API를 제공합니다. base_url만 바꾸면 동일한 코드로 Qwen3·OpenAI·Codestral 6개 모델 모두 사용 가능합니다.
# pip install openai python-dotenv
import os
from openai import OpenAI
client = OpenAI(
api_key=os.getenv("OPENROUTER_API_KEY"),
base_url="https://openrouter.ai/api/v1",
)
# ── 용도별 모델 선택 ──────────────────────────────────
EMBED_MODELS = {
"general": "openai/text-embedding-3-small", # 범용 · 생태계 표준 · $0.02/M
"multilingual": "qwen/qwen3-embedding-8b", # 다국어·한국어 · MTEB 1위 · $0.01/M
"quality": "openai/text-embedding-3-large", # 고정밀 영어 · $0.13/M
"code": "mistralai/codestral-embed-2505", # 코드 전용 · SWE-Bench 1위 · $0.15/M
"balanced": "qwen/qwen3-embedding-4b", # 비용·성능 균형 · $0.02/M
"lightweight": "qwen/qwen3-embedding-0.6b", # 초경량 · OSS · 가격 확인
}
EMBED_MODEL = EMBED_MODELS["multilingual"] # ← 여기서 선택
def embed_text(text: str, model: str = EMBED_MODEL) -> list[float]:
"""단일 텍스트 임베딩"""
response = client.embeddings.create(model=model, input=text.strip())
return response.data[0].embedding
def embed_batch(texts: list[str], model: str = EMBED_MODEL) -> list[list[float]]:
"""배치 임베딩 — API 1회 호출로 복수 처리"""
response = client.embeddings.create(model=model, input=[t.strip() for t in texts])
return [e.embedding for e in sorted(response.data, key=lambda x: x.index)]
# 사용 예시
query_vec = embed_text("유튜브 썸네일 클릭률 높이는 방법")
doc_vecs = embed_batch(["청크 1 내용", "청크 2 내용", "청크 3 내용"])
print(f"차원: {len(query_vec)}") # Qwen3-8B → 4096증분 임베딩 — 변경된 것만 재임베딩
콘텐츠를 추가할 때마다 전체를 다시 임베딩하면 비용이 낭비됩니다. SHA-256 해시(fingerprint)로 변경 여부를 감지해 변경된 것만 재임베딩합니다.
import hashlib, json
import libsql_experimental as libsql, os
conn = libsql.connect(
database=os.getenv("TURSO_DATABASE_URL"),
auth_token=os.getenv("TURSO_AUTH_TOKEN"),
)
def fingerprint(text: str) -> str:
return hashlib.sha256(text.encode()).hexdigest()
def sync_chunk(title, content, content_type, source_id, chunk_index, embed_fn, model_name):
new_fp = fingerprint(content)
row = conn.execute(
"SELECT fingerprint FROM rag_documents WHERE source_id=? AND chunk_index=?",
(source_id, chunk_index)
).fetchone()
if row and row[0] == new_fp:
return "skipped" # 변경 없음 → API 호출 없음
vec = embed_fn(content)
if vec is None:
return "failed"
conn.execute(
"""INSERT INTO rag_documents
(title,content,type,source_id,chunk_index,model,fingerprint,embedding)
VALUES (?,?,?,?,?,?,?,vector32(?))
ON CONFLICT(source_id,chunk_index) DO UPDATE SET
content=excluded.content, model=excluded.model,
fingerprint=excluded.fingerprint, embedding=excluded.embedding,
updated_at=CURRENT_TIMESTAMP""",
(title, content, content_type, source_id, chunk_index,
model_name, new_fp, json.dumps(vec))
)
conn.commit()
return "upserted"비용 절감 전략 체크리스트
임베딩 비용 절감
- ✅fingerprint 증분 임베딩 — 변경된 것만 재임베딩
- ✅Gemini 무료 티어 — 1,500 req/일, 개인 수준 충분
- ✅Qwen3-8B ($0.01/M) — 다국어 1위, text-3-small 대비 절반
- ✅Qwen3-0.6B — 초경량 MTEB 상위권, 로컬 셀프호스팅 가능
- ☐MRL 차원 압축 — Codestral 256d+int8도 경쟁사 최고 성능 상회
LLM 비용 절감
- ✅Grok 4.1 Fast / Grok 4 Fast — $0.20/$0.50/M, 2M ctx (메이저 대비 저가)
- ✅GPT-5.4 nano — $0.20/$1.25 per 1M, RAG 초저가 (400K ctx)
- ✅Gemma 3n 2B 무료 — OpenRouter $0/M (8K ctx)
- ✅Gemini 2.5 Flash-Lite — $0.10/$0.40/M, 1.05M ctx
- ☐청크 수 줄이기 — top_k=5 → 3으로 컨텍스트 단축
- ☐응답 캐싱 — 같은 질문 반복 시 DB에서 답변 재사용
💡 월 운영비 시나리오별 요약 (개인 유튜버, 100회/월 기준)
🆓 완전 무료
Gemini Embed 무료 + Gemini 2.5 Flash 무료 + Turso 무료
무료 tier 한도 내. 분당 요청 제한 있음.
⚡ 극한 저비용
Qwen3-8B ($0.01/M) + Grok 4.1 Fast + Turso 무료
메이저 모델로 비용 최소화. 다국어 강점.
✅ 권장 균형
text-embedding-3-small + Claude Sonnet 4.6 + Turso 무료
안정적 성능·품질. 기본 추천 스택.
🏆 고품질
text-embedding-3-small + Claude Opus 4.6 + Turso 무료
복잡한 질의·멀티스텝 추론에 적합.
🌟 Google 최신
Gemini Embed 무료 + Gemini 2.5 Pro + Turso 무료
Google 생태계 통일. 1.05M ctx Pro.
💎 OpenAI 완전체
text-embedding-3-large + GPT-5.4 + Turso 무료
1M+ ctx 프론티어. 코딩·멀티모달·도구 사용 균형.
* 2026-03-22 기준. 실제 비용은 사용 패턴에 따라 달라집니다. 공식 사이트에서 최신 가격을 반드시 확인하세요.
타임스탬프 기반 RAG — 영상 몇 분에 무슨 말?
SRT/VTT 자막 파일을 파싱하면 영상의 특정 시간대에 한 말을 RAG로 검색하고, 해당 구간 유튜브 링크까지 자동 생성할 수 있습니다.
01. 타임스탬프 RAG 전체 파이프라인
자막 소스 결정
├─ YouTube 자막 있음 → yt-dlp --write-subs → .vtt 파일
├─ YouTube Studio → 직접 다운로드 → .srt 파일
└─ 자막 없음 → Whisper 음성 전사 → .srt 생성
│
▼
포맷 파싱 (SRT / VTT → Python dict)
[{ start: "00:03:15,000", end: "00:03:20,000", text: "수익화 조건은..." }]
│
▼
타임스탬프 청킹 (10세그먼트 묶음, 오버랩 2세그먼트)
chunk = {
content: "[00:03:10 ~ 00:03:50] 수익화 조건은 구독자...",
metadata: { start_time: "00:03:10,000", video_id: "abc123" }
}
│
▼
임베딩 → 벡터 DB 저장 (Chroma / Qdrant)
+ metadata: { start_seconds: 190, youtube_url: "?t=190" }
│
▼
검색 시: 질문 임베딩 → 코사인 유사도 top-k
│
▼
답변 + "▶ 03:10에서 보기" 버튼 (youtu.be/VIDEO_ID?t=190)02. 자막 포맷 3종 — SRT / VTT / Whisper
SRT
가장 범용SubRip Text. 가장 범용적인 자막 포맷으로 YouTube Studio 직접 다운로드와 yt-dlp 모두 지원합니다. 블록 번호 → 타임코드 → 텍스트 순서의 단순 구조.
1
00:00:05,000 --> 00:00:10,000
안녕하세요 여러분, 오늘은
2
00:00:10,500 --> 00:00:15,000
썸네일 클릭률에 대해 이야기해 보겠습니다
3
00:03:15,000 --> 00:03:20,000
수익화 조건은 구독자 1000명 이상💡 정규식: (\d{2}:\d{2}:\d{2},\d{3}) --> (\d{2}:\d{2}:\d{2},\d{3})
03. 자막 수집 방법 3종 — 상황별 선택
yt-dlp (권장)
자동 수집채널 전체 영상 자막을 한 번에 일괄 다운로드. 한국어 자동 자막(auto) 또는 수동 자막(manual) 선택 가능.
# 단일 영상 자막 다운로드
yt-dlp --write-subs \
--write-auto-subs \
--sub-lang ko \
--sub-format srt \
--skip-download \
"https://youtu.be/VIDEO_ID"
# 채널 전체 자막 일괄 다운로드
yt-dlp --write-subs \
--write-auto-subs \
--sub-lang ko \
--sub-format vtt \
--skip-download \
--output "data/raw/subtitles/%(id)s.%(ext)s" \
"https://youtube.com/@채널핸들"Whisper AI (음성 전사)
자막 없을 때YouTube 자막이 없거나 품질이 낮을 때. 음성 파일 자체를 텍스트로 변환. faster-whisper 사용 시 CPU만으로도 실용적.
# faster-whisper 설치 (4배 빠름)
pip install faster-whisper
from faster_whisper import WhisperModel
model = WhisperModel(
"medium", # tiny/base/small/medium/large
device="cpu", # GPU 없어도 가능
compute_type="int8"
)
segments, info = model.transcribe(
"video.mp4",
language="ko",
beam_size=5
)
# SRT 변환
for i, seg in enumerate(segments, 1):
s = format_srt_time(seg.start)
e = format_srt_time(seg.end)
print(f"{i}\n{s} --> {e}\n{seg.text.strip()}\n")YouTube Studio
소수 영상채널 소유자라면 YouTube Studio에서 자막을 직접 .srt로 다운로드 가능. 소수 영상에 적합. 100개 이상은 yt-dlp 자동화 권장.
# YouTube Studio 수동 다운로드 경로
1. studio.youtube.com 접속
2. 콘텐츠 → 영상 선택
3. 자막 탭 클릭
4. 한국어 행 우측 ⋮ → 다운로드
5. SRT 형식 선택
# 다운로드한 파일을 아래 폴더에 저장
data/raw/subtitles/
VIDEO_ID_1.srt
VIDEO_ID_2.srt
...
# 파일명에서 video_id 추출
import os
for fname in os.listdir("data/raw/subtitles"):
video_id = fname.replace(".srt", "")
print(video_id)04. SRT/VTT 파싱 + 타임스탬프 청킹 완성 코드
# 1c_parse_subtitles.py ─ SRT/VTT 파싱 + 타임스탬프 청킹
import re, os, json
from pathlib import Path
# ────────────────────────────────────────────────
# 1. SRT 파서
# ────────────────────────────────────────────────
def parse_srt(srt_text: str) -> list[dict]:
"""SRT 파일 → [{start, end, start_seconds, text}] 리스트"""
blocks = re.split(r'\n\n+', srt_text.strip())
segments = []
for block in blocks:
lines = block.strip().split('\n')
if len(lines) < 3:
continue
# 타임코드 추출: 00:03:15,000 --> 00:03:20,000
match = re.match(
r'(\d{2}:\d{2}:\d{2}[,.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,.]\d{3})',
lines[1].strip()
)
if not match:
continue
start_ts = match.group(1).replace('.', ',') # VTT 점(.) → SRT 쉼표(,) 통일
end_ts = match.group(2).replace('.', ',')
text = ' '.join(lines[2:]).strip()
if not text:
continue
segments.append({
'start': start_ts,
'end': end_ts,
'start_seconds': ts_to_seconds(start_ts), # URL 생성용 초 단위
'text': text
})
return segments
def ts_to_seconds(ts: str) -> int:
"""'00:03:15,000' → 195 (초 단위, 정수)"""
ts_clean = ts.replace(',', '.')
parts = ts_clean.split(':')
h, m, s = int(parts[0]), int(parts[1]), float(parts[2])
return int(h * 3600 + m * 60 + s)
# ────────────────────────────────────────────────
# 2. VTT 파서 (webvtt-py 사용)
# ────────────────────────────────────────────────
def parse_vtt(vtt_path: str) -> list[dict]:
"""VTT 파일 → [{start, end, start_seconds, text}] 리스트"""
try:
import webvtt
segments = []
for caption in webvtt.read(vtt_path):
text = re.sub(r'<[^>]+>', '', caption.text).strip() # HTML 태그 제거
if not text:
continue
start_ts = caption.start.replace('.', ',')
segments.append({
'start': start_ts,
'end': caption.end.replace('.', ','),
'start_seconds': ts_to_seconds(start_ts),
'text': text
})
return segments
except ImportError:
# webvtt-py 없을 때 정규식 폴백
with open(vtt_path, 'r', encoding='utf-8') as f:
content = f.read()
# VTT 헤더·NOTE 블록 제거 후 SRT와 동일 파서로 처리
content = re.sub(r'WEBVTT.*?\n\n', '', content, flags=re.DOTALL)
content = re.sub(r'NOTE.*?\n\n', '', content, flags=re.DOTALL)
return parse_srt(content)
# ────────────────────────────────────────────────
# 3. 타임스탬프 청킹 (10세그먼트, 오버랩 2)
# ────────────────────────────────────────────────
def build_timestamp_chunks(
segments: list[dict],
video_id: str,
chunk_size: int = 10,
overlap: int = 2,
) -> list[dict]:
"""
10개 자막 세그먼트를 묶어 1개 청크로 생성.
오버랩 2: 앞 청크의 마지막 2세그먼트를 다음 청크 앞에 포함.
"""
chunks = []
step = chunk_size - overlap # 실제 전진 세그먼트 수
for i in range(0, len(segments), step):
batch = segments[i : i + chunk_size]
if not batch:
break
start_ts = batch[0]['start']
end_ts = batch[-1]['end']
start_sec = batch[0]['start_seconds']
content = ' '.join(s['text'] for s in batch)
# 유튜브 타임스탬프 URL 자동 생성
youtube_url = f"https://youtu.be/{video_id}?t={start_sec}"
chunks.append({
'content': f"[{start_ts} ~ {end_ts}] {content}",
'metadata': {
'type': 'subtitle',
'video_id': video_id,
'start_time': start_ts,
'end_time': end_ts,
'start_seconds': start_sec, # URL ?t= 파라미터 값
'youtube_url': youtube_url, # 클릭 가능 링크
'chunk_index': len(chunks),
}
})
return chunks
# ────────────────────────────────────────────────
# 4. 메인 실행 — 모든 자막 파일 처리
# ────────────────────────────────────────────────
def process_all_subtitles(subtitle_dir: str = "data/raw/subtitles") -> list[dict]:
all_chunks = []
sub_dir = Path(subtitle_dir)
for sub_file in sorted(sub_dir.iterdir()):
if sub_file.suffix not in ('.srt', '.vtt'):
continue
video_id = sub_file.stem # 파일명 = video_id
print(f" 처리 중: {video_id}{sub_file.suffix}", end="")
if sub_file.suffix == '.srt':
with open(sub_file, 'r', encoding='utf-8') as f:
segments = parse_srt(f.read())
else:
segments = parse_vtt(str(sub_file))
if not segments:
print(" → 세그먼트 없음, 스킵")
continue
chunks = build_timestamp_chunks(segments, video_id)
all_chunks.extend(chunks)
print(f" → {len(segments)}개 세그먼트 → {len(chunks)}개 청크")
return all_chunks
if __name__ == "__main__":
chunks = process_all_subtitles()
# 기존 all_data.json에 추가 (이미 있으면 subtitle 타입 교체)
with open("data/processed/all_data.json", "r", encoding="utf-8") as f:
all_data = json.load(f)
non_subtitle = [d for d in all_data if d['metadata'].get('type') != 'subtitle']
merged = non_subtitle + chunks
with open("data/processed/all_data.json", "w", encoding="utf-8") as f:
json.dump(merged, f, ensure_ascii=False, indent=2)
print(f"\n✅ 완료: 자막 청크 {len(chunks)}개 추가 → 전체 {len(merged)}개")# 실행 결과 예시
처리 중: dQw4w9WgXcY.srt → 847개 세그먼트 → 94개 청크
처리 중: xvFZjo5PgG0.vtt → 312개 세그먼트 → 35개 청크
처리 중: M7lc1UVf-VE.srt → 623개 세그먼트 → 70개 청크
...
처리 중: abc123def.srt → 0개 세그먼트 → 0개 청크, 스킵
✅ 완료: 자막 청크 1840개 추가 → 전체 6,247개05. 타임스탬프 → 유튜브 URL 자동 변환
YouTube는 ?t=초단위 URL 파라미터로 특정 시간부터 재생을 시작할 수 있습니다. SRT 타임코드를 초 단위로 변환하여 저장하면, RAG 답변에 클릭 가능한 타임스탬프 링크를 자동으로 첨부할 수 있습니다.
| SRT 타임코드 | → 초 단위 | → YouTube URL |
|---|---|---|
| 00:02:30,000 | 150s | https://youtu.be/VIDEO_ID?t=150 |
| 00:12:45,500 | 765s | https://youtu.be/VIDEO_ID?t=765 |
| 01:03:20,000 | 3800s | https://youtu.be/VIDEO_ID?t=3800 |
# ts_to_seconds() 내부 계산 예시
def ts_to_seconds(ts: str) -> int:
# "01:03:20,000" → 초 단위 정수
ts_clean = ts.replace(',', '.') # "01:03:20.000"
h, m, s = ts_clean.split(':') # ["01", "03", "20.000"]
total = int(h) * 3600 # 1 * 3600 = 3600
+ int(m) * 60 # 3 * 60 = 180
+ float(s) # 20.000 = 20
# total = 3800
return int(total) # → 3800
# → https://youtu.be/VIDEO_ID?t=3800
# → 영상에서 정확히 1시간 3분 20초부터 재생 ✅06. 타임스탬프 RAG 검색 + 링크 자동 첨부
# 타임스탬프 RAG 검색 + URL 생성
def search_with_timestamp(
query: str,
vectordb,
k: int = 5,
type_filter: str = "subtitle",
) -> list[dict]:
"""
자막 청크 검색 후 타임스탬프 URL 포함 반환
"""
results = vectordb.similarity_search_with_score(
query,
k=k,
filter={"type": type_filter} # 자막만 검색
)
output = []
for doc, dist in results:
sim = round(1 - dist, 4)
meta = doc.metadata
sec = meta.get("start_seconds", 0)
vid = meta.get("video_id", "")
output.append({
"similarity": sim,
"content": doc.page_content,
"start_time": meta.get("start_time"),
"youtube_url": f"https://youtu.be/{vid}?t={sec}",
"embed_url": f"https://www.youtube.com/embed/{vid}?start={sec}",
})
return output
# 사용 예시
hits = search_with_timestamp(
"수익화 조건이 뭐라고 했어?",
vectordb, k=3
)
for h in hits:
print(f"[{h['similarity']}] {h['start_time']}")
print(f" {h['content'][:80]}...")
print(f" ▶ {h['youtube_url']}")🔍 실제 검색 결과 예시
질문: "수익화 조건이 뭐라고 했어?" [0.9124] 00:12:30,000 ~ 00:12:58,000 수익화 조건은 구독자 1000명 이상, 시청 시간 4000시간 이상입니다. ▶ https://youtu.be/dQw4w9W?t=750 [0.8831] 00:15:02,000 ~ 00:15:30,000 YouTube 파트너 프로그램에 신청하셔야 하는데요, 검토에 1개월 정도 걸립니다. ▶ https://youtu.be/dQw4w9W?t=902 [0.8612] 00:28:15,000 ~ 00:28:44,000 쇼츠 수익화는 조건이 달라서 구독자 500명, 조회수 300만 이상입니다. ▶ https://youtu.be/dQw4w9W?t=1695
# React UI에서 타임스탬프 버튼 렌더링
{hits.map((hit, i) => (
<div key={i} className="border rounded-xl p-3 mb-2">
<p className="text-sm">{hit.content}</p>
<a
href={hit.youtube_url}
target="_blank"
className="inline-flex items-center gap-1
bg-red-600 text-white text-xs px-3 py-1
rounded-full mt-2 hover:bg-red-700"
>
▶ {hit.start_time.slice(0, 8)} 에서 보기
</a>
</div>
))}07. Whisper 음성 전사 — 자막 없는 영상 처리
YouTube 자동 자막이 없거나 품질이 낮은 영상(초기 영상, 비공개 자막 설정 등)은 OpenAI Whisper로 직접 음성을 전사합니다. faster-whisper는 원본보다 4배 빠르고 메모리도 적게 사용해 CPU 환경에서도 실용적입니다.
tiny
75 MB
32× 실시간
정확도: 낮음
빠른 테스트
medium
1.5 GB
6× 실시간
정확도: 높음
한국어 권장
large-v3
3 GB
2× 실시간
정확도: 최고
최고 품질
# yt-dlp로 음성 추출 → faster-whisper로 전사 → SRT 저장
import subprocess
from faster_whisper import WhisperModel
from pathlib import Path
def transcribe_video(video_id: str, model_size: str = "medium"):
url = f"https://youtu.be/{video_id}"
mp3_out = f"data/raw/audio/{video_id}.mp3"
srt_out = f"data/raw/subtitles/{video_id}.srt"
# 1. 오디오만 다운로드 (영상 없이)
subprocess.run([
"yt-dlp", "-x",
"--audio-format", "mp3",
"--audio-quality", "0",
"-o", mp3_out,
url
], check=True)
# 2. Whisper로 전사
model = WhisperModel(model_size, device="cpu", compute_type="int8")
segs, info = model.transcribe(
mp3_out, language="ko", beam_size=5
)
# 3. SRT 파일로 저장
with open(srt_out, "w", encoding="utf-8") as f:
for i, seg in enumerate(segs, 1):
s = format_srt_time(seg.start) # 초 → "00:03:15,000"
e = format_srt_time(seg.end)
f.write(f"{i}\n{s} --> {e}\n{seg.text.strip()}\n\n")
print(f"✅ {video_id}: {info.duration:.0f}초 → {srt_out}")
def format_srt_time(seconds: float) -> str:
h = int(seconds // 3600)
m = int((seconds % 3600) // 60)
s = int(seconds % 60)
ms = int((seconds - int(seconds)) * 1000)
return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"08. 타임스탬프 RAG 응용 아이디어
타임스탬프 링크 자동 첨부
답변에 '▶ 03:10에서 보기' 버튼을 자동 생성. youtu.be/ID?t=190 클릭 시 해당 구간부터 재생.
youtube_url =
f"youtu.be/{vid}?t={sec}"인라인 영상 임베드
답변 내에 YouTube 임베드 플레이어를 직접 삽입. start 파라미터로 정확한 구간부터 재생 시작.
embed =
f"youtube.com/embed/{vid}
?start={sec}&end={sec+30}"챕터 자동 생성
타임스탬프별 내용을 분석해 영상 챕터 목록을 자동 생성. YouTube 설명란에 붙여넣기만 하면 챕터 활성화.
# 0:00 인트로 # 2:30 수익화 조건 # 15:00 알고리즘 설명
채널 전체 자막 통합 검색
영상 287개 전체 자막을 하나의 벡터 DB에 통합. '언제 CTR 얘기했지?'를 채널 전체에서 즉시 검색.
filter={"type":"subtitle"}
→ 자막 청크만 검색
→ 관련 영상 + 타임코드 반환하이라이트 구간 추출
특정 키워드(핵심 팁, 결론 등) 관련 구간을 자동 추출. 유튜브 쇼츠용 핵심 구간 후보 리스트 자동화.
hits = search("핵심 팁 결론")
# → 상위 5개 타임스탬프
# → 쇼츠 편집 소스로 활용토픽별 발화 빈도 분석
특정 주제를 언급한 구간 수를 집계해 '내가 가장 많이 다룬 주제'를 자동 파악. 콘텐츠 전략 데이터화.
for topic in ["썸네일","알고리즘"]:
hits = search(topic, k=100)
print(f"{topic}: {len(hits)}회")⚠️ VTT vs SRT 포맷 주의
yt-dlp는 기본적으로 VTT 포맷으로 다운로드합니다. VTT는 타임코드 구분자가 쉼표(,) 대신 점(.)을 사용하고 HTML 태그(<00:00:12.000>)가 포함될 수 있습니다. 파서에서 .replace('.', ',')와 HTML 태그 제거를 반드시 처리하세요.
💡 chunk_size 10 vs 5 선택 기준
자막 세그먼트 1개는 약 2~5초 분량입니다. chunk_size=10이면 약 20~50초 구간, chunk_size=5면 약 10~25초 구간입니다. 더 정밀한 구간 검색이 필요하면 5로, 맥락이 중요한 내용은 10~15로 설정하세요.
임베딩 AI 모델 종합 비교 (2026) — 어떤 모델이 최강?
2026년 기준 실제 벤치마크 성능, 비용, 멀티모달 지원 여부로 임베딩 모델을 종합 비교합니다. OpenRouter 실제 카탈로그 기준 모델명·가격·연동 코드까지 수록합니다.
핵심 결론: "절대적인 1위"는 없다
용도에 따라 최적의 모델이 완전히 달라집니다. 범용 RAG, 다국어, 코드, 멀티모달, 저비용 등 목적에 맞는 모델을 선택하세요.
⚠️ 아래 표의 모델명·가격·제공 여부는 수시로 변경됩니다. 반드시 openrouter.ai/models 카탈로그에서 최신 정보를 확인하세요.
⚠️ MTEB 등 벤치마크 순위는 태스크·언어·시점마다 다릅니다. "전체 1위" 표현은 특정 태스크 기준 참고용으로만 이해하세요.
⚠️ 이 칼럼의 수치·모델 목록은 2026년 3월 기준입니다. MTEB 리더보드 에서 현재 순위를 항상 교차 확인하세요.
OpenRouter 제공 임베딩 모델 — 실제 카탈로그 기준
아래는 OpenRouter 카탈로그에 실제로 등록된 임베딩 모델 목록입니다 (2026년 3월 확인 기준). 가격·컨텍스트는 변동될 수 있으므로 공식 카탈로그에서 반드시 재확인하세요.
| 모델 | 컨텍스트 | 1M 토큰 비용 | 특징 |
|---|---|---|---|
Qwen3 Embedding 8B 🏆 성능·가성비 균형 | 32K | $0.01 | MTEB Health #23 · Legal #26. 다국어·코드·긴 문서 검색. 32K 컨텍스트로 긴 청크 처리 가능. |
Qwen3 Embedding 4B ⚡ 경량 다국어 | 33K | $0.02 | 8B보다 소형이지만 긴 컨텍스트(33K). 메모리 제한 환경에서 8B 대안. |
Qwen3 Embedding 0.6B 🚀 초저지연 | 8K | 가격 확인 필요 | 초경량 모델. 저지연이 최우선인 실시간 서비스용. |
OpenAI text-embedding-3-small ✅ 범용 표준 | 8K | $0.02 | MTEB Health #45 · Legal #36. 검증된 범용 임베딩. 가장 넓은 생태계 지원. |
OpenAI text-embedding-3-large 💎 OpenAI 최고품질 | 8K | $0.13 | 가장 높은 OpenAI 임베딩 품질. 고정밀 도메인 검색 서비스용. |
Mistral Codestral Embed 2505 💻 코드 특화 1위 | 8K | $0.15 | 코드 특화 임베딩. 코드 저장소·함수 검색·코딩 어시스턴트용으로 설계. |
NVIDIA Nemotron Embed VL 1B V2 🖼️ 멀티모달 무료 | 131K | 무료 | 멀티모달(이미지+텍스트) 지원. 131K 컨텍스트. 무료 티어에서 멀티모달 필요 시 최우선 검토. |
* 위 목록은 OpenRouter 카탈로그 일부입니다. 전체 목록·최신 가격은 openrouter.ai/models에서 확인하세요. GTE-Large, E5-Large-v2, Multilingual-E5-Large, paraphrase-MiniLM 등 구세대 모델은 현재 카탈로그에서 찾기 어려울 수 있습니다.
OpenRouter 임베딩 API 연동 코드
# OpenRouter 임베딩 API — openai 클라이언트 재사용 가능
pip install openai
import os
from openai import OpenAI
client = OpenAI(
base_url="https://openrouter.ai/api/v1",
api_key=os.getenv("OPENROUTER_API_KEY"),
)
def embed_openrouter(
text: str,
model: str = "qwen/qwen3-embedding-8b"
) -> list[float]:
"""
OpenRouter 임베딩 API 호출.
model 문자열 형식: "provider/model-name"
예시:
"qwen/qwen3-embedding-8b" — Qwen3 8B (다국어·범용)
"qwen/qwen3-embedding-0.6b" — Qwen3 0.6B (초경량)
"openai/text-embedding-3-small" — OpenAI small (안정성)
"openai/text-embedding-3-large" — OpenAI large (고품질)
"mistralai/codestral-embed" — 코드 특화
"""
response = client.embeddings.create(
model=model,
input=text,
)
return response.data[0].embedding
# 배치 처리 (여러 텍스트 한 번에)
def embed_batch_openrouter(
texts: list[str],
model: str = "qwen/qwen3-embedding-8b"
) -> list[list[float]]:
response = client.embeddings.create(
model=model,
input=texts, # 리스트로 한 번에 전송 (API 호출 횟수 절감)
)
# 입력 순서대로 정렬
return [item.embedding for item in sorted(response.data, key=lambda x: x.index)]
# 사용 예시
vec = embed_openrouter("썸네일 클릭률 높이는 방법")
print(f"벡터 차원: {len(vec)}") # Qwen3-8B: 4096차원
# RAG용 저장 임베딩 vs 쿼리 임베딩
# ※ Qwen3은 task_type 파라미터가 없으므로 동일 모델로 저장·검색 모두 처리
store_vec = embed_openrouter("안녕하세요 오늘은 썸네일...") # 문서 저장용
query_vec = embed_openrouter("썸네일 클릭률 높이는 방법?") # 검색 쿼리용OpenRouter 외 주목할 모델들
Gemini Embedding 2 (Google)
다국어·멀티모달크로스링구얼 리트리벌 최상위권(MTEB 기준). 텍스트·이미지·비디오·오디오·PDF 5개 모달리티 지원. 한국어↔영어 교차 검색에 강점.
📦 패키지: google-generativeai
Voyage Multimodal 3.5
균형형 올라운더전 영역에서 고르게 강함. MRL 차원 압축(ρ≈0.88)으로 벡터 저장 비용 절감 가능. 법률·금융 도메인 특화 버전(voyage-law-2, voyage-finance-2) 별도 제공.
📦 패키지: voyageai
Qwen3-VL-Embedding-2B (로컬)
무료 오픈소스 멀티모달HuggingFace에서 직접 실행 가능. 텍스트-이미지 혼합 인덱스 최적화. OpenRouter 제공 Qwen3 텍스트 모델과 별개 — 멀티모달이 필요한 경우에만 선택.
📦 패키지: transformers
BGE-M3 (BAAI)
셀프호스팅 최적dense + sparse 하이브리드 리트리벌 지원. 568M 파라미터로 서버 직접 실행 가능. 고유명사·코드 식별자처럼 의미 검색이 취약한 쿼리를 BM25 sparse 검색으로 보완.
📦 패키지: FlagEmbedding
pip install google-generativeai
import google.generativeai as genai
import os
genai.configure(api_key=os.getenv("GOOGLE_API_KEY"))
def embed_gemini_document(text: str) -> list[float]:
"""DB 저장용 — retrieval_document"""
result = genai.embed_content(
model="models/gemini-embedding-exp-03-07", # 최신 모델명은 공식 문서 확인
content=text,
task_type="retrieval_document" # 문서 저장 시
)
return result["embedding"] # 3072차원 (확인 필요)
def embed_gemini_query(query: str) -> list[float]:
"""검색 쿼리용 — retrieval_query (반드시 다른 task_type 사용!)"""
result = genai.embed_content(
model="models/gemini-embedding-exp-03-07",
content=query,
task_type="retrieval_query" # 쿼리 검색 시
)
return result["embedding"]
# ⚠️ task_type을 혼용하면 검색 품질이 저하됩니다
# 저장: retrieval_document / 검색: retrieval_query 를 항상 구분하세요pip install FlagEmbedding
from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel(
"BAAI/bge-m3",
use_fp16=True # GPU 메모리 절약
)
# dense + sparse + colbert 동시 생성
output = model.encode(
["썸네일 클릭률 높이는 방법", "CTR 향상 전략"],
return_dense=True, # 의미 유사도용
return_sparse=True, # BM25 키워드 매칭용
return_colbert_vecs=False # 정밀 비교용 (선택)
)
dense_vecs = output["dense_vecs"] # shape: (2, 1024)
sparse_vecs = output["lexical_weights"] # 토큰 가중치 딕셔너리
# 하이브리드 점수 = α × dense_score + (1-α) × sparse_score
# α ≈ 0.6~0.7 이 일반적으로 권장됨나에게 맞는 모델 고르기 — 의사결정 흐름
어떤 데이터를 임베딩하나요?
│
├─ 코드 (Python/JS/SQL 등)
│ └─ → Mistral Codestral Embed 2505 (OpenRouter)
│
├─ 이미지 + 텍스트 혼합 (멀티모달)
│ ├─ 무료 원함 → NVIDIA Nemotron Embed VL 1B V2 (OpenRouter)
│ ├─ 오픈소스 원함 → Qwen3-VL-Embedding-2B (로컬 실행)
│ └─ 고품질 원함 → Gemini Embedding 2 (Google API)
│
└─ 텍스트 전용
│
├─ 한국어 포함 다국어?
│ ├─ 예 → Qwen3 Embedding 8B (OpenRouter, $0.01/M)
│ │ 또는 Gemini Embedding 2 (한국어↔영어 교차 검색 강점)
│ └─ 영어만 → text-embedding-3-small (OpenAI, 넓은 생태계)
│
├─ 서버에 직접 설치하고 싶다?
│ └─ BGE-M3 (568M, 하이브리드 리트리벌 지원)
│
├─ 벡터 저장 비용이 걱정된다?
│ └─ Voyage MM-3.5 (MRL 압축, 차원 축소로 비용 절감)
│
├─ 최대한 빠른 프로토타입?
│ └─ text-embedding-3-small (OpenAI, 코드 예제 가장 많음)
│
└─ 고정밀 프로덕션?
└─ text-embedding-3-large 또는 Qwen3-8B 비교 테스트 후 선택용도별 추천 정리
| 목적 | 추천 모델 | 접근 방법 |
|---|---|---|
| 범용 RAG — 빠른 구축 | text-embedding-3-small | OpenAI API / OpenRouter |
| 다국어·한국어↔영어 | Qwen3 Embedding 8B | OpenRouter ($0.01/M) |
| 코드 저장소·코딩 어시스턴트 | Mistral Codestral Embed 2505 | OpenRouter ($0.15/M) |
| 멀티모달 — 무료 | NVIDIA Nemotron Embed VL 1B V2 | OpenRouter (무료) |
| 멀티모달 — 고품질 | Gemini Embedding 2 | Google AI API |
| 저비용 셀프호스팅 | BGE-M3 | FlagEmbedding 로컬 실행 |
| 벡터 저장 비용 절감(MRL) | Voyage MM-3.5 | Voyage AI API |
| 무료 오픈소스 멀티모달 | Qwen3-VL-Embedding-2B | HuggingFace 로컬 |
| 초경량 저지연 서비스 | Qwen3 Embedding 0.6B | OpenRouter |
| 고정밀 영어 전용 | text-embedding-3-large | OpenAI API / OpenRouter |
⚠️ 모델 교체 시 반드시 알아야 할 것
벡터 공간은 모델마다 다릅니다. text-embedding-3-small로 저장한 벡터와qwen3-embedding-8b로 만든 쿼리 벡터를 코사인 유사도로 비교하면 의미 없는 숫자가 나옵니다.
저장 시 모델명을 DB에 기록하세요. model 컬럼에 저장해두면 나중에 모델을 교체할 때 어떤 행을 재임베딩해야 하는지 즉시 파악할 수 있습니다. (SectionStorageStructure 참고)
차원도 모델마다 다릅니다. Qwen3-8B는 4096차원, text-embedding-3-small은 1536차원, Gemini Embedding은 768~3072차원입니다. 차원이 다른 벡터들이 같은 테이블에 섞이면 검색 자체가 불가능합니다.
🎯 2026년 기준 빠른 선택 가이드
🆕 처음 시작한다면
text-embedding-3-small
레퍼런스·예제 코드가 가장 많고 LangChain 기본 지원. 먼저 익힌 후 교체 검토.
🌏 한국어 서비스라면
Qwen3 Embedding 8B
$0.01/M 저렴하고 32K 컨텍스트. OpenRouter로 바로 연동.
💰 비용 제로로 시작
Gemini text-embedding-004
무료 1500 req/일. task_type 구분 필수. 768차원으로 저장 용량도 절반.
모든 수치는 2026년 3월 기준 — 공식 문서에서 항상 최신 정보 재확인 필수
RAG 활용 산업별 사례 — 이미 이렇게 쓰고 있다
법률, 의료, 이커머스, 금융, 교육, 개인 지식 관리 — RAG는 2025~2026년 현재 실제 프로덕션에서 수억 달러 규모의 가치를 만들어내고 있습니다.
01. 산업별 RAG 도입 효과 — 실측 수치
법률
36.9h/월
업무 시간 절감
의료
60%↓
검색 시간 절감
금융
70%↓
컴플라이언스 검토
이커머스
40~60%
CS 자동화율
교육
35%↑
학습 효율 향상
전체
40~80%
할루시네이션 감소
02. 산업별 실제 Q&A 시나리오 — 인터랙티브
법률
Harvey AI — 창업 36개월 만에 ARR $100M 돌파. 법률 파워 유저 월 36.9시간 절감.
💬 실제 Q&A 시나리오
Q.이 계약에 위약금 조항이 있어?
A.7조 3항에 계약 해지 시 총액의 20% 위약금 조항이 명시되어 있습니다.
Q.유사 판례 찾아줘 — 임차인 보증금 반환 거부
A.대법원 2023다12345 등 유사 판례 18건 검색 완료. 원고 승소율 73%.
Q.이 조항이 GDPR을 위반하는가?
A.GDPR 17조(삭제권) 요건 미충족 가능성 있음. 3개 관련 조항 출처 제시.
📊 실측 수치
$100M+
Harvey AI ARR
창업 36개월 만에 달성 (2025)
↓36.9h/월
법률 검토 시간
파워 유저 평균 절감
94.8%
AI 정확도
변호사 베이스라인 70.1% 대비
5.8%
할루시네이션
RAG 적용 후 통제 환경 기준
# 법률 RAG 파이프라인 요약
계약서 PDF / 판례 DB
→ 청킹 (조항 단위 500~1000자)
→ 임베딩 (법률 특화 모델)
→ pgvector / Weaviate
→ 의미 검색 + 메타 필터 (법원, 연도)
→ GPT-4o / Claude 3.5 답변 + 출처 조항 번호03. 유튜버가 직접 RAG를 구축하면 — 구체적 활용 시나리오 6가지
콘텐츠 전략 AI
전략 분석"내 채널에서 조회수 10만 넘은 영상의 공통점은?"
287개 영상 분석 결과: 제목에 숫자 포함 시 CTR +23%, 12~18분 영상 평균 시청 지속시간 최고, 썸네일 대비 강도 상위 20% 영상이 추천 알고리즘 진입률 2.1배.
# 구현 방식
video metadata + 자막 → view_count 필터 + 벡터 검색
나만의 말투 스크립트 생성
스크립트 생성"구독자 증가 팁 영상 스크립트 내 스타일로 작성해줘"
내 대본 55개 학습 결과: 도입부 질문형 훅 패턴, '여러분' 호칭, 3-4분 간격 CTA 삽입 패턴 추출. 동일 스타일 새 스크립트 생성.
# 구현 방식
대본(script) 청크 → SemanticChunker → 문체 학습 프롬프트
댓글 인사이트 분석
댓글 분석"시청자들이 가장 많이 요청하는 주제 TOP 10은?"
댓글 2,814개 분석: 1위 '쇼츠 알고리즘 변화' (47회), 2위 '수익화 기준' (39회), 3위 '협찬 단가' (31회). 감성 분석: 긍정 74%, 부정 8%.
# 구현 방식
comments 전체 → 클러스터링 + 빈도 분석 프롬프트
중복 주제 경고
중복 방지"알고리즘 영상 새로 만들려는데 비슷한 거 있어?"
유사도 0.89 이상 영상 3개 발견: 2023-03 '유튜브 알고리즘 완전정복', 2024-01 '알고리즘 바뀐 것들'. 새 영상에서 차별화할 포인트: 2025년 Shorts 가중치 변화.
# 구현 방식
새 제목 임베딩 → 기존 영상 코사인 유사도 > 0.85 필터
타임스탬프 기반 검색
타임스탬프"CTR 올리는 법 언급한 영상 타임코드 전부 알려줘"
23개 영상, 67개 구간 검색 완료. 가장 상세히 다룬 구간: 영상A 00:03:42~00:08:15, 영상B 00:12:04~00:15:30. 링크 생성 가능.
# 구현 방식
subtitle chunks + start_time 메타 → 타임스탬프 URL 생성
성과 예측 & 벤치마크
성과 예측"이 썸네일 문구가 기존 영상 대비 CTR 높을까?"
기존 고CTR 영상 50개와 유사도 0.82. '숫자+감정단어' 패턴 일치. 예상 CTR: 7~9% (채널 평균 5.3% 대비 +33~70%).
# 구현 방식
고CTR 영상 벡터 + 새 제목 코사인 유사도 → 예측 프롬프트
04. RAG가 탁월한 4가지 공통 조건
도메인 특화 대용량 문서
일반 AI가 학습하지 않은 내부 문서, 전문 지식, 회사 규정, 개인 데이터 등. DB 업데이트만으로 즉시 반영.
자주 업데이트되는 정보
LLM 재학습(파인튜닝) 없이 벡터 DB 업데이트만으로 최신 정보 반영 가능. 시간·비용 혁신적으로 절감.
출처 투명성이 중요한 영역
답변의 근거 문서와 청크를 함께 제시. 법률·의료·금융에서 검증 가능한 신뢰성 확보.
할루시네이션 방지 필수
검색된 실제 문서만 참고하므로 허구 답변 40~80% 감소. 프로덕션 AI의 핵심 품질 지표.
05. RAG vs 파인튜닝 vs 프롬프트 엔지니어링 — 언제 무엇을 쓸까
| 기준 | 프롬프트 엔지니어링 | RAG | 파인튜닝 (Fine-tuning) |
|---|---|---|---|
| 💰 비용 | 거의 0 (API 비용만) | $0.1~$5 (임베딩 1회) | $100~$10,000+ (GPU 학습) |
| ⏱️ 구축 시간 | 수 분 | 수 시간~수 일 | 수 주~수 개월 |
| 📚 외부 지식 반영 | ❌ 컨텍스트 창 한계 | ✅ 무제한 DB 검색 | △ 학습 데이터만 |
| 🔄 실시간 업데이트 | ❌ 불가 | ✅ DB 추가만으로 즉시 | ❌ 재학습 필요 |
| 🎭 문체·스타일 학습 | △ 예시 제공 수준 | △ 예시 검색 수준 | ✅ 완전한 스타일 내재화 |
| 🔍 출처 제시 | ❌ | ✅ 청크 + 메타데이터 | ❌ 학습 내재화 |
| 🛡️ 할루시네이션 | ❌ 높음 | ✅ 크게 감소 (40~80%↓) | △ 여전히 발생 |
| 📦 데이터 필요량 | 예시 3~10개 | 청크 수천 개 | 고품질 데이터 수만 건 |
| 🎯 추천 상황 | 단순 지시 / 포맷 변환 | 지식 검색 / Q&A / 문서 | 특수 도메인 언어 / 스타일 |
💡 실전 조합 전략: 대부분의 프로덕션 시스템은 RAG + 프롬프트 엔지니어링을 함께 사용합니다. 문체나 브랜드 일관성이 필요하다면 소량의 파인튜닝을 추가하는 RAG + 파인튜닝 하이브리드도 가능합니다. 유튜버 RAG의 경우 RAG로 채널 지식을 검색하고 프롬프트로 말투를 제어하는 조합이 비용 대비 최고 효율입니다.
06. RAG 시장 전망 — 지금이 구축할 타이밍인 이유
$1.94B → $9.86B
RAG 시장 규모
2025 → 2030 전망 (CAGR 38.4%)
38.4%
연평균 성장률 (CAGR)
AI 전 분야 중 최고 수준 성장
80%+
기업 AI 도입 계획
2025 엔터프라이즈 RAG 도입 예정 비율
🎯 지금 이 칼럼으로 구축하는 유튜버 RAG의 위치
Harvey AI와 같은 원리
수억 달러 가치의 법률 AI와 동일한 RAG 파이프라인. 도메인만 다를 뿐 아키텍처는 완전히 동일합니다.
비용은 1/10,000
Harvey AI 구독료 $1,000/좌석/월 대신, 직접 구축 시 최초 임베딩 $5 + 질문당 $0.01로 동일 기능 구현.
채널 데이터 완전 소유
외부 서비스에 데이터를 맡기지 않고 본인 서버에 벡터 DB를 직접 보유. 데이터 소유권 100%.
확장성 무제한
영상 추가 시 청크만 임베딩하면 즉시 반영. 파인튜닝 재학습 없이 지식 베이스를 실시간 확장.
RAG 잘 설계된 코드 분석 — memo-embedding 아키텍처
실무에서 운영 중인 RAG 코드의 설계 원칙·잘된 점·개선 포인트를 심층 분석합니다. 이 구조는 이 사이트 자체에서 사용 중입니다.
💡 이 섹션의 목적: 앞 섹션들(임베딩·벡터 DB·RAG 체인·비용 최적화)에서 배운 개념이 실제 프로덕션 코드에서 어떻게 구현되는지 확인합니다. 각 파일이 어떤 섹션의 개념을 구현하는지 연결해가며 읽으세요.
전체 데이터 흐름
[ 메모 저장/수정 ]
│
▼
[ run-sync.ts ] ← 오케스트레이션: full / stale / missing / recent_1 / recent_20 모드 선택
│
├─ 변경된 메모만? → fingerprint(SHA-256) 비교 → 같으면 SKIP (비용 0)
│
├─ [ text.ts ] ← 텍스트 전처리: 정리본 앞 + 원본 뒤, 32K 상한 트림
│
├─ [ constants.ts ] ← 모델명·차원·상한 상수 (한 곳에서만 관리)
│
├─ 임베딩 API 호출 (OpenAI text-embedding-3-small, 1536차원)
│
└─ [ upsert.ts ] ← INSERT ... ON CONFLICT DO UPDATE (멱등성 보장)
[ 사용자 질문 ]
│
▼
[ vector-search.ts ] ← vector_top_k() 시맨틱 검색 → JOIN으로 삭제 메모 필터
│
▼
[ rag-context.ts ] ← 검색 결과를 LLM 프롬프트용 컨텍스트로 조립
│
▼
[ LLM 답변 생성 ]각 파일이 한 가지 역할만 담당합니다 — 관심사 분리(Separation of Concerns)의 모범 사례.
모듈 구조 — 파일별 역할과 연결 개념
lib/memo-embedding/
├── constants.ts ← 모델명·차원·최대 글자 수 상수 (한 곳에서만 관리)
│ → 섹션 7 임베딩 모델 원리, 섹션 10 DB 저장 형태
├── text.ts ← 텍스트 전처리 + 임베딩 입력 조합
│ → 섹션 6 청킹(Chunking) 전략
├── upsert.ts ← 벡터 DB 저장/업데이트 (멱등성)
│ → 섹션 9 Turso 벡터 DB, 섹션 10 DB 저장 형태
├── vector-search.ts ← 코사인 유사도 시맨틱 검색
│ → 섹션 8 벡터 DB 종류 비교, 섹션 11 RAG 체인
├── run-sync.ts ← 임베딩 동기화 오케스트레이션 (5가지 모드)
│ → 섹션 15 비용 최적화 (증분 임베딩)
└── rag-context.ts ← LLM 프롬프트용 컨텍스트 조립
→ 섹션 11 검색+생성 RAG 체인constants.ts모델·차원·상한 상수 중앙화
모델 교체 시 이 파일 하나만 수정. 다른 파일은 건드릴 필요 없음.
export const EMBEDDING_MODEL = "text-embedding-3-small"; export const EMBEDDING_DIM = 1536; export const MAX_CHARS = 32_000;
text.ts정리본 앞 + 원본 뒤 전략
32K 상한에 걸릴 때 덜 중요한 원본 꼬리부터 잘림. 정보 손실 최소화.
export function buildEmbeddingInput(
refined: string, raw: string
): string {
const combined = `${refined}\n---\n${raw}`;
return combined.slice(0, MAX_CHARS); // 꼬리 트림
}upsert.tsINSERT ON CONFLICT — 멱등성 보장
중복 실행해도 데이터가 깨지지 않음. 재시도 안전.
INSERT INTO memo_embedding (memo_id, embedding, fingerprint, model) VALUES (?, vector32(?), ?, ?) ON CONFLICT(memo_id) DO UPDATE SET embedding = excluded.embedding, fingerprint = excluded.fingerprint, updated_at = CURRENT_TIMESTAMP
vector-search.ts코사인 유사도 + 삭제 필터
vector_top_k 후 JOIN으로 삭제된 메모를 걸러냄.
SELECT m.id, m.title, m.content, vector_distance_cos(e.embedding, vector32(?)) AS dist FROM memo_embedding e JOIN memo m ON m.id = e.memo_id WHERE m.deleted_at IS NULL AND e.model = ? ORDER BY dist LIMIT ?
run-sync.ts5가지 동기화 모드
상황별 최적 모드 선택. 비용·시간 모두 절약.
// full : 전체 재색인 // stale : fingerprint 변경된 것만 // missing : 임베딩 없는 것만 // recent_1 : 최근 1개 즉시 // recent_20: 최근 20개 배치
rag-context.ts검색 결과 → LLM 프롬프트 조립
유사도 점수 기반 필터 + 컨텍스트 포맷팅.
export function buildRagContext(
results: SearchResult[]
): string {
return results
.filter(r => r.dist < SIMILARITY_THRESHOLD)
.map((r, i) =>
`[참고 ${i+1}] ${r.title}\n${r.content}`
).join("\n\n");
}✅ 잘 설계된 점 — 5가지 원칙
관심사 분리 (Separation of Concerns)
설계 원칙텍스트 전처리·벡터 저장·검색·동기화·RAG 조립이 각각 한 파일에서만 처리됩니다. 모델을 바꾸거나 DB를 교체해도 영향 범위가 한 파일로 한정됩니다. constants.ts 하나만 수정하면 모든 모델명·차원 참조가 함께 바뀝니다.
fingerprint 기반 증분 동기화 — 비용 핵심
비용 절감원본 텍스트의 SHA-256 해시를 DB에 저장해두고, 변경된 것만 재임베딩합니다. 300개 메모 중 1개만 수정됐을 때 1건의 API 비용만 발생합니다. 섹션 15 비용 최적화의 핵심 전략이 실제 코드로 구현된 사례입니다.
5가지 동기화 모드 — 상황별 최적 선택
운영 유연성full(전체 재색인)·stale(변경분)·missing(누락분)·recent_1(즉시 1건)·recent_20(배치 20건). "메모 저장 직후 즉시 임베딩"부터 "전체 재색인"까지 상황에 맞는 모드를 선택해 불필요한 API 호출을 원천 차단합니다.
임베딩 입력 구성 — 정보 손실 최소화 전략
품질 설계정리본(refined)을 앞에, 원본(raw)을 뒤에 배치하고 32K 상한 초과 시 꼬리부터 자릅니다. 중요도 높은 정리본이 항상 온전히 포함됩니다. 단순 truncate보다 검색 품질이 높습니다.
INSERT ON CONFLICT — 멱등성(Idempotency) 보장
안정성동일한 sync를 여러 번 실행해도 데이터가 깨지지 않습니다. 배포 중 재시작, 네트워크 오류 후 재시도, Cron 중복 실행 등 모든 상황에서 안전합니다. 섹션 9 Turso 스키마 설계의 ON CONFLICT 패턴이 실제 적용된 사례입니다.
⚠️ 개선 포인트 — 문제·원인·해결 코드
⚠️ 에러 처리 부재 — 배치 전체가 날아갈 수 있음
심각도 높음현재 embedAndUpsertBatch는 API 호출 실패 시 전체 배치가 중단됩니다. 네트워크 오류·Rate Limit·타임아웃 발생 시 성공한 것도 저장되지 않습니다.
// ✅ 개선: 부분 실패 허용 + 지수 백오프 재시도
async function embedWithRetry(
text: string,
maxRetries = 3
): Promise<number[] | null> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const res = await openai.embeddings.create({
model: EMBEDDING_MODEL,
input: text,
});
return res.data[0].embedding;
} catch (err: unknown) {
const isRateLimit =
err instanceof Error && err.message.includes("429");
if (isRateLimit && attempt < maxRetries - 1) {
const wait = Math.pow(2, attempt) * 1000; // 1s, 2s, 4s
console.warn(`Rate limit. ${wait}ms 후 재시도...`);
await new Promise((r) => setTimeout(r, wait));
} else {
console.error(`임베딩 실패 (시도 ${attempt + 1})`, err);
return null; // null 반환 → 이 항목만 스킵, 배치 계속
}
}
}
return null;
}
async function embedAndUpsertBatch(memos: Memo[]) {
const results = { success: 0, skipped: 0, failed: 0 };
for (const memo of memos) {
const vec = await embedWithRetry(buildEmbeddingInput(memo));
if (vec === null) { results.failed++; continue; } // ← 실패해도 계속
await upsertMemoEmbedding(memo.id, vec, memo.fingerprint);
results.success++;
}
console.log("배치 완료:", results);
return results;
}⚠️ 직렬 upsert — DB 라운드트립 과다
성능현재 for 루프에서 await upsertMemoEmbedding을 하나씩 기다립니다. 20개 메모라면 DB 라운드트립 20회. Promise.all 병렬 처리로 대폭 단축 가능합니다.
// ✅ 개선: Promise.all 병렬 upsert (동시 실행 상한 포함)
async function upsertBatchParallel(
items: Array<{ id: string; vec: number[]; fp: string }>,
concurrency = 5 // 동시 실행 상한 — DB 과부하 방지
) {
for (let i = 0; i < items.length; i += concurrency) {
const chunk = items.slice(i, i + concurrency);
await Promise.all(
chunk.map(({ id, vec, fp }) =>
upsertMemoEmbedding(id, vec, fp)
)
);
}
}
// 사용 예
const upsertItems = embeddedMemos
.filter((m) => m.vec !== null)
.map((m) => ({ id: m.id, vec: m.vec!, fp: m.fingerprint }));
await upsertBatchParallel(upsertItems, 5);⚠️ 검색마다 불필요한 COUNT(*) 쿼리
최적화searchMemosSemantic에서 매번 SELECT COUNT(*) FROM memo_embedding을 실행합니다. 검색이 빈번한 프로덕션에서는 불필요한 DB 부하입니다. 카운트가 필요하다면 캐싱하거나, 실제로 필요한지 재검토하세요.
// ❌ 현재: 검색마다 COUNT 실행
const total = await db.execute("SELECT COUNT(*) FROM memo_embedding");
// ... 그 다음 실제 검색
// ✅ 개선 A: COUNT 제거 — 실제로 필요한 경우만 별도 엔드포인트로
// (대부분의 RAG 검색에서 총 카운트는 불필요)
// ✅ 개선 B: 필요한 경우 인메모리 캐시 (60초 TTL)
let _countCache: { value: number; at: number } | null = null;
async function getCachedCount(): Promise<number> {
const now = Date.now();
if (_countCache && now - _countCache.at < 60_000) {
return _countCache.value;
}
const row = await db.execute("SELECT COUNT(*) as n FROM memo_embedding");
const value = Number(row.rows[0].n);
_countCache = { value, at: now };
return value;
}⚠️ 삭제 메모로 인한 실제 반환 건수 부족
데이터 정합성현재 vector_top_k로 k건을 먼저 뽑은 뒤 JOIN + WHERE로 삭제 메모를 걸러냅니다. 삭제된 메모가 많으면 top_k=5인데 실제로 2~3건만 반환될 수 있습니다. 두 가지 해결 방식이 있습니다.
-- ✅ 해결 A: 삭제 시 memo_embedding에서도 즉시 DELETE -- (소프트 딜리트 대신 임베딩 행만 하드 딜리트) DELETE FROM memo_embedding WHERE memo_id = :id; -- ✅ 해결 B: 초과 fetch 후 슬라이스 (삭제 비율이 낮을 때) -- top_k * 2 를 뽑아서, 필터 후 k건만 사용 SELECT m.id, m.title, m.content, vector_distance_cos(e.embedding, vector32(:qvec)) AS dist FROM memo_embedding e JOIN memo m ON m.id = e.memo_id WHERE m.deleted_at IS NULL AND e.model = :model ORDER BY dist LIMIT :k_times_2; -- ← k 대신 k*2 fetch, TypeScript에서 slice(0, k) -- ✅ 해결 C: 벡터 DB에 model 컬럼 필터 추가 (혼재 방지) -- 다른 모델로 생성된 벡터가 섞이면 유사도 계산이 무의미 WHERE e.model = 'text-embedding-3-small'
⚠️ 모델 교체 시 혼재 벡터 위험
데이터 정합성text-embedding-3-small(1536차원)과 Qwen3-8B(4096차원) 벡터가 같은 테이블에 섞이면 코사인 유사도 계산이 무의미해집니다. 검색 결과가 완전히 깨집니다.
// ✅ 개선: 검색 전 모델 혼재 여부 검증
async function assertNoModelMismatch(currentModel: string) {
const row = await db.execute(
"SELECT COUNT(*) as n FROM memo_embedding WHERE model != ?",
[currentModel]
);
const mismatch = Number(row.rows[0].n);
if (mismatch > 0) {
throw new Error(
`모델 불일치: ${mismatch}건의 벡터가 ${currentModel}이 아닌 다른 모델로 생성됨. `
+ `전체 재임베딩(run-sync full 모드) 후 검색하세요.`
);
}
}
// 검색 전 호출
await assertNoModelMismatch(EMBEDDING_MODEL);개선 전/후 비교 요약
| 항목 | 현재 (개선 전) | 개선 후 | 효과 |
|---|---|---|---|
| API 실패 처리 | 배치 전체 중단 | 실패 항목만 스킵, 재시도 | 안정성 대폭 향상 |
| DB upsert 방식 | 직렬 1건씩 await | Promise.all 병렬 (상한 5) | 라운드트립 ~80% 감소 |
| COUNT 쿼리 | 검색마다 실행 | 캐시(60s TTL) 또는 제거 | DB 부하 감소 |
| 삭제 메모 처리 | top_k 후 JOIN 필터 | 삭제 시 즉시 DELETE 또는 top_k*2 | 반환 건수 보장 |
| 모델 혼재 방지 | 검증 없음 | 검색 전 model 컬럼 검증 | 검색 품질 보장 |
| fingerprint 증분 | ✅ 이미 구현 | ✅ 유지 | API 비용 최소화 |
| 5가지 동기화 모드 | ✅ 이미 구현 | ✅ 유지 | 운영 유연성 확보 |
| 멱등성 (ON CONFLICT) | ✅ 이미 구현 | ✅ 유지 | 재시도 안전 |
📝 설계 평가 요약
✅ 이미 탄탄한 것들
- •관심사 분리 — 6개 파일, 6개 역할
- •fingerprint 증분 동기화 — 비용 절감
- •5가지 동기화 모드 — 운영 유연성
- •ON CONFLICT 멱등성 — 재시도 안전
- •정리본 우선 텍스트 전략 — 품질 설계
🔧 보강하면 프로덕션 완성
- •부분 실패 허용 + 지수 백오프 재시도
- •Promise.all 병렬 upsert (상한 5)
- •COUNT(*) 캐싱 또는 제거
- •삭제 메모 즉시 DELETE 또는 top_k*2
- •검색 전 model 컬럼 혼재 검증
핵심 흐름(텍스트 → 임베딩 → 저장 → 검색 → RAG 조립)이 깔끔하게 연결되어 있고, fingerprint 기반 증분 동기화까지 갖추고 있어 실용적입니다. 위 5가지 개선을 추가하면 트래픽이 늘어도 안정적인 프로덕션 수준이 됩니다.
FAQ — 자주 묻는 질문
RAG & 벡터 DB 구축 시 가장 많이 묻는 질문 36가지를 모았습니다.
Q1.RAG를 도입하면 기존 ChatGPT/Claude보다 확실히 더 낫나요?
특정 도메인에서는 압도적으로 낫습니다. 일반 ChatGPT는 내 YouTube 채널 데이터를 모르지만, RAG는 내 영상 300개·댓글 3,000개를 기반으로 정확하게 답합니다. 단, 일반 지식 질문에는 일반 ChatGPT가 여전히 유리합니다. 목적에 따라 조합해서 사용하세요.
Q2.쿼리 벡터를 따로 만들어야 한다는 게 무슨 뜻인가요?
DB에 저장된 벡터들은 문서 내용을 숫자로 바꿔놓은 것입니다. 사용자가 "지난주 회의 내용"이라고 검색하면, 이 검색어도 숫자로 바꿔야 저장된 벡터들과 거리를 비교할 수 있습니다. 그게 쿼리 벡터입니다. 임베딩 모델을 두 번 씁니다 — 저장할 때 1번, 검색할 때 1번. 단, 반드시 같은 모델을 써야 합니다.
Q3.embedding 컬럼의 바이너리 데이터는 사람이 읽을 수 없나요?
원본 텍스트는 content 컬럼에 그대로 저장되어 언제든 읽고 수정할 수 있습니다. embedding 컬럼만 바이너리(이진 데이터)입니다. DB 뷰어(TablePlus, DBeaver 등)로 열면 content는 한글로 보이고, embedding은 이상한 문자로 보입니다. 이는 정상입니다.
Q4.Gemini API로 전부 무료로 할 수 있나요?
임베딩은 Gemini API 무료 티어(text-embedding-004, 1500 req/일)로 가능합니다. 답변 생성도 Gemini 1.5 Flash 무료 티어로 가능합니다. 단, 무료 티어는 분당 요청 수 제한이 있습니다. 상업적 사용 시 유료 플랜을 권장합니다.
Q5.RAG 데이터를 한 번 구축하면, 어떤 앱이나 웹에서도 쓸 수 있나요?
맞습니다. Turso DB에 저장된 벡터는 숫자 배열일 뿐이라서 접근 권한이 있는 어떤 클라이언트든 가져다 쓸 수 있습니다. 웹앱, 모바일 앱, 슬랙 봇, Obsidian 플러그인 등 어디서든 같은 DB를 호출하면 됩니다. 단, 검색 쿼리도 같은 임베딩 모델로 변환해야 합니다.
Q6.NotebookLM이 있는데 왜 직접 RAG를 구축해야 하나요?
NotebookLM은 용량 제한·자동화 불가·외부 연동 불가 등의 한계가 있습니다. 직접 구축하면 데이터 용량 제한 없음, 자동화(새 영상 업로드 시 자동 임베딩), 내 말투 학습, 다른 서비스와 API 연동, 맞춤 프롬프트 설계 등이 가능합니다.
Q7.chunk_size 500이 정답인가요? 더 크거나 작으면 안 되나요?
이 칼럼 예제의 RecursiveCharacterTextSplitter는 기본적으로 문자(character) 수 기준입니다. 500은 그런 의미의 시작점이고, 토큰으로 나누려면 tiktoken 등 별도 설정이 필요합니다. 데이터 유형에 따라 조정하세요. 짧은 Q&A·댓글: 100~300자, 대본·문서: 500~700자 등. 오버랩은 청크 크기의 약 10%를 많이 씁니다. 실제로 테스트하며 검색 품질을 보는 것이 가장 정확합니다.
Q8.오버랩(overlap)을 0으로 설정하면 어떤 문제가 생기나요?
청크 경계에서 문장이 잘리면 앞뒤 맥락이 완전히 단절됩니다. 예를 들어 "구독자 1000명 이상이면 수익화가 가능합니다"라는 문장이 두 청크로 쪼개지면 각각의 벡터가 불완전한 의미를 담습니다. 오버랩 10~20%를 설정하면 경계 문장이 양쪽 청크에 중복 포함되어 맥락 단절을 방지합니다.
Q9.전처리에서 이모지를 제거하는 이유가 뭔가요? 감정 분석에 필요하지 않나요?
임베딩 모델은 이모지를 유니코드 코드포인트로 처리해 의미 없는 벡터 차원을 낭비합니다. "👍👍👍 좋아요"보다 "좋아요"가 훨씬 깔끔한 벡터를 만듭니다. 감정 분석이 목적이라면 이모지를 텍스트로 변환(예: 👍→"좋아요")하는 전략도 가능하지만, RAG 검색 품질 측면에서는 제거가 권장됩니다.
Q10.임베딩 모델을 중간에 바꾸면 어떻게 되나요?
기존에 저장된 벡터와 새 모델의 벡터는 서로 다른 공간에 있어 거리 비교가 의미 없어집니다. 모델을 바꾸면 전체 데이터를 새 모델로 재임베딩해야 합니다. 이 때문에 constants.ts에 모델명을 상수로 관리하고, DB에 model 컬럼을 저장하는 설계가 중요합니다. 재임베딩 비용은 크지 않으므로 초기에 좋은 모델을 선택하는 게 좋습니다.
Q11.text-embedding-3-small과 text-embedding-3-large 중 어떤 걸 써야 하나요?
대부분의 개인 RAG 프로젝트에는 small(1536차원, $0.02/1M 토큰)이 충분합니다. large(3072차원, $0.13/1M 토큰)는 고정밀 도메인(법률·의료)에서 약 2~5% 정확도 향상이 있지만 비용이 6.5배입니다. 먼저 small로 구축하고, 검색 품질이 부족하다고 느껴질 때 large로 교체·재임베딩하는 전략이 비용 효율적입니다.
Q12.Gemini의 task_type을 왜 저장과 검색에서 다르게 써야 하나요?
Gemini 임베딩 모델은 task_type에 따라 벡터 공간을 내부적으로 다르게 최적화합니다. retrieval_document는 문서의 핵심 내용을 잘 인코딩하도록, retrieval_query는 질문 의도를 잘 잡아내도록 최적화됩니다. 둘 다 같은 task_type을 쓰면 검색 정확도가 10~20% 하락할 수 있습니다. OpenAI 모델은 이 구분이 없어 하나의 함수로 처리합니다.
Q13.OpenRouter로 여러 임베딩 모델을 한 API로 쓸 수 있다던데 맞나요?
맞습니다. OpenRouter는 OpenAI 호환 API를 제공해서 base_url만 바꾸면 Qwen3-8B, text-embedding-3-small, Codestral Embed 등 다양한 모델을 동일한 코드로 호출할 수 있습니다. 단, 모델마다 벡터 차원이 다르므로(예: Qwen3-8B은 4096, text-3-small은 1536) DB에 저장할 때 차원을 반드시 일치시켜야 합니다.
Q14.Chroma와 Turso 중 어떤 걸 써야 하나요?
Chroma는 로컬 개발·프로토타이핑에 최적입니다. pip install 후 3줄 코드로 바로 사용 가능하고 서버가 필요 없습니다. Turso는 Vercel·Next.js 스택과 통합할 때 최적입니다. SQL + 벡터 검색을 한 DB에서 처리하며 클라우드 기반이라 배포가 간편합니다. 일반적으로 로컬에서 Chroma로 테스트 → 배포 시 Turso나 Qdrant Cloud로 마이그레이션하는 전략을 권장합니다.
Q15.vector32()에 벡터를 넣을 때 왜 JSON 문자열로 변환해야 하나요?
Turso의 vector32() 함수는 SQL 문자열 파라미터로 JSON 배열 형태를 받도록 설계되어 있습니다. Python 리스트를 그대로 넣으면 SQL 파서가 이해하지 못합니다. json.dumps(vector_list)로 변환하면 "[0.12, -0.34, 0.56]" 같은 문자열이 되고, 이것이 vector32()의 올바른 입력 형식입니다.
Q16.fingerprint(SHA-256 해시)로 비용을 아끼는 원리가 뭔가요?
content 텍스트의 SHA-256 해시를 DB에 저장해둡니다. 다음에 같은 데이터를 동기화할 때 새로 계산한 해시와 기존 해시를 비교합니다. 같으면 내용이 변경되지 않은 것이므로 임베딩 API를 호출하지 않고 건너뜁니다. 300개 메모 중 1개만 수정됐을 때 1건만 재임베딩하므로 API 비용을 99% 이상 절약할 수 있습니다.
Q17.벡터 DB에 저장된 데이터를 백업하거나 내보낼 수 있나요?
가능합니다. Chroma는 ./chroma_db 폴더 자체를 복사하면 됩니다. Turso는 turso db shell로 SQL 덤프를 뜰 수 있고, Qdrant는 snapshots API를 제공합니다. 원본 텍스트(content)는 항상 사람이 읽을 수 있는 형태로 저장되어 있으므로 벡터 DB가 없어져도 원본 데이터는 복구 가능합니다. 벡터만 재생성(재임베딩)하면 됩니다.
Q18.Vercel에서 Python(Streamlit)을 사용할 수 없나요?
Vercel은 JavaScript/TypeScript(Node.js) 기반 서버리스만 지원합니다. Python Streamlit 앱은 Vercel에 배포할 수 없습니다. Python 앱 배포는 Streamlit Cloud(무료), Railway, Render(무료/유료) 등을 사용하세요. 요금은 호스팅사 공식 사이트에서 확인하세요. 기존 Next.js 스택에서 RAG를 쓰려면 LangChain.js, Vercel AI SDK 등을 활용합니다.
Q19.Streamlit Cloud 무료 플랜의 15분 슬립이 문제가 되나요?
개인용이나 데모용이라면 큰 문제는 아닙니다. 15분간 접속이 없으면 앱이 슬립 상태로 전환되고, 다음 접속 시 30~60초간 콜드 스타트가 발생합니다. 항상 켜져 있어야 하는 서비스라면 Railway($5/월)나 Render($7/월~)에 Docker로 배포하거나, Vercel + Turso 스택으로 전환하세요.
Q20.Qdrant Cloud 무료 플랜 1GB로 얼마나 저장할 수 있나요?
1536차원 벡터 기준 약 10만~15만 개 청크를 저장할 수 있습니다. 유튜브 채널 300개 영상 + 댓글 3,000개의 전체 청크(약 6,000개)는 무료 1GB의 약 5~10%만 사용합니다. 개인 유튜버 수준에서는 매우 여유롭습니다. 신용카드 등록도 필요 없습니다.
Q21.타임스탬프 RAG를 하려면 SRT 파일이 꼭 필요한가요?
SRT 파일이 가장 쉬운 방법입니다. SRT가 없으면 Whisper AI(OpenAI)로 영상 음성에서 자동으로 자막을 생성할 수도 있습니다. yt-dlp 도구의 --write-subs 옵션으로 YouTube 자막을 자동 다운로드할 수도 있습니다. YouTube Studio에서 직접 다운로드도 가능합니다.
Q22.YouTube 자동 생성 자막과 수동 업로드 자막 중 어떤 게 RAG에 더 좋나요?
수동 업로드 자막이 정확도가 높아 RAG 품질도 더 좋습니다. 자동 생성 자막(ASR)은 전문 용어·고유명사·한국어 받침 등에서 오류가 많습니다. 다만 자막이 아예 없는 것보다는 자동 자막이라도 있는 게 훨씬 낫습니다. 자동 자막 사용 시 전처리 단계에서 명백한 오류를 정규식으로 보정하는 것을 권장합니다.
Q23.전체 RAG 파이프라인을 구축하는 데 비용이 얼마나 드나요?
완전 무료 조합이 가능합니다. Gemini 임베딩 무료 + Gemini 2.5 Flash 무료 + Turso 무료 = 월 $0. 권장 조합(text-embedding-3-small + GPT-4o-mini + Turso)은 최초 임베딩 약 $0.05~0.12, 이후 질문당 약 $0.003~0.01입니다. 월 100회 질문 기준 약 $0.3~1 수준입니다.
Q24.LLM 답변 생성 비용이 임베딩보다 비싼 이유가 뭔가요?
임베딩은 최초 1회(+증분 업데이트)만 발생하지만, LLM 답변 생성은 질문마다 매번 호출됩니다. 또한 LLM은 수천억 파라미터 모델을 autoregressive로 토큰을 하나씩 생성하는 연산이라 임베딩(1회 forward pass)보다 연산량이 100배 이상 큽니다. 이 때문에 전체 비용의 70~80%가 LLM 비용입니다. GPT-4o-mini나 Gemini Flash 같은 경량 모델을 선택하면 비용을 크게 줄일 수 있습니다.
Q25.RAG 품질·할루시네이션을 어떻게 평가하나요?
운영에서는 검색 적중률, 답변이 근거 청크에 얼마나 기대는지(faithfulness), 질문에 답했는지(answer relevance) 등을 지표로 둡니다. RAGAS 같은 프레임워크로 샘플 질문 세트에 대해 자동 점수를 낼 수 있고, 소규모라도 사람이 근거 링크와 답변을 함께 검토하는 것이 중요합니다.
Q26.검색 결과가 관련 없는 내용만 나올 때는 어떻게 해야 하나요?
세 가지를 점검하세요. 첫째, 청크 크기가 너무 크거나 작지 않은지 확인합니다(500자 전후 권장). 둘째, 전처리가 제대로 됐는지 확인합니다 — URL·이모지·해시태그 등 노이즈가 벡터 품질을 떨어뜨립니다. 셋째, 유사도 임계값을 설정해 코사인 거리가 0.3 이상(관련 없는 결과)은 필터링하세요. 그래도 안 되면 top_k를 늘리거나 하이브리드 검색(벡터+BM25)을 시도합니다.
Q27.RAG 답변에서 "데이터에서 찾을 수 없습니다"라고 하면 어떻게 개선하나요?
이 응답은 검색된 청크가 없거나 유사도가 너무 낮을 때 시스템 프롬프트에 의해 발생합니다. 원인은 대부분 두 가지입니다. 첫째, 해당 주제의 데이터가 실제로 DB에 없는 경우 — 관련 콘텐츠를 추가로 임베딩해야 합니다. 둘째, 질문 표현이 DB 내용과 의미적으로 너무 다른 경우 — HyDE(가상 문서 임베딩) 기법으로 질문을 먼저 가상 답변으로 확장한 뒤 검색하면 적중률이 크게 향상됩니다.
Q28.하이브리드 검색(벡터 + 키워드)은 언제 필요한가요?
고유명사(사람 이름, 제품명, 코드 식별자)나 정확한 숫자·날짜를 검색할 때 필요합니다. 벡터 검색은 의미 유사도에 강하지만 "MKBHD"나 "2024년 1월" 같은 정확한 문자열 매칭에는 약합니다. BM25 키워드 검색과 벡터 검색을 가중합(예: 벡터 70% + BM25 30%)으로 결합하면 두 장점을 모두 취할 수 있습니다. Qdrant·Weaviate는 하이브리드를 내장 지원하고, Chroma는 LangChain EnsembleRetriever로 구현합니다.
Q29.Reranker란 무엇이고 꼭 써야 하나요?
Reranker는 벡터 검색으로 1차 후보(예: 20개)를 뽑은 뒤, Cross-Encoder 모델로 질문-청크 쌍을 정밀 평가해 순위를 재조정하는 2단계 검색 기법입니다. 검색 정확도가 5~15% 향상되지만 추가 API 비용과 지연 시간이 발생합니다. 개인 RAG에서는 선택 사항이지만, 프로덕션 서비스에서 답변 품질이 중요하다면 도입을 권장합니다. Cohere Rerank나 Jina Reranker가 대표적입니다.
Q30.RAG와 파인튜닝을 같이 쓸 수 있나요?
가능하고, 실제로 프로덕션에서 많이 조합합니다. RAG는 지식 검색 담당, 파인튜닝은 문체·스타일·도메인 언어 내재화 담당으로 역할을 나눕니다. 예를 들어 채널 데이터는 RAG로 검색하고, 내 특유의 말투와 설명 패턴은 파인튜닝된 모델이 담당합니다. 다만 파인튜닝은 비용($100~$10,000+)과 시간이 크므로 먼저 RAG + 프롬프트 엔지니어링만으로 충분한지 평가하세요.
Q31.멀티모달 RAG(이미지+텍스트)도 가능한가요?
가능합니다. 썸네일 이미지를 CLIP이나 Gemini Embedding 2 같은 멀티모달 임베딩 모델로 벡터화하면 텍스트-이미지 교차 검색이 됩니다. "밝은 색 썸네일"이라고 텍스트로 검색하면 해당 조건의 썸네일 이미지가 반환됩니다. OpenRouter의 NVIDIA Nemotron Embed VL 1B V2는 무료로 멀티모달 임베딩을 제공합니다. 단, 이 칼럼은 텍스트 RAG에 집중하며 멀티모달은 별도 심화 주제입니다.
Q32.임베딩·LLM API 키를 브라우저(프론트엔드) 코드에 넣어도 되나요?
안 됩니다. 번들에 포함된 JavaScript는 사용자에게 그대로 노출되므로 키가 유출되고 악용될 수 있습니다. 호출은 Next.js API Route, Edge Function, Streamlit 서버 등 서버 측에서만 하고, 클라이언트는 인증된 요청만 보내도록 설계하세요. 환경 변수는 서버에서만 읽고, Turso 등 DB URL·토큰도 노출되지 않게 관리합니다.
🚀 RAG 구축의 핵심 메시지
RAG = 의미로 인덱싱된 DB + LLM이 그 결과를 읽고 답변
어떤 데이터든 임베딩으로 벡터화해서 DB에 저장하면, 그 DB는 "의미 기반 검색"이 가능한
인덱싱된 지식 창고가 됩니다. 나머지는 전부 구현 디테일입니다.