DBMS 및 SQL 활용 #3 집계함수, 고급 객체기능, 고급 인덱스 #
#2025-08-28
1. GROUPBY #
GROUP BY
테이블 안에 있는 데이터를 특정 기준으로 묶어서 요약.
테이블 embedding_store에서
- id, user_id, cluster_id, similarity, tag 5개 컬럼이 있는데
- 있는 그대로보면 큰 그림을 보기 힘들다 즉 해석이 어렵다.
- GROUP BY를 쓰면 요약 정보를 만들수있는데
- user_id로 묶으면 “사용자 A는 총 10건, 사용자 B는 총 5건” 같은 식으로 정리 / cluster_id로 묶으면 “클러스터 1은 평균 유사도가 0.8, 클러스터 2는 0.5” / tag로 묶으면 “계약 태그는 100건, 고객상담 태그는 30건” 같은 결과가 나오고 이렇게 하면 데이터의 전체 분포와 패턴을 이해할 수 있다.
- id, user_id, cluster_id, similarity, tag 5개 컬럼이 있는데
AI 연계?
벡터 데이터에서 클러스터링을 하고 나면 각 클러스터의 특징을 봐야되는데
- SQL로 GROUP BY cluster_id를 해서 평균 유사도, 최소 유사도 등을 구해서 평균 유사도가 지나치게 낮은 클러스터가 발견되면 “이 클러스터는 불분명하게 묶였네” 이런식으로 클러스터를 판단할수있다
- SQL로 GROUP BY tag 해서 클러스터내 사용자별 태그 분포를 보면 어떤 사용자가 어떤 패턴을 많이 보이는지를 확인할수있다.
이런식으로 단순 SQL 집계가 단순 통계가 아니라 이상치 탐색, 품질 저하 감지, 태그 자동 분류 같은 AI 전처리 과정에 활용 가능.
#
Vector DB 분석에서 GROUP BY 활용
- 벡터 데이터는?
- 문장, 이미지 같은 걸 임베딩해서 저장해둔 값
- 클러스터링을 하고 나면 각 클러스터가 잘 묶였는지를 확인해야 하고 이때 GROUP BY cluster_id로 묶어서 평균 유사도를 보면 클러스터를 판단할수있다
-- 클러스터별 평균 유사도와 최소 유사도
SELECT cluster_id,
ROUND(AVG(similarity), 3) AS avg_sim,
MIN(similarity) AS min_sim
FROM embedding_store
GROUP BY cluster_id;
- 여기서 평균 유사도가 0.9 이상이면 잘 뭉쳐진 클러스터일 가능성이 크고 0.5 이하라면 내부 데이터가 제각각이라 불분명하게 묶인 클러스터라고 판단가능
- 이렇게 SQL 집계로 클러스터 품질을 확인할수있다.
#
AI 결과 검증에서 GROUP BY 활용
- AI 모델이 분류 작업을 했을때
- 실제 라벨(true_label)과 예측 결과(pred_label)가 테이블에 있고 카테고리별 정확도를 구할 수 있다
-- 카테고리별 정확도 집계
SELECT true_label,
ROUND(SUM(CASE WHEN true_label = pred_label THEN 1 ELSE 0 END)::decimal
/ COUNT(*), 3) AS accuracy
FROM prediction_results
GROUP BY true_label;
- 이렇게 하면 “카테고리 A의 정확도는 0.95, 카테고리 B는 0.62” 같은 결과가 나오니까 어떤 클래스에서 모델이 잘 못 맞추는지 바로 확인할 수 있고 이는 모델 개선 포인트로 이어진다.
#
추천 시스템에서 GROUP BY 활용
- 추천 시스템에서는 사용자가 어떤 아이템을 자주 고르는지, 또는 어떤 유형의 아이템을 선호하는지를 분석해야 하는데
- 사용자별 선택 기록을 GROUP BY user_id나 GROUP BY item_category로 묶으면 개인의 선호를 확인 가능하다
-- 사용자별 아이템 선택 패턴
SELECT user_id, item_category, COUNT(*) AS choice_count
FROM user_choice_log
GROUP BY user_id, item_category
ORDER BY user_id, choice_count DESC;
- 이렇게 하면 “사용자 A는 주로 액션 영화를 많이 선택, 사용자 B는 로맨스 위주” 같은 패턴이 보이고 이를 활용해서 토대로 개인화 추천을 강화할 수 있다.
#
분류 성능 비교에서 GROUP BY 활용
- 분류 모델이 여러 개 있다면 카테고리별로 각 모델의 성능을 나란히 비교할수있다.
-- 모델별, 카테고리별 정확도 비교
SELECT model_name, true_label,
ROUND(SUM(CASE WHEN true_label = pred_label THEN 1 ELSE 0 END)::decimal
/ COUNT(*), 3) AS accuracy
FROM prediction_results
GROUP BY model_name, true_label;
- 이렇게 하면 “모델 A는 카테고리 X에서는 정확도가 높지만, 카테고리 Y에서는 낮다” 같은 판단(비교) 가능.
#
2. ROLLUP & CUBE #
-- 원본데이터
region | product | amount
-------+---------+-------
East | A | 100
East | B | 150
West | A | 200
West | B | 50
sales_summary 테이블
- 지역(region), 제품(product), 매출액(amount)
- East 지역의 A 제품 매출 100, B 제품 매출 150 / West 지역의 A 제품 200, B 제품 50
- 일반적인 GROUP BY region, product를 쓰면?
-- 기본 GROUP BY
SELECT region, product, SUM(amount) AS total
FROM sales_summary
GROUP BY region, product;
region | product | total
-------+---------+------
East | A | 100
East | B | 150
West | A | 200
West | B | 50
- SUM()으로 합계를 계산했고 그대로 네 줄이 다시 나오면서 매출액이 합계로 정리된다
- 그런데 이렇게 하면 지역별 합계나 전체 합계를 따로 보려면 다시 쿼리를 작성해야함.
#
- GROUP BY ROLLUP(region, product)를 쓰면?
-- GROUP BY ROLLUP
SELECT region, product, SUM(amount) AS total
FROM sales_summary
GROUP BY ROLLUP(region, product);
region | product | total
-------+---------+------
East | A | 100
East | B | 150
East | NULL | 250 <- East 소계
West | A | 200
West | B | 50
West | NULL | 250 <- West 소계
NULL | NULL | 500 <- 전체 합계
- 네 줄의 상세 데이터에 더해서 지역별 소계와 전체 합계까지 자동으로 붙는다.
- East 소계: East 지역은 A 100, B 150을 합쳐 250
- West 소계: West는 A 200, B 50을 합쳐 250
- 전체 합계: 500
- 소계를 표시할 때는 product 칸이 NULL로 나타나고 전체 합계는 region과 product가 모두 NULL로 표시.
#
- GROUP BY CUBE(region, product)를 쓰면?
-- GROUP BY CUBE
SELECT region, product, SUM(amount) AS total
FROM sales_summary
GROUP BY CUBE(region, product);
region | product | total
-------+---------+------
East | A | 100
East | B | 150
East | NULL | 250 <- East 소계
West | A | 200
West | B | 50
West | NULL | 250 <- West 소계
NULL | A | 300 <- 제품 A 전체 합계
NULL | B | 200 <- 제품 B 전체 합계
NULL | NULL | 500 <- 전체 합계
- 지역별 합계와 전체 합계뿐 아니라 제품별 합계도 같이 나온다.
- East-A, East-B, West-A, West-B 같은 상세 데이터가 나오고 (기본 GROUP BY)
- East 전체, West 전체, 그냥 전체 데이터가 나오고 (GROUP BY ROLLUP)
- 제품 A 전체, 제품 B 전체 데이터도 나온다.
#
ROLLUP과 CUBE의 차이?
- ROLLUP은 계층적으로 요약
- ROLLUP(region, product)이면
- 첫 번째 컬럼(region)을 기준으로 묶고 -> 그 “안에서” 두 번째 컬럼(product)을 묶고 -> 마지막으로 전체 합계까지 올라감
- East-A 100, East-B 150, East 전체 250 / West-A 200, West-B 50, West 전체 250 / 전체 500
- 보면 East / West 로 묶고 -> East 안에서 A/B로 묶고 -> 전체 500 함.
- ROLLUP(region, product)이면
- CUBE는 가능한 모든 조합
- CUBE(region, product)이면
- East-A 100, East-B 150, East 전체 250 / West-A 200, West-B 50, West 전체 250 / 제품 A 전체 300 / 제품 B 전체 200 / 전체 500
- 보면 East / West 로 묶고 -> East 안에서 A/B로 묶고 -> A/B로 묶고 -> A안에서 East/West로 묶는건 의미없으니 없고 -> 전체 500 함.
- East-A 100, East-B 150, East 전체 250 / West-A 200, West-B 50, West 전체 250 / 제품 A 전체 300 / 제품 B 전체 200 / 전체 500
- CUBE(region, product)이면
#
3. UDF & 시퀀스 & 저장 프로시저 & UDT & 트리거 #
(p.95-101)
UDF
- SQL 문법만으로는 반복적인 계산이나 특정 규칙 적용이 어려운데
- UDF를 만들어놓으면 데이터베이스 안에 내장된 함수 외에도 필요할 때 불러다 쓸 수 있다.
- is_similar 함수
- 두 개의 실수값이 주어진 임계치 이상으로 가까운지를 판별하는함수
- 실질적 활용?
- 임베딩 스토어에서 코사인 유사도가 일정 기준 이상인 후보만 필터링하는 기능이니까
- 데이터베이스 안에서 바로 AI 예측 후보 선별에 쓸수있다.
- 임베딩 스토어에서 코사인 유사도가 일정 기준 이상인 후보만 필터링하는 기능이니까
#
시퀀스
- 자동으로 증가하는 고유 ID를 만들어줌
- 테이블에 데이터를 넣을 때 시퀀스를 만들어 두고 nextval로 꺼내 쓰면 순차적으로 값이 올라가니까 데이터마다 일일이 ID를 붙이지 않아도 된다.
-- 시퀀스 생성
CREATE SEQUENCE my_seq START 1;
-- 임베딩을 저장하는 테이블 embedding_store
CREATE TABLE embedding_store (
id BIGINT PRIMARY KEY DEFAULT nextval('my_seq'),
user_text TEXT,
embedding VECTOR(4)
);
-- nextval('my_seq')로 자동 증가하는 ID를 달아주기
INSERT INTO embedding_store (user_text, embedding)
VALUES ('hello world', '[0.1, 0.2, 0.3, 0.4]');
-- 또 다른 데이터를 넣으면 ID가 자동으로 2가 됨
INSERT INTO embedding_store (user_text, embedding)
VALUES ('how are you', '[0.5, 0.6, 0.7, 0.8]');
-- 확인해보면?
SELECT * FROM embedding_store;
id | user_text | embedding
---+---------------+------------------------
1 | hello world | [0.1, 0.2, 0.3, 0.4]
2 | how are you | [0.5, 0.6, 0.7, 0.8]
- 예시
- CREATE SEQUENCE my_seq START 1; -> 이렇게 만들어 두면
- DEFAULT nextval(‘my_seq’)를 컬럼에 달아주면
- INSERT INTO embedding_store (user_text, embedding) 할 때 자동으로 ID가 올라간다
- 매번 새로운 번호가 붙기 때문에 중복 없는 고유 ID를 쉽게 관리할수있다.
- 실질적 활용?
- 모델 예측 결과나 벡터 데이터가 쌓일 때 결과를 추적하거나 버전을 구분할때
- 벡터를 하나씩 저장할 때마다 고유 번호를 자동으로 달아주면 나중에 “이 임베딩이 어떤 실험에서 나온 것인지”를 관리하기 쉽다.
- 결과 추적?
- 어떤 문장을 임베딩해서 384차원짜리 벡터를 만들었고 -> 벡터를 테이블에 저장할건데 -> 임베딩은 숫자 배열이므로 나중에 “이 벡터가 언제, 어떤 실험, 어떤 모델로 만들어진 건지”를 추적하기 어려운데 -> 이때 시퀀스로 생성한 고유 ID를 같이 붙여 주면?
- 첫 번째 벡터 저장 → ID = 1000
- 두 번째 벡터 저장 → ID = 1001
- 세 번째 벡터 저장 → ID = 1002
- 이렇게 고유 ID가 붙으면 나중에 분석할 때 “ID=1002인 벡터는 실험 X에서 나온 결과다” 하고 연결하기 쉽다.
- 어떤 문장을 임베딩해서 384차원짜리 벡터를 만들었고 -> 벡터를 테이블에 저장할건데 -> 임베딩은 숫자 배열이므로 나중에 “이 벡터가 언제, 어떤 실험, 어떤 모델로 만들어진 건지”를 추적하기 어려운데 -> 이때 시퀀스로 생성한 고유 ID를 같이 붙여 주면?
- 버전 관리?
- 같은 문장을 두번 실험에 다르게 임베딩했으면 1차 실험 때는 모델 버전 1로 뽑은 벡터 2차 실험 때는 모델 버전 2로 뽑은 벡터가 있을 수 있고 -> 이럴 때 고유 ID를 붙여 두면 “실험 1번에서 나온 ID 1010 벡터와, 실험 2번에서 나온 ID 2020 벡터를 비교하자” 이렇게 버전 관리 할수있다.
- 모델 예측 결과나 벡터 데이터가 쌓일 때 결과를 추적하거나 버전을 구분할때
#
저장 프로시저
- 여러 SQL 문장을 묶어 하나의 절차처럼 실행
- 예시
- 예측 결과 테이블 prediction_results가 있고 실제 라벨(true_label)과 모델이 예측한 라벨(pred_label)이 있다.
- AI 모델이 예측한 결과를 5개 저장하려고 한다.
-- 예측 결과 테이블 prediction_results
CREATE TABLE prediction_results (
id BIGSERIAL PRIMARY KEY,
true_label TEXT,
pred_label TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 저장 프로시저 insert_prediction_batch
CREATE OR REPLACE PROCEDURE insert_prediction_batch(
p_true_label TEXT,
p_pred_label TEXT,
p_count INT
)
LANGUAGE plpgsql
AS $$
BEGIN
FOR i IN 1..p_count LOOP
INSERT INTO prediction_results (true_label, pred_label)
VALUES (p_true_label, p_pred_label);
END LOOP;
END;
$$;
-- AI 모델의 예측 결과 저장하기
-- 저장 프로시저 안쓴경우
INSERT INTO prediction_results (true_label, pred_label) VALUES ('spam', 'spam');
INSERT INTO prediction_results (true_label, pred_label) VALUES ('spam', 'spam');
INSERT INTO prediction_results (true_label, pred_label) VALUES ('spam', 'spam');
INSERT INTO prediction_results (true_label, pred_label) VALUES ('spam', 'spam');
INSERT INTO prediction_results (true_label, pred_label) VALUES ('spam', 'spam');
-- 저장 프로시저를 쓴 경우
CALL insert_prediction_batch('spam', 'spam', 5);
- 저장 프로시저가 없으면 개발자가 직접 5번 INSERT 문을 날려야하는데
- 저장 프로시저가 있으면 똑같이 5건을 넣어야 하는 상황에서 CALL 한 줄만 쓰면 된다.
- 프로시저 내부에 반복문(FOR i IN 1..p_count)이 있어서 알아서 5번 INSERT를 실행해준다
#
사용자 정의 데이터 타입(UDT)
- 보통 테이블 컬럼은 숫자, 문자열 같은 단순 타입인데 내가 원하는 구조를 만들어서 하나의 타입처럼 쓸 수 있다.
- 예측 결과를 저장하려고 할때.
- 썼을때와 안썼을때의 차이를 보면?
-- 안 썼을 때
-- 예측 테이블 prediction_logs_plain 생성
CREATE TABLE prediction_logs_plain (
id SERIAL PRIMARY KEY,
model_name TEXT,
label TEXT,
score FLOAT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 데이터 넣기
INSERT INTO prediction_logs_plain (model_name, label, score)
VALUES ('spam_detector', 'spam', 0.92);
-- 조회
SELECT id, model_name, label, score
FROM prediction_logs_plain;
id | model_name | label | score
----+---------------+-------+-------
1 | spam_detector | spam | 0.92
-- 썼을 때
-- UDT로 (label, score)를 하나의 덩어리로 정의
CREATE TYPE prediction_result_type AS (
label TEXT,
score FLOAT
);
-- 예측 테이블 prediction_logs_udt 생성
CREATE TABLE prediction_logs_udt (
id SERIAL PRIMARY KEY,
model_name TEXT,
result prediction_result_type,
created_at TIMESTAMP DEFAULT NOW()
);
-- 데이터 넣기
INSERT INTO prediction_logs_udt (model_name, result)
VALUES ('spam_detector', ROW('spam', 0.92)::prediction_result_type);
-- 조회
SELECT id, model_name, (result).label, (result).score
FROM prediction_logs_udt;
id | model_name | label | score
----+---------------+-------+-------
1 | spam_detector | spam | 0.92
- 구체적으로 어디가 다르냐면
- 데이터 넣기
- 안썼을때: (model_name, label, score) -> label과 score를 각각 컬럼에 직접 넣는다.
- udt 썼을때: (model_name, result) -> label과 score를 ROW()로 묶어서 result라는 한 컬럼에 넣는다.
- 조회
- 안썼을때: SELECT label, score -> 그냥 컬럼 이름(label, score)으로 바로 꺼낸다.
- udt 썼을때: SELECT (result).label, (result).score -> result 안에서 필드를 꺼내는 방식으로 꺼낸다.
- 데이터 넣기
- 의문점
- 출력 결과가 똑같은데 왜쓰는거지?
- 답
- 출력 결과만 비교하면 같지만 확장성에서 차이가있다.
- 안 썼을 때는 함수가 여러 개 값을 리턴해야 하면 RETURNS TABLE(label TEXT, score FLOAT) 같은 형태로 정의해야 하는데 썼을 때는 함수가 RETURNS prediction_result_type로 정의되니까 “이 함수는 예측 결과 하나를 리턴한다”라고 직관적으로 쓸 수 있다 즉 데이터 구조를 하나의 타입으로 추상화할 수 있다.
- 안 썼을 때는 label, score를 다른 테이블에서도 쓰려면 매번 두 컬럼을 복사해야 하는데 썼을 때는 그냥 result prediction_result_type 하나만 선언하면 되니까 중복 정의를 줄이고 일관성 유지 가능(이건 예시에선 2개여서 메리트 없어보이는데 개수 늘어나면 납득됨)
- 복잡한 구조 확장
- 예측 결과가 단순히 label+score로 끝나지 않고 label, score, confidence_interval, metadata 같이 커질 수 있는데 안 썼을 때는 컬럼이 점점 늘어나고 테이블마다 다 복사해야 하지만 썼을 때는 타입만 확장하면 모든 테이블·함수에서 동일하게 활용 가능하다.
- 출력 결과만 비교하면 같지만 확장성에서 차이가있다.
#
트리거
- 데이터가 삽입, 수정, 삭제될 때 자동으로 실행되는 규칙
- 예시
- 새로운 벡터가 들어왔는데 유사도가 0.5보다 낮으면 경고 테이블에 따로 기록하려고 할때?
-- 트리거 안 썼을 때
-- 1. 메인 테이블
CREATE TABLE embedding_store_plain (
id SERIAL PRIMARY KEY,
text_input TEXT,
similarity FLOAT
);
CREATE TABLE similarity_warnings_plain (
warning_id SERIAL PRIMARY KEY,
embedding_id INT,
similarity FLOAT,
warning_msg TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 2. 데이터 넣을 때
-- 새 데이터 삽입
INSERT INTO embedding_store_plain (text_input, similarity)
VALUES ('bad case', 0.3);
-- 임계치 체크 후 경고 삽입
INSERT INTO similarity_warnings_plain (embedding_id, similarity, warning_msg)
VALUES (currval('embedding_store_plain_id_seq'), 0.3, 'Similarity below threshold');
-- 트리거 썼을 때
-- 1. 메인 테이블 + 경고 테이블
CREATE TABLE embedding_store (
id SERIAL PRIMARY KEY,
text_input TEXT,
similarity FLOAT
);
CREATE TABLE similarity_warnings (
warning_id SERIAL PRIMARY KEY,
embedding_id INT,
similarity FLOAT,
warning_msg TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 2. 트리거 함수 + 트리거 생성
CREATE OR REPLACE FUNCTION check_similarity_threshold()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.similarity < 0.5 THEN
INSERT INTO similarity_warnings (embedding_id, similarity, warning_msg)
VALUES (NEW.id, NEW.similarity, 'Similarity below threshold');
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_check_similarity
AFTER INSERT ON embedding_store
FOR EACH ROW
EXECUTE FUNCTION check_similarity_threshold();
-- 3. 데이터 넣기
INSERT INTO embedding_store (text_input, similarity)
VALUES ('bad case', 0.3);
- 궁극적인 차이는
- 메인 테이블 + 경고 테이블: 트리거를 쓰든 안 쓰든 구조는 똑같음
- 데이터 넣을 때
- 트리거 안 쓰면: INSERT (메인 테이블), INSERT (경고 테이블, 조건 만족 시) -> N개의 쿼리를 개발자가 직접 작성
- 트리거 쓰면: INSERT (메인 테이블) -> → 1줄만 작성하면 나머지(조건 체크 + 경고 INSERT)는 DB가 자동 처리.
#
4. 윈도우 함수 #
(p.126-129)
집계함수와 윈도우함수 차이
- 비슷하지만 GROUP BY처럼 그룹을 한 줄로 압축하지 않고, 각 행마다 순위, 누적합, 이전 값 같은 걸 계산함
-- scores 데이터
CREATE TABLE scores (
student TEXT,
subject TEXT,
score INT
);
INSERT INTO scores VALUES
('철수', '수학', 80),
('철수', '영어', 90),
('영희', '수학', 95),
('영희', '영어', 85),
('민수', '수학', 70),
('민수', '영어', 75);
-- 집계 함수 GROUP BY로 학생별 평균 점수 구하기
SELECT student, AVG(score) AS avg_score
FROM scores
GROUP BY student;
student | avg_score
--------+----------
철수 | 85
영희 | 90
민수 | 72.5
-- 윈도우 함수 OVER로 학생별 평균 점수 구하기
SELECT student, subject, score,
AVG(score) OVER (PARTITION BY student) AS avg_score
FROM scores;
student | subject | score | avg_score
--------+---------+-------+----------
철수 | 수학 | 80 | 85
철수 | 영어 | 90 | 85
영희 | 수학 | 95 | 90
영희 | 영어 | 85 | 90
민수 | 수학 | 70 | 72.5
민수 | 영어 | 75 | 72.5
- GROUPBY -> 학생(그룹) 단위로 묶어서 한 줄로 결과를 압축했다.
- OVER (PARTITION BY student) -> 학생(그룹)별로 평균을 계산하되 결과는 행마다 달아줬다.
#
윈도우 함수
- ROW_NUMBER()
- 그룹 안에서 순번을 매긴다.
- 사용자별로 점수를 내림차순 정렬하고 ROW_NUMBER를 매기면, 그 사용자 안에서 1등, 2등, 3등을 구할 수 있다.
- 그룹 안에서 순번을 매긴다.
- RANK()
- 동점이 있을 때 같은 순위를 부여하고 건너뛰기가 발생한다.
- 1등이 두 명이면 다음 순위는 3등.
- 동점이 있을 때 같은 순위를 부여하고 건너뛰기가 발생한다.
- DENSE_RANK()
- 같은 순위가 있더라도 건너뛰지 않고 다음을 2등으로 붙인다.
- NTILE(n)
- 데이터를 n개 구간으로 자른다.
- 100명을 NTILE(5)로 나누면 성적을 기준으로 20명씩 다섯 구간으로 나눌 수 있다.
- LAG() & LEAD()
- 현재 행 기준으로 앞 행이나 뒤 행 값을 참고할 수 있어서 시간 순서대로 점수를 나열해 두면 바로 직전 점수와 비교하거나 다음 점수를 미리 볼 수 있다
- SUM() OVER, AVG() OVER
- 누적합이나 누적평균 구한다.
-- ROW_NUMBER()
student | subject | score | rownum
--------+---------+-------+-------
철수 | 수학 | 95 | 1
철수 | 영어 | 95 | 2
철수 | 과학 | 90 | 3
영희 | 수학 | 100 | 1
영희 | 영어 | 80 | 2
민수 | 수학 | 70 | 1
민수 | 과학 | 70 | 2
민수 | 영어 | 60 | 3
-- RANK()
student | subject | score | rnk
--------+---------+-------+----
철수 | 수학 | 95 | 1
철수 | 영어 | 95 | 1
철수 | 과학 | 90 | 3
-- NTILE(n)
student | subject | score | bucket
--------+---------+-------+-------
철수 | 수학 | 95 | 1
철수 | 영어 | 95 | 1
철수 | 과학 | 90 | 2
-- 상위 절반 = 그룹 1, 하위 절반 = 그룹 2
-- LAG() & LEAD()
student | subject | score | prev_score | next_score
--------+---------+-------+------------+------------
민수 | 과학 | 70 | (null) | 60
민수 | 수학 | 70 | 70 | 60
민수 | 영어 | 60 | 70 | (null)
-- 직전 점수와 다음 점수 참조: 민수의 행마다 앞뒤 점수가 달라 붙음.
-- SUM() OVER, AVG() OVER
student | subject | score | running_total | avg_score
--------+---------+-------+----------------+----------
철수 | 수학 | 95 | 95 | 93.3
철수 | 영어 | 95 | 190 | 93.3
철수 | 과학 | 90 | 280 | 93.3
- running_total은 점수 누적합, avg_score는 평균.
#
AI 연계
- 예측 결과를 저장한 prediction_logs 테이블
- 활용
- 여러 모델 버전이 같은 사용자에 대해 점수를 매겼을 때 그중 가장 높은 점수를 고르기.
- ROW_NUMBER() OVER (PARTITION BY user_id ORDER BY pred_score DESC) -> rownum = 1인 행만 선택
- 모델 간 성능 비교
- RANK() OVER (PARTITION BY user_id ORDER BY pred_score DESC)
- 예측 점수 상위 20% 사용자 그룹을 뽑기
- NTILE(5) OVER (ORDER BY pred_score DESC) -> bucket = 1인 행만 선택
- 이전 점수와 비교해 사용자의 점수가 올랐는지 떨어졌는지 확인
- LAG(pred_score) OVER (PARTITION BY user_id ORDER BY created_at) -> pred_score - prev_score 차이 계산
- 모델 정확도의 누적 변화 확인
- SUM(pred_score) OVER (…), AVG(pred_score) OVER (…)
- 여러 모델 버전이 같은 사용자에 대해 점수를 매겼을 때 그중 가장 높은 점수를 고르기.
#
5. 고급 인덱스 #
(p.144-148)
고급 인덱스?
- 일반적인 데이터베이스 인덱스는 B-Tree 인덱스.
- AI에서 다루는 데이터는 단순 숫자 키가 아니라 JSON 문서, 벡터, 시계열 로그처럼 복잡하거나 대용량 특성이 있어서 다른 종류의 인덱스들이 필요하다.
#
GIN 인덱스
- Inverted Index: 거꾸로 색인.
- 보통 행 -> 마다 단어가 있는데
- “찾고 싶은 단어 -> 그 단어가 들어 있는 행"으로 인덱스를 만든다.
{"category": "esg", "type": "finance"} -> 1행
{"category": "ai", "type": "tech"} -> 2행
{"category": "esg", "type": "policy"} -> 3행
"category"="esg" -> 1행, 3행
"category"="ai" -> 2행
"type"="finance" -> 1행
"type"="tech" -> 2행
"type"="policy" -> 3행
- “category”=“esg"인 행을 찾고 싶으면 테이블을 처음부터 끝까지 보지 않고 인덱스를 통해 곧바로 1, 3행을 볼수있다.
#
GiST 인덱스
- AI에서 쓰는 벡터 데이터에서의 인덱싱은
- 사전처럼 정확한 값을 빠르게 찾기보다는 이 벡터와 가장 비슷한 벡터를 찾는, 정확히 같은 값이 아니라 가까운 값을 찾는 경우가 많다.
- GiST 인덱스는 “거리 기반” 검색을 빠르게 해 주는 구조여서 가까운 것을 찾는 인덱싱에 적합하다.
#
BRIN 인덱스
- 범위별 최소·최대 값만 기록해 두고, 그 안에 데이터가 있을 거라고 좁혀 가는 방식
- 일기장이 날짜 순으로
- 1월 1일~1월 10일 -> 1권
- 1월 11일~1월 20일 -> 2권
- 1월 21일~1월 31일 -> 3권
- 이렇게 적혀있으면 1월 15일 일기를 찾으려고하면 2권만 열어 보면 된다.
- 빠른 이유는 범위만 보고 필요한 블록만 열어보면 되기 때문.
- 잘 맞는 경우는 로그, 시계열 데이터
- 잘 안 맞는 경우는 무작위 데이터. 왜냐면 “최소~최대”로 구간을 좁힐 수 없기 때문에 범위가 의미가 없다.