콘텐츠로 이동
Data Prep
상세

데이터베이스 (Databases)

데이터를 체계적으로 저장, 관리, 검색하기 위한 시스템. LLM/VLM에서는 벡터 DB, 임베딩 저장, RAG 파이프라인 구축에 핵심적.


왜 데이터베이스를 알아야 하는가

  1. 데이터 저장: 효율적인 저장과 검색 전략
  2. 성능 최적화: 인덱스, 쿼리 튜닝, 정규화/비정규화
  3. 시스템 설계: RAG 파이프라인, 벡터 검색 아키텍처
  4. 트러블슈팅: 느린 쿼리, 데드락, 연결 풀 문제

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: 대용량 조인

핵심 개념

자주 나오는 질문

  1. 인덱스는 왜 빠른가? B-Tree 설명
  2. B-Tree: 균형 트리, O(log n) 검색
  3. 디스크 I/O 최소화 (높이가 낮음)

  4. 트랜잭션의 ACID를 설명하시오

  5. Atomicity: 전부 또는 전무
  6. Consistency: 제약 조건 유지
  7. Isolation: 동시 트랜잭션 격리
  8. Durability: 영구 저장

  9. 정규화 vs 비정규화

  10. 정규화: 중복 제거, 데이터 무결성
  11. 비정규화: 읽기 성능, 조인 감소

  12. 인덱스를 언제 사용해야 하는가?

  13. WHERE, JOIN, ORDER BY
  14. 카디널리티 높은 컬럼
  15. 자주 조회, 드물게 수정

  16. SQL vs NoSQL 선택 기준

  17. SQL: 구조화된 데이터, 트랜잭션, 복잡한 쿼리
  18. NoSQL: 유연한 스키마, 수평 확장, 높은 처리량

  19. 데드락이란? 해결 방법은?

  20. 두 트랜잭션이 서로의 락 대기
  21. 해결: 락 순서 고정, 타임아웃, 데드락 탐지

실무 연결

1. "LLM 응답을 캐싱하려면?"
   -> Redis, TTL 설정, 프롬프트 해시 키

2. "벡터 검색의 정확도를 높이려면?"
   -> 더 좋은 임베딩 모델, 하이브리드 검색, 리랭킹

3. "대용량 데이터 마이그레이션은?"
   -> 배치 처리, 중단 없는 마이그레이션 (dual-write)

4. "조인이 느린 경우 해결책은?"
   -> 인덱스 확인, 비정규화 고려, 캐싱

참고 자료