DBMS 및 SQL 활용 #4 pgvector 기반 유사도 검색 + FastAPI 연동 #
#2025-08-28
1. 실습 시나리오 #
-- 1. 확장 설치 및 테이블 생성
-- 2. 예시 데이터 삽입 (10건만 임시)
-- 3. 인덱스 생성 및 분석
-- 4. 성능 비교: LIMIT 5 vs LIMIT 50
-- 5. 인덱스 종류별 비교 (코사인 vs L2)
-- 6. 사용자 입력 벡터를 Python에서 API로 전달하여 동적 쿼리 구성 예시 (FastAPI 측에서 처리)
#
2. 코드 #
#1 SQL 유사도 검색
-- 1. 확장 설치 및 테이블 생성
CREATE EXTENSION IF NOT EXISTS vector;
DROP TABLE IF EXISTS design_doc;
CREATE TABLE design_doc (
id SERIAL PRIMARY KEY,
title TEXT,
content TEXT,
embedding_vector VECTOR(384)
);
-- 2. 데이터 삽입
-- \i '/Users/yshmbid/Documents/home/github/SQL/example_design_doc_inserts_120.sql'
-- 3. 인덱스 생성
-- 코사인 거리 기준 인덱스
CREATE INDEX ON design_doc USING ivfflat (embedding_vector vector_cosine_ops) WITH (lists = 100);
-- L2 거리 기준 인덱스
CREATE INDEX ON design_doc USING ivfflat (embedding_vector vector_l2_ops) WITH (lists = 100);
-- 4. 난수 벡터 생성 UDF
CREATE OR REPLACE FUNCTION random_vector()
RETURNS vector AS $$
SELECT array_agg(random())::vector(384)
FROM generate_series(1,384);
$$ LANGUAGE sql VOLATILE;
-- 5. 성능 비교: (LIMIT 5 vs LIMIT 50) & (코사인 vs L2)
DO $$
DECLARE
t1 TIMESTAMP;
t2 TIMESTAMP;
BEGIN
-- LIMIT 5 성능 측정
t1 := clock_timestamp();
PERFORM id, title
FROM design_doc
ORDER BY embedding_vector <=> random_vector()
LIMIT 5;
t2 := clock_timestamp();
RAISE NOTICE 'LIMIT 5 실행 시간: % ms', EXTRACT(MILLISECOND FROM (t2 - t1));
-- LIMIT 50 성능 측정
t1 := clock_timestamp();
PERFORM id, title
FROM design_doc
ORDER BY embedding_vector <=> random_vector()
LIMIT 50;
t2 := clock_timestamp();
RAISE NOTICE 'LIMIT 50 실행 시간: % ms', EXTRACT(MILLISECOND FROM (t2 - t1));
-- 코사인 거리 성능 측정
t1 := clock_timestamp();
PERFORM id, title
FROM design_doc
ORDER BY embedding_vector <=> random_vector()
LIMIT 5;
t2 := clock_timestamp();
RAISE NOTICE '코사인 거리 실행 시간: % ms', EXTRACT(MILLISECOND FROM (t2 - t1));
-- L2 거리 성능 측정
t1 := clock_timestamp();
PERFORM id, title
FROM design_doc
ORDER BY embedding_vector <-> random_vector()
LIMIT 5;
t2 := clock_timestamp();
RAISE NOTICE 'L2 거리 실행 시간: % ms', EXTRACT(MILLISECOND FROM (t2 - t1));
END;
$$;
#
#2 vector_search_api.py
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
import psycopg2
import os
from dotenv import load_dotenv
os.chdir("/Users/yshmbid/Documents/home/github/SQL") # set path
load_dotenv() # .env 파일 로드
# 1. FastAPI 앱 생성
app = FastAPI()
# 2. 요청 데이터 모델 정의 (Pydantic BaseModel)
class QueryVector(BaseModel):
vector: list[float]
limit: int = 5
# 3. DB 연결 함수 정의
def get_db_conn():
return psycopg2.connect(
dbname="postgres",
user="postgres",
password=os.getenv("PG_PASSWORD"),
host="localhost"
)
# 4. search_vector()
@app.post("/search") # HTTP POST 요청이 /search 경로로 들어오면 search_vector 함수를 실행.
def search_vector(data: QueryVector):
try:
conn = get_db_conn()
cur = conn.cursor()
# content까지 포함
query = """
SELECT id, title, content
FROM design_doc
ORDER BY embedding_vector <=> %s::vector
LIMIT %s;
"""
# Python list → pgvector 문자열 변환
vector_str = "[" + ",".join(map(str, data.vector)) + "]"
cur.execute(query, (vector_str, data.limit))
rows = cur.fetchall()
cur.close()
conn.close()
return {
"results": [
{"id": r[0], "title": r[1], "content": r[2]} for r in rows
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"DB error: {str(e)}")
#
#3 client.py
import requests
import psycopg2
import os
import ast
from dotenv import load_dotenv
os.chdir("/Users/yshmbid/Documents/home/github/SQL") # set path
load_dotenv() # .env 파일 로드
# 1. DB 연결
def get_db_conn():
return psycopg2.connect(
dbname="postgres",
user="postgres",
password=os.getenv("PG_PASSWORD"),
host="localhost"
)
# 2. 기준 문서(id=1) 가져오기 및 출력
conn = get_db_conn()
cur = conn.cursor()
cur.execute("SELECT id, title, content, embedding_vector FROM design_doc WHERE id = 1;")
row = cur.fetchone()
cur.close()
conn.close()
query_id, query_title, query_content, vec_raw = row
if isinstance(vec_raw, str):
vec = ast.literal_eval(vec_raw)
else:
vec = list(vec_raw)
print("=== 쿼리로 사용된 문서 ===")
print(f"id: {query_id}")
print(f"title: {query_title}")
print(f"content: {query_content}")
# 3. API 요청 및 출력 (가장 유사한 문서 1개)
response = requests.post(
"http://127.0.0.1:8000/search",
json={"vector": vec, "limit": 1}
)
print("=== 원본 API 응답 ===")
print(response.json())
# 결과가 있으면 하나만 출력
if "results" in response.json() and len(response.json()["results"]) > 0:
r = response.json()["results"][0]
print("\n=== 가장 유사한 문서 ===")
print(f"id: {r['id']}")
print(f"title: {r['title']}")
print(f"content: {r['content']}")
#
#4 터미널 실행
# terminal 1
$ pwd
/Users/yshmbid/Documents/home/github/SQL
$ uvicorn vector_search_api:app --reload
# terminal 2
$ pwd
/Users/yshmbid/Documents/home/github/SQL
$ python client.py
#
3. 코드설명 #
#1 SQL 유사도 검색
-- 3. 인덱스 생성
-- 코사인 거리 기준 인덱스
CREATE INDEX ON design_doc USING ivfflat (embedding_vector vector_cosine_ops) WITH (lists = 100);
-- L2 거리 기준 인덱스
CREATE INDEX ON design_doc USING ivfflat (embedding_vector vector_l2_ops) WITH (lists = 100);
- embedding_vector vector_cosine_ops
- embedding_vector 컬럼을 대상으로 인덱스를 생성
- 코사인 거리(cosine distance)를 기준으로 유사도 검색을 최적화
- WITH (lists = 100)
- ivfflat는 전체 벡터 공간을 리스트로 나눠서 가장 가까울가능성이 높은 그룹에서 탐색하는 기법을 쓰는데 → 100개의 리스트로 나눠 탐색한다.
- embedding_vector vector_l2_ops
- embedding_vector 컬럼을 대상으로 인덱스를 생성
- L2 거리(유클리드 거리)를 기준으로 유사도 검색을 최적화
#
-- 4. 난수 벡터 생성 UDF
CREATE OR REPLACE FUNCTION random_vector()
RETURNS vector AS $$
SELECT array_agg(random())::vector(384)
FROM generate_series(1,384);
$$ LANGUAGE sql VOLATILE;
- random_vector() 목적
- 성능 실험용으로 길이 384짜리 난수 벡터 생성
- array_agg(random())::vector(384)
- array_agg(random()): 0 이상 1 미만 난수 384번 생성해서 384차원 배열 생성
- ::vector(384): 벡터로 변환
#
-- 5. 성능 비교: (LIMIT 5 vs LIMIT 50) & (코사인 vs L2)
DO $$
DECLARE
t1 TIMESTAMP;
t2 TIMESTAMP;
BEGIN
-- LIMIT 5 성능 측정
t1 := clock_timestamp();
PERFORM id, title
FROM design_doc
ORDER BY embedding_vector <=> random_vector()
LIMIT 5;
t2 := clock_timestamp();
RAISE NOTICE 'LIMIT 5 실행 시간: % ms', EXTRACT(MILLISECOND FROM (t2 - t1));
-- LIMIT 50 성능 측정
t1 := clock_timestamp();
PERFORM id, title
FROM design_doc
ORDER BY embedding_vector <=> random_vector()
LIMIT 50;
t2 := clock_timestamp();
RAISE NOTICE 'LIMIT 50 실행 시간: % ms', EXTRACT(MILLISECOND FROM (t2 - t1));
-- 코사인 거리 성능 측정
t1 := clock_timestamp();
PERFORM id, title
FROM design_doc
ORDER BY embedding_vector <=> random_vector()
LIMIT 5;
t2 := clock_timestamp();
RAISE NOTICE '코사인 거리 실행 시간: % ms', EXTRACT(MILLISECOND FROM (t2 - t1));
-- L2 거리 성능 측정
t1 := clock_timestamp();
PERFORM id, title
FROM design_doc
ORDER BY embedding_vector <-> random_vector()
LIMIT 5;
t2 := clock_timestamp();
RAISE NOTICE 'L2 거리 실행 시간: % ms', EXTRACT(MILLISECOND FROM (t2 - t1));
END;
$$;
- 블록 목적
- LIMIT 5 vs 50, 코사인 vs L2 케이스별 실행 속도 비교
- DO $$ … $$
- 익명 PL/pgSQL 블록 (DB에 저장되지 않는 블록)
- DECLARE
- 블록 안에서 사용할 Timestamp 변수 t1, t2를 선언
- BEGIN … END;
- 실제 실행할 로직을 작성
- t1 := clock_timestamp(), t2 := clock_timestamp()
- t1에 시작 시간, t2에 끝 시간 저장
- PERFORM id, title
- PERFORM: 쿼리 실행
- LIMIT 5, LIMIT 50
- 가장 유사한 문서 5개만 찾을 때와 50개 찾을 때.
- FROM design_doc ORDER BY embedding_vector <=> random_vector()
- embedding_vector와 랜덤으로 만든 벡터(random_vector())의 코사인 거리를 계산해서 정렬
- FROM design_doc ORDER BY embedding_vector <-> random_vector()
- embedding_vector와 랜덤으로 만든 벡터(random_vector())의 L2 거리를 계산해서 정렬
#
#2 vector_search_api.py
# 4. search_vector()
@app.post("/search")
def search_vector(data: QueryVector):
try:
conn = get_db_conn()
cur = conn.cursor()
# content까지 포함
query = """
SELECT id, title, content
FROM design_doc
ORDER BY embedding_vector <=> %s::vector
LIMIT %s;
"""
# Python list → pgvector 문자열 변환
vector_str = "[" + ",".join(map(str, data.vector)) + "]"
cur.execute(query, (vector_str, data.limit))
rows = cur.fetchall()
cur.close()
conn.close()
return {
"results": [
{"id": r[0], "title": r[1], "content": r[2]} for r in rows
]
}
except Exception as e:
raise HTTPException(status_code=500, detail=f"DB error: {str(e)}")
- search_vector() 목적
- 클라이언트가 벡터를 보내면 DB에서 가장 비슷한 문서들을 찾아서 반환
- @app.post("/search")
- HTTP POST 요청이 /search 경로로 들어오면 search_vector 함수를 실행.
- get_db_conn()
- PostgreSQL 연결 생성 (psycopg2)
- conn.cursor()
- SQL 실행을 위한 커서(cursor) 객체 생성
- query
- 입력 벡터와 가장 코사인 거리가 가까운 문서 N개를 찾는 쿼리
- embedding_vector <=> %s::vector
- Python에서 넘긴 벡터 문자열을 vector 타입으로 가져오는데 정렬 기준은 코사인 거리
- “[” + “,".join(map(str, data.vector)) + “]”
- 클라이언트가 보낸 vector(리스트)를 문자열로 바꿔서 PostgreSQL의 vector 타입으로 해석되게.
- cur.execute(query, (vector_str, data.limit)) → rows = cur.fetchall()
- 쿼리 실행 & 결과(rows) 가져옴
- return …
- DB에서 가져온 튜플들을 JSON 응답 형식으로 반환
- except Exception as e: raise HTTPException(status_code=500, detail=f"DB error: {str(e)}”)
- DB 연결 실패, 쿼리 오류 등이 나면 500 Error 처리.
#
#3 client.py
# 2. 기준 문서(id=1) 가져오기 및 출력
conn = get_db_conn()
cur = conn.cursor()
cur.execute("SELECT id, title, content, embedding_vector FROM design_doc WHERE id = 1;")
row = cur.fetchone()
cur.close()
conn.close()
query_id, query_title, query_content, vec_raw = row
if isinstance(vec_raw, str):
vec = ast.literal_eval(vec_raw)
else:
vec = list(vec_raw)
print("=== 쿼리로 사용된 문서 ===")
print(f"id: {query_id}")
print(f"title: {query_title}")
print(f"content: {query_content}")
- cur.execute(“SELECT id, title, content, embedding_vector FROM design_doc WHERE id = 1;”)
- 첫 번째 문서를 기준 문서로 사용할예정이므로 design_doc 테이블에서 id=1인 문서 조회
#
# 3. API 요청 및 출력 (가장 유사한 문서 1개)
response = requests.post(
"http://127.0.0.1:8000/search",
json={"vector": vec, "limit": 1}
)
print("=== 원본 API 응답 ===")
print(response.json())
# 결과가 있으면 하나만 출력
if "results" in response.json() and len(response.json()["results"]) > 0:
r = response.json()["results"][0]
print("\n=== 가장 유사한 문서 ===")
print(f"id: {r['id']}")
print(f"title: {r['title']}")
print(f"content: {r['content']}")
- requests.post(“http://127.0.0.1:8000/search”)
- HTTP POST 요청: 로컬에서 실행 중인 FastAPI 서버 주소 http://127.0.0.1:8000/search로
- json={“vector”: vec, “limit”: 1}
- 기준 문서에서 뽑아온 벡터(vec)와 가장 가까운 문서 1개 요청
- response.json()[“results”][0]
- 결과 리스트의 첫 번째 요소(가장 유사한 문서) 가져오기
#
4. 실행 결과 및 해석 #
#1 성능 비교 (LIMIT 5 vs LIMIT 50) & (cosine vs L2)
- LIMIT 5 vs LIMIT 50
- LIMIT 5: 9.582 ms
- LIMIT 50: 4.426 ms
- LIMIT 50이 LIMIT 5보다 약 5ms 더 빠르게 수행됨.
- cosine vs L2
- cosine: 6.079 ms
- L2: 4.114 ms
- L2 연산이 cosine 연산보다 약 2ms 더 빠르게 수행됨.
- 결과 해석
- LIMIT 값이 크다고 무조건 느려지지 않았는데, 실행 시간은 LIMIT 값에 비례하지 않을 수 있고 이는 ivfflat 인덱스를 사용할 때는 “몇 개를 더 읽어오느냐”보다 “인덱스에서 후보군을 어떻게 선택하느냐”가 더 중요하기 때문일수 있다
- ivfflat은 “전체 데이터를 다 보지 않고, 후보군(클러스터)만 먼저 고른 뒤, 그 안에서 정렬해서 결과를 뽑는 방식”인데
- LIMIT 값이 작든 크든 먼저 후보군을 고르고 정렬하는 과정은 거의 똑같은데 실제로 시간이 더 걸리는 건 “후보군 선택과 정렬”이지 LIMIT 5에서 5개를, LIMIT 50에서 50개를 뽑는 그 ‘추출 단계’ 자체는 별로 비중이 크지 않기 때문일 수 있다
- 그래서 LIMIT 값이 크다고 무조건 느려지지 않았던것일수있다.
- L2(<->)가 코사인(<=>)보다 빠르게 나왔는데 L2 거리는 그냥 좌표 차이 제곱해서 더하는 계산이고 코사인 거리 = 내적 계산 + 벡터 크기(norm) 계산이 필요하기 때문에 연산이 더 복잡하므로 시간이 더 소요되는 것이 정상적인 결과
- 실제 서비스에서 속도만 중요하다면 L2를 쓰고 의미적 유사도(문장의 방향성)가 더 중요하다면 코사인을 쓰는 게 맞을수있다
- 벡터 길이가 384차원이고 쿼리도 정렬 기반인데 모두 10ms 이내라면 인덱스가 잘 적용되고 있는 것으로 보이고
- 인덱스가 없었다면 후보군 없이 전체 데이터를 일일이 다 비교해야 해서 시간이 훨씬 소요되는데 ivfflat이 후보군을 뽑아서 연산 범위를 줄여줬기 때문에 시간이 많이 감소하였다.
- LIMIT 값이 크다고 무조건 느려지지 않았는데, 실행 시간은 LIMIT 값에 비례하지 않을 수 있고 이는 ivfflat 인덱스를 사용할 때는 “몇 개를 더 읽어오느냐”보다 “인덱스에서 후보군을 어떻게 선택하느냐”가 더 중요하기 때문일수 있다
#
#2 FastAPI 서버 실행 및 클라이언트 실행
- 실행 내용
- DB의 id=1번 문서를 쿼리로 사용해서 가장 유사한 문서 1개를 반환했고 id=1번 문서가 반환
- 결과 해석
- 쿼리로 준 문서 벡터 id=1와 가장 가까운 것은 id=1이므로 그대로 반환
#
5. 개념 #
- ivfflat?
- 일반 텍스트 검색이나 숫자 검색은 B-Tree 인덱스를 많이 쓰지만
- 벡터 검색은 고차원 벡터 간 거리 계산이 필요하기 때문에 가장 가까울 가능성이 높은 그룹에서만 검색하는 근사 최근접 탐색(ANN, Approximate Nearest Neighbor) 기반으로 유사한 데이터를 찾아서 탐색속도가 빠른 ivfflat를 쓴다.
- 인덱스 생성하는 이유?
- 문서 의미가 얼마나 방향이 비슷한지를 빠르게 찾기위해서.
- 인덱스가 문서 의미가 얼마나 방향이 비슷한지를 빠르게 찾는데 필요한 이유?
- 인덱스 없는 경우
- design_doc 테이블의 모든 행에 대해 embedding_vector와 query_vector의 코사인 거리를 계산하므로 10만 건 데이터가 있으면 10만 번의 384차원 내적 연산을 수행.
- 인덱스 있는 경우
- USING ivfflat (embedding_vector vector_cosine_ops) 하면 벡터 공간을 리스트 여러개로 미리나눠두고 가장 가까울 가능성이 높은 리스트 몇 개만 선택해서 선택된 리스트 안에서만 거리를 계산한다. 비슷한 후보군 안에서만 비교하기 때문에 속도가 훨씬 빨라진다.
- 인덱스 없는 경우
- 익명 PL/pgSQL 블록 사용 장점? (함수나 프로시저로 저장하지 않고 일회성 코드 블록으로 실행하는 이유?)
- 간단히 성능 테스트, 데이터 초기화, 실험을 할거라서 굳이 DB 객체(함수·프로시저)를 생성하고 저장할필요가 없어서 실행 후 흔적이 안남게함.
- 일반 SQL로는 안 되는 로직(변수 선언, IF 조건문, LOOP 반복문)을 실행할수있어서.