데이터베이스 (Databases)¶
데이터를 체계적으로 저장, 관리, 검색하기 위한 시스템. LLM/VLM에서는 벡터 DB, 임베딩 저장, RAG 파이프라인 구축에 핵심적.
왜 데이터베이스를 알아야 하는가¶
- 데이터 저장: 효율적인 저장과 검색 전략
- 성능 최적화: 인덱스, 쿼리 튜닝, 정규화/비정규화
- 시스템 설계: RAG 파이프라인, 벡터 검색 아키텍처
- 트러블슈팅: 느린 쿼리, 데드락, 연결 풀 문제
RDBMS (관계형 데이터베이스)¶
핵심 개념¶
테이블 구조:
documents 테이블
+----+------------------+------------+------------+
| id | content | created_at | updated_at |
+----+------------------+------------+------------+
| 1 | "LLM이란..." | 2024-01-01 | 2024-01-02 |
| 2 | "Transformer..." | 2024-01-02 | 2024-01-02 |
+----+------------------+------------+------------+
Primary Key: id (유일성 보장)
관계 유형:
1:1 (One-to-One)
User ----- Profile
1:N (One-to-Many)
User ----- Posts
N:M (Many-to-Many)
Students ----- Courses
\ /
Enrollments (중간 테이블)
정규화 (Normalization)¶
중복을 제거하고 데이터 무결성을 보장하기 위한 설계 기법.
| 정규형 | 조건 | 목적 |
|---|---|---|
| 1NF | 원자값만 포함 (반복 그룹 제거) | 기본 구조화 |
| 2NF | 1NF + 부분 함수 종속 제거 | 삽입/삭제 이상 방지 |
| 3NF | 2NF + 이행 함수 종속 제거 | 갱신 이상 방지 |
| BCNF | 모든 결정자가 후보키 | 완전한 정규화 |
정규화 예시:
비정규화 (1NF 위반):
+----+------+----------------+
| id | name | phone_numbers |
+----+------+----------------+
| 1 | 김철수| 010-1234, 010-5678|
+----+------+----------------+
1NF:
+----+------+-----------+
| id | name | phone |
+----+------+-----------+
| 1 | 김철수| 010-1234 |
| 1 | 김철수| 010-5678 |
+----+------+-----------+
2NF (부분 종속 제거):
Users 테이블 + Phones 테이블로 분리
비정규화 (Denormalization):
언제 필요한가?
- 읽기 성능이 중요할 때
- 조인 비용이 클 때
- 캐싱과 함께 사용 시
LLM 문서 저장 예시:
CREATE TABLE document_chunks (
id SERIAL PRIMARY KEY,
document_id INT,
document_title VARCHAR(255), -- 중복 저장 (조인 회피)
chunk_text TEXT,
chunk_index INT,
embedding_id INT
);
SQL 기초¶
-- 기본 CRUD
INSERT INTO documents (content) VALUES ('LLM 학습 자료');
SELECT * FROM documents WHERE id = 1;
UPDATE documents SET content = '수정된 내용' WHERE id = 1;
DELETE FROM documents WHERE id = 1;
-- 조인 (JOIN)
SELECT d.title, c.chunk_text
FROM documents d
INNER JOIN chunks c ON d.id = c.document_id
WHERE d.category = 'ML';
-- LEFT JOIN: 왼쪽 테이블 전체 + 매칭되는 오른쪽
-- RIGHT JOIN: 오른쪽 테이블 전체 + 매칭되는 왼쪽
-- FULL OUTER JOIN: 양쪽 전체
-- 집계 (Aggregation)
SELECT
category,
COUNT(*) as count,
AVG(LENGTH(content)) as avg_len
FROM documents
GROUP BY category
HAVING COUNT(*) > 10
ORDER BY count DESC;
-- 서브쿼리
SELECT * FROM documents
WHERE author_id IN (
SELECT id FROM users WHERE role = 'admin'
);
-- 윈도우 함수
SELECT
id,
content,
ROW_NUMBER() OVER (PARTITION BY category ORDER BY created_at DESC) as rank
FROM documents;
-- CTE (Common Table Expression)
WITH recent_docs AS (
SELECT * FROM documents
WHERE created_at > NOW() - INTERVAL '7 days'
)
SELECT * FROM recent_docs WHERE category = 'ML';
인덱스 (Index)¶
데이터 검색 속도를 높이기 위한 자료구조.
인덱스 종류:
| 종류 | 설명 | 사용 사례 |
|---|---|---|
| B-Tree | 기본, 범위 검색 | 대부분의 컬럼 |
| Hash | 동등 비교만 | 정확한 매칭 |
| GiST | 공간, 전문 검색 | PostGIS, 텍스트 |
| GIN | 역인덱스 | 배열, JSONB, 전문 검색 |
인덱스 생성:
-- B-Tree 인덱스 (기본)
CREATE INDEX idx_documents_category ON documents(category);
-- 복합 인덱스 (순서 중요!)
CREATE INDEX idx_docs_cat_date ON documents(category, created_at DESC);
-- category = ? AND created_at < ? 에 효과적
-- created_at < ? 만으로는 비효율
-- 부분 인덱스 (특정 조건만)
CREATE INDEX idx_active_docs ON documents(created_at)
WHERE is_active = true;
-- 커버링 인덱스 (INCLUDE)
CREATE INDEX idx_covering ON documents(category)
INCLUDE (title, summary);
-- SELECT title, summary FROM documents WHERE category = ?
-- 테이블 접근 없이 인덱스만으로 응답
-- UNIQUE 인덱스
CREATE UNIQUE INDEX idx_unique_email ON users(email);
인덱스 선택 기준:
인덱스 추가해야 할 때:
- WHERE 절에 자주 사용
- JOIN 조건
- ORDER BY 대상
- 카디널리티가 높은 컬럼 (unique한 값이 많은)
인덱스 피해야 할 때:
- 작은 테이블 (Full Scan이 빠름)
- 자주 UPDATE/INSERT 되는 컬럼
- 카디널리티가 낮은 컬럼 (성별 등)
실행 계획 분석:
-- 쿼리 실행 계획 확인
EXPLAIN ANALYZE SELECT * FROM documents WHERE category = 'ML';
-- 출력 예시:
-- Index Scan using idx_documents_category on documents
-- Index Cond: (category = 'ML')
-- Rows Removed by Filter: 0
-- Actual Time: 0.015..0.020
트랜잭션과 ACID¶
| 속성 | 설명 | 예시 |
|---|---|---|
| Atomicity (원자성) | 전부 성공 또는 전부 실패 | 송금: 출금+입금 |
| Consistency (일관성) | 트랜잭션 전후 제약 조건 유지 | FK, UNIQUE 제약 |
| Isolation (격리성) | 동시 트랜잭션 간 간섭 방지 | 격리 수준 |
| Durability (지속성) | 커밋된 데이터 영구 저장 | 장애 복구 |
트랜잭션 예시:
BEGIN;
-- 임베딩 저장
INSERT INTO embeddings (document_id, vector)
VALUES (1, '[0.1, 0.2, ...]');
-- 문서 상태 업데이트
UPDATE documents SET has_embedding = true WHERE id = 1;
-- 오류 없으면 커밋
COMMIT;
-- 오류 발생 시
ROLLBACK;
격리 수준 (Isolation Level):
| 수준 | Dirty Read | Non-repeatable Read | Phantom Read |
|---|---|---|---|
| Read Uncommitted | O | O | O |
| Read Committed | X | O | O |
| Repeatable Read | X | X | O |
| Serializable | X | X | X |
Dirty Read: 커밋 안 된 데이터 읽음
Non-repeatable Read: 같은 쿼리, 다른 결과 (UPDATE)
Phantom Read: 같은 쿼리, 새로운 행 (INSERT)
실무:
- PostgreSQL 기본: Read Committed
- MySQL InnoDB 기본: Repeatable Read
락 (Lock)¶
락 종류:
1. 공유 락 (Shared Lock, S Lock)
- 읽기 락
- 여러 트랜잭션이 동시에 획득 가능
2. 배타 락 (Exclusive Lock, X Lock)
- 쓰기 락
- 하나의 트랜잭션만 획득 가능
3. 행 락 (Row Lock)
- 특정 행만 잠금
4. 테이블 락 (Table Lock)
- 테이블 전체 잠금
데드락 예방:
-- 데드락 발생 예시
-- 트랜잭션 1: row A 락 -> row B 락 시도
-- 트랜잭션 2: row B 락 -> row A 락 시도
-- 해결: 항상 같은 순서로 락 획득
-- 해결: SELECT FOR UPDATE NOWAIT (대기 안함)
-- 해결: 타임아웃 설정
NoSQL 데이터베이스¶
유형 비교¶
| 유형 | 특징 | 예시 | 사용 사례 |
|---|---|---|---|
| Key-Value | 단순, 빠름 | Redis, DynamoDB | 캐싱, 세션 |
| Document | 유연한 스키마 | MongoDB | 문서 저장, CMS |
| Column-Family | 대용량 쓰기 | Cassandra, HBase | 로그, 시계열 |
| Graph | 관계 표현 | Neo4j | 지식 그래프, 소셜 |
CAP 이론:
분산 시스템에서 동시에 2가지만 보장 가능:
- Consistency (일관성): 모든 노드가 같은 데이터
- Availability (가용성): 항상 응답
- Partition Tolerance (분단 내성): 네트워크 분할에도 동작
CP: MongoDB, Redis Cluster
AP: Cassandra, DynamoDB
CA: 단일 노드 RDBMS (분산 아님)
Redis¶
import redis
import json
r = redis.Redis(host='localhost', port=6379, db=0)
# 기본 연산
r.set('key', 'value')
r.get('key')
r.delete('key')
# TTL 설정 (캐시)
r.setex('cache:response:hash123', 3600, 'cached response')
# 해시 (구조화된 데이터)
r.hset('session:1', mapping={
'user_id': '123',
'context': 'previous conversation',
'token_count': 500
})
r.hgetall('session:1')
# 리스트 (대화 히스토리)
r.lpush('history:user123', json.dumps(message))
r.lrange('history:user123', 0, -1) # 전체 조회
r.ltrim('history:user123', 0, 99) # 최근 100개만 유지
# Sorted Set (순위)
r.zadd('leaderboard', {'player1': 100, 'player2': 85})
r.zrevrange('leaderboard', 0, 9, withscores=True) # Top 10
# Pub/Sub
pubsub = r.pubsub()
pubsub.subscribe('stream:user123')
for message in pubsub.listen():
print(message)
LLM 캐싱 패턴:
import hashlib
import json
def get_cached_response(prompt, model, params):
"""LLM 응답 캐싱"""
# 캐시 키 생성 (입력의 해시)
cache_key = hashlib.sha256(
json.dumps({
'prompt': prompt,
'model': model,
'params': params
}, sort_keys=True).encode()
).hexdigest()
# 캐시 확인
cached = r.get(f'llm:cache:{cache_key}')
if cached:
return json.loads(cached)
# 캐시 미스: 모델 호출
response = call_llm(prompt, model, params)
# 캐시 저장 (1시간)
r.setex(f'llm:cache:{cache_key}', 3600, json.dumps(response))
return response
MongoDB¶
from pymongo import MongoClient
from datetime import datetime
client = MongoClient('mongodb://localhost:27017/')
db = client['llm_db']
collection = db['documents']
# 삽입
doc = {
'title': 'LLM 개요',
'content': 'Large Language Model은...',
'metadata': {
'author': 'user1',
'tags': ['AI', 'NLP'],
'created_at': datetime.now()
},
'chunks': [
{'index': 0, 'text': 'chunk1', 'embedding_id': 'emb1'},
{'index': 1, 'text': 'chunk2', 'embedding_id': 'emb2'}
]
}
result = collection.insert_one(doc)
# 조회
docs = collection.find({
'metadata.tags': {'$in': ['AI', 'ML']},
'metadata.created_at': {'$gte': datetime(2024, 1, 1)}
}).sort('metadata.created_at', -1).limit(10)
# 업데이트
collection.update_one(
{'_id': doc_id},
{'$set': {'metadata.updated_at': datetime.now()},
'$push': {'metadata.tags': 'updated'}}
)
# 집계 파이프라인
pipeline = [
{'$match': {'metadata.tags': 'AI'}},
{'$unwind': '$chunks'},
{'$group': {
'_id': '$metadata.author',
'total_chunks': {'$sum': 1},
'avg_chunk_len': {'$avg': {'$strLenCP': '$chunks.text'}}
}}
]
results = collection.aggregate(pipeline)
# 인덱스
collection.create_index([('metadata.tags', 1)])
collection.create_index([('content', 'text')]) # 텍스트 검색
벡터 데이터베이스¶
LLM의 RAG(Retrieval-Augmented Generation) 파이프라인의 핵심.
벡터 DB 비교¶
| DB | 특징 | 인덱스 | 규모 | 용도 |
|---|---|---|---|---|
| pgvector | PostgreSQL 확장 | IVFFlat, HNSW | 수백만 | 기존 PG 사용 시 |
| Pinecone | 관리형, 쉬운 사용 | 자동 최적화 | 수십억 | 서버리스, 빠른 개발 |
| Milvus | 오픈소스, 고성능 | IVF, HNSW | 수십억 | 대규모, 온프레미스 |
| Qdrant | Rust 기반 | HNSW | 수억 | 프로덕션, 필터링 |
| Weaviate | 하이브리드 검색 | HNSW | 수억 | 벡터+키워드 |
| ChromaDB | 경량 | HNSW | 수십만 | 프로토타입, 개발 |
벡터 인덱스 알고리즘¶
IVF (Inverted File Index):
1. 학습: 벡터들을 k개 클러스터로 분할 (k-means)
2. 삽입: 각 벡터를 가장 가까운 클러스터에 할당
3. 검색: 쿼리와 가까운 nprobe개 클러스터만 탐색
장점: 메모리 효율적, 대용량 적합
단점: 클러스터 경계에서 정확도 저하
파라미터:
- nlist: 클러스터 수 (sqrt(n) 권장)
- nprobe: 탐색 클러스터 수 (정확도-속도 트레이드오프)
HNSW (Hierarchical Navigable Small World):
다층 그래프 구조, 높은 레이어에서 대략적 탐색 후 하위로 이동
Layer 2: A -------- B
| |
Layer 1: A -- C --- B -- D
| | | |
Layer 0: A-E--C-F---B-G--D-H
검색: 상위 레이어에서 시작, 점점 하위로
장점: 높은 정확도, 빠른 검색
단점: 메모리 사용량 높음, 구축 느림
파라미터:
- M: 각 노드의 연결 수 (16 권장)
- ef_construction: 구축 시 탐색 너비 (정확도-속도)
- ef_search: 검색 시 탐색 너비
pgvector 사용¶
-- 확장 설치
CREATE EXTENSION vector;
-- 테이블 생성
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
content TEXT,
embedding vector(1536), -- 1536차원 (OpenAI ada-002)
metadata JSONB
);
-- 인덱스 생성
-- HNSW (권장, 높은 정확도)
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- IVFFlat (대용량)
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100); -- sqrt(row_count)
-- 검색 (코사인 유사도)
SELECT id, content,
1 - (embedding <=> query_embedding) as similarity
FROM documents
ORDER BY embedding <=> query_embedding
LIMIT 10;
-- 거리 연산자
-- <-> : L2 거리 (euclidean)
-- <=> : 코사인 거리 (1 - similarity)
-- <#> : 내적의 음수
-- 메타데이터 필터와 함께
SELECT id, content
FROM documents
WHERE metadata->>'category' = 'AI'
ORDER BY embedding <=> query_embedding
LIMIT 10;
# Python에서 사용
import psycopg2
from pgvector.psycopg2 import register_vector
import numpy as np
conn = psycopg2.connect("postgresql://...")
register_vector(conn)
cur = conn.cursor()
# 삽입
embedding = np.random.rand(1536).tolist()
cur.execute("""
INSERT INTO documents (content, embedding, metadata)
VALUES (%s, %s, %s)
""", ("LLM 문서", embedding, '{"source": "wiki"}'))
# 검색
query_embedding = np.random.rand(1536).tolist()
cur.execute("""
SELECT id, content, 1 - (embedding <=> %s) as similarity
FROM documents
ORDER BY embedding <=> %s
LIMIT 10
""", (query_embedding, query_embedding))
results = cur.fetchall()
ChromaDB 사용¶
import chromadb
from chromadb.utils import embedding_functions
# 클라이언트 생성
client = chromadb.PersistentClient(path="./chroma_db")
# 임베딩 함수 설정
embedding_fn = embedding_functions.SentenceTransformerEmbeddingFunction(
model_name="all-MiniLM-L6-v2"
)
# 컬렉션 생성
collection = client.get_or_create_collection(
name="documents",
embedding_function=embedding_fn,
metadata={"hnsw:space": "cosine"}
)
# 문서 추가
collection.add(
documents=[
"LLM은 대규모 언어 모델입니다.",
"Transformer는 어텐션 메커니즘을 사용합니다."
],
metadatas=[{"source": "wiki"}, {"source": "paper"}],
ids=["doc1", "doc2"]
)
# 검색
results = collection.query(
query_texts=["언어 모델이란?"],
n_results=5,
where={"source": "wiki"}, # 메타데이터 필터
include=["documents", "distances", "metadatas"]
)
RAG 파이프라인 구축¶
전체 아키텍처¶
+------------+ +-------------+ +------------+
| 문서 로더 | --> | 청크 분할기 | --> | 임베딩 모델 |
+------------+ +-------------+ +------------+
|
v
+------------+ +-------------+ +------------+
| LLM | <-- | 프롬프트 | <-- | 벡터 DB |
+------------+ +-------------+ +------------+
|
v
+------------+
| 응답 |
+------------+
청킹 전략¶
from langchain.text_splitter import (
RecursiveCharacterTextSplitter,
CharacterTextSplitter,
TokenTextSplitter
)
# 1. 재귀적 문자 분할 (권장)
# 단락 -> 문장 -> 단어 순으로 분할 시도
splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200, # 문맥 유지
separators=["\n\n", "\n", ".", " "]
)
# 2. 토큰 기반 분할 (정확한 토큰 수 필요 시)
splitter = TokenTextSplitter(
chunk_size=500,
chunk_overlap=50
)
# 3. 시맨틱 분할 (문맥 고려)
# 문단, 섹션 등 의미 단위 유지
chunks = splitter.split_documents(documents)
청킹 파라미터 가이드:
chunk_size:
- 너무 작음: 문맥 손실
- 너무 큼: 노이즈 증가, 비용 증가
- 권장: 500-1500 토큰
chunk_overlap:
- 문맥 연속성 유지
- 권장: chunk_size의 10-20%
하이브리드 검색¶
벡터 검색 + 키워드 검색 결합.
from rank_bm25 import BM25Okapi
class HybridRetriever:
def __init__(self, vectorstore, documents):
self.vectorstore = vectorstore
# BM25 인덱스 구축
tokenized_docs = [doc.split() for doc in documents]
self.bm25 = BM25Okapi(tokenized_docs)
self.documents = documents
def search(self, query, k=10, alpha=0.5):
"""
alpha: 벡터 검색 가중치 (1-alpha: BM25 가중치)
"""
# 벡터 검색
vector_results = self.vectorstore.similarity_search_with_score(
query, k=k*2
)
# BM25 검색
tokenized_query = query.split()
bm25_scores = self.bm25.get_scores(tokenized_query)
bm25_top = sorted(
enumerate(bm25_scores),
key=lambda x: x[1],
reverse=True
)[:k*2]
# Reciprocal Rank Fusion (RRF)
combined_scores = {}
for rank, (doc, score) in enumerate(vector_results):
doc_id = doc.metadata.get('id')
combined_scores[doc_id] = alpha / (rank + 60)
for rank, (idx, score) in enumerate(bm25_top):
doc_id = idx
if doc_id in combined_scores:
combined_scores[doc_id] += (1 - alpha) / (rank + 60)
else:
combined_scores[doc_id] = (1 - alpha) / (rank + 60)
# 최종 순위
final = sorted(combined_scores.items(), key=lambda x: x[1], reverse=True)
return final[:k]
실무 이슈와 해결¶
N+1 문제¶
# 문제: N+1 쿼리 발생
users = User.objects.all() # 1 쿼리
for user in users:
print(user.posts.all()) # N 쿼리
# 해결: Eager Loading
users = User.objects.prefetch_related('posts').all() # 2 쿼리
# SQL 레벨
SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);
커넥션 풀¶
from sqlalchemy import create_engine
from sqlalchemy.pool import QueuePool
engine = create_engine(
"postgresql://user:pass@localhost/db",
poolclass=QueuePool,
pool_size=5, # 기본 연결 수
max_overflow=10, # 추가 허용 연결
pool_timeout=30, # 연결 대기 타임아웃
pool_recycle=1800 # 연결 재사용 시간 (초)
)
# 연결 풀 모니터링
print(f"Pool size: {engine.pool.size()}")
print(f"Connections in use: {engine.pool.checkedin()}")
슬로우 쿼리 분석¶
-- PostgreSQL: 슬로우 쿼리 로깅 설정
ALTER SYSTEM SET log_min_duration_statement = 1000; -- 1초 이상
-- 실행 계획 분석
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT * FROM documents WHERE category = 'AI';
-- 주요 지표:
-- Seq Scan: 테이블 전체 스캔 (인덱스 필요?)
-- Index Scan: 인덱스 사용
-- Nested Loop: 조인 방식 (작은 테이블에 적합)
-- Hash Join: 대용량 조인
핵심 개념¶
자주 나오는 질문¶
- 인덱스는 왜 빠른가? B-Tree 설명
- B-Tree: 균형 트리, O(log n) 검색
-
디스크 I/O 최소화 (높이가 낮음)
-
트랜잭션의 ACID를 설명하시오
- Atomicity: 전부 또는 전무
- Consistency: 제약 조건 유지
- Isolation: 동시 트랜잭션 격리
-
Durability: 영구 저장
-
정규화 vs 비정규화
- 정규화: 중복 제거, 데이터 무결성
-
비정규화: 읽기 성능, 조인 감소
-
인덱스를 언제 사용해야 하는가?
- WHERE, JOIN, ORDER BY
- 카디널리티 높은 컬럼
-
자주 조회, 드물게 수정
-
SQL vs NoSQL 선택 기준
- SQL: 구조화된 데이터, 트랜잭션, 복잡한 쿼리
-
NoSQL: 유연한 스키마, 수평 확장, 높은 처리량
-
데드락이란? 해결 방법은?
- 두 트랜잭션이 서로의 락 대기
- 해결: 락 순서 고정, 타임아웃, 데드락 탐지
실무 연결¶
1. "LLM 응답을 캐싱하려면?"
-> Redis, TTL 설정, 프롬프트 해시 키
2. "벡터 검색의 정확도를 높이려면?"
-> 더 좋은 임베딩 모델, 하이브리드 검색, 리랭킹
3. "대용량 데이터 마이그레이션은?"
-> 배치 처리, 중단 없는 마이그레이션 (dual-write)
4. "조인이 느린 경우 해결책은?"
-> 인덱스 확인, 비정규화 고려, 캐싱