2026 . 오은

~~~~~~><>

임베딩 검색 구현기 - pgvector+HNSW

2026.05.09

pgvector + HNSW 검색 시스템 설계 후기

0. 시작 vs 끝 시점의 멘탈 모델

시작 시점의 멘탈 모델

pg_trgm + GIN 같은 거 해봤으니 pgvector도 비슷하게 인덱스 만들고 WHERE + ORDER BY 하면 될 거 같다

끝 시점의 멘탈 모델

  • HNSW는 일반 인덱스와 본질이 다르다. "사전 그래프"여서 필터와 결합되는 순간 옵티마이저의 path 선택 문제가 발생.
  • 옵티마이저는 데이터 분포 보고 케이스별로 옳은 선택을 하고 있다 (Path A vs Path B).
  • 우리가 할 일은 ef_search 같은 힌트로 후보 풀 크기를 조절하는 것.
  • 100만건 적재는 INSERT가 아니라 COPY. 라운드트립 vs 단일 스트림 차이.

1. 호스팅 결정: Supabase / Railway / 로컬 Docker

후보 비교

SupabaseRailway Hobby로컬 Docker (홈서버)
비용무료 시작 가능8GB RAM전기세
pgvector○○○
RAM플랜 의존8GB (HNSW 빌드 가능)홈서버 사양
강점Auth/Realtime/Storage/RLS배포 단순함백엔드와 같은 위치
이번 프로젝트와의 매치강점이 거의 안 쓰임백엔드도 따로 배포 필요백엔드와 같이 묶임

선택: 로컬 Docker

  • 이유: 어차피 백엔드를 홈서버에서 운영. DB가 같은 머신이면 네트워크 0ms.
  • 트레이드오프: 가용성/백업은 직접 책임. 대신 HNSW 인덱스 메모리 상주를 자유롭게 튜닝 가능.
  • pgvector 단일 용도면 Supabase는 "비싼 Postgres"가 됨. 강점 매트릭스가 안 맞을 때 무료 플랜이라도 도구가 과해짐.

다음에 비슷한 결정 만나면

  • 멀티테넌트 + Auth + RLS 필요 → Supabase
  • 빠른 PoC + 외부 노출 → Railway
  • 비용/성능/통제권 우선 + 운영 책임 OK → 로컬 또는 자체 호스팅

2. HNSW 인덱스 빌드 파라미터

이번 프로젝트의 실제 숫자

  • 데이터: 100만 행 × 1536차원
  • 인덱스 빌드 시간: 60분 (m=16, ef_construction=64)
  • 빌드 중 메모리 사용: 8GB
  • 인덱스 디스크 크기: 6GB
  • ef_search=80에서 평균 응답:226ms
-- HNSW 인덱스: 정확도와 속도의 균형
-- m=16, ef_construction=64는 일반적인 권장값
CREATE INDEX idx_personas_embedding ON personas
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- 통계 갱신
ANALYZE personas;
  • m: 그래프 연결 차수. 클수록 정확↑, 메모리↑, 빌드 느림. 16이 표준.
  • ef_construction: 빌드 시 탐색 폭. 클수록 정확↑, 빌드 느림. 64~128.
  • 검색 시엔 ef_search (런타임 파라미터)로 정확도/속도 조절. 기본 40, 높이면 더 정확.
  • 나는 최종적으로 80으로 잡았다.
  • pgvector HNSW 인덱스는 메모리 상주가 성능에 직결됨.
  • 100만건 × 1536차원 인덱스는 대략 6~8GB RAM 정도 잡아먹는다(m=16 기준).

3. 그러나 옵티마이저는 HNSW 를 바로 안타네?

처음에 이상했던 것

sex=여자 + age 20-29 + 서울 (15,864명) 쿼리에서 HNSW 인덱스를 만들었는데도 EXPLAIN을 보니 demographic 인덱스만 쓰고 있었음.

무슨일이 일어났나?

Index Scan using idx_personas_demo ← demographic 인덱스(sex/age/province)만 사용 
Index Cond: (sex = '여자' AND age >= 20 AND age <= 29 AND province = '서울') rows=15864
...그후...
Sort (top-N heapsort) ← 15,864명을 가져와서 메모리에서 코사인 유사도로 전체 정렬
  1. demographic 필터로 15,864명 추림
  2. 15,864명 전부 임베딩 가져와서 쿼리 벡터와 거리 계산

"인덱스 만들었는데 왜 안 써?"가 첫 반응.

알게 된 것: 필터 + 벡터 검색의 근본 트레이드오프

HNSW는 사전에 그래프를 만들어두는 인덱스라 "코사인 거리로 가까운 K개"는 잘 찾지만, "필터를 만족하면서 가까운 K개"는 본질적으로 어렵다.

옵티마이저는 두 가지 path 중 하나를 선택:

Path APath B
흐름HNSW로 K개 → 필터 → 부족하면 K 늘림필터로 좁힘 → 무차별 거리 계산
빡빡한 필터(1.6%)K 키워도 필터 통과 못 채울 위험후보 작아서 빠름
느슨한 필터(50%)후보 풀 충분, HNSW 유리50만 건 전부 계산, 느림

옵티마이저는 데이터 분포 통계 보고 케이스별로 옳은 선택.

두 케이스 비교 (실측)

쿼리선택한 path이유
sex=여자 + 20-29 + 서울 (15,864명, 1.6%)demographic → 정렬 (Path B)후보 작아서 무차별이 안전
sex=여자 (50만명, 50%)HNSW → 필터 (Path A)후보 너무 커서 HNSW가 유리

내가 한 처치: ef_search=100

SET LOCAL hnsw.ef_search = 100을 추가한 게 path를 바꾼 게 아니라는 점을 알게 됨. path 결정은 필터 selectivity가 한다.

ef_search의 진짜 효과는:

  • HNSW path가 선택된 상황에서, 후보 풀 크기를 키워서 "LIMIT 5인데 필터 통과한 게 3개뿐" 같은 사태를 방지.
  • 기본 40 → 80~100으로 올리면 안전 마진.

핵심 인사이트

  • "인덱스 안 쓰네?"는 옵티마이저가 데이터 분포 보고 더 빠른 길을 골랐을 가능성이 높다.
  • 의심하기 전에 EXPLAIN으로 "어느 path를 골랐고 왜 그게 합리적인지" 검토할 것.
  • pg_trgm + GIN 다뤘던 경험이 그대로 적용됨: 옵티마이저는 통계와 selectivity로 판단한다.

4. 콜드 캐시 vs 웜 캐시 (1243ms → 184ms)

무엇을 봤나

  • 처음 던진 쿼리: 1243ms
  • 같은 쿼리 두 번째: 184ms
  • 약 6.7배 차이

왜 그런가

PostgreSQL은 데이터/인덱스를 페이지(8KB) 단위로 읽는다.

  • 콜드: 페이지가 shared_buffers나 OS page cache에 없어서 디스크 I/O 발생
  • 웜: 같은 페이지가 메모리에 올라와 있어서 바로 읽음

HNSW에선 이게 더 두드러짐:

  • HNSW는 그래프 탐색이라 여러 페이지를 점프하며 읽음
  • 인덱스 일부만 캐시에 있으면 그래프 탐색 중간에 디스크 I/O가 끼어듦
  • 한 번 따뜻해지면 그래프 전체가 메모리에서 동작

함의

  • 1243ms는 "이 쿼리의 진짜 성능"이 아니라 "콜드 페널티 포함 성능"
  • 운영에선 자주 쓰이는 인덱스가 항상 웜 상태이도록 해야 함
  • shared_buffers가 인덱스를 다 담을 수 있는지 확인 (이번 케이스 6~8GB)
  • 서비스 시작 직후의 첫 요청 페널티를 피하려면 워밍업 쿼리 (pg_prewarm 익스텐션 등)

벤치마크할 때 주의

  • "쿼리 한 번 돌려보고 N ms 나왔다"는 거의 의미 없음
  • 같은 쿼리 여러 번 돌려서 콜드/웜을 분리해서 보거나
  • 다양한 쿼리로 캐시를 의도적으로 흔들면서 측정

COPY로 100만건 적재 패턴 (INSERT 대비)

  • INSERT 100만 건은 30분1시간, COPY는 510분 (네트워크 라운드트립 vs 단일 스트림 차이)
  • with cur.copy(SQL) as copy: 블록 안에서 copy.write_row(fields)로 행 단위 push, 블록 빠지면 자동 flush+종료
  • vector 컬럼 값은 register_vector() 자동 변환 안 통하고 직접 "[" + ",".join(...) + "]" 문자열로 만들어 넘겨야 함 (COPY TEXT 포맷의 한계)

5. Prisma + pg.Pool 하이브리드 패턴

역할 분담

  • Prisma: 메타 CRUD (페르소나 메타 정보, 사용자, 검색 로그 등)
    • 스키마 → 타입 자동 생성으로 DX 좋음
    • db pull/push로 마이그레이션 관리
  • pg.Pool (raw SQL): 벡터 검색 쿼리
    • vector 타입과 거리 연산자(<=>, <->, <#>) 사용
    • SET LOCAL로 ef_search 같은 튜닝 파라미터 제어
    • EXPLAIN 분석 가능

왜 raw SQL인가

  • Prisma는 vector 타입을 정식 매핑하지 않음 (Unsupported로만 받을 수 있음)
  • 거리 연산자 <=>는 Prisma 빌더로 표현 불가
  • HNSW 튜닝(ef_search)은 ORM 추상화 밖

두 클라이언트 공존이 위험하지 않은가

  • 같은 DB를 가리키므로 데이터 일관성 OK
  • 트랜잭션 경계만 주의: Prisma와 pg.Pool 양쪽에 걸치는 트랜잭션은 피함
  • 커넥션 풀은 분리됨 → 풀 사이즈를 양쪽 합산해서 max_connections 안 넘게 산정

부수 이점: db pull 안 해도 됨

  • 검색 쿼리가 Prisma 스키마와 무관해서, pgvector 컬럼/인덱스 변경 시 Prisma 재생성 사이클 안 거쳐도 됨
  • 운영 중 인덱스 튜닝이 가벼워짐

오버뷰보러가기